Skip to content

Node & edge presentation

Documented API v0.21.0
Exact package bun add @domudev/graphraum@0.21.0
Release notes Open reference Immutable contract Browse source

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.

Prefer proof over snippets? Run the same contract across three domains in the interactive demos.

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 "@domudev/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, and strokeColor 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.

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.

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 and selectedEdges 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.

createOverlay() projects a bounded set of host-created HTML elements over the canvas. It is the same dense-canvas plus sparse-DOM split used by graph editors: WebGL remains responsible for the graph, while your application owns rich text, CSS, buttons, and action handling. The two-draw-call GPU path is unchanged—labels stay in the DOM overlay.

Prefer autoLabels: true when the host should not guess setLabels() every frame. On each view change, graphraum:

  1. Builds candidates from getLabelCandidates()visible from screen projection, importance from incident edge degree (selected nodes get a large boost so they stay labeled).
  2. Keeps only visible candidates, sorts by importance descending (stable by id on ties), and takes at most maxLabels (50 by default).
  3. Syncs the DOM label set to that budgeted id list.
const overlay = graph.createOverlay({
autoLabels: true,
maxLabels: 50,
renderLabel: ({ id, presentation }) => {
const label = document.createElement("span");
label.className = "my-graph-label";
label.textContent = presentation?.title ?? id;
return label;
},
renderToolbar: ({ id, presentation }) => {
if (!presentation) return null;
const toolbar = document.createElement("div");
for (const action of presentation.actions) {
const button = document.createElement("button");
button.textContent = action.label;
button.disabled = action.disabled ?? false;
button.onclick = () => runNodeAction(id, action.id);
toolbar.append(button);
}
return toolbar;
},
});
overlay.setToolbar("person:ada");

Off-screen nodes stay out of the budgeted set. You can still call setLabels() while autoLabels is on; the next view change overwrites that list from the budget policy. Export selectBudgetedLabelIds if you need the same policy outside the overlay.

With autoLabels left false (the default), the host chooses ids:

overlay.setLabels(["person:ada", "place:london"]);

setLabels() still throws when the list exceeds maxLabels. Off-screen and missing nodes are safely hidden or ignored. The overlay tracks camera movement automatically; call destroy() with the graph lifecycle.

Color, shape, and size can remain visible across the graph. Titles use the budgeted overlay policy above (autoLabels or a host-owned setLabels() list). Full properties and actions belong to a bounded focused or selected subset in host HTML. This preserves both accessibility and the dense rendering path.