'use client' import { useId, useMemo } from 'react' import { Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import { badgeGlyph, dimOpacity, intentColor, spotlightColor, } from '@/lib/interactive-demo/intent-colors' import type { DemoScene, Panel } from '@/lib/interactive-demo/types' import type { ResolvedAnnotation, StepResolvedState, } from '@/lib/interactive-demo/resolve' import { useDarkMode } from '@/components/interactive-demo/demo-speak' import { DemoRichLabel } from '@/components/interactive-demo/demo-rich-label' import { cn } from '@/lib/utils' type AnnKind = ResolvedAnnotation['kind'] export type DemoSceneViewProps = { scene: DemoScene state: StepResolvedState reducedMotion?: boolean className?: string } type NodePos = { x: number y: number w: number h: number } function elementOpacity( id: string, state: StepResolvedState, dim: number ): number { // Overview = full scene at full brightness (matches edges behavior) if (state.overview) return 1 if (!(id in state.revealed)) return 0 if (state.spotlight.length === 0) return 1 return state.spotlight.includes(id) ? 1 : dim } function estimateNodeSize(label: string): { w: number; h: number } { const lines = label.split(/\\n|\n/) const hasMath = /\$/.test(label) const visualLen = (s: string) => s.replace(/\$[^$]+\$/g, (m) => 'x'.repeat(Math.min(18, Math.max(6, m.length * 0.45)))) .length const longest = Math.max(...lines.map(visualLen), 6) const w = Math.min(280, Math.max(128, longest * 7.4 + 40)) const h = Math.max( hasMath ? 58 : 48, 30 + lines.length * (hasMath ? 24 : 18) ) return { w, h } } /** Prefer circular layout when edges form a closed loop covering most nodes. */ function findCycleOrder( ids: string[], edges: { from: string; to: string }[] ): string[] | null { if (ids.length < 3) return null const idSet = new Set(ids) const outs = new Map() for (const id of ids) outs.set(id, []) for (const e of edges) { if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue outs.get(e.from)!.push(e.to) } for (const start of ids) { const path = [start] const seen = new Set([start]) let cur = start while (path.length < ids.length) { const nexts = (outs.get(cur) ?? []).filter((t) => !seen.has(t)) if (nexts.length === 0) break // Prefer continuing a simple cycle (single unused neighbor) const n = nexts[0]! path.push(n) seen.add(n) cur = n } const closes = (outs.get(cur) ?? []).includes(start) if (closes && path.length >= 3 && path.length >= Math.ceil(ids.length * 0.75)) { return path } } return null } /** * Layout strategies: * 1. AttnRes trunk pattern (residualTrunk + layers) * 2. Closed cycle → circular * 3. DAG layered (top → bottom) * 4. Fallback grid */ function layoutNodes( nodes: { id: string; label?: string }[], edges: { from: string; to: string }[] ): { positions: Map; width: number; height: number } { const sizes = new Map( nodes.map((n) => [n.id, estimateNodeSize(n.label ?? n.id)] as const) ) const positions = new Map() const padX = 40 const padY = 36 const gapX = 52 const gapY = 32 const trunk = nodes.find((n) => n.id === 'residualTrunk') const layersOnly = nodes.filter((n) => n.id !== 'residualTrunk') if (trunk && layersOnly.length >= 2) { const layerSizes = layersOnly.map((n) => sizes.get(n.id)!) const maxLayerW = Math.max(...layerSizes.map((s) => s.w), 96) const trunkSize = sizes.get(trunk.id)! const contentH = padY * 2 + layerSizes.reduce((acc, s) => acc + s.h, 0) + gapY * Math.max(0, layersOnly.length - 1) const height = Math.max(240, contentH) const width = padX * 2 + maxLayerW + gapX + trunkSize.w + 24 let y = padY layersOnly.forEach((n) => { const s = sizes.get(n.id)! positions.set(n.id, { x: padX + maxLayerW / 2, y: y + s.h / 2, w: s.w, h: s.h, }) y += s.h + gapY }) positions.set(trunk.id, { x: padX + maxLayerW + gapX + trunkSize.w / 2, y: height / 2, w: trunkSize.w, h: trunkSize.h, }) return { positions, width, height } } const cycle = findCycleOrder( nodes.map((n) => n.id), edges ) if (cycle) { const maxW = Math.max(...cycle.map((id) => sizes.get(id)!.w), 128) const maxH = Math.max(...cycle.map((id) => sizes.get(id)!.h), 48) const radius = Math.max(110, (cycle.length * 42) / (2 * Math.PI) + maxW * 0.35) const leftovers = nodes.filter((n) => !cycle.includes(n.id)) const leftoverRowH = leftovers.length ? maxH + gapY : 0 const width = Math.max( 420, padX * 2 + radius * 2 + maxW, padX * 2 + leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) + gapX * Math.max(0, leftovers.length - 1) ) const height = Math.max(360, padY * 2 + radius * 2 + maxH + leftoverRowH) const cx = width / 2 const cy = padY + radius + maxH / 2 cycle.forEach((id, i) => { const s = sizes.get(id)! const angle = -Math.PI / 2 + (2 * Math.PI * i) / cycle.length positions.set(id, { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle), w: s.w, h: s.h, }) }) // Leftover nodes: horizontal row BELOW the ring (never overlapping it) if (leftovers.length) { const totalW = leftovers.reduce((acc, n) => acc + sizes.get(n.id)!.w, 0) + gapX * (leftovers.length - 1) let x = cx - totalW / 2 const y = cy + radius + maxH / 2 + gapY + maxH / 2 for (const n of leftovers) { const s = sizes.get(n.id)! positions.set(n.id, { x: x + s.w / 2, y, w: s.w, h: s.h }) x += s.w + gapX } } return { positions, width, height } } // Topological layers (sources at top) const ids = nodes.map((n) => n.id) const idSet = new Set(ids) const indeg = new Map(ids.map((id) => [id, 0])) const outs = new Map(ids.map((id) => [id, [] as string[]])) for (const e of edges) { if (!idSet.has(e.from) || !idSet.has(e.to) || e.from === e.to) continue indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1) outs.get(e.from)!.push(e.to) } const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0) const order: string[] = [] const depth = new Map() queue.forEach((id) => depth.set(id, 0)) const q = [...queue] while (q.length) { const u = q.shift()! order.push(u) for (const v of outs.get(u) ?? []) { depth.set(v, Math.max(depth.get(v) ?? 0, (depth.get(u) ?? 0) + 1)) indeg.set(v, (indeg.get(v) ?? 1) - 1) if ((indeg.get(v) ?? 0) === 0) q.push(v) } } const isDag = order.length === ids.length && edges.length > 0 if (isDag) { const byDepth = new Map() for (const id of ids) { const d = depth.get(id) ?? 0 if (!byDepth.has(d)) byDepth.set(d, []) byDepth.get(d)!.push(id) } const maxDepth = Math.max(...byDepth.keys(), 0) const rowHeights: number[] = [] let width = padX * 2 for (let d = 0; d <= maxDepth; d++) { const row = byDepth.get(d) ?? [] const rowH = Math.max(...row.map((id) => sizes.get(id)!.h), 36) rowHeights.push(rowH) const rowW = row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) + gapX * Math.max(0, row.length - 1) width = Math.max(width, padX * 2 + rowW) } const height = padY * 2 + rowHeights.reduce((a, b) => a + b, 0) + gapY * Math.max(0, maxDepth) let y = padY for (let d = 0; d <= maxDepth; d++) { const row = byDepth.get(d) ?? [] const rowH = rowHeights[d]! const rowW = row.reduce((acc, id) => acc + sizes.get(id)!.w, 0) + gapX * Math.max(0, row.length - 1) let x = (width - rowW) / 2 for (const id of row) { const s = sizes.get(id)! positions.set(id, { x: x + s.w / 2, y: y + rowH / 2, w: s.w, h: s.h, }) x += s.w + gapX } y += rowH + gapY } return { positions, width: Math.max(400, width), height: Math.max(220, height) } } // Grid fallback const cols = Math.min(3, Math.max(1, Math.ceil(Math.sqrt(nodes.length)))) const rows = Math.ceil(nodes.length / cols) const colW: number[] = Array.from({ length: cols }, (_, c) => { let max = 120 nodes.forEach((n, i) => { if (i % cols === c) max = Math.max(max, sizes.get(n.id)!.w) }) return max }) const rowH: number[] = Array.from({ length: rows }, (_, r) => { let max = 48 nodes.forEach((n, i) => { if (Math.floor(i / cols) === r) max = Math.max(max, sizes.get(n.id)!.h) }) return max }) const width = padX * 2 + colW.reduce((a, b) => a + b, 0) + gapX * Math.max(0, cols - 1) const height = padY * 2 + rowH.reduce((a, b) => a + b, 0) + gapY * Math.max(0, rows - 1) nodes.forEach((n, i) => { const c = i % cols const r = Math.floor(i / cols) const s = sizes.get(n.id)! const xOff = padX + colW.slice(0, c).reduce((a, b) => a + b, 0) + gapX * c + colW[c]! / 2 const yOff = padY + rowH.slice(0, r).reduce((a, b) => a + b, 0) + gapY * r + rowH[r]! / 2 positions.set(n.id, { x: xOff, y: yOff, w: s.w, h: s.h }) }) return { positions, width: Math.max(400, width), height: Math.max(220, height) } } /** Border intersection: line from center A → center B, clipped to rect A. */ function borderPoint( from: NodePos, to: NodePos ): { x: number; y: number } { const dx = to.x - from.x const dy = to.y - from.y if (dx === 0 && dy === 0) return { x: from.x, y: from.y } const hw = from.w / 2 const hh = from.h / 2 const ax = Math.abs(dx) / (hw || 1) const ay = Math.abs(dy) / (hh || 1) const t = 1 / Math.max(ax, ay) return { x: from.x + dx * t, y: from.y + dy * t } } function edgePath(a: NodePos, b: NodePos, tipInset = 10): string { const p0 = borderPoint(a, b) const p1 = borderPoint(b, a) const dx = p1.x - p0.x const dy = p1.y - p0.y const len = Math.hypot(dx, dy) || 1 // Stop short of the target so markerEnd tip lands on the node border, not under the card const inset = Math.min(tipInset, len * 0.35) const endX = p1.x - (dx / len) * inset const endY = p1.y - (dy / len) * inset const mx = (p0.x + endX) / 2 const my = (p0.y + endY) / 2 const bend = Math.min(36, len * 0.22) const cx = mx - (dy / len) * bend const cy = my + (dx / len) * bend return `M ${p0.x} ${p0.y} Q ${cx} ${cy} ${endX} ${endY}` } function AnnotationsOverlay({ annotations, getAnchor, dark, markerId, shadowId, }: { annotations: ResolvedAnnotation[] getAnchor: ( id: string, kind: AnnKind ) => { x: number; y: number } | null dark: boolean markerId: string shadowId?: string }) { const outline = spotlightColor(dark) return ( {annotations.map((ann, i) => { const target = ann.targetIds[0] if (!target) return null const pos = getAnchor(target, ann.kind) if (!pos) return null const color = intentColor(ann.intent, dark) const key = `${ann.kind}-${target}-${i}` if (ann.kind === 'circle') { return ( ) } if (ann.kind === 'badge') { const n = ann.badgeIndex ?? i + 1 return ( {badgeGlyph(n)} ) } if (ann.kind === 'callout') { const text = ann.text ?? '' const w = Math.min(200, Math.max(72, text.length * 6.5 + 20)) return ( {text} ) } if (ann.kind === 'arrow') { return ( ) } return null })} ) } function SvgScenePanel({ panel, state, reducedMotion, dark, }: { panel: Extract state: StepResolvedState reducedMotion?: boolean dark: boolean }) { const uid = useId().replace(/:/g, '') const nodes = panel.payload.nodes const edges = panel.payload.edges ?? [] const dim = dimOpacity(dark) const outline = spotlightColor(dark) const markerId = `demo-arrow-${uid}` const dotsId = `demo-dots-${uid}` const { positions, width, height } = useMemo( () => layoutNodes(nodes, edges), [nodes, edges] ) const transition = reducedMotion ? 'none' : 'opacity 220ms ease, transform 220ms ease, box-shadow 220ms ease' const gridStroke = dark ? 'rgba(255,255,255,0.07)' : 'rgba(15,23,42,0.07)' const boardBg = dark ? '#0c0e12' : '#f7f5f0' return (
{edges.map((e) => { const a = positions.get(e.from) const b = positions.get(e.to) if (!a || !b) return null const revealed = e.id in state.revealed || state.overview if (!revealed) return null const op = elementOpacity(e.id, state, dim) if (op === 0) return null const lit = state.overview || state.spotlight.includes(e.id) const stroke = intentColor(e.intent, dark) const widthStroke = (lit ? 2.8 : 1.8) + (e.weight ?? 1) * 0.45 return ( ) })} { const p = positions.get(id) if (!p) return null if (kind === 'badge') { return { x: p.x + p.w / 2 - 2, y: p.y - p.h / 2 + 2 } } if (kind === 'callout') { return { x: p.x + p.w / 2 - 4, y: p.y } } return { x: p.x, y: p.y } }} /> {nodes.map((n) => { const p = positions.get(n.id) if (!p) return null const op = elementOpacity(n.id, state, dim) if (op === 0) return null const lit = state.overview || state.spotlight.includes(n.id) const accent = intentColor(n.intent, dark) const label = n.label ?? n.id return (
) })}
) } function ChartPanel({ panel, state, reducedMotion, dark, }: { panel: Extract state: StepResolvedState reducedMotion?: boolean dark: boolean }) { const series = panel.payload.series const dim = dimOpacity(dark) const maxLen = Math.max(...series.map((s) => s.values.length), 0) const data = useMemo(() => { return Array.from({ length: maxLen }, (_, i) => { const row: Record = { i: String(i + 1) } for (const s of series) { if (!(s.id in state.revealed)) continue row[s.id] = s.values[i] ?? 0 } return row }) }, [maxLen, series, state.revealed]) const Chart = panel.payload.chartType === 'bar' ? BarChart : panel.payload.chartType === 'area' ? AreaChart : LineChart return (
{series.map((s) => { if (!(s.id in state.revealed)) return null const op = elementOpacity(s.id, state, dim) const stroke = intentColor(s.intent, dark) if (panel.payload.chartType === 'bar') { return ( ) } if (panel.payload.chartType === 'area') { return ( ) } return ( ) })}
) } function HeatmapPanel({ panel, state, reducedMotion, dark, }: { panel: Extract state: StepResolvedState reducedMotion?: boolean dark: boolean }) { const { rows, cols, values, rowLabels, colLabels, triangular } = panel.payload const cell = 34 const labelW = 44 const labelH = 26 const w = labelW + cols * cell + 8 const h = labelH + rows * cell + 8 const transition = reducedMotion ? 'none' : 'opacity 220ms ease' const dim = dimOpacity(dark) const fillIntent = spotlightColor(dark) const ghostStroke = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.16)' // Intensity ∝ value — normalized so real-world scales (not just [0,1]) work const maxAbsV = Math.max( 1e-9, ...values.flat().map((v) => Math.abs(v)) ) const cellRect = (r: number, c: number) => ({ x: labelW + (c - 1) * cell, y: labelH + (r - 1) * cell, }) const positions = useMemo(() => { const map = new Map< string, { cx: number; cy: number; trX: number; trY: number } >() for (let r = 1; r <= rows; r++) { for (let c = 1; c <= cols; c++) { if (triangular === 'lower' && c > r) continue if (triangular === 'upper' && c < r) continue const id = `r${r}.c${c}` const { x, y } = cellRect(r, c) map.set(id, { cx: x + cell / 2, cy: y + cell / 2, trX: x + cell - 2, trY: y + 2, }) } } return map }, [rows, cols, triangular]) const uid = useId().replace(/:/g, '') const markerId = `demo-arrowhead-hm-${uid}` return ( {colLabels?.map((lab, i) => ( {lab} ))} {rowLabels?.map((lab, i) => ( {lab} ))} {Array.from({ length: rows }, (_, ri) => Array.from({ length: cols }, (_, ci) => { const r = ri + 1 const c = ci + 1 if (triangular === 'lower' && c > r) return null if (triangular === 'upper' && c < r) return null const id = `r${r}.c${c}` const { x, y } = cellRect(r, c) const revealed = id in state.revealed const v = values[ri]?.[ci] ?? 0 const lit = state.overview || state.spotlight.includes(id) const op = revealed ? elementOpacity(id, state, dim) : 1 if (!revealed) { return ( ) } const vNorm = Math.min(1, Math.abs(v) / maxAbsV) const fillOpacity = 0.14 + vNorm * 0.78 const textFill = vNorm > 0.45 ? (dark ? '#0a0a0a' : '#fff') : dark ? '#eee' : '#111' return ( {v.toFixed(2)} ) }) )} { const p = positions.get(id) if (!p) return null if (kind === 'badge') return { x: p.trX, y: p.trY } if (kind === 'callout') return { x: p.trX + 4, y: p.cy } return { x: p.cx, y: p.cy } }} /> ) } export function DemoSceneView({ scene, state, reducedMotion, className, }: DemoSceneViewProps) { const dark = useDarkMode() return (
{scene.panels.map((panel) => (
{panel.type === 'svg-scene' && ( )} {panel.type === 'chart' && (
)} {panel.type === 'heatmap-matrix' && (
)}
))}
) }