@diagrammo/dgmo 0.7.2 → 0.8.0

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 (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3529 -1061
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3516 -1061
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +312 -73
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +726 -231
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +82 -31
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
@@ -11,11 +11,11 @@ import type {
11
11
  ISGroup,
12
12
  InitiativeStatus,
13
13
  } from './types';
14
- import { VALID_STATUSES } from './types';
14
+ import { VALID_STATUSES, STATUS_ALIASES } from './types';
15
15
  import { inferParticipantType } from '../sequence/participant-inference';
16
16
  import { matchTagBlockHeading, injectDefaultTagMetadata, validateTagValues } from '../utils/tag-groups';
17
17
  import type { TagGroup } from '../utils/tag-groups';
18
- import { extractColor } from '../utils/parsing';
18
+ import { extractColor, parseFirstLine, ALL_CHART_TYPES, OPTION_NOCOLON_RE } from '../utils/parsing';
19
19
 
20
20
  // ============================================================
21
21
  // Heuristic — does this content look like an initiative-status diagram?
@@ -35,8 +35,10 @@ export function looksLikeInitiativeStatus(content: string): boolean {
35
35
  if (!trimmed || trimmed.startsWith('//')) continue;
36
36
  if (trimmed.match(/^chart\s*:/i)) continue;
37
37
  if (trimmed.match(/^title\s*:/i)) continue;
38
+ // Skip new-style first line (bare chart type name)
39
+ if (parseFirstLine(trimmed)) continue;
38
40
  if (trimmed.includes('->')) hasArrow = true;
39
- if (/\|\s*(done|wip|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
41
+ if (/\|\s*(done|doing|wip|blocked|paused|waiting|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
40
42
  // Indented arrow is a strong signal — only initiative-status uses this
41
43
  const isIndented = line.length > 0 && line !== trimmed && /^\s/.test(line);
42
44
  if (isIndented && (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed))) hasIndentedArrow = true;
@@ -80,18 +82,36 @@ export function parseNodeMetadata(
80
82
  // key: value pair
81
83
  const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
82
84
  const value = trimmed.slice(colonIdx + 1).trim();
83
- // Resolve alias to group name
84
- const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
85
- metadata[resolvedKey] = value;
85
+
86
+ // Handle explicit `status: keyword` form
87
+ if (rawKey === 'status') {
88
+ hadStatusWord = true;
89
+ const lower = value.toLowerCase();
90
+ const canonical = STATUS_ALIASES[lower] ?? lower;
91
+ if (VALID_STATUSES.includes(canonical)) {
92
+ status = canonical as InitiativeStatus;
93
+ } else if (lineNum !== undefined && diagnostics) {
94
+ const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
95
+ const hint = suggest(lower, allKnown);
96
+ const msg = `Unknown status "${value}"${hint ? `. ${hint}` : ''}`;
97
+ diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
98
+ }
99
+ } else {
100
+ // Resolve alias to group name
101
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
102
+ metadata[resolvedKey] = value;
103
+ }
86
104
  } else {
87
- // Bare word — check if it's a status keyword
105
+ // Bare word — check if it's a status keyword (or alias)
88
106
  hadStatusWord = true;
89
107
  const lower = trimmed.toLowerCase();
90
- if (VALID_STATUSES.includes(lower)) {
91
- status = lower as InitiativeStatus;
108
+ const canonical = STATUS_ALIASES[lower] ?? lower;
109
+ if (VALID_STATUSES.includes(canonical)) {
110
+ status = canonical as InitiativeStatus;
92
111
  } else if (lineNum !== undefined && diagnostics) {
93
112
  // Unknown bare word — likely a status typo, emit warning
94
- const hint = suggest(lower, VALID_STATUSES);
113
+ const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
114
+ const hint = suggest(lower, allKnown);
95
115
  const msg = `Unknown status "${trimmed}"${hint ? `. ${hint}` : ''}`;
96
116
  diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
97
117
  }
@@ -108,10 +128,12 @@ export function parseNodeMetadata(
108
128
  function parseStatus(raw: string, line: number, diagnostics: DgmoError[]): InitiativeStatus {
109
129
  const trimmed = raw.trim().toLowerCase();
110
130
  if (!trimmed) return 'na';
111
- if (VALID_STATUSES.includes(trimmed)) return trimmed as InitiativeStatus;
131
+ const canonical = STATUS_ALIASES[trimmed] ?? trimmed;
132
+ if (VALID_STATUSES.includes(canonical)) return canonical as InitiativeStatus;
112
133
 
113
134
  // Unknown status — emit warning with suggestion
114
- const hint = suggest(trimmed, VALID_STATUSES);
135
+ const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
136
+ const hint = suggest(trimmed, allKnown);
115
137
  const msg = `Unknown status "${raw.trim()}"${hint ? `. ${hint}` : ''}`;
116
138
  diagnostics.push(makeDgmoError(line, msg, 'warning'));
117
139
  return null;
@@ -165,36 +187,32 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
165
187
  // Skip blanks and comments
166
188
  if (!trimmed || trimmed.startsWith('//')) continue;
167
189
 
168
- // chart: header
169
- const chartMatch = trimmed.match(/^chart\s*:\s*(.+)/i);
170
- if (chartMatch) {
171
- const chartType = chartMatch[1].trim().toLowerCase();
172
- if (chartType !== 'initiative-status') {
173
- const diag = makeDgmoError(lineNum, `Expected chart type "initiative-status", got "${chartType}"`);
190
+ // First line: chart type + optional title (new syntax: `initiative-status My Dashboard`)
191
+ const firstLineResult = parseFirstLine(trimmed);
192
+ if (firstLineResult && !contentStarted) {
193
+ if (firstLineResult.chartType !== 'initiative-status') {
194
+ const diag = makeDgmoError(lineNum, `Expected chart type "initiative-status", got "${firstLineResult.chartType}"`);
174
195
  result.diagnostics.push(diag);
175
196
  result.error = formatDgmoError(diag);
176
197
  return result;
177
198
  }
199
+ if (firstLineResult.title) {
200
+ result.title = firstLineResult.title;
201
+ result.titleLineNumber = lineNum;
202
+ }
178
203
  continue;
179
204
  }
180
205
 
181
- // title: header
182
- const titleMatch = trimmed.match(/^title\s*:\s*(.+)/i);
183
- if (titleMatch) {
184
- result.title = titleMatch[1].trim();
185
- result.titleLineNumber = lineNum;
186
- continue;
187
- }
188
-
189
- // hide: directive — parse before tag blocks and content
190
- const hideMatch = trimmed.match(/^hide\s*:\s*(.+)/i);
191
- if (hideMatch) {
206
+ // hide directive (no colon): `hide phase Planning, phase Review`
207
+ const hideMatch = trimmed.match(/^hide\s+(.+)/i);
208
+ if (hideMatch && !trimmed.match(/^hide\s*\|/)) {
209
+ // Parse comma-separated tag-value pairs: `phase Planning, phase Review`
192
210
  const pairs = hideMatch[1].split(',');
193
211
  for (const pair of pairs) {
194
- const colonIdx = pair.indexOf(':');
195
- if (colonIdx >= 0) {
196
- const groupKey = pair.slice(0, colonIdx).trim().toLowerCase();
197
- const value = pair.slice(colonIdx + 1).trim().toLowerCase();
212
+ const tokens = pair.trim().split(/\s+/);
213
+ if (tokens.length >= 2) {
214
+ const groupKey = tokens[0].toLowerCase();
215
+ const value = tokens.slice(1).join(' ').toLowerCase();
198
216
  if (groupKey && value) {
199
217
  if (!result.initialHiddenTagValues.has(groupKey)) {
200
218
  result.initialHiddenTagValues.set(groupKey, new Set());
@@ -206,6 +224,20 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
206
224
  continue;
207
225
  }
208
226
 
227
+ // Options (space-separated, non-indented): `active-tag Priority`
228
+ if (!contentStarted && measureIndent(raw) === 0) {
229
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
230
+ if (optMatch) {
231
+ const key = optMatch[1].toLowerCase();
232
+ const value = optMatch[2].trim();
233
+ // Only recognize known option keys (not node content)
234
+ if (key === 'active-tag' || key === 'sort') {
235
+ result.options[key] = value;
236
+ continue;
237
+ }
238
+ }
239
+ }
240
+
209
241
  // Tag group heading — must be checked BEFORE group/node/edge matching
210
242
  const tagBlockMatch = matchTagBlockHeading(trimmed);
211
243
  if (tagBlockMatch) {
@@ -216,7 +248,10 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
216
248
  continue;
217
249
  }
218
250
  if (tagBlockMatch.deprecated) {
219
- pushWarning(lineNum, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
251
+ result.diagnostics.push(
252
+ makeDgmoError(lineNum, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag ${tagBlockMatch.name}' instead`)
253
+ );
254
+ continue;
220
255
  }
221
256
  currentTagGroup = {
222
257
  name: tagBlockMatch.name,
@@ -227,35 +262,47 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
227
262
  if (tagBlockMatch.alias) {
228
263
  aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
229
264
  }
265
+ // Handle inline values from single-line tag declaration
266
+ if (tagBlockMatch.inlineValues) {
267
+ for (const rawVal of tagBlockMatch.inlineValues) {
268
+ const { label, color } = extractColor(rawVal);
269
+ currentTagGroup.entries.push({
270
+ value: label,
271
+ color: color ?? '',
272
+ lineNumber: lineNum,
273
+ });
274
+ }
275
+ // First entry is the default
276
+ if (currentTagGroup.entries.length > 0) {
277
+ currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
278
+ }
279
+ }
230
280
  result.tagGroups.push(currentTagGroup);
231
281
  continue;
232
282
  }
233
283
 
234
- // Tag group entries (indented Value(color) [default] under tag heading)
284
+ // Tag group entries (indented Value(color) under tag heading — first value is the default)
235
285
  if (currentTagGroup && !contentStarted) {
236
286
  const indent = measureIndent(raw);
237
287
  if (indent > 0) {
238
- const isDefault = /\bdefault\s*$/i.test(trimmed);
239
- const entryText = isDefault
240
- ? trimmed.replace(/\s+default\s*$/i, '').trim()
241
- : trimmed;
242
- const { label, color } = extractColor(entryText);
243
- if (isDefault) {
244
- currentTagGroup.defaultValue = label;
245
- }
288
+ const { label, color } = extractColor(trimmed);
246
289
  currentTagGroup.entries.push({
247
290
  value: label,
248
291
  color: color ?? '',
249
292
  lineNumber: lineNum,
250
293
  });
294
+ // First entry is the default
295
+ if (currentTagGroup.entries.length === 1) {
296
+ currentTagGroup.defaultValue = label;
297
+ }
251
298
  continue;
252
299
  }
253
300
  // Non-indented line after tag group — close and fall through
254
301
  currentTagGroup = null;
255
302
  }
256
303
 
257
- // Group header: [Group Name]
258
- const groupMatch = trimmed.match(/^\[(.+)\]\s*$/);
304
+ // Group header: [Group Name] or [Group Name] | metadata
305
+ const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
259
306
  if (groupMatch) {
260
307
  contentStarted = true;
261
308
  currentTagGroup = null;
@@ -263,7 +310,26 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
263
310
  if (currentGroup) {
264
311
  result.groups.push(currentGroup);
265
312
  }
266
- currentGroup = { label: groupMatch[1], nodeLabels: [], lineNumber: lineNum };
313
+ const groupMeta: Record<string, string> = {};
314
+ if (groupMatch[2]) {
315
+ // Parse pipe metadata for group (only key:value pairs, no status)
316
+ const items = groupMatch[2].split(',');
317
+ for (const item of items) {
318
+ const ci = item.indexOf(':');
319
+ if (ci >= 0) {
320
+ const rawKey = item.slice(0, ci).trim().toLowerCase();
321
+ const value = item.slice(ci + 1).trim();
322
+ const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
323
+ groupMeta[resolvedKey] = value;
324
+ }
325
+ }
326
+ }
327
+ currentGroup = {
328
+ label: groupMatch[1],
329
+ nodeLabels: [],
330
+ lineNumber: lineNum,
331
+ metadata: Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
332
+ };
267
333
  continue;
268
334
  }
269
335
 
@@ -307,6 +373,14 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
307
373
  } else {
308
374
  nodeLabels.add(node.label);
309
375
  }
376
+ // Cascade group metadata into node (group provides defaults, node overrides)
377
+ if (currentGroup && isIndented && currentGroup.metadata) {
378
+ for (const [key, val] of Object.entries(currentGroup.metadata)) {
379
+ if (!(key in node.metadata)) {
380
+ node.metadata[key] = val;
381
+ }
382
+ }
383
+ }
310
384
  result.nodes.push(node);
311
385
  // Add to current group if indented
312
386
  if (currentGroup && isIndented) {
@@ -395,7 +469,7 @@ function parseEdgeLine(
395
469
  // or: <source> -<label>-> <target> [| <status>]
396
470
 
397
471
  // Check for labeled arrow form: SOURCE -LABEL-> TARGET [| status]
398
- const labeledMatch = trimmed.match(/^(\S+)\s+-(.+)->\s+(.+)$/);
472
+ const labeledMatch = trimmed.match(/^(\S+)\s*-(.+)->\s*(.+)$/);
399
473
  if (labeledMatch) {
400
474
  const source = labeledMatch[1];
401
475
  const label = labeledMatch[2].trim();
@@ -10,15 +10,15 @@ import {
10
10
  LEGEND_HEIGHT,
11
11
  LEGEND_PILL_PAD,
12
12
  LEGEND_PILL_FONT_SIZE,
13
- LEGEND_PILL_FONT_W,
14
13
  LEGEND_CAPSULE_PAD,
15
14
  LEGEND_DOT_R,
16
15
  LEGEND_ENTRY_FONT_SIZE,
17
- LEGEND_ENTRY_FONT_W,
18
16
  LEGEND_ENTRY_DOT_GAP,
19
17
  LEGEND_ENTRY_TRAIL,
20
18
  LEGEND_GROUP_GAP,
19
+ measureLegendText,
21
20
  } from '../utils/legend-constants';
21
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from '../utils/title-constants';
22
22
  import { contrastText, mix } from '../palettes/color-utils';
23
23
  import type { TagGroup } from '../utils/tag-groups';
24
24
  import type { PaletteColors } from '../palettes';
@@ -55,11 +55,12 @@ const COLLAPSE_BAR_HEIGHT = 6;
55
55
 
56
56
  function statusColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
57
57
  switch (status) {
58
- case 'done': return palette.colors.green;
59
- case 'wip': return palette.colors.yellow;
60
- case 'todo': return palette.colors.red;
61
- case 'na': return isDark ? palette.colors.gray : '#2e3440';
62
- default: return palette.textMuted;
58
+ case 'done': return palette.colors.green;
59
+ case 'doing': return palette.colors.blue;
60
+ case 'blocked': return palette.colors.orange;
61
+ case 'todo': return palette.colors.red;
62
+ case 'na': return isDark ? palette.colors.gray : '#2e3440';
63
+ default: return palette.textMuted;
63
64
  }
64
65
  }
65
66
 
@@ -91,13 +92,14 @@ interface ISLegendEntry {
91
92
  }
92
93
 
93
94
  const IS_STATUS_LABELS: Record<string, string> = {
94
- done: 'Done',
95
- wip: 'In Progress',
96
- todo: 'To Do',
97
- na: 'N/A',
95
+ done: 'Done',
96
+ doing: 'In Progress',
97
+ blocked: 'Blocked',
98
+ todo: 'To Do',
99
+ na: 'N/A',
98
100
  };
99
101
 
100
- const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', 'wip', 'done', 'na'];
102
+ const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', 'blocked', 'doing', 'done', 'na'];
101
103
 
102
104
  function collectStatuses(parsed: ParsedInitiativeStatus): ISLegendEntry[] {
103
105
  const present = new Set<string>();
@@ -114,7 +116,7 @@ const LEGEND_GROUP_NAME = 'Status';
114
116
  function legendEntriesWidth(entries: ISLegendEntry[]): number {
115
117
  let w = 0;
116
118
  for (const e of entries) {
117
- w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
119
+ w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
118
120
  }
119
121
  return w;
120
122
  }
@@ -556,11 +558,11 @@ export function renderInitiativeStatus(
556
558
  .append('text')
557
559
  .attr('class', 'chart-title')
558
560
  .attr('x', width / 2)
559
- .attr('y', 30)
561
+ .attr('y', TITLE_Y)
560
562
  .attr('text-anchor', 'middle')
561
563
  .attr('fill', palette.text)
562
- .attr('font-size', '20px')
563
- .attr('font-weight', '700')
564
+ .attr('font-size', TITLE_FONT_SIZE)
565
+ .attr('font-weight', TITLE_FONT_WEIGHT)
564
566
  .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
565
567
  .text(parsed.title);
566
568
 
@@ -599,7 +601,7 @@ export function renderInitiativeStatus(
599
601
  color: statusColor(e.statusKey, palette, isDark),
600
602
  value: e.statusKey ?? 'na',
601
603
  }));
602
- const pillW = LEGEND_GROUP_NAME.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
604
+ const pillW = measureLegendText(LEGEND_GROUP_NAME, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
603
605
  const entrW = legendEntriesWidth(legendEntries);
604
606
  legendGroups.push({
605
607
  name: LEGEND_GROUP_NAME,
@@ -617,10 +619,10 @@ export function renderInitiativeStatus(
617
619
  color: e.color || palette.textMuted,
618
620
  value: e.value.toLowerCase(),
619
621
  }));
620
- const pillW = tg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
622
+ const pillW = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
621
623
  let entrW = 0;
622
624
  for (const e of entries) {
623
- entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
625
+ entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
624
626
  }
625
627
  legendGroups.push({
626
628
  name: tg.name,
@@ -645,7 +647,7 @@ export function renderInitiativeStatus(
645
647
  let totalLegendW = 0;
646
648
  for (const lg of visibleLegendGroups) {
647
649
  const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
648
- const pillW = lg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
650
+ const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
649
651
  totalLegendW += isActive ? lg.width : pillW;
650
652
  totalLegendW += LEGEND_GROUP_GAP;
651
653
  }
@@ -663,7 +665,7 @@ export function renderInitiativeStatus(
663
665
 
664
666
  for (const lg of visibleLegendGroups) {
665
667
  const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
666
- const pillW = lg.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
668
+ const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
667
669
  const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
668
670
  const groupW = isActive ? lg.width : pillW;
669
671
 
@@ -674,6 +676,11 @@ export function renderInitiativeStatus(
674
676
  .attr('data-legend-group', lg.key)
675
677
  .style('cursor', 'pointer');
676
678
 
679
+ // Mark inactive pills so exports can hide them
680
+ if (!isActive) {
681
+ gEl.attr('data-export-ignore', 'true');
682
+ }
683
+
677
684
  if (isActive) {
678
685
  // Outer capsule background
679
686
  gEl.append('rect')
@@ -724,18 +731,33 @@ export function renderInitiativeStatus(
724
731
  // Determine which values are hidden for this group
725
732
  const hiddenSet = !lg.isStatus ? hiddenTagValues?.get(lg.key) : undefined;
726
733
 
727
- let entryX = pillXOff + pillW + 4;
734
+ // Render each entry in its own <g> with local coordinates,
735
+ // positioned via transform so we can reflow after measuring.
736
+ const entryStartX = pillXOff + pillW + 4;
737
+ const entryData: { g: d3Selection.Selection<SVGGElement, unknown, null, undefined>; textEl: SVGTextElement; estimatedW: number }[] = [];
738
+ let estimatedX = entryStartX;
739
+
728
740
  for (const entry of lg.entries) {
729
741
  const isHidden = hiddenSet?.has(entry.value) ?? false;
742
+ const estimatedTextW = measureLegendText(entry.label, LEGEND_ENTRY_FONT_SIZE);
730
743
 
731
744
  const entryG = gEl.append('g')
732
745
  .attr('data-legend-entry', entry.value)
733
- .style('cursor', !lg.isStatus ? 'pointer' : 'default');
746
+ .attr('transform', `translate(${estimatedX}, 0)`)
747
+ .style('cursor', 'pointer');
748
+
749
+ // Transparent hit-area rect
750
+ const entryW = LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + estimatedTextW + LEGEND_ENTRY_TRAIL;
751
+ entryG.append('rect')
752
+ .attr('x', -2)
753
+ .attr('y', 0)
754
+ .attr('width', entryW + 4)
755
+ .attr('height', LEGEND_HEIGHT)
756
+ .attr('fill', 'transparent');
734
757
 
735
758
  if (isHidden) {
736
- // Hidden: hollow ring + dimmed text (strikethrough-like)
737
759
  entryG.append('circle')
738
- .attr('cx', entryX + LEGEND_DOT_R)
760
+ .attr('cx', LEGEND_DOT_R)
739
761
  .attr('cy', LEGEND_HEIGHT / 2)
740
762
  .attr('r', LEGEND_DOT_R)
741
763
  .attr('fill', 'none')
@@ -743,16 +765,15 @@ export function renderInitiativeStatus(
743
765
  .attr('stroke-width', 1.2)
744
766
  .attr('opacity', 0.5);
745
767
  } else {
746
- // Visible: solid dot
747
768
  entryG.append('circle')
748
- .attr('cx', entryX + LEGEND_DOT_R)
769
+ .attr('cx', LEGEND_DOT_R)
749
770
  .attr('cy', LEGEND_HEIGHT / 2)
750
771
  .attr('r', LEGEND_DOT_R)
751
772
  .attr('fill', entry.color);
752
773
  }
753
774
 
754
- entryG.append('text')
755
- .attr('x', entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
775
+ const textEl = entryG.append('text')
776
+ .attr('x', LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
756
777
  .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
757
778
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
758
779
  .attr('fill', palette.textMuted)
@@ -761,7 +782,20 @@ export function renderInitiativeStatus(
761
782
  .attr('text-decoration', isHidden ? 'line-through' : 'none')
762
783
  .text(entry.label);
763
784
 
764
- entryX += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.label.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
785
+ entryData.push({ g: entryG, textEl: textEl.node()!, estimatedW: estimatedTextW });
786
+ estimatedX += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + estimatedTextW + LEGEND_ENTRY_TRAIL;
787
+ }
788
+
789
+ // Reflow using measured text widths for even spacing
790
+ let reflowX = entryStartX;
791
+ for (const ed of entryData) {
792
+ const measuredW = ed.textEl.getComputedTextLength?.() ?? 0;
793
+ const textW = measuredW > 0 ? measuredW : ed.estimatedW;
794
+ ed.g.attr('transform', `translate(${reflowX}, 0)`);
795
+ // Update hit-area rect width to match actual width
796
+ const actualEntryW = LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + textW + LEGEND_ENTRY_TRAIL;
797
+ ed.g.select('rect').attr('width', actualEntryW + 4);
798
+ reflowX += actualEntryW;
765
799
  }
766
800
  }
767
801
 
@@ -992,7 +1026,7 @@ export function renderInitiativeStatus(
992
1026
  .attr('stroke', 'transparent')
993
1027
  .attr('stroke-width', Math.max(6, Math.round(16 / (edge.parallelCount ?? 1))));
994
1028
 
995
- edgeG
1029
+ const edgePath = edgeG
996
1030
  .append('path')
997
1031
  .attr('d', pathD)
998
1032
  .attr('fill', 'none')
@@ -1000,6 +1034,11 @@ export function renderInitiativeStatus(
1000
1034
  .attr('stroke-width', EDGE_STROKE_WIDTH)
1001
1035
  .attr('marker-end', `url(#${markerId})`)
1002
1036
  .attr('class', 'is-edge');
1037
+
1038
+ // Dashed stroke for 'todo' edges
1039
+ if (edge.status === 'todo') {
1040
+ edgePath.attr('stroke-dasharray', '6 3');
1041
+ }
1003
1042
  }
1004
1043
 
1005
1044
  // Edge label placed on its own path
@@ -1072,6 +1111,18 @@ export function renderInitiativeStatus(
1072
1111
  const stroke = nodeStroke(node.status, palette, isDark);
1073
1112
  renderNodeShape(nodeG, node.shape, node.width, node.height, fill, stroke);
1074
1113
 
1114
+ // Apply dashed border for 'todo' status
1115
+ if (node.status === 'todo') {
1116
+ nodeG.selectAll('rect, ellipse, polygon, circle')
1117
+ .each(function () {
1118
+ const el = d3Selection.select(this);
1119
+ // Only dash stroked elements (not fills or transparent hit areas)
1120
+ if (el.attr('stroke') && el.attr('stroke') !== 'none' && el.attr('stroke') !== 'transparent') {
1121
+ el.attr('stroke-dasharray', '6 3');
1122
+ }
1123
+ });
1124
+ }
1125
+
1075
1126
  const textColor = contrastText(fill, '#eceff4', '#2e3440');
1076
1127
 
1077
1128
  // Label placement: actors put label below the figure, others center inside
@@ -6,9 +6,16 @@ import type { DgmoError } from '../diagnostics';
6
6
  import type { ParticipantType } from '../sequence/parser';
7
7
  import type { TagGroup } from '../utils/tag-groups';
8
8
 
9
- export type InitiativeStatus = 'done' | 'wip' | 'todo' | 'na' | null;
9
+ export type InitiativeStatus = 'done' | 'doing' | 'blocked' | 'todo' | 'na' | null;
10
10
 
11
- export const VALID_STATUSES: readonly string[] = ['done', 'wip', 'todo', 'na'];
11
+ export const VALID_STATUSES: readonly string[] = ['done', 'doing', 'blocked', 'todo', 'na'];
12
+
13
+ /** Aliases that map to canonical status values during parsing. */
14
+ export const STATUS_ALIASES: Record<string, string> = {
15
+ wip: 'doing',
16
+ paused: 'blocked',
17
+ waiting: 'blocked',
18
+ };
12
19
 
13
20
  export interface ISNode {
14
21
  label: string;
@@ -31,6 +38,7 @@ export interface ISGroup {
31
38
  label: string;
32
39
  nodeLabels: string[];
33
40
  lineNumber: number;
41
+ metadata?: Record<string, string>;
34
42
  }
35
43
 
36
44
  export interface ParsedInitiativeStatus {