3D network graph
Overview
A graph3d trace draws a network in a 3D scene: the nodes as lit spheres or as flat sprites, and the links between them as lines or as tubes. It takes the data of the 2D graph trace, node and link containers in which a link names its two ends by node index, and adds node.z. The positions are given, or computed in space: by a force-directed layout in three dimensions, or by a layered one that gives every rank of a directed graph a plane of its own.
Use it when the positions are three-dimensional data (the atoms of a crystal, sensors in a building), or when a network is too tangled for the plane and its reader can turn it: with a third dimension a force layout has more room, and clusters that overlap in a 2D drawing can sit beside, above and behind one another.
That last condition matters. A 3D drawing has to be turned to be read: in any one view, nodes hide other nodes, and it is hard to tell what is near from what is far. If the chart will be read as a still picture (a report, a slide, a printed page), if every node needs its label, or if the structure has a 2D arrangement of its own (a tree, a pipeline, a ring), the 2D graph reads better.
graph3d is not part of the full bundle. With the full bundle, add one import next to it. In a partial bundle, register tracesGraph3d, which brings the 3D scene with it; it is apart from tracesGraph so that an app with 2D graphs only does not load the 3D package (see adding the package):
import '@mk7s/holochart';
import '@mk7s/holochart/graph';Pick a different chart when:
- a flat drawing is enough, or the chart must work without interaction: use the 2D graph, which also has trees, layered diagrams with routed links, circular, arc and hive arrangements, and box and lasso selection;
- the points in space have no links: use scatter3d;
- how much flows between the nodes matters more than how they are connected: use a chord diagram or a sankey.
Minimal example
import { createChart } from '@mk7s/holochart';
import '@mk7s/holochart/graph';
createChart(document.getElementById('chart')!, {
data: [
{
type: 'graph3d',
node: { label: ['Ada', 'Bo', 'Cy', 'Dee', 'Eli'] },
link: { source: [0, 0, 1, 2, 3], target: [1, 2, 2, 3, 4] },
},
],
});Nodes are numbered by their index in node.label, and every link names its source and its target node. The nodes have no positions, so the 3D force layout places them (arrangement: 'force'). The trace creates the default scene, layout.scene, whose axes are hidden because layout positions are not a scale to read. Drag to turn the scene and scroll to move closer.
The live example has fourteen people and link.value, the number of reviews between two of them. It pulls the two together in the layout and, with link.widthby: 'value', sets the width of their link:
graph3d/basicData format
- Nodes and links. As for graph:
link.sourceandlink.targetare parallel arrays of node indices (whole numbers; numeric strings are accepted), andnode.labelnames the nodes. The number of nodes is the length of the longest per-node array (node.label,x,y,group,customdata, andsizeandcolorwhen they are arrays); without any of them it is one more than the largest index a link names. See the data model and getting data in for the adapters that build these containers. link.value. A positive number per link: its weight for the layout (force.linkweightsays how it is used) and%{value}in hover labels. It sets the width only withlink.widthby: 'value'. A missing or non-positive value counts as 1 for the layout.- Positions.
node.x,node.yandnode.z, one value per node. What they mean depends on the arrangement (below). - Trees.
labelsandparents, as the hierarchical traces take them, are a second way to give a tree when the trace has nolink.source: every row is a node, and every row with a parent gets a link from its parent. A parent is named by its id (ids, else its label),''marks a root, and a parent that is no row becomes a node of its own. - Left out. A link whose source or target is not a node index is dropped. A link from a node to itself is not drawn. A node that is not drawn (no position under
'preset', or a group hidden through the legend) takes its links with it. - Scene.
scene: 'scene2'puts the trace in another scene (default'scene'); see several scenes.
arrangement picks what places the nodes. The default is 'preset' when node.x, node.y and node.z cover every node, else 'force':
arrangement | Positions | Scene axes |
|---|---|---|
'preset' | node.x / y / z are data on the scene's axes: numbers, dates or categories, by the axis type. A node without all three is not drawn. | shown |
'force' | A force-directed layout in three dimensions (options: force). A node given x, y and z stays where it is, in layout units, and one given some of them is held along those axes. | hidden |
'layered' | A directed graph in planes, one per rank; the nodes of a plane are spread by the force layout (options: layered and force). | hidden |
'custom' | The layout registered under custom.name with registerGraphLayout, called with the graph and custom.options. It returns z as well as x and y; without z the graph is drawn flat. See your own layout. | hidden |
The computed arrangements return layout units. The trace asks the scene for the same extent on all three axes, so one unit is as long along x, y and z and the layout keeps its proportions, and its longest side fills the scene's box.
import { createChart } from '@mk7s/holochart';
import '@mk7s/holochart/graph';
// Methane: positions are data (here in ångström), so the arrangement is 'preset'.
createChart(document.getElementById('chart')!, {
data: [
{
type: 'graph3d',
node: {
label: ['C', 'H', 'H', 'H', 'H'],
x: [0, 0.629, -0.629, -0.629, 0.629],
y: [0, 0.629, -0.629, 0.629, -0.629],
z: [0, 0.629, 0.629, -0.629, -0.629],
size: [30, 18, 18, 18, 18],
color: ['#555b6e', '#c7c7c7', '#c7c7c7', '#c7c7c7', '#c7c7c7'],
},
link: { source: [0, 0, 0, 0], target: [1, 2, 3, 4], width: 5 },
},
],
});What the 2D trace has and this one does not
graph3d has the attributes of graph wherever one means the same in space. These are left out:
- Arrangements.
'tree','radial','dendrogram','circular','grid','arc'and'hive', with their option containers, andnode.value, which only they read. The 2Dlayeredoptions that order nodes and route links (rankdir,nodesep,routing,clusters, …) are replaced bylayered.axisand the planes. - Box nodes (
node.shape): a node is a sphere or a sprite. - The style of set-apart links (
link.secondary): with'layered', a link that closes a cycle is drawn like the others, in the direction the data gives it. - Selection styles (
selected,unselected) and the path between two selected nodes: a scene has no box or lasso selection.highlight.pathstill draws a path the figure names. - Dragging nodes (
node.draggable): a drag in a scene turns the camera. force.simulate: the layout is drawn settled; its cooling is not animated in a scene.- Large-graph options (
worker,lod,link.bundle): the layout runs on the main thread, everything is drawn at every zoom, and links are not bundled.
Variations
Force layout with groups
node.group gives every node a group: its color, and one legend item, which a click hides. force.groupstrength pulls the nodes of a group together even where their links do not. node.sizeby: 'degree' sizes the nodes by their number of links, between the two diameters of node.sizerange. The layout is deterministic, so this figure looks the same on every machine:
graph3d/forceLayered: one plane per rank
arrangement: 'layered' breaks the cycles of a directed graph and gives every node a rank, as the 2D layered layout does (layered.ranker picks how), and makes each rank a plane. The planes are layered.ranksep apart (default 80 layout units) along layered.axis (default 'z'), the first rank at the top, so links run down. Within its plane a node is placed by the force layout, whose links rest at the distance between two planes, so linked nodes end up above one another. layered.showplanes (default true) outlines every plane, in layered.planecolor.
Arrowheads are on by default under 'layered' (link.arrow.end), as in the 2D trace, and off under the other arrangements unless you set link.arrow.end or start. They are lit cones, link.arrow.size long, whose tips stop at the surface of the node they point at:
graph3d/layeredGiven positions on the scene's axes
With node.x, node.y and node.z for every node, positions are data. The scene's axes are drawn, with ticks and titles, and scene.aspectmode shapes the box as for any 3D trace. Here the ions of rock salt sit on a cubic lattice; the bonds are 7 px wide, which makes them tubes:
graph3d/presetSpheres or sprites, tubes or lines
Each part of the graph can be an object in the scene or a mark on the screen:
| Attribute | Value | Drawn as | Size |
|---|---|---|---|
node.render | 'sphere' (default) | lit spheres | node.size px at the middle of the scene; larger near the camera, smaller far |
node.render | 'sprite' | flat markers that face the camera, with node.symbol and node.line | node.size px at every depth |
link.render | 'tube' | lit tubes | link.width px across at the middle of the scene |
link.render | 'line' | screen-space lines, which take link.dash | link.width px at every depth |
link.render | 'auto' (default) | tubes when the widest link is 3 px or wider and the graph has at most 5,000 links to draw, else lines | as above |
"At the middle of the scene" means at the point the camera looks at in the first view that is drawn. A sphere of size: 10 is 10 CSS px across there; it is an object in the scene from then on, so it grows as the camera comes closer, and an arrowhead ends on it at any zoom. Sprites and lines keep their px, as scatter3d markers and lines do. Here the two kinds of node differ by symbol as well as by color:
graph3d/spritesParallel links and loops
Links between the same two nodes are fanned out as arcs, so that each can be seen and hovered, and two opposite links bow apart. A link from a node to itself is a ring beside the node, on the side away from its neighbours; further ones are larger rings. link.curve sets the curvature by hand, as a fraction of the link's length: a positive value bows to the left of the link's direction seen from above, and a vertical link bows along y. Arrowheads end on the node's surface along the curve:
graph3d/linksA larger graph
2,400 nodes and about 2,600 links. The links are 1 px wide, so they are lines, and the labels are turned off (node.textposition: 'none') and left to hover:
graph3d/largeAn orbiting camera
The scene is the one every 3D trace is drawn in, so chart.animateCamera and scene.autorotate work as they are. Turning the graph is what shows its depth. Numbers in node.color go through node.colorscale, here with a colorbar:
graph3d/orbitStyling
- Nodes.
node.size(a diameter in px, or one per node; default 10) ornode.sizeby('degree','indegree'or'outdegree': the area grows with the count, between the diameters ofnode.sizerange, default 6 to 30), andnode.opacity. For sprites,node.symbol(one, or one per node) andnode.line.color/node.line.width. - Node colors.
node.coloris one CSS color, one per node, or numbers mapped throughnode.colorscale(withcmin,cmax,reversescale,showscaleandcolorbar, or a sharedcoloraxis). Default: the group's color whennode.groupis given (the colorway, one color per group), else the trace's colorway color. See colors and colorscales. - Links.
link.color(one, or one per link; default: a translucent gray chosen for the background),link.width(default 1) orlink.widthby: 'value'withlink.widthrange(default 1 to 8 px),link.opacity, andlink.dashfor lines. Tubes are lit and opaque: a translucent link color is mixed with the background into an opaque one, andlink.opacityfades the tubes as a whole. - Arrowheads.
link.arrow.endandlink.arrow.start, withlink.arrow.size(default 8 px, measured likenode.size). A wide link gets a head at least three times its width, and a link too short for its head gets a shorter one. - Labels.
node.textposition('auto': to the right of the node; scatter's nine positions;'none') andnode.textfont(default:layout.fontwith the automatic halo). Labels face the camera and keep their px size. Where they would overlap each other or a node on screen, the nodes with the most links keep theirs; turning the scene or moving closer shows others. - Trace opacity.
opacitymultiplies the opacity of nodes, links and labels.
import { createChart } from '@mk7s/holochart';
import '@mk7s/holochart/graph';
createChart(document.getElementById('chart')!, {
data: [
{
type: 'graph3d',
node: {
label: ['Gateway', 'Auth', 'Orders', 'Stock', 'Mail'],
group: ['Edge', 'Core', 'Core', 'Core', 'Edge'],
sizeby: 'degree',
sizerange: [12, 28],
textfont: { size: 13 },
},
link: {
source: [0, 0, 1, 2, 2],
target: [1, 2, 2, 3, 4],
color: '#8a8fa3',
width: 4,
arrow: { end: true, size: 14 },
},
},
],
layout: { scene: { camera: { eye: { x: 1.1, y: 1.1, z: 0.7 } } } },
});Interactivity
Camera. Drag to turn the scene, scroll or pinch to zoom, double-click to go back to the first view; the scene controls list every gesture and
scene.dragmode.Hover. Nodes and links are picked on the GPU, so what you hover is what you see, not something hidden behind it; where a node and a link are equally near, the node wins. A node's label shows its name, its number of links (
Links in: …, out: …when the trace has arrowheads) and its group. A link's label shows its two ends (joined by→,←or↔for the arrowheads it has, else–),link.labeland its value. See hover and picking: the label is hidden while the camera moves, andscene.hovermode: falseturns hover off.Highlighting. Hovering a node keeps it, its links and the nodes they lead to, and fades the rest; hovering a link keeps the link and its two ends. The
highlightcontainer is the 2D trace's:hopsfor a wider neighbourhood,direction('both','out','in'),dim,color, andnodesandpathto highlight without a pointer. Spheres, tubes and arrowheads are opaque, so what is dimmed fades toward the scene's background color instead of turning transparent. Highlighting follows the scene's hover: there is none withscene.hovermode: falseorhoverinfo: 'skip'.Templates.
node.hovertemplatetakes%{label},%{degree},%{indegree},%{outdegree},%{group},%{size},%{color}and%{customdata}, and witharrangement: 'preset'also%{x},%{y}and%{z}, formatted like the scene's axes (layout positions are not reported).link.hovertemplatetakes%{label},%{value},%{source.label},%{target.label},%{source.index},%{target.index}and%{customdata}.<extra>…</extra>replaces the trace name:tsimport { createChart } from '@mk7s/holochart'; import '@mk7s/holochart/graph'; createChart(document.getElementById('chart')!, { data: [ { type: 'graph3d', node: { label: ['Ada', 'Bo', 'Cy'], customdata: ['lead', 'reviewer', 'author'], hovertemplate: '<b>%{label}</b> (%{customdata})<br>%{degree} links<extra></extra>', }, link: { source: [0, 0, 1], target: [1, 2, 2], value: [9, 4, 7], hovertemplate: '%{source.label} and %{target.label}: %{value} reviews<extra></extra>', }, }, ], });Hover control.
hoverinfo('all','none'or'skip') is the default ofnode.hoverinfoandlink.hoverinfo:'none'keeps the events without labels,'skip'turns hover and events off for that part.Legend. With
node.groupthe legend has one item per group, in order of first appearance. A click hides the group's nodes and their links; the others stay where they are, since hidden nodes still take part in the layout. The hidden names are inlayout.hiddenlabels. A trace without groups has no legend item by default. Whennode.coloris given per node, the legend has one item for the trace, not one per group.Events.
hover,unhoverandclickpoints say what they are inkind('node'or'link'), withcurveNumberandpointNumber: the node index, or the link's index in thelinkarrays. A node carrieslabel,degree,indegree,outdegree,group,size,colorandcustomdata, and with'preset'itsx,yandz. A link carrieslabel,value,customdata, andsourceandtarget, each with theindex,labelanddegreeof that node. A press on the scene starts a camera gesture; releasing it without dragging emitsclick:tschart.on('click', (e) => { const p = e.points[0]; if (p?.kind === 'node') console.log(p.pointNumber, p.label, p.degree); });
3D-native options
- The scene. Everything under
layout.sceneapplies: thecameraand its projection,dragmode,domain, annotations and several scenes in one figure. See 3D scenes. - Axes. With a computed arrangement the scene's axes default to
visible: false, which leaves out the grids, walls, ticks, titles and spikes: layout units are not a scale to read. This applies when every trace of the scene asks for it, and the figure can still setscene.xaxis.visible(andyaxis,zaxis). With'preset'the axes are drawn and typed like any 3D trace's (linear, log, date or category). - Aspect. With
'preset',scene.aspectmodeshapes the box from the data. With a computed arrangement all three axes get the same extent, so every mode but'manual'draws a cube in which the layout keeps its proportions. - Sizes in the scene. Spheres, tubes and arrowheads are sized once, for the first view that is drawn, and are objects in the scene from then on (see spheres or sprites). Set
scene.camerain the figure to choose the view the sizes are measured in. - Light. Spheres are shaded by a light of their own that moves with the camera, the same in every scene; they do not read
scene.lighting. Tubes and arrowheads are meshes: they are lit like other 3D meshes, and take the scene's lights whenscene.lightingis set. The trace has nolightingormaterialattributes of its own. - Camera animation.
chart.animateCameraflies the camera to a new view, andscene.autorotateturns the scene like a turntable.
A layout of your own
arrangement: 'custom' runs a layout you register by name: a function from the graph to typed arrays of positions, here with a z. It runs in calc and must return a finite position for every node:
import { createChart } from '@mk7s/holochart';
import { registerGraphLayout } from '@mk7s/holochart/graph';
// The nodes on a helix, in index order: one turn every eight nodes.
registerGraphLayout('helix', (graph) => {
const x = new Float64Array(graph.nodes);
const y = new Float64Array(graph.nodes);
const z = new Float64Array(graph.nodes);
for (let i = 0; i < graph.nodes; i++) {
const angle = (i * Math.PI) / 4;
x[i] = 100 * Math.cos(angle);
y[i] = 100 * Math.sin(angle);
z[i] = 12 * i;
}
return { x, y, z };
});
const chain = Array.from({ length: 23 }, (_, i) => i);
createChart(document.getElementById('chart')!, {
data: [
{
type: 'graph3d',
arrangement: 'custom',
custom: { name: 'helix' },
link: { source: chain, target: chain.map((i) => i + 1) },
},
],
});Performance notes
- Draw calls. Each part is one draw call, whatever the size of the graph: the nodes (ray-cast sphere impostors, four vertices each, or sprites), the links as one line with gaps, or as one mesh of tubes and arrowheads, the labels, and the plane outlines. The 2,400-node example draws its nodes and links in two.
- The camera. Orbiting, zooming and panning move the camera; positions are not computed or uploaded again. The parts sized in scene units (sphere sizes, tubes, arrowheads) are built again when the scene's layout or size changes, not while the camera moves. Two things do follow the camera: the choice of labels and the order of translucent nodes (both below).
- Lines or tubes. A line is three vertices per link. A tube is two rings of eight vertices, built on the CPU, and an arrowhead is a cone on top of that.
link.render: 'auto'draws lines above 5,000 links; set'line'yourself to get them for fewer. - The layout runs in calc, synchronously, before anything is drawn. The force layout is a Barnes–Hut simulation on an octree, so a step costs about n log n for n nodes, not _n_².
force.ticksis the number of steps: by default 600 up to 500 nodes and fewer for larger graphs (300 from 1,000 to 3,000 nodes, 90 at 10,000). Fewer steps are faster and leave a rougher layout. It runs again when something it reads changes (the nodes, the links, their sizes, theforceoptions), not when a color changes. The 2D trace'sworker,lodandlink.bundleare not attributes ofgraph3d: its layout always runs on the main thread. - Positions you already have cost nothing: compute them once, elsewhere or ahead of time, and pass them as
node.x/y/zwitharrangement: 'preset'. - Labels are chosen on the CPU for the view of the moment, a moment after the camera comes to rest, among the 600 nodes with the most links. Turn them off with
node.textposition: 'none'for graphs that are read by hover. - Translucent nodes (
node.opacitybelow 1, or given per node) are blended and sorted by depth as the camera moves, up to 200,000 nodes. Keep nodes opaque in large graphs. - Bundle size. An entry with the runtime and
tracesGraph3d, the trace and the 3D scene it is drawn in, is budgeted at 236 kB minified and gzipped, without three.js. The renderer's 3D lines, markers and spheres, and its mesh code for tubes and arrowheads, are lazy chunks, loaded on first use.
Accessibility notes
- Screen readers: the hidden description (see the accessibility guide) is the 2D trace's, beginning
3D network graph: the numbers of nodes, links and groups, the arrangement (a layered graph says its axis and its number of ranks), whether everything is connected, the most-connected nodes by name and the groups with their sizes. The data table is the list of links. - Keyboard: Tab moves into the plot area. The stops are the drawn nodes and, below each node, its links: ↓ from a node goes to its first link, ← / → move through that node's links, ↓ follows the link to its other end and ↑ returns. Under
'layered'the nodes are walked plane by plane, with ↑ / ↓ along the links. Shift + arrow keys orbit the camera,+/-move it in and out and0resets it, as in every 3D scene. - Do not rely on depth. A reader who cannot drag sees one view, so set a
scene.camerathat shows the structure you describe, and say in the text around the chart what the graph shows.scene.autorotatelets depth be seen without a gesture; it stops with reduced motion. - Color and shape: groups are told apart by color only when nodes are spheres. Where the kind of a node matters, use
node.render: 'sprite'with anode.symbolper kind, or label the nodes. Spheres are shaded, which darkens a part of each: pick group colors that differ in hue, not only in lightness. - Labels are partial. Only the labels that fit are drawn, so a node's name may be visible only on hover or from the keyboard. Where every name matters, use the 2D graph.
Attribute reference
See the graph3d attribute reference for every attribute, its type and its default: for instance arrangement, node.render, link.render, force and layered. Scene attributes (camera, axes, aspect, lights) are under scene in the layout reference.
Related charts
- Network graph: the same data in 2D, with more arrangements, routed and curved links, box nodes and selection
- Scatter3D: points, lines and text at x, y, z without the node and link model
- 3D scenes: the camera, axes, controls, hover and animation every 3D trace shares
- Graphs: the data model, the adapters and measures, and choosing an arrangement
Plotly migration notes
- Plotly.js has no graph trace, in 2D or in 3D. A network is drawn there as a
scatter3dtrace of markers for the nodes and another of lines for the links, with the positions computed elsewhere (networkx, igraph) and the line coordinates listed pair by pair with gaps between. - To port such a figure, pass the node positions as
node.x,node.yandnode.zand the pairs aslink.sourceandlink.target(node indices). With all three coordinates the arrangement is'preset', so the picture keeps its positions and its axes. Marker colors, sizes andtextbecomenode.color,node.sizeandnode.label. - Or leave the positions out and let
arrangement: 'force'compute them. The result will not match a layout computed by another library. layout.sceneis Plotly's: the camera,aspectmode, the axis attributes anddragmodemean the same.scene.autorotateandscene.lightingare Holochart extensions.- A
scatter3dfigure of a network still draws as it is, without this trace.