@diagrammo/dgmo 0.8.4 → 0.8.6

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 (68) hide show
  1. package/.claude/commands/dgmo.md +300 -0
  2. package/.cursorrules +20 -2
  3. package/.github/copilot-instructions.md +20 -2
  4. package/.windsurfrules +20 -2
  5. package/AGENTS.md +23 -3
  6. package/dist/cli.cjs +191 -189
  7. package/dist/editor.cjs +5 -18
  8. package/dist/editor.cjs.map +1 -1
  9. package/dist/editor.js +5 -18
  10. package/dist/editor.js.map +1 -1
  11. package/dist/highlight.cjs +543 -0
  12. package/dist/highlight.cjs.map +1 -0
  13. package/dist/highlight.d.cts +32 -0
  14. package/dist/highlight.d.ts +32 -0
  15. package/dist/highlight.js +513 -0
  16. package/dist/highlight.js.map +1 -0
  17. package/dist/index.cjs +3253 -3356
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +77 -56
  20. package/dist/index.d.ts +77 -56
  21. package/dist/index.js +3247 -3349
  22. package/dist/index.js.map +1 -1
  23. package/docs/ai-integration.md +1 -1
  24. package/docs/language-reference.md +113 -33
  25. package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
  26. package/gallery/fixtures/slope.dgmo +7 -6
  27. package/package.json +26 -6
  28. package/src/boxes-and-lines/collapse.ts +78 -0
  29. package/src/boxes-and-lines/layout.ts +319 -0
  30. package/src/boxes-and-lines/parser.ts +694 -0
  31. package/src/boxes-and-lines/renderer.ts +848 -0
  32. package/src/boxes-and-lines/types.ts +40 -0
  33. package/src/c4/parser.ts +10 -5
  34. package/src/c4/renderer.ts +232 -56
  35. package/src/chart.ts +9 -4
  36. package/src/cli.ts +49 -6
  37. package/src/completion.ts +25 -33
  38. package/src/d3.ts +187 -46
  39. package/src/dgmo-router.ts +3 -7
  40. package/src/echarts.ts +38 -2
  41. package/src/editor/highlight-api.ts +444 -0
  42. package/src/editor/keywords.ts +6 -19
  43. package/src/er/parser.ts +10 -4
  44. package/src/gantt/parser.ts +7 -4
  45. package/src/gantt/renderer.ts +3 -5
  46. package/src/index.ts +106 -50
  47. package/src/infra/parser.ts +7 -5
  48. package/src/infra/renderer.ts +2 -2
  49. package/src/kanban/parser.ts +7 -5
  50. package/src/kanban/renderer.ts +43 -18
  51. package/src/org/parser.ts +7 -4
  52. package/src/org/renderer.ts +40 -29
  53. package/src/sequence/parser.ts +11 -5
  54. package/src/sequence/renderer.ts +114 -45
  55. package/src/sitemap/parser.ts +8 -4
  56. package/src/sitemap/renderer.ts +137 -57
  57. package/src/utils/legend-svg.ts +44 -20
  58. package/src/utils/parsing.ts +1 -1
  59. package/src/utils/tag-groups.ts +21 -1
  60. package/gallery/fixtures/initiative-status-full.dgmo +0 -46
  61. package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
  62. package/gallery/fixtures/initiative-status.dgmo +0 -9
  63. package/src/initiative-status/collapse.ts +0 -76
  64. package/src/initiative-status/filter.ts +0 -63
  65. package/src/initiative-status/layout.ts +0 -650
  66. package/src/initiative-status/parser.ts +0 -629
  67. package/src/initiative-status/renderer.ts +0 -1199
  68. package/src/initiative-status/types.ts +0 -57
@@ -0,0 +1,40 @@
1
+ import type { TagGroup } from '../utils/tag-groups';
2
+ import type { DgmoError } from '../diagnostics';
3
+
4
+ export interface BLNode {
5
+ label: string;
6
+ lineNumber: number;
7
+ metadata: Record<string, string>;
8
+ description?: string;
9
+ }
10
+
11
+ export interface BLEdge {
12
+ source: string;
13
+ target: string;
14
+ label?: string;
15
+ bidirectional: boolean;
16
+ lineNumber: number;
17
+ metadata: Record<string, string>;
18
+ }
19
+
20
+ export interface BLGroup {
21
+ label: string;
22
+ children: string[];
23
+ lineNumber: number;
24
+ metadata: Record<string, string>;
25
+ }
26
+
27
+ export interface ParsedBoxesAndLines {
28
+ type: 'boxes-and-lines';
29
+ title: string | null;
30
+ titleLineNumber: number | null;
31
+ nodes: BLNode[];
32
+ edges: BLEdge[];
33
+ groups: BLGroup[];
34
+ tagGroups: TagGroup[];
35
+ options: Record<string, string>;
36
+ initialHiddenTagValues: Map<string, Set<string>>;
37
+ direction: 'LR' | 'TB';
38
+ diagnostics: DgmoError[];
39
+ error: string | null;
40
+ }
package/src/c4/parser.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  import type { PaletteColors } from '../palettes';
6
6
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
7
7
  import type { TagGroup } from '../utils/tag-groups';
8
- import { matchTagBlockHeading } from '../utils/tag-groups';
8
+ import {
9
+ matchTagBlockHeading,
10
+ stripDefaultModifier,
11
+ } from '../utils/tag-groups';
9
12
  import { inferParticipantType } from '../sequence/participant-inference';
10
13
  import {
11
14
  measureIndent,
@@ -319,11 +322,12 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
319
322
  }
320
323
  }
321
324
 
322
- // Tag group entries — first entry is the default (no `default` keyword)
325
+ // Tag group entries — first entry is the default unless another is marked `default`
323
326
  if (currentTagGroup && !contentStarted) {
324
327
  const indent = measureIndent(line);
325
328
  if (indent > 0) {
326
- const { label, color } = extractColor(trimmed, palette);
329
+ const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
330
+ const { label, color } = extractColor(cleanEntry, palette);
327
331
  if (!color) {
328
332
  pushError(
329
333
  lineNumber,
@@ -331,8 +335,9 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
331
335
  );
332
336
  continue;
333
337
  }
334
- // First entry becomes the default
335
- if (currentTagGroup.entries.length === 0) {
338
+ if (isDefault) {
339
+ currentTagGroup.defaultValue = label;
340
+ } else if (currentTagGroup.entries.length === 0) {
336
341
  currentTagGroup.defaultValue = label;
337
342
  }
338
343
  currentTagGroup.entries.push({
@@ -11,7 +11,13 @@ import { renderInlineText } from '../utils/inline-markdown';
11
11
  import type { ParsedC4 } from './types';
12
12
  import type { C4LayoutResult, C4LayoutEdge, C4LegendGroup } from './layout';
13
13
  import { parseC4 } from './parser';
14
- import { layoutC4Context, layoutC4Containers, layoutC4Components, layoutC4Deployment, collectCardMetadata } from './layout';
14
+ import {
15
+ layoutC4Context,
16
+ layoutC4Containers,
17
+ layoutC4Components,
18
+ layoutC4Deployment,
19
+ collectCardMetadata,
20
+ } from './layout';
15
21
  import {
16
22
  LEGEND_HEIGHT,
17
23
  LEGEND_PILL_FONT_SIZE,
@@ -81,10 +87,14 @@ function typeColor(
81
87
  ): string {
82
88
  if (nodeColor) return nodeColor;
83
89
  switch (type) {
84
- case 'person': return palette.colors.blue;
85
- case 'container': return palette.colors.purple;
86
- case 'component': return palette.colors.green;
87
- default: return palette.colors.teal;
90
+ case 'person':
91
+ return palette.colors.blue;
92
+ case 'container':
93
+ return palette.colors.purple;
94
+ case 'component':
95
+ return palette.colors.green;
96
+ default:
97
+ return palette.colors.teal;
88
98
  }
89
99
  }
90
100
 
@@ -247,7 +257,9 @@ export function renderC4Context(
247
257
  const fixedLegend = !exportDims && hasLegend;
248
258
  const legendLayoutSpace = C4_LAYOUT_MARGIN + LEGEND_HEIGHT;
249
259
  const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
250
- const diagramH = fixedLegend ? layout.height - legendLayoutSpace : layout.height;
260
+ const diagramH = fixedLegend
261
+ ? layout.height - legendLayoutSpace
262
+ : layout.height;
251
263
  const availH = height - titleHeight - legendReserveH;
252
264
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
253
265
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
@@ -308,7 +320,10 @@ export function renderC4Context(
308
320
  .attr('fill', palette.text)
309
321
  .attr('font-size', TITLE_FONT_SIZE)
310
322
  .attr('font-weight', TITLE_FONT_WEIGHT)
311
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
323
+ .style(
324
+ 'cursor',
325
+ onClickItem && parsed.titleLineNumber ? 'pointer' : 'default'
326
+ )
312
327
  .text(parsed.title);
313
328
 
314
329
  if (parsed.titleLineNumber) {
@@ -415,7 +430,7 @@ export function renderC4Context(
415
430
  edgeG
416
431
  .append('text')
417
432
  .attr('x', midPt.x)
418
- .attr('y', (labelText ? textY + 18 : textY + 4))
433
+ .attr('y', labelText ? textY + 18 : textY + 4)
419
434
  .attr('text-anchor', 'middle')
420
435
  .attr('fill', edgeColor)
421
436
  .attr('font-size', TECH_FONT_SIZE)
@@ -444,7 +459,8 @@ export function renderC4Context(
444
459
  // Fall back to the group's defaultValue so hover-dimming works for
445
460
  // nodes that inherit the default (e.g. sc: Internal default).
446
461
  const tagGroup = parsed.tagGroups.find(
447
- (g) => g.name.toLowerCase() === tagKey || g.alias?.toLowerCase() === tagKey
462
+ (g) =>
463
+ g.name.toLowerCase() === tagKey || g.alias?.toLowerCase() === tagKey
448
464
  );
449
465
  if (tagGroup?.defaultValue) {
450
466
  nodeG.attr(`data-tag-${tagKey}`, tagGroup.defaultValue.toLowerCase());
@@ -573,12 +589,17 @@ export function renderC4Context(
573
589
  // Drillable accent bar — solid bar at bottom of card, clipped to rounded corners
574
590
  if (node.drillable) {
575
591
  const clipId = `clip-drill-${node.id.replace(/\s+/g, '-')}`;
576
- nodeG.append('clipPath').attr('id', clipId)
592
+ nodeG
593
+ .append('clipPath')
594
+ .attr('id', clipId)
577
595
  .append('rect')
578
- .attr('x', -w / 2).attr('y', -h / 2)
579
- .attr('width', w).attr('height', h)
596
+ .attr('x', -w / 2)
597
+ .attr('y', -h / 2)
598
+ .attr('width', w)
599
+ .attr('height', h)
580
600
  .attr('rx', CARD_RADIUS);
581
- nodeG.append('rect')
601
+ nodeG
602
+ .append('rect')
582
603
  .attr('x', -w / 2)
583
604
  .attr('y', h / 2 - DRILL_BAR_HEIGHT)
584
605
  .attr('width', w)
@@ -594,14 +615,22 @@ export function renderC4Context(
594
615
  // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
595
616
  // Export mode: render inside scaled contentG at layout coordinates.
596
617
  const legendParent = fixedLegend
597
- ? svg.append('g')
618
+ ? svg
619
+ .append('g')
598
620
  .attr('class', 'c4-legend-fixed')
599
621
  .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
600
622
  : contentG.append('g').attr('class', 'c4-legend');
601
623
  if (activeTagGroup) {
602
624
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
603
625
  }
604
- renderLegend(legendParent as GSelection, layout, palette, isDark, activeTagGroup, fixedLegend ? width : null);
626
+ renderLegend(
627
+ legendParent as GSelection,
628
+ layout,
629
+ palette,
630
+ isDark,
631
+ activeTagGroup,
632
+ fixedLegend ? width : null
633
+ );
605
634
  }
606
635
  }
607
636
 
@@ -938,8 +967,14 @@ function pointToPolylineDist(
938
967
 
939
968
  /** Check if a rect overlaps another rect. */
940
969
  function rectsOverlap(
941
- ax: number, ay: number, aw: number, ah: number,
942
- bx: number, by: number, bw: number, bh: number,
970
+ ax: number,
971
+ ay: number,
972
+ aw: number,
973
+ ah: number,
974
+ bx: number,
975
+ by: number,
976
+ bw: number,
977
+ bh: number,
943
978
  pad: number
944
979
  ): boolean {
945
980
  return !(
@@ -1006,7 +1041,7 @@ function placeEdgeLabels(
1006
1041
  const placedRects: { x: number; y: number; w: number; h: number }[] = [];
1007
1042
 
1008
1043
  // Bias samples toward target end (50–90%) where edges have diverged
1009
- const SAMPLES = [0.40, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75, 0.80, 0.85, 0.90];
1044
+ const SAMPLES = [0.4, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9];
1010
1045
 
1011
1046
  // Pre-compute candidate positions for each label
1012
1047
  const candidates = labels.map((lbl) => {
@@ -1021,7 +1056,8 @@ function placeEdgeLabels(
1021
1056
  order.sort((a, b) => {
1022
1057
  const midA = interpolateAlongPath(allPaths[labels[a]!.edgeIdx]!, 0.5);
1023
1058
  const midB = interpolateAlongPath(allPaths[labels[b]!.edgeIdx]!, 0.5);
1024
- let nearA = 0, nearB = 0;
1059
+ let nearA = 0,
1060
+ nearB = 0;
1025
1061
  for (let e = 0; e < allPaths.length; e++) {
1026
1062
  if (e === labels[a]!.edgeIdx) continue;
1027
1063
  if (pointToPolylineDist(midA, allPaths[e]!) < 100) nearA++;
@@ -1055,7 +1091,19 @@ function placeEdgeLabels(
1055
1091
  // Penalty for overlapping already-placed labels
1056
1092
  let labelOverlapPenalty = 0;
1057
1093
  for (const placed of placedRects) {
1058
- if (rectsOverlap(pt.x, pt.y, lbl.bgW, lbl.bgH, placed.x, placed.y, placed.w, placed.h, 6)) {
1094
+ if (
1095
+ rectsOverlap(
1096
+ pt.x,
1097
+ pt.y,
1098
+ lbl.bgW,
1099
+ lbl.bgH,
1100
+ placed.x,
1101
+ placed.y,
1102
+ placed.w,
1103
+ placed.h,
1104
+ 6
1105
+ )
1106
+ ) {
1059
1107
  labelOverlapPenalty += 200;
1060
1108
  }
1061
1109
  }
@@ -1063,7 +1111,19 @@ function placeEdgeLabels(
1063
1111
  // Penalty for overlapping boundary/obstacle rects (e.g. boundary labels)
1064
1112
  if (obstacleRects) {
1065
1113
  for (const obs of obstacleRects) {
1066
- if (rectsOverlap(pt.x, pt.y, lbl.bgW, lbl.bgH, obs.x + obs.w / 2, obs.y + obs.h / 2, obs.w, obs.h, 6)) {
1114
+ if (
1115
+ rectsOverlap(
1116
+ pt.x,
1117
+ pt.y,
1118
+ lbl.bgW,
1119
+ lbl.bgH,
1120
+ obs.x + obs.w / 2,
1121
+ obs.y + obs.h / 2,
1122
+ obs.w,
1123
+ obs.h,
1124
+ 6
1125
+ )
1126
+ ) {
1067
1127
  labelOverlapPenalty += 200;
1068
1128
  }
1069
1129
  }
@@ -1087,31 +1147,87 @@ function placeEdgeLabels(
1087
1147
  const nx = -tan.y / tLen;
1088
1148
  const ny = tan.x / tLen;
1089
1149
  const offsetDist = lbl.bgH / 2 + 4;
1090
- const sideA = { x: bestPt.x + nx * offsetDist, y: bestPt.y + ny * offsetDist };
1091
- const sideB = { x: bestPt.x - nx * offsetDist, y: bestPt.y - ny * offsetDist };
1150
+ const sideA = {
1151
+ x: bestPt.x + nx * offsetDist,
1152
+ y: bestPt.y + ny * offsetDist,
1153
+ };
1154
+ const sideB = {
1155
+ x: bestPt.x - nx * offsetDist,
1156
+ y: bestPt.y - ny * offsetDist,
1157
+ };
1092
1158
 
1093
1159
  // Score each side: clearance from other edges + overlap with placed labels
1094
- let scoreA = Infinity, scoreB = Infinity;
1160
+ let scoreA = Infinity,
1161
+ scoreB = Infinity;
1095
1162
  for (let e = 0; e < allPaths.length; e++) {
1096
1163
  if (e === ownEdgeIdx) continue;
1097
1164
  scoreA = Math.min(scoreA, pointToPolylineDist(sideA, allPaths[e]!));
1098
1165
  scoreB = Math.min(scoreB, pointToPolylineDist(sideB, allPaths[e]!));
1099
1166
  }
1100
1167
  for (const placed of placedRects) {
1101
- if (rectsOverlap(sideA.x, sideA.y, lbl.bgW, lbl.bgH, placed.x, placed.y, placed.w, placed.h, 6)) {
1168
+ if (
1169
+ rectsOverlap(
1170
+ sideA.x,
1171
+ sideA.y,
1172
+ lbl.bgW,
1173
+ lbl.bgH,
1174
+ placed.x,
1175
+ placed.y,
1176
+ placed.w,
1177
+ placed.h,
1178
+ 6
1179
+ )
1180
+ ) {
1102
1181
  scoreA -= 200;
1103
1182
  }
1104
- if (rectsOverlap(sideB.x, sideB.y, lbl.bgW, lbl.bgH, placed.x, placed.y, placed.w, placed.h, 6)) {
1183
+ if (
1184
+ rectsOverlap(
1185
+ sideB.x,
1186
+ sideB.y,
1187
+ lbl.bgW,
1188
+ lbl.bgH,
1189
+ placed.x,
1190
+ placed.y,
1191
+ placed.w,
1192
+ placed.h,
1193
+ 6
1194
+ )
1195
+ ) {
1105
1196
  scoreB -= 200;
1106
1197
  }
1107
1198
  }
1108
1199
  if (obstacleRects) {
1109
1200
  for (const obs of obstacleRects) {
1110
- const cx = obs.x + obs.w / 2, cy = obs.y + obs.h / 2;
1111
- if (rectsOverlap(sideA.x, sideA.y, lbl.bgW, lbl.bgH, cx, cy, obs.w, obs.h, 6)) {
1201
+ const cx = obs.x + obs.w / 2,
1202
+ cy = obs.y + obs.h / 2;
1203
+ if (
1204
+ rectsOverlap(
1205
+ sideA.x,
1206
+ sideA.y,
1207
+ lbl.bgW,
1208
+ lbl.bgH,
1209
+ cx,
1210
+ cy,
1211
+ obs.w,
1212
+ obs.h,
1213
+ 6
1214
+ )
1215
+ ) {
1112
1216
  scoreA -= 200;
1113
1217
  }
1114
- if (rectsOverlap(sideB.x, sideB.y, lbl.bgW, lbl.bgH, cx, cy, obs.w, obs.h, 6)) {
1218
+ if (
1219
+ rectsOverlap(
1220
+ sideB.x,
1221
+ sideB.y,
1222
+ lbl.bgW,
1223
+ lbl.bgH,
1224
+ cx,
1225
+ cy,
1226
+ obs.w,
1227
+ obs.h,
1228
+ 6
1229
+ )
1230
+ ) {
1115
1231
  scoreB -= 200;
1116
1232
  }
1117
1233
  }
@@ -1138,19 +1254,25 @@ function renderLegend(
1138
1254
  /** When set, center groups horizontally across this width (fixed overlay mode). */
1139
1255
  fixedWidth?: number | null
1140
1256
  ): void {
1141
- const visibleGroups = activeTagGroup != null
1142
- ? layout.legend.filter((g) => g.name.toLowerCase() === (activeTagGroup ?? '').toLowerCase())
1143
- : layout.legend;
1144
-
1145
- const pillWidthOf = (g: C4LegendGroup) => measureLegendText(g.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1146
- const effectiveW = (g: C4LegendGroup) => activeTagGroup != null ? g.width : pillWidthOf(g);
1257
+ const visibleGroups =
1258
+ activeTagGroup != null
1259
+ ? layout.legend.filter(
1260
+ (g) => g.name.toLowerCase() === (activeTagGroup ?? '').toLowerCase()
1261
+ )
1262
+ : layout.legend;
1263
+
1264
+ const pillWidthOf = (g: C4LegendGroup) =>
1265
+ measureLegendText(g.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1266
+ const effectiveW = (g: C4LegendGroup) =>
1267
+ activeTagGroup != null ? g.width : pillWidthOf(g);
1147
1268
 
1148
1269
  // In fixed mode, compute centered x-positions
1149
1270
  let fixedPositions: Map<string, number> | null = null;
1150
1271
  if (fixedWidth != null && visibleGroups.length > 0) {
1151
1272
  fixedPositions = new Map();
1152
- const totalW = visibleGroups.reduce((s, g) => s + effectiveW(g), 0)
1153
- + (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1273
+ const totalW =
1274
+ visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1275
+ (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1154
1276
  let cx = Math.max(DIAGRAM_PADDING, (fixedWidth - totalW) / 2);
1155
1277
  for (const g of visibleGroups) {
1156
1278
  fixedPositions.set(g.name, cx);
@@ -1190,8 +1312,8 @@ function renderLegend(
1190
1312
  }
1191
1313
 
1192
1314
  const pillX = isActive ? LEGEND_CAPSULE_PAD : 0;
1193
- const pillY = isActive ? LEGEND_CAPSULE_PAD : 0;
1194
- const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
1315
+ const pillY = LEGEND_CAPSULE_PAD;
1316
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
1195
1317
 
1196
1318
  gEl
1197
1319
  .append('rect')
@@ -1249,7 +1371,10 @@ function renderLegend(
1249
1371
  .attr('fill', palette.textMuted)
1250
1372
  .text(entry.value);
1251
1373
 
1252
- entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1374
+ entryX =
1375
+ textX +
1376
+ measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
1377
+ LEGEND_ENTRY_TRAIL;
1253
1378
  }
1254
1379
  }
1255
1380
  }
@@ -1289,7 +1414,9 @@ export function renderC4Containers(
1289
1414
  const fixedLegend = !exportDims && hasLegend;
1290
1415
  const legendLayoutSpace = C4_LAYOUT_MARGIN + LEGEND_HEIGHT;
1291
1416
  const legendReserveH = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
1292
- const diagramH = fixedLegend ? layout.height - legendLayoutSpace : layout.height;
1417
+ const diagramH = fixedLegend
1418
+ ? layout.height - legendLayoutSpace
1419
+ : layout.height;
1293
1420
  const availH = height - titleHeight - legendReserveH;
1294
1421
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
1295
1422
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
@@ -1348,7 +1475,10 @@ export function renderC4Containers(
1348
1475
  .attr('fill', palette.text)
1349
1476
  .attr('font-size', TITLE_FONT_SIZE)
1350
1477
  .attr('font-weight', TITLE_FONT_WEIGHT)
1351
- .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
1478
+ .style(
1479
+ 'cursor',
1480
+ onClickItem && parsed.titleLineNumber ? 'pointer' : 'default'
1481
+ )
1352
1482
  .text(parsed.title);
1353
1483
 
1354
1484
  if (parsed.titleLineNumber) {
@@ -1453,7 +1583,12 @@ export function renderC4Containers(
1453
1583
  }
1454
1584
 
1455
1585
  // ── Collect boundary label rects as obstacles for edge label placement ──
1456
- const boundaryLabelObstacles: { x: number; y: number; w: number; h: number }[] = [];
1586
+ const boundaryLabelObstacles: {
1587
+ x: number;
1588
+ y: number;
1589
+ w: number;
1590
+ h: number;
1591
+ }[] = [];
1457
1592
  if (layout.boundary) {
1458
1593
  const b = layout.boundary;
1459
1594
  const labelText = `${b.label} \u2014 ${b.typeLabel}`;
@@ -1468,7 +1603,13 @@ export function renderC4Containers(
1468
1603
  }
1469
1604
 
1470
1605
  // ── Edges (behind nodes) ──
1471
- renderEdges(contentG as GSelection, layout.edges, palette, onClickItem, boundaryLabelObstacles);
1606
+ renderEdges(
1607
+ contentG as GSelection,
1608
+ layout.edges,
1609
+ palette,
1610
+ onClickItem,
1611
+ boundaryLabelObstacles
1612
+ );
1472
1613
 
1473
1614
  // ── Nodes ──
1474
1615
  for (const node of layout.nodes) {
@@ -1488,7 +1629,8 @@ export function renderC4Containers(
1488
1629
  // Fall back to the group's defaultValue so hover-dimming works for
1489
1630
  // nodes that inherit the default (e.g. sc: Internal default).
1490
1631
  const tagGroup = parsed.tagGroups.find(
1491
- (g) => g.name.toLowerCase() === tagKey || g.alias?.toLowerCase() === tagKey
1632
+ (g) =>
1633
+ g.name.toLowerCase() === tagKey || g.alias?.toLowerCase() === tagKey
1492
1634
  );
1493
1635
  if (tagGroup?.defaultValue) {
1494
1636
  nodeG.attr(`data-tag-${tagKey}`, tagGroup.defaultValue.toLowerCase());
@@ -1519,7 +1661,14 @@ export function renderC4Containers(
1519
1661
 
1520
1662
  // Card background — shape-specific
1521
1663
  if (shape === 'database' || shape === 'cache') {
1522
- drawCylinderCard(nodeG as GSelection, w, h, fill, stroke, shape === 'cache');
1664
+ drawCylinderCard(
1665
+ nodeG as GSelection,
1666
+ w,
1667
+ h,
1668
+ fill,
1669
+ stroke,
1670
+ shape === 'cache'
1671
+ );
1523
1672
  } else {
1524
1673
  drawCardRect(nodeG as GSelection, w, h, fill, stroke, isExternalShape);
1525
1674
  }
@@ -1557,7 +1706,12 @@ export function renderC4Containers(
1557
1706
  const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
1558
1707
  const textX = iconCx + PERSON_ICON_W / 2 + gap;
1559
1708
 
1560
- drawPersonIcon(nodeG as GSelection, iconCx, yPos + NAME_FONT_SIZE / 2 - 2, stroke);
1709
+ drawPersonIcon(
1710
+ nodeG as GSelection,
1711
+ iconCx,
1712
+ yPos + NAME_FONT_SIZE / 2 - 2,
1713
+ stroke
1714
+ );
1561
1715
 
1562
1716
  nodeG
1563
1717
  .append('text')
@@ -1688,12 +1842,17 @@ export function renderC4Containers(
1688
1842
  // Drillable accent bar — solid bar at bottom of card, clipped to rounded corners
1689
1843
  if (node.drillable) {
1690
1844
  const clipId = `clip-drill-${node.id.replace(/\s+/g, '-')}`;
1691
- nodeG.append('clipPath').attr('id', clipId)
1845
+ nodeG
1846
+ .append('clipPath')
1847
+ .attr('id', clipId)
1692
1848
  .append('rect')
1693
- .attr('x', -w / 2).attr('y', -h / 2)
1694
- .attr('width', w).attr('height', h)
1849
+ .attr('x', -w / 2)
1850
+ .attr('y', -h / 2)
1851
+ .attr('width', w)
1852
+ .attr('height', h)
1695
1853
  .attr('rx', CARD_RADIUS);
1696
- nodeG.append('rect')
1854
+ nodeG
1855
+ .append('rect')
1697
1856
  .attr('x', -w / 2)
1698
1857
  .attr('y', h / 2 - DRILL_BAR_HEIGHT)
1699
1858
  .attr('width', w)
@@ -1709,14 +1868,22 @@ export function renderC4Containers(
1709
1868
  // App mode: fixed overlay at SVG top so it's always readable regardless of scale.
1710
1869
  // Export mode: render inside scaled contentG at layout coordinates.
1711
1870
  const legendParent = fixedLegend
1712
- ? svg.append('g')
1871
+ ? svg
1872
+ .append('g')
1713
1873
  .attr('class', 'c4-legend-fixed')
1714
1874
  .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleHeight})`)
1715
1875
  : contentG.append('g').attr('class', 'c4-legend');
1716
1876
  if (activeTagGroup) {
1717
1877
  legendParent.attr('data-legend-active', activeTagGroup.toLowerCase());
1718
1878
  }
1719
- renderLegend(legendParent as GSelection, layout, palette, isDark, activeTagGroup, fixedLegend ? width : null);
1879
+ renderLegend(
1880
+ legendParent as GSelection,
1881
+ layout,
1882
+ palette,
1883
+ isDark,
1884
+ activeTagGroup,
1885
+ fixedLegend ? width : null
1886
+ );
1720
1887
  }
1721
1888
  }
1722
1889
 
@@ -1841,9 +2008,18 @@ export function renderC4Deployment(
1841
2008
  isDark: boolean,
1842
2009
  onClickItem?: (lineNumber: number) => void,
1843
2010
  exportDims?: { width?: number; height?: number },
1844
- activeTagGroup?: string | null,
2011
+ activeTagGroup?: string | null
1845
2012
  ): void {
1846
- renderC4Containers(container, parsed, layout, palette, isDark, onClickItem, exportDims, activeTagGroup);
2013
+ renderC4Containers(
2014
+ container,
2015
+ parsed,
2016
+ layout,
2017
+ palette,
2018
+ isDark,
2019
+ onClickItem,
2020
+ exportDims,
2021
+ activeTagGroup
2022
+ );
1847
2023
  }
1848
2024
 
1849
2025
  /**
@@ -1852,7 +2028,7 @@ export function renderC4Deployment(
1852
2028
  export function renderC4DeploymentForExport(
1853
2029
  content: string,
1854
2030
  theme: 'light' | 'dark' | 'transparent',
1855
- palette: PaletteColors,
2031
+ palette: PaletteColors
1856
2032
  ): string {
1857
2033
  const parsed = parseC4(content, palette);
1858
2034
  if (parsed.error || parsed.deployment.length === 0) return '';
package/src/chart.ts CHANGED
@@ -330,8 +330,12 @@ export function parseChart(
330
330
  // Supports comma-separated multi-values: "Jan 100, 200, 300"
331
331
  // Supports space-separated multi-values when series are defined: "Jan 100 200 300"
332
332
  // Supports comma-grouped numbers: "Revenue 1,200, 1,500" → [1200, 1500]
333
- const multiValue = (result.seriesNames?.length ?? 0) >= 2;
334
- const dataValues = parseDataRowValues(trimmed, { multiValue });
333
+ const seriesCount = result.seriesNames?.length ?? 0;
334
+ const multiValue = seriesCount >= 2;
335
+ const dataValues = parseDataRowValues(trimmed, {
336
+ multiValue,
337
+ expectedValues: multiValue ? seriesCount : undefined,
338
+ });
335
339
  if (dataValues) {
336
340
  const { label: rawLabel, color: pointColor } = extractColor(
337
341
  dataValues.label,
@@ -455,7 +459,7 @@ export function parseChart(
455
459
  */
456
460
  export function parseDataRowValues(
457
461
  line: string,
458
- options?: { multiValue?: boolean }
462
+ options?: { multiValue?: boolean; expectedValues?: number }
459
463
  ): { label: string; values: number[] } | null {
460
464
  // First, normalize comma-grouped numbers: replace patterns like "1,087" with "1087"
461
465
  // We need to be careful: commas also separate multi-values.
@@ -550,9 +554,10 @@ export function parseDataRowValues(
550
554
  if (tokens.length < 2) return null;
551
555
 
552
556
  if (options?.multiValue) {
557
+ const limit = options.expectedValues ?? Infinity;
553
558
  const values: number[] = [];
554
559
  let idx = tokens.length - 1;
555
- while (idx >= 1) {
560
+ while (idx >= 1 && values.length < limit) {
556
561
  const tok = tokens[idx];
557
562
  const num = parseFloat(tok);
558
563
  if (isNaN(num) || !isFinite(Number(tok))) break;