Kanzo UI
Graph

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
Loading

— nodes · — edges

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 + GraphCounts

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

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. onFailure hands it over whole; the host keys on code.
  • What a selection means beyond the graph — onSelect and onFocus, 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/.

On this page