@diagrammo/dgmo 0.2.19 → 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.
@@ -2,10 +2,15 @@
2
2
  // .dgmo Unified Format — Chart Type Router
3
3
  // ============================================================
4
4
 
5
- import { looksLikeSequence } from './sequence/parser';
6
- import { looksLikeFlowchart } from './graph/flowchart-parser';
7
- import { looksLikeClassDiagram } from './class/parser';
8
- import { looksLikeERDiagram } from './er/parser';
5
+ import { looksLikeSequence, parseSequenceDgmo } from './sequence/parser';
6
+ import { looksLikeFlowchart, parseFlowchart } from './graph/flowchart-parser';
7
+ import { looksLikeClassDiagram, parseClassDiagram } from './class/parser';
8
+ import { looksLikeERDiagram, parseERDiagram } from './er/parser';
9
+ import { parseChart } from './chart';
10
+ import { parseEChart } from './echarts';
11
+ import { parseD3 } from './d3';
12
+ import { parseOrg } from './org/parser';
13
+ import type { DgmoError } from './diagnostics';
9
14
 
10
15
  /**
11
16
  * Framework identifiers used by the .dgmo router.
@@ -85,3 +90,61 @@ export function parseDgmoChartType(content: string): string | null {
85
90
 
86
91
  return null;
87
92
  }
93
+
94
+ // Standard chart types parsed by parseChart (then rendered via ECharts)
95
+ const STANDARD_CHART_TYPES = new Set([
96
+ 'bar', 'line', 'multi-line', 'area', 'pie', 'doughnut',
97
+ 'radar', 'polar-area', 'bar-stacked',
98
+ ]);
99
+
100
+ // ECharts-native types parsed by parseEChart
101
+ const ECHART_TYPES = new Set([
102
+ 'scatter', 'sankey', 'chord', 'function', 'heatmap', 'funnel',
103
+ ]);
104
+
105
+ /**
106
+ * Parse DGMO content and return diagnostics without rendering.
107
+ * Useful for the CLI and editor to surface all errors before attempting render.
108
+ */
109
+ export function parseDgmo(content: string): { diagnostics: DgmoError[] } {
110
+ const chartType = parseDgmoChartType(content);
111
+
112
+ if (!chartType) {
113
+ // No chart type detected — try D3 parser as fallback (it handles missing chart: line)
114
+ const parsed = parseD3(content);
115
+ return { diagnostics: parsed.diagnostics };
116
+ }
117
+
118
+ if (chartType === 'sequence') {
119
+ const parsed = parseSequenceDgmo(content);
120
+ return { diagnostics: parsed.diagnostics };
121
+ }
122
+ if (chartType === 'flowchart') {
123
+ const parsed = parseFlowchart(content);
124
+ return { diagnostics: parsed.diagnostics };
125
+ }
126
+ if (chartType === 'class') {
127
+ const parsed = parseClassDiagram(content);
128
+ return { diagnostics: parsed.diagnostics };
129
+ }
130
+ if (chartType === 'er') {
131
+ const parsed = parseERDiagram(content);
132
+ return { diagnostics: parsed.diagnostics };
133
+ }
134
+ if (chartType === 'org') {
135
+ const parsed = parseOrg(content);
136
+ return { diagnostics: parsed.diagnostics };
137
+ }
138
+ if (STANDARD_CHART_TYPES.has(chartType)) {
139
+ const parsed = parseChart(content);
140
+ return { diagnostics: parsed.diagnostics };
141
+ }
142
+ if (ECHART_TYPES.has(chartType)) {
143
+ const parsed = parseEChart(content);
144
+ return { diagnostics: parsed.diagnostics };
145
+ }
146
+
147
+ // D3 types (slope, wordcloud, arc, timeline, venn, quadrant)
148
+ const parsed = parseD3(content);
149
+ return { diagnostics: parsed.diagnostics };
150
+ }
@@ -0,0 +1,77 @@
1
+ // ============================================================
2
+ // Structured Diagnostic Types
3
+ // ============================================================
4
+
5
+ export type DgmoSeverity = 'error' | 'warning';
6
+
7
+ export interface DgmoError {
8
+ line: number; // 1-based (0 = no line info)
9
+ column?: number; // optional 1-based column
10
+ message: string; // without "Line N:" prefix
11
+ severity: DgmoSeverity;
12
+ }
13
+
14
+ export function makeDgmoError(
15
+ line: number,
16
+ message: string,
17
+ severity: DgmoSeverity = 'error'
18
+ ): DgmoError {
19
+ return { line, message, severity };
20
+ }
21
+
22
+ export function formatDgmoError(err: DgmoError): string {
23
+ return err.line > 0 ? `Line ${err.line}: ${err.message}` : err.message;
24
+ }
25
+
26
+ // ============================================================
27
+ // "Did you mean?" Suggestions
28
+ // ============================================================
29
+
30
+ /**
31
+ * Simple Levenshtein distance between two strings.
32
+ */
33
+ function levenshtein(a: string, b: string): number {
34
+ const m = a.length;
35
+ const n = b.length;
36
+ const dp: number[] = Array(n + 1)
37
+ .fill(0)
38
+ .map((_, i) => i);
39
+
40
+ for (let i = 1; i <= m; i++) {
41
+ let prev = dp[0];
42
+ dp[0] = i;
43
+ for (let j = 1; j <= n; j++) {
44
+ const tmp = dp[j];
45
+ dp[j] =
46
+ a[i - 1] === b[j - 1]
47
+ ? prev
48
+ : 1 + Math.min(prev, dp[j], dp[j - 1]);
49
+ prev = tmp;
50
+ }
51
+ }
52
+ return dp[n];
53
+ }
54
+
55
+ /**
56
+ * Returns a "did you mean 'X'?" suggestion if the input is close to one of the candidates.
57
+ * Returns null if no good match is found.
58
+ * Threshold: distance ≤ max(2, floor(input.length / 3))
59
+ */
60
+ export function suggest(input: string, candidates: readonly string[]): string | null {
61
+ if (!input || candidates.length === 0) return null;
62
+ const lower = input.toLowerCase();
63
+ const threshold = Math.max(2, Math.floor(lower.length / 3));
64
+
65
+ let best: string | null = null;
66
+ let bestDist = Infinity;
67
+
68
+ for (const c of candidates) {
69
+ const dist = levenshtein(lower, c.toLowerCase());
70
+ if (dist < bestDist && dist <= threshold && dist > 0) {
71
+ bestDist = dist;
72
+ best = c;
73
+ }
74
+ }
75
+
76
+ return best ? `Did you mean '${best}'?` : null;
77
+ }
package/src/echarts.ts CHANGED
@@ -52,6 +52,8 @@ export interface ParsedHeatmapRow {
52
52
  lineNumber: number;
53
53
  }
54
54
 
55
+ import type { DgmoError } from './diagnostics';
56
+
55
57
  export interface ParsedEChart {
56
58
  type: EChartsChartType;
57
59
  title?: string;
@@ -72,6 +74,7 @@ export interface ParsedEChart {
72
74
  sizelabel?: string;
73
75
  showLabels?: boolean;
74
76
  categoryColors?: Record<string, string>;
77
+ diagnostics: DgmoError[];
75
78
  error?: string;
76
79
  }
77
80
 
@@ -84,6 +87,7 @@ import type { PaletteColors } from './palettes';
84
87
  import { getSeriesColors, getSegmentColors } from './palettes';
85
88
  import { parseChart } from './chart';
86
89
  import type { ParsedChart } from './chart';
90
+ import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
87
91
 
88
92
  // ============================================================
89
93
  // Parser
@@ -111,6 +115,7 @@ export function parseEChart(
111
115
  const result: ParsedEChart = {
112
116
  type: 'scatter',
113
117
  data: [],
118
+ diagnostics: [],
114
119
  };
115
120
 
116
121
  // Track current category for grouped scatter charts
@@ -168,7 +173,13 @@ export function parseEChart(
168
173
  ) {
169
174
  result.type = chartType;
170
175
  } else {
171
- result.error = `Unsupported chart type: ${value}. Supported types: scatter, sankey, chord, function, heatmap, funnel.`;
176
+ const validTypes = ['scatter', 'sankey', 'chord', 'function', 'heatmap', 'funnel'];
177
+ let msg = `Unsupported chart type: ${value}. Supported types: ${validTypes.join(', ')}.`;
178
+ const hint = suggest(chartType, validTypes);
179
+ if (hint) msg += ` ${hint}`;
180
+ const diag = makeDgmoError(lineNumber, msg);
181
+ result.diagnostics.push(diag);
182
+ result.error = formatDgmoError(diag);
172
183
  return result;
173
184
  }
174
185
  continue;
@@ -341,42 +352,40 @@ export function parseEChart(
341
352
  }
342
353
  }
343
354
 
355
+ const warn = (line: number, message: string): void => {
356
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
357
+ };
358
+
344
359
  if (!result.error) {
345
360
  if (result.type === 'sankey') {
346
361
  if (!result.links || result.links.length === 0) {
347
- result.error =
348
- 'No links found. Add links in format: Source -> Target: 123';
362
+ warn(1, 'No links found. Add links in format: Source -> Target: 123');
349
363
  }
350
364
  } else if (result.type === 'chord') {
351
365
  if (!result.links || result.links.length === 0) {
352
- result.error =
353
- 'No links found. Add links in format: Source -> Target: 123';
366
+ warn(1, 'No links found. Add links in format: Source -> Target: 123');
354
367
  }
355
368
  } else if (result.type === 'function') {
356
369
  if (!result.functions || result.functions.length === 0) {
357
- result.error =
358
- 'No functions found. Add functions in format: Name: expression';
370
+ warn(1, 'No functions found. Add functions in format: Name: expression');
359
371
  }
360
372
  if (!result.xRange) {
361
373
  result.xRange = { min: -10, max: 10 }; // Default range
362
374
  }
363
375
  } else if (result.type === 'scatter') {
364
376
  if (!result.scatterPoints || result.scatterPoints.length === 0) {
365
- result.error =
366
- 'No scatter points found. Add points in format: Name: x, y or Name: x, y, size';
377
+ warn(1, 'No scatter points found. Add points in format: Name: x, y or Name: x, y, size');
367
378
  }
368
379
  } else if (result.type === 'heatmap') {
369
380
  if (!result.heatmapRows || result.heatmapRows.length === 0) {
370
- result.error =
371
- 'No heatmap data found. Add data in format: RowLabel: val1, val2, val3';
381
+ warn(1, 'No heatmap data found. Add data in format: RowLabel: val1, val2, val3');
372
382
  }
373
383
  if (!result.columns || result.columns.length === 0) {
374
- result.error =
375
- 'No columns defined. Add columns in format: columns: Col1, Col2, Col3';
384
+ warn(1, 'No columns defined. Add columns in format: columns: Col1, Col2, Col3');
376
385
  }
377
386
  } else if (result.type === 'funnel') {
378
387
  if (result.data.length === 0) {
379
- result.error = 'No data found. Add data in format: Label: value';
388
+ warn(1, 'No data found. Add data in format: Label: value');
380
389
  }
381
390
  }
382
391
  }
package/src/er/layout.ts CHANGED
@@ -153,17 +153,59 @@ export function layoutERDiagram(parsed: ParsedERDiagram): ERLayoutResult {
153
153
  };
154
154
  });
155
155
 
156
- // Compute total dimensions
157
- let totalWidth = 0;
158
- let totalHeight = 0;
156
+ // Compute total dimensions from nodes, edge points, and labels
157
+ let minX = Infinity;
158
+ let minY = Infinity;
159
+ let maxX = 0;
160
+ let maxY = 0;
159
161
  for (const node of layoutNodes) {
162
+ const left = node.x - node.width / 2;
160
163
  const right = node.x + node.width / 2;
164
+ const top = node.y - node.height / 2;
161
165
  const bottom = node.y + node.height / 2;
162
- if (right > totalWidth) totalWidth = right;
163
- if (bottom > totalHeight) totalHeight = bottom;
166
+ if (left < minX) minX = left;
167
+ if (right > maxX) maxX = right;
168
+ if (top < minY) minY = top;
169
+ if (bottom > maxY) maxY = bottom;
164
170
  }
165
- totalWidth += 40;
166
- totalHeight += 40;
171
+ for (const edge of layoutEdges) {
172
+ for (const pt of edge.points) {
173
+ if (pt.x < minX) minX = pt.x;
174
+ if (pt.x > maxX) maxX = pt.x;
175
+ if (pt.y < minY) minY = pt.y;
176
+ if (pt.y > maxY) maxY = pt.y;
177
+ }
178
+ // Edge labels extend ~50px from midpoint
179
+ if (edge.label && edge.points.length > 0) {
180
+ const midPt = edge.points[Math.floor(edge.points.length / 2)];
181
+ const labelHalfW = (edge.label.length * 7 + 8) / 2;
182
+ if (midPt.x + labelHalfW > maxX) maxX = midPt.x + labelHalfW;
183
+ if (midPt.x - labelHalfW < minX) minX = midPt.x - labelHalfW;
184
+ }
185
+ }
186
+
187
+ // Padding for cardinality markers (~25px) + edge labels
188
+ const EDGE_MARGIN = 60;
189
+ const HALF_MARGIN = EDGE_MARGIN / 2;
190
+
191
+ // Shift all nodes and edges so content starts at HALF_MARGIN (breathing room on all sides)
192
+ const shiftX = -minX + HALF_MARGIN;
193
+ const shiftY = -minY + HALF_MARGIN;
194
+ for (const node of layoutNodes) {
195
+ node.x += shiftX;
196
+ node.y += shiftY;
197
+ }
198
+ for (const edge of layoutEdges) {
199
+ for (const pt of edge.points) {
200
+ pt.x += shiftX;
201
+ pt.y += shiftY;
202
+ }
203
+ }
204
+ maxX += shiftX;
205
+ maxY += shiftY;
206
+
207
+ const totalWidth = maxX + HALF_MARGIN;
208
+ const totalHeight = maxY + HALF_MARGIN;
167
209
 
168
210
  return {
169
211
  nodes: layoutNodes,
package/src/er/parser.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
4
  import type {
4
5
  ParsedERDiagram,
5
6
  ERTable,
@@ -155,6 +156,14 @@ export function parseERDiagram(
155
156
  options: {},
156
157
  tables: [],
157
158
  relationships: [],
159
+ diagnostics: [],
160
+ };
161
+
162
+ const fail = (line: number, message: string): ParsedERDiagram => {
163
+ const diag = makeDgmoError(line, message);
164
+ result.diagnostics.push(diag);
165
+ result.error = formatDgmoError(diag);
166
+ return result;
158
167
  };
159
168
 
160
169
  const tableMap = new Map<string, ERTable>();
@@ -200,8 +209,11 @@ export function parseERDiagram(
200
209
 
201
210
  if (key === 'chart') {
202
211
  if (value.toLowerCase() !== 'er') {
203
- result.error = `Line ${lineNumber}: Expected chart type "er", got "${value}"`;
204
- return result;
212
+ const allTypes = ['er', 'class', 'flowchart', 'sequence', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
213
+ let msg = `Expected chart type "er", got "${value}"`;
214
+ const hint = suggest(value.toLowerCase(), allTypes);
215
+ if (hint) msg += `. ${hint}`;
216
+ return fail(lineNumber, msg);
205
217
  }
206
218
  continue;
207
219
  }
@@ -278,8 +290,23 @@ export function parseERDiagram(
278
290
 
279
291
  // Validation
280
292
  if (result.tables.length === 0 && !result.error) {
281
- result.error =
282
- 'No tables found. Add table declarations like "users" or "orders (blue)".';
293
+ const diag = makeDgmoError(1, 'No tables found. Add table declarations like "users" or "orders (blue)".');
294
+ result.diagnostics.push(diag);
295
+ result.error = formatDgmoError(diag);
296
+ }
297
+
298
+ // Warn about isolated tables (not in any relationship)
299
+ if (result.tables.length >= 2 && result.relationships.length >= 1 && !result.error) {
300
+ const connectedIds = new Set<string>();
301
+ for (const rel of result.relationships) {
302
+ connectedIds.add(rel.source);
303
+ connectedIds.add(rel.target);
304
+ }
305
+ for (const table of result.tables) {
306
+ if (!connectedIds.has(table.id)) {
307
+ result.diagnostics.push(makeDgmoError(table.lineNumber, `Table "${table.name}" is not connected to any other table`, 'warning'));
308
+ }
309
+ }
283
310
  }
284
311
 
285
312
  return result;
@@ -17,6 +17,7 @@ import { layoutERDiagram } from './layout';
17
17
  // ============================================================
18
18
 
19
19
  const DIAGRAM_PADDING = 20;
20
+ const MAX_SCALE = 3;
20
21
  const TABLE_FONT_SIZE = 13;
21
22
  const COLUMN_FONT_SIZE = 11;
22
23
  const EDGE_LABEL_FONT_SIZE = 11;
@@ -227,7 +228,7 @@ export function renderERDiagram(
227
228
  const availH = height - titleHeight;
228
229
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
229
230
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
230
- const scale = Math.min(1, scaleX, scaleY);
231
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
231
232
 
232
233
  const scaledW = diagramW * scale;
233
234
  const scaledH = diagramH * scale;
package/src/er/types.ts CHANGED
@@ -29,6 +29,8 @@ export interface ERRelationship {
29
29
  lineNumber: number;
30
30
  }
31
31
 
32
+ import type { DgmoError } from '../diagnostics';
33
+
32
34
  export interface ParsedERDiagram {
33
35
  type: 'er';
34
36
  title?: string;
@@ -36,5 +38,6 @@ export interface ParsedERDiagram {
36
38
  options: Record<string, string>;
37
39
  tables: ERTable[];
38
40
  relationships: ERRelationship[];
41
+ diagnostics: DgmoError[];
39
42
  error?: string;
40
43
  }
@@ -1,5 +1,6 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
4
  import type {
4
5
  ParsedGraph,
5
6
  GraphNode,
@@ -236,6 +237,15 @@ export function parseFlowchart(
236
237
  direction: 'TB',
237
238
  nodes: [],
238
239
  edges: [],
240
+ options: {},
241
+ diagnostics: [],
242
+ };
243
+
244
+ const fail = (line: number, message: string): ParsedGraph => {
245
+ const diag = makeDgmoError(line, message);
246
+ result.diagnostics.push(diag);
247
+ result.error = formatDgmoError(diag);
248
+ return result;
239
249
  };
240
250
 
241
251
  const nodeMap = new Map<string, GraphNode>();
@@ -428,8 +438,11 @@ export function parseFlowchart(
428
438
 
429
439
  if (key === 'chart') {
430
440
  if (value.toLowerCase() !== 'flowchart') {
431
- result.error = `Line ${lineNumber}: Expected chart type "flowchart", got "${value}"`;
432
- return result;
441
+ const allTypes = ['flowchart', 'sequence', 'class', 'er', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
442
+ let msg = `Expected chart type "flowchart", got "${value}"`;
443
+ const hint = suggest(value.toLowerCase(), allTypes);
444
+ if (hint) msg += `. ${hint}`;
445
+ return fail(lineNumber, msg);
433
446
  }
434
447
  continue;
435
448
  }
@@ -448,7 +461,8 @@ export function parseFlowchart(
448
461
  continue;
449
462
  }
450
463
 
451
- // Unknown metadata skip
464
+ // Store other options (e.g., color: off)
465
+ result.options[key] = value;
452
466
  continue;
453
467
  }
454
468
 
@@ -460,7 +474,23 @@ export function parseFlowchart(
460
474
 
461
475
  // Validation: no nodes found
462
476
  if (result.nodes.length === 0 && !result.error) {
463
- result.error = 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).';
477
+ const diag = makeDgmoError(1, 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).');
478
+ result.diagnostics.push(diag);
479
+ result.error = formatDgmoError(diag);
480
+ }
481
+
482
+ // Warn about orphaned nodes (not referenced in any edge)
483
+ if (result.nodes.length >= 2 && result.edges.length >= 1 && !result.error) {
484
+ const connectedIds = new Set<string>();
485
+ for (const edge of result.edges) {
486
+ connectedIds.add(edge.source);
487
+ connectedIds.add(edge.target);
488
+ }
489
+ for (const node of result.nodes) {
490
+ if (!connectedIds.has(node.id)) {
491
+ result.diagnostics.push(makeDgmoError(node.lineNumber, `Node "${node.label}" is not connected to any other node`, 'warning'));
492
+ }
493
+ }
464
494
  }
465
495
 
466
496
  return result;