@diagrammo/dgmo 0.8.19 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/cli.cjs +92 -131
  2. package/dist/editor.cjs +13 -1
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +13 -1
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +13 -1
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +13 -1
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +4524 -1511
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +427 -186
  13. package/dist/index.d.ts +427 -186
  14. package/dist/index.js +4526 -1503
  15. package/dist/index.js.map +1 -1
  16. package/docs/guide/chart-mindmap.md +198 -0
  17. package/docs/guide/chart-sequence.md +23 -1
  18. package/docs/guide/chart-wireframe.md +100 -0
  19. package/docs/guide/index.md +8 -0
  20. package/docs/language-reference.md +210 -2
  21. package/package.json +22 -9
  22. package/src/boxes-and-lines/collapse.ts +21 -3
  23. package/src/boxes-and-lines/layout.ts +51 -9
  24. package/src/boxes-and-lines/parser.ts +16 -4
  25. package/src/boxes-and-lines/renderer.ts +121 -23
  26. package/src/boxes-and-lines/types.ts +1 -0
  27. package/src/c4/parser.ts +8 -7
  28. package/src/class/parser.ts +6 -0
  29. package/src/cli.ts +1 -9
  30. package/src/completion.ts +26 -0
  31. package/src/d3.ts +169 -266
  32. package/src/dgmo-router.ts +103 -5
  33. package/src/diagnostics.ts +16 -6
  34. package/src/echarts.ts +43 -10
  35. package/src/editor/keywords.ts +12 -0
  36. package/src/er/parser.ts +22 -2
  37. package/src/gantt/renderer.ts +2 -2
  38. package/src/graph/flowchart-parser.ts +89 -52
  39. package/src/graph/layout.ts +73 -9
  40. package/src/graph/state-collapse.ts +78 -0
  41. package/src/graph/state-parser.ts +60 -35
  42. package/src/graph/state-renderer.ts +139 -34
  43. package/src/index.ts +41 -16
  44. package/src/infra/parser.ts +9 -2
  45. package/src/kanban/renderer.ts +305 -59
  46. package/src/mindmap/collapse.ts +88 -0
  47. package/src/mindmap/layout.ts +605 -0
  48. package/src/mindmap/parser.ts +379 -0
  49. package/src/mindmap/renderer.ts +543 -0
  50. package/src/mindmap/text-wrap.ts +207 -0
  51. package/src/mindmap/types.ts +55 -0
  52. package/src/palettes/color-utils.ts +4 -12
  53. package/src/palettes/index.ts +0 -4
  54. package/src/render.ts +31 -20
  55. package/src/sequence/parser.ts +7 -2
  56. package/src/sequence/renderer.ts +141 -21
  57. package/src/sharing.ts +2 -0
  58. package/src/sitemap/layout.ts +35 -12
  59. package/src/sitemap/renderer.ts +1 -6
  60. package/src/utils/arrows.ts +180 -11
  61. package/src/utils/d3-types.ts +4 -0
  62. package/src/utils/export-container.ts +3 -2
  63. package/src/utils/legend-constants.ts +0 -4
  64. package/src/utils/legend-d3.ts +1 -0
  65. package/src/utils/legend-layout.ts +2 -2
  66. package/src/utils/parsing.ts +2 -0
  67. package/src/utils/time-ticks.ts +213 -0
  68. package/src/wireframe/layout.ts +460 -0
  69. package/src/wireframe/parser.ts +956 -0
  70. package/src/wireframe/renderer.ts +1293 -0
  71. package/src/wireframe/types.ts +110 -0
  72. package/src/branding.ts +0 -67
  73. package/src/dgmo-mermaid.ts +0 -262
  74. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -274,6 +274,7 @@ function renderPill(
274
274
  .append('g')
275
275
  .attr('transform', `translate(${pill.x},${pill.y})`)
276
276
  .attr('data-legend-group', pill.groupName.toLowerCase())
277
+ .attr('data-export-ignore', 'true')
277
278
  .style('cursor', 'pointer');
278
279
 
279
280
  g.append('rect')
@@ -570,8 +570,8 @@ function layoutRows(
570
570
  const last = groupItemsInRow0[groupItemsInRow0.length - 1];
571
571
  controlsGroup.x = last.x + last.width + LEGEND_GROUP_GAP;
572
572
  } else {
573
- // No group items — controls group at start
574
- controlsGroup.x = 0;
573
+ // No group items — center the controls group
574
+ controlsGroup.x = (containerWidth - controlsGroup.width) / 2;
575
575
  }
576
576
  controlsGroup.y = 0;
577
577
  }
@@ -47,6 +47,8 @@ export const ALL_CHART_TYPES = new Set([
47
47
  'infra',
48
48
  'gantt',
49
49
  'boxes-and-lines',
50
+ 'mindmap',
51
+ 'wireframe',
50
52
  ]);
51
53
 
52
54
  /** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
@@ -0,0 +1,213 @@
1
+ // ============================================================
2
+ // Time axis tick computation — shared by d3.ts and gantt/renderer.ts
3
+ // ============================================================
4
+
5
+ import * as d3Scale from 'd3-scale';
6
+
7
+ export const MONTH_ABBR = [
8
+ 'Jan',
9
+ 'Feb',
10
+ 'Mar',
11
+ 'Apr',
12
+ 'May',
13
+ 'Jun',
14
+ 'Jul',
15
+ 'Aug',
16
+ 'Sep',
17
+ 'Oct',
18
+ 'Nov',
19
+ 'Dec',
20
+ ];
21
+
22
+ function fractionalYearToDate(frac: number): Date {
23
+ const year = Math.floor(frac);
24
+ const remainder = frac - year;
25
+ // Inverse of: (month-1)/12 + (day-1)/365 + hour/8760 + minute/525600
26
+ const monthFrac = remainder * 12;
27
+ const month = Math.floor(monthFrac); // 0-based
28
+ const monthRemainder = remainder - month / 12;
29
+ const dayFrac = monthRemainder * 365; // fractional day-of-year offset
30
+ const day = Math.floor(dayFrac) + 1;
31
+ const dayRemainder = dayFrac - Math.floor(dayFrac);
32
+ const hourFrac = dayRemainder * 24;
33
+ const hour = Math.floor(hourFrac);
34
+ const minute = Math.round((hourFrac - hour) * 60);
35
+ return new Date(year, month, day, hour, minute);
36
+ }
37
+
38
+ /** Convert a Date to a fractional year number. */
39
+ function dateToFractionalYear(d: Date): number {
40
+ return (
41
+ d.getFullYear() +
42
+ d.getMonth() / 12 +
43
+ (d.getDate() - 1) / 365 +
44
+ d.getHours() / 8760 +
45
+ d.getMinutes() / 525600
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Generates adaptive tick marks along a time axis.
51
+ * Picks the right granularity (years, months, weeks, days, hours, minutes)
52
+ * based on the domain span.
53
+ *
54
+ * Optional boundary parameters add ticks at exact data start/end:
55
+ * - boundaryStart/boundaryEnd: numeric date values
56
+ * - boundaryStartLabel/boundaryEndLabel: formatted labels for those dates
57
+ */
58
+ export function computeTimeTicks(
59
+ domainMin: number,
60
+ domainMax: number,
61
+ scale: d3Scale.ScaleLinear<number, number>,
62
+ boundaryStart?: number,
63
+ boundaryEnd?: number,
64
+ boundaryStartLabel?: string,
65
+ boundaryEndLabel?: string
66
+ ): { pos: number; label: string }[] {
67
+ const minYear = Math.floor(domainMin);
68
+ const maxYear = Math.floor(domainMax);
69
+ const span = domainMax - domainMin;
70
+
71
+ let ticks: { pos: number; label: string }[] = [];
72
+
73
+ // Year ticks for multi-year spans (need at least 2 boundaries)
74
+ const firstYear = Math.ceil(domainMin);
75
+ const lastYear = Math.floor(domainMax);
76
+ if (lastYear >= firstYear + 1) {
77
+ // Decimate ticks for long spans so labels don't overlap
78
+ const yearSpan = lastYear - firstYear;
79
+ let step = 1;
80
+ if (yearSpan > 80) step = 20;
81
+ else if (yearSpan > 40) step = 10;
82
+ else if (yearSpan > 20) step = 5;
83
+ else if (yearSpan > 10) step = 2;
84
+
85
+ // Align to step boundary so ticks land on round years (1700, 1710, …)
86
+ const alignedFirst = Math.ceil(firstYear / step) * step;
87
+ for (let y = alignedFirst; y <= lastYear; y += step) {
88
+ ticks.push({ pos: scale(y), label: String(y) });
89
+ }
90
+ } else if (span > 0.25) {
91
+ // Month ticks for spans > ~3 months
92
+ const crossesYear = maxYear > minYear;
93
+ for (let y = minYear; y <= maxYear + 1; y++) {
94
+ for (let m = 1; m <= 12; m++) {
95
+ const val = y + (m - 1) / 12;
96
+ if (val > domainMax) break;
97
+ if (val >= domainMin) {
98
+ ticks.push({
99
+ pos: scale(val),
100
+ label: crossesYear
101
+ ? `${MONTH_ABBR[m - 1]} '${String(y).slice(-2)}`
102
+ : MONTH_ABBR[m - 1],
103
+ });
104
+ }
105
+ }
106
+ }
107
+ } else if (span <= 0.000685) {
108
+ // Minute ticks for spans ≤ ~6 hours
109
+ // Adaptive step: >3h → 30min, >1h → 15min, >30min → 10min, else 5min
110
+ let stepMin = 5;
111
+ const spanHours = span * 8760;
112
+ if (spanHours > 3) stepMin = 30;
113
+ else if (spanHours > 1) stepMin = 15;
114
+ else if (spanHours > 0.5) stepMin = 10;
115
+
116
+ // Iterate from the start hour boundary
117
+ const startDate = fractionalYearToDate(domainMin);
118
+ // Round down to nearest step boundary
119
+ startDate.setMinutes(
120
+ Math.floor(startDate.getMinutes() / stepMin) * stepMin,
121
+ 0,
122
+ 0
123
+ );
124
+
125
+ while (true) {
126
+ const val = dateToFractionalYear(startDate);
127
+ if (val > domainMax) break;
128
+ if (val >= domainMin) {
129
+ const hh = String(startDate.getHours()).padStart(2, '0');
130
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
131
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
132
+ }
133
+ startDate.setMinutes(startDate.getMinutes() + stepMin);
134
+ }
135
+ } else if (span <= 0.00822) {
136
+ // Hour ticks for spans ≤ ~3 days
137
+ // Adaptive step: >2d → 6h, >1d → 3h, >12h → 2h, else 1h
138
+ let stepHour = 1;
139
+ const spanHours = span * 8760;
140
+ if (spanHours > 48) stepHour = 6;
141
+ else if (spanHours > 24) stepHour = 3;
142
+ else if (spanHours > 12) stepHour = 2;
143
+
144
+ // For single-day spans, just show HH:MM without the date prefix
145
+ const singleDay = spanHours <= 24;
146
+
147
+ const startDate = fractionalYearToDate(domainMin);
148
+ // Round down to nearest step boundary
149
+ startDate.setHours(
150
+ Math.floor(startDate.getHours() / stepHour) * stepHour,
151
+ 0,
152
+ 0,
153
+ 0
154
+ );
155
+
156
+ while (true) {
157
+ const val = dateToFractionalYear(startDate);
158
+ if (val > domainMax) break;
159
+ if (val >= domainMin) {
160
+ const hh = String(startDate.getHours()).padStart(2, '0');
161
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
162
+ if (singleDay) {
163
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
164
+ } else {
165
+ const mon = MONTH_ABBR[startDate.getMonth()];
166
+ const d = startDate.getDate();
167
+ ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
168
+ }
169
+ }
170
+ startDate.setHours(startDate.getHours() + stepHour);
171
+ }
172
+ } else {
173
+ // Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
174
+ for (let y = minYear; y <= maxYear + 1; y++) {
175
+ for (let m = 1; m <= 12; m++) {
176
+ for (const d of [1, 8, 15, 22]) {
177
+ const val = y + (m - 1) / 12 + (d - 1) / 365;
178
+ if (val > domainMax) break;
179
+ if (val >= domainMin) {
180
+ ticks.push({
181
+ pos: scale(val),
182
+ label: `${MONTH_ABBR[m - 1]} ${d}`,
183
+ });
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // Add boundary ticks at exact data start/end if provided
191
+ // When a boundary tick collides with a standard tick, replace the standard tick
192
+ const collisionThreshold = 40; // pixels
193
+
194
+ if (boundaryStart !== undefined && boundaryStartLabel) {
195
+ const boundaryPos = scale(boundaryStart);
196
+ // Remove any standard ticks that would collide with the start boundary
197
+ ticks = ticks.filter(
198
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
199
+ );
200
+ ticks.unshift({ pos: boundaryPos, label: boundaryStartLabel });
201
+ }
202
+
203
+ if (boundaryEnd !== undefined && boundaryEndLabel) {
204
+ const boundaryPos = scale(boundaryEnd);
205
+ // Remove any standard ticks that would collide with the end boundary
206
+ ticks = ticks.filter(
207
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
208
+ );
209
+ ticks.push({ pos: boundaryPos, label: boundaryEndLabel });
210
+ }
211
+
212
+ return ticks;
213
+ }
@@ -0,0 +1,460 @@
1
+ // ============================================================
2
+ // Wireframe Diagram Layout Engine (Document Flow)
3
+ // ============================================================
4
+
5
+ import type {
6
+ WireframeElement,
7
+ ParsedWireframe,
8
+ WireframeFormFactor,
9
+ } from './types';
10
+
11
+ // ============================================================
12
+ // Constants
13
+ // ============================================================
14
+
15
+ const DESKTOP_WIDTH = 1200;
16
+ const MOBILE_WIDTH = 375;
17
+
18
+ const CHAR_WIDTH = 7.5;
19
+ const LABEL_PADDING = 16;
20
+
21
+ /** Element type-specific heights */
22
+ const ELEMENT_HEIGHTS: Record<string, number> = {
23
+ textInput: 36,
24
+ button: 40,
25
+ dropdown: 36,
26
+ checkbox: 24,
27
+ radio: 24,
28
+ heading: 48,
29
+ subheading: 36,
30
+ divider: 24,
31
+ text: 22,
32
+ listItem: 24,
33
+ image: 120,
34
+ progress: 28,
35
+ chart: 160,
36
+ alert: 40,
37
+ nav: 44,
38
+ tabs: 44,
39
+ };
40
+
41
+ /** Spacing after specific element types */
42
+ const SPACING_AFTER: Record<string, number> = {
43
+ heading: 16,
44
+ subheading: 12,
45
+ divider: 12,
46
+ textInput: 8,
47
+ dropdown: 8,
48
+ checkbox: 8,
49
+ radio: 8,
50
+ button: 8,
51
+ text: 12,
52
+ listItem: 4,
53
+ group: 16,
54
+ image: 12,
55
+ progress: 12,
56
+ chart: 16,
57
+ alert: 12,
58
+ nav: 12,
59
+ tabs: 12,
60
+ table: 16,
61
+ };
62
+
63
+ const GROUP_PADDING_TOP_WITH_LABEL = 32; // label + top padding
64
+ const GROUP_PADDING_TOP_NO_LABEL = 10; // just content padding
65
+ const GROUP_PADDING_BOTTOM = 12;
66
+
67
+ /** Resolved at layout time based on showGroupLabels option */
68
+ let GROUP_PADDING_TOP = GROUP_PADDING_TOP_WITH_LABEL;
69
+ const GROUP_PADDING_X = 12;
70
+ const FRAME_PADDING = 20;
71
+ const TITLE_HEIGHT = 40;
72
+
73
+ /** Smart sizing: recognized region name prefixes → width fraction */
74
+ const REGION_SIZES: Record<string, number> = {
75
+ sidebar: 0.25,
76
+ side: 0.25,
77
+ left: 0.25,
78
+ right: 0.25,
79
+ main: 0, // fill remaining
80
+ content: 0, // fill remaining
81
+ center: 0, // fill remaining
82
+ };
83
+
84
+ /** Full-width regions (thin strips) */
85
+ const FULL_WIDTH_REGIONS = new Set(['header', 'top', 'footer', 'bottom']);
86
+ const HEADER_REGION_HEIGHT = 60;
87
+ const FOOTER_REGION_HEIGHT = 40;
88
+
89
+ // ============================================================
90
+ // Layout Types
91
+ // ============================================================
92
+
93
+ export interface WireframeLayoutNode {
94
+ id: string;
95
+ x: number;
96
+ y: number;
97
+ width: number;
98
+ height: number;
99
+ element: WireframeElement;
100
+ children: WireframeLayoutNode[];
101
+ /** For label-field pairs: the x offset where fields align */
102
+ fieldAlignX?: number;
103
+ }
104
+
105
+ export interface WireframeLayout {
106
+ width: number;
107
+ height: number;
108
+ titleHeight: number;
109
+ nodes: WireframeLayoutNode[];
110
+ modalNodes: WireframeLayoutNode[];
111
+ }
112
+
113
+ // ============================================================
114
+ // Layout Engine
115
+ // ============================================================
116
+
117
+ export function layoutWireframe(
118
+ parsed: ParsedWireframe,
119
+ _options?: Record<string, string>,
120
+ overrideWidth?: number,
121
+ showGroupLabels = true
122
+ ): WireframeLayout {
123
+ GROUP_PADDING_TOP = showGroupLabels
124
+ ? GROUP_PADDING_TOP_WITH_LABEL
125
+ : GROUP_PADDING_TOP_NO_LABEL;
126
+ const defaultWidth =
127
+ parsed.formFactor === 'mobile' ? MOBILE_WIDTH : DESKTOP_WIDTH;
128
+ const frameWidth = overrideWidth ?? defaultWidth;
129
+ const titleHeight = parsed.title ? TITLE_HEIGHT : 0;
130
+
131
+ const contentWidth = frameWidth - FRAME_PADDING * 2;
132
+
133
+ // Layout top-level roots
134
+ const nodes = layoutTopLevel(parsed.roots, contentWidth, parsed.formFactor);
135
+
136
+ // Calculate total height from positioned nodes
137
+ let maxY = 0;
138
+ for (const n of nodes) {
139
+ maxY = Math.max(maxY, n.y + n.height);
140
+ }
141
+
142
+ // Layout modals below main content
143
+ const modalNodes: WireframeLayoutNode[] = [];
144
+ let modalY = maxY + 24;
145
+ for (const modal of parsed.modals) {
146
+ const modalWidth = Math.min(contentWidth * 0.7, 600);
147
+ const modalX = (contentWidth - modalWidth) / 2;
148
+ const node = layoutElement(modal, modalX, modalY, modalWidth);
149
+ modalNodes.push(node);
150
+ modalY += node.height + 24;
151
+ }
152
+
153
+ const totalHeight =
154
+ (modalNodes.length > 0
155
+ ? modalNodes[modalNodes.length - 1].y +
156
+ modalNodes[modalNodes.length - 1].height
157
+ : maxY) +
158
+ FRAME_PADDING * 2 +
159
+ titleHeight;
160
+
161
+ return {
162
+ width: frameWidth,
163
+ height: totalHeight,
164
+ titleHeight,
165
+ nodes,
166
+ modalNodes,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Layout top-level roots: separate into full-width regions (header/footer)
172
+ * and horizontal siblings, then stack vertically.
173
+ */
174
+ function layoutTopLevel(
175
+ roots: WireframeElement[],
176
+ contentWidth: number,
177
+ formFactor: WireframeFormFactor
178
+ ): WireframeLayoutNode[] {
179
+ const result: WireframeLayoutNode[] = [];
180
+ let y = 0;
181
+
182
+ // Classify roots into rows
183
+ const rows: WireframeElement[][] = [];
184
+ let currentRow: WireframeElement[] = [];
185
+
186
+ for (const root of roots) {
187
+ const regionName = root.label.toLowerCase().split(/\s+/)[0];
188
+ if (FULL_WIDTH_REGIONS.has(regionName) || formFactor === 'mobile') {
189
+ // Full-width: flush current row first, then add as its own row
190
+ if (currentRow.length > 0) {
191
+ rows.push(currentRow);
192
+ currentRow = [];
193
+ }
194
+ rows.push([root]);
195
+ } else {
196
+ currentRow.push(root);
197
+ }
198
+ }
199
+ if (currentRow.length > 0) rows.push(currentRow);
200
+
201
+ // Layout each row
202
+ for (const row of rows) {
203
+ if (row.length === 1 && formFactor !== 'mobile') {
204
+ const el = row[0];
205
+ const regionName = el.label.toLowerCase().split(/\s+/)[0];
206
+
207
+ if (FULL_WIDTH_REGIONS.has(regionName)) {
208
+ // Full-width strip
209
+ const node = layoutElement(el, 0, y, contentWidth);
210
+ // Enforce min height for header/footer
211
+ if (regionName === 'header' || regionName === 'top') {
212
+ node.height = Math.max(node.height, HEADER_REGION_HEIGHT);
213
+ } else {
214
+ node.height = Math.max(node.height, FOOTER_REGION_HEIGHT);
215
+ }
216
+ result.push(node);
217
+ y += node.height + 16;
218
+ continue;
219
+ }
220
+ }
221
+
222
+ if (formFactor === 'mobile' || row.length === 1) {
223
+ // Stack vertically
224
+ for (const el of row) {
225
+ const node = layoutElement(el, 0, y, contentWidth);
226
+ result.push(node);
227
+ y += node.height + 16;
228
+ }
229
+ } else {
230
+ // Horizontal siblings — smart sizing
231
+ const allocated = allocateHorizontalWidths(row, contentWidth);
232
+ let x = 0;
233
+ let maxHeight = 0;
234
+ const rowNodes: WireframeLayoutNode[] = [];
235
+
236
+ for (let i = 0; i < row.length; i++) {
237
+ const w = allocated[i];
238
+ const node = layoutElement(row[i], x, y, w);
239
+ rowNodes.push(node);
240
+ maxHeight = Math.max(maxHeight, node.height);
241
+ x += w + 12; // gap between horizontal siblings
242
+ }
243
+
244
+ // Equalize heights (tallest wins, F4)
245
+ for (const n of rowNodes) {
246
+ n.height = maxHeight;
247
+ }
248
+
249
+ result.push(...rowNodes);
250
+ y += maxHeight + 16;
251
+ }
252
+ }
253
+
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * Allocate horizontal widths for side-by-side regions using smart sizing.
259
+ */
260
+ function allocateHorizontalWidths(
261
+ elements: WireframeElement[],
262
+ totalWidth: number
263
+ ): number[] {
264
+ const gap = 12;
265
+ const totalGaps = (elements.length - 1) * gap;
266
+ const available = totalWidth - totalGaps;
267
+
268
+ const widths: number[] = new Array(elements.length).fill(0);
269
+ let allocated = 0;
270
+ let fillCount = 0;
271
+
272
+ for (let i = 0; i < elements.length; i++) {
273
+ const name = elements[i].label.toLowerCase().split(/\s+/)[0];
274
+ const fraction = REGION_SIZES[name];
275
+
276
+ if (fraction !== undefined && fraction > 0) {
277
+ widths[i] = available * fraction;
278
+ allocated += widths[i];
279
+ } else if (fraction === 0) {
280
+ // Fill remaining
281
+ fillCount++;
282
+ } else {
283
+ // Unknown — will be equal-split
284
+ fillCount++;
285
+ }
286
+ }
287
+
288
+ // Distribute remaining to fill/unknown regions
289
+ if (fillCount > 0) {
290
+ const remaining = Math.max(0, available - allocated);
291
+ const each = remaining / fillCount;
292
+ for (let i = 0; i < widths.length; i++) {
293
+ if (widths[i] === 0) widths[i] = Math.max(40, each);
294
+ }
295
+ }
296
+
297
+ // Clamp all widths to minimum
298
+ for (let i = 0; i < widths.length; i++) {
299
+ widths[i] = Math.max(40, widths[i]);
300
+ }
301
+
302
+ return widths;
303
+ }
304
+
305
+ /**
306
+ * Layout a single element and its children recursively.
307
+ */
308
+ function layoutElement(
309
+ el: WireframeElement,
310
+ x: number,
311
+ y: number,
312
+ width: number
313
+ ): WireframeLayoutNode {
314
+ const node: WireframeLayoutNode = {
315
+ id: el.id,
316
+ x,
317
+ y,
318
+ width,
319
+ height: 0,
320
+ element: el,
321
+ children: [],
322
+ };
323
+
324
+ // Nav and tabs render children internally as a horizontal bar — treat as leaf for layout
325
+ const selfRendered = el.type === 'nav' || el.type === 'tabs';
326
+
327
+ if (!el.isContainer || el.children.length === 0 || selfRendered) {
328
+ // Leaf element — use type-specific height
329
+ node.height = getElementHeight(el);
330
+ return node;
331
+ }
332
+
333
+ // Container — layout children
334
+ const isInlineRow =
335
+ el.metadata._inlineRow === 'true' || el.metadata._labelField === 'true';
336
+ const padTop = isInlineRow ? 0 : GROUP_PADDING_TOP;
337
+ const padBottom = isInlineRow ? 0 : GROUP_PADDING_BOTTOM;
338
+ const padX = isInlineRow ? 0 : GROUP_PADDING_X;
339
+ const innerWidth = width - padX * 2;
340
+ const innerX = padX;
341
+
342
+ if (el.orientation === 'horizontal') {
343
+ // Horizontal layout: children in a row
344
+ const childWidths = allocateEqualWidths(el.children, innerWidth);
345
+ let cx = innerX;
346
+ let maxChildHeight = 0;
347
+
348
+ for (let i = 0; i < el.children.length; i++) {
349
+ const child = el.children[i];
350
+ const cw = childWidths[i];
351
+ const childNode = layoutElement(child, cx, padTop, cw);
352
+ node.children.push(childNode);
353
+ maxChildHeight = Math.max(maxChildHeight, childNode.height);
354
+ cx += cw + 8;
355
+ }
356
+
357
+ // Equalize child heights
358
+ for (const cn of node.children) {
359
+ cn.height = maxChildHeight;
360
+ }
361
+
362
+ node.height = padTop + maxChildHeight + padBottom;
363
+ } else {
364
+ // Vertical layout: stack children
365
+ let cy = padTop;
366
+
367
+ // Check for label-field auto-alignment (ADR-4)
368
+ const fieldAlignX = computeFieldAlignX(el.children);
369
+ if (fieldAlignX > 0) {
370
+ node.fieldAlignX = fieldAlignX;
371
+ }
372
+
373
+ for (const child of el.children) {
374
+ const childNode = layoutElement(child, innerX, cy, innerWidth);
375
+ node.children.push(childNode);
376
+ cy += childNode.height + getSpacingAfter(child);
377
+ }
378
+
379
+ node.height = cy + padBottom;
380
+ }
381
+
382
+ // Collapsed state: only show header
383
+ if (el.states.includes('collapsed')) {
384
+ node.height = GROUP_PADDING_TOP;
385
+ node.children = [];
386
+ }
387
+
388
+ return node;
389
+ }
390
+
391
+ function allocateEqualWidths(
392
+ children: WireframeElement[],
393
+ totalWidth: number
394
+ ): number[] {
395
+ const gap = 8;
396
+ const totalGaps = (children.length - 1) * gap;
397
+ const each = Math.max(40, (totalWidth - totalGaps) / children.length);
398
+ return children.map(() => each);
399
+ }
400
+
401
+ function getElementHeight(el: WireframeElement): number {
402
+ if (el.type === 'heading') {
403
+ return el.headingLevel === 2
404
+ ? (ELEMENT_HEIGHTS.subheading ?? 36)
405
+ : (ELEMENT_HEIGHTS.heading ?? 48);
406
+ }
407
+
408
+ if (el.type === 'textInput' && el.fieldVariant === 'textarea') {
409
+ return 80;
410
+ }
411
+
412
+ if (el.type === 'table') {
413
+ const headerH = 32;
414
+ const rowH = 28;
415
+ const rows = el.tableData?.length || el.tableRows || 0;
416
+ return headerH + rows * rowH + 8;
417
+ }
418
+
419
+ // Image hints affect height
420
+ if (el.type === 'image') {
421
+ if (el.imageHint === 'round') return 80;
422
+ if (el.imageHint === 'wide') return 80;
423
+ return ELEMENT_HEIGHTS.image ?? 120;
424
+ }
425
+
426
+ // Label-field wrapper
427
+ if (el.metadata._labelField === 'true') {
428
+ return 36; // input height
429
+ }
430
+
431
+ return ELEMENT_HEIGHTS[el.type] ?? 24;
432
+ }
433
+
434
+ function getSpacingAfter(el: WireframeElement): number {
435
+ if (el.type === 'heading' && el.headingLevel === 2) {
436
+ return SPACING_AFTER.subheading ?? 12;
437
+ }
438
+ return SPACING_AFTER[el.type] ?? 8;
439
+ }
440
+
441
+ /**
442
+ * Compute auto-alignment X offset for label-field pairs in a group (ADR-4, EC8).
443
+ * Returns 0 if no label-field pattern detected.
444
+ */
445
+ function computeFieldAlignX(children: WireframeElement[]): number {
446
+ let maxLabelWidth = 0;
447
+ let labelFieldCount = 0;
448
+
449
+ for (const child of children) {
450
+ if (child.metadata._labelField === 'true' && child.children.length >= 2) {
451
+ const labelEl = child.children[0];
452
+ const labelWidth = labelEl.label.length * CHAR_WIDTH;
453
+ maxLabelWidth = Math.max(maxLabelWidth, labelWidth);
454
+ labelFieldCount++;
455
+ }
456
+ }
457
+
458
+ if (labelFieldCount < 2) return 0;
459
+ return maxLabelWidth + LABEL_PADDING;
460
+ }