Skip to content

Hypergraph

nodus.hypergraph visualizes hypergraphs, where a single hyperedge may connect any number of vertices. It implements the scalable hypergraph visualization of Oliver et al. (TVCG 2023, arXiv:2308.05043). The pipeline is chainable:

nodus.hypergraph
.setData(data)
.simplify(/* opts */)
.layout(/* opts */)
.then(() => nodus.hypergraph.render(/* opts */));

For the concept and a walkthrough see Hypergraphs and the Hypergraph Visualization guide.

type HypergraphData = {
vertices: { id: string | number; nodeId?: string | number; data?: any }[];
hyperedges: { id: string | number; vertices: (string | number)[]; data?: any }[];
};

A Vertex carries an id, an optional nodeId linking it to a graph node, and optional data. A Hyperedge carries an id, the list of vertex ids it connects, and optional data.

  • setData(data) -> this — set the HypergraphData directly.
  • fromNodesAndEdges({ groupBy: (node) => id[] }) -> this — build a hypergraph from the current graph, where groupBy returns the hyperedge ids each node belongs to.
nodus.hypergraph.setData({
vertices: [{ id: 'v1' }, { id: 'v2' }, { id: 'v3' }],
hyperedges: [
{ id: 'e1', vertices: ['v1', 'v2'] },
{ id: 'e2', vertices: ['v2', 'v3'] },
],
});
simplify(opts?) -> { scales, operations }

Reduces the hypergraph through a sequence of operations, producing a multi-scale representation. Options:

Option Type
alpha? number
beta? number
gamma? number
adjacencyT? number
maxScales? number
stopWhen? 'linear' | 'noForbidden' | 'targetVertices' | 'targetHyperedges' | 'manual'
targetVertexCount? number
targetHyperedgeCount? number
forbiddenSubgraphs? (the forbidden-subgraph set)
betweennessRefreshEvery? number

Returns { scales, operations }.

const { scales, operations } = nodus.hypergraph.simplify({
stopWhen: 'targetVertices',
targetVertexCount: 50,
});
layout(opts?) -> Promise<{ totalEnergy, overlapCount }>

Computes positions by minimizing an energy. Options:

Option Type
weights? { regularity, area, separation, intersection, coordination }
separationIters? number
regularityIters? number
solver? 'lbfgs' | 'adam'
optimizeDual? boolean
targetAreaPerCardinality?(k) function of cardinality k
bufferDistance? number
useWorker? boolean (default true)
progressEvery? number
onProgress?(info) progress callback

Resolves to { totalEnergy, overlapCount }.

const result = await nodus.hypergraph.layout({
solver: 'lbfgs',
weights: { regularity: 1, area: 1, separation: 1, intersection: 1, coordination: 1 },
onProgress: (info) => console.log(info),
});
console.log(result.totalEnergy, result.overlapCount);
render(opts?) -> { primal?, dual? }

Draws the hypergraph. Options:

Option Type
fillOpacity? number
strokeOpacity? number
palette? (the color palette)
showVertices? boolean
showIncidenceEdges? boolean
position? 'below' | 'above'
layer? 'canvas' | 'svg'
view? 'primal' | 'dual' | 'both' | '2.5d' | 'surface3d'
projection? 'flat' | 'oblique' | 'surface3d'
vertexRadius? number
interactive? boolean
hoverFillOpacityScale? number
hoverStrokeWidth? number
selectionStrokeColor? color
selectionStrokeWidth? number
selectionDash? (dash spec)
levelOf? function
levelSpacing? number
obliqueTilt? number
zOfVertex? function
zOfHyperedge? function
surface? { mode?, backend?, heightScale?, thickness?, showMesh?, showSkirts?, orbitControls?, graph?, radius?, camera?, lighting? }
vertexTag? (vertex tag spec)

Returns { primal?, dual? }.

nodus.hypergraph.render({
view: 'both',
fillOpacity: 0.25,
interactive: true,
});

For a 3D hypergraph view, projection: 'surface3d' renders each hyperedge as a closed translucent membrane body around its Bubble-Set boundary, plus explicit ultragraph incidence by default: a hyperedge anchor, anchor-to-member legs, and source/range arrowheads when the data provides directed metadata. The membrane mesh is generated in nodus-core/WASM and rendered with native WebGPU when available, falling back to native WebGL when WebGPU cannot initialize; no Three.js dependency is required. The base Nodus graph joins the same 3D scene by default (surface.graph: 'scene'): edges become 3D lines, nodes bound to hypergraph vertices anchor at the vertex’s elevation, and the flat 2D graph layer is hidden so there is one coherent projection. Use surface.graph: 'flat' to keep the legacy screen-space graph layer visible, or 'hidden' for a membranes-only scene. Use surface.ultragraph: false only when you need a quieter membrane-only view. Use surface.mode: 'heightfield' only when you want the older terrain-style scalar surface. Use surface.backend: 'webgpu' when a demo or test must fail visibly instead of silently using WebGL.

nodus.hypergraph.render({
projection: 'surface3d',
zOfVertex: (v) => v.data?.level ?? 0,
surface: {
mode: 'membrane',
backend: 'webgpu',
heightScale: 90,
thickness: 24,
showMesh: true,
showIncidence: 'auto',
lighting: 'soft',
},
});

To explore the 3D surface interactively — orbit, lighting, membrane vs heightfield, the base graph in-scene, ultragraph arrows, and the 4D timeline — run the dev server and open the playground, which generates its own data and needs no backend:

5199/examples/local/surface3d-playground.html
npx vite # then open

Its diagnostics panel reports the resolved GPU backend and whether membrane geometry came from nodus-core/WASM (coreMesh: true) or the TypeScript fallback.

Two control schemes, selected with surface.controls (default 'orbit') or toggled at runtime with F.

Orbit — the default. Good for overview and picking. It does not stop at the closest orbit: once distance bottoms out, further zoom-in translates the target along the view direction, so the camera travels into the scene rather than stalling on its surface.

input action
drag orbit (the near face follows the pointer)
right-drag move the scene (pan the target) — a two-finger click on a Mac trackpad
shift-drag, middle-drag (wheel button) move the scene
double-click, hold and drag move the scene, for trackpads with no middle button
wheel zoom, then fly forward/back
W/A/S/D, arrows step forward / strafe
Q / E, space step down / up

A pan never selects what it started on. Right-drag fires no click at all, and the layer holds its click back ~200 ms so the first press of a double-click-to-pan is cancelled rather than selecting whatever sat under it. A genuine single click still selects, 200 ms later. Right-drag also suppresses the context menu inside the layer.

Fly — game-style free flight. Entering it preserves the current viewpoint.

input action
mouse look (pointer-locked; falls back to drag-look where lock is refused)
W/A/S/D move, held, with acceleration and inertia
Q / E, space descend / ascend
shift sprint (≈3× cruise)
wheel thrust along the view ray
click pick at the crosshair
Esc back to orbit

Movement speed scales with the graph’s extent, so it handles identically on a tiny graph and on a hundred-million-term corpus. Flight runs its own requestAnimationFrame loop that only re-renders the camera — it never rebuilds geometry — and parks itself once you stop.

In fly mode W/A/S/D move on the horizontal plane by where you face, and altitude is its own axis (Q/E/space) — the creative-flight convention. If movement followed the view ray, entering fly while looking down would drive you straight through the floor. Pair it with surface.grid: true: a free camera with no ground has no horizon, so you lose all orientation once you leave the data.

The 3D layer can anchor text at world positions and billboard it every frame, fading it by distance so near regions read and far ones recede:

layer.setLabels(
[{ id: 'physics', x: 120, y: -40, z: 90, text: 'Physics', color: '#2563eb', priority: 2 }],
{ maxLabels: 28, fadeDistance: 1.8, fontSize: 13 },
);

Labels behind the camera or beyond fadeDistance × fit are dropped; when more are in view than maxLabels, the highest priority and nearest win, and any label that would overprint one already placed is skipped. Text is DOM, so it stays crisp at any zoom and costs one transform per visible label.

Nameplates. Give a label near and it behaves like a game’s entity name tag: it only materialises once the camera is within near × fit, and vanishes again when you leave. Whichever visible label is closest to the crosshair (within focusRadius pixels) is emphasised, so the thing you are facing names itself.

layer.setLabels([
{ id: 'physics', x, y, z, text: 'Physics', priority: 4 }, // a place name
{ id: 'p:42', x, y, z: z + lift, offsetY: -16, // a nameplate
text: 'Kinetics of steel slag leaching · 2019', near: 0.3 },
], { maxLabels: 44, fadeDistance: 2.6, focusRadius: 80 });

Labels are occluded by the world: one anchored behind a membrane is hidden rather than reading through it. The layer tests the eye→anchor ray against each membrane’s height field, so it is exact for slabs of any thickness, and it costs nothing measurable — the ray is clipped to each slab’s bounding box first, which rejects almost everything before any point-in-polygon work happens. Pass { occlude: false } to setLabels for the old see-through behaviour.

offsetY nudges the drawn text in screen space while the label stays anchored at its world point — so a plate floats above its dot, yet aiming at the dot is what focuses it. Anchor at z + diagnostics.vertexLift to sit exactly where the renderer draws the vertex, keeping aim, nameplate and pick() in agreement.

For custom overlays and HUDs:

  • layer.project(x, y, z) → { x, y, w, visible } in screen pixels (w <= 0 means behind the camera).
  • layer.getEyeWorld() → where the camera is in world space.
  • nodus.hypergraph._module.getHyperedgesAtPoint({ x, y }) → which regions contain a point, i.e. which hyperedges you are currently inside.

getCamera() returns { yaw, pitch, distance, fov, target, mode, eye }. eye is authoritative in fly mode and null in orbit; target is null until the camera has been flown or panned. resetCamera() clears both and returns to orbit. layer.setControls('fly' | 'orbit') and layer.getControls() switch programmatically. Set surface.orbitControls: false to disable all input.

By default each membrane carries its own name, painted onto its top face as world geometry — not a screen overlay. The text is rasterized once into a texture atlas, then draped over the slab’s height field, so it sits in perspective, tilts with the surface, is occluded by nearer terrain, and grows as you fly toward it. Zooming is the level of detail: a region announces itself as you approach and goes quiet as you leave.

nodus.hypergraph.render({
projection: 'surface3d',
surface: {
engrave: true, // default
// engrave: { lift: 2, opacity: 0.9 },
// engrave: false, // no lettering
},
});
nodus.hypergraph.setData({
vertices,
hyperedges: [
{ id: 'sdv', vertices: [...], data: { label: 'Life Sciences', sublabel: '36 papers' } },
],
});

The main line comes from data.label ?? data.name ?? id; data.sublabel adds a smaller second line. Lettering is bounded to a fraction of its region’s smaller side, so a short name is not stretched to fill a whole slab. A slab too narrow to carry lettering is skipped, and the largest regions win the atlas when there are more than ~120 of them — the world stays readable rather than becoming a wall of type.

Because the text lies flat in the world, it would read upside down from the far side. The shader carries a second, 180°-rotated set of texture coordinates and picks between them from the camera each frame, so a name is never mirrored — and that costs a uniform, not a rebuild.

Give a region an engraved name and it needs no DOM chip: two names on one region just fight each other.

Lettering fades out once its on-screen cap height drops below legibility (the shader measures atlas texels per screen pixel). It never aliases into noise: the atlas ships with a full mip chain. getDiagnostics().engraved reports how many regions are currently lettered.

Covered by npm run smoke:surface3d:engrave.

A name tells you which thing you are looking at. layer.describe(hit) tells you what it is and why it is shaped that way, using only facts the hypergraph already defines — so it holds for any dataset:

const { looking } = layer.probe(); // what the crosshair is on
const d = layer.describe(looking);
switch (d.kind) {
case 'hyperedge': // a membrane IS a set of vertices
d.members; // how many it encloses
d.z; // membrane height over its centroid
d.overlaps; // [{ id, shared }] — shared vertices, most first
d.contains; // hyperedges whose members are a strict subset
d.within; // those that contain it; .length is its nesting depth
break;
case 'vertex':
d.regions; // hyperedges holding it, most specific first
d.degree; // links to it drawn in the scene
break;
case 'edge':
d.source; d.target; // endpoints, as vertex ids when bound
d.shared; // hyperedges holding BOTH ends
break;
}

So an overlap in the world is literally shared vertices, and nesting is subset containment — the geometry is not a metaphor for the structure, it is the structure. What a channel encodes (that height is domain depth, that colour is a field) is yours to declare; a legend of those channels is what turns a pretty scene into a readable one.

Links are pickable too. pick() now returns { hyperedge, vertex, edge }, and edge is filled independently of the other two, because a link almost always lies on a membrane — precedence would make one of the pair unreachable. Links only exist in the scene under surface.graph: 'scene' (the default).

Guard against older layers with typeof layer.describe === 'function', and cache the result per hit: describe() walks every hyperedge’s member set, while the crosshair target changes far less often than a 60 Hz HUD tick.

Covered by npm run smoke:surface3d:describe (20 checks against a scene whose nesting and overlaps are known by construction).

nodus.hypergraph.timeline scrubs either the current hypergraph or supplied snapshots. Temporal values may be dates, numbers, { start, end } intervals, or Allen relation objects.

nodus.hypergraph.timeline.configure({
timeOfVertex: (v) => v.data?.issued,
timeOfHyperedge: (he) => he.data?.temporal,
trail: true,
});
await nodus.hypergraph.timeline.setTime(2024);
nodus.hypergraph.timeline.play();
  • getData() — the current HypergraphData.
  • getDual() — the dual hypergraph.
  • getScales() — the simplification scales.
  • getOperationStack() — the operation stack from simplify.
  • getVertexPositions() -> Map<id, {x, y}> — primal vertex positions.
  • getDualPositions() — dual positions.
  • getHyperedgePolygon(id) -> {x, y}[] — a hyperedge’s polygon outline.
  • getQualityMetrics() -> { overlapCount, overlapArea, avgRegularity, forbiddenSubgraphCount }.
const positions = nodus.hypergraph.getVertexPositions();
const polygon = nodus.hypergraph.getHyperedgePolygon('e1');
const metrics = nodus.hypergraph.getQualityMetrics();
  • getSelectedHyperedges(), getSelectedVertices()
  • setSelected({ hyperedges?, vertices?, replace? })
  • toggleSelected({ hyperedge?, vertex? })
  • clearSelection()
  • getHoveredHyperedge(), getHoveredVertex(), getHoveredDual()
  • getHyperedgeAtPoint(world) — hit-test a world-space point.
nodus.hypergraph.setSelected({ hyperedges: ['e1'], replace: true });
nodus.hypergraph.toggleSelected({ vertex: 'v2' });
  • setActiveView('primal' | 'dual' | 'both')
  • refresh() — redraw.
  • hide(), show() — toggle visibility.
  • destroy() — tear down the hypergraph view.
nodus.hypergraph.setActiveView('dual');
nodus.hypergraph.refresh();

Subscribe with on(event, cb) and unsubscribe with off(cb). Available events:

simplifyStart, simplifyEnd, layoutStart, layoutPhase, layoutIter, layoutEnd, render, viewChanged, hyperedgeHover, hyperedgeOut, hyperedgeClick, vertexHover, vertexOut, vertexClick, selectionChanged.

const onClick = (e) => console.log('hyperedge clicked', e);
nodus.hypergraph.on('hyperedgeClick', onClick);
nodus.hypergraph.on('layoutEnd', () => console.log('layout done'));
// later:
nodus.hypergraph.off(onClick);