Skip to content

Data model

This is the centerpiece of this documentation: the shapes a tool builder actually writes code against. Everything here is exported from @liminis/diagrams/core (re-exported from @liminis/diagrams). Source of truth: src/core/types.ts.

Pipeline shape

source string --[parseC4]--> C4Diagram --[layoutC4Diagram]--> LayoutResult

parseC4 never lays anything out — a C4Diagram has no coordinates. layoutC4Diagram never re-parses — it only positions what it’s given. The two steps are independent; you can call layoutC4Diagram repeatedly (e.g., on every drag) against the same C4Diagram without re-parsing.

C4Diagram

interface C4Diagram {
elements: C4Element[]; // flat list — every element, nesting via .parent
relationships: C4Relationship[];
direction?: C4Direction; // 'down' | 'right' | 'up' | 'left', from LAYOUT_* — see dsl-reference.md
}

elements is flat, not a tree — every element parsed anywhere in the source, top-level or nested, is in this one array. Nesting is represented separately (next section). This means “all elements” is just diagram.elements; you don’t need to walk a tree to enumerate them.

C4Element

interface C4Element {
type: C4ElementType; // 'system' | 'container' | 'component' | 'person'
id: string; // from the macro's alias argument — unique within THIS diagram only
name: string; // from the macro's "label" argument
properties: C4Properties;
children: C4Element[]; // derived convenience view, see below
parent?: string; // the PRIMITIVE: this element's parent id, if nested
}
interface C4Properties {
tech?: string;
description?: string;
external?: boolean; // true for _Ext macros
shape?: C4Shape; // 'rectangle' | 'cylinder' | 'queue'
direction?: C4Direction; // layout direction for this element's own children
style?: C4Style; // 'boundary' — set on boundary/deployment-node macros
}

How nesting is represented: parent is the primitive, children is derived

parent is a string reference to another element’s id (or undefined for a top-level element) — it’s set once, while parsing, and it’s the actual source of truth for “what contains what.” children is populated by the parser as a convenience: after parsing everything inside a boundary’s { ... }, the parser scans the elements it just parsed and copies every one whose .parent equals this element’s id into this.children. The two are redundant views of the same relationship — children exists so consumers don’t have to reconstruct the tree themselves by scanning the flat elements array and matching on parent, but if you’re ever unsure which one to trust, parent is the one the parser actually assigns first.

Because elements is flat, a nested element like a Container inside a System_Boundary appears twice in diagram.elements in one sense — once as itself with parent set, and again inside its parent’s .children array (same object reference, not a copy). It does not appear twice in the flat array itself; elements has exactly one entry per parsed macro. LayoutResult.nodes (below), by contrast, is flattened by the layout step and does list every node exactly once as a top-level array entry, in addition to nesting them under .children.

C4Relationship

interface C4Relationship {
sourceId: string; // C4Element.id this relationship starts from
targetId: string; // C4Element.id this relationship points to
label: string; // "label" or "label [technology]" if a technology arg was given
}

Flat, unordered, no nesting concept — a Rel always connects two element IDs regardless of how deeply either is nested.

LayoutResult

interface LayoutResult {
nodes: LayoutNode[]; // every node, flattened — see note below
edges: LayoutEdge[];
width: number; // total diagram width in SVG user units
height: number;
viewBoxX: number; // SVG viewBox origin X — see "viewBoxX/viewBoxY" below
viewBoxY: number;
}

nodes is the flattened output of layout: every node — top-level and nested — is a direct entry in this array (unlike C4Diagram.elements, which only has nesting via .parent/.children, LayoutResult.nodes lists every node once at the top level and again under its parent’s .children, both pointing at the same object). If you just want to draw every node, iterate layout.nodes directly — you don’t need to recurse into .children to find anything you don’t already have.

LayoutNode

interface LayoutNode {
id: string; // same as C4Element.id
x: number; // left edge, in SVG user units
y: number; // top edge
width: number;
height: number;
element: C4Element; // back-reference to the source element
children?: LayoutNode[]; // nested layout nodes, positioned in the SAME coordinate space as this node (not relative to it)
}

x/y are absolute — a child’s x/y are already offset to be correct in the same coordinate space as its parent and everything else in the diagram; you do not need to add the parent’s x/y to a child’s to get its true position.

LayoutEdge

interface LayoutEdge {
source: string; // LayoutNode.id
target: string;
points: Point[]; // [sourcePoint, targetPoint] — where the edge crosses each node's boundary
label: string;
}

Point

interface Point {
x: number;
y: number;
}

viewBoxX / viewBoxY — the auto-layout vs. manual-layout asymmetry

These exist for one reason: dragging a node past the diagram’s current top or left edge produces negative coordinates. Rather than shifting every node’s x/y to keep everything non-negative (which would desynchronize the coordinates you render from the coordinates you’d persist as “the manual position”), the layout step instead moves the SVG viewBox origin to cover the negative space, leaving every node’s stored x/y untouched.

It happens on both paths. The obvious case is the manual-position path, where a node dragged past the top or left edge lands at a negative coordinate. The less obvious case is the auto-layout path: dagre’s own output is non-negative, but the cross-boundary alignment pass that runs after it shifts elements outside a boundary to line up with their targets inside it, and that can move them left of the origin. An element outside a System_Boundary with a Rel to something inside is enough.

Always read viewBoxX/viewBoxY from the LayoutResult and use them in your <svg viewBox="...">, rather than assuming 0 0.

Verified, both paths, same diagram:

const { diagram } = parseC4(`Person(user, "User")\nSystem(app, "App")\nRel(user, app, "Uses")`);
layoutC4Diagram(diagram);
// viewBoxX: 0, viewBoxY: 0 (auto layout — zero *here*, because nothing in this
// diagram lands near the origin; not a guarantee)
const { diagram: crossing } = parseC4(
`Person(u, "You")\nSystem_Boundary(b, "Boundary") {\n Container(c, "Inner")\n}\nRel(u, c, "Uses")`,
);
layoutC4Diagram(crossing);
// viewBoxX: -50, viewBoxY: 0 (auto layout, element outside a boundary pushed left)
layoutC4Diagram(diagram, undefined, { user: { x: -100, y: -50 }, app: { x: 200, y: 200 } });
// viewBoxX: -140, viewBoxY: -90 (manual layout, negative position dragged in)
// nodes: [{ id: 'user', x: -100, y: -50 }, { id: 'app', x: 200, y: 200 }]
// ^ note: user.x/y are exactly what was passed in, untouched — only viewBoxX/Y compensate

Worked example: parse → layout, real output

import { parseC4, layoutC4Diagram } from '@liminis/diagrams/core';
const { diagram, errors } = parseC4(`
Person(user, "User", "End user")
System_Boundary(app, "My App") {
Container(fe, "Frontend", "React")
ContainerDb(db, "Database", "PostgreSQL", "Stores data")
}
Rel(user, fe, "Uses", "HTTPS")
Rel(fe, db, "Reads/writes", "SQL")
`);

errors is [] and diagram is non-null — but its type is C4Diagram | null (null only on unrecoverable parse failure), so code passing it on to layoutC4Diagram needs to narrow it first. diagram.elements has 4 entries (user, app, fe, db); app’s children array contains the same fe/db objects that also appear as top-level entries in diagram.elements. diagram.relationships is:

[
{ "sourceId": "user", "targetId": "fe", "label": "Uses [HTTPS]" },
{ "sourceId": "fe", "targetId": "db", "label": "Reads/writes [SQL]" }
]
if (!diagram) {
throw new Error(`parse failed: ${JSON.stringify(errors)}`);
}
const layout = layoutC4Diagram(diagram);

produces (trimmed to the shape-relevant fields):

{
"nodes": [
{ "id": "user", "x": -10, "y": 123, "width": 120, "height": 120, "children": [] },
{ "id": "app", "x": 190, "y": 40, "width": 320, "height": 461,
"children": [
{ "id": "fe", "x": 230, "y": 135, "width": 240, "height": 96 },
{ "id": "db", "x": 230, "y": 331, "width": 240, "height": 130 }
]
},
{ "id": "fe", "x": 230, "y": 135, "width": 240, "height": 96 },
{ "id": "db", "x": 230, "y": 331, "width": 240, "height": 130 }
],
"edges": [
{ "source": "user", "target": "fe", "label": "Uses [HTTPS]",
"points": [{ "x": 110, "y": 183 }, { "x": 230, "y": 183 }] },
{ "source": "fe", "target": "db", "label": "Reads/writes [SQL]",
"points": [{ "x": 350, "y": 231 }, { "x": 350, "y": 331 }] }
],
"width": 550,
"height": 541,
"viewBoxX": 0,
"viewBoxY": 0
}

Note fe and db each appear twice in nodes — once as a top-level array entry, once inside app.children — both referring to the same position (x: 230, y: 135 for fe in both places), confirming child coordinates are already absolute, not relative to app.