@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.
- package/AGENTS.md +15 -20
- package/README.md +56 -58
- package/dist/cli.cjs +188 -181
- package/dist/index.cjs +3529 -1061
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -43
- package/dist/index.d.ts +196 -43
- package/dist/index.js +3516 -1061
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +629 -289
- package/package.json +1 -1
- package/src/c4/layout.ts +6 -9
- package/src/c4/parser.ts +189 -83
- package/src/c4/renderer.ts +8 -9
- package/src/chart.ts +296 -83
- package/src/class/parser.ts +54 -37
- package/src/class/renderer.ts +8 -8
- package/src/cli.ts +8 -8
- package/src/colors.ts +4 -1
- package/src/completion.ts +757 -10
- package/src/d3.ts +312 -73
- package/src/dgmo-router.ts +63 -8
- package/src/echarts.ts +726 -231
- package/src/er/parser.ts +94 -76
- package/src/er/renderer.ts +6 -5
- package/src/gantt/parser.ts +144 -69
- package/src/gantt/renderer.ts +50 -14
- package/src/gantt/types.ts +3 -3
- package/src/graph/flowchart-parser.ts +97 -37
- package/src/graph/flowchart-renderer.ts +4 -3
- package/src/graph/state-parser.ts +50 -31
- package/src/graph/state-renderer.ts +4 -3
- package/src/index.ts +14 -5
- package/src/infra/compute.ts +1 -0
- package/src/infra/layout.ts +3 -0
- package/src/infra/parser.ts +291 -92
- package/src/infra/renderer.ts +172 -30
- package/src/infra/types.ts +5 -0
- package/src/initiative-status/layout.ts +1 -1
- package/src/initiative-status/parser.ts +121 -47
- package/src/initiative-status/renderer.ts +82 -31
- package/src/initiative-status/types.ts +10 -2
- package/src/kanban/parser.ts +60 -37
- package/src/kanban/renderer.ts +2 -2
- package/src/kanban/types.ts +1 -0
- package/src/org/layout.ts +9 -9
- package/src/org/parser.ts +39 -40
- package/src/org/renderer.ts +5 -6
- package/src/org/resolver.ts +26 -19
- package/src/render.ts +1 -1
- package/src/sequence/parser.ts +304 -95
- package/src/sequence/renderer.ts +9 -9
- package/src/sitemap/layout.ts +3 -4
- package/src/sitemap/parser.ts +57 -49
- package/src/sitemap/renderer.ts +6 -7
- package/src/utils/arrows.ts +25 -6
- package/src/utils/duration.ts +43 -7
- package/src/utils/legend-constants.ts +26 -0
- package/src/utils/legend-svg.ts +167 -0
- package/src/utils/parsing.ts +247 -7
- package/src/utils/tag-groups.ts +160 -15
- 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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
//
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
|
|
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
|
|
195
|
-
if (
|
|
196
|
-
const groupKey =
|
|
197
|
-
const value =
|
|
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
|
-
|
|
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)
|
|
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
|
|
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(/^\[(
|
|
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
|
-
|
|
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
|
|
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':
|
|
59
|
-
case '
|
|
60
|
-
case '
|
|
61
|
-
case '
|
|
62
|
-
|
|
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:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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', '
|
|
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
|
|
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',
|
|
561
|
+
.attr('y', TITLE_Y)
|
|
560
562
|
.attr('text-anchor', 'middle')
|
|
561
563
|
.attr('fill', palette.text)
|
|
562
|
-
.attr('font-size',
|
|
563
|
-
.attr('font-weight',
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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' | '
|
|
9
|
+
export type InitiativeStatus = 'done' | 'doing' | 'blocked' | 'todo' | 'na' | null;
|
|
10
10
|
|
|
11
|
-
export const VALID_STATUSES: readonly string[] = ['done', '
|
|
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 {
|