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
| Method | What 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, setViewport | move 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
worldToScreenand reaches a realOrthographicCameraintact; 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:
lengthas an intrinsic inantialiasWidth, the colourmixin the shading node, which is vec3 and cannot go through a float interface at all, and themul(size, 0.5)that halves a rounded rect's extents inside a deferredFnbody 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
disposefrees 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 beforeinit()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 wrongtransform-originwould 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
maxVertexBuffersis 8 and the instanced node pipeline uses 7 of them. three's WebGL2 path binds attributes withvertexAttribPointerinto a VAO, which has no buffer-slot limit at all and a ceiling ofMAX_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. Seepackages/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
alphais on under WebGPU (Background.jsin 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-queryfeature; WebGL2 needsEXT_disjoint_timer_query_webgl2, which is absent on plenty of machines and isnullwhen 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.jsin 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.backendif 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();

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.

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 attached | sync median | Frame, panning | Frame, still | Frame, panning, promoted |
|---|---|---|---|---|
| 120 | 0.2 ms | 33.3 ms | ||
| 357 | 0.2 ms | 83.3 ms | 16.7 ms | |
| 744 | 0.6 ms | 16.7 ms | ||
| 1073 | 0.5 ms | 216.7 ms | 83.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()onRenderer(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).