@diagrammo/dgmo 0.4.4 → 0.5.1
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 +149 -149
- package/dist/index.cjs +689 -174
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -19
- package/dist/index.d.ts +26 -19
- package/dist/index.js +689 -174
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chart.ts +5 -2
- package/src/d3.ts +622 -62
- 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/src/sharing.ts +8 -0
package/src/d3.ts
CHANGED
|
@@ -65,13 +65,14 @@ export interface ArcNodeGroup {
|
|
|
65
65
|
lineNumber: number;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export type TimelineSort = 'time' | 'group';
|
|
68
|
+
export type TimelineSort = 'time' | 'group' | 'tag';
|
|
69
69
|
|
|
70
70
|
export interface TimelineEvent {
|
|
71
71
|
date: string;
|
|
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,7 +154,9 @@ export interface ParsedD3 {
|
|
|
153
154
|
timelineGroups: TimelineGroup[];
|
|
154
155
|
timelineEras: TimelineEra[];
|
|
155
156
|
timelineMarkers: TimelineMarker[];
|
|
157
|
+
timelineTagGroups: TagGroup[];
|
|
156
158
|
timelineSort: TimelineSort;
|
|
159
|
+
timelineDefaultSwimlaneTG?: string;
|
|
157
160
|
timelineScale: boolean;
|
|
158
161
|
timelineSwimlanes: boolean;
|
|
159
162
|
vennSets: VennSet[];
|
|
@@ -178,9 +181,12 @@ export interface ParsedD3 {
|
|
|
178
181
|
import { resolveColor } from './colors';
|
|
179
182
|
import type { PaletteColors } from './palettes';
|
|
180
183
|
import { getSeriesColors } from './palettes';
|
|
184
|
+
import { mix } from './palettes/color-utils';
|
|
181
185
|
import type { DgmoError } from './diagnostics';
|
|
182
186
|
import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
183
|
-
import { collectIndentedValues } from './utils/parsing';
|
|
187
|
+
import { collectIndentedValues, extractColor, parsePipeMetadata } from './utils/parsing';
|
|
188
|
+
import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
|
|
189
|
+
import type { TagGroup } from './utils/tag-groups';
|
|
184
190
|
|
|
185
191
|
// ============================================================
|
|
186
192
|
// Shared Rendering Helpers
|
|
@@ -342,6 +348,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
342
348
|
timelineGroups: [],
|
|
343
349
|
timelineEras: [],
|
|
344
350
|
timelineMarkers: [],
|
|
351
|
+
timelineTagGroups: [],
|
|
345
352
|
timelineSort: 'time',
|
|
346
353
|
timelineScale: true,
|
|
347
354
|
timelineSwimlanes: false,
|
|
@@ -379,28 +386,74 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
379
386
|
const freeformLines: string[] = [];
|
|
380
387
|
let currentArcGroup: string | null = null;
|
|
381
388
|
let currentTimelineGroup: string | null = null;
|
|
389
|
+
let currentTimelineTagGroup: TagGroup | null = null;
|
|
390
|
+
const timelineAliasMap = new Map<string, string>();
|
|
382
391
|
|
|
383
392
|
for (let i = 0; i < lines.length; i++) {
|
|
384
|
-
const
|
|
393
|
+
const rawLine = lines[i];
|
|
394
|
+
const line = rawLine.trim();
|
|
395
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
385
396
|
const lineNumber = i + 1;
|
|
386
397
|
|
|
387
398
|
// Skip empty lines
|
|
388
399
|
if (!line) continue;
|
|
389
400
|
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
401
|
+
// Timeline tag group heading: `tag: Name [alias X]`
|
|
402
|
+
if (result.type === 'timeline' && indent === 0) {
|
|
403
|
+
const tagBlockMatch = matchTagBlockHeading(line);
|
|
404
|
+
if (tagBlockMatch) {
|
|
405
|
+
if (tagBlockMatch.deprecated) {
|
|
406
|
+
result.diagnostics.push(makeDgmoError(lineNumber,
|
|
407
|
+
`'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning'));
|
|
408
|
+
}
|
|
409
|
+
currentTimelineTagGroup = {
|
|
410
|
+
name: tagBlockMatch.name,
|
|
411
|
+
alias: tagBlockMatch.alias,
|
|
412
|
+
entries: [],
|
|
413
|
+
lineNumber,
|
|
414
|
+
};
|
|
415
|
+
if (tagBlockMatch.alias) {
|
|
416
|
+
timelineAliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
|
|
417
|
+
}
|
|
418
|
+
result.timelineTagGroups.push(currentTimelineTagGroup);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Timeline tag group entries (indented under tag: heading)
|
|
424
|
+
if (currentTimelineTagGroup && indent > 0) {
|
|
425
|
+
const trimmedEntry = line;
|
|
426
|
+
const isDefault = /\bdefault\s*$/.test(trimmedEntry);
|
|
427
|
+
const entryText = isDefault
|
|
428
|
+
? trimmedEntry.replace(/\s+default\s*$/, '').trim()
|
|
429
|
+
: trimmedEntry;
|
|
430
|
+
const { label, color } = extractColor(entryText, palette);
|
|
431
|
+
if (color) {
|
|
432
|
+
if (isDefault) currentTimelineTagGroup.defaultValue = label;
|
|
433
|
+
currentTimelineTagGroup.entries.push({ value: label, color, lineNumber });
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// End tag group on non-indented line
|
|
439
|
+
if (currentTimelineTagGroup && indent === 0) {
|
|
440
|
+
currentTimelineTagGroup = null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// [Group] container headers for arc diagram node grouping and timeline eras
|
|
444
|
+
const groupMatch = line.match(/^\[(.+?)\](?:\s*\(([^)]+)\))?\s*$/);
|
|
445
|
+
if (groupMatch) {
|
|
393
446
|
if (result.type === 'arc') {
|
|
394
|
-
const name =
|
|
395
|
-
const color =
|
|
396
|
-
? resolveColor(
|
|
447
|
+
const name = groupMatch[1].trim();
|
|
448
|
+
const color = groupMatch[2]
|
|
449
|
+
? resolveColor(groupMatch[2].trim(), palette)
|
|
397
450
|
: null;
|
|
398
451
|
result.arcNodeGroups.push({ name, nodes: [], color, lineNumber });
|
|
399
452
|
currentArcGroup = name;
|
|
400
453
|
} else if (result.type === 'timeline') {
|
|
401
|
-
const name =
|
|
402
|
-
const color =
|
|
403
|
-
? resolveColor(
|
|
454
|
+
const name = groupMatch[1].trim();
|
|
455
|
+
const color = groupMatch[2]
|
|
456
|
+
? resolveColor(groupMatch[2].trim(), palette)
|
|
404
457
|
: null;
|
|
405
458
|
result.timelineGroups.push({ name, color, lineNumber });
|
|
406
459
|
currentTimelineGroup = name;
|
|
@@ -408,6 +461,19 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
408
461
|
continue;
|
|
409
462
|
}
|
|
410
463
|
|
|
464
|
+
// Reject legacy ## group syntax
|
|
465
|
+
if (/^#{2,}\s+/.test(line) && (result.type === 'arc' || result.type === 'timeline')) {
|
|
466
|
+
const name = line.replace(/^#{2,}\s+/, '').replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
467
|
+
result.diagnostics.push(makeDgmoError(lineNumber, `'## ${name}' is no longer supported. Use '[${name}]' instead`, 'warning'));
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Clear group context on un-indented lines (except [Group] already handled above)
|
|
472
|
+
if (indent === 0) {
|
|
473
|
+
currentArcGroup = null;
|
|
474
|
+
currentTimelineGroup = null;
|
|
475
|
+
}
|
|
476
|
+
|
|
411
477
|
// Skip comments
|
|
412
478
|
if (line.startsWith('//')) {
|
|
413
479
|
continue;
|
|
@@ -498,11 +564,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
498
564
|
const amount = parseFloat(durationMatch[2]);
|
|
499
565
|
const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y';
|
|
500
566
|
const endDate = addDurationToDate(startDate, amount, unit);
|
|
567
|
+
const segments = durationMatch[5].split('|');
|
|
568
|
+
const metadata = segments.length > 1
|
|
569
|
+
? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
|
|
570
|
+
: {};
|
|
501
571
|
result.timelineEvents.push({
|
|
502
572
|
date: startDate,
|
|
503
573
|
endDate,
|
|
504
|
-
label:
|
|
574
|
+
label: segments[0].trim(),
|
|
505
575
|
group: currentTimelineGroup,
|
|
576
|
+
metadata,
|
|
506
577
|
lineNumber,
|
|
507
578
|
uncertain,
|
|
508
579
|
});
|
|
@@ -514,11 +585,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
514
585
|
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)?\s*:\s*(.+)$/
|
|
515
586
|
);
|
|
516
587
|
if (rangeMatch) {
|
|
588
|
+
const segments = rangeMatch[4].split('|');
|
|
589
|
+
const metadata = segments.length > 1
|
|
590
|
+
? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
|
|
591
|
+
: {};
|
|
517
592
|
result.timelineEvents.push({
|
|
518
593
|
date: rangeMatch[1],
|
|
519
594
|
endDate: rangeMatch[2],
|
|
520
|
-
label:
|
|
595
|
+
label: segments[0].trim(),
|
|
521
596
|
group: currentTimelineGroup,
|
|
597
|
+
metadata,
|
|
522
598
|
lineNumber,
|
|
523
599
|
uncertain: rangeMatch[3] === '?',
|
|
524
600
|
});
|
|
@@ -530,11 +606,16 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
530
606
|
/^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+)$/
|
|
531
607
|
);
|
|
532
608
|
if (pointMatch) {
|
|
609
|
+
const segments = pointMatch[2].split('|');
|
|
610
|
+
const metadata = segments.length > 1
|
|
611
|
+
? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
|
|
612
|
+
: {};
|
|
533
613
|
result.timelineEvents.push({
|
|
534
614
|
date: pointMatch[1],
|
|
535
615
|
endDate: null,
|
|
536
|
-
label:
|
|
616
|
+
label: segments[0].trim(),
|
|
537
617
|
group: currentTimelineGroup,
|
|
618
|
+
metadata,
|
|
538
619
|
lineNumber,
|
|
539
620
|
});
|
|
540
621
|
continue;
|
|
@@ -735,10 +816,19 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
735
816
|
if (key === 'sort') {
|
|
736
817
|
const v = line
|
|
737
818
|
.substring(colonIndex + 1)
|
|
738
|
-
.trim()
|
|
739
|
-
|
|
740
|
-
if (
|
|
741
|
-
result.timelineSort =
|
|
819
|
+
.trim();
|
|
820
|
+
const vLower = v.toLowerCase();
|
|
821
|
+
if (vLower === 'time' || vLower === 'group') {
|
|
822
|
+
result.timelineSort = vLower;
|
|
823
|
+
} else if (vLower === 'tag' || vLower.startsWith('tag:')) {
|
|
824
|
+
result.timelineSort = 'tag';
|
|
825
|
+
if (vLower.startsWith('tag:')) {
|
|
826
|
+
// Extract group name (preserving original case for display)
|
|
827
|
+
const groupRef = v.substring(4).trim();
|
|
828
|
+
if (groupRef) {
|
|
829
|
+
result.timelineDefaultSwimlaneTG = groupRef;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
742
832
|
}
|
|
743
833
|
continue;
|
|
744
834
|
}
|
|
@@ -914,7 +1004,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
914
1004
|
// Validate arc ordering vs groups
|
|
915
1005
|
if (result.arcNodeGroups.length > 0) {
|
|
916
1006
|
if (result.arcOrder === 'name' || result.arcOrder === 'degree') {
|
|
917
|
-
warn(1, `Cannot use "order: ${result.arcOrder}" with
|
|
1007
|
+
warn(1, `Cannot use "order: ${result.arcOrder}" with [Group] headers. Use "order: group" or remove group headers.`);
|
|
918
1008
|
result.arcOrder = 'group';
|
|
919
1009
|
}
|
|
920
1010
|
if (result.arcOrder === 'appearance') {
|
|
@@ -928,6 +1018,48 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
928
1018
|
if (result.timelineEvents.length === 0) {
|
|
929
1019
|
warn(1, 'No events found. Add events as "YYYY: description" or "YYYY->YYYY: description"');
|
|
930
1020
|
}
|
|
1021
|
+
// Validate tag values and inject defaults
|
|
1022
|
+
if (result.timelineTagGroups.length > 0) {
|
|
1023
|
+
validateTagValues(
|
|
1024
|
+
result.timelineEvents,
|
|
1025
|
+
result.timelineTagGroups,
|
|
1026
|
+
(line, msg) => result.diagnostics.push(makeDgmoError(line, msg, 'warning')),
|
|
1027
|
+
suggest,
|
|
1028
|
+
);
|
|
1029
|
+
for (const group of result.timelineTagGroups) {
|
|
1030
|
+
if (!group.defaultValue) continue;
|
|
1031
|
+
const key = group.name.toLowerCase();
|
|
1032
|
+
for (const event of result.timelineEvents) {
|
|
1033
|
+
if (!event.metadata[key]) {
|
|
1034
|
+
event.metadata[key] = group.defaultValue;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Resolve sort: tag default swimlane group
|
|
1041
|
+
if (result.timelineSort === 'tag') {
|
|
1042
|
+
if (result.timelineTagGroups.length === 0) {
|
|
1043
|
+
warn(1, '"sort: tag" requires at least one tag group definition');
|
|
1044
|
+
result.timelineSort = 'time';
|
|
1045
|
+
} else if (result.timelineDefaultSwimlaneTG) {
|
|
1046
|
+
// Resolve alias → full group name
|
|
1047
|
+
const ref = result.timelineDefaultSwimlaneTG.toLowerCase();
|
|
1048
|
+
const match = result.timelineTagGroups.find(
|
|
1049
|
+
(g) => g.name.toLowerCase() === ref || g.alias?.toLowerCase() === ref
|
|
1050
|
+
);
|
|
1051
|
+
if (match) {
|
|
1052
|
+
result.timelineDefaultSwimlaneTG = match.name;
|
|
1053
|
+
} else {
|
|
1054
|
+
warn(1, `"sort: tag:${result.timelineDefaultSwimlaneTG}" — no tag group matches "${result.timelineDefaultSwimlaneTG}"`);
|
|
1055
|
+
result.timelineDefaultSwimlaneTG = result.timelineTagGroups[0].name;
|
|
1056
|
+
}
|
|
1057
|
+
} else {
|
|
1058
|
+
// Default to first tag group
|
|
1059
|
+
result.timelineDefaultSwimlaneTG = result.timelineTagGroups[0].name;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
931
1063
|
return result;
|
|
932
1064
|
}
|
|
933
1065
|
|
|
@@ -2682,7 +2814,10 @@ export function renderTimeline(
|
|
|
2682
2814
|
palette: PaletteColors,
|
|
2683
2815
|
isDark: boolean,
|
|
2684
2816
|
onClickItem?: (lineNumber: number) => void,
|
|
2685
|
-
exportDims?: D3ExportDimensions
|
|
2817
|
+
exportDims?: D3ExportDimensions,
|
|
2818
|
+
activeTagGroup?: string | null,
|
|
2819
|
+
swimlaneTagGroup?: string | null,
|
|
2820
|
+
onTagStateChange?: (activeTagGroup: string | null, swimlaneTagGroup: string | null) => void
|
|
2686
2821
|
): void {
|
|
2687
2822
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
2688
2823
|
|
|
@@ -2699,6 +2834,11 @@ export function renderTimeline(
|
|
|
2699
2834
|
} = parsed;
|
|
2700
2835
|
if (timelineEvents.length === 0) return;
|
|
2701
2836
|
|
|
2837
|
+
// When sort: tag is set and no explicit swimlane param, use the default
|
|
2838
|
+
if (swimlaneTagGroup == null && timelineSort === 'tag' && parsed.timelineDefaultSwimlaneTG) {
|
|
2839
|
+
swimlaneTagGroup = parsed.timelineDefaultSwimlaneTG;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2702
2842
|
const tooltip = createTooltip(container, palette, isDark);
|
|
2703
2843
|
|
|
2704
2844
|
const width = exportDims?.width ?? container.clientWidth;
|
|
@@ -2719,7 +2859,63 @@ export function renderTimeline(
|
|
|
2719
2859
|
groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]);
|
|
2720
2860
|
});
|
|
2721
2861
|
|
|
2862
|
+
// When tag-based swimlanes are active, compute lanes from tag values
|
|
2863
|
+
// and populate groupColorMap with tag entry colors for lane headers.
|
|
2864
|
+
type Lane = { name: string; events: TimelineEvent[] };
|
|
2865
|
+
let tagLanes: Lane[] | null = null;
|
|
2866
|
+
|
|
2867
|
+
if (swimlaneTagGroup) {
|
|
2868
|
+
const tagKey = swimlaneTagGroup.toLowerCase();
|
|
2869
|
+
const tagGroup = parsed.timelineTagGroups.find(
|
|
2870
|
+
(g) => g.name.toLowerCase() === tagKey
|
|
2871
|
+
);
|
|
2872
|
+
if (tagGroup) {
|
|
2873
|
+
// Collect events per tag value
|
|
2874
|
+
const buckets = new Map<string, TimelineEvent[]>();
|
|
2875
|
+
const otherEvents: TimelineEvent[] = [];
|
|
2876
|
+
for (const ev of timelineEvents) {
|
|
2877
|
+
const val = ev.metadata[tagKey];
|
|
2878
|
+
if (val) {
|
|
2879
|
+
const list = buckets.get(val) ?? [];
|
|
2880
|
+
list.push(ev);
|
|
2881
|
+
buckets.set(val, list);
|
|
2882
|
+
} else {
|
|
2883
|
+
otherEvents.push(ev);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
// Order lanes by earliest event date
|
|
2888
|
+
const laneEntries = [...buckets.entries()].sort((a, b) => {
|
|
2889
|
+
const aMin = Math.min(
|
|
2890
|
+
...a[1].map((e) => parseTimelineDate(e.date))
|
|
2891
|
+
);
|
|
2892
|
+
const bMin = Math.min(
|
|
2893
|
+
...b[1].map((e) => parseTimelineDate(e.date))
|
|
2894
|
+
);
|
|
2895
|
+
return aMin - bMin;
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
tagLanes = laneEntries.map(([name, events]) => ({ name, events }));
|
|
2899
|
+
if (otherEvents.length > 0) {
|
|
2900
|
+
tagLanes.push({ name: '(Other)', events: otherEvents });
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
// Populate groupColorMap from tag entry colors
|
|
2904
|
+
for (const entry of tagGroup.entries) {
|
|
2905
|
+
groupColorMap.set(entry.value, entry.color);
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// Determine effective color source: explicit colorTG > swimlaneTG > group
|
|
2911
|
+
const effectiveColorTG = activeTagGroup ?? swimlaneTagGroup ?? null;
|
|
2912
|
+
|
|
2722
2913
|
function eventColor(ev: TimelineEvent): string {
|
|
2914
|
+
// Tag color takes priority when a tag group is active
|
|
2915
|
+
if (effectiveColorTG) {
|
|
2916
|
+
const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups, effectiveColorTG);
|
|
2917
|
+
if (tagColor) return tagColor;
|
|
2918
|
+
}
|
|
2723
2919
|
if (ev.group && groupColorMap.has(ev.group)) {
|
|
2724
2920
|
return groupColorMap.get(ev.group)!;
|
|
2725
2921
|
}
|
|
@@ -2811,29 +3007,87 @@ export function renderTimeline(
|
|
|
2811
3007
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
2812
3008
|
) {
|
|
2813
3009
|
g.selectAll<SVGGElement, unknown>(
|
|
2814
|
-
'.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker'
|
|
3010
|
+
'.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker, .tl-tag-legend-entry'
|
|
2815
3011
|
).attr('opacity', 1);
|
|
2816
3012
|
g.selectAll<SVGGElement, unknown>('.tl-era').attr('opacity', 1);
|
|
2817
3013
|
}
|
|
2818
3014
|
|
|
3015
|
+
function fadeToTagValue(
|
|
3016
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3017
|
+
tagKey: string,
|
|
3018
|
+
tagValue: string
|
|
3019
|
+
) {
|
|
3020
|
+
const attrName = `data-tag-${tagKey}`;
|
|
3021
|
+
g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
3022
|
+
const el = d3Selection.select(this);
|
|
3023
|
+
const val = el.attr(attrName);
|
|
3024
|
+
el.attr('opacity', val === tagValue ? 1 : FADE_OPACITY);
|
|
3025
|
+
});
|
|
3026
|
+
g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(
|
|
3027
|
+
'opacity', FADE_OPACITY
|
|
3028
|
+
);
|
|
3029
|
+
g.selectAll<SVGGElement, unknown>('.tl-marker').attr('opacity', FADE_OPACITY);
|
|
3030
|
+
// Fade legend entry dots/labels that don't match (keep group pill visible)
|
|
3031
|
+
g.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
|
|
3032
|
+
const el = d3Selection.select(this);
|
|
3033
|
+
const entryValue = el.attr('data-legend-entry');
|
|
3034
|
+
if (entryValue === '__group__') return; // keep group pill at full opacity
|
|
3035
|
+
const entryGroup = el.attr('data-tag-group');
|
|
3036
|
+
el.attr('opacity', entryGroup === tagKey && entryValue === tagValue ? 1 : FADE_OPACITY);
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
/** Attach data-tag-* attributes on an event group element */
|
|
3041
|
+
function setTagAttrs(
|
|
3042
|
+
evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3043
|
+
ev: TimelineEvent
|
|
3044
|
+
) {
|
|
3045
|
+
for (const [key, value] of Object.entries(ev.metadata)) {
|
|
3046
|
+
evG.attr(`data-tag-${key}`, value.toLowerCase());
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
// Reserve space for tag legend between title and chart content
|
|
3051
|
+
const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
|
|
3052
|
+
|
|
2819
3053
|
// ================================================================
|
|
2820
3054
|
// VERTICAL orientation (time flows top→bottom)
|
|
2821
3055
|
// ================================================================
|
|
2822
3056
|
if (isVertical) {
|
|
2823
|
-
|
|
3057
|
+
const useGroupedVertical = tagLanes != null ||
|
|
3058
|
+
(timelineSort === 'group' && timelineGroups.length > 0);
|
|
3059
|
+
if (useGroupedVertical) {
|
|
2824
3060
|
// === GROUPED: one column/lane per group, vertical ===
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
)
|
|
2829
|
-
|
|
2830
|
-
|
|
3061
|
+
let laneNames: string[];
|
|
3062
|
+
let laneEventsByName: Map<string, TimelineEvent[]>;
|
|
3063
|
+
|
|
3064
|
+
if (tagLanes) {
|
|
3065
|
+
laneNames = tagLanes.map((l) => l.name);
|
|
3066
|
+
laneEventsByName = new Map(tagLanes.map((l) => [l.name, l.events]));
|
|
3067
|
+
} else {
|
|
3068
|
+
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
3069
|
+
const ungroupedEvents = timelineEvents.filter(
|
|
3070
|
+
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
3071
|
+
);
|
|
3072
|
+
laneNames =
|
|
3073
|
+
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
3074
|
+
laneEventsByName = new Map(
|
|
3075
|
+
laneNames.map((name) => [
|
|
3076
|
+
name,
|
|
3077
|
+
timelineEvents.filter((ev) =>
|
|
3078
|
+
name === '(Other)'
|
|
3079
|
+
? ev.group === null || !groupNames.includes(ev.group)
|
|
3080
|
+
: ev.group === name
|
|
3081
|
+
),
|
|
3082
|
+
])
|
|
3083
|
+
);
|
|
3084
|
+
}
|
|
2831
3085
|
|
|
2832
3086
|
const laneCount = laneNames.length;
|
|
2833
3087
|
const scaleMargin = timelineScale ? 40 : 0;
|
|
2834
3088
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
2835
3089
|
const margin = {
|
|
2836
|
-
top: 104 + markerMargin,
|
|
3090
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
2837
3091
|
right: 40 + scaleMargin,
|
|
2838
3092
|
bottom: 40,
|
|
2839
3093
|
left: 60 + scaleMargin,
|
|
@@ -2901,6 +3155,23 @@ export function renderTimeline(
|
|
|
2901
3155
|
);
|
|
2902
3156
|
}
|
|
2903
3157
|
|
|
3158
|
+
// Render swimlane backgrounds for vertical lanes
|
|
3159
|
+
if (timelineSwimlanes || tagLanes) {
|
|
3160
|
+
laneNames.forEach((laneName, laneIdx) => {
|
|
3161
|
+
const laneX = laneIdx * laneWidth;
|
|
3162
|
+
const fillColor = laneIdx % 2 === 0 ? textColor : 'transparent';
|
|
3163
|
+
g.append('rect')
|
|
3164
|
+
.attr('class', 'tl-swimlane')
|
|
3165
|
+
.attr('data-group', laneName)
|
|
3166
|
+
.attr('x', laneX)
|
|
3167
|
+
.attr('y', 0)
|
|
3168
|
+
.attr('width', laneWidth)
|
|
3169
|
+
.attr('height', innerHeight)
|
|
3170
|
+
.attr('fill', fillColor)
|
|
3171
|
+
.attr('opacity', 0.06);
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
|
|
2904
3175
|
laneNames.forEach((laneName, laneIdx) => {
|
|
2905
3176
|
const laneX = laneIdx * laneWidth;
|
|
2906
3177
|
const laneColor = groupColorMap.get(laneName) ?? textColor;
|
|
@@ -2933,11 +3204,7 @@ export function renderTimeline(
|
|
|
2933
3204
|
.attr('stroke-width', 1)
|
|
2934
3205
|
.attr('stroke-dasharray', '4,4');
|
|
2935
3206
|
|
|
2936
|
-
const laneEvents =
|
|
2937
|
-
laneName === '(Other)'
|
|
2938
|
-
? ev.group === null || !groupNames.includes(ev.group)
|
|
2939
|
-
: ev.group === laneName
|
|
2940
|
-
);
|
|
3207
|
+
const laneEvents = laneEventsByName.get(laneName) ?? [];
|
|
2941
3208
|
|
|
2942
3209
|
for (const ev of laneEvents) {
|
|
2943
3210
|
const y = yScale(parseTimelineDate(ev.date));
|
|
@@ -2966,12 +3233,15 @@ export function renderTimeline(
|
|
|
2966
3233
|
.on('click', () => {
|
|
2967
3234
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
2968
3235
|
});
|
|
3236
|
+
setTagAttrs(evG, ev);
|
|
3237
|
+
|
|
3238
|
+
const evColor = eventColor(ev);
|
|
2969
3239
|
|
|
2970
3240
|
if (ev.endDate) {
|
|
2971
3241
|
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
2972
3242
|
const rectH = Math.max(y2 - y, 4);
|
|
2973
3243
|
|
|
2974
|
-
let fill: string =
|
|
3244
|
+
let fill: string = evColor;
|
|
2975
3245
|
if (ev.uncertain) {
|
|
2976
3246
|
const gradientId = `uncertain-vg-${ev.lineNumber}`;
|
|
2977
3247
|
const defs =
|
|
@@ -3020,7 +3290,7 @@ export function renderTimeline(
|
|
|
3020
3290
|
.attr('cx', laneCenter)
|
|
3021
3291
|
.attr('cy', y)
|
|
3022
3292
|
.attr('r', 4)
|
|
3023
|
-
.attr('fill',
|
|
3293
|
+
.attr('fill', evColor)
|
|
3024
3294
|
.attr('stroke', bgColor)
|
|
3025
3295
|
.attr('stroke-width', 1.5);
|
|
3026
3296
|
evG
|
|
@@ -3039,7 +3309,7 @@ export function renderTimeline(
|
|
|
3039
3309
|
const scaleMargin = timelineScale ? 40 : 0;
|
|
3040
3310
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
3041
3311
|
const margin = {
|
|
3042
|
-
top: 104 + markerMargin,
|
|
3312
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
3043
3313
|
right: 200,
|
|
3044
3314
|
bottom: 40,
|
|
3045
3315
|
left: 60 + scaleMargin,
|
|
@@ -3183,6 +3453,7 @@ export function renderTimeline(
|
|
|
3183
3453
|
.on('click', () => {
|
|
3184
3454
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
3185
3455
|
});
|
|
3456
|
+
setTagAttrs(evG, ev);
|
|
3186
3457
|
|
|
3187
3458
|
if (ev.endDate) {
|
|
3188
3459
|
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
@@ -3285,24 +3556,30 @@ export function renderTimeline(
|
|
|
3285
3556
|
const BAR_H = 22; // range bar thickness (tall enough for text inside)
|
|
3286
3557
|
const GROUP_GAP = 12; // vertical gap between group swim-lanes
|
|
3287
3558
|
|
|
3288
|
-
|
|
3559
|
+
const useGroupedHorizontal = tagLanes != null ||
|
|
3560
|
+
(timelineSort === 'group' && timelineGroups.length > 0);
|
|
3561
|
+
if (useGroupedHorizontal) {
|
|
3289
3562
|
// === GROUPED: swim-lanes stacked vertically, events on own rows ===
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3563
|
+
let lanes: Lane[];
|
|
3564
|
+
|
|
3565
|
+
if (tagLanes) {
|
|
3566
|
+
lanes = tagLanes;
|
|
3567
|
+
} else {
|
|
3568
|
+
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
3569
|
+
const ungroupedEvents = timelineEvents.filter(
|
|
3570
|
+
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
3571
|
+
);
|
|
3572
|
+
const laneNames =
|
|
3573
|
+
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
3574
|
+
lanes = laneNames.map((name) => ({
|
|
3575
|
+
name,
|
|
3576
|
+
events: timelineEvents.filter((ev) =>
|
|
3577
|
+
name === '(Other)'
|
|
3578
|
+
? ev.group === null || !groupNames.includes(ev.group)
|
|
3579
|
+
: ev.group === name
|
|
3580
|
+
),
|
|
3581
|
+
}));
|
|
3582
|
+
}
|
|
3306
3583
|
|
|
3307
3584
|
const totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);
|
|
3308
3585
|
const scaleMargin = timelineScale ? 24 : 0;
|
|
@@ -3313,7 +3590,7 @@ export function renderTimeline(
|
|
|
3313
3590
|
// Group-sorted doesn't need legend space (group names shown on left)
|
|
3314
3591
|
const baseTopMargin = title ? 50 : 20;
|
|
3315
3592
|
const margin = {
|
|
3316
|
-
top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin,
|
|
3593
|
+
top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
|
|
3317
3594
|
right: 40,
|
|
3318
3595
|
bottom: 40 + scaleMargin,
|
|
3319
3596
|
left: dynamicLeftMargin,
|
|
@@ -3387,7 +3664,7 @@ export function renderTimeline(
|
|
|
3387
3664
|
|
|
3388
3665
|
// Render swimlane backgrounds first (so they appear behind events)
|
|
3389
3666
|
// Extend into left margin to include group names
|
|
3390
|
-
if (timelineSwimlanes) {
|
|
3667
|
+
if (timelineSwimlanes || tagLanes) {
|
|
3391
3668
|
let swimY = markerMargin;
|
|
3392
3669
|
lanes.forEach((lane, idx) => {
|
|
3393
3670
|
const laneSpan = lane.events.length * rowH;
|
|
@@ -3480,6 +3757,9 @@ export function renderTimeline(
|
|
|
3480
3757
|
.on('click', () => {
|
|
3481
3758
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
3482
3759
|
});
|
|
3760
|
+
setTagAttrs(evG, ev);
|
|
3761
|
+
|
|
3762
|
+
const evColor = eventColor(ev);
|
|
3483
3763
|
|
|
3484
3764
|
if (ev.endDate) {
|
|
3485
3765
|
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
@@ -3488,7 +3768,7 @@ export function renderTimeline(
|
|
|
3488
3768
|
const estLabelWidth = ev.label.length * 7 + 16;
|
|
3489
3769
|
const labelFitsInside = rectW >= estLabelWidth;
|
|
3490
3770
|
|
|
3491
|
-
let fill: string =
|
|
3771
|
+
let fill: string = evColor;
|
|
3492
3772
|
if (ev.uncertain) {
|
|
3493
3773
|
// Create gradient for uncertain end - fades last 20%
|
|
3494
3774
|
const gradientId = `uncertain-${ev.lineNumber}`;
|
|
@@ -3510,7 +3790,7 @@ export function renderTimeline(
|
|
|
3510
3790
|
.enter()
|
|
3511
3791
|
.append('stop')
|
|
3512
3792
|
.attr('offset', (d) => d.offset)
|
|
3513
|
-
.attr('stop-color',
|
|
3793
|
+
.attr('stop-color', evColor)
|
|
3514
3794
|
.attr('stop-opacity', (d) => d.opacity);
|
|
3515
3795
|
fill = `url(#${gradientId})`;
|
|
3516
3796
|
}
|
|
@@ -3563,7 +3843,7 @@ export function renderTimeline(
|
|
|
3563
3843
|
.attr('cx', x)
|
|
3564
3844
|
.attr('cy', y)
|
|
3565
3845
|
.attr('r', 5)
|
|
3566
|
-
.attr('fill',
|
|
3846
|
+
.attr('fill', evColor)
|
|
3567
3847
|
.attr('stroke', bgColor)
|
|
3568
3848
|
.attr('stroke-width', 1.5);
|
|
3569
3849
|
evG
|
|
@@ -3589,7 +3869,7 @@ export function renderTimeline(
|
|
|
3589
3869
|
const scaleMargin = timelineScale ? 24 : 0;
|
|
3590
3870
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
3591
3871
|
const margin = {
|
|
3592
|
-
top: 104 + (timelineScale ? 40 : 0) + markerMargin,
|
|
3872
|
+
top: 104 + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
|
|
3593
3873
|
right: 40,
|
|
3594
3874
|
bottom: 40 + scaleMargin,
|
|
3595
3875
|
left: 60,
|
|
@@ -3739,6 +4019,7 @@ export function renderTimeline(
|
|
|
3739
4019
|
.on('click', () => {
|
|
3740
4020
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
3741
4021
|
});
|
|
4022
|
+
setTagAttrs(evG, ev);
|
|
3742
4023
|
|
|
3743
4024
|
if (ev.endDate) {
|
|
3744
4025
|
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
@@ -3837,6 +4118,283 @@ export function renderTimeline(
|
|
|
3837
4118
|
}
|
|
3838
4119
|
});
|
|
3839
4120
|
}
|
|
4121
|
+
|
|
4122
|
+
// ── Tag Legend (org-chart-style pills) ──
|
|
4123
|
+
if (parsed.timelineTagGroups.length > 0) {
|
|
4124
|
+
const LG_HEIGHT = 28;
|
|
4125
|
+
const LG_PILL_PAD = 16;
|
|
4126
|
+
const LG_PILL_FONT_SIZE = 11;
|
|
4127
|
+
const LG_PILL_FONT_W = LG_PILL_FONT_SIZE * 0.6;
|
|
4128
|
+
const LG_CAPSULE_PAD = 4;
|
|
4129
|
+
const LG_DOT_R = 4;
|
|
4130
|
+
const LG_ENTRY_FONT_SIZE = 10;
|
|
4131
|
+
const LG_ENTRY_FONT_W = LG_ENTRY_FONT_SIZE * 0.6;
|
|
4132
|
+
const LG_ENTRY_DOT_GAP = 4;
|
|
4133
|
+
const LG_ENTRY_TRAIL = 8;
|
|
4134
|
+
const LG_GROUP_GAP = 12;
|
|
4135
|
+
const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space)
|
|
4136
|
+
|
|
4137
|
+
const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
|
|
4138
|
+
const mainG = mainSvg.select<SVGGElement>('g');
|
|
4139
|
+
if (!mainSvg.empty() && !mainG.empty()) {
|
|
4140
|
+
const legendY = title ? 50 : 10;
|
|
4141
|
+
|
|
4142
|
+
const groupBg = isDark
|
|
4143
|
+
? mix(palette.surface, palette.bg, 50)
|
|
4144
|
+
: mix(palette.surface, palette.bg, 30);
|
|
4145
|
+
|
|
4146
|
+
// Pre-compute group widths (minified and expanded)
|
|
4147
|
+
type LegendGroup = {
|
|
4148
|
+
group: TagGroup;
|
|
4149
|
+
minifiedWidth: number;
|
|
4150
|
+
expandedWidth: number;
|
|
4151
|
+
};
|
|
4152
|
+
const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
|
|
4153
|
+
const pillW = g.name.length * LG_PILL_FONT_W + LG_PILL_PAD;
|
|
4154
|
+
// Expanded: pill + icon + entries
|
|
4155
|
+
let entryX = LG_CAPSULE_PAD + pillW + LG_ICON_W + 4;
|
|
4156
|
+
for (const entry of g.entries) {
|
|
4157
|
+
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
4158
|
+
entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
|
|
4159
|
+
}
|
|
4160
|
+
return {
|
|
4161
|
+
group: g,
|
|
4162
|
+
minifiedWidth: pillW,
|
|
4163
|
+
expandedWidth: entryX + LG_CAPSULE_PAD,
|
|
4164
|
+
};
|
|
4165
|
+
});
|
|
4166
|
+
|
|
4167
|
+
// Two independent state axes: swimlane source + color source
|
|
4168
|
+
let currentActiveGroup: string | null = activeTagGroup ?? null;
|
|
4169
|
+
let currentSwimlaneGroup: string | null = swimlaneTagGroup ?? null;
|
|
4170
|
+
|
|
4171
|
+
/** Render the swimlane icon (3 horizontal bars of varying width) */
|
|
4172
|
+
function drawSwimlaneIcon(
|
|
4173
|
+
parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
4174
|
+
x: number,
|
|
4175
|
+
y: number,
|
|
4176
|
+
isSwimActive: boolean
|
|
4177
|
+
) {
|
|
4178
|
+
const iconG = parent.append('g')
|
|
4179
|
+
.attr('class', 'tl-swimlane-icon')
|
|
4180
|
+
.attr('transform', `translate(${x}, ${y})`)
|
|
4181
|
+
.style('cursor', 'pointer');
|
|
4182
|
+
|
|
4183
|
+
const barColor = isSwimActive ? palette.primary : palette.textMuted;
|
|
4184
|
+
const barOpacity = isSwimActive ? 1 : 0.35;
|
|
4185
|
+
const bars = [
|
|
4186
|
+
{ y: 0, w: 8 },
|
|
4187
|
+
{ y: 4, w: 12 },
|
|
4188
|
+
{ y: 8, w: 6 },
|
|
4189
|
+
];
|
|
4190
|
+
for (const bar of bars) {
|
|
4191
|
+
iconG.append('rect')
|
|
4192
|
+
.attr('x', 0)
|
|
4193
|
+
.attr('y', bar.y)
|
|
4194
|
+
.attr('width', bar.w)
|
|
4195
|
+
.attr('height', 2)
|
|
4196
|
+
.attr('rx', 1)
|
|
4197
|
+
.attr('fill', barColor)
|
|
4198
|
+
.attr('opacity', barOpacity);
|
|
4199
|
+
}
|
|
4200
|
+
return iconG;
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
/** Full re-render with updated swimlane state */
|
|
4204
|
+
function relayout() {
|
|
4205
|
+
renderTimeline(
|
|
4206
|
+
container, parsed, palette, isDark, onClickItem, exportDims,
|
|
4207
|
+
currentActiveGroup, currentSwimlaneGroup, onTagStateChange
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
function drawLegend() {
|
|
4212
|
+
// Remove previous legend
|
|
4213
|
+
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
4214
|
+
|
|
4215
|
+
// Compute total width and center horizontally in SVG
|
|
4216
|
+
const totalW = legendGroups.reduce((s, lg) => {
|
|
4217
|
+
const isActive = currentActiveGroup != null &&
|
|
4218
|
+
lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
|
|
4219
|
+
return s + (isActive ? lg.expandedWidth : lg.minifiedWidth);
|
|
4220
|
+
}, 0) + (legendGroups.length - 1) * LG_GROUP_GAP;
|
|
4221
|
+
|
|
4222
|
+
let cx = (width - totalW) / 2;
|
|
4223
|
+
|
|
4224
|
+
for (const lg of legendGroups) {
|
|
4225
|
+
const groupKey = lg.group.name.toLowerCase();
|
|
4226
|
+
const isActive = currentActiveGroup != null &&
|
|
4227
|
+
currentActiveGroup.toLowerCase() === groupKey;
|
|
4228
|
+
const isSwimActive = currentSwimlaneGroup != null &&
|
|
4229
|
+
currentSwimlaneGroup.toLowerCase() === groupKey;
|
|
4230
|
+
|
|
4231
|
+
const pillLabel = lg.group.name;
|
|
4232
|
+
const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
|
|
4233
|
+
|
|
4234
|
+
const gEl = mainSvg
|
|
4235
|
+
.append('g')
|
|
4236
|
+
.attr('transform', `translate(${cx}, ${legendY})`)
|
|
4237
|
+
.attr('class', 'tl-tag-legend-group tl-tag-legend-entry')
|
|
4238
|
+
.attr('data-legend-group', groupKey)
|
|
4239
|
+
.attr('data-tag-group', groupKey)
|
|
4240
|
+
.attr('data-legend-entry', '__group__')
|
|
4241
|
+
.style('cursor', 'pointer')
|
|
4242
|
+
.on('click', () => {
|
|
4243
|
+
currentActiveGroup = currentActiveGroup === groupKey ? null : groupKey;
|
|
4244
|
+
drawLegend();
|
|
4245
|
+
recolorEvents();
|
|
4246
|
+
onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
|
|
4247
|
+
});
|
|
4248
|
+
|
|
4249
|
+
// Outer capsule background (active only)
|
|
4250
|
+
if (isActive) {
|
|
4251
|
+
gEl.append('rect')
|
|
4252
|
+
.attr('width', lg.expandedWidth)
|
|
4253
|
+
.attr('height', LG_HEIGHT)
|
|
4254
|
+
.attr('rx', LG_HEIGHT / 2)
|
|
4255
|
+
.attr('fill', groupBg);
|
|
4256
|
+
}
|
|
4257
|
+
|
|
4258
|
+
const pillXOff = isActive ? LG_CAPSULE_PAD : 0;
|
|
4259
|
+
const pillYOff = isActive ? LG_CAPSULE_PAD : 0;
|
|
4260
|
+
const pillH = LG_HEIGHT - (isActive ? LG_CAPSULE_PAD * 2 : 0);
|
|
4261
|
+
|
|
4262
|
+
// Pill background
|
|
4263
|
+
gEl.append('rect')
|
|
4264
|
+
.attr('x', pillXOff)
|
|
4265
|
+
.attr('y', pillYOff)
|
|
4266
|
+
.attr('width', pillWidth)
|
|
4267
|
+
.attr('height', pillH)
|
|
4268
|
+
.attr('rx', pillH / 2)
|
|
4269
|
+
.attr('fill', isActive ? palette.bg : groupBg);
|
|
4270
|
+
|
|
4271
|
+
// Active pill border
|
|
4272
|
+
if (isActive) {
|
|
4273
|
+
gEl.append('rect')
|
|
4274
|
+
.attr('x', pillXOff)
|
|
4275
|
+
.attr('y', pillYOff)
|
|
4276
|
+
.attr('width', pillWidth)
|
|
4277
|
+
.attr('height', pillH)
|
|
4278
|
+
.attr('rx', pillH / 2)
|
|
4279
|
+
.attr('fill', 'none')
|
|
4280
|
+
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
4281
|
+
.attr('stroke-width', 0.75);
|
|
4282
|
+
}
|
|
4283
|
+
|
|
4284
|
+
// Pill text
|
|
4285
|
+
gEl.append('text')
|
|
4286
|
+
.attr('x', pillXOff + pillWidth / 2)
|
|
4287
|
+
.attr('y', LG_HEIGHT / 2 + LG_PILL_FONT_SIZE / 2 - 2)
|
|
4288
|
+
.attr('font-size', LG_PILL_FONT_SIZE)
|
|
4289
|
+
.attr('font-weight', '500')
|
|
4290
|
+
.attr('font-family', FONT_FAMILY)
|
|
4291
|
+
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
4292
|
+
.attr('text-anchor', 'middle')
|
|
4293
|
+
.text(pillLabel);
|
|
4294
|
+
|
|
4295
|
+
// Entries + swimlane icon inside capsule (active only)
|
|
4296
|
+
if (isActive) {
|
|
4297
|
+
// Swimlane icon right after the pill label, with breathing room
|
|
4298
|
+
const iconX = pillXOff + pillWidth + 5;
|
|
4299
|
+
const iconY = (LG_HEIGHT - 10) / 2; // vertically centered
|
|
4300
|
+
const iconEl = drawSwimlaneIcon(gEl, iconX, iconY, isSwimActive);
|
|
4301
|
+
iconEl
|
|
4302
|
+
.attr('data-swimlane-toggle', groupKey)
|
|
4303
|
+
.on('click', (event: MouseEvent) => {
|
|
4304
|
+
event.stopPropagation();
|
|
4305
|
+
currentSwimlaneGroup = currentSwimlaneGroup === groupKey ? null : groupKey;
|
|
4306
|
+
onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
|
|
4307
|
+
relayout();
|
|
4308
|
+
});
|
|
4309
|
+
|
|
4310
|
+
let entryX = pillXOff + pillWidth + LG_ICON_W + 4;
|
|
4311
|
+
for (const entry of lg.group.entries) {
|
|
4312
|
+
const tagKey = lg.group.name.toLowerCase();
|
|
4313
|
+
const tagVal = entry.value.toLowerCase();
|
|
4314
|
+
|
|
4315
|
+
const entryG = gEl.append('g')
|
|
4316
|
+
.attr('class', 'tl-tag-legend-entry')
|
|
4317
|
+
.attr('data-tag-group', tagKey)
|
|
4318
|
+
.attr('data-legend-entry', tagVal)
|
|
4319
|
+
.style('cursor', 'pointer')
|
|
4320
|
+
.on('mouseenter', (event: MouseEvent) => {
|
|
4321
|
+
event.stopPropagation();
|
|
4322
|
+
fadeToTagValue(mainG, tagKey, tagVal);
|
|
4323
|
+
// Also fade legend entries on the SVG level
|
|
4324
|
+
mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
|
|
4325
|
+
const el = d3Selection.select(this);
|
|
4326
|
+
const ev = el.attr('data-legend-entry');
|
|
4327
|
+
if (ev === '__group__') return;
|
|
4328
|
+
const eg = el.attr('data-tag-group');
|
|
4329
|
+
el.attr('opacity', eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY);
|
|
4330
|
+
});
|
|
4331
|
+
})
|
|
4332
|
+
.on('mouseleave', (event: MouseEvent) => {
|
|
4333
|
+
event.stopPropagation();
|
|
4334
|
+
fadeReset(mainG);
|
|
4335
|
+
mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry')
|
|
4336
|
+
.attr('opacity', 1);
|
|
4337
|
+
})
|
|
4338
|
+
.on('click', (event: MouseEvent) => {
|
|
4339
|
+
event.stopPropagation(); // don't toggle group
|
|
4340
|
+
});
|
|
4341
|
+
|
|
4342
|
+
entryG.append('circle')
|
|
4343
|
+
.attr('cx', entryX + LG_DOT_R)
|
|
4344
|
+
.attr('cy', LG_HEIGHT / 2)
|
|
4345
|
+
.attr('r', LG_DOT_R)
|
|
4346
|
+
.attr('fill', entry.color);
|
|
4347
|
+
|
|
4348
|
+
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
4349
|
+
entryG.append('text')
|
|
4350
|
+
.attr('x', textX)
|
|
4351
|
+
.attr('y', LG_HEIGHT / 2 + LG_ENTRY_FONT_SIZE / 2 - 1)
|
|
4352
|
+
.attr('font-size', LG_ENTRY_FONT_SIZE)
|
|
4353
|
+
.attr('font-family', FONT_FAMILY)
|
|
4354
|
+
.attr('fill', palette.textMuted)
|
|
4355
|
+
.text(entry.value);
|
|
4356
|
+
|
|
4357
|
+
entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
|
|
4358
|
+
}
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
cx += (isActive ? lg.expandedWidth : lg.minifiedWidth) + LG_GROUP_GAP;
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
// Build a quick lineNumber→event lookup
|
|
4366
|
+
const eventByLine = new Map<string, TimelineEvent>();
|
|
4367
|
+
for (const ev of timelineEvents) {
|
|
4368
|
+
eventByLine.set(String(ev.lineNumber), ev);
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
function recolorEvents() {
|
|
4372
|
+
const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;
|
|
4373
|
+
mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
4374
|
+
const el = d3Selection.select(this);
|
|
4375
|
+
const lineNum = el.attr('data-line-number');
|
|
4376
|
+
const ev = lineNum ? eventByLine.get(lineNum) : undefined;
|
|
4377
|
+
if (!ev) return;
|
|
4378
|
+
|
|
4379
|
+
let color: string;
|
|
4380
|
+
if (colorTG) {
|
|
4381
|
+
const tagColor = resolveTagColor(
|
|
4382
|
+
ev.metadata, parsed.timelineTagGroups, colorTG
|
|
4383
|
+
);
|
|
4384
|
+
color = tagColor ?? (ev.group && groupColorMap.has(ev.group)
|
|
4385
|
+
? groupColorMap.get(ev.group)! : textColor);
|
|
4386
|
+
} else {
|
|
4387
|
+
color = ev.group && groupColorMap.has(ev.group)
|
|
4388
|
+
? groupColorMap.get(ev.group)! : textColor;
|
|
4389
|
+
}
|
|
4390
|
+
el.selectAll('rect').attr('fill', color);
|
|
4391
|
+
el.selectAll('circle:not(.tl-event-point-outline)').attr('fill', color);
|
|
4392
|
+
});
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
drawLegend();
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
3840
4398
|
}
|
|
3841
4399
|
|
|
3842
4400
|
// ============================================================
|
|
@@ -5158,6 +5716,7 @@ export async function renderD3ForExport(
|
|
|
5158
5716
|
collapsedNodes?: Set<string>;
|
|
5159
5717
|
activeTagGroup?: string | null;
|
|
5160
5718
|
hiddenAttributes?: Set<string>;
|
|
5719
|
+
swimlaneTagGroup?: string | null;
|
|
5161
5720
|
},
|
|
5162
5721
|
options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string; scenario?: string }
|
|
5163
5722
|
): Promise<string> {
|
|
@@ -5463,7 +6022,8 @@ export async function renderD3ForExport(
|
|
|
5463
6022
|
} else if (parsed.type === 'arc') {
|
|
5464
6023
|
renderArcDiagram(container, parsed, effectivePalette, isDark, undefined, dims);
|
|
5465
6024
|
} else if (parsed.type === 'timeline') {
|
|
5466
|
-
renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims
|
|
6025
|
+
renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims,
|
|
6026
|
+
orgExportState?.activeTagGroup, orgExportState?.swimlaneTagGroup);
|
|
5467
6027
|
} else if (parsed.type === 'venn') {
|
|
5468
6028
|
renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
|
|
5469
6029
|
} else if (parsed.type === 'quadrant') {
|