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.
Reference: <cae-layered-graph>
// 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);}The model
Section titled “The model”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.
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.
Order and drawing
Section titled “Order and drawing”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.
Selection and events
Section titled “Selection and events”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 |