@diagrammo/dgmo 0.2.21 → 0.2.23

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.
@@ -0,0 +1,43 @@
1
+ // ============================================================
2
+ // Initiative Status Diagram — Types
3
+ // ============================================================
4
+
5
+ import type { DgmoError } from '../diagnostics';
6
+ import type { ParticipantType } from '../sequence/parser';
7
+
8
+ export type InitiativeStatus = 'done' | 'wip' | 'todo' | 'na' | null;
9
+
10
+ export const VALID_STATUSES: readonly string[] = ['done', 'wip', 'todo', 'na'];
11
+
12
+ export interface ISNode {
13
+ label: string;
14
+ status: InitiativeStatus;
15
+ shape: ParticipantType;
16
+ lineNumber: number;
17
+ }
18
+
19
+ export interface ISEdge {
20
+ source: string; // node label
21
+ target: string; // node label
22
+ label?: string; // e.g. "getUser"
23
+ status: InitiativeStatus;
24
+ lineNumber: number;
25
+ }
26
+
27
+ export interface ISGroup {
28
+ label: string;
29
+ nodeLabels: string[];
30
+ lineNumber: number;
31
+ }
32
+
33
+ export interface ParsedInitiativeStatus {
34
+ type: 'initiative-status';
35
+ title: string | null;
36
+ titleLineNumber: number | null;
37
+ nodes: ISNode[];
38
+ edges: ISEdge[];
39
+ groups: ISGroup[];
40
+ options: Record<string, string>;
41
+ diagnostics: DgmoError[];
42
+ error?: string;
43
+ }
@@ -267,7 +267,9 @@ export function renderKanban(
267
267
  ? parsed.title.length * TITLE_FONT_SIZE * 0.6 + 16
268
268
  : 0;
269
269
  let legendX = DIAGRAM_PADDING + titleTextWidth;
270
- const groupBg = mix(palette.surface, palette.bg, isDark ? 35 : 20);
270
+ const groupBg = isDark
271
+ ? mix(palette.surface, palette.bg, 50)
272
+ : mix(palette.surface, palette.bg, 30);
271
273
  const capsulePad = 4;
272
274
 
273
275
  for (const group of parsed.tagGroups) {
@@ -345,7 +347,12 @@ export function renderKanban(
345
347
  if (isActive) {
346
348
  let entryX = pillX + pillWidth + 4;
347
349
  for (const entry of group.entries) {
348
- svg
350
+ const entryG = svg
351
+ .append('g')
352
+ .attr('data-legend-entry', entry.value.toLowerCase())
353
+ .style('cursor', 'pointer');
354
+
355
+ entryG
349
356
  .append('circle')
350
357
  .attr('cx', entryX + LEGEND_DOT_R)
351
358
  .attr('cy', legendY + LEGEND_HEIGHT / 2)
@@ -353,7 +360,7 @@ export function renderKanban(
353
360
  .attr('fill', entry.color);
354
361
 
355
362
  const entryTextX = entryX + LEGEND_DOT_R * 2 + 4;
356
- svg
363
+ entryG
357
364
  .append('text')
358
365
  .attr('x', entryTextX)
359
366
  .attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
@@ -464,6 +471,19 @@ export function renderKanban(
464
471
  .attr('data-card-id', card.id)
465
472
  .attr('data-line-number', card.lineNumber);
466
473
 
474
+ // Expose active tag group value for legend-entry hover dimming
475
+ if (activeTagGroup) {
476
+ const tagKey = activeTagGroup.toLowerCase();
477
+ const tagValue = card.tags[tagKey];
478
+ const group = parsed.tagGroups.find(
479
+ (tg) => tg.name.toLowerCase() === tagKey
480
+ );
481
+ const value = tagValue ?? group?.defaultValue;
482
+ if (value) {
483
+ cg.attr(`data-tag-${tagKey}`, value.toLowerCase());
484
+ }
485
+ }
486
+
467
487
  const cx = colLayout.x + cardLayout.x;
468
488
  const cy = colLayout.y + cardLayout.y;
469
489
 
package/src/org/layout.ts CHANGED
@@ -56,6 +56,7 @@ export interface OrgLegendEntry {
56
56
 
57
57
  export interface OrgLegendGroup {
58
58
  name: string;
59
+ alias?: string;
59
60
  entries: OrgLegendEntry[];
60
61
  x: number;
61
62
  y: number;
@@ -270,17 +271,30 @@ function centerHeavyChildren(node: TreeNode): void {
270
271
  // Layout
271
272
  // ============================================================
272
273
 
273
- function computeLegendGroups(tagGroups: OrgTagGroup[], _showEyeIcons: boolean): OrgLegendGroup[] {
274
+ function computeLegendGroups(
275
+ tagGroups: OrgTagGroup[],
276
+ _showEyeIcons: boolean,
277
+ usedValuesByGroup?: Map<string, Set<string>>
278
+ ): OrgLegendGroup[] {
274
279
  const groups: OrgLegendGroup[] = [];
275
280
 
276
281
  for (const group of tagGroups) {
277
282
  if (group.entries.length === 0) continue;
278
283
 
284
+ // Filter entries to only values actually used by nodes (if provided)
285
+ const usedValues = usedValuesByGroup?.get(group.name.toLowerCase());
286
+ const visibleEntries = usedValues
287
+ ? group.entries.filter((e) => usedValues.has(e.value.toLowerCase()))
288
+ : group.entries;
289
+ if (visibleEntries.length === 0) continue;
290
+
291
+ // Pill label shows just the group name (alias is for DSL shorthand only)
279
292
  const pillWidth = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
293
+ const minPillWidth = pillWidth;
280
294
 
281
295
  // Capsule: pad + pill + gap + entries + pad
282
296
  let entriesWidth = 0;
283
- for (const entry of group.entries) {
297
+ for (const entry of visibleEntries) {
284
298
  entriesWidth +=
285
299
  LEGEND_DOT_R * 2 +
286
300
  LEGEND_ENTRY_DOT_GAP +
@@ -292,12 +306,16 @@ function computeLegendGroups(tagGroups: OrgTagGroup[], _showEyeIcons: boolean):
292
306
 
293
307
  groups.push({
294
308
  name: group.name,
295
- entries: group.entries.map((e) => ({ value: e.value, color: e.color })),
309
+ alias: group.alias,
310
+ entries: visibleEntries.map((e) => ({
311
+ value: e.value,
312
+ color: e.color,
313
+ })),
296
314
  x: 0,
297
315
  y: 0,
298
316
  width: capsuleWidth,
299
317
  height: LEGEND_HEIGHT,
300
- minifiedWidth: pillWidth,
318
+ minifiedWidth: minPillWidth,
301
319
  minifiedHeight: LEGEND_HEIGHT,
302
320
  });
303
321
  }
@@ -348,20 +366,22 @@ export function layoutOrg(
348
366
  return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
349
367
  }
350
368
 
351
- // Layout legend groups horizontally (all minified when no nodes)
352
- let cx = MARGIN;
369
+ // Legend-only mode: stack groups vertically, all expanded
370
+ let cy = MARGIN;
371
+ let maxWidth = 0;
353
372
  for (const g of legendGroups) {
354
- g.x = cx;
355
- g.y = MARGIN;
356
- cx += g.minifiedWidth + LEGEND_GROUP_GAP;
373
+ g.x = MARGIN;
374
+ g.y = cy;
375
+ cy += LEGEND_HEIGHT + LEGEND_GROUP_GAP;
376
+ if (g.width > maxWidth) maxWidth = g.width;
357
377
  }
358
378
  return {
359
379
  nodes: [],
360
380
  edges: [],
361
381
  containers: [],
362
382
  legend: legendGroups,
363
- width: cx - LEGEND_GROUP_GAP + MARGIN,
364
- height: LEGEND_HEIGHT + MARGIN * 2,
383
+ width: maxWidth + MARGIN * 2,
384
+ height: cy - LEGEND_GROUP_GAP + MARGIN,
365
385
  };
366
386
  }
367
387
 
@@ -1084,13 +1104,28 @@ export function layoutOrg(
1084
1104
  const totalWidth = finalMaxX - finalMinX + MARGIN * 2;
1085
1105
  const totalHeight = finalMaxY - minY + MARGIN * 2;
1086
1106
 
1107
+ // Collect which tag group values are actually used by nodes
1108
+ const usedValuesByGroup = new Map<string, Set<string>>();
1109
+ for (const group of parsed.tagGroups) {
1110
+ const key = group.name.toLowerCase();
1111
+ const used = new Set<string>();
1112
+ const walk = (node: OrgNode) => {
1113
+ if (!node.isContainer && node.metadata[key]) {
1114
+ used.add(node.metadata[key].toLowerCase());
1115
+ }
1116
+ for (const child of node.children) walk(child);
1117
+ };
1118
+ for (const root of parsed.roots) walk(root);
1119
+ usedValuesByGroup.set(key, used);
1120
+ }
1121
+
1087
1122
  // Compute legend for tag groups
1088
1123
  const showEyeIcons = hiddenAttributes !== undefined;
1089
- const legendGroups = computeLegendGroups(parsed.tagGroups, showEyeIcons);
1124
+ const legendGroups = computeLegendGroups(parsed.tagGroups, showEyeIcons, usedValuesByGroup);
1090
1125
  let finalWidth = totalWidth;
1091
1126
  let finalHeight = totalHeight;
1092
1127
 
1093
- const legendPosition = parsed.options?.['legend-position'] ?? 'bottom';
1128
+ const legendPosition = parsed.options?.['legend-position'] ?? 'top';
1094
1129
 
1095
1130
  // When a tag group is active, only that group is laid out (full size).
1096
1131
  // When none is active, all groups are laid out minified.
@@ -1133,28 +1168,31 @@ export function layoutOrg(
1133
1168
 
1134
1169
  finalHeight = totalHeight + LEGEND_GAP + LEGEND_HEIGHT;
1135
1170
  } else {
1136
- // Top: horizontal row at top-right
1171
+ // Top: horizontal row above chart content, left-aligned
1172
+ const legendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP;
1173
+
1174
+ // Push all chart content down
1175
+ for (const n of layoutNodes) n.y += legendShift;
1176
+ for (const c of containers) c.y += legendShift;
1177
+ for (const e of layoutEdges) {
1178
+ for (const p of e.points) p.y += legendShift;
1179
+ }
1180
+
1137
1181
  const totalGroupsWidth =
1138
1182
  visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
1139
1183
  (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
1140
- const legendStartX = totalWidth - MARGIN + LEGEND_GAP;
1141
- const legendY = MARGIN;
1142
1184
 
1143
- let cx = legendStartX;
1185
+ let cx = MARGIN;
1144
1186
  for (const g of visibleGroups) {
1145
1187
  g.x = cx;
1146
- g.y = legendY;
1188
+ g.y = MARGIN;
1147
1189
  cx += effectiveW(g) + LEGEND_GROUP_GAP;
1148
1190
  }
1149
1191
 
1150
- const legendRight = legendStartX + totalGroupsWidth + MARGIN;
1151
- if (legendRight > finalWidth) {
1152
- finalWidth = legendRight;
1153
- }
1154
-
1155
- const legendBottom = legendY + LEGEND_HEIGHT + MARGIN;
1156
- if (legendBottom > finalHeight) {
1157
- finalHeight = legendBottom;
1192
+ finalHeight += legendShift;
1193
+ const neededWidth = totalGroupsWidth + MARGIN * 2;
1194
+ if (neededWidth > finalWidth) {
1195
+ finalWidth = neededWidth;
1158
1196
  }
1159
1197
  }
1160
1198
  }
@@ -77,14 +77,12 @@ function nodeFill(
77
77
  isDark: boolean,
78
78
  nodeColor?: string
79
79
  ): string {
80
- if (nodeColor) {
81
- return mix(nodeColor, isDark ? palette.surface : palette.bg, 25);
82
- }
83
- return mix(palette.primary, isDark ? palette.surface : palette.bg, 15);
80
+ const color = nodeColor ?? palette.primary;
81
+ return mix(color, isDark ? palette.surface : palette.bg, 25);
84
82
  }
85
83
 
86
84
  function nodeStroke(palette: PaletteColors, nodeColor?: string): string {
87
- return nodeColor ?? palette.textMuted;
85
+ return nodeColor ?? palette.primary;
88
86
  }
89
87
 
90
88
  function containerFill(
@@ -206,6 +204,15 @@ export function renderOrg(
206
204
  .attr('class', 'org-container')
207
205
  .attr('data-line-number', String(c.lineNumber)) as GSelection;
208
206
 
207
+ // Expose active tag group value for legend-entry hover dimming
208
+ if (activeTagGroup) {
209
+ const tagKey = activeTagGroup.toLowerCase();
210
+ const metaValue = c.metadata[tagKey];
211
+ if (metaValue) {
212
+ cG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
213
+ }
214
+ }
215
+
209
216
  // Toggle attribute for containers that have (or had) children
210
217
  if (c.hasChildren) {
211
218
  cG.attr('data-node-toggle', c.nodeId)
@@ -333,6 +340,15 @@ export function renderOrg(
333
340
  .attr('class', 'org-node')
334
341
  .attr('data-line-number', String(node.lineNumber)) as GSelection;
335
342
 
343
+ // Expose active tag group value for legend-entry hover dimming
344
+ if (activeTagGroup) {
345
+ const tagKey = activeTagGroup.toLowerCase();
346
+ const metaValue = node.metadata[tagKey];
347
+ if (metaValue) {
348
+ nodeG.attr(`data-tag-${tagKey}`, metaValue.toLowerCase());
349
+ }
350
+ }
351
+
336
352
  // Toggle attribute for nodes that have (or had) children
337
353
  if (node.hasChildren) {
338
354
  nodeG
@@ -447,27 +463,34 @@ export function renderOrg(
447
463
 
448
464
  // Render legend — kanban-style pills.
449
465
  // Skip in export mode (unless legend-only chart).
466
+ // Legend-only (no nodes): all groups rendered as expanded capsules.
450
467
  // Active group: only that group rendered as capsule (pill + entries).
451
468
  // No active group: all groups rendered as standalone pills.
452
- if (!exportDims || layout.nodes.length === 0) for (const group of layout.legend) {
469
+ const legendOnly = layout.nodes.length === 0;
470
+ if (!exportDims || legendOnly) for (const group of layout.legend) {
453
471
  const isActive =
454
- activeTagGroup != null &&
455
- group.name.toLowerCase() === activeTagGroup.toLowerCase();
472
+ legendOnly ||
473
+ (activeTagGroup != null &&
474
+ group.name.toLowerCase() === activeTagGroup.toLowerCase());
456
475
 
457
- // When a group is active, skip all other groups entirely
458
- if (activeTagGroup != null && !isActive) continue;
476
+ // When a group is active, skip all other groups entirely (not in legend-only mode)
477
+ if (!legendOnly && activeTagGroup != null && !isActive) continue;
459
478
 
460
- const groupBg = mix(palette.surface, palette.bg, isDark ? 35 : 20);
479
+ const groupBg = isDark
480
+ ? mix(palette.surface, palette.bg, 50)
481
+ : mix(palette.surface, palette.bg, 30);
461
482
 
483
+ // Pill label: just the group name (alias is for DSL shorthand only)
484
+ const pillLabel = group.name;
462
485
  const pillWidth =
463
- group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
486
+ pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
464
487
 
465
488
  const gEl = contentG
466
489
  .append('g')
467
490
  .attr('transform', `translate(${group.x}, ${group.y})`)
468
491
  .attr('class', 'org-legend-group')
469
492
  .attr('data-legend-group', group.name.toLowerCase())
470
- .style('cursor', 'pointer');
493
+ .style('cursor', legendOnly ? 'default' : 'pointer');
471
494
 
472
495
  // Outer capsule background (active only)
473
496
  if (isActive) {
@@ -516,13 +539,18 @@ export function renderOrg(
516
539
  .attr('font-weight', '500')
517
540
  .attr('fill', isActive ? palette.text : palette.textMuted)
518
541
  .attr('text-anchor', 'middle')
519
- .text(group.name);
542
+ .text(pillLabel);
520
543
 
521
544
  // Entries inside capsule (active only)
522
545
  if (isActive) {
523
546
  let entryX = pillX + pillWidth + 4;
524
547
  for (const entry of group.entries) {
525
- gEl
548
+ const entryG = gEl
549
+ .append('g')
550
+ .attr('data-legend-entry', entry.value.toLowerCase())
551
+ .style('cursor', 'pointer');
552
+
553
+ entryG
526
554
  .append('circle')
527
555
  .attr('cx', entryX + LEGEND_DOT_R)
528
556
  .attr('cy', LEGEND_HEIGHT / 2)
@@ -530,15 +558,16 @@ export function renderOrg(
530
558
  .attr('fill', entry.color);
531
559
 
532
560
  const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
533
- gEl
561
+ const entryLabel = entry.value;
562
+ entryG
534
563
  .append('text')
535
564
  .attr('x', textX)
536
565
  .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
537
566
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
538
567
  .attr('fill', palette.textMuted)
539
- .text(entry.value);
568
+ .text(entryLabel);
540
569
 
541
- entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
570
+ entryX = textX + entryLabel.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
542
571
  }
543
572
  }
544
573
  }
@@ -200,6 +200,7 @@ async function resolveFile(
200
200
  const bodyStartIndex = findBodyStart(lines);
201
201
 
202
202
  // Collect header lines (chart:, title:, options, tags:)
203
+ let tagsLineNumber = 0; // 1-based line number of the tags: directive
203
204
  for (let i = 0; i < bodyStartIndex; i++) {
204
205
  const trimmed = lines[i].trim();
205
206
  if (trimmed === '' || trimmed.startsWith('//')) {
@@ -212,6 +213,7 @@ async function resolveFile(
212
213
  const tagsMatch = trimmed.match(TAGS_RE);
213
214
  if (tagsMatch) {
214
215
  tagsDirective = tagsMatch[1].trim();
216
+ tagsLineNumber = i + 1; // 1-based
215
217
  continue;
216
218
  }
217
219
 
@@ -228,7 +230,7 @@ async function resolveFile(
228
230
  tagsFileGroups = extractTagGroups(tagsLines);
229
231
  } catch {
230
232
  diagnostics.push(
231
- makeDgmoError(0, `Tags file not found: ${tagsDirective}`)
233
+ makeDgmoError(tagsLineNumber, `Tags file not found: ${tagsDirective}`)
232
234
  );
233
235
  }
234
236
  }
package/src/render.ts CHANGED
@@ -49,6 +49,9 @@ export async function render(
49
49
  theme?: 'light' | 'dark' | 'transparent';
50
50
  palette?: string;
51
51
  branding?: boolean;
52
+ c4Level?: 'context' | 'containers' | 'components' | 'deployment';
53
+ c4System?: string;
54
+ c4Container?: string;
52
55
  },
53
56
  ): Promise<string> {
54
57
  const theme = options?.theme ?? 'light';
@@ -66,5 +69,10 @@ export async function render(
66
69
 
67
70
  // D3 and unknown/null frameworks both go through D3 renderer
68
71
  await ensureDom();
69
- return renderD3ForExport(content, theme, paletteColors, undefined, { branding });
72
+ return renderD3ForExport(content, theme, paletteColors, undefined, {
73
+ branding,
74
+ c4Level: options?.c4Level,
75
+ c4System: options?.c4System,
76
+ c4Container: options?.c4Container,
77
+ });
70
78
  }
@@ -203,6 +203,7 @@ const PARTICIPANT_RULES: readonly InferenceRule[] = [
203
203
  { pattern: /User$/i, type: 'actor' },
204
204
  { pattern: /Actor$/i, type: 'actor' },
205
205
  { pattern: /Analyst$/i, type: 'actor' },
206
+ { pattern: /Staff$/i, type: 'actor' },
206
207
 
207
208
  // ── 7. Frontend patterns ────────────────────────────────
208
209
  { pattern: /App$/i, type: 'frontend' },
@@ -1925,7 +1925,8 @@ export function renderSequenceDiagram(
1925
1925
  'data-line-number',
1926
1926
  String(messages[step.messageIndex].lineNumber)
1927
1927
  )
1928
- .attr('data-msg-index', String(step.messageIndex));
1928
+ .attr('data-msg-index', String(step.messageIndex))
1929
+ .attr('data-step-index', String(i));
1929
1930
 
1930
1931
  if (step.label) {
1931
1932
  const labelEl = svg
@@ -1940,7 +1941,8 @@ export function renderSequenceDiagram(
1940
1941
  'data-line-number',
1941
1942
  String(messages[step.messageIndex].lineNumber)
1942
1943
  )
1943
- .attr('data-msg-index', String(step.messageIndex));
1944
+ .attr('data-msg-index', String(step.messageIndex))
1945
+ .attr('data-step-index', String(i));
1944
1946
  renderInlineText(labelEl, step.label, palette);
1945
1947
  }
1946
1948
  } else {
@@ -1966,7 +1968,8 @@ export function renderSequenceDiagram(
1966
1968
  'data-line-number',
1967
1969
  String(messages[step.messageIndex].lineNumber)
1968
1970
  )
1969
- .attr('data-msg-index', String(step.messageIndex));
1971
+ .attr('data-msg-index', String(step.messageIndex))
1972
+ .attr('data-step-index', String(i));
1970
1973
 
1971
1974
  if (step.label) {
1972
1975
  const midX = (x1 + x2) / 2;
@@ -1982,7 +1985,8 @@ export function renderSequenceDiagram(
1982
1985
  'data-line-number',
1983
1986
  String(messages[step.messageIndex].lineNumber)
1984
1987
  )
1985
- .attr('data-msg-index', String(step.messageIndex));
1988
+ .attr('data-msg-index', String(step.messageIndex))
1989
+ .attr('data-step-index', String(i));
1986
1990
  renderInlineText(labelEl, step.label, palette);
1987
1991
  }
1988
1992
  }
@@ -2011,7 +2015,8 @@ export function renderSequenceDiagram(
2011
2015
  'data-line-number',
2012
2016
  String(messages[step.messageIndex].lineNumber)
2013
2017
  )
2014
- .attr('data-msg-index', String(step.messageIndex));
2018
+ .attr('data-msg-index', String(step.messageIndex))
2019
+ .attr('data-step-index', String(i));
2015
2020
 
2016
2021
  if (step.label) {
2017
2022
  const midX = (x1 + x2) / 2;
@@ -2027,7 +2032,8 @@ export function renderSequenceDiagram(
2027
2032
  'data-line-number',
2028
2033
  String(messages[step.messageIndex].lineNumber)
2029
2034
  )
2030
- .attr('data-msg-index', String(step.messageIndex));
2035
+ .attr('data-msg-index', String(step.messageIndex))
2036
+ .attr('data-step-index', String(i));
2031
2037
  renderInlineText(labelEl, step.label, palette);
2032
2038
  }
2033
2039
  }