@diagrammo/dgmo 0.31.0 → 0.32.1

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 (72) hide show
  1. package/.cursorrules +4 -1
  2. package/.github/copilot-instructions.md +4 -1
  3. package/.windsurfrules +4 -1
  4. package/SKILL.md +4 -1
  5. package/dist/advanced.cjs +1297 -358
  6. package/dist/advanced.d.cts +117 -15
  7. package/dist/advanced.d.ts +117 -15
  8. package/dist/advanced.js +1291 -358
  9. package/dist/auto.cjs +1087 -316
  10. package/dist/auto.js +98 -98
  11. package/dist/auto.mjs +1087 -316
  12. package/dist/cli.cjs +140 -140
  13. package/dist/index.cjs +1090 -397
  14. package/dist/index.js +1090 -397
  15. package/docs/ai-integration.md +4 -1
  16. package/docs/language-reference.md +282 -27
  17. package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
  18. package/gallery/fixtures/c4-full.dgmo +4 -5
  19. package/gallery/fixtures/c4.dgmo +2 -3
  20. package/package.json +7 -1
  21. package/src/advanced.ts +7 -0
  22. package/src/boxes-and-lines/focus.ts +257 -0
  23. package/src/boxes-and-lines/layout-search.ts +131 -65
  24. package/src/boxes-and-lines/layout.ts +7 -1
  25. package/src/boxes-and-lines/parser.ts +19 -4
  26. package/src/boxes-and-lines/renderer.ts +54 -3
  27. package/src/c4/parser.ts +8 -7
  28. package/src/chart-type-registry.ts +129 -4
  29. package/src/chart-types.ts +4 -4
  30. package/src/chart.ts +18 -1
  31. package/src/colors.ts +225 -2
  32. package/src/cycle/parser.ts +2 -7
  33. package/src/d3.ts +67 -54
  34. package/src/diagnostics.ts +17 -0
  35. package/src/dimensions.ts +9 -13
  36. package/src/echarts.ts +42 -14
  37. package/src/er/parser.ts +6 -1
  38. package/src/gantt/parser.ts +44 -7
  39. package/src/graph/flowchart-parser.ts +77 -3
  40. package/src/graph/state-renderer.ts +2 -2
  41. package/src/infra/parser.ts +80 -0
  42. package/src/journey-map/parser.ts +8 -7
  43. package/src/kanban/parser.ts +8 -7
  44. package/src/map/context-labels.ts +134 -27
  45. package/src/map/geo.ts +10 -2
  46. package/src/map/layout.ts +259 -4
  47. package/src/map/parser.ts +2 -0
  48. package/src/map/renderer.ts +22 -11
  49. package/src/map/resolver.ts +68 -19
  50. package/src/mindmap/parser.ts +15 -7
  51. package/src/mindmap/renderer.ts +50 -12
  52. package/src/org/parser.ts +8 -7
  53. package/src/org/renderer.ts +22 -7
  54. package/src/palettes/color-utils.ts +12 -2
  55. package/src/palettes/index.ts +1 -0
  56. package/src/pert/renderer.ts +2 -2
  57. package/src/pyramid/parser.ts +2 -7
  58. package/src/quadrant/renderer.ts +2 -2
  59. package/src/raci/parser.ts +2 -7
  60. package/src/raci/renderer.ts +4 -4
  61. package/src/ring/parser.ts +2 -7
  62. package/src/sequence/parser.ts +18 -7
  63. package/src/sequence/renderer.ts +4 -4
  64. package/src/sitemap/parser.ts +8 -7
  65. package/src/sitemap/renderer.ts +2 -2
  66. package/src/tech-radar/parser.ts +2 -7
  67. package/src/timeline/renderer.ts +15 -5
  68. package/src/utils/parsing.ts +13 -1
  69. package/src/utils/scaling.ts +38 -81
  70. package/src/utils/tag-groups.ts +38 -0
  71. package/src/visualizations/parse.ts +6 -1
  72. package/src/wireframe/parser.ts +6 -1
@@ -4,7 +4,7 @@
4
4
 
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import type { PaletteColors } from '../palettes';
7
- import { contrastText, mix, shapeFill } from '../palettes/color-utils';
7
+ import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
8
8
  import {
9
9
  parseInlineMarkdown,
10
10
  truncateBareUrl,
@@ -2010,7 +2010,7 @@ export function renderSequenceDiagram(
2010
2010
  const fillColor = groupTagColor
2011
2011
  ? mix(
2012
2012
  groupTagColor,
2013
- isDark ? palette.surface : palette.bg,
2013
+ themeBaseBg(palette, isDark),
2014
2014
  isDark ? 15 : 20
2015
2015
  )
2016
2016
  : isDark
@@ -2139,7 +2139,7 @@ export function renderSequenceDiagram(
2139
2139
  const pFill = effectiveTagColor
2140
2140
  ? mix(
2141
2141
  effectiveTagColor,
2142
- isDark ? palette.surface : palette.bg,
2142
+ themeBaseBg(palette, isDark),
2143
2143
  isDark ? 30 : 40
2144
2144
  )
2145
2145
  : isDark
@@ -2507,7 +2507,7 @@ export function renderSequenceDiagram(
2507
2507
  .attr('y', y1)
2508
2508
  .attr('width', sActivationWidth)
2509
2509
  .attr('height', y2 - y1)
2510
- .attr('fill', isDark ? palette.surface : palette.bg);
2510
+ .attr('fill', themeBaseBg(palette, isDark));
2511
2511
 
2512
2512
  // Canonical 25% tint via shapeFill() (or full intent when solid-fill is on).
2513
2513
  const actFill = shapeFill(palette, actBaseColor, isDark, { solid });
@@ -8,6 +8,7 @@ import {
8
8
  descriptionBareRemovedMessage,
9
9
  formatDgmoError,
10
10
  makeDgmoError,
11
+ makeFail,
11
12
  METADATA_DIAGNOSTIC_CODES,
12
13
  pipeOperatorRemovedMessage,
13
14
  suggest,
@@ -165,12 +166,7 @@ export function parseSitemap(
165
166
  error: null,
166
167
  };
167
168
 
168
- const fail = (line: number, message: string): ParsedSitemap => {
169
- const diag = makeDgmoError(line, message);
170
- result.diagnostics.push(diag);
171
- result.error = formatDgmoError(diag);
172
- return result;
173
- };
169
+ const fail = makeFail(result);
174
170
 
175
171
  const pushError = (line: number, message: string): void => {
176
172
  const diag = makeDgmoError(line, message);
@@ -324,7 +320,12 @@ export function parseSitemap(
324
320
  const indent = measureIndent(line);
325
321
  if (indent > 0) {
326
322
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
327
- const { label, color } = extractColor(cleanEntry, palette);
323
+ const { label, color } = extractColor(
324
+ cleanEntry,
325
+ palette,
326
+ result.diagnostics,
327
+ lineNumber
328
+ );
328
329
  // Bare value (no explicit color) → keep it; finalized below.
329
330
  currentTagGroup.entries.push({
330
331
  value: label,
@@ -6,7 +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 { contrastText, mix, shapeFill } from '../palettes/color-utils';
9
+ import { contrastText, mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
10
10
  import type { ParsedSitemap } from './types';
11
11
  import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
12
12
  import { renderInlineText } from '../utils/inline-markdown';
@@ -83,7 +83,7 @@ function containerFill(
83
83
  nodeColor?: string
84
84
  ): string {
85
85
  if (nodeColor) {
86
- return mix(nodeColor, isDark ? palette.surface : palette.bg, 10);
86
+ return mix(nodeColor, themeBaseBg(palette, isDark), 10);
87
87
  }
88
88
  return mix(palette.surface, palette.bg, 40);
89
89
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
- formatDgmoError,
3
2
  makeDgmoError,
3
+ makeFail,
4
4
  METADATA_DIAGNOSTIC_CODES,
5
5
  pipeOperatorRemovedMessage,
6
6
  suggest,
@@ -72,12 +72,7 @@ export function parseTechRadar(content: string): ParsedTechRadar {
72
72
  error: null,
73
73
  };
74
74
 
75
- const fail = (line: number, message: string): ParsedTechRadar => {
76
- const diag = makeDgmoError(line, message);
77
- result.diagnostics.push(diag);
78
- result.error = formatDgmoError(diag);
79
- return result;
80
- };
75
+ const fail = makeFail(result);
81
76
 
82
77
  const warn = (line: number, message: string): void => {
83
78
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
@@ -24,7 +24,7 @@ import type {
24
24
  import { parseTimelineDate } from '../timeline/parser';
25
25
  import type { PaletteColors } from '../palettes';
26
26
  import { getSeriesColors } from '../palettes';
27
- import { mix, shapeFill } from '../palettes/color-utils';
27
+ import { mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
28
28
  import { resolveTagColor } from '../utils/tag-groups';
29
29
  import type { TagGroup } from '../utils/tag-groups';
30
30
  import {
@@ -808,7 +808,7 @@ function setupTimeline(
808
808
  const textColor = palette.text;
809
809
  const mutedColor = palette.border;
810
810
  const bgColor = palette.bg;
811
- const bg = isDark ? palette.surface : palette.bg;
811
+ const bg = themeBaseBg(palette, isDark);
812
812
  const colors = getSeriesColors(palette);
813
813
 
814
814
  const groupColorMap = new Map<string, string>();
@@ -1451,7 +1451,7 @@ function renderTimelineHorizontalTimeSort(
1451
1451
  setup: TimelineSetup,
1452
1452
  hovers: TimelineHoverHelpers,
1453
1453
  onClickItem: ((lineNumber: number) => void) | undefined,
1454
- _exportDims: D3ExportDimensions | undefined,
1454
+ exportDims: D3ExportDimensions | undefined,
1455
1455
  _swimlaneTagGroup: string | null | undefined,
1456
1456
  _activeTagGroup: string | null | undefined,
1457
1457
  _onTagStateChange:
@@ -1461,6 +1461,7 @@ function renderTimelineHorizontalTimeSort(
1461
1461
  ): void {
1462
1462
  const {
1463
1463
  width,
1464
+ height,
1464
1465
  tooltip,
1465
1466
  solid,
1466
1467
  textColor,
@@ -1538,6 +1539,15 @@ function renderTimelineHorizontalTimeSort(
1538
1539
  const innerHeight = rowH * sorted.length;
1539
1540
  const usedHeight = margin.top + innerHeight + margin.bottom;
1540
1541
 
1542
+ // On-screen (non-export) the content can be taller than the host pane. Rather
1543
+ // than overflow it (forcing the user to scroll), scale the whole SVG down to
1544
+ // fit the available height: the viewBox keeps the natural content geometry
1545
+ // while the rendered height is clamped to the container, and
1546
+ // preserveAspectRatio uniformly shrinks + centers it. Export keeps the full
1547
+ // natural height so the image is never compressed.
1548
+ const fitToContainer = !exportDims && height > 0 && usedHeight > height;
1549
+ const svgHeight = fitToContainer ? height : usedHeight;
1550
+
1541
1551
  const xScale = d3Scale
1542
1552
  .scaleLinear()
1543
1553
  .domain([minDate - datePadding, maxDate + datePadding])
@@ -1547,12 +1557,12 @@ function renderTimelineHorizontalTimeSort(
1547
1557
  .select(container)
1548
1558
  .append('svg')
1549
1559
  .attr('width', width)
1550
- .attr('height', usedHeight)
1560
+ .attr('height', svgHeight)
1551
1561
  .attr('viewBox', `0 0 ${width} ${usedHeight}`)
1552
1562
  .attr('preserveAspectRatio', 'xMidYMin meet')
1553
1563
  .style('background', bgColor);
1554
1564
 
1555
- if (ctx.isBelowFloor) {
1565
+ if (ctx.isBelowFloor && !fitToContainer) {
1556
1566
  svg.attr('width', '100%');
1557
1567
  }
1558
1568
 
@@ -8,6 +8,7 @@ import {
8
8
  RECOGNIZED_COLOR_NAMES,
9
9
  resolveColor,
10
10
  resolveColorWithDiagnostic,
11
+ invalidColorDiagnostic,
11
12
  } from '../colors';
12
13
  import {
13
14
  emptyMetadataValueMessage,
@@ -122,7 +123,18 @@ export function extractColor(
122
123
  if (lastSpaceIdx < 0) return { label };
123
124
  const trailing = label.substring(lastSpaceIdx + 1);
124
125
  // Case-sensitive lowercase match against the closed 11-name palette.
125
- if (!RECOGNIZED_COLOR_SET.has(trailing)) return { label };
126
+ if (!RECOGNIZED_COLOR_SET.has(trailing)) {
127
+ // Not a valid color. If it nonetheless LOOKS like an intended color (a hex
128
+ // literal or a CSS color name like `pink`), flag it — otherwise the
129
+ // trailing-token rule would silently swallow it into the label with no
130
+ // diagnostic, and the MCP color gate would have nothing to block on. A
131
+ // genuine label word (`Zinfandel`) is not color-like, so it stays as-is.
132
+ if (diagnostics && line !== undefined) {
133
+ const diag = invalidColorDiagnostic(trailing, line);
134
+ if (diag) diagnostics.push(diag);
135
+ }
136
+ return { label };
137
+ }
126
138
  let color: string | undefined;
127
139
  if (diagnostics && line !== undefined) {
128
140
  color = resolveColorWithDiagnostic(trailing, line, diagnostics, palette);
@@ -21,6 +21,41 @@ export class ScaleContext {
21
21
  return new ScaleContext(clamped, minScaleFactor);
22
22
  }
23
23
 
24
+ /**
25
+ * Fit content into a bounding box, scaling by whichever dimension is more
26
+ * constraining (the smaller of the width- and height-fit ratios) so the
27
+ * diagram never overflows the canvas in either axis. Like {@link from}, the
28
+ * factor is clamped to `[minScaleFactor, 1]` (content is never enlarged, and
29
+ * never shrunk past the readability floor).
30
+ */
31
+ static fromBox(
32
+ containerWidth: number,
33
+ idealWidth: number,
34
+ containerHeight: number,
35
+ idealHeight: number,
36
+ minScaleFactor = DEFAULT_MIN_SCALE_FACTOR
37
+ ): ScaleContext {
38
+ const wRaw = idealWidth > 0 ? containerWidth / idealWidth : 1;
39
+ const hRaw = idealHeight > 0 ? containerHeight / idealHeight : 1;
40
+ const raw = Math.min(wRaw, hRaw);
41
+ const clamped = Math.max(Math.min(raw, 1), minScaleFactor);
42
+ return new ScaleContext(clamped, minScaleFactor);
43
+ }
44
+
45
+ /**
46
+ * Build a context from an explicit raw factor (clamped to
47
+ * `[minScaleFactor, 1]`). Used to refine a fit iteratively: layout scaling is
48
+ * non-linear (gaps shrink faster than floored text), so the first-pass factor
49
+ * can still overflow — re-measure the laid-out result and tighten.
50
+ */
51
+ static fromFactor(
52
+ rawFactor: number,
53
+ minScaleFactor = DEFAULT_MIN_SCALE_FACTOR
54
+ ): ScaleContext {
55
+ const clamped = Math.max(Math.min(rawFactor, 1), minScaleFactor);
56
+ return new ScaleContext(clamped, minScaleFactor);
57
+ }
58
+
24
59
  static identity(): ScaleContext {
25
60
  return new ScaleContext(1, DEFAULT_MIN_SCALE_FACTOR);
26
61
  }
@@ -42,7 +77,9 @@ export class ScaleContext {
42
77
  }
43
78
 
44
79
  // ============================================================
45
- // Minimum dimensions formula table
80
+ // ContentCounts per-chart-type content tallies that feed the registry's
81
+ // `minDims` formulas (chart-type-registry.ts). The formulas themselves moved
82
+ // there (Story 111.5) so a chart type's sizing is defined in one descriptor.
46
83
  // ============================================================
47
84
 
48
85
  export interface ContentCounts {
@@ -57,83 +94,3 @@ export interface ContentCounts {
57
94
  roles?: number;
58
95
  blips?: number;
59
96
  }
60
-
61
- const DEFAULT_MIN = { width: 300, height: 200 };
62
-
63
- export function computeMinDimensions(
64
- chartType: string,
65
- counts: ContentCounts
66
- ): { width: number; height: number } {
67
- switch (chartType) {
68
- case 'sequence':
69
- return {
70
- width: Math.max((counts.participants ?? 2) * 80, 320),
71
- height: Math.max((counts.messages ?? 1) * 20 + 120, 200),
72
- };
73
- case 'raci':
74
- return {
75
- width: Math.max((counts.roles ?? 2) * 50 + 180, 300),
76
- height: Math.max((counts.tasks ?? 1) * 28 + 80, 200),
77
- };
78
- case 'mindmap':
79
- return {
80
- width: Math.max((counts.nodes ?? 3) * 30, 300),
81
- height: Math.max((counts.depth ?? 2) * 60, 200),
82
- };
83
- case 'tech-radar':
84
- return { width: 360, height: 400 };
85
- case 'heatmap':
86
- return {
87
- width: Math.max((counts.columns ?? 3) * 40, 300),
88
- height: Math.max((counts.rows ?? 3) * 30 + 60, 200),
89
- };
90
- case 'arc':
91
- return {
92
- width: 300,
93
- height: Math.max((counts.nodes ?? 3) * 20 + 120, 200),
94
- };
95
- case 'org':
96
- return {
97
- width: Math.max((counts.nodes ?? 3) * 60, 300),
98
- height: Math.max((counts.depth ?? 2) * 80, 200),
99
- };
100
- case 'gantt':
101
- return {
102
- width: 400,
103
- height: Math.max((counts.tasks ?? 3) * 24 + 80, 200),
104
- };
105
- case 'kanban':
106
- return {
107
- width: Math.max((counts.columns ?? 3) * 120, 360),
108
- height: 300,
109
- };
110
- case 'er':
111
- return {
112
- width: Math.max((counts.nodes ?? 2) * 140, 300),
113
- height: Math.max((counts.nodes ?? 2) * 80, 200),
114
- };
115
- case 'class':
116
- return {
117
- width: Math.max((counts.nodes ?? 2) * 140, 300),
118
- height: Math.max((counts.nodes ?? 2) * 80, 200),
119
- };
120
- case 'flowchart':
121
- case 'state':
122
- return {
123
- width: Math.max((counts.nodes ?? 3) * 60, 300),
124
- height: Math.max((counts.nodes ?? 3) * 50, 200),
125
- };
126
- case 'pert':
127
- return {
128
- width: Math.max((counts.tasks ?? 3) * 80, 340),
129
- height: Math.max((counts.tasks ?? 3) * 40 + 80, 200),
130
- };
131
- case 'infra':
132
- return {
133
- width: Math.max((counts.nodes ?? 3) * 80, 300),
134
- height: Math.max((counts.nodes ?? 3) * 60, 200),
135
- };
136
- default:
137
- return { ...DEFAULT_MIN };
138
- }
139
- }
@@ -572,6 +572,44 @@ export function validateTagGroupNames(
572
572
  }
573
573
  }
574
574
 
575
+ // ── Parent → Child Tag Cascade ────────────────────────────
576
+
577
+ /**
578
+ * Cascade explicit tag values down a node tree: a child that has no value of
579
+ * its own for a given tag group inherits the value of its nearest ancestor
580
+ * that does. A child's own explicit value always wins and becomes the new
581
+ * inherited value for its subtree.
582
+ *
583
+ * Run this on the parsed tree BEFORE {@link injectDefaultTagMetadata} so that
584
+ * an inherited ancestor value takes precedence over the group's global
585
+ * default — only nodes with no tagged ancestor fall through to the default.
586
+ * Idempotent and mutates `metadata` in place.
587
+ *
588
+ * @param roots Root nodes of the tree (each with mutable `metadata` + `children`)
589
+ * @param tagGroups Declared tag groups (only `.name` is used)
590
+ */
591
+ export function cascadeTagMetadata<
592
+ T extends { metadata: Record<string, string>; children: readonly T[] },
593
+ >(roots: readonly T[], tagGroups: ReadonlyArray<{ name: string }>): void {
594
+ const keys = tagGroups.map((g) => g.name.toLowerCase());
595
+ if (keys.length === 0) return;
596
+
597
+ const walk = (node: T, inherited: Record<string, string>): void => {
598
+ const childInherited = { ...inherited };
599
+ for (const key of keys) {
600
+ const own = node.metadata[key];
601
+ if (own) {
602
+ childInherited[key] = own; // own explicit value propagates downward
603
+ } else if (inherited[key]) {
604
+ node.metadata[key] = inherited[key]; // inherit from nearest ancestor
605
+ }
606
+ }
607
+ for (const child of node.children) walk(child, childInherited);
608
+ };
609
+
610
+ for (const root of roots) walk(root, {});
611
+ }
612
+
575
613
  // ── Default Metadata Injection ────────────────────────────
576
614
 
577
615
  /**
@@ -215,7 +215,12 @@ function parseVisualizationFull(
215
215
  // Timeline tag group entries (indented under tag heading)
216
216
  if (currentTimelineTagGroup && indent > 0) {
217
217
  const { text: entryText, isDefault } = stripDefaultModifier(line);
218
- const { label, color } = extractColor(entryText, palette);
218
+ const { label, color } = extractColor(
219
+ entryText,
220
+ palette,
221
+ result.diagnostics,
222
+ lineNumber
223
+ );
219
224
  if (color) {
220
225
  if (isDefault) {
221
226
  currentTimelineTagGroup.defaultValue = label;
@@ -895,7 +895,12 @@ export function parseWireframe(content: string): ParsedWireframe {
895
895
  // Indented tag entry: `Value color` or `Value color default`
896
896
  if (indent > 0 && currentTagGroup) {
897
897
  const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
898
- const { label, color } = extractColor(cleanEntry);
898
+ const { label, color } = extractColor(
899
+ cleanEntry,
900
+ undefined,
901
+ diagnostics,
902
+ lineNumber
903
+ );
899
904
  // Bare value (no explicit color) → keep it; finalized below.
900
905
  currentTagGroup.entries.push({
901
906
  value: label,