Download src/LandingView.tsx from seonglae/AgentStateGraph-Browser: direct link, hf CLI and curl.
- Browser
- Download file 37.2 kB
-
https://huggingface.co/spaces/seonglae/AgentStateGraph-Browser/resolve/main/src/LandingView.tsx
- Command line
-
hf download hf://spaces/seonglae/AgentStateGraph-Browser/src/LandingView.tsx
-
curl -L -o LandingView.tsx https://huggingface.co/spaces/seonglae/AgentStateGraph-Browser/resolve/main/src/LandingView.tsx
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> | |
| ) | |
| } | |