Computing a Layout
A layout says where each box goes. Ours is simple and predictable: the nodes that nothing calls go in the top row, the nodes they call in the next row, and so on. Each row is centred, and the nodes of a row keep the order of the description. Graphviz and its friends do much more (they move nodes to avoid crossings and route lines around boxes), but this one fits on a page, and it is enough for the diagrams of this book.
The functions of this section live in the library module draw.xqm,
namespace urn:example:draw. We'll read it a part at a time and run each
part as we go; Writing SVG with XQuery shows the module
as a whole.
Ranks
The rank of a node is its row: how many edges away it is from the top.
The top row starts with the nodes without incoming edges, here the two
SDKs. XPath finds them, and the nodes one edge further from the flags
module:
$ sxml xpath --context-item=diagram.xml '//node[not(@id = //edge/@to)]/@id/string()'
"browser"
"node"
$ sxml xpath --context-item=diagram.xml '//edge[@from = "flags"]/@to/string()'
"registry"
"cache"
Doing that step again and again until no new node turns up is a
breadth-first search. The ranks found so far are a map from node id to
rank, and each step adds the nodes one edge away from the last rank,
leaving out the ones that already have a rank. map:build makes the
first map from a sequence of keys and a function that gives each key's
value; fn { 0 } is a function that ignores its argument:
$ sxml xpath --context-item=diagram.xml 'map:build(("browser", "node"), value := fn { 0 })'
{"browser":0,"node":0}
Here is the search as two functions of draw.xqm:
(:~ The ids of the nodes one edge away from the last rank found. :)
declare %private function draw:next(
$diagram as element(diagram),
$ranks as map(xs:string, xs:integer)
) as xs:string* {
let $last := max(map:items($ranks))
let $frontier := map:keys($ranks)[$ranks(.) = $last]
return distinct-values($diagram/edge[@from = $frontier]/@to)
[not(map:contains($ranks, .))]
};
(:~ The rank of every node: a map from node id to row number. :)
declare function draw:ranks($diagram as element(diagram)) as map(xs:string, xs:integer) {
let $targets := $diagram/edge/@to
let $starts := ($diagram/node[1], $diagram/node[not(@id = $targets)])/@id
let $reached := while-do(
map:build($starts, string#1, fn { 0 }),
fn($ranks) { exists(draw:next($diagram, $ranks)) },
fn($ranks) {
let $rank := max(map:items($ranks)) + 1
return map:merge(($ranks, map:build(draw:next($diagram, $ranks), value := fn { $rank })))
}
)
let $rest := $diagram/node/@id[not(map:contains($reached, .))]
return map:merge(($reached, map:build($rest, string#1, fn { max(map:items($reached)) + 1 })))
};
draw:next finds the frontier (the nodes of the last rank) and the
nodes their edges lead to. while-do holds the state of the loop, the
map, and calls its third argument as long as its second one says there
is more to do. Because a node gets a rank only once, the search ends
even when the edges go round in a circle.
Two details make it work on any graph. The first node of the description
always starts too, so a diagram in which every node has an incoming edge
(a state machine, say) still has a top row; and nodes the search never
reaches go into one last row, so no node is lost. In a module, draw: is
the module's prefix, and %private keeps draw:next out of the
module's interface.
Let's run it. An import module declaration in the query names the
module's namespace and file, and --method=adaptive shows the map:
$ sxml xquery --method=adaptive --context-item=diagram.xml --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:ranks(/diagram)'
{"browser":0,"node":0,"api":1,"flags":2,"registry":3,"cache":3,"postgresql":4,"redis":4}
A diagram with cycles works the same way. states.xml has four states
of a feature flag; every state has an incoming edge except archived:
states.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!-- Three states of a feature flag and a fourth that nothing leads to.
Every state has an incoming edge except archived. -->
<diagram title="Flag states">
<node id="stable" label="stable"/>
<node id="checking" label="checking"/>
<node id="rolling-out" label="rolling-out"/>
<node id="archived" label="archived"/>
<edge from="stable" to="checking" label="rollout.request"/>
<edge from="checking" to="rolling-out" label="check.passed"/>
<edge from="checking" to="stable" label="check.failed"/>
<edge from="rolling-out" to="stable" label="step.done"/>
<edge from="archived" to="stable" label="kill.request"/>
</diagram>
$ sxml xquery --method=adaptive --context-item=states.xml --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:ranks(/diagram)'
{"stable":0,"archived":0,"checking":1,"rolling-out":2}
stable is first, so it starts the search; archived has no incoming edge,
so it starts too. The edges back to stable change nothing, because it
already has a rank. And here are two nodes that only point at each
other, behind a first node that leads nowhere: they go into the last row.
$ sxml xquery --method=adaptive --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:ranks(<diagram><node id="a"/><node id="b"/><node id="c"/><edge from="b" to="c"/><edge from="c" to="b"/></diagram>)'
{"a":0,"b":1,"c":1}
The same search as a fold
while-do stops when the frontier leads nowhere new. The search can
also be written as a fixed number of passes: a graph of n nodes has no
rank above n − 1, so n passes of fold-left are always enough, and
pass number p ranks the nodes one edge away from rank p − 1:
ranks-fold.xq:
xquery version "4.0";
(: The ranks of diagram.xml (the context item) with a fold: one pass per
node, each pass ranking the nodes one edge away from the last rank. :)
let $diagram := /diagram
let $starts := ($diagram/node[1], $diagram/node[not(@id = $diagram/edge/@to)])/@id
return fold-left(
1 to count($diagram/node),
map:build($starts, string#1, fn { 0 }),
fn($ranks, $pass) {
let $frontier := map:keys($ranks)[$ranks(.) = $pass - 1]
let $next := distinct-values($diagram/edge[@from = $frontier]/@to)
[not(map:contains($ranks, .))]
return map:merge(($ranks, map:build($next, value := fn { $pass })))
}
)
$ sxml xquery --method=adaptive --context-item=diagram.xml ranks-fold.xq
{"browser":0,"node":0,"api":1,"flags":2,"registry":3,"cache":3,"postgresql":4,"redis":4}
$ sxml xquery --method=adaptive --context-item=states.xml ranks-fold.xq
{"stable":0,"archived":0,"checking":1,"rolling-out":2}
The results are the same. The fold says plainly that it ends, and it
does its last passes for nothing; while-do stops as soon as it can. The
distinct-values matters in both: map:build keeps every value given
for a key, so a node that two frontier nodes lead to would get its rank
twice:
$ sxml xpath 'map:build(("a", "b", "a"), value := fn { 0 })'
{"a":(0,0),"b":0}
Rows
With the ranks known, the rows are a grouping: the nodes grouped by their rank, in the order of the ranks. Each group becomes an array of ids, so that the rows stay apart (a sequence of sequences would flatten into one sequence):
rows.xq:
xquery version "4.0";
(: The rows of diagram.xml (the context item): one array of node ids per rank. :)
import module namespace draw = "urn:example:draw" at "draw.xqm";
let $ranks := draw:ranks(/diagram)
for $node in /diagram/node
group by $rank := $ranks($node/@id)
order by $rank
return array { $node/@id/string() }
$ sxml xquery --method=adaptive --context-item=diagram.xml rows.xq
["browser","node"]
["api"]
["flags"]
["registry","cache"]
["postgresql","redis"]
$ sxml xquery --method=adaptive --context-item=states.xml rows.xq
["stable","archived"]
["checking"]
["rolling-out"]
group by keeps the nodes of each group in their order in the
description, so registry comes before cache.
Places as records
Each box needs a place: the x and y of its top left corner. A map
with two entries holds that well, and a record type says which entries
it has and what they hold. declare record names the type and also
declares a function of the same name that makes one. The module starts
with the sizes of the drawing and two records:
(: The sizes of the drawing, in SVG user units. :)
declare variable $draw:box-width := 140;
declare variable $draw:box-height := 40;
declare variable $draw:gap-across := 40;
declare variable $draw:gap-down := 60;
declare variable $draw:padding := 20;
(: The top left corner of a node's box. :)
declare record draw:place(x as xs:integer, y as xs:integer);
(: A computed layout: the size of the drawing and a place per node id. :)
declare record draw:layout(
width as xs:integer,
height as xs:integer,
places as map(xs:string, draw:place)
);
A record is a map, so ?x reads an entry. The type checks what goes in:
$ sxml xquery --method=adaptive --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:place(110, 20), draw:place(110, 20)?x'
{"x":110,"y":20}
110
$ sxml xquery --method=adaptive --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:place(1.5, 20)'
expression:1:66: err:XPTY0004: The value does not match the required type (XPath 4.0, section 3.4, coercion rules)
draw:layout uses a record type for its result too, with a typed map
inside: map(xs:string, draw:place) is a map from strings to places.
The widest row, and centring
A row of n boxes is n × 140 wide plus n − 1 gaps of 40. The drawing is as wide as its widest row plus the padding on both sides, and each row starts half of its free space in from the left. The width of a row is a small function, kept in a variable so that it can be used twice:
$ sxml xpath 'let $row-width := fn($row as array(*)) { array:size($row) * 140 + (array:size($row) - 1) * 40 } return (["api"], ["registry", "cache"]) ! $row-width(.)'
140
320
For the Vane diagram the widest rows have two boxes, 320, so the drawing is 360 wide. The row of one box starts at (360 − 140) / 2 = 110, and the row of two at (360 − 320) / 2 = 20. Rows are 40 high with 60 between them, so row r (counting from 1) is at y = 20 + (r − 1) × 100, and five rows make the drawing 2 × 20 + 5 × 40 + 4 × 60 = 480 high. Here is all of it:
(:~ The layout of a diagram: the size of the drawing and where each box goes. :)
declare function draw:layout($diagram as element(diagram)) as draw:layout {
let $ranks := draw:ranks($diagram)
let $rows := (
for $node in $diagram/node
group by $rank := $ranks($node/@id)
order by $rank
return array { $node/@id/string() }
)
let $row-width := fn($row as array(*)) as xs:integer {
array:size($row) * $draw:box-width + (array:size($row) - 1) * $draw:gap-across
}
let $width := 2 * $draw:padding + max($rows ! $row-width(.))
let $height := 2 * $draw:padding + count($rows) * $draw:box-height
+ (count($rows) - 1) * $draw:gap-down
let $places := map:merge(
for $row at $row-number in $rows
let $left := ($width - $row-width($row)) idiv 2
for member $id at $position in $row
return {
$id: draw:place(
$left + ($position - 1) * ($draw:box-width + $draw:gap-across),
$draw:padding + ($row-number - 1) * ($draw:box-height + $draw:gap-down)
)
}
)
return draw:layout($width, $height, $places)
};
The places come from two nested loops: for $row at $row-number over
the sequence of rows, and for member $id at $position over the
members of one row's array. Each pair gives a one-entry map, and
map:merge puts them together.
$ sxml xquery --method=adaptive --context-item=diagram.xml --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:layout(/diagram)'
{"width":360,"height":480,"places":{"browser":{"x":20,"y":20},"node":{"x":200,"y":20},"api":{"x":110,"y":120},"flags":{"x":110,"y":220},"registry":{"x":20,"y":320},"cache":{"x":200,"y":320},"postgresql":{"x":20,"y":420},"redis":{"x":200,"y":420}}}
These are the numbers in diagram.svg: the rect of registry is at
x="20" y="320".
Where the lines go
An edge is a line between two boxes. Which sides it joins depends on
where the boxes are, and draw:line returns the line's ends as four
numbers, or nothing for an edge from a node to itself, which
draw:loop draws as a small curve below the box instead:
(:~ The ends of an edge's line (x1, y1, x2, y2), or nothing for an edge
from a node to itself. :)
declare function draw:line($from as draw:place, $to as draw:place) as xs:integer* {
let $half := $draw:box-width idiv 2
let $middle := $draw:box-height idiv 2
return if ($from?x = $to?x and $from?y = $to?y) then ()
else if ($to?y > $from?y) then (: down: bottom centre to top centre :)
($from?x + $half, $from?y + $draw:box-height, $to?x + $half, $to?y)
else if ($to?y < $from?y) then (: up: right of the centre, so that it
does not cover an edge going down :)
($from?x + $half + 30, $from?y, $to?x + $half + 30, $to?y + $draw:box-height)
else if ($to?x > $from?x) then (: across, to the right :)
($from?x + $draw:box-width, $from?y + $middle, $to?x, $to?y + $middle)
else (: across, to the left :)
($from?x, $from?y + $middle, $to?x + $draw:box-width, $to?y + $middle)
};
(:~ The path of an edge from a node to itself: a loop below the box, left
of the edges that leave its centre. :)
declare function draw:loop($place as draw:place) as xs:string {
let $left := $place?x + 20
let $bottom := $place?y + $draw:box-height
return `M {$left} {$bottom} C {$left} {$bottom + 24}, {$left + 30} {$bottom + 24}, {$left + 30} {$bottom}`
};
An edge down, the common case, leaves the bottom centre of its source
and arrives at the top centre of its target. An edge up (in states.xml,
check.failed from checking back to stable) runs a little to the
right, so that it doesn't cover the edge that goes down between the same
boxes. Edges in one row join the sides. A line that passes a box on its
way is hidden under it, because the boxes are drawn last; a bigger tool
would route it around.
$ sxml xquery --method=adaptive --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:line(draw:place(110, 220), draw:place(20, 320)), draw:loop(draw:place(20, 20))'
180
260
90
320
"M 40 60 C 40 84, 70 84, 70 60"
The first four numbers are the edge from flags down to registry, as
diagram.svg draws it: from (180, 260) to (90, 320).
The layout as XML
Maps and records are what XQuery computes with, but another tool can't
read them. layout.xq writes the layout as XML: the description again,
with the size of the drawing on diagram, the place on each node, and
the line (or the loop's path, d) on each edge:
layout.xq:
xquery version "4.0";
(: The layout of a diagram (the context item) as XML: the description
with the size of the drawing, the place of every box, and the line
(or, from a node to itself, the loop) of every edge. :)
import module namespace draw = "urn:example:draw" at "draw.xqm";
declare option output:indent "yes";
let $diagram := /diagram
let $layout := draw:layout($diagram)
let $places := $layout?places
return <diagram>{
$diagram/@*,
attribute width { $layout?width },
attribute height { $layout?height },
for $node in $diagram/node
let $place := $places($node/@id)
return <node>{ $node/@*, attribute x { $place?x }, attribute y { $place?y } }</node>,
for $edge in $diagram/edge
let $from := $places($edge/@from)
let $line := draw:line($from, $places($edge/@to))
return <edge>{
$edge/@*,
if (exists($line)) then
for $name at $position in ("x1", "y1", "x2", "y2")
return attribute { $name } { $line[$position] }
else attribute d { draw:loop($from) }
}</edge>
}</diagram>
Attributes are copied from the description and made with computed
attribute constructors: attribute { $name } { … } takes its name from
an expression, which is how the four ends of a line get their names.
An edge between two modules has no label in the description, so it
has none here either: $edge/@* copies the attributes there are.
$ sxml xquery --context-item=diagram.xml layout.xq
<diagram title="Vane" width="360" height="480">
<node id="browser" label="@vane/browser" x="20" y="20"/>
<node id="node" label="@vane/node" x="200" y="20"/>
<node id="api" label="vane/api" x="110" y="120"/>
<node id="flags" label="vane/flags" x="110" y="220"/>
<node id="registry" label="vane/registry" x="20" y="320"/>
<node id="cache" label="vane/cache" x="200" y="320"/>
<node id="postgresql" label="postgresql" kind="store" x="20" y="420"/>
<node id="redis" label="redis" kind="store" x="200" y="420"/>
<edge from="browser" to="api" label="HTTP" x1="90" y1="60" x2="180" y2="120"/>
<edge from="node" to="api" label="HTTP" x1="270" y1="60" x2="180" y2="120"/>
<edge from="api" to="flags" x1="180" y1="160" x2="180" y2="220"/>
<edge from="flags" to="registry" x1="180" y1="260" x2="90" y2="320"/>
<edge from="flags" to="cache" x1="180" y1="260" x2="270" y2="320"/>
<edge from="registry" to="postgresql" label="SQL" x1="90" y1="360" x2="90" y2="420"/>
<edge from="cache" to="redis" label="Redis" x1="270" y1="360" x2="270" y2="420"/>
</diagram>
Drawing with XSLT draws from this file. First, the drawing in XQuery itself.