@diagrammo/dgmo 0.4.1 → 0.4.3

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 (59) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +611 -153
  9. package/dist/index.cjs +8371 -3200
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +502 -58
  12. package/dist/index.d.ts +502 -58
  13. package/dist/index.js +8594 -3444
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +575 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1509 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sitemap/collapse.ts +187 -0
  55. package/src/sitemap/layout.ts +738 -0
  56. package/src/sitemap/parser.ts +489 -0
  57. package/src/sitemap/renderer.ts +774 -0
  58. package/src/sitemap/types.ts +42 -0
  59. package/src/utils/tag-groups.ts +119 -0
package/src/d3.ts CHANGED
@@ -5159,7 +5159,7 @@ export async function renderD3ForExport(
5159
5159
  activeTagGroup?: string | null;
5160
5160
  hiddenAttributes?: Set<string>;
5161
5161
  },
5162
- options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string }
5162
+ options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string; scenario?: string }
5163
5163
  ): Promise<string> {
5164
5164
  // Flowchart and org chart use their own parser pipelines — intercept before parseD3()
5165
5165
  const { parseDgmoChartType } = await import('./dgmo-router');
@@ -5191,7 +5191,8 @@ export async function renderD3ForExport(
5191
5191
  effectiveParsed,
5192
5192
  hiddenCounts.size > 0 ? hiddenCounts : undefined,
5193
5193
  activeTagGroup,
5194
- hiddenAttributes
5194
+ hiddenAttributes,
5195
+ true // expandAllLegend — show all tag groups expanded in export
5195
5196
  );
5196
5197
 
5197
5198
  const PADDING = 20;
@@ -5204,6 +5205,46 @@ export async function renderD3ForExport(
5204
5205
  return finalizeSvgExport(container, theme, effectivePalette, options);
5205
5206
  }
5206
5207
 
5208
+ if (detectedType === 'sitemap') {
5209
+ const { parseSitemap } = await import('./sitemap/parser');
5210
+ const { layoutSitemap } = await import('./sitemap/layout');
5211
+ const { collapseSitemapTree } = await import('./sitemap/collapse');
5212
+ const { renderSitemap } = await import('./sitemap/renderer');
5213
+
5214
+ const isDark = theme === 'dark';
5215
+ const effectivePalette = await resolveExportPalette(theme, palette);
5216
+
5217
+ const sitemapParsed = parseSitemap(content, effectivePalette);
5218
+ if (sitemapParsed.error || sitemapParsed.roots.length === 0) return '';
5219
+
5220
+ // Apply interactive collapse state when provided
5221
+ const collapsedNodes = orgExportState?.collapsedNodes;
5222
+ const activeTagGroup = orgExportState?.activeTagGroup ?? null;
5223
+ const hiddenAttributes = orgExportState?.hiddenAttributes;
5224
+
5225
+ const { parsed: effectiveParsed, hiddenCounts } =
5226
+ collapsedNodes && collapsedNodes.size > 0
5227
+ ? collapseSitemapTree(sitemapParsed, collapsedNodes)
5228
+ : { parsed: sitemapParsed, hiddenCounts: new Map<string, number>() };
5229
+
5230
+ const sitemapLayout = layoutSitemap(
5231
+ effectiveParsed,
5232
+ hiddenCounts.size > 0 ? hiddenCounts : undefined,
5233
+ activeTagGroup,
5234
+ hiddenAttributes,
5235
+ true,
5236
+ );
5237
+
5238
+ const PADDING = 20;
5239
+ const titleOffset = effectiveParsed.title ? 30 : 0;
5240
+ const exportWidth = sitemapLayout.width + PADDING * 2;
5241
+ const exportHeight = sitemapLayout.height + PADDING * 2 + titleOffset;
5242
+ const container = createExportContainer(exportWidth, exportHeight);
5243
+
5244
+ renderSitemap(container, effectiveParsed, sitemapLayout, effectivePalette, isDark, undefined, { width: exportWidth, height: exportHeight }, activeTagGroup, hiddenAttributes);
5245
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5246
+ }
5247
+
5207
5248
  if (detectedType === 'kanban') {
5208
5249
  const { parseKanban } = await import('./kanban/parser');
5209
5250
  const { renderKanban } = await import('./kanban/renderer');
@@ -5336,6 +5377,55 @@ export async function renderD3ForExport(
5336
5377
  return finalizeSvgExport(container, theme, effectivePalette, options);
5337
5378
  }
5338
5379
 
5380
+ if (detectedType === 'infra') {
5381
+ const { parseInfra } = await import('./infra/parser');
5382
+ const { computeInfra } = await import('./infra/compute');
5383
+ const { layoutInfra } = await import('./infra/layout');
5384
+ const { renderInfra, computeInfraLegendGroups } = await import('./infra/renderer');
5385
+
5386
+ const effectivePalette = await resolveExportPalette(theme, palette);
5387
+ const infraParsed = parseInfra(content);
5388
+ if (infraParsed.error || infraParsed.nodes.length === 0) return '';
5389
+
5390
+ const selectedScenario = options?.scenario
5391
+ ? infraParsed.scenarios.find((s) => s.name.toLowerCase() === options.scenario!.toLowerCase()) ?? null
5392
+ : null;
5393
+ const infraComputed = computeInfra(infraParsed, selectedScenario ? { scenario: selectedScenario } : {});
5394
+ const infraLayout = layoutInfra(infraComputed);
5395
+
5396
+ const titleOffset = infraParsed.title ? 40 : 0;
5397
+ const legendGroups = computeInfraLegendGroups(infraLayout.nodes, infraParsed.tagGroups, effectivePalette);
5398
+ const legendOffset = legendGroups.length > 0 ? 28 : 0;
5399
+ const exportWidth = infraLayout.width;
5400
+ const exportHeight = infraLayout.height + titleOffset + legendOffset;
5401
+ const container = createExportContainer(exportWidth, exportHeight);
5402
+
5403
+ renderInfra(container, infraLayout, effectivePalette, theme === 'dark', infraParsed.title, infraParsed.titleLineNumber, infraParsed.tagGroups, null, false, null, null, true);
5404
+ // Restore explicit pixel dimensions for resvg (renderer uses 100%/viewBox for app scaling)
5405
+ const infraSvg = container.querySelector('svg');
5406
+ if (infraSvg) {
5407
+ infraSvg.setAttribute('width', String(exportWidth));
5408
+ infraSvg.setAttribute('height', String(exportHeight));
5409
+ }
5410
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5411
+ }
5412
+
5413
+ if (detectedType === 'state') {
5414
+ const { parseState } = await import('./graph/state-parser');
5415
+ const { layoutGraph } = await import('./graph/layout');
5416
+ const { renderState } = await import('./graph/state-renderer');
5417
+
5418
+ const effectivePalette = await resolveExportPalette(theme, palette);
5419
+ const stateParsed = parseState(content, effectivePalette);
5420
+ if (stateParsed.error || stateParsed.nodes.length === 0) return '';
5421
+
5422
+ const layout = layoutGraph(stateParsed);
5423
+ const container = createExportContainer(EXPORT_WIDTH, EXPORT_HEIGHT);
5424
+
5425
+ renderState(container, stateParsed, layout, effectivePalette, theme === 'dark', undefined, { width: EXPORT_WIDTH, height: EXPORT_HEIGHT });
5426
+ return finalizeSvgExport(container, theme, effectivePalette, options);
5427
+ }
5428
+
5339
5429
  const parsed = parseD3(content, palette);
5340
5430
  // Allow sequence diagrams through even if parseD3 errors —
5341
5431
  // sequence is parsed by its own dedicated parser (parseSequenceDgmo)
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { looksLikeSequence, parseSequenceDgmo } from './sequence/parser';
6
6
  import { looksLikeFlowchart, parseFlowchart } from './graph/flowchart-parser';
7
+ import { looksLikeState, parseState } from './graph/state-parser';
7
8
  import { looksLikeClassDiagram, parseClassDiagram } from './class/parser';
8
9
  import { looksLikeERDiagram, parseERDiagram } from './er/parser';
9
10
  import { parseChart } from './chart';
@@ -13,6 +14,8 @@ import { parseOrg, looksLikeOrg } from './org/parser';
13
14
  import { parseKanban } from './kanban/parser';
14
15
  import { parseC4 } from './c4/parser';
15
16
  import { looksLikeInitiativeStatus, parseInitiativeStatus } from './initiative-status/parser';
17
+ import { looksLikeSitemap, parseSitemap } from './sitemap/parser';
18
+ import { parseInfra } from './infra/parser';
16
19
  import type { DgmoError } from './diagnostics';
17
20
 
18
21
  /**
@@ -62,6 +65,9 @@ export const DGMO_CHART_TYPE_MAP: Record<string, DgmoFramework> = {
62
65
  kanban: 'd3',
63
66
  c4: 'd3',
64
67
  'initiative-status': 'd3',
68
+ state: 'd3',
69
+ sitemap: 'd3',
70
+ infra: 'd3',
65
71
  };
66
72
 
67
73
  /**
@@ -94,6 +100,8 @@ export function parseDgmoChartType(content: string): string | null {
94
100
  if (looksLikeClassDiagram(content)) return 'class';
95
101
  if (looksLikeERDiagram(content)) return 'er';
96
102
  if (looksLikeInitiativeStatus(content)) return 'initiative-status';
103
+ if (looksLikeState(content)) return 'state';
104
+ if (looksLikeSitemap(content)) return 'sitemap';
97
105
  if (looksLikeOrg(content)) return 'org';
98
106
 
99
107
  return null;
@@ -120,6 +128,9 @@ const PARSE_DISPATCH = new Map<string, (content: string) => { diagnostics: DgmoE
120
128
  ['kanban', (c) => parseKanban(c)],
121
129
  ['c4', (c) => parseC4(c)],
122
130
  ['initiative-status', (c) => parseInitiativeStatus(c)],
131
+ ['state', (c) => parseState(c)],
132
+ ['sitemap', (c) => parseSitemap(c)],
133
+ ['infra', (c) => parseInfra(c)],
123
134
  ]);
124
135
 
125
136
  /**
package/src/echarts.ts CHANGED
@@ -26,6 +26,7 @@ export interface ParsedSankeyLink {
26
26
  source: string;
27
27
  target: string;
28
28
  value: number;
29
+ color?: string;
29
30
  lineNumber: number;
30
31
  }
31
32
 
@@ -74,6 +75,7 @@ export interface ParsedEChart {
74
75
  sizelabel?: string;
75
76
  showLabels?: boolean;
76
77
  categoryColors?: Record<string, string>;
78
+ nodeColors?: Record<string, string>;
77
79
  diagnostics: DgmoError[];
78
80
  error: string | null;
79
81
  }
@@ -87,7 +89,8 @@ import { getSeriesColors, getSegmentColors } from './palettes';
87
89
  import { parseChart } from './chart';
88
90
  import type { ParsedChart } from './chart';
89
91
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
90
- import { collectIndentedValues, extractColor, parseSeriesNames } from './utils/parsing';
92
+ import { resolveColor } from './colors';
93
+ import { collectIndentedValues, extractColor, measureIndent, parseSeriesNames } from './utils/parsing';
91
94
 
92
95
  // ============================================================
93
96
  // Shared Constants
@@ -129,6 +132,9 @@ export function parseEChart(
129
132
  // Track current category for grouped scatter charts
130
133
  let currentCategory = 'Default';
131
134
 
135
+ // Sankey indentation state: stack of source nodes by indent level
136
+ const sankeyStack: { name: string; indent: number }[] = [];
137
+
132
138
  for (let i = 0; i < lines.length; i++) {
133
139
  const trimmed = lines[i].trim();
134
140
  const lineNumber = i + 1;
@@ -160,6 +166,22 @@ export function parseEChart(
160
166
 
161
167
  // Parse key: value pairs
162
168
  const colonIndex = trimmed.indexOf(':');
169
+
170
+ // Sankey: bare label (no colon) at any indent = source node for indented children
171
+ if (result.type === 'sankey' && colonIndex === -1) {
172
+ const indent = measureIndent(lines[i]);
173
+ while (sankeyStack.length && sankeyStack.at(-1)!.indent >= indent) {
174
+ sankeyStack.pop();
175
+ }
176
+ const { label: nodeName, color: nodeColor } = extractColor(trimmed, palette);
177
+ if (nodeColor) {
178
+ if (!result.nodeColors) result.nodeColors = {};
179
+ result.nodeColors[nodeName] = nodeColor;
180
+ }
181
+ sankeyStack.push({ name: nodeName, indent });
182
+ continue;
183
+ }
184
+
163
185
  if (colonIndex === -1) continue;
164
186
 
165
187
  const key = trimmed.substring(0, colonIndex).trim().toLowerCase();
@@ -264,20 +286,59 @@ export function parseEChart(
264
286
  continue;
265
287
  }
266
288
 
267
- // Check for Sankey arrow syntax: Source -> Target: Value
268
- const arrowMatch = trimmed.match(/^(.+?)\s*->\s*(.+?):\s*(\d+(?:\.\d+)?)$/);
289
+ // Check for Sankey arrow syntax: Source (color) -> Target (color): Value (color)
290
+ const arrowMatch = trimmed.match(/^(.+?)\s*->\s*(.+?):\s*(\d+(?:\.\d+)?)\s*(?:\(([^)]+)\))?\s*$/);
269
291
  if (arrowMatch) {
270
- const [, source, target, val] = arrowMatch;
292
+ const [, rawSource, rawTarget, val, rawLinkColor] = arrowMatch;
293
+ const { label: source, color: sourceColor } = extractColor(rawSource.trim(), palette);
294
+ const { label: target, color: targetColor } = extractColor(rawTarget.trim(), palette);
295
+ if (sourceColor || targetColor) {
296
+ if (!result.nodeColors) result.nodeColors = {};
297
+ if (sourceColor) result.nodeColors[source] = sourceColor;
298
+ if (targetColor) result.nodeColors[target] = targetColor;
299
+ }
300
+ const linkColor = rawLinkColor ? resolveColor(rawLinkColor.trim(), palette) : undefined;
271
301
  if (!result.links) result.links = [];
272
302
  result.links.push({
273
- source: source.trim(),
274
- target: target.trim(),
303
+ source,
304
+ target,
275
305
  value: parseFloat(val),
306
+ ...(linkColor && { color: linkColor }),
276
307
  lineNumber,
277
308
  });
278
309
  continue;
279
310
  }
280
311
 
312
+ // Sankey: indented "Target: Value" under a source node on the indent stack
313
+ if (result.type === 'sankey' && sankeyStack.length > 0) {
314
+ const indent = measureIndent(lines[i]);
315
+ if (indent > 0) {
316
+ // Pop entries at same or deeper indent to find the parent
317
+ while (sankeyStack.length && sankeyStack.at(-1)!.indent >= indent) {
318
+ sankeyStack.pop();
319
+ }
320
+ if (sankeyStack.length > 0) {
321
+ const source = sankeyStack.at(-1)!.name;
322
+ const { label: target, color: targetColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
323
+ if (targetColor) {
324
+ if (!result.nodeColors) result.nodeColors = {};
325
+ result.nodeColors[target] = targetColor;
326
+ }
327
+ // Parse value with optional trailing (color) for link color
328
+ const valColorMatch = value.match(/^(\d+(?:\.\d+)?)\s*(?:\(([^)]+)\))?\s*$/);
329
+ const val = valColorMatch ? parseFloat(valColorMatch[1]) : NaN;
330
+ const linkColor = valColorMatch?.[2] ? resolveColor(valColorMatch[2].trim(), palette) : undefined;
331
+ if (!isNaN(val)) {
332
+ if (!result.links) result.links = [];
333
+ result.links.push({ source, target, value: val, ...(linkColor && { color: linkColor }), lineNumber });
334
+ // Push target as potential source for deeper nesting
335
+ sankeyStack.push({ name: target, indent });
336
+ continue;
337
+ }
338
+ }
339
+ }
340
+ }
341
+
281
342
  // For function charts, treat non-numeric values as function expressions
282
343
  if (result.type === 'function') {
283
344
  const { label: fnName, color: fnColor } = extractColor(trimmed.substring(0, colonIndex).trim(), palette);
@@ -504,7 +565,7 @@ function buildSankeyOption(
504
565
  const nodes = Array.from(nodeSet).map((name, index) => ({
505
566
  name,
506
567
  itemStyle: {
507
- color: colors[index % colors.length],
568
+ color: parsed.nodeColors?.[name] ?? colors[index % colors.length],
508
569
  },
509
570
  }));
510
571
 
@@ -526,7 +587,12 @@ function buildSankeyOption(
526
587
  nodeGap: 12,
527
588
  nodeWidth: 20,
528
589
  data: nodes,
529
- links: parsed.links ?? [],
590
+ links: (parsed.links ?? []).map(link => ({
591
+ source: link.source,
592
+ target: link.target,
593
+ value: link.value,
594
+ ...(link.color && { lineStyle: { color: link.color } }),
595
+ })),
530
596
  lineStyle: {
531
597
  color: 'gradient',
532
598
  curveness: 0.5,
package/src/er/parser.ts CHANGED
@@ -29,6 +29,9 @@ const TABLE_DECL_RE = /^([a-zA-Z_]\w*)(?:\s+\(([^)]+)\))?\s*$/;
29
29
  // Column: name: type [constraints] or name [constraints] or name: type or name
30
30
  const COLUMN_RE = /^(\w+)(?:\s*:\s*(\w[\w()]*(?:\s*\[\])?))?(?:\s+\[([^\]]+)\])?\s*$/;
31
31
 
32
+ // Indented relationship: 1-* target or 1-label-* target
33
+ const INDENT_REL_RE = /^([1*?])-(?:(.+)-)?([1*?])\s+([a-zA-Z_]\w*)\s*$/;
34
+
32
35
  // Constraint keywords
33
36
  const CONSTRAINT_MAP: Record<string, ERConstraint> = {
34
37
  pk: 'pk',
@@ -224,8 +227,27 @@ export function parseERDiagram(
224
227
  if (!/\s/.test(key)) continue;
225
228
  }
226
229
 
227
- // Indented lines = columns of current table
230
+ // Indented lines = columns or relationships of current table
228
231
  if (indent > 0 && currentTable) {
232
+ // Try indented relationship first: 1-* target or 1-label-* target
233
+ const indentRel = trimmed.match(INDENT_REL_RE);
234
+ if (indentRel) {
235
+ const fromCard = parseCardSide(indentRel[1]);
236
+ const toCard = parseCardSide(indentRel[3]);
237
+ if (fromCard && toCard) {
238
+ const targetName = indentRel[4];
239
+ getOrCreateTable(targetName, lineNumber);
240
+ result.relationships.push({
241
+ source: currentTable.id,
242
+ target: tableId(targetName),
243
+ cardinality: { from: fromCard, to: toCard },
244
+ ...(indentRel[2]?.trim() && { label: indentRel[2].trim() }),
245
+ lineNumber,
246
+ });
247
+ }
248
+ continue;
249
+ }
250
+
229
251
  const colMatch = trimmed.match(COLUMN_RE);
230
252
  if (colMatch) {
231
253
  const colName = colMatch[1];
@@ -332,6 +354,10 @@ export function looksLikeERDiagram(content: string): boolean {
332
354
  if (/\[(pk|fk)\]/i.test(trimmed)) {
333
355
  hasConstraint = true;
334
356
  }
357
+ // Indented relationship is a strong ER signal
358
+ if (INDENT_REL_RE.test(trimmed)) {
359
+ hasRelationship = true;
360
+ }
335
361
  } else {
336
362
  // Check for table-like declaration
337
363
  if (TABLE_DECL_RE.test(trimmed)) {
@@ -347,8 +373,8 @@ export function looksLikeERDiagram(content: string): boolean {
347
373
  // [pk]/[fk] constraint is a strong enough signal
348
374
  if (hasConstraint && hasTableDecl) return true;
349
375
 
350
- // Relationship with table declarations and constraints
351
- if (hasRelationship && hasTableDecl && hasConstraint) return true;
376
+ // Relationship with table declarations is sufficient
377
+ if (hasRelationship && hasTableDecl) return true;
352
378
 
353
379
  return false;
354
380
  }
@@ -6,6 +6,7 @@ import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
+ import { mix } from '../palettes/color-utils';
9
10
  import { getSeriesColors } from '../palettes';
10
11
  import type { ParsedERDiagram, ERConstraint } from './types';
11
12
  import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
@@ -27,21 +28,6 @@ const MEMBER_LINE_HEIGHT = 18;
27
28
  const COMPARTMENT_PADDING_Y = 8;
28
29
  const MEMBER_PADDING_X = 10;
29
30
 
30
- // ============================================================
31
- // Color helpers
32
- // ============================================================
33
-
34
- function mix(a: string, b: string, pct: number): string {
35
- const parse = (h: string) => {
36
- const r = h.replace('#', '');
37
- const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
38
- return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
39
- };
40
- const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
41
- const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
42
- return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
43
- }
44
-
45
31
  // ============================================================
46
32
  // Constraint icons (text glyphs for resvg compat)
47
33
  // ============================================================
@@ -6,7 +6,6 @@ import type {
6
6
  ParsedGraph,
7
7
  GraphNode,
8
8
  GraphEdge,
9
- GraphGroup,
10
9
  GraphShape,
11
10
  GraphDirection,
12
11
  } from './types';
@@ -195,9 +194,9 @@ function parseArrowToken(token: string, palette?: PaletteColors): ArrowInfo {
195
194
  }
196
195
 
197
196
  // ============================================================
198
- // Group heading pattern
197
+ // Legacy group heading (deprecated — emit error)
199
198
  // ============================================================
200
- const GROUP_HEADING_RE = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
199
+ const LEGACY_GROUP_RE = /^##\s+/;
201
200
 
202
201
  // ============================================================
203
202
  // Main parser
@@ -227,8 +226,6 @@ export function parseFlowchart(
227
226
 
228
227
  const nodeMap = new Map<string, GraphNode>();
229
228
  const indentStack: { nodeId: string; indent: number }[] = [];
230
- let currentGroup: GraphGroup | null = null;
231
- const groups: GraphGroup[] = [];
232
229
  let contentStarted = false;
233
230
 
234
231
  function getOrCreateNode(ref: NodeRef, lineNumber: number): GraphNode {
@@ -241,16 +238,10 @@ export function parseFlowchart(
241
238
  shape: ref.shape,
242
239
  lineNumber,
243
240
  ...(ref.color && { color: ref.color }),
244
- ...(currentGroup && { group: currentGroup.id }),
245
241
  };
246
242
  nodeMap.set(ref.id, node);
247
243
  result.nodes.push(node);
248
244
 
249
- // Add to current group
250
- if (currentGroup && !currentGroup.nodeIds.includes(ref.id)) {
251
- currentGroup.nodeIds.push(ref.id);
252
- }
253
-
254
245
  return node;
255
246
  }
256
247
 
@@ -387,23 +378,11 @@ export function parseFlowchart(
387
378
  // Skip comments
388
379
  if (trimmed.startsWith('//')) continue;
389
380
 
390
- // Group headings
391
- const groupMatch = trimmed.match(GROUP_HEADING_RE);
392
- if (groupMatch) {
393
- const groupLabel = groupMatch[1].trim();
394
- const groupColorName = groupMatch[2]?.trim();
395
- const groupColor = groupColorName
396
- ? resolveColor(groupColorName, palette)
397
- : undefined;
398
-
399
- currentGroup = {
400
- id: `group:${groupLabel.toLowerCase()}`,
401
- label: groupLabel,
402
- nodeIds: [],
403
- lineNumber,
404
- ...(groupColor && { color: groupColor }),
405
- };
406
- groups.push(currentGroup);
381
+ // Legacy ## group headings — no longer supported
382
+ if (LEGACY_GROUP_RE.test(trimmed)) {
383
+ result.diagnostics.push(
384
+ makeDgmoError(lineNumber, '## group syntax is not supported in flowcharts. Remove the ## line.', 'error')
385
+ );
407
386
  continue;
408
387
  }
409
388
 
@@ -447,8 +426,6 @@ export function parseFlowchart(
447
426
  processContentLine(trimmed, lineNumber, indent);
448
427
  }
449
428
 
450
- if (groups.length > 0) result.groups = groups;
451
-
452
429
  // Validation: no nodes found
453
430
  if (result.nodes.length === 0 && !result.error) {
454
431
  const diag = makeDgmoError(1, 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).');
@@ -6,8 +6,9 @@ import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
+ import { mix } from '../palettes/color-utils';
9
10
  import type { ParsedGraph, GraphShape } from './types';
10
- import type { LayoutResult, LayoutNode, LayoutEdge, LayoutGroup } from './layout';
11
+ import type { LayoutResult, LayoutNode, LayoutEdge } from './layout';
11
12
  import { parseFlowchart } from './flowchart-parser';
12
13
  import { layoutGraph } from './layout';
13
14
 
@@ -19,7 +20,6 @@ const DIAGRAM_PADDING = 20;
19
20
  const MAX_SCALE = 3;
20
21
  const NODE_FONT_SIZE = 13;
21
22
  const EDGE_LABEL_FONT_SIZE = 11;
22
- const GROUP_LABEL_FONT_SIZE = 11;
23
23
  const EDGE_STROKE_WIDTH = 1.5;
24
24
  const NODE_STROKE_WIDTH = 1.5;
25
25
  const ARROWHEAD_W = 10;
@@ -27,23 +27,11 @@ const ARROWHEAD_H = 7;
27
27
  const IO_SKEW = 15;
28
28
  const SUBROUTINE_INSET = 8;
29
29
  const DOC_WAVE_HEIGHT = 10;
30
- const GROUP_EXTRA_PADDING = 12;
31
30
 
32
31
  // ============================================================
33
- // Color helpers (inline mix to avoid cross-module import issues)
32
+ // Color helpers
34
33
  // ============================================================
35
34
 
36
- function mix(a: string, b: string, pct: number): string {
37
- const parse = (h: string) => {
38
- const r = h.replace('#', '');
39
- const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
40
- return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
41
- };
42
- const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
43
- const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
44
- return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
45
- }
46
-
47
35
  function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTerminal?: boolean, colorOff?: boolean): string {
48
36
  if (colorOff) return palette.textMuted;
49
37
  switch (shape) {
@@ -53,6 +41,7 @@ function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTermi
53
41
  case 'io': return palette.colors.purple;
54
42
  case 'subroutine': return palette.colors.teal;
55
43
  case 'document': return palette.colors.orange;
44
+ default: return palette.colors.blue;
56
45
  }
57
46
  }
58
47
 
@@ -337,48 +326,58 @@ export function renderFlowchart(
337
326
  .append('g')
338
327
  .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
339
328
 
340
- // Render groups (background layer)
341
- for (const group of layout.groups) {
342
- if (group.width === 0 && group.height === 0) continue;
343
- const gx = group.x - GROUP_EXTRA_PADDING;
344
- const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
345
- const gw = group.width + GROUP_EXTRA_PADDING * 2;
346
- const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
347
-
348
- const fillColor = group.color
349
- ? mix(group.color, isDark ? palette.surface : palette.bg, 10)
350
- : isDark
351
- ? palette.surface
352
- : mix(palette.border, palette.bg, 30);
353
- const strokeColor = group.color ?? palette.textMuted;
354
-
355
- contentG
356
- .append('rect')
357
- .attr('x', gx)
358
- .attr('y', gy)
359
- .attr('width', gw)
360
- .attr('height', gh)
361
- .attr('rx', 6)
362
- .attr('fill', fillColor)
363
- .attr('stroke', strokeColor)
364
- .attr('stroke-width', 1)
365
- .attr('stroke-opacity', 0.5)
366
- .attr('class', 'fc-group');
367
-
368
- contentG
369
- .append('text')
370
- .attr('x', gx + 8)
371
- .attr('y', gy + GROUP_LABEL_FONT_SIZE + 4)
372
- .attr('fill', strokeColor)
373
- .attr('font-size', GROUP_LABEL_FONT_SIZE)
374
- .attr('font-weight', 'bold')
375
- .attr('opacity', 0.7)
376
- .attr('class', 'fc-group-label')
377
- .text(group.label);
329
+ // Compute edge label positions with perpendicular offset to hug their path,
330
+ // then resolve remaining collisions.
331
+ const LABEL_CHAR_W = 7;
332
+ const LABEL_PAD = 8;
333
+ const LABEL_H = 16;
334
+ const PERP_OFFSET = 10;
335
+
336
+ interface LabelPos { x: number; y: number; w: number; h: number; edgeIdx: number }
337
+ const labelPositions: LabelPos[] = [];
338
+
339
+ for (let ei = 0; ei < layout.edges.length; ei++) {
340
+ const edge = layout.edges[ei];
341
+ if (!edge.label || edge.points.length < 2) continue;
342
+ const midIdx = Math.floor(edge.points.length / 2);
343
+ const midPt = edge.points[midIdx];
344
+ const bgW = edge.label.length * LABEL_CHAR_W + LABEL_PAD;
345
+
346
+ const prev = edge.points[Math.max(0, midIdx - 1)];
347
+ const next = edge.points[Math.min(edge.points.length - 1, midIdx + 1)];
348
+ const dx = next.x - prev.x;
349
+ const dy = next.y - prev.y;
350
+ const len = Math.sqrt(dx * dx + dy * dy);
351
+ let lx = midPt.x;
352
+ let ly = midPt.y;
353
+ if (len > 0) {
354
+ lx += (-dy / len) * PERP_OFFSET;
355
+ ly += (dx / len) * PERP_OFFSET;
356
+ }
357
+
358
+ labelPositions.push({ x: lx, y: ly, w: bgW, h: LABEL_H, edgeIdx: ei });
359
+ }
360
+
361
+ // Resolve remaining label collisions.
362
+ labelPositions.sort((a, b) => a.y - b.y);
363
+ for (let i = 0; i < labelPositions.length; i++) {
364
+ for (let j = i + 1; j < labelPositions.length; j++) {
365
+ const a = labelPositions[i];
366
+ const b = labelPositions[j];
367
+ const overlapX = Math.abs(a.x - b.x) < (a.w + b.w) / 2;
368
+ const overlapY = Math.abs(a.y - b.y) < (a.h + b.h) / 2;
369
+ if (overlapX && overlapY) {
370
+ b.y = a.y + (a.h + b.h) / 2 + 2;
371
+ }
372
+ }
378
373
  }
379
374
 
375
+ const labelPosMap = new Map<number, LabelPos>();
376
+ for (const lp of labelPositions) labelPosMap.set(lp.edgeIdx, lp);
377
+
380
378
  // Render edges (middle layer)
381
- for (const edge of layout.edges) {
379
+ for (let ei = 0; ei < layout.edges.length; ei++) {
380
+ const edge = layout.edges[ei];
382
381
  if (edge.points.length < 2) continue;
383
382
  const edgeG = contentG
384
383
  .append('g')
@@ -402,21 +401,15 @@ export function renderFlowchart(
402
401
  .attr('class', 'fc-edge');
403
402
  }
404
403
 
405
- // Edge label at midpoint
406
- if (edge.label) {
407
- const midIdx = Math.floor(edge.points.length / 2);
408
- const midPt = edge.points[midIdx];
409
-
410
- // Background rect for legibility
411
- const labelLen = edge.label.length;
412
- const bgW = labelLen * 7 + 8;
413
- const bgH = 16;
404
+ // Edge label with collision-resolved position
405
+ const lp = labelPosMap.get(ei);
406
+ if (edge.label && lp) {
414
407
  edgeG
415
408
  .append('rect')
416
- .attr('x', midPt.x - bgW / 2)
417
- .attr('y', midPt.y - bgH / 2 - 1)
418
- .attr('width', bgW)
419
- .attr('height', bgH)
409
+ .attr('x', lp.x - lp.w / 2)
410
+ .attr('y', lp.y - lp.h / 2 - 1)
411
+ .attr('width', lp.w)
412
+ .attr('height', lp.h)
420
413
  .attr('rx', 3)
421
414
  .attr('fill', palette.bg)
422
415
  .attr('opacity', 0.85)
@@ -424,8 +417,8 @@ export function renderFlowchart(
424
417
 
425
418
  edgeG
426
419
  .append('text')
427
- .attr('x', midPt.x)
428
- .attr('y', midPt.y + 4)
420
+ .attr('x', lp.x)
421
+ .attr('y', lp.y + 4)
429
422
  .attr('text-anchor', 'middle')
430
423
  .attr('fill', edgeColor)
431
424
  .attr('font-size', EDGE_LABEL_FONT_SIZE)