Writing SVG with XQuery
With the layout computed, drawing is a matter of building elements: a group, a box and a label for each node, a line and a label for each edge. XQuery's constructors build them, as they build any XML, and the one thing to get right is the namespace.
Elements in the SVG namespace
An element constructor written in a query is an element in the result,
and an xmlns attribute on it puts it, and the elements inside it, in
a namespace:
$ sxml xquery --expression='<svg xmlns="http://www.w3.org/2000/svg" width="180" height="60"><rect x="20" y="10" width="140" height="40"/></svg>'
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="60"><rect x="20" y="10" width="140" height="40"/></svg>
$ sxml xquery --method=adaptive --expression='namespace-uri(<svg><rect/></svg>), namespace-uri(<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>/*)'
""
"http://www.w3.org/2000/svg"
Without the namespace, the result looks right and draws nothing: to a
browser, rect in no namespace is not an SVG rectangle.
A query that writes only SVG can say the namespace once, in its prolog:
declare default element namespace puts every unprefixed element name
in the query into it. Every name: the names in constructors, and the
names in paths too. That is a trap when the input has no namespace:
$ sxml xquery --context-item=diagram.xml --expression='declare default element namespace "http://www.w3.org/2000/svg"; count(//node), count(//Q{}node)'
0 8
//node now looks for SVG node elements and finds none. Q{}node
names an element in no namespace explicitly, whatever the default is.
Here is a first drawing, one box per node in a column, with the
namespace declared once:
boxes.xq:
xquery version "4.0";
(: One box per node of a diagram (the context item), in a column. :)
declare default element namespace "http://www.w3.org/2000/svg";
declare option output:indent "yes";
let $nodes := //Q{}node
return <svg width="180" height="{ count($nodes) * 60 + 20 }">{
for $node at $position in $nodes
let $y := $position * 60 - 40
return (
<rect x="20" y="{ $y }" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>,
<text x="90" y="{ $y + 25 }" text-anchor="middle">{ string($node/@label) }</text>
)
}</svg>
$ sxml xquery --context-item=diagram.xml boxes.xq
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="500">
<rect x="20" y="20" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="45" text-anchor="middle">@vane/browser</text>
<rect x="20" y="80" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="105" text-anchor="middle">@vane/node</text>
<rect x="20" y="140" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="165" text-anchor="middle">vane/api</text>
<rect x="20" y="200" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="225" text-anchor="middle">vane/flags</text>
<rect x="20" y="260" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="285" text-anchor="middle">vane/registry</text>
<rect x="20" y="320" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="345" text-anchor="middle">vane/cache</text>
<rect x="20" y="380" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="405" text-anchor="middle">postgresql</text>
<rect x="20" y="440" width="140" height="40" fill="#eef3fb" stroke="#3b5b92"/>
<text x="90" y="465" text-anchor="middle">redis</text>
</svg>
The default namespace does not touch attributes: an unprefixed attribute
name is always in no namespace, so $node/@label works as before.
Attributes from expressions
Curly braces in an attribute value hold an expression, and literal text
around them stays. When the expression gives several values they are
joined with spaces, and an empty sequence adds nothing, which is just
what the class attribute of SVG needs:
$ sxml xquery --expression='<g class="{ "node", "store", () }" id="node-{ "cache" }"/>'
<g class="node store" id="node-cache"/>
Curly braces in text
CSS uses curly braces too. In the content of a direct constructor a
single brace starts an expression, so a style rule written as it is
fails to parse; doubled, {{ and }} are one brace each:
$ sxml xquery --expression='<style>rect { fill: blue }</style>'
expression:1:15: err:XPST0003: The text is not valid XQuery 4.0 syntax (XQuery 4.0, Appendix A)
$ sxml xquery --expression='<style>rect {{ fill: blue }}</style>'
<style>rect { fill: blue }</style>
String templates, the strings between backticks, follow the same rule. The module keeps its five style rules in one:
(: The style rules every drawing has. :)
declare variable $draw:style := `
.node rect {{ fill: #eef3fb; stroke: #3b5b92; stroke-width: 1.5 }}
.store rect {{ fill: #f6efe3; stroke: #8a6a2f }}
.node text {{ font: 14px sans-serif; fill: #1b1b1b }}
.edge line {{ stroke: #555555; stroke-width: 1.5 }}
.edge text {{ font: 12px sans-serif; fill: #555555 }}`;
(: Rules a drawing has only when it uses the class (or, for "loop", has
an edge from a node to itself). :)
declare variable $draw:class-rules := {
"loop": ".edge path { fill: none; stroke: #555555; stroke-width: 1.5 }",
"added": ".added rect, .added line, .added path { stroke: #2e7d32; stroke-width: 3 }",
"removed": ".removed rect, .removed line, .removed path { stroke: #c62828; stroke-dasharray: 6 4 }",
"changed": ".changed rect, .changed line, .changed path { stroke: #b26a00; stroke-width: 3 }"
};
The second variable holds rules that a drawing gets only when it needs
them: a rule for loops when an edge goes from a node to itself, and one
for each of the classes added, removed and changed, which the
later projects use to draw differences. These are ordinary string
literals, where a brace is just a brace.
Computed constructors
When the name of an element depends on the data, a computed
constructor gives it as an expression: element { $name } { content }.
Its attributes can be computed as well, with attribute name { value },
and the content of the braces decides which ones there are. This
variant of the drawing puts each box at its place from draw:layout,
and draws stores as ellipses:
shapes.xq:
xquery version "4.0";
(: The boxes of diagram.xml (the context item) at their places, with
stores drawn as ellipses: the element's name is computed. :)
import module namespace draw = "urn:example:draw" at "draw.xqm";
declare default element namespace "http://www.w3.org/2000/svg";
declare option output:indent "yes";
let $diagram := /Q{}diagram
let $layout := draw:layout($diagram)
return <svg width="{ $layout?width }" height="{ $layout?height }">{
for $node in $diagram/Q{}node
let $place := $layout?places($node/@id)
let $shape := if ($node/@kind = "store") then "ellipse" else "rect"
return element { $shape } {
if ($shape = "ellipse") then (
attribute cx { $place?x + 70 }, attribute cy { $place?y + 20 },
attribute rx { 70 }, attribute ry { 20 }
) else (
attribute x { $place?x }, attribute y { $place?y },
attribute width { 140 }, attribute height { 40 }
),
attribute fill { "none" },
attribute stroke { "black" }
}
}</svg>
$ sxml xquery --context-item=diagram.xml shapes.xq
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="480">
<rect x="20" y="20" width="140" height="40" fill="none" stroke="black"/>
<rect x="200" y="20" width="140" height="40" fill="none" stroke="black"/>
<rect x="110" y="120" width="140" height="40" fill="none" stroke="black"/>
<rect x="110" y="220" width="140" height="40" fill="none" stroke="black"/>
<rect x="20" y="320" width="140" height="40" fill="none" stroke="black"/>
<rect x="200" y="320" width="140" height="40" fill="none" stroke="black"/>
<ellipse cx="90" cy="440" rx="70" ry="20" fill="none" stroke="black"/>
<ellipse cx="270" cy="440" rx="70" ry="20" fill="none" stroke="black"/>
</svg>
A computed name without a prefix is in the default element namespace,
like a direct one, so the ellipses are SVG ellipses. The description is
read as /Q{}diagram, because of the trap above.
The drawing module
draw.xqm does not declare a default element namespace. It reads the
description with plain names, and puts the xmlns attribute on the
outermost constructor of each function that writes SVG instead. Inside
such a constructor the default namespace is SVG as well, for paths too,
so each function reads what it needs from the description before its
constructor, in let clauses, and inside uses only variables and
attributes. A node is a group with a box and a label:
(:~ One node: a group with a box and its label. :)
declare function draw:node($node as element(node), $place as draw:place) as element() {
let $kind := ($node/@kind, "service")[1]
return <g xmlns="http://www.w3.org/2000/svg"
class="{ "node", $kind, $node/@class }" id="node-{ $node/@id }">
<rect x="{ $place?x }" y="{ $place?y }"
width="{ $draw:box-width }" height="{ $draw:box-height }"
rx="{ if ($kind = "store") then 20 else 6 }"/>
<text x="{ $place?x + $draw:box-width idiv 2 }" y="{ $place?y + 25 }"
text-anchor="middle">{ $node/@label/string() }</text>
</g>
};
An edge is a group with a line and its label at the line's middle, moved a little to the right and down; an edge from a node to itself gets the loop instead:
(:~ One edge: a group with a line (or a loop) and its label. :)
declare function draw:edge($edge as element(edge), $places as map(*)) as element() {
let $from := $places($edge/@from)
let $line := draw:line($from, $places($edge/@to))
let $label := $edge/@label/string()
return <g xmlns="http://www.w3.org/2000/svg"
class="{ "edge", $edge/@class }" id="edge-{ $edge/@from }-{ $edge/@to }">{
if (empty($line)) then (
<path d="{ draw:loop($from) }" marker-end="url(#arrow)"/>,
$label ! <text x="{ $from?x + 56 }" y="{ $from?y + 60 }">{ . }</text>
) else (
<line x1="{ $line[1] }" y1="{ $line[2] }" x2="{ $line[3] }" y2="{ $line[4] }"
marker-end="url(#arrow)"/>,
$label ! <text x="{ ($line[1] + $line[3]) idiv 2 + 6 }"
y="{ ($line[2] + $line[4]) idiv 2 + 4 }">{ . }</text>
)
}</g>
};
$label ! <text …>{ . }</text> makes a text only when there is a
label: ! over an empty sequence gives an empty sequence. And
draw:svg puts it all together:
(:~ The drawing of a diagram: edges first, so that boxes cover their ends. :)
declare function draw:svg($diagram as element(diagram)) as element() {
let $layout := draw:layout($diagram)
let $title := $diagram/@title/string()
let $nodes := $diagram/node
let $edges := $diagram/edge
let $features := distinct-values((
if ($edges[@from = @to]) { "loop" },
($nodes, $edges)/@class ! tokenize(.)
))
return <svg xmlns="http://www.w3.org/2000/svg" width="{ $layout?width }"
height="{ $layout?height }" viewBox="0 0 { $layout?width } { $layout?height }">
<title>{ $title }</title>
<style>{
string-join(($draw:style, $features ! $draw:class-rules(.)), " ") || " "
}</style>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5"
markerWidth="8" markerHeight="8" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555555"/>
</marker>
</defs>
{ $edges ! draw:edge(., $layout?places) }
{ $nodes ! draw:node(., $layout?places(@id)) }
</svg>
};
The edges come before the nodes, because SVG draws in document order: the boxes are drawn over the ends of the lines, which makes the arrows stop exactly at the box. The style is the five rules plus the ones for the features the diagram uses, one rule per line.
Writing the file
The main query that draws a diagram is short. It imports the module and declares how to serialize the result:
draw.xq:
xquery version "4.0";
(: Draws diagram.xml (the context item) as SVG. :)
import module namespace draw = "urn:example:draw" at "draw.xqm";
declare option output:indent "yes";
draw:svg(/diagram)
declare option output:indent "yes" asks for indentation, one element
per line. Without it the only line breaks are the ones in the style
text; a browser draws the result the same way, but it is hard to read
and to compare:
$ sxml xquery --context-item=diagram.xml --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:svg(/diagram)' | wc -l
7
$ sxml xquery --context-item=diagram.xml draw.xq | wc -l
72
--output writes the result to a file, and only when the query
succeeds. The drawing it writes is the committed diagram.svg, byte for
byte:
$ sxml xquery --context-item=diagram.xml --output=drawn.svg draw.xq
$ diff drawn.svg diagram.svg && echo same
same
--output won't replace a file that exists unless --force is given,
so an old drawing is never lost by accident:
$ sxml xquery --context-item=diagram.xml --output=drawn.svg draw.xq
sxml xquery: 'drawn.svg' exists; give --force to replace it
Drawing a change
The optional class attribute of a node or an edge is copied to its
group, and the three classes added, removed and changed get their
own style rules. change.xml is the Vane system after a change to the
flags module: a kill no longer reads its flag from the cache module,
each kill is recorded by a new audit module, which writes to
PostgreSQL and posts to the owners' webhook at hooks.example.com, and
the way flags calls the registry changed. The cache module stays, and
Redis with it, because other modules still use it; only the edge from
flags goes. The nodes of each row are in an order that keeps the
lines from crossing: the layout keeps the order of the description, so
the author can still decide that much.
change.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!-- The Vane system after the change to vane 2.4.0: the flags module no
longer reads a kill's flag from the cache module (which stays, with
Redis: other modules use it), it records each kill with the new audit
module, which writes to PostgreSQL and posts to the owners' webhook,
and its calls to the registry module changed. The nodes of a row are
in an order that keeps the lines from crossing. -->
<diagram title="Vane after the change">
<node id="browser" label="@vane/browser"/>
<node id="node" label="@vane/node"/>
<node id="api" label="vane/api"/>
<node id="flags" label="vane/flags" class="changed"/>
<node id="cache" label="vane/cache"/>
<node id="registry" label="vane/registry"/>
<node id="audit" label="vane/audit" class="added"/>
<node id="redis" label="redis" kind="store"/>
<node id="postgresql" label="postgresql" kind="store"/>
<node id="hooks" label="hooks.example.com" class="added"/>
<edge from="browser" to="api" label="HTTP"/>
<edge from="node" to="api" label="HTTP"/>
<edge from="api" to="flags"/>
<edge from="flags" to="cache" class="removed"/>
<edge from="flags" to="registry" class="changed"/>
<edge from="flags" to="audit" class="added"/>
<edge from="cache" to="redis" label="Redis"/>
<edge from="registry" to="postgresql" label="SQL"/>
<edge from="audit" to="postgresql" label="SQL" class="added"/>
<edge from="audit" to="hooks" label="HTTP" class="added"/>
</diagram>
$ sxml xquery --context-item=change.xml --output=change.svg draw.xq
$ sxml xpath --context-item=change.svg '//*:style/string()'
"
.node rect { fill: #eef3fb; stroke: #3b5b92; stroke-width: 1.5 }
.store rect { fill: #f6efe3; stroke: #8a6a2f }
.node text { font: 14px sans-serif; fill: #1b1b1b }
.edge line { stroke: #555555; stroke-width: 1.5 }
.edge text { font: 12px sans-serif; fill: #555555 }
.changed rect, .changed line, .changed path { stroke: #b26a00; stroke-width: 3 }
.added rect, .added line, .added path { stroke: #2e7d32; stroke-width: 3 }
.removed rect, .removed line, .removed path { stroke: #c62828; stroke-dasharray: 6 4 }
"
$ sxml xpath --context-item=change.svg '//*:g[tokenize(@class) = ("added", "removed", "changed")] ! `{@id}: {@class}`'
"edge-flags-cache: edge removed"
"edge-flags-registry: edge changed"
"edge-flags-audit: edge added"
"edge-audit-postgresql: edge added"
"edge-audit-hooks: edge added"
"node-flags: node service changed"
"node-audit: node service added"
"node-hooks: node service added"
The added module and the webhook, and the three edges to and from the
audit module, are drawn with a thick green line, the removed edge with a
dashed red one, and the changed module and edge in amber. Each class
rule is in the style once, in the order the classes first appear in the
description: changed on flags, added on audit, removed on the
first edge. A rule written
later in the style wins over an earlier one of the same weight, which is
why the class rules come last.
Using the module elsewhere
Any query can draw a diagram by importing the module; all it needs is a
diagram element, from a file or built on the spot. The state machine,
call-chain and trace projects each keep a link to svg/draw.xqm in
their own directory and import it there. The module's functions are its
interface:
| Function | Result |
|---|---|
draw:svg($diagram) | the drawing, an SVG svg element |
draw:layout($diagram) | a draw:layout record: width, height, places |
draw:ranks($diagram) | a map from node id to rank |
draw:line($from, $to) | the ends of a line between two places, or nothing |
draw:loop($place) | the path of an edge from a node to itself |
draw:node($node, $place), draw:edge($edge, $places) | one group |
A diagram built in the query itself is drawn the same way. Here the loop shows up, with its style rule:
$ sxml xquery --expression='import module namespace draw = "urn:example:draw" at "draw.xqm"; draw:svg(<diagram title="Polling"><node id="flag" label="flag"/><edge from="flag" to="flag" label="status.request"/></diagram>)' --output=loop.svg
$ sxml xpath --context-item=loop.svg '//*:path[not(ancestor::*:defs)]/@d/string(), contains(//*:style, ".edge path")'
"M 40 60 C 40 84, 70 84, 70 60"
true()