Kanzo UI
Graph

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 · nonemotion · settled

Loading

status and motion are two axes

ValuesMoves when
statusnone · loading · idle · failedthe graph, a binding or a filter is read and drawn, something fails
motionrunning · paused · settleda layout starts, is paused, or cools to rest
progress0 – 1, in twentiethsa 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.

SettingUp to 200,000 verticesAbove
how long it cools (simulationDecay, frames)600240
the layout's square (spaceSize)4,0968,192
links while it runsdrawn up to 250,000 links, hidden above — they return when it settles or pausesthe same
VerticesLinksDecaySquareLinks while runningLoadSettledFrame (median)
10,00040,0006004,096drawn88 ms10.0 s16.7 ms
50,000200,0006004,096drawn156 ms12.3 s16.7 ms
200,000800,0006004,096drawn456 ms43.0 s66.7 ms
200,000800,0006004,096hidden457 ms11.9 s16.7 ms
200,000800,0005,0004,096drawn444 msunsettled at 120 s66.7 ms
500,0002,000,0006004,096hidden1,097 ms25.2 s33.7 ms
500,0002,000,0002408,192hidden1,128 ms11.0 s48.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. highlightedPointIndices alone leaves links lit; highlightedLinkIndices is 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 wasWhen a canvas attaches again
settledstands still where it settled — no layout runs, nothing is re-framed
pausedstands still, and stays paused until Resume
runningresumes 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 x and y when 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.
  • 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.

On this page