Ferrigura / Diagrams
Sankey Diagram
The sankey diagram draws flows between nodes as bands whose width follows the value. Its text is CSV: a header line sankey (or sankey-beta), then one source,target,value record per line. ferrigura parses it with a hand-written scanner in ferrigura-syntax/src/sankey/ and draws it with the type's own renderer in ferrigura-render/src/sankey/, whose layout is a port of d3-sankey 0.12.3. It does not use the unified renderer or a layout engine crate.
Model
ferrigura_model::sankey::SankeyDiagram is the DB state after the parse (canonical-model.md has the conventions):
title: the front matter's title, sanitized, or"". Nothing draws it, but the DB holds it. An entity in it is not encoded and stays as written.accTitle,accDescr: always"". The grammar has no statement for them.nodes:{id}in creation order: the source of a record before its target, by first appearance. A node id is the name as the DB sanitizes it, so two names that sanitize alike are one node.links:{source, target, value}in input order, with the ids of the ends. Duplicates are kept. A value that is not finite is its sentinel string.
Left out: the layout (x0, y0, layer, width, the node values) and the configuration.
Parsing
sankey/mod.rs runs the preprocessing steps of Diagram.fromText, sets the front matter title, and then applies the parser wrapper's prepareTextForParsing: the blanks at the very start and end of the text go (a blank that starts or ends another line stays), every run of line breaks becomes one line break, inside quoted fields too, and the text is trimmed. Blank lines vanish, while a line of blanks in the middle stays and is an error. scanner.rs then reads the whole text into tokens, and the grammar (start: SANKEY NEWLINE csv, record: field COMMA field COMMA field) calls the DB once per record: the source node, the target node, then the link.
The scanner ports sankey.jison: the initial state reads the header, a csv state reads the rest, and a quoted field enters escaped_text. A field is printable ASCII (U+0020 to U+007E) without the double quote and the comma. Jison's rules can match the empty text, and the grammar never accepts an empty field, so the scanner reports the first character no rule takes (a tab, a non-ASCII character, the end of the text inside quotes) as an error at once. The accept and reject results are Jison's.
Quirks that ferrigura keeps:
- The header is
sankeyorsankey-betaalone on its line, in any case for the parser (the detector is case-sensitive). Other text on the line, and a word glued to the keyword (sankeyfoo), is an error. A header without a record is an error. - A record has exactly three fields. An empty field is an error (
,b,1,a,b,); a field of blanks is the empty name"", and a value of blanks isNaN. At the very end of the text the blanks are removed, so such a field is empty and the text an error. - Quoted fields:
""is one quote (resolved after the trim); a line break inside is kept, and a run of them collapses; nothing may follow the closing quote; the field must close. - Text outside printable ASCII is an error, quoted or not (
Käse). An entity fails:#name;and#digits;become placeholders that are not ASCII, while"without the hash is text. - The value is
parseFloatof the field:10abcis 10,1eis 1,0x10is 0,1_000is 1,"1,2"is 1;x,NaNand-areNaN;Infinityand2e308are infinite. - A name is sanitized as the DB does:
<script>goes with its content,<img onerror>loses the handler, an open tag swallows the rest (a<bisa),a<isa<, anda&banda=bare kept. - A cycle parses; the layout rejects it. Prototype names (
__proto__,constructor) are ordinary ids. - The grammar has no
title,accTitleoraccDescrstatement, so such a line is an error;sankeyis an ordinary name inside a record. Comment lines and directives are removed; a%%in the middle of a line is text, and a line of%%alone is an error.
Error messages are ferrigura's own (0007): the differential run compares accept and reject, not Jison's text. Lines are counted in the text the grammar reads, after the blank lines were removed, plus the front matter's lines.
Layout and rendering
layout.rs is d3-sankey 0.12.3 (sankey.js, align.js), run with Mermaid's settings: node ids, nodeWidth, nodePadding, the alignment (left, right, center or justify) and the extent [[0, 0], [width, height]]; the defaults of the library that Mermaid leaves alone are six iterations and the default sorts. The phases and their order are the library's, because the last bits of the positions are part of the geometry: node links, node values (max(sum(out), sum(in)), where the sum skips NaN and zero), depths and heights by breadth-first search (a cycle throws circular link), layers, the initial breadths, and the relaxation sweeps with alpha = 0.99^i and beta = max(1 - alpha, (i + 1) / iterations), with the collision resolution of the library (a move only for more than 1e-6) and its comparators on a stable sort. The sorts and sums are the shared d3 subset in common/d3/.
mod.rs is sankeyRenderer.ts and styles.js. It writes the root, the style sheet (the common sheet and the sankey rules, own_rules in style.rs), g.nodes with one g.node and rect per node, g.node-labels with the labels, and g.links with one g.link per link: a linearGradient in gradient mode and a path with d3's linkHorizontal (M x0,y0 C xm,y0 xm,y1 x1,y1, xm = (x0 + x1) / 2), stroke width max(1, width). Node colors come from nodeColors or the Tableau 10 ordinal scale, assigned in node order. A label is SVG text; with labelStyle: outlined each label is two texts, sankey-label-bg then sankey-label-fg.
The root's size is the bounding box of the drawing, as setupGraphViewbox reads getBBox: the layout's width and height only set the extent, and labels that spill past it widen the viewBox. ferrigura computes the box from the node rectangles, the link bands and the label boxes (ferrigura_text::measure_box), leaving out the elements with NaN geometry.
Differences from Mermaid
- Prototype names.
nodeColorsis a plain object in the reference, so a node namedconstructoror__proto__reads the member ofObject.prototypeas its color, and the reference writes that member's text into the attribute ([object Object],function Object() { [native code] }). ferrigura writes the same strings (object_prototype_member). useMaxWidth. The schema saysfalsefor sankey, but the reference's built default istrue. ferrigura follows the reference (ts_defaults.json).- Detector.
sankeyfoois not a sankey diagram. Upstream's detector takes any word that starts with the keyword, which is a bug of Mermaid; the reference carries a patch that ends the detector at a word boundary, as ferrigura's does (0014). - Themes. Only the shipped themes exist (0011). The dark custom properties are written but not compared, since the harness forces
redux-color.
Known gaps
None known for this type. A probe the reference cannot render (a cycle gives circular link) counts as a reference failure in the geometry run, not as a difference; ferrigura fails on a cycle too.
Checking it
pnpm -C tools/port port:diff sankeycompares the canonical models on every fixture, spec string, documentation example and case ofcases.ts;export-sankey.tsruns the reference's parser and DB afterprepareTextForParsing.port:diff configcompares thesankeysection.pnpm -C tools/port port:geometry sankeycompares the geometry of the fixtures and of the probes intools/port/geometry/probes/sankey/(cycles, zero, negative,NaNand infinite values, prototype names, spilling and outlined labels, every alignment, link colors, prefix and suffix, many links).- The Rust specs:
ferrigura-syntax/tests/sankey_spec.rsports the reference's spec,sankey_grammar.rshas a test per quirk above, andferrigura-render/tests/sankey_layout_d3.rscompares the layout to the bit withd3-sankeyon random graphs (zero, negative,NaNand infinite values, every alignment, cycles) that the real library laid out.
Learnings
- Zero, negative,
NaNand infinite values do not throw. The sums skipNaNand zero, so a node's value is 0 andkyisInfinityorNaN: every position becomesNaN, the rect getsheight="NaN", the pathd="M10,NaNC…"andstroke-width="NaN". A negative value givesstroke-width="1"(Math.max(1, -Infinity)). Chromium readsy="NaN"of a text as 0, so such labels still count in the root box. The render must write the same strings and succeed. - The label's newline collapses to a space. The text is
name\nvalue, which SVG draws as one line, so the box is that ofa 8. The embedded subset must cover the collapsed text, space included: a subset cut froma,\nand8has no space glyph, and Chromium draws the space with a fallback glyph of another width, which put the boxes 0.42 px off. - Values are rounded with
Math.round(value * 100) / 100and printed with JavaScript's number rules:0.597is0.6,2.675is2.68(the product is a double that rounds up),1e21is999999999999999900000,1e-7is0.Math.roundrounds half up (-2.5is-2), which Rust'srounddoes not. - The ordinal scale assigns colors in order of first use. The rectangles call it in node order; the gradient stops come after (every source stop, then every target stop) and add no new value. A non-custom node's color therefore depends on how many non-custom nodes precede it, and the 11th takes
#4e79a7again. - Node ids are
node-<n>andlinearGradient-<n>from one counter that the reference never resets, so a second diagram on a page continues the numbering. ferrigura numbers each render from 1; the harness pairs elements without their ids. - The legacy label has no
classattribute; thexandyofg.nodeare written though nothing draws them; theheightattribute of the root is written afterxmlnsand thewidthafter theid. nodePaddingisnodePadding + 15when values are shown, andwidthandheightof the configuration set only the extent:width: 300gives a root 300 wide because the nodes span it.- Floating-point residue is part of the output: the justified layout writes
viewBox="0 -2.664530515715871e-14 410 400". Only the library's order of operations reproduces it. nodeColorspasses the directive filter: a value must be a hex color,rgb(…),hsl(…)or a plain word (rgba(…)is removed), and a key that starts with__or containsprotoorconstris removed.- A latent bug stays latent:
heightfalls back to the default width, but the merged defaults always provideheight.