Connection validation
Typed Blueprint pins refuse mismatched wires automatically (a string won’t plug into a number). A custom guard adds cycle prevention on top. Every attempt is logged live.
{
"version": "xenolith.v1",
"nodes": [
{ "id": "a", "type": "A", "position": { "x": 0, "y": 40 }, "render": { "title": "A", "category": "data" },
"pins": [
{ "id": "a:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "a:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "b", "type": "B", "position": { "x": 230, "y": 40 }, "render": { "title": "B", "category": "data" },
"pins": [
{ "id": "b:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "b:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "c", "type": "C", "position": { "x": 460, "y": 40 }, "render": { "title": "C", "category": "data" },
"pins": [
{ "id": "c:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "c:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "text", "type": "Text", "position": { "x": 40, "y": 210 }, "render": { "title": "Text", "category": "logic" },
"pins": [
{ "id": "text:out", "kind": "data", "direction": "out", "type": "string", "multiple": true, "label": "Out" }
] },
{ "id": "caption", "type": "Caption", "position": { "x": 460, "y": 210 }, "render": { "title": "Caption", "category": "logic" },
"pins": [
{ "id": "caption:in", "kind": "data", "direction": "in", "type": "string", "multiple": false, "label": "In" }
] }
],
"edges": [
{ "id": "e-ab", "from": { "node": "a", "pin": "a:out" }, "to": { "node": "b", "pin": "b:in" } },
{ "id": "e-bc", "from": { "node": "b", "pin": "b:out" }, "to": { "node": "c", "pin": "c:in" } },
{ "id": "e-tc", "from": { "node": "text", "pin": "text:out" }, "to": { "node": "caption", "pin": "caption:in" } }
]
} import { useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithControls } from '@xenolithengine/graph-react'
import { buildConnectionValidation, type Attempt } from '@xenolithengine/demo/connection-validation'
import { DemoStage } from '../Layout.js'
// Showcase: typed-pin validation + a custom cycle-prevention guard. The graph + the guard live in the
// framework-agnostic core (@xenolithengine/demo/connection-validation); this React file just renders the
// live attempt log fed by the core's `log` callback.
function RulesPanel({ log }: { log: Attempt[] }): React.ReactElement {
return (
<XenolithPanel position="top-right" style={{ display: 'flex', flexDirection: 'column', gap: 8, width: 230 }}>
<p style={{ margin: 0, fontSize: 11, textTransform: 'uppercase', letterSpacing: '.05em', color: 'var(--xeno-muted)' }}>Connection rules</p>
<span style={{ color: 'var(--xeno-muted)', fontSize: 11, lineHeight: 1.5 }}>
Pins are typed. Drag <b style={{ color: 'var(--xeno-text)' }}>Text</b> → a float input — refused (snaps back).
Drag <b style={{ color: 'var(--xeno-text)' }}>C</b> → <b style={{ color: 'var(--xeno-text)' }}>A</b> — blocked (cycle).
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, maxHeight: 168, overflow: 'hidden' }}>
{log.length === 0 && <span style={{ color: 'var(--xeno-muted)', fontSize: 11 }}>No attempts yet.</span>}
{log.map((a, i) => (
<span key={i} style={{ fontSize: 11, fontFamily: 'var(--xeno-mono, monospace)', color: a.ok ? '#39d98a' : '#ff5b6e' }}>
{a.ok ? '✓' : '✗'} {a.text}
</span>
))}
</div>
</XenolithPanel>
)
}
/** Showcase: typed-pin validation + custom cycle-prevention guard. */
export function ConnectionValidationDemo(): React.ReactElement {
const [log, setLog] = useState<Attempt[]>([])
const push = (a: Attempt): void => setLog((l) => [a, ...l].slice(0, 8))
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={(editor) => buildConnectionValidation(editor, push)}>
<XenolithControls position="bottom-left" />
<RulesPanel log={log} />
</XenolithGraph>
</DemoStage>
)
} // Showcase: connection rules. Pins are typed (Blueprint-style) so the built-in check refuses a
// string→float wire automatically. On top of that, a custom isValidConnection guard uses the core
// wouldCreateCycle() helper to forbid loops. The graph is DATA (connection-validation.json); the only
// host-specific piece is the `log` sink the guard + edge:connected event report attempts to.
import { wouldCreateCycle } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './connection-validation.json'
export interface Attempt { ok: boolean; text: string }
export function buildConnectionValidation(editor: XenolithEditor, log: (a: Attempt) => void): void {
editor.loadJSON(graph)
const name = (id: NodeId): string => editor.getNode(id)?.type ?? '?'
editor.setIsValidConnection((conn) => {
if (wouldCreateCycle(editor.readGraph(), conn.source, conn.target)) {
log({ ok: false, text: `${name(conn.source)} → ${name(conn.target)} · would create a cycle` })
return false
}
return true
})
editor.on('edge:connected', (e) => log({ ok: true, text: `${name(e.edge.from.node)} → ${name(e.edge.to.node)} · connected` }))
editor.fitView({ padding: 64, maxZoom: 1 })
} {
"version": "xenolith.v1",
"nodes": [
{ "id": "a", "type": "A", "position": { "x": 0, "y": 40 }, "render": { "title": "A", "category": "data" },
"pins": [
{ "id": "a:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "a:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "b", "type": "B", "position": { "x": 230, "y": 40 }, "render": { "title": "B", "category": "data" },
"pins": [
{ "id": "b:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "b:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "c", "type": "C", "position": { "x": 460, "y": 40 }, "render": { "title": "C", "category": "data" },
"pins": [
{ "id": "c:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "c:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "text", "type": "Text", "position": { "x": 40, "y": 210 }, "render": { "title": "Text", "category": "logic" },
"pins": [
{ "id": "text:out", "kind": "data", "direction": "out", "type": "string", "multiple": true, "label": "Out" }
] },
{ "id": "caption", "type": "Caption", "position": { "x": 460, "y": 210 }, "render": { "title": "Caption", "category": "logic" },
"pins": [
{ "id": "caption:in", "kind": "data", "direction": "in", "type": "string", "multiple": false, "label": "In" }
] }
],
"edges": [
{ "id": "e-ab", "from": { "node": "a", "pin": "a:out" }, "to": { "node": "b", "pin": "b:in" } },
{ "id": "e-bc", "from": { "node": "b", "pin": "b:out" }, "to": { "node": "c", "pin": "c:in" } },
{ "id": "e-tc", "from": { "node": "text", "pin": "text:out" }, "to": { "node": "caption", "pin": "caption:in" } }
]
} <script setup lang="ts">
// Vue — the graph and the cycle guard live in @xenolithengine/demo/connection-validation.
// This file only keeps the attempt log the core reports into.
import { ref } from 'vue'
import { XenolithGraph, XenolithControls } from '@xenolithengine/graph-vue'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import { buildConnectionValidation, type Attempt } from '@xenolithengine/demo/connection-validation'
import RulesPanel from './RulesPanel.vue'
const log = ref<Attempt[]>([])
function push(attempt: Attempt): void {
log.value = [attempt, ...log.value].slice(0, 8)
}
function ready(editor: XenolithEditor): void {
buildConnectionValidation(editor, push)
}
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false" @ready="ready">
<XenolithControls position="bottom-left" />
<RulesPanel :log="log" />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { XenolithPanel } from '@xenolithengine/graph-vue'
import type { Attempt } from '@xenolithengine/demo/connection-validation'
defineProps<{ log: Attempt[] }>()
</script>
<template>
<XenolithPanel position="top-right">
<div style="display:flex;flex-direction:column;gap:8px;width:230px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Connection rules</p>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.5;">
Pins are typed. Drag <b style="color:var(--xeno-text);">Text</b> → a float input — refused (snaps back).
Drag <b style="color:var(--xeno-text);">C</b> → <b style="color:var(--xeno-text);">A</b> — blocked (cycle).
</span>
<div style="display:flex;flex-direction:column;gap:3px;max-height:168px;overflow:hidden;">
<span v-if="log.length === 0" style="color:var(--xeno-muted);font-size:11px;">No attempts yet.</span>
<span
v-for="(attempt, i) in log"
:key="i"
style="font-size:11px;font-family:var(--xeno-mono, monospace);"
:style="{ color: attempt.ok ? '#39d98a' : '#ff5b6e' }"
>{{ attempt.ok ? '✓' : '✗' }} {{ attempt.text }}</span>
</div>
</div>
</XenolithPanel>
</template> // Showcase: connection rules. Pins are typed (Blueprint-style) so the built-in check refuses a
// string→float wire automatically. On top of that, a custom isValidConnection guard uses the core
// wouldCreateCycle() helper to forbid loops. The graph is DATA (connection-validation.json); the only
// host-specific piece is the `log` sink the guard + edge:connected event report attempts to.
import { wouldCreateCycle } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './connection-validation.json'
export interface Attempt { ok: boolean; text: string }
export function buildConnectionValidation(editor: XenolithEditor, log: (a: Attempt) => void): void {
editor.loadJSON(graph)
const name = (id: NodeId): string => editor.getNode(id)?.type ?? '?'
editor.setIsValidConnection((conn) => {
if (wouldCreateCycle(editor.readGraph(), conn.source, conn.target)) {
log({ ok: false, text: `${name(conn.source)} → ${name(conn.target)} · would create a cycle` })
return false
}
return true
})
editor.on('edge:connected', (e) => log({ ok: true, text: `${name(e.edge.from.node)} → ${name(e.edge.to.node)} · connected` }))
editor.fitView({ padding: 64, maxZoom: 1 })
} {
"version": "xenolith.v1",
"nodes": [
{ "id": "a", "type": "A", "position": { "x": 0, "y": 40 }, "render": { "title": "A", "category": "data" },
"pins": [
{ "id": "a:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "a:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "b", "type": "B", "position": { "x": 230, "y": 40 }, "render": { "title": "B", "category": "data" },
"pins": [
{ "id": "b:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "b:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "c", "type": "C", "position": { "x": 460, "y": 40 }, "render": { "title": "C", "category": "data" },
"pins": [
{ "id": "c:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "c:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "text", "type": "Text", "position": { "x": 40, "y": 210 }, "render": { "title": "Text", "category": "logic" },
"pins": [
{ "id": "text:out", "kind": "data", "direction": "out", "type": "string", "multiple": true, "label": "Out" }
] },
{ "id": "caption", "type": "Caption", "position": { "x": 460, "y": 210 }, "render": { "title": "Caption", "category": "logic" },
"pins": [
{ "id": "caption:in", "kind": "data", "direction": "in", "type": "string", "multiple": false, "label": "In" }
] }
],
"edges": [
{ "id": "e-ab", "from": { "node": "a", "pin": "a:out" }, "to": { "node": "b", "pin": "b:in" } },
{ "id": "e-bc", "from": { "node": "b", "pin": "b:out" }, "to": { "node": "c", "pin": "c:in" } },
{ "id": "e-tc", "from": { "node": "text", "pin": "text:out" }, "to": { "node": "caption", "pin": "caption:in" } }
]
} <!-- Svelte — the attempt log is local state. The guard and the graph are shared. -->
<script lang="ts">
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithControls } from '@xenolithengine/graph-svelte/components'
import { buildConnectionValidation, type Attempt } from '@xenolithengine/demo/connection-validation'
const panelEditor = createXenolithEditorContext()
let log = $state<Attempt[]>([])
function push(attempt: Attempt): void {
log = [attempt, ...log].slice(0, 8)
}
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => { panelEditor.set(e.detail); buildConnectionValidation(e.detail, push) }}
style="position:absolute;inset:0"
></div>
<XenolithControls position="bottom-left" />
<XenolithPanel position="top-right">
<div style="display:flex;flex-direction:column;gap:8px;width:230px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--xeno-muted);">Connection rules</p>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.5;">
Pins are typed. Drag <b style="color:var(--xeno-text);">Text</b> → a float input — refused (snaps back).
Drag <b style="color:var(--xeno-text);">C</b> → <b style="color:var(--xeno-text);">A</b> — blocked (cycle).
</span>
<div style="display:flex;flex-direction:column;gap:3px;max-height:168px;overflow:hidden;">
{#if log.length === 0}
<span style="color:var(--xeno-muted);font-size:11px;">No attempts yet.</span>
{/if}
{#each log as attempt, i (i + attempt.text)}
<span style={`font-size:11px;font-family:var(--xeno-mono, monospace);color:${attempt.ok ? '#39d98a' : '#ff5b6e'};`}>
{attempt.ok ? '✓' : '✗'} {attempt.text}
</span>
{/each}
</div>
</div>
</XenolithPanel> // Showcase: connection rules. Pins are typed (Blueprint-style) so the built-in check refuses a
// string→float wire automatically. On top of that, a custom isValidConnection guard uses the core
// wouldCreateCycle() helper to forbid loops. The graph is DATA (connection-validation.json); the only
// host-specific piece is the `log` sink the guard + edge:connected event report attempts to.
import { wouldCreateCycle } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './connection-validation.json'
export interface Attempt { ok: boolean; text: string }
export function buildConnectionValidation(editor: XenolithEditor, log: (a: Attempt) => void): void {
editor.loadJSON(graph)
const name = (id: NodeId): string => editor.getNode(id)?.type ?? '?'
editor.setIsValidConnection((conn) => {
if (wouldCreateCycle(editor.readGraph(), conn.source, conn.target)) {
log({ ok: false, text: `${name(conn.source)} → ${name(conn.target)} · would create a cycle` })
return false
}
return true
})
editor.on('edge:connected', (e) => log({ ok: true, text: `${name(e.edge.from.node)} → ${name(e.edge.to.node)} · connected` }))
editor.fitView({ padding: 64, maxZoom: 1 })
} {
"version": "xenolith.v1",
"nodes": [
{ "id": "a", "type": "A", "position": { "x": 0, "y": 40 }, "render": { "title": "A", "category": "data" },
"pins": [
{ "id": "a:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "a:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "b", "type": "B", "position": { "x": 230, "y": 40 }, "render": { "title": "B", "category": "data" },
"pins": [
{ "id": "b:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "b:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "c", "type": "C", "position": { "x": 460, "y": 40 }, "render": { "title": "C", "category": "data" },
"pins": [
{ "id": "c:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "c:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "text", "type": "Text", "position": { "x": 40, "y": 210 }, "render": { "title": "Text", "category": "logic" },
"pins": [
{ "id": "text:out", "kind": "data", "direction": "out", "type": "string", "multiple": true, "label": "Out" }
] },
{ "id": "caption", "type": "Caption", "position": { "x": 460, "y": 210 }, "render": { "title": "Caption", "category": "logic" },
"pins": [
{ "id": "caption:in", "kind": "data", "direction": "in", "type": "string", "multiple": false, "label": "In" }
] }
],
"edges": [
{ "id": "e-ab", "from": { "node": "a", "pin": "a:out" }, "to": { "node": "b", "pin": "b:in" } },
{ "id": "e-bc", "from": { "node": "b", "pin": "b:out" }, "to": { "node": "c", "pin": "c:in" } },
{ "id": "e-tc", "from": { "node": "text", "pin": "text:out" }, "to": { "node": "caption", "pin": "caption:in" } }
]
} // Solid — no panel package. The log is a signal the shared guard writes into.
import { createSignal, For, Show } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import { buildConnectionValidation, type Attempt } from '@xenolithengine/demo/connection-validation'
export function ConnectionValidationDemo() {
const [log, setLog] = createSignal<Attempt[]>([])
function push(attempt: Attempt): void {
setLog((prev) => [attempt, ...prev].slice(0, 8))
}
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => {
e.detail.chrome.setControls({ position: 'bottom-left' })
buildConnectionValidation(e.detail, push)
}}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;right:12px;z-index:5;display:flex;flex-direction:column;gap:8px;width:230px;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);">Connection rules</p>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.5;">
Pins are typed. Drag <b style="color:var(--xeno-text);">Text</b> → a float input — refused (snaps back).
Drag <b style="color:var(--xeno-text);">C</b> → <b style="color:var(--xeno-text);">A</b> — blocked (cycle).
</span>
<div style="display:flex;flex-direction:column;gap:3px;max-height:168px;overflow:hidden;">
<Show when={log().length === 0}>
<span style="color:var(--xeno-muted);font-size:11px;">No attempts yet.</span>
</Show>
<For each={log()}>{(attempt) =>
<span style={`font-size:11px;font-family:var(--xeno-mono, monospace);color:${attempt.ok ? '#39d98a' : '#ff5b6e'};`}>
{attempt.ok ? '✓' : '✗'} {attempt.text}
</span>
}</For>
</div>
</div>
</div>
)
} // Showcase: connection rules. Pins are typed (Blueprint-style) so the built-in check refuses a
// string→float wire automatically. On top of that, a custom isValidConnection guard uses the core
// wouldCreateCycle() helper to forbid loops. The graph is DATA (connection-validation.json); the only
// host-specific piece is the `log` sink the guard + edge:connected event report attempts to.
import { wouldCreateCycle } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './connection-validation.json'
export interface Attempt { ok: boolean; text: string }
export function buildConnectionValidation(editor: XenolithEditor, log: (a: Attempt) => void): void {
editor.loadJSON(graph)
const name = (id: NodeId): string => editor.getNode(id)?.type ?? '?'
editor.setIsValidConnection((conn) => {
if (wouldCreateCycle(editor.readGraph(), conn.source, conn.target)) {
log({ ok: false, text: `${name(conn.source)} → ${name(conn.target)} · would create a cycle` })
return false
}
return true
})
editor.on('edge:connected', (e) => log({ ok: true, text: `${name(e.edge.from.node)} → ${name(e.edge.to.node)} · connected` }))
editor.fitView({ padding: 64, maxZoom: 1 })
} {
"version": "xenolith.v1",
"nodes": [
{ "id": "a", "type": "A", "position": { "x": 0, "y": 40 }, "render": { "title": "A", "category": "data" },
"pins": [
{ "id": "a:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "a:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "b", "type": "B", "position": { "x": 230, "y": 40 }, "render": { "title": "B", "category": "data" },
"pins": [
{ "id": "b:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "b:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "c", "type": "C", "position": { "x": 460, "y": 40 }, "render": { "title": "C", "category": "data" },
"pins": [
{ "id": "c:in", "kind": "data", "direction": "in", "type": "float", "multiple": false, "label": "In" },
{ "id": "c:out", "kind": "data", "direction": "out", "type": "float", "multiple": true, "label": "Out" }
] },
{ "id": "text", "type": "Text", "position": { "x": 40, "y": 210 }, "render": { "title": "Text", "category": "logic" },
"pins": [
{ "id": "text:out", "kind": "data", "direction": "out", "type": "string", "multiple": true, "label": "Out" }
] },
{ "id": "caption", "type": "Caption", "position": { "x": 460, "y": 210 }, "render": { "title": "Caption", "category": "logic" },
"pins": [
{ "id": "caption:in", "kind": "data", "direction": "in", "type": "string", "multiple": false, "label": "In" }
] }
],
"edges": [
{ "id": "e-ab", "from": { "node": "a", "pin": "a:out" }, "to": { "node": "b", "pin": "b:in" } },
{ "id": "e-bc", "from": { "node": "b", "pin": "b:out" }, "to": { "node": "c", "pin": "c:in" } },
{ "id": "e-tc", "from": { "node": "text", "pin": "text:out" }, "to": { "node": "caption", "pin": "caption:in" } }
]
} // Angular — the attempt log is a signal. The guard and the graph are shared; viewport
// controls go through editor.chrome (no controls component in this adapter).
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import { buildConnectionValidation, type Attempt } from '@xenolithengine/demo/connection-validation'
@Component({
selector: 'connection-validation-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">Connection rules</p>
<span class="note">
Pins are typed. Drag <b>Text</b> → a float input — refused (snaps back).
Drag <b>C</b> → <b>A</b> — blocked (cycle).
</span>
<div class="log">
@if (log().length === 0) {
<span class="note">No attempts yet.</span>
}
@for (attempt of log(); track $index) {
<span class="line" [class.ok]="attempt.ok" [class.bad]="!attempt.ok">
{{ attempt.ok ? '✓' : '✗' }} {{ attempt.text }}
</span>
}
</div>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; right:12px; z-index:5; display:flex; flex-direction:column;
gap:8px; width:230px; 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); }
.note { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.5; }
.note b { color:var(--xeno-text,#cfcfcf); font-weight:600; }
.log { display:flex; flex-direction:column; gap:3px; max-height:168px; overflow:hidden; }
.line { font-size:11px; font-family:var(--xeno-mono, monospace); }
.line.ok { color:#39d98a; }
.line.bad { color:#ff5b6e; }
`],
})
export class ConnectionValidationDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
log = signal<Attempt[]>([])
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
editor.chrome.setControls({ position: 'bottom-left' })
buildConnectionValidation(editor, (attempt) => {
this.log.update((prev) => [attempt, ...prev].slice(0, 8))
})
}
ngOnDestroy(): void {
this.graph.destroy()
}
} // Showcase: connection rules. Pins are typed (Blueprint-style) so the built-in check refuses a
// string→float wire automatically. On top of that, a custom isValidConnection guard uses the core
// wouldCreateCycle() helper to forbid loops. The graph is DATA (connection-validation.json); the only
// host-specific piece is the `log` sink the guard + edge:connected event report attempts to.
import { wouldCreateCycle } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './connection-validation.json'
export interface Attempt { ok: boolean; text: string }
export function buildConnectionValidation(editor: XenolithEditor, log: (a: Attempt) => void): void {
editor.loadJSON(graph)
const name = (id: NodeId): string => editor.getNode(id)?.type ?? '?'
editor.setIsValidConnection((conn) => {
if (wouldCreateCycle(editor.readGraph(), conn.source, conn.target)) {
log({ ok: false, text: `${name(conn.source)} → ${name(conn.target)} · would create a cycle` })
return false
}
return true
})
editor.on('edge:connected', (e) => log({ ok: true, text: `${name(e.edge.from.node)} → ${name(e.edge.to.node)} · connected` }))
editor.fitView({ padding: 64, maxZoom: 1 })
}