Aller au contenu

@glitchr/graphjs

A layered directed graph you can drag and zoom: HTML nodes, SVG links, no dependency

Étage
Bibliothèque
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
npm install @glitchr/graphjs

A layered directed graph that is dragged and zoomed. The nodes are HTML - cards the server rendered, links that stay links - and the edges are SVG. No dependency, no jQuery, no build step. Written for family trees (the tree preset: ranks from top to bottom, right-angled edges) and pipelines (the pipeline preset: stages from left to right, curved edges, a state on each job). Everything a site restyles is a --graph-* CSS variable.

The library has two halves. src/js/layout.js places the nodes (ranks, order inside a rank, x and y in pixels, the points of every edge) without touching the DOM: it runs in a browser, a worker, Node or a server. src/js/graph.js is the Graph class: it reads the nodes the page declares (or the data it is given), measures them, asks the layout for their places, draws the edges and handles the view (drag, zoom, keyboard, full screen, the lit line of a node).

When to use it

  • A graph of a few to a few hundred nodes whose nodes must stay real HTML: links a crawler follows, cards styled by the site's own CSS, text a screen reader reads.
  • A family tree, an organisation chart, a dependency or build pipeline: a directed graph whose edges go from one rank to the next.

When not: a graph whose cycles matter (an edge closing a cycle is set aside, see What is set aside); a force-directed or radial drawing; tens of thousands of nodes (every node is an element, and Graph computes the layout in one go on the main thread, again at each layout()).

Entry points

Import File What it gives
@glitchr/graphjs src/index.js Graph (default and named export), layout, path, lineage; imports the stylesheet (a bundler must handle CSS imports)
@glitchr/graphjs/graph src/js/graph.js Graph (default and named export), without the stylesheet
@glitchr/graphjs/layout src/js/layout.js layout (default and named), path, lineage: no DOM
@glitchr/graphjs/css src/css/index.css the stylesheet

The package is an ES module ("type": "module") and declares side effects: importing graph.js (directly or through src/index.js) in a browser starts every [data-graph] of the page and sets window.Graph when nothing holds that name yet. See Starting.

Installation

With a bundler

npm install @glitchr/graphjs
import Graph from '@glitchr/graphjs';          // the class, and its stylesheet

A bundler that does not handle CSS imports takes the class and the stylesheet apart:

import Graph from '@glitchr/graphjs/graph';
import '@glitchr/graphjs/css';                 // or a <link> to src/css/index.css

Without a bundler

src/index.js imports a stylesheet, which a browser cannot import as a module: load src/js/graph.js (it imports ./layout.js by itself) and link the stylesheet.

<link rel="stylesheet" href="node_modules/@glitchr/graphjs/src/css/index.css">
<script type="module">import Graph from './node_modules/@glitchr/graphjs/src/js/graph.js';</script>

The module needs to be served over HTTP: browsers refuse module scripts read from file://.

In a Symfony / Encore site

npm install @glitchr/graphjs
// assets/app-defer.js
import '@glitchr/graphjs';
<div data-graph data-graph-preset="tree" data-graph-focus="{{ focus.id }}"
     data-graph-label-zoom-in="{{ 'graph.zoom_in'|trans }}" data-graph-label-fit="{{ 'graph.fit'|trans }}">
    {% for person in people %}
        <a href="{{ path('person_show', {id: person.id}) }}" data-graph-node="{{ person.id }}" data-graph-from="{{ person.parents|join(' ') }}">{{ person.name }}</a>
    {% endfor %}
</div>

The graphs start by themselves at page load and at each transparent:load (a navigation of @glitchr/flowjs). A large graph is better loaded as JSON and given to setData().

A first graph

Declared in the page:

<div data-graph data-graph-preset="tree" data-graph-focus="me" aria-label="My family">
    <a href="/people/1" data-graph-node="father">Henri Vasseur</a>
    <a href="/people/2" data-graph-node="mother">Jeanne Collet</a>
    <span data-graph-node="union" data-graph-kind="junction" data-graph-from="father mother"></span>
    <a href="/people/3" data-graph-node="me" data-graph-from="union">Paul Vasseur</a>
</div>

Given as data:

const graph = new Graph(document.querySelector('#pipeline'), { preset: 'pipeline' });

graph.setData({
    nodes: [
        { id: 'build', label: 'build', state: 'success' },
        { id: 'test', label: 'tests', sub: '2 min 10', state: 'running', href: '/jobs/12' },
        { id: 'deploy', label: 'deploy', state: 'pending' },
    ],
    edges: [{ from: 'build', to: 'test' }, { from: 'test', to: 'deploy' }],
});

graph.setState('test', 'success');   // one job changes: nothing else is drawn again

example/index.html shows both (a family tree over five generations, a pipeline of ten jobs): serve the repository (python3 -m http.server 8000) and open http://localhost:8000/example/.

How this manual is organised

  • The graph: starting a graph, the attributes a page declares, the data setData() takes, every option with its default and its attribute, the presets.
  • Moving about: drag, wheel, pinch, keyboard, full screen, the remembered view, the lit line of a node.
  • API: every static and instance method, the events, the classes and attributes the script sets.
  • The layout: layout(), path() and lineage() without the DOM - how ranks, order and places are decided.
  • Styling: the --graph-* variables, the themes, the presets, the states.
  • Testing: the test suite and the example page.

Version and licence

This manual describes version 1.0.0 (package.json). The package keeps no CHANGELOG file: the git history of github.com/glitchr-studio/graphjs is its record. MIT since 2026-10-09; earlier versions remain published under LGPL-3.0-or-later.

Proposer une modification docs/index.md

The graph

Starting

In a browser, importing src/js/graph.js (or src/index.js, which imports it):

  • starts every [data-graph] of the page - at once when the document is parsed already, else at DOMContentLoaded;
  • starts them again at every transparent:load event on window (a navigation of @glitchr/flowjs swaps the page without loading it): the graphs already started are left as they are, the new ones start;
  • sets window.Graph to the class, unless something holds that name already.

Graph.ready(root) starts the [data-graph] elements under root (and root itself when it matches) that are not started yet, for a fragment added later; it never starts one twice. A graph is also started by hand, on any element, [data-graph] or not:

import Graph from '@glitchr/graphjs';

const graph = new Graph(document.getElementById('pipeline'), { preset: 'pipeline', lineage: 'select' });

new Graph(element) on an element that has a graph already returns that graph, and the options given then are ignored. Graph.get(element) returns the graph of an element, or null.

On start the element gets the class graph, a .graph-viewport (focusable when keyboard is on, role="group", labelled by the element's aria-label, else labels.graph) holding a .graph-canvas, which holds the SVG of the edges (.graph-edges) and the nodes: the [data-graph-node] elements found inside the element are moved into the canvas, in their order. With controls on, a .graph-controls block of four buttons follows. Then the declared nodes are read and laid out (refresh()).

Until the first layout, the stylesheet hides the declared nodes (.graph:not(.is-ready) [data-graph-node] { visibility: hidden }): a page never shows them out of place. A graph whose element has no size yet (hidden, in a closed tab) is laid out again when it gets one (a ResizeObserver), and once more when the page's fonts are loaded (document.fonts.ready), the cards having changed size.

Declared in the page

<div data-graph data-graph-preset="tree" data-graph-focus="me" aria-label="My family">
    <a href="/people/1" data-graph-node="father">Henri Vasseur</a>
    <a href="/people/2" data-graph-node="mother">Jeanne Collet</a>
    <span data-graph-node="union" data-graph-kind="junction" data-graph-from="father mother"></span>
    <a href="/people/3" data-graph-node="me" data-graph-from="union">Paul Vasseur</a>
</div>

On a node

Attribute Read by Effect
data-graph-node="id" refresh() the node, and its identifier. A second element with the same identifier is moved into the canvas with the others but is not a node: neither placed nor counted
data-graph-from="a b" refresh() the nodes it comes from, separated by spaces or commas: one edge each, from each of them to this node
data-graph-rank="2" refresh() its rank, when the graph must not decide (a number; rounded)
data-graph-state="success" refresh(), the stylesheet pending, running, success, failed, warning, manual, skipped, canceled: the node's colour, and the colour of the edges that reach it
data-graph-kind="junction" the stylesheet a small round node of 14 px: a union, a gate
data-graph-nodrag the drag on anything inside a node that a drag must not start from (form fields and contenteditable never start one)

Nothing else of a declared node is read: its content, its classes and its links stay the server's. Nodes are measured as they are drawn (offsetWidth, offsetHeight), so a card's size comes from the stylesheet.

On the graph

Each data-graph-* attribute of the element sets the option of the same name (see Options): data-graph-rank-gap sets rankGap, data-graph-label-zoom-in sets labels.zoomIn. A boolean attribute is false when it reads false, 0, off or no, true otherwise (an empty data-graph-controls is true). A number attribute that is empty or not a number is ignored.

Attribute Option
data-graph-preset preset
data-graph-direction direction
data-graph-edges edges
data-graph-focus focus
data-graph-lineage lineage
data-graph-key key
data-graph-align align
data-graph-sort sort (boolean)
data-graph-controls, data-graph-keyboard, data-graph-remember controls, keyboard, remember (booleans)
data-graph-gap, data-graph-rank-gap, data-graph-radius gap, rankGap, radius (numbers)
data-graph-min-zoom, data-graph-max-zoom, data-graph-padding minZoom, maxZoom, padding (numbers)
data-graph-label-graph, -label-zoom-in, -label-zoom-out, -label-fit, -label-fullscreen labels.graph, labels.zoomIn, labels.zoomOut, labels.fit, labels.fullscreen
data-graph-theme="light" or "dark" none: read by the stylesheet, see Styling

zoomStep and render have no attribute: they are given to new Graph() only.

Given as data

graph.setData({
    nodes: [
        { id: 1, label: 'build', state: 'success' },
        { id: 2, label: 'test', sub: '2 min', state: 'running', href: '/jobs/2' },
        { id: 'gate', kind: 'junction' },
    ],
    edges: [{ from: 1, to: 2 }, { from: 2, to: 'gate', state: 'failed' }],
});

setData() removes the nodes the graph holds (declared or given), builds one element per node, and lays them out. Identifiers are turned into strings (1 and '1' are the same node); a node given twice is built once.

A node

Field Effect
id required: the identifier, written in data-graph-node
label the first line, in a <span class="graph-node-label"> (text, escaped). Without it the line shows the id
sub a second line, in a <span class="graph-node-sub"> (text, escaped)
href the node is an <a href> (else a <div>)
title the element's title
className the element's classes, before graph-node
html the element's content, inserted as it is in place of label and sub: it is the caller's to escape
rank written in data-graph-rank; the rank the layout keeps
state written in data-graph-state
kind written in data-graph-kind. A junction without label (and without html) has no text: it is a dot

The element built for this data:

<a href="/jobs/2" data-graph-node="2" class="graph-node" data-graph-state="running" data-graph-placed-rank="1"><span class="graph-node-label">test</span><span class="graph-node-sub">2 min</span></a>

The render(node, graph) option builds the element instead: it receives the node as given (its id a string) and the graph, and returns an element; when it returns nothing, the default element is built. data-graph-node, the class graph-node and the rank, state and kind attributes are set on what it returns.

new Graph(element, {
    render: (node) => {
        const card = document.createElement('article');
        card.innerHTML = '<h3></h3><p></p>';
        card.querySelector('h3').textContent = node.label;
        card.querySelector('p').textContent = node.born + ' - ' + (node.died || '');
        return card;
    },
});

An edge

Field Effect
from, to required: the identifiers of the two nodes (turned into strings)
state written in the path's data-graph-state (its colour); without it, the path takes the state of the node it reaches
kind written in the path's data-graph-kind, for a stylesheet to hook on

An edge to a node that does not exist, from a node to itself, given twice, or closing a cycle is not drawn: see What is set aside.

The second argument

setData(data, {focus, keepView}):

  • focus: changes the focus option (the node the graph opens on and marks is-focus); null removes it.
  • keepView: true: the view stays where it is. Otherwise the graph is placed again as on its first layout: the remembered view, else the focus, else the whole graph (see The first view).

A declared graph (refresh()) and a graph given as data are the same thing afterwards: every method works on both. refresh() after setData() reads the elements in the canvas, so it gives back the nodes setData() built, with their data-graph-rank and data-graph-state, and without the edges (the built elements have no data-graph-from).

Options

Given to new Graph(element, options) or Graph.ready(root, options). A preset sets some of them; the element's data-graph-* attributes win over the preset, the options over both. labels is merged key by key (the defaults, then the attributes, then the options).

Option Type Default Attribute Effect
preset 'tree', 'pipeline' or null null data-graph-preset the defaults of a kind of graph (below); written back in data-graph-preset when only given as an option, for the stylesheet
direction 'down', 'right' 'down' data-graph-direction down stacks the ranks from top to bottom, right from left to right; written back in data-graph-direction
edges 'curve', 'step', 'line' 'curve' data-graph-edges the shape of the edges: an S between two ranks, right angles, straight lines
radius number (px) 8 data-graph-radius the rounding of a step edge's corners
gap number (px) 24 data-graph-gap between two neighbours of a rank (half of it between two edges crossing a rank)
rankGap number (px) 56 data-graph-rank-gap between two ranks
align 'start', 'center', 'end' 'start' data-graph-align where a node shallower than its rank stands in it
sort boolean true data-graph-sort false keeps the nodes of a rank in the order they came
focus node id or null null data-graph-focus the node the graph opens on; it gets the class is-focus; Home comes back to it
lineage 'hover', 'select', 'off' 'hover' data-graph-lineage how a node's line (its ancestors, its descendants) is lit: on pointing and focus, on a click that keeps it lit, or never (highlight() still works)
controls boolean true data-graph-controls the four buttons: zoom in, zoom out, fit, full screen
keyboard boolean true data-graph-keyboard the viewport is focusable and answers the keys of The keyboard
remember boolean true data-graph-remember the view is kept in sessionStorage and given back on return
key string or null null data-graph-key the name the view is kept under (graphjs: + it); null: the element's id, else the page's path and query string
minZoom number 0.15 data-graph-min-zoom the smallest scale, for every zoom and every fit
maxZoom number 2.5 data-graph-max-zoom the largest scale
zoomStep number 1.3 none the factor of one zoomIn() / zoomOut() (the buttons, + and -)
padding number (px) 32 data-graph-padding the margin kept around the graph by fit()
labels object see below data-graph-label-* the accessible names, in the page's language
render (node, graph) => HTMLElement or null null none builds a node's element for setData()

labels and their defaults:

Key Default Used as
graph Graph the viewport's aria-label, when the element has no aria-label
zoomIn Zoom in the button's title and aria-label
zoomOut Zoom out the same
fit Fit the same
fullscreen Full screen the same

The options are read once: changing graph.options afterwards changes what the methods read next (lineage, focus, the zoom limits) but not what was built (the buttons, the listeners chosen for lineage: 'hover' and keyboard).

Presets

Preset direction edges gap rankGap The stylesheet
tree down step 24 48 centred cards, 8.5rem to 12.5rem wide
pipeline right curve 14 64 pill-shaped jobs on one line, a disc for the state, no dots on the ground

A preset only sets defaults: data-graph-rank-gap="80" on a tree gives 80, and new Graph(element, {edges: 'line'}) wins over both. Graph.presets holds the table above (the script's half); the stylesheet's half is keyed on [data-graph-preset], see Styling.

Proposer une modification docs/graph.md

Moving about

Pointer, wheel, touch

Gesture Effect
Drag (mouse: the main button; a finger; a pen) moves the graph. Under 4 px it is still a click: a link in a node stays a link. Past 4 px the click that ends the drag is swallowed
Ctrl / ⌘ + wheel, a trackpad pinch zooms on the pointer: the point under it stays where it is
Two fingers zoom on their middle, and their middle drags the graph; one finger left after a pinch drags on
The wheel alone scrolls the page; in full screen it moves the graph
A click on a node dispatches graph:select; with lineage: 'select' it lights the node's line, or puts it out when it is lit already
A click on the ground with lineage: 'select', puts the lit line out

A drag never starts from a form field (input, textarea, select), a contenteditable element or anything inside a [data-graph-nodrag] element. The browser's own drag of a link or a picture inside a node is prevented. A mouse wheel (notches of 50 or more) zooms by smaller factors than the small steps of a trackpad pinch, so both feel alike; a step is capped at 240 px of wheel.

The keyboard

With keyboard on (the default), the viewport is focusable (tabindex="0") and, once it or a node inside it has the focus:

Key Effect
Arrows move the graph by 60 px; with Shift, by 240 px
+ or = zoomIn()
- or _ zoomOut()
0 fit()
Home center(focus) when a focus is set, else fit()
F or f fullscreen(): on, or off
Tab goes through what is focusable among the nodes (nodes that are links, links inside cards), in the page's order; a node that takes the focus from the keyboard (:focus-visible) is brought into view (reveal())

A key pressed with Ctrl, ⌘ or Alt is left to the browser, and so is any key typed in a form field or a contenteditable element inside a node. A node that takes the focus does not scroll the viewport (the browser's scrolling into view is undone): the graph moves instead.

Full screen

fullscreen() (the fourth button, the F key) asks the browser to show the graph's element in full screen (the Fullscreen API). Where there is none (an iPhone), or the browser refuses, the element gets the class is-fullscreen and the stylesheet makes it cover the window (position: fixed, the highest z-index). fullscreen(false) leaves either. The class is-fullscreen is also set while the browser shows the element in full screen (fullscreenchange), so a stylesheet hooks on one class. In full screen the wheel alone moves the graph.

The first view

When a graph is placed for the first time (its nodes measured, its viewport with a size), it opens on:

  1. the remembered view, when remember is on and one was kept for this graph, with the same focus and the same number of nodes;
  2. else, when a focus is set and the whole graph would be shown at less than 0.7 of its size, the focused node in the middle at its natural size (scale 1);
  3. else the whole graph (fit()), never larger than its natural size.

setData() places the graph again in the same way, unless it is given {keepView: true}.

When the viewport changes size afterwards (the window, a panel), what was in the middle stays in the middle; the zoom does not change.

The remembered view

With remember on, each move of the view writes, once per animation frame, in sessionStorage under graphjs: + the key (the key option, else the element's id, else the page's path and query string) a record of this shape:

{"cx": 412, "cy": 230, "scale": 0.8, "focus": "me", "nodes": 17}

cx, cy are the point of the graph in the middle of the view, so the view comes back right in a window of another size. A view kept for another focus or another number of nodes is not given back. Two graphs of a page without id and without key share one key: give each an id or a data-graph-key. In a private window, or with the storage full, nothing is kept and nothing fails.

The lit line

A node's line is the node, everything it descends from and everything that descends from it along the edges drawn (lineage() of The layout, run on the edges the layout kept: an edge it set aside - a cycle's, a loop, a duplicate, one to no node - is not drawn and leads nowhere). Lit, its nodes and edges get the class is-lineage, the element gets has-lineage, and the stylesheet dims everything else (--graph-dim) and draws the lit edges in --graph-accent.

lineage Lit by
hover (default) pointing at a node (not by touch) and giving it the focus; leaving it lights the pinned node again, if any
select a click on a node, which keeps it lit; a second click on it, or a click on the ground, puts it out. A graph:select whose preventDefault() was called lights nothing
off nothing but highlight()

highlight(id) lights a node's line and keeps it lit (the pinned node) whatever the mode; highlight(null) puts it out.

Proposer une modification docs/interaction.md

API

Static

Member Returns Effect
Graph.ready(root = document, options = {}) Graph[] starts the [data-graph] elements under root (and root itself when it matches) that are not started yet, with options; returns every graph found, the ones started before included. Outside a browser (no document) returns []
Graph.get(element) Graph or null the graph of an element
Graph.layout, Graph.path, Graph.lineage functions the functions of src/js/layout.js, for a page that loaded only the class (see The layout)
Graph.presets object {tree: {...}, pipeline: {...}}: the options each preset sets (see Presets)
new Graph(element, options = {}) Graph starts a graph on element; the graph it has already, when it has one

Instance methods

Every method but isFullscreen() and destroy() returns the graph, so calls chain: graph.setData(data).center('me', {scale: 1}).

Method Effect
setData({nodes, edges}, {focus, keepView} = {}) replaces the graph: see Given as data
refresh() reads the declared nodes again (data-graph-node, -from, -rank, -state of the elements in the canvas) and lays them out: the server changed them. Nodes added to the element after the start must be put in the canvas first (graph.canvas.appendChild(el)): only the canvas is read
layout() measures every node, places them, draws the edges again; dispatches graph:layout. Call it when a node changed size
setState(id, state) sets (or, with an empty state, removes) a node's data-graph-state and the state of every edge that reaches it, without laying anything out again
fit({animate} = {}) shows the whole graph, centred, never larger than its natural size, within padding, minZoom and maxZoom
center(id, {scale, animate} = {}) puts a node in the middle of the view; scale changes the zoom too (clamped). An unknown id does nothing
reveal(id, margin = 16) moves the view just enough for a node to be seen whole, margin px from the edges, gliding
zoomTo(scale, at, {animate} = {}) zooms to scale (clamped to minZoom..maxZoom), the point at ({x, y} in px from the viewport's top left; default: its middle) staying where it is
zoomIn(), zoomOut() zoomTo() the scale times or divided by zoomStep, on the middle, gliding
pan(dx, dy) moves the view by dx, dy px
fullscreen(on) full screen (true), back (false), or the other way round (no argument): see Full screen
isFullscreen() true while the element is shown in full screen or has the class is-fullscreen
highlight(id) lights a node's line and keeps it lit; null puts it out: see The lit line
destroy() stops the graph and gives the element back as it was before the start: removes every listener the graph added (pointer, wheel, keyboard, focus, fullscreen, the buttons'), stops watching the viewport's size, takes the viewport, the canvas, the edges and the buttons out, puts the declared nodes back where they stood with their own classes and style (a node the site put in the canvas after the start is moved back into the element; the nodes setData() made are removed), restores the element's class, style, data-graph-preset and data-graph-direction, leaves full screen, and forgets the graph (Graph.get() then answers null). A later Graph.ready() - the next transparent:load included - starts the element afresh, as on a first load: one viewport, one set of buttons; a page that wants it stopped for good removes its data-graph attribute too. A second call does nothing

{animate: true} makes the view glide (the class is-gliding for 320 ms, a CSS transition of 0.28 s; none under prefers-reduced-motion: reduce); {animate: false} stops a glide under way.

Instance properties

Read them; the methods keep them in step.

Property Holds
root the element
options the options in force (defaults, preset, attributes, options)
view {x, y, scale}: the canvas's offset in px and its scale
data {nodes, edges}: what was declared or given
result what layout() returned last (see The layout)
elements Map of node id to element
paths [{element, from, to, index}]: the drawn edges, index in data.edges
pinned the node whose line is kept lit, or null
viewport, canvas, svg, controls the parts the graph built (controls only with the option on)

Events

Dispatched on the graph's element; they bubble, so one listener on document hears every graph.

Event When detail Cancelable
graph:select a click on a node (not the click that ends a drag) {id, element, graph, originalEvent} yes: preventDefault() stops the click (a link is not followed) and, with lineage: 'select', lights nothing
graph:view the view moved (drag, zoom, fit, keys, resize), at most once per animation frame; not for the view given back from sessionStorage {x, y, scale, graph} no
graph:layout each layout(): the nodes were placed and the edges drawn {graph, layout}: layout is the result of layout() no
document.addEventListener('graph:select', (event) => {
    if (event.detail.id === 'deploy-production') {
        event.preventDefault();               // the link is not followed
        openConfirmDialog(event.detail.element);
    }
});

element.addEventListener('graph:view', ({ detail }) => {
    zoomLabel.textContent = Math.round(detail.scale * 100) + ' %';
});

The script listens to transparent:load on window (to start the graphs of a page swapped in) and to fullscreenchange on document.

Classes and attributes the script sets

On Set
the element .graph; .is-ready after the first layout; .is-dragging during a drag; .is-gliding during an animated move; .is-fullscreen; .has-lineage while a line is lit; data-graph-direction; data-graph-preset when the preset came as an option; the CSS variable --graph-scale (the current scale)
a node .graph-node; .is-focus on the focused node; .is-lineage on the lit line; data-graph-placed-rank (the rank the layout gave it); style.left, style.top
a node built by setData() also data-graph-node, data-graph-rank, data-graph-state, data-graph-kind from the data
an edge (<path>) .graph-edge; .graph-edge-flat for an edge in a rank or against the direction; .is-lineage; data-graph-edge="from to"; data-graph-state (its own, else the node it reaches); data-graph-kind (from the data)
a button .graph-control and .graph-control-zoomIn, -zoomOut, -fit, -fullscreen; title and aria-label from labels

Proposer une modification docs/reference.md

The layout

layout(data, options) in src/js/layout.js - no DOM. It runs in a browser, a worker, Node or a server; Graph calls it with the sizes it measured.

import { layout, path, lineage } from '@glitchr/graphjs/layout';

layout(
    { nodes: [{ id, rank?, width?, height? }], edges: [{ from, to }] },
    { direction: 'down', gap: 24, rankGap: 56, sort: true, align: 'start', nodeWidth: 160, nodeHeight: 48 },
);

It returns {nodes: Map(id → {id, x, y, width, height, rank, order}), edges: [{from, to, index, points, flat}], ignored: [{from, to, index, reason}], ranks: [{rank, start, size}], width, height, direction}.

Input

Field Type Effect
nodes[].id string or number the identifier, turned into a string; a node given twice counts once (the first)
nodes[].rank number a rank to keep (rounded); empty, null or not a number: the layout decides
nodes[].width, nodes[].height number (px) the node's size; missing or not a number: nodeWidth, nodeHeight; a negative size counts as 0
edges[].from, edges[].to string or number the two nodes, by identifier

Other fields are ignored (the edges' state and kind are Graph's).

Options

Option Type Default Effect
direction 'down', 'right' 'down' down stacks the ranks from top to bottom (x along a rank, y across), right from left to right
nodeWidth number (px) 160 the width of a node that gives none
nodeHeight number (px) 48 the height of a node that gives none
gap number (px) 24 between two neighbours of a rank; half of it between two edges that cross a rank side by side
rankGap number (px) 56 between two ranks
sort boolean true false keeps the order the nodes came in (no barycentric sweeps)
sweeps number 12 the most ordering sweeps (they stop as soon as nothing crosses)
align 'start', 'center', 'end' 'start' where a node shallower than its rank stands in it

Graph passes direction, gap, rankGap, sort and align from its own options; nodeWidth, nodeHeight and sweeps keep their defaults there (a node not measured yet - hidden, no size - is laid out at 160 x 48 until it is).

Result

Field Holds
nodes Map of id to {id, x, y, width, height, rank, order}: x, y the top left corner in px (rounded to 0.01), rank the rank, order the place in the rank from 0
edges the drawn edges, in the order they came: {from, to, index, points, flat}, index the edge's index in data.edges
ignored the edges set aside, by index: {from, to, index, reason}, reason one of unknown, loop, duplicate, cycle
ranks [{rank, start, size}]: every rank from the first to the last, the empty ones included; start where it begins across the ranks, size its depth (its deepest node)
width, height the size of the whole drawing in px; the left-most node at x 0 (down) or the top-most at y 0 (right)
direction 'down' or 'right'

An empty graph gives no node, no edge, no rank, a size of 0 x 0.

An example, run

import { layout, path, lineage } from '@glitchr/graphjs/layout';

const data = {
    nodes: [
        { id: 'build', width: 120, height: 32 },
        { id: 'test', width: 120, height: 32 },
        { id: 'lint', width: 120, height: 32 },
        { id: 'deploy', width: 120, height: 32 },
    ],
    edges: [
        { from: 'build', to: 'test' },
        { from: 'build', to: 'lint' },
        { from: 'test', to: 'deploy' },
        { from: 'lint', to: 'deploy' },
        { from: 'deploy', to: 'build' },     // closes a cycle
        { from: 'test', to: 'nowhere' },     // no such node
    ],
};

const result = layout(data, { direction: 'right', gap: 14, rankGap: 64 });

Under Node 22 (node:22-alpine), result.nodes:

'build'  => { id: 'build',  x: 0,   y: 23, width: 120, height: 32, rank: 0, order: 0 }
'test'   => { id: 'test',   x: 184, y: 0,  width: 120, height: 32, rank: 1, order: 0 }
'lint'   => { id: 'lint',   x: 184, y: 46, width: 120, height: 32, rank: 1, order: 1 }
'deploy' => { id: 'deploy', x: 368, y: 23, width: 120, height: 32, rank: 2, order: 0 }

result.edges[0], result.ignored, result.ranks, width, height, direction:

{"from":"build","to":"test","index":0,"points":[{"x":120,"y":39},{"x":184,"y":16,"via":152}],"flat":false}
[ { from: 'deploy', to: 'build', index: 4, reason: 'cycle' },
  { from: 'test', to: 'nowhere', index: 5, reason: 'unknown' } ]
[ { rank: 0, start: 0, size: 120 }, { rank: 1, start: 184, size: 120 }, { rank: 2, start: 368, size: 120 } ] 488 78 right

1. What is set aside

An edge to a node that does not exist (unknown), from a node to itself (loop), given twice (duplicate), or closing a cycle (cycle) is listed in ignored and neither counted nor drawn. The cycle is cut where the walk, made in the order the nodes came in, meets a node it is still in.

The walk is depth-first and iterative: a chain of thousands of nodes does not exhaust the stack.

2. Ranks

  • A rank given on a node is kept.
  • Otherwise a node stands one rank past its farthest parent (longest path).
  • A node without parent stands just before the nearest thing it leads to, not at the top. In a family tree the spouse who enters at a union has no parent in the tree: they stand in their partner's rank.

ranks lists every rank from the first to the last, the empty ones included.

A node whose only parents are such parentless nodes stands at rank 1. Given ranks may be any integers, negative included: ranks then starts at the smallest.

3. Order in a rank

Barycentric sweeps: each rank is sorted by the mean place of its neighbours in the rank above, then below, up to sweeps times (12); the order with the fewest crossings is kept. A node with no neighbour on the side looked at keeps its slot. sort: false keeps the order the nodes came in.

Before the sweeps, each rank holds its nodes in the order they came; an edge that crosses a rank is given a slot there beside its source's place in that order.

4. Places

Each node is drawn towards its neighbours of both sides, its rank's order and gap being kept (least squares under the separation, by pooling). A link between two nodes that have only each other on that side weighs eight times more, and is straightened last where there is room: a chain is a straight line, parents stand around their union, an only child under it.

Across the ranks, a rank is as deep as its deepest node, and rankGap separates two ranks. align (start, center, end) places a node that is shallower than its rank.

5. Edges

points go from the source's far side to the target's near side. An edge over several ranks is led through a free slot of each rank it crosses: it enters the rank at its start and, when the rank has a depth, leaves it at its end. A point that ends a stretch between two ranks (the target, and the entry into a crossed rank) carries via: where, between the two ranks, a step edge turns and a curve bends. The first point and the exit from a crossed rank carry none: the edge runs straight from there. An edge between two nodes of the same rank, or going against the direction (both only possible with given ranks), is flat: a straight line, kept out of the ordering.

An edge a → c crossing rank 1 (60 px deep) of a down graph:

[{"x":96,"y":40},{"x":5.33,"y":96,"via":68},{"x":5.33,"y":156},{"x":50,"y":212,"via":184}]

Graph draws a flat edge dashed (.graph-edge-flat).

path()

path(points, {direction, shape, radius}) gives the SVG path (d) of an edge through its points.

Option Type Default Effect
direction 'down', 'right' 'down' the direction the points were laid out in
shape 'curve', 'step', 'line' 'curve' an S between two ranks (a cubic Bézier through via), right angles at via rounded by radius, or straight segments
radius number (px) 8 the rounding of a step's corners, made smaller where the step is too short for it

A stretch without via, or whose two ends are aligned, is a straight segment in every shape. No point: an empty string. The first edge of the example above, in each shape:

curve  M120 39C152 39 152 16 184 16
step   M120 39L144 39Q152 39 152 31L152 24Q152 16 160 16L184 16
line   M120 39L184 16

Lineage

lineage(data, id) returns {nodes: Set, edges: Set}: the node, everything it descends from and everything that descends from it (edge indexes are those of data.edges).

Siblings are not part of a line: a sibling is neither an ancestor nor a descendant. The walk follows every edge of data.edges, as given: run on the example's data, lineage(data, 'lint') gives the nodes lint, build, deploy, test and nowhere, and the edges 0 to 5, the cycle's edge and the edge to no node included. A graph's lit line runs it on the edges the layout drew only (see The lit line): pass {edges: result.edges} for the same walk (its edge indexes are then those of result.edges).

Proposer une modification docs/layout.md

Styling

The stylesheet (src/css/index.css) reads only --graph-* variables. Set them on the [data-graph] element or on anything above it, with any selector.

The variables

Variable Default (light) Dark
--graph-height 32rem the height of the view
--graph-bg #f6f5f1 #1b1a17 the ground
--graph-grid rgba(40, 36, 28, .11) rgba(240, 234, 220, .09) its dots, every 20 px (transparent: no dots)
--graph-border rgba(40, 36, 28, .16) rgba(240, 234, 220, .16) the frame
--graph-radius 6px the frame's corners, and the buttons' block
--graph-node-bg #fffefb #26241f a node
--graph-node-color #23211c #ece7da a node's text (and the element's)
--graph-node-border rgba(40, 36, 28, .28) rgba(240, 234, 220, .26) a node's border
--graph-node-radius 5px a node's corners
--graph-node-padding .55rem .8rem
--graph-node-shadow 0 1px 0 rgba(40, 36, 28, .08) 0 1px 0 rgba(0, 0, 0, .4)
--graph-node-font inherit the font shorthand of a node
--graph-node-min-width, --graph-node-max-width 7rem, 15rem a node is as wide as its content between the two
--graph-sub-color #6c675c #a8a292 a node's second line, and a skipped or canceled node's text
--graph-edge #8d877a #7d776a an edge, and a junction's ring
--graph-edge-width 1.5px an edge; a lit edge is 0.5 px wider
--graph-accent #1f4e79 #8fb8e0 the focused node, the lit line, the keyboard focus, a link node under the pointer
--graph-dim .32 the opacity of what is off the lit line
--graph-control-bg, --graph-control-color, --graph-control-border #fffefb, #23211c, rgba(40, 36, 28, .28) #26241f, #ece7da, rgba(240, 234, 220, .26) the buttons
--graph-success #2e7650 #6cba8d the states
--graph-failed #ad3329 #e58076
--graph-running #2a5d9f #86b0e6
--graph-warning #9a6a12 #d8a94a warning and manual
--graph-pending #8a857a #8f897c pending, and any other state

Two variables are set for a stylesheet to read, not to be set: --graph-scale on the element (the current zoom, written by the script at every move) and --graph-state on a node with a state (the colour of its state).

Dark values apply under prefers-color-scheme: dark; data-graph-theme="light" or "dark" on the element forces one. A site with its own theme switch sets the variables under its own selector (for instance [data-theme="dark"] .graph, loaded after the stylesheet), or writes its theme into data-graph-theme.

The stylesheet never declares a --graph-* variable on the element: its defaults, the dark values and the presets' values are fallbacks of private tokens (--_graph-bg: var(--graph-bg, #f6f5f1) on .graph, and so on), which its rules read. So a value set by the site always wins over them, whatever its selector's specificity and wherever it is set: on the element with one class, on a wrapper, on :root for every graph of the site. Inside the graph (on the element's children) every --graph-* is set back to the value in force, so a site's own rule for a node's content reads var(--graph-sub-color) or var(--graph-accent) as expected.

:root {                 /* every graph of the site */
    --graph-accent: var(--ink-red);
}
.my-tree {              /* one class is enough, before or after src/css/index.css */
    --graph-bg: var(--paper);
    --graph-node-bg: var(--paper-raised);
    --graph-node-font: 500 .95rem/1.25 var(--font-text);
}
.my-tree .dates { color: var(--graph-sub-color); }

A value the site sets is the same in light and in dark (it replaces both); for a different dark value, the site sets it under its own @media (prefers-color-scheme: dark) or theme selector. The private --_graph-* tokens are the stylesheet's own: set the public names, not those.

Classes and attributes to hook on

.graph, .graph.is-ready, .is-dragging, .is-gliding, .is-fullscreen, .has-lineage the element
.graph-viewport, .graph-canvas, .graph-edges, .graph-controls, .graph-control-zoomIn … its parts
.graph-node, .is-focus, .is-lineage, [data-graph-state], [data-graph-kind], [data-graph-placed-rank] a node
.graph-node-label, .graph-node-sub the two lines setData() writes
.graph-edge, .graph-edge-flat, .is-lineage, [data-graph-edge="from to"], [data-graph-state] (the state of the node it reaches), [data-graph-kind] an edge

The full list, with when each is set: Classes and attributes the script sets.

States

State A node An edge reaching it
success --graph-success drawn in --graph-success
failed --graph-failed drawn in --graph-failed
running --graph-running drawn in --graph-running, dashed, the dashes flowing
warning --graph-warning as any edge
manual --graph-warning dotted
pending --graph-pending dotted
skipped, canceled --graph-pending, a dashed border, the text in --graph-sub-color dotted

Outside the pipeline preset, a node's state colour is only set in --graph-state (and the dashed border of skipped and canceled): the pipeline preset draws it as a disc. A stylesheet uses it elsewhere, for instance .graph-node[data-graph-state] { border-left: 3px solid var(--graph-state); }. Under prefers-reduced-motion: reduce nothing turns or flows, and the view does not glide.

Presets

  • tree: direction: down, edges: step, centred cards.
  • pipeline: direction: right, edges: curve, pill-shaped jobs with a disc for the state (ring: pending, turning: running, full: success or failed, dotted: skipped or canceled), no dots on the ground.

A preset only sets defaults: the element's data-graph-* attributes win over it, the options given to new Graph() over both.

What each preset's stylesheet sets, on .graph[data-graph-preset="..."] (as defaults: a variable the site sets wins over the preset's value too):

Preset Variables Nodes
tree --graph-node-padding: .5rem .75rem, --graph-node-min-width: 8.5rem, --graph-node-max-width: 12.5rem text centred
pipeline --graph-node-padding: .4rem .8rem .4rem 2rem, --graph-node-radius: 999px, --graph-node-min-width: 8rem, --graph-grid: transparent on one line (nowrap), the label at weight 500, a disc of 0.8rem at the head in the state's colour; a failed job's border in --graph-failed

The script's half of a preset (direction, edges, gaps) is in Presets.

Junctions

A node [data-graph-kind="junction"] is a ring of 14 px in --graph-edge on --graph-bg, without padding nor shadow: a union in a family tree, a gate in a pipeline. Given as data without a label, it has no text.

Proposer une modification docs/styling.md

Testing

The test suite

npm test                          # node --test: the layout, and the Graph class in Chrome
CHROME=/path/to/chrome npm test   # another browser than the macOS application

npm test runs node --test, which finds test/layout.test.js: 16 tests of layout(), path() and lineage(), without a browser (the layout needs no DOM); and test/browser.test.js, which opens each page of test/browser/ in a headless Chrome (Node 22 or later: it drives Chrome over its DevTools protocol with Node's own WebSocket), under a light then a dark colour scheme. Where no Chrome is found (CHROME, by default /Applications/Google Chrome.app/Contents/MacOS/Google Chrome), that test is skipped and says so.

Test Checks
ranks follow the longest path a, b, c, d with a shortcut a → c: ranks 0, 1, 2, 3
a given rank is kept, and what follows is ranked after it ranks 3 and 7 given: b at 4, ranks 3 to 7 with the empty ones
a spouse without parents stands in the rank of the partner a three-generation family: the spouse at the partner's rank, next to them
an edge closing a cycle is ignored, the rest is laid out a → b → c → a: {from: 'c', to: 'a', index: 2, reason: 'cycle'}
loops, repeated edges and edges to nowhere are ignored the reasons loop, duplicate, unknown
no two nodes overlap, and the gap is kept inside a rank
no overlap in a wide random graph with nodes of different sizes 120 nodes, both directions, every node inside width x height
direction right stacks the ranks from left to right x by rank, direction: 'right', the edge from the source's right side to the target's left
sort: false keeps the order the nodes came in and sort: true undoes a crossing
an edge over several ranks is led through each of them its points never run under a node
an only child stands straight under its parent a chain is a straight line
parents stand around their union, children under it
an empty graph and a lone node 0 x 0; a lone node at 0, 0 with its size
a long chain does not exhaust the stack 5000 nodes in a chain: the last at rank 4999
path: curve, step and line the exact d of each shape, the radius, a straight stretch, direction: 'right', no point
lineage: ancestors and descendants, not the siblings
Page of test/browser/ Checks
css-variables.html a --graph-* set on a wrapper (height, ground, accent, over a preset's value), on the element with one class, on a node; the defaults in light and dark, data-graph-theme, a preset's own value; var(--graph-*) read inside the graph
lineage.html the lit line follows the edges drawn: with a → b → c, d → c and the cycle's c → a set aside, d lights c and d only
destroy.html destroy() gives the element back as it was (the nodes where they stood), a second start builds one viewport and one set of buttons, a button kept from before calls nothing, a graph given as data is given back empty

Each page writes its verdict in #result (lines starting with ok, or FAIL); a console error or an uncaught exception fails it too. In a container, an image with Node 22 and a Chrome (the puppeteer image) runs the whole suite; Chrome there needs --no-sandbox, which a two-line wrapper gives:

printf '#!/bin/sh\nexec /path/to/chrome --no-sandbox "$@"\n' > chrome.sh && chmod +x chrome.sh
docker run --rm -v "$PWD":/repo:ro -v /path/to/dir-with-chrome.sh:/scratch:ro \
    -e CHROME=/scratch/chrome.sh -w /repo --entrypoint node ghcr.io/puppeteer/puppeteer:24.10.0 --test

There the suite ends:

# tests 23
# suites 0
# pass 23
# fail 0
# cancelled 0
# skipped 0
# todo 0

The rest of the Graph class (the pointer, the wheel, the keyboard, full screen) is checked by hand on the example page.

The example page

python3 -m http.server 8000       # then http://localhost:8000/example/

example/index.html loads src/css/index.css and src/js/graph.js from the repository (no build): a family tree declared in the page (five generations, unions as junctions, focus on the youngest) with buttons for fit(), center() and highlight(), and a pipeline given as data (setData(), ten jobs in every state, lineage: 'select') with a button for setState(). It prints the graph:select and graph:view events under each graph, and exposes window.tree and window.pipeline for the console.

Testing a page that uses graphjs

  • The layout is a pure function: test what a page gives the graph by calling layout() on the same data in Node and asserting ranks, order or ignored.
  • In a browser test, wait for the class is-ready on the element (set after the first layout) or for the graph:layout event before reading places; Graph.get(element).result holds the last layout.
  • remember keeps the view in sessionStorage: a test that reloads a page and expects the first view sets data-graph-remember="false" or clears sessionStorage.

Proposer une modification docs/testing.md