@diagrammo/dgmo 0.2.18 → 0.2.20

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.
@@ -16,6 +16,7 @@ import { layoutGraph } from './layout';
16
16
  // ============================================================
17
17
 
18
18
  const DIAGRAM_PADDING = 20;
19
+ const MAX_SCALE = 3;
19
20
  const NODE_FONT_SIZE = 13;
20
21
  const EDGE_LABEL_FONT_SIZE = 11;
21
22
  const GROUP_LABEL_FONT_SIZE = 11;
@@ -43,7 +44,8 @@ function mix(a: string, b: string, pct: number): string {
43
44
  return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
44
45
  }
45
46
 
46
- function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTerminal?: boolean): string {
47
+ function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTerminal?: boolean, colorOff?: boolean): string {
48
+ if (colorOff) return palette.textMuted;
47
49
  switch (shape) {
48
50
  case 'terminal': return isEndTerminal ? palette.colors.red : palette.colors.green;
49
51
  case 'process': return palette.colors.blue;
@@ -54,13 +56,13 @@ function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTermi
54
56
  }
55
57
  }
56
58
 
57
- function nodeFill(palette: PaletteColors, isDark: boolean, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean): string {
58
- const color = nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal);
59
+ function nodeFill(palette: PaletteColors, isDark: boolean, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean, colorOff?: boolean): string {
60
+ const color = nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal, colorOff);
59
61
  return mix(color, isDark ? palette.surface : palette.bg, 25);
60
62
  }
61
63
 
62
- function nodeStroke(palette: PaletteColors, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean): string {
63
- return nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal);
64
+ function nodeStroke(palette: PaletteColors, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean, colorOff?: boolean): string {
65
+ return nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal, colorOff);
64
66
  }
65
67
 
66
68
  // ============================================================
@@ -69,7 +71,7 @@ function nodeStroke(palette: PaletteColors, shape: GraphShape, nodeColor?: strin
69
71
 
70
72
  type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
71
73
 
72
- function renderTerminal(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, isEnd: boolean): void {
74
+ function renderTerminal(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, isEnd: boolean, colorOff?: boolean): void {
73
75
  const w = node.width;
74
76
  const h = node.height;
75
77
  const rx = h / 2;
@@ -80,12 +82,12 @@ function renderTerminal(g: GSelection, node: LayoutNode, palette: PaletteColors,
80
82
  .attr('height', h)
81
83
  .attr('rx', rx)
82
84
  .attr('ry', rx)
83
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color, isEnd))
84
- .attr('stroke', nodeStroke(palette, node.shape, node.color, isEnd))
85
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, isEnd, colorOff))
86
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, isEnd, colorOff))
85
87
  .attr('stroke-width', NODE_STROKE_WIDTH);
86
88
  }
87
89
 
88
- function renderProcess(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
90
+ function renderProcess(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
89
91
  const w = node.width;
90
92
  const h = node.height;
91
93
  g.append('rect')
@@ -95,12 +97,12 @@ function renderProcess(g: GSelection, node: LayoutNode, palette: PaletteColors,
95
97
  .attr('height', h)
96
98
  .attr('rx', 3)
97
99
  .attr('ry', 3)
98
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
99
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
100
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
101
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
100
102
  .attr('stroke-width', NODE_STROKE_WIDTH);
101
103
  }
102
104
 
103
- function renderDecision(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
105
+ function renderDecision(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
104
106
  const w = node.width / 2;
105
107
  const h = node.height / 2;
106
108
  const points = [
@@ -111,12 +113,12 @@ function renderDecision(g: GSelection, node: LayoutNode, palette: PaletteColors,
111
113
  ].join(' ');
112
114
  g.append('polygon')
113
115
  .attr('points', points)
114
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
115
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
116
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
117
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
116
118
  .attr('stroke-width', NODE_STROKE_WIDTH);
117
119
  }
118
120
 
119
- function renderIO(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
121
+ function renderIO(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
120
122
  const w = node.width / 2;
121
123
  const h = node.height / 2;
122
124
  const sk = IO_SKEW;
@@ -128,15 +130,15 @@ function renderIO(g: GSelection, node: LayoutNode, palette: PaletteColors, isDar
128
130
  ].join(' ');
129
131
  g.append('polygon')
130
132
  .attr('points', points)
131
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
132
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
133
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
134
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
133
135
  .attr('stroke-width', NODE_STROKE_WIDTH);
134
136
  }
135
137
 
136
- function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
138
+ function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
137
139
  const w = node.width;
138
140
  const h = node.height;
139
- const s = nodeStroke(palette, node.shape, node.color);
141
+ const s = nodeStroke(palette, node.shape, node.color, undefined, colorOff);
140
142
  // Outer rectangle
141
143
  g.append('rect')
142
144
  .attr('x', -w / 2)
@@ -145,7 +147,7 @@ function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColor
145
147
  .attr('height', h)
146
148
  .attr('rx', 3)
147
149
  .attr('ry', 3)
148
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
150
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
149
151
  .attr('stroke', s)
150
152
  .attr('stroke-width', NODE_STROKE_WIDTH);
151
153
  // Left inner border
@@ -166,7 +168,7 @@ function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColor
166
168
  .attr('stroke-width', NODE_STROKE_WIDTH);
167
169
  }
168
170
 
169
- function renderDocument(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
171
+ function renderDocument(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
170
172
  const w = node.width;
171
173
  const h = node.height;
172
174
  const waveH = DOC_WAVE_HEIGHT;
@@ -186,30 +188,30 @@ function renderDocument(g: GSelection, node: LayoutNode, palette: PaletteColors,
186
188
 
187
189
  g.append('path')
188
190
  .attr('d', d)
189
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
190
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
191
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
192
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
191
193
  .attr('stroke-width', NODE_STROKE_WIDTH);
192
194
  }
193
195
 
194
- function renderNodeShape(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, endTerminalIds: Set<string>): void {
196
+ function renderNodeShape(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, endTerminalIds: Set<string>, colorOff?: boolean): void {
195
197
  switch (node.shape) {
196
198
  case 'terminal':
197
- renderTerminal(g, node, palette, isDark, endTerminalIds.has(node.id));
199
+ renderTerminal(g, node, palette, isDark, endTerminalIds.has(node.id), colorOff);
198
200
  break;
199
201
  case 'process':
200
- renderProcess(g, node, palette, isDark);
202
+ renderProcess(g, node, palette, isDark, colorOff);
201
203
  break;
202
204
  case 'decision':
203
- renderDecision(g, node, palette, isDark);
205
+ renderDecision(g, node, palette, isDark, colorOff);
204
206
  break;
205
207
  case 'io':
206
- renderIO(g, node, palette, isDark);
208
+ renderIO(g, node, palette, isDark, colorOff);
207
209
  break;
208
210
  case 'subroutine':
209
- renderSubroutine(g, node, palette, isDark);
211
+ renderSubroutine(g, node, palette, isDark, colorOff);
210
212
  break;
211
213
  case 'document':
212
- renderDocument(g, node, palette, isDark);
214
+ renderDocument(g, node, palette, isDark, colorOff);
213
215
  break;
214
216
  }
215
217
  }
@@ -251,7 +253,7 @@ export function renderFlowchart(
251
253
  const availH = height - titleHeight;
252
254
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
253
255
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
254
- const scale = Math.min(1, scaleX, scaleY);
256
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
255
257
 
256
258
  // Center the diagram in the area below the title
257
259
  const scaledW = diagramW * scale;
@@ -443,6 +445,7 @@ export function renderFlowchart(
443
445
  }
444
446
 
445
447
  // Render nodes (top layer)
448
+ const colorOff = graph.options?.color === 'off';
446
449
  for (const node of layout.nodes) {
447
450
  const nodeG = contentG
448
451
  .append('g')
@@ -458,7 +461,7 @@ export function renderFlowchart(
458
461
  }
459
462
 
460
463
  // Shape
461
- renderNodeShape(nodeG as GSelection, node, palette, isDark, endTerminalIds);
464
+ renderNodeShape(nodeG as GSelection, node, palette, isDark, endTerminalIds, colorOff);
462
465
 
463
466
  // Label
464
467
  nodeG
@@ -33,6 +33,8 @@ export interface GraphGroup {
33
33
  lineNumber: number;
34
34
  }
35
35
 
36
+ import type { DgmoError } from '../diagnostics';
37
+
36
38
  export interface ParsedGraph {
37
39
  type: 'flowchart';
38
40
  title?: string;
@@ -41,5 +43,7 @@ export interface ParsedGraph {
41
43
  nodes: GraphNode[];
42
44
  edges: GraphEdge[];
43
45
  groups?: GraphGroup[];
46
+ options: Record<string, string>;
47
+ diagnostics: DgmoError[];
44
48
  error?: string;
45
49
  }
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
+ // ============================================================
2
+ // Diagnostics
3
+ // ============================================================
4
+
5
+ export { makeDgmoError, formatDgmoError } from './diagnostics';
6
+ export type { DgmoError, DgmoSeverity } from './diagnostics';
7
+
1
8
  // ============================================================
2
9
  // Unified API
3
10
  // ============================================================
@@ -11,6 +18,7 @@ export { render } from './render';
11
18
  export {
12
19
  parseDgmoChartType,
13
20
  getDgmoFramework,
21
+ parseDgmo,
14
22
  DGMO_CHART_TYPE_MAP,
15
23
  } from './dgmo-router';
16
24
  export type { DgmoFramework } from './dgmo-router';
@@ -139,6 +147,9 @@ export { renderOrg, renderOrgForExport } from './org/renderer';
139
147
  export { collapseOrgTree } from './org/collapse';
140
148
  export type { CollapsedOrgResult } from './org/collapse';
141
149
 
150
+ export { resolveOrgImports } from './org/resolver';
151
+ export type { ReadFileFn, ResolveImportsResult } from './org/resolver';
152
+
142
153
  export { layoutGraph } from './graph/layout';
143
154
  export type {
144
155
  LayoutResult,
package/src/org/layout.ts CHANGED
@@ -61,6 +61,8 @@ export interface OrgLegendGroup {
61
61
  y: number;
62
62
  width: number;
63
63
  height: number;
64
+ minifiedWidth: number;
65
+ minifiedHeight: number;
64
66
  }
65
67
 
66
68
  export interface OrgLayoutResult {
@@ -301,6 +303,8 @@ function computeLegendGroups(tagGroups: OrgTagGroup[], showEyeIcons: boolean): O
301
303
  (colWidths.length - 1) * LEGEND_ENTRY_GAP;
302
304
  const maxRowWidth = Math.max(headerWidth, totalColumnsWidth);
303
305
 
306
+ const minifiedWidth = group.name.length * CHAR_WIDTH + LEGEND_PAD * 2;
307
+
304
308
  groups.push({
305
309
  name: group.name,
306
310
  entries: group.entries.map((e) => ({ value: e.value, color: e.color })),
@@ -308,6 +312,8 @@ function computeLegendGroups(tagGroups: OrgTagGroup[], showEyeIcons: boolean): O
308
312
  y: 0,
309
313
  width: maxRowWidth + LEGEND_PAD * 2,
310
314
  height: LEGEND_HEADER_H + numRows * LEGEND_ENTRY_H + LEGEND_PAD,
315
+ minifiedWidth,
316
+ minifiedHeight: LEGEND_HEADER_H + LEGEND_PAD,
311
317
  });
312
318
  }
313
319
 
@@ -577,18 +583,26 @@ export function layoutOrg(
577
583
  {
578
584
  type HNode = (typeof h);
579
585
  const subtreeExtent = (node: HNode): { minX: number; maxX: number } => {
580
- let min = Infinity;
581
- let max = -Infinity;
582
- const walk = (n: HNode) => {
583
- // Container boxes extend beyond card bounds by padding
584
- const pad = n.data.orgNode.isContainer ? CONTAINER_PAD_X : 0;
585
- const l = n.x! - n.data.width / 2 - pad;
586
- const r = n.x! + n.data.width / 2 + pad;
587
- if (l < min) min = l;
588
- if (r > max) max = r;
589
- if (n.children) n.children.forEach(walk);
590
- };
591
- walk(node);
586
+ // Start with this node's own card/header bounds
587
+ let min = node.x! - node.data.width / 2;
588
+ let max = node.x! + node.data.width / 2;
589
+
590
+ // Include children's subtree extents
591
+ if (node.children) {
592
+ for (const child of node.children) {
593
+ const childExt = subtreeExtent(child);
594
+ if (childExt.minX < min) min = childExt.minX;
595
+ if (childExt.maxX > max) max = childExt.maxX;
596
+ }
597
+ }
598
+
599
+ // Container boxes wrap their content with padding — mirror the
600
+ // actual bounding-box computation so compaction sees the true width.
601
+ if (node.data.orgNode.isContainer) {
602
+ min -= CONTAINER_PAD_X;
603
+ max += CONTAINER_PAD_X;
604
+ }
605
+
592
606
  return { minX: min, maxX: max };
593
607
  };
594
608
 
@@ -1072,12 +1086,22 @@ export function layoutOrg(
1072
1086
 
1073
1087
  const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
1074
1088
 
1075
- if (legendGroups.length > 0) {
1089
+ // When a tag group is active, only that group is laid out (full size).
1090
+ // When none is active, all groups are laid out minified.
1091
+ const visibleGroups = activeTagGroup != null
1092
+ ? legendGroups.filter((g) => g.name.toLowerCase() === activeTagGroup.toLowerCase())
1093
+ : legendGroups;
1094
+ const effectiveW = (g: OrgLegendGroup) =>
1095
+ activeTagGroup != null ? g.width : g.minifiedWidth;
1096
+ const effectiveH = (g: OrgLegendGroup) =>
1097
+ activeTagGroup != null ? g.height : g.minifiedHeight;
1098
+
1099
+ if (visibleGroups.length > 0) {
1076
1100
  if (legendPosition === 'bottom') {
1077
1101
  // Bottom: center legend groups horizontally below diagram content
1078
1102
  const totalGroupsWidth =
1079
- legendGroups.reduce((s, g) => s + g.width, 0) +
1080
- (legendGroups.length - 1) * H_GAP;
1103
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1104
+ (visibleGroups.length - 1) * H_GAP;
1081
1105
  const neededWidth = totalGroupsWidth + MARGIN * 2;
1082
1106
 
1083
1107
  if (neededWidth > totalWidth) {
@@ -1096,24 +1120,25 @@ export function layoutOrg(
1096
1120
 
1097
1121
  let cx = startX;
1098
1122
  let maxH = 0;
1099
- for (const g of legendGroups) {
1123
+ for (const g of visibleGroups) {
1100
1124
  g.x = cx;
1101
1125
  g.y = legendY;
1102
- cx += g.width + H_GAP;
1103
- if (g.height > maxH) maxH = g.height;
1126
+ cx += effectiveW(g) + H_GAP;
1127
+ const h = effectiveH(g);
1128
+ if (h > maxH) maxH = h;
1104
1129
  }
1105
1130
 
1106
1131
  finalHeight = totalHeight + LEGEND_GAP + maxH;
1107
1132
  } else {
1108
1133
  // Top (default): stack legend groups vertically at top-right
1109
- const maxLegendWidth = Math.max(...legendGroups.map((g) => g.width));
1134
+ const maxLegendWidth = Math.max(...visibleGroups.map((g) => effectiveW(g)));
1110
1135
  const legendStartX = totalWidth - MARGIN + LEGEND_GAP;
1111
1136
  let legendY = MARGIN;
1112
1137
 
1113
- for (const g of legendGroups) {
1138
+ for (const g of visibleGroups) {
1114
1139
  g.x = legendStartX;
1115
1140
  g.y = legendY;
1116
- legendY += g.height + LEGEND_V_GAP;
1141
+ legendY += effectiveH(g) + LEGEND_V_GAP;
1117
1142
  }
1118
1143
 
1119
1144
  const legendRight = legendStartX + maxLegendWidth + MARGIN;
package/src/org/parser.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
+ import type { DgmoError } from '../diagnostics';
4
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
5
 
4
6
  // ============================================================
5
7
  // Types
@@ -37,6 +39,7 @@ export interface ParsedOrg {
37
39
  roots: OrgNode[];
38
40
  tagGroups: OrgTagGroup[];
39
41
  options: Record<string, string>;
42
+ diagnostics: DgmoError[];
40
43
  error: string | null;
41
44
  }
42
45
 
@@ -90,12 +93,26 @@ export function parseOrg(
90
93
  roots: [],
91
94
  tagGroups: [],
92
95
  options: {},
96
+ diagnostics: [],
93
97
  error: null,
94
98
  };
95
99
 
96
- if (!content || !content.trim()) {
97
- result.error = 'No content provided';
100
+ const fail = (line: number, message: string): ParsedOrg => {
101
+ const diag = makeDgmoError(line, message);
102
+ result.diagnostics.push(diag);
103
+ result.error = formatDgmoError(diag);
98
104
  return result;
105
+ };
106
+
107
+ /** Push a recoverable error and continue parsing. */
108
+ const pushError = (line: number, message: string): void => {
109
+ const diag = makeDgmoError(line, message);
110
+ result.diagnostics.push(diag);
111
+ if (!result.error) result.error = formatDgmoError(diag);
112
+ };
113
+
114
+ if (!content || !content.trim()) {
115
+ return fail(0, 'No content provided');
99
116
  }
100
117
 
101
118
  const lines = content.split('\n');
@@ -138,8 +155,11 @@ export function parseOrg(
138
155
  if (chartMatch) {
139
156
  const chartType = chartMatch[1].trim().toLowerCase();
140
157
  if (chartType !== 'org') {
141
- result.error = `Line ${lineNumber}: Expected chart type "org", got "${chartType}"`;
142
- return result;
158
+ const allTypes = ['org', 'class', 'flowchart', 'sequence', 'er', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
159
+ let msg = `Expected chart type "org", got "${chartType}"`;
160
+ const hint = suggest(chartType, allTypes);
161
+ if (hint) msg += `. ${hint}`;
162
+ return fail(lineNumber, msg);
143
163
  }
144
164
  continue;
145
165
  }
@@ -172,8 +192,8 @@ export function parseOrg(
172
192
  const groupMatch = trimmed.match(GROUP_HEADING_RE);
173
193
  if (groupMatch) {
174
194
  if (contentStarted) {
175
- result.error = `Line ${lineNumber}: Tag groups (##) must appear before org content`;
176
- return result;
195
+ pushError(lineNumber, 'Tag groups (##) must appear before org content');
196
+ continue;
177
197
  }
178
198
  const groupName = groupMatch[1].trim();
179
199
  const alias = groupMatch[2] || undefined;
@@ -201,8 +221,8 @@ export function parseOrg(
201
221
  : trimmed;
202
222
  const { label, color } = extractColor(entryText, palette);
203
223
  if (!color) {
204
- result.error = `Line ${lineNumber}: Expected 'Value(color)' in tag group '${currentTagGroup.name}'`;
205
- return result;
224
+ pushError(lineNumber, `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`);
225
+ continue;
206
226
  }
207
227
  if (isDefault) {
208
228
  currentTagGroup.defaultValue = label;
@@ -259,10 +279,10 @@ export function parseOrg(
259
279
  // Find the parent node: top of stack (the most recent node)
260
280
  const parent = findMetadataParent(indent, indentStack);
261
281
  if (!parent) {
262
- result.error = `Line ${lineNumber}: Metadata has no parent node`;
263
- return result;
282
+ pushError(lineNumber, 'Metadata has no parent node');
283
+ } else {
284
+ parent.metadata[key] = value;
264
285
  }
265
- parent.metadata[key] = value;
266
286
  } else if (metadataMatch && indentStack.length === 0) {
267
287
  // Metadata with no parent — could be a node label that happens to contain ':'
268
288
  // Treat it as a node if it's at indent 0 and no nodes exist yet
@@ -272,8 +292,7 @@ export function parseOrg(
272
292
  const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap);
273
293
  attachNode(node, indent, indentStack, result);
274
294
  } else {
275
- result.error = `Line ${lineNumber}: Metadata has no parent node`;
276
- return result;
295
+ pushError(lineNumber, 'Metadata has no parent node');
277
296
  }
278
297
  } else {
279
298
  // It's a node label — possibly with single-line pipe-delimited metadata
@@ -283,7 +302,9 @@ export function parseOrg(
283
302
  }
284
303
 
285
304
  if (result.roots.length === 0 && !result.error) {
286
- result.error = 'No nodes found in org chart';
305
+ const diag = makeDgmoError(1, 'No nodes found in org chart');
306
+ result.diagnostics.push(diag);
307
+ result.error = formatDgmoError(diag);
287
308
  }
288
309
 
289
310
  return result;
@@ -15,6 +15,7 @@ import { layoutOrg } from './layout';
15
15
  // ============================================================
16
16
 
17
17
  const DIAGRAM_PADDING = 20;
18
+ const MAX_SCALE = 3;
18
19
  const TITLE_HEIGHT = 30;
19
20
  const TITLE_FONT_SIZE = 18;
20
21
  const LABEL_FONT_SIZE = 13;
@@ -144,7 +145,7 @@ export function renderOrg(
144
145
  const diagramH = layout.height + titleOffset;
145
146
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
146
147
  const scaleY = (height - DIAGRAM_PADDING * 2) / diagramH;
147
- const scale = Math.min(scaleX, scaleY);
148
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
148
149
 
149
150
  // Center the diagram
150
151
  const scaledW = diagramW * scale;
@@ -209,6 +210,7 @@ export function renderOrg(
209
210
  }
210
211
 
211
212
  // Render container backgrounds (bottom layer)
213
+ const colorOff = parsed.options?.color === 'off';
212
214
  for (const c of layout.containers) {
213
215
  const cG = contentG
214
216
  .append('g')
@@ -234,8 +236,8 @@ export function renderOrg(
234
236
  });
235
237
  }
236
238
 
237
- const fill = containerFill(palette, isDark, c.color);
238
- const stroke = containerStroke(palette, c.color);
239
+ const fill = containerFill(palette, isDark, colorOff ? undefined : c.color);
240
+ const stroke = containerStroke(palette, colorOff ? undefined : c.color);
239
241
 
240
242
  // Background rect
241
243
  cG.append('rect')
@@ -301,7 +303,7 @@ export function renderOrg(
301
303
  .attr('y', c.height - COLLAPSE_BAR_HEIGHT)
302
304
  .attr('width', c.width - COLLAPSE_BAR_INSET * 2)
303
305
  .attr('height', COLLAPSE_BAR_HEIGHT)
304
- .attr('fill', containerStroke(palette, c.color))
306
+ .attr('fill', containerStroke(palette, colorOff ? undefined : c.color))
305
307
  .attr('clip-path', `url(#${clipId})`)
306
308
  .attr('class', 'org-collapse-bar');
307
309
  }
@@ -360,8 +362,8 @@ export function renderOrg(
360
362
  }
361
363
 
362
364
  // Card background
363
- const fill = nodeFill(palette, isDark, node.color);
364
- const stroke = nodeStroke(palette, node.color);
365
+ const fill = nodeFill(palette, isDark, colorOff ? undefined : node.color);
366
+ const stroke = nodeStroke(palette, colorOff ? undefined : node.color);
365
367
 
366
368
  const rect = nodeG
367
369
  .append('rect')
@@ -448,22 +450,30 @@ export function renderOrg(
448
450
  .attr('y', node.height - COLLAPSE_BAR_HEIGHT)
449
451
  .attr('width', node.width - COLLAPSE_BAR_INSET * 2)
450
452
  .attr('height', COLLAPSE_BAR_HEIGHT)
451
- .attr('fill', nodeStroke(palette, node.color))
453
+ .attr('fill', nodeStroke(palette, colorOff ? undefined : node.color))
452
454
  .attr('clip-path', `url(#${clipId})`)
453
455
  .attr('class', 'org-collapse-bar');
454
456
  }
455
457
 
456
458
  }
457
459
 
458
- // Render legend — skip entirely in export mode; hide non-active groups when one is active
460
+ // Render legend — skip entirely in export mode.
461
+ // No active group: all groups rendered minified (compact chips).
462
+ // Active group selected: only that group rendered (full size), others hidden.
459
463
  if (!exportDims) for (const group of layout.legend) {
460
464
  const isActive =
461
465
  activeTagGroup != null &&
462
466
  group.name.toLowerCase() === activeTagGroup.toLowerCase();
463
467
 
464
- // When a group is active, skip rendering all other groups
468
+ // When a group is active, skip all other groups entirely
465
469
  if (activeTagGroup != null && !isActive) continue;
466
470
 
471
+ // No active group → minified; active group → full
472
+ const isMinified = activeTagGroup == null;
473
+
474
+ const renderW = isMinified ? group.minifiedWidth : group.width;
475
+ const renderH = isMinified ? group.minifiedHeight : group.height;
476
+
467
477
  const gEl = contentG
468
478
  .append('g')
469
479
  .attr('transform', `translate(${group.x}, ${group.y})`)
@@ -477,8 +487,8 @@ export function renderOrg(
477
487
  .append('rect')
478
488
  .attr('x', 0)
479
489
  .attr('y', 0)
480
- .attr('width', group.width)
481
- .attr('height', group.height)
490
+ .attr('width', renderW)
491
+ .attr('height', renderH)
482
492
  .attr('rx', LEGEND_RADIUS)
483
493
  .attr('fill', legendFill);
484
494
 
@@ -499,11 +509,14 @@ export function renderOrg(
499
509
  .append('text')
500
510
  .attr('x', LEGEND_PAD)
501
511
  .attr('y', LEGEND_HEADER_H / 2 + LEGEND_FONT_SIZE / 2 - 2)
502
- .attr('fill', palette.text)
512
+ .attr('fill', isMinified ? palette.textMuted : palette.text)
503
513
  .attr('font-size', LEGEND_FONT_SIZE)
504
514
  .attr('font-weight', 'bold')
505
515
  .text(group.name);
506
516
 
517
+ // Minified groups only show the header — skip entries and eye icon
518
+ if (isMinified) continue;
519
+
507
520
  // Eye icon for visibility toggle (interactive only, not export)
508
521
  if (hiddenAttributes !== undefined && !exportDims) {
509
522
  const groupKey = group.name.toLowerCase();