Discovery workspace
A recipe — the engine, a graph attached by name, and the graph parts and charts on one crossfilter.
A discovery workspace is the graph and the panels that interrogate it: a canvas with its tools, a legend over it, a footer that says where the graph is and how much of it there is, an Info panel to find and read a vertex, a Settings panel for the picture, and charts that filter with the graph. It is how a host composes a discovery page and how the workspace showcase is built. Every piece is a library part; what the host writes is the arrangement and its own policy.
- Contract
- Field report
- Tag
- Beast
- Member
- Region
The shape
engine() the page's one DuckDB and its coordinator
attach("archive", { engine, url }) the graph, attached as a catalog
KanzoThemeProvider sections={[GRAPH_SECTION]}
└── MosaicProvider engine joins owns the page's one selection
└── GraphRoot from engine filterBy … one root, one Mosaic client
├── header view switch: Graph · Dashboard
├── main GraphCanvas > GraphToolbar + GraphLegend or the charts
├── dock
│ ├── Info GraphSearch + GraphInspector
│ └── Settings PreferencesSections namespace="graph" + the gestures
└── footer GraphStatus + GraphCountsOne engine under everything. The graph and every chart are clients of the coordinator inside
the same engine — one DuckDB connection, one query cache — and share one crossfilter. One root around
everything graph: the canvas, the dock and the footer all read the same GraphRoot, so a search in
the dock reveals on the canvas and a look chosen in Settings repaints it.
Step by step
Register the graph's preferences
PreferencesSections writes GRAPH_SECTION and useGraphPrefs reads it; both need the section on
the theme provider. Hoist the array: a new one each render re-resolves every preference.
import { KanzoThemeProvider } from "@kanzo-tech/ui";
import { GRAPH_SECTION } from "@kanzo-tech/graph/section";
const SECTIONS = [GRAPH_SECTION];
export function Providers({ children }: { children: React.ReactNode }) {
return <KanzoThemeProvider sections={SECTIONS}>{children}</KanzoThemeProvider>;
}Attach the graph, once, to the page's engine
engine() is the page's DuckDB-WASM and its coordinator, memoised per document.
@kanzo-tech/graphar-duckdb's attachGraph attaches a GraphAr graph to it under a name: a view per
type — "archive"."Sighting" — and the information files as "archive".graphar_vertex_types,
"archive".graphar_edge_types and "archive".graphar_properties. Every file is read through the
engine, by the name resolve gives its path. Attach once and keep the attachment it answers, whose
detach() lets the graph go.
import { attachGraph } from "@kanzo-tech/graphar-duckdb";
import { readJoinGraph } from "@kanzo-tech/relations";
import { engine } from "@kanzo-tech/mosaic/engine";
import { MosaicProvider } from "@kanzo-tech/ui/analytics";
const e = await engine();
const archive = await attachGraph(e, "archive", (paths) => paths.map((p) => `${url}/${p}`), { entry: "fossil.graph.yml" });
const joins = await readJoinGraph(e, "archive", { signal });
<MosaicProvider engine={e} joins={joins}>
<Workspace />
</MosaicProvider>;url is origin-qualified — DuckDB-WASM resolves a root-relative path in its own filesystem — and,
under a basePath, prefixed by hand.
One selection
The graph names the catalog and reads it through the same engine the charts use, on the page's one selection:
const { engine, selection } = useMosaic();
<GraphRoot from="archive" engine={engine} filterBy={selection} onFailure={setFailure}>…</GraphRoot>
<ChartRoot relation="Sighting">…</ChartRoot>A chart's brush greys out on the canvas the vertices it drops, and the reader's click or lasso is
published as a dense_id clause that every chart filters by and the graph does not. A table that
lacks a clause's columns keeps none of its vertices under it, so a clause naming columns no vertex
table has greys the whole graph (why). A type the
joins have is read by its name, catalog and all. A clause on one relation reaches a part on another
crossed by dense_id, or keeps none of it where there is no way across, so a Dashboard over a
relation of the graph shares this selection with the graph in both directions — what the page
sees.
The root, with the preferences and its channels
import { GraphRoot, useGraphPrefs } from "@kanzo-tech/graph";
const KINDS = { Dataset: "Dataset", Keyword: "Keyword", Publisher: "Publisher" }; // key order = colour rank
const { look, sim, placement } = useGraphPrefs();
<GraphRoot
from="archive"
engine={e}
filterBy={selection}
categories={KINDS}
fill="kind"
stroke="var(--muted-foreground)"
r="degree"
title="label"
look={look}
sim={sim}
{...placement}
onFailure={setFailure}
>look, sim and placement come from the preferences, already memoised; placement is {},
{ x, y } or { cluster }, spread on the root. The channels are the host's: a look
is form, and which column wears colour or shape is a binding the reader's preferences never change.
The canvas, with its chrome
import { GraphCanvas, GraphLegend, GraphToolbar, useGraphState } from "@kanzo-tech/graph";
function GraphRegion() {
const failed = useGraphState((s) => s.status === "failed");
return (
<GraphCanvas className="absolute inset-0">
<GraphToolbar className="absolute end-2 top-2 z-10" />
<GraphLegend className="absolute start-2 bottom-2 z-10" />
{/* failed: the host's own failure view, keyed on the error's code */}
</GraphCanvas>
);
}The Info panel
import { GraphInspector, GraphSearch } from "@kanzo-tech/graph";
function GraphInfo() {
return (
<div className="flex h-full flex-col">
<div className="border-b p-2">
<GraphSearch placeholder="Find anything in the graph…" />
</div>
<ScrollArea className="min-h-0 flex-1 p-3">
<GraphInspector>{(detail) => <ProductFields detail={detail} />}</GraphInspector>
</ScrollArea>
</div>
);
}The search is a cue here and a palette over the canvas: the field opens it, and so does ⌘K. It asks
the graph as the reader types, turns type: and <column>:<value> into chips, groups what it finds
by type and remembers what the reader went to. Enter reveals the vertex, and the inspector below
reads it: its type and name, Locate beside them, and its IRI with a copy button. The render prop is where a product adds fields the graph does not carry.
The Settings panel
import { Kbd, KbdGroup, PreferencesSections } from "@kanzo-tech/ui";
const GESTURES = [
{ keys: <Kbd>Drag</Kbd>, what: "Pan — or move a node" },
{ keys: <Kbd>Wheel</Kbd>, what: "Zoom where you point" },
{ keys: <Kbd>Click</Kbd>, what: "Focus a node with its neighbours" },
{ keys: <KbdGroup><Kbd>Shift</Kbd><Kbd>Drag</Kbd></KbdGroup>, what: "Marquee, without picking a tool" },
{ keys: <Kbd>⌘ / Ctrl</Kbd>, what: "Add what you draw to the selection, or to what the filters keep" },
{ keys: <Kbd>Alt</Kbd>, what: "Remove it from the selection instead" },
{ keys: <Kbd>Esc</Kbd>, what: "Back out — the drag, then the tool, then the canvas's selection" },
];
function GraphSettings() {
return (
<ScrollArea className="h-full p-3">
<PreferencesSections namespace="graph" />
<dl>{/* GESTURES, one row each */}</dl>
</ScrollArea>
);
}The graph's section, whole: the picture, where the points come from — the archive's halls make Clustered worth trying — and the forces. One line, because the root is the section's owner: it answers the graph's columns to the panel, so the panel holds no state and the page passes no list. Pausing, re-running and framing the layout are the toolbar's, so the panel needs no camera button. The gestures are the canvas's own; they are written down here because a reader scanning for "how do I select two clusters" wants a table.
The footer
import { GraphCounts, GraphStatus } from "@kanzo-tech/graph";
<ShellFooter className="flex-row items-center gap-2">
<GraphStatus />
<GraphCounts />
</ShellFooter>;One word for where the graph is — Loading, Laying out 42%, Ready, Failed — then the graph:
"5K nodes · 8K edges"; what the filters keep is the FilterBar readout's. The word reads the
data's status and the layout's motion together, so the footer never says "idle" over a running
layout — two axes, see Layout and camera.
What stays the host's
- Attaching and detaching the graph, and what signs it.
- The channels: which column wears colour, shape and size, and the names in
categories. - What a failure says.
onFailurehands it over whole; the host keys oncode. - What a selection means beyond the graph —
onSelectandonFocus, for a URL, a toast, a panel that opens. - The arrangement: which views, which panels, what the header holds. None of it is a part, because none of it would survive a second host.
The whole thing, live
The workspace showcase is this recipe at full size, with a Sightings
dashboard as the second main view, Orders and an Ask panel in the dock, and the crossfilter shared
between them. Its source is in docs/showcases/workspace/.
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.
Benchmarks
Our viewer against cosmos.gl alone on the same positions, how far cosmos.gl's live layout goes, and where the layout's tuning was measured.