feat: code DAG visualizer #11
3 changed files with 204 additions and 8 deletions
|
|
@ -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
102
deps.html
|
|
@ -23,7 +23,7 @@
|
|||
<button onclick='zoomIn()'>+</button>
|
||||
<button onclick='zoomOut()'>−</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(); }
|
||||
|
|
|
|||
|
|
@ -1096,7 +1096,7 @@ fn generate_html(dot_source: &str) -> String {
|
|||
<button onclick='zoomIn()'>+</button>
|
||||
<button onclick='zoomOut()'>−</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(); }}
|
||||
|
|
|
|||
Loading…
Reference in a new issue