Skip to main content

Embedding the editor

import { createMapper } from "@crosswalkjs/canvas";

const mapper = createMapper(element, options);

createMapper builds the editor inside element and returns an object for controlling it. The editor fills the element, so give it a height.

Options​

OptionWhat it does
documentThe map to start with. Required. To start empty, give it your schemas with no nodes or links.
registryThe operators to offer. Defaults to every built-in. See Your own operators.
themeA theme id, or your own theme definition. Defaults to "ink". See Themes.
onChange(document)Called after every edit made in the editor, with a copy of the new map.
toolboxCollapsedStart with the toolbox collapsed to a narrow rail (true) or open (false). Leave it out and it collapses by itself while the editor is narrower than 720px, until someone opens or closes it.
onToolboxToggle(collapsed, automatic)Called when the toolbox is collapsed or expanded, so you can remember the choice. automatic is true when it folded or opened by itself as the editor's width changed, which usually isn't worth remembering.
readOnlyShow the map without letting anyone change it.
mappedOnlyHide fields nothing maps to or from.
autoLayoutPlace operators between the fields they connect, ignoring saved positions.
fitContentSize the editor to its content rather than its container.
phoneViewfalse keeps the canvas on a phone-sized screen, rather than the list of fields. See On a phone.
zoomHow far the grid starts zoomed, from 0.4 to 2. Full size, 1, by default. The ⤢ button by the zoom fits every operator in view.
hidePagesHide the page tabs and show the whole map on one grid. The pages are kept in the document.
hideSearchHide the magnifier in each tree's heading that finds a field.
editSchemasLet people replace the source or target schema from each tree's heading: from a sample, a JSON Schema, or an OpenAPI document. Off by default. See Schemas.
onPerformanceCalled with { kind, ms }: how long a render took after an edit, including the browser laying it out, and each redraw of the lines. See Big maps.
preview{ input, run?, delay? }: show each target field's value for a sample input. See Connecting to a server.
labelsThe editor's wording, in your words or language. See In your own words.
onSelectionChange(selection)Called when what's selected changes. See Following along.
onPageChange(page, submap)Called when the page or sub-map shown changes.
onIssuesChange(issues)Called with every issue when the map is first shown, and whenever they change.

For a read-only summary, createMapView turns on the last four together.

Try some, and watch the console as you edit:

main.js
import { createMapper } from "@crosswalkjs/canvas";
import startingMap from "./order.map.json";

// Try readOnly: true, mappedOnly: true, autoLayout: true, or theme: "dracula".
createMapper(document.getElementById("mapper"), {
document: startingMap,
theme: "ink",
toolboxCollapsed: true,
onToolboxToggle: (collapsed) => console.log("Toolbox collapsed:", collapsed),
onChange: (map) => console.log("Changed: the map has", map.links.length, "links"),
});
order.map.json
{
"version": 1,
"source": {
"type": "object",
"title": "Order",
"properties": {
"Number": {
"type": "string"
},
"Status": {
"type": "string"
},
"Billing": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Shipping": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Date": {
"type": "string"
},
"Qty": {
"type": "number"
}
}
}
}
}
},
"target": {
"type": "object",
"title": "Invoice",
"properties": {
"Reference": {
"type": "string"
},
"Status": {
"type": "string"
},
"BillTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"ShipTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Units": {
"type": "number"
},
"FirstOrdered": {
"type": "string"
}
}
}
}
}
},
"loops": [
{
"source": "Lines[]",
"target": "Products[]",
"sortBy": [
{
"path": "Lines[].Date"
}
],
"groupBy": "Lines[].Sku"
}
],
"nodes": [
{
"id": "status",
"operator": "convert.lookup",
"constants": {
"1": {
"P": "Paid",
"S": "Shipped",
"C": "Cancelled"
},
"2": "Unknown"
},
"position": {
"x": 150,
"y": 50
}
},
{
"id": "units",
"operator": "aggregate.sum",
"scope": "Lines[]",
"position": {
"x": 150,
"y": 290
}
}
],
"links": [
{
"from": {
"source": "Number"
},
"to": {
"target": "Reference"
}
},
{
"from": {
"source": "Status"
},
"to": {
"node": "status",
"input": 0
}
},
{
"from": {
"node": "status"
},
"to": {
"target": "Status"
}
},
{
"from": {
"source": "Billing"
},
"to": {
"target": "BillTo"
},
"submap": "Address"
},
{
"from": {
"source": "Shipping"
},
"to": {
"target": "ShipTo"
},
"submap": "Address"
},
{
"from": {
"source": "Lines[].Sku"
},
"to": {
"target": "Products[].Sku"
}
},
{
"from": {
"source": "Lines[].Qty"
},
"to": {
"node": "units",
"input": 0
}
},
{
"from": {
"node": "units"
},
"to": {
"target": "Products[].Units"
}
},
{
"from": {
"source": "Lines[].Date"
},
"to": {
"target": "Products[].FirstOrdered"
}
}
],
"notes": [
{
"id": "note1",
"text": "One product per SKU, earliest order first. Units add up across the SKU's lines.",
"position": {
"x": 24,
"y": 22
},
"on": {
"node": "units"
}
}
],
"submaps": {
"Address": {
"source": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"target": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"nodes": [
{
"id": "postcode",
"operator": "text.upper",
"position": {
"x": 160,
"y": 90
}
}
],
"links": [
{
"from": {
"source": "Street"
},
"to": {
"target": "Line1"
}
},
{
"from": {
"source": "City"
},
"to": {
"target": "Town"
}
},
{
"from": {
"source": "Postcode"
},
"to": {
"node": "postcode",
"input": 0
}
},
{
"from": {
"node": "postcode"
},
"to": {
"target": "Postcode"
}
}
],
"notes": [
{
"id": "note1",
"text": "Both addresses on the order use this.",
"position": {
"x": 120,
"y": 190
}
}
]
}
}
}

The editor object​

MethodWhat it does
getDocument()A copy of the current map.
setDocument(map)Replaces the map, such as after loading another one. It doesn't call onChange, and it can be undone.
getIssues()Every current issue, as analyseMap reports them.
setTheme(theme)Switches theme straight away: an id or a definition.
setPreviewInput(input)Changes the preview's sample input, or turns the preview off with undefined.
getSelection()What's selected, if anything, in the same shape as onSelectionChange gives it.
undo(), redo()The same as Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z.
destroy()Removes the editor from the page. Call it when the element goes away.

Each button calls one:

main.js
import { createMapper } from "@crosswalkjs/canvas";
import orderMap from "./order.map.json";
import lineMap from "./order-line.map.json";

const mapper = createMapper(document.getElementById("mapper"), { document: orderMap });
const on = (id, action) => document.getElementById(id).addEventListener("click", action);

// Make a change in the editor, then try undo and redo.
on("undo", () => mapper.undo());
on("redo", () => mapper.redo());
on("issues", () => console.log(mapper.getIssues()));
on("document", () => console.log(`${mapper.getDocument().links.length} links, ${mapper.getDocument().nodes.length} operators`));

let dark = false;
on("theme", () => mapper.setTheme((dark = !dark) ? "dracula" : "studio"));

let showingOrder = true;
on("load", () => mapper.setDocument((showingOrder = !showingOrder) ? orderMap : lineMap));
on("destroy", () => mapper.destroy());
index.html
<style>
.buttons { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px; }
</style>
<div class="buttons">
<button id="undo">undo()</button>
<button id="redo">redo()</button>
<button id="issues">getIssues()</button>
<button id="document">getDocument()</button>
<button id="theme">setTheme()</button>
<button id="load">setDocument()</button>
<button id="destroy">destroy()</button>
</div>
<div id="mapper" style="height: 380px"></div>
order.map.json
{
"version": 1,
"source": {
"type": "object",
"title": "Order",
"properties": {
"Number": {
"type": "string"
},
"Status": {
"type": "string"
},
"Billing": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Shipping": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Date": {
"type": "string"
},
"Qty": {
"type": "number"
}
}
}
}
}
},
"target": {
"type": "object",
"title": "Invoice",
"properties": {
"Reference": {
"type": "string"
},
"Status": {
"type": "string"
},
"BillTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"ShipTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Units": {
"type": "number"
},
"FirstOrdered": {
"type": "string"
}
}
}
}
}
},
"loops": [
{
"source": "Lines[]",
"target": "Products[]",
"sortBy": [
{
"path": "Lines[].Date"
}
],
"groupBy": "Lines[].Sku"
}
],
"nodes": [
{
"id": "status",
"operator": "convert.lookup",
"constants": {
"1": {
"P": "Paid",
"S": "Shipped",
"C": "Cancelled"
},
"2": "Unknown"
},
"position": {
"x": 150,
"y": 50
}
},
{
"id": "units",
"operator": "aggregate.sum",
"scope": "Lines[]",
"position": {
"x": 150,
"y": 290
}
}
],
"links": [
{
"from": {
"source": "Number"
},
"to": {
"target": "Reference"
}
},
{
"from": {
"source": "Status"
},
"to": {
"node": "status",
"input": 0
}
},
{
"from": {
"node": "status"
},
"to": {
"target": "Status"
}
},
{
"from": {
"source": "Billing"
},
"to": {
"target": "BillTo"
},
"submap": "Address"
},
{
"from": {
"source": "Shipping"
},
"to": {
"target": "ShipTo"
},
"submap": "Address"
},
{
"from": {
"source": "Lines[].Sku"
},
"to": {
"target": "Products[].Sku"
}
},
{
"from": {
"source": "Lines[].Qty"
},
"to": {
"node": "units",
"input": 0
}
},
{
"from": {
"node": "units"
},
"to": {
"target": "Products[].Units"
}
},
{
"from": {
"source": "Lines[].Date"
},
"to": {
"target": "Products[].FirstOrdered"
}
}
],
"notes": [
{
"id": "note1",
"text": "One product per SKU, earliest order first. Units add up across the SKU's lines.",
"position": {
"x": 24,
"y": 22
},
"on": {
"node": "units"
}
}
],
"submaps": {
"Address": {
"source": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"target": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"nodes": [
{
"id": "postcode",
"operator": "text.upper",
"position": {
"x": 160,
"y": 90
}
}
],
"links": [
{
"from": {
"source": "Street"
},
"to": {
"target": "Line1"
}
},
{
"from": {
"source": "City"
},
"to": {
"target": "Town"
}
},
{
"from": {
"source": "Postcode"
},
"to": {
"node": "postcode",
"input": 0
}
},
{
"from": {
"node": "postcode"
},
"to": {
"target": "Postcode"
}
}
],
"notes": [
{
"id": "note1",
"text": "Both addresses on the order use this.",
"position": {
"x": 120,
"y": 190
}
}
]
}
}
}
order-line.map.json
{
"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" } }
]
}

On a phone​

Three columns side by side don't fit a phone, so below 560px wide the editor shows the map as a list instead. Each target field says where its value comes from and, with a preview input, what it is for the sample. Tapping a field opens a sheet with how it's made, step by step, each with its value, and there you can link a source field, add an operator, type a value or stop mapping it. A step inside a total shows a value for each item, so you can see what a filter keeps.

It's the same map: changes go through the same onChange and undo as the canvas, and open unchanged on a bigger screen. Sub-maps, pages, notes and what a repeating record repeats over are set on a bigger screen.

Pass phoneView: false to keep the canvas at every size.

In your own words​

Every piece of the editor's wording can be changed with labels, to translate it or to use your product's own terms, such as "property" for "field". Give only what you want to change; the rest stays in English. englishLabels, exported from @crosswalkjs/canvas, has every one, and its type, Labels, documents them.

Wording with something in it is a function, so a translation can put the words in its own order and get its plurals right: inputLabel: (index) => `Entrée ${index + 1}` . Two cover what comes from elsewhere:

  • operator(definition) gives an operator's name, description and input labels, so the built-in operators can be translated too.
  • issue(issue) words an issue. Each issue has a code, such as input-count or required-unmapped, listed in How a map runs, so you can word them by code.

The read-only view takes labels as well.

main.js
import { createMapper } from "@crosswalkjs/canvas";
import startingMap from "./order.map.json";

// The operators' names in French, by id. Anything not here keeps its own name.
const operatorNames = {
"text.concat": "Concaténer",
"text.upper": "Majuscules",
"convert.lookup": "Table de correspondance",
"aggregate.sum": "Somme",
};

createMapper(document.getElementById("mapper"), {
document: startingMap,
labels: {
operators: "Opérateurs",
findOperator: "Chercher un opérateur",
toolboxHint: "Glissez un opérateur sur la grille, puis des champs dessus.",
notes: "Notes",
showing: "Affichage",
mainMap: "Carte principale",
close: "Fermer",
inputs: "Entrées",
inputLabel: (index) => `Entrée ${index + 1}`,
deleteOperator: "Supprimer l'opérateur",
zoomIn: "Agrandir",
zoomOut: "Réduire",
findField: (side) => (side === "source" ? "Chercher un champ source" : "Chercher un champ cible"),
operator: (definition) => ({ label: operatorNames[definition.id] ?? definition.label }),
// Issues have a code, so they can be worded by it.
issue: (issue) => (issue.code === "input-count" ? "Cet opérateur n'a pas assez d'entrées." : issue.message),
},
});
order.map.json
{
"version": 1,
"source": {
"type": "object",
"title": "Order",
"properties": {
"Number": {
"type": "string"
},
"Status": {
"type": "string"
},
"Billing": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Shipping": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Date": {
"type": "string"
},
"Qty": {
"type": "number"
}
}
}
}
}
},
"target": {
"type": "object",
"title": "Invoice",
"properties": {
"Reference": {
"type": "string"
},
"Status": {
"type": "string"
},
"BillTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"ShipTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Units": {
"type": "number"
},
"FirstOrdered": {
"type": "string"
}
}
}
}
}
},
"loops": [
{
"source": "Lines[]",
"target": "Products[]",
"sortBy": [
{
"path": "Lines[].Date"
}
],
"groupBy": "Lines[].Sku"
}
],
"nodes": [
{
"id": "status",
"operator": "convert.lookup",
"constants": {
"1": {
"P": "Paid",
"S": "Shipped",
"C": "Cancelled"
},
"2": "Unknown"
},
"position": {
"x": 150,
"y": 50
}
},
{
"id": "units",
"operator": "aggregate.sum",
"scope": "Lines[]",
"position": {
"x": 150,
"y": 290
}
}
],
"links": [
{
"from": {
"source": "Number"
},
"to": {
"target": "Reference"
}
},
{
"from": {
"source": "Status"
},
"to": {
"node": "status",
"input": 0
}
},
{
"from": {
"node": "status"
},
"to": {
"target": "Status"
}
},
{
"from": {
"source": "Billing"
},
"to": {
"target": "BillTo"
},
"submap": "Address"
},
{
"from": {
"source": "Shipping"
},
"to": {
"target": "ShipTo"
},
"submap": "Address"
},
{
"from": {
"source": "Lines[].Sku"
},
"to": {
"target": "Products[].Sku"
}
},
{
"from": {
"source": "Lines[].Qty"
},
"to": {
"node": "units",
"input": 0
}
},
{
"from": {
"node": "units"
},
"to": {
"target": "Products[].Units"
}
},
{
"from": {
"source": "Lines[].Date"
},
"to": {
"target": "Products[].FirstOrdered"
}
}
],
"notes": [
{
"id": "note1",
"text": "One product per SKU, earliest order first. Units add up across the SKU's lines.",
"position": {
"x": 24,
"y": 22
},
"on": {
"node": "units"
}
}
],
"submaps": {
"Address": {
"source": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"target": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"nodes": [
{
"id": "postcode",
"operator": "text.upper",
"position": {
"x": 160,
"y": 90
}
}
],
"links": [
{
"from": {
"source": "Street"
},
"to": {
"target": "Line1"
}
},
{
"from": {
"source": "City"
},
"to": {
"target": "Town"
}
},
{
"from": {
"source": "Postcode"
},
"to": {
"node": "postcode",
"input": 0
}
},
{
"from": {
"node": "postcode"
},
"to": {
"target": "Postcode"
}
}
],
"notes": [
{
"id": "note1",
"text": "Both addresses on the order use this.",
"position": {
"x": 120,
"y": 190
}
}
]
}
}
}

Following along​

Three events let your page keep up with the editor, such as showing your own panel for the selected operator, or a list of problems beside it. Each is called only when something changes.

EventCalled with
onSelectionChange(selection){ kind: "operator", id }, { kind: "operators", ids }, { kind: "link", index } (its place in the map's links) or { kind: "loop", target }; undefined when nothing is.
onPageChange(page, submap)The id of the page shown, undefined in a map without pages, and the sub-map shown, if one is.
onIssuesChange(issues)Every issue, as getIssues() gives them: once when the map is first shown, and again whenever they change.
main.js
import { createMapper } from "@crosswalkjs/canvas";
import startingMap from "./order.map.json";

const output = document.getElementById("output");
const log = (...parts) => (output.textContent = `${parts.join(" ")}\n${output.textContent}`);

// Click an operator, a line or a loop; press Escape; delete something to make an issue.
createMapper(document.getElementById("mapper"), {
document: startingMap,
toolboxCollapsed: true,
onSelectionChange: (selection) => log("Selected:", selection ? JSON.stringify(selection) : "nothing"),
onPageChange: (page, submap) => log("Showing:", submap ? `sub-map ${submap}` : "the main map", page ? `page ${page}` : ""),
onIssuesChange: (issues) => log("Issues:", issues.length === 0 ? "none" : issues.map((issue) => issue.code).join(", ")),
});
index.html
<div id="mapper" style="height: 360px"></div>
<pre id="output"></pre>
order.map.json
{
"version": 1,
"source": {
"type": "object",
"title": "Order",
"properties": {
"Number": {
"type": "string"
},
"Status": {
"type": "string"
},
"Billing": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Shipping": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Date": {
"type": "string"
},
"Qty": {
"type": "number"
}
}
}
}
}
},
"target": {
"type": "object",
"title": "Invoice",
"properties": {
"Reference": {
"type": "string"
},
"Status": {
"type": "string"
},
"BillTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"ShipTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Units": {
"type": "number"
},
"FirstOrdered": {
"type": "string"
}
}
}
}
}
},
"loops": [
{
"source": "Lines[]",
"target": "Products[]",
"sortBy": [
{
"path": "Lines[].Date"
}
],
"groupBy": "Lines[].Sku"
}
],
"nodes": [
{
"id": "status",
"operator": "convert.lookup",
"constants": {
"1": {
"P": "Paid",
"S": "Shipped",
"C": "Cancelled"
},
"2": "Unknown"
},
"position": {
"x": 150,
"y": 50
}
},
{
"id": "units",
"operator": "aggregate.sum",
"scope": "Lines[]",
"position": {
"x": 150,
"y": 290
}
}
],
"links": [
{
"from": {
"source": "Number"
},
"to": {
"target": "Reference"
}
},
{
"from": {
"source": "Status"
},
"to": {
"node": "status",
"input": 0
}
},
{
"from": {
"node": "status"
},
"to": {
"target": "Status"
}
},
{
"from": {
"source": "Billing"
},
"to": {
"target": "BillTo"
},
"submap": "Address"
},
{
"from": {
"source": "Shipping"
},
"to": {
"target": "ShipTo"
},
"submap": "Address"
},
{
"from": {
"source": "Lines[].Sku"
},
"to": {
"target": "Products[].Sku"
}
},
{
"from": {
"source": "Lines[].Qty"
},
"to": {
"node": "units",
"input": 0
}
},
{
"from": {
"node": "units"
},
"to": {
"target": "Products[].Units"
}
},
{
"from": {
"source": "Lines[].Date"
},
"to": {
"target": "Products[].FirstOrdered"
}
}
],
"notes": [
{
"id": "note1",
"text": "One product per SKU, earliest order first. Units add up across the SKU's lines.",
"position": {
"x": 24,
"y": 22
},
"on": {
"node": "units"
}
}
],
"submaps": {
"Address": {
"source": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"target": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"nodes": [
{
"id": "postcode",
"operator": "text.upper",
"position": {
"x": 160,
"y": 90
}
}
],
"links": [
{
"from": {
"source": "Street"
},
"to": {
"target": "Line1"
}
},
{
"from": {
"source": "City"
},
"to": {
"target": "Town"
}
},
{
"from": {
"source": "Postcode"
},
"to": {
"node": "postcode",
"input": 0
}
},
{
"from": {
"node": "postcode"
},
"to": {
"target": "Postcode"
}
}
],
"notes": [
{
"id": "note1",
"text": "Both addresses on the order use this.",
"position": {
"x": 120,
"y": 190
}
}
]
}
}
}

Saving and loading​

The map is the only state worth saving; the editor holds nothing else.

main.js
import { createMapper } from "@crosswalkjs/canvas";
import emptyMap from "./empty-order.map.json";

// Draw a line or two, then press Run again: the map comes back from
// localStorage. Reset clears it.
const mapper = createMapper(document.getElementById("mapper"), {
document: JSON.parse(localStorage.getItem("map") ?? "null") ?? emptyMap,
onChange: (map) => {
localStorage.setItem("map", JSON.stringify(map));
console.log(`Saved: ${map.links.length} line${map.links.length === 1 ? "" : "s"}.`);
},
});

console.log(localStorage.getItem("map") ? "Loaded the saved map." : "Nothing saved yet, so it starts empty.");
empty-order.map.json
{
"version": 1,
"source": {
"type": "object",
"title": "Order",
"properties": {
"Number": {
"type": "string"
},
"Status": {
"type": "string"
},
"Billing": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Shipping": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Date": {
"type": "string"
},
"Qty": {
"type": "number"
}
}
}
}
}
},
"target": {
"type": "object",
"title": "Invoice",
"properties": {
"Reference": {
"type": "string"
},
"Status": {
"type": "string"
},
"BillTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"ShipTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Units": {
"type": "number"
},
"FirstOrdered": {
"type": "string"
}
}
}
}
}
},
"nodes": [],
"links": []
}

onChange fires on every edit, including each drag that moves an operator. Debounce it if saving is expensive.

Showing the output as people edit​

The editor doesn't run the map itself. To show live output, run it in onChange. A map with errors can't run, so show why instead:

main.js
import { createMapper } from "@crosswalkjs/canvas";
import { analyseMap, createRegistry, createTransform } from "@crosswalkjs/core";
import startingMap from "./order.map.json";
import sample from "./sample-order.json";

const registry = createRegistry();
const output = document.getElementById("output");

// Delete a line in the editor, or drag a new one, and the output follows.
function preview(map) {
const errors = analyseMap(map, registry).issues.filter((issue) => issue.severity === "error");
if (errors.length > 0) {
// A map with errors can't run, so say why rather than showing old output.
output.textContent = errors.map((issue) => issue.message).join("\n");
return;
}
output.textContent = JSON.stringify(createTransform(map, { registry })(sample), null, 2);
}

createMapper(document.getElementById("mapper"), { document: startingMap, registry, onChange: preview });
preview(startingMap);
index.html
<div id="mapper" style="height: 360px"></div>
<pre id="output"></pre>
order.map.json
{
"version": 1,
"source": {
"type": "object",
"title": "Order",
"properties": {
"Number": {
"type": "string"
},
"Status": {
"type": "string"
},
"Billing": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Shipping": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Date": {
"type": "string"
},
"Qty": {
"type": "number"
}
}
}
}
}
},
"target": {
"type": "object",
"title": "Invoice",
"properties": {
"Reference": {
"type": "string"
},
"Status": {
"type": "string"
},
"BillTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"ShipTo": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
}
},
"Products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Sku": {
"type": "string"
},
"Units": {
"type": "number"
},
"FirstOrdered": {
"type": "string"
}
}
}
}
}
},
"loops": [
{
"source": "Lines[]",
"target": "Products[]",
"sortBy": [
{
"path": "Lines[].Date"
}
],
"groupBy": "Lines[].Sku"
}
],
"nodes": [
{
"id": "status",
"operator": "convert.lookup",
"constants": {
"1": {
"P": "Paid",
"S": "Shipped",
"C": "Cancelled"
},
"2": "Unknown"
},
"position": {
"x": 150,
"y": 50
}
},
{
"id": "units",
"operator": "aggregate.sum",
"scope": "Lines[]",
"position": {
"x": 150,
"y": 290
}
}
],
"links": [
{
"from": {
"source": "Number"
},
"to": {
"target": "Reference"
}
},
{
"from": {
"source": "Status"
},
"to": {
"node": "status",
"input": 0
}
},
{
"from": {
"node": "status"
},
"to": {
"target": "Status"
}
},
{
"from": {
"source": "Billing"
},
"to": {
"target": "BillTo"
},
"submap": "Address"
},
{
"from": {
"source": "Shipping"
},
"to": {
"target": "ShipTo"
},
"submap": "Address"
},
{
"from": {
"source": "Lines[].Sku"
},
"to": {
"target": "Products[].Sku"
}
},
{
"from": {
"source": "Lines[].Qty"
},
"to": {
"node": "units",
"input": 0
}
},
{
"from": {
"node": "units"
},
"to": {
"target": "Products[].Units"
}
},
{
"from": {
"source": "Lines[].Date"
},
"to": {
"target": "Products[].FirstOrdered"
}
}
],
"notes": [
{
"id": "note1",
"text": "One product per SKU, earliest order first. Units add up across the SKU's lines.",
"position": {
"x": 24,
"y": 22
},
"on": {
"node": "units"
}
}
],
"submaps": {
"Address": {
"source": {
"type": "object",
"properties": {
"Street": {
"type": "string"
},
"City": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"target": {
"type": "object",
"properties": {
"Line1": {
"type": "string"
},
"Town": {
"type": "string"
},
"Postcode": {
"type": "string"
}
},
"title": "Address"
},
"nodes": [
{
"id": "postcode",
"operator": "text.upper",
"position": {
"x": 160,
"y": 90
}
}
],
"links": [
{
"from": {
"source": "Street"
},
"to": {
"target": "Line1"
}
},
{
"from": {
"source": "City"
},
"to": {
"target": "Town"
}
},
{
"from": {
"source": "Postcode"
},
"to": {
"node": "postcode",
"input": 0
}
},
{
"from": {
"node": "postcode"
},
"to": {
"target": "Postcode"
}
}
],
"notes": [
{
"id": "note1",
"text": "Both addresses on the order use this.",
"position": {
"x": 120,
"y": 190
}
}
]
}
}
}
sample-order.json
{
"Number": "SO-1042",
"Status": "S",
"Billing": {
"Street": "4 Mill Lane",
"City": "Leeds",
"Postcode": "ls1 4ab"
},
"Shipping": {
"Street": "Unit 9, Canal Wharf",
"City": "Leeds",
"Postcode": "ls11 5pq"
},
"Lines": [
{
"Sku": "MUG-01",
"Date": "2026-03-04",
"Qty": 2
},
{
"Sku": "TEA-12",
"Date": "2026-03-01",
"Qty": 1
},
{
"Sku": "MUG-01",
"Date": "2026-03-02",
"Qty": 4
}
]
}

Styling​

The editor adds two style elements to the page's <head> the first time it's used: one for its layout and one per theme in use. Every class starts with cw-, and all of its colours, fonts and corner radii are --cw-* custom properties set by the theme. Prefer a theme of your own to overriding classes: themes are the supported way to change the look, while class names may change between versions.