Graph diff (PR review)
Two versions of a graph side by side, with structural diff highlights — green = added, red = removed, yellow = modified. Drop-in PR-review for node graphs.
import { useCallback, useEffect, useRef, useState } from 'react'
import { XenolithGraph, XenolithPanel, useEditor } from '@xenolithengine/graph-react'
import { diffGraphs, type XenolithEditor, type GraphDiff } from '@xenolithengine/graph-editor'
import type { NodeSchema } from '@xenolithengine/graph-core'
// PR-review style diff. BEFORE pane is just the old graph — no highlights, no legend.
// AFTER pane carries the entire diff story:
// - added nodes → static GREEN ring (setNodeStatus 'ok')
// - modified → static YELLOW DOM ring (the editor's 'running' status pulses, which would
// read as "this is currently running" — wrong semantics for a diff. Use a DOM overlay so
// the ring is rock-solid.)
// - removed → red GHOST rectangle in AFTER at the node's BEFORE position, so the
// reader can see exactly where the deletion happened in the old layout.
// Both panes are READ-ONLY (no pan, no zoom, no drag) — diff is for inspection only.
//
// Cross-editor data (the diff) lives at the parent — there's no canonical way around that.
// Each pane is its own <XenolithGraph>; each owns its build + read-only setup via an in-editor
// child reading `useEditor()`. They call up to the parent so the diff can be computed once
// BOTH editors are mounted.
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
// A tiny throwaway node type only PREV uses so the diff has a real `removed` to highlight.
{ type: 'Probe', title: 'Probe', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
interface Built {
ids: Record<string, string>
meta: Record<string, { key: string }>
/** PREV-pane only: positions of every node, keyed by the stable `key` so the AFTER pane can
* paint ghost rectangles for removed-in-AFTER nodes at the BEFORE coordinates. */
positions: Record<string, { x: number; y: number; w: number; h: number }>
/** PREV-pane only: node type by key, so removed-ghost labels can say "— Probe removed —" */
typeByKey: Record<string, string>
}
function buildPrev(editor: XenolithEditor): Built {
// Idempotence guard: React StrictMode double-invokes this effect and some remount paths reuse
// the booted editor — either way the builder can run twice on ONE editor and the diff would
// see doubled graphs (observed as uuid-keyed noise in addedEdges). Always start from empty.
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const out = editor.insertNode('Display', { x: 640, y: 60 })!
const dbg = editor.insertNode('Probe', { x: 640, y: 220 })! // only in PREV → removed in NEXT
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
const o = (n: typeof a): string => n.pins.find((p) => p.direction === 'out')!.id
const iAt = (n: typeof add, i: number): string => n.pins.filter((p) => p.direction === 'in')[i]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: a.id, pin: o(a) }, to: { node: add.id, pin: iAt(add, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: b.id, pin: o(b) }, to: { node: add.id, pin: iAt(add, 1) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: add.id, pin: o(add) }, to: { node: out.id, pin: iAt(out, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: add.id, pin: o(add) }, to: { node: dbg.id, pin: iAt(dbg, 0) } } as never)
editor.view.fitView({ padding: 60 })
const positions: Built['positions'] = {}
for (const n of editor.getGraphReadonly().nodes) {
positions[String(n.id)] = { x: n.position.x, y: n.position.y, w: n.size?.x ?? 200, h: n.size?.y ?? 80 }
}
return {
ids: { a: String(a.id), b: String(b.id), add: String(add.id), out: String(out.id), dbg: String(dbg.id) },
meta: {
[a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' },
[out.id]: { key: 'out' }, [dbg.id]: { key: 'dbg' },
},
positions: {
a: positions[String(a.id)]!, b: positions[String(b.id)]!, add: positions[String(add.id)]!,
out: positions[String(out.id)]!, dbg: positions[String(dbg.id)]!,
},
typeByKey: { a: 'Const', b: 'Const', add: 'Add', out: 'Display', dbg: 'Probe' },
}
}
function buildNext(editor: XenolithEditor): Built {
editor.clear() // idempotence guard — see buildPrev
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const extra = editor.insertNode('Const', { x: 320, y: 280 })!
const mul = editor.insertNode('Multiply', { x: 560, y: 160 })!
const out = editor.insertNode('Display', { x: 880, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 7) // modified (was 3)
editor.setWidgetValue(extra.id, 'value', 4)
const o = (n: typeof a): string => n.pins.find((p) => p.direction === 'out')!.id
const iAt = (n: typeof add, i: number): string => n.pins.filter((p) => p.direction === 'in')[i]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: a.id, pin: o(a) }, to: { node: add.id, pin: iAt(add, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: b.id, pin: o(b) }, to: { node: add.id, pin: iAt(add, 1) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: add.id, pin: o(add) }, to: { node: mul.id, pin: iAt(mul, 0) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: extra.id, pin: o(extra) }, to: { node: mul.id, pin: iAt(mul, 1) } } as never)
editor.addEdge({ id: crypto.randomUUID(), from: { node: mul.id, pin: o(mul) }, to: { node: out.id, pin: iAt(out, 0) } } as never)
editor.view.fitView({ padding: 60 })
const positions: Built['positions'] = {}
for (const n of editor.getGraphReadonly().nodes) {
positions[String(n.id)] = { x: n.position.x, y: n.position.y, w: n.size?.x ?? 200, h: n.size?.y ?? 80 }
}
return {
ids: { a: String(a.id), b: String(b.id), add: String(add.id), mul: String(mul.id), extra: String(extra.id), out: String(out.id) },
meta: {
[a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' },
[mul.id]: { key: 'mul' }, [extra.id]: { key: 'extra' }, [out.id]: { key: 'out' },
},
positions: {
a: positions[String(a.id)]!, b: positions[String(b.id)]!, add: positions[String(add.id)]!,
mul: positions[String(mul.id)]!, extra: positions[String(extra.id)]!, out: positions[String(out.id)]!,
},
typeByKey: { a: 'Const', b: 'Const', add: 'Add', mul: 'Multiply', extra: 'Const', out: 'Display' },
}
}
function snapshotWithKeys(editor: XenolithEditor, meta: Record<string, { key: string }>): { nodes: { id: string; type: string; position: { x: number; y: number }; state: Record<string, unknown> }[]; edges: { id: string; from: { node: string; pin: string }; to: { node: string; pin: string } }[] } {
const snap = editor.getGraphReadonly()
const nodes = snap.nodes.map((n) => ({
id: meta[String(n.id)]?.key ?? String(n.id),
type: n.type,
position: { x: n.position.x, y: n.position.y },
state: { ...n.state },
}))
const edges = snap.edges.map((e) => {
const fk = meta[String(e.from.node)]?.key ?? String(e.from.node)
const tk = meta[String(e.to.node)]?.key ?? String(e.to.node)
return { id: `${fk}→${tk}`, from: { node: fk, pin: '' }, to: { node: tk, pin: '' } }
})
return { nodes, edges }
}
/** Lock the editor: no node drag, no pin connect, no marquee — pan/zoom stays live so the user can
* still navigate the comparison. Programmatic mutations (used by the diff paint pass) keep working
* because they go through the public API, not the interactive event path. */
function makeReadOnly(editor: XenolithEditor): void {
editor.setInteractive(false)
editor.selection.clear()
}
interface DomOverlayHandles {
modified: Map<string, HTMLDivElement>
removed: Map<string, HTMLDivElement>
}
function reposition(editor: XenolithEditor, handles: DomOverlayHandles, keyToId: Record<string, string>, removedPositions: Record<string, { x: number; y: number; w: number; h: number }>): void {
const snap = editor.getGraphReadonly()
const nodeById = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [key, el] of handles.modified) {
const id = keyToId[key]; if (!id) continue
const n = nodeById.get(String(id)); if (!n) continue
const tl = editor.view.worldToScreen(n.position)
const br = editor.view.worldToScreen({ x: n.position.x + (n.size?.x ?? 200), y: n.position.y + (n.size?.y ?? 80) })
el.style.left = `${tl.x - 3}px`
el.style.top = `${tl.y - 3}px`
el.style.width = `${br.x - tl.x + 6}px`
el.style.height = `${br.y - tl.y + 6}px`
}
for (const [key, el] of handles.removed) {
const p = removedPositions[key]; if (!p) continue
const tl = editor.view.worldToScreen({ x: p.x, y: p.y })
const br = editor.view.worldToScreen({ x: p.x + p.w, y: p.y + p.h })
const wPx = br.x - tl.x
const hPx = br.y - tl.y
el.style.left = `${tl.x}px`
el.style.top = `${tl.y}px`
el.style.width = `${wPx}px`
el.style.height = `${hPx}px`
// Scale the label so it never overflows the small box at zoom-out. Below ~60px wide
// there isn't room for the type name at all — drop to a single dash.
const fontPx = Math.max(7, Math.min(12, wPx / 16))
el.style.fontSize = `${fontPx}px`
el.style.padding = `${Math.max(2, hPx * 0.1)}px`
el.style.lineHeight = '1.05'
el.style.textAlign = 'center'
if (wPx < 60) el.textContent = '—'
else el.textContent = el.dataset['fullLabel'] ?? el.textContent ?? ''
}
}
function paintAfterOverlay(editor: XenolithEditor, diff: GraphDiff, afterIds: Record<string, string>, prevPositions: Record<string, { x: number; y: number; w: number; h: number }>, prevTypes: Record<string, string>): DomOverlayHandles {
for (const n of editor.getGraphReadonly().nodes) editor.setNodeStatus(n.id as never, 'idle')
for (const k of diff.addedNodes) { const id = afterIds[k]; if (id) editor.setNodeStatus(id as never, 'ok') }
const handles: DomOverlayHandles = { modified: new Map(), removed: new Map() }
for (const k of diff.modifiedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px solid #fcb400', borderRadius: '10px',
pointerEvents: 'none', boxShadow: '0 0 0 1px rgba(0,0,0,0.4) inset', zIndex: '20',
} as Partial<CSSStyleDeclaration>)
el.setAttribute('data-diff-modified', k)
editor.chrome.overlayRoot.appendChild(el)
handles.modified.set(k, el)
}
for (const k of diff.removedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px dashed #ff5b6e', borderRadius: '10px',
pointerEvents: 'none', background: 'rgba(255,91,110,0.08)', zIndex: '20',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#ff8898', fontSize: '11px', fontFamily: 'ui-monospace, monospace',
overflow: 'hidden', boxSizing: 'border-box', whiteSpace: 'nowrap',
} as Partial<CSSStyleDeclaration>)
el.setAttribute('data-diff-removed', k)
const fullLabel = `— ${prevTypes[k] ?? 'node'} removed —`
el.dataset['fullLabel'] = fullLabel
el.textContent = fullLabel
editor.chrome.overlayRoot.appendChild(el)
handles.removed.set(k, el)
}
reposition(editor, handles, afterIds, prevPositions)
return handles
}
interface PaneReport { editor: XenolithEditor; built: Built }
function PrevPane({ onReady }: { onReady: (r: PaneReport) => void }) {
const editor = useEditor()
useEffect(() => {
const built = buildPrev(editor)
onReady({ editor, built })
}, [editor, onReady])
return (
<XenolithPanel position="top-left" style={{ padding: 8 }}>
<div style={{ fontSize: 12, color: 'var(--xeno-text)', fontWeight: 600 }}>BEFORE</div>
</XenolithPanel>
)
}
function NextPane({ onReady, diff }: { onReady: (r: PaneReport) => void; diff: GraphDiff | null }) {
const editor = useEditor()
useEffect(() => {
const built = buildNext(editor)
onReady({ editor, built })
}, [editor, onReady])
return (
<XenolithPanel position="top-left" style={{ padding: 8 }}>
<div style={{ fontSize: 12, color: 'var(--xeno-text)', fontWeight: 600 }}>AFTER</div>
{diff && (
<div style={legendRow}>
<span style={swatch('#39d98a')} /> added {diff.addedNodes.size}
<span style={swatch('#fcb400')} /> modified {diff.modifiedNodes.size}
<span style={swatch('#ff5b6e')} /> removed {diff.removedNodes.size}
</div>
)}
</XenolithPanel>
)
}
export function GraphDiffDemo() {
const prevRef = useRef<PaneReport | null>(null)
const nextRef = useRef<PaneReport | null>(null)
const overlayRef = useRef<DomOverlayHandles | null>(null)
const [diff, setDiff] = useState<GraphDiff | null>(null)
const finalise = useCallback((): void => {
const prev = prevRef.current, next = nextRef.current
if (!prev || !next) return
makeReadOnly(prev.editor)
makeReadOnly(next.editor)
const prevSnap = snapshotWithKeys(prev.editor, prev.built.meta)
const nextSnap = snapshotWithKeys(next.editor, next.built.meta)
const d = diffGraphs(prevSnap, nextSnap)
setDiff(d)
overlayRef.current = paintAfterOverlay(next.editor, d, next.built.ids, prev.built.positions, prev.built.typeByKey)
// Clear any auto-selection that came from inserting nodes — the diff is a read-only view,
// selection rings would be visual noise.
next.editor.selection.clear()
const ed = next.editor
const ids = next.built.ids
const positions = prev.built.positions
ed.on('viewport:changed', () => reposition(ed, overlayRef.current!, ids, positions))
ed.on('node:moved', () => reposition(ed, overlayRef.current!, ids, positions))
;(window as unknown as { __xenoGraphDiff?: { diff: GraphDiff; prev: XenolithEditor; next: XenolithEditor } }).__xenoGraphDiff = {
diff: d, prev: prev.editor, next: next.editor,
}
}, [])
const onPrev = useCallback((r: PaneReport) => { prevRef.current = r; finalise() }, [finalise])
const onNext = useCallback((r: PaneReport) => { nextRef.current = r; finalise() }, [finalise])
useEffect(() => () => {
(window as unknown as { __xenoGraphDiff?: unknown }).__xenoGraphDiff = undefined
if (overlayRef.current) {
for (const [, el] of overlayRef.current.modified) el.remove()
for (const [, el] of overlayRef.current.removed) el.remove()
}
}, [])
return (
<>
<div style={paneStyle('left')}>
<XenolithGraph className="xeno" resizeToWindow={false}>
<PrevPane onReady={onPrev} />
</XenolithGraph>
</div>
<div style={paneStyle('right')}>
<XenolithGraph className="xeno" resizeToWindow={false}>
<NextPane onReady={onNext} diff={diff} />
</XenolithGraph>
</div>
</>
)
}
const paneStyle = (side: 'left' | 'right'): React.CSSProperties => ({
position: 'absolute', top: 0, bottom: 0,
left: side === 'left' ? 0 : '50%', right: side === 'left' ? '50%' : 0,
borderRight: side === 'left' ? '1px solid var(--xeno-border, #222)' : undefined,
overflow: 'hidden',
})
const swatch = (color: string): React.CSSProperties => ({ display: 'inline-block', width: 10, height: 10, borderRadius: 10, background: color, margin: '0 6px 0 10px' })
const legendRow: React.CSSProperties = { fontSize: 11, color: 'var(--xeno-muted)', display: 'flex', alignItems: 'center', marginTop: 6 } <script setup lang="ts">
// Two <XenolithGraph> trees, so each XenolithPanel injects its own editor.
import { onUnmounted, shallowRef, watch } from 'vue'
import { XenolithGraph, XenolithPanel } from '@xenolithengine/graph-vue'
import type { GraphDiff, XenolithEditor } from '@xenolithengine/graph-editor'
import { presentGraphDiff } from '@xenolithengine/demo/graph-diff-demo'
const before = shallowRef<XenolithEditor | null>(null)
const after = shallowRef<XenolithEditor | null>(null)
const diff = shallowRef<GraphDiff | null>(null)
let presentation: { dispose(): void } | null = null
watch([before, after], () => {
if (!before.value || !after.value || presentation) return
const presented = presentGraphDiff(before.value, after.value)
presentation = presented
diff.value = presented.diff
})
onUnmounted(() => { presentation?.dispose(); presentation = null })
function onBefore(editor: XenolithEditor): void { before.value = editor }
function onAfter(editor: XenolithEditor): void { after.value = editor }
</script>
<template>
<div style="position:absolute;inset:0;">
<div style="position:absolute;top:0;bottom:0;left:0;right:50%;overflow:hidden;border-right:1px solid var(--xeno-border,#222);">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="onBefore">
<XenolithPanel position="top-left">
<div style="font-size:12px;font-weight:600;">BEFORE</div>
</XenolithPanel>
</XenolithGraph>
</div>
<div style="position:absolute;top:0;bottom:0;left:50%;right:0;overflow:hidden;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="onAfter">
<XenolithPanel position="top-left">
<div style="font-size:12px;font-weight:600;">AFTER</div>
<div v-if="diff" style="font-size:11px;color:var(--xeno-muted);display:flex;align-items:center;margin-top:6px;">
<span style="display:inline-block;width:10px;height:10px;border-radius:10px;background:#39d98a;margin:0 6px 0 10px;" /> added {{ diff.addedNodes.size }}
<span style="display:inline-block;width:10px;height:10px;border-radius:10px;background:#fcb400;margin:0 6px 0 10px;" /> modified {{ diff.modifiedNodes.size }}
<span style="display:inline-block;width:10px;height:10px;border-radius:10px;background:#ff5b6e;margin:0 6px 0 10px;" /> removed {{ diff.removedNodes.size }}
</div>
</XenolithPanel>
</XenolithGraph>
</div>
</div>
</template> // Two-pane structural diff. The React island keeps its own copy (`window.__xenoGraphDiff`).
// `presentGraphDiff` builds BEFORE and AFTER, locks both editors, and paints the AFTER overlay.
import type { NodeSchema } from '@xenolithengine/graph-core'
import { diffGraphs, type GraphDiff, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
{ type: 'Probe', title: 'Probe', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
interface Box { x: number; y: number; w: number; h: number }
function place(editor: XenolithEditor): void {
editor.view.fitView({ padding: 60 })
}
function wire(editor: XenolithEditor, from: { id: string; pins: { id: string; direction: string }[] }, to: { id: string; pins: { id: string; direction: string }[] }, index: number): void {
const out = from.pins.find((p) => p.direction === 'out')!.id
const inp = to.pins.filter((p) => p.direction === 'in')[index]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: from.id, pin: out }, to: { node: to.id, pin: inp } } as never)
}
interface Built {
ids: Record<string, string>
meta: Record<string, { key: string }>
positions: Record<string, Box>
typeByKey: Record<string, string>
}
function measure(editor: XenolithEditor, keys: Record<string, string>): Record<string, Box> {
const positions: Record<string, Box> = {}
for (const n of editor.getGraphReadonly().nodes) {
const key = keys[String(n.id)]
if (!key) continue
positions[key] = { x: n.position.x, y: n.position.y, w: n.size?.x ?? 200, h: n.size?.y ?? 80 }
}
return positions
}
function buildBefore(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const out = editor.insertNode('Display', { x: 640, y: 60 })!
const dbg = editor.insertNode('Probe', { x: 640, y: 220 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, out, 0)
wire(editor, add, dbg, 0)
place(editor)
const ids = { a: String(a.id), b: String(b.id), add: String(add.id), out: String(out.id), dbg: String(dbg.id) }
const meta = { [a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' }, [out.id]: { key: 'out' }, [dbg.id]: { key: 'dbg' } }
return { ids, meta, positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [out.id]: 'out', [dbg.id]: 'dbg' }), typeByKey: { a: 'Const', b: 'Const', add: 'Add', out: 'Display', dbg: 'Probe' } }
}
function buildAfter(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const extra = editor.insertNode('Const', { x: 320, y: 280 })!
const mul = editor.insertNode('Multiply', { x: 560, y: 160 })!
const out = editor.insertNode('Display', { x: 880, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 7)
editor.setWidgetValue(extra.id, 'value', 4)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, mul, 0)
wire(editor, extra, mul, 1)
wire(editor, mul, out, 0)
place(editor)
const meta = {
[a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' },
[mul.id]: { key: 'mul' }, [extra.id]: { key: 'extra' }, [out.id]: { key: 'out' },
}
return {
ids: { a: String(a.id), b: String(b.id), add: String(add.id), mul: String(mul.id), extra: String(extra.id), out: String(out.id) },
meta,
positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [mul.id]: 'mul', [extra.id]: 'extra', [out.id]: 'out' }),
typeByKey: { a: 'Const', b: 'Const', add: 'Add', mul: 'Multiply', extra: 'Const', out: 'Display' },
}
}
function snapshot(editor: XenolithEditor, meta: Record<string, { key: string }>) {
const snap = editor.getGraphReadonly()
return {
nodes: snap.nodes.map((n) => ({
id: meta[String(n.id)]?.key ?? String(n.id),
type: n.type,
position: { x: n.position.x, y: n.position.y },
state: { ...n.state },
})),
edges: snap.edges.map((e) => {
const fk = meta[String(e.from.node)]?.key ?? String(e.from.node)
const tk = meta[String(e.to.node)]?.key ?? String(e.to.node)
return { id: `${fk}→${tk}`, from: { node: fk, pin: '' }, to: { node: tk, pin: '' } }
}),
}
}
export interface GraphDiffPresentation {
diff: GraphDiff
dispose(): void
}
/** Fill `before` and `after`, then paint added / modified / removed on the after pane. */
export function presentGraphDiff(before: XenolithEditor, after: XenolithEditor): GraphDiffPresentation {
const prev = buildBefore(before)
const next = buildAfter(after)
before.setInteractive(false)
after.setInteractive(false)
before.selection.clear()
after.selection.clear()
const diff = diffGraphs(snapshot(before, prev.meta), snapshot(after, next.meta))
for (const n of after.getGraphReadonly().nodes) after.setNodeStatus(n.id as never, 'idle')
for (const key of diff.addedNodes) {
const id = next.ids[key]
if (id) after.setNodeStatus(id as never, 'ok')
}
const modified = new Map<string, HTMLDivElement>()
const removed = new Map<string, HTMLDivElement>()
for (const key of diff.modifiedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px solid #fcb400', borderRadius: '10px',
pointerEvents: 'none', boxShadow: '0 0 0 1px rgba(0,0,0,0.4) inset', zIndex: '20',
})
after.chrome.overlayRoot.appendChild(el)
modified.set(key, el)
}
for (const key of diff.removedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px dashed #ff5b6e', borderRadius: '10px',
pointerEvents: 'none', background: 'rgba(255,91,110,0.08)', zIndex: '20',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#ff8898', fontFamily: 'ui-monospace, monospace', overflow: 'hidden',
boxSizing: 'border-box', whiteSpace: 'nowrap',
})
el.dataset['fullLabel'] = `— ${prev.typeByKey[key] ?? 'node'} removed —`
el.textContent = el.dataset['fullLabel']
after.chrome.overlayRoot.appendChild(el)
removed.set(key, el)
}
const reposition = (): void => {
const snap = after.getGraphReadonly()
const byId = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [key, el] of modified) {
const n = byId.get(next.ids[key] ?? '')
if (!n) continue
const tl = after.view.worldToScreen(n.position)
const br = after.view.worldToScreen({ x: n.position.x + (n.size?.x ?? 200), y: n.position.y + (n.size?.y ?? 80) })
el.style.left = `${tl.x - 3}px`
el.style.top = `${tl.y - 3}px`
el.style.width = `${br.x - tl.x + 6}px`
el.style.height = `${br.y - tl.y + 6}px`
}
for (const [key, el] of removed) {
const p = prev.positions[key]
if (!p) continue
const tl = after.view.worldToScreen({ x: p.x, y: p.y })
const br = after.view.worldToScreen({ x: p.x + p.w, y: p.y + p.h })
const wPx = br.x - tl.x
el.style.left = `${tl.x}px`
el.style.top = `${tl.y}px`
el.style.width = `${wPx}px`
el.style.height = `${br.y - tl.y}px`
el.style.fontSize = `${Math.max(7, Math.min(12, wPx / 16))}px`
el.textContent = wPx < 60 ? '—' : (el.dataset['fullLabel'] ?? '')
}
}
reposition()
const offs = [
after.on('viewport:changed', reposition),
after.on('node:moved', reposition),
]
after.selection.clear()
return {
diff,
dispose(): void {
for (const off of offs) off()
for (const el of modified.values()) el.remove()
for (const el of removed.values()) el.remove()
},
}
} <script lang="ts">
// One Svelte editor context cannot serve two panes, so the labels are plain DOM.
import { onDestroy } from 'svelte'
import { xenolith } from '@xenolithengine/graph-svelte'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { presentGraphDiff } from '@xenolithengine/demo/graph-diff-demo'
let beforeEd = $state<XenolithEditor | null>(null)
let afterEd = $state<XenolithEditor | null>(null)
let added = $state(0)
let modified = $state(0)
let removed = $state(0)
let presentation: { dispose(): void } | null = null
$effect(() => {
const before = beforeEd
const after = afterEd
if (!before || !after) return
const presented = presentGraphDiff(before, after)
presentation = presented
added = presented.diff.addedNodes.size
modified = presented.diff.modifiedNodes.size
removed = presented.diff.removedNodes.size
return () => { presented.dispose(); if (presentation === presented) presentation = null }
})
onDestroy(() => { presentation?.dispose(); presentation = null })
</script>
<div style="position:absolute;inset:0;">
<div style="position:absolute;top:0;bottom:0;left:0;right:50%;overflow:hidden;border-right:1px solid var(--xeno-border,#222);">
<div use:xenolith={{ resizeToWindow: false }} onready={(e) => { beforeEd = e.detail }} style="position:absolute;inset:0"></div>
<div style="position:absolute;top:12px;left:12px;z-index:5;padding:8px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;font-weight:600;">BEFORE</div>
</div>
<div style="position:absolute;top:0;bottom:0;left:50%;right:0;overflow:hidden;">
<div use:xenolith={{ resizeToWindow: false }} onready={(e) => { afterEd = e.detail }} style="position:absolute;inset:0"></div>
<div style="position:absolute;top:12px;left:12px;z-index:5;padding:8px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<div style="font-size:12px;font-weight:600;">AFTER</div>
{#if beforeEd && afterEd}
<div style="font-size:11px;color:var(--xeno-muted);display:flex;align-items:center;margin-top:6px;">
<span style="display:inline-block;width:10px;height:10px;border-radius:10px;background:#39d98a;margin:0 6px 0 10px;"></span> added {added}
<span style="display:inline-block;width:10px;height:10px;border-radius:10px;background:#fcb400;margin:0 6px 0 10px;"></span> modified {modified}
<span style="display:inline-block;width:10px;height:10px;border-radius:10px;background:#ff5b6e;margin:0 6px 0 10px;"></span> removed {removed}
</div>
{/if}
</div>
</div>
</div> // Two-pane structural diff. The React island keeps its own copy (`window.__xenoGraphDiff`).
// `presentGraphDiff` builds BEFORE and AFTER, locks both editors, and paints the AFTER overlay.
import type { NodeSchema } from '@xenolithengine/graph-core'
import { diffGraphs, type GraphDiff, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
{ type: 'Probe', title: 'Probe', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
interface Box { x: number; y: number; w: number; h: number }
function place(editor: XenolithEditor): void {
editor.view.fitView({ padding: 60 })
}
function wire(editor: XenolithEditor, from: { id: string; pins: { id: string; direction: string }[] }, to: { id: string; pins: { id: string; direction: string }[] }, index: number): void {
const out = from.pins.find((p) => p.direction === 'out')!.id
const inp = to.pins.filter((p) => p.direction === 'in')[index]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: from.id, pin: out }, to: { node: to.id, pin: inp } } as never)
}
interface Built {
ids: Record<string, string>
meta: Record<string, { key: string }>
positions: Record<string, Box>
typeByKey: Record<string, string>
}
function measure(editor: XenolithEditor, keys: Record<string, string>): Record<string, Box> {
const positions: Record<string, Box> = {}
for (const n of editor.getGraphReadonly().nodes) {
const key = keys[String(n.id)]
if (!key) continue
positions[key] = { x: n.position.x, y: n.position.y, w: n.size?.x ?? 200, h: n.size?.y ?? 80 }
}
return positions
}
function buildBefore(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const out = editor.insertNode('Display', { x: 640, y: 60 })!
const dbg = editor.insertNode('Probe', { x: 640, y: 220 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, out, 0)
wire(editor, add, dbg, 0)
place(editor)
const ids = { a: String(a.id), b: String(b.id), add: String(add.id), out: String(out.id), dbg: String(dbg.id) }
const meta = { [a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' }, [out.id]: { key: 'out' }, [dbg.id]: { key: 'dbg' } }
return { ids, meta, positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [out.id]: 'out', [dbg.id]: 'dbg' }), typeByKey: { a: 'Const', b: 'Const', add: 'Add', out: 'Display', dbg: 'Probe' } }
}
function buildAfter(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const extra = editor.insertNode('Const', { x: 320, y: 280 })!
const mul = editor.insertNode('Multiply', { x: 560, y: 160 })!
const out = editor.insertNode('Display', { x: 880, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 7)
editor.setWidgetValue(extra.id, 'value', 4)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, mul, 0)
wire(editor, extra, mul, 1)
wire(editor, mul, out, 0)
place(editor)
const meta = {
[a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' },
[mul.id]: { key: 'mul' }, [extra.id]: { key: 'extra' }, [out.id]: { key: 'out' },
}
return {
ids: { a: String(a.id), b: String(b.id), add: String(add.id), mul: String(mul.id), extra: String(extra.id), out: String(out.id) },
meta,
positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [mul.id]: 'mul', [extra.id]: 'extra', [out.id]: 'out' }),
typeByKey: { a: 'Const', b: 'Const', add: 'Add', mul: 'Multiply', extra: 'Const', out: 'Display' },
}
}
function snapshot(editor: XenolithEditor, meta: Record<string, { key: string }>) {
const snap = editor.getGraphReadonly()
return {
nodes: snap.nodes.map((n) => ({
id: meta[String(n.id)]?.key ?? String(n.id),
type: n.type,
position: { x: n.position.x, y: n.position.y },
state: { ...n.state },
})),
edges: snap.edges.map((e) => {
const fk = meta[String(e.from.node)]?.key ?? String(e.from.node)
const tk = meta[String(e.to.node)]?.key ?? String(e.to.node)
return { id: `${fk}→${tk}`, from: { node: fk, pin: '' }, to: { node: tk, pin: '' } }
}),
}
}
export interface GraphDiffPresentation {
diff: GraphDiff
dispose(): void
}
/** Fill `before` and `after`, then paint added / modified / removed on the after pane. */
export function presentGraphDiff(before: XenolithEditor, after: XenolithEditor): GraphDiffPresentation {
const prev = buildBefore(before)
const next = buildAfter(after)
before.setInteractive(false)
after.setInteractive(false)
before.selection.clear()
after.selection.clear()
const diff = diffGraphs(snapshot(before, prev.meta), snapshot(after, next.meta))
for (const n of after.getGraphReadonly().nodes) after.setNodeStatus(n.id as never, 'idle')
for (const key of diff.addedNodes) {
const id = next.ids[key]
if (id) after.setNodeStatus(id as never, 'ok')
}
const modified = new Map<string, HTMLDivElement>()
const removed = new Map<string, HTMLDivElement>()
for (const key of diff.modifiedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px solid #fcb400', borderRadius: '10px',
pointerEvents: 'none', boxShadow: '0 0 0 1px rgba(0,0,0,0.4) inset', zIndex: '20',
})
after.chrome.overlayRoot.appendChild(el)
modified.set(key, el)
}
for (const key of diff.removedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px dashed #ff5b6e', borderRadius: '10px',
pointerEvents: 'none', background: 'rgba(255,91,110,0.08)', zIndex: '20',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#ff8898', fontFamily: 'ui-monospace, monospace', overflow: 'hidden',
boxSizing: 'border-box', whiteSpace: 'nowrap',
})
el.dataset['fullLabel'] = `— ${prev.typeByKey[key] ?? 'node'} removed —`
el.textContent = el.dataset['fullLabel']
after.chrome.overlayRoot.appendChild(el)
removed.set(key, el)
}
const reposition = (): void => {
const snap = after.getGraphReadonly()
const byId = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [key, el] of modified) {
const n = byId.get(next.ids[key] ?? '')
if (!n) continue
const tl = after.view.worldToScreen(n.position)
const br = after.view.worldToScreen({ x: n.position.x + (n.size?.x ?? 200), y: n.position.y + (n.size?.y ?? 80) })
el.style.left = `${tl.x - 3}px`
el.style.top = `${tl.y - 3}px`
el.style.width = `${br.x - tl.x + 6}px`
el.style.height = `${br.y - tl.y + 6}px`
}
for (const [key, el] of removed) {
const p = prev.positions[key]
if (!p) continue
const tl = after.view.worldToScreen({ x: p.x, y: p.y })
const br = after.view.worldToScreen({ x: p.x + p.w, y: p.y + p.h })
const wPx = br.x - tl.x
el.style.left = `${tl.x}px`
el.style.top = `${tl.y}px`
el.style.width = `${wPx}px`
el.style.height = `${br.y - tl.y}px`
el.style.fontSize = `${Math.max(7, Math.min(12, wPx / 16))}px`
el.textContent = wPx < 60 ? '—' : (el.dataset['fullLabel'] ?? '')
}
}
reposition()
const offs = [
after.on('viewport:changed', reposition),
after.on('node:moved', reposition),
]
after.selection.clear()
return {
diff,
dispose(): void {
for (const off of offs) off()
for (const el of modified.values()) el.remove()
for (const el of removed.values()) el.remove()
},
}
} import { createSignal, onCleanup } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { presentGraphDiff } from '@xenolithengine/demo/graph-diff-demo'
export function GraphDiffDemo() {
let before: XenolithEditor | null = null
let after: XenolithEditor | null = null
let presentation: { dispose(): void } | null = null
const [counts, setCounts] = createSignal<{ added: number; modified: number; removed: number } | null>(null)
const tryPresent = (): void => {
if (!before || !after || presentation) return
const presented = presentGraphDiff(before, after)
presentation = presented
setCounts({
added: presented.diff.addedNodes.size,
modified: presented.diff.modifiedNodes.size,
removed: presented.diff.removedNodes.size,
})
}
onCleanup(() => { presentation?.dispose(); presentation = null })
const swatch = (color: string): string => `display:inline-block;width:10px;height:10px;border-radius:10px;background:${color};margin:0 6px 0 10px;`
const label = 'position:absolute;top:12px;left:12px;z-index:5;padding:8px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;'
return (
<div style="position:absolute;inset:0">
<div style="position:absolute;top:0;bottom:0;left:0;right:50%;overflow:hidden;border-right:1px solid var(--xeno-border,#222);">
<div use:xenolith={{ resizeToWindow: false }} on:ready={(e) => { before = e.detail; tryPresent() }} style="position:absolute;inset:0" />
<div style={label + 'font-weight:600;'}>BEFORE</div>
</div>
<div style="position:absolute;top:0;bottom:0;left:50%;right:0;overflow:hidden;">
<div use:xenolith={{ resizeToWindow: false }} on:ready={(e) => { after = e.detail; tryPresent() }} style="position:absolute;inset:0" />
<div style={label}>
<div style="font-size:12px;font-weight:600;">AFTER</div>
{counts() && (
<div style="font-size:11px;color:var(--xeno-muted);display:flex;align-items:center;margin-top:6px;">
<span style={swatch('#39d98a')} /> added {counts()!.added}
<span style={swatch('#fcb400')} /> modified {counts()!.modified}
<span style={swatch('#ff5b6e')} /> removed {counts()!.removed}
</div>
)}
</div>
</div>
</div>
)
} // Two-pane structural diff. The React island keeps its own copy (`window.__xenoGraphDiff`).
// `presentGraphDiff` builds BEFORE and AFTER, locks both editors, and paints the AFTER overlay.
import type { NodeSchema } from '@xenolithengine/graph-core'
import { diffGraphs, type GraphDiff, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
{ type: 'Probe', title: 'Probe', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
interface Box { x: number; y: number; w: number; h: number }
function place(editor: XenolithEditor): void {
editor.view.fitView({ padding: 60 })
}
function wire(editor: XenolithEditor, from: { id: string; pins: { id: string; direction: string }[] }, to: { id: string; pins: { id: string; direction: string }[] }, index: number): void {
const out = from.pins.find((p) => p.direction === 'out')!.id
const inp = to.pins.filter((p) => p.direction === 'in')[index]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: from.id, pin: out }, to: { node: to.id, pin: inp } } as never)
}
interface Built {
ids: Record<string, string>
meta: Record<string, { key: string }>
positions: Record<string, Box>
typeByKey: Record<string, string>
}
function measure(editor: XenolithEditor, keys: Record<string, string>): Record<string, Box> {
const positions: Record<string, Box> = {}
for (const n of editor.getGraphReadonly().nodes) {
const key = keys[String(n.id)]
if (!key) continue
positions[key] = { x: n.position.x, y: n.position.y, w: n.size?.x ?? 200, h: n.size?.y ?? 80 }
}
return positions
}
function buildBefore(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const out = editor.insertNode('Display', { x: 640, y: 60 })!
const dbg = editor.insertNode('Probe', { x: 640, y: 220 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, out, 0)
wire(editor, add, dbg, 0)
place(editor)
const ids = { a: String(a.id), b: String(b.id), add: String(add.id), out: String(out.id), dbg: String(dbg.id) }
const meta = { [a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' }, [out.id]: { key: 'out' }, [dbg.id]: { key: 'dbg' } }
return { ids, meta, positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [out.id]: 'out', [dbg.id]: 'dbg' }), typeByKey: { a: 'Const', b: 'Const', add: 'Add', out: 'Display', dbg: 'Probe' } }
}
function buildAfter(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const extra = editor.insertNode('Const', { x: 320, y: 280 })!
const mul = editor.insertNode('Multiply', { x: 560, y: 160 })!
const out = editor.insertNode('Display', { x: 880, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 7)
editor.setWidgetValue(extra.id, 'value', 4)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, mul, 0)
wire(editor, extra, mul, 1)
wire(editor, mul, out, 0)
place(editor)
const meta = {
[a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' },
[mul.id]: { key: 'mul' }, [extra.id]: { key: 'extra' }, [out.id]: { key: 'out' },
}
return {
ids: { a: String(a.id), b: String(b.id), add: String(add.id), mul: String(mul.id), extra: String(extra.id), out: String(out.id) },
meta,
positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [mul.id]: 'mul', [extra.id]: 'extra', [out.id]: 'out' }),
typeByKey: { a: 'Const', b: 'Const', add: 'Add', mul: 'Multiply', extra: 'Const', out: 'Display' },
}
}
function snapshot(editor: XenolithEditor, meta: Record<string, { key: string }>) {
const snap = editor.getGraphReadonly()
return {
nodes: snap.nodes.map((n) => ({
id: meta[String(n.id)]?.key ?? String(n.id),
type: n.type,
position: { x: n.position.x, y: n.position.y },
state: { ...n.state },
})),
edges: snap.edges.map((e) => {
const fk = meta[String(e.from.node)]?.key ?? String(e.from.node)
const tk = meta[String(e.to.node)]?.key ?? String(e.to.node)
return { id: `${fk}→${tk}`, from: { node: fk, pin: '' }, to: { node: tk, pin: '' } }
}),
}
}
export interface GraphDiffPresentation {
diff: GraphDiff
dispose(): void
}
/** Fill `before` and `after`, then paint added / modified / removed on the after pane. */
export function presentGraphDiff(before: XenolithEditor, after: XenolithEditor): GraphDiffPresentation {
const prev = buildBefore(before)
const next = buildAfter(after)
before.setInteractive(false)
after.setInteractive(false)
before.selection.clear()
after.selection.clear()
const diff = diffGraphs(snapshot(before, prev.meta), snapshot(after, next.meta))
for (const n of after.getGraphReadonly().nodes) after.setNodeStatus(n.id as never, 'idle')
for (const key of diff.addedNodes) {
const id = next.ids[key]
if (id) after.setNodeStatus(id as never, 'ok')
}
const modified = new Map<string, HTMLDivElement>()
const removed = new Map<string, HTMLDivElement>()
for (const key of diff.modifiedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px solid #fcb400', borderRadius: '10px',
pointerEvents: 'none', boxShadow: '0 0 0 1px rgba(0,0,0,0.4) inset', zIndex: '20',
})
after.chrome.overlayRoot.appendChild(el)
modified.set(key, el)
}
for (const key of diff.removedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px dashed #ff5b6e', borderRadius: '10px',
pointerEvents: 'none', background: 'rgba(255,91,110,0.08)', zIndex: '20',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#ff8898', fontFamily: 'ui-monospace, monospace', overflow: 'hidden',
boxSizing: 'border-box', whiteSpace: 'nowrap',
})
el.dataset['fullLabel'] = `— ${prev.typeByKey[key] ?? 'node'} removed —`
el.textContent = el.dataset['fullLabel']
after.chrome.overlayRoot.appendChild(el)
removed.set(key, el)
}
const reposition = (): void => {
const snap = after.getGraphReadonly()
const byId = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [key, el] of modified) {
const n = byId.get(next.ids[key] ?? '')
if (!n) continue
const tl = after.view.worldToScreen(n.position)
const br = after.view.worldToScreen({ x: n.position.x + (n.size?.x ?? 200), y: n.position.y + (n.size?.y ?? 80) })
el.style.left = `${tl.x - 3}px`
el.style.top = `${tl.y - 3}px`
el.style.width = `${br.x - tl.x + 6}px`
el.style.height = `${br.y - tl.y + 6}px`
}
for (const [key, el] of removed) {
const p = prev.positions[key]
if (!p) continue
const tl = after.view.worldToScreen({ x: p.x, y: p.y })
const br = after.view.worldToScreen({ x: p.x + p.w, y: p.y + p.h })
const wPx = br.x - tl.x
el.style.left = `${tl.x}px`
el.style.top = `${tl.y}px`
el.style.width = `${wPx}px`
el.style.height = `${br.y - tl.y}px`
el.style.fontSize = `${Math.max(7, Math.min(12, wPx / 16))}px`
el.textContent = wPx < 60 ? '—' : (el.dataset['fullLabel'] ?? '')
}
}
reposition()
const offs = [
after.on('viewport:changed', reposition),
after.on('node:moved', reposition),
]
after.selection.clear()
return {
diff,
dispose(): void {
for (const off of offs) off()
for (const el of modified.values()) el.remove()
for (const el of removed.values()) el.remove()
},
}
} import { Component, OnDestroy, signal } from '@angular/core'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { presentGraphDiff } from '@xenolithengine/demo/graph-diff-demo'
import { GraphDiffPaneComponent } from './GraphDiffPane.component'
@Component({
selector: 'graph-diff-demo',
standalone: true,
imports: [GraphDiffPaneComponent],
template: `
<div style="position:absolute;inset:0;">
<graph-diff-pane side="left" label="BEFORE" (ready)="onBefore($event)" />
<graph-diff-pane side="right" label="AFTER" [counts]="counts()" (ready)="onAfter($event)" />
</div>
`,
})
export class GraphDiffDemoComponent implements OnDestroy {
private before: XenolithEditor | null = null
private after: XenolithEditor | null = null
private presentation: { dispose(): void } | null = null
counts = signal<{ added: number; modified: number; removed: number } | null>(null)
onBefore(editor: XenolithEditor): void { this.before = editor; this.tryPresent() }
onAfter(editor: XenolithEditor): void { this.after = editor; this.tryPresent() }
private tryPresent(): void {
if (!this.before || !this.after || this.presentation) return
const presented = presentGraphDiff(this.before, this.after)
this.presentation = presented
this.counts.set({
added: presented.diff.addedNodes.size,
modified: presented.diff.modifiedNodes.size,
removed: presented.diff.removedNodes.size,
})
}
ngOnDestroy(): void { this.presentation?.dispose(); this.presentation = null }
} import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnDestroy, Output, inject, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
@Component({
selector: 'graph-diff-pane',
standalone: true,
providers: [XenolithGraphService],
template: `
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="label">
<div class="title">{{ label }}</div>
@if (counts) {
<div class="legend">
<span class="swatch" style="background:#39d98a"></span> added {{ counts.added }}
<span class="swatch" style="background:#fcb400"></span> modified {{ counts.modified }}
<span class="swatch" style="background:#ff5b6e"></span> removed {{ counts.removed }}
</div>
}
</div>
`,
styles: [`
:host { display:block; position:absolute; top:0; bottom:0; overflow:hidden; }
:host(.left) { left:0; right:50%; border-right:1px solid var(--xeno-border,#222); }
:host(.right) { left:50%; right:0; }
.label { position:absolute; top:12px; left:12px; z-index:5; padding:8px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:10px; color:var(--xeno-text,#cfcfcf);
font:13px system-ui,sans-serif; }
.title { font-size:12px; font-weight:600; }
.legend { font-size:11px; color:var(--xeno-muted,#9a9a9a); display:flex; align-items:center; margin-top:6px; }
.swatch { display:inline-block; width:10px; height:10px; border-radius:10px; margin:0 6px 0 10px; }
`],
host: { '[class.left]': 'side === "left"', '[class.right]': 'side === "right"' },
})
export class GraphDiffPaneComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
@Input() side: 'left' | 'right' = 'left'
@Input() label = ''
@Input() counts: { added: number; modified: number; removed: number } | null = null
@Output() ready = new EventEmitter<XenolithEditor>()
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.ready.emit(editor)
}
ngOnDestroy(): void { this.graph.destroy() }
} // Two-pane structural diff. The React island keeps its own copy (`window.__xenoGraphDiff`).
// `presentGraphDiff` builds BEFORE and AFTER, locks both editors, and paints the AFTER overlay.
import type { NodeSchema } from '@xenolithengine/graph-core'
import { diffGraphs, type GraphDiff, type XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Const', title: 'Const', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'number', label: 'Out' }],
widgets: [{ id: 'value', key: 'value', type: 'number', label: 'value', min: -999, max: 999, step: 1 }] },
{ type: 'Add', title: 'Add', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'sum' },
] },
{ type: 'Multiply', title: 'Multiply', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'number', label: 'a' },
{ kind: 'data', direction: 'in', type: 'number', label: 'b' },
{ kind: 'data', direction: 'out', type: 'number', label: 'product' },
] },
{ type: 'Display', title: 'Display', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
{ type: 'Probe', title: 'Probe', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'number', label: 'In' }] },
]
interface Box { x: number; y: number; w: number; h: number }
function place(editor: XenolithEditor): void {
editor.view.fitView({ padding: 60 })
}
function wire(editor: XenolithEditor, from: { id: string; pins: { id: string; direction: string }[] }, to: { id: string; pins: { id: string; direction: string }[] }, index: number): void {
const out = from.pins.find((p) => p.direction === 'out')!.id
const inp = to.pins.filter((p) => p.direction === 'in')[index]!.id
editor.addEdge({ id: crypto.randomUUID(), from: { node: from.id, pin: out }, to: { node: to.id, pin: inp } } as never)
}
interface Built {
ids: Record<string, string>
meta: Record<string, { key: string }>
positions: Record<string, Box>
typeByKey: Record<string, string>
}
function measure(editor: XenolithEditor, keys: Record<string, string>): Record<string, Box> {
const positions: Record<string, Box> = {}
for (const n of editor.getGraphReadonly().nodes) {
const key = keys[String(n.id)]
if (!key) continue
positions[key] = { x: n.position.x, y: n.position.y, w: n.size?.x ?? 200, h: n.size?.y ?? 80 }
}
return positions
}
function buildBefore(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const out = editor.insertNode('Display', { x: 640, y: 60 })!
const dbg = editor.insertNode('Probe', { x: 640, y: 220 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 3)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, out, 0)
wire(editor, add, dbg, 0)
place(editor)
const ids = { a: String(a.id), b: String(b.id), add: String(add.id), out: String(out.id), dbg: String(dbg.id) }
const meta = { [a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' }, [out.id]: { key: 'out' }, [dbg.id]: { key: 'dbg' } }
return { ids, meta, positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [out.id]: 'out', [dbg.id]: 'dbg' }), typeByKey: { a: 'Const', b: 'Const', add: 'Add', out: 'Display', dbg: 'Probe' } }
}
function buildAfter(editor: XenolithEditor): Built {
editor.clear()
for (const s of SCHEMAS) editor.registry.register(s)
const a = editor.insertNode('Const', { x: 0, y: 0 })!
const b = editor.insertNode('Const', { x: 0, y: 140 })!
const add = editor.insertNode('Add', { x: 320, y: 60 })!
const extra = editor.insertNode('Const', { x: 320, y: 280 })!
const mul = editor.insertNode('Multiply', { x: 560, y: 160 })!
const out = editor.insertNode('Display', { x: 880, y: 160 })!
editor.setWidgetValue(a.id, 'value', 2)
editor.setWidgetValue(b.id, 'value', 7)
editor.setWidgetValue(extra.id, 'value', 4)
wire(editor, a, add, 0)
wire(editor, b, add, 1)
wire(editor, add, mul, 0)
wire(editor, extra, mul, 1)
wire(editor, mul, out, 0)
place(editor)
const meta = {
[a.id]: { key: 'a' }, [b.id]: { key: 'b' }, [add.id]: { key: 'add' },
[mul.id]: { key: 'mul' }, [extra.id]: { key: 'extra' }, [out.id]: { key: 'out' },
}
return {
ids: { a: String(a.id), b: String(b.id), add: String(add.id), mul: String(mul.id), extra: String(extra.id), out: String(out.id) },
meta,
positions: measure(editor, { [a.id]: 'a', [b.id]: 'b', [add.id]: 'add', [mul.id]: 'mul', [extra.id]: 'extra', [out.id]: 'out' }),
typeByKey: { a: 'Const', b: 'Const', add: 'Add', mul: 'Multiply', extra: 'Const', out: 'Display' },
}
}
function snapshot(editor: XenolithEditor, meta: Record<string, { key: string }>) {
const snap = editor.getGraphReadonly()
return {
nodes: snap.nodes.map((n) => ({
id: meta[String(n.id)]?.key ?? String(n.id),
type: n.type,
position: { x: n.position.x, y: n.position.y },
state: { ...n.state },
})),
edges: snap.edges.map((e) => {
const fk = meta[String(e.from.node)]?.key ?? String(e.from.node)
const tk = meta[String(e.to.node)]?.key ?? String(e.to.node)
return { id: `${fk}→${tk}`, from: { node: fk, pin: '' }, to: { node: tk, pin: '' } }
}),
}
}
export interface GraphDiffPresentation {
diff: GraphDiff
dispose(): void
}
/** Fill `before` and `after`, then paint added / modified / removed on the after pane. */
export function presentGraphDiff(before: XenolithEditor, after: XenolithEditor): GraphDiffPresentation {
const prev = buildBefore(before)
const next = buildAfter(after)
before.setInteractive(false)
after.setInteractive(false)
before.selection.clear()
after.selection.clear()
const diff = diffGraphs(snapshot(before, prev.meta), snapshot(after, next.meta))
for (const n of after.getGraphReadonly().nodes) after.setNodeStatus(n.id as never, 'idle')
for (const key of diff.addedNodes) {
const id = next.ids[key]
if (id) after.setNodeStatus(id as never, 'ok')
}
const modified = new Map<string, HTMLDivElement>()
const removed = new Map<string, HTMLDivElement>()
for (const key of diff.modifiedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px solid #fcb400', borderRadius: '10px',
pointerEvents: 'none', boxShadow: '0 0 0 1px rgba(0,0,0,0.4) inset', zIndex: '20',
})
after.chrome.overlayRoot.appendChild(el)
modified.set(key, el)
}
for (const key of diff.removedNodes) {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', border: '2px dashed #ff5b6e', borderRadius: '10px',
pointerEvents: 'none', background: 'rgba(255,91,110,0.08)', zIndex: '20',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#ff8898', fontFamily: 'ui-monospace, monospace', overflow: 'hidden',
boxSizing: 'border-box', whiteSpace: 'nowrap',
})
el.dataset['fullLabel'] = `— ${prev.typeByKey[key] ?? 'node'} removed —`
el.textContent = el.dataset['fullLabel']
after.chrome.overlayRoot.appendChild(el)
removed.set(key, el)
}
const reposition = (): void => {
const snap = after.getGraphReadonly()
const byId = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [key, el] of modified) {
const n = byId.get(next.ids[key] ?? '')
if (!n) continue
const tl = after.view.worldToScreen(n.position)
const br = after.view.worldToScreen({ x: n.position.x + (n.size?.x ?? 200), y: n.position.y + (n.size?.y ?? 80) })
el.style.left = `${tl.x - 3}px`
el.style.top = `${tl.y - 3}px`
el.style.width = `${br.x - tl.x + 6}px`
el.style.height = `${br.y - tl.y + 6}px`
}
for (const [key, el] of removed) {
const p = prev.positions[key]
if (!p) continue
const tl = after.view.worldToScreen({ x: p.x, y: p.y })
const br = after.view.worldToScreen({ x: p.x + p.w, y: p.y + p.h })
const wPx = br.x - tl.x
el.style.left = `${tl.x}px`
el.style.top = `${tl.y}px`
el.style.width = `${wPx}px`
el.style.height = `${br.y - tl.y}px`
el.style.fontSize = `${Math.max(7, Math.min(12, wPx / 16))}px`
el.textContent = wPx < 60 ? '—' : (el.dataset['fullLabel'] ?? '')
}
}
reposition()
const offs = [
after.on('viewport:changed', reposition),
after.on('node:moved', reposition),
]
after.selection.clear()
return {
diff,
dispose(): void {
for (const off of offs) off()
for (const el of modified.values()) el.remove()
for (const el of removed.values()) el.remove()
},
}
}