Skip to main content
View as Markdown

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

app.yaml
- 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 order

There 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

  • label present: the drawing is role="img" carrying it as the accessible name
  • label absent: the drawing is aria-hidden and 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: true draws 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
  • groupBy bands a column, one band per distinct value of that node field
  • sortBy + sortDirection: desc orders 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 emptyMessage and 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, paused and disabled apart
  • An unreadable pause ledger leaves the state ABSENT — a lane is not drawn as healthy
  • layout: 'lanes' declaring no lanes renders 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 columns array is refused at boot
  • A column with an empty kinds list is refused rather than read as "admit nothing"
  • A column with kinds and no label is refused at boot
  • An empty dataSource.system.endpoint is refused at boot, naming the offending path
  • A DB-table dataSource is refused rather than decoding and drawing nothing
  • A publishes carrying bindTo and no param is refused at boot
  • A layout the renderer cannot draw is refused at boot, naming the value
  • A closed-set value outside its set is refused wherever one is declared
  • A lanes carrying a spine and no station track is refused at boot
  • A graph declaring both columns and lanes is 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 selection declared, nodes are focusable in DOM order
  • Enter and Space each 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.

Built with Sovrium