Skip to content

Layered graph

<cae-layered-graph> draws nodes in layers, joined by edges, inside clusters: a pipeline, a build, any flow of steps. Each node holds a group of marks, one per series id, at the same place in every node, so one series can be followed across the graph. Register it with @contraagent/elements/graph/define (it registers <cae-mark-matrix> and <cae-segmented> too); the types, arrange and layoutGraph are in @contraagent/elements/graph.

docs/examples/graph.ts
// A layered graph: a hand-written build pipeline of three steps, the last two in a
// cluster that repeats (a retry loop), one mark per service in each step. The host
// keeps the selection and the order, and answers the events.
import '@contraagent/elements/graph/define';
import type { GraphInput, GraphMark, GraphNodeInput } from '@contraagent/elements/graph';
import type { StateStyles } from '@contraagent/elements/primitives';
const states: StateStyles = {
ok: { label: 'ok', tone: 'done', shape: 'round', rank: 0 },
busy: { label: 'busy', tone: 'running', shape: 'round', rank: 2 },
broken: { label: 'broken', tone: 'failed', shape: 'square', rank: 3 },
queued: { label: 'queued', tone: 'neutral', shape: 'ring', rank: 1 },
};
const services = ['api', 'web', 'worker'];
/** One mark per service: its id is unique in the graph, so it is what the graph selects. */
const marks = (step: string, looks: string[]): GraphMark[] =>
services.map((s, i) => ({ id: `${step}#${s}`, state: looks[i] ?? 'queued', label: `${step} · ${s} | ${looks[i] ?? 'queued'}`, tip: `${step} · ${s}` }));
/** A step: its counts and its header's count ("2/3") come from its marks. */
const node = (id: string, label: string, looks: string[], cluster: string | null): GraphNodeInput => ({ id, label, cluster, marks: marks(id, looks) });
const graph: GraphInput = {
series: services,
nodes: [
node('build', 'build', ['ok', 'ok', 'ok'], null),
node('test', 'test', ['ok', 'broken', 'ok'], 'retry'),
node('deploy', 'deploy', ['busy', 'queued', 'ok'], 'retry'),
],
clusters: [{ id: 'retry', parent: null, label: 'retry', note: 'up to 3 rounds', cycle: true }],
edges: [
{ from: 'build', to: 'test', style: 'solid', weight: 3, ranks: true },
{ from: 'test', to: 'deploy', style: 'solid', weight: 3, ranks: true },
// The next round: back from deploy to test; it does not set the layers.
{ from: 'deploy', to: 'test', style: 'solid', weight: 1, ranks: false },
],
stubs: [{ id: 'commit', label: 'commit', to: 'build', style: 'solid', weight: 1 }],
};
export function mount(host: HTMLElement): void {
const el = document.createElement('cae-layered-graph');
el.graph = graph;
el.states = states;
el.key = 'pipeline';
el.strings = { cycle: 'next round' };
const card = document.createElement('div');
card.slot = 'inspector';
el.append(card);
const shown = document.createElement('p');
el.addEventListener('cae-select', (e) => {
el.selected = e.detail.id;
card.innerHTML = e.detail.id ? `<h4>${e.detail.id}</h4>` : '';
});
el.addEventListener('cae-activate', (e) => (shown.textContent = `Opened: ${e.detail.id}`));
host.replaceChildren(el, shown);
}

A GraphInput names the series, the nodes, the clusters, the edges and the stubs (edges from outside the graph). A state is any id: states (StateStyles) gives each its word, its tone and its shape, and its key order is the legend’s.

A mark is in a series when the same item has a mark in several nodes: a service built, tested and deployed is a series, and its marks line up across the steps. A mark of one node only (a job of one CI step) is in that node’s loose.

A node’s counts (the bar of a node with many marks) and progress (the count in its header, “2/3”) default to what its marks say: its marks by state, and its marks whose state has the tone done of all its marks, in the words of strings.progress. Give them when a node stands for more items than it has marks.

src/graph/types.ts
export interface GraphNodeInput {
id: string;
label: string;
/** The tooltip of its name. */
tip?: string;
/** The accessible name of the node: its facts in sentences; default: `label`. */
summary?: string;
/** The innermost cluster it is drawn in, or null. */
cluster: string | null;
/** Icon names (`registerIcon`) before its label. */
icons?: readonly string[];
/** An accent along its side: an identity, not a status. */
accent?: boolean;
/** It has an inner view: it is drawn as a stack, and Enter and a double click send `cae-drill`. */
drill?: boolean;
/**
* One per series id (`GraphInput.series`), null where the series has no mark here.
* A mark belongs in a series when the same item has a mark in several nodes (a
* service in each step); default: [].
*/
marks?: readonly (GraphMark | null)[];
/** Its marks outside any series: items of this node only (the jobs of one step); default: []. */
loose?: readonly GraphMark[];
/** Its items by state (the states of `StateStyles`): the bar a node with many marks draws; default: its marks by state. */
counts?: Readonly<Record<string, number>>;
/**
* Its header's count ("28/30") and its words; default: its marks whose state has
* the tone `done`, of all its marks, in the words of `strings.progress`.
*/
progress?: NodeProgress;
/** The line under its marks ("1 failed | 1 not started"). */
notes?: readonly Fact[];
/** Its frame's tone: failed, running or done. */
look?: Tone;
/** It can be drawn open, as a cluster of what is inside it: a control in its header sends `cae-toggle` (`closed`: it is drawn closed now). */
toggle?: 'closed';
}

A cluster is a box of nodes (and of other clusters). A cluster that cycles draws a return from its last layer to its first; an edge with ranks: false closes that cycle: it never sets the layers and is not drawn. An edge may join items in different clusters, at any depth: it leaves and enters each box on its way.

An item the host can draw open or closed carries toggle: a node with toggle: 'closed' draws a control in its header (strings.open, “Show inside”) and a cluster with toggle: 'open' one in its corner (strings.close); either sends cae-toggle with the item’s id and whether it is to be open, and the host draws the graph again. A cluster named by a path of parts shows them as “a › b › c”: the first part is its name, and each later part sends cae-drill with that part’s id. focusToggle(id) puts the focus on an item’s control once it is drawn again.

key is the graph’s identity. A new key is a new graph: the frame shows all of it again, and the focused mark of each node is forgotten. Keep the key while the same graph changes (a run in progress), so the reader’s zoom and pan stay.

arrange gives each item its layer by the longest path to it, orders each layer to reduce crossings, and gives each edge its edge path: a point on every layer it crosses. The order depends on the structure only. Give the element the order of an earlier drawing of the same graph to keep its items in place: an item that appears later is added at the end of its layer. layoutGraph places everything from the measured sizes; the element measures its nodes itself. Below 640 px of width, direction: 'auto' draws top to bottom. extentOf(arrangement) gives a drawing’s shape before it is measured: the layers it spans along the flow and the items it stacks across it at most, a cluster counted as what it holds; a host can choose the direction, or how much to draw, from it.

The element is as wide as its container. Without a height, the frame is as tall as the whole drawing at that width (never scaled up), up to 70% of the window’s height or 360 px, whichever is more; top to bottom, as tall as the drawing. With a height set on the element (or given by a flex or grid container), the crumbs slot and the row under the frame keep their height, and the frame takes the rest, with the whole drawing fitted and centred in it.

A click selects a mark, a node or a cluster and sends cae-select; the element does not select by itself: the host sets selected. Enter or a double click sends cae-activate, or cae-drill for an item with drill (an inner view). “Graph | Table” sends cae-view-change: the table is a row per node with the nodes it is joined to. With matrix, the frame shows a mark matrix as the table.

The card of the selected item is the host’s content in the inspector slot, beside the item in the frame; its first heading and its [data-status] element name the item to a screen reader. With nothing in the slot when the element draws (a new selected draws it), there is no card: a page that shows the selection elsewhere leaves the slot empty.

Hovering, focusing or selecting a mark highlights its series; related names more marks to light (a mark’s id, or a series id, to the ids of the marks it lights).

The line under the legend holds the key hints. It keeps its height at all times, and shows its words while the graph has the focus. On a screen with touch alone (no fine pointer) there is no line; ::part(keys) { display: none; } removes it everywhere.

The words are strings (Strings). renderIcon draws the icons a node names (icons), and iconStyles adds styles for them. A mark has the state’s look as data-tone and data-shape. The parts, for ::part():

Part What it is
frame the frame every view is drawn in
stage what pans and zooms
node, node-header, node-name each node, its first line, its name
mark each mark
cluster, cluster-label each cluster, its header
edge, stub each edge, each stub
legend the legend
keys the line of key hints
inspector the selected item’s card
table, row the node table, each of its rows