@diagrammo/dgmo 0.2.19 → 0.2.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.
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 {
@@ -95,18 +97,17 @@ const CONTAINER_META_LINE_HEIGHT = 16;
95
97
  const STACK_V_GAP = 20;
96
98
 
97
99
 
98
- // Legend
100
+ // Legend (kanban-style pills)
99
101
  const LEGEND_GAP = 30;
100
- const LEGEND_DOT_R = 5;
101
- const LEGEND_DOT_TEXT_GAP = 6;
102
- const LEGEND_ENTRY_GAP = 12;
103
- const LEGEND_PAD = 10;
104
- const LEGEND_HEADER_H = 20;
105
- const LEGEND_ENTRY_H = 18;
106
- const LEGEND_MAX_PER_ROW = 3;
107
- const LEGEND_V_GAP = 12;
108
- const EYE_ICON_WIDTH = 16;
109
- const EYE_ICON_GAP = 6;
102
+ const LEGEND_HEIGHT = 28;
103
+ const LEGEND_PILL_PAD = 16;
104
+ const LEGEND_PILL_FONT_W = 11 * 0.6;
105
+ const LEGEND_CAPSULE_PAD = 4;
106
+ const LEGEND_DOT_R = 4;
107
+ const LEGEND_ENTRY_FONT_W = 10 * 0.6;
108
+ const LEGEND_ENTRY_DOT_GAP = 4;
109
+ const LEGEND_ENTRY_TRAIL = 8;
110
+ const LEGEND_GROUP_GAP = 12;
110
111
 
111
112
  // ============================================================
112
113
  // Helpers
@@ -269,45 +270,35 @@ function centerHeavyChildren(node: TreeNode): void {
269
270
  // Layout
270
271
  // ============================================================
271
272
 
272
- function computeLegendGroups(tagGroups: OrgTagGroup[], showEyeIcons: boolean): OrgLegendGroup[] {
273
+ function computeLegendGroups(tagGroups: OrgTagGroup[], _showEyeIcons: boolean): OrgLegendGroup[] {
273
274
  const groups: OrgLegendGroup[] = [];
274
275
 
275
276
  for (const group of tagGroups) {
276
277
  if (group.entries.length === 0) continue;
277
278
 
278
- const entryWidths = group.entries.map(
279
- (e) =>
280
- LEGEND_DOT_R * 2 + LEGEND_DOT_TEXT_GAP + e.value.length * CHAR_WIDTH
281
- );
279
+ const pillWidth = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
282
280
 
283
- // Compute max width per column so columns align across rows
284
- const numRows = Math.ceil(entryWidths.length / LEGEND_MAX_PER_ROW);
285
- const colWidths: number[] = [];
286
- for (let col = 0; col < LEGEND_MAX_PER_ROW; col++) {
287
- let maxW = 0;
288
- for (let row = 0; row < numRows; row++) {
289
- const idx = row * LEGEND_MAX_PER_ROW + col;
290
- if (idx < entryWidths.length && entryWidths[idx] > maxW) {
291
- maxW = entryWidths[idx];
292
- }
293
- }
294
- if (maxW > 0) colWidths.push(maxW);
281
+ // Capsule: pad + pill + gap + entries + pad
282
+ let entriesWidth = 0;
283
+ for (const entry of group.entries) {
284
+ entriesWidth +=
285
+ LEGEND_DOT_R * 2 +
286
+ LEGEND_ENTRY_DOT_GAP +
287
+ entry.value.length * LEGEND_ENTRY_FONT_W +
288
+ LEGEND_ENTRY_TRAIL;
295
289
  }
296
-
297
- const eyeExtra = showEyeIcons ? EYE_ICON_GAP + EYE_ICON_WIDTH : 0;
298
- const headerWidth = group.name.length * CHAR_WIDTH + eyeExtra;
299
- const totalColumnsWidth =
300
- colWidths.reduce((s, w) => s + w, 0) +
301
- (colWidths.length - 1) * LEGEND_ENTRY_GAP;
302
- const maxRowWidth = Math.max(headerWidth, totalColumnsWidth);
290
+ const capsuleWidth =
291
+ LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
303
292
 
304
293
  groups.push({
305
294
  name: group.name,
306
295
  entries: group.entries.map((e) => ({ value: e.value, color: e.color })),
307
296
  x: 0,
308
297
  y: 0,
309
- width: maxRowWidth + LEGEND_PAD * 2,
310
- height: LEGEND_HEADER_H + numRows * LEGEND_ENTRY_H + LEGEND_PAD,
298
+ width: capsuleWidth,
299
+ height: LEGEND_HEIGHT,
300
+ minifiedWidth: pillWidth,
301
+ minifiedHeight: LEGEND_HEIGHT,
311
302
  });
312
303
  }
313
304
 
@@ -350,7 +341,28 @@ export function layoutOrg(
350
341
  hiddenAttributes?: Set<string>
351
342
  ): OrgLayoutResult {
352
343
  if (parsed.roots.length === 0) {
353
- return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
344
+ // Legend-only: compute and position legend groups even without nodes
345
+ const showEyeIcons = hiddenAttributes !== undefined;
346
+ const legendGroups = computeLegendGroups(parsed.tagGroups, showEyeIcons);
347
+ if (legendGroups.length === 0) {
348
+ return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
349
+ }
350
+
351
+ // Layout legend groups horizontally (all minified when no nodes)
352
+ let cx = MARGIN;
353
+ for (const g of legendGroups) {
354
+ g.x = cx;
355
+ g.y = MARGIN;
356
+ cx += g.minifiedWidth + LEGEND_GROUP_GAP;
357
+ }
358
+ return {
359
+ nodes: [],
360
+ edges: [],
361
+ containers: [],
362
+ legend: legendGroups,
363
+ width: cx - LEGEND_GROUP_GAP + MARGIN,
364
+ height: LEGEND_HEIGHT + MARGIN * 2,
365
+ };
354
366
  }
355
367
 
356
368
  // Inject default tag group values into node metadata for display.
@@ -577,18 +589,26 @@ export function layoutOrg(
577
589
  {
578
590
  type HNode = (typeof h);
579
591
  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);
592
+ // Start with this node's own card/header bounds
593
+ let min = node.x! - node.data.width / 2;
594
+ let max = node.x! + node.data.width / 2;
595
+
596
+ // Include children's subtree extents
597
+ if (node.children) {
598
+ for (const child of node.children) {
599
+ const childExt = subtreeExtent(child);
600
+ if (childExt.minX < min) min = childExt.minX;
601
+ if (childExt.maxX > max) max = childExt.maxX;
602
+ }
603
+ }
604
+
605
+ // Container boxes wrap their content with padding — mirror the
606
+ // actual bounding-box computation so compaction sees the true width.
607
+ if (node.data.orgNode.isContainer) {
608
+ min -= CONTAINER_PAD_X;
609
+ max += CONTAINER_PAD_X;
610
+ }
611
+
592
612
  return { minX: min, maxX: max };
593
613
  };
594
614
 
@@ -1072,12 +1092,22 @@ export function layoutOrg(
1072
1092
 
1073
1093
  const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
1074
1094
 
1075
- if (legendGroups.length > 0) {
1095
+ // When a tag group is active, only that group is laid out (full size).
1096
+ // When none is active, all groups are laid out minified.
1097
+ const visibleGroups = activeTagGroup != null
1098
+ ? legendGroups.filter((g) => g.name.toLowerCase() === activeTagGroup.toLowerCase())
1099
+ : legendGroups;
1100
+ const effectiveW = (g: OrgLegendGroup) =>
1101
+ activeTagGroup != null ? g.width : g.minifiedWidth;
1102
+ const effectiveH = (g: OrgLegendGroup) =>
1103
+ activeTagGroup != null ? g.height : g.minifiedHeight;
1104
+
1105
+ if (visibleGroups.length > 0) {
1076
1106
  if (legendPosition === 'bottom') {
1077
1107
  // Bottom: center legend groups horizontally below diagram content
1078
1108
  const totalGroupsWidth =
1079
- legendGroups.reduce((s, g) => s + g.width, 0) +
1080
- (legendGroups.length - 1) * H_GAP;
1109
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1110
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1081
1111
  const neededWidth = totalGroupsWidth + MARGIN * 2;
1082
1112
 
1083
1113
  if (neededWidth > totalWidth) {
@@ -1095,33 +1125,34 @@ export function layoutOrg(
1095
1125
  const startX = (finalWidth - totalGroupsWidth) / 2;
1096
1126
 
1097
1127
  let cx = startX;
1098
- let maxH = 0;
1099
- for (const g of legendGroups) {
1128
+ for (const g of visibleGroups) {
1100
1129
  g.x = cx;
1101
1130
  g.y = legendY;
1102
- cx += g.width + H_GAP;
1103
- if (g.height > maxH) maxH = g.height;
1131
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
1104
1132
  }
1105
1133
 
1106
- finalHeight = totalHeight + LEGEND_GAP + maxH;
1134
+ finalHeight = totalHeight + LEGEND_GAP + LEGEND_HEIGHT;
1107
1135
  } else {
1108
- // Top (default): stack legend groups vertically at top-right
1109
- const maxLegendWidth = Math.max(...legendGroups.map((g) => g.width));
1136
+ // Top: horizontal row at top-right
1137
+ const totalGroupsWidth =
1138
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1139
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1110
1140
  const legendStartX = totalWidth - MARGIN + LEGEND_GAP;
1111
- let legendY = MARGIN;
1141
+ const legendY = MARGIN;
1112
1142
 
1113
- for (const g of legendGroups) {
1114
- g.x = legendStartX;
1143
+ let cx = legendStartX;
1144
+ for (const g of visibleGroups) {
1145
+ g.x = cx;
1115
1146
  g.y = legendY;
1116
- legendY += g.height + LEGEND_V_GAP;
1147
+ cx += effectiveW(g) + LEGEND_GROUP_GAP;
1117
1148
  }
1118
1149
 
1119
- const legendRight = legendStartX + maxLegendWidth + MARGIN;
1150
+ const legendRight = legendStartX + totalGroupsWidth + MARGIN;
1120
1151
  if (legendRight > finalWidth) {
1121
1152
  finalWidth = legendRight;
1122
1153
  }
1123
1154
 
1124
- const legendBottom = legendY - LEGEND_V_GAP + MARGIN;
1155
+ const legendBottom = legendY + LEGEND_HEIGHT + MARGIN;
1125
1156
  if (legendBottom > finalHeight) {
1126
1157
  finalHeight = legendBottom;
1127
1158
  }
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
 
@@ -76,6 +79,20 @@ const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
76
79
  const TITLE_RE = /^title\s*:\s*(.+)/i;
77
80
  const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
78
81
 
82
+ // ============================================================
83
+ // Inference
84
+ // ============================================================
85
+
86
+ /** Returns true if content contains tag group headings (`## ...`), suggesting an org chart. */
87
+ export function looksLikeOrg(content: string): boolean {
88
+ for (const line of content.split('\n')) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed || trimmed.startsWith('//')) continue;
91
+ if (GROUP_HEADING_RE.test(trimmed)) return true;
92
+ }
93
+ return false;
94
+ }
95
+
79
96
  // ============================================================
80
97
  // Parser
81
98
  // ============================================================
@@ -90,12 +107,26 @@ export function parseOrg(
90
107
  roots: [],
91
108
  tagGroups: [],
92
109
  options: {},
110
+ diagnostics: [],
93
111
  error: null,
94
112
  };
95
113
 
96
- if (!content || !content.trim()) {
97
- result.error = 'No content provided';
114
+ const fail = (line: number, message: string): ParsedOrg => {
115
+ const diag = makeDgmoError(line, message);
116
+ result.diagnostics.push(diag);
117
+ result.error = formatDgmoError(diag);
98
118
  return result;
119
+ };
120
+
121
+ /** Push a recoverable error and continue parsing. */
122
+ const pushError = (line: number, message: string): void => {
123
+ const diag = makeDgmoError(line, message);
124
+ result.diagnostics.push(diag);
125
+ if (!result.error) result.error = formatDgmoError(diag);
126
+ };
127
+
128
+ if (!content || !content.trim()) {
129
+ return fail(0, 'No content provided');
99
130
  }
100
131
 
101
132
  const lines = content.split('\n');
@@ -138,8 +169,11 @@ export function parseOrg(
138
169
  if (chartMatch) {
139
170
  const chartType = chartMatch[1].trim().toLowerCase();
140
171
  if (chartType !== 'org') {
141
- result.error = `Line ${lineNumber}: Expected chart type "org", got "${chartType}"`;
142
- return result;
172
+ const allTypes = ['org', 'class', 'flowchart', 'sequence', 'er', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
173
+ let msg = `Expected chart type "org", got "${chartType}"`;
174
+ const hint = suggest(chartType, allTypes);
175
+ if (hint) msg += `. ${hint}`;
176
+ return fail(lineNumber, msg);
143
177
  }
144
178
  continue;
145
179
  }
@@ -172,8 +206,8 @@ export function parseOrg(
172
206
  const groupMatch = trimmed.match(GROUP_HEADING_RE);
173
207
  if (groupMatch) {
174
208
  if (contentStarted) {
175
- result.error = `Line ${lineNumber}: Tag groups (##) must appear before org content`;
176
- return result;
209
+ pushError(lineNumber, 'Tag groups (##) must appear before org content');
210
+ continue;
177
211
  }
178
212
  const groupName = groupMatch[1].trim();
179
213
  const alias = groupMatch[2] || undefined;
@@ -201,8 +235,8 @@ export function parseOrg(
201
235
  : trimmed;
202
236
  const { label, color } = extractColor(entryText, palette);
203
237
  if (!color) {
204
- result.error = `Line ${lineNumber}: Expected 'Value(color)' in tag group '${currentTagGroup.name}'`;
205
- return result;
238
+ pushError(lineNumber, `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`);
239
+ continue;
206
240
  }
207
241
  if (isDefault) {
208
242
  currentTagGroup.defaultValue = label;
@@ -259,10 +293,10 @@ export function parseOrg(
259
293
  // Find the parent node: top of stack (the most recent node)
260
294
  const parent = findMetadataParent(indent, indentStack);
261
295
  if (!parent) {
262
- result.error = `Line ${lineNumber}: Metadata has no parent node`;
263
- return result;
296
+ pushError(lineNumber, 'Metadata has no parent node');
297
+ } else {
298
+ parent.metadata[key] = value;
264
299
  }
265
- parent.metadata[key] = value;
266
300
  } else if (metadataMatch && indentStack.length === 0) {
267
301
  // Metadata with no parent — could be a node label that happens to contain ':'
268
302
  // Treat it as a node if it's at indent 0 and no nodes exist yet
@@ -272,8 +306,7 @@ export function parseOrg(
272
306
  const node = parseNodeLabel(trimmed, indent, lineNumber, palette, ++nodeCounter, aliasMap);
273
307
  attachNode(node, indent, indentStack, result);
274
308
  } else {
275
- result.error = `Line ${lineNumber}: Metadata has no parent node`;
276
- return result;
309
+ pushError(lineNumber, 'Metadata has no parent node');
277
310
  }
278
311
  } else {
279
312
  // It's a node label — possibly with single-line pipe-delimited metadata
@@ -282,8 +315,10 @@ export function parseOrg(
282
315
  }
283
316
  }
284
317
 
285
- if (result.roots.length === 0 && !result.error) {
286
- result.error = 'No nodes found in org chart';
318
+ if (result.roots.length === 0 && result.tagGroups.length === 0 && !result.error) {
319
+ const diag = makeDgmoError(1, 'No nodes found in org chart');
320
+ result.diagnostics.push(diag);
321
+ result.error = formatDgmoError(diag);
287
322
  }
288
323
 
289
324
  return result;