Skip to content

Feat/sigma renderer#2

Merged
Mnikley merged 70 commits into
mainfrom
feat/sigma-renderer
Jun 16, 2026
Merged

Feat/sigma renderer#2
Mnikley merged 70 commits into
mainfrom
feat/sigma-renderer

Conversation

@Mnikley

@Mnikley Mnikley commented Jun 16, 2026

Copy link
Copy Markdown
Member

feat/sigma-renderer → main (v1.15.0)

Summary

Migrates the rendering stack from AntV G6 5.x (canvas) to Sigma.js v3 (WebGL) on a graphology data model, then builds out layouts, communities, edge flow,
heatmap, and a UI rework on top. 68 commits, 131 files, +30.5k/−4k.

Why

G6's canvas renderer didn't scale. On the 6000-node / 9000-edge benchmark:

Metric G6 (1.14.x) Sigma (1.15.0)
Load (data → rendered) 1.6 s 0.79 s
Wheel-zoom FPS ~4 ~60
First-interaction stall ~11 s 64 ms
500-node select 140 ms 46 ms

Distribution is also ≈0.9 MB smaller (vendored g6.min.js removed).

What's included

  • Renderer: all 6 node shapes, selection/hover/dim reducers, lasso, drag-with-persistence, tooltips, native bubble-group canvas layer (bubblesets-js),
    minimap, and PNG/SVG export — all ported to sigma.
  • Layouts: live-animating ForceAtlas2 (web-worker supervisor), Dagre, circlepack, random, full-workspace re-layout with transitions, noverlap post-pass.
  • Communities & metrics: weighted Louvain detection into bubble groups; centralities via graphology-metrics.
  • Edges & flow: animated directional flow (dash/pulse/comet/chevron) on straight + curved edges; arrow markers and halos with fill/border controls.
  • Nodes & styling: pie-chart nodes, GLSL circle borders, node badges, dark-mode toggle.
  • Heatmap: configurable density overlay (replaces selection glow).
  • UI: reworked selection grouping + selection-driven styling cards; compact density-aware filter panel.
  • Repo hygiene: CI, ESLint/Prettier, coverage provider, SECURITY.md, third-party notices.

Known degradations

Dashed edges render solid; polyline edges render curved (no off-the-shelf WebGL programs for either).

Compatibility

Existing graph files load unchanged — version-less 1.14.x JSON included. No saved-file format break.

Tests

774+ tests green across renderer, layouts, interactions, export, heatmap, and persistence; perf gates passing.

Mnikley added 30 commits June 10, 2026 14:04
Phase 0+1 of the full G6→Sigma cutover per MIGRATION.md:

- vendor sigma@3 + graphology + bubblesets-js as two esbuild bundles
  (graphology node-safe for vitest, sigma browser-only, 267 kB combined)
- add deterministic 6000n/9000e perf harness (npm run perf) encoding the
  MIGRATION.md acceptance gates; all five gates pass (load 729 ms,
  first-interaction stall 65 ms, wheel-zoom 60 FPS, 500-node select 43 ms)
- new graph_model.js: graphology population + pure node/edge reducers
- new sigma_adapter.js: transitional G6-shaped facade (sole sigma importer)
- core.js: replace G6 instance/event choreography with sequential sigma
  lifecycle; delete G6#6373 zoom workaround and dead event locks
- filter.js: port visibility to graphology hidden-attr diffing
- tests: 645 passing (29 new for model/reducers/filter; 9 G6-source-pinning
  tests removed); review fixes from code-reviewer + js-reviewer passes

Interactions, shapes/labels parity, bubble sets and layout parity follow in
phases 2-5; g6.min.js removal in phase 6.
Phase 2 of the G6→Sigma cutover per MIGRATION.md:

- node shapes: circle/square native; bordered circles/rects and
  diamond/hexagon/triangle/star via crisp SVG-texture programs
  (@sigma/node-image, new shape_textures.js); paint values validated
  against an SVG-safe allowlist before embedding, texture cache capped
- edges: straight/curved x arrow/sourceArrow/doubleArrow program matrix;
  polyline degrades to curve and lineDash is not rendered (documented in
  API.md; values still round-trip)
- labels: custom node/edge drawers (label_renderers.js) honoring per-element
  size/color/background/placement/offset/auto-rotate; CFG.HIDE_LABELS synced
  live with explicit labels kept visible (G6 semantics)
- states: halo treatment per old G6 spec, colors centralized in
  DEFAULTS.STATE (config.js); selected > highlight > dim precedence
- y-axis: app model stays y-down, flipped exactly once at the graphology
  boundary so legacy saved layouts keep their orientation (G6 size diameter
  → sigma radius likewise mapped)
- tests: 700 passing (Excel style round-trip, shape textures incl. injection
  guards, label drawers incl. vertical-edge rotation boundary)

All five perf gates still pass (load 831 ms, stall 65 ms, wheel 60 FPS,
500-node select 62 ms).
Phase 3 of the G6→Sigma cutover per MIGRATION.md — replaces the G6
behaviors/plugins with explicit interaction wiring (new interactions.js,
node-safe lasso_geometry.js):

- node drag: downNode + captor mousemove → viewportToGraph, camera pinned
  via custom bbox; positions persisted on mouseup (serialized so overlapping
  drag-ends cannot race), no y double-flip (verified in-browser)
- selection: click / shift-toggle / canvas-deselect routed through
  GraphSelectionManager so undo/redo, data table and buttons stay in sync;
  drag-ending clicks suppressed
- hover-activate: 1-degree neighborhood via a dedicated hoverIds Set so
  hover can never corrupt selection; CFG.DISABLE_HOVER_EFFECT checked live
- lasso: freehand canvas overlay + even-odd point-in-polygon over visible
  node viewport coords; shift adds; Esc/pointercancel clean up
- tooltip: DOM tooltip fed from cache.toolTips, now sanitized with DOMPurify
  at the display boundary (labels/descriptions from loaded files are
  untrusted; XSS payload verified inert in-browser); expand/close buttons
  moved from inline onclick to a delegated listener
- async sigma event handlers wrapped so rejections surface via ui.error
  instead of vanishing unhandled
- deleted: adapter behavior/event/plugin stubs, core.js BEHAVIOURS stubs,
  all APPLY_BUBBLE_SET_HOTFIX paths incl. the CFG flag

Tests 700 → 724 (lasso geometry incl. boundary/self-intersection pinning,
hover layer); all five perf gates pass (load 862 ms, stall 66 ms, wheel
60 FPS, select 40 ms).
Phase 4 of the G6→Sigma cutover per MIGRATION.md — bubble sets, PNG
export and minimap move onto owned canvas layers:

- bubble sets: one canvas under sigma's edge/node layers (new
  bubble_layer.js + node-safe bubble_geometry.js); bubblesets-js outlines
  computed from viewport rects, cached in graph space and reprojected on
  pan (zoom drift >0.3 log2 refits after a 150 ms settle); membership/
  style keys stored at update time so the per-frame cost is one O(n)
  integer-hash position checksum; GraphBubbleSetManager call-surface
  unchanged (filter sync, manual groups, style UI, group labels all
  verified in-browser); the G6 #7195 path-churn bug class is gone
- avoid-members: threshold raised 300→1000 backed by measurements
  (69 ms at 1000 avoid rects, 12 s cliff at 5700 — documented in config)
- PNG export: adapter.toDataURL composites the bubble layer under the
  @sigma/export-image render (viewport-aligned at any DPR); bitmap
  closed and errors rethrown descriptively
- minimap: owned thumbnail (minimap.js — dots, viewport rect, click/drag
  pan, bottom-right as shipped), replaces the G6 plugin and .g6-minimap CSS
- review fixes: order-sensitive integer position hash (float-accumulation
  collisions at large coords), content-keyed avoid members, save/restore
  bracketing per group draw, defensive canvas removal on destroy,
  NUL-joined id keys, JSON-stringified style keys

Tests 724 → 748; all five perf gates pass (load 765 ms, stall 62 ms,
wheel 59.8 FPS, select 43 ms); bubble probe: 300-member group recompute
47 ms cold / 14 ms membership, 59.8 FPS wheel with overlays active.
- extract layout execution from sigma_adapter into node-safe src/graph/layout_algorithms.js (circular/grid/forceAtlas2 moved verbatim)
- add radial/concentric/mds layouts via headless @antv/layout v2 (execute + forEachNode readback, finite-coordinate guard, instance destroy)
- vendor @antv/layout into graphology.bundle.mjs (108 -> 207 kB)
- add 27 requirement-driven tests covering all 6 layout types, edge cases (empty/single-node/disconnected) and fallback behavior
- delete vendored g6.min.js (1.1 MB) and its script tag; distribution shrinks ~0.9 MB net
- fix guided tour minimap selector (.g6-minimap -> .gll-minimap)
- trim dead LAYOUT_INTERNALS options (force/circular/grid take none) and drop commented-out fruchterman/antv-dagre entries
- rewrite stale G6 comments (gll.js, api_client.js, io.js, server/static.js, vendor_libs.js)
- README: drop the fixed antvis/G6#7195 known issue
- THIRD_PARTY_NOTICES: add bundled runtime deps (sigma+plugins, graphology+utils, @antv/layout, bubblesets-js, marked, dompurify)
- CHANGELOG 1.15.0 with measured perf table; MIGRATION.md marked complete
- bump version to 1.15.0 (config injected)

All 775 tests green; perf gates pass (load 785 ms, zoom 59.7 fps, stall 64 ms, select 46 ms).
- updateManualGroupStatus mixed && / || without parentheses, so filtered-out or stale manual members could still count toward the status badge
- remove the dead INVISIBLE_DUMMY_NODE config and its guard conditions in bubble_sets.js — nothing adds the dummy node since the sigma cutover, and the nodeRef/current-graph checks subsume every guard
- document the bubble label placement/close-to-path/auto-rotate no-op knobs in the 1.15.0 changelog entry
- dragging a selected node now moves the whole selection (per-frame delta applied to every selected id; cache.selectedNodes normalized Set/Array)
- lasso overlay canvas z-index 1000 -> 900 so the toolbar (z 1000) stays clickable and the lasso icon can disable lasso mode
- dragged node's label pinned via forceLabel for the drag duration so sigma's label grid can't drop or reassign it mid-drag
…tainer resize

- force TEXTURE_MIN_FILTER=LINEAR after every atlas bindTextures: ANGLE/radeonsi intermittently fails generateMipmap on NPOT atlas re-uploads, leaving textures mipmap-incomplete and sampled opaque black (the hover black boxes after restyling)
- atlas regeneration debounce 500 -> 50 ms so restyled nodes aren't invisible (transparent base color) while their new textures load
- hover drawer gated during node drag so passed-over nodes can't pop their hover label (square program wraps its instance drawHover; shape/circle go through defaultDrawNodeHover)
- debounced ResizeObserver on the graph container replaces the five race-prone setTimeout resize workarounds in ui.js; sigma v3 only auto-resizes on window resize, so panel toggles left a stale viewport until the next zoom/pan
…, empty-query AST

- hotkey/global event registration guarded by module-scope flags: Cache.reset() recreated EVENT_LOCKS on every file load, stacking anonymous document listeners (even counts made the lasso hotkey a no-op)
- createGraphInstance re-applies per-layout node/edge styles when the layout carries any (lost in fd7e342: G6's createSimplifiedDataForGraphObject merged them, buildGraphologyGraph doesn't) — fixes data-editor apply and JSON load rendering unstyled until a manual workspace switch; skipped when no custom styles exist (the reset pass costs ~700 ms at 15k elements)
- updateMetricUI also proceeds when the selected metric has no cached values: under sigma fresh elements report 'visible', the visibility diff is empty, and visibleElementsChanged never fired on load, so metrics silently never computed
- empty query AST now matches everything (no constraints) instead of nothing
- new tests: query AST empty/malformed semantics, metrics gate (jsdom, real centrality calculations)
…e-to-path/auto-rotate

- bubblesets-js influence radii were fixed pixel constants: zoomed out, non-member avoid discs cancelled the members' energy field and the outline collapsed to an empty path, vanishing the group. Field constants now scale with sigma's node-size factor (1/sqrt(camera.ratio)), with a one-shot no-avoid retry as safety net and the ratio bucket in the outline cache key so empty results aren't sticky
- settle-recompute machinery removed (dead once recompute keys on ratio buckets)
- labelPlacement (top/bottom/left/right/center), labelCloseToPath and labelAutoRotate style knobs now work: outlineLabelAnchor returns anchor + tangent angle + outward normal per placement; detached labels stand off along the normal; auto-rotate aligns text to the outline tangent. Default placement 'bottom' restores the old G6 look (labels move from the hardcoded top)
- badge data (badge/badges/badgePalette/badgeFontSize) flowed through the style pipeline but was never mapped to sigma attributes nor drawn — badges now render as G6-parity colored pills with white text on the node perimeter (all 12 placements)
- badge_clear emits explicit badge:false + empty arrays so mergeNodeAttributes can't leave stale badges
- texture->native program transitions clear stale image/fillColor attrs (wrong hover state-texture colors) and restore the fill color
- v1 limitation: badges draw in the label pass, so they follow label visibility/thinning
- export FA2Layout from the node-safe graphology vendor bundle (worker
  module only touches Worker/window.URL at instantiation)
- executeLayout force branch: where Worker exists, run the FA2 worker
  supervisor for a bounded time budget (min(5000, 500 + 2*order) ms) so
  the layout animates live without blocking the main thread; stop()+kill()
  in finally; per-graph re-entrancy guard
- node (vitest) keeps the deterministic synchronous forceAtlas2.assign
  path unchanged; test seam via optional ForceSupervisor override
- 5 new tests (818 total green); perf gates unaffected (benchmark fixture
  carries persisted positions, force never runs during timed load)
- add graphology-metrics@2.4.0 to the node-safe graphology vendor bundle
- build a temp undirected multigraph from the visible subgraph and run
  library betweenness/closeness/eigenvector/pagerank/degree on it; result
  contract (scores/nodeValues/graphLevelMetrics/popups) preserved
- value fixes vs hand-rolled: eigenvector now truly converges on bipartite
  graphs ((I+A)x iteration), Graph Density genuinely computed, n<=2
  betweenness no longer NaN
- new graph-level metrics: density (degree panel), diameter (closeness
  panel, '∞ (disconnected)' when infinite)
- eigenvector non-convergence caught and surfaced via ui.error; loading
  overlay protected by try/finally in updateMetricUI/ensureMetricValues
- review fixes: uniform n<=1 early exit in all five calculators, max||1
  guard against '(NaN %)' score texts, self-loop degree semantics pinned
- metrics.js 963→~800 LOC; tests/metrics.test.js 30→58 tests (846 total
  green); perf gates green
- add graphology-communities-louvain@2.0.2 to the node-safe vendor
  bundle, plus modularity from graphology-metrics
- new node-safe src/graph/communities.js: detectCommunities builds the
  visible subgraph, runs seeded louvain.detailed (mulberry32, fixed
  seed → reproducible), buckets communities largest-first and maps the
  top 4 onto the bubble group keys; null guard when no visible edges
- extract buildVisibleGraph from metrics.js into shared node-safe
  src/graph/visible_graph.js (pure move, reused by metrics + communities)
- GraphBubbleSetManager.detectCommunities replaces the current layout's
  four ${group}ManualMembers sets and reruns the existing manual-group
  choreography; result toast reports community count + modularity
- 🧩 button in the selected-nodes row (matches neighboring inline-onclick
  idiom)
- 8 new node-safe tests in tests/communities.test.js (854 total green);
  perf gates green
- add graphology-layout-noverlap@0.4.2 to the node-safe vendor bundle
- executeLayout strips spec.noverlap and runs exported applyNoverlap
  (margin 5, max 100 iterations, respects node size attr) after any base
  layout type; flag never leaks into @AntV options or LAYOUT_INTERNALS
- GraphLayoutManager.removeNodeOverlaps() applies the pass to the live
  graphology instance for all nodes in the current workspace, persists
  positions via the existing getNodeData sync, triggers redraw
- 'Remove overlaps' button in the workspace action row
- 8 new tests across layout-algorithms/layout-selected-nodes (864 total
  green); perf gates green
- add @sigma/node-border@3.0.0 to the browser-only sigma vendor bundle
- bordered circles render through a new borderCircle program (outer ring
  reads borderColor + relative borderRatio = min(lineWidth/radius, 1),
  inner fill ring consumes the rest) — crisp at all zooms, no texture
  atlas churn; non-circle shapes and bordered rects stay on the SVG
  texture program
- nodeProgramAttributes is now a three-branch dispatch (texture /
  borderCircle / native) where every branch writes the full merge-hygiene
  attr set; native branch unconditionally clears borderColor/borderSize
  so lineWidth-only deltas can't leak a stale stroke into halo textures
  (review fix)
- selection halos deliberately stay on the texture path for all shapes:
  a GLSL halo would duplicate the halo implementation for circles only
  and expand the state/program matrix; dim on bordered circles now takes
  the cheap native branch
- no hover guard needed (program has no instance drawHover → falls
  through to guarded drawDiscNodeHover); PNG export inherits the program
  registry via sigma.getSettings()
- 13 new attribute-level tests incl. 6-case transition merge matrix
  (877 total green); perf gates green
- new node-safe src/graph/webgl_support.js: webgl2 probe + persistent
  in-container error message; core.js imports SigmaAdapter lazily so the
  sigma bundle's module-scope WebGL probes can't kill app boot, probes
  before construction, and wraps it defensively — on failure cache.graph
  stays null (same state as 'no data loaded') and app chrome survives
- createGraphInstance memoizes its in-flight promise (re-entrancy across
  the lazy-import await could construct two adapters, leaking a context)
- silent null-graph no-op guards on the UI-reachable filter/selection
  entry points reachable when init failed
- resize blank-graph fix: sigma.resize() clears the WebGL buffers but
  never schedules a render — the adapter facade resize() now calls
  sigma.scheduleRender() (render() resizes first); evidence + manual
  repro kept as scripts/resize_redraw_check.js
- 24 new tests across webgl-support/graph-core-init/selection-null-graph/
  filter-visibility (898 total green); perf gates green
- new src/graph/edge_programs.js: parametric SDF marker-head program
  (arrow/rect/diamond/circle/tee inhibition marker selected per edge end
  via float enum, per-end sizes in graph px, zero-area collapse for
  disabled ends), EdgeRectangleProgram-clone halo underdraw reading
  haloWidth/haloColor, curve halo via re-processed curve program
- curved edges fully supported: marker heads orient along the
  quadratic-bezier tangent (edge-curve's baked arrowHead bypassed)
- registry collapses to 4 keys: line/curve fast paths unchanged,
  styledLine/styledCurve compounds (halo → body → source head → target
  head); marker/halo are per-edge attributes so the vocabulary never
  grows the registry
- start/end arrow size and type UI knobs now take effect; new marker
  vocabulary arrow/rect/diamond/circle/tee with legacy G6 names aliased
  at render time (files round-trip unchanged); Excel validators accept
  both; halo enable/color/width implemented; API.md updated
- selection composes with user halos: post-reducer size widens line and
  halo together
- 17 new attr-mapping/hygiene tests (915 total green); perf gates green
  (fixture edges route to the unchanged plain-line path); visual pixel
  probe script scripts/edge_markers_check.js 13/13
- new badgeScaleWithNode style prop (default off, preserves current
  behavior): zoom-independent factor nodeDiameter/DEFAULTS.NODE.SIZE is
  baked into badgeScaleFactor by graph_model so the renderer stays
  config-free; drawNodeBadges multiplies it into the badge font size
- Badge Size slider (4-32, exposes the previously unreachable
  badgeFontSize) and Badge Scale With Node toggle in the node style card
- badgeScaleWithNode normalized in style.js so it round-trips through
  Excel export like the other badge props
- 6 new tests across graph-model/label-renderers (921 total green);
  perf gates green
- CSS custom-property tokens at the top of style.css (:root light set +
  [data-theme="dark"] overrides, color-scheme flip for native widgets);
  ~230 hardcoded colors converted across panels, sidebars, inputs,
  popups, tables, tooltips, overlays; brand purple/red kept as-is
- new src/utilities/theme.js: resolveInitialTheme (stored pref wins,
  prefers-color-scheme as unstored default that keeps following the OS),
  applyTheme via data-theme on <html> persisting to localStorage gllTheme
- 🌙/☀️ toggle button; renderer default label color flips live via
  setSetting; stage/minimap/bubble canvases themed
- label readability: io.js bakes labelFill #000000 into every labelled
  element, so exactly that baked default is treated as 'no explicit
  choice' and follows the theme; any other user-set label color is
  honored (deliberate trade-off: an explicit pure-#000000 label lights
  up in dark mode)
- review fixes: hover-pill pins only the FALLBACK label color to #000
  via the {attribute, color} form so explicit label colors survive
  hover; adapter construction resolves the theme via idempotent
  initTheme (no race with the DOMContentLoaded boot); toggleDarkMode
  de-async'd
- 16 new tests (936 total green); perf gates green
- suppress sigma's hover pill layer while a drag is in flight: the
  dragged node is permanently hovered and its white disc + label pill
  (drawn on the hovers canvas, above the labels canvas) invisibly
  blanked every label it passed over on light backgrounds
- pin all on-screen labels (forceLabel) for the duration of a node
  drag so the rebuilt label grid cannot evict neighbours' labels from
  cells the dragged node passes through; pins release on mouseup
- tune labelGridCellSize to 50 (sigma default 100): ~4x more labels
  shown and smaller label-competition zones between nearby nodes
- add drag interaction tests (label pinning, group drag, position
  persistence) with a stub sigma + real graphology
Replace the full-screen dimmed centered overlay with a compact status
card docked top-center.

- Drop the rgba(0,0,0,0.2) dimming; the full-screen layer stays at
  z-index 10001 to keep loading blocking, but is now transparent.
- Top-center placement avoids the bottom-right minimap and the
  top-right selected-elements panel.
- Inline 20px spinner beside a header + muted detail line; messages
  wrap (including long URLs) instead of truncating, so no info is lost.
- Zero the .h7 margin on the detail text so it left-aligns under the
  header; add role=status/aria-live for screen-reader progress.
Extend edge end-markers with per-end fill color, border color, and border
size, threaded through the full styling pipeline:

- config: arrow color/border-color (null = inherit edge color / no border)
  and border-size (0 = auto, scales with the marker) defaults + palettes
- style/graph_model: map the new style keys to start/end marker attrs,
  emitting the full set so a disable overwrites stale values
- edge_programs: add a_borderColor + a_borderSize to the marker-head WebGL
  program; draw an SDF border band (explicit px, else ~20% proportional)
- ui_style_div: color pickers + border-size sliders for start/end arrows
- io: Excel columns for round-trip of the new arrow style props
- tests: marker color/size mapping + extended round-trip coverage
The node/edge-count cutoff that auto-disabled hover highlighting was a G6-era
guard; sigma renders hover fast enough that it is no longer needed. Hover is
now controlled solely by the manual toggle button.

- config: drop MAX_NODES/EDGES_BEFORE_DISABLING_HOVER_EFFECT (keep the flag)
- io: stop deriving DISABLE_HOVER_EFFECT from graph size in preProcessData
- tests: drop the assertion on the removed constant
The new arrow color/border controls (palette swatches + picker + hex input)
wrapped to two lines at 360px. 420px keeps every row on a single line. The
panel is a flexbox sibling, so the graph overlays (minimap, selection frame)
and the tour popup follow the new width automatically.
Network metric calculations (betweenness, eigenvector, etc.) ran on every
filter change, even when the metrics panel was closed and nobody was
viewing them. Gate computation on panel visibility instead:

- updateMetricUI() early-returns while the panel is collapsed
- toggleUI() triggers a compute when the panel opens
- invalidateMetricValues() blanks stale per-node tooltip metric text on
  every visibility change, skipping the tooltip scan when no metric text
  is active (metricTooltipsActive flag)
- fix collapsed init (false -> true) to match the default-closed CSS

Tooltips now show either the current correct value (panel open) or
nothing (panel closed) -- never a stale value. Metric-driven styling is
unchanged (already a snapshot taken at apply-time).
bubblesets-js sample(step) treats step as an array index stride, and
PointPath.get() returns undefined for a fractional index. The outline
sampler passed OUTLINE_SAMPLE_PX * scale directly, so any zoom whose
1/sqrt(ratio) field factor made 8*scale fractional (e.g. 2.83) poisoned
the sampled path with undefined points and crashed bSplines()/simplify().

The throw escaped the bubble layer's rAF paint, freezing the bubble
canvas: zoom appeared to ignore the camera and snapped back only at the
original zoom, re-detecting communities or modifying a zoomed graph drew
nothing. Ratio 1 (integer stride 8) is why it always worked first.

- Round the stride to an integer >= 1 (Math.round).
- Guard the bubblesets-js call so a degenerate outline returns [] per the
  function's documented contract instead of throwing into the renderer.
- Add a faithful BubbleSetLayer harness reproducing the zoom/clear/re-add
  paths and a fractional-step geometry regression.
…tays selectable

A fixed absolute slider step (1e-6) floored the reachable max below a
column's true max, so the node holding the maximum value (e.g. ADCY1 in
the AOP208 column) was excluded from BETWEEN filters and bubble-set
assignment — selectable only via the query editor.

- Float columns now use step="any" (continuous): the exact max and any
  high-decimal value are reachable via both slider and number box, at any
  column magnitude.
- Integer columns keep step=1 (discrete counts).
- Drop the now-unused FILTER_STEP_SIZE_FLOAT constant.
- Tests updated; add constructor-wiring tests asserting integer->1 and
  float->"any" with the full-precision AOP208 max preserved.
The 🧩 button now opens a configurator instead of running immediately:
pick a numeric edge property to weight by (or topology-only) and the
Louvain resolution, then detect. Weighting lets community structure that
is invisible to pure topology (e.g. STRING confidence scores) drive the
grouping; resolution tunes community granularity.

- communities.js: detectCommunities(cache, groups, {weightProperty,
  resolution}) -> getEdgeWeight + resolution. Fixed seed kept, so results
  stay reproducible.
- visible_graph.js: optional {weightProperty} attaches a numeric weight
  per edge from edge.featureValues (fallback 1 for missing/non-finite).
  The metrics callers pass no options and are unchanged.
- bubble_sets.js: enumerate numeric edge props from filterDefaults, build
  the popover, default to Combined Score when present (else topology),
  report weight + resolution + modularity.
- Tests: weighting recovers structure invisible to topology, equal
  weights match unweighted, missing-value fallback, resolution raises
  community count, omitted resolution defaults to 1.
Mnikley added 28 commits June 12, 2026 16:08
Optional per-edge flow overlay (source→target movement), styled like the
existing edge halo and persisted with the graph data:

- edge_flow_programs.js (new): EdgeFlowProgram — EdgeRectangleProgram clone
  drawing marching dashes or travelling pulse dots over the edge body in
  screen-px units (zoom-stable spacing); flowMode 0 collapses to zero
  fragments so non-flowing edges cost nothing
- flow_animator.js (new): FlowAnimator — rAF loop advancing the shared
  flowClock and issuing redraw-only refreshes, active only while ≥1 visible
  edge flows and the tab is visible; graphology-event dirty flag, full
  teardown in destroy()
- graph_model.js: FLOW_MODES, edgeFlowMode, lightenHexColor; per-edge
  flowMode/flowSpeed/flowColor attrs with the full-set invariant (off →
  0/0/null so stale flow never survives a disable); flow routes edges to
  the styled compound programs
- sigma_adapter.js: flow sub-program registered in styledLine after the
  body, before marker heads; animator lifecycle next to the bubble layer
- ui_style_div.js: Flow on/off, type (dash|pulse), speed, color rows
  mirroring the halo block
- style.js: flow keys added to the persisted-style whitelist so loaded
  files keep their flow styling
- config.js: DEFAULTS.EDGE.FLOW + EDGE_FLOW_TYPES vocabulary

Curved edges route to styledCurve but draw no overlay yet (curve-shader
fork lands separately). Tests: +11 (1085 total expected).
Fork the bundled @sigma/edge-curve shaders so curved edges animate the
same dash/pulse flow as straight ones:

- edge_flow_glsl.js (new, node-safe): pure string patchers over the
  bundled GLSL — getDistanceVector/distToQuadraticBezierCurve rewritten
  to expose the closest-point bezier parameter t (out param), flow
  varyings + u_time injected, body output swapped for the dash/pulse
  mask, vertex gains a_flow/a_flowSpeed + flow-off collapse + an
  8-segment projected arc-length varying (CSS px) that t scales by.
  Every patch anchors on exact bundle substrings and throws on drift;
  explicit stage assertions reject wrong-stage sources.
- edge_flow_programs.js: createCurveFlowProgram — patches the parent's
  shaders eagerly at class-creation time, appends the two float attrs
  after the computed parent stride, rides the parent's a_color slot for
  the flow color (CPU-side substitution, like the curve halo).
- sigma_adapter.js: registered in the styledCurve compound between body
  and marker heads inside try/catch — anchor drift after a sigma upgrade
  degrades to non-animated curves with a console.warn, straight-edge
  flow unaffected.

Pattern constants are duplicated across the straight program and the
patchers (GLSL stages can't share consts); a co-change test parses both
sources and fails on numeric drift. 19 new patcher tests run against
byte-verbatim bundle fixture snapshots (1103 total).
Atmospheric 2d canvas layer below the edge layer (deepest of the
beforeLayer: "edges" stack — earliest-created sits deepest, verified
against sigma's createLayer), with two independent off-by-default passes
toggled from the workspace toolbar (🌡️ / 🔆, hover-toggle conventions):

- HEATMAP: per-node gaussian alpha splats stamped into an offscreen
  canvas in graph-space px, colored through a ramp LUT, cached under a
  viewport-INDEPENDENT key (positions checksum + serialized ramp stops +
  bandwidth/resolution config) — pan/zoom never re-splats; each frame is
  one drawImage under the camera affine. Light/dark ramps in config;
  theme flips recolor via the key.
- GLOW: accent radial gradients behind selected visible nodes in
  viewport space; selected node positions ride the redraw signature so
  dragging a selected node moves its glow.

heatmap_geometry.js (node-safe) carries the pure math — bbox, splat
transform with degenerate-bbox guards, auto bandwidth (diagonal/√n,
clamped), validated hex parsing, ramp LUT build/apply. destroy()
releases the offscreen's backing store eagerly (workspace switches
replace the adapter). 31 new tests (1134 total).
…rry bubbles, blank 8x exports) by re-fitting and re-painting instead of bitmap-stretching the on-screen canvas and by respecting the GPU's WebGL framebuffer instead of 2D-canvas constants
# Conflicts:
#	src/graph/sigma_adapter.js
…ap in styling panel

- remove the selection-glow pass (redundant with the reducers' accent
  halo ring): GLOW config, layer pass, and toolbar button are gone
- add runtime heatmap settings on the layer (intensity, opacity, radius
  scale, gamma/contrast, density threshold, ramp preset, dim-graph) with
  NaN-safe validation; field-shaping knobs ride the offscreen cache key
- wire the previously unused gamma exponent into the ramp (default 0.7
  boosts low-density haze) and add a density threshold that clears
  sub-floor pixels and renormalizes survivors, so a floor just above the
  splat intensity shows only overlapping nodes (clusters), not singles
- replace RAMP_LIGHT/RAMP_DARK with five theme-aware ramp presets
  (default, viridis, magma, accent, grayscale); resolved stops ride the
  cache key so ramp switches and theme flips recolor correctly
- move all controls from the toolbar (🌡️/🎛️ buttons removed) into a
  collapsible Density Heatmap card in the styling panel, next to the
  workspace-level Bubble Sets card; sliders update live while dragging
- add a dim-graph companion: stateless nodes/edges render dimmed while
  the heatmap is on so the field reads through; selection/hover/explicit
  states keep their normal treatment
- close anchored popovers on graph teardown (closeAnchoredPopovers in
  destroyGraphAndRollBackUI) so outside-click document listeners and the
  destroyed adapter are never referenced by a stale popover
…y controls

- two new flowType patterns in both WebGL programs (straight + curved GLSL fork): comet (sharp head, quadratic fading tail) and chevron (cross-axis-swept dash bands pointing along the travel direction)
- flowOpacity folds into the flow color's alpha CPU-side via new graph_model.applyHexOpacity — zero shader cost
- flowDensity rides a new per-edge a_flowDensity attribute multiplying the per-mode pattern period (sparser = subtler); dot size and AA stay fixed; off-state emits neutral 1 since the shaders divide by it
- styling panel: Flow Opacity + Flow Density slider rows, extended Flow Type vocabulary and tooltips
- GLSL anchor tests extended to the new constants/varyings; pattern-constant co-change contract now covers chevron/comet
The reset-style button's tooltip promised a position reset that could
never fire: after the first render every node's position is persisted,
so changeLayout always took the animated path and pinned reset nodes to
their current on-screen coords while excluding them from the tween. No
original position is stored to restore to either.

Remove the dead RESET_SELECTION_BUTTON_RESETS_POSITIONS flag, the
position-deletion block in resetStyleForSelectedElements, and the
misleading conditional tooltip. The button now only clears per-view
custom styles, matching its static title. Per-workspace position
save/restore is unaffected.
- Rename AGENTS.md to ARCHITECTURE.md and rewrite as an OSS-facing
  codebase map: Sigma v3 + graphology (was G6), corrected file inventory,
  SVG export, synced npm scripts; drop agent-session framing.
- CHANGELOG: add Unreleased section (density heatmap, animated edge flow
  incl. comet/chevron, pie nodes, Dagre, SVG export, bubble-label knobs
  now honored, reset-style fix); remove the now-false no-op claim.
- README: surface edge flow / heatmap / pie nodes, add SVG to exports,
  link ARCHITECTURE.md.
- API.md: document edge arrow color/border and flow style fields.
- SERVICE.md: genericize stale v1.13.1 startup banner.
- Add the six arrow color/border columns (Start/End Arrow Color,
  Arrow Border Color, Arrow Border Size) the Excel importer parses but
  the readme tab never documented.
- Fix wrong default values against config.js: Label Font Size, Label
  Placement, Label Auto Rotate, Label Offset X, Label Color, Label
  Background Color, and both Arrow Type rows (default + enum).
- Add a guard test asserting the readme tab documents exactly
  EXCEL_NODE_PROPERTIES / EXCEL_EDGE_PROPERTIES (fails on drift in
  either direction).
- Export now writes a top-level `version` stamp (was absent); loading a
  file saved by a newer app surfaces a soft notice via ui.info. Older and
  version-less files load unchanged (backward compatible).
- The density-heatmap overlay (enabled + appearance settings), which lives
  on the adapter and not in cache.data, is now folded into the JSON export
  and restored after the graph renders on import.
- Add StaticUtilities.isVersionNewer (dotted-numeric, rejects malformed /
  empty / non-numeric segments) and 29 tests covering the version stamp,
  heatmap round-trip, backward compatibility, and version comparison.
- buildExportPayload is pure (does not mutate cache.data); version key is
  written last so the current app stamp always wins.
Creating a workspace with a super-linear layout (dagre/mds) on a large
graph froze the page and let the user tinker mid-load. Three causes:

- dagre/mds/radial/concentric ran synchronously on the main thread. Run
  them in a Blob worker (vendor_entry_layout_worker + embedded
  layout_worker_source), mirroring the FA2 force worker; the synchronous
  path stays as the node fallback and test seam. Main thread no longer
  freezes, so the overlay can paint and block.
- the loading overlay was dropped mid-chain by a nested render's
  #postRefresh hideLoading(), exposing bubble-sync/hide-disconnected/
  metrics and the layout worker. Add a counted hold/release on the
  overlay; addLayout/changeLayout pin it from showLoading and release it
  at the existing pre-tween point. hideLoading() is now idempotent.
- keydown hotkeys bypassed the overlay (pointer-only barrier). Gate them
  on the new ui.isBusy(), which stays true while held.

Also warn before kicking off an EXPENSIVE_LAYOUTS template above
LAYOUT_NODE_WARNING_THRESHOLD (2000) nodes; declining cancels cleanly.

Adds 12 tests (worker branch, busy-gate, hold/release, size guard).
8x exports silently failed on real GPUs: a ~14k-px WebGL framebuffer plus
the same-size 2D composite canvas exhaust memory for large graphs, yielding
blank or partial renders (edges survive, nodes drop) that evade the
blank-canvas retry net. On HiDPI displays 8x clamped to ~5x anyway.

- EXPORT_SCALES -> [1, 2, 4]; persisted 8 falls back to 1x via the
  existing includes() guard
- add WEBGL_SAFE_SIDE_FRACTION (0.9) applied to the probed WebGL max side
  in toDataURL so even 4x never renders right at MAX_TEXTURE_SIZE, the
  regime where blank/partial/scanline corruption occurs
- sync doc comments and the resolution-picker ladder
Fit each group's outline once at the ratio-1 reference scale and cache it
in graph space, reprojecting per frame. Member enclosure is now zoom-
invariant (nodes no longer flip in/out of a group as you zoom) and the
hull always hugs the nodes, since outline and nodes ride the same camera
transform. The camera is no longer in the outline cache key, so zooming
never re-fits — only reprojects — removing the zoom lag and the mid-zoom
phantom/misplaced artifacts.

Repair self-intersecting bubblesets outlines via polygon self-union
(new polygon-clipping dependency, vendored into graphology.bundle.mjs)
instead of the earlier convex-hull fallback. This eliminates phantom
chords/lobes and is essential now that a fixed fit no longer self-heals
on zoom; a keep-last-good guard prevents a bad fit replacing a good one.

Paint the bubble body above the node layer (afterLayer: "nodes") so
groups stay visible at deep zoom. Removes the settle-debounce, hide-on-
zoom, and ratio-bucket refit machinery, which are no longer needed.
Loading a JSON workspace restores each group's ManualMembers and renders
the bubbles, but the selection-panel deselect toggles only build via
bs.updateManualGroupStatus(), which the load path never called — so
auto/louvain groups loaded un-deselectable.

Mirror the post-layout sync after the load render. Add a regression test
covering panel refresh on successful load and no refresh on empty data.
Bubble-group membership had three independent sources (filter/prop,
manual selection, Louvain auto) whose UI surfaces drifted apart.

- Add getEffectiveGroupMembers() as the single source of truth for a
  group's visible members (prop union manual, renderer's visibility
  filter). The status badge, styling-card enable state, and rendered
  outline all derive from it, so the displayed count can no longer
  diverge from what is highlighted.
- Prompt for confirmation before Auto (Louvain) overwrites existing
  manual groups; cancel leaves the graph untouched.
- Clearing a group (per-group badge and Clear all) now clears both
  manual members and filter/prop assignments, rebuilding the filter UI
  so the panel quadrants reflect the cleared state.
- Fix the 'Add to group' quadrant button desync: resync it in
  updateSelectedNodesAndEdges (where cache.selectedNodes is
  authoritatively refreshed) instead of updateSelectedState, which ran
  on a stale selection.

Adds bubble-group-seams (12) and selection-group-button-sync (2) tests.
Layouts previously only ran at workspace creation; within a workspace only
partial 'arrange selection' was available. Add a whole-graph re-layout:

- New Popup.layoutSelectDialog: algorithm picker pre-selected to the
  workspace's origin type, with a static overwrite warning and a dynamic
  perf warning for super-linear layouts (dagre/mds) on large graphs.
- New GraphLayoutManager.relayoutWorkspace: recomputes all positions via
  the same setLayout/layout/persist/animated-transition pipeline as
  template creation, but stays on the current workspace and touches only
  positions (styles, filters, query, bubble membership untouched).
- Stamp layoutType onto template- and clone-created workspaces so the
  picker defaults to the origin type and round-trips through JSON.
- Add a relayout icon button to the workspace toolbar.
- Tests: relayout-workspace (10) + popup-layout-select (11).
…teps

- Header step: split into header actions (PNG/SVG export, dark mode) and
  toolbar pills; drop stale PNG-only and per-panel parentheticals
- Workspaces: list all five icon buttons incl. new re-layout (🔄)
- Filtering: rename Edit Mode → Details pill, show min/max inputs in mock-up
- Add pie-chart nodes, edge flow, density heatmap to styling step
- Selection panel: rewrite to match redesigned HUD (Tools/✕/lasso/style/
  undo-redo, Add-to-group, Louvain 🧩 Auto + Clear all); fix non-red ✕
- Move Selection, Advanced Tools, and Styling steps up after the Canvas step
- Collapse the selection panel during the Canvas step, restore on exit
- Reorder closing keyboard-shortcut list to match the new tour flow
Wire two graphology-native layouts into the LAYOUT_INTERNALS vocabulary so
they surface automatically in the create/re-layout dropdowns:

- circlepack: d3-hierarchy circle packing, radius from each node's size attr
- random: uniform scatter centered on the origin, extent scaling with order

Both are O(n) geometric layouts (no expense guard) and reuse the existing
pure (graph, spec) seam. Covered by the parametrized it.each plus dedicated
non-overlap, size-scaling, and bounds tests.

Also fix the simple-template readme tab: Start/End Arrow Border Size default
FALSE -> 0 (number).
Reformat four outlier files (single quotes, expanded the minified excelData literal in io.js) to match the new .prettierrc. No behavioral change.
- Popup.confirm/prompt render messages via textContent, eliminating a file-borne XSS sink where layout/query names from loaded JSON reached innerHTML
- query.js escapes untrusted main-group/sub-group/property/category names in the query-highlight overlay (new StaticUtilities.escapeHtml)
- electron_app.js gates shell.openExternal behind an http(s)/mailto scheme allowlist
- add escapeHtml regression tests
- selection.js: updateSelectionCache referenced a bare `cache` instead of `this.cache`, throwing ReferenceError whenever invoked
- io.js, data_editor.js: replace unsafe obj.hasOwnProperty() with Object.prototype.hasOwnProperty.call(); declare ExcelJS global (vendored script tag)
- color_scale_picker.js: [...x] || [] fallback was dead and threw on undefined; use [...(x || [])]
- drop the non-functional "Q" query button (console.log stub) and dead getPos() logger
- remove completed-migration verification one-offs: phase4_browser_test.js, phase4_perf_probe.js, edge_markers_check.js (0 references, not wired into npm/CI)
- remove ASSISTANT_QUESTIONS.md (not loaded by the app; orphaned doc)
…hygiene

Tooling:
- ESLint 9 flat config + Prettier (.prettierrc.json/.prettierignore) with lint/format scripts; eslint-config-prettier to avoid conflicts; per-file /* global */ directives for worker/browser-eval/ExcelJS scopes
- @vitest/coverage-v8 + vitest.config.js; test:coverage script
- .github/workflows/ci.yml runs lint + tests on PRs/main (perf gates as non-blocking job)

Community & metadata:
- SECURITY.md (disclosure policy for the Electron app + ingest service) and CODE_OF_CONDUCT.md
- package.json: add license, repository, bugs fields

Dependencies & attribution:
- bump DOMPurify 3.4.1 -> 3.4.10 (re-vendored) to clear the advisory
- regenerate THIRD_PARTY_NOTICES from the production tree via scripts/gen_third_party_notices.mjs (55 packages, all permissive; drop non-redistributed dev tooling)
- gitignore coverage/
Fold the Unreleased section into a dated 1.15.0 entry and reorganize the Sigma-migration notes (layouts, overlays, file-format compatibility).
Cap the wheel-zoom FPS gate against the idle rAF ceiling (the treatment
drag-pan already received) so rAF-pinned gates can't fail in environments
that throttle below the raw limit. When the idle ceiling proves the runner
has no usable GPU (< 30 fps), report the GPU-bound load gate without
enforcing it. Strict gates are unchanged on real-GPU hardware.
The idle rAF ceiling is not a reliable no-GPU signal: a CI runner can
clock requestAnimationFrame at 60 Hz while painting through SwiftShader at
~4 fps, so the prior idle-ceiling check passed the environment as real-GPU
and the GPU-bound gates failed.

Read UNMASKED_RENDERER_WEBGL directly and treat a software rasterizer
(SwiftShader/llvmpipe/etc.) — or a sub-30 fps idle ceiling as a fallback —
as a non-representative environment. There, the render/frame-cadence gates
(load, first-interaction stall, wheel-zoom, drag-pan) are reported but not
enforced; only the pure-JS 500-node select gate stays enforced. Full strict
gates are unchanged on real-GPU hardware.
@Mnikley Mnikley merged commit dab8dcb into main Jun 16, 2026
2 checks passed
@Mnikley Mnikley deleted the feat/sigma-renderer branch June 16, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant