Kanzo UI
The design of Kanzo UI

The graph view

How @kanzo-tech/graph is built — one Mosaic client on the page's coordinator over an Apache GraphAr graph @kanzo-tech/graphar-duckdb attached, uploaded to cosmos.gl once and greyed out by the crossfilter, a scale limit that is guarded and raised only by measurement, a canvas a keyboard and a screen reader can use, an Ark-shaped root over flat parts — and what would reverse each rule.

The graph page is what @kanzo-tech/graph ships. This page is how it is built and why.

Status: partly to be built. The tree takes a coordinator, treats a lost WebGL context and a failed read as terminal, does not abort a read, ships a pick's ids as SQL text, has no guard at 2²⁴ vertices and exposes the canvas to assistive technology as an unnamed div. The scale limit, recovery, a large pick and the canvas for assistive technology are decided and not built; the Breaking list at the end is what a consumer edits. Everything else on the page is the tree.

The contract is a catalog. @kanzo-tech/graphar-duckdb's attachGraph(engine, name, resolve, { entry }) attaches an Apache GraphAr graph to the page's DuckDB as the catalog name: a view per vertex type, "<name>"."<type>", holding _graphArVertexIndex — GraphAr's index within the type, 0 … count − 1 — and its properties; a view per edge type, "<name>"."<src_type>_<edge_type>_<dst_type>", holding _graphArSrcIndex and _graphArDstIndex into its two types; and the information files as relations, graphar_vertex_types(type, count, ordinal), graphar_edge_types(name, src_type, edge_type, dst_type, count, directed, ordinal), graphar_properties(type, property, data_type, is_primary, is_nullable, ordinal) and graphar_extra_info(key, value) (0.1.0-alpha.0, Kanzo-Tech/graphar 9cae19a). The graph reads the first three. The graph carries no position.

The reference is Cosmograph v2, read from its shipped 2.5.1 bundle — its code is CC-BY-NC, so only the architecture is borrowed: DuckDB-WASM, a Mosaic coordinator and cosmos.gl; points indexed 0 … N − 1 and links as index pairs, which is exactly the global id, src and dst; position as a view choice, with a GPU simulation from a seeded start when nothing places the points; a cluster column fed to setPointClusters; filtering as a query for the surviving indices, drawn as a GPU greyout; widgets and search as clients of the same coordinator. It has no map mode, and neither does this: lon/lat as x/y is what there is.

The graph reads SQL, through the page's coordinator

The host attaches the graph and names the catalog; GraphRoot reads it with SQL — the catalog for its JoinGraph, once, through readJoinGraph (below), the views for rows and links, one SELECT for a focused row, a LIMITed ILIKE for a search — every statement built from mosaic-sql's nodes and run through coordinator.query(query, { type: "arrow" }). A search is text a reader typed, so no statement is a template string: the nodes quote their own identifiers and literals. It imports nothing of graphar's or fossil's.

host                                                    @kanzo-tech/graph
engine() → DuckDB-WASM + coordinator                    parts     GraphCanvas · GraphLegend · GraphStatus · GraphCounts · GraphToolbar · GraphInspector · GraphSearch
attachGraph(engine, name, resolve) → "<name>".<type>    adapter   useGraph · GraphRoot · GraphRootProvider · useGraphContext · useGraphState
  "<name>".graphar_vertex_types · _edge_types · _properties · _chunks  render  typed arrays → cosmos.gl, uploaded once per graph
<GraphRoot from={name} coordinator filterBy>            core      source: SQL through the coordinator · client · load · store

Every read of the catalog is one function, @kanzo-tech/relations's readJoinGraph: the canvas and every other reader read the JoinGraph it answers, through the engine, so a page that draws the graph and a dashboard over it reads the catalog once, and nothing else in the library names a catalog relation or reads a footer. An edge's label is GraphAr's edge_type and its ends its src_type and dst_type, never parsed out of a view's name. What would reverse it: a reader that needs a fact of the graph at a moment readJoinGraph is not run — then the fact is added to what it answers, not read a second way.

It replaced fossil's manifest relations, fossil_tables, fossil_columns, fossil_annotations and fossil_stats, which @fossil-lang/corpus's attach made of a fossil/1 corpus. fossil's run moves to writing a GraphAr graph, and @fossil-lang/corpus goes with the format (fossil's /docs/design/graphar, its step F1), so the one reader of a graph's metadata is graphar-duckdb, in GraphAr's words, and this function reads those words. The JoinGraph it answers kept its shape where GraphAr states the same fact, and lost every field GraphAr does not state (below). What would reverse it: a graph format other than GraphAr — then a second CatalogReader reads that catalog, and nothing past it moves.

It replaced "no SQL in @kanzo-tech/graph", under which every read went through fossil's scan on a connection of its own: rows decoded on the main thread, a cache nobody else shared, and a second reader of the same bytes beside the coordinator every chart used. Through the engine there is one connection and one cache, and a chart and the graph asking the same question get one answer.

What would reverse it: a graph the page cannot reach as SQL — served only through an API with no DuckDB views — or a second host that has to draw without Mosaic. Then a reader comes back behind the loader, and the parts do not move.

Held by packages/graph/src/layers.test.ts, "builds statements only where it reads, writes none in a string, and imports nothing of graphar's or fossil's"; packages/graph/src/index.test.ts, "keeps the second reader deleted: no opener, no source, no slice"; packages/graph/src/core/store.test.ts, "reads the structure from GraphAr's relations, and every vertex by its global id"; packages/graph/src/core/catalog.test.ts, "takes an edge's label and its ends from graphar_edge_types, never from the view's name".

The global id is the graph's arithmetic

GraphAr numbers vertices within a type, and the graph needs one number per vertex across every type: it is the index into cosmos.gl's buffers, and mosaic's cross-type semi-join (dense_id IN (…)) needs one id space. Nothing writes it, and the graph computes it:

first(t)  = Σ count of the vertex types before t, in graphar_vertex_types.ordinal (the graph document's order)
dense_id  = CAST(first(t) + _graphArVertexIndex AS UINTEGER)
src, dst  = CAST(first(src_type) + _graphArSrcIndex AS UINTEGER), and the same for dst_type

Each type's table in the join graph is that projection over the type's view, and every reader reads the type through it — the rows, the links, the crossfilter's client, vertices and every relation a dashboard builds — so a clause published on dense_id means the same vertex everywhere. A lookup by id (the focused row, a label's text) reads the view by GraphAr's own index, id − first(t), which a chunk's statistics can skip on. The cast keeps the Arrow at four bytes an id, where GraphAr's int64 is eight; fossil's writer refuses more than 2³² vertices, so it fits. The sum runs over every vertex type, so the ids are 0 … V − 1 with no hole. The key is the graph's own constant, KEY in core/source.ts, and each type's first is a field of its JoinType: GraphAr states no global id to read it from.

It replaced the corpus's dense_id and "the key is read, not written down", when fossil wrote a global id into every vertex table and gave its column the address role, which the graph read. The arithmetic was measured by fossil before the format moved (/docs/design/graphar there, 2026-10-14: 2,000,000 edges read through it in DuckDB-WASM within the run-to-run spread of the stored id — 183 and 201 ms medians against 205 and 172 — and the Arrow at 16.0 MB, where GraphAr's int64 read uncast is 32.0 MB).

What would reverse it: a reader that needs the global id in the files, with no query between it and them — then a writer stores it beside GraphAr's index and the graph reads that column; or the arithmetic measured above a fifth of a read on a quiet host.

Held by packages/graph/src/core/catalog.test.ts, "numbers the types in the graph document's order, each from the counts before it", "gives each edge its ends as global ids, from GraphAr's per-type indices"; packages/graph/src/core/crossfilter.property.test.ts, whose oracle computes the id by hand from _graphArVertexIndex.

A vertex type is a node type, a list property is a value

Every GraphAr vertex type is drawn. Its identity — what a label falls back to, what the inspector shows as its IRI, and the type's identity role in the join graph — is its is_primary property, and the global id itself where the type has none.

A multi-valued property is a list property of the type that owns it (cardinality: list; fossil's decision, 2026-10-14): nickname is a VARCHAR[] column of Person, a row has all its nicknames, and no type of rows-per-value exists to join. What the graph does with a list column:

  • It is a column of its type, in the join graph and in vertices, and a search can name it (nickname:ana); the inspector shows its values, comma-separated.
  • It is not a field. It is not offered as a position, a group or a timeline column in the graph's settings, and fieldStats leaves it out (Mosaic's jsType has no word for a list), so no dashboard facet, axis or question is made of it. Its counts are never read, because a distinct count of lists would not be a count of the values a person sees.

It replaced the multi-valued property as a type of the join graph, reached by a hop named by the property through its own table, which fossil's fossil_tables made possible by naming the table's source and the column holding its key. A dashboard over the join graph no longer reaches laureate.nickname as a step; it is a list column of laureate.

What would reverse it: a reader who groups or filters by a value of a list — a facet of nicknames. Then the join graph states a list column as a type of its elements, UNNESTed and keyed by its owner's id, which is the table the old format wrote, made by the engine and not by the writer.

Held by packages/graph/src/core/catalog.test.ts, "reads a multi-valued property as a list column of the type that owns it, and counts none of a list", "makes a vertex type with no primary key a type of its own, labelled by its id"; packages/graph/src/parts/graph-section.test.tsx, "offers the corpus's numeric fields to the map, from the root that attached it"; packages/graph/src/parts/parts.test.tsx, "reads the focused vertex's row with one statement, grouped, and draws a host's extra fields after its values"; packages/graph/src/core/store.test.ts, "refuses a graph with no vertex as graph/nothing-to-draw".

What GraphAr does not carry

GraphAr states a type, its count, its properties' types, which one is the primary key and which may be null, and an edge type's ends; its chunks' footers add what fossil's writer counted. The JoinGraph readJoinGraph answers holds exactly those. What fossil's manifest added beyond them is gone from it, and from the join graph's types and columns, each falling back to what a reader did without it:

factwhere it is nowwhat the graph does without it
a column's role (address, identity, endpoint)the global id is the graph's address; is_primary is the identitystates both in the join graph itself
class and predicate IRIs, term typesthe RML mapping (mapping.ttl, named in graphar_extra_info)reads none; an identity is an identifier by its role
annotations — a label in a languagethe mapping, when fossil emits it with a reader for it (R-b)a type, an edge and a field are called by their names (labelOf falls back)
a datatype beyond GraphAr's — xsd:gYear over an int32the mapping (fossil's D4)a year is a number; a time is GraphAr's date or timestamp
a column's counts — nulls, distinct values, extent, commonest valueseach chunk's Parquet footer (below)useFieldStats reads the relation with one SUMMARIZE
candidate keysderivedthe global id and the primary key

What would reverse it: for datatypes, the host passing them from rudof's Mapping.model() after the first paint (fossil's D4) — then a year is a time again. For annotations, the mapping read the same way, after the first paint, by whoever reads RDF: the graph never parses it.

What a column holds is read from the footers

fossil's writer counts each column once, at write, into the Parquet footer of the chunk that holds it: nulls and the extent in the row groups' own Statistics, the distinct count and the commonest values with their share of the rows in the file's key_value_metadata under fossil:stats (JSON, keyed by column). attachGraph lists every vertex chunk in graphar_chunks by the name the engine reads it with, so readJoinGraph reads them with DuckDB's own parquet_kv_metadata(path) and parquet_metadata(path) — two statements, through the coordinator, after the catalog's — and no code that knows fossil's file layout.

  • A column is counted when exactly one chunk of its type states it. A distinct count does not combine across chunks, and fossil's writer makes one chunk of a table, so a column stated in two chunks (a table cut into several) is uncounted rather than summed wrongly.
  • A bound is kept for a number or a time, and only when every row group states it exact. A text's bound is truncated by the writer and never read; nulls a row group does not state leave the column uncounted. A list column is never counted.
  • A graph whose chunks state nothing is a graph with no counts, and one that a foreign writer made is exactly that: useFieldStats reads such a relation with one SUMMARIZE, as it reads any relation nothing counted. There is one path: the counts or the SUMMARIZE, never both for a column.
  • The vertices union carries none, because they were counted per type and do not combine.

It replaced fossil_stats, the manifest relation @fossil-lang/corpus made of the same counts. They moved from a relation fossil's reader built to the footers, so a Parquet reader that never heard of fossil can read them too; the graph reads them through the coordinator it already has.

What would reverse it: a Parquet release with table-level distinct counts or frequent values in its footer — then each fact is read from its field and fossil:stats goes; or the footer reads measured as a cost on the first paint, which moves them after it (a column is SUMMARIZEd until they arrive).

Held by packages/graph/src/core/catalog.test.ts, "reads what the writer counted from the chunks' footers: its distinct and commonest values, and the footer's nulls and extent", "counts nothing for a graph whose chunks state none", "counts nothing of a column that two chunks of its type state, because counts do not combine"; packages/graph/src/layers.test.ts, "builds statements only where it reads, writes none in a string, and imports nothing of graphar's or fossil's".

One catalog type, and the graph keeps none

The canvas reads a JoinGraph, and nothing in this package describes the graph a second time. Structure — the canvas's vertex, edge and property tables — and the 110-line bridge that renamed it into mosaic's JoinGraph (sourceKey to src, sqlType to type, first dropped, a vertices union invented on the way) were two descriptions of one catalog with a renamer between them. Relations holds the model and the reader contract; of the two facts only a drawing needs, first is a field of JoinType the reader fills, and identity is the column whose role is identity (the key where a type has none), already in the description, so the canvas reads type.first and identityOf(type) and keeps no table of its own. The snapshot's structure is that JoinGraph, and a part that selects from it selects the fields every other reader names. Structure, readCatalog, joinGraphOf and readJoinGraph left the package: a host that wants the model and no canvas builds it without installing @cosmos.gl/graph.

The failure scenario is the migration, not the duplicate. GraphAr numbers vertices per type from zero ("corresponding to the row number", the specification's wording as graphar's attach reads it) and the library numbers them graph-wide. With two descriptions, the same edge column meant "an index into the source type" to the resolver's src and "a graph id after adding first" to the canvas; one reader reconciles them in one place, first, and refuses a type whose ids have a gap.

What would reverse it: a fact the drawing needs and no second reader would ask for. Then it stays local to the canvas, as a field of the snapshot and not of the catalog; the test is whether a second reader would ask for it. A fact JoinGraph cannot carry is relations' reversal, and it grows the field, not a sibling type.

Not yet held: a layers.test.ts case that the package defines no catalog type and no second reader of the catalog, and a canvas test that loads from a JoinGraph made by the real reader over a vendored GraphAr fixture.

The graph is a client of the page's coordinator

GraphClient is a MosaicClient connected, through the engine, to the page's coordinator, like every chart beside it, and it reads vertices: every vertex table, as one relation. The coordinator hands it the page's predicate minus the graph's own clause, every clause published on another relation already crossed into vertices by the page's resolver (across relations), and it answers with the vertices that survive: SELECT dense_id … WHERE <predicate> per vertex table, UNION ALL. The predicate runs as Mosaic wrote it — nothing is translated. Which table a clause filters is the clause rule: a table that cannot answer a clause keeps none of its vertices, so only the tables that answer every clause are asked, under all of them, and a clause no table can answer keeps nothing (Crossfilter decided it, 11 October 2026). Before, a table that could not answer a clause was left unfiltered by it and a clause no table could answer was refused as graph/unfilterable; the timeline already greyed a type without its column, and two chips that looked alike in the bar greyed different things. The rule is held per table rather than by NULL in the union a chart reads vertices as: a clause that tests for NULL would keep a type without the column there, and a column whose type differs across tables is not in the union at all.

A filtered-out vertex is greyed, never hidden. The surviving ids become cosmos.gl's highlightedPointIndices and the links between two of them highlightedLinkIndices — cosmos.gl does not grey a link by itself. No buffer is uploaded, so a running layout is never reset by a brush.

The canvas's pick is published from the client. A lasso, a marquee or a click is a clauseSemiJoin on the vertex key, labelled with the selection's own label, whose source is the graph, so the crossfilter applies it to every client whose rows carry the key and skips it for the graph — a canvas that greyed all but a lasso of thirteen would hide the neighbourhood the reader was looking at. How the clause names its members, by the ids themselves or by a query, is a pick's. Retracting it where it was published — a chip's remove, the page's reset — calls the graph back, and the pick leaves the canvas too. A reconnect withdraws it from the selection it left and publishes it on the one it joins, so a clause never outlives the client it exempts.

Every other pick is a clause of its own, and picks intersect. A search's matches, a rule's findings, an answer's rows: each place that picks beside the canvas publishes through usePick(id), whose source is that place's for the root's whole life. The graph is one more client of it, so it greys out what the pick drops; two places intersect, and one place picking again replaces only its own clause. That is Selection.crossfilter() as Mosaic defines it — one source per publisher — and the page's chips read the same clauses. It replaced one selection shared by every pick, where a host's GraphSelect and the search's Select all went through the canvas's own select as an "external" selection: every pick replaced the last, so a rule's findings and a lasso could not narrow each other, and a pick from beside the canvas was exempt from the graph it was meant to grey.

A place reads the subset without its own clause. usePick(id).predicate() is Selection.predicate(client) for that place's source, so a search lists matches inside what every other clause keeps and a rule validates there, and neither is narrowed by what it picked itself — Mosaic's cross-filtering, where a brush never filters its own chart. A source is therefore a MosaicClient that is never connected: Mosaic exempts a clause by its clients, which a semi-join fills from its source when that source is a client. The search counts the matches outside the subset rather than hiding them, so a name that exists is never reported missing. What would reverse it: a reader who expects a search to look past the page's filters — then it reads predicate(null) and the footer counts what is inside instead.

It replaced "the graph listens to the selection; it is not a client", under which the graph translated mosaic-sql's nodes into scan's typed filter, refused every node it could not translate, ran the filter as a second scan, and hid the losers by uploading every position again with NaN — which snapped a running layout back to where the graph had put it. A client runs the predicate it is handed, and a mask is config.

What would reverse it: the greyout measured too slow at the largest scale — the index lists are built on the main thread — which moves them into a shader; or a reader who needs filtered vertices gone rather than grey, which brings hiding back as a size of zero, still with no position upload. Picks that intersect are reversed by readers asking for a union across places — these or those — which Mosaic answers with Selection.union(), a choice of the selection the page owns and not of the graph.

Held by packages/graph/src/core/client.ts, GraphClient, publish; packages/graph/src/core/store.test.ts, "asks the coordinator which vertices survive, and keeps every buffer it has", "runs the predicate as Mosaic wrote it on every table that has its columns, and keeps none of one that has not", "keeps nothing under a clause no vertex type can answer, and fails nothing", "publishes the reader's pick as a semi-join on dense_id, and is exempt from its own clause", "withdraws its pick from the crossfilter it leaves, and publishes it on the one it joins"; packages/graph/src/core/crossfilter.property.test.ts, "keeps none of it, whatever else the page holds (ans(c, v) = false)"; packages/graph/src/parts/parts.test.tsx, "publishes a place's pick as a clause of its own, which greys the rest of the graph out", "intersects with another place's pick and with the canvas's, one clause each", "is still the publisher of its clause after its holder mounts again, so picking replaces it"; packages/graph/src/render/renderer.test.ts, "greys out a filter with config alone: no upload, no render, links said out loud"; packages/graph/src/render/motion.test.ts, "is what the filter keeps, and the links whose two ends it keeps".

The clause rule: column predicates, or a semi-join on identity

A clause on the page's selection is one of two things. Either it is column predicates on the same relation — a brush, a pick, a search: hour BETWEEN 2 AND 5 names hour, and only a relation with an hour answers it. Or it is a semi-join on vertex identity: dense_id IN (3, 4), or dense_id IN (SELECT …) over any relation at all. A relation answers a clause when it has every column the clause names on it, and a subquery's columns are not among them — they are another relation's. So a semi-join on dense_id names dense_id alone and every vertex table answers it, whatever its subquery reads: a dashboard over an edge table, a rule's findings, an answer to a question — each filters the graph, and the graph's pick filters each of them, through the one identity they share.

clauseSemiJoin(key, ids | query, { source, label }) in @kanzo-tech/mosaic is the constructor of the second form, built from mosaic-sql's nodes; clauseColumns(filter) is the reading of both, and GraphClient applies it clause by clause. Neither names the graph: the key is the caller's, and a relation keyed by anything else crosses the same way. A column clause a vertex table cannot answer keeps none of that table.

A clause published on one relation reaches a client of another as the second form, written by the page. A dashboard over a relation filters its tiles by "Person.country", which vertices has not. The tiles publish into the page's one selection, each clause saying the relation it was published on (onRelation), and the graph says the one it reads (relation = "vertices"); the page's selection is crossfilterOver(joins) (in @kanzo-tech/relations) — Mosaic's crossfilter, with a resolver of ours through Mosaic's own extension point, new Selection(resolver) — which at predicate time hands a client of the same relation the clause as published and a client of another k IN (SELECT key FROM <relation> WHERE <every clause on it>), into the client's step of the clause's root type, or FALSE where it has none (one selection across relations). So the graph answers a dashboard by the rule it already had, and nothing in GraphClient names a dashboard. A timeline over the graph is ChartTimeline relation="vertices": its window is date BETWEEN … on vertices, the graph's own relation, so the graph reads it as published, and a type without a date holds NULL there and is greyed, as Cosmograph greys it (why); a dashboard's relation reads it through its root, with no rows when the root has no date. A clause is retracted where it is held, on the one selection: a chip's × is Mosaic's selection.reset([clause]), which calls the clause's source back.

Any panel is a client of the selection by the same protocol. useChartQuery is a panel's query filtered by the live selection and re-run when it changes, and says the relation it reads by relation or an enclosing MosaicClients; and FilterBar over the selection is the scope every client is under — a semi-join wears its publisher's label.

It replaced a graph that answered only column predicates, which read every column of every clause, subqueries included, so a set of vertices found in an edge table or another type named src and dst and was refused. The alternative was a second clause kind the graph alone understood; it would have filtered the graph and nothing else.

The page's resolver replaced the bridge, a crossfilter per dashboard joined to the page by a map, semiJoinOf(key, relation) through bridgeSelection, which itself replaced "a graph and a relation dashboard do not share a crossfilter". The bridge crossed correctly but cost a selection, a relay and a retraction path per group of clients, and a reset had to walk upstream to reach a clause where it was published. The alternatives were a dashboard-shaped case in GraphClient, which would have taught the graph one publisher's vocabulary; and one semi-join per tile clause, which is wrong on a joined relation, where a root with a row where a and b is not a root with a row where a and one where b — the resolver crosses every clause on one relation together for the same reason.

What would reverse it: an identity two graphs do not share — two graphs attached on one page, whose dense_ids collide — which would make the key a pair, (graph, dense_id), and the semi-join a tuple; or a relation whose rows carry a vertex's IRI and not its dense_id, measured common enough that joining through the identity column on every read costs more than carrying the id.

Held by packages/mosaic/src/clause.test.ts, "builds a semi-join on identity from keys, or from a statement that selects them", "names the key alone: a subquery's columns are another relation's"; packages/graph/src/core/store.test.ts, "answers a semi-join on dense_id on every table, whatever relation its subquery reads", "publishes the reader's pick as a semi-join on dense_id, and is exempt from its own clause", "lets the pick go when its clause is retracted where it was published"; packages/mosaic/src/clause.test.ts, "names a semi-join by its publisher's label, and does not count its members"; packages/relations/src/crossfilter.test.ts, "crosses a clause into another relation by the key of the step of its root's type", "keeps none of a relation with no step of the clause's type"; packages/ui/src/mosaic-public-api.test.ts, "reaches no underscore member of a Mosaic object in any shipped package"; packages/graph/src/core/one-selection.test.ts, "filters the graph by what the tiles' brushes keep, as the root vertices they keep", "filters the dashboard by the graph's pick, crossed into the relation by its root's key".

A relation's columns are named by their path, and the root's key is bare

Every column of a relation is named by its path from the root. relationQuery names a column by the hops that reach it — country on the root, knows.country one hop away, ^hasCreator.length back along an edge — because a join puts two types' columns side by side and a bare country would be ambiguous. The root's columns are on the empty path, so its key keeps its own name, dense_id and not Person.dense_id: what was once the one exception to an <alias>.<column> rule is now the rule. A relation's rows are its root's, keyed the way the root's table is, so the semi-join a relation publishes to the page — dense_id IN (SELECT dense_id FROM <relation> WHERE …) — names a column every vertex table answers, and a lasso's dense_id IN (3, 4) filters a joined relation exactly as it filters the root's table. Prefixed, the root's key would be a column no other relation has, and every crossing would need a map from one name to the other. The path is Malloy's dotted path from a source, and why a field is one is on Tasks; it replaced Person.country and Person2.country, which named a self-join's second step by a type and a number that were no path.

A host reaches a step's key by JoinResolver.steps(relation), never by spelling it. Each step is its path, its type and its key as the relation names it; the root is the first, at the empty path. relationSteps, relationIdentities and relationRootKey were three readings of that one list, and folded into it.

What would reverse it: a page whose types are keyed by different columns, so that the root's bare key no longer names anything the rest of the page is keyed by — then keeping the name buys nothing, and the root's key would be named by a path like every other column, with steps returning that name and the publishing code unchanged.

Held by packages/relations/src/relation.test.ts, "joins an out-hop through its edge table, and names each column by its path from the root"; packages/relations/src/crossfilter.test.ts, "names each step of a relation by its path from the root, with its key: the root's bare".

The canvas's tools edit the canvas's own clause

What is in full colour is what the page keeps, and the canvas's tools work on it without taking anyone else's clause. Each place that picks owns one clause on the page's crossfilter, and clauses intersect — Mosaic's model, where every interactor publishes one clause from one source and a crossfilter is their conjunction; and Tableau's, where selecting highlights and only a filter filters. The marquee, the lasso and a click publish the canvas's clause from the graph's client. A gesture acts on the canvas's own clause and nothing else's, which either keeps a set or leaves one out (modified in parts/gesture.ts):

GestureNo canvas clauseIt keeps IIt leaves out E
drag, clickkeeps Xkeeps Xkeeps X
⌘/Ctrl + dragkeeps Xkeeps I ∪ Xleaves out E ∖ X
Alt + dragleaves out X: NOT (key IN X)keeps I ∖ Xleaves out E ∪ X
Esca drag in progress, then the tool, then the canvas's clause; never another place's

So a search that kept the 40 airports in Spain is a selection the tools can work on: Alt and a lasso over Madrid publish not Madrid from the canvas, which intersects the search's and reads Spain less Madrid — and stays right when the search changes, because the canvas never copied what the search kept. ⌘ and a lasso over Portugal add nothing in full colour while the search holds, because a filter filters; GraphCounts says so — 20 hidden by other filters — and the search's chip is where it is let go.

Alt with no canvas clause was a copy, and is an exclusion (decided 11 October 2026, Crossfilter). Until then ⌘ and Alt with no canvas clause started from what was in full colour, so Alt over Madrid made the canvas's clause the 40 airports the search kept, less Madrid: the canvas computed another source's result and held it as its own, which went stale when the search changed. This reverses that part of the decision above; ⌘ with no clause keeps what it caught, which a filter still narrows.

Framing is of what is in full colour. frame() fits the camera to the canvas's clause within what the page keeps, or to what the page keeps when the canvas has no clause, or to the canvas's clause when the page keeps everything — and, when nothing is greyed out, to the whole graph, kept framed through a resize until the reader takes the camera. So a search, a rule or a dashboard's keep is framed with the button that frames a lasso, and with nothing kept that button is the old Fit: fit() was frame() over a page that greys nothing, and it is gone. frameSelection was renamed frame for the same reason; it never framed only a selection once a clause from elsewhere could be one.

The toolbar has one Frame, and nothing in it moves. It was two: Fit in the zoom cluster, and a Frame inside a selection group drawn only while something was greyed — a count, Frame, and a × for the canvas's own pick — placed between the tools and the zoom. Every lasso drew that group and pushed zoom and the transport sideways under the reader's hand, and the two buttons that frame were one command read in two states. Now the Frame sits in the camera's cluster, always, named for what it will frame — Frame everything, or Frame what the filters keep — and the count is a readout with no button in it, drawn on the side away from the anchor: first in a row, which the canvas's top-end corner grows leftward, last in a column, which grows downward. The × went because the canvas's pick is already a chip in the FilterBar and Escape drops it: one way to let go of a clause, its chip, as for every other place's. The reference is Figma and tldraw, which keep one zoom cluster in one fixed place and put a count beside it rather than inside it.

What would reverse it: a host that anchors the toolbar at the start edge, often enough that a count drawn first pushes the buttons it was meant to leave alone — then the order follows the anchor, as a prop. Or readers who look for a way to drop a lasso on the canvas rather than at its chip, which puts a × back on the count, not a second Frame.

What is kept is computed once. keptOf in core/kept.ts answers it — every clause on the page, the canvas's own included — and the snapshot's kept is the one reading: the renderer colours it, the labels name it, and the counts, the toolbar and frame() read it through store.visible(), which is kept narrowed to what has a position. A link is kept when its two ends are, so there is no link mask beside it: the timeline's window is a clause on vertex tables and arrives in the filter like any other. Three readings had drifted — the counts, the labels and the canvas each narrowed the filter to the pick on their own, or did not — and a kept vertex with no position was counted as a match and framed. Crossfilter defines kept and placed and the invariants the property tests in core/crossfilter.property.test.ts hold.

It replaced a canvas whose tools saw only their own pick, so a clause from the search, the rules or a dashboard greyed the graph out but could be neither added to, taken from, nor framed — a reader recording the product's demos could not zoom onto what a filter kept. The alternative was to absorb: ⌘ or Alt over a filtered page folds every clause into the canvas's and retracts their chips. It was turned down because a modifier key would erase other places' filters — a dashboard's brush gone because a lasso was drawn — and their chips are where the reader goes to undo them. It is Tableau's Keep only, an explicit action, if it is ever wanted.

What would reverse it: readers who ⌘-drag outside a filter expecting the points to join it, often enough that N hidden by other filters does not explain it. Then an explicit Keep only on the toolbar, not a modifier, turns what is in full colour into the canvas's clause.

Held by packages/graph/src/parts/gesture.test.ts, "keeps what ⌘ caught when the canvas has no clause, within what the other clauses keep", "leaves out what Alt caught when the canvas has no clause, and stays right when another clause changes", "takes from and adds back to a clause that leaves out, and clears it when it leaves nothing out", "escapes a drag, then the tool, then the canvas's clause, and never retracts another source's clause"; packages/graph/src/render/renderer.test.ts, "frames what is in full colour: the pick within what the filter keeps, else what the filter keeps", "colours what the tools act on: the canvas's full colour is store.visible(), filtered and picked"; packages/graph/src/parts/parts.test.tsx, "carries no count, and names the one Frame for what it frames", "frames what the filters keep, and GraphCounts says how many other filters hide"; packages/graph/src/render/renderer.test.ts, "stops re-framing when the reader takes the camera, and starts again with Frame"; packages/graph/src/core/crossfilter.property.test.ts, "leaves out what an excluding canvas clause names, whatever the other clauses become (invariant 9)".

ADR 0001 — the canvas holds the whole graph

fossil cites this decision by its number, so the label stays.

The canvas holds the whole graph. The links — one statement per relation — and the bound columns of every vertex type are read into arrays sized from graphar_vertex_types' count and uploaded to cosmos.gl once. Detail is fetched, not carried: a focused row, a label's text, a hover card and a search are statements of their own, read when needed. Identity is an id, and the id is the index: a vertex type is the contiguous range first … first + rows − 1 of the global id, so its answer in dense_id order is written into its own slice of every buffer and nothing is resolved row by row.

It rests on one premise: the graph is not edited. It is fossil's compiled output, so what was loaded stays true until the host attaches another graph.

What would reverse it: a graph whose first paint or memory passes a threshold agreed with the owner, or one past cosmos.gl's link addressing (see A dense id is a buffer index). Then a partial read comes in behind the loader — a WHERE on the statements it already runs — never a prop, and never a camera in the store.

Held by packages/graph/src/core/store.test.ts, "reads the structure from GraphAr's relations, and every vertex by its global id"; packages/graph/src/index.test.ts, "ships a canvas that owns the renderer, and neither load nor Loaded".

Position is a channel

x and y are bindings, Cosmograph's pointXBy and pointYBy. Both bound, the data places the points and nothing simulates — not simulate, not the transport, not a change of forces. Unbound, they start from a seeded Gaussian and cosmos.gl's force simulation runs on the GPU, unless simulate={false}. A layout over the data's positions only pushed them against the square's walls (vertical streaks at a map's edges, seen on OpenFlights), so the transport is disabled while they place the points, and binding the second axis stops a running layout before the new positions land. cluster feeds setPointClusters. fossil's writer stopped carrying a layout because a picture is a view's choice, not the graph's: the same graph is a map with lon/lat, and a force layout without them. How long the layout cools, the square it runs in and when it hides links follow the vertex count; Layout and camera has the rules and the gate that set them.

What would reverse it: for the layout over bound positions, a force that reads the data's positions as anchors — a link force between placed points that keeps each near its place — which would make a layout over a map a picture rather than streaks. For the binding itself, a layout worth computing offline again — a settle time past what a reader waits, at the scale that exists. Then fossil writes the columns and the host binds them, and nothing here changes.

Held by packages/graph/src/render/motion.test.ts, "runs when the positions are a start unless simulate says not, and never when they are data", "cools in 600 frames up to 200,000 vertices and 240 past them"; packages/graph/src/core/store.test.ts, "starts a layout from a seeded square when x and y are unbound, the same start every time", "draws the data's positions when x and y are bound, and leaves a table without them unplaced"; packages/graph/src/render/renderer.test.ts, "runs the layout by itself when x and y are unbound, in the square and with the decay for its size", "stands still when x and y are bound, whatever simulate says", "runs no layout over the data's positions: resume, restart and a change of forces start nothing", "stops a running layout as soon as the second axis is bound, before the geometry it asks for lands"; packages/graph/src/parts/parts.test.tsx, "offers no layout while the data places the points, and offers it again when an axis is unbound".

The viewer costs what cosmos.gl costs

At 200,000 vertices, measured on 2026-09-30 with graph-bench's measureViewer in a foreground window, the viewer's p95 frame interval was 9.0 ms against 9.3 ms for cosmos.gl alone on the same positions and camera path; the gate is 15%. The difference had been ours, and each cause is now a rule:

the causethe rule
every upload animated for cosmos.gl's default 800 mstransitionDuration: 0
buffers re-uploaded on every changepositions and links upload once per graph; the rest by kind
a filter re-uploaded every positiona filter is config: highlighted indices
a selection called render(), which walks every point and link in JSa state change is setConfigPartial
a hover re-rendered the canvas and read positions back from the GPUthe hover card re-renders; the renderer reports where the point is
camera events reached the storethe camera reaches nothing
painting allocated per link, and labels sorted every vertexarrays allocated once; labels picked in one pass
the canvas re-rendered on every setOptionsparts read the fields they show

What would reverse it: the same measurement outside 15% with the cost in our code. That adds a row here.

Held by packages/graph/src/render/renderer.test.ts, "constructs cosmos.gl with no simulation and no transition", "a camera move notifies nobody", "a selection sets config and calls no render and no setter"; packages/graph/src/parts/parts.test.tsx, "re-renders the card on a hover, and not the canvas".

The reference grid is one canvas, drawn when the camera moved

look.grid is a <canvas> under the surface, and the vignette is one static CSS layer over it. The canvas is drawn by overlays.ts in the frame's single paint, only when the view changed — where world (0, 0) is, how many pixels a unit is, the box and the pixel ratio, and the ink. A settled graph, a hover and a label cost it nothing. The dots sit on the graph's own space: spacing in packages/graph/src/render/grid.ts is the smallest 1, 2 or 5 times a power of ten whose step on screen is at least 22 px, so the step is always in [22, 55) px and zooming in subdivides, 5 → 2 → 1 → 0.5, at round numbers. Each dot is a square of whole device pixels, max(2, round(2 · ratio)) across and snapped to the device grid: the same shape everywhere, no antialiasing to cost or to fade, and one fillRect where a circle was a path. The ink is --input resolved against the canvas on mount and on a theme tick, never per frame. The CSS layer, its per-frame backgroundSize and backgroundPosition writes and the GRID constant are deleted, and there is no switch between the two.

The criterion was written before the numbers. Over the same camera path and with the grid on, every run's p50 and p95 frame interval is the no-grid run's to within 0.5 ms, and the grid is no worse than the CSS layer it replaces on p95 interval and on renderer CPU. It is a frame-interval criterion, because that is what a reader feels; CPU is reported beside it and not hidden by it.

Measured on 2026-10-14, a camera path of zoom in ×4 and a sweep (the one measureViewer drives), at 1280×720, 200 frames a run, nine runs an arm, the arms rotated so none is always first. Chromium 151 headless with SwiftShader, so no real GPU, on four vCPUs shared with a load average near 14: the figures are relative and the frame interval is capped at the 60 Hz vsync, 16.7 ms.

1280×720, 9 runs eachno gridCSS grid (deleted)canvas grid
p50 interval, every run16.7 ms16.7 ms16.7 ms
p95 interval, 1×16.7–16.8 ms in all 916.7–250 ms, 4 of 9 above 16.816.7–16.8 ms in all 9
p95 interval, 2×16.7–16.8 ms in all 916.7–416.7 ms, 1 of 9 above 16.816.7–16.8 ms in all 9
renderer CPU per frame, 1× · 2× (median)0.60 · 0.70 ms1.95 · 1.90 ms1.70 · 1.60 ms
script time per frame, 1× · 2× (median)0.01 ms0.07 · 0.06 ms0.26 · 0.25 ms

It meets the criterion and the grid stays on by default — on the performance question, and not on being seen in a light theme, below. The honest remainder: it is not free in CPU — about 1 ms of renderer time in a frame that moves the camera, of which 0.2 ms is the draw itself — and the GPU process's CPU is the one column this machine could not resolve (30–35 ms a frame in every arm, which is a software compositor redrawing the page). Inside the real scene, 2,000 vertices at 800×450, 1×, nine runs an arm over a 36-frame path, p50 was 16.7 ms in all three arms and the median p95 16.8, 16.7 and 16.8 ms (none, CSS, canvas); the first run of two arms had a 33–50 ms p95. Its CPU there is not reported: it differed eightfold between arms in a way no arm explains, and a previous arm's teardown, 130 s on this machine, runs in the same process. 200,000 vertices could not be measured here: the viewer never reached its first measured frame in eight minutes under SwiftShader, and 10,000 did not finish a 60-frame path in ten. 2,000 is the strictest scene available, since the grid is the largest share of a frame when the graph is the smallest, and a larger graph only dilutes it. measureGrid in docs/showcases/graph-bench/measure-viewer.ts is that comparison, grid off against on, to be run where there is a GPU; the isolated one behind the table needed the CSS layer for its third arm, so it went with it.

It is not in the scene, and that was the proposal. A fragment shader behind the points, in cosmos.gl's own pass, has no seam to stand in: 3.4 clears to transparent and draws links, then points, in a render pass it does not expose, and the public ways in are a second WebGL context — one of Chrome's sixteen per process, the budget releaseContext exists to protect, and one more layer for the compositor all the same — or replacing a private method of a pinned dependency. The canvas is one more layer, drawing what an in-scene pass would have.

Why the light theme lost the dots, and the one place it is cured. The grid canvas is painted in both modes and at both ratios, and in a light theme a software-composited Chromium showed none of it. cosmos.gl's frame clears to store.backgroundColor, and appearance sets that to [r, g, b, 0] for the greyout's sake. On a premultipliedAlpha: true canvas that is not a transparent pixel but an invalid one — red above its alpha — and the compositor adds it: the page behind the canvas in the workspace showcase measured rgb(255, 255, 255) where the theme's --background is rgb(251, 252, 251). A dark theme's RGB is small, so it added little: the dark page measured rgb(14, 22, 16) where --background is rgb(7, 11, 8), twice it, which nobody sees, and that was the whole of "visible in dark". A translucent link carried the background twice for the same reason.

The colour cannot change in cosmos.gl, so the clear is premultiplied where it is applied. premultiplyClear in render/webgl.ts wraps the canvas context's clearColor and multiplies the RGB by the alpha when the bound framebuffer is the canvas's own — [r, g, b, 0] reaches the GPU as [0, 0, 0, 0]. The config keeps [r, g, b, 0], so the greyout reads what it always read. Three ways were measured first, on cosmos.gl 3.4.0 in Chromium with SwiftShader, a page of rgb(251, 252, 251) (light) and rgb(20, 22, 21) (dark), a point greyed at opacity 0.4, and the pixel read from a screenshot at 1× and 2×:

a pixel of the screenshot, lightpage behindgreyed green pointtranslucent kept link
as it was255, 255, 255255, 255, 255255, 255, 255
premultipliedAlpha: false251, 252, 251238, 247, 241245, 246, 246
config clear [0, 0, 0, 0]251, 252, 251168, 187, 175221, 222, 224
premultiplied clear251, 252, 251220, 240, 227221, 222, 224
the model, α·g + (1 − α)·page251, 252, 251219, 240, 227
  • premultipliedAlpha: false is refused. The cleared pixel is then transparent, but the blend that writes a point's alpha is the premultiplied one, so a point at opacity α is composited by α twice: the greyed point above is 238 where the greyout's own model says 219, a quarter as far from the page as it was derived to be, and the contrast greyout.ts guarantees is gone. It also needs a device of the package's own, which is two dependencies on cosmos.gl's internals.
  • A config clear of [0, 0, 0, 0] is refused. cosmos.gl reads the same colour to decide whether a greyed point darkens or lightens (index.js:141), and a black one darkens: the light theme's greyed point is 168 where the model has 219, darker than the page, and the clear it asks for is the only channel — there is no separate colour for the greyout, and pointGreyoutColor is one flat colour for every point, which loses the hue.
  • Premultiplying in the config is the same refusal: rgb × 0 is [0, 0, 0, 0].

Seen through the real canvas, in the workspace showcase at 1× and 2× (the end-to-end test, with the clear put back as it was for the "before"): the page behind the canvas went from rgb(255, 255, 255) to rgb(251, 252, 251) in light and from rgb(14, 22, 16) to rgb(7, 11, 8) in dark, each now --background; the dots in --input seen in the screenshot went from 0–1 pixels to 932 (1×) and 10,510 (2×) in light. A greyed point in dark, read as the opacity its pixel implies, has the same median before and after (1.09 at 1×, 1.18 at 2×, over 51–69 points; the model's dark constants are a little short, and that does not move). In light the dense middle of the archive is links summing to rgb(255, 255, 255), a few levels above the page, as they did over the white one it was drawn on before; a greyed point there is read over that, so a real-canvas reading of its colour is not cleaner than the single point in the table, and is not asserted. The offscreen passes are left alone — cosmos.gl clears its simulation textures to sentinels such as [-1, 1, 0, 0], and a multiply would corrupt them, which is why the framebuffer is checked.

The vignette stays a CSS gradient. It is static: nothing writes its style, so it costs a layer that never repaints, and an in-shader one would not have been simpler without the shader.

What would reverse it: the bench on a real GPU showing a p95 interval difference between the grid on and off above its own run-to-run spread, which turns the default off and says so here; cosmos.gl exposing a hook before its points, which moves the grid into the scene and deletes the canvas; or a theme that needs the grid to read over the marks. The premultiplied clear is reversed by cosmos.gl taking the greyout's colour from a config of its own, or premultiplying its clear itself, which deletes premultiplyClear and leaves the config as it is; or by a cosmos.gl whose render passes stop going through gl.clearColor, which the composite test below turns red.

Held by packages/graph/src/render/grid.test.ts, "keeps the on-screen step inside [min, 2.5·min) at every zoom", "only ever subdivides when zooming in, and only ever by a rung of the ladder", "is a lattice anchored on world (0, 0), so the grid moves with the graph", "makes a dot a whole number of device pixels wide, scaled with the ratio, centred on its lattice point"; packages/graph/src/parts/parts.test.tsx, "draws the grid under the surface, so no dot lands on a point", "mounts no grid layer at all when look.grid is off"; packages/graph/src/render/webgl.test.ts, "premultiplies a translucent clear by its alpha", "leaves an offscreen pass alone: cosmos.gl clears its textures to sentinels"; packages/testing/e2e/grid.e2e.test.ts, "the reference grid", "the reference grid seen through the canvas".

The layers, and the reference each one copies

Each layer has a named reference whose shape it takes. A layer with no reference is a black box.

layerreferenceownsowns nothing of
core — the reads and the storeTanStack Query's QueryObserverwhat is read, once, and the state a reader selects fromReact, the DOM, cosmos.gl
client — the bridgeMosaic's MosaicClientthe crossfilter's predicate as surviving ids, and the clause the graph publishesthe coordinator's connection
rendercosmos.gl's own buffer APItyped arrays → cosmos.gl, one upload per changed kind, one render per framewhat to read
adapterTanStack Query's core/adapter split; Ark's root/provider/contextthe store's lifetime in React, and the context the parts readhow the graph loads
partsArk's flat parts over one contextthe canvas element, legend, toolbar, inspectorany product's policy

Every module stays under four hundred lines:

packages/graph/src/
├── core/                     no React; nothing here imports cosmos.gl
│   ├── source.ts             the catalog's statements: structure, columns, links, a row, titles, search
│   ├── client.ts             GraphClient — the crossfilter's survivors — and the clause the graph publishes
│   ├── load.ts               answers → buffers by dense_id; the seeded start; the layout's square
│   ├── channels.ts           Plot's bindings, and the columns they read
│   ├── kept.ts               what the filter keeps and what is in full colour — one rule, both readings
│   ├── store.ts              createGraph(options): subscribe · getSnapshot · setOptions
│   ├── state.ts              the options, the state a host selects from, and the store's shape
│   └── categories.ts         the categorical domain, fixed before the graph loads
├── render/
│   ├── renderer.ts           cosmos.gl's lifetime, dirty flags by kind, one render per frame
│   ├── motion.ts             how long a layout cools, when it hides links, whether it runs, which links are in full colour
│   ├── placement.ts · camera.ts   where bound positions sit in the square; who holds the camera
│   └── graph-looks · graph-model · graph-sim · obligations · css-color · webgl
├── react/                    use-graph · use-graph-state · graph-root · use-graph-prefs
├── parts/                    the canvas, legend, counts, toolbar, inspector, search, looks; overlays · gesture · shape-glyph
├── index.ts
└── section.ts                the ./section entry

What would reverse it: a layer whose reference stops describing what it does. Then either the page names a better reference, or the behaviour is in the wrong layer.

Held by packages/graph/src/layers.test.ts, "keeps every module under four hundred lines", "keeps the core free of React and of cosmos.gl".

The renderer uploads once, and renders once a frame

cosmos.gl 3.4 replaces a whole buffer per setter, so what changed is decided per kind of buffer:

  • A graph uploads positions and links once, and binding x or y uploads positions again. A binding, a look or a theme change uploads colours, sizes and shapes.
  • A filter and a state change upload nothing. The surviving ids, a selection and a focus are config — the ids are the indices — and call no render().
  • One frame renders once, and only for an upload. Every dirty flag is discharged in one requestAnimationFrame.
  • Nothing animates. transitionDuration is 0.
  • The layout runs by the rule above, from wherever the points are. A drag moves a node and holds nothing: there is no pinning, because a held node is a second state beside the selection that no panel can read, and an arrangement worth keeping is a placement.
  • The camera is cosmos.gl's. A zoom repaints the overlays and reaches no store; render/camera.ts owns who holds it, and Layout and camera says when it moves.
  • The arrangement is the store's. A destroyed renderer writes cosmos.gl's positions and the camera back with store.keep, and the next one uploads them and goes on as the layout was: model and view, the way cosmos.gl's own setPointPositions / getPointPositions split them. A canvas that is not shown holds no GPU context; a new geometry drops what was kept.

What would reverse it: cosmos.gl gaining a sub-range upload, so a binding could upload only what it changed; or a picture that needs a transition to be read, measured against the frames it costs. The arrangement goes back to the renderer if reading every position back on detach — one readPixels of the position texture — costs a switch of views more than a re-layout does.

Held by packages/graph/src/render/renderer.test.ts, "uploads positions and links once per corpus, and renders once", "repaints colours, sizes and shapes on a look change, and uploads no geometry", "starts and stops with the simulate prop, after the first load", "leaves a dragged node free, with or without a layout running", "leaves the points and the camera to the next canvas, which starts settled from them and runs nothing", "drops what it kept when the geometry changes, and the next canvas places the new one"; packages/graph/src/react/graph-root.test.tsx, "does not rebuild the renderer because a callback changed identity".

The React API is a store and an adapter

The graph's state changes outside React and at frame rate, so the core owns it as a store in TanStack Query's observer shape, and React subscribes:

  • The core is createGraph(options): subscribe, getSnapshot, setOptions and destroy. It knows nothing about React. The first subscriber connects its client through the engine and the last one lets it go.
  • The adapter is useGraph(props): useBaseQuery's three moves — create the store once in useState, subscribe with useSyncExternalStore, hand it the latest props with setOptions in an effect whose dependencies are the props themselves. Callbacks are read through a ref, so an inline onFailure is never a change of options.
  • The surface is Ark's. useGraph creates the api, GraphRoot is useGraph plus the context, GraphRootProvider takes an api a host built, and useGraphContext reads it — useDialog, DialogRoot, DialogRootProvider, useDialogContext.
  • The api is the commands, and the state is a selector. The api is stable for the root's life, and useGraphState(selector) reads a slice and re-renders only when it moves. status is the data's life and motion the layout's — two axes, Layout and camera.

What would reverse it: the store costing more than it buys — measured as renders per hover with and without useSyncExternalStore.

Held by packages/graph/src/core/store.test.ts, "keeps getSnapshot stable between notifications", "connects its client on the first subscriber and lets it go with the last, as StrictMode does twice", "is idle only once the graph is loaded and drawn, and has none without a corpus"; packages/graph/src/react/graph-root.test.tsx, "does not reach setOptions on a render that changed no prop", "keeps the api's identity across hovers, and re-renders a part only on the slice it selects"; packages/graph/src/index.test.ts, "is Ark's four: useGraph creates, GraphRoot provides, GraphRootProvider takes, useGraphContext reads".

The parts own the vocabulary; the host owns the policy

GraphRoot is a facade: the host names a catalog and an engine and a few props, and the parts under it read the root.

  • GraphCanvas — the element, and what the look declares around it: the grid under the points, a canvas drawn when the camera moved (The reference grid); the vignette, labels and hover card over them; and the marquee and lasso gesture. cosmos.gl clears to a transparent background and the element paints the colour, which is Cosmograph's arrangement: it keeps "the WebGL canvas transparent; the wrapper div carries the configured background". Nothing between the element and the points can show otherwise, and a grid over the canvas laid a lattice across a dense graph, one dot over every point beneath it. The RGB stays in the config at zero alpha, because cosmos.gl's greyout reads it to dim darker or lighter, and the clear is premultiplied where it is applied, so the canvas is transparent (the reference grid). Point blending writes alpha with one, one-minus-src-alpha, so a transparent clear composites correctly, and occlusion culling depends only on pointOpacity and highlighting. Held by packages/graph/src/parts/parts.test.tsx, "draws the grid under the surface, so no dot lands on a point"; packages/graph/src/render/graph-model.test.ts, "clears cosmos.gl transparent and keeps the background's RGB for the greyout"; packages/graph/src/render/webgl.test.ts, "clears the canvas to a colour the compositor can add: rgb at alpha 0 is transparent". What would reverse it: a theme that needs the grid to read over the marks.
  • GraphLegend — the categorical scale, or the vertex types when nothing is bound, with how many of each are drawn. Its domain is fixed before the graph loads, so a row never moves. Under bound positions a row is what the map places of its whole, and a category with no position at all moves into a muted No position group with its whole count, rather than reading 0: a map of CORDIS placed Organisation alone, and Project 0 read as missing data when it was a type with no place.
  • GraphStatus — where the graph is, in one word: Loading, Laying out with the percentage, Ready, Failed. It owns the fold of the two axes, status and motion, because a host that folded them itself read status alone and said "idle" over a running layout. What would reverse it: a host that needs the two axes apart in its footer, which useGraphState already gives it.
  • GraphCounts — what is drawn, in one sentence: "N nodes · E edges". It said what the page's filter keeps too ("X of N nodes match"), and so did the legend, a toolbar box and every semi-join chip: four figures for one thing on one screen. That figure is the FilterBar readout's now, once, at the start of the filter row — dc.js's dataCount, Kibana Discover's hit count — and this keeps what only it says: the edges both of whose ends are drawn, and M hidden by other filters. A footer carries it, which is why it is not the legend's last line. "Placed" is the verb only under bound positions, because there a vertex with no value in x or y really is hidden, not greyed: "P of N nodes placed · E of L edges". Both figures are drawn of total there, because the footer that mixed the graph's nodes with the drawn edges read "206.6K nodes · 0 edges" over a map with no edge to draw. A vertex is placed when it has both values; half a position is none. Held by packages/graph/src/parts/parts.test.tsx, "says how many of the corpus the map places, and how many edges it draws of all of them" and "says so when no edge has both ends on the map". What would reverse it: a placement that draws the unplaced vertices somewhere — by their links, say — at which point they are drawn and a third count says how.
  • GraphToolbar — how much is in full colour, the tools, zoom and Frame, and the layout's transport. The canvas's tools says why it has one Frame and why nothing in it moves.
  • GraphInspector — the focused vertex, Linkurious's inspector: headed by its title or its IRI's local name under its type, Locate beside it (reveal: frame it with its neighbours, and keep the camera there), the IRI under it with its copy button, then its row in two columns, in graphar_properties' order, grouped as values and dates, and a render prop for fields a product adds. Sections and rules, not a card: in a dock a card is a second border. It does nothing with the neighbourhood: a count per relation repeated what the canvas draws one click away, a "select neighbours" repeated what a click and Locate already select, and three icon buttons (zoom, focus, copy the row) read as a riddle — the row copy had no reader at all. Held by packages/graph/src/parts/parts.test.tsx, "heads with the IRI's local name when there is no title, prints the IRI under it with Copy, and lists no neighbours" and "locates the vertex, and offers nothing a click on the canvas already does". What would reverse it: a graph whose relations the canvas cannot draw — too many edges to load — where the panel is the only place a neighbourhood can be read.
  • GraphSearch — a palette in cmdk's shape, Raycast's and Linear's: a LIMITed ILIKE per pause in typing, with Bloom's type: and <column>:<value> prefixes, grouped by type and counted past the limit; Enter is reveal, and the root remembers it. In the page it is only a cue — a button that prints ⌘K, Shark's command trigger, never an inline list — and the palette is a full-height CommandDialog, because docked it was cramped and its results covered the panel. The key, the chips and the height are ui's, not the graph's (CommandDialog's hotkey, CommandInput's children): the graph keeps only what a graph knows — what a prefix is, the grouping, ⌘Enter. Empty, it is never a bare input: Recent, then every vertex type with its count, a pick becoming a type: chip — GitHub's palette scopes, the start a reader of an unfamiliar graph lacks. A match prints its identity's local name on the right, Linear's issue key, because titles repeat. Held by packages/graph/src/parts/parts.test.tsx, "opens and closes the palette on ⌘K or Ctrl+K", "turns a type: or column: word into a chip on Space, keeps free text as text, and takes the last chip back on Backspace" and "offers, empty, every vertex type with its count, and picking one makes it a chip". One palette per page is what lets the key go unarbitrated — what would reverse it is a page that needs two graphs' palettes at once, and then the arbitration is a provider, never module state.

The settings are not a part; they are the section. GRAPH_SECTION declares the picture, where the points come from and the forces, and @kanzo-tech/ui draws them — PreferencesSections namespace="graph" whole, or <Pref> by <Pref>. What the manifest cannot know, the root answers as the section's owner (GraphSection, a SectionProvider inside every root): the attached graph's columns for x-by, y-by and cluster-by, and the miniatures on the Marks, Edges and Placement cards. Two components stood here, GraphLooks and GraphPlacement, the second holding the placement as the host's state with a callback of its own; both were a second way to draw a setting. /docs/design/preferences has the model.

Placement is the person's, so a column outlives its graph. Stored with the preferences, a column reaches graphs that lack it, and the root reads it as unbound (carries, in the loaders) — the same rule resolvePref applies to a value its options no longer hold, applied where the options are known. What would reverse it: readers expecting each graph to remember its own placement, which would make it the host's state again — in the URL, per graph.

Held by packages/graph/src/core/store.test.ts, "reads a column the corpus does not carry as unbound, so a column chosen on another corpus places nothing"; packages/graph/src/parts/graph-section.test.tsx, "offers the corpus's numeric fields to the map, from the root that attached it".

A selection names its gesture, never its host's panel. VertexSelectionSource is the canvas's own gestures — marquee, lasso, node — and nothing else: a host's pick is not the canvas's selection but a clause of its own, named by the id it passes usePick. It was order and ask besides, and then external, which were a host's panels spelled into the renderer's type. What would reverse it: a part of this package that must tell two of the canvas's own gestures apart by more than the three.

Names are data on the root. The catalog names no category values, so the host passes categories on the root, where the legend, the hover card and the inspector all read it.

Policy stays out, through the root's callbacks — onSelect, onFocus and onFailure. No part takes a callback of its own. onFailure is the one required prop: unhandled, a browser with no WebGL context shows an empty box. It receives what was thrown, whole — a read's own error with its code, or a Problem: graph/no-webgl, graph/context-lost, graph/no-positions or graph/nothing-to-draw — so a host chooses what to show by code.

What would reverse it: a part a second host cannot use without a prop that encodes that host's policy. That part goes back to the call site and is deleted from the package.

Held by packages/graph/src/index.test.ts, "carry no callback but the root's"; packages/graph/src/parts/parts.test.tsx, "draws the domain before the graph has loaded, named by the root", "reads the focused vertex's row with one statement, grouped, and draws a host's extra fields after its values"; packages/graph/src/core/store.test.ts, "fixes the categorical domain before the graph loads".

Attaching is the host's

Where the graph is and what signs it are the host's, and so are attaching and detaching: attachGraph answers the attachment whose detach() gives the catalog back. The graph is handed a name and the page's one coordinator — one per page, because MosaicProvider makes its coordinator vgplot's active one and that setter is process-wide. Discovery workspace is the wiring, and the workspace showcase the reference composition: what it writes is the product's — the pairings of look and channels, the hall's name in the inspector, the panels — and nothing in it reads a renderer or a buffer.

The graph bounds the one wait it owns. The waits inside attachGraph are the host's, and the host awaits them before it names the catalog; the reads after that have a signal and no deadline (failure). cosmos.gl's ready never settles when no device can be made, so after 10 s the root fails, by the kernel's within and not a timer of the graph's own, with graph/no-webgl and data.after.

What would reverse it: a host that draws a graph on a second engine — two engines, two caches — which is the cost this design removed; or the composition needing a prop only one host passes, which is the parts rule above firing.

Held by packages/graph/package.json, //peers, //graphar; packages/graph/src/render/renderer.test.ts, "fails with graph/no-webgl and when the deadline fired, if WebGL exists but no device ever comes up".

The public surface

the adapter        GraphRoot · GraphRootProvider · useGraph · useGraphContext · useGraphState
the parts          GraphCanvas · GraphLegend · GraphStatus · GraphCounts · GraphToolbar · GraphInspector
                   GraphSearch
picking            usePick
the picture        lookFrom · simFrom · useGraphPrefs
types              GraphRootProps · GraphRootProviderProps · UseGraphProps · GraphApi · GraphCanvasProps
                   GraphLegendProps · GraphStatusProps · GraphCountsProps · GraphToolbarProps · GraphInspectorProps
                   GraphSearchProps · VertexPick
                   GraphCanvasTranslations · GraphLegendTranslations · GraphCountsTranslations
                   GraphStatusTranslations · GraphToolbarTranslations · GraphInspectorTranslations
                   GraphSearchTranslations
                   GraphState · DataStatus · Drawn · VertexDetail
                   Channels · Placement · Look · LookPatch · Shape · Sim · Motion · VertexSelection · VertexSelectionSource
                   Tool · GraphCommands · VertexId
./section          GRAPH_SECTION

GraphRoot takes from, engine, filterBy, fill, symbol, stroke, r, title, x, y, cluster, categories, look, sim, simulate, onFailure, onSelect and onFocus.

What would reverse it: a host that has to reach past the parts to the renderer or the loaded arrays. Then the name comes back, and the part that failed it is the first thing to look at.

Held by packages/graph/src/index.test.ts, "is exactly this list, and grows only by somebody writing a name into it", "keeps the canvas's own steps internal".

The parts are under the guards

@kanzo-tech/graph declares tailwind-variants, so guard-corpus.ts scans it as it scans ui and ai, and every part renders through ark.*. The corpus had admitted graph on a judgement that expired by its own words — one file writing classes by hand, two literal strings; it expires the moment that file grows — and by 13 October 2026 the parts wrote their appearance in dozens of classNames with no recipe, no asChild, a physical left-0 and a data-slot written before the spread, none of it seen by the guards that hold every other package. Joining the corpus by the corpus's own rule was the fix, not a wider exception, and it found what was there: a physical offset, a slot a caller's spread could erase, and a module registering a listener without "use client".

So each part writes its appearance as a tv() recipe, renders through ark with the props of its element and asChild, and takes every word it writes as translations (one prop translates) — the canvas's Loading the graph…, the toolbar's names, 5K nodes · 8K edges as patterns over its figures. Where a part's surface is a ui part's, it composes that part: the legend is a Card, the status a Badge, the toolbar's clusters ButtonGroups, the search's cue a Button whose props it takes.

One case does not fit the recipe, and is the case the rule already allows. The labels and the hover card are placed by overlays.ts every frame, at cosmos.gl's screen coordinates — a corner, not a reading direction — so their origin is inline, beside the transform the renderer writes, and does not mirror under dir="rtl".

What would reverse it: a part that cannot be written with a recipe at all — a canvas overlay whose whole appearance is the renderer's every frame. Then that part's style is inline and computed, which the guards already allow, and the part stays under them.

Held by packages/ui/src/guard-corpus.ts, ROOTS; packages/ui/src/logical-properties.test.ts, "uses no physical direction utility outside the reviewed allowlist"; packages/ui/src/data-slot.test.tsx, "writes data-slot after the spread, so a caller's cannot erase it".

Where the points sit in cosmos.gl's square

cosmos.gl simulates in [0, spaceSize]²: gravity pulls toward its centre and many-body bins points on a grid over it. The square is decided in one place, never written where it is used. Unbound, it is the layout's own — spaceFor, 4,096 up to 200,000 vertices and 8,192 above, measured. Bound, it is the data's extent, centred in the square at its own scale — an extent left where it was contracts toward a corner the moment a layout runs over it — and scaled down only past half the device's MAX_TEXTURE_SIZE, where cosmos.gl would shrink the square under the positions anyway.

One case does not fit. cosmos.gl's zoom floor is 1e-3, and framing a five-million-vertex extent at 800×600 wants 0.000151, so only part of it fits on screen.

What would reverse it: cosmos.gl making spaceSize init-only, or a simulation whose centre is a parameter rather than the square's middle — then the placement is a config value and not an upload.

Held by packages/graph/src/coordinate-box.test.ts, "never writes a number where cosmos.gl asks what the space is", "declares no coordinate-space constant of its own"; packages/graph/src/core/load.ts, spaceFor; packages/graph/src/render/renderer.test.ts, "centres bound positions' extent in it, at the data's own scale", "scales the extent down only past the box the device simulates in", "frames bound positions' extent before it draws anything, and only once".

A dense id is a buffer index

VertexId is a number: the global id, numbering every vertex of the graph 0 … V − 1, each vertex type one contiguous range. It is the index into every buffer, the value a selection publishes, and the key the inspector reads by.

The limit is cosmos.gl's, and it is guarded. It addresses a link's ends with a Float32Array, exact up to 2²⁴ — about 16.7 million ids. Past it a link's end rounds to a neighbouring id, so the graph refuses the graph as graph/too-large and does not draw a wrong one (the scale limit). A graph near it is the day ADR 0001's partial read arrives, not a day to widen a type.

What would reverse it: a graph that breaks density — ids with holes, or a table that is not one range — which brings back a map built once at load; or a graph past 2²⁴ vertices that a measured plan shows the canvas can address.

Held by packages/graph/src/core/source.ts, Float32Array; packages/graph/src/core/store.test.ts, "reads the structure from GraphAr's relations, and every vertex by its global id".

A look is form, and a channel is a binding

Look is form and carries no encoding. What a channel carries is bound on the root, under Plot's names and Plot's rule that a CSS colour is a constant and anything else is a column: fill, symbol, r, stroke, title, and the position channels x, y and cluster. symbol stands beside fill, so identity may be drawn as colour, as shape or as both. stroke absent tints each link by the vertex it leaves; r absent is degree.

A binding is a column read. fill, r and cluster as columns are read where a table has them. fill as a constant and stroke are never read, and symbol is read only as the one categorical column when fill is a constant or absent. isColour is the constant test, narrowed to var(…), a hex and the colour functions, so a bare word is always a column. A theme may not rebind an encoding — Vega-Lite's config rule.

The case that does not fit. Ink's radius floor exists because it spends shape on identity and size on degree at once, so that obligation is graded against the composition.

A radius is screen pixels, scalePointsOnZoom: false, which is cosmos.gl 3.4's default and Cosmograph's. Sizing in graph space was measured on 2026-10-08 at 327,588 vertices and 913,221 links in the default look, framed whole: zoomed out, the points go under a pixel and the link layer covers them, so the disc reads as link colour with no type mix left in it, where pixel sizes keep the points visible through the links. Without links it is the better picture, overlap reading as density, but links are drawn by default. What would reverse it: a graph framed whole with links drawn where points in graph space stay visible, or a density control that makes overlap legible at pixel sizes.

What this does not touch. The graph holds one categorical column, and it authors no colour; it reads the page's categorical scale.

What would reverse it: the composition check never firing — if every host that binds symbol also asks for the form bundled with it, the pairing was the abstraction.

Held by packages/graph/src/render/graph-looks.ts, Look; packages/graph/src/core/channels.ts, Channels, bindingOf; packages/graph/src/render/obligations.ts, gradeComposition; packages/graph/src/core/store.test.ts, "sizes by degree when r is unbound, and by the column when it is bound", "colours by vertex type unless fill binds a column".

A look declares what it changes

A look declares only what it moves from the shared defaults, and the preferences panel offers the axes a reader can name — marks, edges, additive-links, labels, vignette — which recover the three shipped looks and combine into others. edges and labels are Cosmograph's configuration read as choices: showLinks and curvedLinks as Hidden, Straight or Curved, and the hovered, top and dynamic labels as one ordered scale, None to All, because a reader asks how much text rather than which of three switches. additive-links stays its own switch: it is how links composite, not the path they take, and Nebula is additive and straight.

fieldnebulaatlasinknebula → atlas
point radius2 → 82.2 → 94 → 13+10% / +12.5%
link opacity0.420.450.28+7%
link width0.60.70.5+0.1 px
link fade200–1400220–1500180–1200±10%
link curve00.120straight → bowed
additive linksyesnonoon → off
label budget142640×1.9
vignetteyesnonoon → off

Six of the ten fields separate Nebula from Atlas by 7–17%, below a luminance JND of 6.5–11.3 ΔL* (a range this repository quotes without naming its source: to be cited or dropped, and the conclusion rests on neither until then). Ink is a real form. An empty look is Atlas.

The three names are gone; the axes are the whole model. They lived on for a while as presets over the axes, offered as cards above them, and the panel repeated itself: a preset set the axes, an axis un-set the preset, and "Custom" was where most readers ended. Cosmograph's configuration and Gephi Lite's appearance panel offer the axes alone with good defaults, and so does the section. The three pictures stay reachable as axis values, which is what the test below holds. With no names, a host has nothing to pair channels with, and that is right: a look is form, and which column wears colour or shape is a binding the host makes once.

What would reverse it: a reader telling Nebula from Atlas in a blind pairing on those six fields, or readers asking for a named starting point the axes' defaults do not give them.

Held by packages/graph/src/section.test.ts, "still draws the three pictures the three names drew"; packages/graph/src/render/graph-looks.ts, lookFrom.

What is still open

  • Names for a channel's values. The catalog names no categories, so the host passes categories. If GraphAr's catalog ever carries them, the catalog's win.
  • A column two types share. A column clause carries no table, so a filter on name filters every vertex type that has one — a WHERE over a union. A host reading it wrongly would reopen it; a clause that means one type is a semi-join, whose subquery names it. And a column whose type differs across tables is absent from the vertices union, so the timeline's list of dates does not offer it and nothing says why; whether the reader reports it is relations' to decide.

Production-grade axes

AxisWhat this page commits to
Failure and recoveryEvery read has the load's signal and the engine interrupts it; retry(); a lost context restored inside a bound; a failed filter is a failure; one code per cause, and graph/too-large before any wrong picture
Security and privacyStatements are built from nodes and carry no literal in a failure (the engine); a pick's ids stay out of SQL text above K; the graph does no network I/O of its own; the stored placement is a column name read only against the attached graph, and a host that syncs preferences to a server syncs those names too
Performance and sizeThe proven limit stated, the guard at 2²⁴, the 1M / 5M plan with criteria set before the runs; the peer's cost reported beside the gauge; nothing promised between the tiers
Stable API, accessibility, testsOne catalog type; a role, a name, a keyboard model and a reduced-motion rule for the canvas, committed and not yet audited; a test corpus above 20 vertices and a conformance double

References, and how far each was read

SourceStatusUsed for
cosmos.gl 3.4.0 package.json, dist/config.d.ts, dist/index.jsreadthe buffer API, the Float32Array links, no webglcontext* handler
WAI-ARIA Graphics Module 1.0readgraphics-document as the role this widget's reversal names
WCAG 2.2, SC 2.2.2 Pause, Stop, Hidereadthe pause mechanism that cannot be a toolbar a host omits
WCAG 2.2, SC 1.1.1, 1.4.1, 1.4.13, 2.1.1, 2.4.7, 2.4.11, 2.5.1, 2.5.7, 2.5.8unverifiedcited from the standard, not re-read for this page
MDN, webglcontextlostreadthe event; it does not say what restores a context
WebGL specification, context loss and restoration; Khronos wiki on handling itunverifiedthe prevent-default and rebuild sequence; the wiki returned a redirect
Heer and Moritz, Mosaic (TVCG 2024)cited by crossfilterthe client and coordinator model
Sigma.js, graphology, d3-force; Jacomy et al. on ForceAtlas2not readnamed only to say no comparison is recorded

Breaking, in the consumer's words

  • GraphRoot and useGraph take engine, not coordinator; one engine() serves the graph, the charts and Ask.
  • GraphError is gone; onFailure receives a Problem, and a host narrows with isProblem and reads code. graph/nothing-to-draw carries data.reason, "no-vertex" or "no-address", and graph/too-large is new.
  • A graph of more than 16,777,216 vertices is refused, where it used to draw links attached to the wrong vertices.
  • readJoinGraph moved to @kanzo-tech/relations; the snapshot's structure is a JoinGraph, whose vertex types are its types without of, each with first and rows.
  • retry() is on the api. A failure whose status is not failed is a degradation: do not blank the canvas on it.
  • The canvas is a focusable role="application" with the keys above; a host that bound F, P, [ or ] on an ancestor loses them while the canvas has focus.
  • A host that kept the graph's window.__KANZO_TESTING__ hook for its end-to-end tests reads where the canvas draws off its element, screenOf(vertex) and screenAt({ x, y }): the global is gone.
  • keasy's own run codes leave the graph/ area, which the package owns.

On this page