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.
Data types
Section titled “Data types”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.
Loading data
Section titled “Loading data”setData(data) -> this— set theHypergraphDatadirectly.fromNodesAndEdges({ groupBy: (node) => id[] }) -> this— build a hypergraph from the current graph, wheregroupByreturns 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
Section titled “simplify”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
Section titled “layout”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
Section titled “render”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:
npx vite # then openIts diagnostics panel reports the resolved GPU backend and whether membrane
geometry came from nodus-core/WASM (coreMesh: true) or the TypeScript
fallback.
Navigating the 3D scene
Section titled “Navigating the 3D scene”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.
Naming the world
Section titled “Naming the world”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 <= 0means 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.
Names engraved on the world
Section titled “Names engraved on the world”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.
Explaining the geometry
Section titled “Explaining the geometry”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 onconst 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).
Timeline
Section titled “Timeline”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();Queries
Section titled “Queries”getData()— the currentHypergraphData.getDual()— the dual hypergraph.getScales()— the simplification scales.getOperationStack()— the operation stack fromsimplify.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();Selection and hover
Section titled “Selection and hover”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' });View and lifecycle
Section titled “View and lifecycle”setActiveView('primal' | 'dual' | 'both')refresh()— redraw.hide(),show()— toggle visibility.destroy()— tear down the hypergraph view.
nodus.hypergraph.setActiveView('dual');nodus.hypergraph.refresh();Events
Section titled “Events”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);