React bindings
@dagr/react is the package that joins the other three. A Graph goes in, a
canvas comes out, and the wiring in between (running the layout, converting it
into a scene, building the renderer, keeping the overlay in step, taking it all
back down on unmount) is the component rather than something every host writes
again.
import { Graph } from '@dagr/graph';
import { DagrCanvas, Html } from '@dagr/react';
const graph = new Graph();
graph.addNode({ id: 'plan' });
graph.addNode({ id: 'build' });
graph.addEdge({ source: 'plan', target: 'build' });
export function Board() {
return (
<DagrCanvas graph={graph} style={{ width: '100%', height: 480 }}>
<Html node="plan">Plan</Html>
<Html node="build">Build</Html>
</DagrCanvas>
);
}
This page describes the package as of M5.3a: the component, the hook under it, the animation an edit gets for free, the overlay sugar, and the conversions the renderer deliberately does not own.
The graph prop is controlled, and controlled here means watched
A Graph is mutable. Passing one as a prop and comparing it by identity, the
way React compares everything, would mean that graph.addNode(...) changed
nothing on screen until the caller also replaced the object, which is a rule
nobody remembers on the third edit.
So the hook under the component subscribes to the graph instead.
Graph.subscribe takes a listener and returns the function that stops
watching, which is exactly the shape React's useSyncExternalStore wants, and
an edit anywhere reaches the canvas:
// Both of these redraw.
graph.addNode({ id: 'ship' });
setGraph(rebuildFromScratch());
The one window this leaves open is worth knowing about, because it is narrow
and it is real. React subscribes in an effect, after the render that read the
store, and effects run child first. A child's mount effect that edits the
graph therefore runs before the canvas has subscribed, and that one edit is not
drawn until the next one arrives. Closing it properly needs an O(1) mutation
counter on Graph itself; the two ways of closing it from this side are a
second layout on every mount, or a listener that outlives every component and
makes the graph build a patch on every mutation forever. Both cost more than
the window does. use-dagr.ts carries the full argument.
It is one edit of latency rather than a disagreement that persists.
relayout re-runs the pipeline over the graph the engine holds and measures the
delta against the geometry it last reported, so the next edit reports both and
the drawing catches up. Any resubscribe (a new graph, a new config, React's
StrictMode remount) closes it outright, because a resubscribe rebuilds the
engine and lays the graph out cold.
An edit is a relayout, not a cold run
useDagr holds a createLayout engine for as long as it is watching one graph
with one config, and calls relayout(patch) with the patch the graph delivers.
So it returns four things rather than two:
const { result, error, delta, from } = useDagr(graph);
delta is a LayoutDelta: what appeared, what went away, what moved, and what
the box around the lot became. It is what <DagrCanvas animate> animates from,
and what a caller driving @dagr/render themselves wants. from is the drawing
that delta is a difference FROM, and it has a section of its own below.
delta is null on a cold run, and that is a statement rather than a missing
value. A delta is a difference from a drawing; the first run of a graph has no
drawing to differ from, and neither does the run after a config change or the
recovery from an engine that fell out of step with its graph. null is how those
runs say "reseat, do not retarget", and retarget (exported) is where that
decision is written down.
The engine runs in the graph listener, which is neither render nor an effect.
relayout does not apply its patch: the graph is already mutated, the patch
describes an edit already made, and a patch the graph disagrees with is refused.
A hook that kept those patches in a queue and drained it during render would be
doing a side effect in render, and concurrent rendering is entitled to discard
that render and run it again, which would consume a patch twice or not at all.
So there is no queue: Graph.subscribe hands over one patch per mutating call,
straight after it commits, and the relayout happens right there. Render only
reads the result.
One consequence worth stating: the relayout happens inside your own
graph.addNode(...) call. A layout that fails is reported through error
rather than thrown, because addNode is not a function anyone expects to raise
a layout error. And wrap a multi-step edit in graph.batch: it is one patch
and one relayout rather than three, which is the same advice relayout gives
for its own reasons. Unbatched, the intermediate layouts are computed and never
drawn, because React commits once, and the section below is about what the
component then has to do to stay correct.
The engine is disposed when the component stops watching, which is what
LayoutEngine.dispose is for: the graph, the previous run's pipeline state and
the reported-geometry snapshot are retained for the life of an engine, and on a
large graph they are larger than the result you can see.
A delta is only safe to apply to the drawing it was measured from
The state changes once per layout. An effect keyed on it runs once per commit. Those are different counts, because React renders the latest snapshot of an external store rather than every one, so two mutating calls in one task are two layouts and one commit holding the second. The delta you are handed is then a difference from a drawing you never drew, and applying it to the drawing you did draw leaves the two disagreeing from then on.
That is what from is for, and the check is one line:
// `drawn` is the result the motion is currently holding. `rosterOf` builds the
// whole scene from a result, which is what a reseat needs and a retarget does
// not: it has to be the CURRENT layout, not one hoisted earlier.
useEffect(() => {
if (state.result === null) return;
const continues = drawn.current !== null && drawn.current === state.from;
retarget(motion, continues ? state.delta : null, rosterOf(state.result));
drawn.current = state.result;
}, [state]);
Do not leave that check to the motion. SceneMotion.apply refuses a delta
naming an id whose presence it disagrees about, which catches some of these, and
a delta naming only ids it already holds applies cleanly and leaves the drawing
wrong in silence. Creating a node and then labelling it, in one handler, is
enough to produce one. <DagrCanvas animate> does this check for you, and
onLayout hands you from for the same reason.
The other half of the answer is graph.batch: one patch, one layout, one delta,
nothing to miss.
The layout still runs during render, synchronously
The first run for a graph is a useMemo, exactly as it was: synchronous, and
during render. The result is referentially stable, and so is the whole state
object: a render that changed neither the graph nor the config hands back the
same one, so an effect keyed on it does not run.
There is no worker here, and that is a decision rather than an omission. A
Worker has to be constructed by the host, because new Worker(new URL('./x.ts', import.meta.url)) is an expression a bundler reads statically and a new URL
inside this package would have to resolve, and emit its own chunk, under
everyone's bundler. @dagr/campaign-stage takes a createWorker factory for
exactly that reason. Inviting one here before M3.9b has built the worker-side
session that would make a per-edit round trip worth taking would be guessing at
a shape M3.9b is going to decide.
The config is compared by value
LayoutConfig is the one prop a caller writes as an object literal in JSX:
<DagrCanvas graph={graph} config={{ nodeSep: 80, rankSep: 120 }} />
Compared by identity, that would relayout the whole graph on every render of
the surrounding application. So useDagr compares the config field by field,
including one level into defaultNodeSize, and keeps the old object when they
agree.
nodeSize is the exception and it cannot be anything else: it is a function,
and two functions that agree on every node are indistinguishable without
calling them on every node, which is the work the comparison exists to avoid.
Memoise it, the way React asks for every callback prop. The same goes for
nodeAppearance and edgeColor.
A layout that fails is reported, not thrown
useDagr returns error rather than throwing. A graph a user is
editing passes through states the layout refuses, and throwing would unmount
the subtree to the nearest error boundary on the keystroke that made the graph
momentarily invalid. It does not hold the last good result either: a stale
picture presented as the current one is the failure mode that is hardest to
notice.
<DagrCanvas> is the one that decides. By default it throws the failure during
render, so a React error boundary catches it; an onError prop takes it
instead and suppresses the throw. Both beat the third option, which is to
render an empty box, because an empty box is indistinguishable from an empty
graph.
One class of failure is recovered from instead, and only one.
EngineStateError means the engine and the graph have fallen out of step, which
a cold run fixes, so the hook rebuilds and runs cold and reports the result with
no delta. Everything else a relayout raises is reported, exactly as the same
failure from a cold run already is. The alternative, recovering from all of
them, would make a failure reachable only under a warm start invisible: every
edit would come back cold, undelta'd and unanimated, with nothing saying why.
The cost of that choice, stated rather than buried: such a failure now reaches
your error boundary rather than quietly degrading to a correct but unanimated
drawing.
The flip, and why it lives here
@dagr/render refuses to name a LayoutResult. Naming one would make
@dagr/layout a dependency of the renderer, and the y-down to y-up conversion
belongs to whoever owns the layout. This package owns both, so the conversion
is here, and it is exported rather than hidden:
import { toSceneNodes, toSceneEdges, toWorldBounds } from '@dagr/react';
renderer.setNodes(toSceneNodes(result));
renderer.setEdges('my-edges', toSceneEdges(result));
renderer.camera.fitBounds(toWorldBounds(result.bounds));
A layout runs y-down, ranks increasing downwards, as dagre does. The renderer's world is y-up, because its camera is. Nodes, route points and bounds are three separate expressions and flipping two of the three draws a picture that is half upside down with every unit test on the flipped halves still green, which is why the suite runs a real layout through all three and asserts they agree.
The delta half is the same flip in three more expressions, and it is the worse
three: a target flipped the wrong way does not draw a node upside down, it
springs the node to the mirror of where it belongs and leaves it there. So
toMotionDelta is asserted against what toSceneNodes and toWorldBounds put
in the same place for the same run, rather than against numbers written by hand.
import { retarget, toMotionDelta, toMotionRoster } from '@dagr/react';
Appearance is a callback taking a node id:
<DagrCanvas
graph={graph}
nodeAppearance={useCallback(
(id) => (id === selected ? { fillColor: 0x2563eb, glowWorld: 6 } : undefined),
[selected],
)}
/>
Returning undefined takes the defaults, and a partial record is merged per
field, so recolouring one node does not mean restating its shape and both halo
fields. Geometry is not on the record: a node's centre and size are the
layout's answer, and overriding them here would draw a picture that disagrees
with the bounds, the routes and every stability guarantee the layout makes. Set
config.nodeSize instead, upstream, where the layout can account for it.
animate is one word, and it is the flagship
<DagrCanvas graph={graph} animate />
With it, an edit glides to its new layout instead of cutting to it: nodes spring to their new centres, edges follow their new routes, and the drawing's box moves with them. Without it, nothing tweens, which is what the component did before M5.3a and is still the default.
It is a prop rather than a hook because this component already owns all four
things a hook would have to hand back out: the coalesced frame, the renderer,
the scene conversions, and the delta. What keeps the prop from foreclosing the
other shape is that createMotionLoop takes its scheduler as an option. A
caller who owns their own frame leaves animate off, takes the renderer off
useDagrCanvas, takes the delta and its from off onLayout, and drives
createSceneMotion from their own loop, which is
the worked example
on the render page. toMotionDelta, toMotionRoster and retarget are
exported for exactly that caller, so the flip and the continuity check are not
theirs to rewrite. The component hands the loop its own requestDraw, so there
is one frame budget here rather than two, and a burst of edits in one task is
one frame.
The feel is the same prop:
<DagrCanvas graph={graph} animate={{ halfLifeSeconds: 0.3 }} />
halfLifeSeconds is how long a spring takes to close half the remaining gap
(default 0.12) and restEpsilon is how close, in world units, counts as arrived
(default 0.05). Both are @dagr/render's, one number each for the whole scene,
because one delta is one change and three arrival times would read as three. The
object is compared by value, like config.
Four things worth knowing:
- The first layout does not animate. A scene built from a result has no history to come from, so it is seeded at rest and drawn where the layout put it. Only an edit glides.
- Sizes do not spring. A node that changed size takes its new box on the frame the edit lands, and only its centre glides. A label that grew measures wider because the text that made it wider changed instantly, and a box lagging its own contents would clip them.
- A removed node leaves on the frame its spring settles, which for a node that was standing still is the next one: it is gone rather than faded. A node removed mid-glide finishes its move first, so it does not jump on the way out, unless that removal arrived in a burst that had to reseat, in which case it is gone at once: a reseat describes a whole state, and a node not in it has no departure to finish. Nothing fades, because a fade is an appearance and this component has no opinion about appearance.
- The loop stops itself. It asks for no frame after the one on which every spring has arrived, so an idle canvas is an idle canvas.
The living graph demo is this prop, onLayout, and a few
hundred lines of page. Its source is packages/living-stage, and the README
there is worth reading before writing your own: it is mostly a list of the
things that turned out to matter, including the two shapes of edit that looked
right and either moved the whole drawing or moved none of it.
The camera is fitted once, and the sprung box is yours
The first frame that has both a layout and a viewport frames the graph. Nothing
refits after that, and fit={false} skips even the first. Refitting on every
edit would be a camera that jumps whenever the graph changes, which is the
instability the whole incremental-layout milestone exists to keep out of the
layout, reintroduced one level up where no stability metric would see it. An
animated demo that refits every frame would look impressive and would hide the
thing it exists to show, because a drawing that stays put while the camera moves
is indistinguishable from a drawing that moves.
A caller who does want a following camera has the box on every frame, sprung along with everything else, and writes the one line themselves:
<DagrCanvas
graph={graph}
animate
onFrame={(frame, renderer) => {
if (following && frame.bounds !== null) renderer.camera.fitBounds(frame.bounds);
}}
/>
The types these props are spelled in are re-exported from this package, so a
caller whose only contact with the renderer is <DagrCanvas> does not take a
dependency on @dagr/render to write an annotation:
import type { SceneMotionFrame, SceneMotionOptions, Renderer } from '@dagr/react';
const feel: SceneMotionOptions = { halfLifeSeconds: 0.3 };
function follow(frame: SceneMotionFrame, renderer: Renderer): void {
if (frame.bounds !== null) renderer.camera.fitBounds(frame.bounds);
}
They are the same types, re-exported rather than restated, so one can still be
handed to @dagr/render directly. Anything that drives the renderer itself
still imports from @dagr/render, which stays a peer dependency.
onFrame runs after the renderer has been told what to draw and before it
draws, so a camera moved there moves on that frame rather than the next. The
renderer comes with the frame so that line needs no ref: reaching it through
useDagrCanvas would be a child component written to call fitBounds once. It
is not called when animate is off, because then there are no frames between
layouts to hand over.
The living graph demo takes the third option this decision leaves open, which is worth naming because it is the one an animated demo usually wants: it does not follow the box at all, and instead its graph is built so that no edit can make the drawing bigger, which makes one fit correct forever. A refit button is there for a visitor who has panned away, and a person pressing it is the whole difference between that and an automatic refit.
<Html> puts React content in world coordinates
createHtmlOverlay takes a create callback returning an HTMLElement, which
is the right shape for a caller building DOM by hand and the wrong one for
React. <Html> inverts it: the component owns one host element for its whole
life, create hands the overlay that same element every time, and the children
go into it through a portal. The overlay attaches and detaches an element whose
contents React has been maintaining all along.
<DagrCanvas graph={graph}>
<Html node="plan" minScreenWidth={120}>
<strong>Plan</strong>
</Html>
<Html placement={{ kind: 'point', at: { x: 0, y: 40 } }}>Legend</Html>
</DagrCanvas>
Exactly one of node and placement is given, and the type enforces it. The
node form sits over the box the layout gave that node and takes the overlay's
two screen-width gates; the placement form takes an OverlayPlacement
straight through, and carries its own gates inside it if it is a box.
<Html> is for the tens, not the thousands. The overlay's create is lazy
precisely so that a scene with 2,800 nodes builds DOM for the few dozen on
screen. A portal is not lazy: an <Html> that is culled still has its subtree
mounted. Ten labels and a card or two is nothing; one per node on a big graph
gives up the cap that makes the overlay work, and the thing to reach for there
is createRichNodes, which is pooled and imperative on purpose.
An <Html> naming a node the layout does not have registers nothing and
renders nothing, rather than throwing. A node can legitimately vanish while an
edit is in flight.
Reaching the renderer
Anything inside the canvas can have it:
function ZoomOut() {
const { renderer, requestDraw } = useDagrCanvas();
return (
<button
onClick={() => {
renderer.camera.setZoom(renderer.camera.zoom * 0.8);
requestDraw();
}}
>
Zoom out
</button>
);
}
The handle carries the renderer, the overlay, the layout currently on screen,
and requestDraw. Nothing calls renderer.render() directly: requestDraw
coalesces every reason to draw in one frame into a single callback, and the
overlay's own sync runs inside it, because a second animation loop is a
second frame budget and a frame of skew, which reads as the labels swimming
over the graph during a pan.
Children do not render at all until the renderer, the overlay and the layout all exist, so nothing on the handle is nullable. A caller who wants a spinner in the meantime renders it outside the canvas.
useDagrCanvas outside a <DagrCanvas> throws CanvasContextError, with code
OUTSIDE_CANVAS. A missing provider is the one mistake in a React package that
is otherwise completely silent, because useContext of an unprovided context
returns a default value and the failure surfaces several frames away from the
component that was in the wrong place.
Three props are read once
clearColor, sceneStyle and edgeStyle are taken when the renderer is
built. Edge groups are declared at construction in draw order, and rebuilding a
device context because a colour changed would drop every instance handle in the
scene to honour a prop nobody animates. A caller who does want to animate one
holds the renderer and calls setEdgeStyle on it.
What is not here yet
- Interaction. Hover, selection and drag are M5.2, and they want the GPU picking pass of M4.8 underneath rather than a hit test invented here against a scene array.
- A node ontology. What a node looks like is a callback and it stays one. Deciding that a node of kind X draws as a hexagon belongs to the visual-language toolkit, which is scoped precisely so that Dagr ships no ontology of its own.