feat(skill): Sigma.js viz fallback + batched community-label reconciliation#1701
feat(skill): Sigma.js viz fallback + batched community-label reconciliation#1701docwilde wants to merge 11 commits into
Conversation
…iation vis-network's client-side forceAtlas2 physics simulation is verified slow/laggy past a few hundred nodes regardless of hardware (apa monorepo, 1083-community aggregated view, 2026-07-03). Step 6 now generates a separate WebGL Sigma.js render with the layout precomputed offline in Python once a view exceeds ~300 nodes, documented in a new shared reference (references/sigma-viz.md) shipped to every split-platform skill variant via tools/skillgen. Step 5's community labeling also gains a batched-subagent flow for 100+ communities (sample top-degree nodes per community, dispatch one subagent per ~15-20-community batch in parallel) plus a required cross-batch label-reconciliation pass, since batches only see their own slice and naturally produce duplicate labels across siblings (338 of 1083 communities, 31%, collided before reconciliation on the same run). Ported from local edits to the installed ~/.claude/skills/graphify/ skill made in a prior session against a production run, which were not yet committed to the repo.
…lter panel Fixes a node-sizing bug in the Sigma.js viz: itemSizesReference: "positions" needs autoRescale: false alongside it, per sigma's own fit-sizes-to-positions example, otherwise the one-time auto-fit repositions the graph without symmetrically adjusting sizes. Node size is now also calibrated against the layout's own median nearest-neighbor distance instead of a fixed constant, so communities stay legible instead of overlapping as node count grows. Adds three things vis-network's graph.html doesn't surface: an icon per community's dominant content kind (code/document/paper/image/rationale/ concept, via @sigma/node-image's NodePictogramProgram), a color per dominant top-level module/directory, and a left-side panel to filter by entity kind and by relation type — grouped into calls/structure/ imports/references/documentation-and-concepts/groups so non-code relations (cites, rationale_for, semantically_similar_to, etc.) are represented alongside code relations, not just calls/imports. The module legend doubles as an isolate-by-module toggle. Also fixes several bugs found in review: a crash when no community meets MIN_COMMUNITY_SIZE, a missing 'links'/'edges' key compatibility guard (present elsewhere in the codebase but missing here), hyperedges never being processed so the "Groups" filter was permanently empty, an unescaped innerHTML injection of LLM-generated labels and directory names, a case-sensitive </script> safety check, search results ignoring active filters, and a camera-pan target using raw graph coordinates instead of sigma's own display-space coordinates.
…-labels' (v8 update) into feature branch # Conflicts: # CHANGELOG.md
|
Pushed a follow-up commit addressing review feedback:
Also merged in the latest |
…esolution)
Found by actually opening a generated graph_sigma.html in a real browser
(Chrome via Playwright), which the prior fixes were not verified against:
- autoRescale: false (added to fix the earlier sizing bug) also disables
sigma's automatic "fit the camera to the graph" behavior on load. With
no replacement, the camera stayed at its default state while every
node lives in the 0-1000 layout range Step 1 produces - the camera was
pointed at empty space, not a badly-framed graph. Fixed by calling
fitViewportToNodes (from @sigma/utils, the same package sigma's own
demo uses) once after construction and again in the reset handler,
replacing camera.animatedReset() which had the identical bug.
- More fundamentally: the @sigma/node-image and @sigma/utils ESM imports
were throwing at module-load time. esm.sh resolves their `sigma` peer
dependency (declared as a version range) to a literal broken module
path instead of the pinned 3.0.3, so `import { NodePictogramProgram }
from "..."` never completed - and since that's the first statement in
the module, the entire script died before the Sigma instance was ever
constructed. This is the more fundamental cause of the black screen;
the camera fix alone was not sufficient. Both @sigma/* imports now
pin the dependency explicitly via esm.sh's `?deps=sigma@3.0.3`.
- An independent second-opinion review (a separate agent instance,
briefed only on the bug history and told to be skeptical of every
camera/coordinate claim) caught a third latent bug before it shipped:
the icon SVGs had no explicit width/height, which @sigma/node-image's
raster-image path needs to determine a data-URI icon's intrinsic size.
- maxCameraRatio raised 3 -> 10 for headroom on narrow/short viewports,
per the same review.
Verified end-to-end in a real browser against both a small synthetic
multi-type example (icons/colors/filters all visibly correct, zero
console errors) and the actual 20,097-node/1083-community production
graph that motivated this feature (342 aggregated communities, zero
console errors, correct meta line/filter panel/module legend).
|
Critical fix, found by actually opening the generated HTML in a real browser — the previous fix reported here made the graph render as a solid black screen with nothing visible. Root causes:
Verified end-to-end in Chrome (via Playwright) against both a small synthetic multi-type example and the real 20,097-node/1083-community production graph (apa) that motivated this feature — zero console errors, correct rendering, icons/module-colors/filter panel/legend all working. |
…le links Usability pass driven by actually using the rendered graph against a real 20,097-node/1083-community production corpus: - Layout spread: the default forceAtlas2 parameters (linear attraction, no repulsion halo) packed 87% of nodes within 15% of the bounding-box center on that real graph - genuinely unreadable, not just suboptimal. linlog=True + a node_size repulsion halo proportional to member count + a much higher scaling_ratio drops that to ~15-20% while keeping structurally related communities near each other, verified by measuring nearest-neighbor spacing across several parameter sets before picking one, not by eyeballing a single result. - Edges are now colored AND labeled by their dominant relation bucket instead of a flat gray. renderEdgeLabels: true draws them; sigma has no independent edge-label zoom threshold, but ties edge-label visibility to node-label visibility, which manages clutter on a 1000+-edge graph automatically rather than needing custom logic. - Nodes are draggable (sigma's own downNode/moveBody/upNode/upStage pattern, verified against sigma's actual official example source). The setCustomBBox freeze is required, not optional: without it sigma recomputes its normalization extent from live positions on every reindex even with autoRescale:false, so dragging one node visibly pans every other one. Reset now restores original positions too, not just filters/highlight. - The click panel lists each community's actual representative source files (capped at 8, true total count for "+N more"), linked via file:// when .graphify_root confirms the graph is being viewed on the machine that generated it. - doc_ref (a newer AST-derived file_type not in the original six-value schema, added by an upstream ADR/RFC-citation feature) now gets an icon instead of silently miscategorizing as code. - Filter panel legibility: entity-kind icons were solid black rendered as raw <img> against a dark panel (meant to be WebGL-tinted, not shown directly) - now forced white via CSS. Relation-type checkboxes get a color swatch matching their edge color. Verified end-to-end in a real browser (Chrome via Playwright): a synthetic multi-type example confirmed drag + connected-edge-following + click-highlight + file-panel + reset all compose correctly, and the same was independently confirmed on the real production graph via direct DOM/data inspection (all 342 nodes carry correct file data with resolved absolute file:// URLs) plus visual confirmation of the layout spread, edge colors/labels, and a real click showing real file paths.
…panel Follow-up round driven by direct feedback after actually using the rendered graph, not just reading the code: - Layout: the first spread fix (gravity=0.5, scaling_ratio=20) measured better on a numeric spread proxy but was still reported as too bunched to read labels without manually dragging nodes apart. A second pass (gravity=0.15, scaling_ratio=80) actually fixes it - verified by reading real community labels at the default fit-all zoom on the 342-community production graph, not by trusting the proxy metric a second time. - Node labels get their own small background box. Sigma's default label renderer is bare fillText with no background option; defaultDrawNodeLabel is the documented override point for a custom renderer. First attempt used a near-black fill that was indistinguishable from the canvas's own background color - fixed to a visibly lighter tone matching the UI panel chrome. - The click panel's file list is no longer capped with "+N more" - Step 1 sends every distinct file, rendered as a filterable, scrollable list. Clicking a file opens a movable dialog (dragged via its header, plain screen-pixel CSS, unrelated to sigma's graph/camera coordinate spaces) showing an embedded content preview - Step 1 reads a bounded preview from disk for the first 8 files per community, since embedding full content for every file would bloat the self-contained HTML far more than paths alone do. - Embedding real file content surfaced a new instance of the script-tag-collision risk: a previewed HTML/JS file routinely contains a literal </script (confirmed on the real corpus - a Vite index.html's <script type="module"> tag). This used to be a check-after-the-fact step; Step 3 now escapes it unconditionally before embedding, since previews make the collision routine rather than a rare edge case. - The entity-properties panel, previously a separate floating box that could get clipped by the viewport independently of the main panel, is now nested inside the main sidebar - same width, same scroll, no separate truncation point. A parallel Sonnet review pass over the accumulated diff (this file grew quickly through many rapid incremental edits) found no functional bugs, just two cosmetic nitpicks (an unused data attribute, a stray character in placeholder text), both fixed. Verified end-to-end in a real browser (Chrome via Playwright): the label-background fix, merged panel layout, scrollable/filterable file list, and draggable preview dialog were all confirmed on a synthetic example (including an exact-coordinate drag verified via getNodeDisplayData, since eyeballed pixel coordinates missed targets repeatedly); the layout re-tune and script-tag auto-escape were confirmed on the real 20,097-node/1083-community production graph.
|
Second follow-up round, driven by direct feedback after actually using the rendered graph:
A parallel Sonnet review pass over the accumulated diff found no functional bugs, just two cosmetic nitpicks (an unused data attribute, a stray character in placeholder text), both fixed. Verified end-to-end in a real browser (Chrome via Playwright): the label-background fix, merged panel layout, scrollable/filterable file list, and draggable preview dialog were all confirmed on a synthetic example (switched to exact-coordinate clicks via |
A well-spread layout with every edge drawn at once still reads as a hairball - nothing visually ties related things together beyond a color dot. Three additions: - Module clustering: Step 1 boosts the layout-only edge weight (a separate layout_weight attribute, not the real weight that drives rendered thickness) for same-module edges before running forceAtlas2, so modules pull together spatially instead of scattering by topology alone. Verified by measuring the ratio of within-module to between-module node distance on the real production graph: 0.96 (no boost) down to 0.48 at the chosen factor; a higher factor stopped improving it further. - Module hulls: a soft translucent polygon behind each module's cluster, computed with a small pure-Python convex-hull implementation (Andrew's monotone chain - not worth scipy for one shape per module) and rendered on a 2D canvas layer behind sigma's WebGL canvas, redrawn on every afterRender event so it tracks pan/zoom via renderer.graphToViewport. - Edge decluttering: edges below a weight percentile are hidden by default (a "show all edges" checkbox opts back in; anything touching the highlighted node always shows regardless of weight). This surfaced a real gap: nothing ever called applyReducers() at startup, so the filter/highlight reducers had been silently inert until the first user interaction the whole time - harmless before (their default state was a no-op identity function), not harmless once hiding-by-weight became a real default-view behavior. Also fixes a genuine sidebar bug: position: absolute let the panel drift out of sync with the actual viewport whenever the page gained any scroll, which read as "the panel is truncated on my laptop" on a short screen - fixed to position: fixed, plus a visible custom scrollbar (macOS hides scrollbars until actively scrolling, making an already-working scroll region look like a dead end) and height instead of max-height so the panel always spans the full viewport as a proper sidebar rather than shrinking to fit its current content. Verified end-to-end on the real 20,097-node/1083-community production graph: 12 module hulls render correctly and stay aligned through pan/zoom, the show-all-edges toggle visibly changes edge density in both directions, module-isolate correctly gates its hull via the same activeModules check nodes/edges already use, and the sidebar renders as a full-height, properly-scrolling panel at both a short laptop-sized viewport (1440x760) and a large one (2560x1440).
|
Third follow-up round: "more structure" + sidebar fixes.
Verified end-to-end on the real 20,097-node/1083-community production graph: 12 module hulls render correctly and track pan/zoom, the show-all-edges toggle visibly changes edge density in both directions, module-isolate correctly gates its hull via the same |
Resizing the browser window after load left the graph/hulls pinned at their original size, since sigma's own resize listener re-normalizes node coordinates asynchronously on the next animation frame. Calling fitViewportToNodes synchronously in the same resize handler read node display data mid-flight through that pending pass and got back stale, un-normalized coordinates, pointing the camera outside the graph and rendering a blank canvas - worse than the dead-space bug it fixed. Deferring the re-fit one frame with requestAnimationFrame lets sigma's own resize/re-normalization finish first. Verified against the real 20,097-node/1083-community production graph: loading at one window size and resizing larger now fills the new viewport with no dead space and no rendering corruption.
…pread, add repo/branch header The requestAnimationFrame-deferred resize fix from the previous commit merely assumed it would run after sigma's own internal resize handling, which held in common cases but raced and failed when the window was resized immediately after load or resized rapidly (a real drag-resize fires many resize events in quick succession). It also caused the hull overlay to visibly desync from the graph for a frame, since drawing it right after mutating the camera reflected the new state before sigma's own WebGL canvases had repainted. Both are fixed by tying the re-fit and the hull redraw to sigma's own `afterRender` event instead of a guessed frame count - that event only fires once a render pass (including any pending re-normalization) has actually completed. Verified against the real 20,097-node/1083-community production graph: load-then-resize-immediately (both directions) and a simulated rapid multi-step drag-resize all settle correctly with no dead space, no blank canvas, and hulls staying aligned with nodes throughout. Also: MODULE_CLUSTER_BOOST=15 was found to actively worsen bunching in the two largest modules specifically (more same-module edges compounds the boost), collapsing them into their own dense sub-hairball. Lowered to 6 (with scaling_ratio 80->100), measured to cut the near-center node fraction from 0.28 to 0.12 on the real production graph at an accepted cost to module separation tightness. The header now also shows the repo name, scan-root path, and git branch a given graph_sigma.html was generated from, since nothing previously distinguished one open copy from another.
The sidebar's custom scrollbar only used ::-webkit-scrollbar-* rules, a Chromium/WebKit-only proprietary API that does nothing in Firefox. Firefox users got no visible scrollbar at all and would hit the exact "looks truncated, unscrollable" problem this was added to fix in the first place. Add the standard scrollbar-width/scrollbar-color properties alongside the existing WebKit rules for cross-browser coverage.
Summary
Adds a Sigma.js + graphology (WebGL) fallback for
graph.htmlonce a view exceeds ~300 nodes, plus a batched-subagent flow for community labeling at scale, both validated against a real production run (apa, a 20,097-node/1083-community monorepo) — then substantially hardened across six follow-up review rounds.Sigma.js viz (
graph_sigma.html):linlog=True+ anode_sizerepulsion halo + a highscaling_ratio, tuned across three passes against real production output rather than a numeric proxy alone.@sigma/node-image'sNodePictogramProgram), color per dominant module, node labels on their own background box (no sigma built-in for this; used the documenteddefaultDrawNodeLabeloverride point), edges colored + labeled by their dominant relation bucket.cites/rationale_forare represented too), a clickable module legend, and draggable nodes for manual layout tidying.file://link when opened on the machine that generated the graph.graph_sigma.htmlwas generated from, so one open copy can be told apart from another (e.g. comparing branches, or a stale copy left over from a previous run).Community labeling (Step 5): a batched-subagent flow for 100+ communities (sample top-degree nodes, dispatch one subagent per ~15-20-community batch in parallel) replacing one-at-a-time labeling and a naive path-prefix heuristic, plus a required cross-batch label-reconciliation pass — batches only see their own slice, so duplicate labels across siblings are the common case at scale (31% of communities collided on the same production run), not an edge case.
Why: Sigma.js instead of vis-network past ~300 nodes
vis-network runs a live, single-threaded JS forceAtlas2 physics simulation on load; past a few hundred nodes this is genuinely slow/laggy regardless of hardware. The fix is removing client-side physics entirely: precompute the layout once, offline, and render only via WebGL. Ships as a separate output file, not a replacement — vis-network is fine below the threshold and has features (a community filter dropdown, confidence-styled edges) this lighter template doesn't replicate.
Review history
graph.jsonsamples.autoRescale: false(added for a sizing fix) silently broke camera framing, and a separate esm.sh peer-dependency resolution issue broke the icon/utils imports entirely before any script code ran. An independent second-opinion review caught a third latent bug (icon SVG sizing) before it shipped.<script>tag collided with the page's), and a floating info panel getting clipped independently of the main sidebar.position: absolutedrifting out of sync with the visible viewport on any page scroll) fixed toposition: fixedwithheightinstead ofmax-height.fitViewportToNodescalled synchronously in the resize handler) was worse than the bug — it read node data mid-flight through sigma's own pending re-normalization and rendered a blank canvas. The second attempt deferred that call by onerequestAnimationFrame, which fixed the common case but still assumed frame ordering rather than verifying it — it raced and failed on an immediate post-load resize or a rapid multi-step resize.afterRenderevent instead of a guessed frame count, since that event only fires once a render pass has genuinely finished. This round also re-tuned the module-clustering boost after a report that the two largest modules were still visibly clumping into their own sub-hairball — a too-high boost was found to actively compound on modules with more same-module edges rather than plateauing harmlessly — and added the repo/branch header line.Scope note
Only the shared
core.mdfragment (covering all 14 split-platform skills, rendered viatools/skillgen) was updated. Theaideranddevinmonolith skill bodies maintain their own independent Step 5/6 sections and were intentionally left untouched.Test plan
uv run python -m tools.skillgen(+--check/--audit-coverage/--schema-singleton/--monolith-roundtrip/--always-on-roundtrip) — all passuv run python -m pytest -q— full suite green (2962 passed, 3 skipped, with[all]optional extras installed)graph.jsonsamples, including forced edge cases (empty-communities fallback,edges-vs-linkskey, synthetic hyperedges)graph_sigma.htmlopened in a real browser (Chrome via Playwright, served over local HTTP) against a synthetic multi-type example and the real 20,097-node/1083-community production graph — zero console errors, correct rendering, icons/colors/labels/filter panel/legend/click-highlight/dragging/file dialog/module hulls/edge decluttering/repo-branch header all confirmed working