Layout and camera
The data's status and the layout's motion, when the GPU layout runs and how it scales, what a drag does to the points, when the camera follows, and what is kept when the canvas goes.
A graph view does two different things over time: it reads a graph, and it lays out points.
@kanzo-tech/graph keeps them on two axes, runs the layout whenever the data does not say where a
point goes, and moves the camera for the reader only while a layout is moving the points.
status and motion are two axes
| Values | Moves when | |
|---|---|---|
status | none · loading · idle · failed | the graph, a binding or a filter is read and drawn, something fails |
motion | running · paused · settled | a layout starts, is paused, or cools to rest |
progress | 0 – 1, in twentieths | a running layout cools toward rest |
status is the data's life. none is no catalog named; loading is the graph, a binding or a
filter not yet read or not yet drawn; idle is all of it drawn; failed is a graph that would not
read, or a canvas with no GPU device — none came up, or its context was lost.
motion is the layout's. running is a live layout; paused is one waiting for the reader;
settled is one that converged on its own — or none ever ran, which is how a graph with bound x
and y loads.
They are separate because each moves without the other. A filter loads under a paused layout —
loading and paused — and a layout runs over a graph with nothing left to read — idle and
running. Folding them into one state would make one of those unsayable. So a word that sums them
reads both, and GraphStatus is that word: Loading while anything is read, Laying out with the
percentage while a layout runs over a drawn graph, Ready when both are at rest, Failed. A footer
draws the part rather than reading the axes:
<GraphStatus />Reading status alone is the mistake it exists to prevent: a footer that did said "idle" over a
layout visibly running. GraphCounts' aria-busy follows the load alone, because a layout moves the
points and not the figures it reports.
When the layout runs
Cosmograph's rule: the data places the points, or the layout does. Bind x and y to two
numeric columns — x="lon" y="lat" — and every point is drawn at its row's values and nothing
simulates; a vertex whose table lacks either column is not drawn. There is no base map: lon/lat as
x/y is a scatter of positions, which is what Cosmograph does too. Leave them unbound — a fossil
graph carries no positions — and the points start from a seeded Gaussian around the centre of the
layout's square, its spread growing with the graph, and the GPU force simulation runs as soon as the
graph is drawn. The seed is the vertex count, so the same graph starts the same way.
cluster names a column the running layout pulls together by — cosmos.gl's cluster force over
setPointClusters. It is a binding like any other: change it and the layout reheats.
Latitude as y draws north up. cosmos.gl's space is y-up, and the data's extent is placed in its
square by a positive scale and a translation, never a flip. A reader picks among the three —
the layout, two columns, the layout with a cluster column — through the graph section's
placement, whose column selects offer the graph's own fields.
simulate={false} keeps an unbound graph at its seeded start. Nothing runs a layout over bound
positions — not simulate, not the transport, not a change of forces: the forces would only push the
data's points against the walls of the layout's square. Binding the second axis stops a running
layout at once, before the positions it asks for have loaded. The toolbar's transport runs and
stops the layout whatever simulate says — Pause the layout while running, Resume the layout when
paused, Run the layout when settled, and Re-run the layout, which starts again from full heat —
and is disabled while x and y place the points. The api is the same transport: resume(),
pause(), restart(), which do nothing then. Changing sim while a layout runs reheats it, so a
reader tuning gravity sees the change; a paused layout stays paused.
How the layout scales
Three settings follow the vertex count, each decided by measurement — cosmos.gl 3.4.0, an Apple M4 Pro, Chrome 154 headed, a real GPU, a community graph of four links per vertex. The shape is done in one to three seconds at every size; what the settings buy is a layout that stops spending frames soon after, at a frame rate a reader can pan through.
| Setting | Up to 200,000 vertices | Above |
|---|---|---|
how long it cools (simulationDecay, frames) | 600 | 240 |
the layout's square (spaceSize) | 4,096 | 8,192 |
| links while it runs | drawn up to 250,000 links, hidden above — they return when it settles or pauses | the same |
| Vertices | Links | Decay | Square | Links while running | Load | Settled | Frame (median) |
|---|---|---|---|---|---|---|---|
| 10,000 | 40,000 | 600 | 4,096 | drawn | 88 ms | 10.0 s | 16.7 ms |
| 50,000 | 200,000 | 600 | 4,096 | drawn | 156 ms | 12.3 s | 16.7 ms |
| 200,000 | 800,000 | 600 | 4,096 | drawn | 456 ms | 43.0 s | 66.7 ms |
| 200,000 | 800,000 | 600 | 4,096 | hidden | 457 ms | 11.9 s | 16.7 ms |
| 200,000 | 800,000 | 5,000 | 4,096 | drawn | 444 ms | unsettled at 120 s | 66.7 ms |
| 500,000 | 2,000,000 | 600 | 4,096 | hidden | 1,097 ms | 25.2 s | 33.7 ms |
| 500,000 | 2,000,000 | 240 | 8,192 | hidden | 1,128 ms | 11.0 s | 48.3 ms |
Load is setPointPositions plus setLinks, synchronous on the main thread. Settled is wall
time until cosmos.gl's alpha reaches its floor. The last row's graph has tighter communities (99% of
links inside one); on that graph the 4,096 square was still visually mixing at 20 s, and 8,192 was
settled by 3 s.
- Links are the cost, not the forces. At 800,000 links a layout ticked 14 times a second with links drawn and 51 without. With links drawn throughout, the practical limit is about 50,000 vertices.
- A longer decay buys nothing. At 5,000 frames the 200,000-vertex layout looked done by 10 s and was still running at 120 s.
- Greying needs both index lists.
highlightedPointIndicesalone leaves links lit;highlightedLinkIndicesis set beside it — the filter.
Measured on 2026-10-02, one run per row.
Dragging a node
In the reading posture — no tool armed — a drag on the background pans and a drag on a node moves it.
Nothing holds it there: a running layout pulls it back among its neighbours, and a settled or paused
one leaves it where it was dropped. There is no pinning. A pin is a second state beside the selection
that no panel can read, and an arrangement worth keeping is a placement — x and y bound to
columns — not a set of nodes held by hand.
Where the layout simulates
cosmos.gl simulates in a square, [0, spaceSize]², and two of its forces care where the points are
in it: gravity pulls toward the square's centre, and the many-body force bins points on a grid over
the square. Drawing is translation-invariant; the layout is not. An unbound graph is seeded in the
middle of the square above. Bound positions are centred in it, at their own scale, so a layout
run over them does not contract into a corner; the extent is scaled down only past what the device
can simulate in — half its MAX_TEXTURE_SIZE.
The camera
At load, the camera frames the bound positions' extent — or, for a layout, the seeded points — and keeps it framed until you move it. It frames before anything is drawn, so the first frame is already the whole graph — and again when cosmos.gl's device comes up and whenever the canvas changes size, because a canvas in a split panel or beside a dock settles its width after mount, and a frame taken at the old size opens zoomed into a corner.
While a layout runs, the camera follows it. A force layout contracts a spread-out graph, and a camera that stayed put would watch it shrink to a dot. So every 900 ms the camera re-frames the moving points, gliding over 700 ms, and frames them once more when the layout settles — Cosmograph's fit-on-settle, kept up for the whole run.
The reader can take the camera back. A wheel, a pan or a pinch hands the camera to the reader: a resize leaves it where it is, and a running layout stops following until the next run starts. Frame, with nothing greyed, hands it back. A camera that fought the reader's zoom would be the bug, not the feature.
Frame frames what is there. With nothing greyed it frames the whole graph: before any layout
has run, the bound extent; once a layout has moved the points the extent is history, so where the
points are now. With something greyed it frames what is in full colour, and reveal zooms to one
vertex; neither of those two changes who holds the camera.
A frame of the points waits for the points. Framing where the points are reads them back from
the GPU, so it waits for cosmos.gl's device and for the render that uploaded them — and the layout
starts only after that render, because cosmos.gl 3.4 turning its simulation on discards an upload
that has not been rendered yet. If the read fails anyway, the device holds no positions: the graph
fails once with graph/no-positions and the camera stops reading, rather than leaving a blank canvas
that throws on every tick.
When the canvas goes
The arrangement is the store's, and GraphCanvas is a view of it — cosmos.gl's own contract,
setPointPositions in and getPointPositions out. A host that switches the canvas away — to a
dashboard, another tab — unmounts it, and the GPU is freed: cosmos.gl's graph is destroyed and its
WebGL context given back. As it goes, the canvas writes where the points are and where the camera
looks back into the store. The next GraphCanvas under the same root uploads those positions instead
of the seeded start, puts the camera where it was, and the layout goes on as it was left:
| The layout was | When a canvas attaches again |
|---|---|
| settled | stands still where it settled — no layout runs, nothing is re-framed |
| paused | stands still, and stays paused until Resume |
| running | resumes from the kept positions with a reheat, and the camera follows it again |
motion keeps its value while no canvas is attached: it is what the layout will do when one is.
A new geometry drops what was kept — another graph, or x and y bound to other columns — and
the next canvas places it as at load. A change of look, colour, size or cluster keeps it: those
repaint the points and do not move them, attached or not. A restored camera is the reader's: a resize
leaves it where it is, and Frame hands it back.
There is no hidden canvas and no host prop: a canvas kept alive off-screen would hold its GPU context, and Chrome keeps sixteen per renderer process before it evicts the oldest.
Large graphs
The camera that follows a layout is not free. Every re-frame after a layout has run reads every
point's position back from the GPU — a synchronous readPixels of the position texture, sixteen
bytes a point, and an array of two numbers a point on the JavaScript side. At a few thousand points
it is invisible; past tens of thousands it is a stall each time, and a running layout asks for one
every 900 ms on top of a simulation step that already costs 10 ms at 50,000 points and 62 ms at
200,000.
The camera waits ten times what its last re-frame stalled before the next one. The read is synchronous, so it waits for every step the GPU has queued. On a GPU that is milliseconds and the 900 ms rule holds. On software WebGL it is not: SwiftShader, which headless Chromium falls back to, steps 206,629 vertices about once a second, a read stalled about two seconds, and a re-frame every 900 ms held the page's main thread 99% of the time — the 10 ms timers that ran fell from 459 to 2 in five seconds, so nothing else on the page (DuckDB's replies, rudof's checks) ran either. Waiting ten times the stall leaves it 90% free on any device, with no vertex count to tune. The layout itself still costs what it costs there: 240 steps at a step a second is four minutes to settle, where an M4 Pro's GPU settles the same graph in under ten. Measured on 2026-10-09 with cosmos.gl 3.4.0 alone, Playwright's headless Chromium on SwiftShader and on Metal, 206,629 vertices and 316,766 links. What would reverse it: a readback cosmos.gl answers without waiting for the queue, which would make every re-frame cheap.
What to do about it, in the order it pays:
- Bind
xandywhen the data has positions: nothing runs and nothing is read back. - Take the camera — a wheel or a pan — once a run has started, and the re-frames stop until the next run.
- Start large graphs at other forces through the tenant policy over
GRAPH_SECTION's axes — less repulsion, more friction — so a run settles in fewer frames.
Related
- Graph — the root, the parts and their API.
- The graph view — the renderer's rules and the tests that hold them.
- Benchmarks — the viewer against cosmos.gl alone, and a live layout's step cost.
Graph
The graph view over an Apache GraphAr graph — attached to the page's DuckDB, read on the page's engine and filtered as one Mosaic client beside the charts, laid out and drawn with cosmos.gl on the GPU.
Discovery workspace
A recipe — the engine, a graph attached by name, and the graph parts and charts on one crossfilter.