Skip to content
Ferrigura

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:

FieldHolds
title, accTitle, accDescrThe 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
derivedgroupAlignments 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 is false; the renderer treats "" and undefined alike. The service, group and junction texts are not sanitized by the DB and keep the entity placeholders.
  • derived.groupAlignments is 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 to horizontal or vertical. A later edge between the same groups overwrites the value and keeps the key's place.
  • derived.spatialMaps is 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's visited flag 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):

  1. 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.
  2. 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.
  3. Every service, then every junction, in text order, with the same id and parent checks; the parent must be a group.
  4. 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.
  5. 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, T or B is a side letter and then the rest, so Lx and Bob cannot be ids. A word that begins with title is the title line and then the rest, so title1 and an id title are rejected; accTitle and accDescr are 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. architecture is the header keyword only at the start of the text and an id when a parenthesis follows (service architecture(server)); architecturex and architecture-betax are not headers. The reference needs patches for this (0014): upstream's grammar knows only architecture-beta although its detector takes both (patch 02), and the first fix made architecture a 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 title line 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 proof formula, 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 of updateDisplacements, with a shuffle of the relative-placement node lists every 10 iterations.
  • The output is the center of each node's rectangle after relocateComponent moves 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: true runs 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, with randomize, the spectral start.
  • sin, cos, log and pow are ferrigura-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, draft quality, the fixed-node constraint, and the positions of hidden children. Only geometry.rs is 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 at translate(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,ey through 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 by padding + 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.5 for a Y edge and |dx| / 2 for an XY edge; Y labels are rotated by -90 degrees and XY labels by -x*y*45 degrees 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 the unknown icon, a question mark whose font list ArialMT, Arial is the reference's. A service with no icon draws the rounded square M0,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 uses dominant-baseline: auto after its turn, and the group titles the alphabetic baseline (start is not a dominant-baseline value).
  • Root. The size is the box of everything drawn, excluding title, desc and style and without stroke widths, padded by architecture.padding (40); useMaxWidth is true by default.
  • Style. .edge (stroke width archEdgeWidth, stroke archEdgeColor, no fill), .arrow, .node-bkg (dashed with 8, stroke archGroupBorderColor), 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 5 is 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 iconText is HTML in a foreignObject, 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

  • iconText is drawn as the reference draws it, a div.node-icon-text with a -webkit-line-clamp in a foreignObject over the blank icon, so it is not SVG text as 0009 requires. The harness does not measure foreignObject sizes.
  • Icon packs. Only the icons of the mermaid-architecture pack are bundled, with or without the prefix; any other name, such as logos:aws, draws the unknown icon.
  • The unknown icon's question mark names ArialMT, 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 architecture compares the canonical models, derived block included, of the fixtures, the string literals of Mermaid's specs and documentation, and the cases of tools/port/cases.ts (every quirk above, and the capitalized header, which both sides leave undetected). pnpm -C tools/port port:diff config compares the architecture section.
  • pnpm -C tools/port port:geometry architecture compares the geometry of the fixtures and of the probes under tools/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 (gen01 to gen12).
  • The fcose engine is checked on its own: crates/ferrigura-cose/tests/fcose_corpus.rs replays 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 with randomize: 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.ts generates 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 by port:diff config), architecture_grammar (the lexer and grammar quirks) and architecture_db_spec (order, header texts, the derived block) in ferrigura-syntax; architecture_render in ferrigura-render (the ids of svgDraw.spec.ts, the seed of architectureSeed.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 Math results.
  • 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.
  • nodeSeparation only matters with randomize: true; idealEdgeLengthMultiplier and edgeElasticity change 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.js 115-121) and nowhere else.
  • A one- or two-node random run has a NaN second coordinate of its spectral start: spectral.js adds the function idealEdgeLength to a number, which gives a string. The NaN rectangle coordinates are then set to 0.
  • The __proto__ id. relocateComponent walks Object.keys of 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 order Object.keys first, so no map that matters is iterated that way.
  • A group's gravity set is per graph: calculateNodesToApplyGravitationTo takes 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.random for each run and restores it, so fresh and shared pages give the same SVG once the icon ids are masked; @iconify/utils draws its ids from the clock and Math.random outside the seeded call, so icon ids differ from render to render in the reference. layout-base's page-wide RandomSeed is never reached by fcose.
  • The libm domain is small here. The cooling uses pow of small integers and two log constants, which agree between V8 and the platform's libm; ferrigura-jsmath is used anyway, since V8's Math.log differs 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.ts fails in jsdom because cytoscape's canvas renderer has no 2D context there; ferrigura's port of its checks does not need one.