Cytoscape.js v4 (src)
First pass of the v4 performance redesign spec'd in
#3486: a separate
prototype core with a CPU-canonical columnar model (typed-array columns,
stable slots, per-column coalesced dirty spans) written through to
persistent GPU buffers, rendered by a WebGPU pipeline (SDF node
shapes, straight and curved edges — round 12a's bundled bezier +
self-loops and round 12b's unbundled-bezier/segments/taxi families —
reading endpoint positions and curve params on-GPU, GPU picking,
compute culling + indirect draws + LOD). Round 13 (2026-07-31) swept
the straightforward v3 style props into the prototype — ghosts,
overlay/underlay + core theming, the opacity split, border/outline
geometry, dashes and casing, arrow scalars and mid-arrows, gradients,
custom polygons, and the label prop families (fonts,
min-zoomed-font-size, the alignment grid, source/target labels) —
each with stored-truth readback and a golden and/or live v3
pixel-parity pin (details per prop below and in PLAN.md). Round 14
(2026-07-31) brought compound nodes: parent/child hierarchy with
auto-sized parents materialized into the columnar model,
parents-under-descendants draw order, ancestor-gated visibility and
rendered effective opacity, ported event bubbling, a parents sheet
group with structural query/case terms, and compound loop edges.
Rounds 15–18 (2026-08-01) closed the design queue: background
images (tiered texture arrays + mips, SVG zoom-promotion, the SDF
icon mode, multi-image parity), multiline labels + label bounding
boxes (the wrap family; labels join bb/fit by default),
the event vocabulary + extension contract (the curated set +
pointer events; registry-free layouts), and the GPU force layout
(CPU reference + on-device integrator under the position lease).
Round 19 (2026-08-01) closed the last open architecture item:
slot-moving compaction — live elements move down to a dense slot
prefix (a monotone remap, so compaction is a visual no-op) with
forwarded lazy ref repair, an automatic dead-slot trigger plus
cy.compact(), and highWater/capacity shrinking to the current graph
instead of its peak. Round 20 (2026-08-01) closed the interaction
options + touch parity gap: the tuning quartet (wheelSensitivity,
desktopTapThreshold/touchTapThreshold, tapholdDuration — ctor
options + getter/setters), the events/text-events
pointer-transparency props (a flag bit read by every pick path), and
v3's two-finger cxt and three-finger box touch gestures. Rounds
21–23 (2026-08-01, the third design sitting) removed the animation
queue (concurrency by channel; promises sequence), split
display/visibility (show()/hide() keeps the structural tier —
now re-fanning bezier bundles — while the visibility style prop is
paint-only invisibility that keeps space and bundle ranks), and
brought node charts: v3's pie/stripe props as the lean
list-valued chart family with data-driven values and scheme
palettes. Round 24 (2026-08-01, the fourth design sitting) closed
the animation follow-up: style transitions (the transition-*
config per sheet group — restyles tween on stored truth with
latest-wins eviction, GPU-offloaded when all-paint, under the
auto-vs-explicit mapper-domain performance contract) and the
animation controls (pause/resume/reverse + read-only
progress/paused). Round 25 (2026-08-02) built that record's
logged follow-up, the geometry tweens: node width/height,
edge width (its style-write-baked derivatives riding along),
compound padding and font-size animate and transition on the
CPU path — never leased, never stale (width()/bb()/pick read
the mid-flight value) — with the per-tick invalidation cascade run
by the store's write funnel (label re-anchor, auto-bounds, the
ride lanes) and priced by a dedicated benchmark sweep.
Round 26 (2026-08-02) changed no behaviour at all: it built the
authoring surface the release documentation will be generated
from — JSDoc on every public member of the prototype (a 46% → 100%
sweep, gated by a coverage test), and the first shipped
TypeScript declarations for cytoscape, which carry those
comments into consumers' editors. See "Documenting the source"
below.
Round 27 (2026-08-02) closed the visual-parity tail rounds 13–16 had
left: v4 now renders v3's complete node-shape vocabulary (the
seven round-* keywords, cut-rectangle, right-rhomboid,
concave-hexagon, bottom-round-rectangle and barrel) and
v3's complete arrowhead vocabulary (triangle-tee,
circle-triangle, triangle-cross, triangle-backcurve), sizes
arrowheads by v3's own nonlinear formula, and accepts a numeric
text-rotation on any label. Each family is pinned by a live
v3-vs-v4 parity diff rather than by a golden alone — see the
round-27 records in PLAN.md for the measurements. border-style/
outline-style remain the one unported style pair.
Round 28 (2026-08-03) took what was left of the gap ledger that
needed no design call: CPU-pick coverage for round 27's shapes
(the shader halves were pinned by parity diffs, the CPU replicas by
nothing — and three specs named for picking asserted only
boundingBox(), so they held with the shape swapped out), the
panBy viewport-animation target, and the ledger's own drift.
What remains in the ledger is now open calls rather than open work
— see PLAN.md.
Round 29 (2026-08-03) asked a different question — not what is
unbuilt but what is unpinned — and answered it in five passes:
the 83-method alias surface is now asserted (its type declarations
and its runtime wiring were separate, so deleting a wiring line
typechecked clean), four public methods no spec had ever called are
covered, and the decided-design drops are enforced at the API
boundary rather than merely intended — which took fixing three
places where a dropped v3 form was accepted and then failed
elsewhere, or silently not at all. It also priced curved edges on
the CPU (benchmark/curves.mjs) and re-ran the renderer
benchmark on real hardware, which showed round 27's shader branches
cost nothing measurable per frame.
Round 30 (2026-08-03) continued that axis onto the part of the
surface this file states most often and the suite tested least:
what v4 throws. Failing loudly is a decided design — an unknown
sheet key, style property, query key or boundingBox() option all
throw — and, measured with source-mapped coverage, 34 of the 191
throw sites in src had never executed. Every Node-reachable
one now does (20 specs), the six png()/jpg() export guards are
pinned in the browser project, three pieces of public surface the
survey turned up beside them are covered (cy.stop(),
renderedTargetEndpoint, the squaredEuclidean/max clustering
metrics), and the measurement itself ships as
scripts/throw-coverage.mjs — see "Measuring the error contract"
below.
Round 31 (2026-08-03) asked the follow-on question — when those throws
fire, do they say the right thing, and does the shipped documentation
admit they exist? It found one message advising a form v4 rejects
(the per-element bypass error sent callers at the style function
form, removed in round 8 and throwing since 29.3, and its doc comment
said the same), fixed it to name the mapper replacement, and closed
the documentation half: every public member that throws now carries
an @throws tag (16/16 at the time, up from 7; 18/18 today),
gated beside the round-26
coverage rule. It also covered mouseout and pointercancel, the
only two names of the round-17 event vocabulary no test mentioned.
Note where that defect lived: this file has always described the
bypass correctly — the stale advice was in the runtime message and the
JSDoc, which a markdown sweep never reads.
Round 32 (2026-08-03) finished that sentence's last clause: every
public member that takes arguments now documents them (221/221 at the
time, up from 143; 232/232 today — round 36.2 widened the audit to
the exported functions it had never walked and 37.3 to the entry point
itself), gated the same way, because
docmaker emits a description per argument and a missing one is a hole in
the release docs rather than only in an editor.
Round 33 (2026-08-03) changed no behaviour either: it was the
benchmark sweep, taking the suites from 14 to 22 so that the
surfaces with no measurement at all — layouts, the algorithm tail, the
style engine, loading and the wire format, picking/box-selection/bounds,
the data sidecar and structured queries, events and the animation
lifecycle, store internals — have one, plus a breadth pass over the rest
of the public API and a third audit script (scripts/bench-coverage.mjs).
Its real output was five paths slower than v3 or than v4's own design
implies, which round 34 (2026-08-03) then fixed — indexOf,
mutableElements() and the emit path's no-listener gate to parity, the
style getters 5.8× → 2.3×, and the layout contract 333 µs → 795 ns per
run. Both rounds are recorded in "Benchmarks" below. Round 35
(2026-08-03) answered the maintainer's question about the residual —
why a 150-case switch and not a lookup — by making readProp a
dispatch table, flattening the per-property spread rather than
uniformly lowering it.
Round 36 (2026-08-04) is the completion round: the tail of work
that needed no decision at all, now that what remains in PLAN.md is
otherwise open calls. @returns is complete (276/276 at the time;
ungated until round 37.1), the @param gate was found never to have
walked the public tier's exported functions (229/229 then), the
browser-only
throw tier is closed by four specs and three honest reclassifications,
three measurements this repo had promised and never recorded are
recorded, and a stranded-doc-block check shipped — which found six
more instances of this codebase's most repeated defect on its first
run, one of them shipping in dist/cytoscape.d.ts.
The fifth design sitting (2026-08-04) then took every open call in
PLAN.md's ledger at once and planned rounds 37–51 through the release:
border-style/outline-style at full coverage, a v4 Event and emitter,
the packaging move that makes v4 the package (v3 to a self-contained
v3/), the small feature tail, the docs generator and site, and the
release engineering — with one question deliberately left open, the
error policy (round 40's own sitting). A second joined it later:
round 41 found that which gesture defaults preventDefault() should
suppress cannot be derived from v3, so that list is a v4 contract still
to be designed.
Round 37 (2026-08-04) is that roadmap's governance close-out, and it
changes almost no behaviour: the two audits held back on policy calls
now gate (throw coverage at zero tolerance, @returns at 277/277
then, 279/279 today),
the legacy-alias triage is finally applied as written (roundrectangle
drops; autolockNodes/autoungrabifyNodes are kept as recorded
exceptions), constructor strictness is closed at the type layer where
the runtime stays deliberately permissive, and the event-name contract
is documented — a round that also corrected two things this file said
that were not true of the code (see the events and JSDoc sections).
Round 39 (2026-08-04) built the sitting's decided feature tail, three
independent small things: overlap box selection
(boxSelectionMode), graph-level data() on the binary wire
(format version 4, applied by options.elements and deliberately
ignored by cy.add), and cy.gc() as the explicit alias of
compact().
Round 41 (2026-08-04) gave v4 its own Event object and emitter,
severing the last import v4 made of v3's event machinery: event.target
is typed, originalEvent is populated by the interaction layer at last,
and the namespace parsing v4 had been inheriting — and contradicting its
own design with — is gone. Two of that round's premises turned out to be
unmeasured claims: the emitter was not v4's only remaining shared
import (five utility modules remain, now audited), and
preventDefault()'s gesture half could not be enumerated from v3, which
never reads the flag either — so that half is an open call.
Round 42 (2026-08-04) is the packaging move, and it changes no
behaviour: v4 became the package. This source promoted from
src/gpu/ to src/, the whole v3 file set moved into a
self-contained v3/ subproject that still builds and tests on its
own (cd v3 && npm run build), and the root package.json is v4's
alone — cytoscape@4.0.0-unstable, v4 as exports["."], ./gpu
kept as a deprecated alias. The gpu-/webgpu- prefixes dropped
from the test, benchmark, script, debug and Playwright names; the
five utility modules v4 had still been importing from v3 are now
v4's own copies, so nothing under src/ imports outside it. The
factory, the bundles, the declaration, the UMD global and the
exported type names are all plainly cytoscape's now: Core,
Collection, Event, Stylesheet, CytoscapeOptions and the rest
lost the Gpu prefix in 42.6, with no deprecated aliases — the
prerelease line has no published consumers to break. Six internal
names keep it (GpuContext, GpuTimer, GpuForceRuntime,
GpuTweenRuntime, GpuTweenSink, GpuWriteKind), where it means
the device half against a CPU counterpart rather than "the
prototype"; that is the same rule that kept gpu-context.mts and
render/gpu-*.mts while gpu-types.mts became public-types.mts.
v3 stays untouched inside v3/, so every v3 asset remains
available for comparison benchmarks and parity work.
Round 43 (2026-08-04) rebuilt the debug harness — v4's only
manual page — which had been quietly discarding the whole style
surface: its sanitizer kept a 14-property whitelist and dropped
every mapper, so each fixture rendered as flat monochrome discs.
It now carries hand-authored v4 sheets per network (including the
real enrichmentmap.org style, whose per-element colour function
becomes one diverging mapper), two genuinely compound graphs, the
v3 page's view/layout/toggle/selection/event/add-remove sections,
and a module spec that compiles every sheet against its own fixture.
The round also fixed the background-grab indicator, which had never
followed the cursor — see the core-theming notes below.
Rounds 44, 45, 47 and 48 (2026-08-04/05) are the release sequence's
decision-free part: 44 gates the packaging chain (rolldown
outputs → dist:copy → the manifest → the tarball, every link
hand-maintained and none of its failures loud); 45 is the
docs generator, turning nineteen rounds of gated JSDoc into 362
documented members validated against the shipped declaration —
and it found event.mts outside the audit's public tier, optional
class members invisible to every audit, and a layout-extension
contract that shipped no types at all; 47 is the migration
guide (MIGRATING.md) and CHANGELOG.md, whose property table is
measured against both libraries rather than remembered; and 48
is the soak tier, which found four defects — a corrupt wire
buffer that made a load never return, two more costing 25.9 s and
5.7 s, and element identity comparing equal across two instances,
so union() silently dropped the other graph's elements. Each has
its own section below.
Culling: a compute pre-pass per group (nodes, edges, glyphs) compacts the
drawable slots into a visible list + drawIndexedIndirect args — a
deterministic three-dispatch stream compaction that preserves slot order
(the in-group z-order), with an exact segment-vs-rect test for edges — so
the render pass draws exactly what's visible instead of running the vertex
shader over every allocated slot.
- Entry point:
cytoscape(options)fromsrc/index.mts(import cytoscape from 'cytoscape', UMD globalcytoscape). It ships TypeScript declarations since round 26.5 —dist/cytoscape.d.ts, built bynpm run build:types, carrying the source JSDoc through to editors. - Headless-friendly: without a
containerno GPU is required (Node-testable). With acontainer, WebGPU is mandatory — the factory throws synchronously whennavigator.gpuis missing, and.readyrejects when no adapter can be acquired. - Events: v4 owns its own
event.mtsandemitter.mtssince round 41 — one emitter per core holding every listener (core, delegated and per-element) told apart by a qualifier, and an Event object with a typedtarget, a populatedoriginalEventand no namespace field. Before that round both came from v3's shared modules, which is how v4 spent thirty rounds running namespace semantics its own design had dropped. contract.mtsis the co-signed source of truth for the column/flag layout shared by the model (store/) and the renderer (render/) — change it first when the layout changes.- Manual testing:
npm run watch→ http://localhost:3333/. The harness (round 43) offers nine networks — six from real exports (four fixtures shared with v3's WebGL harness underv3/debug/webgl/, the 465k-edgendex-x-largelocal todebug/, and a clustered variant derived from em-web in-page) and three built in-page — each with a hand-authored v4 stylesheet, plus sections for the viewport, layouts, the core toggles, query-object selection, an event log and add/remove.test/modules/debug-harness.mjsis its only automated coverage: every fixture exists at the path the page fetches, every sheet compiles against that fixture's real data, the compound fixture lays out into disjoint parent boxes, the event log reads layout once per frame rather than once per event, andwatch:syncbinds livereload on every interface (the 2026-08-05 review pass — three defects the harness's first real user found, recorded under round 43 in PLAN.md). Browser tests: therendererPlaywright project, plus thevisualproject — golden-image diffs (pixelmatch against PNGs inplaywright-tests/goldens/, pinned to the SwiftShader adapter so the goldens are machine-independent; regenerate intended changes withUPDATE_GOLDENS=1) and live v3-vs-v4 parity diffs (playwright-page/parity.htmlrenders both renderers side by side — no v3 baselines are checked in). The two answer different questions, and round 27 is the cautionary tale: a golden compares v4 against its own previous output at a 0.5% tolerance, so it asks "did this change?", while only the parity diff asks "is this right?". v4's arrow sizing deviated from v3 at every width and the arrow goldens passed throughout, before and after the fix. Anything claiming v3 parity needs the parity diff, and a new parity test should be run once with its feature disabled to prove it can fail. A WYSIWYG self-diff spec pinspng()to the on-screen pixels. On Linux both Chromium projects add ANGLE-on-Vulkan compositing flags (seeplaywright.config.js) — without them Dawn renders fine but WebGPU canvases present blank in headless Chromium (adapters acquire, composited pixels stay transparent); the flags are Linux-gated because--use-angle=vulkandoes not exist on macOS (Metal). A second adapter footgun (2026-08-01):requestAdapter()returns null onabout:blank, so probe adapters from a served page — a bare-page probe reads as "no GPU" on a box that has one.
API scope (pass 1)
v3's method aliases are kept throughout (each/forEach,
centre/center, bc/betweennessCentrality, the set-op spellings,
…): 84 of them across the core and collection (83 through round 29;
gc joined in 39.3), each a declare in the
class body plus a separate prototype assignment. Since round 29.1 the
whole surface is pinned by a table in test/aliases.mjs that
asserts alias-target identity and cross-checks itself against the
sources in both directions — the type declaration alone would keep the
typecheck green if a wiring line were deleted.
Core: viewport fns (zoom, pan, panBy, fit, center, extent,
plus reset, viewport, zoomRange, getFitViewport/getCenterPan,
renderedExtent, size), events (with the usual aliases +
onRender/offRender; delegation via predicate functions), graph
manipulation, style() (the { nodes, edges, parents, core }
sheet), layout()/
makeLayout (grid, preset, circle, concentric, breadthfirst, random,
and — round 18 — the GPU-capable force; plus the round-17
extension contract: cy.layout({ impl }) runs a user layout
class/object with no registry — plus eles.layout() for subset
scopes and the v3 layoutPositions
plumbing with spacingFactor/transform/animate — an animated layout
fits by animating the viewport to the box at the final positions,
concurrently with the node tweens), pick(), png()/jpg() (async image
export — see the design decisions below),
renderer()/forceRender()/resize(), graph-level
data()/scratch(), batching (startBatch/endBatch/batch/
batchData/batching — see below), json() (export-only),
box selection (elementsInBox + the pointer gesture — mouse/pen,
and the round-20.5 three-finger touch box),
selectionType, boxSelectionIncludesLabels (round 16.5) and
boxSelectionMode (round 39.1),
the round-20.1 interaction tuning quartet (wheelSensitivity,
desktopTapThreshold/touchTapThreshold, tapholdDuration —
ctor options + getter/setters, see the gestures notes below),
interaction gating
(autolock/autoungrabify/autounselectify,
panningEnabled/zoomingEnabled + user* variants,
boxSelectionEnabled), introspection (instanceString, isReady,
headless, mutableElements, hasElementWithId/$id, options),
destroy(), width()/height(), and compact() (round 19 — the
explicit form of the automatic slot-compaction trigger; see below;
gc is its alias since round 39.3).
Collections: cy()/renderer()/element(), events, graph
manipulation (incl. edge move()), position/dimensions (model +
rendered, shift, silent variants, edge midpoint/endpoints —
curve-aware since round 12a, along with controlPoints/
renderedControlPoints/isBundledBezier, and — 12b —
segmentPoints/renderedSegmentPoints for segments/taxi edges, with
controlPoints covering unbundled-bezier control lists; since 12c
haystack edges answer endpoints/midpoint/bb with their offset
points, and manual-endpoint edges resolve everything through the
route evaluator),
iteration (sort, reduce, max/min), comparison, building/
filtering (byGroup, diff, absoluteComplement, set aliases),
traversal (outgoers/incomers, roots/leaves,
successors/predecessors, edgesWith/edgesTo,
parallelEdges/codirectedEdges, components), the compound
surface (round 14: parent/parents/ancestors/children/
descendants/siblings/orphans/nonorphans/commonAncestors,
isParent/isChildless/isChild/isOrphan, move({ parent }),
compound-relative relativePosition, real padding()/
paddedWidth/paddedHeight), degree
(degree/indegree/outdegree are singular first-element accessors as
in v3 — the whole-collection sum is totalDegree — plus min/max stats),
select/unselect/selectify, grabbable/lock,
active/activate, pannable/panify,
show/hide, data()/scratch()/json(), label() (read-only),
read-only style getters (style/css, renderedStyle,
numericStyle, effectiveOpacity/transparent/takesUpSpace/
interactive — since round 20.2 interactive() folds the events
prop, and since round 22 visible() is the draw tier while
takesUpSpace() is the space tier — see below),
boundingBox({ includeLabels }) +
labelBoundingBox() (round 16.4), the background-image family
(round 15), the wrap family (round 16), the round-22 visibility
prop, the round-23 chart family, the round-24 transition-*
config in the sheet (whose tweenable set grew the geometry numerics
in round 25 — width/height, edge width, padding, font-size — the
same channels animate() accepts), the round-17
event vocabulary (pointer*/tap*/grab-drag-free families, viewport
gestures), and graph algorithms (round 10, growing):
bfs/dfs (+ long aliases), dijkstra, aStar, bellmanFord,
floydWarshall, kruskal, tarjanStronglyConnected (+tsc etc.,
iterative — deep graphs cannot overflow the JS stack),
hopcroftTarjanBiconnected (+htbc etc.), hierholzer,
kargerStein, pageRank, degreeCentrality/dc (+normalized),
closenessCentrality/cc (+normalized), betweennessCentrality/bc,
kMeans, kMedoids, fuzzyCMeans/fcm, hierarchicalClustering/
hca, markovClustering/mcl, affinityPropagation/ap
— the full v3 algorithm surface. Graph walks are slot-native over the
CSR adjacency; the attribute-space clustering algorithms work on
handles as v3 does. v3 option/result shapes are kept, except that
node arguments are collections (selector strings throw) and
weight/heuristic/attributes are plain functions.
Batching (v3 semantics): a startBatch()/endBatch() pair (or
cy.batch(fn)) defers style application — the first apply of
elements added inside the batch, sheet re-application (cy.style(sheet)
compiles and validates immediately, applies at the flush), and
data-mapped label refresh — into one bulk pass at the outermost
endBatch, filtered to still-live elements. Events keep firing during
the batch, and style-derived reads (width(), label(), style())
may be stale inside it. Renderer scheduling needs no batch deferral:
the dirty tracker already coalesces per microtask, which fires after
the batch's synchronous block anyway. v3's notify/noNotifications
have no v4 counterpart for the same reason.
Style getters read the stored channels — the resolved values the
renderer draws from — not the sheet's declarations: style(name)
returns numbers for numeric props, rgb()/rgba() strings for colors
and keywords otherwise; style() returns the whole group's props;
renderedStyle scales length props (width, height, border-width,
font-size) by the zoom; numericStyle returns the number (throws for
non-numeric props). Consequences of reading stored truth: an
equal-radii ellipse reads back as 'ellipse' whatever keyword compiled
it, arrow getters derive from the stored arrow color (alpha folds in
edge opacity, so a fully transparent arrow reads shape 'none'), and
label channels (font-size, color) come from the label sidecar when
the node is labelled, else resolve through the sheet (mapped channels
evaluate for that slot). When the GPU eval kernel owns
a paint channel (see the mapper DSL below), its stored bytes go stale
after data writes and the getter evaluates the shared mapper IR lazily
instead — same math as the kernel, agreeing with rendered pixels within
±1 per RGBA byte. The setter forms throw: v4 has no per-element bypass —
per-element styling is a mapper (case conditionals, data(key)
scales).
Ghost props (round 13 A1): ghost ('yes' | 'no'), ghost-offset-x/y
and ghost-opacity duplicate the basic node body — shape, border,
background — at the offset as one extra instance draw under the node
(the 2026-07-29 triage's simplified scope: never a whole-node redraw;
labels and decorations excluded). All four are node-only and
mapper-capable; offsets grow the bounding-box scans. Recorded
deviations: ghosts are not pickable and box selection ignores ghost
extents. The renderer pays nothing while no node styles a ghost (the
ghost cull + draw are skipped outright at a live count of 0).
Overlay/underlay (round 13 A2, nodes): the 10 overlay-*/
underlay-* props draw a filled round-rectangle or ellipse around
the node's size + padding — the underlay under the body, the overlay
above it (and, a recorded deviation, under the label layer — v3
draws overlay over its node's label). Color/opacity/padding are
mapper-capable; layer opacity folds into the stored color (folded
readback); padding grows the bb scans; zero-cost when unused.
line-outline-width/-color (round 13 B4) stroke a casing under
the edge line at width + outline width via the same layer machinery,
alpha folded by v3's effective line opacity; an enabled casing
demotes the element-opacity mapper to the CPU path (the fold must
track writes). Edge layers stroke the edge geometry at width + 2 × padding (every
family — haystack offsets and the triangle taper included), the
underlay under the edges and the overlay over edges + arrows, both
under the nodes; strokes are solid with butt caps (v3 rounds stroke
ends — a recorded deviation), and overlay-shape/-corner-radius
stay node-only.
Core theming (round 13 A2): the sheet takes an optional core group
with v3's core-selector props, constants only —
selection-box-color/-opacity/-border-color/-border-width
theme the DOM selection box, and active-bg-color/-opacity/-size
drive the background-grab indicator circle at the press point (a DOM
element above the canvas, like the selection box — v3 draws it into
the canvas, so v4 exports never include it; recorded).
The indicator is anchored in model space (round 43.7, v3's rule):
it was positioned once at pointerdown in screen space and never moved
again, so a background pan slid the graph out from under it. The
press point is now stored as a model position and re-projected per
move, which keeps it glued to the point pressed. The two are
observationally identical during a pan — the graph delta is the
cursor delta — and differ only when a press stays in pan mode while
nothing pans, which needs both userPanningEnabled and
boxSelectionEnabled off (otherwise the drag becomes a box gesture
and shows no indicator at all).
The channel-opacity split (round 13 B1): background-opacity,
border-opacity, line-opacity and text-opacity fold into the
stored channel alphas at write time (element opacity stays the
master multiplier, its own column). The arrow fold is
base × opacity × line-opacity (v3's effective arrow opacity), all
four props take CPU mappers, and readback is folded. A non-1 (or
mapped) channel opacity demotes the sibling color channel's GPU
mapper eval to the CPU path (the kernel would overwrite the folded
bytes) — a recorded scope note. Note the pre-existing band rule:
the node FS picks border or fill per fragment, so a translucent
border shows the border color alone where v3 blends it over the
fill in the inner band half.
Node outlines (round 13 B5): outline-color/-opacity/-width/
-offset draw a solid ring outside the border at offset/2 past its
outer edge — exactly v3's scaled-path stroke for circles and squares
(anisotropic shapes deviate from v3's per-axis scaling, recorded);
ghosts carry their outline; outlines are not pickable and grow the
bb by offset/2 + width. outline-style is still unbuilt, with
border-style: both need a perimeter coordinate the node
fragment shader does not have. The edge shader dashes for free
because it carries u (model px along the edge) as a varying,
whereas nodeSD returns a bare signed distance and discards the
nearest-feature information a perimeter parameter would be built
from. Closed form exists for circles and rectangles; polygons need
the SDF loop to also track the argmin edge and its clamped
projection against a cumulative perimeter — which is a scope call
(which shapes earn the cost) rather than a missing technique.
Border geometry (round 13 B2): border-position defaults to v3's
center (the band straddles the boundary — v4 previously drew all
borders inside, an unrecorded deviation now closed), and
corner-radius (number | 'auto') feeds the round-rectangle SDF in
the node/ghost shaders, the depth prepass and the CPU pick alike,
with 'auto' = v3's min(w/4, h/4, 8) (previously min(w, h)/8; also
closed). bb keeps the outerHalf center convention for every border
position, matching v3's outerWidth.
Gradients (round 13 C2): background-fill
(solid | linear-gradient | radial-gradient) with stop
colors/positions and v3's to-* directions, and line-fill with
stops along the drawn span (the polyline arc length on curved
edges; radial mirrors about the midpoint). Stops interpolate in
sRGB (v3's canvas gradients — OKLab stays the mapper default), cap
at 5 per element, and stop lists are constants-only; the
fill/direction enums take mappers. The depth prepass skips
gradient fills conservatively, and plain-LOD far-zoom discs show
the flat base color (both recorded).
Custom polygons (round 13 C3): shape: 'polygon' with
shape-polygon-points — flat unit pairs in v3's [-1, 1] space —
stored per element in a second curve-blob pool whose packed
offset|count ref rides the borderGeom radius word (meaningless
for polygons). The node FS runs an exact sdPolygon loop over the
blob range (crisp AA and borders under anisotropy, like the
generated shapes) and CPU pick runs point-in-polygon over the same
record — dual consumers, agreeing by construction. Points are
constants-only (one list per sheet), validated (even count, >= 3
pairs, values in [-1, 1]) and capped at 32 points (recorded); unit
points keep the bb term at the node box.
Multiline labels + label bounding boxes (round 16)
Landed 2026-08-01, per the PLAN.md round-16 plan:
- The wrap family (both label groups, mapper-capable):
text-wrap(none | wrap | ellipsis),text-max-width,line-height,text-overflow-wrap(whitespace | anywhere) andtext-justification(auto | left | center | right) — v3's keyword sets, defaults and breaking rules (wraphonors embedded newlines and greedily word-wraps; over-long words overflow underwhitespaceand split underanywhere;ellipsistruncates one line with '…';autojustification resolves againsttext-halign, v3's hanging-label rule). - One breaker, three consumers:
label-wrap.mts(gpu root) is a pure module with injected advances — the renderer lays glyphs with real atlas advances behind a shaping memo (labels are model-space, so breaking is zoom-invariant and identical (text, wrap-params) pairs share one laid block; hits/misses riderenderer().stats()), while the store estimates dims with flat advances so bounds work headless. Exact laid dims feed back into the store per glyph build. - Labels join
boundingBox()/fit()by default:boundingBox({ includeLabels })(default true; unknown keys throw) on collections,renderedBoundingBox, the whole-graph store scan behind no-argfit/center/getFitViewport, andboundingBoxAt(animated-layout fit targets cover labels). Node labels are exact (the laid block at its D3 anchor + text-box padding); edge labels are conservative (a rotation-safe block-covering radius about both endpoints — sound wherever the anchor lands on the drawn path).eles.labelBoundingBox()is the public exact measure — the v4 form of v3's text-metrics. boxSelectionIncludesLabels(core option + ctor, default false — v3's box-select-labels default): when on, box selection requires the node's label box to be contained too.- Recorded deviations: headless label dims are flat-advance estimates (rendered instances upgrade them to exact); the edge-label bb term is conservative (fit may slightly over-fit, never under); alignment shifts and text boxes use block metrics (advance width × line-stacked height), not ink extents.
- Costs (Node sweep,
benchmark/labels.mjs): wrapped-label builds ~5 µs/label at 100k (write-driven — per frame only under a round-25 font-size tween on a wrapped label, the recorded expensive configuration; wrap-none tweens ride the dims fast path); the whole-graph bb scan pays ~0.1 µs/label for its label terms.
The force layout (round 18)
Landed 2026-08-01, per the PLAN.md round-18 plan — the round-9 "GPU layouts" design, built:
cy.layout({ name: 'force' })— spring–electric with uniform-grid cutoff repulsion, springs toward per-edge ideal lengths (edgeLengthas a number or a plain fn resolved once), centering gravity, and pure damped gradient integration under d3-shaped alpha annealing. Seeded and deterministic on the CPU executor (seed,randomize); leaves only (parents derive); locked nodes pin as obstacles; subset scopes simulate the subset only. Runs through the round-17 extension contract — the built-in is the contract's first production consumer.- Two executors, one spec. The CPU reference
(
layout/force-sim.mts) always exists — headless instances, compound graphs (the 14.11 lease rule),animate: false— and is what the Node specs pin. Underanimate: trueon a flat rendered graph, the GPU integrator (render/gpu-force.mts) takes over: six dispatches per iteration (grid build by counting sort → force gather → apply-and-publish) encoded ahead of the cull pass, so 100k-node layouts animate live with edges and labels following on-GPU.node.positionis GPU-owned for the run (the tween lease — CPU reads stale mid-run, the motion-staleness rule), and convergence triggers one readback (the round-9 exception) that settles the CPU columns through the normal dirty-span path. - Recorded deviations/limits: a cutoff model does not promise global untangling (a curled chain is a legitimate local minimum — multilevel refinement is future work); the repulsion cutoff is the mean ideal edge length (a connected pair's equilibrium is L itself); GPU trajectories are not bit-stable run-to-run (atomic in-cell scatter order) — seeded bit-reproducibility is the CPU executor's guarantee, and the executors agree on invariants, not trajectories; live streaming writes through the bulk slot path, which emits no per-node position events.
- Harness:
debug/?layout=force(+&seed=N); benchmark:benchmark:renderer -- --layoutruns a live force to convergence per scene (v3's cose as the classic baseline, bounded by nested test-style timeouts — a 30 s in-page stop reporting a measured floor and a 60 s runner-side bail reporting "> 60 s", since a single cose iteration outgrows any in-page cap at benchmark scale;--layout-uncappedmeasures full runs). On an RX 580 (re-measured 2026-08-04, round 36.5; first run 2026-08-01, PLAN.md "hardware validation pass"): force converges in 0.76–1.6 s across 25k, 100k and ndex — and in 14.8 s on the compound scene, which runs the CPU executor under the 14.11 lease rule — where the cose baseline exceeds the 60 s bail on every scene. Read these rows as ±25%: round 18.3 recorded that GPU trajectories are not bit-stable run-to-run (atomic in-cell scatter order), so the iteration count to convergence varies, and the 2026-08-01 and 2026-08-04 runs differ by −20% to +24% in both directions with nothing in the layout path having changed between them.
Slot compaction (round 19)
Landed 2026-08-01, per the PLAN.md round-19 plan — the last open architecture item, closing the policy questions logged since the 2026-07-27 compaction analysis (the slot-stable tier — id blob, CSR, dictionaries — has self-compacted since round 11; this round moves the element slots themselves):
- What it does. Live elements move down to a dense slot prefix
per group, so
highWater(every CPU pick walk and GPU cull dispatch width) and column capacity (CPU columns, GPU mirrors, mapper regions) shrink to the current graph instead of its peak. The remap is monotone — relative slot order is preserved by construction — which is what makes the three design calls cheap: draw order is unchanged (compaction is a visual no-op, pinned by a byte-identical screenshot spec), curve bundle rank / loop stagger / orientation signs are unchanged (derived params survive with no re-derivation), and CSR incident order is unchanged. - Refs survive via forwarding + lazy repair. Moved elements take
fresh generations, so every stale ref fails plain validation and
routes through a per-group forwarding chain that rewrites the ref
in place on first touch — fixing every holder of that object.
Collections sync lazily against a compaction epoch (one int compare
on the hot path; the packed membership cache drops with it),
interned handles keep their identity and scratch (
cy.$idreturns the same object), element-bound listeners keep firing and stay removable, and running animations re-key with their slot lists re-pointed. A removed element's ref stays dead — repair never resurrects. Forwarding entries persist and compose across compactions. - Triggers: auto + explicit. The automatic trigger applies the
round-11 waste-over-half policy to slots — dead slots exceeding the
live count, past a 1024-slot floor — at safe boundaries (a
completed
remove(), the outermostendBatch).cy.compact()is the explicit form for deterministic timing —cy.gc()is its alias (round 39.3), v3's name for the same idea, kept because an upgrading app already types it and v4 has no separate garbage-collection concept for it to name instead; it throws mid-batch and defers (with a warning) while a GPU force run owns the position column. Mid-flight GPU tweens demote to the CPU path (they write the value reached, leave the device, and finish on repaired slot lists — not ended early, unlike the reparent settle). - Renderer handshake: the
resizedflags drive the mirror's capacity-aware realloc + full re-upload and the pick-cache invalidation; the mapper runtime rebuilds its capacity-aligned data regions; the parent draw permutation re-uploads; and the glyph streams clear wholesale before rebuilding (owner slots are baked into glyph instances — an incremental rebuild could alias a moved element's stale run onto a different element's new slot). - Recorded limits: data-sidecar column buffers permute in place and never shrink (bound mapper evaluators hold them by reference); the conservative monotone maxima (curve slack terms) are not recomputed at compaction (sound — slack can only be loose); the auto trigger never fires mid-batch or during a live force run (deferred to the next boundary).
- Costs and wins (Node sweep,
benchmark/compaction.mjs, 200k-node peak cut to 10%; the renderer bench's compaction scenario measures the device side — see below):compact()is a ~114 ms one-shot, and the auto trigger adds it to a removal whose own cascade + emits cost ~1.8 s at this scale (~6% overhead); the held-collection first-touch repair of 20k moved refs is ~0.5 ms; the synchronous CPU node pick drops ~5.5× (2.15 → 0.39 ms background miss); cull dispatch width falls 200k → 20k lanes per group per frame; column memory falls 37 → 4.6 MiB (nodes) and 76 → 0 MiB (edges). The forwarding machinery is free on the hot path:isCurrenton a current ref is parity (1.01×) with forwards present, and a stale-ref chase + rewrite is ~40 ns once per ref. Honesty controls pin what compaction does not change: order-list scans and whole-graph bounds are ≈parity (1.1–1.2×, dense-prefix cache locality), since those ride the insertion-order list that has self-compacted since round 11. On the device (RX 580, the renderer bench's compaction scenario): wall time stays at the vsync floor — a 10%-live scene was already fast — but the unbounded GPU pass isolates the dead-lane overhead compaction removes: panning 10k live nodes over 100k + 300k peak lanes costs 2.2 ms/frame of device time, 0.5 ms once compacted (4.4×); in-browsercompact()is a ~60 ms one-shot at that scale.
Event vocabulary + the extension contract (round 17)
Landed 2026-08-01, per the PLAN.md round-17 plan — two permanent-API calls made deliberately rather than by accretion:
- The curated event vocabulary. Adopted with v3 semantics:
the drag-state family (
grab/grabon,drag,free/freeon,dragfree/dragfreeon— the-onvariants only on the directly grabbed element, the plain forms on it and every selected companion), the device-normalized family (tapstart,tapdragwhile pressed,tapend,tapselect/tapunselect,tapdragover/tapdragout,cxtdragover/cxtdragout), the viewport gestures (dragpan,scrollzoom,pinchzoom— core level, with positions), and the official pointer family (pointerdown/pointermove/pointerup/pointercancel/pointerover/pointerout) — the events the interaction layer itself consumes, so touch rides the same paths by construction. Dropped, recorded: thevmouse*aliases (tap*is the normalized vocabulary) and v3's raw mouse/touch re-emits (mousedown/click/touchstart/... —pointer*is their one modern spelling; the existingmouseover/mouseoutstay);event.preventDefault()is half wired (round 41.4). The DOM half works:originalEventis populated by the interaction layer since 41.4 — the DOM event a gesture came from is reachable from the handler, andpreventDefault()reaches the browser's default through it. The gesture half does not: nothing insrcreadsisDefaultPrevented(), so the call cannot stop a tap selecting or a grab starting, and those defaults stay gated by options. The fifth sitting decided that half should be built; round 41 found the enumeration it planned to derive cannot be derived — v3 never reads the flag either, so the list of preventable defaults is a v4 contract to design rather than a v3 behaviour to port. It is open call 12 in PLAN.md. "Dropped" here means "never emitted", not "rejected" (measured 2026-08-03):cy.on('vmousedown', h),cy.on('mousedown', h),cy.on('click', h)andcy.on('touchstart', h)all register cleanly and then never fire, so a ported v3 handler silently does nothing. Call taken (2026-08-04, fifth sitting): these names stay open — custom events are supported API (node.emit('foo')), so a name cannot be validated against a list without breaking them. No denylist; the behaviour is documented onon()/emit()instead (round 37.4) and pinned by specs. Event namespaces were a longer story than this file told. It claimed for several rounds that'tap.ns'fired for neithertapnortap.ns, and that the shared emitter kept its namespace parsing "only for v3"; round 37.4 measured both and found them wrong — v4 imported v3's emitter, so namespaces worked in full v3 semantics, and the only true part was that v4 never emits a qualified name. Round 41.2 closed it properly by giving v4 its own emitter: a type is matched whole, so'tap.ns'is one literal name thatemit('tap')does not reach andemit('tap.ns')reaches alone. The design and the code finally agree, and a spec pins the new rule row by row. Deviation:tapdragover/cxtdragovertarget nodes only (the synchronous CPU pick; edges would need the async GPU tile). - Extensions are direct objects — no registry. No
cytoscape.use, no string registration, no global state: an extension layout is an import passed straight tocy.layout({ impl, ...opts })(oreles.layout) — a class or object implementing{ run(ctx), stop?() },runoptionally async (the GPU-layout shape). The LayoutContext is columnar-first —nodeSlots()pre-filtered to unlocked leaves, live position/endpoint views, O(1) CSR degrees, bulksetPositions, thelayoutPositionsfinisher with the whole v3 plumbing — with handles reachable atctx.eles. Lifecycle events fire on the core exactly once per run; layout instances stay non-emitters. Core/collection/renderer extension points stay out (recorded: mappers + predicates cover the common cases; revisit on demand). A worked example (SpiralLayout) ships indebug(?layout=spiral), and the contract-conformance specs intest/layout-contract.mjsare the template external authors can crib. The contract's types ship since round 45:LayoutContext,LayoutImplandCustomLayoutare exported from the entry point, sorun( ctx )has a real parameter type. Until then onlyCustomLayoutOptionsreached the declaration andLayoutContextwas in none at all, so the one surface the contract exists to make obvious was the one an external author had to type by hand.
Design decisions (v4 API direction)
Decisions made for the v4 direction and reflected in this prototype; each is deliberate, not a pass-1 deferral:
- No selector strings, anywhere. v4 drops the selector language
outright — there is no parser, no dialect of v3 selectors, and no plan
to grow one back. The replacements, by role:
- Queries (evaluate now → collection): structured query objects
compiled to the matcher IR —
cy.nodes({ selected: true }),cy.filter({ group: 'edges' }),eles.filter({ selected: false }), structural booleans (round 14.7:parent/child— nodes only, answering v3's:parent/:childless/:child/:orphanas pure flag scans; an explicitly-edges query with a structural key throws), and data conditions over the sidecar columns (round 10):cy.nodes({ data: { weight: { gt: 0.5 } } })— one ofeq/ne/lt/lte/gt/gte/inper key (a bare value meanseq; keys AND together), sharing thecasemapper's vocabulary and semantics (a missing value fails every op,neincluded), answered inside the columnar scan with per-key readers hoisted out of the loop. Unknown query keys throw (a typo must not silently match-all). - Predicates (evaluate per element, lodash-style): plain functions —
cy.filter( ele => ele.data('weight') > 0.5 ), and event delegationcy.on('tap', ele => ele.isNode(), handler)(predicates compare by function identity inoff(), so removing a delegated handler takes the same(events, predicate, handler)triple). - Id lookup:
cy.$id(id)/getElementById(the O(1) id index). cy.$()is gone; set ops andedgesWith-style methods take collections, not selector strings.- The rejection is enforced at the boundary (round 29.3), which it
was not before: a selector string now throws from the query
compiler, from the twelve collection methods that take another
collection, and from event delegation, each message naming the
replacement. Previously a v3 string produced "Unknown query key
'0'" (its character indices read as keys), an internal
other._refs is not iterable, a silentsame() === false, or — forcy.on('tap', 'node', cb)— a TypeError raised inside the emitter on the next tap, which is both late and somewhere else. The whole ledger of removals is pinned bytest/decided-drops.mjs.
- Queries (evaluate now → collection): structured query objects
compiled to the matcher IR —
- The matcher IR is the contract, not a syntax.
matcher.mtscompiles a query to per-group(mask, want)flag tests answered by one columnar scan (GraphStore.scanRefsInto) — no element handles, no per-element matching. Richer predicates later (data over the sidecar columns, structural terms) extend the IR with more test kinds; any future frontend (chained builder, serialized JSON query) compiles to it rather than growing its own matching. - Strictness resolves at the type layer at the constructor, and at
runtime everywhere else (decided 2026-08-04, fifth design sitting;
pinned by round 37.3). v4 fails loudly on an unknown sheet key,
style property, query key or
boundingBox()option, on the reasoning that a typo must not silently do nothing. The constructor is the deliberate exception:{ motionBlur: true }and{ totallyUnknownOption: 1 }construct happily and round-trip throughcy.options(). The reason is that the typo guard already exists one layer up — TypeScript's excess-property check rejects both againstCytoscapeOptions— and v4 does not replicate at runtime what the build checks. The boundary is TypeScript's, and worth knowing: excess-property checking applies to object literals, so options assembled into a variable first are widened and pass. Both halves are pinned — a Node spec for the permissiveness, and four@ts-expect-errordirectives intypescript/tests/api.test-d.tsthat fail the typecheck if the options type ever stops rejecting excess keys. - No classes in v4 (
addClass/removeClass/class selectors). The role classes played in v3 — user-defined state driving filtering and styling — belongs to the columnardata()sidecar (for state) plus mappers and predicates (for behaviour). - No z-index in v4 (decided 2026-08-01). Draw order is
structural — compound parent bodies, then edges, then leaf nodes,
then labels, slot order within a stream — and stays that way:
z-index,z-compound-depthandz-index-compareare not coming to v4, and neither is a built-in grab-raise. Element stacking is a document/UI concept without a strong graph use case: node overlap is a layout artifact rather than an authored arrangement, layered emphasis is served structurally (overlay/underlay props, opacity dimming), and v3 carried the prop triple at the cost of a whole-scene comparator sort per frame. Edges into child nodes stay visible because parent bodies draw under all edges (the round-14 stream split). If real demand for raise-above-the-crowd styling ever appears, the logged extension is a single boolean elevated tier (one extra batch per group) — never arbitrary integer stacking. - Style is
{ nodes, edges }, no selector blocks and no style functions. Each key is a props object whose values are constants or mapper objects; all per-element variation is declarative (scales andcaseconditionals), so every value is analyzable, serializable, and GPU-evaluable. The opaque(ele) => propsform was removed — its cases are covered by mappers (casefor conditionals,data(id)for identity), and selection-dependent recolouring is intentionally gone (the:selectedaccent ring is shader-drawn). Removed means rejected, since round 29.3: a group written as a function throws atsetSheet. Until then it was silently ignored, so a v3 stylesheet ported wholesale produced an unstyled graph and no error — the worst available failure mode for a decision this deliberate. Everything stays fresh automatically: a data write re-derives the affected mapped channels, gated on the mapped keys. - Every mapper is cheaply CPU-evaluable — a load-bearing invariant.
It is what keeps
ele.style()(andnumericStyle/renderedStyle) synchronous, keeps headless mode and Node tests working (the same IR runs on CPU, on the GPU, and in tests), and keeps determinism. Reads are not async: an async read would be viral across every call site, open reentrancy windows, and break headless/testability — all to answer a question the CPU can already answer from the IR in nanoseconds. GPU evaluation is an optimization layered over the CPU-evaluable IR, never a source of values the CPU can't reproduce; a mapper that can't be GPU-packed (conditional, multi-key, mixed column) simply stays CPU-evaluated. Async is reserved for genuinely GPU-only reads (rendered pixels, image export — already async), a different category from resolved-style reads. - Mappers are a serializable object DSL, evaluated GPU-side (landed;
design decided 2026-07-24). A style prop value can be a plain object
spec —
{ data, scale?, domain?, range?, ... }— no string parsing, no builder; the spec is JSON-round-trippable and compiles to a closure-free IR (style-scales.mts). Scales:linear(default),log,sqrt,pow,symlog,diverging([min, mid, max] domain),ordinal(categories),thresholdandquantize(bins). Colors interpolate in OKLab by default (interpolate: 'srgb'opts out) with named schemes (viridis/plasma/magma/inferno, ColorBrewer ramps,category10/dark2) and multi-stop ranges (pairwise when domain and range lengths match, evenly spread otherwise). Semantics: clamp by default; missing/unmappable data resolves tofallbackelse the channel default (never keep-previous — refresh is idempotent);domainomitted/'auto' is a live extent (Vega-Lite semantics): the data extent re-checks on writes of the mapped key and a moved extent re-derives the whole channel (log auto-extents use positive values only) — the O(n) case; an explicitdomainkeeps every data write O(changed elements), the round-24 performance contract (pindomainwhen a stream grows its own extent — see the transitions bullet). Refresh is dependency-gated per (group, key, channel); edge data writes refresh edge channels;labeltakes the passthrough form only ({ data: key }, or the legacy'data(key)'string sugar). - Conditionals: the
casemapper.{ case: [{ when: { data, gt/lt/eq/ne/in/... }, then }], else }— clauses in order, conditions AND-ed within a clause, first match wins;whenreads any data key or the first-classid, plus the structural forms{ parent: bool }/{ child: bool }(round 14.7 — nodes only; a structural condition stands alone, AND it with data conditions via thewhenarray form). Structural conditions re-evaluate automatically on hierarchy changes. The declarative replacement for(ele) => cond ? a : b, and the natural form for typed edges (type == 'activation' → ...). CPU-evaluated (multi-key, conditional), so it stays off the GPU eval kernel and refreshes via the CPU path. - GPU evaluation: the paint/geometry split. Paint channels — fill,
border and line colors, opacities, arrow colors — are evaluated by a
per-group compute kernel that interprets the packed program array
(
render/mapper-runtime.mts,mapper-shaders.mts) and writes the existing channel storage buffers: render pipelines are untouched and there are zero pipeline permutations. A bulk data write uploads only the touched data bytes (f32 shadow + present mask; dict indices for string ordinals) and dispatches once — no CPU restyle (200k color write: 78.5 → 15.9 ms, the rest being the data-write loop itself). Geometry channels (size, border-width, shape, edge width) and the label sidecar stay eagerly CPU-evaluated — the invariant: anything read by a cull predicate, the CPU pick replica, or a columnar scan (fit, box selection, grid layout) stays CPU-canonical. Arrow alpha folds in-kernel (evaluated or constant opacity; a mapped arrow shape demotes all edge paint to the CPU, as does a mapped column promoting to mixed). Headless or adapterless instances run the whole DSL eagerly on the CPU — the kernel is an optimization layer, not a requirement. - Animations sequence by promise, not by queue (decided
2026-08-01, third design sitting; built as round 21): v4 drops
v3's per-element animation queue — queueing existed to sequence
animations, which
await a.promise()does better. Animations on disjoint channels run concurrently; starting one that overlaps a running animation's channels stops the older one in place (its promise resolves, values freeze, any GPU lease settles) and the new one captures from there. There is noqueueoption (nothing to opt out of — the spelling throws) and no v3stepcallback (onRender+ promises observe progress). Thepause/progress/reversecontrols and style transitions were logged open at the sitting; the fourth sitting (2026-08-01) scoped both as round 24 — style transitions landed as 24.1/24.2 (the bullet below) and the controls landed as 24.3:pause()/resume()/reverse()on the Animation handle (element and viewport), plus read-onlyprogress()/paused()(progressis a getter only — no scrubbing;apply/applyingstay out). Pause freezes elapsed in place (values hold, the promise stays pending) and resume excludes the paused span from the timeline; reverse swaps the tween's ends remapping elapsed to 1 − t — value- continuous exactly for point-symmetric easings (linear included), v3's start/end-swap rule — and reversing inside the delay completes at the captured start state. A paused GPU tween settles its lease (the CPU holds the exact value reached) and re-acquires on resume with the shifted clock; a paused animation still owns its channels, so the round-21 eviction stops it like any running one. - Style transitions (rounds 24.1–24.2, landed 2026-08-01). The
transition-property/-duration/-delay/-timing-functionfamily, per sheet group (nodes/edges/parents— the parents spec merges nodes-then-parents under v3's order precedence), constants-only. A transition fires whenever a restyle changes an element's stored tweenable channel — sheet re-application, mapper re-evaluation on data writes (caseflips, scale moves, auto-domain extent shifts), structural restyles (leaf↔parent flips) — under the destination sheet's config; an element's first style application on add is instant (v3's rule, kept by a per-slot styled-generation mark), batched writes capture one transition per net change at the outermostendBatch, andshow()/hide()/visibilityflips are non-triggers (fade is spelled with anopacitytransition). Interruption is the round-21 rule uniformly: latest wins between transitions and user animations, both directions, capturing from the frozen mid-flight value.transition-propertyaccepts every prop name of its group (unknown or wrong-group names throw): number/color channels in the animatable set tween (opacity both groups, background/border/line colors, border-width; since round 25 — nodewidth/heightasnode.sizelane channels, edgewidthwith its baked derivatives as lane rides — casing/overlay/ underlay strokes and the match-line/percent arrow widths, moving only when the width itself moved, with parent slots never recording a size transition (auto-bounds own them) — plus compoundpadding(captured beside the channel funnel in the parents' compound-style write; a px↔% unit flip snaps) andfont-size(the label sidecar; a diff with no entry on either side snaps)), while discrete props snap at the transition's start (recorded). Mechanics: the diff runs on stored truth around the engine's one channel funnel and packs into bulk per-column ChannelWrites (one preset animation per apply pass — never per-element animations), which keeps the auto-domain worst case (one write moves a live extent → the whole channel re-derives) in the cost class it already occupies; the store holds the pre-restyle values until the first post-delay tick (CSS's delay semantics — no target flash, and sync reads during the delay report the old state). Recorded consequences of stored-truth diffing: channel-opacity folds ride the color they fold into (background-opacitymoves transition under'background-color'), and an edge-opacitytransition carries the pre-folded arrow alphas along. The domain performance contract (docs guidance, both modes supported): with an explicitdomaina data write re-evaluates the written elements only — O(changed), never whole-channel — while'auto'pays the O(n) re-derive only when a write actually moves the live extent; pindomainwhen a stream grows its own extent. GPU path (24.2): an all-paint transition offloads to the existing gpu-tween kernels (per-frame CPU ~zero; a border-width write keeps the whole preset on the CPU, the all-or-nothing rule), and a listed transition prop's mapper eval demotes to the CPU — the diff needs fresh stored bytes, so transitions and kernel ownership are mutually exclusive per channel while the tween itself still runs on-device. Measured (benchmark/transitions.mjs, headless 200k): the auto-extent whole-channel re-derive is 326 → 594 ms with transitions on (a 1.82× constant factor, not a new class), an explicit-domain write 4.2 → 6.8 µs (O(changed)), a whole-sheet swap 1.46 → 1.67 s (1.15×), and the 200k-slot CPU tween tick is 15 ms/frame — the cost the GPU offload deletes on rendered instances. - Animation: CPU-canonical, with a GPU fast path for position and paint
under a transient lease. An animation tweens element style/position
(or the viewport) from captured start values to explicit targets over a
duration, easing normalized time (
eles.animate/animation/animated/ stop/delay,cy.animatefor the viewport; since round 24.3 the handle also carriespause/resume/reverseand read-onlyprogress/paused— see the controls bullet above). Because a tween is a pure function of time, it is CPU-reproducible — the CPU is always the reference (works headless, Node-testable), and there is no readback (a settle/stop re-derives the exact current value on the CPU).- One capture, two executors.
capture()snapshots start values into per-channelChannelWrites (column, kind, slots, packed from/to) once; the CPU tick and the GPU kernels then consume the same numbers, so the two executors agree by construction rather than by parallel implementations. - CPU path: each tick writes the store columns (dirty → redraw). The default headless path, and the path for geometry tweens.
- GPU fast path (
render/gpu-tween.mts): when a renderer is present, three kernels (position/scalar/color) evaluatemix(from, to, ease(t))on-device — per-slot from/to uploaded once, a per-batch params buffer holding{start, duration, now, curve}bumped per frame. Per-frame CPU cost is ~zero (no tween loop, no column upload) — the layout-transition-and-fade-at-scale case. Dispatch counts come from WGSLarrayLength(&slots), not a uniform:queue.writeBufferis ordered against submitted command buffers, not against dispatches inside one, so a per-dispatch value cannot live in a shared uniform. - Two tiers decide what may offload. Position runs in its own
pre-cull pass, so the pass barrier lets cull and the edge shaders read
the tweened positions (edges follow for free). Paint (
opacity, fill/border/line color, and the arrow colors) is encoded inside the cull pass aftermapperRuntime.encode(): dispatches in one pass observe prior dispatches' writes, so a live tween wins the channel over the mapper eval kernel. Geometry (width/height,border-width,edge.width, padding, font-size) stays CPU: it is read by cull, CPU pick, and every columnar scan (width()/height(),boundingBox/fit, box select), so a GPU-owned size tween would reopen the store→style layering seam R8.5 flagged — round 25 built the geometry tweens and kept this rule (the geometry-tweens bullet below). Eligibility is all-or-nothing per animation (gpuEligible), so a column is never half-owned. Easings never affect eligibility: every accepted form compiles to something both executors can run. - One curve layer, two executors (
easing.mts).compileEasingturns an easing into anEasingProgram—kindplus a bezier tuple or a progression array — which the CPU calls directly and the kernel reads out of its params (progression arrays ride a storage buffer). Accepted: v3's full enum (linearplus 25 named cubic-beziers, the same control points, so the curves are unchanged),cubic-bezier(x1, y1, x2, y2), CSSlinear(...)progression arrays (stops and all), andspring(bounce). The two evaluators mirror each other step for step (the same 11-sample bracket and Newton refinement; the same binary-search lerp) and agree to float precision, not bit-exactly — invisible mid-flight, and moot at the ends, where t=0/t=1 are exact on both sides and a settle re-derives on the CPU. - Easings are names only — a custom easing function is rejected
(v3 accepted one). A closure cannot cross to the device, so keeping it
would mean a curve that silently depends on whether the animation got
offloaded;
cubic-bezier()/linear()cover any curve you can draw. spring(bounce)is perceptual, and compiles on the CPU. It replaces v3'sspring(tension, friction)with Apple's parameterization, which reduces to a damping ratio of exactly1 − bounce(0 is critically damped, positive rings, negative is overdamped). The compiler samples the closed-form step response over the whole settling window into a progression array, so the kernel needs no physics and springs cost exactly whatlinear()costs.durationis the perceptual duration — the pace of the key movement, held constant as bounce changes — so the animation runs on past it while the ringing decays (durationMs = duration × durationScale).- Bouncy curves overshoot, and scalars clamp. Position is let
through (overshoot is the point of a spring); scalar channels clamp to
their property bounds on both executors (
opacityto [0,1],border-widthat 0), as v3 does via each property'smin/max, and color bytes clamp on pack. - Transient lease: a tweened column is GPU-owned while the tween
runs (the mirror skips its CPU uploads), so sync reads are a stale
mirror during the animation —
position()/pick/extent for position,style('background-color')for paint. On completion or stop the CPU settles the value it reached and reclaims ownership; the settle's write dirties the column, which is already the mapper's re-evaluation trigger, so a mapped channel reclaims itself with no extra machinery. Grabbing is forbidden while an element animates (pointer.canDragconsultsisAnimating), removing the two-way drag-feedback boundary. The renderer drives the frame clock while animations are active (the manager cedes its auto-loop). - Colors tween in OKLab, matching what color mappers already do by
default — one perceptual color model across the library rather than a
mapper/animation split. Endpoints are converted on the CPU and packed
as two
vec4f(L, a, b, alpha), so the kernel needs only the OKLab→sRGB direction it already shares with the mapper kernel and both executors mix identical numbers. This deliberately diverges from v3, which tweened per-channel in sRGB. - Animating
edge.opacityalso tweens the arrow colors, because the arrow vertex stage is at WebGPU's base 8-storage-buffer limit and so edge opacity is pre-folded into stored arrow alpha (stored.a = base.a × opacity). The fold is linear in opacity, so each arrow rides along as a plain color tween tobase × toOpacity. The base comes fromStyleEngine.arrowBase(), not the stored bytes, which cannot recover it when the folded opacity was 0. - Animatable today:
position,opacity(both groups), nodebackground-color/border-color,edge.line-color, nodeborder-width, and — round 25 — nodewidth/height, edgewidth, compoundpaddingandfont-size(the geometry-tween round; see the bullet below). - Viewport targets (round 10):
cy.animate/cy.animationtakepan/zoom, plusfit: { eles | boundingBox, padding }andcenter: { eles }— resolved to concrete pan/zoom when the animation is created (v3 semantics), so later graph changes don't retarget a pending fit.eles.boundingBoxAt(posOrFn)computes the box at hypothetical positions (no store writes), which is what an animated layout fit targets.panBy(round 28.2) joins them as the relative form: the delta resolves against the pan at creation, so it is an absolute target by the time the tween runs and gates onpanningEnabledlike any other. v3's override order is kept —fitbeatscenterbeatspanBybeatspan— with one deviation: passingpanByandpanthrows, where v3 silently preferredpanBy. Core-only, as in v3.
- One capture, two executors.
- Geometry tweens (round 25): CPU-canonical per tick, never leased,
never stale. The geometry numerics tween on the CPU path with the
per-tick invalidation cascade run by the store's write funnel — the
round-9.4 tier rule kept, now a recorded contract point: because a
geometry tween is a plain column write every tick,
width()/boundingBox()/pick mid-tween read the exact mid-flight value (unlike leased paint/position tweens, which go stale against the device). Landed 25.1 — nodewidth/height: two lanes of the size pair column via thelanewrite kind and the store's cascadingsetLane(setPairruns the cascade: outerHalf write-through, the monotone cull meters, label re-anchor — hoisted from the parent-materialize path, closing the raw-size-write staleness hole — and compound auto-bounds marking, so a child's size tween drives its parent's derived box per tick). Landed 25.2 — edgewidth: the width column itself reads live everywhere (quad/strip expansion, arrow sizing, cull), but three derived channels bake it at style-write, all linear in it, so the capture carries them as ride-along lane writes (the arrow-alpha-fold pattern): the casing and overlay/underlay strokes ride additively from stored truth (to = stored + Δwidth — mapper-resolved paddings and outline widths need no engine round trip), gated per slot on the layer being enabled, and the hollow-arrowedge.arrowWidthsride by mode ('match-line' → the target width, percent → pct × target, plain numbers never baked the width and stay), modes answered byStyleEngine.arrowWidthModes()(arrow widths are constants-only props). The stroke lanes of the layer records are ×256 fixed-point; the store'ssetLaneencodes on the way in. Landed 25.4 — compoundpadding: the tween writes the declared padding (px, or the fraction under the '%' unit) through a new partial-mergeupdateCompoundStyle(a{ padding }tick must not reset the unit or min sizes — sheet writes keep their reset-what-you-omit semantics), the auto-bounds flush resolves it per tick (relative modes follow live), and parents-only is the mirror of the size rule: leaves are filtered at capture and re-checked per tick. The transition capture wraps the parents' compound-style write beside the channel funnel (the styled marks are read before the channel pass marks fresh slots, so instant-on-add holds for padding too), and a px↔% unit flip snaps — tweening across units has no meaning (recorded). Landed 25.5 —font-size(both label groups): the tween patches the label sidecar per tick throughsetLabelFontSize— no engine round trip (thereanchorLabelpattern); an edge's write drives all three of its streams (mid + end labels) and re-derives the fontSize-baked edgeanchorY(−fs/2 + marginY); unlabelled elements are filtered at capture, and a transition diff with no sidecar entry on either side snaps (the −1 sentinel — a label added by a restyle has nothing to tween from). Wrapped labels re-break honestly per tick (correct, and the expensive configuration — priced in the benchmark); the defaulttext-wrap: nonecase is cheap through four label-path fixes shipped with the pass, each useful beyond tweens: (a) a pure font-size delta with unchanged breaking scale-patches the stored dims by the ratio — exactness preserved (scaling a laid block is exact) — instead of re-running the estimator; (b) the shaping-memo key dropsmaxWidthunder wrap 'none' (the breaker ignores it), so a tween tick is a memo hit instead of an unbounded-growth miss; (c)GlyphBuffer.setrewrites a same-count replacement in place (no tombstones, no highWater growth, no compaction-forced whole-stream re-uploads under a steady tween); (d) label writes no longer bump the globalgeoEpoch— its only consumer is the per-edge exact curve-bb memo, which has no label terms, so a font-size tick no longer invalidates every edge's cached bb. The compound-loop excursion bound needs no new invalidation: auto-bounds read children's outer halves, so an ancestor's outerHalfW always dominates its descendants' and the bound (a max over both ends' stretches) can only change when the ancestor's own box changes — exactly the eventmaterializeParentGeomalready invalidates on (the containment argument, recorded in PLAN.md round 25). Recorded calls:widthandheightshare thenode.sizeeviction channel (a running width tween is evicted by a starting height tween); compound parents are skipped at capture and per tick (auto-bounds own their size —paddingis the parent knob); lane writes are geometry-tier by construction and never register on the device (the runtime throws on the invariant). Costs (25.6, headless 200k-element ticks,benchmark/geometry-tween.mjs— relative factors are the story; wall numbers are machine-local): against a 65 ms paint-tick baseline, a node size tick is 122 ms unlabelled and 136 ms with center-anchored labels (the re-anchor diff early-outs), rising to 510 ms when every label hangs off an edge of the node (a sidecar rewrite per tick — riding the dims fast path, never the estimator); an edge width tick over 400k edges is 86 ms bare and 130 ms with the full ride set; a padding tick + auto-bounds flush over 25k parents (×8 children) is 75 ms; a font-size tick is 213 ms under wrapnonevs 767 ms wrapped (the honest per-tick re-break — the recorded expensive configuration). Browser pins (thewebgpuproject): a sheet-swap width transition tweens pixels mid-flight withwidth()reading the mid-flight value and the hanging label's anchor tracking −w/2 exactly, and an edge-width transition passes through the casing-band state (white → black casing → red line) at a fixed sample point — only a riding stroke produces it. - Synchronous reads reflect writes; staleness is scoped to motion,
never to a frame. A frame-stale read contract was considered
(let the GPU own expensive geometry and read back a frame later) and
rejected as a default: read-after-write is pervasive and
load-bearing (
data()thenwidth()/bb()in one tick must see the write — layouts and extensions rely on it), headless has no frame or readback so it would still need the full CPU implementation plus a weaker contract, and "a frame stale" is undefined in synchronous code (build-graph → query-bbs loops never reach a frame, so the staleness is unbounded). Staleness is admitted only where a value is already in frame-driven motion — the position tween lease is exactly this, andedge.bb()mid-tween inheriting that staleness is consistent, not a new rule. A discrete user write is never stale. The escape hatch for callers who want GPU-exact geometry after a batch of writes is an explicitawaiton a settle/flush, not a relaxed sync contract. - Expensive GPU-computed geometry uses dual implementations, not
readback. Some geometry is both expensive and read by
.bb()— multiline label metrics (line breaking + block extent) and bundled bezier control points (a v4-but-not-yet direction). Unlike a position tween, these are not cheaply CPU-reproducible, so the position lease's no-readback trick does not apply directly; the safe model is two deterministic implementations that agree by construction (WGSL for the render path, a CPU implementation for reads), each run on the same inputs — never one side reading back the other's result. This is the same discipline already used for the OKLab LUT, mapper stop tables and easing curves, generalized to expensive computations; the cost is keeping the two impls bit-agreeable (divergence shows as bb-doesn't- match-pixels), which is the real gate on whether GPU is worth it per case. Two consumer tiers keep it affordable: cull/fit read a cheap conservative CPU over-approximation (guaranteed to contain the true box — e.g. label: node size + charcount × max advance; bezier: endpoint hull + control-offset bound), while public.bb()triggers the exact lazy CPU compute, memoized per element. For bezier, control points aref(positions, membership), so mid-position-tween they are stale via the lease (consistent) and settle when positions are reclaimed; bundle membership is a cheap CPU structural index rebuilt on add/remove edge, not per frame. - Labels are model-space only.
font-sizeand the wrap width are both in model coordinates (v3 parity), and there is no viewport-fixed label mode. This is load-bearing three ways: (1) line breaking is then zoom-invariant (font-size and wrap width share a space), so label shaping — the expensive part — memoizes and the GPU metrics pass runs on text/font/wrap writes, not per frame (a mixed space would reflow on every zoom and defeat both the CPU memo and the GPU offload); (2) image export is WYSIWYG — afull/high-scaleexport is the screen arrangement at a different transform over identical shaping, so figures for scientific publishing don't reflow between screen and export, and the export path reuses the screen memo verbatim; (3) it matches v3, so existing figures reproduce. Screen-space labels were rejected because they break export WYSIWYG (labels reflow at a scale ≠ current zoom) and their apparent legibility win on dense graphs is really overlap that makes a worse figure — unreadable labels on a huge network are a data-density limit answered editorially (export resolution, label a subset,min-zoomed-font-size), not by a coordinate system. The label-visibility sub-decision is taken (round 9.6): LOD thresholds (labelFadePx,labelMinPx) evaluate at export scale — a full/high-scale export is a self-consistent figure, not a copy of the screen's label culling. - Image export is async, WYSIWYG, and pixel-pinned.
cy.png()/cy.jpg()render the scene into an offscreen texture at the requested viewport (the current view, or the graph bounds withfull) and read the pixels back — the one category where async is the design: rendered pixels are genuinely GPU-only, unlike resolved-style reads, which stay synchronous. v3's option surface is kept (bg,full,scaleormaxWidth/maxHeight,quality,output), every output form resolves through the returned promise, and jpg defaultsbgto white (JPEG has no alpha). Exports are encoded inside the frame loop after that frame's scene work, so they see exactly the state the screen shows — including GPU-owned columns mid-tween (a mid-animation export shows the tweened position the lease makes stale on the CPU). They always render at native resolution (the adaptive render scale never applies), and label LOD evaluates at the export scale (above). The WYSIWYG guarantee is enforced by a Playwright self-diff: a viewport export at scale 1 pixel-matches a screenshot of the live canvas. Headless instances reject (there is no renderer to export from). - Fonts: one global
font-family, and a fixed web font for label tests (round 9.7). The glyph atlas is keyed by character — one font per atlas by design — sofont-familyis a constant, effectively global node style prop (defaultsans-serif); changing it resets the atlas and re-lays-out every label through the existing label-dirty channel.font-styleandfont-weight(round 13 D1) ride the same rule: global constants feeding the atlas's CSS font shorthand, with v3's value sets (normal | italic | oblique; the weight keywords plus the numeric hundreds), any face change resetting the atlas the same way. Per-element fonts would re-key the atlas by (font, char) and are out of scope. Label-test reliability comes from pinning all three variance sources: the font file (a vendored OFL web font, Open Sans via devDependency, loaded with FontFace before instance creation), the GPU (the SwiftShader-pinned visual project), and tolerance for the one layer a web font cannot pin — Chrome rasters the atlas via CoreText on macOS and FreeType on Linux, so label goldens carry a looser diff bound than geometry goldens (per-platform goldens are the reserve escape hatch if CI proves that insufficient). Footgun, and why specs pre-load the font: the atlas rasters glyphs lazily and caches them forever, so a glyph rasterized before the web font finishes loading is cached from the fallback font — but (round- the renderer listens for the font set's
loadingdoneevent and re-rasters the atlas + rebuilds every glyph run when a web font finishes loading, so late-loading fonts self-correct. Specs still pre-load to keep goldens deterministic.
- the renderer listens for the font set's
- Edge labels: built (round 10, pass 1) — exactly the committed
shape: a second glyph stream parallel to the node one (own instance
buffer, own cull group, own draw call, shared atlas); edge glyphs
anchor at the edge midpoint computed in the vertex shader from
the two endpoint positions, so edge labels follow drags, layouts and
position tweens on-GPU with zero rebuild (spec-pinned: an endpoint
move re-uploads ≤ 64 bytes of position column — no glyph is
rewritten — and the label lands at the new midpoint).
The cull predicate mirrors the edge cull (edge SHOWN + both endpoint
nodes SHOWN); the model side group-keys the label sidecar,
label-dirty channel and StyleEngine label channels (the
labelpassthrough,font-size,colorand all the round-10 text visuals work for edges; the text block centers on the midpoint by font size). Edge labels are not pickable, like node labels. Text draws horizontally by default;text-rotation: autorotate(landed 2026-07-29) rotates the glyph run to the edge's angle in the vertex shader, so the rotation also reads live positions and follows drags/layouts/position tweens on-GPU with zero rebuild. The flip rule is v3's verbatim: the angle is the edge's undirected slope (atan(dy/dx)), so the baseline stays within (−90°, 90°] and text never reads upside-down — vertical edges read top-to-bottom at +90°. The keyword (none|autorotate; numeric rotations throw, and the prop throws on the nodes group — per-element numeric rotation is a logged parity gap) is mapper-capable like the other label channels; the model bakes only a flag — bit 31 of the glyph instance's owner word (element slots stay far below 2³¹, and the dead sentinel is the full-ones word) — which the background quad carries too, so a text box rotates with its text, and the edge-glyph cull kernel tests the exact rotated-rect AABB in the same rotation frame as the VS. - Removed elements are terminally dead (decided 2026-07-27). An
element's substance lives in the columns, not the handle, and
remove()tombstones the slot, bumps its generation and free-lists it — the nextadd()may recycle and overwrite those bytes. So v4 does not keep removed elements readable: a ref held in an old collection fails generation validation and reads as dead, and only the handle's cachedid()/group()stay readable (kept forremove-event handlers and predicates). This closes v3's hold-a-removed-element pattern permanently:restore()/clone()and the import form ofcy.json()are not coming to v4 — re-adding from kept definitions is the app's job (exported element json round-trips throughcy.add()). - Slot-stable structures self-compact on waste thresholds (round
11). Three append-only structures leak under remove/add churn even
though the tables' slot free-list keeps the columns from growing:
the id blob (removed ids' UTF-8 bytes), the CSR adjacency (stranded
per-node segment space plus the incremental overlay arrays), and
string data dictionaries (entries whose last reference was
overwritten or cleared). Each meters its waste and reclaims
automatically when it exceeds half the live size (small floors keep
tiny structures from churning) — the threshold policy the
insertion-order list has always used, and no new API. These
reclaims move no element slots, so refs, draw order and the GPU
mirrors are unaffected. Specifics: the id blob compacts live byte
ranges into a right-sized blob (the probe table keys on (group,
slot), never offsets, so it survives; peak-then-small graphs shrink
back toward the floor); the adjacency rebuilds CSR from the live
edges in insertion order (preserving per-node incident order — the
one exception being an edge re-pointed by
move(), which sits at its re-add position until a rebuild returns it to insertion order — and folding purely incremental graphs into the compact CSR shape once past the floor); dictionaries refcount their entries and remap the indices column in place with a per-column epoch, so the GPU ordinal LUT and uploaded index shadow repack through the normal watched-key span path while element values never change (ordinal domains are explicit, so no styling output can move). Slot-moving compaction (dead element slots,highWater, pass widths) was deferred here and since landed as round 19 (see the slot-compaction section above — the policy calls this bullet once logged open are all taken). - GPU layouts: logged for later. A force layout is stateful
(
pos[t+1] = pos[t] + forces(pos[t])), so unlike animation it is not cheaply CPU-reproducible — the GPU would be authoritative during a run with a readback on convergence, and headless would fall back to a CPU reference implementation (which doubles as the spec the kernel must match). It reuses this round's lease + readback machinery, but the per-algorithm kernels and convergence detection are a future round. (Since built: the round-18forcelayout below is exactly this design.) - Curved edges (round 12; the two flagged calls signed off
2026-07-30). v4's default
curve-stylestaysstraight— the perf-first default at v4's target scales, a deliberate divergence from v3's bundled-bezier default (apps and parity scenes opt intobezierexplicitly). Andbezierbundles multi-edges only, verbatim v3: a lone edge between two nodes renders straight undercurve-style: bezier, only parallel edges fan out, and the middle edge of an odd bundle is straight (v3's rule), so curved scenes are pixel-comparable in the live v3-parity harness.- Geometry (12a):
curve-geometry.mtsis the CPU twin of the curve WGSL — v3's formulas verbatim (the intersection frame, the bundle stagger, the loop construction, boundary endpoints toward the control point), one fixed drawn subdivision (CURVE_SEGS = 24 quads per curved edge), and the conservative hull-deviation bound for cull/fit. Per-edge parameters live in theedge.curveParamscolumn (f32×4, kind packed at [3]) and are position-independent — offsets/weights/angles in the endpoint-relative frame — so drags, layouts and position tweens follow on-GPU with zero rebuild. Node boundaries reuse the arrow shader's approximation tier (ellipse/rect exact, round-rect as its box, polygon as its inscribed ellipse) rather than v3's exact per-shape intersections — a recorded deviation, exact for the default ellipse nodes. - Props + derivation (12a):
curve-style(straight|bezier),control-point-step-size,control-point-weight,loop-direction,loop-sweep— edge-only, v3 defaults, constants or mappers; angles take numbers (radians, v3's pfValue convention) ordeg/radstrings and read back in radians. Readback follows the styled record (a lonebezieredge reads back'bezier'though it renders straight — v3 semantics). Thestore/curve-index.mtsbundle index derivesedge.curveParamsfrom the records: the pair map is built lazily on the first bezier record (a straight-only graph pays one loop check per edge add and nothing else), per-node loop lists are always maintained (loops render as loops under every curve style — a v4 deviation: v3 routes straight-styled loops through its unbundled path, v4 always uses the bundled loop construction), and pending pairs re-derive lazily at takeDelta/boundingBox/accessor reads. Fit reads a conservative hull bound per curved edge; the frame-levelstore.curveSlack()bound (monotone maxima) is what the cull kernels grow their straight-chord tests by, since per-edge params can't bind in every kernel within the 8-storage-buffer budget. - Accessors + exact bb (12a):
isBundledBezier()(the v3 style check),controlPoints()/renderedControlPoints()(one point for a bundled bezier, two for a loop, undefined for straight edges — since 12b also the unbundled control list, withsegmentPoints()covering segments/taxi);midpoint()is the curve midpoint andsource/targetEndpoint()the curve's boundary endpoints when the edge curves. Publiceles.boundingBox()is the exact lazy tier of the expensive-geometry design: the flattened polyline at the drawn subdivision, memoized per edge against a store-wide geometry epoch (any geometry write invalidates every cached box — over-broad but sound); fit/cull keep the conservative bounds. - CPU costs (round 29.4, Node sweep,
benchmark/curves.mjs, 20k nodes / 40k bundled-bezier edges, every row against the straight graph of the same shape): derivation is deferred to the first read, which is the shape of every number here — a bulkpositions()write is 0.97× (curves cost the write nothing), the first read after it 1.46×, the same read again 1.22×. The premiums that matter: box selection ~2.3× (the exact curve-vs-rect test — corrected in round 33.5, see below), a bundle re-fan onhide()/show()2.7–3.8× (~3.5–5.2 µs per pair, paid at a sibling's next read),controlPoints()~1.6×, a single-node drag ~1.5×, the whole-graph exactboundingBox()~1.2×, the conservativefit()scan 1.05×. Two rows read ≈1.0× until the benchmark was corrected to force the deferred work: the bulk write genuinely is free, but the re-fan row was measuring a flag write until it read a sibling afterwards. Round 33.5 corrected a third: the box-selection row passed a box object tocy.elementsInBox, which takes four numbers and silently answers the empty collection when handed one — so the 3.29× originally published here was measured on a call that never ran the curve-vs-rect test it names. Fixed, the premium is 2.28–2.35× over two runs. Note also that this suite times one shot per row rather than sampling like the mitata suites, so its ratios move ~±20% run-to-run (the unchanged re-fan row read 3.79× when first published and 2.66–2.98× on re-measurement); read them as magnitudes. - Rendering (12a): curved edges draw in their own pipeline — one
instance per edge as a strip of 24 quads whose vertex shader
evaluates the curve analytically from live positions + the params
column (the WGSL twin of
curve-geometry.mts), extruding along the curve normal at each vertex's own t so the strip is watertight without miter joints. The VS binds exactly 7 columns + the visible list (the base 8-storage-buffer budget); line color/opacity/style fetch in the fragment stage, and dashes follow the curve's polyline arc length. The cull pass splits the edge draw into straight and curved streams on the store-managed FLAG_CURVED bit — the curved stream's chord test grows by the frame'scurveSlackand is not decimated (curved edges are opt-in and far fewer; a far-zoom haystack revisits this in 12c) — and the GPU pick tile draws the same strips, so what you see is what you pick. Curved edges draw after straight edges (two streams; slot order within each — a z-order deviation alongside the existing edges-under-nodes rule); both stream under arrows, nodes and labels, and early-z applies to both. - Arrows (12a): curved-edge arrowheads point along the curve's
true end tangent — the straight arrow math with the control point
substituted for the far endpoint (a quadratic's end tangent runs
control → endpoint), one quad per end off the curved stream's
single-quad indirect args block. (12a recorded a border-exclusive
deviation — no spare binding for the node border column — which
the 12b
node.outerHalfderived column closed: tips sit on the border-inclusive outer boundary now, like the straight arrows.) - Props + derivation (12b):
curve-stylegainsunbundled-bezier|segments|round-segments|taxi|round-taxi(haystack/straight-triangle stay 12c), withcontrol-point-distances/-weights,segment-distances/-weights/-radii,radius-type(per-pointarc-radius/influence-radiuslists, last entry repeating — v3's rule),edge-distances(intersection|node-position;'endpoints'throws until 12c's manual endpoints exist),taxi-direction,taxi-turn(px, negative = from the target, or a percent string storing v3's fraction),taxi-turn-min-distanceandtaxi-radius. All edge-only; scalars/enums are mapper-capable like the 12a props, list props take constants only (a mapper value is one number/keyword, not a list — a recorded scope note). List constants accept arrays or v3's space-separated strings; lists read back as space-separated strings, percent turns as the percent string. Derivation is per-edge (none of these families bundle) into blob-backed records via the CurveIndex; deviations, recorded: interior counts cap at 8 controls / 11 segment points (the strip subdivision); weights clamp to [-1, 2] and any weight outside [0, 1] marks the edge box-bounded (FLAG_CURVED_BOX) for the cull tier;unbundled-bezierwithoutcontrol-point-distancestakes a single control at the step size — matching v3, whose staggerednormctrlptDistis dead on that path (itsedgeIsUnbundledbranch assigns the plainctrlptDist), so this is parity, not a deviation; unbundled-family loops usecontrol-point-distances[0]as the loop distance (v3), falling back to the step size when unset (v3 yields NaN geometry there); and segments/taxi-styled loops keep rendering as loops (the 12a all-loops deviation extended). - Props + derivation (12c):
curve-stylegainshaystack(+haystack-radius, validated [0, 1], default 0 — v3) andstraight-triangle— both derive to straight-stream kinds (FLAG_CURVED clear: they draw in the straight pipeline, so haystack keeps far-zoom decimation). Haystack angles are id-hash-seeded (deterministic across loads/machines; v3 uses Math.random()), offsets scale by the outer halves (inner size in v3 — identical at border 0, recorded), and haystack edges draw no arrows (v3 skips them; stored-truth arrow getters read 'none' — recorded).source/target-endpoint(keyword | 'x y' point with per-component %/px units | angle;-or-labelkeywords throw — no label bb) andsource/target-distance-from-noderesolve through a 10-float endpoint block prefixed to the edge's blob record (CURVE_HAS_ENDPT): straight + endpoints ⇒ the MULTI n = 0 chord, bundled bezier + endpoints ⇒ promoted MULTI n = 1 (identical control formula), taxi keeps distances but forces the keyword modes (v3's override), and loops ignore endpoints entirely (v3 overrides keywords; v4 also drops loop distances — recorded).edge-distances: 'endpoints're-bases the frame on the raw manual anchors when both ends are manual, else warns and falls back (v3's rule). Scalar 12c props are mapper-capable; endpoint props are constants-only (the point form is a list). Cull bounds: px offsets ride the header deviation; pct offsets ≤ node-half ride the slack's node-half term, larger ones mark FLAG_CURVED_BOX and feed the monotone pct term in curveSlack(); haystackSlack() bounds the straight-stream tests. - Geometry + rendering (12b): the route families share 12a's one
curved stream of CURVE_SEGS strips (one indirect draw needs one
indexCount). Variable-length records live in the curve param
blob (
store/curve-blob.mts— round-11 waste-threshold compaction; the params column holds the[offset, dev, n, kind]header, so records stay position-independent and drags/layouts/ tweens cost zero blob traffic); the blob mirrors as one storage buffer, bindable becausenode.outerHalffreed a slot in every curve shader. The route evaluator (evalRoute/evalRouteW— dual impls, same blob) maps subdivision indices onto route pieces so piece boundaries land exactly on indices: legs stay pixel-straight and corners exact regardless of quad distribution (hence the 8-control/11-point caps). Sharp corners join with a clamped discrete miter (v3's canvas setslineJoin: 'round'on edge paths — a recorded deviation confined to the outer join wedge; the live parity diff still measures 0 px at 8 px strokes); round corners are v3'sgetRoundCornerarcs, ported as the purecomputeCorner/computeCornerWpair. Cull: chord-bounded routes grow the 12a chord test by their header deviation via the frame slack; box-bounded ones (taxi, extrapolated weights — FLAG_CURVED_BOX) test the endpoint AABB grown by slack + chord length instead, since no frame constant bounds their excursion. The pick tile draws the same strips, andrefsInBoxtests curve boundary endpoints (the box-selection revisit, closed). - Arrows + accessors (12b): a route's end tangent runs from the
first/last interior point to the boundary endpoint, so route
arrowheads are the straight arrow math with that point substituted
(taxi arrows ride the final axis-aligned leg). The curved-arrow
vertex stage needed the blob, so this end's arrow colors moved
to the fragment stage — no-arrow ends rasterize a small
fully-transparent quad instead of collapsing in the VS (bounded
overdraw on the opt-in curved stream). Accessors:
segmentPoints()/renderedSegmentPoints()answer for segments and taxi (v3 types taxi as 'segments');controlPoints()covers the unbundled control list;midpoint()/endpoints and the exact lazyboundingBox()follow the route via the shared evaluator. - Edge labels (12a; routes since 12b): labels of curved edges
anchor at the curve midpoint, computed in the label vertex
shader from live positions + the params column (zero rebuild,
like everything else) — since 12b, route owners anchor at
routeMidpointW(v3's per-family midpoint rules).text-rotation: autorotateneeded no new math for beziers — a quadratic's t = 0.5 tangent is its chord direction, so the endpoint frame is exact — loops rotate along their c1→c2 midpoint tangent, and route owners take the route midpoint tangent (the arc-apex tangent on round middles, the leg direction on polylines). The edge-glyph cull grows its chord-midpoint test by the frame's curve slack for curved owners (its own 8-buffer budget precludes a params binding), plus the chord length for box-bounded owners; rotated curved labels cull against a frame-independent anchor-centred bound.
- Geometry (12a):
- Parity triage (2026-07-29) — decisions on the v3 leftovers from
the gap analysis. Dropped: the canvas-era perf degradation
options (
hideEdgesOnViewport,textureOnViewport+outside-texture-bg-*,motionBlur/motionBlurOpacity— compute culling + adaptive render scale solve the same problem without degrading output),background-blacken(compute the shade in a color mapper's range instead),bounds-expansion(bounds are computed correctly instead), and the legacy aliases (content,padding-{left,right,top,bottom}, no-dash shape spellings, redundantattr-family duplicates — one name per concept). Two recorded exceptions to that rule (fifth design sitting, 2026-08-04):cy.autolockNodes()andcy.autoungrabifyNodes()were listed in the original triage and are kept — judged possibly useful, and wired and working since long before the call. The same sitting went the other way on the third survivor:roundrectanglewas accepted wherecutrectangleandconcavehexagonthrew, and now throws with them (round 37.2). Kept, with direction:curve-style: haystack(+haystack-radius) andstraight-trianglereturn as real visual styles — not perf modes — with the curved-edge work; ghost props return for SBGN in a simplified form (the ghost duplicates only the basic node body — shape, border, background — at the offset as an extra draw, never a whole-cloth redraw of the full node with labels and decorations); overlay/underlay,active-bg-*andselection-box-*become stylable props, with today's baked-in affordances (shader hover/active brighten, the accent ring, the DOM selection box) as the styled defaults. Deferred:text-metrics/box-select-labelsget their v4 form in the multiline/label-bb round. (Since landed, round 16.4/16.5:eles.labelBoundingBox()andboxSelectionIncludesLabels.)
data(): element data lives in a columnar sidecar — per-(group, key)
columns, not per-element objects: numbers as Float64Array, strings
dictionary-encoded, a plain-array fallback for the rest, each column
adapting to what it holds. id (and source/target on edges) stay
first-class and immutable. Setters emit data per element.
Node labels (SDF): the label style prop takes constant strings or the
passthrough mapper ({ data: key }, or the legacy 'data(key)' string;
id reads the first-class id); mapped labels refresh on data writes (fn
styles do not — see the refresh policy above). font-size and color
take constants or mappers (CPU-evaluated — the label sidecar is not a
GPU column); font-family, font-style and font-weight (round 13 D1) are
constants and effectively global (one face per atlas, defaults
sans-serif/normal/normal — a change resets the atlas and
re-lays-out every label; see the fonts design decision above). Glyphs
come from a runtime SDF atlas (canvas-2D raster → Euclidean distance
transform → one r8 texture) and live in a persistent instance buffer keyed
by node slot — the label vertex shader reads the node position buffer, so
labels follow drags and layouts on-GPU with zero rebuild. Labels fade out
below the labelFadePx LOD threshold.
Events: no namespaces, in the design and — since round 41.2 — in the
code. v4 dropped the 'tap.foo' form (unused, and a per-emit parse
cost), but until round 41 it imported v3's emitter and so inherited v3's
namespace parsing wholesale: a hand-emitted 'tap.ns' behaved exactly as
in v3, which round 37.4 measured after this paragraph had claimed for
several rounds that the parsing was kept "only for v3". v4's own emitter
matches a type whole, so a dot is an ordinary character in a name:
on( 'tap.ns' ) registers a listener for the literal type 'tap.ns',
which v4 never emits and which a plain emit( 'tap' ) does not reach.
Delegation is
predicate-based (cy.on('tap', ele => ele.isNode(), cb)); on remove
events the target handle's cached id()/group() stay readable inside
the predicate, while live state reads report false. Compound
bubbling (round 14.5): every element event on a parented node
bubbles origin → ancestors → core with v3 semantics — event.target
stays the originator, the callback context (this) is the phase
element (v3's currentTarget), and stopPropagation() or a callback
returning false halts the walk. Core predicates keep firing once
against the originator (v3's core-selector delegation), and
orphan/edge targets emit flat exactly as before. The remaining
order deviation is within-phase only: listeners registered on the
same element (or the core) fire in registration order.
Out of scope (deferred): string-formatting label mappers beyond the
passthrough, and the GPU tween fast path for size channels
(position and paint offload today; size is a geometry-tier project,
see the design decisions above). Compound nodes landed as round
14 (PLAN.md, "Round 14 plan — compound nodes", planned and landed
2026-07-31): parent/child hierarchy in the columnar store with
auto-sized parents materialized into the position/size columns,
parents-under-descendants draw order, ancestor-gated visibility +
rendered effective opacity, ported event bubbling, a parents sheet
group plus structural case/query conditions, compound loop edges,
and the layout/tween/interaction rules — each item its own
tests-first commit, with the design decisions and deviations
recorded in the round-14 paragraphs above and the summary bullet in
the deviations list below. v3 compound surface not ported (the
usual one-name-per-concept and geometry-tier calls): the four
min-size bias props (the centered clamp instead; a future round may
add per-side padding props), compound-sizing-wrt-labels: 'include'
(compound auto-sizing reads child body extents, not labels — the
reason narrowed by round 16.4, which put labels into public bb/fit),
:parent:selected restyling, and
z-compound-depth/z-index-compare (dropped outright with z-index,
2026-08-01).
Landed so far (round 14.1 — the hierarchy model): parent is a
first-class node field, not sidecar data — like edge
source/target it is reserved at ingest, immutable through
data() (reparenting is move({ parent }), round 14.2), and
synthesized on read from the hierarchy (data('parent') returns
the parent's id, absent for orphans). The hierarchy itself lives
in a store-side index (store/hierarchy.mts): parent links with
generation guards, per-parent child lists, nesting depth, and the
store-managed FLAG_PARENT/FLAG_CHILD bits in the flags column
(so parent/child predicates stay pure flag scans and the cull
kernels can split the node draw without new bindings). Cycle rule
is v3's: an assignment that would make a node its own ancestor
warns and drops the ref, no throw. A node with children can not be
removed (the collection layer cascades descendants first, v3's
removal semantics), and cy.hasCompoundNodes() reflects live
parents.
Round 14.2 (the compound collection API + lifecycle): the traversal
surface — parent/parents (=ancestors)/children/
descendants/siblings/orphans/nonorphans/commonAncestors
and the isParent/isChildless/isChild/isOrphan predicates —
is slot-native over the hierarchy index with v3 orderings
(ancestors nearest-first, children in link order, descendants
pre-order). One recorded deviation: parent() always returns a
proper collection (v3's single-element fast path returned a raw
element ref and ignored the selector argument). remove()
cascades over descendants and their incident edges (v3);
move({ parent }) re-parents in place — the node keeps its
slot, id, data and edges (v3 does a remove/restore refs cycle) —
emitting moveout then move per changed node; an unknown parent
id is a silent no-op (v3) and a cyclic assignment warns + drops.
Def ingest resolves data.parent in a second pass once the batch's
nodes all exist, so forward references work in any def order;
numeric parents coerce to string ids (v3), and unknown/non-node
parents warn and leave the node an orphan. Element json()
carries parent and round-trips through cy.add().
Round 14.3 (auto-bounds): a parent's geometry is derived from its
children and materialized into the real node.position/node.size
columns by a lazy flush (the CurveIndex pattern: geometry writes
mark ancestor chains pending; GraphStore.flushDerived() — always
hierarchy before curves — drains at takeDelta/bb/refsInBox and the
geometry accessors), so bounding boxes, culling, picking and the GPU
mirror consume parent boxes with no special cases. The derived box
is v3's updateCompoundBounds math: direct children's
border-inclusive extents (hidden children excluded — v3's
display:none rule), padding in px or % of the pre-clamp children bb
per padding-relative-to, the centered min-width/min-height
clamp (the four bias props are dropped by decided design — a future
round may add per-side padding props instead), and a degenerate
fallback to the stashed style size at the stored position when no
shown children remain. The stored size is the padded/drawn box:
width()/height() subtract 2·padding (v3's autoWidth/autoHeight),
paddedWidth/paddedHeight return the drawn box, outerWidth adds
the border, and padding() answers the resolved padding. Setting a
parent's position shifts its whole subtree by the delta (locked
children move too — v3), shift() skips elements whose ancestor is
also shifted (v3's dedupe), descendants moved by a parent's write
emit position (listener-gated), and relativePosition is
compound-relative (model minus the immediate parent's position).
A parent's label re-anchors when auto-bounds resize it (the store
inverts the engine's anchor bake from the sidecar entry). A node
that stops being a parent returns to its stashed style size.
Round 14.4 (ancestor gating): hide() on a parent hides its whole
subtree. The element's own state lives in a new FLAG_SELF_HIDDEN
bit and FLAG_VISIBLE is the effective shown bit (own state AND
no hidden ancestor), recomputed over affected subtrees on visibility
and hierarchy changes — so the cull kernels, columnar scans, bounding
boxes, box selection and the CPU pick all honor ancestor gating by
reading the one bit they already read. A child's own hidden state
survives parent toggles (v3's visible() semantics);
takesUpSpace()/interactive() ride visible(). Box selection
now also requires both edge endpoints shown (the drawn-edge rule —
previously a hidden endpoint's edges stayed box-selectable, a gap
this closes). Effective opacity renders: the stored node
opacity is base × ∏ ancestor bases (v3's product rule — a
deliberate extension of the round-13 fold pattern to a cross-element
fold), so descendants dim with their ancestors on screen;
style('opacity')/numericStyle read the declared base,
effectiveOpacity()/transparent() the fold, and edges keep their
own opacity (v3: edges have no parent). While compounds exist a
GPU-mapped node opacity demotes to the CPU path (the kernel would
overwrite the fold) — the demotion engages/disengages on the
compounds 0↔>0 transitions.
Round 14.6 (the parents sheet group): parent nodes style through a
fourth sheet key — { nodes, edges, parents, core } — whose
channel props overlay the nodes group for parent slots, with v3's
order-based precedence (the default :parent overlay — rectangle,
#eee fill, 1px #ccc border, padding 10 — < user nodes block <
user parents block; v3 applies style blocks in order, and the default
stylesheet sits before the user's, so a user node block restyles
parents too — pinned by the live parity scene).
Parents-block values are constants or mappers, evaluated for parent
slots only; a leaf↔parent flip restyles the node against the right
group automatically. The compound props live in the parents
group and are constants-only: padding (px, or 'N%' of the
children bb per padding-relative-to: width | height | average |
min | max), min-width/min-height (the centered clamp), and
compound-sizing-wrt-labels, where 'exclude' is the only
accepted value ('include' throws — compound auto-sizing reads
the children's body extents, not their labels; since round 16.4
public bb/fit do include labels, but the auto-bounds derivation
deliberately does not — recorded). Compound props throw outside the
parents group. Readback answers from the per-parent record
(style('padding') returns the declared px number or the percent
string; leaves read 0, as v3 leaves do). The v3 :parent:selected
tint is not ported — v4 never restyles on selection (the shader
accent ring is the selection affordance); recorded deviation.
GPU mapper eval: nodes-group paint mappers on channels the parents
group resolves differently (default-overlay channels the nodes block
does not override, plus any user parents-block prop) demote to the
CPU path while compounds exist — the eval kernel runs over every
slot and would repaint parents with the nodes-group value; a
recorded scope note.
Round 14.9 (the parent draw stream): parent bodies render in their
own culled stream drawn right after the depth prepass — under every
edge layer, arrow, leaf and label, v3's compound order — while the
main node stream (and the depth prepass with it) excludes parents on
FLAG_PARENT. Draw order among parents is depth-asc, slot-asc
(outer under inner): the parent cull kernel iterates a CPU-built
permutation uploaded only on hierarchy changes and writes the
permuted slots, so its visible list is already in paint order with
no GPU sorting. The CPU node pick mirrors this in two passes —
leaves by descending slot, then parents in reverse permutation
order — so a parent never swallows its children's picks and the
padding band picks the parent. Recorded deviations: parents are
excluded from the early-z prepass (their interiors must not kill
the edges/children drawn over them — they lose the occlusion
benefit); parent ghost/underlay/overlay/label decorations keep
their existing post-edge draw positions (permanent since the
2026-08-01 z-index drop — decorations are top-tier accents by
design); and v4's parent boxes can sit sub-pixel smaller than v3's
when children have borders — v3's node bb includes the border's
miter-corner overshoot (~(√2−1)·border/2 per side on cornered
shapes) while v4's child extents are the plain border-inclusive
outerHalf (the parity-compounds scene carries a looser bound
for exactly this).
Round 14.10 (compound loop edges): an edge between a node and its
own ancestor/descendant — or a self-loop on a parent — routes
around the outside, whatever its declared curve-style (v3's
default edge:compound block produces the same behavior; a
recorded rule like the forced self-loop construction). The
construction is v3's findCompoundLoopPoints verbatim: two control
points off the endpoints' min top-left corner, stretched by
max(0.5, ln(outerWidth × 0.01)) per end, drawn as two
C1-continuous quadratics through the control midpoint (the loop
pipeline). Control points evaluate from live positions and outer
halves in both the WGSL and the CPU twin, so drags, layouts and
auto-bounds resizes follow on-GPU with zero re-derivation;
reparenting re-derives the moved subtree's incident edges, and a
leaf↔parent flip re-routes its self-loops. Compound loops are
box-bounded for culling (FLAG_CURVED_BOX) with a derivation-time
excursion bound feeding curveSlack() (a 2× stretch margin —
stretch grows only logarithmically with node size, and parent
resizes refresh the bound; recorded). The conservative fit box
does not add the chord length for this kind (corrected
2026-08-05): the chord belongs to the weight-extrapolated blob
routes that share FLAG_CURVED_BOX, where a
control-point-weight outside [0, 1] puts a control that far past
an endpoint. A compound loop's controls hang off the union of the
two node boxes, at most half the excursion bound past its top-left
corner, so the endpoint AABB grown by header + node-half already
contains the curve — and the bb scan visits both endpoints, so
whichever node owns that corner covers it. Adding a chord made
fit() draw every compound graph with a related edge at a
fraction of its size (measured on debug/'s compound fixture:
1718 × 1572 against an exact 802 × 637). The cull kernel
keeps the chord term deliberately: over-inclusion there costs
efficiency, never correctness. Deviation: v4 anchors the
curve endpoints outside-to-node (toward the near control) where
v3's edge:compound block defaults them outside-to-line — a small
angular difference at the boundary, measured at 0.022% in the live
parity scene. controlPoints()/midpoint()/boundingBox()
answer through the shared evaluator like loops.
Round 14.11 (layouts, tweens, interaction): every built-in layout
positions leaves only — parents derive from their placed
children (v3's layoutPositions rule; preset skips parent entries
in both its forms, since a parent position write shifts the whole
subtree). boundingBoxAt skips parent bodies (the leaves'
hypothetical boxes stand in; the parent padding margin is not
modeled — a recorded fit-target approximation). A position
animation targeting any compound-related node (parent or child) is
not GPU-offload-eligible: the lease would leave the CPU
position columns stale under the auto-bounds derivation, and a
tweened parent must shift its subtree per tick — both CPU-only
semantics; unrelated leaves in compound graphs still offload.
Reparenting settles any live GPU tween to the CPU first
(AnimationManager.settleGpuAll). Dragging a parent needs no
special pointer handling — the grab resolves through the compound
pick order and the position write shifts the subtree; dragging a
selected parent together with its selected child moves the child
exactly once (the collection shift dedupe).
Manual edge endpoints + haystack/straight-triangle landed as round
12c (2026-07-30/31 — the round-12 curved-edges plan is complete).
The 2026-08-01 design sitting dropped z-index outright and scoped
rounds 15–18, all landed the same day: background images (15),
multiline labels + label bounding boxes (16 — closing the multiline
direction), the event vocabulary + extension contract (17), and the
GPU force layout (18 — closing the round-9 "GPU layouts: logged"
hook). Their sections above and the PLAN.md records carry the
detail.
Background images (round 15, landing)
The 16-prop background-image family lands on the design calls
recorded in PLAN.md ("Round 15 plan"): size-tiered texture arrays
with hardware mips (never a shelf atlas), SVG zoom-promotion with
export-time re-raster, an explicit SDF icon mode for monochrome
vector icons, and multi-image parity (up to 4 per node, blob-pool
records). Landed so far:
15.1 — the ImageRegistry (
src/image-registry.mts): unique images dedup by (kind, crossorigin, url) into refcounted entries — the string-dictionary discipline applied to rasters. Entry ids are slots (free-list recycle;takeFreed()is the renderer's layer-reclaim channel), rgba entries take a size tier from their decoded longest side (128² / 512² / 1024² cap), and sdf-icon entries raster once at the fixed 128² for the r8 icon array. Decoding runs behind an injectable async rasterizer — headless instances stay pending and never throw, the renderer attaches the browser decoder at mount (setDecoderkicks everything acquired before it), a failed url warns once and renders imageless (recorded: no per-element error state), and stale decodes landing after a free are dropped by object identity.promote(id, demandPx)re-rasters vector entries at the smallest covering tier — the primitive under 15.6's zoom-promotion meter; raster sources never promote (source resolution is their ceiling, as in v3).15.2 — props + model: the 16-prop surface parses, validates and reads back with v3's keyword sets and defaults —
background-image(url / data-URI lists;url(...)wrappers strip),-fit(none | contain | cover),-image-opacity,-position-x/-yand-offset-x/-y(%/px),-width/-height(auto | %/px),-repeat,-clip(none | node),-image-containment(inside | over),-image-smoothing,-image-crossorigin, plus the v4-image-type(auto | sdf-icon) and-image-color(the icon tint). Per-image lists distribute v3-style (last value repeats); at most 4 images per node (a fixed FS loop — recorded);background-width/height-relative-tothrows as unported (a parent's stored size is already the padded box — v3's include-padding default). Records live in an image param blob (round-11 compaction) behind the packednode.imageRefcolumn; restyles acquire-then-release so shared urls survive; image props are draw-only paint — never inboundingBox(), never pickable, and always CPU-evaluated. Mapper rules:background-imagetakes mappers through a string-interning enum channel —{ data: 'photo' }passthrough (photo-per-node),case/ordinal urls (icon-per-type) — and-image-opacity/-image-colorare plain number/color channels; every other image prop is a constants-only list (the 12b rule).15.3 — the RGBA draw path: imaged nodes draw one extra instanced quad per stream (leaves right after their bodies, parents right after theirs — v3's layering), off the same culled visible lists, with imageless instances collapsing in the VS and the whole pass skipped while no node styles an image. Unique images live in per-tier
texture_2d_arrays (128²/512²/1024²) with full blit-generated mip chains — minification samples a coherent low mip instead of scattering across full-res texels — behind an entry-indexed image-table buffer (status/tier/layer + natural/raster dims); layers are slots with free-list reclaim and doubling growth, capped at the 256-layer base limit (warn-once, the glyph-atlas precedent). The FS composites a node's records in list order (later over earlier), sampling with explicit gradients (textureSampleGrad, so per-record branching needs no uniformity), andsmoothing: nosnaps to texel centers. Deviations, recorded:clip: nodemasks by the node SDF with containmentinsideclipping at the border's inner edge — the border stays visible over the image, but a translucent border shows fill rather than image (the B1 band-rule sibling); repeat tiles are confined to the node box; the browser decoder narrows crossoriginnullto same-origin fetches (WebGPU cannot upload tainted content). Pinned by theimages-basic/images-cover-clipgoldens and a live v3 parity scene at 0.000% mismatch (fit/position/opacity math is pixel-exact against v3's canvas renderer).15.4 — multi-image parity: up to 4 images per node composite in v3's layer order — later list entries on top (v3's canvas draws ascending index with source-over; not the CSS first-on-top convention), each with fully independent per-image props at its list index. Pinned by the
images-multigolden (overlaps + translucent blending) and a second live v3 parity scene at 0.000% mismatch.15.5 — sdf icon mode:
background-image-type: 'sdf-icon'sends a source through the glyph pipeline — the decoder returns its alpha silhouette (multi-color sources collapse to it; recorded), the glyph atlas's exact EDT runs at upload, and the distance field lives in a dedicated 128² r8 array (~16 KB per icon vs ~1.3 MB for a 512² rgba mip chain). The FS thresholds at 0.5 with an analytic AA width (the field re-thresholds at screen resolution, so icons stay crisp at every zoom with no promotion machinery) and tints bybackground-image-color— mapper-drivable, so one shared raster serves any per-type palette.background-image-typeitself is constants-only (a list prop, the 12b rule; recorded). Pinned by theimages-sdf-iconsgolden and a crispness spec: at zoom 6 the sdf edge ramps ≤ 2 px where the rgba path ramps ≥ 3.15.6 — svg zoom-promotion + export re-raster: vector sources have no native resolution, so a fixed raster is v4's artifact — the renderer meters each unique svg entry's max on-screen demand (shown, in-viewport users; debounced 250 ms behind viewport events; re-checked when uploads land) and re-rasters at the smallest covering tier once demand exceeds the current raster by 1.5× (hysteresis). Momentary softness self-corrects — the late-font precedent — and promotion ends at the cap tier (recorded blur past it); raster sources never promote, and there is no demotion (the waste policy reclaims; recorded).
png()/jpg()promote at the export scale and await the decodes before encoding, so a high-scale figure is crisp even when the screen never demanded it; at scale 1 promotion no-ops and the WYSIWYG self-diff (now with an imaged phase) still pixel-matches the screen.
Documenting the source (round 26)
The v3 code and the v3 documentation stay in the repo untouched
— since round 42 inside v3/ — so every v3 asset remains available
for comparison benchmarks and parity work. v4 therefore has no docs
site yet, and v3/documentation/ is not touched by v4 work.
Instead:
- JSDoc on the source is v4's documentation source of truth.
Prose about what a member does lives next to the member. The
release documentation will be generated from these comments
(docmaker's per-function shape is
{ name, descr, formats: [ { descr, args: [ { name, descr } ] } ] }— a summary sentence, per-overload descriptions and named arguments, all of which standard JSDoc carries). - This file and PLAN.md keep their roles. The README is scope, design decisions, deviations and the cross-cutting narrative; PLAN.md is the logbook. Neither duplicates per-member documentation.
- Standard tags only —
@param,@returns,@throws,@example,@see,@defaultValue. Overloads get one block per signature (docmaker'sformats). There is deliberately no bespoke@section/@docstag: a generator reads the existing// -- <group> --banner comments incore.mtsandcollection.mtsfor placement, since those groupings already mirror the docs' subsections, so the banners must stay complete and accurate. - A doc comment states the contract, not the implementation —
what it does, what it takes, what it returns, what it throws, and
where v4 deliberately differs from v3, in this file's voice ("v3
does X; v4 does Y because Z"). Round references (
(19.3),(round 25)) stay: they are how this codebase cites its own history. - The declarations ship, with the docs in them (26.5).
cytoscapehas a real.d.ts:rolldown.dts.config.mjsrolls the prototype's declarations up through the same pipeline the v3 entry uses,scripts/build-dts.mjsfinalizes it (the gpu entry is ESM-only — the./gpuexport has norequirecondition — so it keeps the generated ESM shape and only gains the UMD global name), and the./gpuexport carries atypescondition. Over a thousand JSDoc blocks survive intodist/cytoscape.d.ts, so the comments above are hover text in a consumer's editor — which is what makes the pass pay off now rather than at release. Two guards:npm run test:types:surfaceaudits the shipped shape (default export, the named type surface with no leaks, the factory's statics, and a floor on the surviving doc blocks) andtypescript/tests/api.test-d.tsis a compile-only consumer test in thetest:typesproject. Closed by round 41.1: the event object is v4's own, andevent.targettypes as the core or a one-element collection (EventTarget), so a handler narrows with a type guard instead of castingunknown— the compile-only consumer test lost itsaswith it. The round-37.3 additions to this test are the other direction: four@ts-expect-errordirectives pinning that the options type rejects unknown constructor keys, since that is where v4's constructor strictness deliberately lives. - Coverage is enforced, and it is at 100%.
scripts/jsdoc-coverage.mjsaudits every member of an exported class whose name does not start with_, plus every top-level exported function, split into a public-API tier (the entry point,Core,Collection,Viewport, the animation handle, the layout contract, the public style/wire/columnar surface) and an internal tier. Round 26 took the surface from 46% overall (43% public, 55% internal) to 100% in both tiers, sotest/jsdoc-coverage.mjsgates the simplest possible rule: no file insrcmay have an undocumented public member, and the failure message names it. Overload signatures each carry their own block; the implementation signature that closes a run of them is not separately documentable and is skipped. Runnode scripts/jsdoc-coverage.mjs --verbosefor the per-member list. @throwsis enforced too, since round 31.2. The rule above gates that a comment exists; this one gates half of what it must say.auditThrowTags()(same script, tally printed under the coverage report) flags a public member whose body contains athrowand whose comment carries no@throws— 7 of 16 did when the audit was written; the surface was 16/16 by the end of round 31 and is 18/18 today. It under-detects deliberately: a member that throws only through a helper it calls is not flagged, since whether that belongs to its contract is a judgement. Round 31.1 is why this is worth gating — a stale doc comment shipped bad advice into every consumer's editor viadist/cytoscape.d.ts.@paramis enforced on the public tier, since round 32. This one is not about editors: docmaker's per-function shape carries a description per argument and has no return field at all, so a missing@paramis a hole in the release documentation v4 will generate while a missing@returnsis hover text. That is where the gate stops, deliberately. 143 of the 221 public members taking arguments documented them when the audit was written; round 32 took it to 221/221, and round 36 found the audit had been walking class bodies only — so the public tier's exported functions, which are the whole surface ofwire.mtsandcolumnar.mtsand are public members by the script's own definition, sat outside a gate that read as complete. They are inside it now, at 232/232 — the 230th arriving in round 37.3, which found the same failure a third time: the exported-function pattern round 36 added did not spelldefault, sosrc/index.mts, listed in the public tier since round 26, contributed zero members to every audit while reading as audited and complete. Its whole surface isexport default function cytoscape— the package's entry point — and all three of its tags were in fact missing. The standing lesson is the one round 36 wrote intoAGENTS.md: an audit's scope is part of its claim, so check what it enumerates before quoting its 100%.@returnsis written, and gated since round 37.1 (written in round 36). Round 32 measured this tail at 63 of 276 and left it, on the reasoning above; round 36 wrote all 63, so the surface is 279/279 (276 at the time; the 277th is the entry point round 37.3 brought inside the audit, the 278th round 41's event and emitter, the 279th round 45's widening tosrc/event.mtsitself), and left the gate's boundary exactly where round 32 drew it — a policy call of the kind PLAN.md's open call 8 held for test coverage. The fifth design sitting (2026-08-04) took both calls, and this one ratchets:auditReturnTags()prints the tally under the coverage report,--verboselists any miss, andtest/jsdoc-coverage.mjsfails the build on a miss or on the tally falling below its ratchet (276 at round 36's completion, 279 today). The argument that moved it is that these comments ship as.d.tshover text whether or not the docs generator reads them, and round 36's own history — a tail completed once, by hand, four rounds after it was measured — is what an ungated rule looks like. A member counts when its signature carries a return annotation that is notvoid/Promise<void>/undefined/never/this; one with no annotation is skipped rather than guessed at, so the tally is a lower bound. What the tags say is the contract the type cannot: the first-element rule and the undefined case, where a reader answers the effective value rather than the declared one (effectiveOpacity,grabbable), and which predicates are not the negations they look like (inactiveis not!active;isChildlessis not!isParent).
The generator (round 45)
npm run docs:api (scripts/docs-generate.mjs) turns those comments into
the API reference, in docmaker's shape —
{ name, descr, formats: [ { descr, args: [ { name, descr } ] } ] } —
grouped into the // -- section -- banners, which is what round 26 chose
them over a bespoke @section tag for. Reading: 362 documented
members over 48 sections in 7 namespaces (cytoscape, cy, eles,
ani, layout, ctx, event — v3's prefixes where v4 has the same
thing, so an upgrading reader lands where they expect). Nineteen rounds
of gating are what made it a small script: there was no content to write,
because the tags have been complete and enforced since rounds 31.2, 32
and 37.1.
- It extends the coverage audit's scanner rather than adding a second
one —
auditFilereturns each member's doc block and banner beside its name — so the generator and the gates cannot disagree about what a public member is. - Overloads become formats.
descris the first paragraph (docmaker's field is a summary sentence); each format keeps its own block's full prose, so an overload's contract is never flattened into its sibling's.pureAliasescarries the 84declare x: this['y']aliases, as v3's docmaker does.@returns,@throwsand@seeare emitted as their own fields: docmaker has no field for them, but folding them into prose is lossy in a way a template cannot undo. - What is published is derived, not listed: an exported function is
documented iff
index.mtshangs it on the factory, which is what separatesserializeElementsfrom the type predicates beside it. The classes deliberately not published —Viewport,StyleEngine,AnimationManager,Emitter— are a checked table with a reason each: being documented for the next maintainer and being published in the API reference are different questions. - Validated against the shipped declaration, not against the sources it
was generated from.
test/docs-generate.mjschecks the model againstdist/cytoscape.d.ts— a different artifact, from a different tool, and the thing a consumer actually holds — in both directions: a phantom entry sends a reader to a method that does not ship, and a dropped one silently reintroduces what round 26's gate exists to prevent. The stranded-block check is a precondition here and gated at zero for published files, where a displaced block would ship twice under the wrong name; it stays report-only elsewhere, for round 36's reason.
Two things this round changed beyond the generator, both recorded in
PLAN.md: src/event.mts joined the audit's public tier (Event is a named
type export and the object every handler receives — the fourth time an
audit's scope has been the thing that was wrong), and optional class
members (target?: EventTarget) turned out to match no audit's member
pattern at all. Neither was undocumented; both were uncounted.
Measuring the error contract (round 30; gated since round 37)
v4 fails loudly by decided design, which makes its throws part of the
public contract — and until round 30 most of them were unverified.
scripts/throw-coverage.mjs finds every throw new in src
and reports which the Node suite reaches, the same way
scripts/jsdoc-coverage.mjs reports documented members:
node scripts/throw-coverage.mjs [--verbose] [--lcov <file>], or
npm run test:throws (part of npm test).
- A gate since round 37.1, at zero tolerance. A
throw newinsrcthat the Node suite never runs fails the build unless it is classified. The floor was a policy call while it stood open (PLAN.md open call 8); the fifth design sitting took it, and by then round 36.4 had finished the browser tier, so the reading was already 0 and the gate held the day it was written. The script exits nonzero and the failure names the site. - The classification lists are maintained allowlists, which is what
makes zero tolerance honest.
UNREACHABLE(a guard no caller's input can reach) andMISATTRIBUTED(a line the coverage data reads as covered and cannot be) are the only exemptions, so the gate also checks them: an entry that no longer names athrow newline, or that carries no reason, fails in its own right. That check earns its place — the keys arefile:line, and round 34 moved a site out from under its entry by inserting two methods above it. An unchecked exemption silently transfers to whatever line lands on the number. - Reading (2026-08-05, after round 48): 197 sites — 182 run by
the Node suite, 10 browser-only, 5 unreachable by design, 0
Node-reachable and never run. Round 36.4 read 191/176 with the
same three classifications; round 41's emitter added one Node-run
site, and round 48.3's fuzzing added five more — the guards it
needed for corrupt dictionary indices, packed-id blob lengths and
data-key counts, each specced deterministically in
test/wire.mjsbecause this gate measurestest/*.mjsand cannot see a guard reachable only fromtest/soak/. Round 30 read 13 browser-only and 2 unreachable, and pinned six of the browser tier (the export guards, 30.2); round 36.4 finished that tier by specs for four and classification for three. Specced in therendererproject: no adapter, no webgpu canvas context, no 2d context for glyph rasterization, and a 404 background image (the one a caller reaches, whose contract is warn-once-and-render-imageless). Moved to unreachable, each with its reason:gpu-context'snavigator.gpucheck is shadowed by construction (the factory's attach path checks it and then synchronously constructs the Renderer, whose ctor reads it again — nothing runs in between), the column-mirror lookup is a spec/group invariant no public input chooses, and the gpu-tween write-kind guard is barred one layer up by the round-25.1 eligibility rule. They join the big-endian platform guard and the SHAPE_MASK field invariant, all listed with reasons rather than silently skipped. (36.4 also fixed a tally bug the reclassification exposed: a site in a browser directory and in the unreachable list was counted twice, so the tallies summed past the site total.unreachablewins, as it already did in the--verboselabels.) - Two measurement footguns are recorded in the script header,
because the round hit both. Raw
NODE_V8_COVERAGEoffsets do not line up with the.mtssources — tsx transpiles before V8 sees the file — so the first version of this measurement was fiction (it reported 47 dead sites including two that have had throw specs since round 13); coverage is collected through the test runner's own source-mapped lcov instead. And function-level (FN/FNDA) records misattribute one-line arrow functions badly enough to be useless, so nothing reads them. - The tool measured its own error, and says so. Line-level data
attributes the body of a module-level arrow const to the
module-evaluation count, so one guard (
exportScaleinrenderer.mts) reads as covered in Node where no renderer exists. Calibrated against the browser-only tier — 2 of its 14 sites read as covered, one of them genuinely (a Node spec drivesGlyphBufferwith a mock device) — the known error is one site in 192, listed inMISATTRIBUTED. The tally is a lower bound on dead sites.
Benchmarks
Round 33 (2026-08-03) is the benchmark sweep: the suites below grew
from 14 to cover the surfaces that had no measurement at all — layouts,
the algorithm tail, the style engine, loading and the wire format,
picking/box-selection/bounds, the data sidecar and structured queries,
events and the animation manager, images/charts/store internals, and a
breadth pass over the remaining public members. Two rules came out of
it and now apply to every row here: a row is either v3-comparative
(with an idiomatic v3 analogue on the other side) or gpu-only (an
absolute cost, or a premium against a v4 baseline of the same shape),
and it says which; and every performance figure in this file has a
re-runnable source, or is marked in place as a historical one-off
with the date and machine it came from. A number nobody can re-run is
a record, not a measurement. node scripts/bench-coverage.mjs
reports which public surfaces have a benchmark and which do not.
npm run benchmark (Mitata; BENCH_N scales the graph) compares each
core/collection op against its v3 analogue in v3/src/. The suites in
benchmark/, by what they answer:
| suite | what it prices |
|---|---|
index.mjs (core + collection) |
the core/collection micro surface vs v3 |
materializers.mjs |
whole-graph query materializers (runnable at 200k) |
mutators.mjs |
bulk flag/position/data writes vs v3 |
traversal.mjs |
the slot-native walks |
scenarios.mjs |
five composed traces with listeners attached |
algorithms.mjs |
all 21 graph algorithms vs v3 (33.2) |
layouts.mjs |
every built-in layout, the force executor, the round-17 contract (33.1) |
style.mjs |
sheet compile/apply, the parents partition, the readback getters (33.3) |
style-bundle.mjs |
the same getters through the built bundle, where tsx's __name wrapper does not exist (36.5) |
load.mjs |
the three ingest forms, conversion, export, incremental add (33.4) |
spatial.mjs |
CPU pick by shape, box selection, bounds/fit (33.5) |
data.mjs |
the sidecar's column kinds; data + structural queries vs selectors (33.6) |
events.mjs |
emits by qualifier kind, the phased compound walk, animation start/stop (33.7) |
store.mjs |
id index, CSR, blob pool, dirty tracker, image registry, charts (33.8) |
surface.mjs |
the breadth pass: 119 rows over the rest of the public API (33.9, 36.3) |
mappers.mjs |
mapper data-write cost per evaluation policy |
compaction.mjs |
round 19's shrink profile, repair, forwarding hot path |
compound.mjs |
parent/child drags and reparenting vs v3 |
curves.mjs |
the curve premium — every row against the straight graph |
labels.mjs |
shaping, the store label write, the bb label terms |
transitions.mjs |
transitions off vs on, incl. the auto-extent worst case |
geometry-tween.mjs |
one manager tick per geometry channel |
npm run benchmark:report renders a self-contained single-page HTML
report (v3-vs-gpu medians as dumbbells on log time axes, a ranked speedup
overview, per-suite stat tables) into benchmark/results/
(gitignored) next to the timestamped results JSON. Three profiles:
quick by default (the v3-vs-v4 micro and scenario suites — kept quick
deliberately), -- --all for every standalone sweep as well, and
-- --full for the 2k/20k/200k matrix (one process per group at 200k, as
the suite headers require). -- --suite <substr> filters any of them and
-- --render-only <results.json> re-renders without re-running. The two
manually-timed suites (curves, labels) join the table through
finishManualRun, which shapes one-shot rows into the report's job
format; without BENCH_JSON their terminal output is unchanged.
Provenance, and publishing a run (round 46.5). A results file now
carries a structured meta.machine beside the old meta.cpu string —
CPU with the physical/logical split and both clocks, RAM, OS, and a GPU
inventory with VRAM — plus the commit's date and subject and a
dirty flag, because a measurement taken on a dirty tree is not
attributable to the sha it prints, and the report renders that in the
failure colour. A --renderer run additionally records the WebGPU
adapter that actually rendered, which had been captured and discarded
at the --json boundary since the renderer benchmarks landed.
benchmark/results/ stays gitignored and machine-local; npm run benchmark:publish promotes a run into the tracked
benchmark/published/, which is what the status site renders. Runs
are grouped by a machine fingerprint and never compared across
machines — see benchmark/published/README.md. The report renders
every published run through the same renderReport(), and a results
file written before round 46.5 still renders, which is a spec rather
than a courtesy.
What a profile costs, measured 2026-08-04 on the i9-9900K these
rounds have used (round 33's risk register promised this number and no
round had recorded it; the runner prints its own total, so it was
always a run away): quick 7.1 min, --all 17.4 min. --full adds
the 2k/20k/200k matrix and is unmeasured — it is the profile nobody
runs casually, which is the point of keeping quick quick.
What round 33 found, and round 34 fixed — the measurements that went
the other way. Round 33 logged them; round 34 fixed all five, and the
before/after numbers below are through the built bundle at N=2000
(ele.style rows from a dedicated process, since a micro-row in a
shared one varies ±30%):
- The style getters — 292 ns → 122 ns (round 34.5), against v3's
52 ns: the gap went 5.8× → 2.3×; round 35 then replaced the
150-case switch behind them with a dispatch table, which flattens
what remains — see below. Profiling the bundle put 36%
of
readPropinnormalizeProp— a regex replace and a lowercase allocation per read, turningbackgroundColorintobackground-colorbefore the dispatch it precedes — so it is memoized.numericStyle215 → 84 ns,effectiveOpacity240 → 92 ns. The five per-call closures were hoisted to module scope in the same pass; that is worth 1848 → 255 ns under tsx and nothing in the bundle, and is reported as what it is: a fix to the harness, not the product. (Round 33 published this as 13–21× from a suite that importssrc/through tsx; round 34.0 traced the difference to esbuild's__namewrapper, which tsx injects on every closure creation and the bundle does not have. For a closure-heavy hot path, benchmarking the transpiled sources measures the transpiler.) - The emit path's no-listener gate — 338 ns → 8 ns for a node two
ancestors deep (round 34.3):
_emitOnElenow returns before building the event or walking ancestors when nothing listens for the type, which is sound because v4's emitter never bubbles to a parent. It matters because the pointer layer's sixteen call sites are ungated and fire on hover transitions and pointer moves. Round 33 stated this as "a compound child never gets the no-listener fast path", citing achild.position()row — which never reached_emitOnEleat all, since the position writers already gate. That row measured compound auto-bounds invalidation (round 14.3, working as designed). The narrower claim was the true one. - The layout contract — 333 µs → 795 ns per run for an impl that
does nothing (round 34.4).
ctx.eles/.nodesbecame lazy getters, andnodeSlots()/edgeSlots()read the store's insertion-order list (or the scope collection's refs) instead of interning a handle per element. Order is preserved exactly — layouts place by index — and specs pin it againstcy.nodes()/cy.edges(). mutableElements()— 121 µs → 20 ns (round 34.2): the three unfiltered collections (elements,nodes,edgeswith no query) are memoized against a store structure epoch, bumped wherever an element enters or leaves the insertion-order list. A counter and not a count, so add-one-remove-one between two calls cannot read as unchanged. Two calls with no structural change now return the same object, which is the one visible consequence and is pinned by a spec.indexOf()— 12.5 µs → 41 ns (round 34.1), parity with v3: the lazily-built packed-key membershipSetbecame aMapfrom key to first index, so the cache the set ops already build now carries the answer.- Whole-object
data()is 6.3× v3 — the columnar rebuild-the-object cost, showing up exactly where the design predicts.
Round 35 — the readback dispatch table. StyleEngine.readProp
answered all 150 readable property labels from one switch of the same
size, which is a dispatch table written as control flow. V8 does not
hash a string switch that large, so a property's cost depended on where
it sat in the file: moving border-width's case (body untouched) from
sixth to last took it from 56 ns to 90 ns. It is now a Map of 111
readers, and readProp is 60 lines. The effect is a flattening
rather than a uniform speedup — the spread went from 56–286 ns (5.1×)
to 48–110 ns (2.3×), the worst property is 2.6× faster and the
earliest few are ~15 ns slower — and on the aggregate a whole-object
style() is 1.27× faster on a node and 1.48× on an edge, edges
gaining more because their properties sat at the back of the switch.
Equivalence is pinned by test/style-readback-all.mjs, which reads
every one of the 153 properties on a styled node and a styled edge.
The write path's applyProp is a 147-case switch of the same shape and
is deliberately unchanged: it runs per sheet compile (three times
at construction, once per group per cy.style()), not per element and
not per read, so its dispatch is a handful of comparisons against a
27.7 µs compile. The read path earned the change because of how often
it runs, not because a large switch is wrong on sight.
Round 36.5 gave those figures a re-runnable source, and refined one.
Rounds 34 and 35 measured through the built bundle with throwaway
harnesses, which contradicts round 33's rule that every published figure
has a source; benchmark/style-bundle.mjs is that source, and it
joins --all. Re-measured, round 35's numbers reproduce — 68 ns at the
old sixth case, 53 and 50 in the middle, 93 and 110 at the back — but
the spread is two populations, not one. A colour-valued read builds
an rgb()/rgba() string, which costs about as much again as the whole
dispatch-and-decode: background-color 118 ns and border-color 116
against border-width 64 and width 61, and those two colours sat at
opposite ends of the old switch, so it is not residual positional cost.
background-color was the only colour among round 35's six, which is
why it topped that table and why the remaining spread looked larger than
the dispatch actually is.
node scripts/bench-coverage.mjs [--verbose] reports which public
members a benchmark calls (83.7% of the callable surface; core 98.9%,
collection 98.5% since round 36.3 added allAre and is; the total
dipped from 84% when round 45 brought src/event.mts into the public
tier, which is the audit widening rather than coverage falling). Like the stranded-doc-block check it reports and never
gates (throw coverage, its other sibling, has gated since round 37.1) —
and it is the weakest of the three audits, matching call-shaped
mentions, so it over-detects (a comment counts) and under-detects (a
member reached through a wrapper is missed, which is why the engine-side
Viewport/StyleEngine/Animation files read low: the core calls them
for you). Read it differentially, not as a score.
Renderer benchmarks (npm run benchmark:renderer, or
benchmark:report -- --renderer to fold them into the same report):
benchmark/render-bench.mjs drives render-bench.html in Chromium
via Playwright — needs built UMD bundles and a real GPU adapter (the
run aborts on none; software adapters are warned about, their numbers are
a different machine class).
Reading its device numbers (round 29.5): most gpu (device) rows
reproduce run-to-run to ±0.02 ms, which is what makes them usable as a
regression signal — but the compound scene's fit-all pair is bimodal
at the ±40% level (2.11 ms and 3.00 ms on consecutive runs of the same
build). Re-measure before believing a change in those two rows; the
round-29.5 comparison first read a 30% "improvement" there that was
nothing but the other mode. The --layout rows are noisier still
(±25%, and for a structural reason — see the force-layout section), and
round 36.5 adds the obvious-in-hindsight caveat that applies to every
row here: do not run anything else on the box. Its first --layout
run overlapped this repo's own test suite and was discarded. It replays the interactions behind the
recorded renderer numbers on six scenes (seeded 25k×50k and 100k×300k
generators, ndex-x-large, a 25k×50k curved scene whose edges come
in bezier parallel pairs so every edge actually curves, a 25k×50k
compound scene with 1k parents, and a 25k×50k images scene with
icon-per-type url mappers), v3 canvas vs
v4 WebGPU: continuous-pan steady
state at fit-all / zoomed-in 20× / far-zoom (labels off and on),
hover-while-panning pick() latency, and one-shot init / columnar-init /
full-png-export timings. Wall ms-per-rendered-frame is the comparison
metric (vsync-bound — both sides floor at the display refresh when
fast); gpu (device) table rows carry the GPU-pass time from
timestamp-query, the unbounded cost. dpr 2, 1280×800, adaptive render
scale pinned to 1; --scene <substr> filters scenes, --headed debugs,
--gpu-only skips the v3 side (for the gpu-vs-gpu scenarios),
--layout swaps the pan scenarios for the live force-layout mode (see
the round-18 section above; --layout-uncapped lifts its bounds). The
gpu side also runs the round-19 compaction scenario on a fresh
instance last: the scene is cut to ~10% of its nodes through the store
(so the auto trigger doesn't compact the peak state it exists to
measure), panned at peak slot widths, compacted, and panned again —
wall and device ms per frame before/after, plus the in-page
compact() one-shot. Read-heavy structure ops are where
v4 pulls ahead:
degree/totalDegree are O(1) off the adjacency index (~100–200× v3),
components/add+remove ~25–35×, set operations up to ~25×. Collection
identity keys on a packed {group, slot, gen} integer (not a string) and
each collection lazily caches its packed keys (sound because _refs is
immutable), so same/contains/intersection/difference beat v3 once a
collection is reused. Since round 34.1 that cache is a Map from key to
first index rather than a Set, so indexOf answers from it too — one
cache, two consumers, and indexOf went from a linear re-packing scan
(81× v3) to parity. $id resolves through the O(1) id index rather
than materializing and scanning the graph; structured queries are
(group, flag-mask) predicates, so they compile through the matcher IR to
per-group (mask, want) tests answered by one preallocated scan over
the flags column (GraphStore.scanRefsInto) — no element handles, no
per-element matching. With that scan behind
elements/nodes/edges/filter (and the interned-handle pool an array
indexed by slot instead of a Map), the whole-graph materializers and
flag queries all beat v3 — e.g. at 200k nodes
filter({ group: 'nodes', selected: true }) is ~140× v3's
$('node:selected') — and no maintained membership sets (v3's approach)
are needed.
Callback iteration (forEach/map/...) plain-calls the callback when no
thisArg is given, matching v3's semantics (this is undefined inside
the callback) — rebinding the receiver per element cost ~2× on large
collections.
Collection-scale writes are columnar too (benchmark/mutators.mjs
sweeps them at up to 200k nodes; BENCH_OP runs one group per process at
that scale). Flag mutators (select/unselect, show/hide, lock,
grabify, selectify) go through one bulk pass over the flags column
(GraphStore.flagRefs: hoisted columns, one coalesced dirty span per
group); select/unselect skips its restyle pass entirely unless some style
block matches on :selected/:unselected (the selected accent ring is
drawn by the shader, so the default stylesheet never restyles) and only
emits when someone is listening. shift() and constant positions()
are direct column arithmetic — no per-element handles or Position
objects. At 200k nodes vs v3: select+unselect ~38×, lock ~96×, shift
~106×, hide+show ~1400× (v3 pays a style bypass per element), and
removing + re-adding a 256-node band with its incident edges ~1000×.
Composed traces hold up too (benchmark/scenarios.mjs: five
interaction scenarios — explore/click-expand, select-all + fit, band
drag, remove/re-add, dashboard refresh — replayed with core listeners
attached, the axis the micro suites exclude since their emits are
listener-gated). At 200k nodes the gpu side wins every trace 6–530×:
a click-expand-select-fit interaction runs in ~45 µs median (34× v3),
and per-element emit cost is ~85 ns/listener call. The sweep also
settled the lazy-collection question (handle materialization is ~4–6%
of the worst trace — not worth the API change) and exposed the
data-write label path, since fixed: mapped-label refresh on data()
writes is a label-only bulk pass gated on the written keys, not a full
per-element style apply — a 200k bulk write under a mapped label
dropped 85 → 37 ms. (The mapper DSL later generalized this into
StyleEngine.refreshMapped — per-group, per-key gating for every
mapped channel, with the label-only fast path preserved; see
benchmark/mappers.mjs for the write-cost sweep per evaluation
policy.)
Traversal walks (connectedEdges, outgoers/incomers,
neighborhood, roots/leaves, successors/predecessors, edge
endpoints) are slot-native (benchmark/traversal.mjs): they collect
current refs straight off the CSR index with an int-packed (group, slot)
seen-set — no intermediate handles, no packRef dedupe pass — and
successors/predecessors is a raw slot BFS with no per-hop collection
spawns (a 2k-node whole-graph closure is ~350 µs vs ~92 ms before,
~725× v3). Single-hop ops run ~2–5× v3 and a 100-node-band
roots() ~110×. The ~2–5× is a structural ceiling rather than headroom:
v3 traversal is already O(degree) off per-element adjacency arrays, and
returning a v3-shaped collection costs a ref + interned handle per output
element on either side — unlike bulk writes, which touch columns and
return nothing. Going further would mean lazy slot-backed collections
(an API-shape change, noted in PLAN.md under "needs a call").
The graph algorithms have their own sweep (benchmark/algorithms.mjs;
superlinear ops gate on BENCH_N): the slot-native walks win every op at
N=2000 — bfs ~34×, dfs ~39×, dijkstra+pathTo ~33×, tarjan SCC ~19×,
betweenness ~13× — while the dense-matrix ops (pageRank, floydWarshall,
markov/hierarchical/kMeans clustering) are parity as expected, identical
math dominating (within ±1.2× at N=500).
Loading
options.elements accepts the classic definition form (v3-style JSON) or
a columnar bulk-load form: { columnar: true, nodes: { count, ids?, positions?, parent?, data? }, edges: { count, ids?, sources, targets, data? } }
with typed-array columns and edge endpoints as node indices — it
ingests straight into the store (contiguous slot runs are memcpys) with
no per-element objects and no id lookups per edge. data holds sidecar
columns by key (plain arrays, Float64Array with NaN holes, or
dictionary-encoded string columns). Columnar payloads are self-contained: every
edge endpoint indexes a node in the same payload. Convert classic JSON
with cytoscape.toColumnarElements(json). There is also a binary
wire format — cytoscape.serializeElements(elements) (takes either
form) produces one little-endian ArrayBuffer (fixed header + columns; ids
as a UTF-8 blob with prefix offsets), and options.elements/cy.add()
accept the buffer directly (or use deserializeElements to inspect it) —
so a graph can be served as a static binary asset and fed straight from
fetch(...).arrayBuffer() with no JSON parse. Numeric columns
deserialize as zero-copy views into the buffer, and ids stay packed all
the way into the store — the id index is itself blob-native (UTF-8 bytes
- an open-addressing probe table, no JS strings), so id strings are
decoded lazily, only for elements actually touched via handles. The
wire carries the data() sidecar too: numeric columns as f64, string
columns as dictionaries (only the small dictionary decodes), the rest as
JSON per present value. Compound hierarchy rides both forms (round
14.8):
nodes.parentis aUint32Arrayof payload node indices with0xffffffff(NO_PARENT) for orphans — the def converter liftsdata.parentinto it, ingest links it cycle-guarded after the batch's nodes exist, and the wire stores it as its own section (format version 3; version-2 buffers still load, andcy.serialize()exports the live hierarchy). Graph-leveldata()joined the wire in round 39.2 (format version 4; every earlier version still loads, since the reader branches on the presence flags and never on the version number). It is one JSON string rather than a column, deliberately — everything else in the format is per element and scales with the graph, whilecy.data()is a single small object of arbitrary values. Its load is asymmetric, which is the round's one decision:options.elementsapplies graph data, andcy.add( buffer )ignores it, because adding elements to a populated graph must not overwrite that graph's owndata(); a caller who wants it after anaddapplies it explicitly (cy.data( deserializeElements( buf ).data )). Either way, the factory's load path materializes no per-element handles and emits noaddevents (nobody can be listening yet);cy.add()keeps full per-element semantics and takes all three forms. The reverse direction exists too (round 10):cy.serialize()exports the live graph as the wire buffer — ids, positions, selection state, the data() sidecar and graph-level data (style/viewport/scratch are not part of the wire) — and the result feeds straight back intooptions.elements/cy.add(). ndex-x-large (19.6k nodes / 465k edges, 28.6 MB JSON): definition-form init 236 ms, columnar init 80 ms — down from 662 ms before the bulk path. The wire form of the same graph is 9.2 MB and deserializes in ~5 ms, replacing the JSON path's 90–113 ms parse + 27–48 ms convert.
Packaging (round 44)
What ships, and what keeps the manifest honest. The package is
cytoscape@4: exports["."] (plus ./gpu, the deprecated alias v3's
users already type) resolves types → dist/cytoscape.d.ts, import
→ dist/cytoscape.esm.mjs, require → dist/cytoscape.cjs.js, with
the legacy main/module/types fields carrying the same three and
unpkg/jsdelivr the minified UMD. The tarball is those bundles, the
declaration, src/ (source-map resolution, as v3 ships it), the README
and the licence, plus MIGRATING.md and CHANGELOG.md — 106 files
today, before a release build populates dist/.
dist/ holds only the committed declaration in a checkout, and that is
the inherited convention rather than a gap: v3 tracks all six of its
dist/ artifacts and refreshes them at release, so v4's appear when
its first release build runs (round 50). Nothing under dist/ is
gitignored.
test/modules/packaging.mjs gates the chain
rolldown outputs → dist:copy → the manifest → the tarball, which
is hand-maintained at every link. It asks npm for the real file list
rather than re-implementing .npmignore — a denylist, so every new
directory ships by default and the failure mode is additive and silent
— and it imports rolldown.config.mjs rather than parsing it. What it
checks: no development tree or repo document ships; every path the
manifest names is produced by a build script; dist:copy copies
exactly rolldown's five outputs, both directions; types is the first
condition wherever it appears (TypeScript takes the first match, so a
later one is silently dead); import/require/types point at
.mjs/.js/.d.ts; the legacy fields agree with the . conditions;
and ./gpu resolves to identical files. It deliberately does not
check that the bundles exist — they do not until a release build
runs, and whether one ran is release-workflow business.
Robustness + soak (round 48)
npm run test:soak (test/soak/, 24 specs, in the npm test chain) is
the tier a release needs and feature rounds never owed: leaks, sustained
churn, malformed input, and more than one instance on a page. It has its
own script because the leak specs need --expose-gc — a leak spec that
cannot force a collection is a flake generator.
Leaks are gated on reachability, not on bytes, and that is the round's method note. 1000 create/destroy cycles of a 400-element graph grow
heapUsedby a steady ~2.2 KB per cycle, linear across five blocks — which reads exactly like a leak and is not one: aWeakRefto each destroyed core shows every one collected, and what grows is V8's bookkeeping. A byte bound would have encoded the engine's behaviour as v4's contract. The byte check survives as a wide backstop.The round-11 churn profile is a pass/fail gate, not a recorded measurement: 40 rounds of a 400-element band over a 4000-node graph with fresh ids and fresh strings each round (re-adding the same ids would refill the same blob bytes and pass with every reclaim removed). The id blob holds ~42 KiB at round 0 and at round 40; capacity and
highWaternever move.The wire format is fuzzed, and three defects came out of the first run — all one shape, a count or index read out of the payload and driving allocation unchecked. A dictionary index of 2,566,914,049 against a 3-entry dictionary made
cytoscape( { elements: buffer } )never return; a corrupt flags word made the packed-id offsets a float bit pattern and the load took 25.9 s. Each guard sits at the cheapest honest place: the dictionary check is fused intoingestColumn's existing walk (free, where the same check indeserializeElementsmeasured 4× on a reader whose point is being O(1) per column), and the other two are O(1).Elements from different instances no longer compare (48.4). A ref is
{ group, slot, gen }and identity packs those three, all of which are per instance — so the first node of one graph and the first node of another packed identically, and all twelve of round 29.3's guarded methods answered accordingly:same()true,intersection()everything,difference()nothing,union()silently dropping the other graph's elements. They now throw, through the sameassertCollectionguard and for the same stated reason. A behaviour change to public API, carried inCHANGELOG.md.Device loss under load is covered in the
rendererproject (48.5). Round 10's spec loses the device on an idle instance; these lose it mid-animation (a GPU-leased tween), mid-export (the one readback in the architecture) and mid-force-run (the stronger lease — the sim owns the position column for its whole run), and require the promise to settle, the lease to release, and every position to come back finite and writable. Running the control is what earned the third one: with the loss hook neutered the export spec still passed, since it accepts a resolved or rejected export, so it now asserts the loss as well.
Not yet covered, and the remainder of the round: the documented limit edges — the 256-layer image cap, a full glyph atlas, the export texture cap. Each needs a fixture large enough to reach the limit.
Porting from v3 (round 47)
MIGRATING.md at the repo root is the porting guide, and it ships in the
package — someone who has just installed v4 and found their selectors
throwing should have it locally. It carries a recipe table per v3
selector form, the style-property diff, the event names that register and
then silently never fire, and a "behaviour to re-check" table for the
things that compile and then differ (the straight curve-style default,
bottom text-valign, nodes-then-edges elements() order, Float32
positions, OKLab colour tweens, spring( bounce )). CHANGELOG.md is
the summary; this file stays the reasoning.
The property table is measured, not transcribed. v3's registry was
enumerated from a live v3 instance — 291 names, properties plus aliases —
and each offered to v4's sheet in all four groups: 153 accepted (7
only in core), 138 rejected, of which 96 are the numbered
pie-N-*/stripe-N-* families that became the round-23 chart family.
The classification keys on the property-name rejection message
specifically, because v4 also says "unsupported" for an invalid keyword —
a first pass matching the word alone reported shape and
source-arrow-fill as dropped.
test/modules/migration-guide.mjs keeps the table honest: every property
it names as dropped must still be rejected, every replacement it offers
must still compile, and the defaults it tells readers to re-check must
still be what it says.
Known deviations from v3 (accepted for pass 1)
- Listener firing order: one core emitter with ref/predicate-qualified
listeners. Since round 14.5, compound bubbling gives v3's cross-phase
order (origin → ancestors → core, stopPropagation honored); the
remaining deviation is within a phase, where listeners fire in plain
registration order. Round 41.2 added a second, and it is a fix rather
than a loss: v3 snapshots its listener list once per
emit()call, so inemit( 'a b' )a handler forathat callsoff( 'b' )does not stopbfiring; v4's emitter snapshots per event, so the removal takes effect. - No z-index: compound parent bodies draw first (round 14.9, in depth-asc/slot-asc order), then edges, then leaf nodes, then labels; within a stream draw order is slot order (≈ insertion order, but a reused slot draws at the recycled position). A grabbed node does not pop above later-inserted nodes. Permanent since 2026-08-01: z-index is dropped from v4 by decided design (see "Design decisions" above), so draw order stays structural for good.
- Float32 positions: ~7 significant digits of precision (pure-memcpy uploads are worth the trade at this stage).
- Pan-vs-grab is exact: pointerdown does a synchronous CPU node pick (positions are CPU-canonical), so grab targeting has no staleness and a cold start needs no resolved pick.
- Hover pauses during viewport gestures: pan drags and wheel zooms are viewport-only ops with no mouseover/tap semantics, so no pick passes run mid-gesture; a wheel gesture re-picks under the cursor once it settles (~200 ms after the last tick).
- Frame timing in
stats():cpuFrameMsis the encode/submit cost (submission is fire-and-forget, so it stays ~0.1 ms by design);gpuFrameMsis real scene-pass GPU time via the optionaltimestamp-queryfeature (0 when unsupported). Reconcile fps againstgpuFrameMs, notcpuFrameMs. - No selector strings (a v4 decision, not a gap — see "Design
decisions" above): queries are structured objects ({ group, selected }
today), everything richer is a predicate function, ids go through
$id. Style prop values are constants or mapper objects (see the mapper DSL above); per-element styling is declarative (there are no style functions). cy.elements()order: nodes (insertion order) then edges, not the mixed insertion order of v3.- Picking resolves in three stages, cheapest first. (1) Nodes pick
synchronously on the CPU — positions are CPU-canonical, and a
columnar scan replicating the shader semantics (flooring, plain-disc
LOD, shape inside-tests, topmost-slot-wins) answers in ~0.1 ms with
zero GPU work. (2) The last GPU pick tile doubles as a pick cache:
while the cursor stays inside it and neither the viewport nor any
pick-affecting geometry changed, edge/background answers are instant
(color/opacity-only changes keep the cache). (3) Otherwise the GPU
pick pass draws a fixed 64×64 cursor-centered tile — edges only —
(a pick-specific Frame uniform turns the cull pass's viewport test into
cursor-region culling, O(region) not O(scene)), submits in its own
command buffer ahead of scene work, and reads the whole tile back
through a ring of 3 staging buffers (latest-wins; a frame that finds
the ring exhausted defers the still-coalescing request to the next
frame with a free slot — bounded extra latency, never a spurious
null; ring saturation is observable asstats().pickDeferrals). Scene submissions are capped at 2 in flight, so even stage-3 picks resolve in ~1 rAF plus bounded GPU work on GPU-bound graphs. Measured on ndex-x-large at dpr 2: node hovers ~0 ms, cold background/edge ~7 ms, cached ~0.2 ms, hover-while-panning median ~0 ms (was ~70 ms). - Far-zoom edge decimation: once width-floored (hairline) edges fall below half alpha, a hash-stable 1-in-N subset draws at N× alpha (N a power of two ≤ 64). Aggregate edge density is preserved, but individual sub-half-alpha edges may neither draw nor pick at far zoom. This removes the far-zoom worst case where every edge rasterized into a few hundred pixels and serialized at the blend stage (~33 ms → ~8 ms on 465k edges).
- Curved edges (rounds 12a/12b):
curve-style: bezierbundles and self-loops (12a) plusunbundled-bezier,segments,round-segments,taxiandround-taxi(12b) all render on-GPU in one curved stream of 24-quad strips evaluated from live positions (see the design decision above): bezier/loops keep the 12a analytic evaluation, the 12b route families evaluate their route (from the curve param blob) with piece boundaries landing exactly on subdivision indices — legs pixel-straight, corners exact — and discrete miter normals at sharp corners. Deviations, all recorded: node boundaries use the arrow tier's approximations (round-rect as box, polygon as inscribed ellipse); curved edges draw after straight edges (two streams, slot order within each); the curved stream is never decimated at far zoom; sharp segment corners join with a clamped miter where v3's canvas uses round joins (the difference is confined to the outer join wedge); interior point counts cap at 8 controls / 11 segment points; and v3's near-overlap control-point correction (tryToCorrectInvalidPoints) is not ported — overlapping-node curves may differ slightly from v3 in the region the nodes occlude anyway. (12a's border-exclusive curved-arrow tips were fixed in 12b vianode.outerHalf.) Cull: chord-bounded curves grow the Liang-Barsky chord test by the frame slack; box-bounded ones (taxi, extrapolated weights — FLAG_CURVED_BOX) test the endpoint AABB grown by slack + chord length. - Round 12c (manual endpoints, haystack, straight-triangle):
source/target-endpointandsource/target-distance-from-nodederive through a 10-float endpoint block on the edge's blob record; straight + endpoints renders as theCURVE_MULTI n = 0chord in the curved stream, and a bundled bezier with endpoints promotes toCURVE_MULTI n = 1(identical control math). Deviations, all recorded: the-or-labelendpoint keywords throw (no label bb in v4); loops ignore endpoint props entirely (v3 overrides the keywords; v4 also drops loop distances); taxi forces keyword modes to outside-to-node (v3's rule) while distances apply; endpoint props are constants-only (the point form is a list); angle endpoints intersect the arrow tier's approximate boundaries.curve-style: haystack(+haystack-radius) andstraight-triangleare straight-stream kinds: FLAG_CURVED stays clear, so both ride the straight pipeline — haystack keeps far-zoom decimation (the 12a "curved stream is never decimated" trade-off does not apply to it). Haystack angles are id-hash seeded (deterministic across machines — v3 uses Math.random(), so haystack has no exact v3 parity above radius 0; the radius-0 parity scene pins the pipeline and a deterministic golden covers radius > 0); offsets scale by outer halves (v3 uses inner size — identical at border 0); haystack edges draw no arrows (v3 skips them) and their stored-truth arrow getters read 'none'. Haystack box selection tests the offset points (v3's haystackPts); triangle/straight edges keep the endpoint-center approximation. The straight-edge and edge-glyph cull tests grow by the monotonehaystackSlack()bound (radiusMax × node half). node.outerHalfis a store-derived column (12b): size/2 + border/2 per axis, written through on every node size/border write. The curve, arrow and edge-label shaders bind it in place of the size + border pair, which keeps each of those vertex stages within WebGPU's base 8-storage-buffer budget with a slot to spare for the curve param blob; the CPU curve evaluator reads the same column, so both implementations consume identical f32 half-extents. It also closes a latent gap: border writes now invalidate the pick-tile cache through the derived column's dirty span (borders move curved pick geometry, butnode.borderWidthitself is pick-neutral).- Early-z: a depth prepass writes depth for guaranteed-opaque node interiors (skipping translucent fills/borders, LOD alpha and the AA fringe — output is pixel-identical), and edges depth-test against it so fragments under opaque nodes skip blending. Depth values come from a per-element z-rank (two ranks today: edges far, nodes near), a mechanism that could carry more ranks and batches if ever needed (z-index itself is dropped by decided design — see above); content ranked above merely loses the occlusion benefit, never correctness. The round-14 compound split took the batch route instead of a rank: parent bodies draw in their own pre-edge stream and are excluded from the prepass (they must not occlude the edges and children drawn over them). Nodes that can't occlude (translucent or < 4 px) collapse out of the prepass so it costs ~nothing at far zoom.
- Adaptive render scale (
renderScaleMin/renderScaleMax, defaults 0.5/1): the renderer moves its resolution in quarter steps within the band, driven by measured GPU frame time over ~400 ms windows — median above ~14 ms steps down; stepping up requires the projected cost at the higher step (~scale²) to fit under ~10 ms, so raises never pump (backpressure stalls are the fallback signal withouttimestamp-query). Shortly after drawing stops (~250 ms) one frame re-renders at max, so still images are always full resolution — low-res frames only ever exist mid-interaction on expensive scenes. Scaled frames draw into an offscreen target and a fullscreen Catmull-Rom bicubic pass upscales to the canvas (preserves SDF borders and hairlines far better than bilinear). Raster LOD floors (edge width, node size) apply in render px; label thresholds (labelFadePx,labelMinPx) are readability criteria and apply in displayed px, so labels don't blink out when the scale drops mid-gesture. Picking always runs at native resolution. Pinmin === maxfor a fixed scale. (ndex-x-large fit-all pan at dpr 2: settles at 0.5 within ~0.8 s, 25 → 76 fps; far-zoom and idle stay native.) - Label LOD: labels fade below
labelFadePx(glyphs past the fade's zero point are culled in compute, not drawn at zero alpha); the optionallabelMinPxrenderer option hard-culls labels whose on-screen glyph height is below it — too small to read anyway (default 0 = off). - Edge
line-style(round 10):solid(default) |dashed|dotted, in model px so dashes zoom with content, drawn as an AA'd mask in the edge fragment stage. Since round 13 B3 dashed edges use the per-edgeline-dash-pattern(constants-only, normalized to two on/off pairs — longer patterns truncate, a recorded cap) andline-dash-offset, withline-cap(butt | round | square) shaping each dash segment; dotted stays [1, 1]. Dash phase launches at the source boundary (v3's rule); caps apply to dash segments only, not the line ends (the quads end at the endpoints — identical to v3's default butt). Picking ignores the gaps, as v3 does.border-styleis not ported (dashing an arbitrary SDF boundary needs perimeter parameterization — see the border-geometry note above). - Node shapes (round 10):
ellipse/circle,rectangle/square,round-rectangle, plus the polygon family —triangle,pentagon,hexagon,heptagon,octagon,diamond,rhomboid,vee,star,tag— from the same unit point tables v3 builds (shape-points.mts), rendered by generated WGSL polygon SDFs with vertices scaled to device space (exact distance, so AA and borders stay crisp under anisotropy) and picked by an exact CPU point-in-polygon in normalized space. Round 27 completed the vocabulary — every v3 shape keyword is accepted, with the no-dash legacy aliases (cutrectangle,concavehexagonand, since round 37.2,roundrectangle) left out by the 2026-07-29 "one name per concept" triage.roundrectanglehad survived that triage in the code for eight rounds while its two siblings threw — an unevenly applied policy, pinned rather than patched because removing public API is the maintainer's call. The fifth design sitting took it as one call over three names (the round-29 docs check having foundcy.autolockNodes()/cy.autoungrabifyNodes()in the same state) and split it: the shape spelling drops, the two core aliases stay as recorded exceptions. It was accepted in three enums — the nodeshape,overlay/underlay-shapeandtext-background-shape— and drops from all three, since removing it from one would have moved the inconsistency rather than closed it.right-rhomboidandconcave-hexagonjoined as point tables;cut-rectangle(a chamfer of absolute length),bottom-round-rectangleandbarrel(four sampled bezier corners) are parameterized shapes with their own fields; and the sevenround-*keywords render assdPolygon( inward-offset ) − r— the identity that makes corner-rounding exact under anisotropic scaling, which is what the earlier "no clean closed form" note had missed. Round-* shapes reuse their sharp counterparts' tables, as v3 registers them. One prop,corner-radius, carries three different 'auto' rules —min(w/4, h/4, 8)for round-rectangle, a flat 8 for cut-rectangle,min(w/10, h/10, 8)for the round-* family — all of them v3's. Each parameterized shape carries a matchingcpu-pickbranch, and since 28.1 the twins are pinned by specs rather than only by construction: the shader halves are proved by round 27's live v3 parity diffs, the CPU halves by hit tests aimed at what is particular to each branch (the absolute chamfer, the capped barrel offsets, the rounded vertex). Note thatinsideRoundPolygonis the one shape test that is not affine-invariant — the corner radius is a device-px length — so unlike the sharp polygons it works in device space and is pinned at more than one zoom. The custompolygonlanded in round 13 C3 with per-element points in a blob pool (shape-polygon-points, constants-only). Arrow tips on polygon nodes sit on the inscribed ellipse boundary (approximation); the depth prepass treats polygon interiors exactly via their SDF. - Labels: nodes and edges (round 10 — edge labels draw at the
midpoint, following endpoint moves on-GPU; horizontal by default, or
rotated to the edge's angle with
text-rotation: autorotate, never upside-down — see the edge-labels design decision; since round 27.7 any label can also take a fixed rotation in radians), multiline since round 16 (text-wrap: wrap | ellipsis,text-max-width,line-height,text-overflow-wrap,text-justification; undertext-wrap: nonenewlines still collapse to spaces), placement on v3's 3×3text-halign/text-valigngrid for nodes (round 13 D3 — mapper-capable; v4 defaults to'bottom'valign, keeping the round-10 below-node placement, where v3 defaults to'top'; the gap on the top/bottom rows is the fixed 4 px label margin, as v4 has nopaddingprop); edges stay centered on the midpoint — the curve or route midpoint for curved edges (rounds 12a/12b, v3's per-family rules); both offset bytext-margin-x/y; not pickable, one global font face (font-family/-style/-weight— the atlas holds one font), and the glyph atlas is a fixed 1024² texture — once full, new glyphs stop rendering with a console warning. Label color/text bake into glyph instances, so:selected/hover styling does not restyle label text. Round 13 D4 added thesource-label/target-labelfamilies (all ten props): two more glyph streams anchored at arc distancesource/target-text-offsetfrom each end, walked along the drawn path in the label VS (v3'scalculateEndProjectionon-GPU — segments exactly; bezier/loop/multibezier via fixed-sample polylines; route families along the route polyline, corner rounding ignored as v3 does), with per-end margins andautorotateat the local tangent; the remaining text channels are shared with the main label, exactly v3's unprefixed reads. Round 13 D2 added per-elementmin-zoomed-font-size(v3's rule — the label hides whenfont-size × zoom × dprdrops below it), baked into each glyph as a zoom threshold and tested in the glyph cull, so the floor costs nothing per frame. Round 13 B6 addedtext-transform(applied at glyph-run build),text-border-width/-color/-opacity(a band inward from the padded background box) andtext-background-shape(rectangle | round-rectangle, v3's auto radius);text-border-stylestays out with the dash-a-boundary styles — a round-38 question, where the scope call (full coverage, every shape) is taken but three sub-calls found while scoping it are not; see PLAN.md's open call 1.text-rotationtakes a number of radians on any label since round 27.7, alongside theautorotatekeyword (edge labels only — it resolves from an edge's slope). The stored value is the angle, withNaNas the autorotate sentinel: 'none' and 0 radians are the same rendering, so collapsing them leaves the whole real line free for numeric values, where an enum id would have collided with 1 radian. Recorded cost: the glyph instance grew from 56 to 64 bytes to carry the angle, ~14% on the heaviest stream, chosen over a per-owner storage buffer because the edge label pipeline is already at 7 of a base 8. Label visuals (round 10):text-outline-width/-color/-opacity(a second SDF distance threshold — near-free),text-background- color/-opacity/-padding(one solid quad instance preceding the run's glyphs, riding the same buffer/cull/draw; it carries the glyph block's height so it fades and culls exactly with its text), andtext-margin-x/y— all mapper-capable (CPU-evaluated, like font-size), as istext-rotation. Outline and background opacities fold into their stored alphas, so their getters read back folded (the arrow-color precedent). - Arrowheads:
source/target-arrow-shapesupportstriangle(+arrowalias),vee,chevron,circle,square,diamond,teeandnone(round 10 — SDFs generated from v3's arrow point tables and evaluated in the fragment stage; the shape ids ride a fragment-only storage binding, keeping the vertex stage at its 8-buffer budget). Round 27.6 completed the set with v3's compound heads:triangle-tee(a union of two generated polygons — coverage is a smoothstep over the distance, so a union is justmin( sdA, sdB )),circle-triangle(a polygon plus an analytic disc, pulled back by its radius so the disc meets the node boundary — v3'sspacing, and the only head v3 offsets),triangle-cross(whose bar thickness tracks the edge width, resolved per fragment) andtriangle-backcurve(its quadratic sampled at codegen into an ordinary point table). Recorded deviation:arrow-fill: hollowon a compound head falls back to filled — the strokeabs( sd )is wrong at the seam where a union's parts meet, and v3 does not stroke compounds either. Round 13 B7 addedarrow-scale(quantized ×1/16 in storage — readback rounds accordingly),source/target-arrow-fill(filled | hollow — a stroke ring at the per-endsource/target-arrow-width, which takes px, 'match-line' or % of the edge width, resolved at style-write). Round 27.3 ported v3's arrow sizing:max( (13.37 w)^0.9, 29 ) × scale, evaluated in model space before the zoom scale — the 29-unit floor is a model floor, so applying it to the LOD-floored device width would make arrows grow as you zoom out. Note that v3'ssizeis the point-table scale, not the drawn length (its tables span 0.3), and that the arrow quad sizes from a computedARROW_MAX_BACKrather than a fixed 0.3, since the compound heads reach 0.5 and 0.6. Round 13 C1 addedmid-source/mid-target-arrow-shape/-color: mid arrows anchor at the curve/route midpoint on the midpoint tangent (mid-source pointing backward), follow drags/layouts/tweens on-GPU, and are always filled at the standard width (the mid fill/width props are unsupported — a recorded scope note).source/target-arrow-coloras before (v3-like#999default). One quad per visible edge per enabled end, reusing the edge cull stream; the tip sits on the endpoint node's boundary (round-rect approximated by its box, polygons by their inscribed ellipse). Arrows draw over the line — a translucent arrow shows the line through it — are not pickable (the GPU pick pass stays edges-only), and size with the drawn (floored) edge width. - Gestures (round 10 additions): the cxttap family — right
button emits
cxttapstart/cxtdrag(once moving) /cxttapend, pluscxttapwhen the press never moved; the browser context menu is suppressed on the canvas.tapholdfires after an unmoved press ofcy.tapholdDuration()ms (default 500 — v3's constant, made a ctor option + getter/setter in round 20.1).dbltapfires on a second tap on the same target withincy.multiClickDebounceTime()(default 250 ms; ctor option + getter/setter), and the debouncedonetapfires when no second tap arrives — plaintapalways fires immediately, as v3. Dragging a selected node drags every draggable selected node (the whole set moves via one bulk shift per pointer move, all flagged grabbed). - Pointer transparency: the
eventsprop (round 20.2): v3'sevents: 'yes' | 'no'on both groups (default'yes'), constants orcasemappers (CPU-evaluated — the channel is a store-managed flag bit,FLAG_NO_EVENTS).'no'makes an element invisible to every pointer path while it still renders: the CPU node pick scans past it (grab/tap targeting, hover and tapdragover fall through to the element beneath), the GPU edge pick tile drops it (the edge cull kernels test the bit in pick mode only — apickModeFrame field the scene pass leaves 0, so scene culling is untouched), and the box-selection gesture skips it (no selection, no box/boxselect events — v3 boxes over its interactive set).interactive()readsvisible() && events !== 'no'. Recorded scope notes:cy.elementsInBox()stays a pure geometric query (the gesture filters, the query does not), and aneventsflag change invalidates the pick-tile cache through the flags column's dirty span (it changes pick answers, not pixels).text-events(round 20.3, default'no'— v3's): with'yes'the node's label block box (the exact laid dims at the D3 anchor + text-background padding, round 16.4) is part of the node for the CPU pick — tap/grab/hover on the label resolve the node; node labels never rotate, so the test is an exact AABB. Node-only in v4: the edges group throws (edge labels are never pickable — the GPU tile draws edge geometry only; recorded deviation), and anevents: 'no'element stays transparent whatevertext-eventssays. Recorded: the label box picks even when the label is LOD-faded at far zoom (labelFadePxis a renderer readability threshold, not a pick predicate). - The display/visibility split (round 22; third-sitting call —
"the distinction is useful"): two tiers.
show()/hide()stays the display tier (structural element state): a hidden element draws nothing, picks nothing and takes no space — excluded from bb/fit (round 22 also closed a gap where the fit scan and collectionboundingBox()still included hidden elements) and from compound auto-bounds — and, new in 22.3, a hiddenbezier-styled bundle member leaves its bundle: siblings re-fan, and the per-node loop stagger and compound-loop index skip it (v3's display semantics; a hidden node needs no bundle work — every member of a pair shares both endpoints, so the whole bundle disappears together, recorded).visibilityis a style prop ('visible' | 'hidden', both groups, constants orcasemappers — the v4 mechanism for per-element variation): paint-only — an invisible element draws nothing (body, label, ghost, layers — the one WGSLSHOWNmask covers every stream) and is not pickable/hoverable/box-selectable, but keeps its space (bb, fit, auto-bounds, layouts) and its bundle ranks (visibility flips never touch the curve index, so sibling curves are byte-stable). Ancestor-gated for nodes (descendants of an invisible parent are invisible — v3); an edge is invisible while either endpoint is (the kernels' existing endpoint tests). Mechanism: the style engine maintainsFLAG_SELF_INVISIBLE; the store derivesFLAG_DRAWNbeside effectiveFLAG_VISIBLEin the same subtree walk, and the WGSLSHOWNconstant readsALIVE|DRAWN— every cull kernel and the CPU pick honor visibility with zero new bindings. Getters:visible()= drawn (edges fold endpoints — v3's rule, now implemented);takesUpSpace()= the display tier alone (it can now differ fromvisible());interactive()ridesvisible();style('visibility')reads the element's own state.cy.elementsInBox()stays geometric (invisible elements are inside; the box gesture's interactive filter skips them). - Node charts: pie + stripes (round 23; third-sitting call —
"definitely yes, and consider other charts in future"): v3's 101
numbered
pie-*/stripe-*props return as the lean 8-propchartfamily (node-only):chart(none | pie | stripes),chart-values(a number list — a constant array/string, or the{ data: key }passthrough reading a per-element array from the sidecar, refreshed on writes of the key),chart-colors(a color list or a named scheme from the mapper DSL's palette table —category10default, cycling past its length),chart-sizeandchart-hole([0, 1] fractions or 'N%' — the hole makes donuts from the same surface),chart-start-angle,chart-direction(stripes:vertical | horizontal) andchart-opacity(folds into slice alphas, the B1 pattern). Scalars/enums are mapper-capable; the two list props are constants-only (the 12b rule) with the values passthrough as the per-element form. Values are absolute fractions of the whole (v3's percents: a sum under 1 leaves an unpainted remainder, over 1 clamps); slices cap at 16 (v3's N). Records live in a round-11-compacting blob behindnode.chartRef; rendering is a dedicated pass (one quad per charted node off the culled visible lists, after the image pass — v3's order — clipped to the node shape at the border's inner edge, SDF-native with px-space AA at slice boundaries), skipped outright while nothing charts. Charts are paint-only: never in bb, never pickable. Pinned by thecharts-pie-stripesgolden and two live v3 parity scenes — pies at 0.000% (pixel-exact), stripes at 0.005%. Recorded: charts share theimageMinPxreadability floor; two upstream v3 stripe bugs constrain the stripe parity to vertical square-node scenes (v3's 'horizontal' keyword is inert — its draw switch tests a typo'd 'righward' — and its drawStripe swaps W/H in the centering offsets), with the golden pinning v4's horizontal and non-square behavior. - Interaction tuning options (round 20.1, all v3 defaults, all
ctor options with
multiClickDebounceTime-style validated getter/setters read live by the pointer layer):wheelSensitivity(default 1 — a multiplier on the wheel-zoom exponent; custom values keep v3's once-per-instance console warning about hardware variance),desktopTapThreshold(default- and
touchTapThreshold(default 8) — css px a press may move and still count as a tap, chosen per event by pointer type (v4 previously used 4 for all pointer types) — andtapholdDuration(default 500 ms; v3 hardcodes it, v4 makes it configurable — a deliberate small surface addition).pixelRatio('auto'| number — the ctor option overriding the renderer's device pixel ratio) predates the round and is spec-pinned since it: the backing store scales by it while picking stays css-px addressed.
- and
- Box selection: with
boxSelectionEnabled(default on), a drag while a multiple-select key (shift/ctrl/cmd) is held — or any drag when panning is disabled — draws a selection box (a DOM overlay above the canvas) and on release selects the contained elements with the v3 event flow (boxstart/boxendon the core,box/boxselectper element). Geometry is v3's default 'contain' semantics answered by one columnar scan (cy.elementsInBox(x1, y1, x2, y2), model coordinates): a node counts when its bounding box (incl. border) lies fully inside; an edge when both of its endpoints do. Since 12b, curved edges test their curve boundary endpoints — exactly v3's on-boundary rule, via the full-family CPU evaluator (the revisit deferred from 12a); straight edges keep the endpoint-center approximation (a recorded deviation).boxSelectionMode(round 39.1) picks the other rule v3 offers:'contain'(the default, above) or'overlap', where a node counts when its box intersects the band and an edge when any part of its drawn path crosses it — either endpoint inside, or a segment clipped by Liang-Barsky,curve-geometry.mts'ssegmentHitsBoxbeing the CPU twin of the test the cull pass has run per edge per frame since the first cull. Curved edges take the conservative-then-exact shape the rest of the curve geometry uses: the memoized exact bb rejects the common miss, and only a survivor pays for the flattened walk at the drawn subdivision. Two boundaries are deliberate. The mode is read by the gesture only —cy.elementsInBox()stays the pure geometric containment query, so a programmatic caller's results never move under an interaction preference — andboxSelectionIncludesLabelsreverses sense with it, since that is all it can mean in each: under 'contain' the label box must also be inside, under 'overlap' a label crossing the band is enough (containment is an AND over an element's parts, overlap an OR). Recorded shape difference: v3 spells this as a per-element style prop (box-selection, whose third value'none'v4 covers with theeventsprop); v4 makes it a whole-instance option, with the interaction quartet on the core. Cost (benchmark/spatial.mjs, N=2000 / 4000 edges, a band over half the graph): overlap is ~1.9× containment on straight edges (246 → 470 µs) and ~1.9× on curved ones (968 µs → 1.80 ms), the curved pair being dearer on both sides because containment already evaluates curve endpoints there.selectionType()is 'single' (tap/box replaces the selection) or 'additive' (taps toggle, boxes add). Three-finger touch box selection landed in round 20.5 (v3's gesture): withboxSelectionEnabled, three fingers sweep a box from the start centroid of the three to the moving centroid —boxstarton the first move, the themed DOM box drawn live, applied (boxend / box / boxselect, the 20.2 interactive filter, the 16.5 label-containment option) when a finger lifts — and a gesture that boxed never degrades to a pinch (v3's didSelect latch; leftover fingers stay inert until all lift). The box preempts a pinch in progress (v3's branch order), and a third finger landing on an undragged cxt pair converts it to the box gesture (cxttapendfirst) — pointer events land fingers sequentially, so this is the v4 form of v3's simultaneous three-finger landing (recorded call). As v3, the touch box is additive (it never clears the prior selection, unlike the mouse box under 'single'), and an aborted gesture (pointercancel) selects nothing. - Batch flush granularity:
endBatchre-applies style to elements added during the batch and refreshes mapped labels; a sheet set during the batch flushes as one whole-graphapplyAll. Unlike v3 there is no per-notification queue to replay — the renderer is dirty-driven. cy.json()is export-only: the import/restore form throws (rebuilding from a snapshot needs stored defs the prototype does not keep). Exported element jsons round-trip through the definition form ofelements/cy.add().- Two-finger touch: cxt or pinch (the round-20.4 split, v3's
rules): a second finger landing closer than 200 css px to the
first starts the touch cxt gesture —
cxttapstarton the node under finger 1 (else finger 2, else the core; the synchronous CPU pick),cxtdrag+cxtdragover/cxtdragoutas the pair moves,cxttapend(+cxttapwhen it never dragged) when either finger lifts — and the pair spreading past 1.5× (or 150 px) cancels it into a pinch (cxttapend, then the pinch machinery takes over from the current spread). A farther pair pinch-zooms about its midpoint immediately (panning with it). Either way the second finger cancels any pan/grab in progress, and the finger left over after the gesture stays inert until lifted. Like other viewport gestures, no hover/tap semantics apply mid-pinch. Trackpad pinches arrive as ctrl+wheel and take the wheel path. Recorded deviation: v4 thresholdscxtdragon finger-1 movement pasttouchTapThreshold(matching its mouse cxt path) where v3's touch cxt emitscxtdragon any move event. - Image export is promise-only:
png()/jpg()return promises for every output form (a synchronous readback is impossible on WebGPU;'blob-promise'is accepted as an alias of'blob'). Output dimensions are capped by the device's max texture size (typically 8192 px — the export throws rather than tiling;maxWidth/maxHeightare the tool to stay under it), and a viewport export of a zero-sized container throws.renderTois not implemented. mount/unmount(round 10):cy.unmount()tears down the renderer and pointer — the instance becomes headless, with nothing lost (the model is CPU-canonical);cy.mount(container)re-attaches a fresh renderer, which re-uploads every column and rebuilds all glyph runs from the model, so mutations made while headless render on re-mount. Re-mounting to the same container is a no-op; a different container unmounts first.- Compound nodes (round 14) — the deviations in one place (the
round-14 paragraphs above carry the detail): parent decorations
(ghost/underlay/overlay/labels) keep their post-edge draw
positions while parent bodies draw first (permanent — z-index
dropped 2026-08-01);
parents are excluded from the early-z prepass; parent boxes can
sit sub-pixel smaller than v3's with bordered children (no
miter-corner overshoot in the child extents);
parent()always returns a proper collection;move({ parent })re-parents in place (no remove/restore refs cycle); compound-loop endpoints anchor outside-to-node rather than v3's outside-to-line;boundingBoxAtskips parent bodies (fit-target approximation); drag sets don't flag descendantsgrabbed; and the min-size bias props /compound-sizing-wrt-labels: 'include'(compound auto-sizing reads body extents; public bb includes labels since 16.4) /:parent:selected/z-compound-depth/z-index-compareare not ported (decided design). - Background images (round 15) — the deviations in one place
(the round-15 section above carries the detail): at most 4 images
per node (fixed FS loop; warn-once); every per-image list prop is
constants-only, with mappers on the single forms of
background-image,-image-opacityand-image-coloronly;background-width/height-relative-tois not ported; images never joinboundingBox()or picking (unclipped overflow is not in bb — thebounds-expansiondrop's sibling);clip: node+containment: insideclips at the border's inner edge, so a translucent border shows fill rather than image; repeat tiles confine to the node box; raster resolution caps at the top tier (1024²); sdf-icon mode collapses multi-color sources to their alpha silhouette; crossoriginnullnarrows to same-origin fetches (WebGPU cannot upload tainted content); no demotion after zoom-promotion (the waste policy reclaims);imageMinPx(default 8 displayed px) skips images on unreadably small nodes; ghosts do not carry images (the A1 simplified-body rule). - Slot compaction (round 19) — the deviations in one place (the round-19 section above carries the detail): moved elements take fresh generations, so refs held across a compaction repair lazily (in place) rather than staying bit-identical; data-sidecar column buffers never shrink (in-place constraint); the curve-slack maxima stay monotone rather than recomputing; the auto trigger defers mid-batch and during a live GPU force run; a compaction demotes mid-flight GPU tweens to the CPU for the rest of their run.
- Interaction + pointer transparency (round 20) — the deviations
in one place (the round-20 bullets above carry the detail): the
touch tap threshold now differs from the desktop one (8 vs 4 — v4
previously applied 4 to both);
tapholdDurationis configurable (v3 hardcodes 500);events: 'no'elements stay out of the box gesture whilecy.elementsInBox()stays geometric;text-eventsis node-only (the edges group throws; edge labels are never pickable) and a LOD-faded label still picks; touchcxtdragthresholds on finger movement (v3 fires on any move event); the touch box is additive and a third finger on an undragged cxt pair converts to the box gesture (the sequential pointer-events form of v3's simultaneous landing). - No animation queue (round 21, user-approved divergence):
animations start immediately and compose by channel; overlapping
channels evict the older animation in place; sequencing is
await animation.promise();stop(jumpToEnd?)lost v3's clearQueue argument; thequeue/stepoption spellings throw. - Display vs visibility (round 22) — the deviations in one place
(the split's bullet above carries the detail):
visibilityis a style prop, not an element-state API (per-element variation is acasemapper); edgevisible()now folds endpoint state (v3's rule — previously own-flag only);hide()re-fans bezier bundles (previously kept ranks); the fit scan and collectionboundingBox()now exclude display-hidden elements (previously a gap);takesUpSpace()can now differ fromvisible(). - Node charts (round 23) — the deviations in one place (the
charts bullet above carries the detail): 16-slice cap; values are
absolute fractions clamping at 1 (no normalize option — apps
normalize); list props (
chart-values,chart-colors) are constants-only with the{ data }passthrough as the per-element form; charts share theimageMinPxreadability floor; readback reports resolved fractions (declared percent strings do not round-trip); the stripe v3 parity covers vertical square-node scenes only — v3's 'horizontal' keyword and non-square centering are broken upstream (recorded), the golden pins v4's behavior. - Transitions + controls (round 24) — the deviations in one
place (the design bullets above carry the detail): the trigger
taxonomy is v4-specific (no classes, no bypass — restyles and
mapper re-evaluations trigger; v3 transitioned on class/bypass
changes); durations/delays are plain numbers of milliseconds (no
v3 time-unit strings); transition config is constants-only (no
per-element transition props); discrete channels snap at the
transition's start (geometry numerics tween since round 25);
channel-opacity folds transition under their color prop
(stored-truth diffing); a listed prop's mapper eval never runs on
the GPU eval kernel (mutually exclusive per channel);
progressis a getter only (v3's setter/scrubbing is out),apply/applyingare out, and reverse's value continuity is exact only for point-symmetric easings (v3's start/end swap shared the rule). - Device-loss recovery (round 10): an external device loss emits
devicelostand auto-recovers once — the core re-mounts a fresh renderer against the same container (the model is CPU-canonical, so columns, glyph runs and pipelines all rebuild), then emitsdevicerestored. If a loss lands while a recovery is in flight, or the device can't be re-acquired, the instance goes headless-dead and emitserror(the previous behavior).
Follow-up hooks
- The release sequence (rounds 44–51), and what is left of the four
that have landed. 44 (packaging) is complete as a source
concern; its one remaining act is release-time and belongs to round
50 — the first release build must actually commit the five
dist/bundles, andpre_release_test.shshould runtest/modules/packaging.mjsafternpm run dist, where the "do these files exist" half is meaningful. 45 (the docs generator) is complete, and it unblocks 46, the docs site, which now has its input rather than a plan for one. 47 (the migration guide and CHANGELOG) is complete; both ship in the package, so they are documents to keep true rather than to write once. 48 (soak) landed its Node tier and its device-loss-under-load specs; what remains of it is the documented limit edges — the 256-layer image cap, a full glyph atlas, the export texture cap — each of which needs a fixture big enough to reach the limit rather than any new technique. 49–51 (cross-platform validation, release engineering, the release bake) are untouched; 49 needs hardware this box does not have. Two public-surface changes were made in these rounds without a call, and both are logged in PLAN.md's "Open calls for the maintainer" as items 14 and 15 rather than left in a diff: round 45 exported the layout contract's types, and round 48.4 made twelve collection methods throw across instances where they had been answering wrongly. Slot compaction— closed by round 19 (2026-08-01, the section above): live slots compact with a monotone remap, forwarded lazy ref repair, and the auto + explicit trigger pair. The slot-stable tier (id blob, CSR adjacency, string dictionaries) has self-compacted since round 11. No architecture hooks remain open; demand-gated feature hooks (the elevated draw tier, per-side compound padding, multilevel force refinement, more layouts, future chart kinds on the round-23 surface) stay logged in their sections above.- Open API follow-ups:
the animation controls and style transitions— closed by round 24 (2026-08-01, the design bullets above): transitions landed with the stored-truth trigger diff + GPU offload, and the handle carriespause/resume/reverse+ read-onlyprogress/paused.The geometry-tween round— closed by round 25 (2026-08-02, the geometry-tweens bullet above): node width/height, edge width (+ ride lanes), compound padding and font-size tween through the animation system andtransition-property, CPU-canonical per tick with the invalidation cascade in the store's write funnel, benchmarked.The small parity remnants— mostly closed by round 27 (2026-08-02): the unported shape keywords, the compound arrow shapes, v3's nonlinear arrow-size formula and per-element numerictext-rotationall landed, completing v3's node-shape and arrowhead vocabularies.border-style/outline-stylewas the last one left, waiting on a scope call rather than a technique; the fifth design sitting took it — full coverage, every shape — and it builds as round 38, which has not started: scoping it turned up three further sub-calls the sitting did not reach (v3'sdoubleerases a stripe rather than drawing a second band;dashedborders needborder-dash-pattern/-offset, which v4 has for edges and not for nodes;border-cap/-joinhave no v4 counterpart), logged in PLAN.md's open call 1.The— closed by round 28.2 (2026-08-03, the viewport-targets bullet above). Round 28 also closed the verification gap round 27 left behind (its CPU-pick branches were untested) and trued up the gap ledger, which then held only open design calls. Every one of them was taken at the fifth design sitting (2026-08-04), together with the contradictions rounds 28–29 turned up between the code and the decided-design ledger and round 30's question of whether error-contract coverage should gate. The outcomes, and where each lands:panByanimation targetborder-style/outline-styleat full coverage (round 38); the legacy-alias policy split —roundrectangledropped,autolockNodes/autoungrabifyNodeskept as recorded exceptions (landed, round 37.2); overlap box selection,cy.gc()and graph-leveldatain the binary wire format all build — all three landed in round 39 (2026-08-04); core/collection extension points stay demand-gated deferred; unknown constructor options stay runtime-permissive, closed at the type layer instead (landed, round 37.3); dropped v3 event names stay legal and silent, documented (landed, round 37.4);preventDefault()is half wired — its DOM half landed with the v4 Event (round 41.4) while the gesture half became a new open question, since the enumeration of preventable defaults turned out not to be derivable from v3 at all; and both audits gate (landed, round 37.1).cytoscape.warnings()builds too, but the error policy behind it — v3's mostly-no-throw stance against v4's fail-loudly design — is the one question deliberately left open, and is round 40's own sitting. The sitting's packaging decision — v4 becomes the package, v3 into a self-containedv3/— landed as round 42 (2026-08-04), with the two calls it left to docs-first taken there: thegpu-/webgpu-prefixes drop, and the five shared utility modules duplicate rather than stay shared, so nothing undersrc/imports outside it. A third call the plan had not foreseen — theGpu*exported type names, logged as open call 13 — was taken the same day and executed as 42.6, so the whole public surface is unprefixed. What round 42 deliberately did not do, each logged for its owning round: the dist/exports hardening and the pack spec (round 44), and the three v3 release workflows, which stay in.github/because GitHub reads workflows only from the repo root — marked as unadapted rather than half-repointed, and round 50's job. PLAN.md's "Open calls for the maintainer" remains the one place to read before deciding anything about v4's surface: contradictions are logged there rather than patched, because removing public API is a call to be made, not inferred. The debug harness— rebuilt by round 43 (2026-08-04), which was inserted ahead of the release sequence (and renumbered the old 43–50 to 44–51). It found the harness both broken and misleading: four of its seven networks had 404'd since round 42 moved the v3 tree, silently, and its style sanitizer kept a 14-property whitelist that dropped every mapper — so v4's whole style surface was being discarded before it reached the core, and the page read as "v4 can't style". Now hand-authored sheets per fixture (the real enrichmentmap.org style among them), two genuinely compound graphs, the v3 page's control sections, andtest/modules/debug-harness.mjs—debug/'s first test. The round also fixed the background-grab indicator, which had never followed the cursor (see the core-theming notes above). A maintainer review pass on 2026-08-05 found three more, all of which only a person opening the page could have: livereload had never connected (it bindslocalhost, which resolves to::1here, whilehttp-server -oopens the page at127.0.0.1); box selection cost seconds of forced layout, because the event log readscrollHeightafter every appended row and box selection emits three events per element; and the compound fixture was not the verbatim port ofv3/debug/compound.jsits record claimed — the node order had been sorted and v3'scols: 3dropped, which is what made its parent boxes overlap. Fixing the third is what turned up thefit()over-estimate corrected in the compound-loop-edge notes above.The completion tail— closed by round 36 (2026-08-04): the@returnstail round 32 measured and deferred (63 written, so 276/276 — reported and not gated at the time; round 37.1 gates it, and the surface is 279/279 today), the@paramgate's own blind spot (it had never walked the public tier's exported functions; 229/229 then, 232/232 now), the four reachable browser-only throws and the three that are not, the two public collection members no benchmark called, and the three measurements this repo had promised and never taken (--layouton real hardware, the report profiles' wall times, and a re-runnable source for rounds 34–35's bundle figures). What it found rather than closed: a stranded-doc-block check, whose first run turned up six more instances of the eleven-instance pattern — one of them shipping indist/cytoscape.d.ts— and which reports rather than gates because the third shape of the defect is not statically detectable at all.Five measured slow paths(round 33) — all five fixed in round 34 (2026-08-03): the style getters (292 → 122 ns, via memoizingnormalizeProp), the emit path's missing no-listener gate (338 → 8 ns), the layout contract's per-run materialization (333 µs → 795 ns),mutableElements()(121 µs → 20 ns, via a structure-epoch memo) andindexOf()(12.5 µs → 41 ns, parity). Two of the five findings were corrected while being fixed — the style gap was 5.8× rather than 13–21× (tsx's__namewrapper inflated it) and the emit row round 33 cited never reached the emit path at all. The before/after numbers are in the Benchmarks section above.- Documentation — round 26 (2026-08-02) settled the near-term
shape: JSDoc on the source is v4's documentation source of truth
and the declarations ship with it (see "Documenting the source"
above). The generator that turns those comments into docmaker
input was the half left open there, and round 45 built it
(2026-08-04;
npm run docs:api, and "The generator" above). What stays open is the release docs themselves: the site is round 46, andv3/documentation/belongs to v3 until then. What is ready is the input: after rounds 31–32 the public surface carries all three of the tags a generator reads — a doc comment on every member (26),@throwswherever a member throws (31.2), and@paramon every member that takes arguments (32, widened in 36.2 to the exported functions the audit had never walked, and again in 37.3 toexport default function— the entry point, and again in round 45 tosrc/event.mts, a whole file the tier had never listed: 232/232) — each gated, so the generator's input cannot rot before the generator exists.@returnsis complete since round 36 (279/279) and gated since 37.1: docmaker's shape still has no return field, but the tags ship as.d.tshover text regardless, and a tail completed by hand four rounds after it was measured is what an ungated rule looks like. The generator landed as round 45 (2026-08-04; see "The generator" above) —npm run docs:api, 362 documented members over 48 sections, validated against the shipped declaration rather than against the sources it reads. The site is round 46 — no longer "not until v4 ships" but scheduled, and now with its input built. Also logged from 26.5:event.targettyped asunknownon the shared v3 event object — closed by round 41, which gave v4 its own Event and emitter. It did not sever v4's last shared-module import of v3, as the round-41 plan assumed: five generic utility modules remain (math,types,util/colors,util/position,util/sort), now a maintained allowlist intest/modules/import-graph.mjsand a round-42 call.