Skip to main content

Renderer

@dagr/render draws a graph. It takes coordinates, not a Graph: whatever @dagr/layout works out goes on screen through a three.js WebGPURenderer, with an orthographic camera, critically damped springs for carrying nodes between one layout and the next, and one draw call per shape family.

For a working React example, start with rich nodes and edges. For changing layouts, try Follow an edit. The sections below are the detailed renderer reference: scene geometry, camera, motion, backend selection, and HTML overlays. The layout package stays independent of this renderer.

What is on screen

Whatever a caller passes to setNodes, and nothing else. createRenderer mounts a three.js WebGPURenderer on a canvas, draws an empty scene on near-black, and waits.

const renderer = await createRenderer({ canvas });
renderer.setNodes([
{
id: 'preview',
shape: 'roundedRect',
center: { x: 0, y: 0 },
size: { width: 200, height: 80 },
cornerRadius: 16,
fillColor: 0xfb8500,
glowColor: 0xffb703,
glowWorld: 20,
},
]);
renderer.render();

The showcase now uses a small pattern pipeline to demonstrate rich content. Earlier campaign and shape-ladder captures remain in the repository's historical screenshots. Those captures document experiments, not current performance measurements.

Creation is asynchronous, and that is a property of WebGPU rather than a style choice. Getting a device means requesting an adapter from the browser, which is a promise, so there is no synchronous moment at which a WebGPU renderer is usable. Awaiting it once inside createRenderer means the object handed back is ready to draw, and no caller ever holds a renderer that exists but cannot render.

three's WebGPURenderer falls back to WebGL2 by itself when WebGPU is unavailable, so createRenderer resolving is not a promise that WebGPU is in use. Since M4.9a it is not silent about that either: pass backend to say what is acceptable, and read renderer.backend for what happened. See Two backends, and which one you got.

A node keeps its handle

setNodes diffs by id. A node present in two consecutive calls is updated in place, keeping the instance handle it had; one that left is freed, and one that arrived is allocated. The stable handle keeps its instance-buffer identity attached while removals move dense slots. M4.6's springs are keyed by the caller's node id instead, so a handle replacement does not lose velocity.

The one case where the handle cannot survive is a node that changes SHAPE, because the two shape families are two meshes and an instance cannot move between them. It is a removal and an addition. The caller id is also why the spring and M4.8's picking id survive that replacement while the handle does not.

What setNodes deliberately does NOT take is a LayoutResult. Naming one would make @dagr/layout a dependency of this package, and the y-down to y-up conversion belongs to whoever owns the layout. See the conventions section below, which has said so since M4.1.

Shapes are signed distance fields

A shape is not geometry here. Every shape is one padded quad, and the fragment shader asks a single question per pixel: how far is this pixel from the shape's boundary, signed negative inside. roundedRectSDF and circleSDF answer it, and everything visible is read off that one number.

That is what buys the property the whole approach exists for. Fill, outline and glow are three regions of one distance rather than three pieces of geometry, so they cannot disagree about where the edge is, and the edge itself is antialiased analytically per pixel rather than by sampling. A rounded corner is exact at any magnification because it is an arc in the arithmetic, not a run of triangles chosen when the mesh was built.

Two units, on purpose

An outline is measured in device pixels and is inset. A glow is measured in world units and is outside. The asymmetry is deliberate and it is the substantive design call in this task.

Device pixels rather than CSS pixels, and the difference is worth stating because it is visible. The antialiasing width is a derivative taken across a framebuffer pixel, and the framebuffer is the CSS size times the device pixel ratio, so a 2 pixel outline is 2.00 CSS pixels at dpr 1, 1.00 at dpr 2 and 0.67 at dpr 3. The reference frames below were captured at dpr 1. Making the border display independent would need the ratio inside the shader, which would put a second reader of devicePixelRatio next to drawingBufferSize and break the single reader rule the camera states, so the unit is documented rather than converted.

An outline is a property of the screen. A two pixel border should be two pixels at every zoom, which is precisely what a geometry pipeline cannot do without rebuilding geometry and what a distance field does for free: the same derivative that gives the antialiasing width converts pixels into world units at the fragment being shaded. The band's two 50% points are the boundary itself and widthPixels pixels in, so its outer ramp is centred on the boundary exactly like the fill's, and "inset" here means it is drawn over the fill and never against the background rather than that it lies wholly inside the boundary. The shape's footprint is still a contract, and the paragraph after next is what keeps it: a layout gives a node a box, and a border that made the shape a pixel larger than that box would mean a hit test built on the geometry missed pixels the user can see.

A band of w pixels reaches full coverage at w pixel centres, so a hairline draws one opaque pixel and a two pixel border draws two. That is worth stating because an earlier draft of this code got it wrong in a way no test caught. It inset the band by half a pixel so that its coverage was exactly zero at the boundary, which sounds like the stricter contract and is in fact a worse one: the opaque plateau of an inset band is w minus 2 pixels wide, which is EMPTY at w = 2, so both pixel centres of a two pixel outline sampled 0.5 and the outline never drew its own colour anywhere. It took decoding a real frame to see it, where the navy 0x023047 came out as #bc8932, the amber fill half mixed with it.

The inset bought nothing, and that is the part to keep hold of. Coverage here is the fill's own ramp applied to max(d, -(d + w)), and since max(d, anything) is never less than d, outline coverage is at most fill coverage at every distance, width and antialiasing width. The alpha a shape writes is the max of the three coverages, so an outline can never make a pixel more covered than the fill already makes it: the shape's footprint is identical with a border and without one.

The max rather than the textbook abs(d + half) - half is deliberate, and it is why the numbers elsewhere on this page are exact rather than nearly exact. The textbook form computes (d + half) - half, which does not land back on d, so the outer ramp comes out a few ulps off the fill's instead of identical to it, the coverage at the outer cutoff reads about 1e-29 rather than 0, and zoom invariance holds to 1.4e-16 rather than exactly. The max returns d unrounded on the outer side, so all three are exact and the footprint comparison needs no tolerance at all.

A glow is a property of the shape. The quad has to be padded to contain the halo, and that padding sizes the quad in the vertex stage, from the instance's own glow reach, so a pixel-space glow would need the quad resized every time the camera moved. That is a per-frame scene decision and nobody owns it yet, for the reason the padding section above gives. A halo that stayed six pixels wide while its shape grew from one pixel to a thousand would also read as a different effect at each end of the range.

The antialiasing width is a gradient length, not fwidth

The width of the ramp is the larger of the two per-axis gradients of the interpolated POSITION, max(length(vec2(dFdx(p.x), dFdy(p.x))), length(vec2(dFdx(p.y), dFdy(p.y)))), and deliberately not fwidth.

Of the position, not of the distance, which is a correction rather than a detail. Every field here folds: roundedRectDistance runs both coordinates through abs and circleDistance squares them, so on the fragment quad holding a shape's centre all four fragments see the same distance, the difference is zero and the width collapses. The inset outline then vanishes exactly there, which on a small shape is the whole shape. The position has no abs in front of it and cannot fold. The euclidean-gradient argument that used to justify differentiating the distance was sound about magnitude and silent about folding.

Why not fwidth. Each length above differentiates one position COMPONENT along both screen axes. Under today's axis-aligned orthographic camera a world component varies along one screen axis only, so its other derivative is zero and length(vec2(dFdx(p.x), dFdy(p.x))) is abs(dFdx(p.x)), which is fwidth(p.x): the two forms agree exactly in real arithmetic and to within sqrt's rounding on hardware, around every corner and at every zoom. The Euclidean form is kept for the camera this package does not have yet. Under a rotation a component varies along both screen axes, fwidth reads up to a factor of sqrt(2) wider than the Euclidean length depending on the angle, and the ramp would soften and sharpen as the camera turned. It costs two sqrts per fragment, one per length, against none for fwidth. A shear or a non-uniform scale is a case further out, named in the shader comment rather than solved: there the right width is the pixel footprint along the boundary normal, which a per-component max does not compute. An earlier version of this page said fwidth would draw corners up to 41% softer than the flat sides, because fwidth is the L1 norm abs(dFdx) + abs(dFdy) and L1 exceeds the Euclidean length by a factor of sqrt(2) at a 45 degree edge. That is true of the DISTANCE field's gradient, and nothing in the shader differentiates the distance. What the equality does mean is that no screenshot taken through today's camera could tell the two forms apart; see what is knowingly untested for why no Node test could either.

The fields are TRUE euclidean distances outside the shape rather than a cheaper approximation, and that is what makes the width usable: the gradient of such a field has magnitude 1 in world space, so a distance of one aaWidth is one device pixel. The shader never takes that gradient; the unit magnitude is what makes comparing the distance against a width measured off the position mean what it says. Nothing reads the camera: the width follows whatever transform the position has picked up on its way to the fragment stage, which today is the camera's uniform scale alone, since M4.3's per-instance quad scale is applied before the varying and local reaches the fragment stage in world units.

Where the fade stops, measured

A shape drawn smaller than a pixel fades toward the background rather than aliasing into a flickering speck, because the coverage falls away with the shape. That is the behaviour analytic antialiasing is for, and it holds down to about a pixel: at zoom 0.2 the 10-unit rung draws as a 2 by 2 block of #7e4d1b, a dim amber against the #ffb703 it is at full coverage.

Below that it stops, and the reason is worth knowing because it is not the shader's. At zoom 0.1 that same rung does not appear at all: its padded quad is 1.4 by 0.8 CSS pixels, and whether a footprint that small covers a sample point depends on where it lands on the grid. The 10-unit circle beside it survives as one dim pixel in the same frame. Nothing a distance field does can help there, because the fragment that would have faded is never shaded. It is a rasterisation limit, and the honest place for it is here rather than in a claim that shapes fade all the way down.

Those two smallest shapes are also the only ones whose fill ramp is clipped by their own quad in the 0.1x frame, and getting that wrong is easy, so the arithmetic is worth stating. A quad is padded by the glow radius plus one world unit, and the fill's ramp reaches half a pixel past the boundary, so the ramp survives above zoom 1 / (2 * (glowWorld + 1)). Each rung's glow is a quarter of its height, which puts the three crossovers at 0.25, 0.045 and 0.005: four of the six shapes are therefore clean at zoom 0.1 and the two 4-unit-tall ones are not. What is clipped there is the antialiasing of a shape already under a pixel across, which is why it is invisible in the frame, and a zoom-aware quad is nobody's yet: M4.4 scales the quad per instance in the vertex stage but its padding still has no zoom term, and the case it would fix is now handled where it costs nothing (the glow's ramp is capped at the quad, so the halo ends where the quad does rather than being cut by it).

One material per family, provisionally

M4.2 left this open on purpose and named the deciding factor: per-fragment branch cost at ten thousand instances against real fill rate, which cannot be measured while six shapes are on screen. M4.3 owns the per-instance attribute, so it owns the assembly, and the call is one material per shape family rather than one uber-material with a per-instance shape id.

The uber-material's cost is per FRAGMENT and the per-family cost is per DRAW CALL. A shape id branch is evaluated for every pixel every instance covers, and a graph at readable zoom is mostly fill; the draw call it saves is one call. The family count is small and known (a rounded rect, a circle, and whatever M6's VDSL asks for), so the union of uniforms an uber-material pays for grows at the same rate as the calls it saves.

This is PROVISIONAL and the revisit gate is M4.10, which is the first point with the fill rate and the instance count to judge it. What makes it cheap to reverse is M4.2's own decision: the distance functions are composable nodes with no opinion about material assembly, and the shading node consumes a DISTANCE rather than a shape, so reversing this rewires one assembly function and touches no formula.

depthWrite is off on these materials, which is worth stating because three leaves it on for transparent materials. Left on, a fragment with alpha 0 still writes depth and a transparent quad occludes whatever is drawn behind it afterwards. It makes no visible difference today (the quads are provably disjoint) and it is exactly wrong for M4.5, which layers edges behind nodes and selection in front on the same plane.

Instancing, and the one invariant it imposes

One mesh per shape family, each drawing every shape of that family in a single call. A unit quad is scaled in the vertex stage by the instance's own padded quad size, so one geometry serves shapes four world units across and shapes a thousand across, and a campaign of three thousand nodes is two draw calls.

What is per instance is what a graph varies: the centre, the size, the corner radius, the glow's reach in world units, and two colours. What stays a uniform is what a design decides once for the whole drawing: the outline colour, the outline width in device pixels, and the glow's alpha. The glow's REACH is on the instance side and its ALPHA is not, which looks inconsistent until the quad is considered: reach sizes the padded quad, so a shared reach would either clip a large shape's halo or waste fill rate on a small one.

A colour reaching a shader as a uniform is converted from sRGB by three's Color. A colour reaching it as a vertex attribute is converted by nothing, so the conversion happens on the way into the buffer instead. Skipping it does not throw and does not look broken: every colour comes out lighter and flatter.

Removing an instance is swap-with-last, so per-instance state is keyed by HANDLE and never by SLOT. Freeing a slot moves the last live instance into it, which keeps live slots contiguous and keeps one draw call covering them with no holes and no per-slot liveness test. The cost is that a slot index is not durable across any removal, and the failure is silent: the slot stays a perfectly valid index, it merely belongs to a different instance now. A handle is never reused, so a handle held past its instance's removal raises UnknownInstanceHandleError rather than addressing whatever took its place. Spring state was the predicted first consumer of that and is not one: M4.7a keys a node's spring by the caller's own node id, one layer further out again, which is where M4.8a's picking ids also went, which is the same invariant with room to spare: the id survives even a shape change, which reallocates a handle.

None of this is exported. setNodes is the seam a caller feeds a graph through, and an instance HANDLE API on top of it would be a guess at what M4.8's picking pass wants, made before there is a picking pass. The error classes are exported, because an error arrives in a caller's catch whether or not the module that throws it did.

The camera

Camera2D is an orthographic 2D camera: a world centre, a zoom, and the size of the canvas it looks through. It is also the only part of this package a unit test can reach, so it carries the whole of the package's verified contract. See How this package is tested for why that is a design decision rather than an accident.

Conventions

Three conventions are fixed here, and every later M4 task inherits them.

World y is up, screen y is down. World space is the space a layout is computed in and the space a caller thinks in. Screen space is CSS pixels with the origin at the canvas top-left corner, which is where a PointerEvent's offsetX and offsetY already are. Note that offsetX is relative to the event's TARGET, so a drag that leaves the canvas, which is exactly the pan gesture this milestone ships, wants clientX minus the bounding rect instead. The flip itself lives in four methods and nowhere else: screenToWorld, worldToScreen, panByScreen and zoomAtScreen. The last two spell their sign out independently rather than deriving it from the first two, so a future task revisiting the convention has four places to look, not two.

Be aware that @dagr/layout computes in y-down coordinates. This package first drafted a Rect of {x, y, width, height} meaning the bottom-left corner, which was structurally identical to layout's Rect meaning the top-left one. The compiler cannot see the difference: a layout rectangle assigned into a world slot compiled clean, and the symptom was a scene mirrored about the horizontal axis with nothing red anywhere. A docstring on each saying which corner it meant was the first attempt and was not enough.

So there is no Rect here. Camera2D.visibleWorldBounds() returns WorldBounds, which is {minX, minY, maxX, maxY}. Extents are not structurally assignable from either rectangle, so the mistake is a type error rather than a naming convention, and "which corner is x, y" stops being a question instead of being answered. It is also the shape a culling test wants. Converting between the two spaces belongs to whatever feeds a layout result into a scene, which is the caller, and settling the type early is what turned that into a compiler error at the seam rather than a review comment. The campaign demo does the flip in one function at the end of its build, so there is exactly one line where the sign changes.

Zoom is CSS pixels per world unit. At zoom 2 a one-unit box draws two CSS pixels wide, and zooming in raises the number. The alternative reading (world units per pixel, so zooming in lowers it) is defensible and is what a map library sometimes means by scale; this is the one Dagr uses.

The device pixel ratio is read in exactly one method, drawingBufferSize. Every other method is pure CSS pixels and world units, and changing only the ratio cannot move a single result by a single bit. That is asserted rather than intended: the suite checks exact equality across ratios 1, 2 and 3.5, which holds because the ratio never enters the arithmetic at all. The reason to insist is that every input event and every CSS length a caller has is in CSS pixels, so a camera that mixed the two units would be wrong only on the machines the developer is not using.

The API

MethodWhat it answers
screenToWorld(p)where a click landed
worldToScreen(p)where a world point draws
visibleWorldBounds()what the canvas currently shows, as minX/minY/maxX/maxY
orthoFrustum()the extents an orthographic projection needs, centre-relative
drawingBufferSize()how big the drawing buffer should be, in device pixels
panByScreen(dx, dy)drag: the world follows the pointer
zoomAtScreen(anchor, factor)wheel or pinch: zoom towards the cursor
fitBounds(bounds, padding?)frame a region: centre on it at the padded fit zoom
setCenter, setZoom, setViewportmove it, scale it, tell it the canvas resized
setZoomLimits(min, max)rebind the zoom range, clamping the current zoom into it

The zoom range is set at construction and can be rebound later with setZoomLimits, which is what content-derived limits need: the fit zoom depends on the viewport, so a window resize has to be able to move the range. The pure fit arithmetic is also exported as fitZoom(bounds, viewport, padding), so a caller deriving limits and a fitBounds call cannot disagree about what "fits" means.

Two of the methods above have a decision inside them worth knowing about.

zoomAtScreen keeps the world point under anchor exactly where it is on screen, which is what makes a wheel feel like zooming towards the cursor rather than towards the middle. The new zoom is clamped into [minZoom, maxZoom] first, and only then is the corrected centre derived from it. The other order passes every test that does not sit on a clamp boundary, and then drifts the anchor a little further with every further notch of a wheel that is already at its limit, which is exactly when a user keeps scrolling.

drawingBufferSize rounds to the nearest whole device pixel and floors at 1. Nearest rather than floor, because flooring accumulates a bias that shows up as a hairline of unpainted canvas along two edges. The floor at 1 exists because a zero-sized texture is not a legal GPU resource, and 1 is the nearest size that exists rather than a guess at what the caller meant.

Every value the camera holds has been through a check, which is why the state is getters and named setters rather than public fields. A public zoom field costs nothing to assign NaN to, and a NaN zoom does not throw: it propagates silently into every coordinate, and the first sign of it is an empty canvas. Nothing falls back to a default, following the same rule the rest of the repo uses: a fallback is only acceptable where there is a neutral answer, and there is none for a zoom of NaN.

The rule for which error you get is meant to be applicable without judgement, so that the next task in this milestone does not have to re-run the argument: an out-of-range value is a RangeError naming the field, and anything else this package throws gets a named class. Today that means five, under an abstract DagrRenderError that carries a code for a caller who would rather switch on a value than on a class: RendererDisposedError for use after a renderer's dispose, OverlayParentError and OverlayDisposedError from the overlay, and from the instanced path UnknownInstanceHandleError for a handle held past the removal of the instance it named, plus SceneDisposedError for anything holding a scene's GPU resources used after its dispose. The last two are the whole of what instancing puts on the surface, and they are there because an error reaches a caller's catch whether or not the module that throws it was exported. The split is not about counting failure kinds, it is about what a caller can do: a bad number is on a line the caller can see and the field name is the best possible report of it, while use after dispose arrives from a lifecycle race in somebody else's framework and is the one a caller actually writes a catch for. Matching a message string is not a way to catch anything. clearColor is validated on the same terms, because three validates none of it: NaN and Infinity both give black, which is exactly the "broken renderer" frame the amber-on-near-black default exists to rule out.

Resize

A resize preserves the centre and the zoom, so the visible world grows when the canvas grows.

The alternative is defensible, which is why this is stated rather than assumed. A camera that preserved the visible rectangle would rescale the drawing to fit, which is what an image viewer does. A graph canvas is not an image viewer. A user who has zoomed in to read a label and then widens the window expects to see more graph at the same size, not the same graph at a new size, and a resize that quietly changed the zoom would invalidate every on-screen distance they had built an intuition for.

How this package is tested

Node has no WebGPU. That is not a detail to work around later, it is the constraint that decides how every test in this milestone gets written, so M4.1 settles it before writing any.

Three options were on the table. A headless browser run (Playwright against a GPU-backed Chrome) is real and heavy, and it is a new CI dependency for a repo whose CI is currently typecheck plus vitest. A software adapter is portable, slow, and not what any user runs, so a pass proves something about a reference implementation rather than about the code path anyone executes. The third is a split, and it is what this package does.

Where the line falls

Anything that is arithmetic or bookkeeping is a pure module, unit tested in Node, with no device at all. Camera and viewport math is that, and it is tested here. So are instance bookkeeping (M4.3) and spring integration (M4.6), which is arithmetic end to end and is checked against a hand-written Euler integrator of the same equation rather than only against its own algebra. ID encode and decode (M4.8a) is the same shape and gets the same treatment.

Anything that needs a real adapter is verified by a screenshot, committed in the run that changes it, and by nothing else. Screenshots live in assets/screenshots/, capped at 1x device pixel ratio and a stated width.

The shader is arithmetic, and arithmetic is testable

A TSL node graph builds under bare Node with no device and does not evaluate: Fn(([p]) => ...) returns a node, while getNodeType needs a builder and code generation needs a real renderer backend. So the shader's arithmetic cannot be run in a unit test, and the obvious response is to write each formula twice, once in TSL for the GPU and once in TypeScript for the tests.

This package does not do that. Every formula is written ONCE, generic over Arith<T>, an interface of nine arithmetic primitives: a literal, four operators, abs, min, max and sqrt. numberArith implements them with Math and the test suite runs every formula through it; tslArith implements them in TSL and the shader runs the same formulas through that. The suite therefore executes the exact expression tree the fragment shader evaluates, node for node.

What that changes is the size of the untested surface: not six formulas, but nine one-line adapters plus three pieces of TSL named below, and the assumption that WGSL agrees with Math about the nine for finite inputs. Nine is small enough that reading them is reviewing them, and a test pins that the two backends have the same nine members, so a primitive added to one and not the other fails a test rather than a shader compilation on somebody else's machine.

There is a second, less obvious payoff. A shader computes a hypotenuse as sqrt(x*x + y*y), and WGSL has no hypot. Written separately, the scalar copy would reach for Math.hypot, which is a different function: it rescales to avoid intermediate overflow and is accurate to under an ulp where the naive form is not. The two spellings then disagree in the last bits, and an exact assertion either fails for a reason that is not a bug or gets loosened until it stops catching real ones. One definition removes the question.

smoothstep and clamp are WGSL intrinsics and are absent from the nine, deliberately: each is built from the primitives instead, which costs a few ALU operations per fragment against the intrinsic and keeps the ramp the tests exercise identical to the ramp the shader evaluates. M4.10 owns measuring whether that trade is still right at ten thousand instances, where the budget is far more likely to be bound by overdraw than by arithmetic.

length is NOT one of those, and this is where an earlier draft of this page was wrong. It is used as an intrinsic, in antialiasWidth alone. Counting properly, three pieces of TSL are executed by no Node test: those two lengths over a join of the two derivatives; the colour mix in the shading node, which is vec3 and cannot go through a float interface at all; and the mul(size, 0.5) that halves a rounded rect's extents inside a deferred Fn body, which the suite never runs because it builds the body directly from pre-halved literals. Their compensating control is the STRUCTURAL assertions on the node graph rather than a numeric test, and the first of the three is the one that needed it. The mutation that shows why is a factor in front of the position: dFdx(mul(p.x, 2)) builds the same node kinds in the same places, doubles every ramp, and left all 67 tests in the two sdf suites green before the structural assertion existed, because no Node test evaluates a derivative at all. The milder swap of the gradient length for fwidth is invisible for a different reason, that under today's camera the two compute the same number (see why not fwidth), so neither a numeric test nor a screenshot could have caught it. The structural assertion is what holds the form that is still right once the camera rotates.

Crisp at every zoom, as a test rather than a claim

"An edge is crisp at every zoom instead of at one" sounds like something only a screenshot can show. It is not. The antialiasing width is one device pixel measured in world units, which is 1 / (zoom * dpr), so feeding a distance of k pixels through the coverage functions at any zoom has to give the same answer: the zoom cancels, and nothing about the shape's size on screen enters the arithmetic. That is the property a texture atlas baked at one scale does not have.

The ratio cancels with the zoom, so crispness does not depend on it. What the ratio does change is how many device pixels a fixed CSS length buys, which is why the outline's apparent thickness varies across displays while its crispness does not.

The suite asserts it across zooms from 0.1 to 1000. Bit-identical results need a dyadic k AND a dyadic antialiasing width, which means a power-of-two zoom: dyadic k alone is not enough, because the ramp's numerator is aaWidth * (k - 0.5) and k - 0.5 is not a power of two for most dyadic k. An earlier draft of this page claimed dyadic k was sufficient, and algorithms-review refuted it with counterexamples at zoom 2.5, 5, 20 and 40, which are ordinary zooms rather than corners.

Everywhere else the deviation is measured rather than described. The worst across the k and zoom lists the suite runs is 1.6653e-16, at k = 0.123456 and zoom 2.5, against an asserted bound of 5e-16: toBeCloseTo(expected, 15) passes below half a unit in the last stated digit, not a whole one, so the headroom is 3.0x. Over a two million pair random sweep the worst is 3.331e-16, inside the bound by 1.5x, which is what makes 15 digits a real assertion rather than a formality. Every one of those numbers is thirteen orders of magnitude under the 1/255 an 8-bit framebuffer can represent, so "identical" is true of every pixel that can be drawn while "bit identical" is true of the power-of-two case only. The screenshots then cover what only a device can: that a real fragment shader's derivatives agree with that arithmetic.

The split is worth more than its cost because of how the code was arranged to fit it. The arithmetic was pushed into camera.ts, where a seeded property suite covers it, and webgpu-renderer.ts was kept as declarative as it could be. An earlier draft of this page said the result was that the file with no coverage is also the file with no decisions in it. That was wrong, and it is worth recording why rather than quietly deleting: the LIFECYCLE is not wiring. When the drawing buffer is reallocated, whether dispose is idempotent, and whether a disposed renderer refuses to draw are decisions made in plain JavaScript about when to call four methods on three collaborators, they need no adapter, and calling them wiring was how they ended up with no tests. They have tests now, built over stubs that count those calls, with no device anywhere.

What is knowingly untested

"We have tests" and "the shader is correct" are different claims, and this milestone will be tempted to conflate them. So, plainly, what nothing in CI checks:

  • That any shape appears at all, in the right place, at the right size, or in the intended colour. The camera suite proves the frustum agrees with worldToScreen and reaches a real OrthographicCamera intact; it cannot prove a mesh is drawn, or that its winding faces the camera.
  • That the shader computes anything. The nine TSL adapters described above are most of it and not the whole of it. Three other pieces of TSL are executed by no Node test either: length as an intrinsic in antialiasWidth, the colour mix in the shading node, which is vec3 and cannot go through a float interface at all, and the mul(size, 0.5) that halves a rounded rect's extents inside a deferred Fn body the suite bypasses by building that body from pre-halved literals. The tests prove the node graphs are CONSTRUCTIBLE and assert their STRUCTURE, which is a different and much weaker claim than computing the right answer.
  • That a real fragment shader's derivatives agree with the coverage arithmetic, and therefore that the crispness the suite proves about the formulas is the crispness on a display.
  • That the drawing buffer sizes computed here reach a real canvas, or that CSS does not stretch the canvas afterwards.
  • That dispose frees GPU memory. That every resource in the list is disposed exactly once, and that a disposed renderer then refuses to draw, IS tested.
  • That init() succeeds ON WEBGPU, and therefore that the shapes compile and draw there. M4.9a took the WebGL2 half of this entry off the list, with a browser probe that draws through the built package and counts the pixels: see What is verified, and on which backend. Nothing it found is evidence about WGSL, which the other backend generates from the same TSL graphs.
  • The abort check AFTER init(), which cannot be reached without a device to hand back AND an abort in the window between the request and the resolution. Deleting it leaves the suite green, which was measured rather than assumed. The abort check before init() is tested.
  • That the two backends agree with each other. That one is M4.9b's, by screenshot comparison, and it needs a machine with both.
  • That a browser composes the overlay's two transforms the way the algebra says. The layer's translate() scale() and an entry's own transform are asserted as strings by a suite that never renders them, so a wrong composition order or a wrong transform-origin would be a green suite and a label half its own size away from its shape. The screenshot is what checks it.
  • That the reason the overlay rebases its layer origin is quantitatively right. Compositor transforms being single precision, and 1e7 CSS pixels being where that starts to show, is a reading of how browsers work rather than something measured here. What is tested is that the rebase happens when the rule says.
  • That text under a scaled ancestor stays sharp, which is the argument for the overlay carrying no will-change.

The one seam that is checked

A camera whose screenToWorld is perfect in isolation can still be wrong in the only way that matters, by disagreeing with the frustum it hands to three. A click would then land somewhere other than the shape it looked like it hit, and no test in the file would notice.

So orthoFrustum() returns plain data, and the suite builds a real three OrthographicCamera from it, wired exactly as the renderer wires one, projects a world point through Vector3.project, and asserts the result matches the NDC implied by worldToScreen. Worst measured disagreement is 6.7e-16, against an asserted bound of 1e-9.

Running three's own camera rather than reimplementing its algebra is the whole value of the test. OrthographicCamera's constructor takes (left, right, top, bottom, near, far), which is not the field order of OrthoFrustum, and the renderer only avoids that by assigning the fields by name; a hand-rolled projection cannot catch a mistake there, and this one does, verified by making it. The same camera answers a second question for one line: the projected z sits at -0.80, inside the near and far planes, so the z = 0 plane every shape is drawn on being inside the frustum came off the untested list above.

Numerical claims in this package quote a measured bound rather than calling anything exact. The screen round trip holds to within 7.4e-10 CSS pixels over the suite's range (zoom 1e-3 to 1e3, centres out to 1e4 world units), and zoomAtScreen holds its anchor to within 4.4e-8. Asserted bounds sit one to two orders of magnitude above the worst case measured, so each is a bound the suite actually approaches. Nothing here is described as pixel-exact, because no test here establishes that.

three.js is a peer dependency

three is a peerDependency of @dagr/render, and also a devDependency. That is the same shape @dagr/layout uses for @dagr/graph, and for a related reason rather than the same one: @dagr/graph is a peer because nominal typing through #private fields makes two copies incompatible at the type level, and three.js has no such fields. Its hazard is at runtime instead.

An application that renders a Dagr graph quite likely has its own three.js scene already. Two copies of three in one bundle is a large amount of duplicated code and, worse, a source of instanceof checks that fail across the copies: hand a Material built by copy A to a Scene from copy B and the failure is a rendering artefact rather than a type error. three.js flags the situation itself, guarding on window.__THREE__ at module scope and warning "Multiple instances of Three.js being imported". Peer means the application picks the version and owns the single copy. Dev means this package still builds and typechecks on its own. @types/three stays a plain dev dependency, because a consumer needs the types for the three they installed, not for ours.

The range is >=0.180.0 <1.0.0, and both ends are chosen rather than derived. The floor sits a handful of minors below the 0.185.1 the lockfile pins. It is a judgement that the small list of names this package imports from three/webgpu is stable across them, and not a compatibility claim: 0.180.0 was unpacked and read, and it exports every one of them, but 0.181 through 0.184 have not been built against here. By this page's own argument each of three's minors is a release that could break something, so admitting five of them buys a consumer on 0.182 a silent install where a caret would have given them a warning. That is the trade, made deliberately. The ceiling is a real 1.0 rather than a caret, which is the part worth arguing. three's pre-1.0 versioning treats the minor as its breaking-change slot, so ^0.185.1 resolves only 0.185.x and would put a peer warning in front of every consumer tracking three's monthly releases, which is exactly the churn a peer dependency exists to avoid.

No three.js type appears anywhere in this package's public surface. That is a separate decision, and the dependency answer follows from it rather than the other way around. It does not make the peer optional, though: webgpu-renderer.ts imports three/webgpu at module scope and index.ts re-exports it, so this package cannot be imported at all without three being present. The peer is a present necessity, not a forward commitment. What the empty surface changes is the FAILURE MODE of getting it wrong: with no three type in a signature, two copies compile cleanly and misbehave at runtime, where @dagr/graph's #private fields would have made the same mistake a type error at the first signature that saw one. That is the weaker of the two guarantees, and it is the reason the peer declaration is doing real work here rather than documenting something the compiler already enforces.

Two backends, and which one you got

three's WebGPURenderer falls back to a WebGL2 backend by itself when WebGPU is not available. It always has. What M4.9a added is that you can tell.

const renderer = await createRenderer({ canvas });
renderer.backend; // 'webgpu' | 'webgl2' | 'unknown'

backend defaults to 'auto', which takes WebGPU where the machine has it and WebGL2 where it does not, and reports the answer. That is the right default: a consumer on a browser without WebGPU wants a slower picture rather than no picture, and a library that refused would be unavailable on a large share of the web for a reason its user cannot act on.

Name a backend to turn the preference into a requirement.

// Refuses rather than falling back. A `BackendUnavailableError`, code
// BACKEND_UNAVAILABLE, carrying both what you asked for and what came up.
const fast = await createRenderer({ canvas, backend: 'webgpu' });

// Takes the compatible path deliberately, which is what a reproduction or a
// screenshot comparison wants.
const compatible = await createRenderer({ canvas, backend: 'webgl2' });

Both refusals dispose the device they refuse, so the guarantee that you never have to dispose a renderer you did not receive holds here too.

There is no fallback event, and that is a decision rather than an omission. three falls back inside the init() that createRenderer awaits, so by the time you hold a renderer the fallback has already happened and renderer.backend already says so. A callback would deliver the same fact through a second mechanism, before you have anything to act on.

Do not probe navigator.gpu and skip the option. It is not the same question, and the difference is measured rather than argued: on the machine this package's browser probe runs on, 'gpu' in navigator is true and navigator.gpu.requestAdapter() then returns null. A capability check before construction reports WebGPU on a machine that cannot give one. What three built is the only honest report, and it does not exist until init() has resolved, which is where renderer.backend reads it from.

'unknown' is a third value and not a third backend. This package names the backend by reading three's isWebGPUBackend and isWebGLBackend markers, and a three release that renames either one leaves a renderer that draws perfectly and cannot be named. Refusing a working renderer over a naming problem would be worse than saying so, so 'auto' reports 'unknown' and hands it back. A caller who NAMED a backend asked for a guarantee that can no longer be made, and gets the error instead. The same fact, reported one way and refused the other.

What differs between the two

Recorded here rather than left for a consumer to find in a browser, which is what M4.9's roadmap entry asks for. Every entry names where it comes from.

  • Per-instance vertex channels. WebGPU's maxVertexBuffers is 8 and the instanced node pipeline uses 7 of them. three's WebGL2 path binds attributes with vertexAttribPointer into a VAO, which has no buffer-slot limit at all and a ceiling of MAX_VERTEX_ATTRIBS, at least 16. A ninth channel would fail pipeline creation on WebGPU and draw fine on WebGL2, so the narrower ceiling is the one this package builds against. See packages/render/src/instance-attributes.ts.
  • A transparent background is premultiplied differently. three multiplies the clear colour by its alpha unconditionally on WebGL2 and only when the renderer's alpha is on under WebGPU (Background.js in three 0.185.1). This package's background is opaque, so the multiply is by one and the two agree exactly. It is here because it is the first thing to check the day a transparent canvas is offered.
  • A per-pass frame-time breakdown is not available on the same terms. WebGPU asks the adapter for the timestamp-query feature; WebGL2 needs EXT_disjoint_timer_query_webgl2, which is absent on plenty of machines and is null when it is. M4.10's pass breakdown cannot assume one number per pass on both backends.
  • A compute barrier is a no-op on WebGL2 (BarrierNode.js in three 0.185.1). Nothing in this package computes. This is the line that would matter first if anything did.
  • Performance is unmeasured, and that is the honest statement rather than a hedge. The machine this package's probe runs on has no WebGPU adapter at all, so there is no pair of numbers to compare and no cliff to quote. M4.9's own entry says an automatic fallback hides a performance cliff, and an unmeasured cliff is one a consumer finds first, so: assume WebGL2 is slower, and read renderer.backend if that matters to what you draw.

What is verified, and on which backend

bench/browser/backend-probe.mjs opens this package's built dist in a real browser, draws one rounded rectangle and one circle, and counts what reached the canvas. On 2026-08-23, on a headless Chromium with swiftshader and no WebGPU adapter: 'auto' came up on 'webgl2' and drew 10,780 pixels above the clear colour, of which 3,908 are the rectangle's amber fill and 2,432 the circle's blue; 'webgpu' was refused with BACKEND_UNAVAILABLE; and 'webgl2' drew the identical counts. The frame is committed as assets/screenshots/m4.9a-webgl2-shapes.png.

The fill counts agree with the geometry, which is what makes this evidence about size rather than only about presence. The amber region should be the 90 by 50 rounded rectangle inset by its 2 device pixel outline, an area of 3,901 against 3,908 counted; the blue should be the 60 diameter circle inset the same way, 2,463 against 2,432. Both run slightly under, in the direction antialiasing predicts and in the order it predicts (a small circle is nearly all boundary), and the expectations are derived in the probe page from the same node records the renderer is handed.

That is the first time anything in this repository has checked a pixel this package drew rather than the arithmetic behind one, and it closes most of the UNVERIFIED list in webgpu-renderer.ts on WebGL2 only. The shapes appear, in the right place, at the right size and in the intended colours, so the TSL graphs compile and the shader computes. None of that is evidence about WGSL, which a different backend generates from the same graphs. The screenshot comparison between the two backends that M4.9's entry asks for needs a machine with both, and is M4.9b.

Usage

import { Camera2D, createRenderer } from '@dagr/render';

const canvas = document.querySelector('canvas')!;

// No viewport needed: a canvas that has been laid out is the authority on its
// own size, and createRenderer copies it onto the camera. Bring a camera when
// input has to be wired before the async factory resolves.
const camera = new Camera2D({ zoom: 2, minZoom: 0.05, maxZoom: 50 });

// The signal is how a caller abandons a mount without leaking a device. Abort
// before the adapter is requested and it costs nothing; abort during init() and
// the renderer that was built is disposed for you. Either way the promise
// rejects with the signal's own reason, so you never dispose something you were
// not handed.
const controller = new AbortController();
const renderer = await createRenderer({ canvas, camera, signal: controller.signal });
renderer.render();

// A drag, in CSS pixels, then redraw. The camera is a plain mutable object with
// no change notification, so the caller decides when a frame happens.
camera.panByScreen(event.movementX, event.movementY);
renderer.render();

// A wheel, anchored on the cursor so the point under it stays put.
const rect = canvas.getBoundingClientRect();
camera.zoomAtScreen(
{ x: event.clientX - rect.left, y: event.clientY - rect.top },
Math.exp(-event.deltaY * 0.001),
);
renderer.render();

Renderer is a camera, a setNodes, a setEdges, a setEdgeStyle, a setEdgeIntensity, a resize, a render and a dispose. Everything except the four setters was fixed at M4.1 and has not changed since; the lifecycle was always the part that would not.

setNodes was deliberately absent until M4.4. M4.1 drew a hard-coded quad and M4.2 a hard-coded ladder, and a setLayout designed at either point would have been a guess with nothing to check the guess against. What it turned out to want was neither a graph nor a layout result: an ARRAY of nodes, each carrying its own centre, size, shape and colours, because a renderer has no use for adjacency and because a caller's colours are a decision about their data rather than about this package.

renderer.setNodes(
[...layout.nodes.values()].map((node) => ({
id: node.id,
shape: 'roundedRect' as const,
// Layout is y-down and the camera is y-up. The flip belongs here, to the
// caller who owns the layout, and it is worth doing in exactly one place.
center: { x: node.x, y: -node.y },
size: { width: node.width, height: node.height },
cornerRadius: 8,
fillColor: 0x219ebc,
glowColor: 0x8ecae6,
glowWorld: node.height / 4,
})),
);
renderer.render();

The distance fields and the shading node are internal for a reason worth naming: a TSL node is a three.js type, and no three.js type appears in this package's public surface (see below). An exported Node<'float'> would make two copies of three in one consumer's tree a type error rather than the runtime hazard it already is.

render() adopts the WHOLE camera every frame, the drawing buffer size as well as the frustum, so mutating renderer.camera is enough for all three things a caller can change: pan, zoom, and the canvas size. resize(viewport) is sugar for camera.setViewport plus that same sync, a convenience rather than a correctness requirement. That is worth stating because the first draft pulled only the frustum per frame and pushed the buffer size from resize, and a ResizeObserver that called camera.setViewport and then render, which is what the camera's own API table recommends, got a correct frustum drawn into a buffer still sized for the old canvas. The browser stretched it, and nothing threw.

The renderer runs no loop of its own. Frames happen when the caller asks for one. A free-running requestAnimationFrame would wake the GPU sixty times a second to redraw an unchanged frame. M4.6 shipped the springs without adding one, and that is deliberate: a spring step is a pure function of a delta, so the loop belongs to whoever owns the clock. What M4.7c added is a loop that runs only while something is moving and stops itself, createMotionLoop, below; it still calls render() through the caller's own frame callback rather than on its own. Do coalesce, though: an input handler that calls render() synchronously runs at the event rate rather than the display rate, and a trackpad fling dispatches wheel events faster than the screen refreshes. The campaign demo schedules one frame per requestAnimationFrame and drops the rest, which keeps "every frame is one a user asked for" true while capping it at one per refresh.

dispose is idempotent, because a component that unmounts twice is an ordinary thing rather than a bug worth crashing for. Every other method throws RendererDisposedError after disposal, because rendering into a released device gets whatever the driver feels like.

Text, without a glyph pipeline

This package draws signed distance fields and has no text renderer. No task anywhere in M4 or M5 adds one, and an honest one (an atlas, shaping, wrapping, kerning, bidirectional runs) is weeks rather than an increment. Meanwhile a graph nobody can read the labels of is a picture of a graph.

So createHtmlOverlay puts DOM elements in world coordinates over the canvas and keeps them registered with the camera. The GPU draws thousands of shapes, the DOM draws the tens of readable things, and the camera lines them up. The analogue is react-konva-utils' Html, which portals a div and syncs its transform to a Konva stage; this one answers to a Camera2D and carries no framework at all.

import { Camera2D, createHtmlOverlay } from '@dagr/render';

// The parent has to establish a containing block, or the overlay throws
// OverlayParentError naming the fix. It is the element the canvas fills.
const overlay = createHtmlOverlay({ parent: stage, camera });

overlay.add({
// A box scales with the zoom and is gated by how wide it is on screen. The
// gate is half-open, so a label ending at 160 and a card starting at 160 are
// never both shown and never both hidden.
placement: { kind: 'box', bounds, minScreenWidth: 24, maxScreenWidth: 160 },
// Called when it becomes visible, not when it is registered. A scene has far
// more entries than elements.
create: () => buildLabel(node),
});

// From the same callback that renders a frame, never from a rAF of its own.
overlay.sync();

// On unmount. Idempotent, releases every element, and takes the overlay's own
// two divs with it, which nothing else can remove.
overlay.dispose();

The demo at zoom 4: the 4 unit circle carries nothing, the 10 unit rect
carries a one line tag, and the 100 unit rect carries a full card of
fields

That is all three tiers in one frame, at zoom 4, and the readout says two overlay elements of six. The 4 unit circle is 16 CSS pixels wide, under the 24 pixel gate, so the GPU has it to itself and it says nothing. The 10 unit rect is 40 pixels and carries a tag. The 100 unit rect is 400 pixels and carries a card. Nothing in the demo decides that: each shape registered one entry per tier, and the gates picked.

The same demo at zoom 100: the smallest rect fills the canvas and its card,
the same size it was, sits inside its top-left
corner

Zoom in to 100 and the 10 unit rect's box has grown from 40 CSS pixels to 1000, its tag has been replaced by its card, and the card is the same number of pixels it would be at any other zoom. That is the counter-scale, and it is also why the card sits INSIDE the box's top-left corner rather than above it: by the time a box is a thousand pixels wide its top edge is off screen, and anything anchored above it is off screen too.

Both are 1102 by 598 CSS pixels of canvas at device pixel ratio 1, captured through the WebGL2 fallback rather than WebGPU, which is what a headless Chromium on a machine with no GPU has. That bears on the shapes and not on the overlay, which never touches a GPU: what these frames are evidence for is the transform composition and the counter-scale, and those are the browser's compositor either way. Whether the two backends draw the same shapes is M4.9b's question, and it is on the untested list above.

What one sync does, and what it costs

An entry's transform is written in world units measured from a LAYER ORIGIN, and the layer carries the camera's transform. So a pan rewrites one string on one element, no matter how many entries are on screen, and an entry is only rewritten when it appears, when place() replaces its placement, or when the origin is rebased.

The origin is rebased to the centre of the visible region whenever it falls outside it. Compositor transforms are single precision: at zoom 100 over a 100,000 unit graph, an absolute offset reaches 1e7 CSS pixels against float32's roughly 1.7e7 of integer resolution, and cards start to jitter against the shapes they label. Rebasing bounds the layer's own translation by the viewport and an entry's coordinates by the larger of the viewport and that entry's own extent, so a scene of ordinary nodes stays well inside the precision. One entry the size of the whole graph is the case it does not rescue, since a box is placed by its top-left corner and a box wider than the view stays on screen while that corner is arbitrarily far away. Rebasing cannot thrash, because a fresh origin sits at the centre with half a viewport of slack on every side.

sync() reads no layout. No getBoundingClientRect, no offsetWidth, no getComputedStyle inside the loop (there is one call, at creation, to check the parent). One layout read in there would make every frame pay for the styles it wrote a line earlier.

Inside the layer, one CSS pixel is one world unit

The layer is scaled by the zoom, so an entry's content is authored as it should look at zoom 1, which is the same identity the camera already states. A 14px font is 14 world units of text. A 1px border is one world unit and gets thicker as you zoom in.

Two things follow, and both are wanted. Text does not reflow while zooming, because layout happens in the layer's local units and the scale is applied after it. And text stays sharp, because the browser rasterises glyphs after the transform. The exception is will-change: transform on the layer, which promotes it to a compositor layer that is rasterised once and then scaled as a bitmap, so the text goes soft under a zoom. The overlay does not set it.

For content that should stay a constant size while its ENTRY is gated by the node's size on screen, the layer publishes two custom properties, both unitless and both rewritten whenever the zoom changes: --dagr-overlay-zoom and --dagr-overlay-inv-zoom, exported as OVERLAY_ZOOM_PROPERTY and OVERLAY_INV_ZOOM_PROPERTY so a stylesheet is not the only place their names exist. A label inside a box entry counter-scales with transform: scale(var(--dagr-overlay-inv-zoom)) and nothing in JavaScript touches it per frame. That is how the demo's labels stay the same size from zoom 0.1 to zoom 100 while their boxes grow by a factor of a thousand.

The cap, and pointer events

An overlay keeps at most maxElements elements attached, 200 by default, and the ones it keeps are the nearest to the camera centre with ties broken by registration order. This is not a tuning knob: a degenerate zoom qualifies every label in a graph at once, and a hundred thousand DOM elements is a locked-up tab, where a hundred thousand instanced quads is a frame. What it costs is visible: entries pop at the boundary rank as the camera moves.

The layer is pointer-events: none, so the canvas keeps every gesture. An entry with interactive: true takes events again, and then swallows the wheel and the drag over its own area, because the overlay does not forward events to the canvas: forwarding synthesises input the browser did not send and gets the coordinate space wrong in exactly the cases (transforms, pointer capture) this feature is made of. The pattern that works is an inert card with interactive controls inside it.

What is untested here

Two claims in this section are not executed by any test, and they belong on the list further up this page rather than being left implied. That a browser composes the layer's transform and an entry's the way the algebra says is verified by the committed screenshot and by nothing else. And the float32 argument for rebasing is a reading of how compositors work plus the absence of jitter at the zoom the demo reaches, which is evidence rather than a measurement.

Everything else is tested: the transform composition, the anchor percentages, the CSS number formatting (CSS has no exponential notation, and a fixed decimal count would round a small zoom's scale to zero), the gate, the culling, the rebase rule and the cap ranking are pure functions with a suite; the element lifetime, eviction, create and release, the tier bookkeeping and the lifecycle run against jsdom.

One more thing jsdom cannot do, since it bears on the section after next: it has no layout engine, so offsetWidth and offsetHeight there are always zero. measureHtmlSizes is therefore tested for its plumbing (everything mounted before anything is read, the container styled and then removed, ids mapped to the elements they came from) with the sizes themselves stubbed. That a real browser returns the size the content will have where it is drawn is not established anywhere in this repo.

Rich nodes, and why there is no tier machinery

createRichNodes binds a set of nodes to an overlay, and the whole of semantic zoom is that a node registers one entry per tier, all with the same bounds and adjacent gates. Disjoint half-open gates mean at most one of them is ever visible, so the bottom tier is the ABSENCE of an entry, which is the GPU drawing the shape. There is no level-of-detail machinery anywhere in the overlay, and the three tiers the demo shows are three lines of configuration.

import { createRichNodes } from '@dagr/render';

const nodes = createRichNodes({
overlay,
tiers: [
{ name: 'label', minScreenWidth: 24, maxScreenWidth: 160, create, update },
{ name: 'card', minScreenWidth: 160, create, update },
],
});

// Diffs by id: new nodes register, gone nodes release, moved boxes are
// re-placed, and a node whose `data` is a NEW REFERENCE is re-rendered if it
// currently has an element on screen.
nodes.setNodes(laidOutNodes);

create returns a blank element and update fills it in, and the split is what lets a tier pool its elements: a card leaving the view goes back to its pool and the next node to reach card tier gets that element with new content rather than a fresh subtree. It relies on an ordering the overlay guarantees, that one sync() detaches everything that left the view before it creates anything that entered it. Two things follow for a tier's own code. update has to REPLACE what it wrote last time, since the element it is handed may have belonged to a different node a frame ago. And update runs on every pop-in during a pan rather than only when data changes, so a tier that wants its own children back should stash them in a WeakMap keyed by the root in create, instead of re-querying the DOM each time.

setNode is beside setNodes for the single-node case: a hover, a selection, one field going live. Moving one node through the bulk setter means allocating a record per node and walking every tier to change one, which is the wrong shape at a few thousand nodes. setNodes stays the bulk path and the only one that can remove.

Tier gates have to be disjoint, and createRichNodes rejects overlapping ones rather than trusting it. Two elements on one node would make the overlay's cap count entries rather than nodes, and a card would draw under its own title. A caller who genuinely wants two elements on one node at one zoom registers a second binding, which keeps both intentions visible in the code.

The cost of putting tiers in the entries is that entries scale with tiers times nodes, so a 2,800 node scene over three tiers scans 8,400 candidates a frame rather than 2,800. That scan is a few comparisons each and allocates nothing. What does not triple is what reaches the cap or the DOM, because the gates are disjoint.

Content in a tier faces the same choice the demo's labels do: it is laid out in world units, so a card that should stay readable counter-scales through --dagr-overlay-inv-zoom. One thing to know before writing that CSS, because it is invisible until it is wrong: a LAYOUT length on the counter-scaled element (margin, left, top) is still in world units, so a 0.5rem margin is 8 world units and throws the card 800 CSS pixels away at zoom 100. An inset composed into the transform after the scale is 8 CSS pixels at every zoom.

Sizes for layout: declare, or measure in one flush

@dagr/layout takes sizes through LayoutConfig.nodeSize, called once per node during prepare and on the caller's thread even when the run itself is in a worker. So a DOM measurement can feed a layout, and the recommendation is to declare where you can and measure only where you cannot. Declaring is right when content is templated per node kind, where the size is known by construction and 2,800 offscreen mounts at startup buy nothing. Measuring is right when the size is a fact about the text, which no constant stands in for.

measureHtmlSizes is the second case, and it batches:

const sizes = measureHtmlSizes(
nodes.map((node) => ({ id: node.id, create: () => buildCard(node), maxWidth: 220 })),
{ parent: stage },
);
layout({ graph, config: { nodeSize: (node) => sizes.get(node.id) } });

It mounts everything, then reads everything. Interleaving a mount and a read per node forces a layout flush per node, which is the classic quadratic that turns a startup into seconds. Three details it makes the caller's business: parent is required, because inherited font and custom properties decide the answer and a card measured under the wrong styles is measured wrong silently; maxWidth is how wrapping content says what width it will finally have, since an unconstrained paragraph measures as one very long line; and a web font that has not loaded measures in the fallback face, so await document.fonts.ready first.

It reads offsetWidth and offsetHeight rather than a bounding rect, and the difference matters here more than it usually would. A rect is measured after every transform in the ancestor chain, and the section above teaches content to carry transform: scale(var(--dagr-overlay-inv-zoom)), so a rect would return a card's counter-scaled size and a card measured inside a layer would come back multiplied by the zoom. Neither is the box a layout should reserve. The cost is that the sizes are integers, which against a default nodeSep of 50 world units is not a number anybody can see.

In-canvas text: when the DOM stops being the answer

The overlay exists because this package has no glyph pipeline, and the question it leaves open is when it should get one. That was measured rather than argued, with bench/browser/label-throughput.html, which drives the real overlay in a real browser. The full table and the procedure are in bench/browser/README.md; the numbers below are from the dispatch box, headless Chromium with NO GPU and software rasterisation, at 1200 by 800 CSS pixels and device pixel ratio 1.

Elements attachedsync medianFrame, panningFrame, stillFrame, panning, promoted
1200.2 ms33.3 ms
3570.2 ms83.3 ms16.7 ms
7440.6 ms16.7 ms
10730.5 ms216.7 ms83.3 ms

Every frame figure is a multiple of 16.7 ms because the browser paints on a vsync tick, so a row is a frame count rather than a time: 83.3 ms is five ticks. Run to run, a row moves by one tick.

The overlay's own work is not what runs out. sync() costs 0.2 to 0.6 ms at up to a thousand elements, which is under 4% of a 16.7 ms frame. Neither is holding the elements: 744 of them with a still camera hold sixty frames a second. What costs is repainting text under a MOVING transform, about 0.2 ms per element per frame on this box, and that is the number the label tier is bounded by. Promoting the layer with will-change: transform removes most of it, taking 357 elements from 83.3 ms to 16.7, at the price this page names two sections up: a promoted layer is rasterised once and then scaled, so the text softens under a zoom. The overlay does not set it, and a consumer who pans far more than they zoom now knows what setting it themselves buys.

The other bound is legibility, and it is arithmetic rather than measurement. A label around 100 by 18 CSS pixels tiles a 1200 by 800 viewport 530 times with no gaps at all, so a scene a person can read shows one or two hundred. That is the same order as where the frame budget goes, which is why the default element cap of 200 is not an awkward number.

The recommendation

Keep the DOM for both tiers, and schedule an atlas when a scene wants names on thousands of nodes while the camera is moving. That is a real case rather than a hypothetical: it is M4.10's target, ten thousand animating nodes, and if that scene is ever asked to show names the measurement above says the DOM cannot, promoted or not. It is also a different visual product from the label tier as it stands, closer to a wall of text as texture than to a hundred readable tags.

The card tier should stay DOM permanently. Its content is arbitrary markup with links, wrapped prose, images and per-kind structure, and reimplementing that over a glyph atlas is reimplementing a browser. At card zoom only tens of nodes fit on screen, so it never approaches a count where any of this bites. The campaign's cards are the argument in miniature: a per-kind mark on the badge and the same mark again at four times the size behind the rows are two inline svg elements whose shape is one attribute the tier rewrites per node, and an atlas would need a glyph, an upload and a cache for each.

Nothing in the overlay's design changes either way, which is the useful part: the label tier is one entry per node with a gate, so an atlas takes the tier over by taking its gate over, and the tier above and the tier below stay exactly as they are.

Edges are ribbons, and their width is in screen space

setEdges(groupId, edges) takes an edge as an id, a centreline in world units and a colour, and tessellates it into a ribbon: a polyline as a layout routed it, or a centripetal Catmull-Rom curve through the same points when the group asks for one. RoutedEdge.points from @dagr/layout is exactly the input, after the caller's own y flip.

A ribbon is a fixed number of DEVICE pixels wide at every zoom, and that is the thing to know before drawing one, because a caller expecting a world width gets a line that does not thicken as they zoom in. A graph spans decades of zoom and no world width is legible at both ends of one; @dagr/layout gives an edge a polyline and no width at all, so any world width would be invented by the renderer rather than laid out. An outline is measured the same way and for the same reason.

Three things follow. One tessellation is valid at every camera, so panning and zooming never rebuild a buffer. The antialiasing width is exactly one pixel by construction, so the ribbon shader holds no derivative at all. And a dash pattern is in pixels too, so it looks the same and flows at one apparent speed at every zoom.

Groups are the layering. Blend order within one mesh is slot order, which is not durable across a removal, so a scene that wants ribbons under nodes, or a highlighted path over dimmed ones, declares its groups through RendererOptions.edgeGroups and relies on the order it declared them in. One group is one mesh and one material.

setEdgeStyle is the per-frame call and touches no buffer. It carries the width, an alpha, and how far the dash has flowed. ribbonWidthAt is the arithmetic behind the first two: a clamp between a floor and a ceiling, plus the alpha that conserves ink below the floor, since a ribbon drawn wider than the scene says should be fainter in the same proportion. advanceDashFlow moves the pattern and wraps it into one period.

A solid ribbon is the ABSENCE of a dash rather than a duty cycle of 1: a zero-width gap is still a boundary to a distance field, so a duty of 1 draws a half-alpha seam once per period along a line that is supposed to be solid.

setEdgeIntensity is the per-edge call, and it is what a highlight is made of. It takes a function from an edge's id to a number in [0, 1], and the shader multiplies both the ribbon's width and its alpha by it: an edge at 1 draws exactly as the group says, and an edge at 0.25 is a quarter as wide and a quarter as opaque. Hovering a node and fading everything not incident to it is one call, and only the values that changed are uploaded, as one merged range before the next draw.

The split against setEdgeStyle is the split between what a FRAME decides and what a POINTER decides. A style is how a whole group is drawn at this zoom; an intensity is which of its members matter right now. Doing it through groups instead would mean a group per highlight state and a re-tessellation to move an edge between them, and doing it through setEdges would rebuild every buffer to change one float.

Intensity is capped at 1 rather than open above it. A group's width is already a caller's number and raising it there says the same thing to every edge at once, so a channel that could exceed 1 would give a scene two ways to say how wide a ribbon is and no rule for which wins.

Springs, and the fixed timestep that is not here

M4.6 added the motion arithmetic: stepSpring, stepSpring2D, omegaForHalfLife, and two constants of the envelope the last of those reads. Nothing in it touches a GPU, a canvas or three.js, and no Renderer method calls it. It is exported because a caller drives the clock, and M4.7a's node motion, in the next section, is the first thing in this package to call it on a caller's behalf.

A spring here is critically damped, which is the fastest approach to a target that does not oscillate around it. The damping ratio is fixed at 1 by construction rather than passed in: an under-damped spring is a different feeling that would need a second parameter and a second formula, and a ratio a caller can set to 1.0001 is one they can set to 1.0001 by accident.

import { omegaForHalfLife, stepSpring2D, type Spring2DState } from '@dagr/render';

// Half the distance closed in 120ms, released from rest.
const w = omegaForHalfLife(0.12);

const springs = new Map<string, Spring2DState>();
const targets = new Map<string, { x: number; y: number }>();
let previousMs: number | undefined;

function frame(nowMs: number) {
const dtSeconds = previousMs === undefined ? 0 : (nowMs - previousMs) / 1000;
previousMs = nowMs;

renderer.setNodes(
[...targets].map(([id, target]) => {
const stepped = stepSpring2D(
springs.get(id) ?? { position: target, velocity: { x: 0, y: 0 } },
target,
w,
dtSeconds,
);
springs.set(id, stepped);
return {
id,
shape: 'roundedRect' as const,
center: stepped.position,
size: { width: 160, height: 48 },
cornerRadius: 8,
fillColor: 0x219ebc,
glowColor: 0x8ecae6,
glowWorld: 12,
};
}),
);
renderer.render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

The first frame steps by zero, because there is no previous timestamp to subtract and inventing one is inventing motion. A node that appears for the first time starts AT its target rather than at the origin, which is the difference between a new node fading in where it belongs and every new node flying in from the same corner.

target can change on any frame. The target is a parameter of x'' = -2w x' - w^2 (x - target) and the state is the position and velocity, so retargeting mid-flight cannot move either: there is no jump. Acceleration does jump, which is a faint snap at high w, and that is the honest cost.

The step is exact, which is why there is no accumulator

stepSpring does not integrate towards the answer, it evaluates it. With A = x0 - target and B = v0 + wA, the solution over a step of h is x(h) = target + (A + Bh)e^(-wh), and the velocity is its derivative.

The usual reason to run a physics integrator on a fixed substep and accumulate the remainder is that an approximate integrator's error, and therefore its behaviour, changes with the frame rate. Semi-implicit Euler over that same equation is stable only while w * h stays below about 0.83, and inside that bound it still traces a slightly different curve at 60fps than at 144fps. An exact step has no such error to bound: ten steps of a millisecond and one step of ten give the same state to machine precision, which the suite asserts directly.

Adding an accumulator anyway would cost the property it was meant to protect. A fixed substep leaves a remainder every frame, and a remainder is either dropped, which lags the drawing behind the clock by up to a substep and by a different amount at each frame rate, or carried, which lets one frame advance a substep further than its neighbour and shows up as a stagger at constant velocity.

A long frame is safe, and needs no clamp

A backgrounded tab hands back a delta measured in seconds or minutes. Stepped exactly, that lands the spring on its target with zero velocity, which is what a returning tab should show: the settled drawing rather than a minute of catch-up animation. The same delta through Euler is an overflow. A bare decay underflows around a w * dt of 745, but polynomial-scaled residuals such as (1 + w * dt)e^(-w * dt) can remain representable beyond it. stepSpring preserves those residuals. Only an infinite w * dt takes the settled limit directly.

The 0.83 above is measured rather than quoted, and it is worth knowing that it is EARLIER than the w * h of 2 an undamped oscillator gives: what goes unstable first on a critically damped system is the damping term, whose velocity update carries a factor of 1 - 2 w h. At a half-life of 120ms that is a substep ceiling of about 59 milliseconds, which one dropped frame clears.

This is the opinion ribbon.ts said the integrator owed. advanceDashFlow does not clamp either, for the opposite reason: a dash pattern is periodic, so a long frame leaves it somewhere else and nothing is out of range.

No overshoot, and what that does not mean

Critical damping guarantees no OSCILLATION. It does not guarantee no overshoot, and the two claims are worth separating because a design that promises both and also promises mid-flight retargeting is promising something untrue.

The displacement (A + Bt)e^(-wt) is zero at t = -A/B, which is in the future whenever the initial speed towards the target exceeds w times the distance to it. A spring released from rest can never be in that state, so a node that starts still never passes where it is going. A spring retargeted while moving can pass its new target once and come back, and once is the bound: a linear factor times an exponential has one root.

Tuning: half-lives, not stiffnesses

omegaForHalfLife(0.12) is the angular frequency of a spring that closes half its distance in 120 milliseconds, released from rest. That is the number a designer has an opinion about; w is the number the formula wants, and the conversion happens once rather than sixty times a second.

The half-life is a FIRST half-life and not a repeating one. The envelope from rest is (1 + u)e^(-u) with u = wt, which is not an exponential, so the second half-life is shorter than the first: two half-lives leave 15.2% of the distance rather than 25%, and three leave 3.9%. For "how long does this take" rather than "how fast does it start", use SETTLE_OMEGA_1_PERCENT / w, which is when the spring has closed all but one percent of the distance it started with, just under four half-lives.

Where it lives

Inside @dagr/render, exported, with no dependency on anything here that a device could break: the Vec2 type and the shared validators, and nothing else. That is the third option the ROADMAP's M4.6 entry named, and it is the second time this package has taken it, after the HTML overlay. @dagr/react in M5 will want this curve for interaction animation with no graph in it, and if that turns out to be a package rather than an import, the split is a file that travels unchanged rather than code that has to be rewritten.

Deltas drive the springs, and the state is the renderer's

M4.6 shipped the arithmetic. M4.7a is what holds it between two frames: createNodeMotion keeps one spring per node, retargets the springs a LayoutDelta names, and hands back the frame to draw.

import { createNodeMotion } from '@dagr/render';
import { diffLayout } from '@dagr/layout';

const motion = createNodeMotion({ halfLifeSeconds: 0.12 });

// World centres, y up: the same conversion `setNodes` already asks for.
const worldOf = (node) => ({
id: node.id,
center: { x: node.x, y: -node.y },
});

motion.resync([...first.nodes.values()].map(worldOf));

function onRelayout(previous, next) {
const delta = diffLayout(previous, next);
motion.apply({
added: delta.nodes.added.map(worldOf),
removed: [...delta.nodes.removed],
moved: delta.nodes.moved.map((move) => worldOf({ id: move.id, ...move.to })),
});
}

function frame(nowMs) {
const { nodes, settled } = motion.advance((nowMs - previousMs) / 1000);
previousMs = nowMs;
renderer.setNodes(nodes.map(draw));
renderer.render();
if (!settled) requestAnimationFrame(frame);
}

The renderer holds its own scene state, because the alternative is not available. The ROADMAP's M4.7 entry asks whether the renderer applies deltas to state it keeps or is handed the full LayoutResult alongside each delta. A spring's position and velocity are in no LayoutResult: a layout says where a node belongs, and this is about where it currently is on the way there. So the renderer is already stateful and the real question is narrower, whether it keeps a second copy of the layout's answer too. createNodeMotion keeps one target per node and no sizes, shapes, routes, or bounds. While an edge is active, createEdgeMotion retains its current route, resampled target route, and exact rest route. Those routes are the state needed to retarget and then recover the layout's own point count, not a second full LayoutResult.

A delta that does not describe the scene is a throw, not an adoption. One dropped or reordered delta and the picture is wrong with nothing in the system able to notice, and the observable symptoms are exactly three: a move naming a node the motion has never seen, an add naming one it already holds, a removal of something that is not there. Each is a MotionDesyncError, code MOTION_DESYNC. Adopting instead is available, because a moved entry carries a whole target, and it is the worse half of both choices. resync is the way back and it takes the roster whole; it is also how a scene is seeded before any delta exists. Applying a delta is all or nothing, so the scene a refusal leaves behind is the one the caller resyncs from.

A removed node leaves when its spring finishes, not when the delta lands. Until then it is in the frame with departing: true, so a caller can fade it, shrink it, or just keep drawing it. A node removed while already at rest is gone on the next frame, because its spring has finished. A node re-added while still departing is a departure cancelled rather than a node arriving: it keeps where it is and where it was going.

Retargeting mid-flight moves nothing on the frame it arrives. The target is a parameter of the equation and the state is the position and the velocity, so a second delta interrupting the first cannot move the drawing. MotionTarget deliberately carries no from, which is the field LayoutDelta has: a delta's from is where the LAYOUT last put the node, and a spring caught mid-flight is not there. Using it would undo the interruption this is for.

When it settles, the drawing is the layout's answer exactly. A spring's approach is exponential and never arrives, so arrival is a tolerance: restEpsilon, in world units, which are CSS pixels at zoom 1. Reaching it snaps the node onto its target and zeroes its velocity, which is one discontinuity per arrival, bounded by the tolerance and taken at the moment of least motion. The alternative, stopping wherever the tolerance was met, leaves a residual that is bounded and permanent, and the visible form of that is not one node in the wrong place: it is a rank of nodes a layout aligned that stop a hundredth of a unit apart, which reads as ragged at a glance where a single node does not. settled is true only once every spring is exactly on target, so advancing a settled scene again returns the same frame to every bit and a caller can stop asking for one.

The floor is the frame, not the springs. A settled scene costs a per-frame pass over every node even though it does no spring arithmetic at all, because the frame it hands back is the whole scene and that is what setNodes takes. Measured on ten thousand nodes, one node moving costs about the same per frame as none moving, and all ten thousand moving costs about ten times that. So absent-means-unchanged buys the arithmetic and not the frame: a delta is proportional to the change, a frame is proportional to the scene. Whether that floor is worth removing is M4.10's to measure against a real GPU.

The bounds change and the loop that drives all of this landed at M4.7c, two sections down.

An edge needs a correspondence before it needs a spring

M4.7b is the other half of the delta consumer, and the seam between the two is about kind rather than convenience. A node moves as a point, so one two-axis spring is the whole of it and the hard part is the bookkeeping between two deltas. An edge is a polyline whose vertex count changes between two routes, because a long edge gaining a rank to cross gains a bend, and no per-point comparison can even be spelled between two lists of different lengths. There is nothing to retarget until something decides what corresponds to what.

createEdgeMotion is createNodeMotion's shape for routes, down to the two defaults, so one delta's nodes and its edges arrive together:

import { createEdgeMotion, createNodeMotion } from '@dagr/render';

const edges = createEdgeMotion();

// World points, y up: the same flip `setEdges` already asks for.
const routeOf = (edge) => ({
id: edge.id,
points: edge.points.map((point) => ({ x: point.x, y: -point.y })),
});

edges.resync([...first.edges.values()].map(routeOf));

// ... on a relayout, beside the node half:
edges.apply({
added: delta.edges.added.map(routeOf),
removed: [...delta.edges.removed],
rerouted: delta.edges.rerouted.map((reroute) => routeOf({ id: reroute.id, points: reroute.to })),
});

// ... and per frame, beside `motion.advance`:
const frame = edges.advance(elapsedSeconds);
renderer.setEdges('flow', frame.edges.map(draw));

The correspondence is resampling, and the metric that judges it says the resampling is free. alignRoutes(from, to) gives both routes a common list of places along themselves: the union of their own arc-length parameters. Every vertex of each route survives in its own list exactly, and every point either list gains sits on a segment that list already had. @dagr/layout's maxRouteDistance measures a route by Hausdorff distance between the two polylines taken as curves, and it already records that a point added on the line a route already ran along measures zero. So the correspondence costs nothing in the metric M3.4 says this task is judged by: it is not a compromise between two drawings, it is the same two drawings with more names for places on them.

A common count would have cut every corner. Resampling both routes to max(from.length, to.length) evenly spaced points is the obvious reading of a common count, and a bend that does not happen to fall on one of those samples is rounded off. Springing the control points of a curve was the other option, and it needs a curve this package does not have and would leave the settled drawing off the line the layout computed.

A moving edge draws the union-sized points, then compacts on settlement. The union can contain at most from.length + to.length - 2 points, since both routes share the endpoint parameters. An edge that kept it would carry the shape of every route it had ever taken: a session of edits is a polyline with hundreds of vertices drawing a line with three. Compacting is exact rather than a simplification, because every point the union added lay on a segment of the route being arrived at. MotionEdge.points does not keep a stable count across frames. A caller binding per segment should key on the edge and not on the vertex; setEdges rebuilds a group's geometry whole, so nothing here does.

Velocity is resampled with position. A polyline caught mid-flight has a velocity per point as well as a position, and the point a retarget adds needs both. Zeroing it instead would stop a moving edge dead at every new bend, which is the same interruptibility failure that reading a delta's from would cause.

The settled floor is per drawing; the moving cost is per point. Measured against the node half in one invocation, a settled scene of ten thousand edges costs about what a settled scene of ten thousand nodes does, and it barely changes between two points per edge and five: a settled frame is the records this module allocates and nothing else. Moving is where the two halves part. An edge has as many springs as it has points, and per spring an edge costs about two and a half times a node, so whether a COLD reroute of ten thousand edges fits in a frame is decided by how long the routes are rather than by how many edges there are. What makes that the right trade rather than a defect is what the incremental engine is for: a patch reroutes a small fraction of the drawing, and applying a delta of one against ten thousand edges is under a fiftieth of a millisecond. Whether the floor is worth removing is M4.10's to measure against a real GPU.

A removal and an addition under one id replaces the old edge. That is how EdgeDelta reports changed endpoints: the old edge left and a new one arrived. The replacement is seeded immediately on its new directed route, at rest, rather than retargeting the old edge's springs. A genuinely rerouted edge present in both layouts still animates. Everything else is the node half's behaviour exactly: the same MotionDesyncError on a delta that does not describe the scene, the same all-or-nothing apply, the same departing state until a removed edge's springs finish, the same resync back.

The loop, the box, and the scene as one thing

M4.7c is the rest of the delta consumer: the drawing's box, the three halves driven as one scene, and the loop. Together they turn the five-line requestAnimationFrame the two sections above asked a caller to write into two calls, one per relayout and one at mount.

import { createMotionLoop, createSceneMotion } from '@dagr/render';
import { createLayout } from '@dagr/layout';

const engine = createLayout();
const motion = createSceneMotion();

// The flip is the caller's, as it has been since M4.1, and this is it written
// out. A caller who has `@dagr/react` does not write it: `toMotionRoster` with
// `toSceneNodes` and `toSceneEdges` is this roster, `toMotionDelta` is the whole
// `motion.apply` argument below, and `retarget` is that call plus the one check
// this example does not make (see the React page: a delta is only safe to apply
// to the drawing it was measured from). `toWorldBounds` is `boxOf` exactly.
const centreOf = (node) => ({ id: node.id, center: { x: node.x, y: -node.y } });
const routeOf = (edge) => ({
id: edge.id,
points: edge.points.map((p) => ({ x: p.x, y: -p.y })),
});
const boxOf = (rect) => ({
minX: rect.x,
maxX: rect.x + rect.width,
minY: -(rect.y + rect.height),
maxY: -rect.y,
});

const first = engine.run(graph);
motion.resync({
nodes: [...first.nodes.values()].map(centreOf),
edges: [...first.edges.values()].map(routeOf),
bounds: boxOf(first.bounds),
});

const loop = createMotionLoop({
frame(dtSeconds) {
const { nodes, edges, bounds, settled } = motion.advance(dtSeconds);
renderer.setNodes(nodes.map(dress));
renderer.setEdges('flow', edges.map(draw));
if (bounds !== null && following) renderer.camera.fitBounds(bounds);
renderer.render();
overlay.sync();
return settled;
},
});

graph.subscribe((patch) => {
const { delta } = engine.relayout(patch);
motion.apply({
nodes: {
added: delta.nodes.added.map(centreOf),
removed: [...delta.nodes.removed],
moved: delta.nodes.moved.map((m) => centreOf({ id: m.id, ...m.to })),
},
edges: {
added: delta.edges.added.map(routeOf),
removed: [...delta.edges.removed],
rerouted: delta.edges.rerouted.map((r) => routeOf({ id: r.id, points: r.to })),
},
// `undefined` means the box did not change, which is what `LayoutDelta`
// already means by it, so this forwards rather than spreading a condition.
bounds: delta.bounds === undefined ? undefined : boxOf(delta.bounds.to),
});
loop.wake();
});

The box is a third motion module, and the camera is not allowed to read it on its own. The M4.7c entry asked whether a sprung box belongs in camera.ts, which already owns the fit. It does not. <DagrCanvas> fits the camera once and then the camera is the user's, on the argument the React page makes: a camera that refits on every edit is the instability M3 exists to keep out of the layout, reintroduced one level up where no stability metric would see it. So createBoundsMotion hands back a box per frame that glides rather than cuts, and a caller who wants a following camera writes fitBounds on it in their frame, as the example does behind a flag. A caller who does not, reads nothing.

Four numbers are sprung and they are not the corners. Springing the two corners separately turns a box inside out on the way: two corners retargeted by different distances arrive at different times, and a box shrinking from the right while growing from the left crosses over in the middle. The module springs the centre and the two half-extents instead. Released from rest, each is the same convex combination of start and target at every instant, so a half-extent that starts and ends at or above zero stays there and a corner stays on its own side. A retarget mid-flight carries velocity and can overshoot once, like any spring here, and the report clamps a half-extent at zero for that one case. A degenerate box is accepted, because an empty layout has one and a scene seeded from it must not throw; whether it is worth fitting is fitBounds's question, and it already refuses.

A scene delta is applied across all three halves or not at all. Each half is already all or nothing for itself. A scene delta names nodes AND edges, and applying the node half and then refusing the edge half would hand a caller exactly the half-applied scene both halves promise never to produce, with the node springs already moved by the delta the caller is about to resync away from. So the halves grew a two-phase form, a plan that runs every check and a commit that runs none, and createSceneMotion plans all three before it commits any. That plan API is deliberately not exported: a plan is valid only against the state it was made from, and a caller of one half alone has nothing to coordinate with. Absent means unchanged per half, so a relayout that moved two nodes is a delta naming two nodes. A roster is the opposite: resync describes a whole state, and a roster without a box is a scene with no box.

A loop is woken, not started, and it stops itself. wake() on a loop already running is the frame it was going to run anyway, so a burst of edits in one task is one frame. The frame callback returns settled, and the loop asks for no frame after the one that said so. running is true from the wake, not from the first frame, so a second wake before the frame arrives sees a loop already going. A wake that arrives INSIDE a frame, from a delta applied after that frame's advance, wins over that frame's settled: one more frame runs.

The first frame after every wake steps by zero. Not only the first frame ever. A loop that carried its previous timestamp across its own stop would step the first frame of the next animation by however long the scene sat still, and stepSpring on a minute lands every spring on its target: the drawing would cut to the new layout on the exact frame the animation was meant to begin. The timestamp is cleared on every stop. A clock that runs backwards is clamped to zero for the same reason advance refuses a negative step by name.

The scheduler is an option, and that is how the loop coexists with a caller who already has one. The campaign stage and <DagrCanvas> each coalesce their own requestAnimationFrame, and two loops would be two frame budgets and a frame of skew, which is the failure HtmlOverlay.sync already refuses on its own account. FrameScheduler is two functions with the shape of requestAnimationFrame and cancelAnimationFrame, so a caller with a coalesced frame hands theirs in and the loop's frame IS their frame:

const loop = createMotionLoop({
frame,
scheduler: {
request: (callback) => requestDraw(callback), // the caller's coalesced frame
cancel: (handle) => cancelDraw(handle),
},
});

A caller with no loop passes nothing and gets the platform's, resolved at the first wake rather than at construction, so importing the package on a server is not an error and only waking a loop there is. A test passes a Map, which is what makes every timing claim in the suite exact rather than sampled.

A frame that throws stops the loop and lets the throw out. Rescheduling after a throw would be a loop throwing sixty times a second until the tab is closed; swallowing it would be this package's polarity reversed. The loop is usable afterwards, because the failure was the frame's.

Sizes do not spring, and the decision is recorded rather than deferred. A resize arrives through moved and produces no motion, so a node whose label grew snaps to its new width while its centre glides. The M4.7c entry asked whether the size should spring too. It should not, for two reasons. A resize is the caller's own attribute change, made at a moment they chose, and the text that caused it changed instantly whatever the box does; a box that lagged its own contents would clip them for a hundred milliseconds. And the state is per node: springing sizes doubles it, against a settled floor M4.7b measured at 0.34ms per frame for ten thousand nodes and 0.25 to 0.32ms for ten thousand edges in the same invocation, in records those modules allocate and nothing else. A caller who wants a sprung size has stepSpring2D and the node's id.

The cold reroute is still the number the loop lives with, and the loop does not throttle it. M4.7b measured all ten thousand edges rerouting at 12.8 to 36.5ms depending on route length, which is more than one frame. A frame that takes two refreshes is a dropped frame, not a wrong one: the step is exact, so the next frame lands where the clock says. Nothing here skips work to fit a budget, because the lever is upstream: the incremental engine reroutes a small fraction of the drawing per patch, which is what M3 is for, and applying a delta of one against ten thousand edges is under a fiftieth of a millisecond. M4.10 measures the frame against a GPU and decides whether the settled floor is worth removing.

Picking, decided and half built

Hit testing a graph of ten thousand nodes by walking a list is the work the GPU is already doing. M4.8 draws the scene a second time into an offscreen target where every instance is a colour that names it, reads back the single pixel under the pointer, and turns that colour into a node. Hover, select and drag all cost the same regardless of how many nodes are on screen.

Half of that has landed: the encoding, the pixel arithmetic and the bookkeeping, which is M4.8a. Nothing is callable yet. There is no pick() on Renderer and nothing exported, because the pass that writes these bytes and the readback that reads them need a device, and a device is what the machine writing this cannot supply. What follows is what was decided, so that the half still to come is written against something rather than deciding it again.

A pick pixel is three bytes of id and one byte of kind. Tag 0 is nothing, so a target cleared to all zeros reads as a miss with no reserved value anyone has to remember, and no instance is ever given id 0. Three bytes cap one kind at 16,777,215 pickable things. The tag partitions the id space, so nodes and edges keep separate allocators instead of sharing one counter across two meshes that know nothing about each other, and it survives a stale answer: a pick that cannot be resolved can still say the pointer was over an edge.

The id is not the instance's slot, and not its handle. A slot is free, in the sense that the shader already knows its own instance index and needs no attribute at all, and it is wrong: removal swaps the last live instance into the freed slot, so a slot means somebody else after any removal, and a readback answers a question about a frame that has already been drawn. A handle is durable and unbounded, so it runs past three bytes and truncating one is a collision. The pick id is a third name, durable like a handle and bounded like a slot, recycled on purpose rather than by accident.

The id is taken apart on the CPU, not in the shader. Handing the shader one number and letting it split that number into channels costs no bytes per instance, and it fails for a reason that is arithmetic rather than taste. Every vertex of an instance's quad carries the same value, so the interpolated value differs from it by about a float32 ulp, and at 2^24 that ulp is exactly 1: one bit of drift is the next node. Carried as three byte-valued channels the same drift is 6e-8 against a write that rounds to the nearest 1/255, a margin of about 30,000. The suite asserts both, the surviving encoding over every byte value there is and the rejected one at the top of its range.

A pick can be refused, and that is the point. The readback resolves at least a frame after the pass, and in between the scene may have released a node's id and given it to another. Every id remembers when it was assigned, a pass records the registry's stamp when it draws, and an id that has changed hands since is answered with nothing rather than with the wrong node. The comparison is per id: a scene adding a node every frame would otherwise refuse every pick in flight, which is exactly the scene picking exists for.

One assumption is carried rather than checked. Screen y grows downward and three's readback measures y from the bottom of the target, so the pointer's row is flipped on the way in. No test here can confirm that, because confirming it needs a device. It is written down where the flip happens, and M4.8b owes the confirmation.

What is not here yet

The motion arithmetic, the three delta consumers and the loop are headless and complete, and <DagrCanvas animate> drives them from a graph a user is editing (M5.3a). What is still missing is the device work below.

  • The pass half of GPU picking: a material writing the bytes above, an offscreen target, the readback and a pick() on Renderer (M4.8b). What a pixel says and which node an id still means are decided and tested; see Picking.
  • A screenshot comparison between the WebGPU and WebGL2 backends, which needs a machine with both (M4.9b). The selection, the reporting and the differences landed at M4.9a and are two sections above.
  • Ten thousand nodes at sixty frames a second, measured rather than hoped for (M4.10).