@diagrammo/dgmo 0.4.4 → 0.5.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/dist/cli.cjs +148 -148
- package/dist/index.cjs +506 -143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -18
- package/dist/index.d.ts +22 -18
- package/dist/index.js +506 -143
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chart.ts +5 -2
- package/src/d3.ts +388 -21
- package/src/echarts.ts +13 -12
- package/src/er/parser.ts +88 -3
- package/src/er/renderer.ts +91 -2
- package/src/er/types.ts +3 -0
- package/src/kanban/mutations.ts +1 -1
- package/src/kanban/parser.ts +55 -36
package/package.json
CHANGED
package/src/chart.ts
CHANGED
|
@@ -108,8 +108,11 @@ export function parseChart(
|
|
|
108
108
|
// Skip empty lines
|
|
109
109
|
if (!trimmed) continue;
|
|
110
110
|
|
|
111
|
-
//
|
|
112
|
-
if (/^#{2,}\s+/.test(trimmed))
|
|
111
|
+
// Reject legacy ## section headers
|
|
112
|
+
if (/^#{2,}\s+/.test(trimmed)) {
|
|
113
|
+
result.diagnostics.push(makeDgmoError(lineNumber, `'${trimmed}' — ## syntax is no longer supported. Use [Group] containers instead`));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
113
116
|
|
|
114
117
|
// Skip comments
|
|
115
118
|
if (trimmed.startsWith('//')) continue;
|
package/src/d3.ts
CHANGED
|
@@ -72,6 +72,7 @@ export interface TimelineEvent {
|
|
|
72
72
|
endDate: string | null;
|
|
73
73
|
label: string;
|
|
74
74
|
group: string | null;
|
|
75
|
+
metadata: Record<string, string>;
|
|
75
76
|
lineNumber: number;
|
|
76
77
|
uncertain?: boolean;
|
|
77
78
|
}
|
|
@@ -153,6 +154,7 @@ export interface ParsedD3 {
|
|
|
153
154
|
timelineGroups: TimelineGroup[];
|
|
154
155
|
timelineEras: TimelineEra[];
|
|
155
156
|
timelineMarkers: TimelineMarker[];
|
|
157
|
+
timelineTagGroups: TagGroup[];
|
|
156
158
|
timelineSort: TimelineSort;
|
|
157
159
|
timelineScale: boolean;
|
|
158
160
|
timelineSwimlanes: boolean;
|
|
@@ -178,9 +180,12 @@ export interface ParsedD3 {
|
|
|
178
180
|
import { resolveColor } from './colors';
|
|
179
181
|
import type { PaletteColors } from './palettes';
|
|
180
182
|
import { getSeriesColors } from './palettes';
|
|
183
|
+
import { mix } from './palettes/color-utils';
|
|
181
184
|
import type { DgmoError } from './diagnostics';
|
|
182
185
|
import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
183
|
-
import { collectIndentedValues } from './utils/parsing';
|
|
186
|
+
import { collectIndentedValues, extractColor, parsePipeMetadata } from './utils/parsing';
|
|
187
|
+
import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
|
|
188
|
+
import type { TagGroup } from './utils/tag-groups';
|
|
184
189
|
|
|
185
190
|
// ============================================================
|
|
186
191
|
// Shared Rendering Helpers
|
|
@@ -342,6 +347,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
342
347
|
timelineGroups: [],
|
|
343
348
|
timelineEras: [],
|
|
344
349
|
timelineMarkers: [],
|
|
350
|
+
timelineTagGroups: [],
|
|
345
351
|
timelineSort: 'time',
|
|
346
352
|
timelineScale: true,
|
|
347
353
|
timelineSwimlanes: false,
|
|
@@ -379,28 +385,74 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
379
385
|
const freeformLines: string[] = [];
|
|
380
386
|
let currentArcGroup: string | null = null;
|
|
381
387
|
let currentTimelineGroup: string | null = null;
|
|
388
|
+
let currentTimelineTagGroup: TagGroup | null = null;
|
|
389
|
+
const timelineAliasMap = new Map<string, string>();
|
|
382
390
|
|
|
383
391
|
for (let i = 0; i < lines.length; i++) {
|
|
384
|
-
const
|
|
392
|
+
const rawLine = lines[i];
|
|
393
|
+
const line = rawLine.trim();
|
|
394
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
385
395
|
const lineNumber = i + 1;
|
|
386
396
|
|
|
387
397
|
// Skip empty lines
|
|
388
398
|
if (!line) continue;
|
|
389
399
|
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
400
|
+
// Timeline tag group heading: `tag: Name [alias X]`
|
|
401
|
+
if (result.type === 'timeline' && indent === 0) {
|
|
402
|
+
const tagBlockMatch = matchTagBlockHeading(line);
|
|
403
|
+
if (tagBlockMatch) {
|
|
404
|
+
if (tagBlockMatch.deprecated) {
|
|
405
|
+
result.diagnostics.push(makeDgmoError(lineNumber,
|
|
406
|
+
`'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning'));
|
|
407
|
+
}
|
|
408
|
+
currentTimelineTagGroup = {
|
|
409
|
+
name: tagBlockMatch.name,
|
|
410
|
+
alias: tagBlockMatch.alias,
|
|
411
|
+
entries: [],
|
|
412
|
+
lineNumber,
|
|
413
|
+
};
|
|
414
|
+
if (tagBlockMatch.alias) {
|
|
415
|
+
timelineAliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
|
|
416
|
+
}
|
|
417
|
+
result.timelineTagGroups.push(currentTimelineTagGroup);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Timeline tag group entries (indented under tag: heading)
|
|
423
|
+
if (currentTimelineTagGroup && indent > 0) {
|
|
424
|
+
const trimmedEntry = line;
|
|
425
|
+
const isDefault = /\bdefault\s*$/.test(trimmedEntry);
|
|
426
|
+
const entryText = isDefault
|
|
427
|
+
? trimmedEntry.replace(/\s+default\s*$/, '').trim()
|
|
428
|
+
: trimmedEntry;
|
|
429
|
+
const { label, color } = extractColor(entryText, palette);
|
|
430
|
+
if (color) {
|
|
431
|
+
if (isDefault) currentTimelineTagGroup.defaultValue = label;
|
|
432
|
+
currentTimelineTagGroup.entries.push({ value: label, color, lineNumber });
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// End tag group on non-indented line
|
|
438
|
+
if (currentTimelineTagGroup && indent === 0) {
|
|
439
|
+
currentTimelineTagGroup = null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// [Group] container headers for arc diagram node grouping and timeline eras
|
|
443
|
+
const groupMatch = line.match(/^\[(.+?)\](?:\s*\(([^)]+)\))?\s*$/);
|
|
444
|
+
if (groupMatch) {
|
|
393
445
|
if (result.type === 'arc') {
|
|
394
|
-
const name =
|
|
395
|
-
const color =
|
|
396
|
-
? resolveColor(
|
|
446
|
+
const name = groupMatch[1].trim();
|
|
447
|
+
const color = groupMatch[2]
|
|
448
|
+
? resolveColor(groupMatch[2].trim(), palette)
|
|
397
449
|
: null;
|
|
398
450
|
result.arcNodeGroups.push({ name, nodes: [], color, lineNumber });
|
|
399
451
|
currentArcGroup = name;
|
|
400
452
|
} else if (result.type === 'timeline') {
|
|
401
|
-
const name =
|
|
402
|
-
const color =
|
|
403
|
-
? resolveColor(
|
|
453
|
+
const name = groupMatch[1].trim();
|
|
454
|
+
const color = groupMatch[2]
|
|
455
|
+
? resolveColor(groupMatch[2].trim(), palette)
|
|
404
456
|
: null;
|
|
405
457
|
result.timelineGroups.push({ name, color, lineNumber });
|
|
406
458
|
currentTimelineGroup = name;
|
|
@@ -408,6 +460,19 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
408
460
|
continue;
|
|
409
461
|
}
|
|
410
462
|
|
|
463
|
+
// Reject legacy ## group syntax
|
|
464
|
+
if (/^#{2,}\s+/.test(line) && (result.type === 'arc' || result.type === 'timeline')) {
|
|
465
|
+
const name = line.replace(/^#{2,}\s+/, '').replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
466
|
+
result.diagnostics.push(makeDgmoError(lineNumber, `'## ${name}' is no longer supported. Use '[${name}]' instead`, 'warning'));
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Clear group context on un-indented lines (except [Group] already handled above)
|
|
471
|
+
if (indent === 0) {
|
|
472
|
+
currentArcGroup = null;
|
|
473
|
+
currentTimelineGroup = null;
|
|
474
|
+
}
|
|
475
|
+
|
|
411
476
|
// Skip comments
|
|
412
477
|
if (line.startsWith('//')) {
|
|
413
478
|
continue;
|
|
@@ -498,11 +563,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
498
563
|
const amount = parseFloat(durationMatch[2]);
|
|
499
564
|
const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y';
|
|
500
565
|
const endDate = addDurationToDate(startDate, amount, unit);
|
|
566
|
+
const segments = durationMatch[5].split('|');
|
|
567
|
+
const metadata = segments.length > 1
|
|
568
|
+
? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
|
|
569
|
+
: {};
|
|
501
570
|
result.timelineEvents.push({
|
|
502
571
|
date: startDate,
|
|
503
572
|
endDate,
|
|
504
|
-
label:
|
|
573
|
+
label: segments[0].trim(),
|
|
505
574
|
group: currentTimelineGroup,
|
|
575
|
+
metadata,
|
|
506
576
|
lineNumber,
|
|
507
577
|
uncertain,
|
|
508
578
|
});
|
|
@@ -514,11 +584,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
514
584
|
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)?\s*:\s*(.+)$/
|
|
515
585
|
);
|
|
516
586
|
if (rangeMatch) {
|
|
587
|
+
const segments = rangeMatch[4].split('|');
|
|
588
|
+
const metadata = segments.length > 1
|
|
589
|
+
? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
|
|
590
|
+
: {};
|
|
517
591
|
result.timelineEvents.push({
|
|
518
592
|
date: rangeMatch[1],
|
|
519
593
|
endDate: rangeMatch[2],
|
|
520
|
-
label:
|
|
594
|
+
label: segments[0].trim(),
|
|
521
595
|
group: currentTimelineGroup,
|
|
596
|
+
metadata,
|
|
522
597
|
lineNumber,
|
|
523
598
|
uncertain: rangeMatch[3] === '?',
|
|
524
599
|
});
|
|
@@ -530,11 +605,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
530
605
|
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+)$/
|
|
531
606
|
);
|
|
532
607
|
if (pointMatch) {
|
|
608
|
+
const segments = pointMatch[2].split('|');
|
|
609
|
+
const metadata = segments.length > 1
|
|
610
|
+
? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
|
|
611
|
+
: {};
|
|
533
612
|
result.timelineEvents.push({
|
|
534
613
|
date: pointMatch[1],
|
|
535
614
|
endDate: null,
|
|
536
|
-
label:
|
|
615
|
+
label: segments[0].trim(),
|
|
537
616
|
group: currentTimelineGroup,
|
|
617
|
+
metadata,
|
|
538
618
|
lineNumber,
|
|
539
619
|
});
|
|
540
620
|
continue;
|
|
@@ -914,7 +994,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
914
994
|
// Validate arc ordering vs groups
|
|
915
995
|
if (result.arcNodeGroups.length > 0) {
|
|
916
996
|
if (result.arcOrder === 'name' || result.arcOrder === 'degree') {
|
|
917
|
-
warn(1, `Cannot use "order: ${result.arcOrder}" with
|
|
997
|
+
warn(1, `Cannot use "order: ${result.arcOrder}" with [Group] headers. Use "order: group" or remove group headers.`);
|
|
918
998
|
result.arcOrder = 'group';
|
|
919
999
|
}
|
|
920
1000
|
if (result.arcOrder === 'appearance') {
|
|
@@ -928,6 +1008,24 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
928
1008
|
if (result.timelineEvents.length === 0) {
|
|
929
1009
|
warn(1, 'No events found. Add events as "YYYY: description" or "YYYY->YYYY: description"');
|
|
930
1010
|
}
|
|
1011
|
+
// Validate tag values and inject defaults
|
|
1012
|
+
if (result.timelineTagGroups.length > 0) {
|
|
1013
|
+
validateTagValues(
|
|
1014
|
+
result.timelineEvents,
|
|
1015
|
+
result.timelineTagGroups,
|
|
1016
|
+
(line, msg) => result.diagnostics.push(makeDgmoError(line, msg, 'warning')),
|
|
1017
|
+
suggest,
|
|
1018
|
+
);
|
|
1019
|
+
for (const group of result.timelineTagGroups) {
|
|
1020
|
+
if (!group.defaultValue) continue;
|
|
1021
|
+
const key = group.name.toLowerCase();
|
|
1022
|
+
for (const event of result.timelineEvents) {
|
|
1023
|
+
if (!event.metadata[key]) {
|
|
1024
|
+
event.metadata[key] = group.defaultValue;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
931
1029
|
return result;
|
|
932
1030
|
}
|
|
933
1031
|
|
|
@@ -2682,7 +2780,8 @@ export function renderTimeline(
|
|
|
2682
2780
|
palette: PaletteColors,
|
|
2683
2781
|
isDark: boolean,
|
|
2684
2782
|
onClickItem?: (lineNumber: number) => void,
|
|
2685
|
-
exportDims?: D3ExportDimensions
|
|
2783
|
+
exportDims?: D3ExportDimensions,
|
|
2784
|
+
activeTagGroup?: string | null
|
|
2686
2785
|
): void {
|
|
2687
2786
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
2688
2787
|
|
|
@@ -2720,6 +2819,11 @@ export function renderTimeline(
|
|
|
2720
2819
|
});
|
|
2721
2820
|
|
|
2722
2821
|
function eventColor(ev: TimelineEvent): string {
|
|
2822
|
+
// Tag color takes priority when a tag group is active
|
|
2823
|
+
if (activeTagGroup) {
|
|
2824
|
+
const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups, activeTagGroup);
|
|
2825
|
+
if (tagColor) return tagColor;
|
|
2826
|
+
}
|
|
2723
2827
|
if (ev.group && groupColorMap.has(ev.group)) {
|
|
2724
2828
|
return groupColorMap.get(ev.group)!;
|
|
2725
2829
|
}
|
|
@@ -2811,11 +2915,49 @@ export function renderTimeline(
|
|
|
2811
2915
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
2812
2916
|
) {
|
|
2813
2917
|
g.selectAll<SVGGElement, unknown>(
|
|
2814
|
-
'.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker'
|
|
2918
|
+
'.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker, .tl-tag-legend-entry'
|
|
2815
2919
|
).attr('opacity', 1);
|
|
2816
2920
|
g.selectAll<SVGGElement, unknown>('.tl-era').attr('opacity', 1);
|
|
2817
2921
|
}
|
|
2818
2922
|
|
|
2923
|
+
function fadeToTagValue(
|
|
2924
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2925
|
+
tagKey: string,
|
|
2926
|
+
tagValue: string
|
|
2927
|
+
) {
|
|
2928
|
+
const attrName = `data-tag-${tagKey}`;
|
|
2929
|
+
g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
2930
|
+
const el = d3Selection.select(this);
|
|
2931
|
+
const val = el.attr(attrName);
|
|
2932
|
+
el.attr('opacity', val === tagValue ? 1 : FADE_OPACITY);
|
|
2933
|
+
});
|
|
2934
|
+
g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(
|
|
2935
|
+
'opacity', FADE_OPACITY
|
|
2936
|
+
);
|
|
2937
|
+
g.selectAll<SVGGElement, unknown>('.tl-marker').attr('opacity', FADE_OPACITY);
|
|
2938
|
+
// Fade legend entry dots/labels that don't match (keep group pill visible)
|
|
2939
|
+
g.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
|
|
2940
|
+
const el = d3Selection.select(this);
|
|
2941
|
+
const entryValue = el.attr('data-legend-entry');
|
|
2942
|
+
if (entryValue === '__group__') return; // keep group pill at full opacity
|
|
2943
|
+
const entryGroup = el.attr('data-tag-group');
|
|
2944
|
+
el.attr('opacity', entryGroup === tagKey && entryValue === tagValue ? 1 : FADE_OPACITY);
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
/** Attach data-tag-* attributes on an event group element */
|
|
2949
|
+
function setTagAttrs(
|
|
2950
|
+
evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
2951
|
+
ev: TimelineEvent
|
|
2952
|
+
) {
|
|
2953
|
+
for (const [key, value] of Object.entries(ev.metadata)) {
|
|
2954
|
+
evG.attr(`data-tag-${key}`, value.toLowerCase());
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// Reserve space for tag legend between title and chart content
|
|
2959
|
+
const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
|
|
2960
|
+
|
|
2819
2961
|
// ================================================================
|
|
2820
2962
|
// VERTICAL orientation (time flows top→bottom)
|
|
2821
2963
|
// ================================================================
|
|
@@ -2833,7 +2975,7 @@ export function renderTimeline(
|
|
|
2833
2975
|
const scaleMargin = timelineScale ? 40 : 0;
|
|
2834
2976
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
2835
2977
|
const margin = {
|
|
2836
|
-
top: 104 + markerMargin,
|
|
2978
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
2837
2979
|
right: 40 + scaleMargin,
|
|
2838
2980
|
bottom: 40,
|
|
2839
2981
|
left: 60 + scaleMargin,
|
|
@@ -2966,6 +3108,7 @@ export function renderTimeline(
|
|
|
2966
3108
|
.on('click', () => {
|
|
2967
3109
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
2968
3110
|
});
|
|
3111
|
+
setTagAttrs(evG, ev);
|
|
2969
3112
|
|
|
2970
3113
|
if (ev.endDate) {
|
|
2971
3114
|
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
@@ -3039,7 +3182,7 @@ export function renderTimeline(
|
|
|
3039
3182
|
const scaleMargin = timelineScale ? 40 : 0;
|
|
3040
3183
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
3041
3184
|
const margin = {
|
|
3042
|
-
top: 104 + markerMargin,
|
|
3185
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
3043
3186
|
right: 200,
|
|
3044
3187
|
bottom: 40,
|
|
3045
3188
|
left: 60 + scaleMargin,
|
|
@@ -3183,6 +3326,7 @@ export function renderTimeline(
|
|
|
3183
3326
|
.on('click', () => {
|
|
3184
3327
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
3185
3328
|
});
|
|
3329
|
+
setTagAttrs(evG, ev);
|
|
3186
3330
|
|
|
3187
3331
|
if (ev.endDate) {
|
|
3188
3332
|
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
@@ -3313,7 +3457,7 @@ export function renderTimeline(
|
|
|
3313
3457
|
// Group-sorted doesn't need legend space (group names shown on left)
|
|
3314
3458
|
const baseTopMargin = title ? 50 : 20;
|
|
3315
3459
|
const margin = {
|
|
3316
|
-
top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin,
|
|
3460
|
+
top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
|
|
3317
3461
|
right: 40,
|
|
3318
3462
|
bottom: 40 + scaleMargin,
|
|
3319
3463
|
left: dynamicLeftMargin,
|
|
@@ -3480,6 +3624,7 @@ export function renderTimeline(
|
|
|
3480
3624
|
.on('click', () => {
|
|
3481
3625
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
3482
3626
|
});
|
|
3627
|
+
setTagAttrs(evG, ev);
|
|
3483
3628
|
|
|
3484
3629
|
if (ev.endDate) {
|
|
3485
3630
|
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
@@ -3589,7 +3734,7 @@ export function renderTimeline(
|
|
|
3589
3734
|
const scaleMargin = timelineScale ? 24 : 0;
|
|
3590
3735
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
3591
3736
|
const margin = {
|
|
3592
|
-
top: 104 + (timelineScale ? 40 : 0) + markerMargin,
|
|
3737
|
+
top: 104 + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
|
|
3593
3738
|
right: 40,
|
|
3594
3739
|
bottom: 40 + scaleMargin,
|
|
3595
3740
|
left: 60,
|
|
@@ -3739,6 +3884,7 @@ export function renderTimeline(
|
|
|
3739
3884
|
.on('click', () => {
|
|
3740
3885
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
3741
3886
|
});
|
|
3887
|
+
setTagAttrs(evG, ev);
|
|
3742
3888
|
|
|
3743
3889
|
if (ev.endDate) {
|
|
3744
3890
|
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
@@ -3837,6 +3983,227 @@ export function renderTimeline(
|
|
|
3837
3983
|
}
|
|
3838
3984
|
});
|
|
3839
3985
|
}
|
|
3986
|
+
|
|
3987
|
+
// ── Tag Legend (org-chart-style pills) ──
|
|
3988
|
+
if (parsed.timelineTagGroups.length > 0) {
|
|
3989
|
+
const LG_HEIGHT = 28;
|
|
3990
|
+
const LG_PILL_PAD = 16;
|
|
3991
|
+
const LG_PILL_FONT_SIZE = 11;
|
|
3992
|
+
const LG_PILL_FONT_W = LG_PILL_FONT_SIZE * 0.6;
|
|
3993
|
+
const LG_CAPSULE_PAD = 4;
|
|
3994
|
+
const LG_DOT_R = 4;
|
|
3995
|
+
const LG_ENTRY_FONT_SIZE = 10;
|
|
3996
|
+
const LG_ENTRY_FONT_W = LG_ENTRY_FONT_SIZE * 0.6;
|
|
3997
|
+
const LG_ENTRY_DOT_GAP = 4;
|
|
3998
|
+
const LG_ENTRY_TRAIL = 8;
|
|
3999
|
+
const LG_GROUP_GAP = 12;
|
|
4000
|
+
|
|
4001
|
+
const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
|
|
4002
|
+
const mainG = mainSvg.select<SVGGElement>('g');
|
|
4003
|
+
if (!mainSvg.empty() && !mainG.empty()) {
|
|
4004
|
+
// Legend goes in the reserved space between title and chart content.
|
|
4005
|
+
// Title is at y=30 in SVG coords; we place the legend centered in the
|
|
4006
|
+
// tagLegendReserve gap just above where g starts (margin.top).
|
|
4007
|
+
// Render legend directly on SVG (not inside g) for clean centering.
|
|
4008
|
+
const legendY = title ? 50 : 10;
|
|
4009
|
+
|
|
4010
|
+
const groupBg = isDark
|
|
4011
|
+
? mix(palette.surface, palette.bg, 50)
|
|
4012
|
+
: mix(palette.surface, palette.bg, 30);
|
|
4013
|
+
|
|
4014
|
+
// Pre-compute group widths (minified and expanded)
|
|
4015
|
+
type LegendGroup = {
|
|
4016
|
+
group: TagGroup;
|
|
4017
|
+
minifiedWidth: number;
|
|
4018
|
+
expandedWidth: number;
|
|
4019
|
+
};
|
|
4020
|
+
const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
|
|
4021
|
+
const pillW = g.name.length * LG_PILL_FONT_W + LG_PILL_PAD;
|
|
4022
|
+
let entryX = LG_CAPSULE_PAD + pillW + 4;
|
|
4023
|
+
for (const entry of g.entries) {
|
|
4024
|
+
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
4025
|
+
entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
|
|
4026
|
+
}
|
|
4027
|
+
return {
|
|
4028
|
+
group: g,
|
|
4029
|
+
minifiedWidth: pillW,
|
|
4030
|
+
expandedWidth: entryX + LG_CAPSULE_PAD,
|
|
4031
|
+
};
|
|
4032
|
+
});
|
|
4033
|
+
|
|
4034
|
+
// Current active state (for standalone interactivity)
|
|
4035
|
+
let currentActiveGroup: string | null = activeTagGroup ?? null;
|
|
4036
|
+
|
|
4037
|
+
function drawLegend() {
|
|
4038
|
+
// Remove previous legend
|
|
4039
|
+
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
4040
|
+
|
|
4041
|
+
// Compute total width and center horizontally in SVG
|
|
4042
|
+
const totalW = legendGroups.reduce((s, lg) => {
|
|
4043
|
+
const isActive = currentActiveGroup != null &&
|
|
4044
|
+
lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
|
|
4045
|
+
return s + (isActive ? lg.expandedWidth : lg.minifiedWidth);
|
|
4046
|
+
}, 0) + (legendGroups.length - 1) * LG_GROUP_GAP;
|
|
4047
|
+
|
|
4048
|
+
let cx = (width - totalW) / 2;
|
|
4049
|
+
|
|
4050
|
+
for (const lg of legendGroups) {
|
|
4051
|
+
const isActive = currentActiveGroup != null &&
|
|
4052
|
+
lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
|
|
4053
|
+
|
|
4054
|
+
const pillLabel = lg.group.name;
|
|
4055
|
+
const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
|
|
4056
|
+
|
|
4057
|
+
const gEl = mainSvg
|
|
4058
|
+
.append('g')
|
|
4059
|
+
.attr('transform', `translate(${cx}, ${legendY})`)
|
|
4060
|
+
.attr('class', 'tl-tag-legend-group tl-tag-legend-entry')
|
|
4061
|
+
.attr('data-legend-group', lg.group.name.toLowerCase())
|
|
4062
|
+
.attr('data-tag-group', lg.group.name.toLowerCase())
|
|
4063
|
+
.attr('data-legend-entry', '__group__')
|
|
4064
|
+
.style('cursor', 'pointer')
|
|
4065
|
+
.on('click', () => {
|
|
4066
|
+
const groupKey = lg.group.name.toLowerCase();
|
|
4067
|
+
currentActiveGroup = currentActiveGroup === groupKey ? null : groupKey;
|
|
4068
|
+
drawLegend();
|
|
4069
|
+
recolorEvents();
|
|
4070
|
+
});
|
|
4071
|
+
|
|
4072
|
+
// Outer capsule background (active only)
|
|
4073
|
+
if (isActive) {
|
|
4074
|
+
gEl.append('rect')
|
|
4075
|
+
.attr('width', lg.expandedWidth)
|
|
4076
|
+
.attr('height', LG_HEIGHT)
|
|
4077
|
+
.attr('rx', LG_HEIGHT / 2)
|
|
4078
|
+
.attr('fill', groupBg);
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
const pillXOff = isActive ? LG_CAPSULE_PAD : 0;
|
|
4082
|
+
const pillYOff = isActive ? LG_CAPSULE_PAD : 0;
|
|
4083
|
+
const pillH = LG_HEIGHT - (isActive ? LG_CAPSULE_PAD * 2 : 0);
|
|
4084
|
+
|
|
4085
|
+
// Pill background
|
|
4086
|
+
gEl.append('rect')
|
|
4087
|
+
.attr('x', pillXOff)
|
|
4088
|
+
.attr('y', pillYOff)
|
|
4089
|
+
.attr('width', pillWidth)
|
|
4090
|
+
.attr('height', pillH)
|
|
4091
|
+
.attr('rx', pillH / 2)
|
|
4092
|
+
.attr('fill', isActive ? palette.bg : groupBg);
|
|
4093
|
+
|
|
4094
|
+
// Active pill border
|
|
4095
|
+
if (isActive) {
|
|
4096
|
+
gEl.append('rect')
|
|
4097
|
+
.attr('x', pillXOff)
|
|
4098
|
+
.attr('y', pillYOff)
|
|
4099
|
+
.attr('width', pillWidth)
|
|
4100
|
+
.attr('height', pillH)
|
|
4101
|
+
.attr('rx', pillH / 2)
|
|
4102
|
+
.attr('fill', 'none')
|
|
4103
|
+
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
4104
|
+
.attr('stroke-width', 0.75);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
// Pill text
|
|
4108
|
+
gEl.append('text')
|
|
4109
|
+
.attr('x', pillXOff + pillWidth / 2)
|
|
4110
|
+
.attr('y', LG_HEIGHT / 2 + LG_PILL_FONT_SIZE / 2 - 2)
|
|
4111
|
+
.attr('font-size', LG_PILL_FONT_SIZE)
|
|
4112
|
+
.attr('font-weight', '500')
|
|
4113
|
+
.attr('font-family', FONT_FAMILY)
|
|
4114
|
+
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
4115
|
+
.attr('text-anchor', 'middle')
|
|
4116
|
+
.text(pillLabel);
|
|
4117
|
+
|
|
4118
|
+
// Entries inside capsule (active only)
|
|
4119
|
+
if (isActive) {
|
|
4120
|
+
let entryX = pillXOff + pillWidth + 4;
|
|
4121
|
+
for (const entry of lg.group.entries) {
|
|
4122
|
+
const tagKey = lg.group.name.toLowerCase();
|
|
4123
|
+
const tagVal = entry.value.toLowerCase();
|
|
4124
|
+
|
|
4125
|
+
const entryG = gEl.append('g')
|
|
4126
|
+
.attr('class', 'tl-tag-legend-entry')
|
|
4127
|
+
.attr('data-tag-group', tagKey)
|
|
4128
|
+
.attr('data-legend-entry', tagVal)
|
|
4129
|
+
.style('cursor', 'pointer')
|
|
4130
|
+
.on('mouseenter', (event: MouseEvent) => {
|
|
4131
|
+
event.stopPropagation();
|
|
4132
|
+
fadeToTagValue(mainG, tagKey, tagVal);
|
|
4133
|
+
// Also fade legend entries on the SVG level
|
|
4134
|
+
mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
|
|
4135
|
+
const el = d3Selection.select(this);
|
|
4136
|
+
const ev = el.attr('data-legend-entry');
|
|
4137
|
+
if (ev === '__group__') return;
|
|
4138
|
+
const eg = el.attr('data-tag-group');
|
|
4139
|
+
el.attr('opacity', eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY);
|
|
4140
|
+
});
|
|
4141
|
+
})
|
|
4142
|
+
.on('mouseleave', (event: MouseEvent) => {
|
|
4143
|
+
event.stopPropagation();
|
|
4144
|
+
fadeReset(mainG);
|
|
4145
|
+
mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry')
|
|
4146
|
+
.attr('opacity', 1);
|
|
4147
|
+
})
|
|
4148
|
+
.on('click', (event: MouseEvent) => {
|
|
4149
|
+
event.stopPropagation(); // don't toggle group
|
|
4150
|
+
});
|
|
4151
|
+
|
|
4152
|
+
entryG.append('circle')
|
|
4153
|
+
.attr('cx', entryX + LG_DOT_R)
|
|
4154
|
+
.attr('cy', LG_HEIGHT / 2)
|
|
4155
|
+
.attr('r', LG_DOT_R)
|
|
4156
|
+
.attr('fill', entry.color);
|
|
4157
|
+
|
|
4158
|
+
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
4159
|
+
entryG.append('text')
|
|
4160
|
+
.attr('x', textX)
|
|
4161
|
+
.attr('y', LG_HEIGHT / 2 + LG_ENTRY_FONT_SIZE / 2 - 1)
|
|
4162
|
+
.attr('font-size', LG_ENTRY_FONT_SIZE)
|
|
4163
|
+
.attr('font-family', FONT_FAMILY)
|
|
4164
|
+
.attr('fill', palette.textMuted)
|
|
4165
|
+
.text(entry.value);
|
|
4166
|
+
|
|
4167
|
+
entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
|
|
4171
|
+
cx += (isActive ? lg.expandedWidth : lg.minifiedWidth) + LG_GROUP_GAP;
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
// Build a quick lineNumber→event lookup
|
|
4176
|
+
const eventByLine = new Map<string, TimelineEvent>();
|
|
4177
|
+
for (const ev of timelineEvents) {
|
|
4178
|
+
eventByLine.set(String(ev.lineNumber), ev);
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
function recolorEvents() {
|
|
4182
|
+
mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
4183
|
+
const el = d3Selection.select(this);
|
|
4184
|
+
const lineNum = el.attr('data-line-number');
|
|
4185
|
+
const ev = lineNum ? eventByLine.get(lineNum) : undefined;
|
|
4186
|
+
if (!ev) return;
|
|
4187
|
+
|
|
4188
|
+
let color: string;
|
|
4189
|
+
if (currentActiveGroup) {
|
|
4190
|
+
const tagColor = resolveTagColor(
|
|
4191
|
+
ev.metadata, parsed.timelineTagGroups, currentActiveGroup
|
|
4192
|
+
);
|
|
4193
|
+
color = tagColor ?? (ev.group && groupColorMap.has(ev.group)
|
|
4194
|
+
? groupColorMap.get(ev.group)! : textColor);
|
|
4195
|
+
} else {
|
|
4196
|
+
color = ev.group && groupColorMap.has(ev.group)
|
|
4197
|
+
? groupColorMap.get(ev.group)! : textColor;
|
|
4198
|
+
}
|
|
4199
|
+
el.selectAll('rect').attr('fill', color);
|
|
4200
|
+
el.selectAll('circle:not(.tl-event-point-outline)').attr('fill', color);
|
|
4201
|
+
});
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
drawLegend();
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
3840
4207
|
}
|
|
3841
4208
|
|
|
3842
4209
|
// ============================================================
|
package/src/echarts.ts
CHANGED
|
@@ -142,25 +142,26 @@ export function parseEChart(
|
|
|
142
142
|
// Skip empty lines
|
|
143
143
|
if (!trimmed) continue;
|
|
144
144
|
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (catColor) {
|
|
150
|
-
if (!result.categoryColors) result.categoryColors = {};
|
|
151
|
-
result.categoryColors[catName] = catColor;
|
|
152
|
-
}
|
|
153
|
-
currentCategory = catName;
|
|
145
|
+
// Reject legacy ## category syntax
|
|
146
|
+
if (/^#{2,}\s+/.test(trimmed)) {
|
|
147
|
+
const name = trimmed.replace(/^#{2,}\s+/, '').replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
148
|
+
result.diagnostics.push(makeDgmoError(lineNumber, `'## ${name}' is no longer supported. Use '[${name}]' instead`));
|
|
154
149
|
continue;
|
|
155
150
|
}
|
|
156
151
|
|
|
157
152
|
// Skip comments
|
|
158
153
|
if (trimmed.startsWith('//')) continue;
|
|
159
154
|
|
|
160
|
-
//
|
|
161
|
-
const categoryMatch = trimmed.match(/^\[(
|
|
155
|
+
// [Category] container header with optional color: [Category Name] or [Category Name](color)
|
|
156
|
+
const categoryMatch = trimmed.match(/^\[(.+?)\](?:\s*\(([^)]+)\))?\s*$/);
|
|
162
157
|
if (categoryMatch) {
|
|
163
|
-
|
|
158
|
+
const catName = categoryMatch[1].trim();
|
|
159
|
+
const catColor = categoryMatch[2] ? resolveColor(categoryMatch[2].trim(), palette) : null;
|
|
160
|
+
if (catColor) {
|
|
161
|
+
if (!result.categoryColors) result.categoryColors = {};
|
|
162
|
+
result.categoryColors[catName] = catColor;
|
|
163
|
+
}
|
|
164
|
+
currentCategory = catName;
|
|
164
165
|
continue;
|
|
165
166
|
}
|
|
166
167
|
|