<bmx-graph>
A network graph: people, accounts, devices, services or anything else, and the links between them, drawn on a canvas so ten thousand nodes stay smooth. Nothing beyond the library; nothing is fetched except pictures the page names.
32 properties · 6 events · 21 methods · 6 parts
Example
Point at a person to light up who they work with. Ctrl-click two people (or Shift-drag a lasso) and press P for the shortest path between them. Tab to the graph and use the arrow keys, or N to step through a person's colleagues.
Dependencies, as a hierarchy
Directed links get arrowheads, and layout="hierarchy" follows them. Combine services by team with combine="team", then double-click a team (or press E) to open it.
Payments over a year, with a time bar and an overview
Two thousand accounts and their payments. Drag the time bar's window, or press play to watch the network grow. The overview moves the view; double-click an account marked + to load its counterparties from the page.
Show markup
<bmx-graph id="ex-graph" color-by="group" size-by="degree" selectable="multiple" tooltip-fields="role" label="Who works with whom" style="--bmx-graph-height: 30rem"></bmx-graph>
<p class="note" id="ex-graph-out" role="status">Point at a person to light up who they work with. Ctrl-click two people (or Shift-drag a lasso) and press P for the shortest path between them. Tab to the graph and use the arrow keys, or N to step through a person's colleagues.</p>
<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
<label>Layout
<select id="ex-graph-layout">
<option value="force" selected>Force</option>
<option value="circular">Circular</option>
<option value="radial">Radial</option>
<option value="hierarchy">Hierarchy</option>
<option value="grid">Grid</option>
</select>
</label>
<label>Colour by
<select id="ex-graph-colour">
<option value="group" selected>Department</option>
<option value="community">Community (found by the graph)</option>
<option value="tenure">Years at the company</option>
</select>
</label>
<label>Size by
<select id="ex-graph-size">
<option value="degree" selected>Links</option>
<option value="betweenness">Betweenness</option>
<option value="pagerank">PageRank</option>
<option value="none">Same size</option>
</select>
</label>
<label>Find <input type="search" id="ex-graph-find" placeholder="A name" size="12"></label>
<bmx-button variant="outline" tone="neutral" id="ex-graph-path">Path between the two selected</bmx-button>
<bmx-button variant="outline" tone="neutral" id="ex-graph-svg">Copy as SVG</bmx-button>
</div>
<h3>Dependencies, as a hierarchy</h3>
<p class="note">Directed links get arrowheads, and <code>layout="hierarchy"</code> follows them. Combine services by team with <code>combine="team"</code>, then double-click a team (or press E) to open it.</p>
<bmx-graph id="ex-graph-deps" layout="hierarchy" directed combine="team" color-by="team" label="Service dependencies" style="--bmx-graph-height: 24rem"></bmx-graph>
<div class="row" style="gap: 0.75rem; margin-block: 0.5rem 1.5rem">
<label><input type="checkbox" id="ex-graph-combine" checked> Combine by team</label>
<label>Direction
<select id="ex-graph-dir">
<option value="down" selected>Down</option>
<option value="right">Right</option>
</select>
</label>
</div>
<h3>Payments over a year, with a time bar and an overview</h3>
<p class="note">Two thousand accounts and their payments. Drag the time bar's window, or press play to watch the network grow. The overview moves the view; double-click an account marked + to load its counterparties from the page.</p>
<div style="display: flex; gap: 0.75rem; align-items: flex-start; flex-wrap: wrap">
<bmx-graph id="ex-graph-pay" time-bar color-by="community" labels="none" link-distance="40" node-size="5" label="Payments between accounts" style="flex: 1 1 28rem; --bmx-graph-height: 26rem"></bmx-graph>
<bmx-graph-overview for="ex-graph-pay"></bmx-graph-overview>
</div>
<script type="module">
await customElements.whenDefined('bmx-graph');
// A repeatable sequence, so every reader sees the same made-up company.
let seed = 7;
const rand = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
const pick = list => list[Math.floor(rand() * list.length)];
const first = ['Ava', 'Ben', 'Chloe', 'Dev', 'Ella', 'Finn', 'Grace', 'Hari', 'Isla', 'Jack', 'Kira', 'Leo', 'Maya', 'Noah', 'Olu', 'Priya', 'Quinn', 'Rosa', 'Sam', 'Tara', 'Umar', 'Vera', 'Will', 'Xin', 'Yusuf', 'Zoe'];
const last = ['Shah', 'Okafor', 'Murray', 'Chen', 'Novak', 'Reid', 'Patel', 'Lowe', 'Kaur', 'Evans', 'Silva', 'Brooks'];
const departments = ['Finance', 'Engineering', 'Sales', 'Operations', 'People'];
const roles = { Finance: ['Analyst', 'Controller', 'Accountant'], Engineering: ['Engineer', 'Architect', 'Tester'], Sales: ['Account manager', 'Sales lead'], Operations: ['Planner', 'Buyer'], People: ['Recruiter', 'Partner'] };
const people = [];
for (let i = 0; i < 64; i++) {
const group = departments[i % departments.length];
people.push({ id: `p${i}`, label: `${first[i % first.length]} ${last[(i * 7) % last.length]}`, group, role: pick(roles[group]), tenure: 1 + Math.floor(rand() * 15) });
}
const links = [];
for (const a of people)
for (const b of people) {
if (a.id >= b.id) continue;
const same = a.group === b.group;
if (rand() < (same ? 0.16 : 0.012)) links.push({ source: a.id, target: b.id, label: same ? 'team' : 'project', weight: same ? 1 : 2 });
}
const graph = document.getElementById('ex-graph');
graph.nodes = people;
graph.links = links;
const out = document.getElementById('ex-graph-out');
graph.addEventListener('bmxGraphSelect', e => (out.textContent = e.detail.ids.length ? `Selected: ${e.detail.ids.map(id => people.find(p => p.id === id)?.label ?? id).join(', ')}` : 'Nothing selected.'));
document.getElementById('ex-graph-layout').addEventListener('change', e => (graph.layout = e.target.value));
document.getElementById('ex-graph-colour').addEventListener('change', e => (graph.colorBy = e.target.value));
document.getElementById('ex-graph-size').addEventListener('change', e => (graph.sizeBy = e.target.value));
document.getElementById('ex-graph-find').addEventListener('input', async e => {
const ids = await graph.find(e.target.value);
out.textContent = e.target.value ? `${ids.length} found.` : '';
});
document.getElementById('ex-graph-path').addEventListener('click', async () => {
const ids = graph.selected;
if (ids.length < 2) {
out.textContent = 'Select two people first (Ctrl-click).';
return;
}
const path = await graph.findPath(ids[0], ids[1]);
out.textContent = path ? `${path.links.length} steps: ${path.nodes.map(id => people.find(p => p.id === id).label).join(' → ')}` : 'They are not connected.';
});
document.getElementById('ex-graph-svg').addEventListener('click', async () => {
await navigator.clipboard?.writeText(await graph.toSvg());
out.textContent = 'The graph is on the clipboard as SVG.';
});
// Services and what they call.
const services = [
['web', 'Front end'], ['mobile-api', 'Front end'], ['gateway', 'Platform'], ['auth', 'Platform'], ['users', 'Accounts'], ['billing', 'Accounts'],
['orders', 'Commerce'], ['catalogue', 'Commerce'], ['search', 'Commerce'], ['payments', 'Accounts'], ['notify', 'Platform'], ['ledger', 'Accounts'],
['stock', 'Commerce'], ['reports', 'Data'], ['warehouse', 'Data'], ['events', 'Data'],
];
const calls = [
['web', 'gateway'], ['mobile-api', 'gateway'], ['gateway', 'auth'], ['gateway', 'orders'], ['gateway', 'catalogue'], ['gateway', 'search'], ['gateway', 'users'],
['auth', 'users'], ['orders', 'payments'], ['orders', 'stock'], ['orders', 'notify'], ['payments', 'ledger'], ['billing', 'ledger'], ['users', 'billing'],
['search', 'catalogue'], ['catalogue', 'stock'], ['orders', 'events'], ['payments', 'events'], ['events', 'warehouse'], ['warehouse', 'reports'], ['notify', 'users'],
];
const deps = document.getElementById('ex-graph-deps');
deps.nodes = services.map(([id, team]) => ({ id, team, shape: team === 'Data' ? 'hexagon' : 'square' }));
deps.links = calls.map(([source, target]) => ({ source, target }));
document.getElementById('ex-graph-combine').addEventListener('change', e => (deps.combine = e.target.checked ? 'team' : undefined));
document.getElementById('ex-graph-dir').addEventListener('change', e => (deps.direction = e.target.value));
// Accounts and payments over a year, in a few clusters; some accounts have more to load.
const pay = document.getElementById('ex-graph-pay');
const start = Date.UTC(2026, 0, 1);
const day = 86400000;
const accounts = [];
const payments = [];
for (let i = 0; i < 2000; i++) accounts.push({ id: `a${i}`, label: `Account ${1000 + i}`, time: start + Math.floor(rand() * 330) * day, expandable: i % 97 === 0 });
// Ten rings of two hundred accounts that mostly pay each other, and a few payments between rings.
for (let i = 1; i < 2000; i++) {
const ring = Math.floor(i / 200) * 200;
const within = i - ring;
const targets = within === 0 ? [i - 1] : [ring + Math.floor(rand() * within), ...(within > 3 && rand() < 0.6 ? [ring + Math.floor(rand() * within)] : [])];
for (const j of new Set(targets)) payments.push({ source: `a${i}`, target: `a${j}`, time: Math.max(accounts[i].time, accounts[j].time) + Math.floor(rand() * 10) * day });
if (rand() < 0.01) {
const j = Math.floor(rand() * i);
payments.push({ source: `a${i}`, target: `a${j}`, time: Math.max(accounts[i].time, accounts[j].time) });
}
}
pay.nodes = accounts;
pay.links = payments;
pay.addEventListener('bmxGraphExpand', e => {
if (e.detail.action !== 'load') return;
const id = e.detail.id;
const extra = Array.from({ length: 5 }, (_, k) => ({ id: `${id}-c${k}`, label: `Counterparty ${k + 1}` }));
pay.addData({ nodes: extra, links: extra.map(n => ({ source: id, target: n.id })) });
});
</script>
Lay it out by force, as a hierarchy, in rings or on a circle; colour nodes by group, community or a field, and size them by a field or a measure of importance (degree, PageRank, betweenness). Combine nodes by a field and open them again, fold leaves away, or ask the page for a node's neighbours as the reader expands it. Find the shortest path between two nodes; filter by time with a bar that plays the network's history; select by clicking or drawing a lasso. Every node is reachable by the keyboard and every value is in a table.
Keyboard: the graph is one stop. The arrow keys move from node to node in that direction; N and Shift+N move through the current node's neighbours; Enter or Space selects; E expands or folds; P shows the path between two selected nodes; Shift and the arrows pan; + and - zoom; 0 fits; Escape clears the path, then the selection.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
colorBy |
color-by |
string |
'auto' |
How nodes are coloured: auto (by group when nodes have one), none, community, component, or a field (text fields by category, numbers low to high). |
combine |
combine |
string |
— | Combine nodes that share a value of this field into one; the reader opens them again. |
directed |
directed |
boolean |
false |
Links have a direction: arrowheads, a hierarchy follows them, and measures count in and out. |
direction |
direction |
BmxGraphDirection |
'down' |
Which way a hierarchy grows: down (default), up, right or left. |
format |
format |
BmxChartNumberFormat | string |
— | How numbers are written: percent, currency:EUR, decimal:1, compact, or the object. |
highlight |
highlight |
'hover' | 'none' |
'hover' |
Light up a node's neighbours when it is pointed at or reached by the keyboard: hover (default) or none. |
label |
label |
string |
— | What the graph shows: its accessible name. |
labelField |
label-field |
string |
'label' |
The field of a node holding its name. |
labels |
labels |
'auto' | 'all' | 'none' |
'auto' |
Node names: auto (as there is room, and always for what is chosen), all, or none. |
layout |
layout |
BmxGraphLayout |
'force' |
force (default), hierarchy, radial, circular, grid, or preset (each node's own x and y). |
legend |
legend |
'bottom' | 'none' |
'bottom' |
Where the legend goes: bottom (default) or none. |
linkDistance |
link-distance |
number |
70 |
How long links want to be, for the force layout. |
linkLabels |
link-labels |
'auto' | 'all' | 'none' |
'auto' |
Link labels: auto (when zoomed in, and on a path), all, or none. |
links |
links |
BmxGraphLink[] | string |
— | The links: [{ source, target, label, weight, … }] (from and to also work), as a property or JSON. |
locale |
locale |
string |
— | A BCP 47 locale for numbers and times. Default: the page's lang. |
maxNodeSize |
max-node-size |
number |
26 |
The radius of the largest node when sized by a value. |
maxZoom |
max-zoom |
number |
8 |
How far in the reader can zoom. |
nodeSize |
node-size |
number |
8 |
The radius of a node, in pixels at zoom 1. |
nodes |
nodes |
BmxGraphNode[] | { nodes?: unknown; links?: unknown } | string |
— | The nodes: [{ id, label, group, … }], as a property or JSON. May also be { nodes, links }. |
pinOnDrag |
pin-on-drag |
boolean |
true |
A node dragged by the reader stays where it is put. |
root |
root |
string |
— | The node a radial or hierarchy layout starts from. Default: the most linked node of each piece. |
selectable |
selectable |
'none' | 'single' | 'multiple' |
'single' |
Nodes the reader can select: none, single (default) or multiple (Ctrl or Shift, and the lasso). |
selected |
selected |
string[] | string |
[] |
The selected nodes' ids. Changes write back here. |
sizeBy |
size-by |
string |
'none' |
How nodes are sized: none (each node's size, or the same), degree, in-degree, out-degree, pagerank, betweenness, or a number field. |
strings |
strings |
Partial<BmxGraphStrings> | string |
— | Replacements for the graph's wording, as a property or JSON. |
tableView |
table-view |
boolean |
true |
Offer the table view. |
timeBar |
time-bar |
boolean |
false |
Show the time bar (when nodes or links have times): a histogram to choose a range on, and a play button. |
timeField |
time-field |
string |
'time' |
The field holding when a node or link exists. |
timeRange |
time-range |
[number, number] | string | null |
— | Only what exists in this range: [from, to] in milliseconds, or "2026-01-01,2026-06-30". Changes write back here. |
timeZone |
time-zone |
string |
— | The time zone times are written in. Default: the reader's. |
tooltipFields |
tooltip-fields |
string[] | string |
— | Fields of a node shown under its name in the tooltip and the table: "email,role" or a list. |
zoomable |
zoomable |
boolean |
true |
The reader can pan and zoom. |
Events
| Event | Detail | Description |
|---|---|---|
bmxGraphExpand |
BmxGraphExpandDetail |
A node is being expanded or folded. Cancelable; for load, add the node's neighbours with addData(). |
bmxGraphItemClick |
BmxGraphItemDetail |
A node, combined node or link was clicked, or chosen with the keyboard. |
bmxGraphLayoutChange |
BmxGraphLayoutDetail |
Nodes moved: while a layout runs (a few times a second) and once when it comes to rest. |
bmxGraphSelect |
BmxGraphSelectDetail |
Nodes were selected or unselected. |
bmxGraphTimeChange |
BmxGraphTimeDetail |
The time range changed. |
bmxGraphViewChange |
BmxGraphViewDetail |
The view was panned or zoomed. |
Methods
| Method | Signature | Description |
|---|---|---|
addData |
addData(data: { nodes?: unknown; links?: unknown; }) => Promise<void> |
Adds nodes and links ({ nodes, links }), placed beside the nodes they link to. A repeated node id updates that node. |
centerOn |
centerOn(target: string | [number, number], zoom?: number) => Promise<void> |
Centres the view on a node or a point [x, y], at a zoom if given. |
clearPath |
clearPath() => Promise<void> |
Stops showing a path, and what find() lit up. |
clearSelection |
clearSelection() => Promise<void> |
Clears the selection. |
collapse |
collapse(id: string) => Promise<void> |
Collapses a node: folds its leaves away, or closes the combined node it belongs to. |
expand |
expand(id: string) => Promise<void> |
Expands a node: opens a combined node, unfolds leaves, or asks the page for neighbours (bmxGraphExpand). |
find |
find(text: string, fields?: string[]) => Promise<string[]> |
Lights up the nodes whose name (or any field given) contains the text, dimming the rest; returns their ids. Empty text clears it. |
findPath |
findPath(from: string, to: string, options?: { weighted?: boolean; }) => Promise<BmxGraphPathResult | null> |
Finds and shows the shortest path between two nodes; null when there is none. weighted counts link weights as lengths; with directed, links are followed only forwards. |
fit |
fit(ids?: string[] | null) => Promise<void> |
Fits nodes into the view: all of them, or the ones given. |
getMetrics |
getMetrics() => Promise<BmxGraphNodeMetrics[]> |
Measures for every node: degree (in and out), PageRank, betweenness, community and component. |
getPositions |
getPositions() => Promise<Record<string, [number, number]>> |
Every node's place, in the graph's units: { id: [x, y] }. |
getView |
getView() => Promise<BmxGraphViewDetail> |
The zoom and the middle of the view. |
paintOverview |
paintOverview(canvas: HTMLCanvasElement, width: number, height: number) => Promise<BmxGraphOverviewFrame | null> |
Draws the whole graph small into a canvas, for bmx-graph-overview; returns how the canvas maps onto the graph. |
pin |
pin(ids: string[]) => Promise<void> |
Keeps nodes where they are. |
relayout |
relayout() => Promise<void> |
Lays the graph out again from the start. |
removeNodes |
removeNodes(ids: string[]) => Promise<void> |
Takes nodes out, with their links. |
toJson |
toJson() => Promise<{ nodes: BmxGraphNode[]; links: BmxGraphLink[]; }> |
The nodes (with their places) and links, as data. |
toPng |
toPng(scale?: number) => Promise<Blob | null> |
The graph as it is drawn, as a PNG, at scale times the screen's pixels. |
toSvg |
toSvg() => Promise<string> |
The graph as it is drawn, as a standalone SVG document. |
unpin |
unpin(ids?: string[]) => Promise<void> |
Lets pinned nodes move again: the ones given, or all of them. |
whenSettled |
whenSettled() => Promise<void> |
Resolves when the layout has come to rest. |
CSS shadow parts
| Part | Description |
|---|---|
controls |
the zoom, lasso and table buttons. |
legend |
the legend. |
stage |
the drawing. |
table |
the table view. |
time-bar |
the time bar. |
tooltip |
the name and details under the pointer. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-graph-background |
Behind the graph. |
--bmx-graph-color-0 |
The first category. Colours 0 to 9 are used in turn. |
--bmx-graph-color-1 |
Category 2. |
--bmx-graph-color-2 |
Category 3. |
--bmx-graph-color-3 |
Category 4. |
--bmx-graph-color-4 |
Category 5. |
--bmx-graph-color-5 |
Category 6. |
--bmx-graph-color-6 |
Category 7. |
--bmx-graph-color-7 |
Category 8. |
--bmx-graph-color-8 |
Category 9. |
--bmx-graph-color-9 |
Category 10. |
--bmx-graph-height |
How tall the graph is. Default: 30rem. |
--bmx-graph-high |
The highest value. |
--bmx-graph-label |
Node and link names. |
--bmx-graph-link |
A link. |
--bmx-graph-low |
The lowest value, when colouring by a number. |
--bmx-graph-node |
A node with no colouring. |
--bmx-graph-path |
A path found between two nodes. |
--bmx-graph-selected |
The ring round a selected node, and the lasso. |