- 1. Terminology
- 2. Part I — Compact reference
- 3. Part II — Detailed reference
- 3.1. The
graleGraphstructure - 3.2.
layout(graph) - 3.3. Graph label additions (
value) - 3.4. Node label additions (
value) - 3.5. Edge label additions (
value) - 3.6. The extensions, in detail
- 3.6.1. Pinned nodes
- 3.6.2. Respecting previous layouts (stability)
- 3.6.3. Focus
- 3.6.4. Preferred edge direction
- 3.6.5. Hidden links
- 3.6.6. Ports
- 3.6.7. Edge weight and lineWidth
- 3.6.8. Edge routing
- 3.6.9. Self-loops and corner rounding
- 3.6.10. Markers
- 3.6.11. Data
- 3.6.12. Waypoint normals, crossings, and z-order
- 3.6.13. Edge and hyperedge
id - 3.6.14. Hyperedges
- 3.6.15. Debug settings
- 3.6.16. Debug overlay layers
- 3.6.17. Diagnostics (output)
- 3.7. Reduction to dagre
- 3.8. Determinism & coordinate model
- 3.9. TypeScript types
- 3.10. Compatibility matrix
- 3.11. Graph-format features
- 3.1. The
grale is a graph layout engine API, defined as JSON input and output specifications. It’s a strict superset of the dagre serialised JSON structure (graphlib’sjson.writeformat, latestdagrejs/graphlib, carryingdagrejs/dagrelayout attributes). grale is defined as a data format — JSON in, JSON out — not as a class library. Any serialised dagre graph is a valid grale request unchanged, and grale only adds optional fields. On top of the dagre structure grale adds optional capabilities the original lacks: pinned nodes, focus nodes, layout stability across turns (prevLayouts), per-edge preferred directions and hidden constraint links, per-side node ports, reusable edge markers, n-ary hyperedges, an edge-routing style hint, and honoured edge weight and lineWidth. Every label may carry opaquedatafor the renderer, and it returns layout diagnostics (warnings, timing) that dagre omits.
The dagre JSON baseline this document supersets is specified separately in
dagre-js.adoc; this spec describes only what grale adds and how it stays
compatible.
|
Important
|
What "strict superset" means here
grale is a superset of the JSON structure (the keys and label fields), not of dagre’s pixel output. grale is a different layout engine, so for the same graph the computed coordinates differ from dagre’s. The guarantee is:
|
Terms used consistently throughout this spec and the repo:
| Term | Meaning |
|---|---|
engine (synonym: layouter) |
anything implementing |
adapter |
an engine that wraps an existing layouter in the grale contract (this repo ships |
request / result |
the input / output |
envelope |
the top-level JSON object: |
label |
the |
link |
an edge or a hyperedge — used where a statement applies to both (crossings, z-order, the viewer’s link interaction). |
anchor |
a node’s position in the latest |
For developers who already know dagre. Skip to Part II — Detailed reference for the normative detail.
grale is a function from a graph JSON to a graph JSON:
import { layout } from 'grale-api/engines/dagre'; // or any grale engine
const out = layout(graphJson); // graleGraph -> graleGraph (positions filled in)The input is the dagre JSON envelope
({ options, value, nodes, edges }) with optional grale fields added to the labels.
The output is the same envelope with x/y on nodes, points on edges, and
width/height on the graph label filled in.
Any dagre/graphlib graph becomes a grale request by serialising it:
import * as graphlib from '@dagrejs/graphlib';
import { layout } from 'grale-api/engines/dagre'; // or any grale engine
const out = layout(graphlib.json.write(g)); // serialise dagre graph -> grale
const g2 = graphlib.json.read(out); // back to a graph, positions in the labelsNo code change to how the graph was built — only the layouter at the end differs.
{
"options": { "directed": true, "multigraph": false, "compound": false },
"value": { "rankdir": "LR" },
"nodes": [
{ "v": "a", "value": { "width": 60, "height": 40 } },
{ "v": "b", "value": { "width": 60, "height": 40 } }
],
"edges": [
{ "v": "a", "w": "b", "value": { "minlen": 1, "weight": 1 } }
]
}This is a plain dagre-js.adoc document — grale lays it out and returns the same
structure with positions filled in.
{
"options": { "directed": true, "multigraph": false, "compound": false },
"value": {
"rankdir": "TB",
"stability": 0.8 // (2) honour previous node positions
},
"nodes": [
{ "v": "ceo", "value": { "width": 80, "height": 40, "x": 400, "y": 0, "pinned": true } }, // (1) hard pin
{ "v": "cto", "value": { "width": 80, "height": 40 } }
],
"edges": [
{ "v": "cto", "w": "ceo", "value": { "prefDir": "up" } }, // (3) bias this edge up
{ "v": "a", "w": "b", "value": { "prefDir": "right" } }
]
}-
Pin —
ceois fixed at(400, 0); the layouter places everything else around it. -
Stability — past positions are supplied in
prevLayouts(omitted here — this is the first turn); nodes are then kept near where they last were.0= free re-layout,1= move only when forced. -
Preferred direction — the
cto → ceoedge is biased to pointup; set the sameprefDiron every edge of a relation type to bias that whole type.
dagre-js.adoc)
| Location | Fields |
|---|---|
envelope |
|
node |
|
edge |
|
graph |
|
| Location | Field | Meaning |
|---|---|---|
envelope |
|
n-ary edges — a set of node endpoints (GraphML-style) |
edge/hyperedge |
|
optional stable id (debugging / reference) |
graph |
|
honour previous positions |
graph |
|
array of past |
graph |
|
node id(s) the layout centres on |
graph |
|
round edge turns (px) |
graph |
|
routing-style hint |
graph |
|
marker registry (reserve endpoint space; reused like SVG defs) |
graph |
|
debug verbosity; draw debug overlays |
node |
|
hard-pin node at its |
node |
|
port counts per side |
node/edge |
|
draw order (higher on top; in/out) |
edge |
|
per-edge preferred direction |
edge |
|
constrains layout but is not drawn |
edge |
|
reserved corridor width / line width (≠ dagre label |
edge |
|
attach the edge end to a node port |
edge |
|
reference a graph marker by id |
any |
|
opaque passthrough data (renderer-side; never warns) |
output edge |
|
per-point path normals; unavoidable crossing positions |
output |
|
warnings, timing, displacement (dagre returns none) |
output |
|
named, nestable debug overlay layers (line/circle/rect/text) |
graleGraph keeps the dagre JsonGraph envelope intact —
the same options, nodes[], edges[], and value keys, the same { v, w, name? } edge
identity, the same parent for compound graphs — and enriches the three label objects
(value) with the optional fields in Graph label additions (value), Node label additions (value),
and Edge label additions (value). It makes two purely-additive envelope extensions: an optional
id on each edge (see Edge and hyperedge id) and a top-level hyperedges[] array (see
Hyperedges). A document that uses none of these is byte-for-byte a dagre document.
These keys are not a grale invention — they come from graphlib’s serialised form
(json.write, the format dagre-js.adoc specifies) — but grale adopts
them unchanged and they are normative for grale: a grale producer MUST emit them and a
grale consumer MUST honour them with exactly these meanings. They are restated here so a
grale document is self-describing without chasing the dagre baseline.
| Key | Where | Meaning (held identically to dagre) |
|---|---|---|
|
envelope |
|
|
envelope |
one entry per node. |
|
envelope |
one entry per binary edge. |
|
graph / node / edge |
the label object carrying that element’s attributes; omitted when the element has no label. This is where every grale label addition lives. |
|
node, edge |
a node’s id; on an edge, its source node id. |
|
edge |
an edge’s target node id. |
|
edge |
disambiguates parallel edges; present only in a multigraph ( |
|
node |
the containing node’s id; present only in a compound graph ( |
-
An edge’s structural identity is the triple
{ v, w, name? }(grale adds an optionalidbeside it — see Edge and hyperedgeid— but the triple remains the identity). -
valueis omitted when an element has no label; grale, like dagre, writes its computed results back into these samevalueobjects (seelayout(graph)). -
v,w,name,parentare envelope-level keys — they sit on the node / edge object, besidevalue, never inside it.
|
Note
|
In a compound graph grale allows edges that cross cluster boundaries — including an edge between a node and one of its own ancestors (child ↔ parent). dagre rejects these (it crashes with "Cannot set property 'rank' of undefined" — issues #238, #236, #426); grale treats a cluster boundary as permeable and routes the edge through it. |
function layout(graph: graleGraph): graleGraph;Pure function, JSON in / JSON out. It returns a new envelope of the same shape with the computed positions written into the labels:
-
each node
valuegetsx,y— the node centre (pixels, origin top-left) — and a computedzIndex; -
each edge
valuegetspoints: {x,y}[](ahiddenedge gets none) with matchingnormalsand anycrossings, plusx/yfor the edge label when it declares labelwidth/height— see Waypoint normals, crossings, and z-order; -
the graph
valuegetswidth,height— the layout bounding box; -
the envelope gains a
diagnosticsobject — warnings, timing, displacement (see Diagnostics (output)).
The result is itself a valid request: appending its node positions to prevLayouts and
calling again is how incremental layout works — see Respecting previous layouts (stability).
The dagre options (rankdir, align, nodesep, edgesep, ranksep, marginx,
marginy, acyclicer, ranker) are unchanged — see
the baseline. grale adds:
| Field | Type | Default | Meaning |
|---|---|---|---|
|
number |
|
how strongly to honour previous node positions — see Respecting previous layouts (stability) |
|
|
|
layout history; each element is a |
|
|
— |
node id(s) the layout organises around — see Focus |
|
number |
|
round every edge turn to this radius in pixels; |
|
|
|
routing-style hint (a bias, not a constraint) — see Edge routing |
|
|
|
marker registry; edges reference entries via |
|
|
|
log verbosity / diagnostics detail — see Debug settings |
|
boolean |
|
include debug overlays in the output — see Debug settings |
|
object |
— |
opaque passthrough, ignored by the layouter — see Data |
width, height on the graph label remain outputs (the layout bounding box).
The dagre node fields (width, height in; x, y out) are unchanged — see
the baseline. Note that under grale x/y are
also read as input when the node is pinned. (Stability positions travel separately in
prevLayouts, not in these fields.) grale adds:
| Field | Type | Meaning |
|---|---|---|
|
boolean |
hard constraint: treat this node’s |
|
|
number of attachment ports on each side; edges reference them by |
|
number |
draw order (in/out); higher draws on top — see Waypoint normals, crossings, and z-order |
|
object |
opaque passthrough, ignored by the layouter — see Data |
|
Note
|
Estimating
width/height from a label
|
The dagre edge fields (minlen, weight, width, height, labelpos, labeloffset;
points, x, y out) are unchanged — see
the baseline. grale honours weight as edge
importance (see Edge weight and lineWidth) and adds:
| Field | Type | Meaning |
|---|---|---|
|
Direction |
per-edge preferred direction ( |
|
boolean |
edge constrains the layout (rank, order, spacing) but is not drawn — see Hidden links |
|
number |
reserved corridor width / drawn line width (px); distinct from dagre’s label |
|
|
attach the edge’s source end to a port |
|
|
attach the edge’s target end to a port |
|
string |
id of a graph marker placed at the source end — see Markers |
|
string |
id of a graph marker placed at the target end — see Markers |
|
number |
draw order (in/out); higher draws on top — see Waypoint normals, crossings, and z-order |
|
|
out: normal angle (rad) per |
|
|
out: positions where this edge crosses another — see Waypoint normals, crossings, and z-order |
|
object |
opaque passthrough, ignored by the layouter — see Data |
A pin is a hard positional constraint: pinned: true tells the layouter to treat the
node’s x/y as input and place its centre exactly there, rather than computing a
position. There is one position field — x/y — and pinned flips it from output to
input.
[
{ "v": "ceo", "value": { "width": 80, "height": 40, "x": 400, "y": 0, "pinned": true }},
{ "v": "keep", "value": { "width": 60, "height": 40, "x": 120, "y": 80, "pinned": true } }
]-
The node’s
x/ymust be present;pinned: truewithout coordinates is ignored and reported as aPINNED_NO_COORDSwarning. Coordinates are the node centre, in pixels. -
To pin at a chosen position, set
x/yandpinned: true. To lock a node the user dragged, replay a prior output (which already carriesx/y) withpinned: trueon that node — no coordinates to copy. -
The layouter MUST place a pinned node at its
x/yand lay everything else out around it. -
If two pins resolve to the same place, the layouter honours them as closely as it can and emits a
PIN_CONFLICTwarning (see Diagnostics (output)). Pins are always hard — there is no silent relocation.
dagre is stateless: re-laying-out after adding or hiding one node can reshuffle the whole drawing. And a single "previous position" is not enough — over many turns a node may disappear and come back (present in turns 1–4, gone in 5–7, back in 8–10), and when it returns it should land where it last was. A node that has left the graph has no current position to anchor to.
So grale takes the layout history as an explicit input: prevLayouts, an array of past
frames, each a map from node id to its centre position.
{
"value": {
"rankdir": "LR",
"stability": 0.8,
"prevLayouts": [
{ "A": { "x": 120, "y": 300 }, "B": { "x": 320, "y": 300 } }, // turn 8
{ "A": { "x": 120, "y": 300 }, "C": { "x": 520, "y": 300 } } // turn 9 (B gone, C new)
]
},
"nodes": [ { "v": "A" }, { "v": "B" }, { "v": "C" } ] // turn 10: B is back
}-
The array is ordered oldest first; the last element is the most recent frame.
-
A node’s anchor is its position in the latest frame that contains it. In turn 10 above,
B— absent since turn 8 — re-anchors near(320, 300), whileAandCuse their turn-9 positions. A node in no frame has no anchor and is placed freely. -
The caller owns the history and its length: only frames holding currently-absent nodes that might return need to be kept; everyone else is covered by the most recent frame.
stability weights how strongly anchors are honoured:
stability |
Behaviour |
|---|---|
|
free re-layout; anchors used only as a tie-breaker — dagre-like |
|
prefer placements close to the anchor, trading off against layout quality |
|
move a node only when its anchor is invalid or taken by a pin |
Each turn, append the previous output’s positions and call again:
let history = []; // NodePositions[]
function relayout(graph) {
graph.value = { ...graph.value, stability: 0.8, prevLayouts: history };
const out = layout(graph);
const frame = Object.fromEntries(out.nodes.map(n => [n.v, { x: n.value.x, y: n.value.y }]));
history = [...history, frame]; // newest pushed last
return out;
}Round-trip guarantee: laying out a graph whose nodes all have anchors in prevLayouts,
with no pins, MUST reproduce those anchor positions (zero displacement) at any
stability. This is the test that the incremental path does not drift.
|
Note
|
"Incremental" here means stability, not delta updates — the full pipeline runs each call; the history only biases tie-breaks. |
focus names one or more nodes the layout should organise around — a single node id or an
array of them:
[
{ "value": { "focus": "Alice" } },
{ "value": { "focus": ["Alice", "Bob"] } }
]-
The focus set is placed centrally and the rest of the graph is laid out radiating outward from it (e.g. by graph distance). With several ids, the set is centred as a group.
-
A
focusid not present innodes[]is ignored with aBAD_FOCUSwarning. -
A layout with no notion of a centre ignores
focusentirely.
Real graphs mix relation kinds (reportsTo, dependsOn, knows) that should flow
differently. grale lets each edge declare a preferred direction:
{
"edges": [
{ "v": "cto", "w": "ceo", "value": { "prefDir": "up" } },
{ "v": "lib", "w": "app", "value": { "prefDir": "right" } },
{ "v": "p", "w": "q", "value": { "prefDir": "down" } }
]
}Direction is 'up' | 'down' | 'left' | 'right'. An edge with no prefDir follows the
global rankdir flow. To bias a whole relation type, set the same prefDir on every
edge of that type — the caller knows the type, so no per-type table is needed in the API.
A preference is a bias, not a constraint: the layouter honours it where it can without violating pins or acyclicity.
prefDir is relative to rankdirprefDir is authored in the default top-down frame (rankdir: "TB"), where down runs
along the rank flow (source → target). Selecting another rankdir rotates the whole
drawing, and every prefDir rotates with it so it keeps the same meaning relative to the
flow. The effective on-screen direction:
| Authored | TB (default) |
LR |
BT |
RL |
|---|---|---|---|---|
|
down |
right |
up |
left |
|
up |
left |
down |
right |
|
right |
up |
left |
down |
|
left |
down |
right |
up |
So prefDir: "up" points toward the top under TB and toward the left under LR — the
same "against the flow" bias, rotated 90° with the layout. Author prefDirs once, in the TB
frame, and they follow rankdir.
3.6.5. Hidden links
A hidden link is an edge that shapes the layout but is never drawn — the same idea as
PlantUML’s -[hidden]→. Set hidden: true:
{ "v": "yes", "w": "no", "value": { "hidden": true, "prefDir": "right" } }-
A hidden edge participates fully in ranking, ordering, and spacing — to the layouter it is an ordinary constraint.
-
It is not rendered: the layouter emits no
pointsfor it, and the caller skips it. -
Combined with
prefDir, hidden links pin down relative order with no visible connection. A hiddenprefDir: "right"edge from the True branch to the False branch forces True left of False (dagre issue #452); a hidden edge between two nodes that should share a column keeps them aligned (issue #472). This is how grale expresses node-alignment without a dedicated ordering field.
By default an edge attaches at a node’s centre and the router picks where it crosses the boundary. Ports fix the attachment to a named slot on a node side.
Every node has four sides, named as in CSS: top, left, bottom, right. A node
declares how many ports each side carries; a side with k ports has slots 0 … k-1:
{ "v": "fn", "value": { "width": 80, "height": 48,
"ports": { "top": 1, "bottom": 2 } } }Ports run counter-clockwise. The side order is itself counter-clockwise
(top → left → bottom → right), and within each side the index increases along that same
sweep, so every port forms one continuous CCW ring around the node:
| Side | Slot 0 … k-1 runs |
|---|---|
|
right edge → left edge |
|
top → bottom |
|
left edge → right edge |
|
bottom → top |
An edge may link either end to a port via fromPort / toPort, each a
{ side, index }:
{ "v": "fn", "w": "log",
"value": { "fromPort": { "side": "bottom", "index": 0 },
"toPort": { "side": "top", "index": 0 } } }-
fromPortbinds the source end,toPortthe target end; either or both may be omitted, leaving that end to attach at the centre as before. -
indexmust be in range for the side’s declared count, else aBAD_PORTwarning and that end falls back to centre attachment. -
The layouter spaces the slots along the side and routes the edge to them.
Ports have no dagre equivalent.
Two independent edge scalars that dagre either ignores or overloads:
weight-
Importance. A dagre field (default
1) whose effect dagre’s own users report seeing none of (issue #468). grale honours it: higher-weight edges are kept shorter and straighter and win ties in node ordering — weight is how you say "this edge matters most, route it first." Aweightof0is special: the edge is still drawn but does not constrain rank assignment or ordering — Graphviz’sconstraint=false(issues #110, #112). Sohiddenandweight: 0are opposite corners:hiddenconstrains without drawing,weight: 0draws without constraining. lineWidth-
Reserved space. The drawn line width in pixels and the corridor the router keeps clear for the edge, so thick edges don’t collide with neighbours. A grale addition: dagre’s edge
width/heightare the label box size, not the line, so the new name avoids the clash.
{ "v": "a", "w": "b", "value": { "weight": 5, "lineWidth": 4 } }The graph-level edgeRouting hints which routing style the engine should produce:
{ "value": { "edgeRouting": "orthogonal" } } // 'orthogonal' | 'polyline' | 'spline'-
Like
prefDir, it is a bias, not a constraint: an engine that cannot route the requested style falls back to its native one (no warning — the output is still valid). -
The output contract does not change: whatever the style, the route is delivered as the same
pointspolyline (withnormals).orthogonalbiases the engine toward axis-aligned segments,splinetoward routes meant to be drawn smoothly; a renderer combines the points withcornerRadiuseither way. -
Default
'polyline'— free-angle segments, the dagre-like behaviour.
(Promoted to the core in 2.0.0 from the ELK adapter analysis, grale-elk.adoc —
ELK maps it directly to elk.edgeRouting.)
-
Self-loops. An edge whose
vandware the same node (v === w) is allowed and drawn as a loop on one of the node’s sides; it may bind afromPort/toPortlike any other edge. (dagre mishandles self-loops in clusters — issue #120.) -
Corner rounding. grale output is always routed poly-lines — there is no separate spline output shape (issues #111, #136); Edge routing only biases where the waypoints go. The graph-level
cornerRadiusis the one drawing knob: it rounds every edge turn to that radius in pixels,0(default) leaving sharp corners.
An edge end may carry a marker — an arrowhead, a circle, a custom glyph. Markers are
declared once in a graph-level registry and referenced by id, like SVG <defs>:
{
"value": {
"markers": {
"arrow": { "width": 12, "height": 10 },
"dot": { "width": 8, "height": 8 }
}
},
"edges": [
{ "v": "a", "w": "b", "value": { "endMarker": "arrow" } },
{ "v": "c", "w": "d", "value": { "startMarker": "dot", "endMarker": "arrow" } }
]
}-
A
MarkerDefdeclares the rectangular space the marker occupies:widthalong the edge,heightacross it (pixels). The layouter reserves this box at the edge end and shortens the routed line so it stops at the marker’s dock point — the marker never overlaps the node or crowds its neighbours. -
dockis the side of the marker box that meets the line; it defaults toright, so the marker sits back along the edge from its attachment point — the canonical SVG-marker orientation, pointing in the direction of travel. -
startMarker/endMarkerreference a marker id; the same definition is reused by every edge that names it — define once, apply many. -
An edge naming an undeclared id is laid out without that marker and a
BAD_MARKERwarning is emitted.
Because markers consume space they are a layout input, not a pure rendering detail: the
reserved box is why an edge’s points already stop short of the node by the marker depth.
The marker’s appearance (its path, fill, colour) lives renderer-side — see Data.
Any label — graph, node, or edge — may carry a data object of arbitrary data. The
layouter never reads it, never lets it affect geometry, and returns it untouched. It is the
sanctioned home for everything that is the renderer’s business: colours, label text, node
types, dash patterns, sublabels, tooltips, marker glyph shapes.
{ "v": "a", "w": "b",
"value": { "endMarker": "arrow",
"data": { "color": "#444", "label": "knows", "dashed": true } } }data keeps renderer data clear of Diagnostics (output): a stray field outside data is
flagged UNKNOWN_FIELD (likely a typo), while anything inside data passes through
silently. It is how grale carries arbitrary per-element data — colours, label text, types,
dash patterns — without the layouter needing to understand any of it.
The conventional home for display text is data.label, a string. A label may request an
explicit line break with a <br> tag (<br>, <br/>, or <br />, case-insensitive); the
text is otherwise a single logical line that the renderer may soft-wrap to fit the node box.
<br> is the only markup grale recognises in a label — everything else is literal text.
{ "v": "n", "value": { "data": { "label": "Order received<br>(pending review)" } } }The layouter does not read data.label: node geometry comes from width/height
(see Node label additions (value)). A producer that has no real font metrics may estimate
width/height from the label — see the note there.
Three result enrichments grale produces that dagre cannot. normals and
crossings are output only — you never set them; zIndex you may.
// output fragment for one edge
{ "v": "a", "w": "b",
"value": { "points": [ {"x":60,"y":20}, {"x":90,"y":20}, {"x":120,"y":20} ],
"normals": [ 1.5708, 1.5708, 1.5708 ],
"crossings": [ {"x":90,"y":20} ],
"zIndex": 2 } }normals-
An array aligned index-for-index with dagre’s
points: the path’s outward-normal angle in radians at each point, ornullwhere undefined. Renderers use it to orientstartMarker/endMarkerand to offset edge labels off the line — dagre givespointsbut no tangent, so markers otherwise have to guess. crossings-
The pixel positions where this edge unavoidably crosses another. grale routes to minimise crossings and reports the residue, so the renderer can draw crossing shadows or bridges exactly where they occur — the recurring "reduce overlapping edges" ask dagre can’t answer (issues #145, #211, #246, #281).
zIndex-
Draw order on a node or edge
value; higher sits on top. Optional input — set it to force stacking — otherwise the layouter computes one (nested nodes above their clusters, a crossing-over link above the crossed). Echoed on output either way.
Any edge or hyperedge may carry an optional id string. grale does not use it for layout
— it is echoed untouched — but it makes edges addressable for debugging, logging, and
cross-referencing (a binary edge’s structural identity is otherwise just its (v, w, name)
triple). id sits on the edge / hyperedge object, beside value:
{ "v": "a", "w": "b", "id": "e_42", "value": { /* ... */ } }It is a grale envelope extension; dagre and graphlib.json ignore it.
A hyperedge connects a set of nodes rather than a pair — the same distinction GraphML
draws between <edge> and <hyperedge>. Binary edges[] stay exactly as dagre defines
them; hyperedges live in their own top-level array:
{
"nodes": [ /* ... */ ],
"edges": [ { "v": "a", "w": "b" } ], // binary, dagre
"hyperedges": [
{
"id": "booking_82648", // optional (debugging / reference)
"endpoints": [
{ "node": "traveler", "marker": "arrow", "port": { "side": "right", "index": 0 } },
{ "node": "London" },
{ "node": "Paris", "marker": "dot" }
],
"value": { "weight": 2, "lineWidth": 2, "prefDir": "right", "zIndex": 1,
"data": { "type": "booking" } }
}
]
}-
endpointsis an unordered set of{ node, marker?, port? }.markeris an id into the graphmarkersregistry (placed at that node end);portdocks the spoke to a node port, exactly like a binary edge’sfromPort/toPort. A bad marker / port id yields aBAD_MARKER/BAD_PORTwarning; a missing node yieldsDANGLING_EDGE. -
valuecarries the same whole-edge properties as a binary edge —weight,hidden,lineWidth,prefDir,labelpos,zIndex,data, … The per-end binary fields (startMarker/endMarker,fromPort/toPort,points/normals/crossings) do not apply; their job is done by the per-endpointmarker/portand by the outputtree. -
idis optional and also allowed on binary edges (see Edge and hyperedgeid).
Routing a hyperedge yields a tree, not a polyline — a binary edge’s points is just the
degenerate single-path case. The result is written to value.tree:
{
"value": {
"tree": {
"points": [
{ "kind": "endpoint", "node": "traveler", "x": 120, "y": 80, "marker": "arrow" }, // 0
{ "kind": "branch", "x": 200, "y": 140 }, // 1
{ "kind": "bend", "x": 200, "y": 200 }, // 2
{ "kind": "crossing", "x": 240, "y": 200 }, // 3
{ "kind": "endpoint", "node": "London", "x": 300, "y": 200 }, // 4
{ "kind": "endpoint", "node": "Paris", "x": 200, "y": 260 } // 5
],
"segments": [
{ "ends": [0,1], "normals": [0.0, 1.5708] },
{ "ends": [1,2], "normals": [1.5708, 1.5708] },
{ "ends": [2,3], "normals": [0.0, 0.0 ] },
{ "ends": [3,4], "normals": [0.0, 3.1416] },
{ "ends": [1,5], "normals": [4.7124, 1.5708] } // branch 1's third arm
]
}
}
}points-
every routed point, each tagged by
kind:endpoint-
degree-1, sits at a node — carries
nodeand the echoedmarker; branch-
degree ≥ 3 — a junction (Steiner point) where the tree splits;
bend-
degree-2 corner;
crossing-
degree-2 point where this hyperedge crosses another link (the hyperedge analogue of an edge’s
crossings).
segments-
the tree edges, each
{ ends: [i, j], normals: [ni, nj] }— the two point indices it joins, plus the outward-normal angle (rad) at each end (nias the segment leavesitowardj,njas it leavesjtowardi) — the same normal a binary edge’snormalscarries per point (see Waypoint normals, crossings, and z-order). A tree over N points has N − 1 segments; the topology may branch arbitrarily.
Normals live on segment ends, not on points, so every point kind is handled the same way — look up the segments incident to a point and read their end-normals:
-
an
endpoint(degree 1) lies on one segment → its single end-normal orients the marker; -
a
bend/crossing(degree 2) lies on two segments → two end-normals (for a crossing, both run along the through-line); -
a
branch(degree ≥ 3) lies on k segments → k end-normals, one per arm, and each segment’s other endpoint says which neighbour that arm leads to.
cornerRadius rounds bend and branch corners just as it does binary-edge turns.
Two optional graph-level switches for development:
logLevel-
'DEBUG' | 'INFO' | 'WARN' | 'ERROR', default'WARN'. Sets how chatty the layouter is — it filters the messages the engine logs and how much detail lands indiagnostics:DEBUGadds informational notes,ERRORkeeps only failures. visualDebug-
boolean, default
false. Whentrue, the result carries extra debug geometry a renderer can draw on top of the layout — the routing grid, node bounding boxes, waypoint normals, reserved marker / port boxes. This geometry is delivered as the top-leveldebuglayer tree described in Debug overlay layers. Off, the output has none of it.
{ "value": { "logLevel": "DEBUG", "visualDebug": true } }When an engine produces visual debug geometry (typically under visualDebug), it returns it
as a top-level debug array on the envelope — a tree of named layers that a renderer draws
on top of the layout and an interactive viewer can toggle on and off. It is the JSON analogue
of nested SVG <g> groups with simple shapes inside, and is output only: the layouter never
reads it, and a request without it is unaffected (see Reduction to dagre).
{
"debug": [
{
"kind": "layer", "name": "grid", "opacity": 0.6,
"children": [
{ "kind": "line", "x1": 0, "y1": 0, "x2": 280, "y2": 0, "stroke": "#e2e8f0" }
]
},
{
"kind": "layer", "name": "annotations",
"children": [
{ "kind": "layer", "name": "coords", "children": [
{ "kind": "text", "x": 60, "y": 38, "text": "(60,60)", "fill": "#ef4444", "anchor": "middle" }
]},
{ "kind": "layer", "name": "ids", "visible": false, "children": [ /* … */ ] }
]
}
]
}Layers (kind: "layer") are named, nestable groups. A layer carries a name (unique among
its siblings), optional visible (default true — the initial toggle state) and opacity,
and a children array of nested layers and/or leaf shapes, drawn in order. The path of layer
names — e.g. grid, annotations/coords — identifies a layer, so a viewer can show or hide any
subtree by its path. A renderer maps each layer to a group it can toggle (the reference renderer
emits <g data-layer-path="annotations/coords">).
Leaf shapes are simplified, JSON-only analogues of their SVG counterparts — four kinds, each
tagged by kind:
kind |
Geometry |
|---|---|
|
|
|
|
|
|
|
|
All shapes share an optional, minimal style — a subset of SVG presentation attributes: stroke
(colour), strokeWidth, fill ('none' for none; on text it is the text colour), opacity
(0..1), and dashed. Omitted values are the renderer’s choice. All coordinates and sizes are in
the grale coordinate model (px, origin top-left, y down — see Determinism & coordinate model), the same
space as the layout, so the shapes overlay it directly.
dagre returns nothing when something is off — a dangling edge or an impossible
constraint just silently skews the drawing. grale attaches a diagnostics object to
every result so callers can see what happened:
{
"diagnostics": {
"elapsedMicros": 1843,
"warnings": [
{ "code": "DANGLING_EDGE", "message": "edge a->z references missing node 'z'",
"edge": { "v": "a", "w": "z" } }
],
"displacement": { "moved": 2, "totalDx": 34, "totalDy": 0 }
}
}warnings-
Array, empty on a clean run. Each entry is
{ code, message, node?, edge? }.
| Code | Meaning |
|---|---|
|
an edge references a node id that is not in |
|
two pinned nodes resolve to the same position; honoured as closely as possible |
|
a node has |
|
a |
|
an edge references a |
|
a |
|
a label carries a field grale does not recognise (likely a typo); it is ignored |
Unknown fields are ignored (dagre-compatible — labels are open objects), but each one is
surfaced as an UNKNOWN_FIELD warning. A mistyped option — a recurring dagre complaint
where settings silently do nothing (issues #461, #310, #161) — becomes visible instead of
vanishing. Intentional passthrough data belongs under data (see Data), which is never
reported.
elapsedMicros-
Layout wall-time in microseconds. Excluded from the determinism guarantee — everything else in the output is a pure function of the input.
displacement-
Present when
prevLayoutswas honoured (stability > 0and a non-empty history):movedis the number of nodes that moved from their anchor,totalDx/totalDythe aggregate drift. A quality metric for incremental layout, and a debounce signal for UI animation.
grale reduces exactly to the dagre structure when none of the additions are present. Formally, for a request where:
-
no node
valuehaspinnedorports, -
the graph
valuehas nostability(or0), noprevLayouts, nofocus, nocornerRadius(or0), noedgeRouting(or'polyline'), nomarkers, nologLevel, and novisualDebug, -
no edge
valuehasprefDir,hidden,lineWidth,fromPort,toPort,startMarker,endMarker, orweight: 0, -
there are no self-loops,
-
there are no
hyperedges, and no edge carries anid,
the document is a plain dagre-js.adoc JsonGraph: same keys, same label fields,
same meanings and defaults. (Computed coordinates may differ — grale is a different
engine — and the output additionally carries a diagnostics object, per-point normals,
any crossings, a computed zIndex, and — only under visualDebug — a debug layer tree;
otherwise the structure and result shape are identical. See the opening callout.)
-
All coordinates are
number(IEEE-754f64), in CSS pixels. -
The coordinate system is screen / SVG / Canvas, not Cartesian: the origin is the top-left corner, the x-axis points right, and the y-axis points down. A larger
yis therefore lower on screen, not higher. (This is the opposite of the math convention where y points up — engines MUST NOT emit y-up coordinates.) -
Node
x/yis the node centre. -
The flow of
rankdiris defined against this axis: the defaultrankdir: "TB"(top-to-bottom) runs along increasing y — a source sits at a smallerythan its target."BT"runs along decreasing y,"LR"along increasing x,"RL"along decreasing x. (See Preferred edge direction for howprefDirrotates withrankdir.) -
layoutis a pure function of its input: equal request ⇒ equal output, exceptdiagnostics.elapsedMicros. No wall-clock, network, or filesystem dependence. -
layoutdoes not mutate its argument; it returns a new envelope.
The normative TypeScript realisation of this data model lives in this repo, beside the
spec, and is published as the grale-api package:
-
api/types.ts— every interface (graleGraph, the labels, diagnostics, debug layers, hyperedges) plus theLayoutfunction type; -
api/geometry.ts— thePointprimitive.
The spec deliberately does not repeat the listing: the field tables above are the
prose contract, types.ts is the single machine-readable source (its doc comments
reference the sections here). Earlier revisions duplicated the code block in this
section and the two drifted — importing the types is always right:
import type { graleGraph, GraphLabel, NodeLabel, EdgeLabel,
Hyperedge, Diagnostics, Layout } from 'grale-api';The dagre fields are repeated in types.ts so the grale label types are self-contained;
their authoritative semantics live in dagre-js.adoc.
-
dagre-js.adoc— the dagre serialised JSON baseline this spec supersets
| Capability | Status | Note |
|---|---|---|
|
✅ full |
same keys, same shape |
graph label options |
✅ full |
same names, values, defaults |
node |
✅ full |
|
edge |
✅ full |
|
compound (nested) graphs |
✅ full |
node |
multigraph (parallel edges) |
✅ full |
edge |
|
✅ full |
serialised dagre graph is a valid request |
pinned nodes |
➕ added |
not in dagre |
focus node(s) (single or set) |
➕ added |
dagre has none |
stability / previous layout |
➕ added |
not in dagre |
per-edge preferred direction |
➕ added |
not in dagre |
hidden constraint links |
➕ added |
not in dagre (cf. PlantUML |
non-constraining edges ( |
➕ added |
drawn but ignored for rank (Graphviz |
node ports (4 sides, CCW) |
➕ added |
not in dagre |
edge |
➕ added |
dagre ignores weight; no line width |
|
➕ added |
not in dagre |
|
➕ added |
dagre routes one style only |
self-loops ( |
➕ added |
drawn as a loop; dagre buggy in clusters |
cross-cluster / child↔parent edges |
➕ added |
dagre crashes |
reusable edge markers (registry) |
➕ added |
dagre has none |
n-ary hyperedges |
➕ added |
dagre is binary-only (cf. GraphML |
edge / hyperedge |
➕ added |
dagre identity is |
opaque |
➕ added |
renderer data; never affects layout |
waypoint normals (output) |
➕ added |
dagre gives |
edge crossings (output) |
➕ added |
minimised, with the residue reported |
z-order ( |
➕ added |
dagre has none |
diagnostics / warnings on output |
➕ added |
dagre returns none |
debug toggles ( |
➕ added |
dagre has none |
debug overlay layers ( |
➕ added |
named, nestable; line/circle/rect/text leaves |
The graph-format registry (graph-features.adoc) classifies every format against
a fixed set of capability feature-ids. For grale 2.0.0, per feature-id — which
the format uses and which it does not:
format:grale-2.0.0 ..does not use.. multiple-graphs-per-document ,, one graph per document
format:grale-2.0.0 ..uses.. nodes
format:grale-2.0.0 ..uses.. undirected-edges ,, options.directed false
format:grale-2.0.0 ..uses.. directed-edges ,, options.directed true
format:grale-2.0.0 ..does not use.. mixed-directionality-edges ,, directedness is graph-level via options.directed
format:grale-2.0.0 ..uses.. hyperedges ,, n-ary hyperedges[] array, GraphML-style
format:grale-2.0.0 ..uses.. parallel-edges ,, graphlib/dagre multigraph allows multiple edges per pair
format:grale-2.0.0 ..uses.. self-loops ,, edge source and target may be the same node
format:grale-2.0.0 ..does not use.. edges-on-edges
format:grale-2.0.0 ..uses.. nested-graphs-in-nodes ,, compound graphs via node parent (options.compound)
format:grale-2.0.0 ..does not use.. nested-graphs-in-edges
format:grale-2.0.0 ..does not use.. nested-graphs-in-graphs
format:grale-2.0.0 ..uses.. node-labels ,, node value/data label
format:grale-2.0.0 ..uses.. edge-labels ,, edge value/data label
format:grale-2.0.0 ..uses.. attributes-on-nodes ,, node value plus opaque data
format:grale-2.0.0 ..uses.. attributes-on-edges ,, edge value (weight, lineWidth, minlen) plus opaque data
format:grale-2.0.0 ..uses.. attributes-on-graphs ,, graph value label plus opaque data
format:grale-2.0.0 ..does not use.. typed-edges ,, markers are visual, not a semantic edge typeThe registry records the same matrix as its canonical machine-readable source
(formats/json/grale/grale-2.0.0.adoc, using ..supports.. / ..lacks..).