grid.mjs
150 lines · archify/renderers/erd/grid.mjs at a406594
1/** Banded grid placement for the ER IR. Not auto-layout: column widths and row
2 * band heights are measured from the boxes the author declares, so the same
3 * spec always produces the same coordinates. */
5export const DEFAULT_ER_GRID = {
6 mode: 'grid',
7 // The top origin leaves room for a domain caption above the first band, and
8 // the left origin starts the first column inside the page margin so the
9 // canvas is not edge-to-edge. Row bands are taller than the generic rhythm
10 // because a table row is text a reader has to scan, not a node label.
11 origin: [32, 40],
12 gapX: 56,
13 gapY: 44,
14 // Wide enough for a long field name plus its SQL type at the legibility
15 // sizes below (a 20-character name and a numeric(12,2) type).
16 entityW: 240,
17 // Header and row bands are sized for the field type legibility the reader
18 // asked for: an 11-unit field name and a 10.5-unit type sit on a 20-unit
19 // row, and the table name leads them on a 30-unit header.
20 headerH: 30,
21 rowH: 20,
22};
24export function erGridLayout(er) {
25 const raw = er.layout;
26 // Grid cells without a layout block mean the default grid, not absolute
27 // coordinates that were never supplied.
28 if (!raw) {
29 const gridPlaced = (er.entities || []).some((entity) => Number.isFinite(entity?.row) && Number.isFinite(entity?.col));
30 return gridPlaced ? { ...DEFAULT_ER_GRID } : null;
31 }
32 if (raw.mode !== 'grid') return null;
33 return { ...DEFAULT_ER_GRID, ...raw };
34}
36// An entity may omit `width` and inherit the grid's default. Both the layout and
37// the placement validator must resolve that the same way: reading `entity.width`
38// directly turned the documented default into NaN and reported a placement
39// failure for a box the schema accepts.
40export function resolvedEntityWidth(entity, grid) {
41 return Number.isFinite(entity?.width) ? entity.width : (grid?.entityW ?? DEFAULT_ER_GRID.entityW);
42}
44// One header band plus one band per declared column. An entity with no columns
45// still needs a readable body, so the minimum is a single row.
46export function entityHeight(entity, grid) {
47 const rows = Math.max(1, Array.isArray(entity.attributes) ? entity.attributes.length : 0);
48 return grid.headerH + rows * grid.rowH + 6;
49}
51// Every raw column index owns the widest box placed in it and every raw row
52// index owns the tallest, so boxes in one band share a baseline without anyone
53// measuring by hand. Indices with no box still contribute their gap, which is
54// how an author asks for a routing channel between two bands. A box on a half
55// column (a class grid's `col: 1.5`) is centred between two columns: it sizes
56// its row but neither column.
57const gridColumn = (col) => Number.isInteger(col * 2) && col >= 0;
58// `columnGaps` raises the gap after a column to a measured floor (see the
59// renderer's relationship-label demand); the authored or default gapX stays
60// the minimum everywhere, so an absent map reproduces the plain grid.
61export function bandedLayout(entities, grid, { columnGaps = null } = {}) {
62 const widths = new Map();
63 const heights = new Map();
64 let maxCol = -1;
65 for (const entity of entities) {
66 if (!Number.isInteger(entity.row) || !gridColumn(entity.col)) continue;
67 maxCol = Math.max(maxCol, Math.ceil(entity.col));
68 if (Number.isInteger(entity.col)) widths.set(entity.col, Math.max(widths.get(entity.col) || 0, resolvedEntityWidth(entity, grid)));
69 heights.set(entity.row, Math.max(heights.get(entity.row) || 0, entity.height));
70 }
72 const columnX = new Map();
73 const rowY = new Map();
74 let x = grid.origin[0];
75 for (let col = 0; col <= maxCol; col += 1) {
76 columnX.set(col, x);
77 x += (widths.get(col) || 0) + Math.max(grid.gapX, columnGaps?.get(col) || 0);
78 }
79 // A half-column box may be wider than its empty neighbouring bands. Keep
80 // every grid box inside the left margin without changing integer-only grids.
81 let leftInset = 0;
82 for (const entity of entities) {
83 if (!Number.isInteger(entity.row) || !gridColumn(entity.col) || Number.isInteger(entity.col)) continue;
84 const centre = (col) => columnX.get(col) + (widths.get(col) || 0) / 2;
85 const midpoint = (centre(Math.floor(entity.col)) + centre(Math.ceil(entity.col))) / 2;
86 leftInset = Math.max(leftInset, grid.origin[0] - midpoint + resolvedEntityWidth(entity, grid) / 2);
87 }
88 if (leftInset > 0) {
89 for (const [col, column] of columnX) columnX.set(col, column + leftInset);
90 x += leftInset;
91 }
92 let y = grid.origin[1];
93 const maxRow = Math.max(-1, ...heights.keys());
94 for (let row = 0; row <= maxRow; row += 1) {
95 rowY.set(row, y);
96 y += (heights.get(row) || 0) + grid.gapY;
97 }
99 return { columnX, rowY, widths, heights, totalWidth: x, totalHeight: y };
100}
102export function resolveEntityPos(entity, grid, bands) {
103 if (Array.isArray(entity.pos) && entity.pos.length === 2) return entity.pos;
104 if (!grid || !bands) return [NaN, NaN];
105 if (!Number.isInteger(entity.row) || !gridColumn(entity.col)) return [NaN, NaN];
106 const width = resolvedEntityWidth(entity, grid);
107 const rowY = bands.rowY.get(entity.row);
108 if (!Number.isInteger(entity.col)) {
109 const centre = (col) => bands.columnX.get(col) + (bands.widths.get(col) || 0) / 2;
110 const [left, right] = [Math.floor(entity.col), Math.ceil(entity.col)];
111 if (![bands.columnX.get(left), bands.columnX.get(right), rowY].every(Number.isFinite)) return [NaN, NaN];
112 return [(centre(left) + centre(right)) / 2 - width / 2, rowY];
113 }
114 const columnX = bands.columnX.get(entity.col);
115 if (!Number.isFinite(columnX) || !Number.isFinite(rowY)) return [NaN, NaN];
116 // Centre each box in its column so the gaps on both sides stay equal; the
117 // router reads those gaps as corridors.
118 const columnWidth = bands.widths.get(entity.col) || width;
119 return [columnX + (columnWidth - width) / 2, rowY];
120}
122export function validateErGridPlacement(er, grid, bands, problems) {
123 const seen = new Map();
124 for (const entity of er.entities ?? []) {
125 const hasPos = Array.isArray(entity.pos) && entity.pos.length === 2;
126 if (!hasPos && !grid) {
127 problems.push(`Entity "${entity.id}" needs pos [x,y] or a grid layout with row/col.`);
128 continue;
129 }
130 if (hasPos) continue;
131 if (!Number.isInteger(entity.row) || !Number.isInteger(entity.col)) {
132 problems.push(`Entity "${entity.id}" needs pos [x,y] or grid row/col when layout.mode is "grid".`);
133 continue;
134 }
135 if (entity.row < 0 || entity.col < 0) {
136 problems.push(`Entity "${entity.id}" row/col must be non-negative integers.`);
137 continue;
138 }
139 const key = `${entity.row},${entity.col}`;
140 if (seen.has(key)) {
141 problems.push(`Entities "${seen.get(key)}" and "${entity.id}" share grid cell row ${entity.row} col ${entity.col}.`);
142 } else {
143 seen.set(key, entity.id);
144 }
145 const [x, y] = resolveEntityPos(entity, grid, bands);
146 if (!Number.isFinite(x) || !Number.isFinite(y)) {
147 problems.push(`Entity "${entity.id}" could not be placed from row ${entity.row} col ${entity.col}.`);
148 }
149 }
150}