Load a graph
Load a real saved xenolith.v1 graph and reframe it. Built-in controls + a reload panel.
import { useRef } from 'react'
import { XenolithGraph, XenolithControls, XenolithPanel, XenolithButton } from '@xenolithengine/graph-react'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { DemoStage } from '../Layout.js'
import { loadDemo, demoGraph } from '../demo-data.js'
/** Island: load a real saved xenolith.v1 graph; an in-editor panel reloads it. */
export function LoadDemo() {
const editorRef = useRef<XenolithEditor | null>(null)
return (
<DemoStage>
<XenolithGraph
className="xeno"
resizeToWindow={false}
onReady={(e) => { editorRef.current = e; loadDemo(e) }}
>
<XenolithControls position="bottom-left" />
<XenolithPanel position="top-right">
<XenolithButton onClick={() => { const e = editorRef.current; if (e) { e.loadJSON(demoGraph); e.fitView({ padding: 48, maxZoom: 1 }) } }}>
Reload graph
</XenolithButton>
</XenolithPanel>
</XenolithGraph>
</DemoStage>
)
} // The canonical shared demo scene: register the canvas custom widgets + demo node schemas, load the
// shared demo graph (xenolith.v1), and frame it. Framework-agnostic — every host (React/Vue/Svelte)
// calls this in its onReady so the registry has the schemas before loadJSON.
import { demoGraph, demoSchemas, createCurveWidget, createXYPadWidget } from './demo-graph.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export function loadDemo(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.loadJSON(demoGraph)
editor.fitView({ padding: 48, maxZoom: 1 })
} <script setup lang="ts">
// Vue — load a saved xenolith.v1 graph on ready, reload it from the panel.
// The editor arrives on @ready (the payload is the instance, not a DOM event).
import { ref } from 'vue'
import { XenolithGraph, XenolithControls, XenolithPanel, XenolithButton } from '@xenolithengine/graph-vue'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
import { demoGraph } from '@xenolithengine/demo'
const editor = ref<XenolithEditor | null>(null)
function onReady(e: XenolithEditor): void {
editor.value = e
loadDemo(e)
}
function reload(): void {
const e = editor.value
if (!e) return
e.loadJSON(demoGraph)
e.view.fitView({ padding: 48, maxZoom: 1 })
}
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="onReady">
<XenolithControls position="bottom-left" />
<XenolithPanel position="top-right">
<XenolithButton @click="reload">Reload graph</XenolithButton>
</XenolithPanel>
</XenolithGraph>
</div>
</template> // The canonical shared demo scene: register the canvas custom widgets + demo node schemas, load the
// shared demo graph (xenolith.v1), and frame it. Framework-agnostic — every host (React/Vue/Svelte)
// calls this in its onReady so the registry has the schemas before loadJSON.
import { demoGraph, demoSchemas, createCurveWidget, createXYPadWidget } from './demo-graph.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export function loadDemo(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.loadJSON(demoGraph)
editor.fitView({ padding: 48, maxZoom: 1 })
} import type { NodeSchema, XenolithGraphV1, XenolithNodeV1, XenolithEdgeV1, XenolithTemplateV1 } from '@xenolithengine/graph-editor'
import { REROUTE_TYPE, REROUTE_NODE_TYPE, defaultWidgetValue, type WidgetSpec } from '@xenolithengine/graph-core'
export { createCurveWidget } from './curve-widget.js'
export { createXYPadWidget } from './xy-pad-widget.js'
export { createPreviewWidget } from './preview-widget.js'
import { CURVE_DEFAULT } from './curve-widget.js'
import { XYPAD_DEFAULT } from './xy-pad-widget.js'
import { PREVIEW_DEFAULT } from './preview-widget.js'
/** Initial value for a custom widget — its controller's default, persisted into node.state so a
* loaded graph is self-contained (getWidgetValue returns real data, not undefined). */
function customDefault(renderer: string): unknown {
return renderer === 'curve' ? CURVE_DEFAULT
: renderer === 'xypad' ? XYPAD_DEFAULT
: renderer === 'preview' ? PREVIEW_DEFAULT
: null
}
/**
* The canonical Xenolith demo graph, authored once as plain data in our own `xenolith.v1` format.
* Every host (playground app, the site /playground page, the landing showcase) loads it via
* `editor.loadJSON(demoGraph)` — no host hand-builds nodes with addNode/connect anymore.
*
* It exercises the full visual vocabulary: all four node categories, collapsed pills, every pin
* type colour, and BOTH reroute kinds — the inline `$reroute` knot (split into a wire, non-pullable)
* and the rectangular `Reroute` relay node (a movable node you can pull fresh wires from).
*/
type Cat = 'logic' | 'data' | 'macro' | 'utility'
interface PinSpec { dir: 'in' | 'out'; type: string; label: string }
interface NodeSpec {
key: string
type: string
title?: string
category: Cat
description: string
keywords?: string[]
position: { x: number; y: number }
collapsed?: boolean
pins: PinSpec[]
widgets?: WidgetSpec[]
/** Built-in icon name from the editor's icon registry — `cpu`, `database`, `code`, etc. */
icon?: string
iconSide?: 'left' | 'right'
}
type Conn = [from: string, fromPin: number, to: string, toPin: number, type: string]
const p = (dir: 'in' | 'out', type: string, label: string): PinSpec => ({ dir, type, label })
// ---- node definitions ------------------------------------------------------------------------
const SPECS: NodeSpec[] = [
{ key: 'source', type: 'Source', category: 'logic', icon: 'zap', description: 'Emits a stream of float values', keywords: ['input', 'origin'], position: { x: 40, y: 60 }, pins: [p('out', 'float', 'Output')] },
{ key: 'sample', type: 'Sample', category: 'logic', icon: 'play', description: 'Reads a value at the current cursor', keywords: ['read'], position: { x: 40, y: 200 }, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'filter', type: 'Filter', category: 'logic', icon: 'branch', description: 'Drops values failing a predicate', keywords: ['where'], position: { x: 40, y: 360 }, collapsed: true, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'cache', type: 'Cache', category: 'data', icon: 'database', description: 'Memoises the last seen object', keywords: ['store', 'memo'], position: { x: 40, y: 500 }, collapsed: true, pins: [p('in','object','In'), p('out','object','Out')] },
// 'gather' and 'pack' are MACRO groups, authored in the macro section below (not plain nodes).
// NOTE: CONNS indexes pins by POSITION — keep the original [In, Out, …] prefix and APPEND
// widget-bound pins after Out so existing connections still resolve to the right pin (otherwise
// a wire defined as `transform, 1, …` would land on `scale` instead of `Out`).
// Transform column: spread vertically so the wide Validate node (curve widget) has breathing
// room above and below; Validate's wider footprint is centred under the macro frame.
{ key: 'transform', type: 'Transform', category: 'data', icon: 'code', description: 'Maps an object to a new shape', keywords: ['move', 'map'], position: { x: 568, y: 136 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','float','scale'), p('in','string','mode'), p('in','bool','mirror')],
widgets: [
{ id: 'scale', type: 'slider', label: '', key: 'scale', min: 0, max: 2, step: 0.05 },
{ id: 'mode', type: 'combo', label: '', key: 'mode', values: ['fit', 'fill', 'stretch'] },
{ id: 'mirror', type: 'toggle', label: '', key: 'mirror' },
] },
{ key: 'validate', type: 'Validate', category: 'data', icon: 'flag', description: 'Checks an object against a schema', keywords: ['check'], position: { x: 544, y: 320 },
pins: [p('in','object','In'), p('out','wildcard','Out'), p('in','any','response')],
widgets: [
{ id: 'response', type: 'custom', renderer: 'curve', key: 'response', label: '', height: 120 },
] },
{ key: 'enrich', type: 'Enrich', category: 'data', icon: 'layers', description: 'Augments an object with extra fields', keywords: ['augment'], position: { x: 568, y: 536 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','string','tint'), p('in','float','strength')],
widgets: [
{ id: 'tint', type: 'color', label: '', key: 'tint' },
{ id: 'strength', type: 'slider', label: '', key: 'strength', min: 0, max: 1, step: 0.01 },
] },
{ key: 'score', type: 'Score', category: 'macro', icon: 'cpu', description: 'Ranks an object, emits a float score', keywords: ['rank'], position: { x: 820, y: 200 }, collapsed: true, pins: [p('in','object','In'), p('out','float','Out')] },
{ key: 'resolve', type: 'Resolve', category: 'macro', icon: 'play', description: 'Finalises into a string result', keywords: ['finalize'], position: { x: 820, y: 360 },
pins: [p('in','object','In'), p('in','float','Hint'), p('in','wildcard','Aux'), p('out','string','Out'), p('in','float','seed'), p('in','string','prompt')],
widgets: [
{ id: 'seed', type: 'number', label: '', key: 'seed', min: 0, max: 999999, step: 1 },
{ id: 'prompt', type: 'text', label: '', key: 'prompt', placeholder: 'describe…' },
] },
{ key: 'format', type: 'Format', category: 'data', icon: 'code', description: 'Renders a result to a display string', keywords: ['template'], position: { x: 820, y: 600 }, collapsed: true, pins: [p('in','string','In'), p('out','string','Out')] },
{ key: 'display', type: 'Display', category: 'utility', icon: 'square', iconSide: 'right', description: 'Renders a string to the viewport', keywords: ['show', 'output'], position: { x: 1100, y: 120 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'audit', type: 'Audit', category: 'utility', icon: 'clock', description: 'Logs a float for inspection', keywords: ['log'], position: { x: 1100, y: 280 }, pins: [p('in','float','In'), p('out','any','Out')] },
{ key: 'persist', type: 'Persist', category: 'utility', icon: 'database', description: 'Writes a string to durable storage', keywords: ['save', 'write'], position: { x: 1100, y: 440 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'notify', type: 'Notify', category: 'utility', icon: 'flag', description: 'Pushes a notification on completion', keywords: ['alert', 'webhook'],position: { x: 1100, y: 600 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'archive', type: 'Archive', category: 'utility', icon: 'box', description: 'Cold-stores any payload', keywords: ['cold', 'backup'], position: { x: 1380, y: 360 },
pins: [p('in','any','In'), p('out','any','Out'), p('in','any','offset')],
widgets: [
{ id: 'offset', type: 'custom', renderer: 'xypad', key: 'offset', label: '', height: 110 },
] },
// -------- Canon showcase --------------------------------------------------------------------
// Demonstrates the widget canon in one node:
// - `name` IN-pin + text widget (default-value: hides when a wire arrives, reappears on disc).
// - `say` IN-pin + custom 'preview' widget (visibility:'always', renders the wire's value).
// - actions row button (`+ reset`) — under the pin block.
{ key: 'greet', type: 'Greet', category: 'utility',
description: 'Pin-bound widget showcase: default-value text, always-on preview, actions row',
keywords: ['canon', 'widget', 'default', 'preview'],
position: { x: 1660, y: 60 },
// First IN-pair has no widget so it pairs cleanly with `out` on row 0. The widget-carrying
// pins (`name`, `say`) sit on their own rows underneath — the widget takes the row's right
// half without colliding with an opposite-side label.
pins: [p('in','object','In'), p('out','string','out'), p('in','string','name'), p('in','string','say')],
widgets: [
{ id: 'name', type: 'text', label: '', key: 'name' },
{ id: 'say', type: 'custom', renderer: 'preview', key: 'say', label: '', visibility: 'always' },
{ id: 'reset', type: 'button', label: '+ reset', action: 'resetGreet' },
] },
]
// ---- connections (reroutes wired in) ---------------------------------------------------------
// Reroute nodes participate as ordinary endpoints: pin 0 = in, pin 1 = out.
const CONNS: Conn[] = [
['source', 0, 'sample', 0, 'float'],
['sample', 1, 'filter', 0, 'float'],
// cache → (inline $reroute knot) → Gather macro — the knot just carries the object wire through.
['cache', 1, 'rr_inline', 0, 'object'],
// (Gather/Pack macro boundary edges are wired in the macro section below.)
['transform', 1, 'validate', 0, 'object'],
['transform', 1, 'enrich', 0, 'object'],
['enrich', 1, 'score', 0, 'object'],
['enrich', 1, 'resolve', 0, 'object'],
['score', 1, 'resolve', 1, 'float'],
['validate', 1, 'resolve', 2, 'wildcard'],
['resolve', 3, 'format', 0, 'string'],
['resolve', 3, 'display', 0, 'string'],
['format', 1, 'notify', 0, 'string'],
['resolve', 3, 'persist', 0, 'string'],
// source → (rectangular Reroute relay) → audit — a pullable relay node across the canvas.
['source', 0, 'rr_box', 0, 'float'],
['rr_box', 1, 'audit', 0, 'float'],
// Archive.In is a single-feed IN-pin → only `display` feeds it. `persist` already drains from
// `resolve` (line above) — leave its Out free for the user to wire as they like.
['display', 1, 'archive', 0, 'any'],
]
// Reroute placements (positioned by top-left; small footprints).
const REROUTES: { key: string; kind: typeof REROUTE_TYPE | typeof REROUTE_NODE_TYPE; type: string; position: { x: number; y: number } }[] = [
{ key: 'rr_inline', kind: REROUTE_TYPE, type: 'object', position: { x: 210, y: 470 } },
{ key: 'rr_box', kind: REROUTE_NODE_TYPE, type: 'float', position: { x: 624, y: 704 } },
]
// ---- build the data documents ----------------------------------------------------------------
const pinId = (nodeKey: string, i: number): string => `${nodeKey}:pin${i}`
function buildNode(spec: NodeSpec): XenolithNodeV1 {
const node: XenolithNodeV1 = {
id: spec.key,
type: spec.type,
position: spec.position,
pins: spec.pins.map((pn, i) => ({
id: pinId(spec.key, i),
kind: 'data',
direction: pn.dir,
type: pn.type,
multiple: pn.dir === 'out',
label: pn.label,
})),
render: { category: spec.category, title: spec.title ?? spec.type, collapsed: spec.collapsed ?? false },
// Bake schema-level glyph into the saved graph node. `loadJSON` deserialises nodes verbatim
// — it doesn't merge schema defaults — so without this the header icons we register on the
// schema would only appear on Tab-inserted nodes, never on the playground's seed graph.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
}
if (spec.widgets) {
node.widgets = spec.widgets
node.state = {}
for (const w of spec.widgets) {
if (w.key === undefined) continue
node.state[w.key] = w.type === 'custom' ? customDefault(w.renderer) : defaultWidgetValue(w)
}
}
return node
}
function buildReroute(r: (typeof REROUTES)[number]): XenolithNodeV1 {
return {
id: r.key,
type: r.kind,
position: r.position,
pins: [
{ id: pinId(r.key, 0), kind: 'data', direction: 'in', type: r.type, multiple: false },
{ id: pinId(r.key, 1), kind: 'data', direction: 'out', type: r.type, multiple: true },
],
}
}
const nodes: XenolithNodeV1[] = [...SPECS.map(buildNode), ...REROUTES.map(buildReroute)]
const edges: XenolithEdgeV1[] = CONNS.map(([from, fp, to, tp, type], i) => ({
id: `e${i}`,
from: { node: from, pin: pinId(from, fp) },
to: { node: to, pin: pinId(to, tp) },
opts: { sourceType: type },
}))
// ---- macro demo (declarative) ---------------------------------------------------------------
// Two self-contained macro groups, "Gather" and "Pack". Each is authored as DATA: ordinary member
// nodes (inlet → steps → outlet) wired to external sources/sinks, plus a `Macro` node carrying
// state.members + collapsed. loadJSON materialises the collapse (derives proxy pins from the boundary
// edges, rewires them) — so it loads as a real collapsed macro reading like a node with pins. The
// inlet IN pins / outlet OUT pin carry the labels that become the macro's pin labels (A/B/C…/Out).
const macroNodes: XenolithNodeV1[] = []
const macroEdges: XenolithEdgeV1[] = []
let meSeq = 0
const sp = (id: string, dir: 'in' | 'out', type: string, label: string, mult = false) =>
({ id, kind: 'data' as const, direction: dir, type, multiple: mult, label })
const sn = (id: string, x: number, y: number, pins: ReturnType<typeof sp>[], title: string, category: Cat): void => {
macroNodes.push({ id, type: 'Step', position: { x, y }, pins, render: { category, title, collapsed: false } })
}
const me = (from: string, fromPin: string, to: string, toPin: string): void => {
macroEdges.push({ id: `me${meSeq++}`, from: { node: from, pin: fromPin }, to: { node: to, pin: toPin } })
}
interface MacroIn { label: string; from?: { node: string; pin: number }; type: string }
interface MacroOut { to: { node: string; pin: number }; type: string }
/** Turn a demo node into a MACRO IN PLACE: build internal members (inlet per input → steps → outlet)
* wired to the SAME external neighbours the original node had, then a `Macro` node at its position.
* `inputs`/`outputs` describe the external boundary; loadJSON materialises proxy pins (labels kept). */
function macroInPlace(prefix: string, title: string, x: number, y: number, inputs: MacroIn[], outputs: MacroOut[]): void {
const members: string[] = []
// Inlet per external input — its IN pin (one feed) carries the label that becomes the macro pin.
const inletOut: string[] = []
inputs.forEach((inp, i) => {
const inl = `${prefix}_in${i}`
sn(inl, x, y + i * 110, [sp(`${inl}.i`, 'in', inp.type, inp.label), sp(`${inl}.o`, 'out', inp.type, 'Out', true)], inp.label, 'logic')
members.push(inl)
if (inp.from) me(inp.from.node, pinId(inp.from.node, inp.from.pin), inl, `${inl}.i`) // external → inlet
inletOut.push(`${inl}.o`)
})
// Merge the inlets through a binary tree of 2-input merge nodes — every IN pin takes exactly ONE
// edge (no 3-into-1). Single-input macros skip straight to the outlet.
let acc = inletOut[0]!
for (let i = 1; i < inletOut.length; i++) {
const m = `${prefix}_m${i}`
sn(m, x + 180, y + (i - 1) * 110 + 40, [sp(`${m}.a`, 'in', 'object', 'A'), sp(`${m}.b`, 'in', 'object', 'B'), sp(`${m}.o`, 'out', 'object', 'Out', true)], `Merge ${i}`, 'macro')
members.push(m)
me(acc.split('.')[0]!, acc, m, `${m}.a`)
me(inletOut[i]!.split('.')[0]!, inletOut[i]!, m, `${m}.b`)
acc = `${m}.o`
}
// Nested sub-macro (tests macro-in-macro): the merged stream flows acc → [Refine → Emit] → outlet,
// where Refine+Emit are wrapped in their own collapsed Macro that is itself a member of this one.
const subY = y + inputs.length * 110 + 30
const sa = `${prefix}_sa`, sb = `${prefix}_sb`, sm = `${prefix}_sub`
sn(sa, x + 200, subY, [sp(`${sa}.i`, 'in', 'object', 'In'), sp(`${sa}.o`, 'out', 'object', 'Out', true)], 'Refine', 'logic')
sn(sb, x + 360, subY, [sp(`${sb}.i`, 'in', 'object', 'In'), sp(`${sb}.o`, 'out', 'object', 'Out', true)], 'Emit', 'utility')
me(sa, `${sa}.o`, sb, `${sb}.i`)
macroNodes.push({ id: sm, type: 'Macro', position: { x: x + 240, y: subY }, pins: [], state: { members: [sa, sb], collapsed: true }, render: { category: 'macro', title: 'Sub' } })
members.push(sm)
me(acc.split('.')[0]!, acc, sa, `${sa}.i`)
acc = `${sb}.o`
// Outlet (output boundary, label "Out") — fed by the sub-macro, drives the external outputs.
const out = `${prefix}_out`
sn(out, x + 540, y, [sp(`${out}.i`, 'in', 'object', 'In'), sp(`${out}.o`, 'out', outputs[0]?.type ?? 'object', 'Out', true)], 'Result', 'utility')
members.push(out)
me(acc.split('.')[0]!, acc, out, `${out}.i`)
for (const o of outputs) me(out, `${out}.o`, o.to.node, pinId(o.to.node, o.to.pin))
macroNodes.push({ id: prefix, type: 'Macro', position: { x, y }, pins: [], state: { members, collapsed: true }, render: { category: 'macro', title } })
}
// Gather macro (was the demo 'gather' node at 300,200): A←filter, B←sample, C←reroute; Out→transform.
macroInPlace('gather', 'Gather', 300, 200,
[{ label: 'A', from: { node: 'filter', pin: 1 }, type: 'float' },
{ label: 'B', from: { node: 'sample', pin: 1 }, type: 'float' },
{ label: 'C', from: { node: 'rr_inline', pin: 1 }, type: 'object' }],
[{ to: { node: 'transform', pin: 0 }, type: 'object' }])
// Pack macro (was the demo 'pack' node at 300,420): In←Gather, Tag←cache; (Pack output unused in demo).
macroInPlace('pack', 'Pack', 300, 420,
[{ label: 'In', type: 'object' },
{ label: 'Tag', from: { node: 'cache', pin: 1 }, type: 'float' }],
[])
// Macro-to-macro: Gather's outlet feeds Pack's first inlet (member pins, not the macro proxy pins —
// those are derived at load). This becomes a Gather-macro → Pack-macro boundary edge after collapse.
me('gather_out', 'gather_out.o', 'pack_in0', 'pack_in0.i')
// ---- live-template demo (declarative) -------------------------------------------------------
// A reusable Template instance, authored as DATA: a `$templateInstance` node in the main graph whose
// pins mirror a definition stored under `templates`. The "Backup" definition is a small but real
// pipeline — two inputs (Payload, Key), two operations (Compress, Encrypt), two outputs (Archive,
// Size) — so there's something meaningful to dive into and edit. Double-click the instance (or use
// the node menu → Edit) to dive in. Fed by `archive`'s output so it reads as part of the graph.
//
// Payload ─▶ Compress ─┬─▶ Encrypt ─▶ Archive
// └─▶ Size Key ─▶ Encrypt
const TEMPLATE_DEF_ID = 'backup_def'
const templateInstance: XenolithNodeV1 = {
id: 'backup',
type: '$templateInstance',
position: { x: 1660, y: 320 },
pins: [
{ id: 'backup.payload', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Payload' },
{ id: 'backup.key', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'backup.archive', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Archive' },
{ id: 'backup.size', kind: 'data', direction: 'out', type: 'float', multiple: true, label: 'Size' },
],
state: {
definitionId: TEMPLATE_DEF_ID,
pinBoundary: { 'backup.payload': 'bk_payload', 'backup.key': 'bk_key', 'backup.archive': 'bk_archive', 'backup.size': 'bk_size' },
},
render: { category: 'macro', title: 'Backup' },
}
const templateEdge: XenolithEdgeV1 = {
id: 'e-backup', from: { node: 'archive', pin: pinId('archive', 1) }, to: { node: 'backup', pin: 'backup.payload' },
opts: { sourceType: 'object' },
}
const templateDefs: Record<string, XenolithTemplateV1> = {
[TEMPLATE_DEF_ID]: {
title: 'Backup',
nodes: [
// inputs
{ id: 'bk_payload', type: '$templateInput', position: { x: 0, y: 40 },
pins: [{ id: 'bk_payload.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Payload' }],
render: { category: 'utility', title: 'Payload' } },
{ id: 'bk_key', type: '$templateInput', position: { x: 0, y: 200 },
pins: [{ id: 'bk_key.o', kind: 'data', direction: 'out', type: 'string', multiple: true, label: 'Key' }],
render: { category: 'utility', title: 'Key' } },
// operations
{ id: 'bk_compress', type: 'Step', position: { x: 240, y: 40 },
pins: [
{ id: 'bk_compress.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_compress.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Packed' },
],
render: { category: 'logic', title: 'Compress' } },
{ id: 'bk_encrypt', type: 'Step', position: { x: 480, y: 120 },
pins: [
{ id: 'bk_encrypt.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_encrypt.k', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'bk_encrypt.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Cipher' },
],
render: { category: 'data', title: 'Encrypt' } },
// outputs
{ id: 'bk_archive', type: '$templateOutput', position: { x: 740, y: 120 },
pins: [{ id: 'bk_archive.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Archive' }],
render: { category: 'utility', title: 'Archive' } },
{ id: 'bk_size', type: '$templateOutput', position: { x: 740, y: 260 },
pins: [{ id: 'bk_size.i', kind: 'data', direction: 'in', type: 'float', multiple: false, label: 'Size' }],
render: { category: 'utility', title: 'Size' } },
],
edges: [
{ id: 'bk_e0', from: { node: 'bk_payload', pin: 'bk_payload.o' }, to: { node: 'bk_compress', pin: 'bk_compress.i' } },
{ id: 'bk_e1', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.i' } },
{ id: 'bk_e2', from: { node: 'bk_key', pin: 'bk_key.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.k' } },
{ id: 'bk_e3', from: { node: 'bk_encrypt', pin: 'bk_encrypt.o' }, to: { node: 'bk_archive', pin: 'bk_archive.i' } },
{ id: 'bk_e4', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_size', pin: 'bk_size.i' } },
],
},
}
export const demoGraph: XenolithGraphV1 = {
version: 'xenolith.v1',
nodes: [...nodes, ...macroNodes, templateInstance],
edges: [...edges, ...macroEdges, templateEdge],
templates: templateDefs,
// One comment/group frame per pipeline column — drag a header to move the whole group, resize from
// the corner. Also doubles as a perf/LOD test surface (distinct colours + texts across the graph).
comments: [
{ id: 'c-ingest', position: { x: 0, y: 14 }, size: { x: 240, y: 580 }, text: 'Ingest', color: '#85C244' },
{ id: 'c-combine', position: { x: 268, y: 150 }, size: { x: 240, y: 360 }, text: 'Combine', color: '#5B8DEF' },
{ id: 'c-transform', position: { x: 528, y: 88 }, size: { x: 250, y: 658 }, text: 'Transform', color: '#E0795A' },
{ id: 'c-resolve', position: { x: 788, y: 150 }, size: { x: 240, y: 540 }, text: 'Resolve', color: '#B06BE8' },
{ id: 'c-output', position: { x: 1068, y: 70 }, size: { x: 250, y: 620 }, text: 'Output', color: '#4FC3C9' },
],
}
/** Schemas for the insert palette — derived from the same SPECS so Tab / double-click can spawn
* any demo node type. (The rectangular `Reroute` is a built-in, auto-registered by the editor.) */
export const demoSchemas: NodeSchema[] = SPECS.map((spec) => ({
type: spec.type,
title: spec.title ?? spec.type,
category: spec.category,
description: spec.description,
keywords: spec.keywords ?? [],
pins: spec.pins.map((pn) => ({ kind: 'data', direction: pn.dir, type: pn.type, label: pn.label, multiple: pn.dir === 'out' })),
...(spec.widgets ? { widgets: spec.widgets } : {}),
// Header glyph — header icon shows next to the title in the node strip. Categories share
// semantically-similar icons (database for storage, flag for milestones, etc.) so a 20-node graph
// becomes scannable at a glance — the visual shorthand the [icons guide](/guides/icons/) covers.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
})) <!-- Svelte — load a saved graph on ready; the panel reloads it. Panels read the
editor context created during init and filled from onready. -->
<script lang="ts">
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton, XenolithControls } from '@xenolithengine/graph-svelte/components'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
import { demoGraph } from '@xenolithengine/demo'
const panelEditor = createXenolithEditorContext()
let editor = $state<XenolithEditor | null>(null)
function reload(): void {
if (!editor) return
editor.loadJSON(demoGraph)
editor.view.fitView({ padding: 48, maxZoom: 1 })
}
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => { editor = e.detail; panelEditor.set(e.detail); loadDemo(e.detail) }}
style="position:absolute;inset:0"
></div>
<XenolithControls position="bottom-left" />
<XenolithPanel position="top-right">
<XenolithButton onclick={reload}>Reload graph</XenolithButton>
</XenolithPanel> // The canonical shared demo scene: register the canvas custom widgets + demo node schemas, load the
// shared demo graph (xenolith.v1), and frame it. Framework-agnostic — every host (React/Vue/Svelte)
// calls this in its onReady so the registry has the schemas before loadJSON.
import { demoGraph, demoSchemas, createCurveWidget, createXYPadWidget } from './demo-graph.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export function loadDemo(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.loadJSON(demoGraph)
editor.fitView({ padding: 48, maxZoom: 1 })
} import type { NodeSchema, XenolithGraphV1, XenolithNodeV1, XenolithEdgeV1, XenolithTemplateV1 } from '@xenolithengine/graph-editor'
import { REROUTE_TYPE, REROUTE_NODE_TYPE, defaultWidgetValue, type WidgetSpec } from '@xenolithengine/graph-core'
export { createCurveWidget } from './curve-widget.js'
export { createXYPadWidget } from './xy-pad-widget.js'
export { createPreviewWidget } from './preview-widget.js'
import { CURVE_DEFAULT } from './curve-widget.js'
import { XYPAD_DEFAULT } from './xy-pad-widget.js'
import { PREVIEW_DEFAULT } from './preview-widget.js'
/** Initial value for a custom widget — its controller's default, persisted into node.state so a
* loaded graph is self-contained (getWidgetValue returns real data, not undefined). */
function customDefault(renderer: string): unknown {
return renderer === 'curve' ? CURVE_DEFAULT
: renderer === 'xypad' ? XYPAD_DEFAULT
: renderer === 'preview' ? PREVIEW_DEFAULT
: null
}
/**
* The canonical Xenolith demo graph, authored once as plain data in our own `xenolith.v1` format.
* Every host (playground app, the site /playground page, the landing showcase) loads it via
* `editor.loadJSON(demoGraph)` — no host hand-builds nodes with addNode/connect anymore.
*
* It exercises the full visual vocabulary: all four node categories, collapsed pills, every pin
* type colour, and BOTH reroute kinds — the inline `$reroute` knot (split into a wire, non-pullable)
* and the rectangular `Reroute` relay node (a movable node you can pull fresh wires from).
*/
type Cat = 'logic' | 'data' | 'macro' | 'utility'
interface PinSpec { dir: 'in' | 'out'; type: string; label: string }
interface NodeSpec {
key: string
type: string
title?: string
category: Cat
description: string
keywords?: string[]
position: { x: number; y: number }
collapsed?: boolean
pins: PinSpec[]
widgets?: WidgetSpec[]
/** Built-in icon name from the editor's icon registry — `cpu`, `database`, `code`, etc. */
icon?: string
iconSide?: 'left' | 'right'
}
type Conn = [from: string, fromPin: number, to: string, toPin: number, type: string]
const p = (dir: 'in' | 'out', type: string, label: string): PinSpec => ({ dir, type, label })
// ---- node definitions ------------------------------------------------------------------------
const SPECS: NodeSpec[] = [
{ key: 'source', type: 'Source', category: 'logic', icon: 'zap', description: 'Emits a stream of float values', keywords: ['input', 'origin'], position: { x: 40, y: 60 }, pins: [p('out', 'float', 'Output')] },
{ key: 'sample', type: 'Sample', category: 'logic', icon: 'play', description: 'Reads a value at the current cursor', keywords: ['read'], position: { x: 40, y: 200 }, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'filter', type: 'Filter', category: 'logic', icon: 'branch', description: 'Drops values failing a predicate', keywords: ['where'], position: { x: 40, y: 360 }, collapsed: true, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'cache', type: 'Cache', category: 'data', icon: 'database', description: 'Memoises the last seen object', keywords: ['store', 'memo'], position: { x: 40, y: 500 }, collapsed: true, pins: [p('in','object','In'), p('out','object','Out')] },
// 'gather' and 'pack' are MACRO groups, authored in the macro section below (not plain nodes).
// NOTE: CONNS indexes pins by POSITION — keep the original [In, Out, …] prefix and APPEND
// widget-bound pins after Out so existing connections still resolve to the right pin (otherwise
// a wire defined as `transform, 1, …` would land on `scale` instead of `Out`).
// Transform column: spread vertically so the wide Validate node (curve widget) has breathing
// room above and below; Validate's wider footprint is centred under the macro frame.
{ key: 'transform', type: 'Transform', category: 'data', icon: 'code', description: 'Maps an object to a new shape', keywords: ['move', 'map'], position: { x: 568, y: 136 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','float','scale'), p('in','string','mode'), p('in','bool','mirror')],
widgets: [
{ id: 'scale', type: 'slider', label: '', key: 'scale', min: 0, max: 2, step: 0.05 },
{ id: 'mode', type: 'combo', label: '', key: 'mode', values: ['fit', 'fill', 'stretch'] },
{ id: 'mirror', type: 'toggle', label: '', key: 'mirror' },
] },
{ key: 'validate', type: 'Validate', category: 'data', icon: 'flag', description: 'Checks an object against a schema', keywords: ['check'], position: { x: 544, y: 320 },
pins: [p('in','object','In'), p('out','wildcard','Out'), p('in','any','response')],
widgets: [
{ id: 'response', type: 'custom', renderer: 'curve', key: 'response', label: '', height: 120 },
] },
{ key: 'enrich', type: 'Enrich', category: 'data', icon: 'layers', description: 'Augments an object with extra fields', keywords: ['augment'], position: { x: 568, y: 536 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','string','tint'), p('in','float','strength')],
widgets: [
{ id: 'tint', type: 'color', label: '', key: 'tint' },
{ id: 'strength', type: 'slider', label: '', key: 'strength', min: 0, max: 1, step: 0.01 },
] },
{ key: 'score', type: 'Score', category: 'macro', icon: 'cpu', description: 'Ranks an object, emits a float score', keywords: ['rank'], position: { x: 820, y: 200 }, collapsed: true, pins: [p('in','object','In'), p('out','float','Out')] },
{ key: 'resolve', type: 'Resolve', category: 'macro', icon: 'play', description: 'Finalises into a string result', keywords: ['finalize'], position: { x: 820, y: 360 },
pins: [p('in','object','In'), p('in','float','Hint'), p('in','wildcard','Aux'), p('out','string','Out'), p('in','float','seed'), p('in','string','prompt')],
widgets: [
{ id: 'seed', type: 'number', label: '', key: 'seed', min: 0, max: 999999, step: 1 },
{ id: 'prompt', type: 'text', label: '', key: 'prompt', placeholder: 'describe…' },
] },
{ key: 'format', type: 'Format', category: 'data', icon: 'code', description: 'Renders a result to a display string', keywords: ['template'], position: { x: 820, y: 600 }, collapsed: true, pins: [p('in','string','In'), p('out','string','Out')] },
{ key: 'display', type: 'Display', category: 'utility', icon: 'square', iconSide: 'right', description: 'Renders a string to the viewport', keywords: ['show', 'output'], position: { x: 1100, y: 120 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'audit', type: 'Audit', category: 'utility', icon: 'clock', description: 'Logs a float for inspection', keywords: ['log'], position: { x: 1100, y: 280 }, pins: [p('in','float','In'), p('out','any','Out')] },
{ key: 'persist', type: 'Persist', category: 'utility', icon: 'database', description: 'Writes a string to durable storage', keywords: ['save', 'write'], position: { x: 1100, y: 440 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'notify', type: 'Notify', category: 'utility', icon: 'flag', description: 'Pushes a notification on completion', keywords: ['alert', 'webhook'],position: { x: 1100, y: 600 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'archive', type: 'Archive', category: 'utility', icon: 'box', description: 'Cold-stores any payload', keywords: ['cold', 'backup'], position: { x: 1380, y: 360 },
pins: [p('in','any','In'), p('out','any','Out'), p('in','any','offset')],
widgets: [
{ id: 'offset', type: 'custom', renderer: 'xypad', key: 'offset', label: '', height: 110 },
] },
// -------- Canon showcase --------------------------------------------------------------------
// Demonstrates the widget canon in one node:
// - `name` IN-pin + text widget (default-value: hides when a wire arrives, reappears on disc).
// - `say` IN-pin + custom 'preview' widget (visibility:'always', renders the wire's value).
// - actions row button (`+ reset`) — under the pin block.
{ key: 'greet', type: 'Greet', category: 'utility',
description: 'Pin-bound widget showcase: default-value text, always-on preview, actions row',
keywords: ['canon', 'widget', 'default', 'preview'],
position: { x: 1660, y: 60 },
// First IN-pair has no widget so it pairs cleanly with `out` on row 0. The widget-carrying
// pins (`name`, `say`) sit on their own rows underneath — the widget takes the row's right
// half without colliding with an opposite-side label.
pins: [p('in','object','In'), p('out','string','out'), p('in','string','name'), p('in','string','say')],
widgets: [
{ id: 'name', type: 'text', label: '', key: 'name' },
{ id: 'say', type: 'custom', renderer: 'preview', key: 'say', label: '', visibility: 'always' },
{ id: 'reset', type: 'button', label: '+ reset', action: 'resetGreet' },
] },
]
// ---- connections (reroutes wired in) ---------------------------------------------------------
// Reroute nodes participate as ordinary endpoints: pin 0 = in, pin 1 = out.
const CONNS: Conn[] = [
['source', 0, 'sample', 0, 'float'],
['sample', 1, 'filter', 0, 'float'],
// cache → (inline $reroute knot) → Gather macro — the knot just carries the object wire through.
['cache', 1, 'rr_inline', 0, 'object'],
// (Gather/Pack macro boundary edges are wired in the macro section below.)
['transform', 1, 'validate', 0, 'object'],
['transform', 1, 'enrich', 0, 'object'],
['enrich', 1, 'score', 0, 'object'],
['enrich', 1, 'resolve', 0, 'object'],
['score', 1, 'resolve', 1, 'float'],
['validate', 1, 'resolve', 2, 'wildcard'],
['resolve', 3, 'format', 0, 'string'],
['resolve', 3, 'display', 0, 'string'],
['format', 1, 'notify', 0, 'string'],
['resolve', 3, 'persist', 0, 'string'],
// source → (rectangular Reroute relay) → audit — a pullable relay node across the canvas.
['source', 0, 'rr_box', 0, 'float'],
['rr_box', 1, 'audit', 0, 'float'],
// Archive.In is a single-feed IN-pin → only `display` feeds it. `persist` already drains from
// `resolve` (line above) — leave its Out free for the user to wire as they like.
['display', 1, 'archive', 0, 'any'],
]
// Reroute placements (positioned by top-left; small footprints).
const REROUTES: { key: string; kind: typeof REROUTE_TYPE | typeof REROUTE_NODE_TYPE; type: string; position: { x: number; y: number } }[] = [
{ key: 'rr_inline', kind: REROUTE_TYPE, type: 'object', position: { x: 210, y: 470 } },
{ key: 'rr_box', kind: REROUTE_NODE_TYPE, type: 'float', position: { x: 624, y: 704 } },
]
// ---- build the data documents ----------------------------------------------------------------
const pinId = (nodeKey: string, i: number): string => `${nodeKey}:pin${i}`
function buildNode(spec: NodeSpec): XenolithNodeV1 {
const node: XenolithNodeV1 = {
id: spec.key,
type: spec.type,
position: spec.position,
pins: spec.pins.map((pn, i) => ({
id: pinId(spec.key, i),
kind: 'data',
direction: pn.dir,
type: pn.type,
multiple: pn.dir === 'out',
label: pn.label,
})),
render: { category: spec.category, title: spec.title ?? spec.type, collapsed: spec.collapsed ?? false },
// Bake schema-level glyph into the saved graph node. `loadJSON` deserialises nodes verbatim
// — it doesn't merge schema defaults — so without this the header icons we register on the
// schema would only appear on Tab-inserted nodes, never on the playground's seed graph.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
}
if (spec.widgets) {
node.widgets = spec.widgets
node.state = {}
for (const w of spec.widgets) {
if (w.key === undefined) continue
node.state[w.key] = w.type === 'custom' ? customDefault(w.renderer) : defaultWidgetValue(w)
}
}
return node
}
function buildReroute(r: (typeof REROUTES)[number]): XenolithNodeV1 {
return {
id: r.key,
type: r.kind,
position: r.position,
pins: [
{ id: pinId(r.key, 0), kind: 'data', direction: 'in', type: r.type, multiple: false },
{ id: pinId(r.key, 1), kind: 'data', direction: 'out', type: r.type, multiple: true },
],
}
}
const nodes: XenolithNodeV1[] = [...SPECS.map(buildNode), ...REROUTES.map(buildReroute)]
const edges: XenolithEdgeV1[] = CONNS.map(([from, fp, to, tp, type], i) => ({
id: `e${i}`,
from: { node: from, pin: pinId(from, fp) },
to: { node: to, pin: pinId(to, tp) },
opts: { sourceType: type },
}))
// ---- macro demo (declarative) ---------------------------------------------------------------
// Two self-contained macro groups, "Gather" and "Pack". Each is authored as DATA: ordinary member
// nodes (inlet → steps → outlet) wired to external sources/sinks, plus a `Macro` node carrying
// state.members + collapsed. loadJSON materialises the collapse (derives proxy pins from the boundary
// edges, rewires them) — so it loads as a real collapsed macro reading like a node with pins. The
// inlet IN pins / outlet OUT pin carry the labels that become the macro's pin labels (A/B/C…/Out).
const macroNodes: XenolithNodeV1[] = []
const macroEdges: XenolithEdgeV1[] = []
let meSeq = 0
const sp = (id: string, dir: 'in' | 'out', type: string, label: string, mult = false) =>
({ id, kind: 'data' as const, direction: dir, type, multiple: mult, label })
const sn = (id: string, x: number, y: number, pins: ReturnType<typeof sp>[], title: string, category: Cat): void => {
macroNodes.push({ id, type: 'Step', position: { x, y }, pins, render: { category, title, collapsed: false } })
}
const me = (from: string, fromPin: string, to: string, toPin: string): void => {
macroEdges.push({ id: `me${meSeq++}`, from: { node: from, pin: fromPin }, to: { node: to, pin: toPin } })
}
interface MacroIn { label: string; from?: { node: string; pin: number }; type: string }
interface MacroOut { to: { node: string; pin: number }; type: string }
/** Turn a demo node into a MACRO IN PLACE: build internal members (inlet per input → steps → outlet)
* wired to the SAME external neighbours the original node had, then a `Macro` node at its position.
* `inputs`/`outputs` describe the external boundary; loadJSON materialises proxy pins (labels kept). */
function macroInPlace(prefix: string, title: string, x: number, y: number, inputs: MacroIn[], outputs: MacroOut[]): void {
const members: string[] = []
// Inlet per external input — its IN pin (one feed) carries the label that becomes the macro pin.
const inletOut: string[] = []
inputs.forEach((inp, i) => {
const inl = `${prefix}_in${i}`
sn(inl, x, y + i * 110, [sp(`${inl}.i`, 'in', inp.type, inp.label), sp(`${inl}.o`, 'out', inp.type, 'Out', true)], inp.label, 'logic')
members.push(inl)
if (inp.from) me(inp.from.node, pinId(inp.from.node, inp.from.pin), inl, `${inl}.i`) // external → inlet
inletOut.push(`${inl}.o`)
})
// Merge the inlets through a binary tree of 2-input merge nodes — every IN pin takes exactly ONE
// edge (no 3-into-1). Single-input macros skip straight to the outlet.
let acc = inletOut[0]!
for (let i = 1; i < inletOut.length; i++) {
const m = `${prefix}_m${i}`
sn(m, x + 180, y + (i - 1) * 110 + 40, [sp(`${m}.a`, 'in', 'object', 'A'), sp(`${m}.b`, 'in', 'object', 'B'), sp(`${m}.o`, 'out', 'object', 'Out', true)], `Merge ${i}`, 'macro')
members.push(m)
me(acc.split('.')[0]!, acc, m, `${m}.a`)
me(inletOut[i]!.split('.')[0]!, inletOut[i]!, m, `${m}.b`)
acc = `${m}.o`
}
// Nested sub-macro (tests macro-in-macro): the merged stream flows acc → [Refine → Emit] → outlet,
// where Refine+Emit are wrapped in their own collapsed Macro that is itself a member of this one.
const subY = y + inputs.length * 110 + 30
const sa = `${prefix}_sa`, sb = `${prefix}_sb`, sm = `${prefix}_sub`
sn(sa, x + 200, subY, [sp(`${sa}.i`, 'in', 'object', 'In'), sp(`${sa}.o`, 'out', 'object', 'Out', true)], 'Refine', 'logic')
sn(sb, x + 360, subY, [sp(`${sb}.i`, 'in', 'object', 'In'), sp(`${sb}.o`, 'out', 'object', 'Out', true)], 'Emit', 'utility')
me(sa, `${sa}.o`, sb, `${sb}.i`)
macroNodes.push({ id: sm, type: 'Macro', position: { x: x + 240, y: subY }, pins: [], state: { members: [sa, sb], collapsed: true }, render: { category: 'macro', title: 'Sub' } })
members.push(sm)
me(acc.split('.')[0]!, acc, sa, `${sa}.i`)
acc = `${sb}.o`
// Outlet (output boundary, label "Out") — fed by the sub-macro, drives the external outputs.
const out = `${prefix}_out`
sn(out, x + 540, y, [sp(`${out}.i`, 'in', 'object', 'In'), sp(`${out}.o`, 'out', outputs[0]?.type ?? 'object', 'Out', true)], 'Result', 'utility')
members.push(out)
me(acc.split('.')[0]!, acc, out, `${out}.i`)
for (const o of outputs) me(out, `${out}.o`, o.to.node, pinId(o.to.node, o.to.pin))
macroNodes.push({ id: prefix, type: 'Macro', position: { x, y }, pins: [], state: { members, collapsed: true }, render: { category: 'macro', title } })
}
// Gather macro (was the demo 'gather' node at 300,200): A←filter, B←sample, C←reroute; Out→transform.
macroInPlace('gather', 'Gather', 300, 200,
[{ label: 'A', from: { node: 'filter', pin: 1 }, type: 'float' },
{ label: 'B', from: { node: 'sample', pin: 1 }, type: 'float' },
{ label: 'C', from: { node: 'rr_inline', pin: 1 }, type: 'object' }],
[{ to: { node: 'transform', pin: 0 }, type: 'object' }])
// Pack macro (was the demo 'pack' node at 300,420): In←Gather, Tag←cache; (Pack output unused in demo).
macroInPlace('pack', 'Pack', 300, 420,
[{ label: 'In', type: 'object' },
{ label: 'Tag', from: { node: 'cache', pin: 1 }, type: 'float' }],
[])
// Macro-to-macro: Gather's outlet feeds Pack's first inlet (member pins, not the macro proxy pins —
// those are derived at load). This becomes a Gather-macro → Pack-macro boundary edge after collapse.
me('gather_out', 'gather_out.o', 'pack_in0', 'pack_in0.i')
// ---- live-template demo (declarative) -------------------------------------------------------
// A reusable Template instance, authored as DATA: a `$templateInstance` node in the main graph whose
// pins mirror a definition stored under `templates`. The "Backup" definition is a small but real
// pipeline — two inputs (Payload, Key), two operations (Compress, Encrypt), two outputs (Archive,
// Size) — so there's something meaningful to dive into and edit. Double-click the instance (or use
// the node menu → Edit) to dive in. Fed by `archive`'s output so it reads as part of the graph.
//
// Payload ─▶ Compress ─┬─▶ Encrypt ─▶ Archive
// └─▶ Size Key ─▶ Encrypt
const TEMPLATE_DEF_ID = 'backup_def'
const templateInstance: XenolithNodeV1 = {
id: 'backup',
type: '$templateInstance',
position: { x: 1660, y: 320 },
pins: [
{ id: 'backup.payload', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Payload' },
{ id: 'backup.key', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'backup.archive', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Archive' },
{ id: 'backup.size', kind: 'data', direction: 'out', type: 'float', multiple: true, label: 'Size' },
],
state: {
definitionId: TEMPLATE_DEF_ID,
pinBoundary: { 'backup.payload': 'bk_payload', 'backup.key': 'bk_key', 'backup.archive': 'bk_archive', 'backup.size': 'bk_size' },
},
render: { category: 'macro', title: 'Backup' },
}
const templateEdge: XenolithEdgeV1 = {
id: 'e-backup', from: { node: 'archive', pin: pinId('archive', 1) }, to: { node: 'backup', pin: 'backup.payload' },
opts: { sourceType: 'object' },
}
const templateDefs: Record<string, XenolithTemplateV1> = {
[TEMPLATE_DEF_ID]: {
title: 'Backup',
nodes: [
// inputs
{ id: 'bk_payload', type: '$templateInput', position: { x: 0, y: 40 },
pins: [{ id: 'bk_payload.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Payload' }],
render: { category: 'utility', title: 'Payload' } },
{ id: 'bk_key', type: '$templateInput', position: { x: 0, y: 200 },
pins: [{ id: 'bk_key.o', kind: 'data', direction: 'out', type: 'string', multiple: true, label: 'Key' }],
render: { category: 'utility', title: 'Key' } },
// operations
{ id: 'bk_compress', type: 'Step', position: { x: 240, y: 40 },
pins: [
{ id: 'bk_compress.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_compress.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Packed' },
],
render: { category: 'logic', title: 'Compress' } },
{ id: 'bk_encrypt', type: 'Step', position: { x: 480, y: 120 },
pins: [
{ id: 'bk_encrypt.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_encrypt.k', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'bk_encrypt.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Cipher' },
],
render: { category: 'data', title: 'Encrypt' } },
// outputs
{ id: 'bk_archive', type: '$templateOutput', position: { x: 740, y: 120 },
pins: [{ id: 'bk_archive.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Archive' }],
render: { category: 'utility', title: 'Archive' } },
{ id: 'bk_size', type: '$templateOutput', position: { x: 740, y: 260 },
pins: [{ id: 'bk_size.i', kind: 'data', direction: 'in', type: 'float', multiple: false, label: 'Size' }],
render: { category: 'utility', title: 'Size' } },
],
edges: [
{ id: 'bk_e0', from: { node: 'bk_payload', pin: 'bk_payload.o' }, to: { node: 'bk_compress', pin: 'bk_compress.i' } },
{ id: 'bk_e1', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.i' } },
{ id: 'bk_e2', from: { node: 'bk_key', pin: 'bk_key.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.k' } },
{ id: 'bk_e3', from: { node: 'bk_encrypt', pin: 'bk_encrypt.o' }, to: { node: 'bk_archive', pin: 'bk_archive.i' } },
{ id: 'bk_e4', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_size', pin: 'bk_size.i' } },
],
},
}
export const demoGraph: XenolithGraphV1 = {
version: 'xenolith.v1',
nodes: [...nodes, ...macroNodes, templateInstance],
edges: [...edges, ...macroEdges, templateEdge],
templates: templateDefs,
// One comment/group frame per pipeline column — drag a header to move the whole group, resize from
// the corner. Also doubles as a perf/LOD test surface (distinct colours + texts across the graph).
comments: [
{ id: 'c-ingest', position: { x: 0, y: 14 }, size: { x: 240, y: 580 }, text: 'Ingest', color: '#85C244' },
{ id: 'c-combine', position: { x: 268, y: 150 }, size: { x: 240, y: 360 }, text: 'Combine', color: '#5B8DEF' },
{ id: 'c-transform', position: { x: 528, y: 88 }, size: { x: 250, y: 658 }, text: 'Transform', color: '#E0795A' },
{ id: 'c-resolve', position: { x: 788, y: 150 }, size: { x: 240, y: 540 }, text: 'Resolve', color: '#B06BE8' },
{ id: 'c-output', position: { x: 1068, y: 70 }, size: { x: 250, y: 620 }, text: 'Output', color: '#4FC3C9' },
],
}
/** Schemas for the insert palette — derived from the same SPECS so Tab / double-click can spawn
* any demo node type. (The rectangular `Reroute` is a built-in, auto-registered by the editor.) */
export const demoSchemas: NodeSchema[] = SPECS.map((spec) => ({
type: spec.type,
title: spec.title ?? spec.type,
category: spec.category,
description: spec.description,
keywords: spec.keywords ?? [],
pins: spec.pins.map((pn) => ({ kind: 'data', direction: pn.dir, type: pn.type, label: pn.label, multiple: pn.dir === 'out' })),
...(spec.widgets ? { widgets: spec.widgets } : {}),
// Header glyph — header icon shows next to the title in the node strip. Categories share
// semantically-similar icons (database for storage, flag for milestones, etc.) so a 20-node graph
// becomes scannable at a glance — the visual shorthand the [icons guide](/guides/icons/) covers.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
})) // Solid — load a saved graph on ready. No panel package: the reload control is a DOM card.
// on:ready is the one colon attribute; the editor is e.detail.
import { createSignal } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { loadDemo } from '@xenolithengine/demo/scene'
import { demoGraph } from '@xenolithengine/demo'
export function LoadDemo() {
const [editor, setEditor] = createSignal<XenolithEditor | null>(null)
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
setEditor(e.detail)
e.detail.chrome.setControls({ position: 'bottom-left' })
loadDemo(e.detail)
}}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;right:12px;z-index:5;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;">
<button
type="button"
style="font:inherit;font-size:13px;padding:7px 12px;cursor:pointer;border-radius:8px;border:1px solid var(--xeno-border);background:var(--xeno-elevated);color:var(--xeno-text);"
onClick={() => {
const live = editor()
if (!live) return
live.loadJSON(demoGraph)
live.view.fitView({ padding: 48, maxZoom: 1 })
}}
>
Reload graph
</button>
</div>
</div>
)
} // The canonical shared demo scene: register the canvas custom widgets + demo node schemas, load the
// shared demo graph (xenolith.v1), and frame it. Framework-agnostic — every host (React/Vue/Svelte)
// calls this in its onReady so the registry has the schemas before loadJSON.
import { demoGraph, demoSchemas, createCurveWidget, createXYPadWidget } from './demo-graph.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export function loadDemo(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.loadJSON(demoGraph)
editor.fitView({ padding: 48, maxZoom: 1 })
} import type { NodeSchema, XenolithGraphV1, XenolithNodeV1, XenolithEdgeV1, XenolithTemplateV1 } from '@xenolithengine/graph-editor'
import { REROUTE_TYPE, REROUTE_NODE_TYPE, defaultWidgetValue, type WidgetSpec } from '@xenolithengine/graph-core'
export { createCurveWidget } from './curve-widget.js'
export { createXYPadWidget } from './xy-pad-widget.js'
export { createPreviewWidget } from './preview-widget.js'
import { CURVE_DEFAULT } from './curve-widget.js'
import { XYPAD_DEFAULT } from './xy-pad-widget.js'
import { PREVIEW_DEFAULT } from './preview-widget.js'
/** Initial value for a custom widget — its controller's default, persisted into node.state so a
* loaded graph is self-contained (getWidgetValue returns real data, not undefined). */
function customDefault(renderer: string): unknown {
return renderer === 'curve' ? CURVE_DEFAULT
: renderer === 'xypad' ? XYPAD_DEFAULT
: renderer === 'preview' ? PREVIEW_DEFAULT
: null
}
/**
* The canonical Xenolith demo graph, authored once as plain data in our own `xenolith.v1` format.
* Every host (playground app, the site /playground page, the landing showcase) loads it via
* `editor.loadJSON(demoGraph)` — no host hand-builds nodes with addNode/connect anymore.
*
* It exercises the full visual vocabulary: all four node categories, collapsed pills, every pin
* type colour, and BOTH reroute kinds — the inline `$reroute` knot (split into a wire, non-pullable)
* and the rectangular `Reroute` relay node (a movable node you can pull fresh wires from).
*/
type Cat = 'logic' | 'data' | 'macro' | 'utility'
interface PinSpec { dir: 'in' | 'out'; type: string; label: string }
interface NodeSpec {
key: string
type: string
title?: string
category: Cat
description: string
keywords?: string[]
position: { x: number; y: number }
collapsed?: boolean
pins: PinSpec[]
widgets?: WidgetSpec[]
/** Built-in icon name from the editor's icon registry — `cpu`, `database`, `code`, etc. */
icon?: string
iconSide?: 'left' | 'right'
}
type Conn = [from: string, fromPin: number, to: string, toPin: number, type: string]
const p = (dir: 'in' | 'out', type: string, label: string): PinSpec => ({ dir, type, label })
// ---- node definitions ------------------------------------------------------------------------
const SPECS: NodeSpec[] = [
{ key: 'source', type: 'Source', category: 'logic', icon: 'zap', description: 'Emits a stream of float values', keywords: ['input', 'origin'], position: { x: 40, y: 60 }, pins: [p('out', 'float', 'Output')] },
{ key: 'sample', type: 'Sample', category: 'logic', icon: 'play', description: 'Reads a value at the current cursor', keywords: ['read'], position: { x: 40, y: 200 }, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'filter', type: 'Filter', category: 'logic', icon: 'branch', description: 'Drops values failing a predicate', keywords: ['where'], position: { x: 40, y: 360 }, collapsed: true, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'cache', type: 'Cache', category: 'data', icon: 'database', description: 'Memoises the last seen object', keywords: ['store', 'memo'], position: { x: 40, y: 500 }, collapsed: true, pins: [p('in','object','In'), p('out','object','Out')] },
// 'gather' and 'pack' are MACRO groups, authored in the macro section below (not plain nodes).
// NOTE: CONNS indexes pins by POSITION — keep the original [In, Out, …] prefix and APPEND
// widget-bound pins after Out so existing connections still resolve to the right pin (otherwise
// a wire defined as `transform, 1, …` would land on `scale` instead of `Out`).
// Transform column: spread vertically so the wide Validate node (curve widget) has breathing
// room above and below; Validate's wider footprint is centred under the macro frame.
{ key: 'transform', type: 'Transform', category: 'data', icon: 'code', description: 'Maps an object to a new shape', keywords: ['move', 'map'], position: { x: 568, y: 136 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','float','scale'), p('in','string','mode'), p('in','bool','mirror')],
widgets: [
{ id: 'scale', type: 'slider', label: '', key: 'scale', min: 0, max: 2, step: 0.05 },
{ id: 'mode', type: 'combo', label: '', key: 'mode', values: ['fit', 'fill', 'stretch'] },
{ id: 'mirror', type: 'toggle', label: '', key: 'mirror' },
] },
{ key: 'validate', type: 'Validate', category: 'data', icon: 'flag', description: 'Checks an object against a schema', keywords: ['check'], position: { x: 544, y: 320 },
pins: [p('in','object','In'), p('out','wildcard','Out'), p('in','any','response')],
widgets: [
{ id: 'response', type: 'custom', renderer: 'curve', key: 'response', label: '', height: 120 },
] },
{ key: 'enrich', type: 'Enrich', category: 'data', icon: 'layers', description: 'Augments an object with extra fields', keywords: ['augment'], position: { x: 568, y: 536 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','string','tint'), p('in','float','strength')],
widgets: [
{ id: 'tint', type: 'color', label: '', key: 'tint' },
{ id: 'strength', type: 'slider', label: '', key: 'strength', min: 0, max: 1, step: 0.01 },
] },
{ key: 'score', type: 'Score', category: 'macro', icon: 'cpu', description: 'Ranks an object, emits a float score', keywords: ['rank'], position: { x: 820, y: 200 }, collapsed: true, pins: [p('in','object','In'), p('out','float','Out')] },
{ key: 'resolve', type: 'Resolve', category: 'macro', icon: 'play', description: 'Finalises into a string result', keywords: ['finalize'], position: { x: 820, y: 360 },
pins: [p('in','object','In'), p('in','float','Hint'), p('in','wildcard','Aux'), p('out','string','Out'), p('in','float','seed'), p('in','string','prompt')],
widgets: [
{ id: 'seed', type: 'number', label: '', key: 'seed', min: 0, max: 999999, step: 1 },
{ id: 'prompt', type: 'text', label: '', key: 'prompt', placeholder: 'describe…' },
] },
{ key: 'format', type: 'Format', category: 'data', icon: 'code', description: 'Renders a result to a display string', keywords: ['template'], position: { x: 820, y: 600 }, collapsed: true, pins: [p('in','string','In'), p('out','string','Out')] },
{ key: 'display', type: 'Display', category: 'utility', icon: 'square', iconSide: 'right', description: 'Renders a string to the viewport', keywords: ['show', 'output'], position: { x: 1100, y: 120 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'audit', type: 'Audit', category: 'utility', icon: 'clock', description: 'Logs a float for inspection', keywords: ['log'], position: { x: 1100, y: 280 }, pins: [p('in','float','In'), p('out','any','Out')] },
{ key: 'persist', type: 'Persist', category: 'utility', icon: 'database', description: 'Writes a string to durable storage', keywords: ['save', 'write'], position: { x: 1100, y: 440 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'notify', type: 'Notify', category: 'utility', icon: 'flag', description: 'Pushes a notification on completion', keywords: ['alert', 'webhook'],position: { x: 1100, y: 600 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'archive', type: 'Archive', category: 'utility', icon: 'box', description: 'Cold-stores any payload', keywords: ['cold', 'backup'], position: { x: 1380, y: 360 },
pins: [p('in','any','In'), p('out','any','Out'), p('in','any','offset')],
widgets: [
{ id: 'offset', type: 'custom', renderer: 'xypad', key: 'offset', label: '', height: 110 },
] },
// -------- Canon showcase --------------------------------------------------------------------
// Demonstrates the widget canon in one node:
// - `name` IN-pin + text widget (default-value: hides when a wire arrives, reappears on disc).
// - `say` IN-pin + custom 'preview' widget (visibility:'always', renders the wire's value).
// - actions row button (`+ reset`) — under the pin block.
{ key: 'greet', type: 'Greet', category: 'utility',
description: 'Pin-bound widget showcase: default-value text, always-on preview, actions row',
keywords: ['canon', 'widget', 'default', 'preview'],
position: { x: 1660, y: 60 },
// First IN-pair has no widget so it pairs cleanly with `out` on row 0. The widget-carrying
// pins (`name`, `say`) sit on their own rows underneath — the widget takes the row's right
// half without colliding with an opposite-side label.
pins: [p('in','object','In'), p('out','string','out'), p('in','string','name'), p('in','string','say')],
widgets: [
{ id: 'name', type: 'text', label: '', key: 'name' },
{ id: 'say', type: 'custom', renderer: 'preview', key: 'say', label: '', visibility: 'always' },
{ id: 'reset', type: 'button', label: '+ reset', action: 'resetGreet' },
] },
]
// ---- connections (reroutes wired in) ---------------------------------------------------------
// Reroute nodes participate as ordinary endpoints: pin 0 = in, pin 1 = out.
const CONNS: Conn[] = [
['source', 0, 'sample', 0, 'float'],
['sample', 1, 'filter', 0, 'float'],
// cache → (inline $reroute knot) → Gather macro — the knot just carries the object wire through.
['cache', 1, 'rr_inline', 0, 'object'],
// (Gather/Pack macro boundary edges are wired in the macro section below.)
['transform', 1, 'validate', 0, 'object'],
['transform', 1, 'enrich', 0, 'object'],
['enrich', 1, 'score', 0, 'object'],
['enrich', 1, 'resolve', 0, 'object'],
['score', 1, 'resolve', 1, 'float'],
['validate', 1, 'resolve', 2, 'wildcard'],
['resolve', 3, 'format', 0, 'string'],
['resolve', 3, 'display', 0, 'string'],
['format', 1, 'notify', 0, 'string'],
['resolve', 3, 'persist', 0, 'string'],
// source → (rectangular Reroute relay) → audit — a pullable relay node across the canvas.
['source', 0, 'rr_box', 0, 'float'],
['rr_box', 1, 'audit', 0, 'float'],
// Archive.In is a single-feed IN-pin → only `display` feeds it. `persist` already drains from
// `resolve` (line above) — leave its Out free for the user to wire as they like.
['display', 1, 'archive', 0, 'any'],
]
// Reroute placements (positioned by top-left; small footprints).
const REROUTES: { key: string; kind: typeof REROUTE_TYPE | typeof REROUTE_NODE_TYPE; type: string; position: { x: number; y: number } }[] = [
{ key: 'rr_inline', kind: REROUTE_TYPE, type: 'object', position: { x: 210, y: 470 } },
{ key: 'rr_box', kind: REROUTE_NODE_TYPE, type: 'float', position: { x: 624, y: 704 } },
]
// ---- build the data documents ----------------------------------------------------------------
const pinId = (nodeKey: string, i: number): string => `${nodeKey}:pin${i}`
function buildNode(spec: NodeSpec): XenolithNodeV1 {
const node: XenolithNodeV1 = {
id: spec.key,
type: spec.type,
position: spec.position,
pins: spec.pins.map((pn, i) => ({
id: pinId(spec.key, i),
kind: 'data',
direction: pn.dir,
type: pn.type,
multiple: pn.dir === 'out',
label: pn.label,
})),
render: { category: spec.category, title: spec.title ?? spec.type, collapsed: spec.collapsed ?? false },
// Bake schema-level glyph into the saved graph node. `loadJSON` deserialises nodes verbatim
// — it doesn't merge schema defaults — so without this the header icons we register on the
// schema would only appear on Tab-inserted nodes, never on the playground's seed graph.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
}
if (spec.widgets) {
node.widgets = spec.widgets
node.state = {}
for (const w of spec.widgets) {
if (w.key === undefined) continue
node.state[w.key] = w.type === 'custom' ? customDefault(w.renderer) : defaultWidgetValue(w)
}
}
return node
}
function buildReroute(r: (typeof REROUTES)[number]): XenolithNodeV1 {
return {
id: r.key,
type: r.kind,
position: r.position,
pins: [
{ id: pinId(r.key, 0), kind: 'data', direction: 'in', type: r.type, multiple: false },
{ id: pinId(r.key, 1), kind: 'data', direction: 'out', type: r.type, multiple: true },
],
}
}
const nodes: XenolithNodeV1[] = [...SPECS.map(buildNode), ...REROUTES.map(buildReroute)]
const edges: XenolithEdgeV1[] = CONNS.map(([from, fp, to, tp, type], i) => ({
id: `e${i}`,
from: { node: from, pin: pinId(from, fp) },
to: { node: to, pin: pinId(to, tp) },
opts: { sourceType: type },
}))
// ---- macro demo (declarative) ---------------------------------------------------------------
// Two self-contained macro groups, "Gather" and "Pack". Each is authored as DATA: ordinary member
// nodes (inlet → steps → outlet) wired to external sources/sinks, plus a `Macro` node carrying
// state.members + collapsed. loadJSON materialises the collapse (derives proxy pins from the boundary
// edges, rewires them) — so it loads as a real collapsed macro reading like a node with pins. The
// inlet IN pins / outlet OUT pin carry the labels that become the macro's pin labels (A/B/C…/Out).
const macroNodes: XenolithNodeV1[] = []
const macroEdges: XenolithEdgeV1[] = []
let meSeq = 0
const sp = (id: string, dir: 'in' | 'out', type: string, label: string, mult = false) =>
({ id, kind: 'data' as const, direction: dir, type, multiple: mult, label })
const sn = (id: string, x: number, y: number, pins: ReturnType<typeof sp>[], title: string, category: Cat): void => {
macroNodes.push({ id, type: 'Step', position: { x, y }, pins, render: { category, title, collapsed: false } })
}
const me = (from: string, fromPin: string, to: string, toPin: string): void => {
macroEdges.push({ id: `me${meSeq++}`, from: { node: from, pin: fromPin }, to: { node: to, pin: toPin } })
}
interface MacroIn { label: string; from?: { node: string; pin: number }; type: string }
interface MacroOut { to: { node: string; pin: number }; type: string }
/** Turn a demo node into a MACRO IN PLACE: build internal members (inlet per input → steps → outlet)
* wired to the SAME external neighbours the original node had, then a `Macro` node at its position.
* `inputs`/`outputs` describe the external boundary; loadJSON materialises proxy pins (labels kept). */
function macroInPlace(prefix: string, title: string, x: number, y: number, inputs: MacroIn[], outputs: MacroOut[]): void {
const members: string[] = []
// Inlet per external input — its IN pin (one feed) carries the label that becomes the macro pin.
const inletOut: string[] = []
inputs.forEach((inp, i) => {
const inl = `${prefix}_in${i}`
sn(inl, x, y + i * 110, [sp(`${inl}.i`, 'in', inp.type, inp.label), sp(`${inl}.o`, 'out', inp.type, 'Out', true)], inp.label, 'logic')
members.push(inl)
if (inp.from) me(inp.from.node, pinId(inp.from.node, inp.from.pin), inl, `${inl}.i`) // external → inlet
inletOut.push(`${inl}.o`)
})
// Merge the inlets through a binary tree of 2-input merge nodes — every IN pin takes exactly ONE
// edge (no 3-into-1). Single-input macros skip straight to the outlet.
let acc = inletOut[0]!
for (let i = 1; i < inletOut.length; i++) {
const m = `${prefix}_m${i}`
sn(m, x + 180, y + (i - 1) * 110 + 40, [sp(`${m}.a`, 'in', 'object', 'A'), sp(`${m}.b`, 'in', 'object', 'B'), sp(`${m}.o`, 'out', 'object', 'Out', true)], `Merge ${i}`, 'macro')
members.push(m)
me(acc.split('.')[0]!, acc, m, `${m}.a`)
me(inletOut[i]!.split('.')[0]!, inletOut[i]!, m, `${m}.b`)
acc = `${m}.o`
}
// Nested sub-macro (tests macro-in-macro): the merged stream flows acc → [Refine → Emit] → outlet,
// where Refine+Emit are wrapped in their own collapsed Macro that is itself a member of this one.
const subY = y + inputs.length * 110 + 30
const sa = `${prefix}_sa`, sb = `${prefix}_sb`, sm = `${prefix}_sub`
sn(sa, x + 200, subY, [sp(`${sa}.i`, 'in', 'object', 'In'), sp(`${sa}.o`, 'out', 'object', 'Out', true)], 'Refine', 'logic')
sn(sb, x + 360, subY, [sp(`${sb}.i`, 'in', 'object', 'In'), sp(`${sb}.o`, 'out', 'object', 'Out', true)], 'Emit', 'utility')
me(sa, `${sa}.o`, sb, `${sb}.i`)
macroNodes.push({ id: sm, type: 'Macro', position: { x: x + 240, y: subY }, pins: [], state: { members: [sa, sb], collapsed: true }, render: { category: 'macro', title: 'Sub' } })
members.push(sm)
me(acc.split('.')[0]!, acc, sa, `${sa}.i`)
acc = `${sb}.o`
// Outlet (output boundary, label "Out") — fed by the sub-macro, drives the external outputs.
const out = `${prefix}_out`
sn(out, x + 540, y, [sp(`${out}.i`, 'in', 'object', 'In'), sp(`${out}.o`, 'out', outputs[0]?.type ?? 'object', 'Out', true)], 'Result', 'utility')
members.push(out)
me(acc.split('.')[0]!, acc, out, `${out}.i`)
for (const o of outputs) me(out, `${out}.o`, o.to.node, pinId(o.to.node, o.to.pin))
macroNodes.push({ id: prefix, type: 'Macro', position: { x, y }, pins: [], state: { members, collapsed: true }, render: { category: 'macro', title } })
}
// Gather macro (was the demo 'gather' node at 300,200): A←filter, B←sample, C←reroute; Out→transform.
macroInPlace('gather', 'Gather', 300, 200,
[{ label: 'A', from: { node: 'filter', pin: 1 }, type: 'float' },
{ label: 'B', from: { node: 'sample', pin: 1 }, type: 'float' },
{ label: 'C', from: { node: 'rr_inline', pin: 1 }, type: 'object' }],
[{ to: { node: 'transform', pin: 0 }, type: 'object' }])
// Pack macro (was the demo 'pack' node at 300,420): In←Gather, Tag←cache; (Pack output unused in demo).
macroInPlace('pack', 'Pack', 300, 420,
[{ label: 'In', type: 'object' },
{ label: 'Tag', from: { node: 'cache', pin: 1 }, type: 'float' }],
[])
// Macro-to-macro: Gather's outlet feeds Pack's first inlet (member pins, not the macro proxy pins —
// those are derived at load). This becomes a Gather-macro → Pack-macro boundary edge after collapse.
me('gather_out', 'gather_out.o', 'pack_in0', 'pack_in0.i')
// ---- live-template demo (declarative) -------------------------------------------------------
// A reusable Template instance, authored as DATA: a `$templateInstance` node in the main graph whose
// pins mirror a definition stored under `templates`. The "Backup" definition is a small but real
// pipeline — two inputs (Payload, Key), two operations (Compress, Encrypt), two outputs (Archive,
// Size) — so there's something meaningful to dive into and edit. Double-click the instance (or use
// the node menu → Edit) to dive in. Fed by `archive`'s output so it reads as part of the graph.
//
// Payload ─▶ Compress ─┬─▶ Encrypt ─▶ Archive
// └─▶ Size Key ─▶ Encrypt
const TEMPLATE_DEF_ID = 'backup_def'
const templateInstance: XenolithNodeV1 = {
id: 'backup',
type: '$templateInstance',
position: { x: 1660, y: 320 },
pins: [
{ id: 'backup.payload', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Payload' },
{ id: 'backup.key', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'backup.archive', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Archive' },
{ id: 'backup.size', kind: 'data', direction: 'out', type: 'float', multiple: true, label: 'Size' },
],
state: {
definitionId: TEMPLATE_DEF_ID,
pinBoundary: { 'backup.payload': 'bk_payload', 'backup.key': 'bk_key', 'backup.archive': 'bk_archive', 'backup.size': 'bk_size' },
},
render: { category: 'macro', title: 'Backup' },
}
const templateEdge: XenolithEdgeV1 = {
id: 'e-backup', from: { node: 'archive', pin: pinId('archive', 1) }, to: { node: 'backup', pin: 'backup.payload' },
opts: { sourceType: 'object' },
}
const templateDefs: Record<string, XenolithTemplateV1> = {
[TEMPLATE_DEF_ID]: {
title: 'Backup',
nodes: [
// inputs
{ id: 'bk_payload', type: '$templateInput', position: { x: 0, y: 40 },
pins: [{ id: 'bk_payload.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Payload' }],
render: { category: 'utility', title: 'Payload' } },
{ id: 'bk_key', type: '$templateInput', position: { x: 0, y: 200 },
pins: [{ id: 'bk_key.o', kind: 'data', direction: 'out', type: 'string', multiple: true, label: 'Key' }],
render: { category: 'utility', title: 'Key' } },
// operations
{ id: 'bk_compress', type: 'Step', position: { x: 240, y: 40 },
pins: [
{ id: 'bk_compress.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_compress.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Packed' },
],
render: { category: 'logic', title: 'Compress' } },
{ id: 'bk_encrypt', type: 'Step', position: { x: 480, y: 120 },
pins: [
{ id: 'bk_encrypt.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_encrypt.k', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'bk_encrypt.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Cipher' },
],
render: { category: 'data', title: 'Encrypt' } },
// outputs
{ id: 'bk_archive', type: '$templateOutput', position: { x: 740, y: 120 },
pins: [{ id: 'bk_archive.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Archive' }],
render: { category: 'utility', title: 'Archive' } },
{ id: 'bk_size', type: '$templateOutput', position: { x: 740, y: 260 },
pins: [{ id: 'bk_size.i', kind: 'data', direction: 'in', type: 'float', multiple: false, label: 'Size' }],
render: { category: 'utility', title: 'Size' } },
],
edges: [
{ id: 'bk_e0', from: { node: 'bk_payload', pin: 'bk_payload.o' }, to: { node: 'bk_compress', pin: 'bk_compress.i' } },
{ id: 'bk_e1', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.i' } },
{ id: 'bk_e2', from: { node: 'bk_key', pin: 'bk_key.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.k' } },
{ id: 'bk_e3', from: { node: 'bk_encrypt', pin: 'bk_encrypt.o' }, to: { node: 'bk_archive', pin: 'bk_archive.i' } },
{ id: 'bk_e4', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_size', pin: 'bk_size.i' } },
],
},
}
export const demoGraph: XenolithGraphV1 = {
version: 'xenolith.v1',
nodes: [...nodes, ...macroNodes, templateInstance],
edges: [...edges, ...macroEdges, templateEdge],
templates: templateDefs,
// One comment/group frame per pipeline column — drag a header to move the whole group, resize from
// the corner. Also doubles as a perf/LOD test surface (distinct colours + texts across the graph).
comments: [
{ id: 'c-ingest', position: { x: 0, y: 14 }, size: { x: 240, y: 580 }, text: 'Ingest', color: '#85C244' },
{ id: 'c-combine', position: { x: 268, y: 150 }, size: { x: 240, y: 360 }, text: 'Combine', color: '#5B8DEF' },
{ id: 'c-transform', position: { x: 528, y: 88 }, size: { x: 250, y: 658 }, text: 'Transform', color: '#E0795A' },
{ id: 'c-resolve', position: { x: 788, y: 150 }, size: { x: 240, y: 540 }, text: 'Resolve', color: '#B06BE8' },
{ id: 'c-output', position: { x: 1068, y: 70 }, size: { x: 250, y: 620 }, text: 'Output', color: '#4FC3C9' },
],
}
/** Schemas for the insert palette — derived from the same SPECS so Tab / double-click can spawn
* any demo node type. (The rectangular `Reroute` is a built-in, auto-registered by the editor.) */
export const demoSchemas: NodeSchema[] = SPECS.map((spec) => ({
type: spec.type,
title: spec.title ?? spec.type,
category: spec.category,
description: spec.description,
keywords: spec.keywords ?? [],
pins: spec.pins.map((pn) => ({ kind: 'data', direction: pn.dir, type: pn.type, label: pn.label, multiple: pn.dir === 'out' })),
...(spec.widgets ? { widgets: spec.widgets } : {}),
// Header glyph — header icon shows next to the title in the node strip. Categories share
// semantically-similar icons (database for storage, flag for milestones, etc.) so a 20-node graph
// becomes scannable at a glance — the visual shorthand the [icons guide](/guides/icons/) covers.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
})) // Angular — load a saved graph after mount. The host owns the card; the service owns the editor.
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import { loadDemo } from '@xenolithengine/demo/scene'
import { demoGraph } from '@xenolithengine/demo'
@Component({
selector: 'load-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<button type="button" class="btn" (click)="reload()">Reload graph</button>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; right:12px; z-index:5; padding:10px;
background:var(--xeno-panel,#1d1d1d); border:1px solid var(--xeno-border,#333);
border-radius:10px; font:13px system-ui,sans-serif; }
.btn { font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; border-radius:8px;
border:1px solid var(--xeno-border,#333); background:var(--xeno-elevated,#262626);
color:var(--xeno-text,#cfcfcf); }
`],
})
export class LoadDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
editor.chrome.setControls({ position: 'bottom-left' })
loadDemo(editor)
}
reload(): void {
const editor = this.graph.editor
if (!editor) return
editor.loadJSON(demoGraph)
editor.view.fitView({ padding: 48, maxZoom: 1 })
}
ngOnDestroy(): void { this.graph.destroy() }
} // The canonical shared demo scene: register the canvas custom widgets + demo node schemas, load the
// shared demo graph (xenolith.v1), and frame it. Framework-agnostic — every host (React/Vue/Svelte)
// calls this in its onReady so the registry has the schemas before loadJSON.
import { demoGraph, demoSchemas, createCurveWidget, createXYPadWidget } from './demo-graph.js'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
export function loadDemo(editor: XenolithEditor): void {
editor.registerWidget('curve', createCurveWidget())
editor.registerWidget('xypad', createXYPadWidget())
for (const schema of demoSchemas) editor.registry.register(schema)
editor.loadJSON(demoGraph)
editor.fitView({ padding: 48, maxZoom: 1 })
} import type { NodeSchema, XenolithGraphV1, XenolithNodeV1, XenolithEdgeV1, XenolithTemplateV1 } from '@xenolithengine/graph-editor'
import { REROUTE_TYPE, REROUTE_NODE_TYPE, defaultWidgetValue, type WidgetSpec } from '@xenolithengine/graph-core'
export { createCurveWidget } from './curve-widget.js'
export { createXYPadWidget } from './xy-pad-widget.js'
export { createPreviewWidget } from './preview-widget.js'
import { CURVE_DEFAULT } from './curve-widget.js'
import { XYPAD_DEFAULT } from './xy-pad-widget.js'
import { PREVIEW_DEFAULT } from './preview-widget.js'
/** Initial value for a custom widget — its controller's default, persisted into node.state so a
* loaded graph is self-contained (getWidgetValue returns real data, not undefined). */
function customDefault(renderer: string): unknown {
return renderer === 'curve' ? CURVE_DEFAULT
: renderer === 'xypad' ? XYPAD_DEFAULT
: renderer === 'preview' ? PREVIEW_DEFAULT
: null
}
/**
* The canonical Xenolith demo graph, authored once as plain data in our own `xenolith.v1` format.
* Every host (playground app, the site /playground page, the landing showcase) loads it via
* `editor.loadJSON(demoGraph)` — no host hand-builds nodes with addNode/connect anymore.
*
* It exercises the full visual vocabulary: all four node categories, collapsed pills, every pin
* type colour, and BOTH reroute kinds — the inline `$reroute` knot (split into a wire, non-pullable)
* and the rectangular `Reroute` relay node (a movable node you can pull fresh wires from).
*/
type Cat = 'logic' | 'data' | 'macro' | 'utility'
interface PinSpec { dir: 'in' | 'out'; type: string; label: string }
interface NodeSpec {
key: string
type: string
title?: string
category: Cat
description: string
keywords?: string[]
position: { x: number; y: number }
collapsed?: boolean
pins: PinSpec[]
widgets?: WidgetSpec[]
/** Built-in icon name from the editor's icon registry — `cpu`, `database`, `code`, etc. */
icon?: string
iconSide?: 'left' | 'right'
}
type Conn = [from: string, fromPin: number, to: string, toPin: number, type: string]
const p = (dir: 'in' | 'out', type: string, label: string): PinSpec => ({ dir, type, label })
// ---- node definitions ------------------------------------------------------------------------
const SPECS: NodeSpec[] = [
{ key: 'source', type: 'Source', category: 'logic', icon: 'zap', description: 'Emits a stream of float values', keywords: ['input', 'origin'], position: { x: 40, y: 60 }, pins: [p('out', 'float', 'Output')] },
{ key: 'sample', type: 'Sample', category: 'logic', icon: 'play', description: 'Reads a value at the current cursor', keywords: ['read'], position: { x: 40, y: 200 }, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'filter', type: 'Filter', category: 'logic', icon: 'branch', description: 'Drops values failing a predicate', keywords: ['where'], position: { x: 40, y: 360 }, collapsed: true, pins: [p('in','float','In'), p('out','float','Out')] },
{ key: 'cache', type: 'Cache', category: 'data', icon: 'database', description: 'Memoises the last seen object', keywords: ['store', 'memo'], position: { x: 40, y: 500 }, collapsed: true, pins: [p('in','object','In'), p('out','object','Out')] },
// 'gather' and 'pack' are MACRO groups, authored in the macro section below (not plain nodes).
// NOTE: CONNS indexes pins by POSITION — keep the original [In, Out, …] prefix and APPEND
// widget-bound pins after Out so existing connections still resolve to the right pin (otherwise
// a wire defined as `transform, 1, …` would land on `scale` instead of `Out`).
// Transform column: spread vertically so the wide Validate node (curve widget) has breathing
// room above and below; Validate's wider footprint is centred under the macro frame.
{ key: 'transform', type: 'Transform', category: 'data', icon: 'code', description: 'Maps an object to a new shape', keywords: ['move', 'map'], position: { x: 568, y: 136 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','float','scale'), p('in','string','mode'), p('in','bool','mirror')],
widgets: [
{ id: 'scale', type: 'slider', label: '', key: 'scale', min: 0, max: 2, step: 0.05 },
{ id: 'mode', type: 'combo', label: '', key: 'mode', values: ['fit', 'fill', 'stretch'] },
{ id: 'mirror', type: 'toggle', label: '', key: 'mirror' },
] },
{ key: 'validate', type: 'Validate', category: 'data', icon: 'flag', description: 'Checks an object against a schema', keywords: ['check'], position: { x: 544, y: 320 },
pins: [p('in','object','In'), p('out','wildcard','Out'), p('in','any','response')],
widgets: [
{ id: 'response', type: 'custom', renderer: 'curve', key: 'response', label: '', height: 120 },
] },
{ key: 'enrich', type: 'Enrich', category: 'data', icon: 'layers', description: 'Augments an object with extra fields', keywords: ['augment'], position: { x: 568, y: 536 },
pins: [p('in','object','In'), p('out','object','Out'), p('in','string','tint'), p('in','float','strength')],
widgets: [
{ id: 'tint', type: 'color', label: '', key: 'tint' },
{ id: 'strength', type: 'slider', label: '', key: 'strength', min: 0, max: 1, step: 0.01 },
] },
{ key: 'score', type: 'Score', category: 'macro', icon: 'cpu', description: 'Ranks an object, emits a float score', keywords: ['rank'], position: { x: 820, y: 200 }, collapsed: true, pins: [p('in','object','In'), p('out','float','Out')] },
{ key: 'resolve', type: 'Resolve', category: 'macro', icon: 'play', description: 'Finalises into a string result', keywords: ['finalize'], position: { x: 820, y: 360 },
pins: [p('in','object','In'), p('in','float','Hint'), p('in','wildcard','Aux'), p('out','string','Out'), p('in','float','seed'), p('in','string','prompt')],
widgets: [
{ id: 'seed', type: 'number', label: '', key: 'seed', min: 0, max: 999999, step: 1 },
{ id: 'prompt', type: 'text', label: '', key: 'prompt', placeholder: 'describe…' },
] },
{ key: 'format', type: 'Format', category: 'data', icon: 'code', description: 'Renders a result to a display string', keywords: ['template'], position: { x: 820, y: 600 }, collapsed: true, pins: [p('in','string','In'), p('out','string','Out')] },
{ key: 'display', type: 'Display', category: 'utility', icon: 'square', iconSide: 'right', description: 'Renders a string to the viewport', keywords: ['show', 'output'], position: { x: 1100, y: 120 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'audit', type: 'Audit', category: 'utility', icon: 'clock', description: 'Logs a float for inspection', keywords: ['log'], position: { x: 1100, y: 280 }, pins: [p('in','float','In'), p('out','any','Out')] },
{ key: 'persist', type: 'Persist', category: 'utility', icon: 'database', description: 'Writes a string to durable storage', keywords: ['save', 'write'], position: { x: 1100, y: 440 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'notify', type: 'Notify', category: 'utility', icon: 'flag', description: 'Pushes a notification on completion', keywords: ['alert', 'webhook'],position: { x: 1100, y: 600 }, pins: [p('in','string','In'), p('out','any','Out')] },
{ key: 'archive', type: 'Archive', category: 'utility', icon: 'box', description: 'Cold-stores any payload', keywords: ['cold', 'backup'], position: { x: 1380, y: 360 },
pins: [p('in','any','In'), p('out','any','Out'), p('in','any','offset')],
widgets: [
{ id: 'offset', type: 'custom', renderer: 'xypad', key: 'offset', label: '', height: 110 },
] },
// -------- Canon showcase --------------------------------------------------------------------
// Demonstrates the widget canon in one node:
// - `name` IN-pin + text widget (default-value: hides when a wire arrives, reappears on disc).
// - `say` IN-pin + custom 'preview' widget (visibility:'always', renders the wire's value).
// - actions row button (`+ reset`) — under the pin block.
{ key: 'greet', type: 'Greet', category: 'utility',
description: 'Pin-bound widget showcase: default-value text, always-on preview, actions row',
keywords: ['canon', 'widget', 'default', 'preview'],
position: { x: 1660, y: 60 },
// First IN-pair has no widget so it pairs cleanly with `out` on row 0. The widget-carrying
// pins (`name`, `say`) sit on their own rows underneath — the widget takes the row's right
// half without colliding with an opposite-side label.
pins: [p('in','object','In'), p('out','string','out'), p('in','string','name'), p('in','string','say')],
widgets: [
{ id: 'name', type: 'text', label: '', key: 'name' },
{ id: 'say', type: 'custom', renderer: 'preview', key: 'say', label: '', visibility: 'always' },
{ id: 'reset', type: 'button', label: '+ reset', action: 'resetGreet' },
] },
]
// ---- connections (reroutes wired in) ---------------------------------------------------------
// Reroute nodes participate as ordinary endpoints: pin 0 = in, pin 1 = out.
const CONNS: Conn[] = [
['source', 0, 'sample', 0, 'float'],
['sample', 1, 'filter', 0, 'float'],
// cache → (inline $reroute knot) → Gather macro — the knot just carries the object wire through.
['cache', 1, 'rr_inline', 0, 'object'],
// (Gather/Pack macro boundary edges are wired in the macro section below.)
['transform', 1, 'validate', 0, 'object'],
['transform', 1, 'enrich', 0, 'object'],
['enrich', 1, 'score', 0, 'object'],
['enrich', 1, 'resolve', 0, 'object'],
['score', 1, 'resolve', 1, 'float'],
['validate', 1, 'resolve', 2, 'wildcard'],
['resolve', 3, 'format', 0, 'string'],
['resolve', 3, 'display', 0, 'string'],
['format', 1, 'notify', 0, 'string'],
['resolve', 3, 'persist', 0, 'string'],
// source → (rectangular Reroute relay) → audit — a pullable relay node across the canvas.
['source', 0, 'rr_box', 0, 'float'],
['rr_box', 1, 'audit', 0, 'float'],
// Archive.In is a single-feed IN-pin → only `display` feeds it. `persist` already drains from
// `resolve` (line above) — leave its Out free for the user to wire as they like.
['display', 1, 'archive', 0, 'any'],
]
// Reroute placements (positioned by top-left; small footprints).
const REROUTES: { key: string; kind: typeof REROUTE_TYPE | typeof REROUTE_NODE_TYPE; type: string; position: { x: number; y: number } }[] = [
{ key: 'rr_inline', kind: REROUTE_TYPE, type: 'object', position: { x: 210, y: 470 } },
{ key: 'rr_box', kind: REROUTE_NODE_TYPE, type: 'float', position: { x: 624, y: 704 } },
]
// ---- build the data documents ----------------------------------------------------------------
const pinId = (nodeKey: string, i: number): string => `${nodeKey}:pin${i}`
function buildNode(spec: NodeSpec): XenolithNodeV1 {
const node: XenolithNodeV1 = {
id: spec.key,
type: spec.type,
position: spec.position,
pins: spec.pins.map((pn, i) => ({
id: pinId(spec.key, i),
kind: 'data',
direction: pn.dir,
type: pn.type,
multiple: pn.dir === 'out',
label: pn.label,
})),
render: { category: spec.category, title: spec.title ?? spec.type, collapsed: spec.collapsed ?? false },
// Bake schema-level glyph into the saved graph node. `loadJSON` deserialises nodes verbatim
// — it doesn't merge schema defaults — so without this the header icons we register on the
// schema would only appear on Tab-inserted nodes, never on the playground's seed graph.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
}
if (spec.widgets) {
node.widgets = spec.widgets
node.state = {}
for (const w of spec.widgets) {
if (w.key === undefined) continue
node.state[w.key] = w.type === 'custom' ? customDefault(w.renderer) : defaultWidgetValue(w)
}
}
return node
}
function buildReroute(r: (typeof REROUTES)[number]): XenolithNodeV1 {
return {
id: r.key,
type: r.kind,
position: r.position,
pins: [
{ id: pinId(r.key, 0), kind: 'data', direction: 'in', type: r.type, multiple: false },
{ id: pinId(r.key, 1), kind: 'data', direction: 'out', type: r.type, multiple: true },
],
}
}
const nodes: XenolithNodeV1[] = [...SPECS.map(buildNode), ...REROUTES.map(buildReroute)]
const edges: XenolithEdgeV1[] = CONNS.map(([from, fp, to, tp, type], i) => ({
id: `e${i}`,
from: { node: from, pin: pinId(from, fp) },
to: { node: to, pin: pinId(to, tp) },
opts: { sourceType: type },
}))
// ---- macro demo (declarative) ---------------------------------------------------------------
// Two self-contained macro groups, "Gather" and "Pack". Each is authored as DATA: ordinary member
// nodes (inlet → steps → outlet) wired to external sources/sinks, plus a `Macro` node carrying
// state.members + collapsed. loadJSON materialises the collapse (derives proxy pins from the boundary
// edges, rewires them) — so it loads as a real collapsed macro reading like a node with pins. The
// inlet IN pins / outlet OUT pin carry the labels that become the macro's pin labels (A/B/C…/Out).
const macroNodes: XenolithNodeV1[] = []
const macroEdges: XenolithEdgeV1[] = []
let meSeq = 0
const sp = (id: string, dir: 'in' | 'out', type: string, label: string, mult = false) =>
({ id, kind: 'data' as const, direction: dir, type, multiple: mult, label })
const sn = (id: string, x: number, y: number, pins: ReturnType<typeof sp>[], title: string, category: Cat): void => {
macroNodes.push({ id, type: 'Step', position: { x, y }, pins, render: { category, title, collapsed: false } })
}
const me = (from: string, fromPin: string, to: string, toPin: string): void => {
macroEdges.push({ id: `me${meSeq++}`, from: { node: from, pin: fromPin }, to: { node: to, pin: toPin } })
}
interface MacroIn { label: string; from?: { node: string; pin: number }; type: string }
interface MacroOut { to: { node: string; pin: number }; type: string }
/** Turn a demo node into a MACRO IN PLACE: build internal members (inlet per input → steps → outlet)
* wired to the SAME external neighbours the original node had, then a `Macro` node at its position.
* `inputs`/`outputs` describe the external boundary; loadJSON materialises proxy pins (labels kept). */
function macroInPlace(prefix: string, title: string, x: number, y: number, inputs: MacroIn[], outputs: MacroOut[]): void {
const members: string[] = []
// Inlet per external input — its IN pin (one feed) carries the label that becomes the macro pin.
const inletOut: string[] = []
inputs.forEach((inp, i) => {
const inl = `${prefix}_in${i}`
sn(inl, x, y + i * 110, [sp(`${inl}.i`, 'in', inp.type, inp.label), sp(`${inl}.o`, 'out', inp.type, 'Out', true)], inp.label, 'logic')
members.push(inl)
if (inp.from) me(inp.from.node, pinId(inp.from.node, inp.from.pin), inl, `${inl}.i`) // external → inlet
inletOut.push(`${inl}.o`)
})
// Merge the inlets through a binary tree of 2-input merge nodes — every IN pin takes exactly ONE
// edge (no 3-into-1). Single-input macros skip straight to the outlet.
let acc = inletOut[0]!
for (let i = 1; i < inletOut.length; i++) {
const m = `${prefix}_m${i}`
sn(m, x + 180, y + (i - 1) * 110 + 40, [sp(`${m}.a`, 'in', 'object', 'A'), sp(`${m}.b`, 'in', 'object', 'B'), sp(`${m}.o`, 'out', 'object', 'Out', true)], `Merge ${i}`, 'macro')
members.push(m)
me(acc.split('.')[0]!, acc, m, `${m}.a`)
me(inletOut[i]!.split('.')[0]!, inletOut[i]!, m, `${m}.b`)
acc = `${m}.o`
}
// Nested sub-macro (tests macro-in-macro): the merged stream flows acc → [Refine → Emit] → outlet,
// where Refine+Emit are wrapped in their own collapsed Macro that is itself a member of this one.
const subY = y + inputs.length * 110 + 30
const sa = `${prefix}_sa`, sb = `${prefix}_sb`, sm = `${prefix}_sub`
sn(sa, x + 200, subY, [sp(`${sa}.i`, 'in', 'object', 'In'), sp(`${sa}.o`, 'out', 'object', 'Out', true)], 'Refine', 'logic')
sn(sb, x + 360, subY, [sp(`${sb}.i`, 'in', 'object', 'In'), sp(`${sb}.o`, 'out', 'object', 'Out', true)], 'Emit', 'utility')
me(sa, `${sa}.o`, sb, `${sb}.i`)
macroNodes.push({ id: sm, type: 'Macro', position: { x: x + 240, y: subY }, pins: [], state: { members: [sa, sb], collapsed: true }, render: { category: 'macro', title: 'Sub' } })
members.push(sm)
me(acc.split('.')[0]!, acc, sa, `${sa}.i`)
acc = `${sb}.o`
// Outlet (output boundary, label "Out") — fed by the sub-macro, drives the external outputs.
const out = `${prefix}_out`
sn(out, x + 540, y, [sp(`${out}.i`, 'in', 'object', 'In'), sp(`${out}.o`, 'out', outputs[0]?.type ?? 'object', 'Out', true)], 'Result', 'utility')
members.push(out)
me(acc.split('.')[0]!, acc, out, `${out}.i`)
for (const o of outputs) me(out, `${out}.o`, o.to.node, pinId(o.to.node, o.to.pin))
macroNodes.push({ id: prefix, type: 'Macro', position: { x, y }, pins: [], state: { members, collapsed: true }, render: { category: 'macro', title } })
}
// Gather macro (was the demo 'gather' node at 300,200): A←filter, B←sample, C←reroute; Out→transform.
macroInPlace('gather', 'Gather', 300, 200,
[{ label: 'A', from: { node: 'filter', pin: 1 }, type: 'float' },
{ label: 'B', from: { node: 'sample', pin: 1 }, type: 'float' },
{ label: 'C', from: { node: 'rr_inline', pin: 1 }, type: 'object' }],
[{ to: { node: 'transform', pin: 0 }, type: 'object' }])
// Pack macro (was the demo 'pack' node at 300,420): In←Gather, Tag←cache; (Pack output unused in demo).
macroInPlace('pack', 'Pack', 300, 420,
[{ label: 'In', type: 'object' },
{ label: 'Tag', from: { node: 'cache', pin: 1 }, type: 'float' }],
[])
// Macro-to-macro: Gather's outlet feeds Pack's first inlet (member pins, not the macro proxy pins —
// those are derived at load). This becomes a Gather-macro → Pack-macro boundary edge after collapse.
me('gather_out', 'gather_out.o', 'pack_in0', 'pack_in0.i')
// ---- live-template demo (declarative) -------------------------------------------------------
// A reusable Template instance, authored as DATA: a `$templateInstance` node in the main graph whose
// pins mirror a definition stored under `templates`. The "Backup" definition is a small but real
// pipeline — two inputs (Payload, Key), two operations (Compress, Encrypt), two outputs (Archive,
// Size) — so there's something meaningful to dive into and edit. Double-click the instance (or use
// the node menu → Edit) to dive in. Fed by `archive`'s output so it reads as part of the graph.
//
// Payload ─▶ Compress ─┬─▶ Encrypt ─▶ Archive
// └─▶ Size Key ─▶ Encrypt
const TEMPLATE_DEF_ID = 'backup_def'
const templateInstance: XenolithNodeV1 = {
id: 'backup',
type: '$templateInstance',
position: { x: 1660, y: 320 },
pins: [
{ id: 'backup.payload', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Payload' },
{ id: 'backup.key', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'backup.archive', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Archive' },
{ id: 'backup.size', kind: 'data', direction: 'out', type: 'float', multiple: true, label: 'Size' },
],
state: {
definitionId: TEMPLATE_DEF_ID,
pinBoundary: { 'backup.payload': 'bk_payload', 'backup.key': 'bk_key', 'backup.archive': 'bk_archive', 'backup.size': 'bk_size' },
},
render: { category: 'macro', title: 'Backup' },
}
const templateEdge: XenolithEdgeV1 = {
id: 'e-backup', from: { node: 'archive', pin: pinId('archive', 1) }, to: { node: 'backup', pin: 'backup.payload' },
opts: { sourceType: 'object' },
}
const templateDefs: Record<string, XenolithTemplateV1> = {
[TEMPLATE_DEF_ID]: {
title: 'Backup',
nodes: [
// inputs
{ id: 'bk_payload', type: '$templateInput', position: { x: 0, y: 40 },
pins: [{ id: 'bk_payload.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Payload' }],
render: { category: 'utility', title: 'Payload' } },
{ id: 'bk_key', type: '$templateInput', position: { x: 0, y: 200 },
pins: [{ id: 'bk_key.o', kind: 'data', direction: 'out', type: 'string', multiple: true, label: 'Key' }],
render: { category: 'utility', title: 'Key' } },
// operations
{ id: 'bk_compress', type: 'Step', position: { x: 240, y: 40 },
pins: [
{ id: 'bk_compress.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_compress.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Packed' },
],
render: { category: 'logic', title: 'Compress' } },
{ id: 'bk_encrypt', type: 'Step', position: { x: 480, y: 120 },
pins: [
{ id: 'bk_encrypt.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Data' },
{ id: 'bk_encrypt.k', kind: 'data', direction: 'in', type: 'string', multiple: false, label: 'Key' },
{ id: 'bk_encrypt.o', kind: 'data', direction: 'out', type: 'object', multiple: true, label: 'Cipher' },
],
render: { category: 'data', title: 'Encrypt' } },
// outputs
{ id: 'bk_archive', type: '$templateOutput', position: { x: 740, y: 120 },
pins: [{ id: 'bk_archive.i', kind: 'data', direction: 'in', type: 'object', multiple: false, label: 'Archive' }],
render: { category: 'utility', title: 'Archive' } },
{ id: 'bk_size', type: '$templateOutput', position: { x: 740, y: 260 },
pins: [{ id: 'bk_size.i', kind: 'data', direction: 'in', type: 'float', multiple: false, label: 'Size' }],
render: { category: 'utility', title: 'Size' } },
],
edges: [
{ id: 'bk_e0', from: { node: 'bk_payload', pin: 'bk_payload.o' }, to: { node: 'bk_compress', pin: 'bk_compress.i' } },
{ id: 'bk_e1', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.i' } },
{ id: 'bk_e2', from: { node: 'bk_key', pin: 'bk_key.o' }, to: { node: 'bk_encrypt', pin: 'bk_encrypt.k' } },
{ id: 'bk_e3', from: { node: 'bk_encrypt', pin: 'bk_encrypt.o' }, to: { node: 'bk_archive', pin: 'bk_archive.i' } },
{ id: 'bk_e4', from: { node: 'bk_compress', pin: 'bk_compress.o' }, to: { node: 'bk_size', pin: 'bk_size.i' } },
],
},
}
export const demoGraph: XenolithGraphV1 = {
version: 'xenolith.v1',
nodes: [...nodes, ...macroNodes, templateInstance],
edges: [...edges, ...macroEdges, templateEdge],
templates: templateDefs,
// One comment/group frame per pipeline column — drag a header to move the whole group, resize from
// the corner. Also doubles as a perf/LOD test surface (distinct colours + texts across the graph).
comments: [
{ id: 'c-ingest', position: { x: 0, y: 14 }, size: { x: 240, y: 580 }, text: 'Ingest', color: '#85C244' },
{ id: 'c-combine', position: { x: 268, y: 150 }, size: { x: 240, y: 360 }, text: 'Combine', color: '#5B8DEF' },
{ id: 'c-transform', position: { x: 528, y: 88 }, size: { x: 250, y: 658 }, text: 'Transform', color: '#E0795A' },
{ id: 'c-resolve', position: { x: 788, y: 150 }, size: { x: 240, y: 540 }, text: 'Resolve', color: '#B06BE8' },
{ id: 'c-output', position: { x: 1068, y: 70 }, size: { x: 250, y: 620 }, text: 'Output', color: '#4FC3C9' },
],
}
/** Schemas for the insert palette — derived from the same SPECS so Tab / double-click can spawn
* any demo node type. (The rectangular `Reroute` is a built-in, auto-registered by the editor.) */
export const demoSchemas: NodeSchema[] = SPECS.map((spec) => ({
type: spec.type,
title: spec.title ?? spec.type,
category: spec.category,
description: spec.description,
keywords: spec.keywords ?? [],
pins: spec.pins.map((pn) => ({ kind: 'data', direction: pn.dir, type: pn.type, label: pn.label, multiple: pn.dir === 'out' })),
...(spec.widgets ? { widgets: spec.widgets } : {}),
// Header glyph — header icon shows next to the title in the node strip. Categories share
// semantically-similar icons (database for storage, flag for milestones, etc.) so a 20-node graph
// becomes scannable at a glance — the visual shorthand the [icons guide](/guides/icons/) covers.
...(spec.icon ? { glyph: spec.iconSide ? { icon: spec.icon, side: spec.iconSide } : { icon: spec.icon } } : {}),
}))