Files
learnharness/docs/research/graph-rendering.md
T
amosblomqvist 61bd43282d feat: model menu keyboard machine + graph center-anchor on resize
Owner polish pair before the settings milestone:

- Model menu: arrows walk the filtered results (clamped, no wrap) while
  focus stays in the search field; Enter picks the walked row or, with no
  walk yet, the first result; typing resets the walk; click and Enter
  share one pickModel path; walked row gets a muted ring + scrollIntoView;
  cheat-sheet display entries added.

- Graph: window resizes re-anchor the camera so the world point under the
  pre-resize viewport center stays under the new center — the graph stays
  visually still while scaling (was top-left pinned). viewW/H tracked in
  syncSize; re-anchor applied through the zoom behavior (persistence and
  card-dismiss unchanged); kFit refresh as before. Pixel-verified: 7/7
  center samples identical across a 300x200 shrink.
2026-09-18 08:53:31 +03:00

15 KiB
Raw Blame History

Graph rendering & cluster-first layout — research findings

Compiled 2026-09-13 from four research passes (library fact-check ×2, prior-art ×2), all claims source-verified by subagents against shipped package code (npm pack tarballs of sigma@3.0.3, @sigma/layer-webgl@3.0.0), the sigma.js GitHub storybook source, and primary docs. Speculation is marked where it appears. This document is the decision basis for the rendering rebuild; nothing has been implemented yet.


1. Requirements (as of this research)

Current symptoms to eliminate:

  • Hull layers lag behind nodes during pan (desync).
  • Settings changes shift hulls up-left until the next interaction (canvas-sizing race).
  • GPU ~50–100% during pan/zoom (redundant full-canvas 2D repaints per camera event + WebGL re-render per frame; the earlier ctx.filter: blur per frame caused coil whine).
  • Stale/garbage boundary shapes at extreme zoom-out.

Feature set:

  • Circles + directed arrows; precomputed layout (no runtime physics).
  • Cluster regions: hull boundary around each community, dashed stroke, soft translucent fill, stable color per region, region name label.
  • Inverse zoom gates: node labels when zoomed IN, region labels when zoomed OUT, cross-fading over a shared span.
  • Pan, zoom-to-cursor, hover glow, click → detail card, edge-type filtering, camera survives UI changes. 60fps, minimal GPU, ordinary laptop.

New direction (user, 2026-09-13):

  • Cluster-first placement: layout depends on clustering — clusters placed as units, nodes within a cluster close together (compact, natural hull shapes as a consequence).
  • Freeform experimentation: a dev-only playground to try layouts/shapes/styles against real and synthetic data; renderer must be dumb, data-driven, swappable.

Scale: 338 nodes / 211 edges today; low thousands expected (notes imports).

2. Verified facts about the current stack (sigma.js v3)

  • v3.0.3 ships a PUBLIC custom-layer API — createLayer / createCanvas / createCanvasContext / killLayer (typed in shipped .d.ts). Our current firstChild DOM injection was improvising a worse version of a supported API.
  • Sigma sizes and DPR-scales ALL registered canvases in its own resize() — using the public API eliminates the canvas-sizing race by construction.
  • afterRender fires once per rendered frame (rAF-debounced scheduleRender()). Camera motion → full re-render each rAF. Hover is cheap: it only redraws the hoverNodes WebGL layer + edgeLabels canvas via a separate rAF; no afterRender, no full re-render. Sigma is strictly event-driven: no continuous loop, no rendering when idle (verified: zero setInterval, all rAF sites are schedulers).
  • renderer.graphToViewport() is public and typed — the sanctioned graph→pixel path. renderer.matrix is private: building a ctx.setTransform() from it would be guessing at internals; per-vertex graphToViewport() is the supported route.
  • The official cluster-label storybook example is exactly our pattern: custom DOM layer + afterRender + per-cluster graphToViewport() re-projection, maintained in the sigma repo. Our minimal-change fix path is a demonstrated usage.
  • Real v3 layer order (verified in source, NOT in docs): edges (WebGL) → edgeLabels → nodes (WebGL) → labels → hovers → hoverNodes (WebGL) → mouse.

@sigma/layer-webgl@3.0.0 (official background-layer package)

  • bindWebGLLayer inserts a background WebGL layer (beforeLayer: "edges"), redrawn on afterRender with the same camera matrix — fully in-loop, no desync.
  • createContoursProgram renders METABALL-style implicit fields, not hull polygons (verified from the fragment shader: fullscreen quad, summed radial falloffs, analytic contour lines via dFdx/dFdy). Translucent fill: yes (arbitrary CSS color with alpha). Dashed stroke: NO — solid anti-aliased line only; no dash parameter exists. Per-community color: yes, but one program + one WebGL context per community, and each runs a fullscreen fragment pass per frame (browsers cap ~16 WebGL contexts — a problem for dozens of regions).
  • Decisive fact: our exact visual (hull polygon + translucent fill + dashed stroke) is NOT achievable with shipped layer-webgl programs. It would require a custom WebGLLayerProgram (supported extension point, moderate effort, dashes DIY in GLSL) — whereas Canvas 2D gives it for free with ctx.setLineDash().
  • v4-era programs (heatmap, density-splat, dashed/fill layer factories) are NOT in the v3 package.

sigma v4

  • Real but beta (4.0.0-beta.5; docs site self-describes as alpha). Single stage canvas + public createLayer, GPU picking with downsizing, SDF labels, depth layers. Breaking migration. Not a production bet today.

Pan GPU cost at our scale

  • Structural facts verified (event-driven idle; full re-render per camera frame; synchronous gl.readPixels picking per mousemove pre-#1546, since fixed).
  • Absolute GPU cost of panning 338–3k nodes in v3: UNVERIFIED — no public benchmark exists; nobody has measured it. If it matters, we measure it ourselves.

3. Verified facts about the candidates

Candidate Renderer True hull fill+dash Picking Camera Maintenance License Fit
sigma v3 + public 2D canvas layer WebGL (+our 2D) yes (our canvas, free) GPU color-pick built-in active (3.0.3, 2026-04) MIT 7/10
sigma v3 + layer-webgl contours WebGL no (metaballs, solid border) — built-in active MIT 6/10
sigma v4 + layers WebGL, SDF via custom program (beta risk) GPU picking built-in beta MIT 6 (risky)
d3-zoom + Canvas 2D (hand-rolled loop) Canvas 2D yes, trivially distance check d3-zoom (verified math) d3 very active ISC 8/10
PixiJS v8 + pixi-viewport WebGL/WebGPU/Canvas yes (Graphics; dashes via shim pixi-dashed-line) built-in events pixi-viewport 6.0.3 very active MIT 7/10
cytoscape.js 3.34 Canvas 2D via bubblesets/layers extensions built-in built-in active MIT 5/10
vis-network 10.1 Canvas 2D no — clustering = supernodes (collapse, opposite of our regions) built-in built-in sporadic MIT 2/10
@cosmos.gl/graph (Cosmograph engine) WebGL (luma.gl) no (cluster = layout force only) GPU built-in active MIT 3/10

Fact nuances we must not over-trust:

  • "Canvas 2D is GPU-rasterized": half-true. Skia may raster on CPU or GPU depending on browser/OS config. Either way the cost driver is repaint count × pixel area (DPR²), not "Skia = GPU".
  • Canvas 2D at 60fps for hundreds–low-thousands of elements: supported by practitioner evidence (cytoscape is fully canvas-based into the thousands; 10k animated canvas elements ≈ 55fps in one 2025/26 benchmark). Pixel area matters more than node count at our scale.
  • Obsidian's graph view renderer: UNVERIFIED — no primary source. Do not cite.
  • PixiJS exact bundle size: unmeasured. yFiles: commercial, unverified details.

4. Prior art for our specific features

Region/hull visuals

  • Nobody in the graph-tool world ships our region aesthetic: Gephi desktop, Gephi Lite (the flagship sigma app — zero hull code in its tree), Obsidian — all partition by node coloring only. The region feature is a differentiator, not a copied pattern.
  • The aesthetic reference: ggforce geom_mark_hull (expand + round the hull, low-alpha fill, integrated label placement).
  • Bostock's thick-stroke hull trick: one path, wide stroke (e.g. 32px) + linejoin: round = padded, rounded hull with zero polygon-offset math. Replaces our offset-normal code outright.
  • Named-region UX validation: Excalidraw frames / tldraw regions (dashed titled container around content).
  • Closest feature twin: Cosmograph — documents "cluster labels when zoomed out, point labels when zoomed in" as a first-class use case (our exact inverse gate).

Zoom-gated labels

  • Consensus pattern = map-style per-layer zoom ranges (Mapbox/MapLibre minzoom/maxzoom on symbol layers). State labels at far zoom, city labels at near zoom is the standard cartographic solution; our inverse gates are the same mechanism with opposite ranges. Sigma's own labelRenderedSizeThreshold is the zoom-in gate.
  • Cross-fade + hysteresis over a shared span: no single canonical writeup (medium confidence); nearest established practice is map label collision systems.

The sync pattern (fixes our desync class structurally)

  • Canonical and repeatedly documented: one rAF loop with a dirty flag; input/DOM events only mutate state. All layers drawn in the same frame, under the same camera transform — either same canvas (setTransform once, draw back-to-front) or stacked canvases all redrawn by the same loop. Sources: browser.engineering, MDN "Optimizing canvas", web.dev canvas performance.

Picking

  • Canvas 2D at our scale: nearest-node distance check per pointer event is the norm, trivially fast (µs for 3k nodes; spatial grid only past ~10k).

5. Cluster-first layout (the new direction)

  • Two-level pattern is well-trodden: place cluster anchors first (quotient graph — one super-node per community, Gephi's "Groups by Partition" plugin is the desktop prior art), layout nodes within each cluster, compose by translating local layouts to cluster anchors. Bostock's Clustered Force Layout lineage; cosmos.gl's cluster force verified from source (per-frame center-of-mass pull, or user-pinned positions — cluster anchors as data independent of member layout).
  • graphology FA2 has NO cluster-aware settings (full settings list read; gravity is global). LinLog mode (linLogMode: true) is the documented "communities separate tightly" switch — a one-afternoon A/B candidate before any custom pipeline.
  • No turnkey JS package does the composed two-level pipeline — assemble from graphology primitives (~150 lines of glue: quotient build, per-cluster FA2 with circular init, compose transform, noverlap cleanup). The combination is our synthesis from verified primitives.
  • Deterministic given fixed init positions (no RNG dependency in FA2).
  • Server-side at extraction time is the better fit for our architecture: positions become data, rendering stays dumb. Browser worker fallback for live re-layout.
  • Harness precedents: yFiles layout playground (polished), Observable/bl.ocks lineage (notebook = graph + sliders + canvas), react-sigma layouts example. No canonical OSS playground for our stack — copy the config-driven pattern (algorithm dropdown + parameter sliders + seed input + live canvas).

6. Options (the decision)

Option A — Stay on sigma v3; mount the hull canvas via the public createCanvas API; redraw on afterRender via graphToViewport(). Supported, minimal-change, demonstrated by sigma's own cluster-label storybook. Kills the desync, the sizing race, and the multi-event redraws (once per frame, sigma-sized). Our exact visuals stay (dashes free in 2D). Keeps sigma's camera/labels/picking machinery. Residual: full-canvas 2D repaint per interaction frame remains (raster cost, mitigable with move-gating like sigma's own hideLabelsOnMove); WebGL + 2D raster still both run per frame. Est: ~1 day.

Option A′ — sigma + custom WebGLLayerProgram for hulls. Supported extension point, but dashes are DIY GLSL and we'd rebuild in shaders what Canvas 2D gives free. No reason given A exists. Est: several days, for a worse iteration loop.

Option B — d3-zoom + hand-rolled single Canvas 2D renderer. One canvas, one rAF + dirty flag, world-space geometry + one transform, all layers back-to-front in the same frame. Lowest GPU/CPU, total control, perfectly matches the new requirements: renderer is a dumb draw function over data (layout experiments can't fight it), playground is trivial, cluster-first layout slots in as pure data. Costs ~600 lines + camera feel + picking + label gating owned by us. Est: ~1 focused day.

Option C — PixiJS v8 + pixi-viewport. Cleanest scene-graph answer (one ticker, sync by construction), WebGPU headroom. But: dashes need a third-party shim, no graph semantics, bundle, more machinery than Canvas 2D needs at our scale. Est: 1–2 days.

Recommendation: Option B as the target — it is the only option that structurally matches all three requirement sets (bugs, cluster-first placement, freeform experimentation), scored highest (8/10), and removes the vendor-internals class of problems permanently. Option A is a legitimate ~1-day stabilization if we want the current bugs dead immediately while deferring the rebuild; it reuses all our existing visual code and is the officially sanctioned pattern, but it keeps us on sigma's opinionated machinery and the experimentation direction will keep pushing against it.

Layout work rides along independent of renderer choice: first A/B LinLog-only FA2 (one afternoon), then the two-level pipeline (quotient → per-cluster FA2 → compose → noverlap) if needed, developed in the playground.

7. Facts we should NOT rely on

  • "Obsidian's graph view uses renderer X" — unverified.
  • "Sigma's documented canvas DOM order" — source-only, implementation detail, changed in v4.
  • "Canvas 2D is always GPU-rasterized" — false as stated.
  • "Sigma v4 is ready" — beta.
  • "Sigma pan cost at our scale is X%" — nobody has measured it publicly.
  • "@cosmograph/cosmos is the maintained Cosmograph engine" — stale npm channel (Dec 2024); current engine is @cosmos.gl/graph v3.
  • Any PixiJS bundle-size number — unmeasured.

8. Open items for the implementation phase

  • Measure pan GPU/CPU ourselves (Chrome profiler) before/after whichever path is chosen.
  • Playground design: /playground dev-only route, config-driven (layout algorithm dropdown, parameter sliders, seed input, synthetic + real datasets), same renderer.
  • Hull styling decision inside Option B: Bostock thick-stroke trick vs offset polygons (geometry already unit-tested in components/cluster-geometry.ts).
  • Label-gate hysteresis: small dead-band in log-zoom to avoid threshold flicker.
  • Whether layout runs server-side at extraction (persisted as data) or in-browser.

Center-anchor on window resize (2026-09-17)

The camera translate is measured from the canvas's TOP-LEFT corner, so a window resize used to keep the top-left pinned and the graph appeared to slide by half the size delta (owner report). The resize observer now captures the world point under the PRE-resize viewport center (from the last-synced viewW/H — the ResizeObserver already sees the new size in clientWidth/Height), then re-anchors the translate so that same world point lands under the NEW center (applied through the zoom behavior, so d3 state, camera persistence, and card-dismiss all behave like any camera move). Zoom (k) is untouched; refreshFitZoom metadata is viewport-bound and recomputed as before. Verified by canvas pixel sampling: 7/7 center-area samples identical across a 300×200 shrink.