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 · storeEvery 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_typeEach 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
fieldStatsleaves it out (Mosaic'sjsTypehas 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:
| fact | where it is now | what the graph does without it |
|---|---|---|
a column's role (address, identity, endpoint) | the global id is the graph's address; is_primary is the identity | states both in the join graph itself |
| class and predicate IRIs, term types | the RML mapping (mapping.ttl, named in graphar_extra_info) | reads none; an identity is an identifier by its role |
| annotations — a label in a language | the 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 int32 | the 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 values | each chunk's Parquet footer (below) | useFieldStats reads the relation with one SUMMARIZE |
| candidate keys | derived | the 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:
useFieldStatsreads such a relation with oneSUMMARIZE, as it reads any relation nothing counted. There is one path: the counts or theSUMMARIZE, never both for a column. - The
verticesunion 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):
| Gesture | No canvas clause | It keeps I | It leaves out E |
|---|---|---|---|
| drag, click | keeps X | keeps X | keeps X |
| ⌘/Ctrl + drag | keeps X | keeps I ∪ X | leaves out E ∖ X |
| Alt + drag | leaves out X: NOT (key IN X) | keeps I ∖ X | leaves out E ∪ X |
| Esc | a 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 cause | the rule |
|---|---|
| every upload animated for cosmos.gl's default 800 ms | transitionDuration: 0 |
| buffers re-uploaded on every change | positions and links upload once per graph; the rest by kind |
| a filter re-uploaded every position | a filter is config: highlighted indices |
a selection called render(), which walks every point and link in JS | a state change is setConfigPartial |
| a hover re-rendered the canvas and read positions back from the GPU | the hover card re-renders; the renderer reports where the point is |
| camera events reached the store | the camera reaches nothing |
| painting allocated per link, and labels sorted every vertex | arrays allocated once; labels picked in one pass |
the canvas re-rendered on every setOptions | parts 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 each | no grid | CSS grid (deleted) | canvas grid |
|---|---|---|---|
| p50 interval, every run | 16.7 ms | 16.7 ms | 16.7 ms |
| p95 interval, 1× | 16.7–16.8 ms in all 9 | 16.7–250 ms, 4 of 9 above 16.8 | 16.7–16.8 ms in all 9 |
| p95 interval, 2× | 16.7–16.8 ms in all 9 | 16.7–416.7 ms, 1 of 9 above 16.8 | 16.7–16.8 ms in all 9 |
| renderer CPU per frame, 1× · 2× (median) | 0.60 · 0.70 ms | 1.95 · 1.90 ms | 1.70 · 1.60 ms |
| script time per frame, 1× · 2× (median) | 0.01 ms | 0.07 · 0.06 ms | 0.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, light | page behind | greyed green point | translucent kept link |
|---|---|---|---|
| as it was | 255, 255, 255 | 255, 255, 255 | 255, 255, 255 |
premultipliedAlpha: false | 251, 252, 251 | 238, 247, 241 | 245, 246, 246 |
config clear [0, 0, 0, 0] | 251, 252, 251 | 168, 187, 175 | 221, 222, 224 |
| premultiplied clear | 251, 252, 251 | 220, 240, 227 | 221, 222, 224 |
the model, α·g + (1 − α)·page | 251, 252, 251 | 219, 240, 227 |
premultipliedAlpha: falseis 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 contrastgreyout.tsguarantees 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, andpointGreyoutColoris one flat colour for every point, which loses the hue. - Premultiplying in the config is the same refusal:
rgb × 0is[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.
| layer | reference | owns | owns nothing of |
|---|---|---|---|
| core — the reads and the store | TanStack Query's QueryObserver | what is read, once, and the state a reader selects from | React, the DOM, cosmos.gl |
| client — the bridge | Mosaic's MosaicClient | the crossfilter's predicate as surviving ids, and the clause the graph publishes | the coordinator's connection |
| render | cosmos.gl's own buffer API | typed arrays → cosmos.gl, one upload per changed kind, one render per frame | what to read |
| adapter | TanStack Query's core/adapter split; Ark's root/provider/context | the store's lifetime in React, and the context the parts read | how the graph loads |
| parts | Ark's flat parts over one context | the canvas element, legend, toolbar, inspector | any 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 entryWhat 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
xoryuploads 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.
transitionDurationis 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.tsowns 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 ownsetPointPositions/getPointPositionssplit 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,setOptionsanddestroy. 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 inuseState, subscribe withuseSyncExternalStore, hand it the latest props withsetOptionsin an effect whose dependencies are the props themselves. Callbacks are read through a ref, so an inlineonFailureis never a change of options. - The surface is Ark's.
useGraphcreates the api,GraphRootisuseGraphplus the context,GraphRootProvidertakes an api a host built, anduseGraphContextreads 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.statusis the data's life andmotionthe 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 withone, one-minus-src-alpha, so a transparent clear composites correctly, and occlusion culling depends only onpointOpacityand highlighting. Held bypackages/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, andProject 0read 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,statusandmotion, because a host that folded them itself readstatusalone and said "idle" over a running layout. What would reverse it: a host that needs the two axes apart in its footer, whichuseGraphStatealready 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 theFilterBarreadout's now, once, at the start of the filter row — dc.js'sdataCount, 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 inxoryreally 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 bypackages/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, ingraphar_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 bypackages/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: aLIMITedILIKEper pause in typing, with Bloom'stype:and<column>:<value>prefixes, grouped by type and counted past the limit; Enter isreveal, 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-heightCommandDialog, because docked it was cramped and its results covered the panel. The key, the chips and the height areui's, not the graph's (CommandDialog'shotkey,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 atype: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 bypackages/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_SECTIONGraphRoot 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.
| field | nebula | atlas | ink | nebula → atlas |
|---|---|---|---|---|
| point radius | 2 → 8 | 2.2 → 9 | 4 → 13 | +10% / +12.5% |
| link opacity | 0.42 | 0.45 | 0.28 | +7% |
| link width | 0.6 | 0.7 | 0.5 | +0.1 px |
| link fade | 200–1400 | 220–1500 | 180–1200 | ±10% |
| link curve | 0 | 0.12 | 0 | straight → bowed |
| additive links | yes | no | no | on → off |
| label budget | 14 | 26 | 40 | ×1.9 |
| vignette | yes | no | no | on → 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
namefilters every vertex type that has one — aWHEREover 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 theverticesunion, 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
| Axis | What this page commits to |
|---|---|
| Failure and recovery | Every 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 privacy | Statements 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 size | The 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, tests | One 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
| Source | Status | Used for |
|---|---|---|
cosmos.gl 3.4.0 package.json, dist/config.d.ts, dist/index.js | read | the buffer API, the Float32Array links, no webglcontext* handler |
| WAI-ARIA Graphics Module 1.0 | read | graphics-document as the role this widget's reversal names |
| WCAG 2.2, SC 2.2.2 Pause, Stop, Hide | read | the 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.8 | unverified | cited from the standard, not re-read for this page |
MDN, webglcontextlost | read | the event; it does not say what restores a context |
| WebGL specification, context loss and restoration; Khronos wiki on handling it | unverified | the prevent-default and rebuild sequence; the wiki returned a redirect |
| Heer and Moritz, Mosaic (TVCG 2024) | cited by crossfilter | the client and coordinator model |
| Sigma.js, graphology, d3-force; Jacomy et al. on ForceAtlas2 | not read | named only to say no comparison is recorded |
Breaking, in the consumer's words
GraphRootanduseGraphtakeengine, notcoordinator; oneengine()serves the graph, the charts and Ask.GraphErroris gone;onFailurereceives aProblem, and a host narrows withisProblemand readscode.graph/nothing-to-drawcarriesdata.reason,"no-vertex"or"no-address", andgraph/too-largeis new.- A graph of more than 16,777,216 vertices is refused, where it used to draw links attached to the wrong vertices.
readJoinGraphmoved to@kanzo-tech/relations; the snapshot'sstructureis aJoinGraph, whose vertex types are itstypeswithoutof, each withfirstandrows.retry()is on the api. A failure whosestatusis notfailedis a degradation: do not blank the canvas on it.- The canvas is a focusable
role="application"with the keys above; a host that boundF,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)andscreenAt({ x, y }): the global is gone. - keasy's own run codes leave the
graph/area, which the package owns.
Preference or theme
One rule decides whether a value is the person's or the brand's. The declaration lists only preferences, so a value the theme owns never gets a control.
Hardening the graph view
What @kanzo-tech/graph does so that a graph too large to draw exactly, a failed read, a lost WebGL context, a large pick or a person without a pointer or sight does not become a wrong picture, a dead canvas or an inaccessible one.