WLJS LogoWLJS Notebook

Javascript Library

A ready to-go example is in this repository

Clone it to <Documents>/WLJS Notebooks/Extensions/ using:

git clone https://github.com/WLJSTeam/wljs-plugin-example-1

Then restart WLJS Notebook.

Folder name of the extension must match with its unique name

Here is the simples example on what you can do with extensions. Why not to add ApexCharts? They looks beautiful and already animated. What we need

  • Kernel package, which implements ApexCharts[] symbols
  • Javascript bundle, which should include ApexCharts and bridge it with Wolfram Kernel using frontend symbols

Summary what will be done

  • package for evaluation kernel, which adds a new symbol ApexCharts
  • Javascript module, which renders the content of ApexCharts expression

Preparations

Use wljs-plugin-template template and create a new repository or use a ready-to-go example given in the heading of this tutorial.

Then edit the content of package.json

package.json
{
    "name": "wljs-plugin-example-1",
    "version": "0.0.1",
    "scripts": {
        "build": "node --max-old-space-size=8192 ./node_modules/.bin/rollup --config rollup.config.mjs"
    },
    "description": "An example plugin for WLJS Notebook. Library functions",
    "wljs-meta": {
        "kernel": [
            "src/Kernel.wl"
        ],
        "js": "dist/kernel.js",
        "minjs": "dist/kernel.min.js",
        "priority": 5000,
        "category": "Notebook Extensions"
    },
    "repository": {
        "type": "git",
        "url": "https://github.com/WLJSTeam/wljs-plugin-example-1"
    },
    "dependencies": {
        "@rollup/plugin-commonjs": "^25.0.4",
        "@rollup/plugin-json": "^6.0.0",
        "@rollup/plugin-node-resolve": "^15.2.1",
        "@rollup/plugin-terser": "^0.4.4",
        "rollup": "^3.21.6"
    }
}

By the default a template implies, that we will use rollup.js for bundling. Run in the root directory of this package

npm i

And then install apexcharts

npm i apexcharts

Kernel package

Create a new file in src/Kernel.wl, which is going to be our package for evaluation kernel. Looking at ApexCharts API, it is easy to imagine the way how it can be used

ApexCharts[<|
    "series" -> {44, 55, 67, 83},
    "labels" -> {"Apples", "Oranges", "Bananas", "Berries"},
    "chart" -> <|
        "height" -> 350, 
        "type" -> "radialBar"
    |>
|>]

or any other way, one can pre-transform the data and use intermediate symbols. Following the simplest path, we write to our Kernel file

src/Kernel.wl
BeginPackage["CoffeeLiqueur`Extensions`ApexCharts`"]

(* Public context *)

ApexCharts::usage = "ApexCharts[a_Association] constructor"

Begin["`Private`"]



End[]
EndPackage[]

Now we can think about ApexCharts as if it was a new entity, and the interpretation of this entity will be our graphs. To draw actual graphs instead of a symbolic representation we need to define an output form. For that ViewBox comes in hand, which is provided in ``CoffeeLiqueurExtensionsBoxes``` context. We define an internal symbol, that will be used for rendering:

BeginPackage["CoffeeLiqueur`Extensions`ApexCharts`", {
    "CoffeeLiqueur`Extensions`Boxes`"
}]

(* Public context *)

ApexCharts::usage = "ApexCharts[a_Association] constructor"

Begin["`Private`"]

(* Output forms *)

iApexCharts;

ApexCharts /: MakeBoxes[a: ApexCharts[data_Association], StandardForm ] := With[{},
    ViewBox[a, iApexCharts[data]]
]

End[]
EndPackage[]

First a will be an underlying expression (behind the graph), while iApexCharts is going to be rendered instead of it. However, if you try to evaluate this with defined output form, you get a similar error

BROKEN BOX

ViewBox tries to evaluate a non-existing frontend symbol iApexCharts or more specifically CoffeeLiqueur`Extensions`ApexCharts`Private`iApexCharts.

As a rule: do not expose internal symbols in packages to the public context

Javascript Library

Now we need to define iApexCharts symbol on the frontend. The idea is simple: take the provided data and using Apex API render a graph on the given DOM element. This DOM element will be provided by ViewBox behind the scenes

src/kernel.js
let ApexCharts;

core['CoffeeLiqueur`Extensions`ApexCharts`Private`iApexCharts'] = async (args, env) => {
    if (!ApexCharts) ApexCharts = (await import('apexcharts')).default; //lazy loading

    const options = await interpretate(args[0], env);
    const chart = new ApexCharts(env.element, options);
    chart.render();
}

Our association will be in options object after the interpretation.

Implement dynamic imports (lazy) if possible

Now we need to bundle this

npm run build

After the restart, it should work with our extension like a charm

However, after several evaluation dead instances of ApexCharts will pile up as a garbage in Javascript memory. It is recommended to properly remove them. For that we need to identify each instance of ApexCharts and assign destructor function. We use instanced frontend symbools, which behave like classes

src/kernel.js
let ApexCharts;

const apex = async (args, env) => {
    if (!ApexCharts) ApexCharts = (await import('apexcharts')).default; //lazy loading

    const options = await interpretate(args[0], env);
    const chart = new ApexCharts(env.element, options);
    chart.render();

    env.local.chart = chart;
}

apex.destroy = (args, env) => {
    env.local.chart.destroy();
} 

apex.virtual = true

core['CoffeeLiqueur`Extensions`ApexCharts`Private`iApexCharts']  = apex;

Now we need to bundle this

npm run build

Polishing

We might make a few more tweaks. If the ApexCharts expression becomes too large, it can slow down the editor. For this reason, we use Frontend Objects, which allow the data to be stored separately as a JSON blob. From the kernel’s perspective, there is no visible difference: synchronization, packing, and unpacking are handled automatically.

BeginPackage["CoffeeLiqueur`Extensions`ApexCharts`", {
    "CoffeeLiqueur`Extensions`Boxes`",
    "CoffeeLiqueur`Extensions`FrontendObject`"
}]

(* Public context *)
ApexCharts /: MakeBoxes[a: ApexCharts[data_Association], form: StandardForm] := With[{
    o = CreateFrontEndObject[data]
}, {
    oId = ToString[o[[1]], InputForm],
    name = ToString[ApexCharts, InputForm]
},
    ViewBox[RowBow[{name, "[", "FrontEndRef", "[", oId, "]", "]"}], iApexCharts[o]]
] /; ByteCount[a] > 1024*4

Here, we manually construct the inner expression using low-level boxes. Note that we convert ApexCharts to a string instead of writing "ApexCharts" directly. This is required when the package is not imported into the global context, because the full symbol path may be used instead, for example CoffeeLiqueurExtensionsApexChartsApexCharts`.

This complexity gives us full control over what is visible and what remains underneath. We want to avoid either layer leaking into the other. As a result, if you copy the raw expression from the output cell into a plain text editor, it may look like this:

(*VB[*)(ApexCharts[FrontEndRef["someUUID"]])(*,*)(*"1:eJxTTMo....=="*)(*]VB*)

Here, the comment containing 1:eJxTTMo stores the encoded form of iApexCharts[FrontEndExecutable["someUUID"]]. If you evaluate this cell again, FrontEndRef[] automatically expands back into the original data association, and the whole process repeats.

This makes the expression copyable, valid for reevaluation and mutation, and compact in the editor. Frontend objects are stored in the notebook, so they are not lost if the kernel dies.

Another improvement can be WLXForm, if we decide to use those charts on slides or markdown cells. For all WLX-like cells we only need to pass a frontend object instance:

ApexCharts /: MakeBoxes[a: ApexCharts[_Association], form: WLXForm ] := With[{o = CreateFrontEndObject[a]},
    MakeBoxes[o, form]
]

On this page