Ferrigura / Diagrams
Treemap Diagram
The treemap draws a hierarchy as nested rectangles whose area follows the value. It starts with treemap or treemap-beta; each row is a quoted name, indented to give its depth, and a leaf has a value: "Leaf": 20. A row may carry a class with :::name, and classDef rows define the classes. ferrigura parses it with a hand-written lexer and recursive-descent grammar in ferrigura-syntax/src/treemap/ (entry point treemap.rs) and draws it with the type's own renderer in ferrigura-render/src/treemap/, over a port of the layout of d3-hierarchy. No layout engine crate is involved.
Model
ferrigura_model::treemap::TreemapDiagram is the DB state after the parse and buildHierarchy (canonical-model.md has the conventions):
title,accTitle,accDescr: sanitized by the DB's setters. The title is the grammar's when it is non-empty, else the front matter's.accTitleloses its leading blanks;accDescrloses the blanks after each line break.classes: the classes in the order of their first definition, each withstylesandtextStyles(the DB's lists). A class defined twice keeps its place and appends its styles.tree: the root ofgetRoot(), with the name""and the top-level nodes as children. A section haschildren(empty when it has no rows); a leaf hasvalueand nochildren.classSelectoris present when the row gives one, andcssCompiledStyleswhen the DB defines that class. A value that is not finite is its sentinel string ("NaN"for a value of only separators,"Infinity"for a digit run that overflows).- Names are the DB's names: the quoted text without its quotes, nothing sanitized, the entity placeholders kept (
#quot;isfl°quot¶ß).
Left out: the indentation levels and the flat node list (the tree holds the same information), the DB's levels map, and the configuration.
Parsing
treemap.rs runs the preprocessing steps of Diagram.fromText, tokenizes with scanner.rs, parses the rows with grammar.rs and builds the model with db.rs. The DB's order follows Mermaid's populate: the header texts first, then every classDef row in order, then the items (each with the styles of its class as they stand after all the definitions, so a class defined after its use styles the item), then buildHierarchy.
The scanner ports treemap.langium as Chevrotain runs it: the first terminal that matches wins, not the longest, in the order of the grammar's terminals. The accessibility and title terminals come first, since they begin with the blanks before them; then the keyword, classDef, :::, :, ,, the blanks, comments and line breaks (hidden), ID2, NUMBER2 and STRING2. Terminals no rule uses (BOOLEAN) are not in Langium's lexer. The grammar is Treemap: KEYWORD (ACC_DESCR | ACC_TITLE | TITLE | TreemapRow)*, TreemapRow: INDENTATION? (Leaf | Section | CLASS_DEF), Leaf: STRING2 INDENTATION? (":" | ",") INDENTATION? NUMBER2 (":::" ID2)?, Section: STRING2 (":::" ID2)?.
Quirks that ferrigura keeps:
- A name must be quoted (
A: 1is rejected). It may be empty, single-quoted or span lines, and has no escapes. - Values: commas are removed before
parseFloat, so1,2,3is 123;1_000is 1;1..2is 1;.5is 0.5;._.and.areNaN;_alone and1e3are rejected; a digit run that overflows isInfinity. - A blank run that ends a row is the indentation of the next row, so
"A": 1 ⏎ "B": 2putsBone level deeper. Two indentation tokens in a row are rejected, and so is a trailing blank at the end of the text. Tabs count one character each. A comment line takes its leading blanks with it; a comment after a value leaves its blanks as a trailing blank. - A
classDefname needs two characters (cis rejected,_xis fine). A bareclassDeffollowed by a line takes that line as its style text (\scrosses the line break), so"A"disappears; a bareclassDefat the end gives the style''. A style text ends at;, its commas split the styles,\,keeps a comma. Only the exact piecescolor,font-sizeand the other label properties are text styles;color:#fffwithout a space is not. - Class selectors are identifiers:
trueandfalseare plain names.treemap,titleand aclassDefafter:::are rejected, and so is a blank before:::. titlealone is the empty title; a title may sit inside a row ("A" title T) or follow the keyword with no blank (title"A": 1is the title""and a leaf). The last title wins; an empty grammar title keeps the front matter's.treemapXis rejected (the keyword followed by an identifier), whiletreemap "A": 1on one line is accepted. Two roots are accepted: the multiple-root validator is registered but never runs inparse().- Names and values are not sanitized (
<b>C</b>stays as written); entities are placeholders before the parse.
Error messages are ferrigura's own (0007); the differential run compares accept and reject.
Layout and rendering
mod.rs ports renderer.ts (draw), style.rs the colors, the class declarations and the inline style, text.rs the measured text, and config.rs the treemap section. The layout is d3-hierarchy in common/d3/: hierarchy.rs (sum, sort, the traversals), treemap.rs (the paddings, squarify with the golden ratio, dice, slice, the rounding), together with format.rs (d3-format, with toFixed, toExponential and toPrecision rounded on the exact binary value) and ordinal.rs (scaleOrdinal).
The hierarchy is summed from the last child to the first, sorted descending by value (a stable sort), and laid out with size([1000, 400]) for the defaults (nodeWidth and nodeHeight in tenths of a pixel), round(true), paddingInner(padding), and for each node with children a paddingTop of 35 (a 25 px header plus the padding) and the padding on the other sides. A section is a node with children; a node without children is a leaf, including a section with no rows (a zero-value leaf), and an empty treemap draws its root as a leaf. Each section is a group with a header rectangle, a clip path, the body rectangle, a bold label and an italic value; each leaf is a group with a rectangle, a clip path, a label and a value.
Colors are three ordinal scales (the section fill and stroke, and the label), assigned in the order of the first call: sections in breadth-first order (the root's name "" is the first call and takes transparent), leaves in depth-first order. A leaf's fill and stroke use its parent's name.
Leaf labels and values are fitted by shrinking the font, and hidden when they do not fit: the label's size drops by 1 while the text is wider than the leaf allows, the value's is derived from the label's, and a section label is truncated with ... to the header's width minus the value's room, and emptied when even the dots do not fit. A layout of more than 20 leaves uses the complex constants. The decisions read the advance of the text, so text.rs measures as Chromium does (getComputedTextLength snapped to the 1/64 px grid, and the getBBox of one line). The root's viewBox is the union of the boxes that are drawn, plus diagramPadding; an element with display: none (the root section) is left out.
The facade passes TreemapSettings::of(&effective): the typed configuration has no treemap section, because the schema does not name its keys (0008).
Differences from Mermaid
- Label truncation counts characters, where the reference counts UTF-16 units, so an astral character may be cut differently. This is not probed.
- A font family in a class that is not one of the families the harness maps is measured in the bundled face; the reference measures it in that font.
- Text outside the bundled faces is measured with the face's
.notdef; the geometry run leaves such fixtures out (0010). - Colors are custom properties with presentation attributes (0011).
Known gaps
- The
.treemapNode.sectionand.treemapNode.leafrules of Mermaid's styles are left out of the sheet: no element carries those classes.
Checking it
pnpm -C tools/port port:diff treemapcompares the canonical models on every fixture, spec string, documentation example and case ofcases.ts;export-treemap.tsruns the reference's parser and DB with a window for DOMPurify.port:diff configcompares thetreemapsection.pnpm -C tools/port port:geometry treemapcompares the geometry of the fixtures and of the probes intools/port/geometry/probes/treemap/(class definitions, value formats, zero and non-finite values, long labels, many leaves, empty sections, the dark theme, titles and more). The fixture with emoji is excluded for its uncovered glyphs.- The Rust specs:
ferrigura-syntax/tests/treemap_spec.rsports the parser tests of Mermaid's parser package (the twoclass myNode myClasscases are asserted as rejections, as the reference reports parser errors there),treemap_db_spec.rsthebuildHierarchytests and the DB's class and title rules,treemap_grammar.rsthe quirks above;ferrigura-render/tests/treemap_render.rsholds the rules the markup and the errors show.d3-formatwas checked against the library on thousands of generated specifiers and values.
Learnings
valueFormathas two failure modes. The three$branches build their formatter inside the value callback, outside thetry, so an invalid specifier after$($xyz) fails the whole render, but only when a value is written. An invalid specifier without$falls back to,.$0,0is$plus,(decimals kept); a$with a comma takes the first.nof the string as precision ($,.2fgives$1.6e+6);$alone gives$1234567.0shows no value text.- d3-format rounds on the binary value:
.2fof1.005is1.00,.0fof2.5is3, the default type is.12~g, and negative numbers use U+2212. A treemap value cannot be negative. - The layout measures names with the entity placeholders in them, and the SVG carries them decoded: the reference decodes the whole SVG string after the layout. The font subset is built from the decoded characters.
- A class's declarations go into the inline style with the reference's rules. A property given again moves to the place of the later declaration; an
!importantone keeps its value (font-size:20px !importanton a leaf sets its label to 20 px); a d3style(name, value)replaces the value and clears the flag.colorbecomesfillon labels. A label's and a value's font is the one its inline style gives, so a class'sfont-weightandfont-stylechange the measured text and the embedded face. - The text boxes of the viewBox follow Chromium's baselines:
middleis half the x-height andhangingis 0.8 of the rounded ascent, both on the 1/64 px grid. - A
NaNor infinite value ("A": .or a 400-digit number) givesNaNcoordinates, which the browser reads as zero in the box, and aNaNroom cuts no label (Math.maxkeepsNaN, andlength > NaNis false). - Clipping does not change
getBBox, so a clipped label counts in full in the viewBox. - Three configuration keys are never read:
borderWidth,valueFontSizeandlabelFontSizehave defaults but the renderer uses constants (labelFontSize: 30leaves the labels at 38 px).nodeWidthandnodeHeightfall back to 960 and 500 px when falsy,paddinganddiagramPaddingfall back only when missing, andshowValuesis off only forfalse. - The simple and the complex constants switch at more than 20 leaves (base label 38, base value 28, minimum label 8 versus 16, 14, 4).
- The section's value is the sum after
sum, the root included; the leaf'sclassends inx(leaf0x,leaf1 ccxfor a classcc). d3-hierarchy's sum order matters: children are summed from the last to the first, which fixes the floating-point result.