Graph
The graph view over an Apache GraphAr graph — attached to the page's DuckDB, read on the page's engine and filtered as one Mosaic client beside the charts, laid out and drawn with cosmos.gl on the GPU.
@kanzo-tech/graph draws an Apache GraphAr graph — what fossil
compiles a program to. The host attaches it to the page's DuckDB with @kanzo-tech/graphar-duckdb's
attachGraph, under a catalog name; GraphRoot reads that catalog on the page's engine — its own
reads with their signal, so Stop stops the statement, and its filter as one Mosaic client like every
chart beside it — and draws the whole graph with cosmos.gl. It imports
nothing of graphar's or fossil's: the contract is the catalog.
- Contract
- Field report
- Tag
- Beast
- Member
- Region
That is the discovery workspace in miniature — one root, the canvas with its toolbar and legend, an Info panel, a Settings panel and the counts in the footer. Each piece is taken apart below.
Installation
pnpm add @kanzo-tech/graph @cosmos.gl/graph @kanzo-tech/mosaic @kanzo-tech/ui lucide-reactIts stylesheet entry goes after @kanzo-tech/ui's. The canvas is WebGL, but the palette, the
inspector and the legend are utilities, and a utility only this package writes is generated only
when something scans it:
@import "tailwindcss";
@import "@kanzo-tech/ui/tailwind.css";
@import "@kanzo-tech/graph/tailwind.css";Every peer is required: cosmos.gl draws and lays out, @kanzo-tech/mosaic is the page's engine
and crossfilter, and the parts are built from @kanzo-tech/ui and its icons. The host attaches the
graph with @kanzo-tech/graphar-duckdb's attachGraph, which attaches a catalog and answers the
attachment that detaches it; the graph never imports it.
It is a sibling of @kanzo-tech/ui, not part of it. The first admission
rule is domain-free — nothing about RDF / SHACL / graphs / auth,
and a renderer with a required WebGL peer would change what ui is. A separate package is the same
answer palette and theme already got.
Usage
import { attachGraph } from "@kanzo-tech/graphar-duckdb";
import { engine } from "@kanzo-tech/mosaic/engine";
import { GraphCanvas, GraphLegend, GraphRoot, GraphToolbar } from "@kanzo-tech/graph";const e = await engine();
// views "archive"."<type>", graphar_vertex_types, graphar_edge_types, graphar_properties
await attachGraph(e, "archive", (paths) => paths.map((p) => `${url}/${p}`), { entry: "fossil.graph.yml" });
<GraphRoot from="archive" engine={e} r="degree" title="label" onFailure={setFailure}>
<GraphCanvas>
<GraphToolbar className="absolute end-2 top-2" />
<GraphLegend className="absolute start-2 bottom-2" />
</GraphCanvas>
</GraphRoot>;Attaching is the host's, and so is detaching: attachGraph answers the attachment whose detach() gives the catalog
back. from={null} is no graph yet. The canvas is mounted either way and waits over an empty
surface, because a canvas gated on its first answer has no camera to ask with.
Discovery workspace is the whole wiring — the engine, one
crossfilter, the graph and the charts.
Anatomy
GraphRoot the state and the commands; renders no element
├── GraphCanvas the element: points, links, grid, labels, hover card, marquee and lasso
│ ├── GraphToolbar how much is in full colour, the selection tools, zoom and Frame, the layout's transport
│ └── GraphLegend one row per category: glyph, name, how many are drawn
├── GraphStatus where the graph is, in one word: Loading, Laying out 42%, Ready, Failed
├── GraphCounts "5K nodes · 8K edges", or on a map "3K of 5K nodes placed · 1K of 8K edges"
├── GraphSearch a palette: find a vertex, by text, type or column, and reveal it
└── GraphInspector the focused vertex: its rowEvery part reads the root, not its parent. GraphCanvas's children are chrome positioned over
the surface, but GraphToolbar, GraphLegend, GraphStatus, GraphCounts, GraphSearch and
GraphInspector each read the root's context directly, so each can sit anywhere under it — over the canvas, in a
dock, in a footer. The graph's settings are not a part. They are GRAPH_SECTION, drawn by
@kanzo-tech/ui's PreferencesSections or composed from its <Pref>, and the root is the section's
owner: it answers the graph's columns and the cards' pictures to whatever sits beneath it —
Settings.
The mental model
The root holds the state; the api is the commands
GraphRoot holds the graph's state outside React — the loaded graph, the channels, the selection,
the focus and the layout's motion — and renders no element. useGraphContext() answers with the
commands, an object that never changes for the root's life: zoomBy, pause, resume,
restart, reveal, frame, clear, select, setFocus and setTool.
useGraphState(selector) reads a slice of the state and re-renders only when that slice moves.
The state changes at frame rate — a hover, a load, a tick of the layout — so a part that read all of
it would re-render on every hover. It is TanStack Store's useStore(store, selector); pass a second
argument, an equality, when the selector builds an object. Inside a callback, api.getState() reads
the state without subscribing.
Policy stays with the host, through the root's callbacks
A click selects a vertex and its neighbours and focuses it; a lasso selects what it encloses; a
failure is reported. What the host does with each is onSelect, onFocus and onFailure, and no
part takes a callback of its own — the package's barrel test holds that. onFailure is the one required prop;
Failures says why.
Ark's four: useGraph and GraphRootProvider
useGraph creates the api, GraphRoot is useGraph plus the context, GraphRootProvider
takes an api a host built itself, and useGraphContext reads it — useDialog, DialogRoot,
DialogRootProvider and useDialogContext, one for one. Reach for the pair when the host needs the
api above the parts:
The badge is a host's own part: it selects drawn, total and status, and nothing else, so a
hover does not re-render it.
status is the data's; motion is the layout's
status is where the data is — none, loading, idle, failed — and motion where the layout
is — running, paused, settled. Layout and camera
is the one page for both.
Examples
Toolbar
The commands, drawn: how much is in full colour, the selection tools, zoom and Frame, and the
layout's transport. Pick a tool and drag; Shift borrows the marquee whichever tool is
armed, and at release Alt removes what was drawn while ⌘/Ctrl adds — to the canvas's
selection, or, with none, to what the page's filters keep. Esc backs out of the drag, then the
tool, then the canvas's selection, and never another place's filter: that is let go at its chip.
The toolbar carries no count: how much is in full colour is GraphCounts' sentence, which adds
· M hidden by other filters when the selection holds vertices another filter hides. Frame, beside zoom, frames what is in full colour, and is named for it: Frame
everything with nothing greyed, Frame what the filters keep otherwise. Play runs the force layout from where the points are and pause leaves them there. Each cluster is a ButtonGroup, because a cluster is
a claim that its buttons do one job. The toolbar declares no toolbar role: that role promises
roving focus, and each button here is its own tab stop. orientation="vertical" stacks the
clusters.
Legend
- Contract
- Field report
- Tag
- Beast
- Member
- Region
The categorical scale the canvas draws, as rows and nothing else: the glyph, the name, and how
many vertices of that category are drawn. With nothing bound to fill the categories are the vertex
types; with colour a constant and nothing on symbol there is no category to key, and the legend
renders nothing. A row never moves. The domain is fixed before the graph loads — the vertex types
in graphar_vertex_types' order, or the keys of the root's categories — so a colour never changes hands. A
category past the palette's capacity is Other, on the legend as on the canvas.
On a map a row is what the map places: bound positions hide a vertex with no value, so a row reads Organisation 33,447 of 35,122, and a category with no position at all moves into a muted No position group with its whole count and a hollow glyph, rather than reading 0. Under the filter a placed row keeps its n of m and the group keeps its totals. When no relation has both ends placed, a line under the rows says No edges have both ends on the map.
The names are the root's, never the legend's, so the hover card, the search and the inspector say
the same thing. How much of the graph there is, and how much the filter keeps, is not the legend's:
it is GraphCounts.
Counts
- Contract
- Field report
- Tag
- Beast
- Member
- Region
— nodes · — edges
What is drawn, in one sentence: "5K nodes · 8K edges". Not what the page's filters keep: that
is the page's one count, the FilterBar readout at the start of the filter row, and nothing else
repeats it — not this sentence, the legend or a chip. An edge counts when both its ends are drawn,
so the edges shrink with the filter, and "· M hidden by other filters" joins the sentence while
the canvas's pick holds vertices another filter hides. A figure not yet known is "—", and the numbers
are compact in the nearest LocaleProvider's locale.
On a map the verb is "placed", because there a vertex with no value in x or y is hidden, not
greyed, and both figures are drawn of total: "33.4K of 206.6K nodes placed · 0 of 316.8K edges". Hovering
"placed" names the two columns. A vertex is placed when it has both values.
aria-busy follows the load alone: only a load changes these figures, and a running layout moves
the points, not the counts, so a screen reader is not told the sentence is stale while it is not.
Whether a layout runs is GraphStatus's word.
Status
Where the graph is, in one word: Loading, Laying out 42%, Ready or Failed. It reads both axes
of the state — the data's status and the layout's motion with progress — so a layout running
over a drawn graph says "Laying out", never the data's idle; a load wins over a layout, and a root
with no graph yet says Loading. A Badge with a Status dot: info while busy, success when ready,
destructive when failed.
The word is a polite live region, announced once per change; the percentage sits outside it, because
it moves at frame rate. The badge is aria-busy while loading or laying out.
<footer className="flex items-center gap-2">
<GraphStatus />
<GraphCounts />
</footer>Search
Find a vertex and go to it — a palette in the shape of Raycast's and Linear's ⌘K, over Cosmograph's
search, built on Command. The workspace at the top of this page carries one: press
⌘+K, or the field in its Info panel.
In the page it is a cue: a button styled as a field that prints ⌘K and holds nothing, Shark's own
command trigger. It, ⌘K and Ctrl+K open the palette, centred and full-height over the canvas — a
CommandDialog with hotkey="mod+k". One palette per page, so nothing arbitrates the key; the cue
declares it as aria-keyshortcuts. Each pause in typing (150 ms) asks the graph once, on the
engine, for the first limit matches and how many there are of each type; nothing is read
before the reader types and no text is held in the page. Matches are grouped by vertex type — named by
the root's categories and counted past the limit — each with the glyph the canvas draws it in, the
typed text highlighted and, on the right, its identity's local name, as Linear prints an issue's key: a
title is not unique, and fifty rows reading "scout" are told apart by it. The palette keeps its height
whatever it holds, as Raycast's and Linear's do.
Three things narrow it, as Neo4j Bloom's search does:
| Typed | Keeps |
|---|---|
type:Person | the vertices of one table |
<column>:<value> — team:2 | vertices whose column contains the value, case-insensitively |
| anything else | vertices whose title — or their table's identity — contains it |
A word is a prefix only when what precedes its colon is type or a column some vertex table has, so
an IRI typed whole stays text. Every statement is built with mosaic-sql's nodes: what the reader
typed is a literal, never part of the SQL.
A prefix becomes a chip on Space — a TagsInput sharing the palette's input, whose
validate is the same reading, so free text never becomes one. Backspace in an empty
input highlights the last chip, then removes it.
| Key | Does |
|---|---|
| Enter | reveals the highlighted match — the canvas frames it and selects it with its neighbours, and GraphInspector reads it — and closes the palette |
| ⌘/Ctrl+Enter | adds every match in the subset, past the limit, as the search's own clause — Add N to the subset in the footer does the same |
| Esc | closes the palette |
Empty, the palette offers where to start: the last five picks, which the root remembers for its
life — never in localStorage — as Recent, then every vertex type with how many it holds, as
Narrow to a type. Picking a type makes it a type: chip, as GitHub's palette offers its scopes.
limit caps the list (50), and the footer says how many matched and how many of them it shows.
It searches the subset. The matches are those every other clause on the crossfilter keeps —
usePick("search").predicate() — and the footer says how many more lie outside it: 12 matches ·
40 more outside the subset, or None in the subset · 40 outside it. Add N to the subset
publishes the search's own clause over the N inside; one outside would be a clause the others
already exclude, since picks intersect. A reveal selects on the canvas, so the next search stays
inside that neighbourhood until its chip is removed.
A failed read is kept whole: it reaches onFailure as thrown, and the input turns
aria-invalid with the placeholder "The names could not be read." rather than staying disabled.
It is a Command and not ChartSearch because of what the control means: ChartSearch publishes
a substring filter, which narrows the picture; picking is a value, which goes somewhere.
Pick from beside the canvas
A rule and the vertices that break it, an answer and the vertices it counted, a search's matches:
usePick(id) publishes them as a clause of their own on the crossfilter the graph filters by —
key IN (ids), whose chip reads label. Every clause filters every client, the graph included,
which greys out the rest. pick(ids, label) replaces what that place picked before; pick(null)
withdraws it, and pick([]) is a clause that keeps nothing
(why).
Each place is its own source, so picks intersect. A rule's pick and a lasso together keep the
vertices both hold, as Mosaic's crossfilter defines it; one place picking twice replaces its own
clause. id names the place and the source is the root's for its whole life, so a panel that closes
and opens again still owns the clause it left: picked is that clause's label, or null. The clause
outlives the component on purpose — it belongs to the page, and is retracted at its chip.
predicate() is the subset as that place sees it: every clause on the crossfilter but its own,
Mosaic's Selection.predicate(client). A search reads its matches under it, and a rule validates
under it, so neither narrows by what it picked itself.
Inspector
Click a vertex on the canvas to inspect it.
The focused vertex — a click, a search or reveal — read and laid out after the Linkurious
inspector, in sections and rules rather than a card, which inside a dock would be a second border. The loaded graph carries what the channels bind and nothing else, so the row is
read when a reader focuses it: one SELECT of its table at its dense_id.
- The header names the vertex — its type over it, with the canvas's glyph when colour is the
type, then its
title, else its IRI's local name (what follows the last#or/), else#id— with Locate beside it:reveal, which frames it with its neighbours on the canvas and keeps the camera there through a resize or a settling layout. - The IRI under the name, a link out with the copy button beside it: the identity is what a reader carries elsewhere, so it is never a row to hunt for.
- Nothing selects the neighbours here. A click on the canvas already selects a vertex with its neighbours, and so does Locate; the connections are the canvas's to show.
- The fields are the type's, two columns, in
graphar_properties' order, grouped as Values and Dates — a date is a column whose declared type is a date or a time. An IRI is a link out, and a long text is clamped with "more". ADataListItemthe render prop draws lines up with them without a class of its own.
children is a render prop: it receives the VertexDetail and draws fields of the product's own,
a DataListItem each, after the graph's values — here the hall's name, where the relation stores
its id.
Timeline
const { timeline } = useGraphPrefs();
<GraphCanvas className="h-[480px]" />
{timeline && <ChartTimeline field={timeline} relation="vertices" title={`Timeline: ${timeline}`} />}Cosmograph's CosmographTimeline, under the canvas — which is ChartTimeline from
@kanzo-tech/ui/analytics over vertices, every vertex type as one relation, the way Cosmograph's
is its generic Timeline over its own data: the distribution of one temporal column over every type
that has it, a range brushed across it, and Play time, a bare icon at its left edge,
which plays the range: the graph fills in from the range's start a bar at a time, each step waiting
for the graph to answer the last, until the whole range is in colour. With no range it is disabled;
at the end the next press starts over; a pointer down on the bars pauses it. Its chip in the
FilterBar reads the window as it grows, birthday 1980 – 1983, and its × stops play. The bars
are part of the graph's load: the graph reads Loading until the canvas and every chart on its page
that has not drawn yet have drawn, then shows them together — a rule of the graph's, not of the
timeline's. Give the band its height from the first render (minHeight), and it does not jump. It is
named apart from the toolbar's layout transport, which is a play button too.
- The column is a setting.
time-by— Timeline in the graph's settings — offers the graph's temporal columns, the way X axis offers its numeric ones, anduseGraphPrefs().timelineis the one chosen,""for none. A column is temporal by its GraphAr type, adateor atimestamp, or by its datatype in the graph's mapping: passmapping— rudof'sMapping.model()of themapping.ttlbeside the graph — and a year stored as anint32(xsd:gYear) is offered too. Without it, such a year is a number (why). - Only what is in the window stays in colour. The window is a clause on
vertices, the relation the canvas reads too, so the canvas reads it as published; the page crosses it into any other relation bydense_id, as it crosses a dashboard's. A vertex stays in colour when it has the column and its value is in the window; every other is greyed, a type without the column included, as in Cosmograph, and the edges grey with their ends. A window over 30 birthdays reads 30 of 341,661. A dashboard beside the graph is filtered through its relation's root, so one whose root has no such column shows no rows, and the chip that says why names the column: birthday 1981 – 1985. - A date on a relation is a vertex — the year of a prize, the day two people met — because a graph has no edge properties yet (why). Playing it greys the relations of other years and the edges into them.
Settings
The graph's settings are its section, drawn whole: one line. GRAPH_SECTION declares what may
be set — the picture, where the points come from and the forces — and @kanzo-tech/ui draws it. The
root is the section's owner, as the theme provider is the core's: it answers the attached graph's
columns and the pictures on the cards to every <Pref> beneath it, so no host passes a list it
does not own. Sections has the whole model.
import { KanzoThemeProvider, PreferencesSections } from "@kanzo-tech/ui";
import { GraphCanvas, GraphRoot, useGraphPrefs } from "@kanzo-tech/graph";
import { GRAPH_SECTION } from "@kanzo-tech/graph/section";
const SECTIONS = [GRAPH_SECTION]; // module scope: a new array re-resolves every preference
<KanzoThemeProvider sections={SECTIONS}>…</KanzoThemeProvider>;
// under it
const { look, sim, placement } = useGraphPrefs(); // placement: {} | { x, y } | { cluster }; timeline: the column, or ""
<GraphRoot from="archive" engine={e} fill="kind" look={look} sim={sim} {...placement} onFailure={setFailure}>
<GraphCanvas />
<PreferencesSections namespace="graph" />
</GraphRoot>The picture's axes are Cosmograph's configuration, read as choices a reader can name. There are no named looks over them — Cosmograph and Gephi Lite's appearance panel offer the axes with good defaults, and so does this section. A preference a tenant pinned is not offered.
| Preference | Values | What it draws |
|---|---|---|
| Marks | Dense · Legible | How much ink a point spends — cards, each previewing the current picture with that mark. |
| Labels | None · Hovered · Top · Visible · All | Which points carry their title — below. A slider from None to All. |
| Edges | Hidden · Straight · Curved | showLinks and curvedLinks as one axis — cards with a preview. |
| Additive links | switch | Links add where they overlap. Offered only while edges are drawn (when). |
| Vignette · Dot grid | switches, two to a row | The backdrop. |
| Placement | Force · Map · Clustered | Where the points come from — Placement. Cards with a picture of each mode. |
| X axis · Y axis | the graph's numeric fields | Map's two columns. Offered only under Map. |
| Group by | the graph's fields | Clustered's column. Offered only under Clustered. |
| Cluster pull | slider, its ends named | Offered only under Clustered: from Links decide to Tight groups. |
| Timeline | the graph's temporal fields | useGraphPrefs().timeline, the column a timeline over the graph reads — Timeline. |
| Gravity · Repulsion · Link spring · Link distance · Friction | sliders | The forces. Offered only under Force. |
Labels are one ordered scale, each level adding to the one before. None draws no text at all,
not even the hover card. Hovered is the hover card and the focused point's label. Top adds the
150 biggest points by the size channel — Cosmograph's showTopLabelsLimit default. Visible adds
the 100 biggest of cosmos.gl's sample of the points in view (one per 100 px cell), taken again each
time the camera rests — Cosmograph's dynamic labels, showDynamicLabelsLimit. All adds every
point of that sample, with no count of its own: the bound is the screen's, one point per 100 px
square, so an 800 × 600 view adds at most 48 whether the graph holds a thousand points or 327,000.
Cosmograph has no unbounded label set either, and All once was one — a sort, a label element, a
tracked position and a title read per vertex. At every level a label that would overlap a bigger
point's is dropped rather than drawn over it, and the titles are read for the labelled points only. The levels are the type LabelLevel, the labels field of a Look.
A look is form; the channels are the host's. The preferences never change which column wears
colour or shape. A host that wants identity on shape binds symbol to the category and paints
fill one ink, and Legible marks are what make a shape readable.
Placement
Where the points come from, composed here from the part — a page that wants placement alone writes
its <Pref>s, and each draws itself only when its declaration says so. Force is the GPU layout;
Map binds two numeric columns as x and y, and nothing simulates; Clustered runs the
layout with cosmos.gl's cluster force over one column. The columns are Cosmograph's pointXBy,
pointYBy and pointClusterBy, and their options are the graph's own fields from
graphar_properties, answered by the root — numeric ones for Map, any for Clustered; the global
dense_id, a type's primary key and a list property are not fields. A column is a Select, because the data answers its list;
clearing it unbinds it.
<GraphRoot from="archive" engine={e} {...placement} onFailure={setFailure}>
<Pref name="graph.placement" />
<Pref name="graph.x-by" /> {/* only under Map */}
<Pref name="graph.y-by" />
<Pref name="graph.cluster-by" /> {/* only under Clustered */}
</GraphRoot>A column is the person's, not the graph's. It is stored with their preferences, so it reaches
graphs that lack it, and the root reads such a column as unbound — the layout runs — rather than as
a column of nulls. With one of Map's two columns chosen the layout runs too. A vertex with no
value in either column is not drawn, and neither is an edge to it; GraphCounts and
GraphLegend say how much of the graph the map places, so a type with no position reads as one. Latitude as y draws
north up: cosmos.gl's space is y-up, and the placement into its square only scales and translates.
Declared axes, without the panel
lookFrom and simFrom read the same Record<string, string> a preferences section produces —
GRAPH_SECTION on @kanzo-tech/graph/section is the declaration — and useGraphPrefs() reads the
placement beside them. A host under a preferences panel never calls the two itself: useGraphPrefs() does. A host
that knows its graph is large starts its users at other forces through the tenant policy, over the
same declared axes.
The channels are bindings
What a point wears is bound on the root, under the names Plot gives a point mark: fill,
symbol, r, stroke and title.
<GraphRoot from="archive" engine={e} fill="kind" r="degree" onFailure={setFailure}>Unbound, colour is the vertex type, so a graph with several types reads as several colours with nothing passed. A CSS colour is a constant; anything else is a column. That is Plot's rule, and it is what makes the monochrome picture a binding rather than a theme:
<GraphRoot from="archive" engine={e} fill="var(--foreground)" symbol="kind" r="degree" onFailure={setFailure}>Identity has moved to shape, colour is free to mean the selection, and nothing had to know which of
the two a look preferred. A constant is var(…), a hex or a colour function, so a bare word is
always a column, and a graph with a column called red is not a trap.
A binding is a column read. fill, r and cluster as columns are read for each vertex table
that has them; a table without the column draws its vertices as the null category. fill as a
constant and stroke are never read. The graph holds one categorical column, so symbol beside
a fill column reads the same one. r unbound sizes by degree.
categories names the values and ranks them. Its keys are the category's values — table names
when colour is the vertex type — and its values are what the legend, the hover card, the search and
the inspector call them. The keys' order is the colour rank, fixed before the graph loads.
x and y are where a point is drawn. Both bound — x="lon" y="lat", say — the points sit at
the data's values and nothing simulates; unbound, a GPU force layout places them. cluster names the
column a running layout pulls together by. Layout and camera
has the rule.
filterBy is the page's selection — useMosaic().selection, under a MosaicProvider given the
graph's joins. The graph is one more client of it, reading vertices. A chart's clause greys out on the canvas what it drops —
never hides it — and the reader's lasso or click is published back as a semi-join on
the vertex key, for every client and not for the graph. A pick from beside the canvas — usePick —
is a clause of its own, and filters the graph too. Removing that clause where it was published — a
FilterBar chip, the page's reset — clears the pick on the canvas too.
A clause is column predicates, or a semi-join on identity. A column clause on vertices filters
every vertex, and keeps none of a type without a column it names: that type holds NULL there. A
column clause on another relation — a dashboard's tile — reaches the graph crossed by dense_id. A
semi-join — clauseSemiJoin("dense_id", ids | query) — names dense_id alone, so it filters every
vertex table whatever its subquery reads: the vertices a rule found, the endpoints of an edge table,
an answer's rows.
import { Query, clauseSemiJoin } from "@kanzo-tech/mosaic";
// Everyone who lives in Saltmere, published by a panel: the graph greys out the rest.
selection.update(
clauseSemiJoin("dense_id", Query.select("src").from(livesIn).where(inSaltmere), { source: panel, label: "Saltmere" }),
);The clause rule has why, and the graph view has how the graph answers.
Identity
A VertexId is the graph's global id, dense_id, a number: GraphAr numbers vertices within a
type (_graphArVertexIndex), and the graph numbers every vertex 0 … V − 1 from those — each node
type, in the graph document's order, one contiguous range starting at the sum of the counts before
it — so a vertex's id is also its index in cosmos.gl's buffers. Nothing writes it to the files: it is
the column each type's table in the graph and in readJoinGraph projects, so an id from SQL over
those — SELECT dense_id FROM … — is a vertex as it stands, for reveal or a pick.
Every GraphAr vertex type is a node type. Its identity is its is_primary property, and the id
itself where it has none. A multi-valued property is a list property of the type that owns it
(nickname is a VARCHAR[] column of person): the inspector shows its values, a search can name it,
and it is not offered as a position, a group or a timeline column, nor as a field of a dashboard. A
graph with no vertex is refused as graph/nothing-to-draw. Every clause the graph publishes is a
semi-join on dense_id.
readJoinGraph
readJoinGraph(engine, from, { signal }), from @kanzo-tech/relations, is the same graph as a
JoinGraph, read through any Queryable — what
@kanzo-tech/ui/analytics' relations are built over, and the
one constructor of one from a graph. Each vertex type is a type keyed by dense_id, its table the
type's view under the global id, every column described as GraphAr's catalog describes it: its
DuckDB type, whether it is nullable, a role for the two keys — address for dense_id,
identity for the primary key, which are the type's candidateKeys — and what fossil's writer
counted in it (stats: its nulls, its distinct values, its extent and its commonest values), read
from the chunks' Parquet footers. Each edge type joins through src and dst, its ends as global
ids, under GraphAr's edge_type as its label. One more type, vertices, is every vertex type as one
relation (of every type), with NULL where a type has no such column — what the canvas and a
timeline over the graph read; its columns carry no counts, because they were counted per type. The
views a graph is attached as declare no keys, so this is what says an edge type joins anything. The
charts never learn GraphAr; they take the graph as data.
GraphAr's catalog carries no IRIs, term types, datatypes or annotations, so neither does the join
graph: a field is called by its name, and a year is a number (what GraphAr does not
carry). A graph whose chunks state no counts has none,
and useFieldStats reads its relation with one SUMMARIZE.
The catalog is read in one place, this function: the canvas reads it too, on the engine's own door with its signal, so a stop interrupts the read, and adds to each type the first global id it draws from. Nothing caches it: a page that draws the graph and a dashboard beside it reads the catalog twice.
import { readJoinGraph } from "@kanzo-tech/relations";
const graph = await readJoinGraph(e, jobId, { signal });
<MosaicProvider engine={e} joins={graph}>
<Dashboard relation={{ root: "Person", path: [] }} />
</MosaicProvider>joins is what lets the dashboard sit on the graph's selection: its tiles' clauses name
Person.country, which vertices has not, and the page crosses them into the graph as one semi-join
on dense_id — what the page sees.
Held by packages/relations/src/graphar.test.ts, "keys a type by the global id, names its keys,
and joins an edge on its global ends"; 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".
withMapping
withMapping(graph, model) is a JoinGraph described by its mapping: the class, predicate, term type,
datatype and rdfs:labels that an RML-Core mapping states and the graph's tables do not. model is
what rudof's Mapping.model() answers — MappingModel, a structural type, so nothing here imports
rudof. A table is the type of that name, a column of it the one its term is read from, and a relation
the edge from that type to its parent's. A map over a query, as fossil writes a relation or a multi-valued property, is read by the table names its SQL quotes: a relation's edge by its edge table, a list by the one column it unnests. What the mapping states lands on the iri, annotations,
termType and datatype every reader of a JoinGraph already reads, and no other part is told:
import { withMapping, typeWords } from "@kanzo-tech/relations";
const graph = withMapping(await readJoinGraph(e, "archive", { signal }), mapping.model());
<MosaicProvider engine={e} joins={graph}>
<Dashboard relation={{ root: "Person", path: [] }} />
</MosaicProvider>
// The canvas takes its category names from the host (`categories`), and the host reads them off the graph:
const categories = Object.fromEntries(graph.types.map((t) => [t.name, typeWords(graph, t.name)]));Every label is kept, in every language; labelOf(annotations, language) picks one when it is read,
<html lang> by default. typeWords(graph, name, language?) and edgeWords(edge, language?) are
the one place a type's or an edge's words are chosen — the label, else the name split into words
(splitWords) — and RelationPicker, Ask's chips and a column's label all read them. A year the
mapping declares xsd:gYear is a time, whatever integer stores it.
Held by packages/relations/src/mapping.test.ts, "puts the class, the predicates, the term types and the
datatypes on the graph"; "keeps every language, and the reader chooses one: the same graph reads in
either"; "describes an edge from the source's relation to the parent, the one its label names among
several".
Failures
onFailure is required, because unhandled, a browser with no WebGL context shows an empty box.
It receives every failure as it was thrown, and the graph's own are a kernel Problem. The root
reports status: "failed" beside it, so a part can render the failed state without holding the
error. A failed attachGraph is the host's own — above, a coded refusal, routed to the
same handler and drawn by Problem, which reads the code off whatever
was thrown and takes the host's words for it.
Problem code | When |
|---|---|
graph/no-webgl | No WebGL context, or no GPU device came up. data.after is set when the 10 s device deadline fired. |
graph/context-lost | The browser took the WebGL context back and did not return it within 5 s. A context that returns is restored: the canvas is built again over what the store holds, and a layout that was running runs again. |
graph/no-positions | The device came up but holds no positions for the points it was given, found when the camera read them back. cause is what the read threw. |
graph/nothing-to-draw | The attached graph has no vertex. |
graph/too-large | The graph has more than 2²⁴ = 16,777,216 vertices, past which an id no longer fits a Float32 link exactly. data.count and data.limit. Refused from the catalog's counts (graphar_vertex_types.count), before one link is read. |
A part that reads on its own — the search's names, the inspector's row, a label's text — hands its
failure to the same onFailure and shows it in place. A host keys its message on code and falls
back to the message.
Pitfalls
- Memoise
look, and holdsimsteady. The root repaints onlook's identity, so a fresh object per render is an upload per render; a freshsimre-heats a layout that is running.useGraphPrefs()answers the same objects until a preference moves; a hand-built look belongs inuseMemo. categories' key order is the colour rank. Reorder the keys and every category changes colour. Write the object once, in the order the colours should go, at module scope.onFailureis not optional. Without it a browser with no WebGL, a graph that will not read and a filter no table can answer all look like an empty canvas.- A large graph pays for the camera that follows the layout. While a layout runs the camera re-frames every 900 ms — or ten times its last stall, when that is longer — and so does Frame once a layout has run; each re-frame reads every position back from the GPU. Past tens of thousands of points that is a stall per re-frame — Layout and camera has what to do.
- Register
GRAPH_SECTIONonce, at module scope. The graph's settings draw nothing without it, and asectionsarray built in render re-resolves every preference on every render. - A hidden tab is a stopped graph. The canvas draws on animation frames, and
requestAnimationFramenever fires in a background tab.
cosmos.gl behaviours that typecheck while being wrong
setConfigresets everything. In 3.x it restores defaults and then applies the argument. UsesetConfigPartial.getConnectedLinkIndicesis not a neighbourhood. It returns only links whose other endpoint is also in the argument.getNeighboringPointIndicesis the neighbourhood, in buffer indices.- Every index cosmos.gl hands you is a buffer position. Here a buffer position is the vertex's
dense_id, because the graph uploads its vertices in id order; in another host it would name nothing stable. highlightedPointIndicesdoes not grey links. A link between two greyed points stays lit unlesshighlightedLinkIndicesnames the ones to keep — the filter sets both.graph.readyhas no failure path. A device that cannot be created leaves the promise unsettled, which is why the root sets its own deadline.
How far it goes
The layout is tuned by size — how long it cools, how big its square, whether links are drawn while it runs — and done in about ten seconds at 200,000 vertices and at 500,000. Layout and camera has the rules and the measurements; Benchmarks has the viewer against cosmos.gl alone.
Testing
GraphCanvasHarness from @kanzo-tech/testing drives a canvas and its toolbar.
It waits for the canvas's first drawn frame (aria-busy until then) and, after a gesture or
Frame, for a frame drawn after it, counted in data-frame. Points are in the data you bound to
x and y, through cosmos.gl's own space-to-screen conversion.
import { GraphCanvasHarness } from "@kanzo-tech/testing";
const graph = await env.getHarness(GraphCanvasHarness);
await graph.ready();
await graph.lasso([[-10, 40], [5, 40], [5, 55]], { in: "data", with: "Meta" });
await graph.frame();API Reference
packages/graph/src/index.test.ts pins the barrel: adding a name fails that file until somebody
writes it in.
GraphRoot
useGraph plus the context. Renders no element.
| Prop | Type | Default |
|---|---|---|
from | string | null — the catalog attachGraph attached | required |
engine | Engine | null — the page's engine, engine()'s | required |
onFailure | (error: unknown) => void | required |
fill | string — a column, or a CSS colour every point wears | the vertex type |
symbol | string — the column a point's shape carries | — |
stroke | string — what tints a link | the colour of the vertex it leaves |
r | string — the column the size ramp is spent on | degree |
x, y | string — the columns a point is drawn at; both bound, nothing simulates | — |
cluster | string — the column a running layout pulls together by | — |
title | string — the column a label, the hover card and the search read | each table's identity |
categories | Readonly<Record<string, string>> — names by value; key order is the rank | — |
filterBy | Mosaic Selection — the page's crossfilter | — |
look | LookPatch — form, over the package's own | lookFrom() |
sim | Partial<Sim> — forces, over the package's own | simFrom() |
simulate | boolean — runs the layout while true; never while x and y place the points | true |
mapping | MappingModel — the graph's RML mapping, as withMapping reads it; a column's datatype comes from it. May arrive after the first paint | — |
onSelect | (selection: Selection | null) => void | — |
onFocus | (vertex: VertexId | null) => void | — |
children | ReactNode | — |
GraphRootProps is UseGraphProps plus children. GraphRootProvider takes value, a GraphApi
from useGraph, and children (GraphRootProviderProps).
Every part renders through ark and takes the props of the element it renders, asChild among
them, and every word it writes through translations — a partial record, English by default. A
sentence written from figures — 5K nodes · 8K edges — is a pattern of the figures, never English
assembled in the part.
GraphCanvas
| Prop | Type | Default |
|---|---|---|
children | ReactNode — chrome positioned over the surface | — |
translations | Partial<GraphCanvasTranslations> — loading, and holds(count, n) beside a marquee | English |
Extends React.ComponentProps<typeof ark.div>. The frame is relative isolate size-full, so a child's
z-index cannot escape into the page; give it a size from outside.
GraphToolbar
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
translations | Partial<GraphToolbarTranslations> — every cluster's and button's name and hint | English |
Extends React.ComponentProps<typeof ark.div>. Anchor it at the canvas's top end (absolute end-2 top-2):
while something is greyed it draws a count of what is in full colour — first in a row, last in a
column — so it grows away from the anchor and no button moves. Its one Frame, in the camera's
cluster, is named for what it frames: "Frame everything", or "Frame what the filters keep". A
canvas's pick is let go at its chip or with Escape, like every other clause.
GraphLegend
Takes Card's props (GraphLegendProps): the legend is a Card, and composes it rather than drawing
a card's surface by hand. Renders nothing when there is no category to key. translations
(GraphLegendTranslations): other, unplaced and noEdges.
GraphCounts
Takes React.ComponentProps<typeof ark.p> (GraphCountsProps). aria-busy is set while the graph
loads. translations (GraphCountsTranslations) are the sentence's patterns over its figures —
nodes(count), placedNodes(count, total, placed), hidden(count), edges(count),
placedEdges(count, total) — with placed, placedHint(x, y) and the separator.
GraphStatus
Takes Badge's props (GraphStatusProps). aria-busy is set while the graph loads or a layout
runs. translations (GraphStatusTranslations): loading, layingOut, ready and failed.
GraphSearch
| Prop | Type | Default |
|---|---|---|
limit | number | 50 |
size | "sm" | "md" | "lg" — the cue's | "sm" |
translations | Partial<GraphSearchTranslations> — the cue's placeholder, the palette's words, the footer's patterns | English |
Takes the cue's props, a Button's (GraphSearchProps).
GraphInspector
| Prop | Type | Default |
|---|---|---|
children | (detail: VertexDetail) => ReactNode — fields after the graph's values | — |
Extends React.ComponentProps<typeof ark.div> without its children. translations
(GraphInspectorTranslations): empty, missing, unread, locate, copy, values, dates,
more and less.
Hooks
| Name | Signature | What it is |
|---|---|---|
useGraph | (props: UseGraphProps) => GraphApi | Creates the api: a store created once and handed the props when one changes. |
useGraphContext | () => GraphApi | Reads it under a root; throws outside one. |
useGraphState | (selector, isEqual = Object.is) => T | A slice of the GraphState, and a render only when it moves. |
usePick | (id: string) => VertexPick | A place beside the canvas: pick(ids | null, label) publishes its clause, picked is that clause's label or null, predicate() every clause but its own. |
useGraphPrefs | () => { look: Look; sim: Sim; placement: Placement; timeline: string } | The provider's resolved GRAPH_SECTION, as the three things the root takes, and the column a timeline over the graph reads. |
The picture
| Name | What it is |
|---|---|
lookFrom | (values?) => Look — the section's axes, as form. |
simFrom | (values?) => Sim — the same axes, as forces. |
GRAPH_SECTION | On @kanzo-tech/graph/section: the graph's tokens and its preferences — the look's axes, the placement and the forces. |
GraphApi
The commands, stable for the root's life — GraphCommands plus the state's door.
| Member | What it does |
|---|---|
zoomBy(factor) | Zooms the camera by a factor. |
pause() / resume() | Pauses a running layout; resumes a paused one, or runs a settled one again. Does nothing while x and y place the points. |
restart() | Runs the layout from full heat. Does nothing while x and y place the points. |
reveal(vertex) | Selects and focuses a vertex with its neighbours and frames them — zooms into a vertex with none — taking the camera from any fit. |
frame() | Frames what is in full colour: the canvas's selection within what the page's filter keeps, what the filter keeps when nothing is selected, or — when nothing is greyed out — the whole graph: the bound positions' extent, or where the points are once a layout has run, kept framed through a resize. |
clear() | Clears the selection and the focus. |
retry() | Runs the load again from the read that failed, and asks the filter again; a no-op unless the graph is failed because a read was. A graph/too-large fails again with the same code. |
setFocus(vertex) | Focuses a vertex, or none. |
setTool(tool) | Arms "rect", "lasso", or null for the reading posture. |
subscribe(listener) / getState() | The store, for a callback; a render uses useGraphState. |
GraphState
What useGraphState selects from.
| Field | Type | What it is |
|---|---|---|
status | DataStatus — "none" | "loading" | "idle" | "failed" | The data's life. |
motion | Motion — "running" | "paused" | "settled" | The layout's life. |
progress | number | How far a running layout has settled, 0–1. |
total | number | undefined | Every vertex of the graph, from graphar_vertex_types. |
matching | number | null | The vertices of the graph the page's filter keeps; null when nothing is filtered. |
drawn | Drawn | null | vertices, edges, domain and tally under the filter; placed per rank with a position, totals per rank in the graph, links loaded and placedLinks with both ends placed. |
domain | readonly unknown[] | The category domain before anything loads. |
selection | VertexSelection | null | vertices, source (a VertexSelectionSource — "marquee" | "lasso" | "node", the canvas's gestures) and label. |
focus / hovered | VertexId | null | The focused and the hovered vertex. |
tool | Tool | The armed selection tool. |
options | UseGraphProps | The props as given. |
structure | JoinGraph | null | The graph's JoinGraph, once read (@kanzo-tech/relations's readJoinGraph): its vertex types are the types without of, each with first, rows and its columns. |
Types
GraphCanvasProps, GraphLegendProps, GraphCountsProps, GraphStatusProps, GraphToolbarProps,
GraphInspectorProps, GraphSearchProps and GraphRootProviderProps
are the parts' props, and GraphCanvasTranslations, GraphLegendTranslations,
GraphCountsTranslations, GraphStatusTranslations, GraphToolbarTranslations,
GraphInspectorTranslations and GraphSearchTranslations their words; VertexDetail — vertex, table and fields — is what the
inspector's render prop receives. Channels (fill, symbol, stroke, x, y, cluster), Placement, Look, LookPatch,
Shape, Sim, Motion, VertexSelection, VertexSelectionSource, Tool, VertexId,
GraphCommands, VertexPick, DataStatus and Drawn are the vocabulary, and the graph's own failures are the kernel's Problem.
Pitfalls
The failures on the analytics subpath that point somewhere other than their cause — what each looks like, why it happens, and what to do instead.
Layout and camera
The data's status and the layout's motion, when the GPU layout runs and how it scales, what a drag does to the points, when the camera follows, and what is kept when the canvas goes.