Kanzo UI
Graph

Graph

A GPU force layout over cosmos.gl — what @kanzo-tech/graph ships, what it deliberately does not, and how far it scales.

@kanzo-tech/graph renders a knowledge graph on the GPU: the DuckDB relation that feeds it, the buffers that colour it from the page's own theme, and the hooks that own the renderer's lifetime.

It is a sibling of @kanzo-tech/ui, not part of it.

Why it is not in the library

The first admission rule is domain-free — nothing about RDF / SHACL / graphs / auth. Graphs are excluded by name, and deliberately: ui is the generic vocabulary every product shares, and a renderer with a required WebGL peer would change what that package is. A separate package is the same answer palette and theme already got — ours, shipped, not ui.

The smallest one

A coordinator, where the corpus is, and one required callback. The simulation stays off, which is its default: the layout is the corpus' own, and moving it would move the picture out from under the index the next spatial query is expressed in.

This is worse as a first example than what it replaced, and the trade is deliberate. It used to be three typed arrays and one call — no database, no build step, nothing to fetch. Now the smallest thing on this page boots DuckDB-WASM and reads a corpus that has to exist. What it buys is that the first thing you see is the path a product takes: this package exists to draw what fossil compiled, and an opening example that hands the canvas arrays teaches an API you would then have to unlearn. It is the archive the workspace showcase draws, opened the same way — one world, not a second one written for the docs.

If you do hold the arrays already, memorySource is still there and still one call; it is the second thing on this page rather than the first.

Everything else on this page is what you add when one of those assumptions stops holding.

What the canvas owns, and what it does not

GraphCanvas owns three things: the renderer across React's lifecycle, the query loop that follows the camera, and the buffers a look implies. Those were identical in every host and re-wired by hand at each one. It publishes what its chrome needs through useGraphContext.

It owns nothing above that, and that is the shape rather than an omission. A toolbar, a legend, an inspector, a hover card and a search box answer differently per product, so they are children. Overlays and selection are left out for a sharper reason: useGraphOverlays and useGraphSelection need callbacks only a product can write — what a click means, what a lasso commits to — so they stay hooks you call with getGraph and getResident. The relationship is the one ChartRoot has with useChartContext, not a v2 replacing a v1.

When the shortcut is not enough: useGraph and GraphRootProvider

Those two hooks are the reason the graph has Ark's four pieces rather than one component. Both take getGraph and getResident, and both are called beside the canvas — above the element, where a context cannot be read yet. So there is a factory that builds the api where you can hold it, and a provider that renders the surface over one:

const api = useGraph({ source, onFailure });
const overlays = useGraphOverlays({ getGraph: api.getGraph, getResident: api.getResident });

return (
  <GraphRootProvider value={api}>
    <Legend />
  </GraphRootProvider>
);

useGraph creates and useGraphContext reads, which is Ark's convention throughout. What is deliberately not copied is the substance of an Ark api: its value is prop getters that distribute props across many parts, and a canvas is one element, so there is no getRootProps() here.

GraphCanvas is useGraph and GraphRootProvider in one call, and it is what you want until a hook has to sit beside the canvas.

This page said for a long time that there was no canvas component at all. That argument was right about the chrome and wrong about what sat underneath it; what changed and what would change it back is on the bounded reader.

The split is proven unevenly, and it is worth being exact about where. The source half has two call sites: the workspace showcase and graph-bench/measure-bounded.ts, which drives duckBoundedSource, shouldSlice and Slice against synthetic generators. The renderer half — buffers() and the hooks — has one: the workspace. The benchmark route builds its own cosmos.gl instance and its own upload path, because measuring the upload is the thing it is for.

GraphCanvas is therefore a shape proven against one host, which the decision record says plainly rather than hiding. The second comes from migrating a real product screen, not from another example.

Here is that shape with both hooks on it, over the fixture the next section takes apart — the dot grid locked to the graph's own space, standing labels on the hubs, a hover card that clears its own node, and a drag that selects. Pick a tool and drag a box; Shift borrows the marquee whichever tool is armed, and at release Alt removes what was drawn while /Ctrl adds.

basiliskboghoundgrimalkinharpymimicrevenantstonebackwyrmAshfall ReachColdironDuskfenGreenhollowSaltmereThornmarch

Four things in it are the reason the hooks are hooks rather than parts of the canvas.

The overlays never paint on their own. There is one requestAnimationFrame and the host decides when it fires, by wiring onZoom and onTick to schedule. That is not a detail you can leave out: leaving it out gives a grid that sits still while the graph pans under it, and nothing fails.

track() is caller-driven rather than a one-shot, because cosmos.gl clears the tracked set on the render that follows setPointPositions — so the registration is lost exactly once per graph, at construction. And the positions it registers only become readable after a render, which is why the effect ends with a requestAnimationFrame rather than calling schedule() in the same tick.

An overlay is attached to a vertex, never to a slot. Held as an index, a label would keep its position and silently start naming a different node the first time the resident set moved. The labels here are built from vertexId(type, dense) and resolved through Resident at paint time.

The gesture reads its modifiers at release, not at press, which is the small decision that makes it feel right: release is when the reader has decided.

The two halves

Data. A source answers one question — what should I draw — and useGraph asks it. The answer is a Slice: at most limit points, as parallel typed arrays, whose size follows the question rather than the corpus. Moving the camera re-asks, and a window holding more than limit is sampled rather than truncated — one row every ceil(matched / limit) over the corpus' Morton-ordered dense_id, which spreads the marks over the window instead of drawing a corner of it. So a view of everything is still a few thousand marks, and they are still everywhere.

const source = duckBoundedSource({ coordinator, nodes: "nodes", edges: "edges" });
const { slice, refresh } = useGraph({ source, onFailure });
// The camera is already wired: moving it re-asks.

A host that already holds its arrays wraps them in memorySource(...) instead — same path, no database. Under limit either source is asked once for everything and never again, so a graph that fits pays for nothing.

asking…

That is the sightings fixture drawn as the graph it already is: every report joined to the beast it names and the region it happened in, so the beasts and the regions are hubs. Pull on one arc and you get a beast's reports across every region at once — which is the thing the crossfilter charts over the same rows cannot do.

Three things in it are worth reading rather than copying. It is useGraph + GraphRootProvider rather than GraphCanvas, because the count in the corner comes from useGraphContext() and a component that renders the root sits above the provider. The count is marks of n, because n is what the window matched before limit cut it and a truncated answer must not read like a complete one. And every vertex is vertexId(type, dense) rather than its buffer index — an index names a different vertex the moment the camera moves.

The channels ride the question, not the source

What a point wears is bound on the canvas, under the names Plot gives a point mark: fill, symbol, r and stroke.

<GraphCanvas source={source} fill="kind" r="degree" onFailure={setFailure} />

A CSS colour is a constant; anything else is a column. That is Plot's rule, and it is what makes the monochrome picture a binding rather than a theme:

<GraphCanvas source={source} fill="var(--foreground)" symbol="kind" r="degree" onFailure={…} />

Identity has moved to shape, colour is free to mean the selection, and nothing had to know which of those two a look preferred. A bare word is always a column, so a corpus with a column called red is not a trap.

A Look is form only — sizes, link width and curve, labels, vignette. It used to decide whether identity reached the GPU as colour or as shape, which is a theme rewriting an encoding — see a look is form, a channel is a binding. Binding symbol takes on an obligation — gradeComposition(look, channels) returns the four-pixel radius floor that shape and size spent together require, and pairing a glyph with a dense form's ramp now reports.

A source says where the bytes are; a channel says what I want drawn. They used to be one thing — the column names were given to the source at construction — and the cost of that was concrete: recolouring meant building a second source, which restarts the query loop and re-counts the corpus to answer a question about colour. Changing one of these re-asks over the same bytes and nothing else moves. It is the reference's own arrangement: in Plot the mark carries the channels and the mark is what produces the query.

x and y are not channels, and that is the real difference from Plot. In a laid-out corpus a position is a fact rather than an encoding — a layout pass wrote it, and it is the index every spatial question is asked against.

Both are strings naming a column, and there is nothing to be done about that: naming a column is what Plot, Vega and every graphical grammar do. If the column is not there the query fails and says so.

Three sources, three jobs

forwhat it needs to be told
memorySourcearrays you already holdthe arrays
duckBoundedSourceany relation with x/ythe two relations, and which columns carry identity and position
openCorpusa tree fossil wrotewhere it is

None of them is told what to draw. A source is where the bytes are; what colours a point and what the size ramp is spent on are channels on the canvas — fill, r — and they ride each request, so changing one re-asks and rebuilds nothing. A source that took categoryField is how recolouring used to mean constructing a new source and restarting the query loop.

A channel is a column named as a string, which is what naming a column is, and nothing infers one: an unbound fill draws a single colour rather than picking a column that looked plausible. The archive corpus carries cluster_id and no community, so its host binds fill="kind" and says so.

const { source, nodes, edges } = await openCorpus({ coordinator, dest: "/corpus/people" });

That is the whole call. It reads graph.graph.yml, takes the chunk size and the prefixes from the manifests, finds the last tile by probing rather than by listing — a plain HTTP origin cannot expand a glob, because expanding one means listing a directory — and reads each tile's bounding box from the footer once. After that a camera move is arithmetic over those boxes and a list of URLs, with no request between the camera moving and a URL being computable.

It takes no column names and no type index, deliberately. Those come from the manifest or they do not come: a corpus reader that also accepted idField would be duckBoundedSource with extra steps, and there is already one of those for the case this is not. subjects: true adds the identity column, off by default because it costs about twice the drawing tile.

What it buys, and what it does not. Panning a million-vertex corpus went from 534 ms to 72, and stopped growing with the corpus — 73 ms at two hundred thousand against 72 at a million. First paint got slower, and that is the trade rather than a defect: the opening view is the whole extent, so every tile intersects it and there are no bytes to skip. Benchmarks has the figures and the bounded reader has the argument, including why the camera is not one of fossil's verbs — it was, and it was deleted.

A point is addressed by index and identified by pair. cosmos.gl numbers points by their position in the arrays it was last handed, so index 7 is whatever the current answer put seventh — and an answer that comes and goes reuses every index while the vertices behind them change. A vertex is therefore vertexId(type, dense): the pair, because dense_id numbers within one vertex type, so a union of two types repeats every value and a LIMIT breaks the correspondence with position regardless. Everything that outlives one answer — a selection, a label, a hover, a pin — is held as a VertexId and resolved through the Resident that useGraph rebuilds per answer.

VertexId is a bigint, and Slice.vertices a BigUint64Array, because the pair is 64 bits and a number holds 53. That is not a hypothetical: mapbox/node-s2 binds the reference C++ and still returns 1152921504606847000 where Java and Go give 1152921504606846977, open since 2017, and H3 answered the same problem by typing H3Index as a string. It is also the guard — a buffer index is a number, so mixing the two is a type error and not a naming convention. Nothing changes for the GPU: positions and indices stay number and Float32Array.

const { resident } = useGraph({ source, onFailure });
graph.setConfigPartial({ highlightedPointIndices: resident.indicesOf(selection.vertices) });

Do not build a second map. resident is a function of the current answer and nothing else, so a copy assembled beside it is the same value one render later, with no way to notice it has fallen behind the buffers on screen. denseOf(vertex) is the way back down to SQL, where a clause is id IN (…) over one relation and the type is what the table already is.

There is no load(). It read the whole relation into memory — every id, every row, an id→index map — and that made the working set N, so the ceiling was whatever N the machine could hold: 1,225 ms of first paint at 200,000 nodes. ADR-0001 deleted it rather than optimising it. Two consequences are worth stating because they are losses, not refactors: a slice carries category ordinals, not names (what an ordinal is called is a question for a legend, and a legend asks the source), and the label budget has no global ordering — "the most important labels" became a property of the answer, which is the biggest nodes here rather than anywhere.

Appearance. buffers(slice, look, host) turns a look and the live theme into per-point colours, sizes and shapes. A Look carries geometry only — colour comes from the page's categorical scale, because a scale a graph invents is a scale that disagrees with the legend explaining it.

appearance() is the other side of that line, and it is a performance boundary rather than a stylistic one: anything that is one number for the whole canvas belongs in a cosmos.gl uniform, read fresh on every draw. Multiply an opacity slider into half a million per-instance values and every tick re-uploads the array; put it in a uniform and the same change costs nothing.

How far it goes

Measured on an Apple M4 Pro, not quoted. The full table is in BENCHMARKS.md, and /view/showcases/graph-bench re-runs it.

NodesLinksPer simulation stepCeiling
2,00012.5k1.6 ms611 fps
50,000332k10.4 ms98 fps
200,0001.4M61.6 ms16 fps
1,000,0006.9M441 ms2 fps

Nothing failed at any size — a million points initialise, upload and simulate. The ceiling is not the renderer, it is the layout. A live simulation is comfortable to about 50,000 points and finished by 200,000.

Past that, positions become a column. Drawing a million points is cheap; solving them sixty times a second is not, and no renderer changes that. So a simulation is off by default: the coordinates a source hands back are the index the next spatial question is asked against, and a force that moves them moves the picture out from under its own index — the camera drifts away from the corpus within a frame. Lay the graph out once, store the coordinates, and render. Turn the simulation on for the other host: arrays in hand, no layout, few enough points that a live one is the cheapest way to get one.

The picture is declared, not hard-coded

Nothing about how a graph looks is a literal here. lookFrom and simFrom read the same Record<string, string> a preferences section produces — GRAPH_SECTION on @kanzo-tech/graph/section is the declaration, a panel draws it, and these two are the only readers. The values are strings in all three kinds, so a key from a namespace this package has never heard of rides through a write untouched, and a key that is missing takes the manifest's answer.

552 nodes · repulsion 1.10 · friction 0.86

The switches above the canvas stand in for that panel. Two of the four controls are not axes at all, and the difference is the point:

  • adaptive(nodes) is the question a person should not be asked. What a corpus of this size wants — repulsion down, friction up — is computed continuously against node count rather than snapped at breakpoints, because a graph crossing a threshold would visibly jump. It is tuning and not level of detail, and it deliberately leaves the space size alone: that box is the coordinate space a source's positions are expressed in, so resizing it by node count would move the index out from under the camera. A host with a large corpus starts its users at this answer through the tenant policy, not by writing values into storage nobody can then reset.
  • clusterRing is what the canvas calls the moment clusters is passed. Turn it off and watch the communities stay tangled: setPointClusters alone pulls each node toward its own group's centre of mass, which is a target that moves with the thing pulling it, so groups that start overlapped have overlapping centroids and nothing asks them to move apart. Explicit ring positions turn the same force into a positional constraint the simulation converges onto. It is exported for a host seeding its own layout with the same geometry.

Things the source will not tell you twice

These are cosmos.gl behaviours that typecheck perfectly while being wrong.

  • setConfig resets everything. In 3.x it restores defaults and then applies the argument. Use setConfigPartial. Only three fields are genuinely init-only: initialZoomLevel, randomSeed and attribution.
  • getConnectedLinkIndices is not a neighbourhood. It returns only links whose other endpoint is also in the argument — an induced subgraph, which for a single point means its self-loops. Use neighboursOf(graph, index). It answers in buffer indices, like every other cosmos.gl call, so its result is resident.verticesAt(...) away from being something you can keep.
  • Every index cosmos.gl hands you dies with the answer. findPointsInRect, findPointsInPolygon, onPointClick, onDragEnd, getTrackedPointPositionsMap — all of them speak positions in the current buffers, and none of them says so. Nothing raises when one is kept: it stays a valid index and starts naming a different vertex.
  • requestAnimationFrame never fires in a background tab, and the simulation is driven by rendered frames. A backgrounded graph is stopped, not slow — and graph.ready has no failure path, so a device that cannot be created leaves the promise unsettled rather than rejecting.

The types it exports

Every shape below is exported, because a host writing its own source or its own panels implements them rather than passes them.

The source contracts. BoundedSource is the whole of what the canvas asks of your data — answer "what is in this rectangle, at this zoom, in at most this many marks". SliceRequest is that question; ExploringSource is a source that can also answer a topological one, and ExploreRequest is that second question: where to start and how far out. MemoryGraph is the shape the in-memory source takes — vertices, positions as [x0, y0, …] and links as indices into them.

The hook. UseGraphProps is what useGraph takes, GraphApi what a graph publishes to its own chrome and to its host, GraphEvents the gestures in this package's terms, and GraphCommands what the canvas can be told to do — registered by the canvas, called by the panels. GraphOverlays and GraphOverlayOptions are the label and card layer over the canvas, which is DOM rather than WebGL.

The vocabulary. Motion is "running" | "settled" | "paused", and the difference that matters is that settled converged on its own while paused is waiting for you. SelectionSource says where a selection came from — "marquee" | "lasso" | "node" | "order" | "ask" — and is open-ended so a host with its own panels can add its own. ShapeId is a slot in the four-shape scale, the sibling of the colour scale. Channels is what each channel is bound to, in Plot's names and under Plot's rule about what a value means. Buffers is the four Float32Arrays the renderer fills. Sim is the force coefficients, handed straight to the GPU simulation.

On @kanzo-tech/graph/duckdb. DuckSource is a BoundedSource that also publishes the reader's selection as a clause the rest of the page filters by, and DuckSourceOptions is what it takes. openCorpus takes OpenCorpusOptions and returns an OpenedCorpus — the source for the canvas, and the handle for everything else the corpus holds.

API Reference

Thirty-six names, grouped the way the barrel groups them. Most of a graph is composed from the first two blocks; the rest is here because a host that writes its own buffers, its own overlays or its own source needs the same functions this package uses, and two implementations of what colour is var(--primary) here is how a canvas ends up disagreeing with the page around it.

The canvas

NameWhat it is
GraphCanvasThe shortcut: creates the api and renders the element.
useGraphCreates the api, for a host that renders the element itself.
useGraphContextReads it, from inside the canvas.
GraphRootProviderWhat you reach for when a hook has to run beside the canvas — useGraphOverlays and the events block want getGraph and getResident from above the element, where no context is readable.

The slice, and the arrays a renderer wants

NameWhat it is
buffersA slice in, the typed arrays out. The size ramp spans this slice, not the corpus — the trade ADR-0001 names.
scaleOfThe categorical scale the channels imply: what colour and what shape an ordinal wears. capacity is where Other begins.
isColourWhether a channel's value is a colour, and therefore a constant rather than a column. The split it decides happens twice — the buffers paint the constant, and the query must not be asked to fetch a column called var(--foreground).
neighboursOfThe renderer's adjacency: what is on screen, which is what a hover highlight wants. The graph's own answer is a neighbourhood query on a source that supports one.
forcesThe simulation coefficients in cosmos.gl's spelling.
appearanceThe look as uniforms. A uniform is read fresh on every draw, so changing one rebuilds no array — which is why a look change costs a setConfigPartial and no upload at all.

Identity

A vertex is the pair (type_idx, dense_id), because the renderer addresses points by position in the arrays it was handed and a resident set that comes and goes reuses every position.

NameWhat it is
vertexIdThe pair, packed.
typeOf / denseOfThe two halves back out. Both are number: each is 32 bits, which a number holds exactly.
residentOfThe map from identity to buffer index, rebuilt per answer. A host never builds its own — two maps of the same thing is how one ends up describing buffers that are no longer on screen.

The look, and the shape channel

Colour never comes from a look: a scale a graph invents is a scale that disagrees with the legend explaining it.

NameWhat it is
DEFAULT_LOOKThe geometry a canvas draws when nothing is declared.
lookFromThe axes a person chose, as a Look. A missing key, or one the section never offered, takes the manifest's answer.
SHAPEThe five shapes by name — circle, square, triangle, diamond, cross.
SHAPE_ORDERThe scale, in slot order, and it is four long. Plot calls this channel symbol and gives it its own legend, which is the tell that it is a peer of colour and not decoration.
SHAPE_OTHERWhat a category past the scale wears — cross, worn by no slot. The shape channel's --muted-foreground: it says not one of the four rather than repeating the first one.
SHAPE_PATHEach shape as an SVG path, for a legend key drawn in the DOM.

The simulation

NameWhat it is
DEFAULT_SIMThe coefficients a graph starts from.
simFromlookFrom's sibling: the same declared axes, read as forces.
adaptiveWhat a graph of a given size wants — repulsion down and friction up as the corpus grows, because bigger graphs otherwise never settle. Tuning, not level of detail, and it deliberately does not touch the space size: the box is the coordinate space a source's positions are expressed in, so resizing it by node count would move the index under the camera.
clusterRingSeeds each community on its own ring. The one thing a link-driven seed cannot supply when the grouping genuinely crosses the link structure — measured on the archive corpus, an angular hint decays to nothing by tick 50 while a ring holds, because it is a fixed geometry the simulation converges onto rather than a property it might find.
REHEAT0.35 — the energy a wake puts back into a converged layout. Enough to reorganise around a changed force, not so much that the picture you were reading is thrown away.

Bounded sources

NameWhat it is
memorySourceThe source for a host that already holds its arrays. Every consumer needs a source, including the ones bounding buys nothing for, so this one is written here once rather than at each call site differently.
BOUNDED_DEFAULTSThe defaults, each with the reason it is that number — limit is 20,000, the legibility ceiling rather than the renderer's, because it arrives first and is the one a reader actually meets.
shouldSliceWhether to slice at all. undefined from a source that cannot say cheaply means yes: an unknown corpus is more likely to be the large kind.
SUPERSEDEDThe sentinel a query settles with when the camera has already moved on. A symbol and not a message, because you moved on and the database said no are the two things a query loop must tell apart, and a string comparison against a thrown value goes stale with nothing failing.
isSupersededThe test for it. A caller treats it as its own abort, never as a failure.

Chrome you draw on top

Both stay out of the canvas for the same reason: each needs a policy only a product can write.

NameWhat it is
useGraphOverlaysThe grid and the overlay painting, composed with useGraph through GraphRootProvider.
GRID22 — dot spacing at zoom 1. The painter keeps the on-screen spacing inside [GRID, 2·GRID).
useGraphSelectionThe drag gesture that selects — marquee and lasso, with the modifiers adding to or subtracting from the live selection, and a tool to switch between them.
cursorChipThe drag's style, as CSSProperties.

Colour

NameWhat it is
resolveTokenA token resolved against a host element's live cascade — not a frozen copy of it.
toHexThe Rgba it returns, as the string a GPU config wants.

Every part's props are exported as an interface — GraphCanvasProps and GraphRootProviderProps — so a wrapper can take the same props without restating them.

On this page