v2.0.0

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

Path between the two selected Copy as SVG

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.

graph.nodes = [{ id: 'a', label: 'Alice', group: 'Finance' }, …]; graph.links = [{ source: 'a', target: 'b', label: 'pays' }, …];

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

PropertyAttributeTypeDefaultDescription
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

EventDetailDescription
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

MethodSignatureDescription
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

PartDescription
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

PropertyDescription
--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.