@diagrammo/dgmo 0.6.3 → 0.7.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 (44) hide show
  1. package/dist/cli.cjs +180 -178
  2. package/dist/index.cjs +5447 -2229
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +236 -16
  5. package/dist/index.d.ts +236 -16
  6. package/dist/index.js +5439 -2228
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/c4/parser.ts +3 -2
  10. package/src/c4/renderer.ts +6 -6
  11. package/src/class/renderer.ts +183 -7
  12. package/src/cli.ts +3 -11
  13. package/src/colors.ts +3 -3
  14. package/src/d3.ts +132 -29
  15. package/src/dgmo-router.ts +3 -1
  16. package/src/er/parser.ts +5 -3
  17. package/src/er/renderer.ts +11 -5
  18. package/src/gantt/calculator.ts +717 -0
  19. package/src/gantt/parser.ts +767 -0
  20. package/src/gantt/renderer.ts +2251 -0
  21. package/src/gantt/resolver.ts +144 -0
  22. package/src/gantt/types.ts +168 -0
  23. package/src/index.ts +27 -0
  24. package/src/infra/renderer.ts +48 -12
  25. package/src/initiative-status/filter.ts +63 -0
  26. package/src/initiative-status/layout.ts +319 -67
  27. package/src/initiative-status/parser.ts +200 -25
  28. package/src/initiative-status/renderer.ts +293 -10
  29. package/src/initiative-status/types.ts +6 -0
  30. package/src/org/layout.ts +22 -55
  31. package/src/org/parser.ts +7 -5
  32. package/src/org/renderer.ts +4 -8
  33. package/src/palettes/dracula.ts +60 -0
  34. package/src/palettes/index.ts +8 -6
  35. package/src/palettes/monokai.ts +60 -0
  36. package/src/palettes/registry.ts +4 -2
  37. package/src/sequence/parser.ts +10 -9
  38. package/src/sequence/renderer.ts +5 -4
  39. package/src/sharing.ts +8 -0
  40. package/src/sitemap/parser.ts +5 -3
  41. package/src/sitemap/renderer.ts +4 -4
  42. package/src/utils/duration.ts +212 -0
  43. package/src/utils/legend-constants.ts +1 -0
  44. package/src/utils/parsing.ts +23 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/c4/parser.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  measureIndent,
12
12
  extractColor,
13
13
  parsePipeMetadata,
14
+ MULTIPLE_PIPE_WARNING,
14
15
  CHART_TYPE_RE,
15
16
  TITLE_RE,
16
17
  OPTION_RE,
@@ -411,7 +412,7 @@ export function parseC4(
411
412
  // Otherwise it's a deployment node (possibly with pipe metadata)
412
413
  const segments = trimmed.split('|').map((s) => s.trim());
413
414
  const nodeName = segments[0];
414
- const metadata = parsePipeMetadata(segments, aliasMap);
415
+ const metadata = parsePipeMetadata(segments, aliasMap, () => pushError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning'));
415
416
  const shape = inferC4Shape(nodeName, metadata.tech ?? metadata.technology);
416
417
 
417
418
  const dNode: C4DeploymentNode = {
@@ -598,7 +599,7 @@ export function parseC4(
598
599
  namePart = namePart.substring(0, isAMatch.index!).trim();
599
600
  }
600
601
 
601
- const metadata = parsePipeMetadata(segments, aliasMap);
602
+ const metadata = parsePipeMetadata(segments, aliasMap, () => pushError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning'));
602
603
 
603
604
  // Determine shape: explicit > inference
604
605
  const shape =
@@ -256,7 +256,7 @@ export function renderC4Context(
256
256
 
257
257
  const scaledW = diagramW * scale;
258
258
  const offsetX = (width - scaledW) / 2;
259
- const offsetY = titleHeight + DIAGRAM_PADDING;
259
+ const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
260
260
 
261
261
  const svg = d3Selection
262
262
  .select(container)
@@ -592,12 +592,12 @@ export function renderC4Context(
592
592
 
593
593
  // ── Legend ──
594
594
  if (hasLegend) {
595
- // App mode: fixed overlay at SVG bottom so it's always readable regardless of scale.
595
+ // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
596
596
  // Export mode: render inside scaled contentG at layout coordinates.
597
597
  const legendParent = fixedLegend
598
598
  ? svg.append('g')
599
599
  .attr('class', 'c4-legend-fixed')
600
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`)
600
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
601
601
  : contentG.append('g').attr('class', 'c4-legend');
602
602
  if (activeTagGroup) {
603
603
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
@@ -1298,7 +1298,7 @@ export function renderC4Containers(
1298
1298
 
1299
1299
  const scaledW = diagramW * scale;
1300
1300
  const offsetX = (width - scaledW) / 2;
1301
- const offsetY = titleHeight + DIAGRAM_PADDING;
1301
+ const offsetY = titleHeight + DIAGRAM_PADDING + legendReserveH;
1302
1302
 
1303
1303
  const svg = d3Selection
1304
1304
  .select(container)
@@ -1707,12 +1707,12 @@ export function renderC4Containers(
1707
1707
 
1708
1708
  // ── Legend ──
1709
1709
  if (hasLegend) {
1710
- // App mode: fixed overlay at SVG bottom so it's always readable regardless of scale.
1710
+ // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
1711
1711
  // Export mode: render inside scaled contentG at layout coordinates.
1712
1712
  const legendParent = fixedLegend
1713
1713
  ? svg.append('g')
1714
1714
  .attr('class', 'c4-legend-fixed')
1715
- .attr('transform', `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`)
1715
+ .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
1716
1716
  : contentG.append('g').attr('class', 'c4-legend');
1717
1717
  if (activeTagGroup) {
1718
1718
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
@@ -6,6 +6,18 @@ import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
8
  import { runInExportContainer, extractExportSvg } from '../utils/export-container';
9
+ import {
10
+ LEGEND_HEIGHT,
11
+ LEGEND_PILL_PAD,
12
+ LEGEND_PILL_FONT_SIZE,
13
+ LEGEND_PILL_FONT_W,
14
+ LEGEND_CAPSULE_PAD,
15
+ LEGEND_DOT_R,
16
+ LEGEND_ENTRY_FONT_SIZE,
17
+ LEGEND_ENTRY_FONT_W,
18
+ LEGEND_ENTRY_DOT_GAP,
19
+ LEGEND_ENTRY_TRAIL,
20
+ } from '../utils/legend-constants';
9
21
  import type { PaletteColors } from '../palettes';
10
22
  import { mix } from '../palettes/color-utils';
11
23
  import type { ParsedClassDiagram, ClassModifier, RelationshipType } from './types';
@@ -51,6 +63,49 @@ function nodeStroke(palette: PaletteColors, modifier: ClassModifier | undefined,
51
63
  return nodeColor ?? modifierColor(modifier, palette, colorOff);
52
64
  }
53
65
 
66
+ // ============================================================
67
+ // Legend helpers
68
+ // ============================================================
69
+
70
+ interface ClassLegendEntry {
71
+ label: string;
72
+ colorKey: keyof PaletteColors['colors'];
73
+ }
74
+
75
+ const CLASS_TYPE_MAP: Record<string, ClassLegendEntry> = {
76
+ class: { label: 'Class', colorKey: 'teal' },
77
+ abstract: { label: 'Abstract', colorKey: 'purple' },
78
+ interface: { label: 'Interface', colorKey: 'blue' },
79
+ enum: { label: 'Enum', colorKey: 'yellow' },
80
+ };
81
+
82
+ const CLASS_TYPE_ORDER = ['class', 'abstract', 'interface', 'enum'];
83
+
84
+ function collectClassTypes(parsed: ParsedClassDiagram): ClassLegendEntry[] {
85
+ if (parsed.options?.color === 'off') return [];
86
+
87
+ const present = new Set<string>();
88
+ for (const c of parsed.classes) {
89
+ if (c.color) continue; // explicit color override — skip
90
+ present.add(c.modifier ?? 'class');
91
+ }
92
+ return CLASS_TYPE_ORDER.filter((k) => present.has(k)).map((k) => CLASS_TYPE_MAP[k]);
93
+ }
94
+
95
+ const LEGEND_GROUP_NAME = 'Type';
96
+
97
+ function legendEntriesWidth(entries: ClassLegendEntry[]): number {
98
+ let w = 0;
99
+ for (const e of entries) {
100
+ w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
101
+ }
102
+ return w;
103
+ }
104
+
105
+ function classTypeKey(modifier: ClassModifier | undefined): string {
106
+ return modifier ?? 'class';
107
+ }
108
+
54
109
  // ============================================================
55
110
  // Visibility symbols
56
111
  // ============================================================
@@ -107,7 +162,8 @@ export function renderClassDiagram(
107
162
  palette: PaletteColors,
108
163
  isDark: boolean,
109
164
  onClickItem?: (lineNumber: number) => void,
110
- exportDims?: { width?: number; height?: number }
165
+ exportDims?: { width?: number; height?: number },
166
+ legendActive?: boolean | null
111
167
  ): void {
112
168
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
113
169
 
@@ -115,17 +171,22 @@ export function renderClassDiagram(
115
171
  const height = exportDims?.height ?? container.clientHeight;
116
172
  if (width <= 0 || height <= 0) return;
117
173
 
174
+ const legendEntries = collectClassTypes(parsed);
175
+ const hasLegend = legendEntries.length > 1; // only show when multiple types present
176
+
118
177
  const titleHeight = parsed.title ? 40 : 0;
178
+ const LEGEND_FIXED_GAP = 8;
179
+ const legendReserve = hasLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
119
180
  const diagramW = layout.width;
120
181
  const diagramH = layout.height;
121
- const availH = height - titleHeight;
182
+ const availH = height - titleHeight - legendReserve;
122
183
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
123
184
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
124
185
  const scale = Math.min(MAX_SCALE, scaleX, scaleY);
125
186
 
126
187
  const scaledW = diagramW * scale;
127
188
  const offsetX = (width - scaledW) / 2;
128
- const offsetY = titleHeight + DIAGRAM_PADDING;
189
+ const offsetY = titleHeight + legendReserve + DIAGRAM_PADDING;
129
190
 
130
191
  const svg = d3Selection
131
192
  .select(container)
@@ -244,6 +305,114 @@ export function renderClassDiagram(
244
305
  }
245
306
  }
246
307
 
308
+ // ── Legend ──
309
+ // legendActive: true = expanded (default), false = collapsed pill only
310
+ const isLegendExpanded = legendActive !== false;
311
+ if (hasLegend) {
312
+ const groupBg = isDark
313
+ ? mix(palette.surface, palette.bg, 50)
314
+ : mix(palette.surface, palette.bg, 30);
315
+
316
+ const pillWidth = LEGEND_GROUP_NAME.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
317
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
318
+ const entriesW = legendEntriesWidth(legendEntries);
319
+
320
+ const totalW = isLegendExpanded
321
+ ? LEGEND_CAPSULE_PAD * 2 + pillWidth + LEGEND_ENTRY_TRAIL + entriesW
322
+ : pillWidth;
323
+
324
+ const legendX = (width - totalW) / 2;
325
+ const legendY = titleHeight;
326
+
327
+ const legendG = svg
328
+ .append('g')
329
+ .attr('class', 'cd-legend')
330
+ .attr('data-legend-group', 'type')
331
+ .attr('transform', `translate(${legendX}, ${legendY})`)
332
+ .style('cursor', 'pointer');
333
+
334
+ if (isLegendExpanded) {
335
+ // Outer capsule
336
+ legendG.append('rect')
337
+ .attr('width', totalW)
338
+ .attr('height', LEGEND_HEIGHT)
339
+ .attr('rx', LEGEND_HEIGHT / 2)
340
+ .attr('fill', groupBg);
341
+
342
+ // Inner pill
343
+ legendG.append('rect')
344
+ .attr('x', LEGEND_CAPSULE_PAD)
345
+ .attr('y', LEGEND_CAPSULE_PAD)
346
+ .attr('width', pillWidth)
347
+ .attr('height', pillH)
348
+ .attr('rx', pillH / 2)
349
+ .attr('fill', palette.bg);
350
+
351
+ legendG.append('rect')
352
+ .attr('x', LEGEND_CAPSULE_PAD)
353
+ .attr('y', LEGEND_CAPSULE_PAD)
354
+ .attr('width', pillWidth)
355
+ .attr('height', pillH)
356
+ .attr('rx', pillH / 2)
357
+ .attr('fill', 'none')
358
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
359
+ .attr('stroke-width', 0.75);
360
+
361
+ legendG.append('text')
362
+ .attr('x', LEGEND_CAPSULE_PAD + pillWidth / 2)
363
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
364
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
365
+ .attr('font-weight', '500')
366
+ .attr('fill', palette.text)
367
+ .attr('text-anchor', 'middle')
368
+ .attr('font-family', FONT_FAMILY)
369
+ .text(LEGEND_GROUP_NAME);
370
+
371
+ // Entries
372
+ let entryX = LEGEND_CAPSULE_PAD + pillWidth + LEGEND_ENTRY_TRAIL;
373
+ for (const entry of legendEntries) {
374
+ const color = palette.colors[entry.colorKey];
375
+ const typeKey = CLASS_TYPE_ORDER.find((k) => CLASS_TYPE_MAP[k] === entry)!;
376
+
377
+ const entryG = legendG.append('g')
378
+ .attr('data-legend-entry', typeKey);
379
+
380
+ entryG.append('circle')
381
+ .attr('cx', entryX + LEGEND_DOT_R)
382
+ .attr('cy', LEGEND_HEIGHT / 2)
383
+ .attr('r', LEGEND_DOT_R)
384
+ .attr('fill', color);
385
+
386
+ entryG.append('text')
387
+ .attr('x', entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
388
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
389
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
390
+ .attr('fill', palette.textMuted)
391
+ .attr('font-family', FONT_FAMILY)
392
+ .text(entry.label);
393
+
394
+ entryX += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
395
+ }
396
+ } else {
397
+ // Collapsed: single muted pill
398
+ legendG.append('rect')
399
+ .attr('width', pillWidth)
400
+ .attr('height', LEGEND_HEIGHT)
401
+ .attr('rx', LEGEND_HEIGHT / 2)
402
+ .attr('fill', groupBg);
403
+
404
+ legendG.append('text')
405
+ .attr('x', pillWidth / 2)
406
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
407
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
408
+ .attr('font-weight', '500')
409
+ .attr('fill', palette.textMuted)
410
+ .attr('text-anchor', 'middle')
411
+ .attr('font-family', FONT_FAMILY)
412
+ .text(LEGEND_GROUP_NAME);
413
+ }
414
+ }
415
+
247
416
  // ── Content group ──
248
417
  const contentG = svg
249
418
  .append('g')
@@ -320,7 +489,8 @@ export function renderClassDiagram(
320
489
  .attr('transform', `translate(${node.x}, ${node.y})`)
321
490
  .attr('class', 'cd-class')
322
491
  .attr('data-line-number', String(node.lineNumber))
323
- .attr('data-node-id', node.id);
492
+ .attr('data-node-id', node.id)
493
+ .attr('data-cd-type', classTypeKey(node.modifier));
324
494
 
325
495
  if (onClickItem) {
326
496
  nodeG.style('cursor', 'pointer').on('click', () => {
@@ -331,8 +501,11 @@ export function renderClassDiagram(
331
501
  const w = node.width;
332
502
  const h = node.height;
333
503
  const colorOff = parsed.options?.color === 'off';
334
- const fill = nodeFill(palette, isDark, node.modifier, node.color, colorOff);
335
- const stroke = nodeStroke(palette, node.modifier, node.color, colorOff);
504
+ // When legend is collapsed, use neutral color for nodes without explicit color
505
+ const neutralize = hasLegend && !isLegendExpanded && !node.color;
506
+ const effectiveColor = neutralize ? palette.primary : node.color;
507
+ const fill = nodeFill(palette, isDark, node.modifier, effectiveColor, colorOff);
508
+ const stroke = nodeStroke(palette, node.modifier, effectiveColor, colorOff);
336
509
 
337
510
  // Outer rectangle
338
511
  nodeG.append('rect')
@@ -504,8 +677,11 @@ export function renderClassDiagramForExport(
504
677
  const layout = layoutClassDiagram(parsed);
505
678
  const isDark = theme === 'dark';
506
679
 
680
+ const legendEntries = collectClassTypes(parsed);
681
+ const EXPORT_LEGEND_GAP = 8;
682
+ const legendReserve = legendEntries.length > 1 ? LEGEND_HEIGHT + EXPORT_LEGEND_GAP : 0;
507
683
  const exportWidth = layout.width + DIAGRAM_PADDING * 2;
508
- const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0);
684
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + (parsed.title ? 40 : 0) + legendReserve;
509
685
 
510
686
  return runInExportContainer(exportWidth, exportHeight, (container) => {
511
687
  renderClassDiagram(
package/src/cli.ts CHANGED
@@ -8,21 +8,13 @@ import { render } from './render';
8
8
  import { parseDgmo, getAllChartTypes } from './dgmo-router';
9
9
  import { parseDgmoChartType } from './dgmo-router';
10
10
  import { formatDgmoError } from './diagnostics';
11
- import { getPalette } from './palettes/registry';
11
+ import { getPalette, getAvailablePalettes } from './palettes';
12
12
  import { DEFAULT_FONT_NAME } from './fonts';
13
13
  import { encodeDiagramUrl } from './sharing';
14
14
  import { resolveOrgImports } from './org/resolver';
15
15
 
16
- const PALETTES = [
17
- 'nord',
18
- 'solarized',
19
- 'catppuccin',
20
- 'rose-pine',
21
- 'gruvbox',
22
- 'tokyo-night',
23
- 'one-dark',
24
- 'bold',
25
- ];
16
+ // Derived from the palette registry so new palettes are auto-included.
17
+ const PALETTES = getAvailablePalettes().map((p) => p.id);
26
18
 
27
19
  const THEMES = ['light', 'dark', 'transparent'] as const;
28
20
 
package/src/colors.ts CHANGED
@@ -41,9 +41,9 @@ export const colorNames: Record<string, string> = {
41
41
  };
42
42
 
43
43
  /**
44
- * Resolves a color name or hex code to a valid CSS color.
44
+ * Resolves a color name to a valid CSS color.
45
45
  * When a palette is provided, named colors resolve against its color map first.
46
- * Hex codes (e.g. "#ff0000") are passed through regardless of palette (FR8).
46
+ * Hex codes are NOT supported use named colors only.
47
47
  * Unknown names are returned as-is.
48
48
  */
49
49
  export function resolveColor(
@@ -51,7 +51,6 @@ export function resolveColor(
51
51
  palette?: { colors: Record<string, string> }
52
52
  ): string {
53
53
  const lower = color.toLowerCase();
54
- if (lower.startsWith('#')) return lower;
55
54
 
56
55
  if (palette) {
57
56
  const named = palette.colors[lower];
@@ -59,6 +58,7 @@ export function resolveColor(
59
58
  }
60
59
 
61
60
  if (colorNames[lower]) return colorNames[lower];
61
+ // Unknown color name — return as-is so callers can detect + warn
62
62
  return color;
63
63
  }
64
64