Ferrigura / Diagrams
Gantt diagram
A Gantt diagram draws tasks as bars on a time axis, grouped in sections, with milestones, vertical markers, excluded days, a today marker and an optional title. It starts with the keyword gantt. ferrigura parses it with a hand-written parser that ports the Jison grammar gantt.jison, the DB ganttDb.js and the date rules they rely on (crates/ferrigura-syntax/src/gantt/), and draws it with a renderer of its own (crates/ferrigura-render/src/gantt/) on ports of d3-time, d3-scale, d3-time-format and d3-axis. There is no layout engine.
Model
GanttDiagram (crates/ferrigura-model/src/gantt.rs), version 1, is the DB's state after getTasks(), with the dates resolved.
title,accTitle,accDescr: sanitized, with the DB's whitespace rules. Atitlestatement replaces the front matter title even when it sanitizes to empty.titlekeeps its blanks.dateFormat,axisFormat,todayMarker: the text after the keyword, blanks included.tickIntervalis that text, ornullwhen the statement is absent.topAxis,inclusiveEndDates: the flags of the statements of the same name.weekday,weekend: lower-case day names,sundayandsaturdayby default.includes,excludes: lower case, without duplicates, in first-seen order.sections: the section names as written, duplicates kept.links: task id to the URL of itsclick … hrefstatement, aftersanitizeUrl, for the tasks that exist when the line is read.tasks, in the DB's order, each withid(given ortask1,task2, …),task(the text as written, untrimmed),section(""before the first section),order(the position among the non-vertical tasks,-1for a vertical one), the flagsvert,milestone,active,done,crit,classes(in order, duplicates kept;clickablecomes fromclick),manualEndTime, andstartTime,endTimeandrenderEndTime.
Dates are epoch milliseconds in UTC. A date the DB leaves unresolved or holds as an Invalid Date is null. renderEndTime is the end the bar is drawn to, and is null when excludes do not apply.
The model leaves out type (equal to section), raw, prevTaskId, processed, the task map and counters, click callbacks (decision 0006), displayMode (configuration), and the compact row the renderer computes. Task texts and section names are raw (canonical-model.md).
Parsing
Lexer. scanner.rs runs the rules of gantt.jison in their written order over a state stack: first match wins, as Jison's does, with case-insensitive keywords and the word boundary Jison adds to quoted literals (unquoted words such as weekday monday get none). The states are Initial, AccTitle, AccDescr, AccDescrMulti, Href, CallbackName, CallbackArgs and Click. A character no rule starts with is an Invalid token, which the grammar rejects.
Grammar. parser.rs reads the statements of gantt.jison: dateFormat, axisFormat, tickInterval, includes, excludes, todayMarker, weekday, weekend, topAxis, inclusiveEndDates, title, the accessibility lines, section, click and the task lines name : data. Each statement makes the DB call its action makes. A statement keyword's value is the text after the keyword and one more character, as the actions' substr takes it.
DB. db.rs holds the state of one parse. get_tasks compiles the dates as the reference does: one compileTasks pass, then up to ten more while a task is unprocessed, each pass recomputing every task from its raw data in order and reading the previous task's end as it stands. An error that getTasks throws in the reference (Invalid date:, no valid date after 10,000 iterations, a first task with no end to refer to) rejects the input, because the model holds the resolved times.
Dates. All dates are computed in UTC, with the clock as an option (Options::now, milliseconds since the epoch, the system clock by default). Every use of the clock in the reference comes from it: the parts that a format without a year, month or day takes from today, and the fallback of after and until without a known task. date.rs holds the rules:
strict_parseis dayjs 1.11.21's strictcustomParseFormatwithadvancedFormat: missing parts come from the clock, and the parse is valid only when formatting the result with the same format returns the input. The format tokensw,wo,ww,gggg,zandzzzcall methods of plugins the reference does not load, so they throw and reject the diagram.- Durations are
^(\d+(?:\.\d+)?)([Mdhmswy]|ms)$on the trimmed text. Days and weeks round the count and move the calendar day; months and years clamp the day to the month's length; the smaller units add milliseconds. legacy_parseis V8'snew Date(string)for the forms that fixtures and specs reach: digits alone as a year (below 50 is 2000 plus, 50 to 99 is 1900 plus),YYYY,YYYY-MM,Y-M-DandY/M/D, an ISO date with time and zone, andM-D-Y. Other forms are an Invalid Date.isInvalidDate,checkTaskDatesandfixTaskDatesare ported as written, with the same arithmetic.is_invalid_dateis public so that the renderer uses the same rule for excluded days.
Quirks kept on purpose.
- A line with a character followed by
%(A%B :a, 2014-01-01, 1d) is skipped as a comment. sectionandtitletake the next line when only a line break follows (\smatches it), andaccTitle:followed by a line break takes the next line as its value.call fwithout parentheses and an unclosedclick … href "run to the end of the text and fail there.- A task whose data is only tags (
:active,:done,milestone) is an error during the parse. ;or#in task data is an error; in names, titles and sections it is text.- A tag counts only at the start of the data, in any order, and only in lower case (
Doneis not a tag). taskDbmaps an id to the position of its last definition;__proto__is never stored, whileconstructorand the otherObject.prototypenames are.- The reference's DB module keeps
taskDbacross diagrams. ferrigura keeps no state between parses, so a diagram's result depends on its text and the clock only; the exporter loads a fresh DB module for every input.
Layout and rendering
The renderer ports ganttRenderer.js and styles.js. mod.rs sizes the diagram and orders the tasks, and draw.rs writes the elements in the reference's order, each with its attributes in the order the reference sets them. The modules time.rs (d3-time 3.1.0), scale.rs (d3-scale 4.0.2), format.rs (d3-time-format 4.1.0, en-US), axis.rs (d3-axis 3.0.0) and dayjs.rs run in UTC, where the local calendar of the reference's code is the UTC calendar. text.rs handles labels and style.rs the style sheet.
- Width. The width is
gantt.useWidthwhen set, else 1200 px. The reference takes its container'soffsetWidthand falls back to 1200 itself; the harness makes the container 1200 px wide. - Scale. The scale is a time scale over the earliest start and latest end of all tasks (vertical ones included, unresolved ones skipped), mapped with
rangeRoundto[0, width - leftPadding - rightPadding]. Every scale output is an integer, and the text of a bar sits at half-integers. - Order. The task array is sorted by start with a stable insertion sort and the reference's comparator, which cannot panic on a
NaNcomparison, and vertical tasks are drawn last. The categories of the section rows are the unique section names, in the key order of a JavaScript object (array indices first). - Rows. Each non-vertical task is a row of
barHeight + barGap. IndisplayMode: compact,getMaxIntersectionswrites the compact row of each task, and a section label'syaccumulates the counts of the sections before it. - Bars and labels. A milestone is a rotated square; a vertical task is a thin line down the diagram. A label sits inside its bar when it fits and outside it when it does not: to the right of the bar, or to its left when the label would pass the right edge of the diagram. The label's class and its
xuse different ends for a milestone:xthe shifted start and the render end, the class the end time. - Excluded days.
excludesandincludesgive ranges drawn as rectangles behind the bars. A run of excluded days that is still open at the last day of the span is not drawn, as in the reference. - Axes.
axisFormatformats the ticks with d3-time-format;tickInterval(1day,2week, …) selects the interval, withweekdayas the first day of a week;topAxisadds a second axis. A custom interval whose estimate exceeds 10,000 ticks is ignored and the default ticks are used. The axis offset is 0.5, the value at a device pixel ratio of 1. - Today marker. A line at
now, unlesstodayMarkerisoff. The text after the keyword is the line's CSS, with commas written as semicolons. - Links. A task with a
click … hrefis wrapped in<a xlink:href target="_top">at the end of its group. - Title. The title text is written at the top center, also when the diagram has none.
- Root. The viewBox is
0 0 width height. WithuseMaxWidththe root haswidth="100%"and amax-width.
Differences from Mermaid
- Links are anchors. The reference in strict mode binds a JavaScript click; ferrigura writes an
<a>and drops callbacks (0006). - UTC only. The reference reads dates in the browser's zone; ferrigura runs in UTC, and the
TZ=America/Los_Angelestest of the DB spec is ignored. Its comparison runs underTZ=UTCwith a fixed clock. - Colors are custom properties with attribute fallbacks, and only the two redux themes ship (0011). The tick text takes the text color, where the reference's attribute
#000is overridden by its own CSS. - Attributes the sanitizer drops. The reference's SVG passes through DOMPurify, which drops
text-heightand trims attribute values; ferrigura writes neither. - The axis offset is fixed at 0.5. A server render has no device pixel ratio.
- Text widths are measured in the bundled face, with the label laid out as an SVG
textcollapses blanks (0009, 0010).
Known gaps
- Entity names other than the five of XML stay as text (
&name;); a browser would decode the HTML names. Placeholders written by the parser (fl°°N¶ß,fl°name¶ß) are read back to the character they name. legacy_parseleaves out the V8 formsJan 1 2014,2014/1,1/6,1.5, signed years andY-Mwith a one-digit year; the differential run reports an input that reaches one..taskText.clickableis bold in the style sheet, but the embedded faces are regular, so Chromium synthesizes the bold.- A
weekdaythat is not a day name makes the reference's tick function undefined and throw; ferrigura returns a render error. - A task whose dates cannot be resolved is drawn with
NaNinx,widthandtransform-origin, as the reference does; no fixture has one.
Checking it
pnpm -C tools/port port:diff ganttruns the reference's parser and DB over every input withTZ=UTCand the clock fixed to 1010-10-10T00:00Z, a fresh DB module per input, and compares the canonical models with ferrigura'scanonicalexample under the same clock. A rejection on both sides agrees.pnpm -C tools/port port:diff configcovers theganttsection.pnpm -C tools/port port:geometry ganttrenders every fixture on both sides in Chromium, in UTC with the same clock, and compares every element within 0.05 px.- The Rust specs:
crates/ferrigura-syntax/tests/gantt_parser_spec.rs(parser/gantt.spec.js) andgantt_db_spec.rs(ganttDb.spec.ts, with the DB calls turned into diagram text and the log spies into outcomes of the model).crates/ferrigura-render/tests/gantt_render_spec.rspins the structure the geometry comparison relies on, and the modulestime.rs,scale.rs,format.rs,dayjs.rsandtext.rshave unit tests. The reference has no renderer spec for the Gantt diagram.
Learnings
- The hard part is the dates, not the drawing. They depend on dayjs's strict round trip, the clock, V8's
new Date(string)and the time zone. Of the fixtures, the epoch formats, the formats that take the day from the clock (HH:mm,ss,SSS,D) and the ISO date-only strings through the legacy path (UTC midnight) change under another zone. getTasksis not idempotent in the reference, and it sorts and mutates its array in place. The renderer works on a copy and the model stays immutable.- Trailing blanks do not count.
getBBoxof a label ignores trailing blanks (Completed taskfollowed by twelve blanks measures asCompleted task), so the label is laid out with the defaultwhite-spacebefore it is measured. A milestone's label is measured in the regular face, because the class that makes it italic is not set when the reference measures. rangeRoundmakes every position an integer, so a bar's text lands on a half-integer; a coordinate computed another way is off by the rounding.renderEndTime || endTimeis not a null check: it takes the render end whenever it is set, including a time of 0, since a Date is truthy.- A single
DindateFormatchanges the axis format to%d, and the axis format and tick text keep their blanks (' %d/%m'gives' 07/01'). - The harness page has no body margin, so the reference's container is the 1200 px viewport. Without that, every fixture without
useWidthwould differ in width. - d3-axis offsets by the device pixel ratio. The offset is 0.5 at a ratio of 1 and 0 above it; the reference's own end-to-end baselines are taken at a ratio of 2.