Spaces:
Running
Running
| <!-- Prefix Tree Collapse: Animated tree → FSM visualization --> | |
| <div class="prefix-collapse"></div> | |
| <style> | |
| .prefix-collapse { position: relative; width: 100%; min-height: 480px; } | |
| .prefix-collapse svg { display: block; width: 100%; } | |
| .prefix-collapse .top-bar { | |
| display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; | |
| } | |
| .prefix-collapse .seg-control { | |
| display: inline-flex; background: var(--surface-bg); | |
| border: 1px solid var(--border-color); border-radius: 8px; | |
| padding: 3px; gap: 2px; | |
| } | |
| .prefix-collapse .seg-control button { | |
| padding: 5px 14px; border-radius: 6px; border: none; | |
| background: transparent; font-size: 12px; font-weight: 500; | |
| color: var(--text-color); cursor: pointer; transition: all 0.15s ease; | |
| opacity: 0.6; | |
| } | |
| .prefix-collapse .seg-control button:hover { opacity: 0.8; } | |
| .prefix-collapse .seg-control button.active { | |
| background: var(--text-color); color: var(--page-bg); | |
| opacity: 1; font-weight: 600; | |
| } | |
| .prefix-collapse .stat-pills { | |
| display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; | |
| } | |
| .prefix-collapse .stat-pill { | |
| padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; | |
| border: 1px solid var(--border-color); background: var(--surface-bg); | |
| color: var(--text-color); font-variant-numeric: tabular-nums; | |
| } | |
| .prefix-collapse .stat-pill.highlight { | |
| border-color: #3d5a80; color: #3d5a80; | |
| } | |
| .prefix-collapse .node-label-tree { | |
| font-size: 9px; fill: var(--text-color); pointer-events: none; text-anchor: middle; | |
| opacity: 0.7; | |
| } | |
| </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('.prefix-collapse:not([data-mounted])'); | |
| if (!container) return; | |
| container.dataset.mounted = 'true'; | |
| const d3 = window.d3; | |
| // --- Palette (ported from browser/) --- | |
| const RUST = '#3d5a80'; | |
| const traces = [ | |
| ['init','sys','usr','bash','tool','edit','tool','bash','tool','submit','tool'], | |
| ['init','sys','usr','bash','tool','bash','tool','edit','tool','submit','tool'], | |
| ['init','sys','usr','edit','tool','bash','tool','submit','tool'], | |
| ['init','sys','usr','bash','tool','edit','tool','edit','tool','bash','tool','submit','tool'], | |
| ['init','sys','usr','bash','tool','bash','tool','bash','tool','submit','tool'], | |
| ]; | |
| // Build prefix tree | |
| let nextId = 0; | |
| const root = { id: nextId++, label: 'init', children: {}, depth: 0 }; | |
| traces.forEach(trace => { | |
| let node = root; | |
| for (let i = 1; i < trace.length; i++) { | |
| const sym = trace[i]; | |
| if (!node.children[sym]) { | |
| node.children[sym] = { id: nextId++, label: sym, children: {}, depth: i }; | |
| } | |
| node = node.children[sym]; | |
| } | |
| }); | |
| function flattenTree(node, parent) { | |
| const result = [{ id: node.id, label: node.label, parent: parent ? parent.id : null, depth: node.depth }]; | |
| Object.values(node.children).forEach(child => { | |
| result.push(...flattenTree(child, node)); | |
| }); | |
| return result; | |
| } | |
| const treeNodes = flattenTree(root, null); | |
| const treeEdges = treeNodes.filter(n => n.parent !== null).map(n => ({ source: n.parent, target: n.id })); | |
| const fsmNodes = [ | |
| { id: 'f-init', label: 'init' }, | |
| { id: 'f-sys', label: 'sys' }, | |
| { id: 'f-usr', label: 'usr' }, | |
| { id: 'f-bash', label: 'bash' }, | |
| { id: 'f-tool', label: 'tool' }, | |
| { id: 'f-edit', label: 'edit' }, | |
| { id: 'f-submit', label: 'submit' }, | |
| ]; | |
| const fsmEdges = [ | |
| { source: 'f-init', target: 'f-sys' }, | |
| { source: 'f-sys', target: 'f-usr' }, | |
| { source: 'f-usr', target: 'f-bash' }, | |
| { source: 'f-usr', target: 'f-edit' }, | |
| { source: 'f-bash', target: 'f-tool' }, | |
| { source: 'f-edit', target: 'f-tool' }, | |
| { source: 'f-tool', target: 'f-bash' }, | |
| { source: 'f-tool', target: 'f-edit' }, | |
| { source: 'f-tool', target: 'f-submit' }, | |
| { source: 'f-submit', target: 'f-tool' }, | |
| { source: 'f-tool', target: 'f-usr' }, | |
| ]; | |
| // Tree node colors (warm, subtle) | |
| const treeNodeColor = '#3d5a80'; | |
| let showFSM = true; | |
| // Top bar | |
| const topBar = document.createElement('div'); | |
| topBar.className = 'top-bar'; | |
| const seg = document.createElement('div'); | |
| seg.className = 'seg-control'; | |
| const treeBtn = document.createElement('button'); | |
| treeBtn.textContent = 'Prefix Tree'; | |
| const mergeBtn = document.createElement('button'); | |
| mergeBtn.textContent = 'Merged FSM'; | |
| mergeBtn.className = 'active'; | |
| seg.append(treeBtn, mergeBtn); | |
| const statPills = document.createElement('div'); | |
| statPills.className = 'stat-pills'; | |
| topBar.append(seg, statPills); | |
| container.prepend(topBar); | |
| function updateStats() { | |
| if (!showFSM) { | |
| statPills.innerHTML = '<span class="stat-pill">' + treeNodes.length + ' nodes</span><span class="stat-pill">' + treeEdges.length + ' edges</span>'; | |
| } else { | |
| const ratio = Math.round(treeNodes.length / fsmNodes.length); | |
| statPills.innerHTML = '<span class="stat-pill">' + fsmNodes.length + ' states</span><span class="stat-pill">' + fsmEdges.length + ' edges</span><span class="stat-pill highlight">' + treeNodes.length + ' \u2192 ' + fsmNodes.length + ' (' + ratio + '\u00d7)</span>'; | |
| } | |
| } | |
| function render() { | |
| const oldSvg = container.querySelector('svg'); | |
| if (oldSvg) { | |
| oldSvg.style.transition = 'opacity 0.2s ease'; | |
| oldSvg.style.opacity = '0'; | |
| setTimeout(() => oldSvg.remove(), 200); | |
| } | |
| const rect = container.getBoundingClientRect(); | |
| const W = Math.max(400, Math.round(rect.width)); | |
| const H = 420; | |
| updateStats(); | |
| setTimeout(() => { | |
| const svg = d3.select(container).append('svg') | |
| .attr('width', W).attr('height', H) | |
| .style('opacity', '0') | |
| .style('transition', 'opacity 0.25s ease'); | |
| if (!showFSM) { | |
| // ========== Prefix Tree ========== | |
| // Arrow marker for tree | |
| const defs = svg.append('defs'); | |
| defs.append('marker') | |
| .attr('id', 'pc-tree-arrow').attr('viewBox', '0 0 10 6') | |
| .attr('refX', 10).attr('refY', 3) | |
| .attr('markerWidth', 5).attr('markerHeight', 3.5) | |
| .attr('orient', 'auto') | |
| .append('path').attr('d', 'M0,0 L10,3 L0,6').attr('fill', '#b5afa5'); | |
| const treeData = d3.stratify() | |
| .id(d => d.id) | |
| .parentId(d => d.parent)(treeNodes); | |
| const treeLayout = d3.tree().size([W - 60, H - 80]); | |
| const layoutData = treeLayout(treeData); | |
| // Edges - warm taupe | |
| svg.append('g').selectAll('path').data(layoutData.links()).enter().append('path') | |
| .attr('d', d => { | |
| const sx = d.source.x + 30, sy = d.source.y + 35; | |
| const tx = d.target.x + 30, ty = d.target.y + 35; | |
| const my = (sy + ty) / 2; | |
| return 'M' + sx + ',' + sy + ' C' + sx + ',' + my + ' ' + tx + ',' + my + ' ' + tx + ',' + ty; | |
| }) | |
| .attr('stroke', '#b5afa5') | |
| .attr('stroke-opacity', 0.35).attr('stroke-width', 1.2) | |
| .attr('fill', 'none'); | |
| // Nodes - rust tinted by depth | |
| svg.append('g').selectAll('circle').data(layoutData.descendants()).enter().append('circle') | |
| .attr('cx', d => d.x + 30).attr('cy', d => d.y + 35) | |
| .attr('r', d => d.depth === 0 ? 7 : 5) | |
| .attr('fill', d => { | |
| if (d.depth === 0) return 'var(--surface-bg)'; | |
| const t = Math.min(1, d.depth / 8); | |
| return 'rgba(61, 90, 128, ' + (0.15 + t * 0.35) + ')'; | |
| }) | |
| .attr('stroke', d => d.depth === 0 ? RUST : '#b5afa5') | |
| .attr('stroke-width', d => d.depth === 0 ? 2 : 0.8); | |
| // Init double circle | |
| const initNode = layoutData.descendants().find(d => d.depth === 0); | |
| if (initNode) { | |
| svg.append('circle') | |
| .attr('cx', initNode.x + 30).attr('cy', initNode.y + 35) | |
| .attr('r', 4).attr('fill', 'none').attr('stroke', RUST).attr('stroke-width', 1); | |
| } | |
| // Labels for shallow nodes | |
| svg.append('g').selectAll('text').data(layoutData.descendants().filter(d => d.depth <= 2)) | |
| .enter().append('text').attr('class', 'node-label-tree') | |
| .attr('x', d => d.x + 30).attr('y', d => d.y + 23) | |
| .attr('font-family', 'IBM Plex Mono, ui-monospace, monospace') | |
| .attr('font-size', '8.5px') | |
| .text(d => d.data.label); | |
| } else { | |
| // ========== Merged FSM (browser-style) ========== | |
| // Arrow marker - warm taupe | |
| const defs = svg.append('defs'); | |
| defs.append('marker') | |
| .attr('id', 'pc-fsm-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', '#8a8478'); | |
| // Pre-compute degree | |
| const deg = {}; | |
| fsmNodes.forEach(n => { deg[n.id] = 0; }); | |
| fsmEdges.forEach(e => { deg[e.source]++; deg[e.target]++; }); | |
| const maxDeg = Math.max(1, ...Object.values(deg)); | |
| // Edge set for bidirectional detection | |
| const edgeSet = new Set(); | |
| fsmEdges.forEach(e => edgeSet.add(e.source + '|' + e.target)); | |
| function nodeR(id) { | |
| if (id === 'f-init') return 13; | |
| return 12 + (deg[id] / maxDeg) * 6; | |
| } | |
| // Force layout - run to completion, then static drag | |
| const nodes = fsmNodes.map(n => ({ ...n })); | |
| const links = fsmEdges.map(e => ({ source: e.source, target: e.target })); | |
| const simulation = d3.forceSimulation(nodes) | |
| .force('link', d3.forceLink(links).id(d => d.id).distance(85)) | |
| .force('charge', d3.forceManyBody().strength(-400)) | |
| .force('center', d3.forceCenter(W / 2, H / 2)) | |
| .force('collision', d3.forceCollide().radius(d => nodeR(d.id) + 6)) | |
| .stop(); | |
| // Run simulation to completion (static layout) | |
| for (let i = 0; i < 300; i++) simulation.tick(); | |
| const linkGroup = svg.append('g'); | |
| const nodeGroup = svg.append('g'); | |
| // Edges | |
| const linkSel = linkGroup.selectAll('path').data(links).enter().append('path') | |
| .attr('fill', 'none') | |
| .attr('stroke', '#b5afa5') | |
| .attr('stroke-width', d => { | |
| return 0.5 + ((deg[d.source.id] + deg[d.target.id]) / (maxDeg * 2)) * 2.5; | |
| }) | |
| .attr('stroke-opacity', d => { | |
| return 0.3 + ((deg[d.source.id] + deg[d.target.id]) / (maxDeg * 2)) * 0.5; | |
| }) | |
| .attr('marker-end', 'url(#pc-fsm-arrow)'); | |
| // Nodes | |
| const nodeGs = nodeGroup.selectAll('g').data(nodes).enter().append('g') | |
| .attr('cursor', 'grab'); | |
| nodeGs.each(function(d) { | |
| const g = d3.select(this); | |
| const isInit = d.id === 'f-init'; | |
| const r = nodeR(d.id); | |
| const degNorm = deg[d.id] / maxDeg; | |
| if (isInit) { | |
| g.append('circle').attr('r', r) | |
| .attr('fill', 'var(--surface-bg)').attr('stroke', RUST).attr('stroke-width', 2); | |
| g.append('circle').attr('r', r - 3) | |
| .attr('fill', 'none').attr('stroke', RUST).attr('stroke-width', 1); | |
| } else { | |
| g.append('circle').attr('r', r) | |
| .attr('fill', 'rgba(61, 90, 128, ' + (0.06 + degNorm * 0.14) + ')') | |
| .attr('stroke', degNorm > 0.5 ? RUST : '#b5afa5') | |
| .attr('stroke-width', degNorm > 0.5 ? 1.5 : 1); | |
| } | |
| g.append('text') | |
| .attr('y', r + 12) | |
| .attr('text-anchor', 'middle') | |
| .attr('fill', 'var(--text-color)').attr('opacity', 0.7) | |
| .attr('font-size', '9px') | |
| .attr('font-family', 'IBM Plex Mono, ui-monospace, monospace') | |
| .attr('font-weight', '600') | |
| .attr('pointer-events', 'none') | |
| .text(d.label); | |
| }); | |
| // Update positions helper (no simulation, just re-render) | |
| function updatePositions() { | |
| linkSel.attr('d', d => { | |
| const s = d.source, t = d.target; | |
| const rS = nodeR(s.id), rT = nodeR(t.id); | |
| if (s.id === t.id) { | |
| return 'M' + s.x + ',' + (s.y - rS) + | |
| ' C' + (s.x - 30) + ',' + (s.y - rS - 35) + | |
| ' ' + (s.x + 30) + ',' + (s.y - rS - 35) + | |
| ' ' + s.x + ',' + (s.y - rS); | |
| } | |
| const dx = t.x - s.x, dy = t.y - s.y; | |
| const dist = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const nx = dx / dist, ny = dy / dist; | |
| if (edgeSet.has(t.id + '|' + s.id)) { | |
| const curve = 22; | |
| const mx = (s.x + t.x) / 2 - ny * curve; | |
| const my = (s.y + t.y) / 2 + nx * curve; | |
| const x1 = s.x + nx * (rS + 2) - ny * 3; | |
| const y1 = s.y + ny * (rS + 2) + nx * 3; | |
| const x2 = t.x - nx * (rT + 4) - ny * 3; | |
| const y2 = t.y - ny * (rT + 4) + nx * 3; | |
| return 'M' + x1 + ',' + y1 + ' Q' + mx + ',' + my + ' ' + x2 + ',' + y2; | |
| } | |
| const x1 = s.x + nx * (rS + 2); | |
| const y1 = s.y + ny * (rS + 2); | |
| const x2 = t.x - nx * (rT + 4); | |
| const y2 = t.y - ny * (rT + 4); | |
| return 'M' + x1 + ',' + y1 + ' L' + x2 + ',' + y2; | |
| }); | |
| nodeGs.attr('transform', d => { | |
| d.x = Math.max(40, Math.min(W - 40, d.x)); | |
| d.y = Math.max(40, Math.min(H - 40, d.y)); | |
| return 'translate(' + d.x + ',' + d.y + ')'; | |
| }); | |
| } | |
| // Initial render from pre-computed positions | |
| updatePositions(); | |
| // Static drag - only moves the dragged node, no forces | |
| nodeGs.call(d3.drag() | |
| .on('start', function() { d3.select(this).attr('cursor', 'grabbing'); }) | |
| .on('drag', (ev, d) => { d.x = ev.x; d.y = ev.y; updatePositions(); }) | |
| .on('end', function() { d3.select(this).attr('cursor', 'grab'); }) | |
| ); | |
| } | |
| // Fade in | |
| requestAnimationFrame(() => { svg.style('opacity', '1'); }); | |
| }, oldSvg ? 200 : 0); | |
| } | |
| treeBtn.addEventListener('click', () => { | |
| if (!showFSM) return; | |
| showFSM = false; | |
| treeBtn.className = 'active'; | |
| mergeBtn.className = ''; | |
| render(); | |
| }); | |
| mergeBtn.addEventListener('click', () => { | |
| if (showFSM) return; | |
| showFSM = true; | |
| mergeBtn.className = 'active'; | |
| treeBtn.className = ''; | |
| render(); | |
| }); | |
| render(); | |
| let resizeTimer; | |
| if (window.ResizeObserver) new ResizeObserver(() => { | |
| clearTimeout(resizeTimer); | |
| resizeTimer = setTimeout(() => render(), 150); | |
| }).observe(container); | |
| }; | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); | |
| else ensureD3(bootstrap); | |
| })(); | |
| </script> | |