Per-node cost heatmap
A RAG pipeline with per-node latency badges — cool blue → hot red. Press Pulse to see metrics breathe. Drop-in observability overlay; no OSS competitor ships it.
import { useEffect, useRef, useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-react'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
import type { NodeId, NodeSchema } from '@xenolithengine/graph-core'
import { DemoStage } from '../Layout.js'
// Per-node "cost / latency" overlay rendered as a colored dot in editor.overlayRoot positioned
// at the node corner. Color is a cool→hot gradient over the [0..1] metric. Simulated workload
// animates the metrics so the heatmap pulses — landing-ready visual moat (no OSS competitor
// has this out of the box).
const SCHEMAS: NodeSchema[] = [
{ type: 'Source', title: 'Source', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'object', label: 'Out' }] },
{ type: 'Embed', title: 'Embed', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Retrieve', title: 'Retrieve', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Rerank', title: 'Rerank', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Prompt', title: 'Prompt', category: 'data',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Model', title: 'Model', category: 'macro',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Output', title: 'Output', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'object', label: 'In' }] },
]
interface NodeMetric {
nodeId: NodeId
/** Stable baseline metric — never mutated. Pulse oscillates AROUND this value, so the
* initial color ordering survives an unbounded animation. */
base: number
/** Per-node phase offset so the pulse doesn't beat in lockstep. */
phase: number
/** Live displayed metric = base + sin(t + phase)*amp. Recomputed each animation tick. */
metric: number
label: string
}
function buildPipeline(editor: XenolithEditor): NodeMetric[] {
for (const s of SCHEMAS) editor.registry.register(s)
const order: { type: string; x: number; y: number; cost: number; label: string }[] = [
{ type: 'Source', x: 0, y: 100, cost: 0.05, label: '5ms' },
{ type: 'Embed', x: 220, y: 100, cost: 0.4, label: '320ms' },
{ type: 'Retrieve', x: 440, y: 100, cost: 0.6, label: '480ms' },
{ type: 'Rerank', x: 660, y: 100, cost: 0.25, label: '180ms' },
{ type: 'Prompt', x: 880, y: 100, cost: 0.1, label: '50ms' },
{ type: 'Model', x: 1100, y: 100, cost: 0.95, label: '2.3s' },
{ type: 'Output', x: 1340, y: 100, cost: 0.05, label: '8ms' },
]
const created = order.map((spec) => ({ spec, node: editor.insertNode(spec.type, { x: spec.x, y: spec.y })! }))
const o0 = (n: { pins: { id: unknown; direction: string }[] }) => n.pins.find((p) => p.direction === 'out')!.id
const i0 = (n: { pins: { id: unknown; direction: string }[] }) => n.pins.find((p) => p.direction === 'in')!.id
for (let i = 0; i < created.length - 1; i++) {
const a = created[i]!.node, b = created[i + 1]!.node
editor.addEdge({ id: crypto.randomUUID(), from: { node: a.id, pin: o0(a) as never }, to: { node: b.id, pin: i0(b) as never } } as never)
}
editor.view.fitView({ padding: 80 })
return created.map(({ spec, node }, i) => ({
nodeId: node.id, base: spec.cost, phase: i * 0.7, metric: spec.cost, label: spec.label,
}))
}
/** Map a 0..1 metric to a HSL color: 200° (cool blue) → 0° (hot red). */
function heatColor(t: number): string {
const c = Math.max(0, Math.min(1, t))
const hue = 200 - 200 * c
return `hsl(${hue}deg, 80%, 55%)`
}
interface HeatDot { el: HTMLDivElement; label: HTMLSpanElement }
function makeDot(): HeatDot {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', width: '16px', height: '16px', borderRadius: '16px',
pointerEvents: 'none', transform: 'translate(-50%, -50%)', zIndex: '15',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 12px currentColor',
} as Partial<CSSStyleDeclaration>)
const label = document.createElement('span')
Object.assign(label.style, {
position: 'absolute', top: '14px', left: '50%', transform: 'translateX(-50%)',
fontSize: '10px', color: 'var(--xeno-text, #cfcfcf)', whiteSpace: 'nowrap',
fontFamily: 'ui-monospace, monospace', pointerEvents: 'none', fontWeight: '600',
} as Partial<CSSStyleDeclaration>)
el.appendChild(label)
return { el, label }
}
function HeatmapPanel() {
const editor = useEditor()
const metricsRef = useRef<NodeMetric[]>([])
const dotsRef = useRef<Map<string, HeatDot>>(new Map())
const sizeCacheRef = useRef<Map<string, { w: number; h: number }>>(new Map())
const applyMetricsRef = useRef<(() => void) | null>(null)
const [pulsing, setPulsing] = useState(false)
// Setup: build pipeline (idempotent — once per editor), create dots, wire viewport listeners.
useEffect(() => {
metricsRef.current = buildPipeline(editor)
const reposition = (): void => {
// Each node's measured size lands on `n.size` asynchronously — the FIRST paint may have
// `n.size === undefined`. Cache the measured value the moment it becomes defined and
// stick to that forever. This pins the badge under the node's TRUE centre (instead of
// a hardcoded width that doesn't match wider node types) AND prevents the "jump on
// micro-zoom" we saw when `n.size` first appeared.
const FALLBACK_W = 220, FALLBACK_H = 80
const snap = editor.getGraphReadonly()
const nodeById = new Map(snap.nodes.map((nn) => [String(nn.id), nn]))
for (const [id, dot] of dotsRef.current) {
const n = nodeById.get(String(id))
if (!n) continue
let cached = sizeCacheRef.current.get(id)
if (!cached && n.size) {
cached = { w: n.size.x, h: n.size.y }
sizeCacheRef.current.set(id, cached)
}
const w = cached?.w ?? FALLBACK_W
const h = cached?.h ?? FALLBACK_H
const bottomCentre = editor.view.worldToScreen({ x: n.position.x + w / 2, y: n.position.y + h })
dot.el.style.left = `${bottomCentre.x}px`
dot.el.style.top = `${bottomCentre.y + 12}px`
}
}
const applyMetrics = (): void => {
for (const m of metricsRef.current) {
let dot = dotsRef.current.get(String(m.nodeId))
if (!dot) {
dot = makeDot()
editor.chrome.overlayRoot.appendChild(dot.el)
dotsRef.current.set(String(m.nodeId), dot)
}
dot.el.style.color = heatColor(m.metric)
dot.el.style.background = heatColor(m.metric)
dot.label.textContent = m.label
}
reposition()
;(window as unknown as { __xenoHeatmap?: { metrics: { id: string; metric: number; label: string }[] } }).__xenoHeatmap = {
metrics: metricsRef.current.map((m) => ({ id: String(m.nodeId), metric: m.metric, label: m.label })),
}
}
applyMetricsRef.current = applyMetrics
applyMetrics()
editor.on('viewport:changed', reposition)
editor.on('node:moved', reposition)
// PIXI measures node footprint lazily — `n.size` is undefined on the synchronous insertNode
// path but becomes defined a frame or two later. Fire a couple of re-positions so the cache
// captures the REAL measured width instead of sticking to the fallback (badge looked
// off-centre on wider node types — image #37 Prompt).
requestAnimationFrame(() => requestAnimationFrame(() => { reposition(); setTimeout(reposition, 100) }))
return () => {
for (const [, d] of dotsRef.current) d.el.remove()
dotsRef.current.clear()
sizeCacheRef.current.clear()
applyMetricsRef.current = null
;(window as unknown as { __xenoHeatmap?: unknown }).__xenoHeatmap = undefined
}
}, [editor])
// Pulse: oscillate around each node's BASE cost. Computing from `base` (not the last metric)
// means the color ordering stays stable forever — even after thousands of frames the Model
// stays red and Source stays blue. Amplitude 0.08 lets each ring breathe ~+/-10%.
useEffect(() => {
if (!pulsing) return
let raf = 0
const step = (): void => {
const t = performance.now() / 500
metricsRef.current = metricsRef.current.map((m) => ({
...m,
metric: Math.max(0.02, Math.min(1, m.base + Math.sin(t + m.phase) * 0.08)),
}))
applyMetricsRef.current?.()
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [pulsing])
return (
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 320, padding: 10 }}>
<div style={{ fontSize: 12, color: 'var(--xeno-text)', fontWeight: 600 }}>Per-node cost heatmap</div>
<div style={{ fontSize: 11, color: 'var(--xeno-muted)', lineHeight: 1.4 }}>
A simulated RAG pipeline. Each node has a per-call latency badge —
<span style={{ color: 'hsl(200deg,80%,55%)' }}> cool blue</span> for cheap,
<span style={{ color: 'hsl(40deg,80%,55%)' }}> warm</span> for medium,
<span style={{ color: 'hsl(0deg,80%,55%)' }}> hot red</span> for the bottleneck.
Press Pulse to animate live metrics.
</div>
<XenolithButton active={pulsing} onClick={() => setPulsing(!pulsing)}>
{pulsing ? '⏸ Pause pulse' : '▶ Pulse'}
</XenolithButton>
</XenolithPanel>
)
}
export function HeatmapDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false}>
<HeatmapPanel />
</XenolithGraph>
</DemoStage>
)
} <script setup lang="ts">
import { XenolithGraph } from '@xenolithengine/graph-vue'
import HeatmapPanel from './HeatmapPanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false">
<HeatmapPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import { XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-vue'
import { mountHeatmap, type HeatmapHandle } from '@xenolithengine/demo/heatmap'
const editor = useEditor()
const pulsing = ref(false)
let handle: HeatmapHandle | null = null
watch(editor, (ed) => {
handle?.dispose()
handle = null
pulsing.value = false
if (!ed) return
handle = mountHeatmap(ed)
}, { immediate: true })
onUnmounted(() => { handle?.dispose(); handle = null })
function toggle(): void {
pulsing.value = !pulsing.value
handle?.setPulsing(pulsing.value)
}
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:320px;">
<div style="font-size:12px;font-weight:600;">Per-node cost heatmap</div>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
A simulated RAG pipeline. Each node has a per-call latency badge —
<span style="color:hsl(200deg,80%,55%)"> cool blue</span> for cheap,
<span style="color:hsl(40deg,80%,55%)"> warm</span> for medium,
<span style="color:hsl(0deg,80%,55%)"> hot red</span> for the bottleneck.
Press Pulse to animate live metrics.
</div>
<XenolithButton :active="pulsing" @click="toggle">
{{ pulsing ? '⏸ Pause pulse' : '▶ Pulse' }}
</XenolithButton>
</div>
</XenolithPanel>
</template> // Per-node latency badges for the heatmap showcase. The React island keeps its own copy
// because Playwright reads `window.__xenoHeatmap` off that file. These helpers are the
// same behaviour for every other host: dots live in editor.chrome.overlayRoot.
import type { NodeId, NodeSchema } from '@xenolithengine/graph-core'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Source', title: 'Source', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'object', label: 'Out' }] },
{ type: 'Embed', title: 'Embed', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Retrieve', title: 'Retrieve', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Rerank', title: 'Rerank', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Prompt', title: 'Prompt', category: 'data',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Model', title: 'Model', category: 'macro',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Output', title: 'Output', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'object', label: 'In' }] },
]
interface NodeMetric {
nodeId: NodeId
base: number
phase: number
metric: number
label: string
}
function heatColor(t: number): string {
const c = Math.max(0, Math.min(1, t))
return `hsl(${200 - 200 * c}deg, 80%, 55%)`
}
function makeDot(): { el: HTMLDivElement; label: HTMLSpanElement } {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', width: '16px', height: '16px', borderRadius: '16px',
pointerEvents: 'none', transform: 'translate(-50%, -50%)', zIndex: '15',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 12px currentColor',
})
const label = document.createElement('span')
Object.assign(label.style, {
position: 'absolute', top: '14px', left: '50%', transform: 'translateX(-50%)',
fontSize: '10px', color: 'var(--xeno-text, #cfcfcf)', whiteSpace: 'nowrap',
fontFamily: 'ui-monospace, monospace', pointerEvents: 'none', fontWeight: '600',
})
el.appendChild(label)
return { el, label }
}
export interface HeatmapHandle {
setPulsing(on: boolean): void
dispose(): void
}
/** Build the RAG chain and pin a latency badge under each node. */
export function mountHeatmap(editor: XenolithEditor): HeatmapHandle {
for (const s of SCHEMAS) editor.registry.register(s)
const order = [
{ type: 'Source', x: 0, y: 100, cost: 0.05, label: '5ms' },
{ type: 'Embed', x: 220, y: 100, cost: 0.4, label: '320ms' },
{ type: 'Retrieve', x: 440, y: 100, cost: 0.6, label: '480ms' },
{ type: 'Rerank', x: 660, y: 100, cost: 0.25, label: '180ms' },
{ type: 'Prompt', x: 880, y: 100, cost: 0.1, label: '50ms' },
{ type: 'Model', x: 1100, y: 100, cost: 0.95, label: '2.3s' },
{ type: 'Output', x: 1340, y: 100, cost: 0.05, label: '8ms' },
]
const created = order.map((spec) => ({ spec, node: editor.insertNode(spec.type, { x: spec.x, y: spec.y })! }))
const outPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'out')!.id
const inPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'in')!.id
for (let i = 0; i < created.length - 1; i++) {
const a = created[i]!.node
const b = created[i + 1]!.node
editor.addEdge({
id: crypto.randomUUID(),
from: { node: a.id, pin: outPin(a) },
to: { node: b.id, pin: inPin(b) },
} as never)
}
editor.view.fitView({ padding: 80 })
let metrics: NodeMetric[] = created.map(({ spec, node }, i) => ({
nodeId: node.id, base: spec.cost, phase: i * 0.7, metric: spec.cost, label: spec.label,
}))
const dots = new Map<string, { el: HTMLDivElement; label: HTMLSpanElement }>()
const sizes = new Map<string, { w: number; h: number }>()
const reposition = (): void => {
const snap = editor.getGraphReadonly()
const nodeById = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [id, dot] of dots) {
const n = nodeById.get(id)
if (!n) continue
let cached = sizes.get(id)
if (!cached && n.size) {
cached = { w: n.size.x, h: n.size.y }
sizes.set(id, cached)
}
const w = cached?.w ?? 220
const h = cached?.h ?? 80
const p = editor.view.worldToScreen({ x: n.position.x + w / 2, y: n.position.y + h })
dot.el.style.left = `${p.x}px`
dot.el.style.top = `${p.y + 12}px`
}
}
const apply = (): void => {
for (const m of metrics) {
let dot = dots.get(String(m.nodeId))
if (!dot) {
dot = makeDot()
editor.chrome.overlayRoot.appendChild(dot.el)
dots.set(String(m.nodeId), dot)
}
const color = heatColor(m.metric)
dot.el.style.color = color
dot.el.style.background = color
dot.label.textContent = m.label
}
reposition()
}
apply()
const offView = editor.on('viewport:changed', reposition)
const offMove = editor.on('node:moved', reposition)
requestAnimationFrame(() => requestAnimationFrame(() => { reposition(); setTimeout(reposition, 100) }))
let raf = 0
return {
setPulsing(on: boolean): void {
cancelAnimationFrame(raf)
if (!on) {
metrics = metrics.map((m) => ({ ...m, metric: m.base }))
apply()
return
}
const step = (): void => {
const t = performance.now() / 500
metrics = metrics.map((m) => ({
...m,
metric: Math.max(0.02, Math.min(1, m.base + Math.sin(t + m.phase) * 0.08)),
}))
apply()
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
},
dispose(): void {
cancelAnimationFrame(raf)
offView()
offMove()
for (const dot of dots.values()) dot.el.remove()
dots.clear()
},
}
} <script lang="ts">
import { onDestroy } from 'svelte'
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import { mountHeatmap, type HeatmapHandle } from '@xenolithengine/demo/heatmap'
const panelEditor = createXenolithEditorContext()
let pulsing = $state(false)
let handle: HeatmapHandle | null = null
function toggle(): void {
pulsing = !pulsing
handle?.setPulsing(pulsing)
}
onDestroy(() => { handle?.dispose(); handle = null })
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => { panelEditor.set(e.detail); handle = mountHeatmap(e.detail) }}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:8px;max-width:320px;">
<div style="font-size:12px;font-weight:600;">Per-node cost heatmap</div>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
A simulated RAG pipeline. Each node has a per-call latency badge —
<span style="color:hsl(200deg,80%,55%)"> cool blue</span> for cheap,
<span style="color:hsl(40deg,80%,55%)"> warm</span> for medium,
<span style="color:hsl(0deg,80%,55%)"> hot red</span> for the bottleneck.
Press Pulse to animate live metrics.
</div>
<XenolithButton active={pulsing} onclick={toggle}>
{pulsing ? '⏸ Pause pulse' : '▶ Pulse'}
</XenolithButton>
</div>
</XenolithPanel> // Per-node latency badges for the heatmap showcase. The React island keeps its own copy
// because Playwright reads `window.__xenoHeatmap` off that file. These helpers are the
// same behaviour for every other host: dots live in editor.chrome.overlayRoot.
import type { NodeId, NodeSchema } from '@xenolithengine/graph-core'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Source', title: 'Source', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'object', label: 'Out' }] },
{ type: 'Embed', title: 'Embed', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Retrieve', title: 'Retrieve', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Rerank', title: 'Rerank', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Prompt', title: 'Prompt', category: 'data',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Model', title: 'Model', category: 'macro',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Output', title: 'Output', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'object', label: 'In' }] },
]
interface NodeMetric {
nodeId: NodeId
base: number
phase: number
metric: number
label: string
}
function heatColor(t: number): string {
const c = Math.max(0, Math.min(1, t))
return `hsl(${200 - 200 * c}deg, 80%, 55%)`
}
function makeDot(): { el: HTMLDivElement; label: HTMLSpanElement } {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', width: '16px', height: '16px', borderRadius: '16px',
pointerEvents: 'none', transform: 'translate(-50%, -50%)', zIndex: '15',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 12px currentColor',
})
const label = document.createElement('span')
Object.assign(label.style, {
position: 'absolute', top: '14px', left: '50%', transform: 'translateX(-50%)',
fontSize: '10px', color: 'var(--xeno-text, #cfcfcf)', whiteSpace: 'nowrap',
fontFamily: 'ui-monospace, monospace', pointerEvents: 'none', fontWeight: '600',
})
el.appendChild(label)
return { el, label }
}
export interface HeatmapHandle {
setPulsing(on: boolean): void
dispose(): void
}
/** Build the RAG chain and pin a latency badge under each node. */
export function mountHeatmap(editor: XenolithEditor): HeatmapHandle {
for (const s of SCHEMAS) editor.registry.register(s)
const order = [
{ type: 'Source', x: 0, y: 100, cost: 0.05, label: '5ms' },
{ type: 'Embed', x: 220, y: 100, cost: 0.4, label: '320ms' },
{ type: 'Retrieve', x: 440, y: 100, cost: 0.6, label: '480ms' },
{ type: 'Rerank', x: 660, y: 100, cost: 0.25, label: '180ms' },
{ type: 'Prompt', x: 880, y: 100, cost: 0.1, label: '50ms' },
{ type: 'Model', x: 1100, y: 100, cost: 0.95, label: '2.3s' },
{ type: 'Output', x: 1340, y: 100, cost: 0.05, label: '8ms' },
]
const created = order.map((spec) => ({ spec, node: editor.insertNode(spec.type, { x: spec.x, y: spec.y })! }))
const outPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'out')!.id
const inPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'in')!.id
for (let i = 0; i < created.length - 1; i++) {
const a = created[i]!.node
const b = created[i + 1]!.node
editor.addEdge({
id: crypto.randomUUID(),
from: { node: a.id, pin: outPin(a) },
to: { node: b.id, pin: inPin(b) },
} as never)
}
editor.view.fitView({ padding: 80 })
let metrics: NodeMetric[] = created.map(({ spec, node }, i) => ({
nodeId: node.id, base: spec.cost, phase: i * 0.7, metric: spec.cost, label: spec.label,
}))
const dots = new Map<string, { el: HTMLDivElement; label: HTMLSpanElement }>()
const sizes = new Map<string, { w: number; h: number }>()
const reposition = (): void => {
const snap = editor.getGraphReadonly()
const nodeById = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [id, dot] of dots) {
const n = nodeById.get(id)
if (!n) continue
let cached = sizes.get(id)
if (!cached && n.size) {
cached = { w: n.size.x, h: n.size.y }
sizes.set(id, cached)
}
const w = cached?.w ?? 220
const h = cached?.h ?? 80
const p = editor.view.worldToScreen({ x: n.position.x + w / 2, y: n.position.y + h })
dot.el.style.left = `${p.x}px`
dot.el.style.top = `${p.y + 12}px`
}
}
const apply = (): void => {
for (const m of metrics) {
let dot = dots.get(String(m.nodeId))
if (!dot) {
dot = makeDot()
editor.chrome.overlayRoot.appendChild(dot.el)
dots.set(String(m.nodeId), dot)
}
const color = heatColor(m.metric)
dot.el.style.color = color
dot.el.style.background = color
dot.label.textContent = m.label
}
reposition()
}
apply()
const offView = editor.on('viewport:changed', reposition)
const offMove = editor.on('node:moved', reposition)
requestAnimationFrame(() => requestAnimationFrame(() => { reposition(); setTimeout(reposition, 100) }))
let raf = 0
return {
setPulsing(on: boolean): void {
cancelAnimationFrame(raf)
if (!on) {
metrics = metrics.map((m) => ({ ...m, metric: m.base }))
apply()
return
}
const step = (): void => {
const t = performance.now() / 500
metrics = metrics.map((m) => ({
...m,
metric: Math.max(0.02, Math.min(1, m.base + Math.sin(t + m.phase) * 0.08)),
}))
apply()
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
},
dispose(): void {
cancelAnimationFrame(raf)
offView()
offMove()
for (const dot of dots.values()) dot.el.remove()
dots.clear()
},
}
} import { createSignal, onCleanup } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import { mountHeatmap, type HeatmapHandle } from '@xenolithengine/demo/heatmap'
export function HeatmapDemo() {
const [pulsing, setPulsing] = createSignal(false)
let handle: HeatmapHandle | null = null
onCleanup(() => { handle?.dispose(); handle = null })
const toggle = (): void => {
const next = !pulsing()
setPulsing(next)
handle?.setPulsing(next)
}
const btn = (on: boolean): string =>
`font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;cursor:pointer;border:1px solid ${on ? 'var(--xeno-accent)' : 'var(--xeno-border)'};background:${on ? 'var(--xeno-accent)' : 'var(--xeno-elevated)'};color:${on ? 'var(--xeno-canvas)' : 'var(--xeno-text)'};`
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => { handle = mountHeatmap(e.detail) }}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;display:flex;flex-direction:column;gap:8px;max-width:320px;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;">
<div style="font-size:12px;font-weight:600;">Per-node cost heatmap</div>
<div style="font-size:11px;color:var(--xeno-muted);line-height:1.4;">
A simulated RAG pipeline. Each node has a per-call latency badge —
<span style="color:hsl(200deg,80%,55%)"> cool blue</span> for cheap,
<span style="color:hsl(40deg,80%,55%)"> warm</span> for medium,
<span style="color:hsl(0deg,80%,55%)"> hot red</span> for the bottleneck.
Press Pulse to animate live metrics.
</div>
<button type="button" style={btn(pulsing())} onClick={toggle}>{pulsing() ? '⏸ Pause pulse' : '▶ Pulse'}</button>
</div>
</div>
)
} // Per-node latency badges for the heatmap showcase. The React island keeps its own copy
// because Playwright reads `window.__xenoHeatmap` off that file. These helpers are the
// same behaviour for every other host: dots live in editor.chrome.overlayRoot.
import type { NodeId, NodeSchema } from '@xenolithengine/graph-core'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Source', title: 'Source', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'object', label: 'Out' }] },
{ type: 'Embed', title: 'Embed', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Retrieve', title: 'Retrieve', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Rerank', title: 'Rerank', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Prompt', title: 'Prompt', category: 'data',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Model', title: 'Model', category: 'macro',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Output', title: 'Output', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'object', label: 'In' }] },
]
interface NodeMetric {
nodeId: NodeId
base: number
phase: number
metric: number
label: string
}
function heatColor(t: number): string {
const c = Math.max(0, Math.min(1, t))
return `hsl(${200 - 200 * c}deg, 80%, 55%)`
}
function makeDot(): { el: HTMLDivElement; label: HTMLSpanElement } {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', width: '16px', height: '16px', borderRadius: '16px',
pointerEvents: 'none', transform: 'translate(-50%, -50%)', zIndex: '15',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 12px currentColor',
})
const label = document.createElement('span')
Object.assign(label.style, {
position: 'absolute', top: '14px', left: '50%', transform: 'translateX(-50%)',
fontSize: '10px', color: 'var(--xeno-text, #cfcfcf)', whiteSpace: 'nowrap',
fontFamily: 'ui-monospace, monospace', pointerEvents: 'none', fontWeight: '600',
})
el.appendChild(label)
return { el, label }
}
export interface HeatmapHandle {
setPulsing(on: boolean): void
dispose(): void
}
/** Build the RAG chain and pin a latency badge under each node. */
export function mountHeatmap(editor: XenolithEditor): HeatmapHandle {
for (const s of SCHEMAS) editor.registry.register(s)
const order = [
{ type: 'Source', x: 0, y: 100, cost: 0.05, label: '5ms' },
{ type: 'Embed', x: 220, y: 100, cost: 0.4, label: '320ms' },
{ type: 'Retrieve', x: 440, y: 100, cost: 0.6, label: '480ms' },
{ type: 'Rerank', x: 660, y: 100, cost: 0.25, label: '180ms' },
{ type: 'Prompt', x: 880, y: 100, cost: 0.1, label: '50ms' },
{ type: 'Model', x: 1100, y: 100, cost: 0.95, label: '2.3s' },
{ type: 'Output', x: 1340, y: 100, cost: 0.05, label: '8ms' },
]
const created = order.map((spec) => ({ spec, node: editor.insertNode(spec.type, { x: spec.x, y: spec.y })! }))
const outPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'out')!.id
const inPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'in')!.id
for (let i = 0; i < created.length - 1; i++) {
const a = created[i]!.node
const b = created[i + 1]!.node
editor.addEdge({
id: crypto.randomUUID(),
from: { node: a.id, pin: outPin(a) },
to: { node: b.id, pin: inPin(b) },
} as never)
}
editor.view.fitView({ padding: 80 })
let metrics: NodeMetric[] = created.map(({ spec, node }, i) => ({
nodeId: node.id, base: spec.cost, phase: i * 0.7, metric: spec.cost, label: spec.label,
}))
const dots = new Map<string, { el: HTMLDivElement; label: HTMLSpanElement }>()
const sizes = new Map<string, { w: number; h: number }>()
const reposition = (): void => {
const snap = editor.getGraphReadonly()
const nodeById = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [id, dot] of dots) {
const n = nodeById.get(id)
if (!n) continue
let cached = sizes.get(id)
if (!cached && n.size) {
cached = { w: n.size.x, h: n.size.y }
sizes.set(id, cached)
}
const w = cached?.w ?? 220
const h = cached?.h ?? 80
const p = editor.view.worldToScreen({ x: n.position.x + w / 2, y: n.position.y + h })
dot.el.style.left = `${p.x}px`
dot.el.style.top = `${p.y + 12}px`
}
}
const apply = (): void => {
for (const m of metrics) {
let dot = dots.get(String(m.nodeId))
if (!dot) {
dot = makeDot()
editor.chrome.overlayRoot.appendChild(dot.el)
dots.set(String(m.nodeId), dot)
}
const color = heatColor(m.metric)
dot.el.style.color = color
dot.el.style.background = color
dot.label.textContent = m.label
}
reposition()
}
apply()
const offView = editor.on('viewport:changed', reposition)
const offMove = editor.on('node:moved', reposition)
requestAnimationFrame(() => requestAnimationFrame(() => { reposition(); setTimeout(reposition, 100) }))
let raf = 0
return {
setPulsing(on: boolean): void {
cancelAnimationFrame(raf)
if (!on) {
metrics = metrics.map((m) => ({ ...m, metric: m.base }))
apply()
return
}
const step = (): void => {
const t = performance.now() / 500
metrics = metrics.map((m) => ({
...m,
metric: Math.max(0.02, Math.min(1, m.base + Math.sin(t + m.phase) * 0.08)),
}))
apply()
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
},
dispose(): void {
cancelAnimationFrame(raf)
offView()
offMove()
for (const dot of dots.values()) dot.el.remove()
dots.clear()
},
}
} import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import { mountHeatmap, type HeatmapHandle } from '@xenolithengine/demo/heatmap'
@Component({
selector: 'heatmap-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">
<div class="title">Per-node cost heatmap</div>
<span class="note">
A simulated RAG pipeline. Each node has a per-call latency badge —
<span style="color:hsl(200deg,80%,55%)"> cool blue</span> for cheap,
<span style="color:hsl(40deg,80%,55%)"> warm</span> for medium,
<span style="color:hsl(0deg,80%,55%)"> hot red</span> for the bottleneck.
Press Pulse to animate live metrics.
</span>
<button type="button" class="btn" [class.on]="pulsing()" (click)="toggle()">
{{ pulsing() ? '⏸ Pause pulse' : '▶ Pulse' }}
</button>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; left:12px; z-index:5; display:flex; flex-direction:column;
gap:8px; max-width:320px; 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; }
.title { font-size:12px; font-weight:600; }
.note { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.4; }
.btn { font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; border-radius:8px;
border:1px solid var(--xeno-border,#333); background:var(--xeno-elevated,#262626); color:var(--xeno-text,#cfcfcf); }
.btn.on { border-color:var(--xeno-accent,#FCB400); background:var(--xeno-accent,#FCB400); color:var(--xeno-canvas,#111); }
`],
})
export class HeatmapDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private handle: HeatmapHandle | null = null
pulsing = signal(false)
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
this.handle = mountHeatmap(editor)
}
toggle(): void {
const next = !this.pulsing()
this.pulsing.set(next)
this.handle?.setPulsing(next)
}
ngOnDestroy(): void {
this.handle?.dispose()
this.handle = null
this.graph.destroy()
}
} // Per-node latency badges for the heatmap showcase. The React island keeps its own copy
// because Playwright reads `window.__xenoHeatmap` off that file. These helpers are the
// same behaviour for every other host: dots live in editor.chrome.overlayRoot.
import type { NodeId, NodeSchema } from '@xenolithengine/graph-core'
import type { XenolithEditor } from '@xenolithengine/graph-editor'
const SCHEMAS: NodeSchema[] = [
{ type: 'Source', title: 'Source', category: 'data',
pins: [{ kind: 'data', direction: 'out', type: 'object', label: 'Out' }] },
{ type: 'Embed', title: 'Embed', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Retrieve', title: 'Retrieve', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Rerank', title: 'Rerank', category: 'logic',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Prompt', title: 'Prompt', category: 'data',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Model', title: 'Model', category: 'macro',
pins: [
{ kind: 'data', direction: 'in', type: 'object', label: 'In' },
{ kind: 'data', direction: 'out', type: 'object', label: 'Out' },
] },
{ type: 'Output', title: 'Output', category: 'utility',
pins: [{ kind: 'data', direction: 'in', type: 'object', label: 'In' }] },
]
interface NodeMetric {
nodeId: NodeId
base: number
phase: number
metric: number
label: string
}
function heatColor(t: number): string {
const c = Math.max(0, Math.min(1, t))
return `hsl(${200 - 200 * c}deg, 80%, 55%)`
}
function makeDot(): { el: HTMLDivElement; label: HTMLSpanElement } {
const el = document.createElement('div')
Object.assign(el.style, {
position: 'absolute', width: '16px', height: '16px', borderRadius: '16px',
pointerEvents: 'none', transform: 'translate(-50%, -50%)', zIndex: '15',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 12px currentColor',
})
const label = document.createElement('span')
Object.assign(label.style, {
position: 'absolute', top: '14px', left: '50%', transform: 'translateX(-50%)',
fontSize: '10px', color: 'var(--xeno-text, #cfcfcf)', whiteSpace: 'nowrap',
fontFamily: 'ui-monospace, monospace', pointerEvents: 'none', fontWeight: '600',
})
el.appendChild(label)
return { el, label }
}
export interface HeatmapHandle {
setPulsing(on: boolean): void
dispose(): void
}
/** Build the RAG chain and pin a latency badge under each node. */
export function mountHeatmap(editor: XenolithEditor): HeatmapHandle {
for (const s of SCHEMAS) editor.registry.register(s)
const order = [
{ type: 'Source', x: 0, y: 100, cost: 0.05, label: '5ms' },
{ type: 'Embed', x: 220, y: 100, cost: 0.4, label: '320ms' },
{ type: 'Retrieve', x: 440, y: 100, cost: 0.6, label: '480ms' },
{ type: 'Rerank', x: 660, y: 100, cost: 0.25, label: '180ms' },
{ type: 'Prompt', x: 880, y: 100, cost: 0.1, label: '50ms' },
{ type: 'Model', x: 1100, y: 100, cost: 0.95, label: '2.3s' },
{ type: 'Output', x: 1340, y: 100, cost: 0.05, label: '8ms' },
]
const created = order.map((spec) => ({ spec, node: editor.insertNode(spec.type, { x: spec.x, y: spec.y })! }))
const outPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'out')!.id
const inPin = (n: { pins: { id: string; direction: string }[] }) => n.pins.find((p) => p.direction === 'in')!.id
for (let i = 0; i < created.length - 1; i++) {
const a = created[i]!.node
const b = created[i + 1]!.node
editor.addEdge({
id: crypto.randomUUID(),
from: { node: a.id, pin: outPin(a) },
to: { node: b.id, pin: inPin(b) },
} as never)
}
editor.view.fitView({ padding: 80 })
let metrics: NodeMetric[] = created.map(({ spec, node }, i) => ({
nodeId: node.id, base: spec.cost, phase: i * 0.7, metric: spec.cost, label: spec.label,
}))
const dots = new Map<string, { el: HTMLDivElement; label: HTMLSpanElement }>()
const sizes = new Map<string, { w: number; h: number }>()
const reposition = (): void => {
const snap = editor.getGraphReadonly()
const nodeById = new Map(snap.nodes.map((n) => [String(n.id), n]))
for (const [id, dot] of dots) {
const n = nodeById.get(id)
if (!n) continue
let cached = sizes.get(id)
if (!cached && n.size) {
cached = { w: n.size.x, h: n.size.y }
sizes.set(id, cached)
}
const w = cached?.w ?? 220
const h = cached?.h ?? 80
const p = editor.view.worldToScreen({ x: n.position.x + w / 2, y: n.position.y + h })
dot.el.style.left = `${p.x}px`
dot.el.style.top = `${p.y + 12}px`
}
}
const apply = (): void => {
for (const m of metrics) {
let dot = dots.get(String(m.nodeId))
if (!dot) {
dot = makeDot()
editor.chrome.overlayRoot.appendChild(dot.el)
dots.set(String(m.nodeId), dot)
}
const color = heatColor(m.metric)
dot.el.style.color = color
dot.el.style.background = color
dot.label.textContent = m.label
}
reposition()
}
apply()
const offView = editor.on('viewport:changed', reposition)
const offMove = editor.on('node:moved', reposition)
requestAnimationFrame(() => requestAnimationFrame(() => { reposition(); setTimeout(reposition, 100) }))
let raf = 0
return {
setPulsing(on: boolean): void {
cancelAnimationFrame(raf)
if (!on) {
metrics = metrics.map((m) => ({ ...m, metric: m.base }))
apply()
return
}
const step = (): void => {
const t = performance.now() / 500
metrics = metrics.map((m) => ({
...m,
metric: Math.max(0.02, Math.min(1, m.base + Math.sin(t + m.phase) * 0.08)),
}))
apply()
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
},
dispose(): void {
cancelAnimationFrame(raf)
offView()
offMove()
for (const dot of dots.values()) dot.el.remove()
dots.clear()
},
}
}