Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Paper DAG Viewer β HuggingFace</title> | |
| <script src="https://d3js.org/d3.v7.min.js"></script> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"> | |
| <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script> | |
| <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script> | |
| <style> | |
| :root { | |
| --bg: #0f1117; | |
| --surface: #1a1d27; | |
| --surface2: #242836; | |
| --border: #2e3348; | |
| --text: #e2e4ed; | |
| --text-dim: #8b8fa3; | |
| --accent: #7c8aff; | |
| --accent-dim: #5c6adf; | |
| } | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| background: var(--bg); color: var(--text); | |
| overflow: hidden; height: 100vh; | |
| } | |
| /* Layout */ | |
| #app { display: flex; height: 100vh; } | |
| /* Paper selector panel (left) */ | |
| #paper-panel { | |
| width: 320px; background: var(--surface); border-right: 1px solid var(--border); | |
| display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; | |
| transition: width 0.2s; | |
| } | |
| #paper-panel.collapsed { width: 0; border: none; overflow: hidden; } | |
| #paper-panel-header { | |
| padding: 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; | |
| } | |
| #paper-panel-header h2 { | |
| font-size: 13px; font-weight: 600; color: var(--text-dim); | |
| text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; | |
| } | |
| #paper-search { | |
| width: 100%; background: var(--surface2); border: 1px solid var(--border); | |
| border-radius: 6px; color: var(--text); padding: 7px 12px; font-size: 13px; outline: none; | |
| } | |
| #paper-search:focus { border-color: var(--accent); } | |
| #paper-search::placeholder { color: var(--text-dim); } | |
| #paper-count { | |
| font-size: 11px; color: var(--text-dim); margin-top: 8px; | |
| } | |
| #paper-list { | |
| flex: 1; overflow-y: auto; padding: 8px; | |
| } | |
| .paper-item { | |
| padding: 10px 12px; border-radius: 6px; cursor: pointer; | |
| margin-bottom: 4px; transition: background 0.15s; border: 1px solid transparent; | |
| } | |
| .paper-item:hover { background: var(--surface2); } | |
| .paper-item.active { background: var(--accent-dim); border-color: var(--accent); } | |
| .paper-item .paper-title { | |
| font-size: 13px; font-weight: 500; line-height: 1.3; | |
| display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; | |
| } | |
| .paper-item .paper-meta { | |
| font-size: 11px; color: var(--text-dim); margin-top: 4px; | |
| display: flex; gap: 8px; | |
| } | |
| .paper-item.active .paper-meta { color: rgba(255,255,255,0.7); } | |
| /* Toggle button for paper panel */ | |
| #toggle-paper-panel { | |
| position: absolute; left: 320px; top: 50%; z-index: 11; | |
| background: var(--surface); border: 1px solid var(--border); border-left: none; | |
| border-radius: 0 6px 6px 0; padding: 8px 4px; cursor: pointer; | |
| color: var(--text-dim); font-size: 14px; transition: left 0.2s; | |
| } | |
| #toggle-paper-panel:hover { color: var(--text); } | |
| #paper-panel.collapsed ~ #toggle-paper-panel { left: 0; } | |
| /* Graph area */ | |
| #graph-container { flex: 1; position: relative; } | |
| #detail-panel { | |
| width: 640px; background: var(--surface); border-left: 1px solid var(--border); | |
| overflow-y: auto; display: flex; flex-direction: column; transition: width 0.2s; | |
| } | |
| #detail-panel.collapsed { width: 0; border: none; } | |
| /* Toolbar */ | |
| #toolbar { | |
| position: absolute; top: 0; left: 0; right: 0; z-index: 10; | |
| background: linear-gradient(to bottom, var(--bg) 60%, transparent); | |
| padding: 16px 20px 32px; display: flex; align-items: center; gap: 12px; | |
| } | |
| #toolbar h1 { | |
| font-size: 14px; font-weight: 600; color: var(--text-dim); | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px; | |
| } | |
| #search { | |
| background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; | |
| color: var(--text); padding: 6px 12px; font-size: 13px; width: 200px; outline: none; | |
| } | |
| #search:focus { border-color: var(--accent); } | |
| #search::placeholder { color: var(--text-dim); } | |
| .toolbar-group { display: flex; gap: 4px; } | |
| .filter-btn { | |
| background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; | |
| color: var(--text-dim); padding: 4px 10px; font-size: 11px; cursor: pointer; | |
| text-transform: uppercase; letter-spacing: 0.5px; transition: all 0.15s; | |
| } | |
| .filter-btn:hover { border-color: var(--text-dim); color: var(--text); } | |
| .filter-btn.active { background: var(--accent-dim); border-color: var(--accent); color: #fff; } | |
| .spacer { flex: 1; } | |
| /* Legend */ | |
| #legend { | |
| position: absolute; bottom: 16px; left: 16px; z-index: 10; | |
| background: var(--surface); border: 1px solid var(--border); border-radius: 8px; | |
| padding: 12px 16px; font-size: 11px; display: flex; flex-direction: column; gap: 6px; | |
| } | |
| .legend-row { display: flex; align-items: center; gap: 8px; } | |
| .legend-dot { | |
| width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; | |
| } | |
| .legend-line { | |
| width: 24px; height: 2px; flex-shrink: 0; | |
| } | |
| /* Tooltip */ | |
| #tooltip { | |
| position: absolute; pointer-events: none; z-index: 20; | |
| background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; | |
| padding: 8px 12px; font-size: 12px; max-width: 300px; | |
| opacity: 0; transition: opacity 0.15s; | |
| } | |
| #tooltip.show { opacity: 1; } | |
| #tooltip .tt-type { color: var(--accent); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; } | |
| #tooltip .tt-title { font-weight: 600; margin: 2px 0; } | |
| #tooltip .tt-summary { color: var(--text-dim); font-size: 11px; } | |
| /* SVG */ | |
| svg { width: 100%; height: 100%; } | |
| .link { fill: none; stroke-width: 1.5; } | |
| .link.primary { stroke: #4a5080; } | |
| .link.secondary { stroke: #3a3e55; stroke-dasharray: 5 4; } | |
| .link.highlighted { stroke: var(--accent); stroke-width: 2.5; } | |
| .link.secondary.highlighted { stroke: var(--accent-dim); stroke-width: 2; } | |
| .link.faded { opacity: 0.08; } | |
| .node-circle { cursor: pointer; stroke-width: 2; transition: r 0.15s; } | |
| .node-circle.faded { opacity: 0.15; } | |
| .node-circle.selected { stroke: #fff ; stroke-width: 3; } | |
| .node-label { | |
| font-size: 11px; fill: var(--text-dim); pointer-events: none; | |
| text-anchor: middle; dominant-baseline: central; | |
| } | |
| .node-label.faded { opacity: 0.1; } | |
| .arrowhead { fill: #4a5080; } | |
| .arrowhead.highlighted { fill: var(--accent); } | |
| /* Detail panel */ | |
| .panel-header { | |
| padding: 20px; border-bottom: 1px solid var(--border); | |
| position: sticky; top: 0; background: var(--surface); z-index: 1; | |
| } | |
| .panel-header .node-type-badge { | |
| display: inline-block; font-size: 10px; text-transform: uppercase; | |
| letter-spacing: 0.5px; padding: 2px 8px; border-radius: 3px; | |
| margin-bottom: 8px; | |
| } | |
| .panel-header h2 { font-size: 16px; font-weight: 600; line-height: 1.3; } | |
| .panel-header .node-id { color: var(--text-dim); font-size: 12px; margin-top: 4px; } | |
| .panel-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; } | |
| .panel-section h3 { | |
| font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; | |
| color: var(--text-dim); margin-bottom: 6px; | |
| } | |
| .panel-section p, .panel-section pre { | |
| font-size: 13px; line-height: 1.5; color: var(--text); | |
| } | |
| .panel-section pre { | |
| background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; | |
| padding: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; | |
| font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; | |
| max-height: 400px; overflow-y: auto; | |
| } | |
| .panel-section .latex-block { | |
| background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; | |
| padding: 14px; overflow-x: auto; overflow-y: auto; max-height: 500px; | |
| font-size: 14px; line-height: 1.7; | |
| } | |
| .panel-section .latex-block .katex-display { margin: 0.6em 0; } | |
| .panel-section .latex-block .katex { font-size: 1em; } | |
| .panel-section .meta-grid { | |
| display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 12px; | |
| } | |
| .panel-section .meta-key { color: var(--text-dim); } | |
| .panel-section .meta-val { color: var(--text); } | |
| .panel-section .edge-list { list-style: none; display: flex; flex-direction: column; gap: 6px; } | |
| .panel-section .edge-item { | |
| font-size: 12px; padding: 6px 10px; background: var(--surface2); | |
| border-radius: 4px; cursor: pointer; transition: background 0.15s; | |
| } | |
| .panel-section .edge-item:hover { background: var(--border); } | |
| .edge-item .edge-relation { | |
| font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; | |
| color: var(--accent); margin-right: 6px; | |
| } | |
| .edge-item .edge-primary { color: var(--text-dim); font-size: 10px; } | |
| .panel-empty { | |
| display: flex; align-items: center; justify-content: center; | |
| height: 100%; color: var(--text-dim); font-size: 13px; | |
| } | |
| .importance-badge { | |
| display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 3px; | |
| margin-left: 6px; | |
| } | |
| .importance-badge.critical_path { background: #3d1f1f; color: #ff8a8a; } | |
| .importance-badge.supporting { background: #1f2d3d; color: #8ac4ff; } | |
| .importance-badge.context { background: #2d2d1f; color: #d4d48a; } | |
| .proof-type-badge { | |
| display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 3px; | |
| margin-bottom: 6px; | |
| } | |
| .proof-type-badge.proof { background: #1f3d2a; color: #8affa8; } | |
| .proof-type-badge.sketch { background: #3d3d1f; color: #ffd48a; } | |
| .proof-type-badge.citation { background: #2a1f3d; color: #c48aff; } | |
| /* Collapse button (right panel) */ | |
| #toggle-panel { | |
| position: absolute; right: 640px; top: 50%; z-index: 11; | |
| background: var(--surface); border: 1px solid var(--border); border-right: none; | |
| border-radius: 6px 0 0 6px; padding: 8px 4px; cursor: pointer; | |
| color: var(--text-dim); font-size: 14px; transition: right 0.2s; | |
| } | |
| #toggle-panel:hover { color: var(--text); } | |
| #detail-panel.collapsed ~ #toggle-panel { right: 0; } | |
| /* Loading overlay */ | |
| #loading { | |
| position: fixed; top: 0; left: 0; right: 0; bottom: 0; | |
| background: var(--bg); z-index: 100; | |
| display: flex; align-items: center; justify-content: center; | |
| flex-direction: column; gap: 16px; | |
| } | |
| #loading .spinner { | |
| width: 32px; height: 32px; border: 3px solid var(--border); | |
| border-top-color: var(--accent); border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| #loading .load-text { color: var(--text-dim); font-size: 14px; } | |
| #loading.hidden { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loading"> | |
| <div class="spinner"></div> | |
| <div class="load-text">Loading papers...</div> | |
| </div> | |
| <div id="app"> | |
| <!-- Paper selector panel (left) --> | |
| <div id="paper-panel"> | |
| <div id="paper-panel-header"> | |
| <h2>Papers</h2> | |
| <input type="text" id="paper-search" placeholder="Filter papers..."> | |
| <div id="paper-count"></div> | |
| </div> | |
| <div id="paper-list"></div> | |
| </div> | |
| <button id="toggle-paper-panel">◀</button> | |
| <div id="graph-container"> | |
| <div id="toolbar"> | |
| <h1 id="paper-title">Select a paper</h1> | |
| <div class="spacer"></div> | |
| <input type="text" id="search" placeholder="Search nodes..."> | |
| <div class="toolbar-group" id="importance-filters"></div> | |
| <div class="toolbar-group"> | |
| <button class="filter-btn" id="toggle-secondary">Secondary edges</button> | |
| </div> | |
| <div class="toolbar-group" id="layout-btns"> | |
| <button class="filter-btn active" data-layout="tree">Tree</button> | |
| <button class="filter-btn" data-layout="force">Force</button> | |
| </div> | |
| </div> | |
| <svg id="svg"></svg> | |
| <div id="tooltip"> | |
| <div class="tt-type"></div> | |
| <div class="tt-title"></div> | |
| <div class="tt-summary"></div> | |
| </div> | |
| <div id="legend"></div> | |
| </div> | |
| <div id="detail-panel"> | |
| <div class="panel-empty">Select a paper, then click a node</div> | |
| </div> | |
| <button id="toggle-panel">▶</button> | |
| </div> | |
| <script> | |
| // --- Color scheme --- | |
| const TYPE_COLORS = { | |
| overview: '#7c8aff', | |
| motivation: '#c78aff', | |
| discussion: '#ff8ac7', | |
| remark: '#ff8a8a', | |
| definition: '#52d6a8', | |
| assumption: '#8adaff', | |
| claim: '#ffd08a', | |
| theorem: '#ff6b6b', | |
| lemma: '#ffa36b', | |
| proposition: '#ffda6b', | |
| corollary: '#6bffc4', | |
| conjecture: '#ff6bda', | |
| proof_technique: '#6bc4ff', | |
| example: '#a8d86b', | |
| construction: '#d8a86b', | |
| }; | |
| const RELATION_COLORS = { | |
| depends_on: '#ff8a6b', | |
| elaborates: '#8ac4ff', | |
| proves: '#6bffc4', | |
| motivates: '#c78aff', | |
| instantiates: '#ffd08a', | |
| generalizes: '#ff6bda', | |
| cites: '#8adaff', | |
| }; | |
| // --- HuggingFace dataset config --- | |
| const HF_DATASET = 'AI-Math-TCS/tcs_dags'; | |
| const HF_CONFIG = 'FOCS_2025'; | |
| const HF_SPLIT = 'gpt_5_4'; | |
| const HF_API = 'https://datasets-server.huggingface.co/rows'; | |
| // --- Globals --- | |
| let papers = []; | |
| let dagCache = {}; // paper_id -> dag JSON string (cached from initial fetch) | |
| let activePaperId = null; | |
| let dag = null; | |
| let nodes = [], links = []; | |
| let simulation = null; | |
| let svg, g, linkG, nodeG, labelG; | |
| let zoom; | |
| let selectedNode = null; | |
| let currentLayout = 'tree'; | |
| let importanceFilter = new Set(['critical_path','supporting','context']); | |
| let searchTerm = ''; | |
| let nodeMap = {}; | |
| let depthMap = {}; | |
| let width, height; | |
| let currentZoomScale = 1; | |
| let hoveredNode = null; | |
| let showSecondaryEdges = false; | |
| let treeChildren = {}; | |
| let treeRoot = null; | |
| let controlsInitialized = false; | |
| // --- Paper panel --- | |
| async function loadPaperIndex() { | |
| const loadText = document.querySelector('#loading .load-text'); | |
| try { | |
| loadText.textContent = 'Fetching papers from HuggingFace...'; | |
| const url = `${HF_API}?dataset=${encodeURIComponent(HF_DATASET)}&config=${HF_CONFIG}&split=${HF_SPLIT}&offset=0&length=100`; | |
| const resp = await fetch(url); | |
| if (!resp.ok) throw new Error(`API error: ${resp.status}`); | |
| const data = await resp.json(); | |
| loadText.textContent = `Processing ${data.rows.length} papers...`; | |
| papers = []; | |
| for (const entry of data.rows) { | |
| const row = entry.row; | |
| dagCache[row.paper_id] = row.dag; | |
| papers.push({ | |
| paper_id: row.paper_id, | |
| title: row.title, | |
| authors: row.authors, | |
| year: row.year, | |
| venue: row.venue, | |
| num_nodes: row.num_nodes, | |
| num_edges: row.num_edges, | |
| }); | |
| } | |
| } catch (e) { | |
| loadText.textContent = `Error loading data: ${e.message}`; | |
| return; | |
| } | |
| renderPaperList(); | |
| document.getElementById('loading').classList.add('hidden'); | |
| // If URL has a paper_id param, load it | |
| const params = new URLSearchParams(window.location.search); | |
| const pid = params.get('paper'); | |
| if (pid && papers.some(p => p.paper_id === pid)) { | |
| loadPaper(pid); | |
| } | |
| } | |
| function renderPaperList(filter) { | |
| const list = document.getElementById('paper-list'); | |
| const term = (filter || '').toLowerCase(); | |
| const filtered = papers.filter(p => | |
| !term || | |
| p.title.toLowerCase().includes(term) || | |
| p.paper_id.toLowerCase().includes(term) || | |
| p.authors.toLowerCase().includes(term) | |
| ); | |
| document.getElementById('paper-count').textContent = `${filtered.length} of ${papers.length} papers`; | |
| list.innerHTML = filtered.map(p => ` | |
| <div class="paper-item ${p.paper_id === activePaperId ? 'active' : ''}" | |
| data-id="${p.paper_id}"> | |
| <div class="paper-title">${esc(p.title)}</div> | |
| <div class="paper-meta"> | |
| <span>${p.num_nodes} nodes</span> | |
| <span>${p.num_edges} edges</span> | |
| <span>${p.venue || ''}</span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| list.querySelectorAll('.paper-item').forEach(el => { | |
| el.addEventListener('click', () => loadPaper(el.dataset.id)); | |
| }); | |
| } | |
| async function loadPaper(paperId) { | |
| if (paperId === activePaperId) return; | |
| activePaperId = paperId; | |
| // Stop any running simulation from the previous paper | |
| if (simulation) { simulation.stop(); simulation = null; } | |
| // Update URL without reload | |
| const url = new URL(window.location); | |
| url.searchParams.set('paper', paperId); | |
| history.replaceState(null, '', url); | |
| // Highlight in list | |
| document.querySelectorAll('.paper-item').forEach(el => { | |
| el.classList.toggle('active', el.dataset.id === paperId); | |
| }); | |
| // Load DAG from cache | |
| dag = JSON.parse(dagCache[paperId]); | |
| document.getElementById('paper-title').textContent = dag.paper_title || papers.find(p => p.paper_id === paperId)?.title || paperId; | |
| document.title = (dag.paper_title || paperId) + ' β DAG Viewer'; | |
| // Normalize node/edge IDs: some HF data has unpadded IDs (node_9 vs node_09) | |
| normalizeDagIds(); | |
| nodeMap = {}; | |
| dag.nodes.forEach(n => nodeMap[n.id] = n); | |
| selectedNode = null; | |
| document.getElementById('detail-panel').innerHTML = '<div class="panel-empty">Click a node to view details</div>'; | |
| buildSpanningTree(); | |
| setupGraph(); | |
| if (!controlsInitialized) { | |
| setupControls(); | |
| controlsInitialized = true; | |
| } | |
| buildLegend(); | |
| applyLayout(); | |
| } | |
| // --- Spanning tree --- | |
| // Build a spanning tree via BFS. Primary edges alone often leave many | |
| // disconnected components in the HF data, so we do a two-phase BFS: | |
| // 1. Traverse primary edges from the root. | |
| // 2. For any nodes still unreached, use ALL edges (including secondary) | |
| // to connect them into the tree, attaching each new component to the | |
| // nearest already-visited node. | |
| function buildSpanningTree() { | |
| treeChildren = {}; | |
| depthMap = {}; | |
| // Build adjacency lists: primary-only and all-edges | |
| const primaryNeighbors = {}; | |
| const allNeighbors = {}; | |
| dag.edges.forEach(e => { | |
| allNeighbors[e.from] = allNeighbors[e.from] || []; | |
| allNeighbors[e.from].push(e.to); | |
| allNeighbors[e.to] = allNeighbors[e.to] || []; | |
| allNeighbors[e.to].push(e.from); | |
| if (e.is_primary) { | |
| primaryNeighbors[e.from] = primaryNeighbors[e.from] || []; | |
| primaryNeighbors[e.from].push(e.to); | |
| primaryNeighbors[e.to] = primaryNeighbors[e.to] || []; | |
| primaryNeighbors[e.to].push(e.from); | |
| } | |
| }); | |
| treeRoot = dag.nodes.find(n => n.type === 'overview') || dag.nodes[0]; | |
| const visited = new Set(); | |
| const queue = [treeRoot.id]; | |
| visited.add(treeRoot.id); | |
| depthMap[treeRoot.id] = 0; | |
| // Phase 1: BFS over primary edges | |
| function bfs(neighbors) { | |
| while (queue.length) { | |
| const id = queue.shift(); | |
| const candidates = neighbors[id] || []; | |
| for (const cid of candidates) { | |
| if (!visited.has(cid)) { | |
| visited.add(cid); | |
| treeChildren[id] = treeChildren[id] || []; | |
| treeChildren[id].push(cid); | |
| depthMap[cid] = depthMap[id] + 1; | |
| queue.push(cid); | |
| } | |
| } | |
| } | |
| } | |
| bfs(primaryNeighbors); | |
| // Phase 2: connect unreached nodes via secondary edges. | |
| // For each unreached node that has a secondary-edge neighbor already in the | |
| // tree, attach it there and BFS its primary-connected component. | |
| if (visited.size < dag.nodes.length) { | |
| // Iterate until all nodes are reached (each pass may reveal new bridges) | |
| let changed = true; | |
| while (changed && visited.size < dag.nodes.length) { | |
| changed = false; | |
| for (const n of dag.nodes) { | |
| if (visited.has(n.id)) continue; | |
| // Look for any edge (primary or secondary) connecting to visited set | |
| const nbs = allNeighbors[n.id] || []; | |
| const bridge = nbs.find(nb => visited.has(nb)); | |
| if (bridge) { | |
| visited.add(n.id); | |
| treeChildren[bridge] = treeChildren[bridge] || []; | |
| treeChildren[bridge].push(n.id); | |
| depthMap[n.id] = depthMap[bridge] + 1; | |
| queue.push(n.id); | |
| // BFS from this node using primary edges to pull in its component | |
| bfs(primaryNeighbors); | |
| changed = true; | |
| } | |
| } | |
| } | |
| } | |
| // Final fallback: any truly isolated nodes attach to root | |
| dag.nodes.forEach(n => { | |
| if (!visited.has(n.id)) { | |
| visited.add(n.id); | |
| treeChildren[treeRoot.id] = treeChildren[treeRoot.id] || []; | |
| treeChildren[treeRoot.id].push(n.id); | |
| depthMap[n.id] = 1; | |
| } | |
| }); | |
| } | |
| function setupGraph() { | |
| const container = document.getElementById('graph-container'); | |
| width = container.clientWidth; | |
| height = container.clientHeight; | |
| svg = d3.select('#svg'); | |
| svg.selectAll('*').remove(); | |
| const defs = svg.append('defs'); | |
| ['primary','secondary','highlighted'].forEach(cls => { | |
| defs.append('marker') | |
| .attr('id', `arrow-${cls}`) | |
| .attr('viewBox', '0 -4 8 8') | |
| .attr('refX', 20).attr('refY', 0) | |
| .attr('markerWidth', 6).attr('markerHeight', 6) | |
| .attr('orient', 'auto') | |
| .append('path') | |
| .attr('d', 'M0,-3L7,0L0,3') | |
| .attr('class', `arrowhead ${cls}`); | |
| }); | |
| g = svg.append('g'); | |
| linkG = g.append('g').attr('class', 'links'); | |
| nodeG = g.append('g').attr('class', 'nodes'); | |
| labelG = g.append('g').attr('class', 'labels'); | |
| zoom = d3.zoom() | |
| .scaleExtent([0.1, 4]) | |
| .on('zoom', e => { | |
| g.attr('transform', e.transform); | |
| currentZoomScale = e.transform.k; | |
| resolveLabels(); | |
| }); | |
| svg.call(zoom); | |
| nodes = dag.nodes.map(n => ({ | |
| id: n.id, data: n, | |
| radius: n.type === 'overview' ? 16 : (n.importance === 'critical_path' ? 12 : 9), | |
| })); | |
| links = dag.edges.map(e => ({ | |
| source: e.from, | |
| target: e.to, | |
| data: e, | |
| })); | |
| renderLinks(); | |
| renderNodes(); | |
| } | |
| function renderLinks() { | |
| const linkSel = linkG.selectAll('path.link').data(links, d => `${d.source.id||d.source}-${d.target.id||d.target}`); | |
| linkSel.exit().remove(); | |
| linkSel.enter().append('path') | |
| .attr('class', d => `link ${d.data.is_primary ? 'primary' : 'secondary'}`) | |
| .attr('marker-end', d => `url(#arrow-${d.data.is_primary ? 'primary' : 'secondary'})`); | |
| } | |
| function renderNodes() { | |
| const circleSel = nodeG.selectAll('circle.node-circle').data(nodes, d => d.id); | |
| circleSel.exit().remove(); | |
| circleSel.enter().append('circle') | |
| .attr('class', 'node-circle') | |
| .attr('r', d => d.radius) | |
| .attr('fill', d => TYPE_COLORS[d.data.type] || '#888') | |
| .attr('stroke', d => d3.color(TYPE_COLORS[d.data.type] || '#888').darker(0.5)) | |
| .on('click', (e, d) => { e.stopPropagation(); selectNode(d); }) | |
| .on('mouseenter', (e, d) => { hoveredNode = d; showTooltip(e, d); resolveLabels(); }) | |
| .on('mousemove', (e) => moveTooltip(e)) | |
| .on('mouseleave', () => { hoveredNode = null; hideTooltip(); resolveLabels(); }) | |
| .call(d3.drag() | |
| .on('start', dragStart) | |
| .on('drag', dragged) | |
| .on('end', dragEnd)); | |
| const labelSel = labelG.selectAll('text.node-label').data(nodes, d => d.id); | |
| labelSel.exit().remove(); | |
| labelSel.enter().append('text') | |
| .attr('class', 'node-label') | |
| .attr('dy', d => d.radius + 14) | |
| .text(d => truncate(d.data.title, 28)); | |
| } | |
| function applyLayout() { | |
| if (simulation) simulation.stop(); | |
| if (currentLayout === 'tree') applyTreeLayout(); | |
| else applyForceLayout(); | |
| } | |
| function applyTreeLayout() { | |
| if (!treeRoot) { applyForceLayout(); return; } | |
| function buildHier(id) { | |
| const ch = (treeChildren[id] || []).map(cid => buildHier(cid)); | |
| return { id, children: ch.length ? ch : undefined }; | |
| } | |
| const hierData = buildHier(treeRoot.id); | |
| const root = d3.hierarchy(hierData); | |
| const leafCount = root.leaves().length; | |
| const hSpacing = Math.max(60, Math.min(120, width / (leafCount + 1))); | |
| const treeLayout = d3.tree().nodeSize([hSpacing, 140]); | |
| treeLayout(root); | |
| const posMap = {}; | |
| root.each(d => { posMap[d.data.id] = { x: d.x + width/2, y: d.y + 80 }; }); | |
| nodes.forEach(n => { | |
| const pos = posMap[n.id] || { x: width/2, y: height - 80 }; | |
| n.fx = pos.x; | |
| n.fy = pos.y; | |
| n.x = n.fx; | |
| n.y = n.fy; | |
| }); | |
| // Apply positions to SVG immediately before starting the simulation, | |
| // so that fitToView sees correct bounding box. | |
| tick(); | |
| simulation = d3.forceSimulation(nodes) | |
| .force('link', d3.forceLink(links).id(d => d.id).strength(0)) | |
| .alphaDecay(0.1) | |
| .on('tick', tick); | |
| updateVisibility(); | |
| fitToView(600); | |
| } | |
| function applyForceLayout() { | |
| const maxDepth = Math.max(...Object.values(depthMap)); | |
| const ySpacing = height / (maxDepth + 2); | |
| nodes.forEach(n => { n.fx = null; n.fy = null; }); | |
| simulation = d3.forceSimulation(nodes) | |
| .force('link', d3.forceLink(links).id(d => d.id).distance(100).strength(d => d.data.is_primary ? 0.7 : 0.1)) | |
| .force('charge', d3.forceManyBody().strength(-300)) | |
| .force('x', d3.forceX(width/2).strength(0.05)) | |
| .force('y', d3.forceY(d => (depthMap[d.id] || 5) * ySpacing + 60).strength(0.3)) | |
| .force('collision', d3.forceCollide().radius(d => d.radius + 12)) | |
| .alphaDecay(0.02) | |
| .on('tick', tick); | |
| updateVisibility(); | |
| setTimeout(() => fitToView(600), 1500); | |
| } | |
| function tick() { | |
| linkG.selectAll('path.link') | |
| .attr('d', d => { | |
| const sx = d.source.x, sy = d.source.y; | |
| const tx = d.target.x, ty = d.target.y; | |
| if (!d.data.is_primary) { | |
| const mx = (sx + tx) / 2 + (ty - sy) * 0.15; | |
| const my = (sy + ty) / 2 - (tx - sx) * 0.15; | |
| return `M${sx},${sy}Q${mx},${my},${tx},${ty}`; | |
| } | |
| return `M${sx},${sy}L${tx},${ty}`; | |
| }); | |
| nodeG.selectAll('circle.node-circle') | |
| .attr('cx', d => d.x).attr('cy', d => d.y); | |
| labelG.selectAll('text.node-label') | |
| .attr('x', d => d.x).attr('y', d => d.y); | |
| resolveLabels(); | |
| } | |
| function _labelPriority(d) { | |
| if (selectedNode && d.id === selectedNode.id) return 100; | |
| if (hoveredNode && d.id === hoveredNode.id) return 90; | |
| if (selectedNode) { | |
| const isConn = links.some(l => { | |
| const s = l.source.id||l.source, t = l.target.id||l.target; | |
| return (s === selectedNode.id && t === d.id) || (t === selectedNode.id && s === d.id); | |
| }); | |
| if (isConn) return 80; | |
| } | |
| if (d.data.type === 'overview') return 70; | |
| if (d.data.importance === 'critical_path') return 60; | |
| if (d.data.importance === 'supporting') return 40; | |
| return 20; | |
| } | |
| function resolveLabels() { | |
| const labels = labelG.selectAll('text.node-label'); | |
| const CHAR_W = 6.5; | |
| const LABEL_H = 16; | |
| const sorted = [...nodes].sort((a, b) => _labelPriority(b) - _labelPriority(a)); | |
| const placed = []; | |
| const visibleIds = new Set(); | |
| for (const nd of sorted) { | |
| const text = truncate(nd.data.title, 28); | |
| const hw = (text.length * CHAR_W / 2) / currentZoomScale; | |
| const hh = (LABEL_H / 2) / currentZoomScale; | |
| const cx = nd.x; | |
| const cy = nd.y + nd.radius + 14 / currentZoomScale; | |
| let overlaps = false; | |
| for (const p of placed) { | |
| if (Math.abs(cx - p.x) < (hw + p.hw) && Math.abs(cy - p.y) < (hh + p.hh)) { | |
| overlaps = true; | |
| break; | |
| } | |
| } | |
| const force = _labelPriority(nd) >= 70; | |
| if (!overlaps || force) { | |
| placed.push({ x: cx, y: cy, hw, hh }); | |
| visibleIds.add(nd.id); | |
| } | |
| } | |
| labels.attr('opacity', d => visibleIds.has(d.id) ? 1 : 0); | |
| } | |
| // --- Interaction --- | |
| function selectNode(d) { | |
| if (selectedNode === d) { deselectNode(); return; } | |
| selectedNode = d; | |
| const connectedIds = new Set([d.id]); | |
| links.forEach(l => { | |
| const sid = l.source.id || l.source; | |
| const tid = l.target.id || l.target; | |
| if (sid === d.id) connectedIds.add(tid); | |
| if (tid === d.id) connectedIds.add(sid); | |
| }); | |
| nodeG.selectAll('circle.node-circle') | |
| .classed('selected', n => n.id === d.id) | |
| .classed('faded', n => !connectedIds.has(n.id)); | |
| labelG.selectAll('text.node-label') | |
| .classed('faded', n => !connectedIds.has(n.id)); | |
| linkG.selectAll('path.link') | |
| .classed('highlighted', l => (l.source.id||l.source) === d.id || (l.target.id||l.target) === d.id) | |
| .classed('faded', l => (l.source.id||l.source) !== d.id && (l.target.id||l.target) !== d.id) | |
| .attr('marker-end', l => { | |
| const involved = (l.source.id||l.source) === d.id || (l.target.id||l.target) === d.id; | |
| return involved ? 'url(#arrow-highlighted)' : `url(#arrow-${l.data.is_primary?'primary':'secondary'})`; | |
| }); | |
| showDetailPanel(d.data); | |
| } | |
| function deselectNode() { | |
| selectedNode = null; | |
| nodeG.selectAll('circle.node-circle').classed('selected', false).classed('faded', false); | |
| labelG.selectAll('text.node-label').classed('faded', false); | |
| linkG.selectAll('path.link') | |
| .classed('highlighted', false).classed('faded', false) | |
| .attr('marker-end', l => `url(#arrow-${l.data.is_primary?'primary':'secondary'})`); | |
| document.getElementById('detail-panel').innerHTML = '<div class="panel-empty">Click a node to view details</div>'; | |
| } | |
| function showDetailPanel(n) { | |
| const panel = document.getElementById('detail-panel'); | |
| const color = TYPE_COLORS[n.type] || '#888'; | |
| const inEdges = dag.edges.filter(e => e.from === n.id); | |
| const outEdges = dag.edges.filter(e => e.to === n.id); | |
| let html = ` | |
| <div class="panel-header"> | |
| <span class="node-type-badge" style="background:${color}22;color:${color}">${n.type}</span> | |
| <span class="importance-badge ${n.importance}">${(n.importance || '').replace('_',' ')}</span> | |
| <h2>${esc(n.title)}</h2> | |
| <div class="node-id">${n.id}${n.section_ref ? ' · ' + esc(n.section_ref) : ''}</div> | |
| </div> | |
| <div class="panel-body"> | |
| <div class="panel-section"> | |
| <h3>Summary</h3> | |
| <p>${esc(n.summary)}</p> | |
| </div>`; | |
| if (n.formal_statement) { | |
| html += ` | |
| <div class="panel-section"> | |
| <h3>Formal Statement</h3> | |
| <div class="latex-block" data-latex="formal_statement"></div> | |
| </div>`; | |
| } | |
| if (n.proof_type && n.proof_content) { | |
| html += ` | |
| <div class="panel-section"> | |
| <h3>Proof / Derivation</h3> | |
| <span class="proof-type-badge ${n.proof_type}">${n.proof_type}</span> | |
| <div class="latex-block" data-latex="proof_content"></div> | |
| </div>`; | |
| } | |
| html += ` | |
| <div class="panel-section"> | |
| <h3>Metadata</h3> | |
| <div class="meta-grid"> | |
| <span class="meta-key">Source</span><span class="meta-val">${n.source}${n.citation ? ' — ' + esc(n.citation) : ''}</span> | |
| ${n.citation_full ? `<span class="meta-key">Reference</span><span class="meta-val">${esc(n.citation_full)}</span>` : ''} | |
| <span class="meta-key">Excerpt</span><span class="meta-val">${esc(n.source_text_excerpt || '')}</span> | |
| </div> | |
| </div>`; | |
| if (inEdges.length) { | |
| html += ` | |
| <div class="panel-section"> | |
| <h3>Parents (${n.id} →)</h3> | |
| <ul class="edge-list">${inEdges.map(e => edgeItem(e, e.to)).join('')}</ul> | |
| </div>`; | |
| } | |
| if (outEdges.length) { | |
| html += ` | |
| <div class="panel-section"> | |
| <h3>Children (→ ${n.id})</h3> | |
| <ul class="edge-list">${outEdges.map(e => edgeItem(e, e.from)).join('')}</ul> | |
| </div>`; | |
| } | |
| html += '</div>'; | |
| panel.innerHTML = html; | |
| panel.querySelectorAll('[data-latex]').forEach(el => { | |
| const field = el.dataset.latex; | |
| if (!n[field]) return; | |
| let text = n[field]; | |
| // If text already has $ delimiters, use as-is; otherwise add them | |
| if (!/\$/.test(text)) { | |
| text = addMathDelimiters(text); | |
| } | |
| el.textContent = text; | |
| }); | |
| renderLatex(panel); | |
| panel.querySelectorAll('.edge-item').forEach(el => { | |
| el.addEventListener('click', () => { | |
| const nid = el.dataset.nodeid; | |
| const nd = nodes.find(n => n.id === nid); | |
| if (nd) selectNode(nd); | |
| }); | |
| }); | |
| } | |
| function renderLatex(el) { | |
| if (typeof renderMathInElement !== 'function') { | |
| setTimeout(() => renderLatex(el), 100); | |
| return; | |
| } | |
| renderMathInElement(el, { | |
| delimiters: [ | |
| { left: '$$', right: '$$', display: true }, | |
| { left: '$', right: '$', display: false }, | |
| { left: '\\[', right: '\\]', display: true }, | |
| { left: '\\(', right: '\\)', display: false }, | |
| ], | |
| throwOnError: false, | |
| }); | |
| } | |
| function edgeItem(edge, otherId) { | |
| const other = nodeMap[otherId]; | |
| const name = other ? other.title : otherId; | |
| const color = other ? TYPE_COLORS[other.type] : '#888'; | |
| const relColor = RELATION_COLORS[edge.relation] || '#888'; | |
| return `<li class="edge-item" data-nodeid="${otherId}"> | |
| <span class="edge-relation" style="color:${relColor}">${edge.relation}</span> | |
| <span style="color:${color}">${esc(truncate(name, 40))}</span> | |
| ${edge.is_primary ? '<span class="edge-primary">PRIMARY</span>' : ''} | |
| ${edge.description ? '<br><span style="color:var(--text-dim);font-size:11px">' + esc(edge.description) + '</span>' : ''} | |
| </li>`; | |
| } | |
| // --- Tooltip --- | |
| function showTooltip(event, d) { | |
| const tt = document.getElementById('tooltip'); | |
| tt.querySelector('.tt-type').textContent = d.data.type; | |
| tt.querySelector('.tt-title').textContent = d.data.title; | |
| tt.querySelector('.tt-summary').textContent = truncate(d.data.summary, 120); | |
| tt.classList.add('show'); | |
| moveTooltip(event); | |
| } | |
| function moveTooltip(event) { | |
| const tt = document.getElementById('tooltip'); | |
| tt.style.left = (event.clientX + 12) + 'px'; | |
| tt.style.top = (event.clientY - 10) + 'px'; | |
| } | |
| function hideTooltip() { | |
| document.getElementById('tooltip').classList.remove('show'); | |
| } | |
| // --- Controls --- | |
| function setupControls() { | |
| svg.on('click', () => deselectNode()); | |
| // Node search | |
| document.getElementById('search').addEventListener('input', e => { | |
| searchTerm = e.target.value.toLowerCase(); | |
| updateVisibility(); | |
| }); | |
| // Paper search | |
| document.getElementById('paper-search').addEventListener('input', e => { | |
| renderPaperList(e.target.value); | |
| }); | |
| // Importance filters | |
| const filterContainer = document.getElementById('importance-filters'); | |
| ['critical_path','supporting','context'].forEach(imp => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'filter-btn active'; | |
| btn.textContent = imp.replace('_',' '); | |
| btn.dataset.importance = imp; | |
| btn.addEventListener('click', () => { | |
| if (importanceFilter.has(imp)) { importanceFilter.delete(imp); btn.classList.remove('active'); } | |
| else { importanceFilter.add(imp); btn.classList.add('active'); } | |
| updateVisibility(); | |
| }); | |
| filterContainer.appendChild(btn); | |
| }); | |
| // Secondary edges toggle | |
| document.getElementById('toggle-secondary').addEventListener('click', () => { | |
| showSecondaryEdges = !showSecondaryEdges; | |
| document.getElementById('toggle-secondary').classList.toggle('active', showSecondaryEdges); | |
| updateVisibility(); | |
| }); | |
| // Layout toggle | |
| document.querySelectorAll('#layout-btns .filter-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('#layout-btns .filter-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| currentLayout = btn.dataset.layout; | |
| applyLayout(); | |
| }); | |
| }); | |
| // Right detail panel toggle | |
| document.getElementById('toggle-panel').addEventListener('click', () => { | |
| const panel = document.getElementById('detail-panel'); | |
| panel.classList.toggle('collapsed'); | |
| const btn = document.getElementById('toggle-panel'); | |
| btn.innerHTML = panel.classList.contains('collapsed') ? '◀' : '▶'; | |
| btn.style.right = panel.classList.contains('collapsed') ? '0' : '640px'; | |
| }); | |
| // Left paper panel toggle | |
| document.getElementById('toggle-paper-panel').addEventListener('click', () => { | |
| const panel = document.getElementById('paper-panel'); | |
| panel.classList.toggle('collapsed'); | |
| const btn = document.getElementById('toggle-paper-panel'); | |
| btn.innerHTML = panel.classList.contains('collapsed') ? '▶' : '◀'; | |
| btn.style.left = panel.classList.contains('collapsed') ? '0' : '320px'; | |
| }); | |
| // Resize | |
| window.addEventListener('resize', () => { | |
| width = document.getElementById('graph-container').clientWidth; | |
| height = document.getElementById('graph-container').clientHeight; | |
| }); | |
| } | |
| function updateVisibility() { | |
| const visible = new Set(); | |
| nodes.forEach(n => { | |
| const matchImportance = importanceFilter.has(n.data.importance); | |
| const matchSearch = !searchTerm || | |
| n.data.title.toLowerCase().includes(searchTerm) || | |
| n.data.id.toLowerCase().includes(searchTerm) || | |
| n.data.type.toLowerCase().includes(searchTerm) || | |
| (n.data.summary || '').toLowerCase().includes(searchTerm); | |
| if (matchImportance && matchSearch) visible.add(n.id); | |
| }); | |
| nodeG.selectAll('circle.node-circle') | |
| .attr('opacity', d => visible.has(d.id) ? 1 : 0.1); | |
| labelG.selectAll('text.node-label') | |
| .attr('opacity', d => visible.has(d.id) ? 1 : 0.08); | |
| linkG.selectAll('path.link') | |
| .attr('opacity', l => { | |
| if (!showSecondaryEdges && !l.data.is_primary) return 0; | |
| const sid = l.source.id || l.source; | |
| const tid = l.target.id || l.target; | |
| return visible.has(sid) && visible.has(tid) ? 1 : 0.05; | |
| }); | |
| } | |
| // --- Drag --- | |
| function dragStart(event, d) { | |
| if (!event.active && simulation) simulation.alphaTarget(0.1).restart(); | |
| d.fx = d.x; d.fy = d.y; | |
| } | |
| function dragged(event, d) { | |
| d.fx = event.x; d.fy = event.y; | |
| } | |
| function dragEnd(event, d) { | |
| if (!event.active && simulation) simulation.alphaTarget(0); | |
| if (currentLayout === 'force') { d.fx = null; d.fy = null; } | |
| } | |
| // --- Fit view --- | |
| function fitToView(duration) { | |
| const bounds = g.node().getBBox(); | |
| if (bounds.width === 0 || bounds.height === 0) return; | |
| const pad = 60; | |
| const scale = Math.min( | |
| (width - pad*2) / bounds.width, | |
| (height - pad*2) / bounds.height, | |
| 1.5 | |
| ); | |
| const tx = width/2 - (bounds.x + bounds.width/2) * scale; | |
| const ty = height/2 - (bounds.y + bounds.height/2) * scale; | |
| svg.transition().duration(duration) | |
| .call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)); | |
| } | |
| // --- Legend --- | |
| function buildLegend() { | |
| const present = new Set(dag.nodes.map(n => n.type)); | |
| const legend = document.getElementById('legend'); | |
| let html = '<div style="font-weight:600;margin-bottom:4px;font-size:12px">Node Types</div>'; | |
| Object.entries(TYPE_COLORS).forEach(([type, color]) => { | |
| if (!present.has(type)) return; | |
| html += `<div class="legend-row"><span class="legend-dot" style="background:${color}"></span>${type}</div>`; | |
| }); | |
| html += '<div style="margin-top:8px;font-weight:600;margin-bottom:4px;font-size:12px">Edges</div>'; | |
| html += `<div class="legend-row"><span class="legend-line" style="background:#4a5080"></span>Primary</div>`; | |
| html += `<div class="legend-row"><span class="legend-line" style="background:#3a3e55;border-top:2px dashed #3a3e55;height:0"></span>Secondary</div>`; | |
| legend.innerHTML = html; | |
| } | |
| // --- Helpers --- | |
| // Normalize IDs: some HF DAGs have unpadded node refs in edges (node_9 vs node_09). | |
| // Build a lookup from the canonical node IDs and fix any edge refs that don't match. | |
| function normalizeDagIds() { | |
| const nodeIds = new Set(dag.nodes.map(n => n.id)); | |
| // Build map: unpadded -> padded (e.g. node_9 -> node_09) | |
| const fixMap = {}; | |
| nodeIds.forEach(id => { | |
| const m = id.match(/^(node_)0*(\d+)$/); | |
| if (m) fixMap[m[1] + m[2]] = id; // node_9 -> node_09 | |
| }); | |
| dag.edges = dag.edges.map(e => ({ | |
| ...e, | |
| from: nodeIds.has(e.from) ? e.from : (fixMap[e.from] || e.from), | |
| to: nodeIds.has(e.to) ? e.to : (fixMap[e.to] || e.to), | |
| })); | |
| // Drop edges that still reference non-existent nodes | |
| dag.edges = dag.edges.filter(e => nodeIds.has(e.from) && nodeIds.has(e.to)); | |
| } | |
| function truncate(s, n) { return s && s.length > n ? s.slice(0, n) + '...' : (s || ''); } | |
| function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; } | |
| // Wrap math spans in $...$ for text that lacks delimiters. | |
| // Splits text into tokens, classifying each as math or prose based on | |
| // the presence of math indicators (^, _, {}, \commands, operators, etc.). | |
| function addMathDelimiters(text) { | |
| // Regex matching a "math span": a sequence that contains math-indicative | |
| // characters/patterns, including surrounding variable names and operators. | |
| // We match greedily and rely on the join logic to merge adjacent spans. | |
| // | |
| // Math indicators: | |
| // \command ^{...} _{...} single-letter vars with sub/superscript | |
| // math symbols: β₯ β€ β β β β© βͺ Γ β β β β β β β β β β β β β Ξ» | |
| // common patterns: f(x), O(n), Pr[...], E[...] | |
| // If the text has LaTeX commands, use the original line-based approach | |
| // but enhanced to also catch bare math | |
| return text.split('\n').map(line => { | |
| if (/\$/.test(line)) return line; // already delimited | |
| // Strategy: split on sentence-like boundaries and wrap segments | |
| // that look mathematical. | |
| const mathIndicators = /[\\^_{}β₯β€ββββββ©βͺΓββββ¦β ββββ βββββλμΟδΡαβγΞΞ©ΞΣΠΦΨ]/; | |
| const mathPattern = /(?:\\[a-zA-Z]+(?:\{[^}]*\})*|[A-Za-z0-9_]+[\^_]\{[^}]*\}|[A-Za-z0-9_]+[\^_][A-Za-z0-9]|\|[^|]+\||[β₯β€ββββββ©βͺΓββββ¦β ββββ βββββλμΟδΡαβγΞΞ©ΞΣΠΦΨ])/; | |
| // If the line has no math indicators at all, return as-is | |
| // Also check for "X = ..." patterns (single capital + equals) | |
| if (!mathIndicators.test(line) && !/[A-Z]_/.test(line) && !/\^/.test(line) | |
| && !/(?:^|[\s(])[A-Z]\s*=/.test(line)) { | |
| return line; | |
| } | |
| // For lines that are mostly math (formal statements), wrap the whole | |
| // thing as inline math segments. Split on sentence boundaries: periods | |
| // followed by space+capital, or colons/semicolons. | |
| const segments = line.split(/(?<=\.\s)(?=[A-Z])|(?<=:\s)|(?<=;\s)/); | |
| return segments.map(seg => { | |
| if (/\$/.test(seg)) return seg; // already has delimiters | |
| if (!mathIndicators.test(seg) && !/[A-Z]_/.test(seg) && !/\^/.test(seg) && !/[=<>]/.test(seg)) { | |
| return seg; // pure prose | |
| } | |
| // Walk through the segment, wrapping math-like tokens in $...$ | |
| // We split on word boundaries and classify runs. | |
| let result = ''; | |
| let mathBuf = ''; | |
| let proseBuf = ''; | |
| // Tokenize: split into "words" preserving whitespace and punctuation | |
| const tokens = seg.match(/:=|\\[{}|]|\\[a-zA-Z]+(?:\{[^}]*\})*|[A-Za-z0-9']+(?:[\^_]\{[^}]*\}|[\^_][A-Za-z0-9])*|\{[^}]*\}|[β₯β€ββββββ©βͺΓββββ¦β ββββ βββββλμΟδΡαβγΞΞ©ΞΣΠΦΨ]+|[=<>]+|[,;.:!?]+|[/\-+*|]|\(|\)|\[|\]|\s+|./g); | |
| if (!tokens) return seg; | |
| const PROSE_WORDS = new Set([ | |
| 'a','an','as','at','be','by','do','if','in','is','it','no','of', | |
| 'on','or','so','to','up','we','and','are','can','for','has','its', | |
| 'may','not','our','the','was','all','but','let','say','set','via', | |
| ]); | |
| function isMathToken(t) { | |
| if (!t) return false; | |
| if (/^\\/.test(t)) return true; // \command | |
| if (/[\^_{}]/.test(t)) return true; // sub/superscript or braces | |
| if (/[β₯β€ββββββ©βͺΓββββ¦β ββββ βββββλμΟδΡαβγΞΞ©ΞΣΠΦΨ]/.test(t)) return true; | |
| if (/^[=<>]+$/.test(t) || t === ':=') return true; // comparison/assignment ops | |
| if (/^[A-Z]$/.test(t)) return true; // single capital (likely variable) | |
| if (/^[a-z]$/.test(t) && !PROSE_WORDS.has(t)) return true; // single lowercase variable (not a common word) | |
| if (/^[0-9]+$/.test(t)) return true; // bare number (in math context) | |
| return false; | |
| } | |
| // Track brace depth so we never flush a math span with unbalanced braces | |
| let braceDepth = 0; | |
| function flushMath() { | |
| if (mathBuf.trim()) result += '$' + mathBuf.trim() + '$'; | |
| else result += mathBuf; | |
| mathBuf = ''; | |
| braceDepth = 0; | |
| } | |
| function flushProse() { | |
| result += proseBuf; | |
| proseBuf = ''; | |
| } | |
| function updateBraceDepth(t) { | |
| // Skip escaped braces \{ \} β they don't affect grouping depth | |
| if (t === '\\{' || t === '\\}') return; | |
| for (const ch of t) { | |
| if (ch === '{') braceDepth++; | |
| if (ch === '}') braceDepth--; | |
| } | |
| } | |
| let inMath = false; | |
| for (let i = 0; i < tokens.length; i++) { | |
| const t = tokens[i]; | |
| const isWs = /^\s+$/.test(t); | |
| const isPunct = /^[,;.:!?]+$/.test(t); | |
| const isBracket = /^[(\)\[\]]$/.test(t); | |
| const isMathOp = /^[/+*|]$/.test(t); | |
| const isMinus = t === '-'; | |
| const isMath = isMathToken(t); | |
| if (isWs || isPunct || isBracket || isMathOp || isMinus) { | |
| if (inMath) { | |
| // Don't break out of math if braces are unbalanced | |
| if (braceDepth !== 0) { | |
| mathBuf += t; | |
| continue; | |
| } | |
| // Look ahead: if next non-ws token is math, stay in math | |
| let nextMath = false; | |
| for (let j = i + 1; j < tokens.length; j++) { | |
| if (/^\s+$/.test(tokens[j])) continue; | |
| nextMath = isMathToken(tokens[j]) || /^[(\)\[\]/+*|]$/.test(tokens[j]); | |
| break; | |
| } | |
| if (nextMath || isBracket || isMathOp) { | |
| mathBuf += t; | |
| } else { | |
| flushMath(); | |
| inMath = false; | |
| proseBuf += t; | |
| } | |
| } else { | |
| proseBuf += t; | |
| } | |
| } else if (isMath) { | |
| if (!inMath) { | |
| flushProse(); | |
| inMath = true; | |
| } | |
| mathBuf += t; | |
| updateBraceDepth(t); | |
| } else { | |
| // Prose word (multiple chars, not a math token) | |
| if (inMath) { | |
| // Don't break out if braces are unbalanced | |
| if (braceDepth !== 0) { | |
| mathBuf += t; | |
| continue; | |
| } | |
| flushMath(); | |
| inMath = false; | |
| } | |
| proseBuf += t; | |
| } | |
| } | |
| // Flush remaining | |
| if (inMath) flushMath(); | |
| else flushProse(); | |
| return result; | |
| }).join(''); | |
| }).join('\n'); | |
| } | |
| loadPaperIndex(); | |
| </script> | |
| </body> | |
| </html> | |