tcs-dag-viewer / index.html
Ian Wu
Interactive DAG viewer for TCS papers
34d3917
<!DOCTYPE html>
<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 !important; 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">&#9664;</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">&#9654;</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 ? ' &middot; ' + 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 ? ' &mdash; ' + 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} &rarr;)</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 (&rarr; ${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') ? '&#9664;' : '&#9654;';
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') ? '&#9654;' : '&#9664;';
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>