Skip to content

Data and visuals

defineVisuals() is the boundary between your domain graph and graphraum’s render buffers. It maps each node and edge once when setData() runs, never inside a frame.

GPU visualAlways cheap

Node color, shape, and size plus edge color, width, opacity, style, and markers are compiled into the existing two batched draw calls.

PresentationOn demand

Titles, subtitles, properties, and action descriptors stay available by ID for your focused or selected UI.

ApplicationOwns behavior

Your framework renders accessible HTML and decides what an action ID does. The engine never stores callbacks.

import { Graphraum, defineVisuals } from "@yggy-tech/graphraum";
type NodeAttributes = {
kind: "person" | "place";
displayName: string;
description?: string;
};
type EdgeAttributes = {
relationship: string;
};
const visuals = defineVisuals<NodeAttributes, EdgeAttributes>({
node: (node) => ({
visual: {
color: node.attributes.kind === "person" ? "#73c7a5" : "#469878",
shape: node.attributes.kind === "person" ? "circle" : "pill",
width: node.attributes.kind === "person" ? 8 : 10,
height: node.attributes.kind === "person" ? 8 : 6,
strokeWidth: node.attributes.kind === "place" ? 1 : 0,
},
presentation: {
title: node.attributes.displayName,
subtitle: node.attributes.description,
properties: [
{ id: "kind", label: "Kind", value: node.attributes.kind },
],
actions: [
{ id: "inspect", label: "Inspect" },
{ id: "remove", label: "Remove", disabled: false },
],
},
}),
edge: (edge) => ({
visual: {
color: "#226f54",
width: 2,
opacity: 0.85,
style: "dashed",
marker: "triangle",
markerEnd: "target",
},
presentation: { title: edge.attributes.relationship },
}),
});
const graph = new Graphraum<NodeAttributes, EdgeAttributes>(container, { visuals });

When attribute generics are supplied, attributes is required on every matching node and edge. This makes missing product data a compile-time error instead of an ambiguous rendering fallback.

graph.setData({
nodes: [
{
id: "person:ada",
position: { x: -20, y: 0 },
attributes: { kind: "person", displayName: "Ada Lovelace" },
},
{
id: "place:london",
position: { x: 20, y: 0 },
attributes: { kind: "place", displayName: "London" },
},
],
edges: [
{
id: "born-in",
source: "person:ada",
target: "place:london",
attributes: { relationship: "Born in" },
},
],
});

The mapper result overrides direct color, shape, size, width, height, strokeWidth, strokeColor, and glow snapshot fields when it supplies a visual. Without a mapper, those direct fields remain the smallest valid API. All seven built-in shapes (circle, square, diamond, hexagon, triangle, pill, and rounded) share one instanced billboard geometry and one node draw call in both 2D and 3D.

size is shorthand for a square node: width and height default to size (or 4 world units) when omitted, so most nodes only ever set size. Set width and height independently for non-square footprints such as the pill-shaped place node above. strokeWidth (world units, 0 by default) draws an SDF ring around the shape’s outer edge; strokeColor falls back to theme.nodeStroke when the node sets a strokeWidth but no color. Picking and the spatial grid already account for the outer boundary, so a stroked node is exactly as clickable as its rendered outline.

Edge visual fields work the same way: color, width, opacity, style (solid, dashed, dotted), marker (none, triangle), markerSize, markerEnd (target, source, both), path (straight, quadratic, cubic), and optional controlPoints override direct edge snapshot fields. Omit controlPoints to use auto-derived handles; provide exactly one point for quadratic or two for cubic. Endpoints come from the source and target node positions, so moving a node moves every incident edge automatically. Overview LOD forces straight segments.

Arbitrary HTML per node is not the dense rendering path: it would trade away batching, viewport materialization, and the 100k-node target. The defineVisuals() layer maps typed domain attributes to node and edge visuals once at ingestion, then compiles them into GPU buffers. Titles, properties, and host-owned action descriptors remain available by entity ID.

circle, square, diamond, hexagon, triangle, pill, and rounded are rendered as signed-distance fields on one instanced billboard geometry. Mixing them does not add meshes, materials, or draw calls, and the same shapes face the camera in 2D and 3D. Picking applies the same shape boundary, including any stroke, in world space for 2D and projected screen space for 3D.

Every shape accepts an independent width and height (square by default) plus an optional strokeWidth and strokeColor for an SDF ring around the outer edge. strokeColor falls back to theme.nodeStroke, the third canonical Porcelain swatch, so a stroked node stays on-palette without an explicit color.

Edge width, dash styles, direction markers, and path kinds (straight, quadratic, cubic) render as one instanced GPU edge batch, packing segment and marker instances into the same edge draw call rather than one object per edge. Curves compile to polylines at pack time. The overview LOD tier drops markers and forces solid, theme-width, straight segments first to keep frame time flat at scale; detail renders the full per-edge style and path. A bounded overlay can project host HTML onto focused nodes; that is not the dense rendering path. See ADR: Fidelity on demand.

Follow the design and benchmark acceptance criteria in the node and edge visual translation issue.

Use graphraum’s picking result to fetch immutable, serializable presentation data. Render it in your own React, Vue, Svelte, or plain HTML surface.

container.addEventListener("click", (event) => {
const hit = graph.pickHit(event.clientX, event.clientY);
if (hit?.kind === "node") {
graph.setSelection([hit.id]);
graph.setEdgeSelection([]);
detailsPanel.render(graph.getNodePresentation(hit.id));
return;
}
if (hit?.kind === "edge") {
graph.setEdgeSelection([hit.id]);
graph.setSelection([]);
detailsPanel.render(graph.getEdgePresentation(hit.id));
return;
}
graph.setSelection([]);
graph.setEdgeSelection([]);
detailsPanel.render(null);
});

pick(clientX, clientY) still returns a node id only (or null) for hosts that do not need edge hits. Prefer pickHit when edges must be selectable. Selected edges use theme.selectedEdge (Porcelain by default) without changing node selection.

To focus a neighborhood, dim the other nodes and edges and highlight the incident edges. Selection wins over dimming, so an edge can stay in both sets:

// nodeIds and edgeIds are the host's own ID lists for the current graph.
const focusNode = (nodeId: string, incidentEdgeIds: readonly string[]) => {
const neighborhood = new Set([nodeId, ...graph.getNeighborIds(nodeId)]);
graph.setNodeState("dimmed", nodeIds.filter((id) => !neighborhood.has(id)));
graph.setEdgeState("dimmed", edgeIds);
graph.setEdgeSelection(incidentEdgeIds);
};
const clearFocus = () => {
graph.setNodeState("dimmed", []);
graph.setEdgeState("dimmed", []);
graph.setEdgeSelection([]);
};

Edge selection and dimming repaint only the edges whose state changed, so calling them on every hover does not rebuild the viewport.

getNodePresentation(id) and getEdgePresentation(id) return null when no presentation was compiled. Returned properties and actions are detached from the mapper input and frozen. Diagnostics expose selectedNodes, selectedEdges, and dimmedEdges counts.

Actions are descriptors, not functions. Stable IDs keep serialization, worker compilation, framework adapters, and testing possible.

function runNodeAction(nodeId: string, actionId: string) {
if (actionId === "inspect") openInspector(nodeId);
if (actionId === "remove") requestRemoval(nodeId);
}

Do not put arbitrary HTML, component instances, closures, or mutable domain objects into presentation metadata. Properties accept only string, number, boolean, or null; numeric values must be finite. Empty labels and duplicate property or action IDs fail during setData() with the entity ID in the error.