/** * Landing page: Turing machine tape feeds into evolving FSM graph. * Receipt-like tape feeds from top-left into the graph that grows in the center. * All traces concatenated — drag the tape to scrub, or play to auto-advance through all traces. */ import { useRef, useMemo, useState, useEffect, useCallback } from 'react' // ---------- Types ---------- interface GraphState { id: string } interface GraphTransition { source: string; target: string; subject: string } interface TraceStep { idx: number; role: string; activity: string fromState: string; toState: string | null; consumed: boolean } interface Trace { id: string; steps: TraceStep[]; success: boolean | null fitness: number; total: number } interface Dataset { id: string meta: { name: string; shortName: string; traces: number; activities: number; description: string } graph: { states: GraphState[]; transitions: GraphTransition[] } traces: Trace[] convergence: { curve: Array<{ n: number; states: number; transitions: number; fitness: number }> } } // ---------- Palette ---------- const RUST = '#c4553a' const SLATE = '#3d5a80' const OCHRE = '#c49a3a' const PAPER = '#f5f0e8' const INK = '#1a1714' const INK3 = '#8a8478' const INK4 = '#b5afa5' // ---------- Force layout ---------- function computeLayout( states: GraphState[], transitions: GraphTransition[], w = 400, h = 400 ): Record { const n = states.length if (n === 0) return {} const cx = w / 2, cy = h / 2 const pos: Record = {} const vel: Record = {} const initIdx = states.findIndex(s => s.id === 'init') const r0 = Math.min(140, n * 18) states.forEach((s, i) => { const idx = initIdx >= 0 ? (i - initIdx + n) % n : i const angle = (idx / n) * 2 * Math.PI - Math.PI / 2 pos[s.id] = { x: cx + r0 * Math.cos(angle), y: cy + r0 * Math.sin(angle) } vel[s.id] = { x: 0, y: 0 } }) const adj = new Map>() for (const s of states) adj.set(s.id, new Set()) for (const t of transitions) { if (t.source !== t.target) { adj.get(t.source)?.add(t.target) adj.get(t.target)?.add(t.source) } } for (let iter = 0; iter < 200; iter++) { const temp = 1 - iter / 200 for (const a of states) { let fx = 0, fy = 0 for (const b of states) { if (a.id === b.id) continue const dx = pos[a.id].x - pos[b.id].x, dy = pos[a.id].y - pos[b.id].y const d2 = dx * dx + dy * dy + 1, f = 6000 / d2 fx += f * dx / Math.sqrt(d2); fy += f * dy / Math.sqrt(d2) } const neighbors = adj.get(a.id) if (neighbors) for (const bId of neighbors) { const dx = pos[bId].x - pos[a.id].x, dy = pos[bId].y - pos[a.id].y const d = Math.sqrt(dx * dx + dy * dy), f = 0.008 * (d - 80) fx += f * dx / (d + 0.1); fy += f * dy / (d + 0.1) } fx += (cx - pos[a.id].x) * 0.002; fy += (cy - pos[a.id].y) * 0.002 vel[a.id].x = (vel[a.id].x + fx) * 0.85 * temp vel[a.id].y = (vel[a.id].y + fy) * 0.85 * temp } for (const s of states) { pos[s.id].x = Math.max(40, Math.min(w - 40, pos[s.id].x + vel[s.id].x)) pos[s.id].y = Math.max(40, Math.min(h - 40, pos[s.id].y + vel[s.id].y)) } } return pos } // ---------- Concatenated step with trace boundary info ---------- interface FlatStep { activity: string consumed: boolean fromState: string toState: string | null traceIdx: number traceId: string stepInTrace: number isTraceStart: boolean } function flattenTraces(traces: Trace[]): FlatStep[] { const out: FlatStep[] = [] const subset = traces.filter(t => t.steps.length > 3) for (let ti = 0; ti < subset.length; ti++) { const trace = subset[ti] for (let si = 0; si < trace.steps.length; si++) { const s = trace.steps[si] out.push({ activity: s.activity, consumed: s.consumed, fromState: s.fromState, toState: s.toState, traceIdx: ti, traceId: trace.id, stepInTrace: si, isTraceStart: si === 0, }) } } return out } // ---------- Vertical Receipt Tape with drag ---------- const CELL_H = 28 const CELL_W = 160 const TRACE_HEADER_H = 18 /** Precompute Y offsets for all steps. * offsets[i] = Y of the CELL top (after any trace header). * headerBefore[i] = extra header height before this cell. */ function buildStepOffsets(steps: FlatStep[]): { cellTop: number[]; totalH: number } { const cellTop = new Array(steps.length + 1) let y = 0 for (let i = 0; i < steps.length; i++) { if (steps[i].isTraceStart) y += TRACE_HEADER_H cellTop[i] = y // Y of the cell itself, after header y += CELL_H } cellTop[steps.length] = y return { cellTop, totalH: y } } /** Y of everything above step i (headers + cells), for virtualization top-pad */ function blockTop(steps: FlatStep[], cellTop: number[], i: number): number { if (i <= 0) return 0 // blockTop = cellTop of step i, minus header if step i has one return steps[i]?.isTraceStart ? cellTop[i] - TRACE_HEADER_H : cellTop[i] } function ReceiptTape({ steps, readHead, onScrub }: { steps: FlatStep[] readHead: number onScrub: (newHead: number) => void }) { const containerRef = useRef(null) const dragging = useRef(false) const dragStartY = useRef(0) const dragStartHead = useRef(0) const didDrag = useRef(false) // Use 100% of container height via ref const [containerH, setContainerH] = useState(600) useEffect(() => { const el = containerRef.current?.parentElement if (el) { const ro = new ResizeObserver(entries => { for (const entry of entries) setContainerH(entry.contentRect.height) }) ro.observe(el) setContainerH(el.clientHeight) return () => ro.disconnect() } }, []) const headOffset = Math.round(containerH * 0.4) // Precompute offsets O(n) once, then O(1) lookup const { cellTop: stepOffsets, totalH: tapeTotalH } = useMemo(() => buildStepOffsets(steps), [steps]) const scrollY = (stepOffsets[readHead] ?? 0) - headOffset const handlePointerDown = useCallback((e: React.PointerEvent) => { dragging.current = true didDrag.current = false dragStartY.current = e.clientY dragStartHead.current = readHead ;(e.target as HTMLElement).setPointerCapture(e.pointerId) }, [readHead]) const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!dragging.current) return const dy = dragStartY.current - e.clientY if (Math.abs(dy) > 3) didDrag.current = true const delta = Math.round(dy / CELL_H) const newHead = Math.max(0, Math.min(steps.length, dragStartHead.current + delta)) onScrub(newHead) }, [steps.length, onScrub]) const handlePointerUp = useCallback(() => { dragging.current = false }, []) const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault() const delta = Math.sign(e.deltaY) onScrub(Math.max(0, Math.min(steps.length, readHead + delta))) }, [readHead, steps.length, onScrub]) // Click on a cell to jump const handleCellClick = useCallback((i: number) => { if (didDrag.current) return // was a drag, not a click onScrub(i) }, [onScrub]) return (
{/* Tape strip — virtualized: only render visible steps */} {(() => { // Find visible range const viewTop = scrollY - 100 // buffer const viewBot = scrollY + containerH + 100 let startIdx = 0, endIdx = steps.length // Binary search for start let lo = 0, hi = steps.length while (lo < hi) { const mid = (lo + hi) >> 1 if (stepOffsets[mid] + CELL_H + TRACE_HEADER_H < viewTop) lo = mid + 1 else hi = mid } startIdx = Math.max(0, lo - 1) lo = startIdx; hi = steps.length while (lo < hi) { const mid = (lo + hi) >> 1 if (stepOffsets[mid] > viewBot) hi = mid else lo = mid + 1 } endIdx = Math.min(steps.length, lo + 1) const topPad = blockTop(steps, stepOffsets, startIdx) const totalH = tapeTotalH return (
{steps.slice(startIdx, endIdx).map((step, idx) => { const i = startIdx + idx const isRead = i < readHead const isCurrent = i === readHead const label = step.activity.length > 14 ? step.activity.slice(0, 12) + '..' : step.activity return (
handleCellClick(i)}> {step.isTraceStart && (
0 ? `1.5px solid ${isRead ? `${RUST}40` : INK4}` : 'none', opacity: isRead ? 0.5 : 1, }}> trace {step.traceIdx + 1}: {step.traceId.slice(0, 18)}
)}
{step.stepInTrace + 1} {label} {isRead && ( )}
) })}
) })()} {/* Reading head bracket — fixed at headOffset */}
{/* Arrow to graph */}
{/* Fades */}
) } // ---------- Zoomable container ---------- function ZoomableGraph({ children }: { children: React.ReactNode }) { const containerRef = useRef(null) const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const dragging = useRef(false) const lastPos = useRef({ x: 0, y: 0 }) const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault() const delta = e.deltaY > 0 ? 0.9 : 1.1 setZoom(z => Math.max(0.3, Math.min(5, z * delta))) }, []) const handlePointerDown = useCallback((e: React.PointerEvent) => { dragging.current = true lastPos.current = { x: e.clientX, y: e.clientY } ;(e.target as HTMLElement).setPointerCapture(e.pointerId) }, []) const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!dragging.current) return const dx = e.clientX - lastPos.current.x const dy = e.clientY - lastPos.current.y lastPos.current = { x: e.clientX, y: e.clientY } setPan(p => ({ x: p.x + dx, y: p.y + dy })) }, []) const handlePointerUp = useCallback(() => { dragging.current = false }, []) const handleDoubleClick = useCallback(() => { setZoom(1) setPan({ x: 0, y: 0 }) }, []) return (
{children}
) } // ---------- SVG Graph ---------- function GraphView({ states, transitions, revealedStates, revealedTransitions, layout, size, currentState, activeEdge, onNodeDrag }: { states: GraphState[] transitions: GraphTransition[] revealedStates: Set revealedTransitions: Set layout: Record size: number currentState: string activeEdge: string | null onNodeDrag?: (id: string, x: number, y: number) => void }) { const edgeSet = useMemo(() => { const s = new Set() for (const t of transitions) s.add(`${t.source}|${t.target}`) return s }, [transitions]) const svgRef = useRef(null) const dragNode = useRef(null) const toSVG = useCallback((clientX: number, clientY: number) => { const svg = svgRef.current if (!svg) return { x: 0, y: 0 } const pt = svg.createSVGPoint() pt.x = clientX; pt.y = clientY const ctm = svg.getScreenCTM() if (!ctm) return { x: 0, y: 0 } const svgPt = pt.matrixTransform(ctm.inverse()) return { x: svgPt.x, y: svgPt.y } }, []) const handlePointerDown = useCallback((e: React.PointerEvent, id: string) => { if (!onNodeDrag) return e.stopPropagation() dragNode.current = id ;(e.target as Element).setPointerCapture(e.pointerId) }, [onNodeDrag]) const handlePointerMove = useCallback((e: React.PointerEvent) => { if (!dragNode.current || !onNodeDrag) return const { x, y } = toSVG(e.clientX, e.clientY) onNodeDrag(dragNode.current, x, y) }, [onNodeDrag, toSVG]) const handlePointerUp = useCallback(() => { dragNode.current = null }, []) return ( {transitions.map((t, i) => { const key = `${t.source}|${t.target}` if (!revealedTransitions.has(key)) return null const from = layout[t.source], to = layout[t.target] if (!from || !to) return null const isNew = revealedTransitions.has(`new:${key}`) const isActive = key === activeEdge const stroke = isActive || isNew ? RUST : INK4 const sw = isActive ? 3 : isNew ? 2 : 1 const op = isActive ? 1 : isNew ? 0.8 : 0.4 const marker = isActive || isNew ? 'url(#la-r)' : 'url(#la)' const al = 10 // arrow length (userSpaceOnUse) if (t.source === t.target) { const nr = 16, loopR = 35 const cx1 = from.x - loopR, cy1 = from.y - loopR * 1.5 const cx2 = from.x + loopR, cy2 = from.y - loopR * 1.5 // Start handle: angle from center to first control point const sa = Math.atan2(cy1 - from.y, cx1 - from.x) const sx = from.x + nr * Math.cos(sa), sy = from.y + nr * Math.sin(sa) // End handle: angle from center to second control point, pull back by al const ea = Math.atan2(cy2 - from.y, cx2 - from.x) const epx = from.x + nr * Math.cos(ea), epy = from.y + nr * Math.sin(ea) const tdx = epx - cx2, tdy = epy - cy2, td = Math.sqrt(tdx * tdx + tdy * tdy) const ex = epx - (tdx / td) * al, ey = epy - (tdy / td) * al return } const dx = to.x - from.x, dy = to.y - from.y, dist = Math.sqrt(dx * dx + dy * dy) const nx = dx / dist, ny = dy / dist, nr = 16 if (edgeSet.has(`${t.target}|${t.source}`)) { const c = 20 const mx = (from.x + to.x) / 2 - ny * c, my = (from.y + to.y) / 2 + nx * c const sa = Math.atan2(my - from.y, mx - from.x) const ea = Math.atan2(my - to.y, mx - to.x) const sx = from.x + nr * Math.cos(sa), sy = from.y + nr * Math.sin(sa) const epx = to.x + nr * Math.cos(ea), epy = to.y + nr * Math.sin(ea) const tdx = epx - mx, tdy = epy - my, td = Math.sqrt(tdx * tdx + tdy * tdy) const ex = epx - (tdx / td) * al, ey = epy - (tdy / td) * al return } return })} {states.map((s) => { if (!revealedStates.has(s.id)) return null const pos = layout[s.id] if (!pos) return null const isInit = s.id === 'init' const isNew = revealedStates.has(`new:${s.id}`) const isCurrent = s.id === currentState const r = isInit ? 16 : 14 const label = s.id.length > 14 ? s.id.slice(0, 12) + '..' : s.id return ( handlePointerDown(e, s.id) : undefined}> {isInit && } {label} ) })} ) } // ---------- Editable Stat ---------- function EditableStat({ label, value, total, color, onSubmit }: { label: string; value: number; total: number; color: string onSubmit: (v: number) => void }) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') const inputRef = useRef(null) const startEdit = useCallback(() => { setDraft(String(value)) setEditing(true) }, [value]) useEffect(() => { if (editing) inputRef.current?.select() }, [editing]) const commit = useCallback(() => { setEditing(false) const n = parseInt(draft, 10) if (!isNaN(n)) onSubmit(n) }, [draft, onSubmit]) return (
{label}
{editing ? (
setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(false) }} onBlur={commit} style={{ width: `${Math.max(2, String(total).length)}ch`, fontSize: 14, fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace", color, textAlign: 'right', border: 'none', borderBottom: `2px solid ${RUST}`, background: 'transparent', outline: 'none', padding: 0, }} /> /{total}
) : (
{value}/{total}
)}
) } // ---------- Main Landing View ---------- export default function LandingView({ ds, datasets, onSelectDataset, onEnterDashboard }: { ds: Dataset; datasets: Dataset[] onSelectDataset: (i: number) => void; onEnterDashboard: () => void }) { const [playing, setPlaying] = useState(true) const [speed, setSpeed] = useState(1) const [readHead, setReadHead] = useState(0) const timerRef = useRef | null>(null) // Flatten all traces into one continuous tape const allSteps = useMemo(() => flattenTraces(ds.traces), [ds.traces]) // Current trace info const currentStep = allSteps[Math.min(readHead, allSteps.length - 1)] const currentTraceIdx = currentStep?.traceIdx ?? 0 const totalTraces = useMemo(() => { const last = allSteps[allSteps.length - 1] return last ? last.traceIdx + 1 : 0 }, [allSteps]) const initialLayout = useMemo(() => computeLayout(ds.graph.states, ds.graph.transitions), [ds.graph]) const [fullLayout, setFullLayout] = useState(initialLayout) useEffect(() => setFullLayout(initialLayout), [initialLayout]) const handleNodeDrag = useCallback((id: string, x: number, y: number) => { setFullLayout(prev => ({ ...prev, [id]: { x, y } })) }, []) // Animation timer useEffect(() => { if (playing) { const ms = Math.max(30, Math.round(180 / speed)) timerRef.current = setInterval(() => { setReadHead(prev => { if (prev >= allSteps.length) { setPlaying(false) return prev } return prev + 1 }) }, ms) } return () => { if (timerRef.current) clearInterval(timerRef.current) } }, [playing, speed, allSteps.length]) // Current state: the state the machine is sitting in RIGHT NOW // = fromState of the next step to read (where we are before consuming it) const currentState = useMemo(() => { if (readHead < allSteps.length) return allSteps[readHead].fromState // All steps consumed: return toState of last step const last = allSteps[allSteps.length - 1] return last?.toState || last?.fromState || 'init' }, [readHead, allSteps]) // Incremental revealed sets — cache forward progress, recompute only on backward jumps const revealCache = useRef<{ rs: Set; rt: Set; consumed: number; total: number knownStates: Set; head: number; stepsRef: FlatStep[] } | null>(null) const { revealedStates, revealedTransitions, statesCount, transitionsCount, currentFitness, activeEdge } = useMemo(() => { const cap = Math.min(readHead, allSteps.length) let cache = revealCache.current // Invalidate cache if dataset changed or scrubbed backward if (!cache || cache.stepsRef !== allSteps || cache.head > cap) { cache = { rs: new Set(['init']), rt: new Set(), consumed: 0, total: 0, knownStates: new Set(), head: 0, stepsRef: allSteps } } // Process only new steps since last cached position for (let i = cache.head; i < cap; i++) { const step = allSteps[i] cache.total++ cache.rs.add(step.fromState) cache.knownStates.add(step.fromState) if (step.toState) { cache.rs.add(step.toState) cache.rt.add(`${step.fromState}|${step.toState}`) cache.consumed++ cache.knownStates.add(step.toState) } } cache.head = cap // Clean previous frame's new: markers for (const k of cache.rs) if (k.startsWith('new:')) cache.rs.delete(k) for (const k of cache.rt) if (k.startsWith('new:')) cache.rt.delete(k) // Active edge + new markers for current step let lastEdge: string | null = null if (cap > 0) { const lastStep = allSteps[cap - 1] const nextStep = allSteps[cap] const crossingTrace = nextStep && nextStep.isTraceStart if (!crossingTrace && lastStep.toState) { const edgeKey = `${lastStep.fromState}|${lastStep.toState}` lastEdge = edgeKey // Check if this state/edge appeared before this step // (knownStates already has it, but "new" means it was JUST added at this exact step) cache.rt.add(`new:${edgeKey}`) } } revealCache.current = cache const realStates = [...cache.rs].filter(s => !s.startsWith('new:')) const realTrans = [...cache.rt].filter(s => !s.startsWith('new:')) return { revealedStates: cache.rs, revealedTransitions: cache.rt, statesCount: realStates.length, transitionsCount: realTrans.length, currentFitness: cache.total > 0 ? cache.consumed / cache.total : 1, activeEdge: lastEdge, } }, [readHead, allSteps]) const onScrub = useCallback((newHead: number) => { setReadHead(newHead) }, []) // Arrow key navigation useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.target as HTMLElement).tagName === 'INPUT') return if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault() setReadHead(prev => Math.min(prev + 1, allSteps.length)) } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault() setReadHead(prev => Math.max(prev - 1, 0)) } else if (e.key === ' ') { e.preventDefault() setPlaying(p => !p) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [allSteps.length]) const advance = useCallback(() => { setReadHead(prev => Math.min(prev + 1, allSteps.length)) }, [allSteps.length]) const reset = useCallback(() => { setPlaying(false) setReadHead(0) }, []) useEffect(() => { setReadHead(0); setPlaying(false) }, [ds]) const graphSize = Math.min(480, typeof window !== 'undefined' ? Math.min(window.innerHeight * 0.65, window.innerWidth * 0.55) : 480) return (
{/* Top bar: title + stats + current step */}

Agent State Graph

{readHead > 0 && readHead <= allSteps.length && (
{allSteps[readHead - 1]?.activity} {'\u2192'} {allSteps[readHead - 1]?.toState || 'REJECT'}
)}
{/* Static stats */} {[ ['States', statesCount, ds.graph.states.length, RUST], ['Trans', transitionsCount, ds.graph.transitions.length, SLATE], ].map(([label, val, total, color]) => (
{label}
{val}/{total}
))} {/* Editable: Trace */} { const ti = Math.max(1, Math.min(totalTraces, v)) - 1 const idx = allSteps.findIndex(s => s.traceIdx === ti) if (idx >= 0) onScrub(idx) }} /> {/* Editable: Steps */} onScrub(Math.max(0, Math.min(allSteps.length, v)))} /> {/* Static: Fitness */}
Fitness
{currentFitness.toFixed(3)}
{/* Main: tape (full height left) + graph (right) */}
{/* Tape column — fills full vertical space */}
{/* Graph — zoomable/pannable */}
{/* Control bar */}
{/* Playback */}
{[ { label: playing ? 'Pause' : 'Play', onClick: () => setPlaying(p => !p), accent: playing }, { label: 'Step', onClick: advance, accent: false }, { label: 'Reset', onClick: reset, accent: false }, ].map(({ label, onClick, accent }) => ( ))}
Speed {[1, 2, 4, 8].map(s => ( ))}
{/* Dataset + enter */}
{datasets.map((d, i) => ( ))}
{/* Progress */}
) }