AgentStateGraph / app /src /content /embeds /banner.html
seonglae's picture
Automata from Agent Traces — interactive article
59027a2
Raw
History Blame Contribute Delete
27.1 kB
<!-- 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>