Spaces:
Running
Running
| <!-- Interactive FSM Hero: flat dashboard-style graph with live trace simulation --> | |
| <div class="fsm-hero"> | |
| <div class="hero-pills"></div> | |
| <div class="hero-graph"></div> | |
| <div class="hero-trace-bar"></div> | |
| </div> | |
| <style> | |
| .fsm-hero { | |
| position: relative; width: 100%; height: 520px; overflow: hidden; | |
| background: #f5f0e8; border-radius: 12px; border: 1px solid #d9d0c4; | |
| } | |
| .fsm-hero .hero-graph { position: absolute; inset: 0; z-index: 1; } | |
| .fsm-hero .hero-graph svg { width: 100%; height: 100%; } | |
| /* Dataset pills - warm theme */ | |
| .fsm-hero .hero-pills { | |
| position: absolute; top: 14px; right: 18px; z-index: 10; | |
| display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; | |
| } | |
| .fsm-hero .pill { | |
| font-size: 10px; padding: 4px 10px; border-radius: 20px; | |
| border: 1px solid #c4b8a8; background: rgba(245,240,232,0.7); | |
| color: #8a8478; cursor: pointer; transition: all 0.2s ease; | |
| font-family: 'IBM Plex Mono', ui-monospace, monospace; font-weight: 500; | |
| letter-spacing: 0.02em; | |
| } | |
| .fsm-hero .pill:hover { border-color: #b5afa5; color: #4a4540; background: rgba(245,240,232,0.9); } | |
| .fsm-hero .pill.active { | |
| border-color: #3d5a80; background: rgba(61, 90, 128,0.06); | |
| color: #3d5a80; font-weight: 600; | |
| } | |
| /* Trace timeline bar - warm theme */ | |
| .fsm-hero .hero-trace-bar { | |
| position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); | |
| z-index: 10; display: flex; align-items: center; gap: 2px; | |
| padding: 5px 10px; border-radius: 10px; | |
| background: rgba(245,240,232,0.85); backdrop-filter: blur(6px); | |
| border: 1px solid #d9d0c4; | |
| max-width: 90%; overflow-x: auto; | |
| } | |
| .fsm-hero .trace-step { | |
| font-size: 8px; padding: 2px 6px; border-radius: 4px; | |
| color: #b5afa5; white-space: nowrap; | |
| font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; | |
| transition: all 0.4s ease; | |
| } | |
| .fsm-hero .trace-step.active { | |
| color: #3d5a80; background: rgba(61, 90, 128,0.08); font-weight: 600; | |
| } | |
| .fsm-hero .trace-step.visited { color: #8a8478; } | |
| .fsm-hero .trace-arrow { | |
| font-size: 7px; color: #c4b8a8; padding: 0 1px; | |
| } | |
| /* SVG styles */ | |
| .fsm-hero .edge-line { fill: none; stroke-linecap: round; } | |
| .fsm-hero .node-circle { cursor: grab; } | |
| .fsm-hero .node-circle:active { cursor: grabbing; } | |
| .fsm-hero .node-label { | |
| font-family: 'IBM Plex Mono', ui-monospace, monospace; | |
| font-size: 8.5px; fill: #4a4540; | |
| text-anchor: middle; pointer-events: none; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .fsm-hero .trace-particle { pointer-events: none; } | |
| .fsm-hero .hero-tip { | |
| position: absolute; top: 0; left: 0; pointer-events: none; padding: 6px 10px; border-radius: 6px; | |
| font-size: 11px; line-height: 1.4; border: 1px solid #d9d0c4; | |
| background: rgba(245,240,232,0.95); color: #4a4540; backdrop-filter: blur(6px); | |
| opacity: 0; transition: opacity 0.15s; z-index: 50; | |
| font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; | |
| max-width: 220px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); | |
| } | |
| [data-theme="dark"] .fsm-hero .hero-tip { | |
| background: rgba(26,24,20,0.95); color: #b5afa5; border-color: #2e2a24; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.3); | |
| } | |
| @media (max-width: 600px) { | |
| .fsm-hero { height: 420px; } | |
| .fsm-hero .hero-trace-bar { bottom: 12px; } | |
| } | |
| /* Dark theme override */ | |
| [data-theme="dark"] .fsm-hero { | |
| background: #1a1814; border-color: #2e2a24; | |
| } | |
| [data-theme="dark"] .fsm-hero .pill { | |
| border-color: #3a3530; background: rgba(26,24,20,0.7); color: #8a8478; | |
| } | |
| [data-theme="dark"] .fsm-hero .pill:hover { | |
| border-color: #4a4540; color: #b5afa5; background: rgba(26,24,20,0.9); | |
| } | |
| [data-theme="dark"] .fsm-hero .pill.active { | |
| border-color: #3d5a80; background: rgba(61, 90, 128,0.12); color: #3d5a80; | |
| } | |
| [data-theme="dark"] .fsm-hero .hero-trace-bar { | |
| background: rgba(26,24,20,0.85); border-color: #2e2a24; | |
| } | |
| [data-theme="dark"] .fsm-hero .trace-step { color: #5a5550; } | |
| [data-theme="dark"] .fsm-hero .trace-step.active { color: #3d5a80; background: rgba(61, 90, 128,0.15); } | |
| [data-theme="dark"] .fsm-hero .trace-step.visited { color: #8a8478; } | |
| [data-theme="dark"] .fsm-hero .trace-arrow { color: #3a3530; } | |
| [data-theme="dark"] .fsm-hero .node-label { fill: #b5afa5; } | |
| </style> | |
| <script> | |
| (() => { | |
| const ensureD3 = (cb) => { | |
| if (window.d3 && typeof window.d3.select === 'function') return cb(); | |
| let s = document.getElementById('d3-cdn-script'); | |
| if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); } | |
| s.addEventListener('load', () => cb(), { once: true }); | |
| }; | |
| const bootstrap = () => { | |
| const container = document.querySelector('.fsm-hero:not([data-mounted])'); | |
| if (!container) return; | |
| container.dataset.mounted = 'true'; | |
| const d3 = window.d3; | |
| // --- Color palette (matches dashboard) --- | |
| const RUST = '#3d5a80'; | |
| const EDGE_COLOR = '#b5afa5'; | |
| const ARROW_COLOR = '#8a8478'; | |
| const TEXT_COLOR = '#4a4540'; | |
| const INIT_FILL = '#f5f0e8'; | |
| const NODE_STROKE = '#b5afa5'; | |
| // --- Short label --- | |
| function shortLabel(id) { | |
| if (id === 'init') return 'q\u2080'; | |
| const p = id.split(':'); | |
| if (p.length === 3 && p[1] === 'tool') return p[2]; | |
| if (p.length === 2 && p[1] === 'text') { | |
| if (['user','assistant','system','tool'].includes(p[0])) return p[0]; | |
| return p[0] + '\u21a9'; | |
| } | |
| if (p[0] === 'assistant') return p[1]; | |
| return p[p.length - 1]; | |
| } | |
| // --- Datasets --- | |
| const datasets = { | |
| 'Mind2Web': { | |
| states: 8, traces: 500, | |
| nodes: ['init','user:text','assistant:type','assistant:click','assistant:select','assistant:hover','assistant:enter','assistant:text'], | |
| edges: [['init','user:text'],['user:text','assistant:type'],['assistant:type','assistant:click'],['assistant:click','assistant:type'],['assistant:click','assistant:click'],['assistant:click','assistant:select'],['assistant:select','assistant:select'],['assistant:select','assistant:click'],['user:text','assistant:click'],['assistant:click','assistant:hover'],['assistant:type','assistant:type'],['user:text','assistant:hover'],['assistant:hover','assistant:click'],['assistant:hover','assistant:hover'],['assistant:type','assistant:select'],['assistant:type','assistant:hover'],['assistant:type','assistant:enter'],['assistant:enter','assistant:click'],['assistant:select','assistant:type'],['user:text','assistant:select'],['assistant:text','assistant:click']] | |
| }, | |
| 'tau2-bench': { | |
| states: 6, traces: 1824, | |
| nodes: ['init','system:text','user:text','assistant:text','assistant:tool_call','tool:text'], | |
| edges: [['init','system:text'],['system:text','user:text'],['user:text','assistant:text'],['assistant:text','user:text'],['user:text','assistant:tool_call'],['assistant:tool_call','tool:text'],['tool:text','assistant:tool_call'],['tool:text','assistant:text']] | |
| }, | |
| 'WebArena': { | |
| states: 25, traces: 8337, | |
| nodes: ['init','assistant:tool:click','click:text','assistant:tool:type','type:text','assistant:tool:select_option','select_option:text','assistant:tool:search','search:text','assistant:tool:hover','hover:text','assistant:tool:scroll_down','scroll_down:text','assistant:tool:quote','quote:text','assistant:tool:go_backward','go_backward:text','assistant:tool:scroll_up','scroll_up:text','assistant:tool:press_enter','press_enter:text','assistant:tool:wait','wait:text','assistant:tool:unknown','unknown:text'], | |
| edges: [['init','assistant:tool:click'],['assistant:tool:click','click:text'],['click:text','assistant:tool:type'],['assistant:tool:type','type:text'],['type:text','assistant:tool:type'],['type:text','assistant:tool:select_option'],['assistant:tool:select_option','select_option:text'],['init','assistant:tool:search'],['assistant:tool:search','search:text'],['search:text','assistant:tool:click'],['click:text','assistant:tool:search'],['click:text','assistant:tool:click'],['type:text','assistant:tool:click'],['init','assistant:tool:type'],['init','assistant:tool:hover'],['assistant:tool:hover','hover:text'],['hover:text','assistant:tool:hover'],['hover:text','assistant:tool:click'],['click:text','assistant:tool:scroll_down'],['assistant:tool:scroll_down','scroll_down:text'],['scroll_down:text','assistant:tool:scroll_down'],['click:text','assistant:tool:quote'],['assistant:tool:quote','quote:text'],['quote:text','assistant:tool:scroll_down'],['scroll_down:text','assistant:tool:quote'],['quote:text','assistant:tool:click'],['select_option:text','assistant:tool:click'],['click:text','assistant:tool:select_option'],['type:text','assistant:tool:scroll_down'],['select_option:text','assistant:tool:type'],['scroll_down:text','assistant:tool:click'],['init','assistant:tool:scroll_down'],['click:text','assistant:tool:go_backward'],['assistant:tool:go_backward','go_backward:text'],['go_backward:text','assistant:tool:click'],['search:text','assistant:tool:scroll_down'],['scroll_down:text','assistant:tool:type'],['go_backward:text','assistant:tool:scroll_down'],['click:text','assistant:tool:scroll_up'],['assistant:tool:scroll_up','scroll_up:text'],['search:text','assistant:tool:select_option'],['select_option:text','assistant:tool:scroll_down'],['select_option:text','assistant:tool:select_option'],['scroll_up:text','assistant:tool:click'],['hover:text','assistant:tool:scroll_down'],['go_backward:text','assistant:tool:scroll_up'],['type:text','assistant:tool:press_enter'],['assistant:tool:press_enter','press_enter:text'],['press_enter:text','assistant:tool:scroll_down'],['scroll_down:text','assistant:tool:scroll_up'],['click:text','assistant:tool:wait'],['assistant:tool:wait','wait:text'],['wait:text','assistant:tool:scroll_down'],['scroll_up:text','assistant:tool:scroll_up'],['search:text','assistant:tool:search'],['scroll_down:text','assistant:tool:unknown'],['assistant:tool:unknown','unknown:text'],['unknown:text','assistant:tool:unknown']] | |
| } | |
| }; | |
| let currentDs = 'WebArena'; | |
| let traceTimer = null; | |
| // === Pre-computed layout (ported from dashboard computeLayout) === | |
| function computeLayout(nodes, edges, W, H) { | |
| const n = nodes.length; | |
| if (n === 0) return {}; | |
| const cx = W / 2, cy = H / 2; | |
| const pos = {}, vel = {}; | |
| const initIdx = nodes.indexOf('init'); | |
| const r0 = Math.min(160, n * 22); | |
| nodes.forEach((id, i) => { | |
| const idx = initIdx >= 0 ? (i - initIdx + n) % n : i; | |
| const angle = (idx / n) * 2 * Math.PI - Math.PI / 2; | |
| pos[id] = { x: cx + r0 * Math.cos(angle), y: cy + r0 * Math.sin(angle) }; | |
| vel[id] = { x: 0, y: 0 }; | |
| }); | |
| // Build adjacency | |
| const adj = {}; | |
| nodes.forEach(id => adj[id] = new Set()); | |
| edges.forEach(([s, t]) => { | |
| if (s !== t) { adj[s].add(t); adj[t].add(s); } | |
| }); | |
| // In-degree | |
| const inDeg = {}; | |
| edges.forEach(([_, t]) => inDeg[t] = (inDeg[t] || 0) + 1); | |
| // Force simulation - 200 iterations then FREEZE | |
| const REPULSION = 8000, ATTRACTION = 0.008, DAMPING = 0.85, ITERS = 200; | |
| for (let iter = 0; iter < ITERS; iter++) { | |
| const temp = 1 - iter / ITERS; | |
| for (const a of nodes) { | |
| let fx = 0, fy = 0; | |
| for (const b of nodes) { | |
| if (a === b) continue; | |
| const dx = pos[a].x - pos[b].x; | |
| const dy = pos[a].y - pos[b].y; | |
| const d2 = dx * dx + dy * dy + 1; | |
| const f = REPULSION / d2; | |
| const d = Math.sqrt(d2); | |
| fx += f * dx / d; | |
| fy += f * dy / d; | |
| } | |
| const neighbors = adj[a]; | |
| if (neighbors) { | |
| for (const bId of neighbors) { | |
| const dx = pos[bId].x - pos[a].x; | |
| const dy = pos[bId].y - pos[a].y; | |
| const d = Math.sqrt(dx * dx + dy * dy); | |
| const idealDist = 100; | |
| const f = ATTRACTION * (d - idealDist); | |
| fx += f * dx / (d + 0.1); | |
| fy += f * dy / (d + 0.1); | |
| } | |
| } | |
| // Center gravity | |
| fx += (cx - pos[a].x) * 0.001; | |
| fy += (cy - pos[a].y) * 0.001; | |
| vel[a].x = (vel[a].x + fx) * DAMPING * temp; | |
| vel[a].y = (vel[a].y + fy) * DAMPING * temp; | |
| } | |
| for (const id of nodes) { | |
| pos[id].x = Math.max(60, Math.min(W - 60, pos[id].x + vel[id].x)); | |
| pos[id].y = Math.max(60, Math.min(H - 60, pos[id].y + vel[id].y)); | |
| } | |
| } | |
| return pos; | |
| } | |
| // --- Build adjacency for random walks --- | |
| function buildAdj(ds) { | |
| const adj = {}; | |
| ds.nodes.forEach(n => adj[n] = []); | |
| ds.edges.forEach(([s, t]) => { if (adj[s]) adj[s].push(t); }); | |
| return adj; | |
| } | |
| function randomWalk(ds, len = 16) { | |
| const adj = buildAdj(ds); | |
| const walk = ['init']; | |
| let cur = 'init'; | |
| for (let i = 0; i < len; i++) { | |
| const nexts = adj[cur]; | |
| if (!nexts || nexts.length === 0) break; | |
| const nonSelf = nexts.filter(n => n !== cur); | |
| if (nonSelf.length > 0 && Math.random() < 0.85) { | |
| cur = nonSelf[Math.floor(Math.random() * nonSelf.length)]; | |
| } else { | |
| cur = nexts[Math.floor(Math.random() * nexts.length)]; | |
| } | |
| walk.push(cur); | |
| } | |
| return walk; | |
| } | |
| // --- Pills --- | |
| const pillsEl = container.querySelector('.hero-pills'); | |
| Object.keys(datasets).forEach(name => { | |
| const pill = document.createElement('div'); | |
| pill.className = 'pill' + (name === currentDs ? ' active' : ''); | |
| pill.textContent = name; | |
| pill.addEventListener('click', () => { | |
| if (name === currentDs) return; | |
| currentDs = name; | |
| pillsEl.querySelectorAll('.pill').forEach(p => p.classList.remove('active')); | |
| pill.classList.add('active'); | |
| renderGraph(); | |
| }); | |
| pillsEl.appendChild(pill); | |
| }); | |
| // --- Trace bar --- | |
| const traceBar = container.querySelector('.hero-trace-bar'); | |
| function updateTraceBar(walk, stepIdx) { | |
| traceBar.innerHTML = ''; | |
| const maxShow = 14; | |
| const start = Math.max(0, stepIdx - Math.floor(maxShow / 2)); | |
| const end = Math.min(walk.length, start + maxShow); | |
| for (let i = start; i < end; i++) { | |
| if (i > start) { | |
| const arrow = document.createElement('span'); | |
| arrow.className = 'trace-arrow'; | |
| arrow.textContent = '\u2192'; | |
| traceBar.appendChild(arrow); | |
| } | |
| const step = document.createElement('span'); | |
| step.className = 'trace-step'; | |
| step.textContent = shortLabel(walk[i]); | |
| if (i === stepIdx) step.classList.add('active'); | |
| else if (i < stepIdx) step.classList.add('visited'); | |
| traceBar.appendChild(step); | |
| } | |
| } | |
| // --- Main graph render --- | |
| function renderGraph() { | |
| if (traceTimer) clearTimeout(traceTimer); | |
| const graphEl = container.querySelector('.hero-graph'); | |
| graphEl.innerHTML = ''; | |
| const ds = datasets[currentDs]; | |
| // Use FIXED 600x400 base (matching dashboard exactly), viewBox scales | |
| const W = 600, H = 400; | |
| // Pre-compute layout (STATIC positions - no force physics) | |
| const nodePositions = computeLayout(ds.nodes, ds.edges, W, H); | |
| const svg = d3.select(graphEl).append('svg') | |
| .attr('viewBox', '-20 -10 640 430') | |
| .style('width', '100%').style('height', '100%'); | |
| const defs = svg.append('defs'); | |
| // Arrow markers (dashboard style) | |
| defs.append('marker').attr('id', 'arrow') | |
| .attr('viewBox', '0 0 10 6').attr('refX', 10).attr('refY', 3) | |
| .attr('markerWidth', 7).attr('markerHeight', 4.5).attr('orient', 'auto') | |
| .append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', ARROW_COLOR); | |
| defs.append('marker').attr('id', 'arrow-active') | |
| .attr('viewBox', '0 0 10 6').attr('refX', 10).attr('refY', 3) | |
| .attr('markerWidth', 7).attr('markerHeight', 4.5).attr('orient', 'auto') | |
| .append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', RUST); | |
| // Detect bidirectional edges | |
| const edgeSet = new Set(ds.edges.map(([s, t]) => s + '|' + t)); | |
| // In-degree for node sizing | |
| const inDeg = {}; | |
| ds.edges.forEach(([_, t]) => inDeg[t] = (inDeg[t] || 0) + 1); | |
| const maxDeg = Math.max(1, ...Object.values(inDeg)); | |
| function nodeR(id) { | |
| if (id === 'init') return 13; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return 14 + visitNorm * 6; | |
| } | |
| // SVG layers | |
| const edgeLayer = svg.append('g').attr('class', 'edge-layer'); | |
| const nodeLayer = svg.append('g').attr('class', 'node-layer'); | |
| const particleLayer = svg.append('g').attr('class', 'particle-layer'); | |
| // Build link data | |
| const linkData = ds.edges.map(([s, t], i) => ({ | |
| source: s, target: t, id: i, | |
| selfLoop: s === t, | |
| bidirectional: edgeSet.has(t + '|' + s), | |
| })); | |
| // Edge path computation (matches dashboard exactly) | |
| function edgePath(d) { | |
| const from = nodePositions[d.source]; | |
| const to = nodePositions[d.target]; | |
| if (!from || !to) return ''; | |
| const r1 = nodeR(d.source); | |
| const r2 = nodeR(d.target); | |
| if (d.selfLoop) { | |
| const r = r1 + 6; | |
| return `M${from.x},${from.y - r} C${from.x - 35},${from.y - r - 40} ${from.x + 35},${from.y - r - 40} ${from.x},${from.y - r}`; | |
| } | |
| const dx = to.x - from.x, dy = to.y - from.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const nx = dx / dist, ny = dy / dist; | |
| if (d.bidirectional) { | |
| const curve = 25; | |
| const mx = (from.x + to.x) / 2 - ny * curve; | |
| const my = (from.y + to.y) / 2 + nx * curve; | |
| return `M${from.x + nx * (r1 + 2) - ny * 3},${from.y + ny * (r1 + 2) + nx * 3} Q${mx},${my} ${to.x - nx * (r2 + 4) - ny * 3},${to.y - ny * (r2 + 4) + nx * 3}`; | |
| } | |
| return `M${from.x + nx * (r1 + 2)},${from.y + ny * (r1 + 2)} L${to.x - nx * (r2 + 4)},${to.y - ny * (r2 + 4)}`; | |
| } | |
| // Recompute all edges for current positions | |
| function updateEdges() { | |
| edgePaths.attr('d', edgePath); | |
| edgeGlows.attr('d', edgePath); | |
| } | |
| // Draw edges | |
| const edgePaths = edgeLayer.selectAll('.edge-line') | |
| .data(linkData).enter().append('path') | |
| .attr('class', 'edge-line') | |
| .attr('d', edgePath) | |
| .attr('stroke', EDGE_COLOR) | |
| .attr('stroke-width', d => { | |
| const freq = (inDeg[d.target] || 0) / maxDeg; | |
| return 0.5 + freq * 2.5; | |
| }) | |
| .attr('stroke-opacity', d => { | |
| const freq = (inDeg[d.target] || 0) / maxDeg; | |
| return 0.3 + freq * 0.5; | |
| }) | |
| .attr('marker-end', d => d.selfLoop ? '' : 'url(#arrow)'); | |
| // Edge glow overlay (for animation) | |
| const edgeGlows = edgeLayer.selectAll('.edge-glow') | |
| .data(linkData).enter().append('path') | |
| .attr('class', 'edge-glow edge-line') | |
| .attr('d', edgePath) | |
| .attr('stroke', RUST) | |
| .attr('stroke-width', 3) | |
| .attr('opacity', 0) | |
| .attr('marker-end', 'url(#arrow-active)'); | |
| // Draw nodes | |
| const nodeGroups = nodeLayer.selectAll('.node-g') | |
| .data(ds.nodes).enter().append('g') | |
| .attr('class', 'node-g') | |
| .attr('transform', id => { | |
| const p = nodePositions[id]; | |
| return p ? `translate(${p.x},${p.y})` : ''; | |
| }); | |
| // Node circles (dashboard style) | |
| nodeGroups.append('circle') | |
| .attr('class', 'node-circle') | |
| .attr('r', id => nodeR(id)) | |
| .attr('fill', id => { | |
| if (id === 'init') return INIT_FILL; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return `rgba(61, 90, 128, ${0.04 + visitNorm * 0.12})`; | |
| }) | |
| .attr('stroke', id => { | |
| if (id === 'init') return RUST; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return visitNorm > 0.5 ? RUST : NODE_STROKE; | |
| }) | |
| .attr('stroke-width', id => { | |
| if (id === 'init') return 2; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return visitNorm > 0.5 ? 1.5 : 1; | |
| }); | |
| // Double circle for init | |
| nodeGroups.filter(id => id === 'init').append('circle') | |
| .attr('r', id => nodeR(id) - 3) | |
| .attr('fill', 'none').attr('stroke', RUST).attr('stroke-width', 1); | |
| // Labels (dashboard style) | |
| nodeGroups.append('text') | |
| .attr('class', 'node-label') | |
| .attr('dy', id => nodeR(id) + 12) | |
| .text(id => { | |
| const label = shortLabel(id); | |
| return label.length > 18 ? label.slice(0, 16) + '..' : label; | |
| }); | |
| // --- Tooltip --- | |
| let tip = container.querySelector('.hero-tip'); | |
| if (!tip) { | |
| tip = document.createElement('div'); | |
| tip.className = 'hero-tip'; | |
| container.appendChild(tip); | |
| } | |
| tip.style.opacity = '0'; | |
| nodeGroups | |
| .on('mouseenter', (ev, id) => { | |
| const deg = inDeg[id] || 0; | |
| const outDeg = ds.edges.filter(([s]) => s === id).length; | |
| tip.innerHTML = '<strong>' + id + '</strong><br/>In: ' + deg + ' | Out: ' + outDeg; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mousemove', (ev) => { | |
| const cr = container.getBoundingClientRect(); | |
| const mx = ev.clientX - cr.left + 14; | |
| const my = ev.clientY - cr.top - 14; | |
| tip.style.transform = 'translate(' + mx + 'px, ' + my + 'px)'; | |
| }) | |
| .on('mouseleave', () => { tip.style.opacity = '0'; }); | |
| // --- Drag behavior (reposition nodes, update edges live) --- | |
| const svgEl = svg.node(); | |
| const drag = d3.drag() | |
| .on('start', function(ev, id) { | |
| d3.select(this).select('.node-circle').attr('stroke', RUST).attr('stroke-width', 2.5); | |
| }) | |
| .on('drag', function(ev, id) { | |
| // Convert screen coords to SVG coords | |
| const pt = svgEl.createSVGPoint(); | |
| pt.x = ev.sourceEvent.clientX; | |
| pt.y = ev.sourceEvent.clientY; | |
| const svgPt = pt.matrixTransform(svgEl.getScreenCTM().inverse()); | |
| nodePositions[id].x = Math.max(20, Math.min(W - 20, svgPt.x)); | |
| nodePositions[id].y = Math.max(20, Math.min(H - 20, svgPt.y)); | |
| d3.select(this).attr('transform', 'translate(' + nodePositions[id].x + ',' + nodePositions[id].y + ')'); | |
| updateEdges(); | |
| }) | |
| .on('end', function(ev, id) { | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| d3.select(this).select('.node-circle') | |
| .attr('stroke', id === 'init' ? RUST : visitNorm > 0.5 ? RUST : NODE_STROKE) | |
| .attr('stroke-width', id === 'init' ? 2 : visitNorm > 0.5 ? 1.5 : 1); | |
| }); | |
| nodeGroups.call(drag); | |
| // Trace particle (rust colored) | |
| const particle = particleLayer.append('circle') | |
| .attr('class', 'trace-particle') | |
| .attr('r', 5).attr('fill', RUST) | |
| .attr('opacity', 0); | |
| const particleTrail = particleLayer.append('circle') | |
| .attr('class', 'trace-particle') | |
| .attr('r', 10).attr('fill', RUST) | |
| .attr('opacity', 0); | |
| // --- Trace simulation (50% speed = doubled durations) --- | |
| function runTrace() { | |
| const walk = randomWalk(ds, ds.nodes.length > 15 ? 20 : 14); | |
| let step = 0; | |
| updateTraceBar(walk, 0); | |
| function findEdge(s, t) { | |
| return linkData.find(e => e.source === s && e.target === t); | |
| } | |
| function animateStep() { | |
| if (step >= walk.length) { | |
| particle.attr('opacity', 0); | |
| particleTrail.attr('opacity', 0); | |
| // Reset node strokes | |
| nodeGroups.select('.node-circle') | |
| .transition().duration(400) | |
| .attr('stroke', function(id) { | |
| if (id === 'init') return RUST; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return visitNorm > 0.5 ? RUST : NODE_STROKE; | |
| }) | |
| .attr('stroke-width', function(id) { | |
| if (id === 'init') return 2; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return visitNorm > 0.5 ? 1.5 : 1; | |
| }); | |
| edgeGlows.transition().duration(600).attr('opacity', 0); | |
| traceTimer = setTimeout(runTrace, 3000); | |
| return; | |
| } | |
| const curId = walk[step]; | |
| const curPos = nodePositions[curId]; | |
| updateTraceBar(walk, step); | |
| // Highlight current node | |
| nodeGroups.select('.node-circle') | |
| .transition().duration(400) | |
| .attr('stroke', function(id) { | |
| if (id === curId) return RUST; | |
| if (id === 'init') return RUST; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return visitNorm > 0.5 ? RUST : NODE_STROKE; | |
| }) | |
| .attr('stroke-width', function(id) { | |
| if (id === curId) return 2.5; | |
| if (id === 'init') return 2; | |
| const visitNorm = (inDeg[id] || 0) / maxDeg; | |
| return visitNorm > 0.5 ? 1.5 : 1; | |
| }); | |
| // Animate particle along edge | |
| if (step < walk.length - 1) { | |
| const nextId = walk[step + 1]; | |
| const nextPos = nodePositions[nextId]; | |
| const edge = findEdge(curId, nextId); | |
| if (edge) { | |
| const edgeIdx = edge.id; | |
| edgeGlows.filter(d => d.id === edgeIdx) | |
| .attr('opacity', 0.6) | |
| .transition().duration(1000).attr('opacity', 0.1); | |
| } | |
| if (curPos && nextPos) { | |
| particle.attr('cx', curPos.x).attr('cy', curPos.y).attr('opacity', 0.9); | |
| particleTrail.attr('cx', curPos.x).attr('cy', curPos.y).attr('opacity', 0.12); | |
| particle.transition().duration(900).ease(d3.easeCubicInOut) | |
| .attr('cx', nextPos.x).attr('cy', nextPos.y); | |
| particleTrail.transition().duration(1000).ease(d3.easeCubicInOut) | |
| .attr('cx', nextPos.x).attr('cy', nextPos.y); | |
| } | |
| } | |
| step++; | |
| traceTimer = setTimeout(animateStep, 1200); | |
| } | |
| traceTimer = setTimeout(animateStep, 800); | |
| } | |
| // Start trace after brief pause | |
| traceTimer = setTimeout(runTrace, 1200); | |
| } | |
| renderGraph(); | |
| // Responsive | |
| let resizeTimer; | |
| if (window.ResizeObserver) { | |
| new ResizeObserver(() => { | |
| clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(renderGraph, 200); | |
| }).observe(container); | |
| } | |
| }; | |
| if (document.readyState === 'loading') | |
| document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); | |
| else ensureD3(bootstrap); | |
| })(); | |
| </script> | |