Stress test (1000s of nodes)
Generate hundreds → thousands of WebGL nodes wired into a chain. Live FPS + node-count overlay top-right; the panel counter is driven by the reactive `useNodes()` hook. Zoom floor dropped to 5% so 10k+ nodes fit on one screen.
import { XenolithGraph, XenolithPanel, XenolithButton, useEditor, useNodes } from '@xenolithengine/graph-react'
import { setupStressTest, addStressNodes } from '@xenolithengine/demo/stress-test'
import { DemoStage } from '../Layout.js'
// Perf flex: hundreds–thousands of WebGL nodes, pan/zoom at 60fps. The count is just
// `useNodes().length` — no handle, no manual setCount. Zoom floor is dropped so 10k+ nodes
// fit in view; minimap helps navigate when zoomed in.
function StressPanel() {
const editor = useEditor()
const nodes = useNodes()
return (
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 6, width: 168 }}>
<p style={{ margin: 0, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.05em', color: 'var(--xeno-muted)' }}>Stress test</p>
<div style={{ fontSize: 22, fontWeight: 700, color: 'var(--xeno-accent)', fontVariantNumeric: 'tabular-nums' }}>
{nodes.length}<span style={{ fontSize: 12, color: 'var(--xeno-muted)', fontWeight: 400 }}> nodes</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<XenolithButton onClick={() => addStressNodes(editor, 500)} style={{ flex: 1 }}>+500</XenolithButton>
<XenolithButton onClick={() => addStressNodes(editor, 1000)} style={{ flex: 1 }}>+1000</XenolithButton>
</div>
<XenolithButton onClick={() => addStressNodes(editor, 5000)} style={{ width: '100%' }}>+5000</XenolithButton>
<XenolithButton onClick={() => editor.clear()} style={{ width: '100%' }}>Reset</XenolithButton>
<span style={{ color: 'var(--xeno-muted)', fontSize: 11, lineHeight: 1.4 }}>
WebGL, render-on-demand. Live stats top-right.
</span>
</XenolithPanel>
)
}
export function StressTestDemo() {
return (
<DemoStage>
<XenolithGraph
className="xeno"
resizeToWindow={false}
zoomBounds={[0.05, 2]}
onReady={setupStressTest}
>
<StressPanel />
</XenolithGraph>
</DemoStage>
)
} // Perf flex: throw hundreds–thousands of WebGL nodes at the editor and pan/zoom at 60fps — the thing
// a DOM-node library (React Flow) chokes on. The graph here is PROCEDURALLY generated (the legitimate
// exception to format-first: a 1000-node JSON makes no sense), all framework-agnostic editor API. The
// built-in stats overlay shows live FPS + counts; the handle adds nodes or resets.
import type { XenolithEditor, Node, NodeSchema } from '@xenolithengine/graph-editor'
// 4 categories × 4 names × 1 glyph per category = 16 schemas. The variety makes the stress grid
// read as an actual graph instead of a wall of identical pills; the bake cache still ends up with
// only 16 unique sprites so memory stays bounded.
const POOL = {
logic: { glyph: '⚙', names: ['Process', 'Transform', 'Compute', 'Reduce'] },
data: { glyph: '◉', names: ['Source', 'Sample', 'Stream', 'Tap'] },
macro: { glyph: '⬢', names: ['Pipeline', 'Cluster', 'Stack', 'Bundle'] },
utility: { glyph: '✦', names: ['Helper', 'Probe', 'Echo', 'Hook'] },
} as const
type CategoryId = keyof typeof POOL
const CATEGORIES = Object.keys(POOL) as CategoryId[]
const SCHEMAS: NodeSchema[] = CATEGORIES.flatMap((cat) =>
POOL[cat].names.map((name) => ({
type: `Box-${cat}-${name.toLowerCase()}`,
title: `${POOL[cat].glyph} ${name}`,
category: cat,
pins: [
{ kind: 'data' as const, direction: 'in' as const, type: 'any', label: 'In' },
{ kind: 'data' as const, direction: 'out' as const, type: 'any', label: 'Out' },
],
widgets: [],
})),
)
// One stable column count for the lifetime of the demo — otherwise every `+N` re-derives `cols`
// from sqrt(total), the old nodes stay on the old grid, the new ones land on a tighter one, and
// you get a visible seam (the "denser bottom half" image #3 was showing).
const COLS = 32
/** Add `n` nodes in a grid (continuing from the current count) and chain consecutive ones with edges.
* Pushes through the command bus inside one transaction — so React hooks (`useNodes`) re-render and
* the whole burst undoes as a single Cmd+Z. */
function addNodes(editor: XenolithEditor, n: number): void {
const existing = [...editor.graphNodes()]
const start = existing.length
editor.transaction(() => {
const made: Node[] = []
for (let i = 0; i < n; i++) {
const idx = start + i
// Cycle through all 16 (category, name) combinations so the grid mixes categories AND names
// across consecutive cells — looks like a real, varied graph rather than category bands.
const type = SCHEMAS[idx % SCHEMAS.length]!.type
const node = editor.insertNode(type, { x: (idx % COLS) * 180, y: Math.floor(idx / COLS) * 110 })
if (node) made.push(node)
}
// Sparse edges: link each new node to the previous one.
const prev = start > 0 ? existing[existing.length - 1] : undefined
const chain = prev ? [prev, ...made] : made
for (let i = 0; i < chain.length - 1; i++) {
const a = chain[i]!, b = chain[i + 1]!
const outPin = a.pins.find((p) => p.direction === 'out')
const inPin = b.pins.find((p) => p.direction === 'in')
if (outPin && inPin) {
editor.connect(a, outPin.id, b, inPin.id)
}
}
})
// Initial mount only — pin to 0.4 (the LOD threshold where nodes bake to sprites). fitView
// would zoom out to ~0.16 for a 500-node grid which is past every interesting render path.
// Subsequent `+N` keep the user's pan/zoom — re-fitting at large N is its own perf trap.
if (start === 0) editor.setViewport({ x: 0, y: 0, zoom: 0.4 })
}
export interface StressHandle {
/** Add `n` nodes; returns the new total. */
add(n: number): number
/** Bulk-clear via editor.clear() (one #clearAll pass + history drop); returns 0. */
reset(): number
count(): number
}
/** Set up the stress-test schema + stats overlay + initial node grid. Pass to `<XenolithGraph onReady>`. */
export function setupStressTest(editor: XenolithEditor, initial = 500): void {
for (const s of SCHEMAS) editor.registry.register(s)
editor.setStatsVisible(true)
addNodes(editor, initial)
}
/** Add `n` more stress nodes to the existing grid. Pure operation — no handle needed. */
export function addStressNodes(editor: XenolithEditor, n: number): void {
addNodes(editor, n)
}
/** @deprecated Use `setupStressTest` + `addStressNodes` + `editor.clear()` + `useNodes().length`. */
export function buildStressTest(editor: XenolithEditor, initial = 500): StressHandle {
setupStressTest(editor, initial)
const count = (): number => [...editor.graphNodes()].length
return {
add: (n) => { addNodes(editor, n); return count() },
reset: () => { editor.clear(); return count() },
count,
}
} <script setup lang="ts">
// Vue — the counter has to live in a child of <XenolithGraph> so useNodes() injects.
// Zoom floor is a mount prop: 10k nodes still fit in one view.
import { XenolithGraph } from '@xenolithengine/graph-vue'
import { setupStressTest } from '@xenolithengine/demo/stress-test'
import StressPanel from './StressPanel.vue'
const zoomBounds = [0.05, 2] as const
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" :zoom-bounds="zoomBounds" @ready="setupStressTest">
<StressPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { XenolithPanel, XenolithButton, useEditor, useNodes } from '@xenolithengine/graph-vue'
import { addStressNodes } from '@xenolithengine/demo/stress-test'
const editor = useEditor()
const nodes = useNodes()
function add(n: number): void {
const live = editor.value
if (live) addStressNodes(live, n)
}
function reset(): void {
editor.value?.clear()
}
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;width:168px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Stress test</p>
<div style="font-size:22px;font-weight:700;color:var(--xeno-accent);font-variant-numeric:tabular-nums;">
{{ nodes.length }}<span style="font-size:12px;color:var(--xeno-muted);font-weight:400;"> nodes</span>
</div>
<div style="display:flex;gap:6px;">
<XenolithButton style="flex:1" @click="add(500)">+500</XenolithButton>
<XenolithButton style="flex:1" @click="add(1000)">+1000</XenolithButton>
</div>
<XenolithButton style="width:100%" @click="add(5000)">+5000</XenolithButton>
<XenolithButton style="width:100%" @click="reset">Reset</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
WebGL, render-on-demand. Live stats top-right. The count is useNodes().length.
</span>
</div>
</XenolithPanel>
</template> // Perf flex: throw hundreds–thousands of WebGL nodes at the editor and pan/zoom at 60fps — the thing
// a DOM-node library (React Flow) chokes on. The graph here is PROCEDURALLY generated (the legitimate
// exception to format-first: a 1000-node JSON makes no sense), all framework-agnostic editor API. The
// built-in stats overlay shows live FPS + counts; the handle adds nodes or resets.
import type { XenolithEditor, Node, NodeSchema } from '@xenolithengine/graph-editor'
// 4 categories × 4 names × 1 glyph per category = 16 schemas. The variety makes the stress grid
// read as an actual graph instead of a wall of identical pills; the bake cache still ends up with
// only 16 unique sprites so memory stays bounded.
const POOL = {
logic: { glyph: '⚙', names: ['Process', 'Transform', 'Compute', 'Reduce'] },
data: { glyph: '◉', names: ['Source', 'Sample', 'Stream', 'Tap'] },
macro: { glyph: '⬢', names: ['Pipeline', 'Cluster', 'Stack', 'Bundle'] },
utility: { glyph: '✦', names: ['Helper', 'Probe', 'Echo', 'Hook'] },
} as const
type CategoryId = keyof typeof POOL
const CATEGORIES = Object.keys(POOL) as CategoryId[]
const SCHEMAS: NodeSchema[] = CATEGORIES.flatMap((cat) =>
POOL[cat].names.map((name) => ({
type: `Box-${cat}-${name.toLowerCase()}`,
title: `${POOL[cat].glyph} ${name}`,
category: cat,
pins: [
{ kind: 'data' as const, direction: 'in' as const, type: 'any', label: 'In' },
{ kind: 'data' as const, direction: 'out' as const, type: 'any', label: 'Out' },
],
widgets: [],
})),
)
// One stable column count for the lifetime of the demo — otherwise every `+N` re-derives `cols`
// from sqrt(total), the old nodes stay on the old grid, the new ones land on a tighter one, and
// you get a visible seam (the "denser bottom half" image #3 was showing).
const COLS = 32
/** Add `n` nodes in a grid (continuing from the current count) and chain consecutive ones with edges.
* Pushes through the command bus inside one transaction — so React hooks (`useNodes`) re-render and
* the whole burst undoes as a single Cmd+Z. */
function addNodes(editor: XenolithEditor, n: number): void {
const existing = [...editor.graphNodes()]
const start = existing.length
editor.transaction(() => {
const made: Node[] = []
for (let i = 0; i < n; i++) {
const idx = start + i
// Cycle through all 16 (category, name) combinations so the grid mixes categories AND names
// across consecutive cells — looks like a real, varied graph rather than category bands.
const type = SCHEMAS[idx % SCHEMAS.length]!.type
const node = editor.insertNode(type, { x: (idx % COLS) * 180, y: Math.floor(idx / COLS) * 110 })
if (node) made.push(node)
}
// Sparse edges: link each new node to the previous one.
const prev = start > 0 ? existing[existing.length - 1] : undefined
const chain = prev ? [prev, ...made] : made
for (let i = 0; i < chain.length - 1; i++) {
const a = chain[i]!, b = chain[i + 1]!
const outPin = a.pins.find((p) => p.direction === 'out')
const inPin = b.pins.find((p) => p.direction === 'in')
if (outPin && inPin) {
editor.connect(a, outPin.id, b, inPin.id)
}
}
})
// Initial mount only — pin to 0.4 (the LOD threshold where nodes bake to sprites). fitView
// would zoom out to ~0.16 for a 500-node grid which is past every interesting render path.
// Subsequent `+N` keep the user's pan/zoom — re-fitting at large N is its own perf trap.
if (start === 0) editor.setViewport({ x: 0, y: 0, zoom: 0.4 })
}
export interface StressHandle {
/** Add `n` nodes; returns the new total. */
add(n: number): number
/** Bulk-clear via editor.clear() (one #clearAll pass + history drop); returns 0. */
reset(): number
count(): number
}
/** Set up the stress-test schema + stats overlay + initial node grid. Pass to `<XenolithGraph onReady>`. */
export function setupStressTest(editor: XenolithEditor, initial = 500): void {
for (const s of SCHEMAS) editor.registry.register(s)
editor.setStatsVisible(true)
addNodes(editor, initial)
}
/** Add `n` more stress nodes to the existing grid. Pure operation — no handle needed. */
export function addStressNodes(editor: XenolithEditor, n: number): void {
addNodes(editor, n)
}
/** @deprecated Use `setupStressTest` + `addStressNodes` + `editor.clear()` + `useNodes().length`. */
export function buildStressTest(editor: XenolithEditor, initial = 500): StressHandle {
setupStressTest(editor, initial)
const count = (): number => [...editor.graphNodes()].length
return {
add: (n) => { addNodes(editor, n); return count() },
reset: () => { editor.clear(); return count() },
count,
}
} <!-- Svelte — the node count is the store bag. Destructure `nodes` before `$`. -->
<script lang="ts">
import { xenolith, createXenolithStores, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { setupStressTest, addStressNodes } from '@xenolithengine/demo/stress-test'
const stores = createXenolithStores()
const nodes = stores.nodes
const panelEditor = createXenolithEditorContext()
let editor = $state<XenolithEditor | null>(null)
function add(n: number): void {
if (editor) addStressNodes(editor, n)
}
function reset(): void {
editor?.clear()
}
</script>
<div
use:xenolith={{ resizeToWindow: false, zoomBounds: [0.05, 2] }}
onready={(e) => { editor = e.detail; panelEditor.set(e.detail); stores.editor.set(e.detail); setupStressTest(e.detail) }}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;width:168px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Stress test</p>
<div style="font-size:22px;font-weight:700;color:var(--xeno-accent);font-variant-numeric:tabular-nums;">
{$nodes.length}<span style="font-size:12px;color:var(--xeno-muted);font-weight:400;"> nodes</span>
</div>
<div style="display:flex;gap:6px;">
<XenolithButton style="flex:1" onclick={() => add(500)}>+500</XenolithButton>
<XenolithButton style="flex:1" onclick={() => add(1000)}>+1000</XenolithButton>
</div>
<XenolithButton style="width:100%" onclick={() => add(5000)}>+5000</XenolithButton>
<XenolithButton style="width:100%" onclick={() => reset()}>Reset</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
WebGL, render-on-demand. Live stats top-right. The count is the nodes store.
</span>
</div>
</XenolithPanel> // Perf flex: throw hundreds–thousands of WebGL nodes at the editor and pan/zoom at 60fps — the thing
// a DOM-node library (React Flow) chokes on. The graph here is PROCEDURALLY generated (the legitimate
// exception to format-first: a 1000-node JSON makes no sense), all framework-agnostic editor API. The
// built-in stats overlay shows live FPS + counts; the handle adds nodes or resets.
import type { XenolithEditor, Node, NodeSchema } from '@xenolithengine/graph-editor'
// 4 categories × 4 names × 1 glyph per category = 16 schemas. The variety makes the stress grid
// read as an actual graph instead of a wall of identical pills; the bake cache still ends up with
// only 16 unique sprites so memory stays bounded.
const POOL = {
logic: { glyph: '⚙', names: ['Process', 'Transform', 'Compute', 'Reduce'] },
data: { glyph: '◉', names: ['Source', 'Sample', 'Stream', 'Tap'] },
macro: { glyph: '⬢', names: ['Pipeline', 'Cluster', 'Stack', 'Bundle'] },
utility: { glyph: '✦', names: ['Helper', 'Probe', 'Echo', 'Hook'] },
} as const
type CategoryId = keyof typeof POOL
const CATEGORIES = Object.keys(POOL) as CategoryId[]
const SCHEMAS: NodeSchema[] = CATEGORIES.flatMap((cat) =>
POOL[cat].names.map((name) => ({
type: `Box-${cat}-${name.toLowerCase()}`,
title: `${POOL[cat].glyph} ${name}`,
category: cat,
pins: [
{ kind: 'data' as const, direction: 'in' as const, type: 'any', label: 'In' },
{ kind: 'data' as const, direction: 'out' as const, type: 'any', label: 'Out' },
],
widgets: [],
})),
)
// One stable column count for the lifetime of the demo — otherwise every `+N` re-derives `cols`
// from sqrt(total), the old nodes stay on the old grid, the new ones land on a tighter one, and
// you get a visible seam (the "denser bottom half" image #3 was showing).
const COLS = 32
/** Add `n` nodes in a grid (continuing from the current count) and chain consecutive ones with edges.
* Pushes through the command bus inside one transaction — so React hooks (`useNodes`) re-render and
* the whole burst undoes as a single Cmd+Z. */
function addNodes(editor: XenolithEditor, n: number): void {
const existing = [...editor.graphNodes()]
const start = existing.length
editor.transaction(() => {
const made: Node[] = []
for (let i = 0; i < n; i++) {
const idx = start + i
// Cycle through all 16 (category, name) combinations so the grid mixes categories AND names
// across consecutive cells — looks like a real, varied graph rather than category bands.
const type = SCHEMAS[idx % SCHEMAS.length]!.type
const node = editor.insertNode(type, { x: (idx % COLS) * 180, y: Math.floor(idx / COLS) * 110 })
if (node) made.push(node)
}
// Sparse edges: link each new node to the previous one.
const prev = start > 0 ? existing[existing.length - 1] : undefined
const chain = prev ? [prev, ...made] : made
for (let i = 0; i < chain.length - 1; i++) {
const a = chain[i]!, b = chain[i + 1]!
const outPin = a.pins.find((p) => p.direction === 'out')
const inPin = b.pins.find((p) => p.direction === 'in')
if (outPin && inPin) {
editor.connect(a, outPin.id, b, inPin.id)
}
}
})
// Initial mount only — pin to 0.4 (the LOD threshold where nodes bake to sprites). fitView
// would zoom out to ~0.16 for a 500-node grid which is past every interesting render path.
// Subsequent `+N` keep the user's pan/zoom — re-fitting at large N is its own perf trap.
if (start === 0) editor.setViewport({ x: 0, y: 0, zoom: 0.4 })
}
export interface StressHandle {
/** Add `n` nodes; returns the new total. */
add(n: number): number
/** Bulk-clear via editor.clear() (one #clearAll pass + history drop); returns 0. */
reset(): number
count(): number
}
/** Set up the stress-test schema + stats overlay + initial node grid. Pass to `<XenolithGraph onReady>`. */
export function setupStressTest(editor: XenolithEditor, initial = 500): void {
for (const s of SCHEMAS) editor.registry.register(s)
editor.setStatsVisible(true)
addNodes(editor, initial)
}
/** Add `n` more stress nodes to the existing grid. Pure operation — no handle needed. */
export function addStressNodes(editor: XenolithEditor, n: number): void {
addNodes(editor, n)
}
/** @deprecated Use `setupStressTest` + `addStressNodes` + `editor.clear()` + `useNodes().length`. */
export function buildStressTest(editor: XenolithEditor, initial = 500): StressHandle {
setupStressTest(editor, initial)
const count = (): number => [...editor.graphNodes()].length
return {
add: (n) => { addNodes(editor, n); return count() },
reset: () => { editor.clear(); return count() },
count,
}
} // Solid — no panel package. The count is stores.nodes(); buttons call the shared helpers.
import { xenolith, createXenolithStores } from '@xenolithengine/graph-solid'
import { setupStressTest, addStressNodes } from '@xenolithengine/demo/stress-test'
export function StressTestDemo() {
const stores = createXenolithStores()
function add(n: number): void {
const live = stores.editor()
if (live) addStressNodes(live, n)
}
const btn = 'font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;border:1px solid var(--xeno-border);background:var(--xeno-elevated);color:var(--xeno-text);cursor:pointer;'
return (
<div
use:xenolith={{ resizeToWindow: false, zoomBounds: [0.05, 2] }}
on:ready={(e) => { stores.setEditor(e.detail); setupStressTest(e.detail) }}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;display:flex;flex-direction:column;gap:6px;width:168px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Stress test</p>
<div style="font-size:22px;font-weight:700;color:var(--xeno-accent);font-variant-numeric:tabular-nums;">
{stores.nodes().length}<span style="font-size:12px;color:var(--xeno-muted);font-weight:400;"> nodes</span>
</div>
<div style="display:flex;gap:6px;">
<button type="button" style={`${btn}flex:1`} onClick={() => add(500)}>+500</button>
<button type="button" style={`${btn}flex:1`} onClick={() => add(1000)}>+1000</button>
</div>
<button type="button" style={`${btn}width:100%`} onClick={() => add(5000)}>+5000</button>
<button type="button" style={`${btn}width:100%`} onClick={() => stores.editor()?.clear()}>Reset</button>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
WebGL, render-on-demand. Live stats top-right. The count is stores.nodes().length.
</span>
</div>
</div>
)
} // Perf flex: throw hundreds–thousands of WebGL nodes at the editor and pan/zoom at 60fps — the thing
// a DOM-node library (React Flow) chokes on. The graph here is PROCEDURALLY generated (the legitimate
// exception to format-first: a 1000-node JSON makes no sense), all framework-agnostic editor API. The
// built-in stats overlay shows live FPS + counts; the handle adds nodes or resets.
import type { XenolithEditor, Node, NodeSchema } from '@xenolithengine/graph-editor'
// 4 categories × 4 names × 1 glyph per category = 16 schemas. The variety makes the stress grid
// read as an actual graph instead of a wall of identical pills; the bake cache still ends up with
// only 16 unique sprites so memory stays bounded.
const POOL = {
logic: { glyph: '⚙', names: ['Process', 'Transform', 'Compute', 'Reduce'] },
data: { glyph: '◉', names: ['Source', 'Sample', 'Stream', 'Tap'] },
macro: { glyph: '⬢', names: ['Pipeline', 'Cluster', 'Stack', 'Bundle'] },
utility: { glyph: '✦', names: ['Helper', 'Probe', 'Echo', 'Hook'] },
} as const
type CategoryId = keyof typeof POOL
const CATEGORIES = Object.keys(POOL) as CategoryId[]
const SCHEMAS: NodeSchema[] = CATEGORIES.flatMap((cat) =>
POOL[cat].names.map((name) => ({
type: `Box-${cat}-${name.toLowerCase()}`,
title: `${POOL[cat].glyph} ${name}`,
category: cat,
pins: [
{ kind: 'data' as const, direction: 'in' as const, type: 'any', label: 'In' },
{ kind: 'data' as const, direction: 'out' as const, type: 'any', label: 'Out' },
],
widgets: [],
})),
)
// One stable column count for the lifetime of the demo — otherwise every `+N` re-derives `cols`
// from sqrt(total), the old nodes stay on the old grid, the new ones land on a tighter one, and
// you get a visible seam (the "denser bottom half" image #3 was showing).
const COLS = 32
/** Add `n` nodes in a grid (continuing from the current count) and chain consecutive ones with edges.
* Pushes through the command bus inside one transaction — so React hooks (`useNodes`) re-render and
* the whole burst undoes as a single Cmd+Z. */
function addNodes(editor: XenolithEditor, n: number): void {
const existing = [...editor.graphNodes()]
const start = existing.length
editor.transaction(() => {
const made: Node[] = []
for (let i = 0; i < n; i++) {
const idx = start + i
// Cycle through all 16 (category, name) combinations so the grid mixes categories AND names
// across consecutive cells — looks like a real, varied graph rather than category bands.
const type = SCHEMAS[idx % SCHEMAS.length]!.type
const node = editor.insertNode(type, { x: (idx % COLS) * 180, y: Math.floor(idx / COLS) * 110 })
if (node) made.push(node)
}
// Sparse edges: link each new node to the previous one.
const prev = start > 0 ? existing[existing.length - 1] : undefined
const chain = prev ? [prev, ...made] : made
for (let i = 0; i < chain.length - 1; i++) {
const a = chain[i]!, b = chain[i + 1]!
const outPin = a.pins.find((p) => p.direction === 'out')
const inPin = b.pins.find((p) => p.direction === 'in')
if (outPin && inPin) {
editor.connect(a, outPin.id, b, inPin.id)
}
}
})
// Initial mount only — pin to 0.4 (the LOD threshold where nodes bake to sprites). fitView
// would zoom out to ~0.16 for a 500-node grid which is past every interesting render path.
// Subsequent `+N` keep the user's pan/zoom — re-fitting at large N is its own perf trap.
if (start === 0) editor.setViewport({ x: 0, y: 0, zoom: 0.4 })
}
export interface StressHandle {
/** Add `n` nodes; returns the new total. */
add(n: number): number
/** Bulk-clear via editor.clear() (one #clearAll pass + history drop); returns 0. */
reset(): number
count(): number
}
/** Set up the stress-test schema + stats overlay + initial node grid. Pass to `<XenolithGraph onReady>`. */
export function setupStressTest(editor: XenolithEditor, initial = 500): void {
for (const s of SCHEMAS) editor.registry.register(s)
editor.setStatsVisible(true)
addNodes(editor, initial)
}
/** Add `n` more stress nodes to the existing grid. Pure operation — no handle needed. */
export function addStressNodes(editor: XenolithEditor, n: number): void {
addNodes(editor, n)
}
/** @deprecated Use `setupStressTest` + `addStressNodes` + `editor.clear()` + `useNodes().length`. */
export function buildStressTest(editor: XenolithEditor, initial = 500): StressHandle {
setupStressTest(editor, initial)
const count = (): number => [...editor.graphNodes()].length
return {
add: (n) => { addNodes(editor, n); return count() },
reset: () => { editor.clear(); return count() },
count,
}
} // Angular — nodes$ drives the counter. This adapter ships no panel component, so the
// buttons are a DOM card over the host the service mounts into.
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { Subscription } from 'rxjs'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import { setupStressTest, addStressNodes } from '@xenolithengine/demo/stress-test'
@Component({
selector: 'stress-test-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">
<p class="label">Stress test</p>
<div class="count">{{ nodes() }}<span class="unit"> nodes</span></div>
<div class="row">
<button type="button" class="btn" (click)="add(500)">+500</button>
<button type="button" class="btn" (click)="add(1000)">+1000</button>
</div>
<button type="button" class="btn" (click)="add(5000)">+5000</button>
<button type="button" class="btn" (click)="reset()">Reset</button>
<span class="note">WebGL, render-on-demand. Live stats top-right. The count is nodes$.</span>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; left:12px; z-index:5; display:flex; flex-direction:column;
gap:6px; width:168px; padding:10px; 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; }
.label { margin:0; font-size:11px; text-transform:uppercase; letter-spacing:.05em;
color:var(--xeno-muted,#9a9a9a); }
.count { font-size:22px; font-weight:700; color:var(--xeno-accent,#FCB400);
font-variant-numeric:tabular-nums; }
.unit { font-size:12px; color:var(--xeno-muted,#9a9a9a); font-weight:400; }
.row { display:flex; gap:6px; }
.btn { flex:1; 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); }
.note { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.4; }
`],
})
export class StressTestDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private sub = new Subscription()
nodes = signal(0)
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, {
resizeToWindow: false,
zoomBounds: [0.05, 2],
})
setupStressTest(editor)
this.sub.add(this.graph.nodes$.subscribe((ns) => this.nodes.set(ns.length)))
}
add(n: number): void {
const editor = this.graph.editor
if (editor) addStressNodes(editor, n)
}
reset(): void {
this.graph.editor?.clear()
}
ngOnDestroy(): void {
this.sub.unsubscribe()
this.graph.destroy()
}
} // Perf flex: throw hundreds–thousands of WebGL nodes at the editor and pan/zoom at 60fps — the thing
// a DOM-node library (React Flow) chokes on. The graph here is PROCEDURALLY generated (the legitimate
// exception to format-first: a 1000-node JSON makes no sense), all framework-agnostic editor API. The
// built-in stats overlay shows live FPS + counts; the handle adds nodes or resets.
import type { XenolithEditor, Node, NodeSchema } from '@xenolithengine/graph-editor'
// 4 categories × 4 names × 1 glyph per category = 16 schemas. The variety makes the stress grid
// read as an actual graph instead of a wall of identical pills; the bake cache still ends up with
// only 16 unique sprites so memory stays bounded.
const POOL = {
logic: { glyph: '⚙', names: ['Process', 'Transform', 'Compute', 'Reduce'] },
data: { glyph: '◉', names: ['Source', 'Sample', 'Stream', 'Tap'] },
macro: { glyph: '⬢', names: ['Pipeline', 'Cluster', 'Stack', 'Bundle'] },
utility: { glyph: '✦', names: ['Helper', 'Probe', 'Echo', 'Hook'] },
} as const
type CategoryId = keyof typeof POOL
const CATEGORIES = Object.keys(POOL) as CategoryId[]
const SCHEMAS: NodeSchema[] = CATEGORIES.flatMap((cat) =>
POOL[cat].names.map((name) => ({
type: `Box-${cat}-${name.toLowerCase()}`,
title: `${POOL[cat].glyph} ${name}`,
category: cat,
pins: [
{ kind: 'data' as const, direction: 'in' as const, type: 'any', label: 'In' },
{ kind: 'data' as const, direction: 'out' as const, type: 'any', label: 'Out' },
],
widgets: [],
})),
)
// One stable column count for the lifetime of the demo — otherwise every `+N` re-derives `cols`
// from sqrt(total), the old nodes stay on the old grid, the new ones land on a tighter one, and
// you get a visible seam (the "denser bottom half" image #3 was showing).
const COLS = 32
/** Add `n` nodes in a grid (continuing from the current count) and chain consecutive ones with edges.
* Pushes through the command bus inside one transaction — so React hooks (`useNodes`) re-render and
* the whole burst undoes as a single Cmd+Z. */
function addNodes(editor: XenolithEditor, n: number): void {
const existing = [...editor.graphNodes()]
const start = existing.length
editor.transaction(() => {
const made: Node[] = []
for (let i = 0; i < n; i++) {
const idx = start + i
// Cycle through all 16 (category, name) combinations so the grid mixes categories AND names
// across consecutive cells — looks like a real, varied graph rather than category bands.
const type = SCHEMAS[idx % SCHEMAS.length]!.type
const node = editor.insertNode(type, { x: (idx % COLS) * 180, y: Math.floor(idx / COLS) * 110 })
if (node) made.push(node)
}
// Sparse edges: link each new node to the previous one.
const prev = start > 0 ? existing[existing.length - 1] : undefined
const chain = prev ? [prev, ...made] : made
for (let i = 0; i < chain.length - 1; i++) {
const a = chain[i]!, b = chain[i + 1]!
const outPin = a.pins.find((p) => p.direction === 'out')
const inPin = b.pins.find((p) => p.direction === 'in')
if (outPin && inPin) {
editor.connect(a, outPin.id, b, inPin.id)
}
}
})
// Initial mount only — pin to 0.4 (the LOD threshold where nodes bake to sprites). fitView
// would zoom out to ~0.16 for a 500-node grid which is past every interesting render path.
// Subsequent `+N` keep the user's pan/zoom — re-fitting at large N is its own perf trap.
if (start === 0) editor.setViewport({ x: 0, y: 0, zoom: 0.4 })
}
export interface StressHandle {
/** Add `n` nodes; returns the new total. */
add(n: number): number
/** Bulk-clear via editor.clear() (one #clearAll pass + history drop); returns 0. */
reset(): number
count(): number
}
/** Set up the stress-test schema + stats overlay + initial node grid. Pass to `<XenolithGraph onReady>`. */
export function setupStressTest(editor: XenolithEditor, initial = 500): void {
for (const s of SCHEMAS) editor.registry.register(s)
editor.setStatsVisible(true)
addNodes(editor, initial)
}
/** Add `n` more stress nodes to the existing grid. Pure operation — no handle needed. */
export function addStressNodes(editor: XenolithEditor, n: number): void {
addNodes(editor, n)
}
/** @deprecated Use `setupStressTest` + `addStressNodes` + `editor.clear()` + `useNodes().length`. */
export function buildStressTest(editor: XenolithEditor, initial = 500): StressHandle {
setupStressTest(editor, initial)
const count = (): number => [...editor.graphNodes()].length
return {
add: (n) => { addNodes(editor, n); return count() },
reset: () => { editor.clear(); return count() },
count,
}
}