AgentStateGraph-Browser / src /LandingView.tsx
seonglae's picture
Interactive research article
9e2b9b8
Raw History Blame Contribute Delete
37.2 kB
/**
* 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<string, { x: number; y: number }> {
const n = states.length
if (n === 0) return {}
const cx = w / 2, cy = h / 2
const pos: Record<string, { x: number; y: number }> = {}
const vel: Record<string, { x: number; y: number }> = {}
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<string, Set<string>>()
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<HTMLDivElement>(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 (
<div
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
style={{
width: CELL_W + 16,
height: '100%',
overflow: 'hidden',
position: 'relative',
borderRight: `2px solid ${INK}20`,
cursor: 'grab',
userSelect: 'none',
touchAction: 'none',
}}
>
{/* 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 (
<div style={{
position: 'absolute',
left: 8,
top: 0,
width: CELL_W,
height: totalH,
transform: `translateY(${-scrollY}px)`,
}}>
<div style={{ height: topPad }} />
{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 (
<div key={i} onClick={() => handleCellClick(i)}>
{step.isTraceStart && (
<div style={{
height: TRACE_HEADER_H,
display: 'flex',
alignItems: 'center',
paddingLeft: 6,
background: isRead ? PAPER : '#f8f5ef',
borderTop: i > 0 ? `1.5px solid ${isRead ? `${RUST}40` : INK4}` : 'none',
opacity: isRead ? 0.5 : 1,
}}>
<span style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 7,
color: isRead ? INK4 : OCHRE,
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
trace {step.traceIdx + 1}: {step.traceId.slice(0, 18)}
</span>
</div>
)}
<div style={{
width: CELL_W,
height: CELL_H,
display: 'flex',
alignItems: 'center',
paddingLeft: 10,
borderBottom: `1px solid ${isRead ? '#d5cfc520' : '#d5cfc5'}`,
background: isCurrent ? '#fffcf3' : isRead ? PAPER : '#fffdf9',
opacity: isRead ? Math.max(0.2, 1 - (readHead - i) * 0.04) : 1,
cursor: 'pointer',
}}>
<span style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 8,
color: isCurrent ? RUST : INK4,
width: 20,
textAlign: 'right',
marginRight: 8,
flexShrink: 0,
}}>
{step.stepInTrace + 1}
</span>
<span style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
color: isCurrent ? RUST : isRead ? INK4 : INK3,
fontWeight: isCurrent ? 700 : 400,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{label}
</span>
{isRead && (
<span style={{
marginLeft: 'auto',
marginRight: 6,
width: 5,
height: 5,
borderRadius: '50%',
background: step.consumed ? RUST : `${SLATE}60`,
flexShrink: 0,
}} />
)}
</div>
</div>
)
})}
</div>
)
})()}
{/* Reading head bracket — fixed at headOffset */}
<div style={{
position: 'absolute',
left: 0,
right: 0,
top: headOffset - 1,
height: CELL_H + 2,
border: `2px solid ${RUST}`,
borderRadius: 2,
pointerEvents: 'none',
zIndex: 2,
}} />
{/* Arrow to graph */}
<div style={{
position: 'absolute',
right: -12,
top: headOffset + CELL_H / 2 - 6,
width: 0, height: 0,
borderTop: '6px solid transparent',
borderBottom: '6px solid transparent',
borderLeft: `8px solid ${RUST}`,
zIndex: 2,
}} />
{/* Fades */}
<div style={{
position: 'absolute', left: 0, right: 0, top: 0, height: 40,
background: `linear-gradient(180deg, ${PAPER}, transparent)`,
pointerEvents: 'none', zIndex: 1,
}} />
<div style={{
position: 'absolute', left: 0, right: 0, bottom: 0, height: 30,
background: `linear-gradient(0deg, ${PAPER}, transparent)`,
pointerEvents: 'none', zIndex: 1,
}} />
</div>
)
}
// ---------- Zoomable container ----------
function ZoomableGraph({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={handleDoubleClick}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
minWidth: 0, overflow: 'hidden', cursor: 'grab', touchAction: 'none',
}}
>
<div style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}>
{children}
</div>
</div>
)
}
// ---------- SVG Graph ----------
function GraphView({
states, transitions, revealedStates, revealedTransitions, layout, size, currentState, activeEdge, onNodeDrag
}: {
states: GraphState[]
transitions: GraphTransition[]
revealedStates: Set<string>
revealedTransitions: Set<string>
layout: Record<string, { x: number; y: number }>
size: number
currentState: string
activeEdge: string | null
onNodeDrag?: (id: string, x: number, y: number) => void
}) {
const edgeSet = useMemo(() => {
const s = new Set<string>()
for (const t of transitions) s.add(`${t.source}|${t.target}`)
return s
}, [transitions])
const svgRef = useRef<SVGSVGElement>(null)
const dragNode = useRef<string | null>(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 (
<svg ref={svgRef} viewBox="-20 -20 440 440" width={size} height={size} style={{ overflow: 'visible' }}
onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp}>
<defs>
<marker id="la" viewBox="0 0 10 7" refX="0" refY="3.5" markerWidth="10" markerHeight="7" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L10,3.5 L0,7" fill={INK4} />
</marker>
<marker id="la-r" viewBox="0 0 10 7" refX="0" refY="3.5" markerWidth="10" markerHeight="7" orient="auto" markerUnits="userSpaceOnUse">
<path d="M0,0 L10,3.5 L0,7" fill={RUST} />
</marker>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
</defs>
{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 <path key={i} d={`M${sx},${sy} C${cx1},${cy1} ${cx2},${cy2} ${ex},${ey}`}
fill="none" stroke={stroke} strokeWidth={sw} strokeOpacity={op} strokeLinecap="round" markerEnd={marker} />
}
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 <path key={i} d={`M${sx},${sy} Q${mx},${my} ${ex},${ey}`}
fill="none" stroke={stroke} strokeWidth={sw} strokeOpacity={op} strokeLinecap="round" markerEnd={marker} />
}
return <line key={i} x1={from.x + nx * nr} y1={from.y + ny * nr} x2={to.x - nx * (nr + al)} y2={to.y - ny * (nr + al)}
stroke={stroke} strokeWidth={sw} strokeOpacity={op} strokeLinecap="round" markerEnd={marker} />
})}
{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 (
<g key={s.id} filter={isCurrent && !isInit ? 'url(#glow)' : undefined}
style={{ cursor: onNodeDrag ? 'grab' : undefined }}
onPointerDown={onNodeDrag ? (e) => handlePointerDown(e, s.id) : undefined}>
<circle cx={pos.x} cy={pos.y} r={r}
fill={isCurrent ? `${RUST}30` : isNew ? `${RUST}18` : '#fffdf9'}
stroke={isCurrent ? RUST : isNew ? RUST : isInit ? RUST : INK4}
strokeWidth={isCurrent ? 3 : isNew ? 2.5 : isInit ? 2.5 : 1.5} />
{isInit && <circle cx={pos.x} cy={pos.y} r={r - 4} fill="none" stroke={RUST} strokeWidth={1} />}
<text x={pos.x} y={pos.y + r + 13} textAnchor="middle"
fill={isCurrent ? RUST : isNew ? RUST : INK3} fontSize="10" fontFamily="IBM Plex Mono, monospace"
fontWeight={isCurrent || isNew ? 700 : 400}>{label}</text>
</g>
)
})}
</svg>
)
}
// ---------- 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<HTMLInputElement>(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 (
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>{label}</div>
{editing ? (
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'flex-end', gap: 1 }}>
<input ref={inputRef} value={draft} onChange={e => 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,
}} />
<span style={{ fontSize: 10, color: INK4 }}>/{total}</span>
</div>
) : (
<div onClick={startEdit} style={{
fontSize: 14, fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace",
color, cursor: 'text', borderBottom: `1px dashed ${INK4}40`, paddingBottom: 1,
}}>
{value}<span style={{ fontSize: 10, color: INK4 }}>/{total}</span>
</div>
)}
</div>
)
}
// ---------- 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<ReturnType<typeof setInterval> | 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<string>; rt: Set<string>; consumed: number; total: number
knownStates: Set<string>; 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 (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: PAPER, overflow: 'hidden' }}>
{/* Top bar: title + stats + current step */}
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', borderBottom: `1px solid #d5cfc5`, gap: 8, overflowX: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0, flexShrink: 1 }}>
<h1 className="serif" style={{ fontSize: 18, lineHeight: 1, letterSpacing: '-0.3px', color: INK, whiteSpace: 'nowrap' }}>
Agent State Graph
</h1>
{readHead > 0 && readHead <= allSteps.length && (
<div style={{
fontFamily: "'IBM Plex Mono', monospace", fontSize: 10,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
minWidth: 0, flexShrink: 1,
}}>
<span style={{ color: allSteps[readHead - 1]?.consumed ? RUST : INK3 }}>
{allSteps[readHead - 1]?.activity}
</span>
<span style={{ color: INK4, margin: '0 4px' }}>{'\u2192'}</span>
<span style={{ color: SLATE }}>
{allSteps[readHead - 1]?.toState || 'REJECT'}
</span>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 20, flexShrink: 0 }}>
{/* Static stats */}
{[
['States', statesCount, ds.graph.states.length, RUST],
['Trans', transitionsCount, ds.graph.transitions.length, SLATE],
].map(([label, val, total, color]) => (
<div key={label as string} style={{ textAlign: 'right' }}>
<div style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>{label}</div>
<div style={{ fontSize: 14, fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace", color: color as string }}>
{val}<span style={{ fontSize: 10, color: INK4 }}>/{total}</span>
</div>
</div>
))}
{/* Editable: Trace */}
<EditableStat label="Trace" value={currentTraceIdx + 1} total={totalTraces} color={OCHRE}
onSubmit={(v) => {
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 */}
<EditableStat label="Steps" value={readHead} total={allSteps.length} color={INK}
onSubmit={(v) => onScrub(Math.max(0, Math.min(allSteps.length, v)))} />
{/* Static: Fitness */}
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>Fitness</div>
<div style={{ fontSize: 14, fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace", color: INK }}>
{currentFitness.toFixed(3)}
</div>
</div>
</div>
</div>
{/* Main: tape (full height left) + graph (right) */}
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* Tape column — fills full vertical space */}
<div style={{ width: CELL_W + 40, flexShrink: 0, height: '100%' }}>
<ReceiptTape steps={allSteps} readHead={readHead} onScrub={onScrub} />
</div>
{/* Graph — zoomable/pannable */}
<ZoomableGraph>
<GraphView
states={ds.graph.states} transitions={ds.graph.transitions}
revealedStates={revealedStates} revealedTransitions={revealedTransitions}
layout={fullLayout} size={graphSize}
currentState={currentState} activeEdge={activeEdge}
onNodeDrag={handleNodeDrag}
/>
</ZoomableGraph>
</div>
{/* Control bar */}
<div style={{ borderTop: `2px solid ${INK}`, flexShrink: 0 }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '10px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10 }}>
{/* Playback */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{[
{ 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 }) => (
<button key={label} onClick={onClick} style={{
padding: '4px 12px', fontSize: 10, letterSpacing: '0.5px', textTransform: 'uppercase' as const,
border: `1px solid ${INK4}`, background: 'transparent', cursor: 'pointer',
color: accent ? RUST : INK, fontFamily: 'inherit',
}}>{label}</button>
))}
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginLeft: 6 }}>
<span style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>Speed</span>
{[1, 2, 4, 8].map(s => (
<button key={s} onClick={() => setSpeed(s)} style={{
padding: '2px 5px', fontSize: 9, fontFamily: "'IBM Plex Mono', monospace",
border: `1px solid ${INK4}`, cursor: 'pointer',
background: speed === s ? INK : 'transparent', color: speed === s ? PAPER : INK,
}}>{s}x</button>
))}
</div>
</div>
{/* Dataset + enter */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<div style={{ display: 'flex', border: `1px solid ${INK4}`, overflowX: 'auto', minWidth: 0, flexShrink: 1 }}>
{datasets.map((d, i) => (
<button key={d.id} onClick={() => onSelectDataset(i)} style={{
padding: '3px 8px', fontSize: 10, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
borderRight: i < datasets.length - 1 ? `1px solid ${INK4}` : 'none',
background: ds.id === d.id ? INK : 'transparent',
color: ds.id === d.id ? PAPER : INK, fontFamily: 'inherit',
}}>{d.meta.shortName}</button>
))}
</div>
<button onClick={onEnterDashboard} style={{
padding: '4px 14px', fontSize: 10, letterSpacing: '0.5px', textTransform: 'uppercase' as const,
border: `2px solid ${INK}`, background: 'transparent', cursor: 'pointer',
fontWeight: 600, fontFamily: 'inherit', flexShrink: 0,
}}>Dashboard {'\u2192'}</button>
</div>
</div>
{/* Progress */}
<div style={{ marginTop: 6, height: 3, background: '#e8e3db', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 2, transition: 'width 120ms',
width: `${(readHead / Math.max(allSteps.length, 1)) * 100}%`,
background: `linear-gradient(90deg, ${RUST}, ${SLATE})`,
}} />
</div>
</div>
</div>
</div>
)
}