feat: inter-module code dependency DAG visualizer

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-02-06 21:51:49 +07:00
parent 2ef5e33450
commit ba7d1c6675
3 changed files with 204 additions and 8 deletions

View file

@ -1,6 +1,14 @@
# swactor
(S)mall (W)ASM-compatible (actor) library
## Useful
View code dependency DAG
```bash
cargo run --manifest-path tools/depgraph/Cargo.toml -- --src-dir src/ --output deps
```
## Quick example
```rust

102
deps.html
View file

@ -23,7 +23,7 @@
<button onclick='zoomIn()'>+</button>
<button onclick='zoomOut()'>&minus;</button>
<button onclick='resetView()'>fit</button>
<span style='margin-left:8px;opacity:0.6'>scroll to zoom · drag to pan</span>
<span style='margin-left:8px;opacity:0.6'>scroll to zoom · drag to pan · click node to focus</span>
</div>
<div id='viewport'></div>
<div id='loading'>Loading Graphviz…</div>
@ -262,8 +262,100 @@ svg.querySelectorAll('.graph > text, .cluster > text, .edge text').forEach(el =>
// Node text (inside record shapes): keep dark for readability on pastel fills
svg.querySelectorAll('.node text').forEach(el => el.setAttribute('fill','#1a1a1a'));
// ─── Click-to-focus ────────────────────────────────────────────────────────
// Build adjacency: for each edge, record which node titles it connects.
const edges = svg.querySelectorAll('.edge');
const nodes = svg.querySelectorAll('.node');
// Cluster chrome = the path + text that draw the cluster box/label (not child nodes)
const clusterChrome = [];
svg.querySelectorAll('.cluster').forEach(c => {
c.querySelectorAll(':scope > path, :scope > polygon, :scope > text').forEach(el => clusterChrome.push(el));
});
// Map: node title → DOM element
const nodeByTitle = new Map();
nodes.forEach(n => {
const t = n.querySelector('title');
if (t) nodeByTitle.set(t.textContent.trim(), n);
});
// Which cluster contains which node titles
const nodeToClusterEls = new Map();
svg.querySelectorAll('.cluster').forEach(cluster => {
const chrome = [...cluster.querySelectorAll(':scope > path, :scope > polygon, :scope > text')];
cluster.querySelectorAll('.node title').forEach(t => {
nodeToClusterEls.set(t.textContent.trim(), chrome);
});
});
// Map: node title → set of connected edge elements + set of neighbor titles
const adj = new Map();
edges.forEach(edge => {
const t = edge.querySelector('title');
if (!t) return;
const parts = t.textContent.trim().split('->').map(s => s.trim());
if (parts.length !== 2) return;
const [src, dst] = parts;
if (!adj.has(src)) adj.set(src, { edges: [], neighbors: new Set() });
if (!adj.has(dst)) adj.set(dst, { edges: [], neighbors: new Set() });
adj.get(src).edges.push(edge);
adj.get(src).neighbors.add(dst);
adj.get(dst).edges.push(edge);
adj.get(dst).neighbors.add(src);
});
const DIM = 0.08;
let focused = null;
function clearFocus() {
focused = null;
nodes.forEach(n => n.style.opacity = '');
edges.forEach(e => e.style.opacity = '');
clusterChrome.forEach(el => el.style.opacity = '');
}
function focusNode(title) {
if (focused === title) { clearFocus(); return; }
focused = title;
const info = adj.get(title) || { edges: [], neighbors: new Set() };
const connected = new Set([title, ...info.neighbors]);
// Dim all nodes, edges, and cluster chrome individually (not the cluster <g>)
nodes.forEach(n => n.style.opacity = DIM);
edges.forEach(e => e.style.opacity = DIM);
clusterChrome.forEach(el => el.style.opacity = DIM);
// Highlight connected nodes
connected.forEach(name => {
const el = nodeByTitle.get(name);
if (el) el.style.opacity = 1;
});
// Highlight connected edges
info.edges.forEach(e => e.style.opacity = 1);
// Highlight cluster chrome for clusters that contain a connected node
const seen = new Set();
connected.forEach(name => {
const chrome = nodeToClusterEls.get(name);
if (chrome) chrome.forEach(el => {
if (!seen.has(el)) { seen.add(el); el.style.opacity = 1; }
});
});
}
// Attach click handlers to nodes
nodes.forEach(node => {
node.style.cursor = 'pointer';
node.addEventListener('click', e => {
e.stopPropagation();
const t = node.querySelector('title');
if (t) focusNode(t.textContent.trim());
});
});
// pan & zoom
let scale = 1, tx = 0, ty = 0, dragging = false, sx = 0, sy = 0;
let scale = 1, tx = 0, ty = 0, dragging = false, didDrag = false, sx = 0, sy = 0;
function applyTransform() { svg.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`; svg.style.transformOrigin = '0 0'; }
function resetView() {
const vw = window.innerWidth, vh = window.innerHeight;
@ -276,9 +368,11 @@ function resetView() {
resetView();
vp.addEventListener('wheel', e => { e.preventDefault(); const f = e.deltaY < 0 ? 1.12 : 0.89; const rect = vp.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; tx = mx - f * (mx - tx); ty = my - f * (my - ty); scale *= f; applyTransform(); }, { passive:false });
vp.addEventListener('pointerdown', e => { dragging=true; sx=e.clientX-tx; sy=e.clientY-ty; vp.setPointerCapture(e.pointerId); });
vp.addEventListener('pointermove', e => { if(!dragging) return; tx=e.clientX-sx; ty=e.clientY-sy; applyTransform(); });
vp.addEventListener('pointerdown', e => { dragging=true; didDrag=false; sx=e.clientX-tx; sy=e.clientY-ty; vp.setPointerCapture(e.pointerId); });
vp.addEventListener('pointermove', e => { if(!dragging) return; didDrag=true; tx=e.clientX-sx; ty=e.clientY-sy; applyTransform(); });
vp.addEventListener('pointerup', () => dragging=false);
// Click background to clear focus (only if it wasn't a drag)
vp.addEventListener('click', e => { if (!didDrag && !e.target.closest('.node')) clearFocus(); });
function zoomIn() { scale*=1.3; applyTransform(); }
function zoomOut() { scale*=0.7; applyTransform(); }

View file

@ -1096,7 +1096,7 @@ fn generate_html(dot_source: &str) -> String {
<button onclick='zoomIn()'>+</button>
<button onclick='zoomOut()'>&minus;</button>
<button onclick='resetView()'>fit</button>
<span style='margin-left:8px;opacity:0.6'>scroll to zoom · drag to pan</span>
<span style='margin-left:8px;opacity:0.6'>scroll to zoom · drag to pan · click node to focus</span>
</div>
<div id='viewport'></div>
<div id='loading'>Loading Graphviz…</div>
@ -1120,8 +1120,100 @@ svg.querySelectorAll('.graph > text, .cluster > text, .edge text').forEach(el =>
// Node text (inside record shapes): keep dark for readability on pastel fills
svg.querySelectorAll('.node text').forEach(el => el.setAttribute('fill','#1a1a1a'));
// ─── Click-to-focus ────────────────────────────────────────────────────────
// Build adjacency: for each edge, record which node titles it connects.
const edges = svg.querySelectorAll('.edge');
const nodes = svg.querySelectorAll('.node');
// Cluster chrome = the path + text that draw the cluster box/label (not child nodes)
const clusterChrome = [];
svg.querySelectorAll('.cluster').forEach(c => {{
c.querySelectorAll(':scope > path, :scope > polygon, :scope > text').forEach(el => clusterChrome.push(el));
}});
// Map: node title → DOM element
const nodeByTitle = new Map();
nodes.forEach(n => {{
const t = n.querySelector('title');
if (t) nodeByTitle.set(t.textContent.trim(), n);
}});
// Which cluster contains which node titles
const nodeToClusterEls = new Map();
svg.querySelectorAll('.cluster').forEach(cluster => {{
const chrome = [...cluster.querySelectorAll(':scope > path, :scope > polygon, :scope > text')];
cluster.querySelectorAll('.node title').forEach(t => {{
nodeToClusterEls.set(t.textContent.trim(), chrome);
}});
}});
// Map: node title → set of connected edge elements + set of neighbor titles
const adj = new Map();
edges.forEach(edge => {{
const t = edge.querySelector('title');
if (!t) return;
const parts = t.textContent.trim().split('->').map(s => s.trim());
if (parts.length !== 2) return;
const [src, dst] = parts;
if (!adj.has(src)) adj.set(src, {{ edges: [], neighbors: new Set() }});
if (!adj.has(dst)) adj.set(dst, {{ edges: [], neighbors: new Set() }});
adj.get(src).edges.push(edge);
adj.get(src).neighbors.add(dst);
adj.get(dst).edges.push(edge);
adj.get(dst).neighbors.add(src);
}});
const DIM = 0.08;
let focused = null;
function clearFocus() {{
focused = null;
nodes.forEach(n => n.style.opacity = '');
edges.forEach(e => e.style.opacity = '');
clusterChrome.forEach(el => el.style.opacity = '');
}}
function focusNode(title) {{
if (focused === title) {{ clearFocus(); return; }}
focused = title;
const info = adj.get(title) || {{ edges: [], neighbors: new Set() }};
const connected = new Set([title, ...info.neighbors]);
// Dim all nodes, edges, and cluster chrome individually (not the cluster <g>)
nodes.forEach(n => n.style.opacity = DIM);
edges.forEach(e => e.style.opacity = DIM);
clusterChrome.forEach(el => el.style.opacity = DIM);
// Highlight connected nodes
connected.forEach(name => {{
const el = nodeByTitle.get(name);
if (el) el.style.opacity = 1;
}});
// Highlight connected edges
info.edges.forEach(e => e.style.opacity = 1);
// Highlight cluster chrome for clusters that contain a connected node
const seen = new Set();
connected.forEach(name => {{
const chrome = nodeToClusterEls.get(name);
if (chrome) chrome.forEach(el => {{
if (!seen.has(el)) {{ seen.add(el); el.style.opacity = 1; }}
}});
}});
}}
// Attach click handlers to nodes
nodes.forEach(node => {{
node.style.cursor = 'pointer';
node.addEventListener('click', e => {{
e.stopPropagation();
const t = node.querySelector('title');
if (t) focusNode(t.textContent.trim());
}});
}});
// pan & zoom
let scale = 1, tx = 0, ty = 0, dragging = false, sx = 0, sy = 0;
let scale = 1, tx = 0, ty = 0, dragging = false, didDrag = false, sx = 0, sy = 0;
function applyTransform() {{ svg.style.transform = `translate(${{tx}}px,${{ty}}px) scale(${{scale}})`; svg.style.transformOrigin = '0 0'; }}
function resetView() {{
const vw = window.innerWidth, vh = window.innerHeight;
@ -1134,9 +1226,11 @@ function resetView() {{
resetView();
vp.addEventListener('wheel', e => {{ e.preventDefault(); const f = e.deltaY < 0 ? 1.12 : 0.89; const rect = vp.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; tx = mx - f * (mx - tx); ty = my - f * (my - ty); scale *= f; applyTransform(); }}, {{ passive:false }});
vp.addEventListener('pointerdown', e => {{ dragging=true; sx=e.clientX-tx; sy=e.clientY-ty; vp.setPointerCapture(e.pointerId); }});
vp.addEventListener('pointermove', e => {{ if(!dragging) return; tx=e.clientX-sx; ty=e.clientY-sy; applyTransform(); }});
vp.addEventListener('pointerdown', e => {{ dragging=true; didDrag=false; sx=e.clientX-tx; sy=e.clientY-ty; vp.setPointerCapture(e.pointerId); }});
vp.addEventListener('pointermove', e => {{ if(!dragging) return; didDrag=true; tx=e.clientX-sx; ty=e.clientY-sy; applyTransform(); }});
vp.addEventListener('pointerup', () => dragging=false);
// Click background to clear focus (only if it wasn't a drag)
vp.addEventListener('click', e => {{ if (!didDrag && !e.target.closest('.node')) clearFocus(); }});
function zoomIn() {{ scale*=1.3; applyTransform(); }}
function zoomOut() {{ scale*=0.7; applyTransform(); }}