Kanzo UI
Graph

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
Loading

— nodes · — edges

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-react

Its 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:

app/globals.css
@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 row

Every 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:

none

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
Loading

— 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>

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:

TypedKeeps
type:Personthe vertices of one table
<column>:<value> — team:2vertices whose column contains the value, case-insensitively
anything elsevertices 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.

KeyDoes
Enterreveals the highlighted match — the canvas frames it and selects it with its neighbours, and GraphInspector reads it — and closes the palette
⌘/Ctrl+Enteradds 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
Esccloses 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". A DataListItem the 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, and useGraphPrefs().timeline is the one chosen, "" for none. A column is temporal by its GraphAr type, a date or a timestamp, or by its datatype in the graph's mapping: pass mapping — rudof's Mapping.model() of the mapping.ttl beside the graph — and a year stored as an int32 (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 by dense_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

Marks
Edges
Placement

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.

PreferenceValuesWhat it draws
MarksDense · LegibleHow much ink a point spends — cards, each previewing the current picture with that mark.
LabelsNone · Hovered · Top · Visible · AllWhich points carry their title — below. A slider from None to All.
EdgesHidden · Straight · CurvedshowLinks and curvedLinks as one axis — cards with a preview.
Additive linksswitchLinks add where they overlap. Offered only while edges are drawn (when).
Vignette · Dot gridswitches, two to a rowThe backdrop.
PlacementForce · Map · ClusteredWhere the points come from — Placement. Cards with a picture of each mode.
X axis · Y axisthe graph's numeric fieldsMap's two columns. Offered only under Map.
Group bythe graph's fieldsClustered's column. Offered only under Clustered.
Cluster pullslider, its ends namedOffered only under Clustered: from Links decide to Tight groups.
Timelinethe graph's temporal fieldsuseGraphPrefs().timeline, the column a timeline over the graph reads — Timeline.
Gravity · Repulsion · Link spring · Link distance · FrictionslidersThe 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

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

repulsion 1.10 · friction 0.86

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 codeWhen
graph/no-webglNo WebGL context, or no GPU device came up. data.after is set when the 10 s device deadline fired.
graph/context-lostThe 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-positionsThe 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-drawThe attached graph has no vertex.
graph/too-largeThe 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 hold sim steady. The root repaints on look's identity, so a fresh object per render is an upload per render; a fresh sim re-heats a layout that is running. useGraphPrefs() answers the same objects until a preference moves; a hand-built look belongs in useMemo.
  • 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.
  • onFailure is 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_SECTION once, at module scope. The graph's settings draw nothing without it, and a sections array built in render re-resolves every preference on every render.
  • A hidden tab is a stopped graph. The canvas draws on animation frames, and requestAnimationFrame never fires in a background tab.

cosmos.gl behaviours that typecheck while being wrong

  • setConfig resets everything. In 3.x it restores defaults and then applies the argument. Use setConfigPartial.
  • getConnectedLinkIndices is not a neighbourhood. It returns only links whose other endpoint is also in the argument. getNeighboringPointIndices is 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.
  • highlightedPointIndices does not grey links. A link between two greyed points stays lit unless highlightedLinkIndices names the ones to keep — the filter sets both.
  • graph.ready has 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.

PropTypeDefault
fromstring | null — the catalog attachGraph attachedrequired
engineEngine | null — the page's engine, engine()'srequired
onFailure(error: unknown) => voidrequired
fillstring — a column, or a CSS colour every point wearsthe vertex type
symbolstring — the column a point's shape carries—
strokestring — what tints a linkthe colour of the vertex it leaves
rstring — the column the size ramp is spent ondegree
x, ystring — the columns a point is drawn at; both bound, nothing simulates—
clusterstring — the column a running layout pulls together by—
titlestring — the column a label, the hover card and the search readeach table's identity
categoriesReadonly<Record<string, string>> — names by value; key order is the rank—
filterByMosaic Selection — the page's crossfilter—
lookLookPatch — form, over the package's ownlookFrom()
simPartial<Sim> — forces, over the package's ownsimFrom()
simulateboolean — runs the layout while true; never while x and y place the pointstrue
mappingMappingModel — 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—
childrenReactNode—

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

PropTypeDefault
childrenReactNode — chrome positioned over the surface—
translationsPartial<GraphCanvasTranslations> — loading, and holds(count, n) beside a marqueeEnglish

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

PropTypeDefault
orientation"horizontal" | "vertical""horizontal"
translationsPartial<GraphToolbarTranslations> — every cluster's and button's name and hintEnglish

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

PropTypeDefault
limitnumber50
size"sm" | "md" | "lg" — the cue's"sm"
translationsPartial<GraphSearchTranslations> — the cue's placeholder, the palette's words, the footer's patternsEnglish

Takes the cue's props, a Button's (GraphSearchProps).

GraphInspector

PropTypeDefault
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

NameSignatureWhat it is
useGraph(props: UseGraphProps) => GraphApiCreates the api: a store created once and handed the props when one changes.
useGraphContext() => GraphApiReads it under a root; throws outside one.
useGraphState(selector, isEqual = Object.is) => TA slice of the GraphState, and a render only when it moves.
usePick(id: string) => VertexPickA 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

NameWhat it is
lookFrom(values?) => Look — the section's axes, as form.
simFrom(values?) => Sim — the same axes, as forces.
GRAPH_SECTIONOn @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.

MemberWhat 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.

FieldTypeWhat it is
statusDataStatus — "none" | "loading" | "idle" | "failed"The data's life.
motionMotion — "running" | "paused" | "settled"The layout's life.
progressnumberHow far a running layout has settled, 0–1.
totalnumber | undefinedEvery vertex of the graph, from graphar_vertex_types.
matchingnumber | nullThe vertices of the graph the page's filter keeps; null when nothing is filtered.
drawnDrawn | nullvertices, 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.
domainreadonly unknown[]The category domain before anything loads.
selectionVertexSelection | nullvertices, source (a VertexSelectionSource — "marquee" | "lasso" | "node", the canvas's gestures) and label.
focus / hoveredVertexId | nullThe focused and the hovered vertex.
toolToolThe armed selection tool.
optionsUseGraphPropsThe props as given.
structureJoinGraph | nullThe 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.

On this page