Ferrigura / Diagrams
Timeline diagram
A timeline diagram draws periods (a year, an era) with the events that happened in them, grouped in optional sections, along an axis. It starts with the keyword timeline, optionally followed by a direction: LR (the default, left to right) or TD (top down). ferrigura parses it with a hand-written parser that ports the Jison grammar timeline.jison and the DB timelineDb.js (crates/ferrigura-syntax/src/timeline/), and draws it with a renderer of its own (crates/ferrigura-render/src/timeline/) that ports timelineRenderer.ts for the horizontal layout and timelineRendererVertical.ts for TD. There is no layout engine.
Model
TimelineDiagram (crates/ferrigura-model/src/timeline.rs), version 1, is the DB's state after parsing.
direction:"LR"or"TD". A plaintimelinekeepsLR.title,accTitle,accDescr: the header texts, sanitized,""when absent.sections: the section names in the order given, duplicates kept, untrimmed.periods: in creation order, each{ section, period, events }.sectionis the section current at creation,""before the first one.periodandeventsare untrimmed.
The DB calls the periods tasks; the model names them as the grammar does. The model leaves out id (a counter that clear does not reset), type (always the section) and score (always 0).
The timeline's text rules differ from the other types' (canonical-model.md):
- The header texts follow the grammar. It trims
accTitleandaccDescrand nothing else, so runs of blanks are kept and a title keeps its leading and trailing blanks. TheaccDescrsetter then removes the indentation after each line break. - The front matter title does not reach the model, because the timeline DB has no title setter. The title is the last
titleline of the body, even when it is blank. - Text is encoded, not decoded. Entities become placeholders before parsing (
#quot;isfl°quot¶ß) and the model keeps them. Sections, periods and events are not sanitized; the title and the header texts are.
Parsing
Lexer. scanner.rs runs the rules of timeline.jison in their written order, over the helpers of scan.rs: the first match wins, keywords are case-insensitive, and the rules are numbered as the generated lexer numbers them. The text always ends in a line break, because Diagram.fromText appends one, so a value state (accTitle: and the other values) is reached at the end of the input only when the line before consumed the last line break.
Grammar. timeline_header document EOF: a line is a statement or a line break, and statements need no line break between them (timeline section A is a header and a section). The actions run in input order: title, accTitle, accDescr, section, a period, and an event (: text). A header token after the first token and an INVALID token are errors.
Pipeline. The steps of Diagram.fromText: front matter (its title is read and dropped), directives, comment lines, entity encoding and a final line break. Detection is the caller's job (ferrigura_syntax::detect).
Quirks of the reference that the parser keeps on purpose:
Timelineis not detected. The detector is case-sensitive, and the text is rejected.- The last
titleline wins, even when blank, andtitleandsectionneed one blank character after the keyword; the value starts at the next character.titlewith nothing after it is a period. - Only
timelinehas a word boundary.timelineXis a period and fails for lack of a header;timeline TDxis a header and a period. - An event needs a colon and a blank.
2004 : a:bis the eventa:b; a colon anywhere else isINVALID. An event attaches to the last period created, wherever it is; with no period it is an error (aTypeErrorin the reference). accTitle:takes the next line as its value, and the indentation of that line goes with the line break.accTitle:oraccDescr:as the last line, and an unclosedaccDescr {, are errors, because the value state ends at the end of the input.#starts a comment at the start of a token and ends a period; in titles, sections and events it is text.%%after exactly one character takes that character with it; after two or more the period keeps the%%.- The
accDescr { … }block has no comment rules:#and%%are text, and the block ends at the first}. - Blank lines and blanks between statements have no effect.
Layout and rendering
The horizontal layout (horizontal.rs) places the sections left to right and the tasks under them; the vertical layout (vertical.rs, TD) places the sections and their tasks down the page and the events to the right of the axis. A node is drawn by node.rs (drawNode, getVirtualNodeHeight, defaultBkg), its wrapped text is text.rs, and the rules are style.rs.
- Constants of the horizontal layout. Tasks and events are 150 wide with 20 padding, tasks are 200 apart, the top is at 50, an event is at least 50 high and the next one starts 10 lower. A section is
200 · max(n, 1) - 50wide. The axis is atmaxSectionH + maxTaskH + 150(maxTaskH + 100without sections) and the viewBox is the box of everything drawn plustimeline.padding(50), computed from the boxes of the drawn elements. - Constants of the vertical layout. Tasks are 200 wide, events 300, padding 5, a gap of 20 between a section and its tasks and 30 between tasks; the axis is
(leftMargin + 50) + 230from the left. - Two measurements per node. The layout first measures every text outside the
section-Ngroups (getVirtualNodeHeight, in the regular face) to get the largest height, anddrawNodemeasures again inside them, where the stylesheet makes the text bold. The pre-pass measures the text[object Object]for tasks (the task object, not its text), so the axis and the smallest node heights come from it. - Wrapping. The text is split at blanks and at lowercase
<br>tokens, one token is added to a line at a time, and a newtspanstarts when the line is wider than the node. A line's width is the advance of its collapsed text, snapped to the layout unit; everytspanis atx="0"and centered bytext-anchor: middle, and later lines are1.1embelow the one before. - Colors. Sections cycle through the theme's color slots (
THEME_COLOR_LIMIT, 12), andsection--1is the first. The redux theme's nodes have square corners and no line under the node. In theneolook the nodes carrydata-look="neo"and a drop shadow, and the horizontal layout defines a gradient when the theme'suseGradientis set. WithdisableMulticolor, only one color is used; without sections, the color advances per task. - Text size. Text is at the theme's
fontSize(14 px in the redux themes); node heights read the top-levelfontSize(16). The title is4ex, four times the x-height of the regular face at the CSS size,ferrigura_text::x_height. - Root.
width="100%"with amax-widthforuseMaxWidth.accTitleandaccDescrare not drawn: the root has its role andaria-roledescription="timeline"and no<title>or<desc>.
Defects of the reference that the renderer reproduces:
- A
TDtimeline has no arrows. Its marker isundefined-arrowhead, becauseinitGraphicsgets no id, and its arrows refer tourl(#arrowhead). Its nodes areundefined-node-Nfor the same reason, and no gradient is defined although the style sheet refers tourl(#id-gradient). - Every horizontal task has its dashed line, with or without events (
if (task.events)is always true). - Tasks before the first section are not drawn, in both layouts, and a repeated section name draws its tasks under each occurrence.
- Only a lowercase
<br>breaks a line.<br/>,<br />and<BR>are text. A leading or trailing<br>leaves an empty line, an emptytspanadds no height, and an overlong word is not split: it takes a line of its own, and the line before it is empty. - Wrapping measures the encoded text (
fl°quot¶ß) while the glyph boxes are those of the decoded text. - A title with markup is escaped twice: the sanitized
&becomes&amp;.
Differences from Mermaid
- Only the redux themes are drawn. The reference chooses between a classic and a redux branch by the theme's name; a theme that ferrigura does not ship is drawn with the redux theme its name falls back to (0011), so the redux branch is the one drawn.
- Colors are custom properties with the concrete color as an attribute. The axis
strokeis the node border color, notblack: the reference's style sheet overridesblackwith the same color. - Rules no element can match are not written (
.edge,.node-icon-N,.section-edge-N,.disabled*and the like), each for its reason instyle.rs. The.lineWrapper linerule is written once, where the reference writes it per color with the same value. - Text the bundled faces do not cover is measured with the bundled glyph, where Chromium falls back to another font (
♥from#9829;) (0010).
Known gaps
- A directive's
securityLevelorhtmlLabelschanges the sanitizing of header texts with markup in the reference; ferrigura sanitizes with the default configuration. - A fixture whose text has characters no bundled face covers cannot be compared in geometry until a fallback font is registered.
Checking it
pnpm -C tools/port port:diff timelineruns the reference's preprocessor, detector, Jison parser and DB (export-timeline.ts) and ferrigura'scanonicalexample over every input, and compares the canonical models. The header texts come from the common DB, which sanitizes, so the reference runs with a window.pnpm -C tools/port port:diff configcovers thetimelinesection.pnpm -C tools/port port:geometry timelinecompares every element within 0.05 px, on the fixtures and on the probetools/port/geometry/probes/timeline/p-named-entities.mmd.- The Rust specs:
crates/ferrigura-syntax/tests/timeline_spec.rsports the DB tests oftimeline.spec.jswith exact checks on the model, andtimeline_grammar.rspins each quirk above, with the reference's value.crates/ferrigura-render/tests/timeline_render.rspins the numbers Chromium gives for the fixtures and the reference's markup, defects included. The reference has no renderer spec for the timeline.
Learnings
getComputedTextLengthcollapses blanks.a banda bhave the same width, and leading and trailing blanks count as nothing. The wrapping therefore measures one collapsed run per line, not a sum of words, and the line breaks before the word that makes it wider than the node.- A text box is the union of its non-empty lines. One line is the font's text box; each further line adds
1.1em. An emptytspanadds no height and moves no line. dominant-baseline: middleputs the middle of the x-height on the baseline position, and the first line'sdyis thetspan's own1em: the text element'sdydoes not add to it. Both are measured in Chromium, not derived from the code.- The title's
4exneeds the font's x-height. The bundled faces declare 496 units per 1000 inOS/2(sxHeight), which is 27.776 px at the CSS size. - The bold face is chosen by weight 600.
.section-N textsets 600 and Chromium picks the bold face, so every drawn node is measured bold; only the pre-pass measures regular. - The viewBox comes from the drawn boxes, not from the layout constants: it is the union of path boxes, text boxes and line extents, without
<style>,<defs>or markers. - Redux or classic is decided by the theme's name, in four places, not by the look.