Your own operators
The operators a map can use, and the editor shows in its toolbox, come from a registry. createRegistry() with no options gives you every built-in operator. Pass the same registry to the editor and to createTransform.
import { createRegistry, createTransform } from "@crosswalkjs/core";
import { createMapper } from "@crosswalkjs/canvas";
const registry = createRegistry({ /* options below */ });
createMapper(element, { document: map, registry });
const run = createTransform(map, { registry });
Choosing the built-ins
createRegistry(); // all of them, the default
createRegistry({ builtins: false }); // none
createRegistry({ builtins: (operator) => operator.category !== "math" }); // everything except Math
createRegistry({ builtins: (operator) => ["text.concat", "flow.onlyIf"].includes(operator.id) });
To take one out after the fact, use registry.unregister("text.replace"). A map that still uses it then reports an unknown-operator issue instead of failing to load.
Here the registry leaves out the Text operators, so the toolbox has none. Change the filter and the toolbox follows, and leave out Math to see what a map that needs a missing operator does:
import { createRegistry } from "@crosswalkjs/core";
// Everything except the Text operators, which the map doesn't need.
// Try `operator.category !== "math"`: the map uses Multiply, so it then
// reports an unknown operator. Or choose operators by id:
// builtins: (operator) => ["math.multiply", "flow.onlyIf"].includes(operator.id)
export const registry = createRegistry({ builtins: (operator) => operator.category !== "text" });
import { createMapper } from "@crosswalkjs/canvas";
import { registry } from "./crosswalk.js";
import startingMap from "./order-line.map.json";
const mapper = createMapper(document.getElementById("mapper"), { document: startingMap, registry });
// A map using an operator the registry doesn't have still loads, with an issue.
const issues = mapper.getIssues();
if (issues.length === 0) console.log("No issues: the registry has every operator the map uses.");
for (const issue of issues) console.warn(issue.code, issue.message);
{
"version": 1,
"source": { "type": "object", "title": "Line", "properties": { "Price": { "type": "number" }, "Quantity": { "type": "number" } } },
"target": { "type": "object", "title": "Invoice line", "properties": { "Total": { "type": "number" } } },
"nodes": [{ "id": "total", "operator": "math.multiply", "position": { "x": 120, "y": 60 } }],
"links": [
{ "from": { "source": "Price" }, "to": { "node": "total", "input": 0 } },
{ "from": { "source": "Quantity" }, "to": { "node": "total", "input": 1 } },
{ "from": { "node": "total" }, "to": { "target": "Total" } }
]
}
Adding an operator
A value operator works out one value from its inputs. Change what run returns and the output follows:
import { createRegistry, defineOperator, toText } from "@crosswalkjs/core";
// Try changing what `run` returns, such as joining the letters with ".", and
// watch the output below the editor.
const initials = defineOperator({
kind: "value",
id: "acme.initials",
label: "Initials",
glyph: "AB.",
category: "text",
description: "The first letter of each word, in capitals.",
inputs: { min: 1, max: 1, labels: ["name"] },
run: ([name]) =>
toText(name ?? null)
.split(/\s+/)
.filter(Boolean)
.map((word) => word[0].toUpperCase())
.join(""),
});
export const registry = createRegistry({ operators: [initials] });
import { createMapper } from "@crosswalkjs/canvas";
import { createTransform } from "@crosswalkjs/core";
import { registry } from "./crosswalk.js";
import startingMap from "./person.map.json";
const person = { Name: "Ana Maria Lima" };
const output = document.getElementById("output");
// Maps run with the same registry the editor has.
function show(map) {
try {
output.textContent = JSON.stringify(createTransform(map, { registry })(person), null, 2);
} catch (error) {
output.textContent = error.message;
}
}
createMapper(document.getElementById("mapper"), { document: startingMap, registry, onChange: show });
show(startingMap);
<div id="mapper" style="height: 360px"></div>
<pre id="output"></pre>
{
"version": 1,
"source": { "type": "object", "title": "Person", "properties": { "Name": { "type": "string" } } },
"target": { "type": "object", "title": "Badge", "properties": { "Name": { "type": "string" }, "Initials": { "type": "string" } } },
"nodes": [{ "id": "initials1", "operator": "acme.initials", "position": { "x": 120, "y": 60 } }],
"links": [
{ "from": { "source": "Name" }, "to": { "target": "Name" } },
{ "from": { "source": "Name" }, "to": { "node": "initials1", "input": 0 } },
{ "from": { "node": "initials1" }, "to": { "target": "Initials" } }
]
}
| Field | What it's for |
|---|---|
id | How maps refer to it. Namespace your own, such as acme.initials, so they can't clash with built-ins. |
label, description | Shown in the toolbox and the inspector. |
glyph | A few characters drawn on the tile. Defaults to the start of the label. |
category | Which toolbox group it goes in. |
inputs | How many inputs it takes: min, an optional max (leave it out for no limit), and optional labels shown in the inspector. tables lists the inputs that take a table of keys and values, such as [1] for Look up's second input, so the inspector offers a table there. |
run(args) | Works out the value. args holds one value per input, in order. |
handlesAbsent | Leave it out normally: an input with no value then makes the output empty without calling run. Set it to true to see missing inputs yourself, as Exists does. |
Return ABSENT (exported from the core package) to leave the target field out, as Only if does. Throw an OperatorError for input that doesn't make sense; the editor and MapRuntimeError report it against the node.
The core package exports the helpers the built-ins use for reading inputs: toText, toNumber (which throws on text that isn't a number), toBoolean, isNumeric and compare.
Operators that wait
Some operators have to wait for their value, such as a lookup in a database or another service. Mark them async: true and return a promise. They get a second argument with a signal to pass on, so a cancelled run stops their work too:
import { createRegistry, defineOperator, OperatorError, toText } from "@crosswalkjs/core";
// api.example.com is answered by the sandbox, after a moment, as a real
// lookup would take: Gold for an id ending in an even digit, Silver otherwise.
const tier = defineOperator({
kind: "value",
async: true,
id: "acme.customerTier",
label: "Customer tier",
category: "acme",
inputs: { min: 1, max: 1, labels: ["customer id"] },
run: async ([id], { signal }) => {
const response = await fetch(`https://api.example.com/customers/${toText(id ?? null)}/tier`, { signal });
if (!response.ok) throw new OperatorError(`No tier for customer ${toText(id ?? null)}`);
return (await response.json()).tier;
},
});
export const registry = createRegistry({ operators: [tier] });
import { createMapper } from "@crosswalkjs/canvas";
import { createAsyncTransform } from "@crosswalkjs/core";
import { registry } from "./crosswalk.js";
import startingMap from "./customer.map.json";
// Try an id ending in an odd digit.
const customer = { CustomerId: "C-1042", Name: "Ana Lima" };
const output = document.getElementById("output");
// A map with an operator that waits runs with createAsyncTransform.
async function show(map) {
try {
output.textContent = JSON.stringify(await createAsyncTransform(map, { registry })(customer), null, 2);
} catch (error) {
output.textContent = error.message;
}
}
createMapper(document.getElementById("mapper"), { document: startingMap, registry, onChange: show });
show(startingMap);
<div id="mapper" style="height: 360px"></div>
<pre id="output"></pre>
{
"version": 1,
"source": { "type": "object", "title": "Customer", "properties": { "CustomerId": { "type": "string" }, "Name": { "type": "string" } } },
"target": { "type": "object", "title": "Account", "properties": { "Name": { "type": "string" }, "Tier": { "type": "string" } } },
"nodes": [{ "id": "tier", "operator": "acme.customerTier", "position": { "x": 120, "y": 60 } }],
"links": [
{ "from": { "source": "Name" }, "to": { "target": "Name" } },
{ "from": { "source": "CustomerId" }, "to": { "node": "tier", "input": 0 } },
{ "from": { "node": "tier" }, "to": { "target": "Tier" } }
]
}
A map that uses one runs with createAsyncTransform rather than createTransform. Aggregate operators are always synchronous, but their input can come from an async operator.
Inside a loop, an async operator is called once per item. If many items share the same input, such as one exchange rate for a whole order, cache inside run so you don't fetch it again for every item.
Aggregate operators
An aggregate operator reduces one input across every item of a repeating record:
import { ABSENT, createRegistry, defineOperator, toText } from "@crosswalkjs/core";
// Try the shortest instead, by sorting the other way.
const longest = defineOperator({
kind: "aggregate",
id: "acme.longest",
label: "Longest",
category: "aggregate",
inputs: { min: 1, max: 1 },
reduce: (values) => values.map((v) => toText(v)).sort((a, b) => b.length - a.length)[0] ?? ABSENT,
});
export const registry = createRegistry({ operators: [longest] });
import { createMapper } from "@crosswalkjs/canvas";
import { createTransform } from "@crosswalkjs/core";
import { registry } from "./crosswalk.js";
import startingMap from "./team.map.json";
const team = { Team: "Billing", People: [{ Name: "Ana" }, { Name: "Bartholomew" }, { Name: "Kai" }] };
const output = document.getElementById("output");
function show(map) {
try {
output.textContent = JSON.stringify(createTransform(map, { registry })(team), null, 2);
} catch (error) {
output.textContent = error.message;
}
}
createMapper(document.getElementById("mapper"), { document: startingMap, registry, onChange: show });
show(startingMap);
<div id="mapper" style="height: 360px"></div>
<pre id="output"></pre>
{
"version": 1,
"source": {
"type": "object",
"title": "Team",
"properties": {
"Team": { "type": "string" },
"People": { "type": "array", "items": { "type": "object", "properties": { "Name": { "type": "string" } } } }
}
},
"target": { "type": "object", "title": "Summary", "properties": { "Team": { "type": "string" }, "LongestName": { "type": "string" } } },
"nodes": [{ "id": "longest", "operator": "acme.longest", "scope": "People[]", "position": { "x": 120, "y": 70 } }],
"links": [
{ "from": { "source": "Team" }, "to": { "target": "Team" } },
{ "from": { "source": "People[].Name" }, "to": { "node": "longest", "input": 0 } },
{ "from": { "node": "longest" }, "to": { "target": "LongestName" } }
]
}
reduce gets the input's value for each item, with missing values already dropped.
Categories
Categories are the groups in the toolbox, shown in the order they're registered. An operator whose category isn't registered gets one automatically, labelled with its id. To give it a proper label and a colour:
import { createRegistry, defineCategory } from "@crosswalkjs/core";
import { initials } from "./initials.js";
// A category of your own with its colour, and a built-in one relabelled.
// Try another colour, or relabelling "math".
const acme = defineCategory({ id: "acme", label: "Acme", color: "#ff3366" });
const strings = defineCategory({ id: "text", label: "Strings" });
export const registry = createRegistry({ categories: [acme, strings], operators: [initials] });
import { defineOperator, toText } from "@crosswalkjs/core";
export const initials = defineOperator({
kind: "value",
id: "acme.initials",
label: "Initials",
glyph: "AB.",
category: "acme",
description: "The first letter of each word, in capitals.",
inputs: { min: 1, max: 1, labels: ["name"] },
run: ([name]) =>
toText(name ?? null)
.split(/\s+/)
.filter(Boolean)
.map((word) => word[0].toUpperCase())
.join(""),
});
import { createMapper } from "@crosswalkjs/canvas";
import { registry } from "./crosswalk.js";
import startingMap from "./person.map.json";
createMapper(document.getElementById("mapper"), { document: startingMap, registry });
{
"version": 1,
"source": { "type": "object", "title": "Person", "properties": { "Name": { "type": "string" } } },
"target": { "type": "object", "title": "Badge", "properties": { "Name": { "type": "string" }, "Initials": { "type": "string" } } },
"nodes": [{ "id": "initials1", "operator": "acme.initials", "position": { "x": 120, "y": 60 } }],
"links": [
{ "from": { "source": "Name" }, "to": { "target": "Name" } },
{ "from": { "source": "Name" }, "to": { "node": "initials1", "input": 0 } },
{ "from": { "node": "initials1" }, "to": { "target": "Initials" } }
]
}
- A category's
coloris used unless the active theme sets one for it. Themes can set colours for your categories too. - Registering a category with a built-in's id relabels or recolours that one:
{ id: "text", label: "Strings" }. - Category ids become CSS class names, so they start with a letter and use only letters, digits,
-and_.
Where your operators run
An operator has to exist wherever a map using it runs, with the same id and the same behaviour. A map only stores the operator's id, so which code that id runs is up to each runtime. Where that is decides where you write it:
| Maps run | Write the operator in | The editor gets it from |
|---|---|---|
| Only in the browser | TypeScript | createRegistry, as above. |
| On a Node server | TypeScript, in one module the browser and the server both import | createRegistry, the same on both sides. |
| On a .NET server | C# | The server's catalogue, with registryFromCatalog. See From your server. |
For a .NET server, the C# operator is the only one. The browser learns its id, label and inputs from the server's catalogue, so the toolbox offers it and maps using it are checked, and a preview runner runs it on the server while the map is drawn. Nothing needs writing twice.
Write a browser version as well only when maps must also run in the browser, such as offline. Pass it as registryFromCatalog(catalog, { local: [initials] }). To keep the two versions the same, write the cases down once, as a JSON list of inputs and the output each should give, and have both test suites run them. That's how the built-in operators stay the same in both runtimes.
Maps are saved and outlive the code that runs them. Once a map uses an operator, changing what it does changes every saved map that uses it. For a change in behaviour, add an operator with a new id, such as acme.initials.v2, and leave the old one for the maps that use it. A runtime that's missing an operator a map uses reports unknown-operator before running anything, so a map is never half run.
In your framework
Make the registry once, in a module of its own, and import it wherever it's needed:
import { createRegistry, defineCategory } from "@crosswalkjs/core";
import { initials } from "./operators/initials";
export const acme = defineCategory({ id: "acme", label: "Acme", color: "#ff3366" });
export const registry = createRegistry({ categories: [acme], operators: [initials] });
Each framework's editor component takes it as registry. The editor reads it once, when it's created. To run maps in the browser too, pass the same registry to createTransform(map, { registry }).
- React
- Vue
- Svelte
- Angular
- Solid
- Astro
- Web Components
- Plain JavaScript
import { MapEditor } from "./MapEditor";
import { registry } from "./crosswalk";
<MapEditor map={map} registry={registry} onChange={setMap} />
From your server
When a server runs the maps, the browser asks it for its operators instead. Add a loader to the same module:
import { registryFromCatalog, type OperatorRegistry } from "@crosswalkjs/core";
export async function loadRegistry(): Promise<OperatorRegistry> {
const response = await fetch("/crosswalk/operators");
if (!response.ok) throw new Error(`Couldn't load the operators (${response.status})`);
return registryFromCatalog(await response.json());
}
The editor reads its registry once, so wait for the catalogue before showing it:
- React
- Vue
- Svelte
- Angular
- Solid
- Astro
- Web Components
- Plain JavaScript
import { useEffect, useState } from "react";
import type { MapDocument, OperatorRegistry } from "@crosswalkjs/core";
import { MapEditor } from "./MapEditor";
import { loadRegistry } from "./crosswalk";
export function MapPage({ map }: { map: MapDocument }) {
const [registry, setRegistry] = useState<OperatorRegistry>();
useEffect(() => {
let current = true;
void loadRegistry().then((loaded) => current && setRegistry(loaded));
return () => {
current = false;
};
}, []);
return registry ? <MapEditor map={map} registry={registry} /> : <p>Loading operators…</p>;
}
In Next.js this is a client component, with "use client"; at the top.
Here it is running, with a stand-in for your server answering /crosswalk/operators and /crosswalk/preview. Customer tier only exists on that server, so it shows in the toolbox and its value in the preview, but it never runs in the browser:
import { registryFromCatalog } from "@crosswalkjs/core";
// Your server's operators, as the editor needs them. Here a stand-in server
// answers; in your app this is your API.
export async function loadRegistry() {
const response = await fetch("/crosswalk/operators");
if (!response.ok) throw new Error(`Couldn't load the operators (${response.status})`);
return registryFromCatalog(await response.json());
}
import { createMapper, previewOverHttp } from "@crosswalkjs/canvas";
import { loadRegistry } from "./crosswalk.js";
import startingMap from "./customer.map.json";
// Customer tier only exists on the server, so the preview runs there too.
// Try a CustomerId ending in an odd digit.
const sample = { CustomerId: "C-1042", Name: "Ana Lima" };
loadRegistry().then((registry) => {
createMapper(document.getElementById("mapper"), {
document: startingMap,
registry,
preview: { input: sample, run: previewOverHttp("/crosswalk/preview") },
});
});
{
"version": 1,
"source": { "type": "object", "title": "Customer", "properties": { "CustomerId": { "type": "string" }, "Name": { "type": "string" } } },
"target": { "type": "object", "title": "Account", "properties": { "Name": { "type": "string" }, "Tier": { "type": "string" } } },
"nodes": [{ "id": "tier", "operator": "acme.customerTier", "position": { "x": 120, "y": 60 } }],
"links": [
{ "from": { "source": "Name" }, "to": { "target": "Name" } },
{ "from": { "source": "CustomerId" }, "to": { "node": "tier", "input": 0 } },
{ "from": { "node": "tier" }, "to": { "target": "Tier" } }
]
}
To preview with the server's operators, give the createMapper call in your component a preview with run: previewOverHttp("/crosswalk/preview"). Connecting to a server covers both endpoints.