Skill Tracer
Skills › Archify › archify › README.md

README.md

246 lines · archify/renderers/erd/README.md at a406594

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Entity-Relationship Renderer
2
3Render `diagram_type: "erd"` JSON files into the standard Archify HTML template.
4
5```bash
6node archify/renderers/erd/render-erd.mjs input.erd.json output.html
7```
8
9The renderer validates input against `archify/schemas/erd.schema.json` with the
10bundled standalone validator. No dependency installation is required.
12If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
13or falls back to `erd.html` in the current working directory.
15After rendering, run the artifact checker:
17```bash
18node archify/scripts/check-render-output.mjs output.html
19```

Entity-Relationship Renderer

Render diagram_type: "erd" JSON files into the standard Archify HTML template.

node archify/renderers/erd/render-erd.mjs input.erd.json output.html

The renderer validates input against archify/schemas/erd.schema.json with the bundled standalone validator. No dependency installation is required.

If output.html is omitted, the renderer uses meta.output from the JSON file or falls back to erd.html in the current working directory.

After rendering, run the artifact checker:

node archify/scripts/check-render-output.mjs output.html
Raw lines 1 to 20
Runs code scripts/check-render-output.mjs :18
21## Input
23ERD JSON files must set:
25```json
27 "schema_version": 1,
28 "diagram_type": "erd",
29 "meta": { "title": "Order management schema" },
30 "entities": [],
31 "relationships": [],
32 "cards": []
34```
36An entity carries the table name in `label`, its attributes in `attributes`, and a
37`row`/`col` cell in the banded grid. The box height follows the declared attribute
38count, so the layout never needs hand-measured sizes. An attribute's `key` accepts
39`pk`, `fk`, or `uk`, or a list of them: a junction table's column is often part of
40the primary key and a foreign key to the same parent, and stating one role would
41hide the other. Each role draws its own badge, tinted in its legend's family, a
42role named twice draws once, and the key column is measured from the widest badge
43run in the table — never narrower than the shared width, wider when the badges
44need it. Every `fk`
45role — alone or inside a list — must also name its target as `"entity.attribute"`
46in `references`, and that target is checked against the declared entities and
47attributes.
49A relationship reads `from` -> `to` and is drawn with crow's foot notation at
50both ends. The foot opens toward the entity whose cardinality it describes: its
51toes stand on that table's edge and its apex sits one foot back along the line,
52so the glyph cannot be read as an arrowhead pointing at the table. Both ends
53declare their own maximum: `fromCardinality`/`toCardinality` take `one` or
54`many` and are required, because a maximum the author never stated is a fact the
55diagram would be inventing; `fromOptional`/`toOptional` add the optional circle
56past the apex and lower that end's minimum from one to zero.
57`identifying: false` draws the non-identifying dashed line, and it owns the dash:
58a `variant` that would draw the relationship solid alongside it is rejected by
59the schema, because the solid line would state the opposite of the fact. The
60variant still selects the accent for every identifying relationship.

Input

ERD JSON files must set:

{
  "schema_version": 1,
  "diagram_type": "erd",
  "meta": { "title": "Order management schema" },
  "entities": [],
  "relationships": [],
  "cards": []
}

An entity carries the table name in label, its attributes in attributes, and a row/col cell in the banded grid. The box height follows the declared attribute count, so the layout never needs hand-measured sizes. An attribute's key accepts pk, fk, or uk, or a list of them: a junction table's column is often part of the primary key and a foreign key to the same parent, and stating one role would hide the other. Each role draws its own badge, tinted in its legend's family, a role named twice draws once, and the key column is measured from the widest badge run in the table — never narrower than the shared width, wider when the badges need it. Every fk role — alone or inside a list — must also name its target as "entity.attribute" in references, and that target is checked against the declared entities and attributes.

A relationship reads from -> to and is drawn with crow's foot notation at both ends. The foot opens toward the entity whose cardinality it describes: its toes stand on that table's edge and its apex sits one foot back along the line, so the glyph cannot be read as an arrowhead pointing at the table. Both ends declare their own maximum: fromCardinality/toCardinality take one or many and are required, because a maximum the author never stated is a fact the diagram would be inventing; fromOptional/toOptional add the optional circle past the apex and lower that end's minimum from one to zero. identifying: false draws the non-identifying dashed line, and it owns the dash: a variant that would draw the relationship solid alongside it is rejected by the schema, because the solid line would state the opposite of the fact. The variant still selects the accent for every identifying relationship.

Raw lines 21 to 61
62## Layout and routing
64`layout.mode: "grid"` places each entity by `row`/`col`. Every raw attribute index
65owns the width of its widest entity and every raw row index owns the height of
66its tallest, so boxes in one band line up without hand measurement and an empty
67index leaves a routing channel. `pos` overrides the cell for a bounded
68exception.
70Relationships route through the orthogonal router the other typed renderers use
71(`../architecture/routing.mjs`), so the same contracts apply: automatic port
72spreading on a shared entity, the endpoint-side contract, the route-rhythm
73floors, and the universal Clean Flow gate. The ERD is a caller, not a fork: it
74passes `sideFor`, `maxPortSpacing`, `preferredCandidates`, and
75`compositionFloors: false`, and reads the planning-free `inferredSides` and the
76`ports` map. Each option is inert when omitted, which is how architecture and
77lifecycle keep their routing byte for byte. Five differences are specific to this
78renderer:
80- Endpoints resolve horizontal-first, like every other non-architecture type: a
81 relationship leaves and enters on the left/right whenever the tables sit side
82 by side, and only falls back to a top/bottom port when they share a column. The
83 shared router's default is architecture's dominant-axis inference, which would
84 split one fan-in into two groups whenever a target happened to sit further away
85 vertically than horizontally.
86- Automatic ports on one side spread up to 24 units apart, because a cardinality
87 glyph is 14 units tall: at the shared 14-unit spread two crow's feet on one table
88 edge read as a single smudge. That spacing is a cap, not a floor — a side only
89 fits `(extent - 32) / (ends - 1)` — so a table with more relationship ends than
90 its side has room for fails with `layout/marker-capacity`, which names the
91 table, the side, the spacing, and the three ways out.
92- Relationships that would share one corridor get a deterministic lane offset,
93 so parallel foreign keys never sit on the identical channel line.
94- Relationships that share one entity side (a fan-in of foreign keys, or a
95 fan-out parent) and one marker style bundle onto a single trunk just outside
96 that side: one bus line with a short branch per relationship, instead of a
97 sheaf of parallel lanes. The trunk bridges one port step of uncovered bus, so
98 the port spread above still reads as one line. The trunk is only a preferred
99 route — a branch that would break the endpoint contract or clip an entity falls
100 back to the ordinary families and stays unbundled, and anything authored
101 (`route`, `via`, `labelAt`) keeps its own line. Logical routes still traverse
102 the trunk, so every gate, label, and the layout report see the full geometry.
103 自动补出的主干桥接还会检查无关关系及已接受的其他桥接;出现交叉、接触或
104 重叠时保留分离的主干段,避免把独立关系画成同一条总线。检查按双方描边的
105 半宽之和留出间隔,并计入无关关系的显式 `width`;中心线未相交但描边接触
106 也会阻止桥接。这个回退不移动
107 实体、显式途经点或端口,也不改变关系基数;无障碍的同簇分支仍可共享主干。
108- Entity boxes are opaque obstacles. When a third entity sits between two
109 aligned anchors, the shared obstacle grid routes around it, and a route that
110 still cannot clear an unrelated entity fails with
111 `clean-flow/edge-through-node` rather than being drawn through the box.
113The renderer also reports ER-specific diagnostics: unknown entities in
114`from`/`to`, unknown attribute names and unresolved `references`, duplicate attribute
115names, self-referencing relationships, overlapping entity boxes, and rows whose
116text cannot fit inside the declared entity width.

Layout and routing

layout.mode: "grid" places each entity by row/col. Every raw attribute index owns the width of its widest entity and every raw row index owns the height of its tallest, so boxes in one band line up without hand measurement and an empty index leaves a routing channel. pos overrides the cell for a bounded exception.

Relationships route through the orthogonal router the other typed renderers use (../architecture/routing.mjs), so the same contracts apply: automatic port spreading on a shared entity, the endpoint-side contract, the route-rhythm floors, and the universal Clean Flow gate. The ERD is a caller, not a fork: it passes sideFor, maxPortSpacing, preferredCandidates, and compositionFloors: false, and reads the planning-free inferredSides and the ports map. Each option is inert when omitted, which is how architecture and lifecycle keep their routing byte for byte. Five differences are specific to this renderer:

  • Endpoints resolve horizontal-first, like every other non-architecture type: a relationship leaves and enters on the left/right whenever the tables sit side by side, and only falls back to a top/bottom port when they share a column. The shared router's default is architecture's dominant-axis inference, which would split one fan-in into two groups whenever a target happened to sit further away vertically than horizontally.
  • Automatic ports on one side spread up to 24 units apart, because a cardinality glyph is 14 units tall: at the shared 14-unit spread two crow's feet on one table edge read as a single smudge. That spacing is a cap, not a floor — a side only fits (extent - 32) / (ends - 1) — so a table with more relationship ends than its side has room for fails with layout/marker-capacity, which names the table, the side, the spacing, and the three ways out.
  • Relationships that would share one corridor get a deterministic lane offset, so parallel foreign keys never sit on the identical channel line.
  • Relationships that share one entity side (a fan-in of foreign keys, or a fan-out parent) and one marker style bundle onto a single trunk just outside that side: one bus line with a short branch per relationship, instead of a sheaf of parallel lanes. The trunk bridges one port step of uncovered bus, so the port spread above still reads as one line. The trunk is only a preferred route — a branch that would break the endpoint contract or clip an entity falls back to the ordinary families and stays unbundled, and anything authored (route, via, labelAt) keeps its own line. Logical routes still traverse the trunk, so every gate, label, and the layout report see the full geometry. 自动补出的主干桥接还会检查无关关系及已接受的其他桥接;出现交叉、接触或 重叠时保留分离的主干段,避免把独立关系画成同一条总线。检查按双方描边的 半宽之和留出间隔,并计入无关关系的显式 width;中心线未相交但描边接触 也会阻止桥接。这个回退不移动 实体、显式途经点或端口,也不改变关系基数;无障碍的同簇分支仍可共享主干。
  • Entity boxes are opaque obstacles. When a third entity sits between two aligned anchors, the shared obstacle grid routes around it, and a route that still cannot clear an unrelated entity fails with clean-flow/edge-through-node rather than being drawn through the box.

The renderer also reports ER-specific diagnostics: unknown entities in from/to, unknown attribute names and unresolved references, duplicate attribute names, self-referencing relationships, overlapping entity boxes, and rows whose text cannot fit inside the declared entity width.

Raw lines 62 to 117
118## Domain bands
120`tag` names a table's functional domain, and a domain is drawn as a band when the
121tables carrying that tag fill a solid rectangle of grid cells — every cell of
122their bounding box holds one of them. A single row, a single column, and a filled
123rectangular block are all that same claim (an L or diagonal is not); a one-table
124domain is that claim at its smallest. The
125band is measured from the placed boxes and carries the tag as its caption, which
126is what makes a grouped schema readable at a glance: the reader sees blocks
127instead of six equally weighted boxes.
129A tag whose members do not fill one rectangle earns no band, because a band
130around them would enclose cells the domain does not own and claim a grouping the
131placement contradicts. The band is the only place a domain name is drawn, so the
132renderer returns `erd/domain-not-drawn` rather than publishing a diagram whose
133tables are complete and whose domain cannot be named. Move the members into one
134rectangle, split the tag, or drop it. The same diagnostic covers the other ways the
135name can go missing: a member placed outside the grid, because a band is measured
136from cells and absolute coordinates have none; two members in one cell, which the
137grid placement check reports instead so the author gets the pair that has to move;
138a band whose padding would cover an unrelated table, which happens when
139`layout.gapX`/`gapY` are smaller than the padding; and a caption the canvas does
140not reach, which a `layout.origin` closer to the edge than the padding produces.
142The header carries the table title alone. It is centered on the table's own
143center line and held inside the interval it is measured against — the sigil porch
144on the left, the padding on the right — so it does not run past either while the
145shared text model's advance estimate holds. A title that cannot fit the header at
146a readable size raises `erd/header-text-capacity`; widen the table or shorten the
147title. Existing header and entity dimensions are preserved unless the author
148changes them. An authored `sublabel` is not drawn: it stays on the node as
149`data-node-sublabel` and in the accessible name.

Domain bands

tag names a table's functional domain, and a domain is drawn as a band when the tables carrying that tag fill a solid rectangle of grid cells — every cell of their bounding box holds one of them. A single row, a single column, and a filled rectangular block are all that same claim (an L or diagonal is not); a one-table domain is that claim at its smallest. The band is measured from the placed boxes and carries the tag as its caption, which is what makes a grouped schema readable at a glance: the reader sees blocks instead of six equally weighted boxes.

A tag whose members do not fill one rectangle earns no band, because a band around them would enclose cells the domain does not own and claim a grouping the placement contradicts. The band is the only place a domain name is drawn, so the renderer returns erd/domain-not-drawn rather than publishing a diagram whose tables are complete and whose domain cannot be named. Move the members into one rectangle, split the tag, or drop it. The same diagnostic covers the other ways the name can go missing: a member placed outside the grid, because a band is measured from cells and absolute coordinates have none; two members in one cell, which the grid placement check reports instead so the author gets the pair that has to move; a band whose padding would cover an unrelated table, which happens when layout.gapX/gapY are smaller than the padding; and a caption the canvas does not reach, which a layout.origin closer to the edge than the padding produces.

The header carries the table title alone. It is centered on the table's own center line and held inside the interval it is measured against — the sigil porch on the left, the padding on the right — so it does not run past either while the shared text model's advance estimate holds. A title that cannot fit the header at a readable size raises erd/header-text-capacity; widen the table or shorten the title. Existing header and entity dimensions are preserved unless the author changes them. An authored sublabel is not drawn: it stays on the node as data-node-sublabel and in the accessible name.

Raw lines 118 to 150
151## Cardinality ink
153The crow's foot, the single bar, and the optional circle are drawn in the theme's
154muted text ink rather than the shared arrow token: at the light theme's arrow
155colour a glyph over a table fill sits near 2:1 contrast and reads as a smudge,
156while the muted text ink clears the 3:1 non-text floor in both themes. The legend
157repeats the same ink and stroke weight, and its labels render one step larger and
158heavier than the shared legend default, so the legend shows the reader the exact
159glyph the diagram draws.

Cardinality ink

The crow's foot, the single bar, and the optional circle are drawn in the theme's muted text ink rather than the shared arrow token: at the light theme's arrow colour a glyph over a table fill sits near 2:1 contrast and reads as a smudge, while the muted text ink clears the 3:1 non-text floor in both themes. The legend repeats the same ink and stroke weight, and its labels render one step larger and heavier than the shared legend default, so the legend shows the reader the exact glyph the diagram draws.

Raw lines 151 to 160
161## The legend band
163The default legend is part of the generated content, so an automatic canvas is
164sized to hold it: the shared measurement decides whether the reserved strip
165fits, and the drawing area grows a band at a time until it does. A route or a
166relationship label that still collides with the placed band is a real capacity
167failure and raises the diagnostic instead of dropping the key, and an authored
168`meta.viewBox` — a fixed drawing area — names the capacity it lacks
169(`legend/vertical-overflow`, `legend/label-too-wide`, or `legend/content-overlap`,
170each with the fix that resolves it).
172The legend has two groups split by a divider. Key entries draw the same badge a
173field row draws; relationship entries draw a short route meeting a table edge
174with the marker's own glyph. ERD kinds are column keys and relationship ends,
175not node kinds, so the entries stay out of the Viewer's counted node lens, which
176would badge each one with a false zero. Hovering or keyboard-focusing an entry
177highlights its matches in the SVG itself: a key fades the other field rows and
178outlines the matching badges; a relationship end recolours that glyph wherever
179it is drawn and fades routes that do not carry it. Static exports keep the plain
180legend.
182A canvas taller than one screen is a normal schema, not a defect. An automatic
183canvas declares `data-reader-fit="intrinsic-height"` and
184`data-reader-min-text="7.5"`, which is the Reader's permission to narrow a tall
185schema down to the point where the page fits — never below that declared minimum,
186so the field rows stay readable and anything taller than that scrolls; an
187authored `meta.viewBox` keeps the authored fit contract. Field rows, their types,
188and the key glyph carry `data-detail="context"` on the text itself, because the
189Reader sizes the diagram from the texts that declare a level — a field that is
190only context inside a row group would be shrunk past the readable floor on a tall
191schema.
193Narrowing is a floor, not a goal. The bundled eleven-table example measures, at
1941440x900 and with the same tables and typography, as a 1230x678 canvas the Reader
195renders at the full 1346px (scale 1.09), so its field types land at 11.5px. The
196same tables grouped as four horizontal domains instead form a 910x874 canvas that
197the Reader narrows to 777px (scale 0.85), landing the same types at 9.0px. A
198horizontal arrangement that reaches the reader width instead of being narrowed
199still trades text size for the columns it needs, which is why the measurement
200here is the pair and not a rule about which axis is right.

The legend band

The default legend is part of the generated content, so an automatic canvas is sized to hold it: the shared measurement decides whether the reserved strip fits, and the drawing area grows a band at a time until it does. A route or a relationship label that still collides with the placed band is a real capacity failure and raises the diagnostic instead of dropping the key, and an authored meta.viewBox — a fixed drawing area — names the capacity it lacks (legend/vertical-overflow, legend/label-too-wide, or legend/content-overlap, each with the fix that resolves it).

The legend has two groups split by a divider. Key entries draw the same badge a field row draws; relationship entries draw a short route meeting a table edge with the marker's own glyph. ERD kinds are column keys and relationship ends, not node kinds, so the entries stay out of the Viewer's counted node lens, which would badge each one with a false zero. Hovering or keyboard-focusing an entry highlights its matches in the SVG itself: a key fades the other field rows and outlines the matching badges; a relationship end recolours that glyph wherever it is drawn and fades routes that do not carry it. Static exports keep the plain legend.

A canvas taller than one screen is a normal schema, not a defect. An automatic canvas declares data-reader-fit="intrinsic-height" and data-reader-min-text="7.5", which is the Reader's permission to narrow a tall schema down to the point where the page fits — never below that declared minimum, so the field rows stay readable and anything taller than that scrolls; an authored meta.viewBox keeps the authored fit contract. Field rows, their types, and the key glyph carry data-detail="context" on the text itself, because the Reader sizes the diagram from the texts that declare a level — a field that is only context inside a row group would be shrunk past the readable floor on a tall schema.

Narrowing is a floor, not a goal. The bundled eleven-table example measures, at 1440x900 and with the same tables and typography, as a 1230x678 canvas the Reader renders at the full 1346px (scale 1.09), so its field types land at 11.5px. The same tables grouped as four horizontal domains instead form a 910x874 canvas that the Reader narrows to 777px (scale 0.85), landing the same types at 9.0px. A horizontal arrangement that reaches the reader width instead of being narrowed still trades text size for the columns it needs, which is why the measurement here is the pair and not a rule about which axis is right.

Raw lines 161 to 201
202## Typography
204Table rows are text a reader scans, so the ERD's rhythm is looser than the generic
205diagram rhythm: a 30-unit header, 20-unit rows, a 13-unit table name, 11-unit
206field names, and a 10.5-unit type. The type keeps the same ink as the field name
207because it is data, not an aside: at the muted token it measured 3.98:1 against a
208table fill, under the 4.5:1 text floor, which is why it read as faint in review.
209A row whose text cannot fit is a diagnostic rather than a silently truncated
210cell, so a narrow table names the field it cannot show and asks for a wider box.
212Field rows, their types, and the key glyph each carry `data-detail="context"` on
213the text itself, not only on the row group: the Viewer's reader sizes the diagram
214from the texts that declare a level, so a tall schema would otherwise be shrunk
215past the readable floor. An automatic canvas also declares
216`data-reader-fit="intrinsic-height"`, which is what lets a schema taller than one
217screen scroll at a readable size instead of being squeezed to fit; an authored
218`meta.viewBox` keeps the authored fit contract.
220Relationship labels are set at 9 units in the relationship's own accent, with the
221mask measured from the same factor, so a labelled schema keeps its semantic text
222above the readable floor. They are the smallest text the diagram carries, and
223they are semantic data: a collision is a repair (move the label, widen the
224corridor), never a reason to drop the wording.

Typography

Table rows are text a reader scans, so the ERD's rhythm is looser than the generic diagram rhythm: a 30-unit header, 20-unit rows, a 13-unit table name, 11-unit field names, and a 10.5-unit type. The type keeps the same ink as the field name because it is data, not an aside: at the muted token it measured 3.98:1 against a table fill, under the 4.5:1 text floor, which is why it read as faint in review. A row whose text cannot fit is a diagnostic rather than a silently truncated cell, so a narrow table names the field it cannot show and asks for a wider box.

Field rows, their types, and the key glyph each carry data-detail="context" on the text itself, not only on the row group: the Viewer's reader sizes the diagram from the texts that declare a level, so a tall schema would otherwise be shrunk past the readable floor. An automatic canvas also declares data-reader-fit="intrinsic-height", which is what lets a schema taller than one screen scroll at a readable size instead of being squeezed to fit; an authored meta.viewBox keeps the authored fit contract.

Relationship labels are set at 9 units in the relationship's own accent, with the mask measured from the same factor, so a labelled schema keeps its semantic text above the readable floor. They are the smallest text the diagram carries, and they are semantic data: a collision is a repair (move the label, widen the corridor), never a reason to drop the wording.

Raw lines 202 to 225
226## Reading a schema
228An ERD is also a table catalogue. For a schema with more than 8 tables or 50
229fields, start with `meta.quality_profile: "standard"` so complete field
230visibility remains the priority; promote to `showcase` only after the grouped
231layout is genuinely readable. Include every physical column in
232`entities[].attributes`, keep its SQL type, and preserve the source comment in the
233type string as `SQL_TYPE|中文备注` when the schema has no separate comment field.
234The renderer shows ERD attribute rows at the default `read` level, so fields do
235not depend on hover or focus. Use cards only for supplementary constraints,
236inferred relationships, and domain rules; never use them to hide the full table.
238Group tables by functional domain before assigning grid cells. Give members of one
239domain the same concise `tag` and keep them in one contiguous, non-interleaved run
240— a row with adjacent columns or a column with adjacent rows; that run is what the
241band is measured from. Give each domain a column of its own when the whole schema
242has to be visible at once: the domain then reads top to bottom, the relationships
243between neighbouring domains run left to right, and the canvas stays wide enough
244for the Reader to show it at full text size. A wide entity is a taller wall for
245the router to get around, and the projected text minimum still applies at
2461440x900, so prefer a compact grouped grid over shrinking or hiding fields.

Reading a schema

An ERD is also a table catalogue. For a schema with more than 8 tables or 50 fields, start with meta.quality_profile: "standard" so complete field visibility remains the priority; promote to showcase only after the grouped layout is genuinely readable. Include every physical column in entities[].attributes, keep its SQL type, and preserve the source comment in the type string as SQL_TYPE|中文备注 when the schema has no separate comment field. The renderer shows ERD attribute rows at the default read level, so fields do not depend on hover or focus. Use cards only for supplementary constraints, inferred relationships, and domain rules; never use them to hide the full table.

Group tables by functional domain before assigning grid cells. Give members of one domain the same concise tag and keep them in one contiguous, non-interleaved run — a row with adjacent columns or a column with adjacent rows; that run is what the band is measured from. Give each domain a column of its own when the whole schema has to be visible at once: the domain then reads top to bottom, the relationships between neighbouring domains run left to right, and the canvas stays wide enough for the Reader to show it at full text size. A wide entity is a taller wall for the router to get around, and the projected text minimum still applies at 1440x900, so prefer a compact grouped grid over shrinking or hiding fields.

Raw lines 226 to 246