Auto-layout (dagre)
A messy 14-node DAG snaps into a clean layered layout. Toggle LR/TB; Cmd+Z restores the mess in a single undo step.
// Vanilla mount for the Auto-Layout example. No React, no framework — just `XenolithEditor.init`,
// the shared scene builder, and two DOM buttons in the editor's overlay root for arrange / direction.
import { XenolithEditor } from '@xenolithengine/graph-editor'
import { buildAutoLayout } from '@xenolithengine/demo/auto-layout'
export async function mount(target: HTMLElement): Promise<() => void> {
// resizeToWindow:false — this demo is embedded in a fixed-size DemoFrame, not full-window.
// Default `true` would size the canvas to window dimensions, overflowing the container and
// visually colliding with the chip switcher below (`.dfr-bar`).
const editor = await XenolithEditor.init(target, { minimap: false, resizeToWindow: false })
const scene = buildAutoLayout(editor)
type Direction = 'LR' | 'TB'
let dir: Direction = 'LR'
let busy = false
// Tiny in-editor panel — themed by --xeno-* tokens like every other panel.
const panel = document.createElement('div')
panel.setAttribute('data-xeno-panel', '')
// pointer-events:auto opts back in — overlayRoot has pointer-events:none so the editor canvas
// underneath stays interactive; every chrome element that needs clicks must opt in explicitly.
panel.style.cssText = 'position:absolute;pointer-events:auto;top:12px;left:12px;display:flex;gap:6px;padding:6px;background:var(--xeno-panel,#1d1d1d);border:1px solid var(--xeno-border,#333);border-radius:8px;font:12px Inter,system-ui,sans-serif;'
const mkBtn = (label: string, primary: () => boolean, onClick: () => Promise<void>): HTMLButtonElement => {
const b = document.createElement('button')
b.textContent = label
b.addEventListener('click', () => { void onClick() })
const paint = (): void => {
const isP = primary()
b.style.cssText = `padding:6px 12px;font-size:12px;border-radius:6px;cursor:pointer;border:1px solid ${isP ? 'var(--xeno-accent,#FCB400)' : 'var(--xeno-border,#333)'};background:${isP ? 'var(--xeno-accent,#FCB400)' : 'transparent'};color:${isP ? 'var(--xeno-canvas,#111)' : 'var(--xeno-text,#cfcfcf)'};`
}
paint()
;(b as HTMLButtonElement & { _repaint: () => void })._repaint = paint
return b
}
const repaintAll = (): void => { for (const c of panel.children) (c as HTMLButtonElement & { _repaint?: () => void })._repaint?.() }
const run = async (next: Direction = dir): Promise<void> => {
if (busy) return
busy = true; runBtn.textContent = 'Arranging…'
try { await scene.arrange({ direction: next }) } finally { busy = false; runBtn.textContent = 'Auto-arrange' }
}
const flip = async (next: Direction): Promise<void> => { dir = next; repaintAll(); await run(next) }
const runBtn = mkBtn('Auto-arrange', () => true, () => run())
const lrBtn = mkBtn('LR', () => dir === 'LR', () => flip('LR'))
const tbBtn = mkBtn('TB', () => dir === 'TB', () => flip('TB'))
panel.append(runBtn, lrBtn, tbBtn)
editor.overlayRoot.appendChild(panel)
return () => { panel.remove(); editor.destroy() }
} // Auto-layout showcase: ~14 ill-placed nodes wired as a fan-in/fan-out DAG. Calling `arrange()`
// runs the dagre engine and applies positions in a single transaction (one undo restores the
// messy original). Reused by both the React demo wrapper and the vanilla mounter — no framework
// in the core; same data + same setup + same plugin.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { autoLayoutPlugin, type AutoLayoutPlugin, type LayoutOpts } from '@xenolithengine/graph-plugin-autolayout'
import { dagreEngine } from '@xenolithengine/graph-plugin-autolayout/dagre'
import type { Node, Edge, NodeId, PinId, EdgeId } from '@xenolithengine/graph-core'
const NODES: { id: string; title: string; x: number; y: number }[] = [
{ id: 'in', title: 'Input', x: 480, y: 60 },
{ id: 'a1', title: 'Tokenize', x: 80, y: 320 },
{ id: 'a2', title: 'Embed', x: 730, y: 410 },
{ id: 'a3', title: 'Encode', x: 250, y: 80 },
{ id: 'b1', title: 'Attention', x: 600, y: 220 },
{ id: 'b2', title: 'Norm', x: 30, y: 200 },
{ id: 'b3', title: 'FFN', x: 410, y: 480 },
{ id: 'c1', title: 'Residual', x: 770, y: 100 },
{ id: 'c2', title: 'Merge', x: 120, y: 480 },
{ id: 'd1', title: 'Project', x: 340, y: 260 },
{ id: 'd2', title: 'Logits', x: 540, y: 350 },
{ id: 'd3', title: 'Sample', x: 200, y: 410 },
{ id: 'd4', title: 'Decode', x: 640, y: 60 },
{ id: 'out', title: 'Output', x: 90, y: 130 },
]
const EDGES: [string, string][] = [
['in', 'a1'], ['in', 'a3'], ['in', 'a2'],
['a1', 'b2'], ['a3', 'b1'], ['a2', 'b1'], ['a2', 'b3'],
['b1', 'c1'], ['b1', 'd1'], ['b2', 'c2'], ['b3', 'd1'], ['b3', 'd2'],
['c1', 'd4'], ['c2', 'd3'], ['d1', 'd2'], ['d2', 'd3'],
['d3', 'out'], ['d4', 'out'],
]
export interface AutoLayoutScene {
/** Installed plugin instance — call `plugin.arrange(opts)` to lay out the graph. */
plugin: AutoLayoutPlugin
/** Convenience: run arrange + refit the viewport. */
arrange: (opts?: LayoutOpts) => Promise<void>
}
// Per-editor plugin handle. The React panel reads this via `runAutoLayout(editor, opts)` so it
// doesn't have to keep a scene object around — `setupAutoLayout` is called in `onReady` and the
// plugin is stashed here for later operations.
const PLUGINS = new WeakMap<XenolithEditor, AutoLayoutPlugin>()
/** Idempotent setup: install the plugin, load the demo graph, fit the view. Safe to pass directly
* to `<XenolithGraph onReady>` — synchronous, no first-paint flicker. */
export function setupAutoLayout(editor: XenolithEditor): void {
const plugin = autoLayoutPlugin({
engine: dagreEngine(),
defaults: { direction: 'LR', spacing: { node: 40, layer: 90 }, animate: { durationMs: 600 } },
})
editor.use(plugin)
PLUGINS.set(editor, plugin)
loadAutoLayoutGraph(editor)
}
/** Run the layout engine in the requested direction and refit. No-op if `setupAutoLayout` hasn't
* run yet on this editor. */
export async function runAutoLayout(editor: XenolithEditor, opts?: LayoutOpts): Promise<void> {
const plugin = PLUGINS.get(editor)
if (!plugin) return
await plugin.arrange(opts)
editor.fitView({ padding: 56, maxZoom: 1 })
}
function loadAutoLayoutGraph(editor: XenolithEditor): void {
const nodes: Node[] = NODES.map((n) => ({
id: n.id as NodeId, type: 'Step', position: { x: n.x, y: n.y }, size: { x: 160, y: 64 },
state: {},
render: { title: n.title, category: 'logic' } as never,
pins: [
{ id: `${n.id}_in` as PinId, kind: 'data', direction: 'in', type: 'float', multiple: false, label: 'in' },
{ id: `${n.id}_out` as PinId, kind: 'data', direction: 'out', type: 'float', multiple: true, label: 'out' },
],
}))
const edges: Edge[] = EDGES.map(([from, to], i) => ({
id: `e${i}` as EdgeId,
from: { node: from as NodeId, pin: `${from}_out` as PinId },
to: { node: to as NodeId, pin: `${to}_in` as PinId },
}))
editor.loadJSON({ version: 'xenolith.v1', nodes, edges })
editor.fitView({ padding: 56, maxZoom: 1 })
}
/** @deprecated Prefer `setupAutoLayout(editor)` + `runAutoLayout(editor, opts)`. Kept for the
* vanilla examples; will be removed in a follow-up. */
export function buildAutoLayout(editor: XenolithEditor): AutoLayoutScene {
setupAutoLayout(editor)
const plugin = PLUGINS.get(editor)!
return {
plugin,
arrange: (opts) => runAutoLayout(editor, opts),
}
} import { useState } from 'react'
import { XenolithGraph, XenolithPanel, useEditor } from '@xenolithengine/graph-react'
import { setupAutoLayout, runAutoLayout } from '@xenolithengine/demo/auto-layout'
import { DemoStage } from '../Layout.js'
type Direction = 'LR' | 'TB'
// Canon: direction lives in the panel (only the panel needs it), the panel calls runAutoLayout
// against `useEditor()` directly. No scene handle, no lifted state, no refs.
function AutoLayoutPanel() {
const editor = useEditor()
const [dir, setDir] = useState<Direction>('LR')
const [busy, setBusy] = useState(false)
const arrange = async (next: Direction = dir): Promise<void> => {
if (busy) return
setBusy(true)
try { await runAutoLayout(editor, { direction: next }) } finally { setBusy(false) }
}
const flip = async (next: Direction): Promise<void> => {
setDir(next)
await arrange(next)
}
return (
<XenolithPanel position="top-left" style={{ display: 'flex', gap: 6, padding: 6 }}>
<button onClick={() => arrange()} disabled={busy} style={btnStyle(true)}>
{busy ? 'Arranging…' : 'Auto-arrange'}
</button>
<button onClick={() => flip('LR')} disabled={busy} style={btnStyle(dir === 'LR')}>LR</button>
<button onClick={() => flip('TB')} disabled={busy} style={btnStyle(dir === 'TB')}>TB</button>
</XenolithPanel>
)
}
/** Island: Auto-Layout. */
export function AutoLayoutDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={setupAutoLayout}>
<AutoLayoutPanel />
</XenolithGraph>
</DemoStage>
)
}
const btnStyle = (primary: boolean): React.CSSProperties => ({
padding: '6px 12px',
fontSize: 12,
borderRadius: 6,
border: `1px solid ${primary ? 'var(--xeno-accent, #FCB400)' : 'var(--xeno-border, #333)'}`,
background: primary ? 'var(--xeno-accent, #FCB400)' : 'var(--xeno-panel, #1d1d1d)',
color: primary ? 'var(--xeno-canvas, #111)' : 'var(--xeno-text, #cfcfcf)',
cursor: 'pointer',
}) // Auto-layout showcase: ~14 ill-placed nodes wired as a fan-in/fan-out DAG. Calling `arrange()`
// runs the dagre engine and applies positions in a single transaction (one undo restores the
// messy original). Reused by both the React demo wrapper and the vanilla mounter — no framework
// in the core; same data + same setup + same plugin.
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { autoLayoutPlugin, type AutoLayoutPlugin, type LayoutOpts } from '@xenolithengine/graph-plugin-autolayout'
import { dagreEngine } from '@xenolithengine/graph-plugin-autolayout/dagre'
import type { Node, Edge, NodeId, PinId, EdgeId } from '@xenolithengine/graph-core'
const NODES: { id: string; title: string; x: number; y: number }[] = [
{ id: 'in', title: 'Input', x: 480, y: 60 },
{ id: 'a1', title: 'Tokenize', x: 80, y: 320 },
{ id: 'a2', title: 'Embed', x: 730, y: 410 },
{ id: 'a3', title: 'Encode', x: 250, y: 80 },
{ id: 'b1', title: 'Attention', x: 600, y: 220 },
{ id: 'b2', title: 'Norm', x: 30, y: 200 },
{ id: 'b3', title: 'FFN', x: 410, y: 480 },
{ id: 'c1', title: 'Residual', x: 770, y: 100 },
{ id: 'c2', title: 'Merge', x: 120, y: 480 },
{ id: 'd1', title: 'Project', x: 340, y: 260 },
{ id: 'd2', title: 'Logits', x: 540, y: 350 },
{ id: 'd3', title: 'Sample', x: 200, y: 410 },
{ id: 'd4', title: 'Decode', x: 640, y: 60 },
{ id: 'out', title: 'Output', x: 90, y: 130 },
]
const EDGES: [string, string][] = [
['in', 'a1'], ['in', 'a3'], ['in', 'a2'],
['a1', 'b2'], ['a3', 'b1'], ['a2', 'b1'], ['a2', 'b3'],
['b1', 'c1'], ['b1', 'd1'], ['b2', 'c2'], ['b3', 'd1'], ['b3', 'd2'],
['c1', 'd4'], ['c2', 'd3'], ['d1', 'd2'], ['d2', 'd3'],
['d3', 'out'], ['d4', 'out'],
]
export interface AutoLayoutScene {
/** Installed plugin instance — call `plugin.arrange(opts)` to lay out the graph. */
plugin: AutoLayoutPlugin
/** Convenience: run arrange + refit the viewport. */
arrange: (opts?: LayoutOpts) => Promise<void>
}
// Per-editor plugin handle. The React panel reads this via `runAutoLayout(editor, opts)` so it
// doesn't have to keep a scene object around — `setupAutoLayout` is called in `onReady` and the
// plugin is stashed here for later operations.
const PLUGINS = new WeakMap<XenolithEditor, AutoLayoutPlugin>()
/** Idempotent setup: install the plugin, load the demo graph, fit the view. Safe to pass directly
* to `<XenolithGraph onReady>` — synchronous, no first-paint flicker. */
export function setupAutoLayout(editor: XenolithEditor): void {
const plugin = autoLayoutPlugin({
engine: dagreEngine(),
defaults: { direction: 'LR', spacing: { node: 40, layer: 90 }, animate: { durationMs: 600 } },
})
editor.use(plugin)
PLUGINS.set(editor, plugin)
loadAutoLayoutGraph(editor)
}
/** Run the layout engine in the requested direction and refit. No-op if `setupAutoLayout` hasn't
* run yet on this editor. */
export async function runAutoLayout(editor: XenolithEditor, opts?: LayoutOpts): Promise<void> {
const plugin = PLUGINS.get(editor)
if (!plugin) return
await plugin.arrange(opts)
editor.fitView({ padding: 56, maxZoom: 1 })
}
function loadAutoLayoutGraph(editor: XenolithEditor): void {
const nodes: Node[] = NODES.map((n) => ({
id: n.id as NodeId, type: 'Step', position: { x: n.x, y: n.y }, size: { x: 160, y: 64 },
state: {},
render: { title: n.title, category: 'logic' } as never,
pins: [
{ id: `${n.id}_in` as PinId, kind: 'data', direction: 'in', type: 'float', multiple: false, label: 'in' },
{ id: `${n.id}_out` as PinId, kind: 'data', direction: 'out', type: 'float', multiple: true, label: 'out' },
],
}))
const edges: Edge[] = EDGES.map(([from, to], i) => ({
id: `e${i}` as EdgeId,
from: { node: from as NodeId, pin: `${from}_out` as PinId },
to: { node: to as NodeId, pin: `${to}_in` as PinId },
}))
editor.loadJSON({ version: 'xenolith.v1', nodes, edges })
editor.fitView({ padding: 56, maxZoom: 1 })
}
/** @deprecated Prefer `setupAutoLayout(editor)` + `runAutoLayout(editor, opts)`. Kept for the
* vanilla examples; will be removed in a follow-up. */
export function buildAutoLayout(editor: XenolithEditor): AutoLayoutScene {
setupAutoLayout(editor)
const plugin = PLUGINS.get(editor)!
return {
plugin,
arrange: (opts) => runAutoLayout(editor, opts),
}
}