Ferrigura / Diagrams
Architecture diagram
An architecture diagram draws a cloud or system architecture: services and junctions with icons, optionally nested in groups, joined by edges that leave and enter a service on a chosen side. The keywords are architecture and architecture-beta. ferrigura reads it with a hand-written scanner and parser of the Langium grammar (ferrigura-syntax, src/architecture) and draws it with a renderer of its own (ferrigura-render, src/architecture). The layout is cytoscape's fcose, force-directed with constraints, which ferrigura-cose ports exactly (src/fcose), run twice per render as Mermaid runs it.
Model
ferrigura_model::architecture::ArchitectureDiagram is the DB state after the parse (architectureDb.ts) with the derived block that the layout reads, version 1:
Scroll sideways for more columns.
| Field | Holds |
|---|---|
title, accTitle, accDescr | The header texts, sanitized as the common setters do (accTitle without leading blanks); "" when unset |
groups | { id, icon, title, in } in text order |
services | { id, icon, iconText, title, in } in text order |
junctions | { id, in } in text order |
edges | { lhsId, lhsDir, lhsInto, lhsGroup, rhsId, rhsDir, rhsInto, rhsGroup, title } in text order |
alignments | { direction, members } for each align row or align column, in text order |
derived | groupAlignments and spatialMaps, computed as the reference's getDataStructures computes them |
The model keeps the DB's quirks:
- The enclosing id is
in, the DB's own name for it: the id of the group a group, service or junction sits in,""at the top. - The lists are in the DB's order. Groups are in text order. Services and junctions are two lists, each in text order, because the DB adds every service before any junction.
- Absent values are empty. An absent icon, icon text, title or parent is
""and an absent arrow head or{group}mark isfalse; the renderer treats""andundefinedalike. The service, group and junction texts are not sanitized by the DB and keep the entity placeholders. derived.groupAlignmentsis a list of[key, axis]pairs. For each node and each of its edges (a self-loop is listed twice), when the two ends are in different groups and the sides are not a bend (one horizontal and one vertical side), the key"A"-"B"(the two ids sorted and JSON-quoted) is set tohorizontalorvertical. A later edge between the same groups overwrites the value and keeps the key's place.derived.spatialMapsis a list of maps, each a list of{ id, x, y }in the order a breadth-first search placed them, one map per connected component, starting at(0, 0). The search's quirks are part of the value: a node'svisitedflag is set when it is taken from the queue, not when it is queued, so a node can be queued twice and placed again, and the last placement wins. A side pair of the same side twice (LL,RR,TT,BB) is no pair, and a pair that two edges give the same node is placed once per edge.
Left out are the icon's artwork (names stay names), the nodes' own edge lists (a cycle), the adjacency map (reproduced through the spatial maps), the element map and the diagram id (renderer objects), and the configuration. The conventions of the whole family are in canonical-model.md.
Parsing
architecture::parse runs the steps of Diagram.fromText: it prepares the text (front matter and its title), removes directives and comment lines, encodes entities and adds a line break. The grammar gives the statements with their offsets, and populate makes the DB calls in the order of architectureParser.ts; the first failing call is the error, with the reference's message and the position of the statement (0007):
- The header texts, when not empty: the description, the accessible title, the title. The front matter title is set before the parse and the grammar's replaces it when not empty.
- Every group, in text order. The id must be free; a parent must be a group that is registered already, and not the group itself.
- Every service, then every junction, in text order, with the same id and parent checks; the parent must be a group.
- Every edge, in text order. Both ends must be registered, and an end that is a group is rejected. A
{group}mark must cross two groups: when both ends have a parent and the parents are equal, the mark is an error. - Every alignment, in text order: at least two members, each a registered service or junction, none twice.
Groups are added before services and junctions whatever the text order, so a service may name a group declared later, and a group may name only an earlier group. A group that names a service is rejected with parent does not exist.
The scanner runs the terminals of the Chevrotain lexer in the order Langium builds them, and the first that matches wins, not the longest: the keywords (architecture-beta, architecture, junction, service, column, group, align, row, --, in, :, -), then ARROW_DIRECTION (L|R|T|B), ARROW_GROUP ({group}), ARROW_INTO (<|>), the accessibility and title terminals, STRING, ID, line breaks, the hidden white space, YAML, directive and comment terminals, and the icon and title terminals. A keyword has a longer alternative: the ID terminal for a word, the YAML block for -- and -. Each terminal's expression is a function (id_len, string_len, icon_len, arch_title_len, and the shared header matchers).
The quirks that ferrigura keeps because the reference has them, each pinned by a test in architecture_grammar.rs or architecture_db_spec.rs:
- A word that begins with
L,R,TorBis a side letter and then the rest, soLxandBobcannot be ids. A word that begins withtitleis thetitleline and then the rest, sotitle1and an idtitleare rejected;accTitleandaccDescrare ids unless a colon or a brace follows. - Keywords as ids. The exact keywords are rejected as ids (
group,in,row,align,junction,column,service); a longer word that begins with one is an id (rowspan,inner). Keywords are case-sensitive. An id cannot end with a dash and may begin with a digit. - The header. Both keywords are accepted, and the header may share its line with a statement.
architectureis the header keyword only at the start of the text and an id when a parenthesis follows (service architecture(server));architecturexandarchitecture-betaxare not headers. The reference needs patches for this (0014): upstream's grammar knows onlyarchitecture-betaalthough its detector takes both (patch 02), and the first fix madearchitecturea keyword everywhere (patch 09). - Icons and titles. An icon is written in parentheses with no quotes and no spaces, and its name holds colons (
logos:aws); a service's icon text is a quoted string without parentheses and may span lines. A title is in brackets, needs a character, and may hold brackets inside quotes, commas and parentheses. - Edges.
end:SIDE [<] (-- | -[label]-) [>] SIDE:end; the label is never empty,---is not an edge, the arrow heads may sit on either side, and the same side on both ends is accepted. Two statements on one line are rejected. - A
---line in the body is hidden as YAML when a closing---follows; without it the dashes are two keywords and the text is rejected. - An edge to a group is rejected (the reference throws a
TypeError); an edge or an alignment may name a node declared later, an alignment never a group. - A blank last
titleline lets the front matter title through.
Layout and rendering
The renderer ports architectureRenderer.ts (draw), svgDraw.ts, architectureStyles.ts and architectureIcons.ts. The cytoscape state is scene.rs (the nodes, edges, label boxes, compound bounds, edge endpoints and bends, element bounds), layout.rs runs fcose twice, constraints.rs builds the alignments and relative placements from the DB's spatial maps, draw.rs writes the SVG and collects the boxes that set the viewBox, geometry.rs holds cytoscape's box arithmetic, and icons.rs and style.rs the artwork and the style rules. The groups of the root are architecture-edges, architecture-services and architecture-groups, in that order; the diagram title is never drawn (upstream has a TODO), while accTitle and accDescr are.
The layout input. Groups, then services, then junctions, then edges are added; a service and a junction are iconSize (80) wide and high, a group with children is as large as its children with the padding of .node-group (40), a childless group is a 30 by 30 leaf. Every simple node starts at (0, 0). An edge inside one group has the ideal length iconSize * idealEdgeLengthMultiplier (120) and the elasticity edgeElasticity (0.45); any other edge 40 and 0.001. Labels are measured as Math.ceil of the canvas width of the text at the configuration's fontSize (16 px), in the bundled sans face, with a margin of 2 px on each side; the compound bounds include them.
Constraints. getAlignments groups the nodes of each spatial map by row and column, and within a row or column by their group; it merges the lists of groups that are aligned or default, drops lists of one member and lists that hold a member of a user align hint, and appends the hints as alignments. getRelativeConstraints emits one relative constraint per adjacent pair of the breadth-first grid, visiting L, R, T, B, with the gap idealEdgeLengthMultiplier * iconSize; duplicates are kept. The alignment object is always given, even with empty lists.
The two runs. layoutArchitecture calls layout.run() twice. The first run lays the scene out; its layoutstop handler computes the weight and distance of the segment of each XY edge (both ends differ in both coordinates) from those positions, and then the second run starts from the first's positions, with the bounds that now include the bends. Each run is one call of fcose::layout with the seed of withSeededRandom, which restarts for each run. The weights are the renderer's and stay outside the engine.
fcose (ferrigura-cose, src/fcose). cytoscape-fcose 2.2.0 on cose-base 2.2.0 and layout-base 2.0.1, with the options of architectureRenderer.ts: quality: proof, randomize (default false), nodeSeparation (75, read only by the spectral start), numIter (2500), and the ideal length and elasticity functions above; the other options are fcose's defaults. The path Mermaid reaches is:
- fcose turns constraints on whenever it gets an alignment object, which Mermaid always gives, so tiling, tree reduction and packing are off and the constraint handler runs in every render, even with empty lists. The whole graph is laid out as one component.
- The engine sets the global edge length to the mean ideal length over the edges, so the repulsion range, the displacement threshold and the minimum repulsion distance depend on the whole diagram.
- The spring embedder runs incrementally: cooling from 0.3 to 0.04 by the
proofformula,maxIterations = max(5n, numIter), a convergence check every 100 iterations, the repulsion grid and the surrounding sets rebuilt every 10, spring forces with per-edge elasticity, gravity (0.25, 1.0 in compounds), and the constraint corrections ofupdateDisplacements, with a shuffle of the relative-placement node lists every 10 iterations. - The output is the center of each node's rectangle after
relocateComponentmoves the layout to the center of the elements' bounding box before the run; the renderer writes positions for the nodes without children, and cytoscape places a compound from its children. randomize: trueruns the spectral start (a pivot-based embedding with a power iteration,spectral.rs) before the layout, and the orthogonal Procrustes fit in the constraint handler (linalg.rs). The generator is mulberry32, seeded per run; its draws are the shuffle and, withrandomize, the spectral start.sin,cos,logandpowareferrigura-jsmath's, which give V8's bits, and every expression keeps the operand order of the source, so a run agrees with the reference exactly, not within a tolerance (0012).- Not ported, because Mermaid cannot reach them: tiling, tree reduction and pruned-node growth, packing of components,
draftquality, the fixed-node constraint, and the positions of hidden children. Onlygeometry.rsis shared with cose-bilkent, which the mindmap uses; the two engines are on different versions of the force code.
Drawing.
- Services are drawn at their cytoscape center, which is the top-left corner of the drawn icon: the icon is 80 by 80 and the layout node is 80 by 80 centered there, so the visible box lies to the lower right of the layout node, and the endpoints and the group rectangles compensate. A service title is wrapped at
iconSize * 1.5(120 px) and placed attranslate(40, 80). Junctions are transparent 80 by 80 boxes that exist so that edges have a box to attach to. - Edges. An edge runs from the source to the target endpoint, as
M sx,sy L mx,my L ex,eythrough the midpoint or the bend. Arrow heads are polygons of a sixth of the icon. An edge that leaves a group moves its start out bypadding + 4(44 px), plus 18 for a bottom side, the height of a service label; an edge that ends at a junction without{group}moves in by half an icon. The label is drawn at the middle with its width|dx|for an X edge,|dy| / 1.5for a Y edge and|dx| / 2for an XY edge; Y labels are rotated by -90 degrees and XY labels by-x*y*45degrees and moved by the box of the rotated text. - Groups are rectangles from the bounds of their children; a group's title does not count in the bounds of the group or of its parent when the group has children (the box is its body), but a childless group's title does, placed on its center line. A service's title counts.
- Icons. The seven built-in icons (
database,server,disk,internet,cloud,unknown,blank) are bundled, each on a blue 80 by 80 square; groups draw theirs at 30 by 30. A name that is not in the pack, or has another prefix, draws theunknownicon, a question mark whose font listArialMT, Arialis the reference's. A service with no icon draws the rounded squareM0,80 V5 Q0,0 5,0 H75 Q80,0 80,5 V80 Z. - Text sizes. The SVG text takes the theme's
fontSize(14 px in the redux themes) while cytoscape measures the node labels at the configuration's 16 px and the edge labels at 16 px; the two are kept apart. The XY edge label usesdominant-baseline: autoafter its turn, and the group titles the alphabetic baseline (startis not adominant-baselinevalue). - Root. The size is the box of everything drawn, excluding
title,descandstyleand without stroke widths, padded byarchitecture.padding(40);useMaxWidthis true by default. - Style.
.edge(stroke widtharchEdgeWidth, strokearchEdgeColor, no fill),.arrow,.node-bkg(dashed with8, strokearchGroupBorderColor), and the rules of the icon text, under the diagram's id (0011).
Differences from Mermaid
- Labels are SVG text and the fonts are embedded subsets (0009, 0010). The layout measures labels in the bundled sans face where the reference's cytoscape measures
Helvetica Neue, Helvetica, sans-serif; the geometry harness maps both names onto the bundled face, so both sides measure the same text. A label's width differs by a fraction in the system font (Link 5is 42.99 px there and 43.15 px in the bundled face), which can move a group by a pixel. - A seed of 0 is an error. In the reference it opts out of the seeded generator and draws from
Math.random, so the layout is not reproducible; ferrigura has no such draw (0012). The default seed is 1. - The text of a service with an
iconTextis HTML in aforeignObject, as in the reference (see Known gaps). - The error messages of the lexer and parser are ferrigura's own, with the position of the token the parser stops at; the DB's messages are the reference's (0007).
Known gaps
iconTextis drawn as the reference draws it, adiv.node-icon-textwith a-webkit-line-clampin aforeignObjectover the blank icon, so it is not SVG text as 0009 requires. The harness does not measureforeignObjectsizes.- Icon packs. Only the icons of the
mermaid-architecturepack are bundled, with or without the prefix; any other name, such aslogos:aws, draws theunknownicon. - The
unknownicon's question mark namesArialMT, Arial, which is the machine's Arial in the harness and the viewer's elsewhere; the glyph lies inside the 80 unit square in Arial's metrics, which bounds it.
Checking it
pnpm -C tools/port port:diff architecturecompares the canonical models, derived block included, of the fixtures, the string literals of Mermaid's specs and documentation, and the cases oftools/port/cases.ts(every quirk above, and the capitalized header, which both sides leave undetected).pnpm -C tools/port port:diff configcompares thearchitecturesection.pnpm -C tools/port port:geometry architecturecompares the geometry of the fixtures and of the probes undertools/port/geometry/probes/architecture/: hand-written ones (nested groups, junction chains, every side pair, XY labels, long titles that wrap, align hints,randomize: true, a seed override, icon mixes, arrows and groups, accessibility and markup, a larger diagram, icon text, symbol arrows) and generated ones (gen01togen12).- The fcose engine is checked on its own:
crates/ferrigura-cose/tests/fcose_corpus.rsreplays each run of a corpus of renders captured from Mermaid 12.2.0 in Chromium (tests/fcose-corpus: the Mermaid fixtures, the specs' inputs and seeded generated diagrams, a part of them withrandomize: true) and requires every leaf center to equal the reference's exactly. Each case holds both runs with the node and edge lists in cytoscape's order, the options, the seed, the constraints and the bounds.tools/port/crates/cose/generate-architectures.tsgenerates the diagrams from a fixed seed; the reference build has no hook that records the fcose runs (the pinned patches add one for ELK only), so a new capture needs an instrumented build, and the corpus is kept as it is. - Rust specs:
architecture_spec(the port of Mermaid's parser and DB specs; the configuration tests and the stylesheet warnings are ignored with their reasons, since the configuration is compared byport:diff config),architecture_grammar(the lexer and grammar quirks) andarchitecture_db_spec(order, header texts, the derived block) inferrigura-syntax;architecture_renderinferrigura-render(the ids ofsvgDraw.spec.ts, the seed ofarchitectureSeed.spec.ts, the fallback icon, the wrapping of a title).
Learnings
- Discrete decisions amplify the last bit. The grid cell of a node, the repulsion's surrounding test, the convergence test, the sign tests of the segment weights and the constraint handler's choices all depend on values near thresholds, so one wrong bit moves nodes by tens of pixels. Keep the operand order of the source, the insertion order of every map and set, and the same
Mathresults. - The order of the two runs matters. Computing the segment weights after the second run instead of between the runs changes three fixtures by up to 137 px; dropping the second run moves most fixtures by a translation but reshapes the deeply nested one.
- A second run is not a repeat. It starts from the first run's positions, and its bounds are taken after the segment weights. fcose's rewrite of the positions of childless parents (
parentsWithoutChildren) never has an entry, because Mermaid hides no element; a childless group is a leaf for cose and is written with the other leaves. nodeSeparationonly matters withrandomize: true;idealEdgeLengthMultiplierandedgeElasticitychange most diagrams; the seed changes only the diagrams whose constraint shuffle draws, which is a minority of fixtures. Some diagrams draw nothing at all.- The global edge length is a mean over the diagram, so the repulsion range and displacement threshold of every node depend on the whole input; a port computes it per run.
IMath.sign(0)is 0, not 1; the displacement cap and the separation amounts rely on it. NaN is zeroed in exactly the places the source zeroes it (cose.js115-121) and nowhere else.- A one- or two-node random run has a NaN second coordinate of its spectral start:
spectral.jsadds the functionidealEdgeLengthto a number, which gives a string. The NaN rectangle coordinates are then set to 0. - The
__proto__id.relocateComponentwalksObject.keysof a plain object keyed by id, and assigning the key__proto__sets the prototype, so that node is left out of the bounding box and is not moved. Mermaid's spec inputs use that id, and the port reproduces the walk. Integer-like ids orderObject.keysfirst, so no map that matters is iterated that way. - A group's gravity set is per graph:
calculateNodesToApplyGravitationTotakes the nodes of every graph that is not connected, child graphs of compounds included, so a group whose children are not joined pulls them toward its center. - The draws do not depend on the page. The seeded generator replaces
Math.randomfor each run and restores it, so fresh and shared pages give the same SVG once the icon ids are masked;@iconify/utilsdraws its ids from the clock andMath.randomoutside the seeded call, so icon ids differ from render to render in the reference.layout-base's page-wideRandomSeedis never reached by fcose. - The libm domain is small here. The cooling uses
powof small integers and twologconstants, which agree between V8 and the platform's libm;ferrigura-jsmathis used anyway, since V8'sMath.logdiffers from the platform's in about 2 percent of random inputs. - cytoscape's compound size includes labels, and a compound's box includes its margin of 2 px for each label; the component center for relocation is
eles.boundingBox()with labels and edges, which the renderer computes, so label metrics belong to the renderer's bounds and the engine measures none. - The reference's own
svgDraw.spec.tsfails in jsdom because cytoscape's canvas renderer has no 2D context there; ferrigura's port of its checks does not need one.