@diagrammo/dgmo 0.7.3 → 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 +3506 -1057
- 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 +3493 -1057
- 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 +310 -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 +42 -23
- 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
package/src/sitemap/parser.ts
CHANGED
|
@@ -11,10 +11,15 @@ import {
|
|
|
11
11
|
measureIndent,
|
|
12
12
|
extractColor,
|
|
13
13
|
parsePipeMetadata,
|
|
14
|
+
normalizeDirection,
|
|
15
|
+
inferArrowColor,
|
|
14
16
|
MULTIPLE_PIPE_WARNING,
|
|
15
17
|
CHART_TYPE_RE,
|
|
16
18
|
TITLE_RE,
|
|
17
19
|
OPTION_RE,
|
|
20
|
+
parseFirstLine,
|
|
21
|
+
OPTION_NOCOLON_RE,
|
|
22
|
+
ALL_CHART_TYPES,
|
|
18
23
|
} from '../utils/parsing';
|
|
19
24
|
import type {
|
|
20
25
|
SitemapNode,
|
|
@@ -26,7 +31,8 @@ import type {
|
|
|
26
31
|
// Regexes
|
|
27
32
|
// ============================================================
|
|
28
33
|
|
|
29
|
-
const CONTAINER_RE = /^\[([^\]]+)\]
|
|
34
|
+
const CONTAINER_RE = /^\[([^\]]+)\]\s*(?:\|\s*(.+))?$/;
|
|
35
|
+
/** Metadata on content nodes: `key: value` (colon-separated, used in content phase) */
|
|
30
36
|
const METADATA_RE = /^([^:]+):\s*(.+)$/;
|
|
31
37
|
|
|
32
38
|
/**
|
|
@@ -54,9 +60,12 @@ function parseArrowLine(
|
|
|
54
60
|
const arrowMatch = trimmed.match(ARROW_RE);
|
|
55
61
|
if (arrowMatch) {
|
|
56
62
|
const label = arrowMatch[1]?.trim() || undefined;
|
|
57
|
-
|
|
58
|
-
? resolveColor(arrowMatch[2].trim(), palette)
|
|
63
|
+
let color = arrowMatch[2]
|
|
64
|
+
? resolveColor(arrowMatch[2].trim(), palette) ?? undefined
|
|
59
65
|
: undefined;
|
|
66
|
+
if (label && !color) {
|
|
67
|
+
color = inferArrowColor(label);
|
|
68
|
+
}
|
|
60
69
|
const target = arrowMatch[3].trim();
|
|
61
70
|
return { label, color, target };
|
|
62
71
|
}
|
|
@@ -149,6 +158,7 @@ export function parseSitemap(
|
|
|
149
158
|
let contentStarted = false;
|
|
150
159
|
let nodeCounter = 0;
|
|
151
160
|
let containerCounter = 0;
|
|
161
|
+
let firstLineParsed = false;
|
|
152
162
|
|
|
153
163
|
// Tag group parsing state
|
|
154
164
|
let currentTagGroup: TagGroup | null = null;
|
|
@@ -189,32 +199,22 @@ export function parseSitemap(
|
|
|
189
199
|
|
|
190
200
|
// --- Header phase ---
|
|
191
201
|
|
|
192
|
-
//
|
|
193
|
-
if (!contentStarted) {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
if (chartType !== 'sitemap') {
|
|
198
|
-
const allTypes =
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
'arc', 'slope', 'kanban', 'c4', 'initiative-status', 'state',
|
|
202
|
-
];
|
|
203
|
-
let msg = `Expected chart type "sitemap", got "${chartType}"`;
|
|
204
|
-
const hint = suggest(chartType, allTypes);
|
|
202
|
+
// First line: try parseFirstLine for `sitemap [Title]`
|
|
203
|
+
if (!firstLineParsed && !contentStarted) {
|
|
204
|
+
const firstLineResult = parseFirstLine(trimmed);
|
|
205
|
+
if (firstLineResult) {
|
|
206
|
+
firstLineParsed = true;
|
|
207
|
+
if (firstLineResult.chartType !== 'sitemap') {
|
|
208
|
+
const allTypes = Array.from(ALL_CHART_TYPES);
|
|
209
|
+
let msg = `Expected chart type "sitemap", got "${firstLineResult.chartType}"`;
|
|
210
|
+
const hint = suggest(firstLineResult.chartType, allTypes);
|
|
205
211
|
if (hint) msg += `. ${hint}`;
|
|
206
212
|
return fail(lineNumber, msg);
|
|
207
213
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// title: value
|
|
213
|
-
if (!contentStarted) {
|
|
214
|
-
const titleMatch = trimmed.match(TITLE_RE);
|
|
215
|
-
if (titleMatch) {
|
|
216
|
-
result.title = titleMatch[1].trim();
|
|
217
|
-
result.titleLineNumber = lineNumber;
|
|
214
|
+
if (firstLineResult.title) {
|
|
215
|
+
result.title = firstLineResult.title;
|
|
216
|
+
result.titleLineNumber = lineNumber;
|
|
217
|
+
}
|
|
218
218
|
continue;
|
|
219
219
|
}
|
|
220
220
|
}
|
|
@@ -227,10 +227,8 @@ export function parseSitemap(
|
|
|
227
227
|
continue;
|
|
228
228
|
}
|
|
229
229
|
if (tagBlockMatch.deprecated) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
`'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`,
|
|
233
|
-
);
|
|
230
|
+
pushError(lineNumber, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag: ${tagBlockMatch.name}' instead`);
|
|
231
|
+
continue;
|
|
234
232
|
}
|
|
235
233
|
currentTagGroup = {
|
|
236
234
|
name: tagBlockMatch.name,
|
|
@@ -245,22 +243,22 @@ export function parseSitemap(
|
|
|
245
243
|
continue;
|
|
246
244
|
}
|
|
247
245
|
|
|
248
|
-
// Generic header options (before content/tag groups)
|
|
249
|
-
|
|
250
|
-
|
|
246
|
+
// Generic header options (space-separated, before content/tag groups)
|
|
247
|
+
// Skip lines with `|` (pipe metadata) or `->` (arrows) — those are content
|
|
248
|
+
if (!contentStarted && !currentTagGroup && measureIndent(line) === 0
|
|
249
|
+
&& !trimmed.includes('|') && !trimmed.includes('->')) {
|
|
250
|
+
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
251
251
|
if (optMatch) {
|
|
252
252
|
const key = optMatch[1].trim().toLowerCase();
|
|
253
|
-
if (key === 'direction') {
|
|
254
|
-
const dir = optMatch[2]
|
|
255
|
-
if (dir
|
|
253
|
+
if (key === 'direction' || key === 'orientation') {
|
|
254
|
+
const dir = normalizeDirection(optMatch[2]);
|
|
255
|
+
if (dir) {
|
|
256
256
|
result.direction = dir as SitemapDirection;
|
|
257
257
|
}
|
|
258
258
|
continue;
|
|
259
259
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
260
|
+
result.options[key] = optMatch[2].trim();
|
|
261
|
+
continue;
|
|
264
262
|
}
|
|
265
263
|
}
|
|
266
264
|
|
|
@@ -268,11 +266,7 @@ export function parseSitemap(
|
|
|
268
266
|
if (currentTagGroup && !contentStarted) {
|
|
269
267
|
const indent = measureIndent(line);
|
|
270
268
|
if (indent > 0) {
|
|
271
|
-
const
|
|
272
|
-
const entryText = isDefault
|
|
273
|
-
? trimmed.replace(/\s+default\s*$/, '').trim()
|
|
274
|
-
: trimmed;
|
|
275
|
-
const { label, color } = extractColor(entryText, palette);
|
|
269
|
+
const { label, color } = extractColor(trimmed, palette);
|
|
276
270
|
if (!color) {
|
|
277
271
|
pushError(
|
|
278
272
|
lineNumber,
|
|
@@ -280,14 +274,15 @@ export function parseSitemap(
|
|
|
280
274
|
);
|
|
281
275
|
continue;
|
|
282
276
|
}
|
|
283
|
-
if (isDefault) {
|
|
284
|
-
currentTagGroup.defaultValue = label;
|
|
285
|
-
}
|
|
286
277
|
currentTagGroup.entries.push({
|
|
287
278
|
value: label,
|
|
288
279
|
color,
|
|
289
280
|
lineNumber,
|
|
290
281
|
});
|
|
282
|
+
// First entry is the default
|
|
283
|
+
if (currentTagGroup.entries.length === 1) {
|
|
284
|
+
currentTagGroup.defaultValue = label;
|
|
285
|
+
}
|
|
291
286
|
continue;
|
|
292
287
|
}
|
|
293
288
|
// Non-indented line after tag group — fall through to content
|
|
@@ -332,11 +327,20 @@ export function parseSitemap(
|
|
|
332
327
|
const rawLabel = containerMatch[1].trim();
|
|
333
328
|
const { label, color } = extractColor(rawLabel, palette);
|
|
334
329
|
|
|
330
|
+
// Parse optional pipe metadata on the container line
|
|
331
|
+
const pipeStr = containerMatch[2];
|
|
332
|
+
const containerMetadata: Record<string, string> = {};
|
|
333
|
+
if (pipeStr) {
|
|
334
|
+
// Build segments array compatible with parsePipeMetadata (first element is label, rest are pipe parts)
|
|
335
|
+
const pipeSegments = ['', pipeStr];
|
|
336
|
+
Object.assign(containerMetadata, parsePipeMetadata(pipeSegments, aliasMap));
|
|
337
|
+
}
|
|
338
|
+
|
|
335
339
|
containerCounter++;
|
|
336
340
|
const node: SitemapNode = {
|
|
337
341
|
id: `container-${containerCounter}`,
|
|
338
342
|
label,
|
|
339
|
-
metadata:
|
|
343
|
+
metadata: containerMetadata,
|
|
340
344
|
children: [],
|
|
341
345
|
parentId: null,
|
|
342
346
|
isContainer: true,
|
|
@@ -466,6 +470,10 @@ function attachNode(
|
|
|
466
470
|
if (indentStack.length > 0) {
|
|
467
471
|
const parent = indentStack[indentStack.length - 1].node;
|
|
468
472
|
node.parentId = parent.id;
|
|
473
|
+
// Cascade container metadata to child nodes (child overrides on conflict)
|
|
474
|
+
if (parent.isContainer && Object.keys(parent.metadata).length > 0 && !node.isContainer) {
|
|
475
|
+
node.metadata = { ...parent.metadata, ...node.metadata };
|
|
476
|
+
}
|
|
469
477
|
parent.children.push(node);
|
|
470
478
|
} else {
|
|
471
479
|
result.roots.push(node);
|
package/src/sitemap/renderer.ts
CHANGED
|
@@ -16,11 +16,9 @@ import {
|
|
|
16
16
|
LEGEND_HEIGHT,
|
|
17
17
|
LEGEND_PILL_PAD,
|
|
18
18
|
LEGEND_PILL_FONT_SIZE,
|
|
19
|
-
LEGEND_PILL_FONT_W,
|
|
20
19
|
LEGEND_CAPSULE_PAD,
|
|
21
20
|
LEGEND_DOT_R,
|
|
22
21
|
LEGEND_ENTRY_FONT_SIZE,
|
|
23
|
-
LEGEND_ENTRY_FONT_W,
|
|
24
22
|
LEGEND_ENTRY_DOT_GAP,
|
|
25
23
|
LEGEND_ENTRY_TRAIL,
|
|
26
24
|
LEGEND_GROUP_GAP,
|
|
@@ -28,6 +26,7 @@ import {
|
|
|
28
26
|
LEGEND_EYE_GAP,
|
|
29
27
|
EYE_OPEN_PATH,
|
|
30
28
|
EYE_CLOSED_PATH,
|
|
29
|
+
measureLegendText,
|
|
31
30
|
} from '../utils/legend-constants';
|
|
32
31
|
|
|
33
32
|
// ============================================================
|
|
@@ -36,8 +35,8 @@ import {
|
|
|
36
35
|
|
|
37
36
|
const DIAGRAM_PADDING = 20;
|
|
38
37
|
const MAX_SCALE = 3;
|
|
38
|
+
import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
|
|
39
39
|
const TITLE_HEIGHT = 30;
|
|
40
|
-
const TITLE_FONT_SIZE = 18;
|
|
41
40
|
const LABEL_FONT_SIZE = 13;
|
|
42
41
|
const META_FONT_SIZE = 11;
|
|
43
42
|
const META_LINE_HEIGHT = 16;
|
|
@@ -209,7 +208,7 @@ export function renderSitemap(
|
|
|
209
208
|
.attr('text-anchor', 'middle')
|
|
210
209
|
.attr('fill', palette.text)
|
|
211
210
|
.attr('font-size', TITLE_FONT_SIZE)
|
|
212
|
-
.attr('font-weight',
|
|
211
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
213
212
|
.attr('class', 'sitemap-title chart-title');
|
|
214
213
|
|
|
215
214
|
if (parsed.titleLineNumber) {
|
|
@@ -523,7 +522,7 @@ export function renderSitemap(
|
|
|
523
522
|
.attr('text-anchor', 'middle')
|
|
524
523
|
.attr('fill', palette.text)
|
|
525
524
|
.attr('font-size', TITLE_FONT_SIZE)
|
|
526
|
-
.attr('font-weight',
|
|
525
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
527
526
|
.attr('class', 'sitemap-title chart-title')
|
|
528
527
|
.style('font-family', FONT_FAMILY);
|
|
529
528
|
|
|
@@ -592,7 +591,7 @@ function renderLegend(
|
|
|
592
591
|
|
|
593
592
|
for (const group of visibleGroups) {
|
|
594
593
|
const isActive = activeTagGroup != null;
|
|
595
|
-
const pillW = group.name
|
|
594
|
+
const pillW = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
596
595
|
|
|
597
596
|
const gX = fixedPositions?.get(group.name) ?? group.x;
|
|
598
597
|
const gY = fixedPositions ? 0 : group.y;
|
|
@@ -705,7 +704,7 @@ function renderLegend(
|
|
|
705
704
|
.attr('fill', palette.textMuted)
|
|
706
705
|
.text(entry.value);
|
|
707
706
|
|
|
708
|
-
entryX = textX + entry.value
|
|
707
|
+
entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
709
708
|
}
|
|
710
709
|
}
|
|
711
710
|
}
|
package/src/utils/arrows.ts
CHANGED
|
@@ -14,14 +14,14 @@ export interface ParsedArrow {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// Forward (call) patterns — participant names may contain spaces, so use non-greedy (.+?)
|
|
17
|
-
const SYNC_LABELED_RE = /^(.+?)\s
|
|
18
|
-
const ASYNC_LABELED_RE = /^(.+?)\s
|
|
17
|
+
const SYNC_LABELED_RE = /^(.+?)\s*-(.+)->\s*(.+)$/;
|
|
18
|
+
const ASYNC_LABELED_RE = /^(.+?)\s*~(.+)~>\s*(.+)$/;
|
|
19
19
|
|
|
20
20
|
// Deprecated patterns — produce errors
|
|
21
|
-
const RETURN_SYNC_LABELED_RE = /^(.+?)\s
|
|
22
|
-
const RETURN_ASYNC_LABELED_RE = /^(.+?)\s
|
|
23
|
-
const BIDI_SYNC_RE = /^(.+?)\s
|
|
24
|
-
const BIDI_ASYNC_RE = /^(.+?)\s
|
|
21
|
+
const RETURN_SYNC_LABELED_RE = /^(.+?)\s*<-(.+)-\s*(.+)$/;
|
|
22
|
+
const RETURN_ASYNC_LABELED_RE = /^(.+?)\s*<~(.+)~\s*(.+)$/;
|
|
23
|
+
const BIDI_SYNC_RE = /^(.+?)\s*<-(.+)->\s*(.+)$/;
|
|
24
|
+
const BIDI_ASYNC_RE = /^(.+?)\s*<~(.+)~>\s*(.+)$/;
|
|
25
25
|
|
|
26
26
|
const ARROW_CHARS = ['->', '~>'];
|
|
27
27
|
|
|
@@ -93,3 +93,22 @@ export function parseArrow(
|
|
|
93
93
|
|
|
94
94
|
return null;
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Match an arrow segment and extract label + async flag.
|
|
99
|
+
* Handles: `->`, `-label->`, `~>`, `~label~>`.
|
|
100
|
+
* Returns null if no arrow pattern matched.
|
|
101
|
+
*/
|
|
102
|
+
export function matchArrowLabel(segment: string): { label: string; async: boolean } | null {
|
|
103
|
+
// Async labeled: ~label~>
|
|
104
|
+
const asyncLabeled = segment.match(/^~(.+?)~>$/);
|
|
105
|
+
if (asyncLabeled) return { label: asyncLabeled[1].trim(), async: true };
|
|
106
|
+
// Async bare: ~>
|
|
107
|
+
if (segment.trim() === '~>') return { label: '', async: true };
|
|
108
|
+
// Sync labeled: -label->
|
|
109
|
+
const syncLabeled = segment.match(/^-(.+?)->$/);
|
|
110
|
+
if (syncLabeled) return { label: syncLabeled[1].trim(), async: false };
|
|
111
|
+
// Sync bare: ->
|
|
112
|
+
if (segment.trim() === '->') return { label: '', async: false };
|
|
113
|
+
return null;
|
|
114
|
+
}
|
package/src/utils/duration.ts
CHANGED
|
@@ -150,6 +150,18 @@ export function addGanttDuration(
|
|
|
150
150
|
}
|
|
151
151
|
return result;
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
case 'h': {
|
|
155
|
+
const result = new Date(startDate);
|
|
156
|
+
result.setTime(result.getTime() + amount * 3600000 * direction);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'min': {
|
|
161
|
+
const result = new Date(startDate);
|
|
162
|
+
result.setTime(result.getTime() + amount * 60000 * direction);
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
153
165
|
}
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -157,7 +169,7 @@ export function addGanttDuration(
|
|
|
157
169
|
* Parse a duration string like "3bd" or "5d".
|
|
158
170
|
*/
|
|
159
171
|
export function parseDuration(s: string): Duration | null {
|
|
160
|
-
const match = s.trim().match(/^(\d+(?:\.\d+)?)(
|
|
172
|
+
const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)$/);
|
|
161
173
|
if (!match) return null;
|
|
162
174
|
return { amount: parseFloat(match[1]), unit: match[2] as DurationUnit };
|
|
163
175
|
}
|
|
@@ -185,22 +197,46 @@ export function parseOffset(value: string): Offset | null {
|
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
/**
|
|
188
|
-
* Parse a date string (YYYY-MM-DD, YYYY-MM, or YYYY) into a Date object.
|
|
189
|
-
*
|
|
200
|
+
* Parse a date string (YYYY-MM-DD, YYYY-MM, YYYY, or YYYY-MM-DD HH:MM) into a Date object.
|
|
201
|
+
* Returns midnight local time unless HH:MM is specified.
|
|
190
202
|
*/
|
|
191
203
|
export function parseGanttDate(s: string): Date {
|
|
192
|
-
|
|
204
|
+
// Split on space to detect optional time component
|
|
205
|
+
const spaceIdx = s.indexOf(' ');
|
|
206
|
+
let datePart = s;
|
|
207
|
+
let hour = 0;
|
|
208
|
+
let minute = 0;
|
|
209
|
+
|
|
210
|
+
if (spaceIdx !== -1) {
|
|
211
|
+
datePart = s.slice(0, spaceIdx);
|
|
212
|
+
const timePart = s.slice(spaceIdx + 1);
|
|
213
|
+
const timeParts = timePart.split(':');
|
|
214
|
+
if (timeParts.length === 2) {
|
|
215
|
+
const h = parseInt(timeParts[0], 10);
|
|
216
|
+
const m = parseInt(timeParts[1], 10);
|
|
217
|
+
if (h >= 0 && h <= 23 && m >= 0 && m <= 59) {
|
|
218
|
+
hour = h;
|
|
219
|
+
minute = m;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const parts = datePart.split('-').map(p => parseInt(p, 10));
|
|
193
225
|
const year = parts[0];
|
|
194
226
|
const month = parts.length >= 2 ? parts[1] - 1 : 0; // JS months are 0-based
|
|
195
227
|
const day = parts.length >= 3 ? parts[2] : 1;
|
|
196
|
-
return new Date(year, month, day);
|
|
228
|
+
return new Date(year, month, day, hour, minute);
|
|
197
229
|
}
|
|
198
230
|
|
|
199
231
|
/**
|
|
200
|
-
* Format a Date as YYYY-MM-DD string.
|
|
232
|
+
* Format a Date as YYYY-MM-DD string, or YYYY-MM-DD HH:MM if time is non-midnight.
|
|
201
233
|
*/
|
|
202
234
|
export function formatGanttDate(date: Date): string {
|
|
203
|
-
|
|
235
|
+
const dateStr = formatDateKey(date);
|
|
236
|
+
const h = date.getHours();
|
|
237
|
+
const m = date.getMinutes();
|
|
238
|
+
if (h === 0 && m === 0) return dateStr;
|
|
239
|
+
return `${dateStr} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
204
240
|
}
|
|
205
241
|
|
|
206
242
|
/**
|
|
@@ -18,6 +18,32 @@ export const LEGEND_EYE_SIZE = 14;
|
|
|
18
18
|
export const LEGEND_EYE_GAP = 6;
|
|
19
19
|
export const LEGEND_ICON_W = 20;
|
|
20
20
|
|
|
21
|
+
// ── Proportional text measurement ────────────────────────────
|
|
22
|
+
// Helvetica character width ratios (fraction of fontSize).
|
|
23
|
+
// Replaces the naive `chars * 0.6 * fontSize` estimate with
|
|
24
|
+
// per-character proportional widths for accurate legend sizing.
|
|
25
|
+
// prettier-ignore
|
|
26
|
+
const CHAR_W: Record<string, number> = {
|
|
27
|
+
' ':.28,'!': .28,'"': .36,'#': .56,'$': .56,'%': .89,'&': .67,"'":.19,
|
|
28
|
+
'(':.33,')':.33,'*': .39,'+':.58,',':.28,'-':.33,'.':.28,'/':.28,
|
|
29
|
+
'0':.56,'1':.56,'2':.56,'3':.56,'4':.56,'5':.56,'6':.56,'7':.56,'8':.56,'9':.56,
|
|
30
|
+
':':.28,';':.28,'<':.58,'=':.58,'>':.58,'?':.56,'@':1.02,
|
|
31
|
+
A:.67,B:.67,C:.72,D:.72,E:.67,F:.61,G:.78,H:.72,I:.28,J:.50,K:.67,L:.56,M:.83,
|
|
32
|
+
N:.72,O:.78,P:.67,Q:.78,R:.72,S:.67,T:.61,U:.72,V:.67,W:.94,X:.67,Y:.67,Z:.61,
|
|
33
|
+
a:.56,b:.56,c:.50,d:.56,e:.56,f:.28,g:.56,h:.56,i:.22,j:.22,k:.50,l:.22,m:.83,
|
|
34
|
+
n:.56,o:.56,p:.56,q:.56,r:.33,s:.50,t:.28,u:.56,v:.50,w:.72,x:.50,y:.50,z:.50,
|
|
35
|
+
};
|
|
36
|
+
const DEFAULT_W = 0.56;
|
|
37
|
+
|
|
38
|
+
/** Estimate rendered text width using Helvetica proportional character widths. */
|
|
39
|
+
export function measureLegendText(text: string, fontSize: number): number {
|
|
40
|
+
let w = 0;
|
|
41
|
+
for (let i = 0; i < text.length; i++) {
|
|
42
|
+
w += (CHAR_W[text[i]] ?? DEFAULT_W) * fontSize;
|
|
43
|
+
}
|
|
44
|
+
return w;
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
// Eye icon SVG paths (14×14 viewBox)
|
|
22
48
|
// Present only in org and sitemap legends (metadata visibility toggle)
|
|
23
49
|
export const EYE_OPEN_PATH =
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Shared legend SVG string generator
|
|
3
|
+
// Produces SVG <g> elements matching the standard legend style
|
|
4
|
+
// used across all diagram types (capsule pills with colored dots).
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
LEGEND_HEIGHT,
|
|
9
|
+
LEGEND_PILL_PAD,
|
|
10
|
+
LEGEND_PILL_FONT_SIZE,
|
|
11
|
+
LEGEND_CAPSULE_PAD,
|
|
12
|
+
LEGEND_DOT_R,
|
|
13
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
14
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
15
|
+
LEGEND_ENTRY_TRAIL,
|
|
16
|
+
LEGEND_GROUP_GAP,
|
|
17
|
+
measureLegendText,
|
|
18
|
+
} from './legend-constants';
|
|
19
|
+
import { mix } from '../palettes/color-utils';
|
|
20
|
+
import { FONT_FAMILY } from '../fonts';
|
|
21
|
+
|
|
22
|
+
// ── Types ────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface LegendGroupData {
|
|
25
|
+
name: string;
|
|
26
|
+
entries: Array<{ value: string; color: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LegendRenderOptions {
|
|
30
|
+
palette: { bg: string; surface: string; text: string; textMuted: string };
|
|
31
|
+
isDark: boolean;
|
|
32
|
+
containerWidth: number;
|
|
33
|
+
/** Grid left offset as percentage (e.g. 12 for '12%'). Centers legend over plot area. */
|
|
34
|
+
gridLeftPct?: number;
|
|
35
|
+
/** Grid right offset as percentage (e.g. 4 for '4%'). Centers legend over plot area. */
|
|
36
|
+
gridRightPct?: number;
|
|
37
|
+
activeGroup?: string | null;
|
|
38
|
+
className?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LegendRenderResult {
|
|
42
|
+
svg: string;
|
|
43
|
+
height: number;
|
|
44
|
+
/** Natural content width (px). Callers can use this for CSS-based centering. */
|
|
45
|
+
width: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function esc(s: string): string {
|
|
51
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pillWidth(name: string): number {
|
|
55
|
+
return measureLegendText(name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function entriesWidth(entries: Array<{ value: string }>): number {
|
|
59
|
+
let w = 0;
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
62
|
+
}
|
|
63
|
+
return w;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function groupTotalWidth(name: string, entries: Array<{ value: string }>, isActive: boolean): number {
|
|
67
|
+
const pw = pillWidth(name);
|
|
68
|
+
if (!isActive) return pw;
|
|
69
|
+
return LEGEND_CAPSULE_PAD * 2 + pw + 4 + entriesWidth(entries);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Main renderer ────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function renderLegendSvg(
|
|
75
|
+
groups: LegendGroupData[],
|
|
76
|
+
options: LegendRenderOptions,
|
|
77
|
+
): LegendRenderResult {
|
|
78
|
+
if (groups.length === 0) return { svg: '', height: 0, width: 0 };
|
|
79
|
+
|
|
80
|
+
const { palette, isDark, containerWidth, activeGroup, className } = options;
|
|
81
|
+
const groupBg = isDark
|
|
82
|
+
? mix(palette.surface, palette.bg, 50)
|
|
83
|
+
: mix(palette.surface, palette.bg, 30);
|
|
84
|
+
|
|
85
|
+
// Pre-compute layout
|
|
86
|
+
const items = groups
|
|
87
|
+
.filter((g) => g.entries.length > 0)
|
|
88
|
+
.map((g) => {
|
|
89
|
+
const isActive = !!activeGroup && g.name.toLowerCase() === activeGroup.toLowerCase();
|
|
90
|
+
const pw = pillWidth(g.name);
|
|
91
|
+
const tw = groupTotalWidth(g.name, g.entries, isActive);
|
|
92
|
+
return { group: g, isActive, pillWidth: pw, totalWidth: tw };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (items.length === 0) return { svg: '', height: 0, width: 0 };
|
|
96
|
+
|
|
97
|
+
const totalWidth = items.reduce((s, it) => s + it.totalWidth, 0) + (items.length - 1) * LEGEND_GROUP_GAP;
|
|
98
|
+
|
|
99
|
+
// Center over the plot area when grid offsets are provided, otherwise full container
|
|
100
|
+
const plotLeft = options.gridLeftPct ? (containerWidth * options.gridLeftPct) / 100 : 0;
|
|
101
|
+
const plotRight = options.gridRightPct ? containerWidth - (containerWidth * options.gridRightPct) / 100 : containerWidth;
|
|
102
|
+
const plotWidth = plotRight - plotLeft;
|
|
103
|
+
let x = Math.max(0, plotLeft + (plotWidth - totalWidth) / 2);
|
|
104
|
+
|
|
105
|
+
const parts: string[] = [];
|
|
106
|
+
const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
|
|
107
|
+
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
const groupKey = item.group.name.toLowerCase();
|
|
110
|
+
const inner: string[] = [];
|
|
111
|
+
|
|
112
|
+
// Outer capsule (active only)
|
|
113
|
+
if (item.isActive) {
|
|
114
|
+
inner.push(
|
|
115
|
+
`<rect width="${item.totalWidth}" height="${LEGEND_HEIGHT}" rx="${LEGEND_HEIGHT / 2}" fill="${esc(groupBg)}"/>`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pillXOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
120
|
+
const pillYOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
121
|
+
const h = item.isActive ? pillH : LEGEND_HEIGHT;
|
|
122
|
+
|
|
123
|
+
// Pill background
|
|
124
|
+
inner.push(
|
|
125
|
+
`<rect x="${pillXOff}" y="${pillYOff}" width="${item.pillWidth}" height="${h}" rx="${h / 2}" fill="${esc(item.isActive ? palette.bg : groupBg)}"/>`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Active pill border
|
|
129
|
+
if (item.isActive) {
|
|
130
|
+
inner.push(
|
|
131
|
+
`<rect x="${pillXOff}" y="${pillYOff}" width="${item.pillWidth}" height="${h}" rx="${h / 2}" fill="none" stroke="${esc(mix(palette.textMuted, palette.bg, 50))}" stroke-width="0.75"/>`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Pill text
|
|
136
|
+
inner.push(
|
|
137
|
+
`<text x="${pillXOff + item.pillWidth / 2}" y="${LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2}" font-size="${LEGEND_PILL_FONT_SIZE}" font-weight="500" fill="${esc(item.isActive ? palette.text : palette.textMuted)}" text-anchor="middle" font-family="${esc(FONT_FAMILY)}">${esc(item.group.name)}</text>`,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Entry dots + labels (active only)
|
|
141
|
+
if (item.isActive) {
|
|
142
|
+
let entryX = pillXOff + item.pillWidth + 4;
|
|
143
|
+
for (const entry of item.group.entries) {
|
|
144
|
+
const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
|
|
145
|
+
inner.push(
|
|
146
|
+
`<g data-legend-entry="${esc(entry.value.toLowerCase())}" data-series-name="${esc(entry.value)}" style="cursor:pointer">` +
|
|
147
|
+
`<circle cx="${entryX + LEGEND_DOT_R}" cy="${LEGEND_HEIGHT / 2}" r="${LEGEND_DOT_R}" fill="${esc(entry.color)}"/>` +
|
|
148
|
+
`<text x="${textX}" y="${LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1}" font-size="${LEGEND_ENTRY_FONT_SIZE}" fill="${esc(palette.textMuted)}" font-family="${esc(FONT_FAMILY)}">${esc(entry.value)}</text>` +
|
|
149
|
+
`</g>`,
|
|
150
|
+
);
|
|
151
|
+
entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
parts.push(
|
|
156
|
+
`<g transform="translate(${x},0)" data-legend-group="${esc(groupKey)}" style="cursor:pointer">${inner.join('')}</g>`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
x += item.totalWidth + LEGEND_GROUP_GAP;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const classAttr = className ? ` class="${esc(className)}"` : '';
|
|
163
|
+
const activeAttr = activeGroup ? ` data-legend-active="${esc(activeGroup.toLowerCase())}"` : '';
|
|
164
|
+
const svg = `<g${classAttr}${activeAttr}>${parts.join('')}</g>`;
|
|
165
|
+
|
|
166
|
+
return { svg, height: LEGEND_HEIGHT, width: totalWidth };
|
|
167
|
+
}
|