diff --git a/README.md b/README.md
index bcce6fe..78c4ddc 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/deps.html b/deps.html
index f461137..9268f46 100644
--- a/deps.html
+++ b/deps.html
@@ -23,7 +23,7 @@
- scroll to zoom · drag to pan
+ scroll to zoom · drag to pan · click node to focus
Loading Graphviz…
@@ -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 )
+ 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(); }
diff --git a/tools/depgraph/src/main.rs b/tools/depgraph/src/main.rs
index 0dbe337..2662195 100644
--- a/tools/depgraph/src/main.rs
+++ b/tools/depgraph/src/main.rs
@@ -1096,7 +1096,7 @@ fn generate_html(dot_source: &str) -> String {
- scroll to zoom · drag to pan
+ scroll to zoom · drag to pan · click node to focus
Loading Graphviz…
@@ -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 )
+ 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(); }}