The Graph Drawing
The graph component — nodes laid out in ordered columns or in lanes of chained steps, with the edges running between them.
The other way to read the same graph a matrix reads. Where a matrix crosses two sets and draws what sits at each intersection, a graph draws the connections themselves. A pair with no edge is a visible empty cell in a matrix; in a graph it is simply nothing.
Reach for it when the question is how something is reached rather than whether it is — a path a reader can follow with a finger, through whatever stands in the middle. Reach for a matrix when the answer is a lookup, and for a chart when the answer is a quantity: no chartType draws nodes and edges, because every one of them plots a value against an axis.
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
layout |
enum | layered, lanes |
How the drawing places its nodes (default: 'layered' — ordered columns, edges between them). lanes draws one row per lane node followed by its chain of stations. Closed because the renderer draws each layout, and it draws these two; a value it cannot draw is refused at boot naming the value. |
|
legend |
boolean | Draw the key mapping each shape to the node kind it stands for (default: false). Rendered INSIDE the drawing, so it is aria-hidden exactly when the drawing is. Its entries are derived from the kinds the declared columns admit, never authored, so it cannot disagree with what is drawn. |
||
label |
string | Accessible name for the drawing. Present: the drawing is role="img" with this as its aria-label. Absent: the drawing is aria-hidden and the accessible twin beneath it carries the facts alone. The twin renders either way. | ||
emptyMessage |
string | Message displayed when the bound graph places no node in any declared column. Not used for a DEGRADED read, where a source could not be resolved and the drawing is missing its contribution rather than empty. |
dataSource
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
dataSource |
object | Graph read-endpoint binding for the node-link drawing | ||
dataSource.system |
object | Graph read-endpoint binding: the nodes are placed into the declared columns and the edges are drawn between them | ||
dataSource.system.endpoint |
string | Read endpoint path the drawing fetches its graph from, with the caller’s own credentials (e.g. /api/admin/organisation/graph) | ||
dataSource.system.nodesKey |
string | Key of the nodes array in the response envelope (default: 'nodes'). Nodes are placed into the declared columns. | ||
dataSource.system.edgesKey |
string | Key of the edges array in the response envelope (default: 'edges'). An edge is drawn only when BOTH its endpoints were placed into a column. | ||
dataSource.system.query |
object | Static query params merged into every request to the endpoint |
autoSave
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
autoSave |
object | Configuration for automatic persistence of edits. Applies to table, form, kanban, and calendar components. | ||
autoSave.saveMode |
enum | auto, onBlur, manual |
Save trigger strategy: 'auto' (debounced), 'onBlur' (field blur), 'manual' (button). Default: 'manual'. | |
autoSave.autoSaveDebounceMs |
number | Debounce delay for auto-save in milliseconds (default: 500, min: 100) | ||
autoSave.showSaveIndicator |
boolean | Display a save status indicator (Saving... / Saved / Error). Default: true when saveMode is auto or onBlur. | ||
autoSave.saveIndicatorPosition |
enum | inline, toast, toolbar |
Where the save status indicator appears |
search
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
search |
object | Shared search bar configuration for data-bound components (table, kanban, calendar) | ||
search.enabled |
boolean | Enable search bar (default: true) | ||
search.placeholder |
string | Search input placeholder text | ||
search.debounceMs |
number | Debounce delay for search input in ms (default: 300) | ||
search.highlight |
boolean | Highlight matched search terms in results (default: false) |
columns
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
columns |
array | The drawing’s columns, in left-to-right order. Every node the bound graph returns is placed into the FIRST column whose kinds admit it; a node no column admits is not drawn, and an edge is drawn only when both its endpoints were. Declared empty is refused — see the check below. |
||
columns[] |
object | One column of the layered drawing: which node kinds it admits, the heading it carries, and the node fields it bands and orders by. The array’s order is the drawing’s left-to-right order. | ||
columns[].kinds |
array (truncated) | Node kinds this column admits, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. Required: in an ordered partition, a column with no filter would draw every node a second time, which is a config nobody means. | ||
columns[].label |
string | The column’s heading, used by the drawing and as the group name in the accessible twin. Required alongside kinds: an unnamed column is an unnamed group in the twin, and naming it after its kinds would leak the endpoint’s vocabulary into the operator’s reading. |
||
columns[].groupBy |
string | Node field whose value bands this column into labelled groups (e.g. a resource family). Names a field of the bound graph’s nodes, so it is an open string. Nodes that do not carry it are placed in the column ungrouped, after the bands. | ||
columns[].sortBy |
string | Node field this column orders by. Names a field of the bound graph’s nodes, so it is an open string; nodes that do not carry it keep their source order after those that do. Omitted, the column is drawn in the order the endpoint emitted. | ||
columns[].sortDirection |
enum | asc, desc |
Sort order: ascending or descending |
lanes
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
lanes |
object | A lanes drawing: which node kinds define a lane, the heading the spine carries, and the station track each lane’s chain is drawn along. Read only when layout is lanes; declaring it beside columns is refused at boot. |
||
lanes.kinds |
array | Node kinds that DEFINE a lane, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. One row is drawn per node admitted here, and its stations are the chain reachable from it. | ||
lanes.kinds[] |
string | One lane node kind, as the bound graph spells it | ||
lanes.label |
string | The lane spine’s heading, drawn above the identity gutter. Required alongside kinds for the reason a column’s label is: an unnamed spine is an unnamed group in the accessible twin. |
||
lanes.stations |
object | The station track of a lanes drawing: which node kinds are stations, and the heading the track carries. Their ORDER is the graph’s — the chain reachable from the lane node — so there is no key to sort them by. | ||
lanes.stations.kinds |
array (truncated) | Node kinds this track admits as STATIONS, as the BOUND GRAPH spells them — open strings, never a platform vocabulary. A lane’s stations are the chain of these reachable from its lane node, in the order the edges put them; no edge kind is named, because the only edges that can match are the ones arriving at a station. | ||
lanes.stations.label |
string | The station track’s heading, drawn once above the track. Required alongside kinds for the reason a column’s label is: an unnamed track is an unnamed group in the accessible twin, and naming it after its kinds would leak the endpoint’s vocabulary into the operator’s reading. |
||
lanes.sortBy |
string | Node field the LANES are ordered by, top to bottom. Names a field of the bound graph’s nodes, so it is an open string; lanes not carrying it keep their source order after those that do. Omitted, the lanes are drawn in the order the endpoint emitted. There is deliberately no equivalent for STATIONS — their order is the chain the edges describe. | ||
lanes.sortDirection |
enum | asc, desc |
Sort order: ascending or descending |
selection
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
selection |
object | Makes the drawing’s nodes selectable and says which way the reach highlight walks from the selected one. Absent: the drawing is not selectable. The highlight is internal state — only the selected node’s id reaches the shared-filter bus. | ||
selection.mode |
enum | single |
How many nodes may be selected at once. Closed at single because the renderer implements one selection behaviour — multiple would leave the reach set ambiguous (union or intersection) and is not defined. |
|
selection.reach |
enum | downstream, upstream, both |
Which way the reach highlight walks the edges from the selected node (default: 'downstream' — what the selection reaches). upstream is the mirror question, both the whole connected component. Closed because a direction is platform vocabulary; the edges it walks are not. |
publishes
| Path | Kind | Values | Default | Description |
|---|---|---|---|---|
publishes |
object | Marks a choice control as a shared-filter publisher: its current value is published on the named channel under param, and every data source whose bindTo names that channel merges it into each request. |
||
publishes.bindTo |
string | Shared-filter channel id this control publishes on (the string a subscriber's bindTo names) | ||
publishes.param |
string | Request-param key this control's value is published under |
layout is layered by default — ordered columns — or lanes, one row per chain, and it picks which of columns and lanes is read. Every property is optional, including the one your layout calls for: a graph that declares layered and no columns renders its empty state rather than stopping your app booting.
Declaring both columns and lanes is the one combination refused outright, by name, when your app boots. Only one of them can be read, so the other would disappear without a trace: you would have written a full three-column partition, looked at a page that seems entirely correct, and had nothing anywhere point at why your columns were not on it.
Binding a graph
The same binding a matrix takes, for the same reason: a graph is two collections that address each other by id. endpoint is required; nodesKey and edgesKey default to nodes and edges, and query merges static parameters into every request. Because the read runs as the visitor, they only ever see the part of the graph they could have read themselves.
How a node is drawn
Two things about a node are legible before its name is: its shape and its weight. Neither is yours to configure, and both come from the endpoint — so this holds whichever layout placed the node.
Shape carries the kind. Shapes are assigned per drawing, in the order the kinds first appear, which is the whole reason legend exists: the built-in organisation graph alone emits twelve node kinds, and twelve shapes are not learnable from the figure on their own.
Weight carries the state. A node your endpoint reports as paused is drawn dashed, one reported disabled is drawn muted, and anything else is drawn at rest. Colour is part of neither — kind is shape, state is weight, and colour stays with your theme's own tokens. paused and disabled are your endpoint's words, exactly as the values in kinds are, and a state Sovrium does not recognise is drawn at rest rather than being an error.
A node reporting no state is not a node reporting active. An endpoint that can report state emits active explicitly, and omits the key entirely when the source it would have read was unavailable. A node drawn at rest therefore means this read could not tell, which is not the same as this thing is running.
The columns
columns is an ordered partition. Each entry names the node kinds it admits, every node is placed into the first column that admits it, and the array's order is the drawing's order from left to right — which is why this is a list rather than a pair of named axes.
kinds and label are both required on a column: kinds admits node kinds as the endpoint spells them, and label is the heading and the group name in the accessible twin. groupBy bands the column into labelled groups, sortBy orders it, and sortDirection is asc by default.
The two are required together because they are the two halves of a named partition. A column with no kinds would admit everything and draw every node a second time, connected to itself; a column with no label would leave an unnamed group in the table beneath. Naming a column after the kinds it admits is the tempting alternative and it is worse: person, agent is your endpoint's vocabulary, where Principals is the word you meant.
A label may be a translation key. graph.principals works in any label you author on a graph or a matrix — a column's, a lane's, one of its stations', or a matrix flag.label — and resolves in the page's active language. It is resolved on the server, before the drawing and its accessible twin are composed, so the two never disagree about which language they are in.
Declare sortDirection even when the endpoint already returns the order you want. A column drawn in whatever order the response happened to arrive in reads correctly right up until the endpoint changes, and nothing will tell you when that was.
An edge is drawn if and only if both its endpoints are. That single rule is the entire edge filter: point the columns at the node kinds you want and the edges follow, so a graph whose columns admit no automation node draws no automation step without your having said so. There is no separate edge list to keep in step with the columns, and therefore no way for the two to disagree.
The lanes
Set layout: lanes and the drawing changes shape. Instead of partitioning every node across columns, you name one kind of node that starts a chain — an automation, a pipeline, a run — and each one gets a row of its own: the node on the left, then the steps leading out of it, running to the right.
lanes.kinds and lanes.label are required, as is stations, itself { kinds, label } with both required — the chain drawn along each lane. sortBy orders the lanes top to bottom, and without it they arrive in the endpoint's order.
- type: graph
dataSource:
system: { endpoint: /api/admin/organisation/graph }
layout: lanes
lanes:
kinds: [automation]
label: Mechanism
sortBy: label
stations:
kinds: [step]
label: Steps, in order
selection: { mode: single, reach: downstream }
label: What each automation does, in orderThere is deliberately no sortBy for the steps. A lane's steps are already in order, because the edges say so: the endpoint runs an edge from the lane node to its first step and one from each step to the next, and walking that chain forward is reading the order. A sort key for the steps would hand you a second, independent answer to a question the graph has already answered — and sooner or later the two would disagree, leaving a drawing that has to pick one and a configuration that looks correct either way.
The walk names no edge kind either, for the same reason the edge rule names none: the only edges that can arrive at a step are the ones running along a chain. Pointing stations.kinds at your endpoint's step nodes is the whole of the filtering.
Selecting a node
selection.mode is required and takes single — one node at a time. reach is downstream by default, or upstream, or both.
With selection declared, every node is focusable in reading order and Enter or Space selects the focused one. Selecting lights the reach set — everything the selection reaches, walking the edges in the reach direction — and dims everything else. downstream answers what does this reach, upstream answers what reaches this, and both is the whole neighbourhood. Omit selection and the drawing is a static figure: nothing takes a tab stop and nothing is ever marked.
publishes takes the same { bindTo, param } a select takes: the selected node's id is published on the named channel under param, and any data source bound to that channel merges it into its next request. Only the id crosses — the reach highlight is the drawing's own state, and a subscriber has no use for it.
Accessibility and rendering
The same contract as a matrix, for the same reason. A graph always renders an accessible twin — a real table listing every node drawn, its kind, the column or lane it sits in, and the nodes it reaches. Nothing switches it off. Under lanes that last column earns its place twice over: because each step's edge runs to the next one, the nodes a step reaches are the rest of its chain, so the table states the running order in words without anyone having to see a line.
label set makes the drawing one named figure with role="img"; omitted, the drawing is hidden from assistive technology. The twin renders either way. legend draws the key inside the drawing, so it is hidden exactly when the drawing is — a key to a figure nobody is being shown is noise, and the twin names every kind in words anyway.
Here a graph and a matrix differ, and it is worth knowing which half you are paying for. A matrix is drawn entirely on the server. A graph's drawing is a lazy island, because selecting a node is something that happens after the page has arrived. Its accessible twin, its empty state and its degraded notice are all server-rendered, in the first response, exactly as a matrix's are. A reader with scripting off still receives the table and every fact in it; what they do not receive is the ability to select.
Behaviour
Accessible twin
labelpresent: the drawing isrole="img"carrying it as the accessible namelabelabsent: the drawing isaria-hiddenand exposes no accessible name- The twin is a real table and renders in BOTH states, never
aria-hidden - The twin has exactly one row per DRAWN node, naming the column that node sits in
- Each twin row names the nodes its node reaches — the edges exist as text, not only as lines
- Every twin row is keyboard-reachable
legend: truedraws the key inside the drawing, hidden exactly when the drawing is- The same map read twice — once as a named figure, once as the table alone — carries the same facts
Column rendering
type: 'graph'decodes, the page boots, and the drawing mounts as an island- One column renders per declared
columns[]entry, in the DECLARED order - A column admits exactly its
kinds; a node no column admits is not drawn at all groupBybands a column, one band per distinct value of that node fieldsortBy+sortDirection: descorders a column most-privileged first, not in source order- An edge is drawn exactly when BOTH its endpoints were drawn, and never otherwise
- The read carries the CALLER's credentials: a member gets no drawing, not somebody else's
- An operator opens the page and reads a banded, ordered map of their own configuration
Empty and degraded reads
- No column admitting a node renders
emptyMessageand no drawing - A degraded read names each unresolved source and is not drawn as empty-but-healthy
- An edge whose endpoint is absent from the body is dropped, and the drawing renders
Lane rendering
layout: 'lanes'draws one lane per admitted lane node, and no column anywhere- Each lane holds exactly its OWN stations, in the order the edge chain puts them
- A lane's state reaches the drawing and tells
active,pausedanddisabledapart - An unreadable pause ledger leaves the state ABSENT — a lane is not drawn as healthy
layout: 'lanes'declaring nolanesrenders the empty state, and the app still boots- The twin carries the chain as text: each row names its lane and the station it reaches next
- Selecting a lane marks its whole chain and dims every other lane
- A lane sub-line longer than the gutter is held inside it, never drawn across the station track
- An operator reads what each automation does, in order, and which ones are not running
Schema refusals
- An empty
columnsarray is refused at boot - A column with an empty
kindslist is refused rather than read as "admit nothing" - A column with
kindsand nolabelis refused at boot - An empty
dataSource.system.endpointis refused at boot, naming the offending path - A DB-table
dataSourceis refused rather than decoding and drawing nothing - A
publishescarryingbindToand noparamis refused at boot - A
layoutthe renderer cannot draw is refused at boot, naming the value - A closed-set value outside its set is refused wherever one is declared
- A
lanescarrying a spine and no station track is refused at boot - A graph declaring both
columnsandlanesis refused at boot, naming both keys - Every mis-declaration is met at boot, and the valid config beside it still draws
Selection and reach
- With
selectiondeclared, nodes are focusable in DOM order EnterandSpaceeach select the focused node- Selecting marks every node and edge in the downstream reach set
- Selecting dims every node and edge OUTSIDE the reach set
reach: 'upstream'walks the mirror direction from the same node- Selecting publishes the node id under
publishes.param, and the reach set crosses nothing - A reader selects a principal, reads what it reaches, and selects a resource to invert it
Last updated September 23, 2026
This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.