WLJS LogoWLJS Notebook

Overview

In this guide, we explore the core ideas behind the WLJS project, what powers the UI, interactivity, and how to extend it to your own needs. As you may know from the introduction, WLJS Notebooks are web-based, which means that graphical objects, sliders, buttons, and UI elements are made with HTML/CSS and JavaScript code. If you want to create your own display function or input element that cannot be easily derived from the basic primitives of the Wolfram Language Standard library (or would be inefficient to do so), you need to write supporting JavaScript code to achieve that.

To bridge the Wolfram Kernel and JavaScript environments seamlessly, we made the latter speak the same language and called it WLJS Interpreter—a small subset of the Wolfram Kernel running in the browser.

You can define symbols and contexts that can be evaluated on the frontend only, with results fetched by the Wolfram Kernel—we refer to them as frontend symbols.

You can also define instanced symbols that are evaluated into scoped runtime instances with local state and reactive bindings to other symbols. This is how reactivity in WLJS Notebooks works under the hood.

The possibilities are limitless: from creating DOM elements and emitting sound, to using 3rd party libraries like ApexCharts or Charts.js to visualize your data in fascinating ways. Frontend symbols give you the full power to go completely beyond the Wolfram Language ecosystem and even access Bluetooth HID, GPU, and serial devices to pipe data in and out.

Quick and Dirty

Create a JavaScript cell, then define a function within the core object—or as we call it later, the global frontend symbol context:

.js core.totalSum = async (args, env) => { const data = await interpretate(args[0], env); const sum = data.reduce((p, a) => p + a, 0); return sum; }

To evaluate it directly from the Wolfram Kernel and get the result back, use FrontFetch:

totalSum[{1,2,3,4,5}] // FrontFetch
FrontFetch is a blocking function. Use FrontFetchAsync in buttons, timers, or asynchronous functions.

Since Alert is also a frontend symbol, we can chain it with our totalSum to display the result in a popup box. In this case, we don't need to fetch the result; instead, we use:

totalSum[{1,2,3,4,5}] // Alert // FrontSubmit

FrontSubmit acts similarly to LocalSubmit or SessionSubmit.

Here is another example using the Canvas API to display a cellular automaton:

.js const canvas = document.createElement("canvas"); canvas.width = 400; canvas.height = 400; let context = canvas.getContext("2d"); context.fillStyle = "lightgray"; context.fillRect(0, 0, 500, 500); core.drawArray = async (args, env) => { const data = await interpretate(args[0], env); //draw our boxes for(let i=0; i<40; ++i) { for (let j=0; j<40; ++j) { if (data[i][j] > 0) { context.fillStyle = "rgba(255,0,0,0.6)"; context.fillRect(i*10 + 1, j*10 + 1, 8, 8); } else { context.fillStyle = "rgba(255,255,255,0.6)"; context.fillRect(i*10 + 1, j*10 + 1, 8, 8); } } } } return canvas

Now we can call drawArray repeatedly in a loop to update the field:

board = RandomInteger[1, {40, 40}]; Do[ board = CellularAutomaton["GameOfLife", board]; drawArray[board] // FrontSubmit; Pause[0.1]; , {i,1,50}]

However, this is not yet the most optimal way of doing that. The output canvas is fixed to a JavaScript cell and cannot be represented as a normal Wolfram Expression. To solve this, we need to go deeper...

A Deeper Look

First, you need to understand how symbols are defined and evaluated:

  • Basic (as shown above)
  • Instanced (with methods)

Basics

When a symbol is evaluated, the runtime does not create or store its state—following the call (evaluate) and forget principle—except for the side effects made by evaluating this symbol. Here are a few rules for them:

  • A frontend symbol is an asynchronous JavaScript function with 2 arguments
  • A frontend symbol should return either nothing or a normal JavaScript expression: string, number, array, or object. It can also return a function only if the result is used by the parent frontend symbol, but not by the Wolfram Kernel via FrontFetch
  • Passed arguments must be evaluated using the window.interpretate function
  • The starting frontend context is always core; you can then diverge the evaluation to any other context

Let's look at the basic structure:

core.MySymbol = async (args, env) => {
  //number of arguments + options
  args.length

  //extract passed options
  const options = await core._getRules(args, env);

  //evaluate 1st argument
  const arg1 = await interpretate(args[0], env);

  //access shared memory (side-effects)
  env.prop = "Hey!";
  
  //return evaluation result
  return 3;
}

By convention, if MySymbol is used in ViewBox as a display function or in the output form of FrontEndExecutable, a visible DOM element is accessible as the element property of env.

Shared memory and side effects

The second argument of the MySymbol asynchronous function is a shared object for the entire expression tree, which can be used to create side effects or pass data implicitly from parent to child expressions. For example, here is a brief implementation of RGBColor in Graphics:

core.RGBColor = async (args, env) => {
  const r = await interpretate(args[0], env);
  const g = await interpretate(args[1], env);
  const b = await interpretate(args[2], env);

  // Modify env
  env.color = new whatever.Color(r, g, b);

  // Return in case it's used directly
  return env.color;
};

env can be scoped using List expression for example:

{Blue, {Red, Sphere[] (* I am red *)}, Sphere[] (* I am blue *)} // Graphics3D

You can also scope it in JavaScript for the children and pass additional properties:

const arg1 = await interpretate(args[0], {...env, boo: true});

For example, this is how Graphics3D works under the hood:

core.Graphics3D = async (args, env) => {
  ...
  const scene = {};
  await interpretate(args[0], { ...env, scene });
  rendered.add(scene); // All objects are there!
};

core.Sphere = async (args, env) => {
  ...
  env.scene.add(new sphere({ color: env.color }));
};

Thus, the desired data ends up in the scene.

To access the non-scopable part of env, use:

//this will be global for all expressions
//no matter if it is passed as {...env} or scoped within List[]
env.global.myParam = "Hey!"

env.global is shared across all branches of the expression tree being evaluated.

Namespaces

Please do not confuse this with Wolfram Language symbol contexts

To prevent name collisions and reduce clutter when a symbol like SymbolA can be interpreted differently depending on its parent expressions, you can specify a namespace context for symbols in JavaScript using:

var subSpace = {};

subSpace.SymbolA = (args, env) => {
  // One thing
};

core.SymbolA = (args, env) => {
  // Completely different thing
};

The interpreter will check all available contexts and use the first match. However, you can prioritize a context by providing it in the env object:

core.GrantSymbol = (args, env) => {
  const data = interpretate(args, { ...env, context: subSpace });
  // ...
  // First match should be in the `subSpace` object
  // "One thing"
};

If you have multiple prioritized contexts, you can pass them as an array:

core.GrantSymbol = (args, env) => {
  const data = interpretate(args, { ...env, context: [subSpace1, subSpace2] });
  // ...
  // First match in `subSpace1`, if not found, then in `subSpace2`, and finally in the `core` context
};

This emulates the pattern matching of Wolfram Language down-values, i.e.:

GrantSymbol[SymbolA[]] := "One thing"
SymbolA[] := "Completely different thing"
Exposing namespaces

You can also expose a namespace to the interpreter, so if it doesn't find the desired symbol in the core context, it tries all exposed namespaces:

const namespace = {};
namespace.name = "Plumbus";

namespace.Symbol = async (args, env) => {}

//HERE ->
interpretate.contextExpand(namespace);

Packed and Numeric Arrays

When sending large chunks of numeric data, the Wolfram Kernel may use PackedArray, which becomes a NumericArrayObject in JavaScript (a wrapper for TypedArray).

Always check for this:

let data = await interpretate(args[0], env);
if (data instanceof NumericArrayObject) {
  // handle TypedArray
} else {
  // handle normal array
}

You can convert to a normal JS array with:

data = data.normal();

Symbol Methods

The symbol itself is an asynchronous function that is called during normal evaluation. However, in WLJS we add more: when data changes occur, this may trigger a propagating chain of calls to the update method of symbols. If an expression is destroyed, this may trigger chain calls to the destroy method of all involved symbols (if applicable). For example:

//evaluation
core.MySymbol = (args, env) => {}

//update chain
core.MySymbol.update = (args, env) => {}
//destroy chain
core.MySymbol.destroy = (args, env) => {}
//custom handler
core.MySymbol.whatever = (args, env) => {}

As you can see, you can specify your own method and invoke it for all tree branches by providing it as:

await interpretate(args[1], {...env, method: 'whatever'});

System handles these methods automatically following the rules:

  1. The update method can be called for any symbol in the expression tree automatically when the data changes.
  2. Only instanced symbols (see later) can invoke update in response to changes in other dependent instanced symbols
  3. destroy only propagates through instanced symbols, skipping any basic frontend symbols, assuming they do not have internal state to clean up

For most stateless frontend symbols like List, Plus, etc., we define:

namespace.SymbolName.update = namespace.SymbolName

Instanced symbols

By setting the virtual property to true

core.MySymbol.virtual = true

we allow MySymbol to have state. In combination with update and destroy methods, it basically becomes a class definition. To sum up:

  1. Instanced symbols inherit all features of stateless frontend symbols
  2. Instanced symbols have state
  3. Instanced symbols can be destroyed with the destroy method (handled automatically)
  4. Instanced symbols listen for any changes from dependent instanced symbols down the expression tree; the change event bubbles up and invokes update

Local state and methods

State is an object that can be accessed using the shared object env (2nd argument) from the constructor as well as from all defined methods:

core.MySymbol = async (args, env) => {
  env.local    // local state for this instance
  env.global   // global state for the call tree
  env.exposed  // state shared outside the instance
  env.element  // usually some visible DOM element associated to a symbol
  env          // sharable memory within the instance and its children
};
core.MySymbol.update = async (args, env)  => {...}
core.MySymbol.destroy = async (args, env)  => {...}
//............
core.MySymbol.virtual = true

Let's look at a simple example that demonstrates local state:

The destroy method is especially helpful for heavy expressions such as Graphics3D, which is re-rendered at 60FPS and, if not disposed of properly, may run in the background like a stray cat occupying your GPU resources.

We can take advantage of having a different method for update events to avoid costly reevaluation of the entire expression tree. For example, instead of redrawing everything on a graph when the data changes, we update only the changed points rather than disposing of and recreating the graph from scratch:

.js core.NiceChartPlot = async (args, env) => { // Create the element, draw axes, etc... (SLOW) const arg1 = await interpretate(args[0], env); const arg2 = await interpretate(args[1], env); const data = await interpretate(args[2], env); const chart = new Charts.newPlot(arg1, arg2, data); // Save to the state env.local.chart = chart; }; core.NiceChartPlot.update = async (args, env) => { const newdata = await interpretate(args[2], env); // Quickly redraw only changed elements const chart = env.local.chart; chart.points.update(newdata); }; core.NiceChartPlot.destroy = (args, env) => { env.local.chart.dispose(); } core.NiceChartPlot.virtual = true;

By having dedicated methods, we not only eliminate lots of resources from being freed and allocated, but also cut subtrees of the expression from evaluating that are not involved in the update.

Dynamic symbols

Here we discuss the relationship between instanced frontend symbols and dynamic symbols. In short, these are the same entities. We use the term dynamic symbols as shorthand. The only difference between:

core.MySymbol = async () => {}
//....
core.MySymbol.virtual = true;

and this:

MySymbol = "Hey";
TextView[MySymbol // Offload]

is that in the latter, MySymbol is automatically generated during runtime into core.MySymbol, which returns its own value in both the constructor and update calls. However, there is one more thing: the Wolfram Kernel also provides automatic one-way synchronization. When MySymbol is changed on the kernel:

MySymbol = "Hoy!";

the data is copied to the frontend definition of core.MySymbol.data, and an update event will bubble up for all instances. This means that if you have multiple expressions that depend on MySymbol, each having a unique instance of it, the updated data will be shared among all of them. This allows you to redraw and recalculate many things in a single synchronization cycle.

Knowing all of this, let's improve our very first Game of Life board example:

Accessing instances

Sometimes you want to add new objects to an existing instance (like a Graphics canvas) without reevaluating everything. You can access the partial instance and evaluate an expression within it if it was placed there before.

First, we create a pointer to reference it later—FrontInstanceReference. For example:

ref = FrontInstanceReference[];
Plot[x, {x, 0, 1}, Epilog -> {ref}]

then we evaluate a new expression within the instance referenced as ref:

FrontSubmit[Text["Plot", {0.5,0.5}, {0,0}], ref];

A text box will appear in the center. Some of the frontend symbols are meant to be evaluated later and associated with some actions, for example ZoomAt:

FrontSubmit[ZoomAt[{0.5,0.5}, 2.0], ref];

In a similar way, you can remove side effects of expressions using FrontInstanceGroup. This groups all instances and allows you to dispose of them if needed:

g = FrontInstanceGroup[];
FrontSubmit[g[{Opacity[0.5], Triangle[{{-1,0}, {0,1}, {1,0}}]}], ref];

then to remove it:

FrontInstanceGroupRemove[g];

Here FrontInstanceGroupRemove effectively calls destroy on all created instances of frontend symbols.

Read more about this technique at:

Frontend Objects

Any interactive widgets or syntax sugar made with ViewBox stores the displayed expression inline, hidden within a code line. This takes up space and adds more load to the cell editor. It's one thing if we keep only MySymbol[data//Offload] inside a ViewBox, and a completely different story if we try to keep Plot[x,{x,0,1}] there, which expands into a bulky 10-100kB expression.

To address this issue, frontend objects are used. Here's what they do:

  • Compress an expression into a reference
  • Upload original data to notebook storage and sync with the frontend on-demand in the form of ExpressionJSON
  • Upon evaluation, expand the reference back to the Wolfram Expression
  • Automatically garbage collect if not used or referenced anywhere

For example, you can compress individual arguments of your expression or an entire expression:

SomeSymbol /: MakeBoxes[SomeSymbol[arg1_, arg2_], StandardForm] := With[{
  fe1 = CreateFrontEndObject[arg1]
},
  ViewBox[..., displaySymbol[fe1, arg2]]
]

The example below is also valid if your definitions match on the Wolfram Kernel and frontend:

SomeSymbol /: MakeBoxes[s_SomeSymbol, StandardForm] := With[{
  display = CreateFrontEndObject[s]
},
  ViewBox[..., display]
]

This method is widely used for entire Graphics, Image, Graphics3D expressions and others when their serialized sizes exceed a certain threshold.

Javascript IO API

An additional to interact with Wolfram Kernel besides ViewBox, FrontSubmit or FrontFetch is to use event-based IO interface exposed globally as:

server.kernel.io

Fire events

To fire events catch by EventHandler - use fire method:

server.kernel.io.fire(id: string, payload: any, pattern?: string = "Default"): void

where id is string id of EventObject, payload can be any Javascript object, except functions or promises. For example:

EventHandler["someRandomId", { _ -> Beep }]
.js setTimeout(() => { server.kernel.io.fire('someRandomId', true); }, 2000);

To fire event without payload - use poke method:

server.kernel.io.poke(id: string): void

Fetching the data

To evaluate and fetch a own-values or down-values of a symbol - use fetch:

server.kernel.io.fetch(id: string) : Promise
server.kernel.io.fetch(id: string, [arg1 : any, arg2 : any, ...]) : Promise

it always return Promise. For example, let's define a symbol:

GetTime := TextString[Now];

Now fetch and display it:

.js const dom = document.createElement('span'); server.kernel.io.fetch('GetTime').then(async (res) => { dom.innerHTML = await interpretate(res, {}); }) return dom;

Return Promise object to defer io fetch request, i.e.

GetTime := With[{p = Promise[]},
    (* do something or set a scheldule, call external *)
    p 
]

Fire event and fetch the result

Here is the signature of method request:

server.kernel.io.request(id: string, payload: any, pattern?: string = "Default"): Promise

WLJS event system provides extra features to mimic full-duplex architecture. If one fires and event, it is possible to read the results of all subscribed listeners (if provided) or their handler functions:

EventHandler[ev, Function[dataA,
	(* do something *)
	dataB (* <--- here *)
]]

EventHandler[ev // EventClone, Function[dataA,
	(* do something *)
	dataC (* <--- here *)
]]


EventFire[ev, ...] (* ---> {dataB, dataC} *)

Here is an example:

.js const button = document.createElement('button'); button.innerText = "Press me"; button.style.background = "pink"; button.style.padding = "1rem"; button.addEventListener('click', async () => { const data = await server.kernel.io.request('eventUid', true); button.innerHTML = await interpretate(data, {}); }); return button;

and now we define a handler function:

EventHandler["eventUid", Function[Null, RandomWord[] ]];

Return Promise if you need to do some deferred calculations.

On this page