Audience: adopters embedding the toolkit inside a larger decision- support or process application, who need their OWN buttons, panels, and workflows to control toolkit behavior, and toolkit state to flow back out into their components.
Every snippet here is typechecked in CI against the real package types
(pnpm run verify:snippets), so a recipe naming a removed or renamed
export fails the build rather than sitting here misleading you. The
integration paths they describe additionally run as executable tests in
examples/wiring/src/. Snippets show their imports and assume the
values you own (ugm, cy, your settings) are in scope.
g3t is a component library with a deliberately thin integration surface, in three parts:
useXStore(selector) in React; useXStore.getState() /
useXStore.subscribe(...) anywhere (event handlers, services,
non-React code).ugm), the encoding spec
(encodingSpec), containment, and onReady (which hands you the
Cytoscape Core for camera and layout control).The ugm you hand to the props channel is yours to build. Give each
edge a key when your data already has a stable edge identity: the
UGM then uses it as the edge id, so selection, overlays, and a
"selected edge" in your URL survive a rebuild from the same data
(without a key, every rebuild generates fresh ids). Keys are unique
across the whole graph, and a duplicate throws.
import { UGM } from "@g3-toolkit/core";
const ugm = new UGM();
ugm.addNode("pump-1", { types: ["Asset"], properties: { name: "Pump" } });
ugm.addNode("site-north", { types: ["Site"], properties: {} });
const edgeId = ugm.addEdge("pump-1", "site-north", {
key: "pump-1|locatedAt|site-north", // your id, kept as-is
type: "locatedAt",
});
// edgeId === "pump-1|locatedAt|site-north"; toJSON/fromJSON and
// exportSubgraphJson carry it unchanged.
A useful consequence: a "custom button" is almost always one line of
store or function call in an onClick.
Icon, the g3t-btn / g3t-select / g3t-input CSS
classes, design tokens (--g3t-*). Use these to make YOUR controls
look native next to ours. The complete token vocabulary is in
design-tokens.md; check a name against that
table before using it, because a var() on a name we do not emit
falls back to your literal and silently ignores the theme forever.SearchBar, ZoomControls, SpecPort,
ThemeSwitcher, SpecLegend, ContextMenu. Single-purpose,
callback-driven; compose them into your own bars and panels.CytoscapeCanvas, GraphToolbar,
EncodingSpecPanel, AlgorithmPanel, LayoutManager,
StatsPanel, the view components. Opinionated assemblies wired to
the stores; drop them in whole, or rebuild them from molecules
using the same stores (GraphToolbar itself is the worked example:
read its source).GraphToolbar already contains a SearchBar. Its anatomy is
[search (flex)] [layout select] [options popover] [Run]. Placing
your own SearchBar beside the toolbar ships two search fields that
both drive the selection store, which is an easy and confusing
duplicate. Use one or the other.
One name for the handle: core. Every component that takes the
Cytoscape instance onReady hands you (GraphToolbar, Minimap)
takes it as core. GraphToolbar shipped it as cy; that name is
still accepted as a deprecated alias through 1.x and is removed in
the next major, so migrate to core when convenient. Resolution is
core ?? cy: when both are supplied, core wins. Development builds
warn once per component when only cy is given; production builds
are silent.
SearchBar needs no callback. onSearchChange is optional; the
bar drives the selection store on an explicit pick either way (this
is what GraphToolbar's internal SearchBar relies on). Pass it only
when your host also wants the live match set on every keystroke.
Single components are catalogued in capability-index.md. The leverage is in the pairings, and each demo shell exercises a near-disjoint slice of them, so reading the one shell closest to your domain teaches that slice and quietly hides the rest. These six are the ones worth knowing before you start.
Selection is the universal join. Every view component subscribes
to useSelectionStore itself. There is no selection prop to thread,
so adding four linked views is four JSX lines over one referentially
stable ugm. This is the single highest-leverage fact in the
library.
Emphasis is not selection. Selection is the user's current focus, one store, one treatment. Overlays are computed membership sets (algorithm results, SHACL tiers) with their own toggles. Reaching for selection to express "these nodes matter" fights the user for the cursor; register an overlay instead.
Register overlays inactive. register(overlay, false). An
overlay active at mount de-emphasizes every non-member, and a
reviewer meeting a mostly-muted graph reads it as a rendering fault
rather than as a filter. Let activation be their act. Overlays carry
an optional tone (neutral / info / warning / danger) so
distinct tiers stay visually distinct once activated.
Overlays beat your stylesheet. The canvas merges the stylesheet
prop after the theme and the encoding rules and BEFORE the g3t-ov-*
overlay rules (issue #63), so an ordinary node { border-width: 1 }
or edge { width: 1.5 } in your sheet styles every element except an
active overlay's members, where the overlay border, line width and
color win. Cytoscape resolves rules by order alone, not CSS
specificity, so this is the only ordering under which the documented
precedence (overlays outrank theme, spec and host chrome) holds. To
change how overlay members look, set the theme's overlayColor and
the semantic tone colors (error, warning, info), which the merge
applies last, rather than restating node.g3t-ov-member in your own
sheet, where a more specific selector would still lose on order. A
per-element bypass (ele.style(...)) outranks every rule, which is
why instance pins still shadow the overlay border on the pinned
element.
The context menu is a command bus. @g3-toolkit/core/events is a
context-action command bus, NOT a fourth integration channel. Add
your verbs as menu actions; do not build a general pub/sub on it.
Scale is a two-graph pattern. Large-graph work holds a detail graph and a cluster graph, and swaps which one the canvas renders. It is not a setting on one graph. See "Scaling: collapse large graphs to clusters" below.
Schema renders through a parallel pipeline. A shape graph is not your data graph with different colors; it projects through its own compartment API. See "Render a SHACL shape graph" below.
import { usePositionPinStore } from "@g3-toolkit/react";
function PinAllButton() {
const allPinned = usePositionPinStore((s) => s.allPinned);
return (
<button
className="g3t-btn"
aria-pressed={allPinned}
onClick={() => usePositionPinStore.getState().setAllPinned(!allPinned)}
>
{allPinned ? "Unpin all" : "Pin all"}
</button>
);
}
The canvas owns the locking: flipping the store flag is the whole job, and releasing returns to any per-node pins your users set via the context menu.
import { useRef } from "react";
import type { Core } from "cytoscape";
import { useSelectionStore, CytoscapeCanvas } from "@g3-toolkit/react";
function FocusButton({ cy, nodeId }: { cy: Core | null; nodeId: string }) {
return (
<button
className="g3t-btn"
onClick={() => {
useSelectionStore.getState().selectNodes([nodeId]);
const ele = cy?.getElementById(nodeId);
if (ele?.nonempty()) {
cy?.animate({ center: { eles: ele }, zoom: 1.4 }, { duration: 250 });
}
}}
>
Focus suspect asset
</button>
);
}
// cy comes from <CytoscapeCanvas onReady={setCy} />
The canvas also accepts layoutOptions, merged into the layout
object after the built-in tuning (caller wins; keyed by content so
inline literals never re-init the instance). Use it for per-view
spacing (idealEdgeLength, nodeRepulsion, padding) or to switch
fcose to end-mode animation (animate: "end") when per-tick
rendering is too heavy.
import { runGraphLayout, DEFAULT_LAYOUT_OPTIONS } from "@g3-toolkit/react";
<button
className="g3t-btn"
onClick={() => runGraphLayout(cy, "force", DEFAULT_LAYOUT_OPTIONS)}
>
Re-layout
</button>;
// Fourth argument `true` randomizes (the "shuffle" escape hatch).
import { useThemeStore } from "@g3-toolkit/react";
useThemeStore.getState().setTheme(orgSettings.darkMode ? "dark" : "light");
By default the store writes color-scheme and the --g3t-* tokens
inline on <html>, which also flips the host page's scrollbars and
form controls. If your app owns the page's light/dark/automatic
scheme, scope the writes to the element that wraps the graph views and
remove them when the last view unmounts:
import { useEffect, useRef } from "react";
import { useThemeStore } from "@g3-toolkit/react";
function GraphArea() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const { setThemeTarget, clearTheme } = useThemeStore.getState();
setThemeTarget(ref.current!);
return () => clearTheme();
}, []);
return <div ref={ref} className="graph-area" />;
}
setThemeTarget moves the current theme to the element (restoring
anything the store had written to <html>), and later setTheme
calls, ThemeSwitcher, and workspace snapshots write there too.
clearTheme removes the inline properties the store set, restoring
any the host had on the element, and keeps the store's theme and
target. The target scopes the DOM chrome (panels, search, legend,
context menu) and color-scheme; the Cytoscape canvas and the Minimap
read the theme object from the store, not CSS variables, so they
follow the theme wherever the target is. injectDesignTokens (the
spatial and typographic constants from @g3-toolkit/core) is separate
and still writes to <html>; nothing in it changes with the theme
except the dark shadow set.
g3t-view root class)Every view and panel root carries the class g3t-view, composed after
your own className and after the component's own class (g3t-table,
g3t-minimap), so selectors on those keep working. style.css sets
the toolkit face, size, line-height and ink on .g3t-view and nowhere
outside it: a view reads in the toolkit face whether or not the host
imports style.base.css, inline sizes inside a view still win, and
nothing on your page changes. Style the toolkit's text through it,
with one rule for every view or a scoped one for a region:
.g3t-view {
font-size: 14px;
}
.sidebar .g3t-view {
font-size: 12px;
}
import { TableView } from "@g3-toolkit/react";
<TableView ugm={ugm} className="host-table" />;
// renders <div class="g3t-table host-table g3t-view" ...>
The spec is plain serializable state YOU own:
import { useState } from "react";
import {
CytoscapeCanvas,
EncodingSpecPanel,
SpecLegend,
type EncodingSpec,
} from "@g3-toolkit/react";
const [spec, setSpec] = useState<EncodingSpec>(initialSpec);
<>
{/* Your button: */}
<button onClick={() => setSpec(riskViewSpec)}>Risk view</button>
{/* Our components: */}
<EncodingSpecPanel ugm={ugm} spec={spec} onChange={setSpec} />
<CytoscapeCanvas ugm={ugm} encodingSpec={spec} />
<SpecLegend ugm={ugm} spec={spec} />
</>;
parseEncodingSpec / serializeEncodingSpec round-trip it through
storage or URLs; reserved-channel violations are rejected by name.
By default SpecLegend (and FloatingLegend, which forwards these
props) heads each section with the encoding internals, color: types
and edge color: relation. For an end-user view, hand it the words
your readers use. sectionTitle receives the section channel and the
driver; labelFor receives the value and the same channel, so one
function can label node types and relation types differently. Edge
color rows come from the data (distinct driver values, colored by the
same resolver the canvas uses), so a categorical edge scale needs no
overrides to appear in the legend.
import { FloatingLegend, type LegendChannel } from "@g3-toolkit/react";
const words: Record<LegendChannel, string> = {
"node.color": "Node type",
"node.size": "Importance",
"node.shape": "Node type",
"node.icon": "Node type",
"edge.color": "Relation",
"edge.width": "Strength",
};
const relationLabels: Record<string, string> = { worksAt: "works at" };
<FloatingLegend
ugm={ugm}
spec={spec}
sectionTitle={(channel) => words[channel]}
labelFor={(value, channel) =>
channel === "edge.color" ? (relationLabels[value] ?? value) : value
}
/>;
There is no onNodeDoubleClick prop. Double-click is a Cytoscape
gesture (dbltap, which also covers touch), so bind it on the core
onReady hands you. NeighborhoodPopout is the usual target, but
any detail surface works:
import { useCallback } from "react";
import type { Core } from "cytoscape";
import type { UGM } from "@g3-toolkit/core";
import { CytoscapeCanvas, useSelectionStore } from "@g3-toolkit/react";
function GraphWithDetail({ ugm }: { ugm: UGM }) {
const handleReady = useCallback((cy: Core) => {
cy.on("dbltap", "node", (evt) => {
const id = evt.target.id();
useSelectionStore.getState().selectNodes([id]);
openYourDetailPanel(id);
});
// Background double-click: a conventional "fit to screen".
cy.on("dbltap", (evt) => {
if (evt.target === cy) {
cy.animate(
{ fit: { eles: cy.elements(), padding: 40 } },
{ duration: 200 },
);
}
});
}, []);
return <CytoscapeCanvas ugm={ugm} onReady={handleReady} />;
}
Bind inside onReady rather than in an effect over a stored cy: the
canvas re-initializes on a genuinely different input graph, and
handlers bound to a destroyed core are silently lost.
onReadyis load-bearing, and that is a known risk. Gestures, position capture,cy.fit, and ad-hoc class application all reach through the raw CytoscapeCore. That handle is a renderer implementation detail, so a future move toward renderer independence would break every consumer doing this at once. It is stable for 1.x. If you can express something through a store, a prop, or a document instead, prefer that; treatonReadyas the escape hatch it is, and keep your uses of it few and centralized.
FacetFilter emits the set of hidden TYPES. Map that to node ids and pass
it to the canvas as hidden; the canvas hides them with a class
(display:none) in a batched restyle, so node positions and the
Cytoscape instance survive. Do NOT feed a pre-filtered UGM as ugm: a new
ugm reference re-creates the instance and re-runs layout on every
toggle. Additive mode (next section) would remove the filtered nodes in
place, but a hidden node keeps its position and its edges for when the
filter lifts, and stays reachable for search, routes and export; a
removed node is laid out again when it returns.
import { useMemo, useState } from "react";
import { CytoscapeCanvas, FacetFilter } from "@g3-toolkit/react";
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
// A node is hidden only when ALL of its types are hidden; it stays
// visible while it still has any shown type (faceted-filter semantics).
const hidden = useMemo(() => {
const ids = new Set<string>();
if (hiddenTypes.size === 0) return ids;
ugm.forEachNode((id, attrs) => {
if (attrs.types.length > 0 && attrs.types.every((t) => hiddenTypes.has(t)))
ids.add(id);
});
return ids;
}, [ugm, hiddenTypes]);
<>
<FacetFilter ugm={ugm} onFilterChange={setHiddenTypes} />
<CytoscapeCanvas ugm={ugm} hidden={hidden} />
</>;
CI-executed in examples/wiring/src/wiring-examples.test.tsx. An
explorer that adds one neighborhood at a time, or collapses one, hands
the canvas a new ugm on every step, and a new identity is a different
graph: the instance is re-created, positions and the camera reset. With
graphUpdate="additive" a new ugm that shares at least one node with
the drawn one is the SAME SCENE and updates the live instance instead:
nodes the new ugm lacks leave in place with their incident edges, the
new nodes and edges join, every surviving node keeps its position, only
the new nodes are laid out (one fcose pass with every existing node
held by fixedNodeConstraint, seeded next to the neighbors they were
expanded from; a pure removal runs no layout), and the camera does not
move. The host still owns the change; it just builds each step from the
previous one.
import { useState } from "react";
import { UGM } from "@g3-toolkit/core";
import { CytoscapeCanvas } from "@g3-toolkit/react";
// Each step is a NEW ugm holding every node of the previous one plus
// the neighborhood. toJSON/fromJSON keeps the edge ids, and `key` names
// the ones you add, so the diff adds only what is new; generated ids
// differ per UGM instance and would replace every edge each step.
const [graph, setGraph] = useState<UGM>(ugm);
const expand = (nodeId: string) => {
const next = UGM.fromJSON(graph.toJSON());
for (const n of neighborhood(nodeId)) {
if (!next.hasNode(n.id))
next.addNode(n.id, { types: n.types, properties: n.properties });
next.addEdge(nodeId, n.id, { type: n.relation, key: `${nodeId}->${n.id}` });
}
setGraph(next);
};
// A collapse is the same recipe minus the nodes: the canvas removes
// them in place and nothing else moves.
const collapse = (nodeIds: readonly string[]) => {
const next = UGM.fromJSON(graph.toJSON());
for (const id of nodeIds) if (next.hasNode(id)) next.removeNode(id);
setGraph(next);
};
<>
<button onClick={() => expand(selectedIds[0])}>Expand</button>
<button onClick={() => collapse(selectedIds)}>Collapse</button>
<CytoscapeCanvas ugm={graph} graphUpdate="additive" />
</>;
The boundary is sharing a node. A new ugm with NO node in common with
the drawn one (a different subject, or an empty graph, which has no
scene to keep) takes the normal rebuild path and fits; so does a
containment change that re-parents a surviving node (removing a parent
whose children survive is not that: the children are moved to the top
level in place and keep their positions). Edges the new ugm no longer
has are removed in place, and drawn elements take the new ugm's data,
so a data-only change is also in place. animate and
animationDuration apply to the growth pass. Store entries that name
a removed node (a pin, a selection, an overlay member) are yours and
are left alone; the canvas skips ids it no longer draws, exactly as a
rebuild does.
Upgrading from 1.2.0: additive mode used to rebuild on any node
removal. A host that relied on a shrink forcing a fresh layout and fit
passes that step through graphUpdate="rebuild" (the prop is read per
render, so switching it for one step is fine), or hands the canvas a
ugm that shares no node with the drawn one.
import {
parseAlgorithmResult,
applyAlgorithmResult,
ingestAlgorithmResults,
type UGM,
} from "@g3-toolkit/core";
import { useOverlayStore } from "@g3-toolkit/react";
async function showCommunities(ugm: UGM) {
const json = await fetch("/api/algorithms/louvain").then((r) => r.text());
const doc = parseAlgorithmResult(json); // versioned, validated
const overlay = applyAlgorithmResult(ugm, doc, ingestAlgorithmResults);
if (overlay) useOverlayStore.getState().register(overlay);
// property-shaped results landed in the UGM: drive the spec from
// them (color by community) and the legend follows.
}
Overlays are the natural unit for a staged narrative: SHACL severity
tiers, algorithm phases, time slices, provenance steps. The store has
no notion of order, so sequencing is a layer over it:
useOverlaySequence holds the index and the transport, and on every
step it sets active EXACTLY the ids it owns. Every other overlay (a
path overlay, a tier registered elsewhere) keeps its state before,
during and after, because the hook merges into activeIds rather
than replacing them. Registration stays yours, and happens before the
sequence takes the ids:
import {
reportFromValidationResults,
severityOverlays,
} from "@g3-toolkit/core";
import {
useOverlayStore,
useOverlaySequence,
OverlayTransport,
} from "@g3-toolkit/react";
import { useEffect, useState } from "react";
// Worst tier last, so the cumulative walk ends on the full picture.
const TIER_ORDER = ["shacl-info", "shacl-warning", "shacl-violation"];
function SeverityWalkthrough() {
const [speedMs, setSpeedMs] = useState(1500);
const [loop, setLoop] = useState(false);
useEffect(() => {
const report = reportFromValidationResults(validationResults);
const tiers = severityOverlays(report);
for (const overlay of tiers) {
useOverlayStore.getState().register(overlay, false);
}
return () => {
for (const overlay of tiers) {
useOverlayStore.getState().unregister(overlay.id);
}
};
}, []);
// "cumulative": step N shows tiers 0..N; "single" shows only step N.
const sequence = useOverlaySequence({
ids: TIER_ORDER,
mode: "cumulative",
speedMs,
loop,
});
// Speed and loop controls render only when wired; drop them (and
// showReadout) to reduce the control to prev / play / next.
return (
<OverlayTransport
sequence={sequence}
speedMs={speedMs}
onSpeedChange={setSpeedMs}
loop={loop}
onLoopChange={setLoop}
/>
);
}
sequence carries index, length, isPlaying, play, pause,
next, prev and goTo for your own controls. Playback runs on
setInterval, cleared on pause and unmount; without loop the last
step holds for one interval and playback pauses. When the sequence
unmounts (or its ids change) it RESTORES the ids it owned to the
active state they had when it took them, so a tier the reviewer had
toggled on by hand comes back; it never touches the others. The
index-to-ids rule is the pure computeSequenceActiveIds(ids, index, mode), and stepSequence(index, direction, length, loop) is the
transport arithmetic, both exported for hosts that drive the store
from their own loop.
The base menu is functional with zero config, and follows a strict contract: items are wired or absent, never rendered dead. With no menuManager the canvas shows one item, a clipboard-wired copy whose label follows the element id ("Copy IRI" for schemed ids, the RDF case; "Copy ID" otherwise). "Inspect properties" appears only when you wire it, because only the host app knows its detail surface:
import { createDefaultMenuManager, useSelectionStore } from "@g3-toolkit/react";
const manager = createDefaultMenuManager({
// Selection IS the inspect surface in most g3t apps: the inspector,
// table, or lineage panel renders whatever is selected.
onInspect: (t) => {
if (t.id) useSelectionStore.getState().selectNodes([t.id]);
},
// idLabel: "iri" | "id" overrides the heuristic; onCopy replaces
// the clipboard behavior.
});
// <CytoscapeCanvas menuManager={manager} ... />
Register app-specific actions on the same manager (the playground's Scale surface does this live with "Drill into cluster"):
import { ContextMenuManager } from "@g3-toolkit/react";
const manager = new ContextMenuManager();
manager.register("my-app", [
{
id: "open-dossier",
label: "Open dossier",
filter: (t) => t.type === "node",
action: (t) => navigate(`/dossier/${t.id}`),
},
]);
// <CytoscapeCanvas menuManager={manager} ... />
registerToolkitActions registers the complete node/edge/multi-select
menu (pin, inspect, neighbors, focus, paths, appearance, hide, bulk
color). Store-backed items work immediately; the rest EMIT events, and
mounting the set without consuming them recreates dead menu items, so
wire every event you expose. The Analytics Dashboard in the playground
consumes all seven (CI-tested there); the pattern:
import { useEffect } from "react";
import { ContextMenuManager, registerToolkitActions } from "@g3-toolkit/react";
import { G3tEventBus } from "@g3-toolkit/core";
const bus = new G3tEventBus();
const manager = new ContextMenuManager();
registerToolkitActions(manager, { ugm, eventBus: bus, defaultHops: 2 });
useEffect(() => {
const unsubs = [
bus.on("context:focusNode", ({ nodeId, hops }) => {
// hide everything outside the k-hop neighborhood
}),
bus.on("context:findPath", ({ sourceId, targetId }) => {
// findShortestPath + select the route
}),
// context:viewNeighbors, context:hideNodes, context:viewSubgraph,
// context:editAppearance (mount NodeStyleEditor),
// context:pinNodes
];
return () => unsubs.forEach((u) => u());
}, [bus]);
Containers with typed compartment rows and boundary ports come back as a versioned geometry document of absolute boxes; rows are REAL elements, so selection, overlays, and badges apply to them like any node:
import { layoutStructural, isChainEdgeId } from "@g3-toolkit/core";
const geometry = await layoutStructural({
nodes: [
{
id: "sensor",
header: { stereotype: "Block", name: "Sensor" },
compartments: [
{
id: "attributes",
title: "attributes",
rows: [
{ id: "sensor.cal", text: "calibrationDate : xsd:date [1..1]" },
],
},
],
ports: [{ id: "sensor.out", side: "EAST" }],
},
{ id: "lens", header: { name: "Lens" } },
],
edges: [
{ id: "feeds", source: "sensor", target: "lens", sourcePort: "sensor.out" },
],
});
// geometry.nodes: absolute top-left boxes; rows carry parent,
// compartment, text, and a divider flag for compartment titles.
// geometry.ports: boundary positions with their declared side.
// Filter synthetic row-ordering edges anywhere you enumerate edges:
// isChainEdgeId(id).
//
// UML edge symbols (A3): set `kind` on a StructuralEdge for the
// relationship arrow vocabulary: "composition" (filled diamond at the
// source/whole end), "aggregation" (hollow diamond), "generalization"
// (hollow triangle at the target/parent end), "dependency" (dashed,
// open arrow), or "association" (plain arrow, the default).
Compartment collapse is NOT part of the surface. A layout-time
collapse input (compartmentKey, collapsedCompartments) and its
context-menu store shipped once and were removed by maintainer
ruling on 2026-07-10; see planning/expand-collapse-postmortem.md
for why, and read it before proposing anything equivalent. To hide
detail today, build the StructuralGraphInput with the rows you
want and re-run layoutStructural: the input IS the projection, so
a host that owns which rows it emits already owns collapse.
A SHACL shapes graph renders through the identical structural pipeline: shapes become containers, property constraints become compartment rows, and a validation report badges individual rows. No SHACL-specific renderer:
import {
shaclShapesToStructural,
shaclRowSeverities,
closedShapeIds,
layoutStructural,
} from "@g3-toolkit/core";
import { CytoscapeCanvas } from "@g3-toolkit/react";
const input = shaclShapesToStructural(shapes, {
references: { "PersonShape::worksFor": "OrgShape" }, // sh:node edges
});
const geometry = await layoutStructural(input);
// Pass closed/open borders and per-row severities as decorations:
<CytoscapeCanvas
ugm={shapeUgm}
structural={{ input, geometry }}
structuralDecorations={{
closedContainers: closedShapeIds(shapes),
rowSeverities: shaclRowSeverities(validationResults), // worst-wins
}}
/>;
A report renders by reusing the overlay + encoding machinery, no SHACL-specific canvas code. Conformance runs wherever (pyshacl, Jena, the in-core validator); the toolkit consumes a versioned document:
import {
validateShacl,
reportFromValidationResults,
severityOverlays,
shaclResultDrivers,
ingestAlgorithmResults,
} from "@g3-toolkit/core";
import { useOverlayStore } from "@g3-toolkit/react";
// parseShaclReport is NOT on the root: it lives on the /shacl
// subpath. Adding it to the import above fails to resolve.
import { parseShaclReport } from "@g3-toolkit/core/shacl";
const report = reportFromValidationResults(validateShacl(ugm, shapes));
// (or parseShaclReport(externalPyshaclReport) for an external engine)
// Severity tiers as independently toggleable overlays. Register them
// INACTIVE: activating at mount dims every non-finding node, and a
// reviewer meeting a mostly-muted graph reads it as broken rather
// than as filtered. Let the toggle be the reviewer's act.
for (const overlay of severityOverlays(report)) {
useOverlayStore.getState().register(overlay, false);
}
// Each tier carries a tone (violation -> danger, warning -> warning,
// info -> info), so an activated violation tier draws red and a
// warning tier amber rather than both drawing the same emphasis.
// Count + worst-severity as encoding drivers (color/size via the grammar):
ingestAlgorithmResults(ugm, shaclResultDrivers(report));
// then point spec.node.color at "_shacl_maxSeverity" and
// spec.node.size at "_shacl_resultCount".
When both canvases are open, cross-link them through the shared selection store: selecting a validation result highlights the focus node (data canvas) and the source shape's container plus the offending property row (shape canvas) at once. No new machinery:
import { resultSelectionIds, resultDetail } from "@g3-toolkit/core";
import { useSelectionStore } from "@g3-toolkit/react";
// On clicking a result in your report list:
useSelectionStore.getState().selectNodes(resultSelectionIds(result));
// -> selects [focusNode, sourceShapeContainer, propertyRow] across
// every canvas subscribed to the store. A node-level result omits
// the row; a result with no source shape selects only the node.
// For an inspector panel, shape the result for display:
const detail = resultDetail(result); // { focusNode, sourceShape, path, severity, message, value }
Canvas application (compound parents + preset row positions) is the next slice; the document is renderer-neutral, so you can already consume it for SVG export or your own drawing layer.
CI-executed in examples/wiring/src/wiring-examples.test.tsx.
ShaclShapeBrowser counts one result per focus node by default. A
host whose report is already summarized (pyshacl, Jena, a SPARQL
COUNT) hands the totals over through summary, which wins over
counting for the shapes it names, so passing focus nodes need no
placeholder results. selectedShape / onSelectShape make the
selection controlled; the selected shape renders expanded, so a shape
chosen from the URL opens on load. Leave both props off and the
browser keeps its own expanded state as before. A shape declares every
target it has through targets; targetClass stays the class the
validator matches:
import { useState } from "react";
import { ShaclShapeBrowser, type ShaclShape } from "@g3-toolkit/react";
const shapes: ShaclShape[] = [
{
id: "ex:QuoteShape",
targetClass: "ex:Quote",
targets: [
{ kind: "class", value: "ex:Quote" },
{ kind: "subjectsOf", value: "ex:quoteVerified" },
],
properties: [
// Two property shapes on one path: give each its own id.
{ id: "ex:QuoteShape-verified", path: "ex:quoteVerified", minCount: 1 },
{
id: "ex:QuoteShape-verified-bool",
path: "ex:quoteVerified",
datatype: "boolean",
},
],
},
];
// From the URL: /shapes?selected=ex:QuoteShape
const [selected, setSelected] = useState<string | null>("ex:QuoteShape");
<ShaclShapeBrowser
shapes={shapes}
validationResults={validationResults}
// Per-shape totals from the report; bySeverity sets the badge tier.
summary={{
"ex:QuoteShape": { focus: 120, failing: 3, bySeverity: { warning: 3 } },
}}
selectedShape={selected}
onSelectShape={setSelected} // null when the selected shape collapses
/>;
// The badge exposes its tier for host CSS or tests:
// [data-testid="shape-badge-ex:QuoteShape"][data-severity="warning"]
CI-executed in examples/wiring/src/wiring-examples.test.tsx. Neither
SchemaModel nor the UGM registry carries subclass edges, so the host
passes rdfs:subClassOf as class -> parent(s); a parent that is not a
listed class leaves its child at the top level. Every shape targeting
a class counts toward its badge (constraints, shapes), and with
results the badge also reports the most severe status (violation >
warning > info, or conforms). A shape targets every class named in
targetClass and in targets of kind class (#56), and because
sh:targetClass selects subclass instances too, a shape declared on a
superclass also appears on each subclass, listed after the direct
shapes and marked (data-inherited-from, the
g3t-schema-shape-inherited class, N inherited on the badge). A
class with several parents renders once under each of them; the copies
share one record, so their badges agree, and each card carries its own
data-parent and a root-to-card data-path:
import { SchemaView, TreeView } from "@g3-toolkit/react";
<SchemaView
schema={{
nodeTypes: ["Agent", "Person"],
edgeTypes: [],
nodeProperties: { Person: ["name", "email"] },
edgeProperties: {},
}}
subClassOf={{ Person: "Agent" }}
shapes={shapes}
validationResults={validationResults}
/>;
// TreeView: several roots render as a forest (no synthetic parent row),
// initialDepth only SEEDS expansion (every row toggles afterwards), the
// [type] prefix is optional, and the rows form a WAI-ARIA tree: one tab
// stop, ArrowUp/Down move, ArrowRight opens or enters, ArrowLeft closes
// or climbs, Home/End jump, Enter or Space selects.
<TreeView
ugm={ugm}
rootIds={["owl:Thing", "prov:Entity"]}
initialDepth={1}
showType={false}
/>;
CI-executed in examples/wiring/src/wiring-examples.test.tsx. Issue
#59 added what a host needs to replace its own keyboard tree with
TreeView for a class, property or vocabulary rail:
filter (a string, case-insensitive on the label, or a predicate
over the row) keeps the matches plus their ancestors and HOLDS those
ancestors open while it is active: their chevron is inert and
ArrowLeft climbs instead of closing. Clearing it restores the
expansion the user had. No match renders a filter-specific empty
state (tree-view-filter-empty).renderLabel(node, ctx) replaces the label text; ctx carries
depth, expanded, selected, group and childCount (children
in the hierarchy, filter or not, so a count badge holds still while
the host types). The [type] prefix and chevron stay around it.isGroup(node) marks expand-only rows ("Object properties" over its
members): click, Enter and Space toggle instead of selecting, the
row carries no aria-selected, and data-group="true" is there for
host CSS. It is a predicate rather than a reserved node property
because the UGM data model belongs to the adapter and a host's group
rows are usually synthetic nodes it injected itself.selected (a string or null) makes the selection CONTROLLED: the
tree highlights that id, writes nothing to useSelectionStore, and
ignores what the store holds, so a URL-driven rail no longer collides
with the canvas or table on the same page. onSelect is then the
only channel. Omit selected and the tree reads and writes the store
as before (the default path); do not switch between the two after
mount.selected, including the one present at mount) opens the branch
holding it and calls scrollIntoView({ block: "nearest" }) on the
row. A pick made in the tree does neither.initialExpanded seeds ids open in addition to initialDepth (same
seed semantics: toggles survive a reseed), and ariaLabel names the
tree (default "Hierarchy").data-path; expansion and focus are per copy), selection is
the node id (selecting one copy highlights every copy, and the
tree-node-<id> testid repeats), a declared root is still never
repeated as a descendant, and a cycle is cut where a node is already
on the current path. The working-set limit counts rows, so a repeated
node counts each time.import { useState } from "react";
import {
TreeView,
type TreeRowNode,
type TreeLabelContext,
} from "@g3-toolkit/react";
function ClassRail() {
// URL-driven: the route owns the selection, not the shared store.
const [selectedClass, setSelectedClass] = useState<string | null>(
"ex:Requirement",
);
const [query, setQuery] = useState("");
return (
<>
<input
aria-label="Filter classes"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<TreeView
ugm={ugm}
rootIds={["owl:Thing"]}
ariaLabel="Classes"
showType={false}
initialDepth={1}
initialExpanded={["ex:Artifact"]}
filter={query}
isGroup={(node: TreeRowNode) => node.type === "Group"}
renderLabel={(node: TreeRowNode, ctx: TreeLabelContext) => (
<span style={{ fontStyle: ctx.group ? "italic" : undefined }}>
{node.label}
{ctx.childCount > 0 && (
<span title={`${ctx.childCount} subclasses`}>
{" "}
({ctx.childCount})
</span>
)}
</span>
)}
selected={selectedClass}
onSelect={(id) => {
setSelectedClass(id);
navigate(`/classes/${encodeURIComponent(id)}`);
}}
/>
</>
);
}
CI-executed in examples/wiring/src/wiring-examples.test.tsx. The
ProvenanceTrace panel renders a pre-order hop chain (any lineage your
app derives; the auditor shell walks PROV-O edges) with tiers, edge
details, and ABSENCE hops for evidence that should exist and does not:
import { ProvenanceTrace, type ProvenanceChain } from "@g3-toolkit/react";
const chain: ProvenanceChain = [
{ id: "rel", tier: "entity", label: "Release 1.2", depth: 0 },
{
id: "build",
tier: "activity",
label: "CI build",
detail: "wasGeneratedBy",
depth: 1,
parentId: "rel",
},
{
id: "rel::gap",
tier: "gap",
label: "No attribution recorded",
depth: 1,
parentId: "rel",
leaf: true,
absence: true,
},
];
<ProvenanceTrace
chain={chain}
title="Lineage"
onSelectHop={(id) => console.log(id)}
/>;
CI-executed in examples/wiring/src/wiring-examples.test.tsx;
demonstrated in the Provenance Auditor shell. A graph canvas is
opaque to assistive technology, so AriaCompanion renders the same
graph as a hidden, focusable, degree-ordered node list (focus selects,
arrow right follows an edge) and one polite live region. The host owns
the wording that only it knows: pass it as announcement (here through
useAnnouncer), let the component debounce rapid changes, mute it
while an animation plays, mirror the canvas hidden set so the list
and the canvas agree, and name the list for your domain:
import { useEffect } from "react";
import {
AriaCompanion,
useAnnouncer,
useSelectionStore,
} from "@g3-toolkit/react";
// Your chain, however you derive it (the auditor walks PROV-O edges).
const chain: string[] = ["ent:release", "act:approve", "ent:review"];
// The same set you pass to CytoscapeCanvas `hidden`.
const hidden = new Set<string>();
function Narrated({ playing }: { playing: boolean }) {
const selectedId = useSelectionStore(
(s) => [...s.selectedNodeIds][0] ?? null,
);
const { announcement, announce } = useAnnouncer();
useEffect(() => {
if (selectedId === null) return;
const name = String(ugm.getNode(selectedId)?.properties.name);
const step = chain.indexOf(selectedId) + 1;
announce(
step > 0
? `Lineage step ${step} of ${chain.length}: ${name}`
: `Selected ${name}`,
);
}, [selectedId, announce]);
return (
<AriaCompanion
ugm={ugm}
hidden={hidden}
label="Provenance nodes"
instructions="Focusing a node selects it on the canvas; arrow right follows an edge."
summarizeNode={(g, id) =>
`${String(g.getNode(id)?.properties.name)}, ${g.getNodeEdges(id).length} provenance edges`
}
announcement={announcement}
announceDebounceMs={300}
muted={playing}
/>
);
}
The region only speaks on a DOM change, so an unchanged re-render and
a repeated identical message are silent by construction. Give the
canvas region itself an accessible name (aria-label on the element
that wraps the canvas) and a one-line aria-describedby saying the
canvas is decorative and how to reach the list. useAnnounce (the
older export) is deprecated: it never reached a region.
All of these run under CI in examples/wiring/src/wiring-examples.test.tsx
("table view recipes" describe).
TableView is a UGM adapter by default (one row per node, columns
from the property registry), but tabular data that is not a graph
(SPARQL SELECT bindings, a named-graph register, a validation-result
list) does not need converting first. Pass rows and columns; a
column names a value reader, optionally a render, and an inline
filter kind ("text" for a substring match, "values" for a
checklist over the distinct values with counts, "range" for a
numeric min/max, "none"). Leave filter out and the kind is
inferred from the column's values: "range" when every present value
is a number, "values" when the distinct values number at most 20
and at most half the rows, "text" otherwise; an explicit kind
always wins. renderCell is a table-wide override on either path
(return undefined to fall back to the column). selection decides
where a row click goes: "store" (default, the shared selection
store, right for a view linked to a canvas), "none" for a standalone
register, or { selected, onSelect } to keep it in your own state;
the host receives the full next set, modifier rules already applied.
import { useState } from "react";
import { TableView, type DataGridColumn } from "@g3-toolkit/react";
interface Binding {
s: string;
label: string;
count: number;
}
const columns: DataGridColumn<Binding>[] = [
{ id: "s", header: "Subject", value: (r) => r.s },
{
id: "label",
header: "Label",
value: (r) => r.label,
render: (v, r) => <a href={r.s}>{String(v)}</a>,
},
{ id: "count", header: "Count", value: (r) => r.count, filter: "range" },
];
function Register({ rows }: { rows: Binding[] }) {
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
return (
<TableView
rows={rows}
columns={columns}
rowId={(r) => r.s}
selection={{ selected, onSelect: (ids) => setSelected(new Set(ids)) }}
/>
);
}
Four toolbar affordances, each off by default so the chrome you have
does not change. exportCsv adds an "Export CSV" button that writes
the FILTERED rows in the current sort, the visible columns in their
current order, as RFC 4180 text from cell values (not rendered
nodes); pass { filename, download } to name the file or to replace
the browser download with your own sink. search adds one box above
the per-column filters, over the VISIBLE columns only (hidden columns
and filter: "none" columns are skipped, and hiding a column drops
its matches at once), stacked on the per-column filters, with the
"N of M rows" count following it. The query is split on whitespace
and every word must appear, as a case-insensitive substring,
somewhere in the row, not necessarily in the same cell (alice acme
finds the row whose name is Alice and whose org is Acme); wrap a
phrase in double quotes to match it as one substring.
The column menu's move buttons reorder columns; hand the table a
columnOrder (TanStack's shape: listed ids first, the rest after in
definition order) and onColumnOrderChange to persist it, or omit
columnOrder and only listen. A "values" column is a checklist
with All/None shortcuts; several ticks are a union within the column,
and the counts follow the other filters and the search.
import { useState } from "react";
import { TableView, type DataGridColumn } from "@g3-toolkit/react";
interface Binding {
s: string;
label: string;
kind: string;
}
const columns: DataGridColumn<Binding>[] = [
{ id: "s", header: "Subject", value: (r) => r.s },
{ id: "label", header: "Label", value: (r) => r.label },
{ id: "kind", header: "Kind", value: (r) => r.kind, filter: "values" },
];
function Register({ rows }: { rows: Binding[] }) {
// Persist the order wherever your settings live (here: local state).
const [order, setOrder] = useState<string[]>(["kind", "label", "s"]);
return (
<TableView
rows={rows}
columns={columns}
rowId={(r) => r.s}
selection="none"
search
exportCsv={{ filename: "bindings.csv" }}
columnOrder={order}
onColumnOrderChange={setOrder}
/>
);
}
getCsv is the pure half of the export, exported for a host that
owns its own TanStack table or wires its own button: give it the rows
of a row model (table.getSortedRowModel().rows is the filtered set
in the current sort) and the columns to write (table .getVisibleLeafColumns() applies visibility and order), and it
returns the text. It is headless, so it runs in a test; the browser
download is the only part that needs a DOM.
import { getCsv } from "@g3-toolkit/react";
// hostTable: your own useReactTable(...) instance.
const csv = getCsv(
hostTable.getSortedRowModel().rows,
hostTable.getVisibleLeafColumns(),
);
What a host replacing its own TanStack table needs beyond the recipes
above. sorting and onSortingChange control the sort (TanStack's
shape, { id, desc }[], first rule outermost) the way columnOrder
does, so it can live in the URL; initialSorting seeds an
uncontrolled table instead. A column with hidden: true starts
toggled off but stays in the column menu. A column's csv reader
replaces the cell value in the export (a cell that renders a local
name exports the full IRI); getCsv takes the same hook as its third
argument, { value: (row, column) => ... }. emptyMessage is what
the body shows when nothing matches (a role="status" row; the
default names the path's noun). toolbar renders your own controls
beside the Export CSV button. pageSizeOptions adds a rows-per-page
select to the pagination bar, which also carries first/last buttons
now; pass onPageSizeChange and set pageSize yourself, or leave it
out and the pick is held internally. Per column, align: "num"
right-aligns on tabular figures, width sizes the header, and
title is the header tooltip. Two things happen without a prop:
repeated rowIds are suffixed #2, #3 in data order so every row
stays addressable, and when the column SET changes (one SPARQL result
replacing another with different variables) filters and the internal
sort on columns that left are dropped, so they do not resurface when
the variable comes back; a controlled sort is reported back pruned
through onSortingChange.
import { useState } from "react";
import {
TableView,
type DataGridColumn,
type TableSort,
} from "@g3-toolkit/react";
interface Binding {
iri: string;
label: string;
statements: number;
graph: string;
}
const columns: DataGridColumn<Binding>[] = [
{
id: "label",
header: "Label",
title: "The rdfs:label, or the IRI's local name",
value: (r) => r.label,
csv: (r) => r.iri, // the export carries the full IRI
width: 240,
},
{
id: "statements",
header: "N",
title: "Statements about the subject",
value: (r) => r.statements,
align: "num", // right-aligned, tabular figures; filter inferred as range
},
{ id: "graph", header: "Graph", value: (r) => r.graph, hidden: true },
];
function Register({ rows }: { rows: Binding[] }) {
// The sort lives wherever your URL state does (here: local state).
const [sorting, setSorting] = useState<TableSort[]>([
{ id: "statements", desc: true },
]);
const [pageSize, setPageSize] = useState(25);
return (
<TableView
rows={rows}
columns={columns}
rowId={(r) => r.iri}
selection="none"
sorting={sorting}
onSortingChange={setSorting}
pageSize={pageSize}
pageSizeOptions={[25, 100, 500]}
onPageSizeChange={setPageSize}
emptyMessage="No subjects match. Clear a filter to widen the set."
exportCsv={{ filename: "subjects.csv" }}
toolbar={
<button type="button" onClick={() => exportJson(rows)}>
Export JSON
</button>
}
/>
);
}
By default the table sizes to its CONTENT, which is wrong for a panel
that has a definite height: rows run off the bottom and take the
Prev/Next controls with them. fill makes the root a flex column at
100% of the host, the body scrolls under a sticky header, and the
pagination bar stays pinned (the rules live in
@g3-toolkit/react/style.css; the regions carry stable classes,
g3t-table-toolbar, g3t-table-scroll, g3t-table-pagination, for
your own overrides). pageSize is live, so a count you compute from
the host applies on the next render without a remount; or pass
rowHeight with autoPageSize and the table measures its scroll
region itself and re-fits on resize.
import { TableView } from "@g3-toolkit/react";
<div style={{ height: 480 }}>
<TableView ugm={ugm} fill rowHeight={28} autoPageSize density="compact" />
</div>;
Pagination bounds the DOM by default, which is the wrong lever when
the analyst wants to scroll one long list (a SPARQL result of a few
thousand bindings) rather than page through it. virtualize renders
only the rows in and around the scroll window of the fill body,
with spacer rows holding the table's height so the scrollbar is
honest and the header stays sticky; it is @tanstack/react-virtual
underneath, a regular dependency of @g3-toolkit/react, nothing to
install. It engages per page once the page holds more than
threshold rows (default 150), so a small table's DOM is exactly the
unvirtualized one; overscan (default 10) is the rows rendered
beyond the window on each side. Give it a large pageSize (the
working-set limit is 10,000) and a scrolling body: fill, or your own
height and overflow on .g3t-table-scroll. rowHeight is the size
estimate when given; without it rows are measured as they mount.
autoPageSize is ignored while virtualize is set, because it sizes
the page to the viewport and that is the one thing virtualization
makes unnecessary. Selection, renderCell, the per-column filters
and their value counts, search and the CSV export all read the row
model, not the DOM, so they see every row on the page; a row holding
keyboard focus (a link or button in a cell) stays mounted while you
scroll.
import { TableView, type DataGridColumn } from "@g3-toolkit/react";
interface Binding {
s: string;
label: string;
}
const columns: DataGridColumn<Binding>[] = [
{ id: "s", header: "Subject", value: (r) => r.s },
{ id: "label", header: "Label", value: (r) => r.label },
];
function LongRegister({ rows }: { rows: Binding[] }) {
return (
<div style={{ height: 480 }}>
<TableView
rows={rows}
columns={columns}
rowId={(r) => r.s}
fill
rowHeight={24}
pageSize={10_000}
virtualize={{ overscan: 20 }}
exportCsv
/>
</div>
);
}
WorkingSetManager owns every per-view limit (canvas 500, table
10,000, tree 1,000, matrix 200, sankey 100, streaming 500), and since
issue #6 its answer says what to DO about an excess, not only whether
there is one. checkLimit returns strategy ("allow" within the
limit; otherwise the view type's row of DEFAULT_LIMIT_STRATEGIES:
canvas, tree and streaming "refuse", table and matrix "page", sankey
"truncate") and pageSize (the limit), with allowed kept for the
callers that only ask the old question. Pass a third argument to
override the row for one call. Both run under CI in
examples/wiring/src/wiring-examples.test.tsx ("working-set limits"
describe).
import {
WorkingSetManager,
pageNodes,
pageCount,
type PageWindow,
} from "@g3-toolkit/core";
const limits = new WorkingSetManager();
const check = limits.checkLimit("matrix", 350);
// { allowed: false, limit: 200, requested: 350, strategy: "page", pageSize: 200 }
if (check.strategy === "page") {
const page = pageNodes(ugm, { offset: 0, size: check.pageSize });
const window: PageWindow = page.window;
// { offset: 0, size: 200, total: 350, totalExact: true }
const pages = pageCount(window); // 2
const next = pageNodes(ugm, { offset: window.size, size: window.size });
void pages;
void next;
}
A page is only meaningful over an ordered result, so pageNodes and
pageCategories sort by id (a code-unit comparison, the same in every
locale) before cutting; the same request returns the same page on
every call, and a comparator in order replaces the sort with id as
the tie-break. The descriptor is offset-based: the in-memory UGM has
no cursor layer, and totalExact: false is reserved for a backend
that answers with an estimate (the control below then says "about").
MatrixView pages itself: past maxSize types (default 200, the
R7.3 number) it renders a maxSize x maxSize window with one
PaginationControl per axis, rows and columns independent, so a
350x350 request opens at 200x200 and every cell is two clicks away at
most. Feed it the limit from your manager so a deployment override
reaches the view. The same control pages anything of yours; it holds
no state, so a zero-based page, the pageSize and the total are
all it needs, and ArrowLeft/ArrowRight/Home/End work from anywhere
inside it.
import { useState } from "react";
import { WorkingSetManager, pageNodes } from "@g3-toolkit/core";
import { MatrixView, PaginationControl } from "@g3-toolkit/react";
const limits = new WorkingSetManager();
function TypeMatrix() {
return <MatrixView ugm={ugm} maxSize={limits.getLimit("matrix")} />;
}
function NodeRegister() {
const [page, setPage] = useState(0);
const size = 50;
const { items, window } = pageNodes(ugm, { offset: page * size, size });
return (
<>
<ul>
{items.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
<PaginationControl
page={page}
pageSize={size}
total={window.total}
totalExact={window.totalExact}
noun="nodes"
onPageChange={setPage}
/>
</>
);
}
Not in this slice: TableView keeps its own TanStack bar (adopting
the shared control there is the follow-up), and canvas and tree still
refuse past their limits, because paging a layout is a different
problem from paging a list.
Box selection is on by default (boxSelectionEnabled: true), but with
panning also enabled cytoscape treats a plain background drag as a PAN;
box mode engages only while a multi-select modifier is held. So:
shift+drag (or ctrl/cmd+drag) the background to box-select; the
box-selection sync pushes the picked nodes into useSelectionStore
like any other selection. (Implementation note: cytoscape emits
boxend BEFORE applying the box's selection, so the sync collects the
per-element box events instead of reading :selected in boxend;
see box-selection-sync.ts.) If your app prefers plain-drag box selection,
disable user panning on the canvas and offer another pan affordance.
The "Patterns/Coordinated Selection" story demonstrates it live.
Subscribe to stores from anything:
import { useSelectionStore } from "@g3-toolkit/react";
// React: your detail pane follows the toolkit selection
function MyDossierPane() {
const selected = useSelectionStore((s) => [...s.selectedNodeIds]);
return <DossierLookup ids={selected} />;
}
// Non-React (services, telemetry, process orchestration):
const unsubscribe = useSelectionStore.subscribe((state) => {
processEngine.notify("selection", [...state.selectedNodeIds]);
});
Workspace snapshots make the WHOLE working state portable into your persistence and process layer:
import {
captureWorkspace,
applyWorkspace,
serializeWorkspace,
parseWorkspace,
} from "@g3-toolkit/react";
const snapshot = captureWorkspace({ cy, spec });
await saveToCase(caseId, serializeWorkspace(snapshot)); // your storage
// later, possibly another session:
applyWorkspace(parseWorkspace(saved), { cy, setSpec });
CI-executed in examples/wiring/src/wiring-examples.test.tsx
("projection pipeline" describe). The pipeline turns a triple graph
into the labeled-property graph the views render; the collapse steps
are what make an RDF dataset legible on a canvas (rdf:type becomes the
node's type, literals become properties, blank nodes and RDF lists
resolve into the structures they encode).
import { createPresetPipeline } from "@g3-toolkit/core";
const ugm = createPresetPipeline("standard").project(rdfGraph);
// rdf:type -> node.types; literals -> node.properties; blank-node and
// list structures resolved. Presets: "standard", "ontology",
// "provenance-preserving".
Compose your own step set when the presets don't fit:
import { ProjectionPipeline, typeCollapse } from "@g3-toolkit/core";
const p = new ProjectionPipeline();
p.addStep({ name: "Type Collapse", transform: typeCollapse, enabled: true });
const ugm = p.project(rdfGraph);
p.getSteps(); // inspectable, so a UI can show or toggle steps
Edge ids are a function of the triple, not of the projection run.
Every resource-object triple becomes one edge whose id is
tripleEdgeKey(subject, predicate, object), the same key the SPARQL
adapter uses, so projecting the same input twice gives the same ids,
a triple listed twice (a SELECT that binds it in two rows) is one
edge rather than two parallel ones (first occurrence wins), and a host
can address an edge by its triple without reading the graph. The key
begins with <, which no IRI, CURIE or blank-node label can, so it
never collides with a node id in the one id space Cytoscape gives
nodes and edges. The optional fourth argument is a named graph: the
pipeline and the adapter merge graphs and never pass one, so it is for
a host that keys its own quads and keeps graphs distinct.
import { tripleEdgeKey } from "@g3-toolkit/core";
const edgeId = tripleEdgeKey(
"http://example.org/p53",
"http://example.org/regulates",
"http://example.org/mdm2",
);
ugm.getEdge(edgeId); // the projected edge, by its triple
The biomedical playground shell renders this live: its canvas toggle
shows the raw triple view beside the projected one, and its caption
lists the preset's step names straight from getSteps().
CI-executed in examples/wiring/src/wiring-examples.test.tsx. When a
graph exceeds what the renderer handles comfortably (~5k nodes),
collapse it to community supernodes; the full graph stays in your UGM
and the canvas sees dozens of nodes. Drill in with buildSubgraph.
The playground's Scale surface runs this live over 8,000 nodes with
the measured timings on screen.
import { collapseByCluster, buildSubgraph } from "@g3-toolkit/core";
const {
ugm: clustered,
members,
collapsed,
} = collapseByCluster(big, {
threshold: 2000, // below this, returned unchanged
maxSupernodes: 200, // smallest communities pool into one "other"
rng: seededRng, // deterministic Louvain (optional)
// clusterProperty: "team", // or skip detection: group by a property
});
// Drill-in: the induced member subgraph, working-set capped.
const memberIds = members.get("cluster:c3") ?? [];
const { ugm: sub, truncated } = buildSubgraph(big, memberIds, 1500);
Supernodes carry memberCount (drive the size channel with it),
typeBreakdown, and a label like "Person cluster (847)"; inter-cluster
edges aggregate into weighted cluster-link edges. This is Approach 1
of planning/large-graph-design.md; Approach 4 (worker layout with
viewport culling, for drilled sets past ~5k) is designed but not yet
implemented. To grow a drilled-in set one neighborhood at a time
without re-laying out what is already drawn, pass each step as a
new ugm built from the previous one with graphUpdate="additive"
("Grow or shrink a drawn graph without rebuilding it", above).
CI-executed in examples/wiring/src/wiring-examples.test.tsx (the pure
rule; the prop is covered by the canvas tests). Cytoscape draws every
label at its element and never moves one out of another's way, so a
dense graph (a citation network in layered rows, say) overprints labels
until the user zooms in. labelCollision is an overlay pass over the
rendered label boxes: after layout settles, after a zoom ends, after a
node is dropped, and on every selection or hover change, the canvas
measures each visible label, walks them by priority, and hides (or in
"shorten" mode first truncates) a label whose box overlaps one already
placed. Priority is selected (an edge counts when either endpoint is) >
hovered (hoveredNodeId in the selection store, incident edges
included) > your rank > node before edge > node degree or edge weight.
Restyle only: no re-init, no refit, no node moves, and the label field
itself is never written (hide is a class, shorten is a _labelShort
field read by a scoped rule), so the encoding spec's label channel and
this pass cannot fight.
import { CytoscapeCanvas } from "@g3-toolkit/react";
<CytoscapeCanvas
ugm={ugm}
labelCollision="shorten" // or "hide"; "none" is the default
labelMaxLength={14} // characters, ellipsis included
// Your rank within a tier. Omit it to read the `_labelPriority`
// element data field instead (0 when absent).
labelPriority={(ele) =>
typeof ele.data("citations") === "number" ? ele.data("citations") : 0
}
/>;
Hosts with their own renderer get the same decision rule without the canvas: measure your label boxes in any one coordinate space, compose the priority the canvas uses, and apply the decisions yourself.
import {
resolveLabelCollisions,
composeLabelPriority,
type LabelCandidate,
} from "@g3-toolkit/react";
const labels: LabelCandidate[] = [
{
id: "p1",
kind: "node" as const,
text: "Deep residual learning",
box: { x: 0, y: 0, w: 140, h: 12 },
priority: 0,
},
{
id: "p2",
kind: "node" as const,
text: "Attention is all you need",
box: { x: 120, y: 4, w: 160, h: 12 },
priority: 0,
},
].map((l) => ({
...l,
priority: composeLabelPriority({
kind: l.kind,
selected: selectedIds.has(l.id),
explicit: 0,
}),
}));
const { decisions, shortened } = resolveLabelCollisions(labels, {
mode: "shorten",
maxLength: 14,
});
// decisions: Map<id, "show" | "hide" | "shorten">; shortened holds the
// truncated text for every "shorten". Deterministic: equal priorities
// break node before edge, then by id.
A render-phase throw unmounts every React ancestor, so without a boundary somewhere above the canvas the user gets a blank page: no message, no reload, nothing to act on. There is no hook form of an error boundary, so the toolkit ships the component.
import { ViewErrorBoundary, CytoscapeCanvas } from "@g3-toolkit/react";
export function Panel() {
return (
<ViewErrorBoundary
// Your reporting. `info.componentStack` locates the throw in a
// production build, where the message alone will not.
onError={(error, info) => logError(error, info.componentStack)}
fallback={({ error, retry }) => (
<div>
<p>The graph could not render: {error.message}</p>
<button onClick={retry}>Try again</button>
</div>
)}
>
<CytoscapeCanvas ugm={ugm} />
</ViewErrorBoundary>
);
}
Omit fallback for a built-in message and retry button. retry
remounts the subtree, which is enough for a transient failure; it is
NOT enough for a React.lazy whose import rejected, because lazy
caches its rejection permanently. If you code-split a view, build a
new lazy from the loader when retry fires.
An ASYNC failure never reaches a boundary, so the hooks report it
themselves. useStructuralLayout returns an error channel alongside
the scene, because a rejected layout and a layout still in flight both
leave structural null and a host that only checks for null spins a
loading state forever:
import type { StructuralGraphInput } from "@g3-toolkit/core";
import { useStructuralLayout } from "@g3-toolkit/react";
export function StructuralPanel({ input }: { input: StructuralGraphInput }) {
const { structural, error } = useStructuralLayout(input);
if (error) return <p>Layout failed: {error.message}</p>;
if (!structural) return <p>Laying out…</p>;
return <pre>{JSON.stringify(structural.geometry.nodes, null, 2)}</pre>;
}
The error belongs to the input that produced it: switching inputs clears it, and a later successful layout of the same input clears it too.
Working-set limits (canvas 500, table 10,000, tree 1,000, matrix 200,
sankey 100, streaming 500 by default) are a property of the hardware a
deployment runs on, so they are configuration, not code. A platform
administrator writes one versioned document per deployment; the host
resolves it once at setup into a manager and mounts a provider around
the views. Every view that enforces a limit (TreeView, MatrixView, and
expandNeighbors through the manager you hand it) reads it from the
nearest provider; an explicit workingSetManager prop wins over the
provider, and with neither the module default keeps today's behavior.
import { WorkingSetManager, parseWorkingSetConfig } from "@g3-toolkit/core";
import { WorkingSetProvider, TreeView, MatrixView } from "@g3-toolkit/react";
// Resolve ONCE at host setup, outside render: the manager's identity
// is what carries any user override.
const manager = WorkingSetManager.fromConfig(
parseWorkingSetConfig(deploymentConfigText),
);
function Workbench() {
return (
<WorkingSetProvider manager={manager}>
<TreeView ugm={ugm} />
<MatrixView ugm={ugm} />
</WorkingSetProvider>
);
}
The document (every view and every field optional):
{
"version": 1,
"limits": {
"canvas": { "default": 300, "ceiling": 800 },
"tree": { "ceiling": 2000 },
"matrix": { "default": 150 }
}
}
Two numbers per view, from spec decision D5: default is the soft
limit an analyst may raise with an explicit acknowledgment; ceiling
is the hard limit no user action can exceed. Precedence, lowest to
highest: built-in defaults, then the deployment document (whose
ceiling also bounds everything beneath it), then a per-user override.
parseWorkingSetConfig throws the same typed DocumentParseError
subclasses as the other versioned documents (unknown view names and a
default above its own ceiling are rejected rather than ignored),
so a broken document fails at setup instead of configuring nothing.
The "explicit user action to exceed" rule goes through the manager, which answers with a typed result rather than a throw:
import {
useWorkingSetManager,
workingSetLimitMessage,
} from "@g3-toolkit/react";
function ShowAllNotice({ requested }: { requested: number }) {
const manager = useWorkingSetManager();
const notice = workingSetLimitMessage(manager, "canvas", requested);
if (notice.kind === "within") return null;
if (notice.kind === "refused") return <p role="status">{notice.message}</p>;
// "prompt": the request is above the soft default but under the ceiling.
return (
<p role="status">
{notice.message}{" "}
<button onClick={() => manager.setUserOverride("canvas", requested)}>
Show all
</button>
</p>
);
}
canRaise(view, n) is the pure form of the same check; getCeiling
reports the administrator bound (undefined when unbounded); and
setUserOverride refuses above the ceiling with
{ ok: false, reason: "above-ceiling" } and leaves the limit in force.
Every snippet here runs under CI in
examples/wiring/src/wiring-examples.test.tsx ("programmatic APIs"
describe). These are the imperative entry points an integrator calls
from their own handlers rather than mounting as components.
import {
findShortestPath,
allShortestPaths,
kShortestPaths,
type PathGraph,
} from "@g3-toolkit/core";
const path = findShortestPath(ugm, "a", "c");
// path.found, path.nodeIds (["a","b","c"]), path.edgeIds, path.length
// The UNION of every shortest route (a subgraph, not one
// representative), plus a route count capped at 50 so labels on
// dense graphs stay honest ("50+").
const all = allShortestPaths(ugm, "a", "c");
// all.nodeIds, all.edgeIds, all.pathCount
// Options, shared by every search: maxHops bounds the search (a
// farther target is found: false, and the search stops at the
// bound instead of walking the whole graph); directed follows edges
// source to target only; nodeFilter decides which nodes may sit
// BETWEEN the endpoints (the endpoints themselves are exempt, so a
// search between two hubs still finds them); edgeTypes filters edges.
const bounded = findShortestPath(ugm, "a", "c", {
maxHops: 4,
directed: true,
nodeFilter: (id) => !id.startsWith("hub:"),
});
// The individual routes, shortest first (Yen's algorithm on unit-weight
// hops; deterministic for a given graph), for a "path 3 of 12" listing.
// Fewer than k exist: the array is shorter. Disconnected: empty.
const routes = kShortestPaths(ugm, "a", "c", { k: 5, maxHops: 4 });
// routes[i].nodeIds, routes[i].edgeIds
// Every search reads only four methods, so a host with its own index
// can pass a PathGraph instead of building a UGM for a read-only
// search. UGM is a PathGraph by construction.
const view: PathGraph = {
hasNode: (id) => ugm.hasNode(id),
getNodeEdges: (id) => ugm.getNodeEdges(id),
getEdgeEndpoints: (id) => ugm.getEdgeEndpoints(id),
getEdge: (id) => ugm.getEdge(id),
};
findShortestPath(view, "a", "c");
Endpoints follow one rule in all three searches: a node the graph does
not report via hasNode is never found, not even as its own zero-hop
path, so a PathGraph that indexes only nodes with edges answers the
same way from every search. Source equals target on an existing node
is the one zero-hop path (nodeIds: [id], edgeIds: [], length: 0;
pathCount: 1 from allShortestPaths; a one-element array from
kShortestPaths).
kShortestPaths costs up to k × L bounded searches on the full graph
(L is the hop count of the longest listed route), so on a large index
it is the expensive one of the three. When the listing only needs the
routes that TIE for shortest, run it over the edge union
allShortestPaths returns, bounded at the shortest length: the union
holds every shortest route and nothing else, and maxHops at that
length keeps a walk from doubling back through the union to a longer
one. Each spur search then reads the union instead of the whole graph.
import {
allShortestPaths,
kShortestPaths,
type PathGraph,
} from "@g3-toolkit/core";
const union = allShortestPaths(ugm, "a", "c");
if (union.found) {
const nodes = new Set(union.nodeIds);
const edges = new Set(union.edgeIds);
const unionView: PathGraph = {
hasNode: (id) => nodes.has(id),
getNodeEdges: (id) => ugm.getNodeEdges(id).filter((e) => edges.has(e)),
getEdgeEndpoints: (id) => ugm.getEdgeEndpoints(id),
getEdge: (id) => ugm.getEdge(id),
};
// Only the routes of length union.length, up to the count cap.
const shortestRoutes = kShortestPaths(unionView, "a", "c", {
k: union.pathCount,
maxHops: union.length,
});
// shortestRoutes[i].nodeIds, shortestRoutes[i].edgeIds
}
import { exportSubgraphJson, exportSubgraphCsv } from "@g3-toolkit/core";
const json = exportSubgraphJson(ugm); // whole graph
const csv = exportSubgraphCsv(ugm, selection); // or a selection
Turtle export (exportSubgraphTurtle) is demonstrated in
examples/decision-dashboards.
DerivedPropertyEngine writes a computed value as a plain node
property, so every reader keeps working, and stamps it in a SIDECAR
registry on the UGM (never on the node's attributes) with the
definition that produced it, the definition's version, the write time
and the expression. The stamp survives toJSON/fromJSON (snapshot
version 2; an older snapshot without the field still loads) and the
JSON subgraph export (each node gains derivations keyed by property;
CSV is unchanged). TableView marks a derived cell with
data-derived="<definitionId>" and the g3t-table-cell-derived
class, and both inspectors show "derived by ugm.updateNodeProperties clears the stamp, since the value is no
longer derived.
import { DerivedPropertyEngine, exportSubgraphJson } from "@g3-toolkit/core";
const engine = new DerivedPropertyEngine();
engine.define({ name: "riskScore", expression: "risk * 100", reactive: false });
engine.compute(ugm);
// Where did this value come from?
const stamp = ugm.getDerivation("pump-1", "riskScore");
// stamp?.definitionId === "riskScore", stamp?.version === 1,
// stamp?.expression === "risk * 100", stamp?.computedAt is ISO-8601.
// A source property has no stamp: ugm.getDerivation("pump-1", "risk")
// is undefined. ugm.listDerivations() lists every stamp (or one node's).
// Change the rule: the version bumps and the old values become STALE
// (stamp version older than the definition's current version).
engine.define({
name: "riskScore",
expression: "risk * 1000",
reactive: false,
});
const stale = engine.listStale(ugm); // [{ nodeId, property, stamp }, ...]
engine.recompute(ugm, "riskScore"); // stale is now []
// The JSON export carries it; a consumer can tell computed from measured.
const doc = JSON.parse(exportSubgraphJson(ugm)) as {
nodes: Array<{ derivations?: Record<string, { definitionId: string }> }>;
};
// doc.nodes[i].derivations?.riskScore?.definitionId === "riskScore"
import { applyEncodingSpec } from "@g3-toolkit/react";
const patch = applyEncodingSpec(spec, ugm);
// patch.nodes / patch.edges: Maps of materialized visual channels
// (_color, _size, _icon, _shape, label) keyed by element id.
import { createTheme } from "@g3-toolkit/react";
const theme = createTheme({ id: "acme", name: "Acme", accentPrimary: "#0af" });
// Derives from LIGHT_THEME and warns when a chosen color fails WCAG
// contrast against its background.
To follow your app's own CSS variables, derive the theme from them and
apply it. themeFromCssVariables reads each named variable through
getComputedStyle on the element you pass (so :root declarations
and var() chains resolve) and fills the fields you do not map from
the base preset. The result is a snapshot: re-derive when your app
switches its theme.
import { themeFromCssVariables, applyTheme } from "@g3-toolkit/react";
const theme = themeFromCssVariables(
document.body,
{
bgPrimary: "--app-surface",
bgSecondary: "--app-surface-raised",
textPrimary: "--app-text",
accentPrimary: "--app-accent",
border: "--app-border",
},
{
id: "app",
name: "App",
colorScheme: orgSettings.darkMode ? "dark" : "light",
},
);
// Store-free: write it to one element and get a dispose back, for a
// host that applies on mount and removes on unmount without the store.
const dispose = applyTheme(theme, { target: document.body });
dispose(); // every property applyTheme set is removed or restored
// Store-backed instead (ThemeSwitcher, workspace snapshots keep working):
// useThemeStore.getState().setCustomTheme(theme)
import { createCameraController } from "@g3-toolkit/react";
const camera = createCameraController(cy, { padding: 48 });
camera.focusNodes(selectedIds); // zoom-to-subgraph
camera.frameAll(); // fit everything
cy is the Cytoscape core the canvas hands you through onReady.
The same core feeds the Minimap and GraphToolbar components, both
under the core prop: store the core from onReady in state and
render <Minimap core={core} /> (an overview inset whose viewport
rectangle tracks and drives the camera) and
<GraphToolbar ugm={ugm} core={core} />; while the core is null the
minimap shows a disabled placeholder and the toolbar's controls
no-op, so both mount safely before the canvas is ready.
pnpm run docs:api →
docs-out/api (every exported type, prop interface, and function).pnpm run storybook.ARCHITECTURE.md, DEVELOPER.md.roadmap/design/algorithm-overlays.md (algorithm documents with
networkx / GraphBLAS exports), roadmap/design/encoding-controls.md
(the spec grammar).