@diagrammo/dgmo 0.15.0 → 0.16.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/README.md +23 -10
- package/dist/advanced.cjs +53094 -0
- package/dist/advanced.d.cts +4690 -0
- package/dist/advanced.d.ts +4690 -0
- package/dist/advanced.js +52849 -0
- package/dist/auto.cjs +2298 -2069
- package/dist/auto.js +132 -109
- package/dist/auto.mjs +2294 -2065
- package/dist/cli.cjs +175 -152
- package/dist/editor.cjs +8 -9
- package/dist/editor.js +8 -9
- package/dist/highlight.cjs +8 -9
- package/dist/highlight.js +8 -9
- package/dist/index.cjs +2281 -2048
- package/dist/index.d.cts +45 -1
- package/dist/index.d.ts +45 -1
- package/dist/index.js +2276 -2044
- package/dist/internal.cjs +2064 -1831
- package/dist/internal.d.cts +113 -113
- package/dist/internal.d.ts +113 -113
- package/dist/internal.js +2059 -1826
- package/dist/pert.cjs +325 -0
- package/dist/pert.d.cts +542 -0
- package/dist/pert.d.ts +542 -0
- package/dist/pert.js +294 -0
- package/docs/language-reference.md +83 -66
- package/gallery/fixtures/area.dgmo +3 -3
- package/gallery/fixtures/bar-stacked.dgmo +5 -5
- package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
- package/gallery/fixtures/c4-full.dgmo +8 -8
- package/gallery/fixtures/class-full.dgmo +2 -2
- package/gallery/fixtures/doughnut.dgmo +6 -6
- package/gallery/fixtures/flowchart-colors.dgmo +3 -3
- package/gallery/fixtures/function.dgmo +3 -3
- package/gallery/fixtures/gantt-full.dgmo +9 -9
- package/gallery/fixtures/gantt.dgmo +7 -7
- package/gallery/fixtures/infra-full.dgmo +6 -6
- package/gallery/fixtures/infra.dgmo +2 -2
- package/gallery/fixtures/kanban.dgmo +9 -9
- package/gallery/fixtures/line.dgmo +2 -2
- package/gallery/fixtures/multi-line.dgmo +3 -3
- package/gallery/fixtures/org-full.dgmo +6 -6
- package/gallery/fixtures/quadrant.dgmo +2 -2
- package/gallery/fixtures/sankey.dgmo +9 -9
- package/gallery/fixtures/scatter.dgmo +3 -3
- package/gallery/fixtures/sequence-tags-protocols.dgmo +8 -8
- package/gallery/fixtures/sequence-tags.dgmo +7 -7
- package/gallery/fixtures/sitemap-full.dgmo +7 -7
- package/gallery/fixtures/slope.dgmo +5 -5
- package/gallery/fixtures/spr-eras.dgmo +9 -9
- package/gallery/fixtures/timeline.dgmo +3 -3
- package/gallery/fixtures/venn.dgmo +3 -3
- package/package.json +28 -3
- package/src/advanced.ts +730 -0
- package/src/auto/index.ts +14 -13
- package/src/boxes-and-lines/layout.ts +481 -445
- package/src/boxes-and-lines/renderer.ts +5 -1
- package/src/c4/parser.ts +8 -8
- package/src/c4/renderer.ts +15 -8
- package/src/chart-types.ts +0 -5
- package/src/chart.ts +18 -9
- package/src/class/parser.ts +8 -15
- package/src/class/renderer.ts +17 -6
- package/src/cli.ts +15 -13
- package/src/completion-types.ts +28 -0
- package/src/completion.ts +28 -21
- package/src/cycle/layout.ts +2 -2
- package/src/cycle/parser.ts +14 -0
- package/src/cycle/renderer.ts +6 -3
- package/src/d3.ts +1537 -1164
- package/src/echarts.ts +37 -20
- package/src/editor/dgmo.grammar +1 -3
- package/src/editor/dgmo.grammar.js +8 -8
- package/src/editor/dgmo.grammar.terms.js +11 -12
- package/src/editor/highlight-api.ts +0 -1
- package/src/editor/highlight.ts +0 -1
- package/src/er/parser.ts +19 -20
- package/src/er/renderer.ts +20 -8
- package/src/gantt/calculator.ts +1 -11
- package/src/gantt/parser.ts +17 -17
- package/src/gantt/renderer.ts +9 -6
- package/src/graph/flowchart-parser.ts +19 -85
- package/src/graph/flowchart-renderer.ts +4 -9
- package/src/graph/layout.ts +0 -2
- package/src/graph/state-parser.ts +17 -62
- package/src/graph/state-renderer.ts +4 -9
- package/src/index.ts +17 -1
- package/src/infra/parser.ts +40 -30
- package/src/infra/renderer.ts +9 -6
- package/src/internal.ts +9 -721
- package/src/journey-map/parser.ts +10 -3
- package/src/journey-map/renderer.ts +3 -1
- package/src/kanban/parser.ts +12 -8
- package/src/kanban/renderer.ts +3 -1
- package/src/mindmap/layout.ts +1 -1
- package/src/mindmap/parser.ts +3 -3
- package/src/mindmap/renderer.ts +2 -1
- package/src/org/parser.ts +3 -3
- package/src/org/renderer.ts +5 -4
- package/src/pert/layout.ts +1 -1
- package/src/pert/monte-carlo.ts +2 -2
- package/src/pert/parser.ts +10 -10
- package/src/pert/renderer.ts +7 -2
- package/src/pert/types.ts +1 -1
- package/src/pyramid/parser.ts +12 -0
- package/src/raci/parser.ts +44 -14
- package/src/raci/renderer.ts +3 -2
- package/src/raci/types.ts +4 -3
- package/src/ring/parser.ts +12 -0
- package/src/sequence/parser.ts +15 -9
- package/src/sequence/renderer.ts +2 -5
- package/src/sitemap/layout.ts +0 -2
- package/src/sitemap/parser.ts +12 -38
- package/src/sitemap/renderer.ts +13 -13
- package/src/sitemap/types.ts +0 -1
- package/src/tech-radar/interactive.ts +1 -1
- package/src/tech-radar/renderer.ts +6 -4
- package/src/tech-radar/types.ts +2 -0
- package/src/utils/arrows.ts +3 -28
- package/src/utils/legend-d3.ts +12 -6
- package/src/utils/legend-layout.ts +1 -1
- package/src/utils/legend-types.ts +1 -1
- package/src/utils/parsing.ts +64 -35
- package/src/utils/tag-groups.ts +109 -30
- package/src/wireframe/layout.ts +11 -7
- package/src/wireframe/parser.ts +4 -4
- package/src/wireframe/renderer.ts +5 -2
package/src/d3.ts
CHANGED
|
@@ -205,6 +205,7 @@ import {
|
|
|
205
205
|
normalizeNumericToken,
|
|
206
206
|
parseFirstLine,
|
|
207
207
|
parsePipeMetadata,
|
|
208
|
+
peelTrailingColorName,
|
|
208
209
|
MULTIPLE_PIPE_ERROR,
|
|
209
210
|
} from './utils/parsing';
|
|
210
211
|
import {
|
|
@@ -593,8 +594,12 @@ export function parseVisualization(
|
|
|
593
594
|
currentTimelineTagGroup = null;
|
|
594
595
|
}
|
|
595
596
|
|
|
596
|
-
// [Group] container headers for arc
|
|
597
|
-
|
|
597
|
+
// [Group] container headers for arc / timeline (§1.5 trailing-token):
|
|
598
|
+
// `[Group]` — no color
|
|
599
|
+
// `[Group] color` — trailing-token color (recognized palette word)
|
|
600
|
+
const groupMatch = line.match(
|
|
601
|
+
/^\[(.+?)\](?:\s+(red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white))?\s*$/
|
|
602
|
+
);
|
|
598
603
|
if (groupMatch) {
|
|
599
604
|
if (result.type === 'arc') {
|
|
600
605
|
const name = groupMatch[1].trim();
|
|
@@ -649,10 +654,11 @@ export function parseVisualization(
|
|
|
649
654
|
currentTimelineGroup = null;
|
|
650
655
|
}
|
|
651
656
|
|
|
652
|
-
// Arc link line
|
|
657
|
+
// Arc link line (§1.5 trailing-token):
|
|
658
|
+
// `source -> target [color] [weight]` — color before weight
|
|
653
659
|
if (result.type === 'arc') {
|
|
654
660
|
const linkMatch = line.match(
|
|
655
|
-
/^(.+?)\s*->\s*(.+?)(?:\(
|
|
661
|
+
/^(.+?)\s*->\s*(.+?)(?:\s+(red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white))?(?:\s+(-?[\d,_]+(?:\.[\d]+)?))?$/
|
|
656
662
|
);
|
|
657
663
|
if (linkMatch) {
|
|
658
664
|
const source = linkMatch[1].trim();
|
|
@@ -698,8 +704,12 @@ export function parseVisualization(
|
|
|
698
704
|
// fall through to process this line normally
|
|
699
705
|
} else {
|
|
700
706
|
if (line.startsWith('//')) continue;
|
|
707
|
+
// Timeline era block entry (\u00a71.5 trailing-token):
|
|
708
|
+
// `<start> -> <end> Label` (no color)
|
|
709
|
+
// `<start> -> <end> Label color` (trailing color word)
|
|
710
|
+
// Color (group 4) must be a recognized lowercase palette word.
|
|
701
711
|
const eraEntryMatch = line.match(
|
|
702
|
-
/^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s
|
|
712
|
+
/^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s+(red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white))?\s*$/
|
|
703
713
|
);
|
|
704
714
|
if (eraEntryMatch) {
|
|
705
715
|
const colorAnnotation = eraEntryMatch[4]?.trim() || null;
|
|
@@ -731,8 +741,9 @@ export function parseVisualization(
|
|
|
731
741
|
// fall through to process this line normally
|
|
732
742
|
} else {
|
|
733
743
|
if (line.startsWith('//')) continue;
|
|
744
|
+
// Timeline marker block entry (§1.5 trailing-token).
|
|
734
745
|
const markerEntryMatch = line.match(
|
|
735
|
-
/^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s
|
|
746
|
+
/^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s+(red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white))?\s*$/
|
|
736
747
|
);
|
|
737
748
|
if (markerEntryMatch) {
|
|
738
749
|
const colorAnnotation = markerEntryMatch[3]?.trim() || null;
|
|
@@ -772,9 +783,11 @@ export function parseVisualization(
|
|
|
772
783
|
continue;
|
|
773
784
|
}
|
|
774
785
|
|
|
775
|
-
// Timeline era lines
|
|
786
|
+
// Timeline era lines, inline (\u00a71.5 trailing-token):
|
|
787
|
+
// `era YYYY->YYYY Label` \u2014 no color
|
|
788
|
+
// `era YYYY->YYYY Label color` \u2014 trailing-token color (recognized palette word)
|
|
776
789
|
const eraMatch = line.match(
|
|
777
|
-
/^era\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s
|
|
790
|
+
/^era\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s+(red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white))?\s*$/
|
|
778
791
|
);
|
|
779
792
|
if (eraMatch) {
|
|
780
793
|
const colorAnnotation = eraMatch[4]?.trim() || null;
|
|
@@ -795,9 +808,11 @@ export function parseVisualization(
|
|
|
795
808
|
continue;
|
|
796
809
|
}
|
|
797
810
|
|
|
798
|
-
// Timeline marker lines
|
|
811
|
+
// Timeline marker lines, inline (§1.5 trailing-token):
|
|
812
|
+
// `marker YYYY Label` — no color
|
|
813
|
+
// `marker YYYY Label color` — trailing-token color
|
|
799
814
|
const markerMatch = line.match(
|
|
800
|
-
/^marker\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s
|
|
815
|
+
/^marker\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s+(red|orange|yellow|green|blue|purple|teal|cyan|gray|black|white))?\s*$/
|
|
801
816
|
);
|
|
802
817
|
if (markerMatch) {
|
|
803
818
|
const colorAnnotation = markerMatch[3]?.trim() || null;
|
|
@@ -959,19 +974,20 @@ export function parseVisualization(
|
|
|
959
974
|
}
|
|
960
975
|
}
|
|
961
976
|
|
|
962
|
-
// Set declaration
|
|
963
|
-
//
|
|
977
|
+
// Set declaration (§1.5 universal trailing-token):
|
|
978
|
+
// `Name` / `Name color` / `Name as <alias>` / `Name color as <alias>`
|
|
979
|
+
// Legacy `Name color alias <token>` emits E_VENN_ALIAS_KEYWORD_REMOVED.
|
|
964
980
|
// Only attempt set parsing if the line wasn't a bare-keyword option (handled above).
|
|
965
981
|
if (!/^(solid-fill|no-name|no-value|no-percent|no-title)$/i.test(line)) {
|
|
966
982
|
// Detect legacy `alias` keyword first — graceful degradation parses
|
|
967
983
|
// the rest of the line so the set still appears.
|
|
968
|
-
const legacyAliasMatch = line.match(
|
|
969
|
-
/^([^(:]+?)(?:\(([^)]+)\))?\s+alias\s+(\S+)\s*$/i
|
|
970
|
-
);
|
|
984
|
+
const legacyAliasMatch = line.match(/^(.+?)\s+alias\s+(\S+)\s*$/i);
|
|
971
985
|
if (legacyAliasMatch) {
|
|
972
|
-
const
|
|
973
|
-
const
|
|
974
|
-
|
|
986
|
+
const nameWithMaybeColor = legacyAliasMatch[1].trim();
|
|
987
|
+
const aliasToken = legacyAliasMatch[2].trim();
|
|
988
|
+
// Split off trailing-token color from the name region.
|
|
989
|
+
const { label: name, colorName } =
|
|
990
|
+
peelTrailingColorName(nameWithMaybeColor);
|
|
975
991
|
let color: string | null = null;
|
|
976
992
|
if (colorName) {
|
|
977
993
|
color =
|
|
@@ -995,11 +1011,14 @@ export function parseVisualization(
|
|
|
995
1011
|
}
|
|
996
1012
|
|
|
997
1013
|
const setDeclMatch = line.match(
|
|
998
|
-
/^(
|
|
1014
|
+
/^(.+?)(?:\s+as\s+([A-Za-z][A-Za-z0-9_]{0,11}))?\s*$/i
|
|
999
1015
|
);
|
|
1000
1016
|
if (setDeclMatch) {
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1017
|
+
const nameWithMaybeColor = setDeclMatch[1].trim();
|
|
1018
|
+
const alias = setDeclMatch[2]?.trim() ?? null;
|
|
1019
|
+
// Split off trailing-token color from the name region (before `as`).
|
|
1020
|
+
const { label: name, colorName } =
|
|
1021
|
+
peelTrailingColorName(nameWithMaybeColor);
|
|
1003
1022
|
let color: string | null = null;
|
|
1004
1023
|
if (colorName) {
|
|
1005
1024
|
color =
|
|
@@ -1010,7 +1029,6 @@ export function parseVisualization(
|
|
|
1010
1029
|
palette
|
|
1011
1030
|
) ?? null;
|
|
1012
1031
|
}
|
|
1013
|
-
const alias = setDeclMatch[3]?.trim() ?? null;
|
|
1014
1032
|
result.vennSets.push({ name, alias, color, lineNumber });
|
|
1015
1033
|
continue;
|
|
1016
1034
|
}
|
|
@@ -1057,19 +1075,20 @@ export function parseVisualization(
|
|
|
1057
1075
|
continue;
|
|
1058
1076
|
}
|
|
1059
1077
|
|
|
1060
|
-
// Quadrant position labels
|
|
1078
|
+
// Quadrant position labels (§1.5 trailing-token):
|
|
1079
|
+
// `top-right Label` — no color
|
|
1080
|
+
// `top-right Label color` — trailing-token color (recognized palette word)
|
|
1061
1081
|
const quadrantLabelRe =
|
|
1062
1082
|
/^(top-right|top-left|bottom-left|bottom-right)\s+(.+)/i;
|
|
1063
1083
|
const quadrantMatch = line.match(quadrantLabelRe);
|
|
1064
1084
|
if (quadrantMatch) {
|
|
1065
1085
|
const position = quadrantMatch[1].toLowerCase();
|
|
1066
1086
|
const labelPart = quadrantMatch[2].trim();
|
|
1067
|
-
//
|
|
1068
|
-
const
|
|
1069
|
-
const
|
|
1070
|
-
const color = labelColorMatch
|
|
1087
|
+
// Peel trailing recognized color word from the label.
|
|
1088
|
+
const { label: text, colorName } = peelTrailingColorName(labelPart);
|
|
1089
|
+
const color = colorName
|
|
1071
1090
|
? (resolveColorWithDiagnostic(
|
|
1072
|
-
|
|
1091
|
+
colorName,
|
|
1073
1092
|
lineNumber,
|
|
1074
1093
|
result.diagnostics,
|
|
1075
1094
|
palette
|
|
@@ -1325,12 +1344,12 @@ export function parseVisualization(
|
|
|
1325
1344
|
continue;
|
|
1326
1345
|
}
|
|
1327
1346
|
|
|
1328
|
-
// Color annotation: `Label
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
const colorPart =
|
|
1347
|
+
// Color annotation (§1.5 trailing-token): `Label color` → split.
|
|
1348
|
+
const { label: labelPart, colorName: colorWord } =
|
|
1349
|
+
peelTrailingColorName(joinedLabel);
|
|
1350
|
+
const colorPart = colorWord
|
|
1332
1351
|
? (resolveColorWithDiagnostic(
|
|
1333
|
-
|
|
1352
|
+
colorWord,
|
|
1334
1353
|
lineNumber,
|
|
1335
1354
|
result.diagnostics,
|
|
1336
1355
|
palette
|
|
@@ -3481,25 +3500,51 @@ function renderTimelineGroupLegend(
|
|
|
3481
3500
|
}
|
|
3482
3501
|
}
|
|
3483
3502
|
|
|
3503
|
+
// ============================================================
|
|
3504
|
+
// Timeline — setup helper (extracted from renderTimeline)
|
|
3505
|
+
// ============================================================
|
|
3506
|
+
|
|
3507
|
+
type Lane = { name: string; events: TimelineEvent[] };
|
|
3508
|
+
|
|
3509
|
+
type TimelineSetup = {
|
|
3510
|
+
width: number;
|
|
3511
|
+
height: number;
|
|
3512
|
+
isVertical: boolean;
|
|
3513
|
+
tooltip: HTMLDivElement;
|
|
3514
|
+
solid: boolean;
|
|
3515
|
+
textColor: string;
|
|
3516
|
+
mutedColor: string;
|
|
3517
|
+
bgColor: string;
|
|
3518
|
+
bg: string;
|
|
3519
|
+
swimlaneTagGroup: string | null;
|
|
3520
|
+
groupColorMap: Map<string, string>;
|
|
3521
|
+
tagLanes: Lane[] | null;
|
|
3522
|
+
eventColor: (ev: TimelineEvent) => string;
|
|
3523
|
+
minDate: number;
|
|
3524
|
+
maxDate: number;
|
|
3525
|
+
datePadding: number;
|
|
3526
|
+
earliestStartDateStr: string;
|
|
3527
|
+
latestEndDateStr: string;
|
|
3528
|
+
tagLegendReserve: number;
|
|
3529
|
+
};
|
|
3530
|
+
|
|
3484
3531
|
/**
|
|
3485
|
-
*
|
|
3486
|
-
*
|
|
3532
|
+
* Computes layout context (dimensions, colors, date domain, tag lanes,
|
|
3533
|
+
* event-color resolver) for a timeline before the orientation-specific
|
|
3534
|
+
* rendering branch runs. Returns null when there is nothing to render
|
|
3535
|
+
* (empty events or zero-sized container).
|
|
3536
|
+
*
|
|
3537
|
+
* Side effects: clears the container and creates the tooltip element.
|
|
3487
3538
|
*/
|
|
3488
|
-
|
|
3539
|
+
function setupTimeline(
|
|
3489
3540
|
container: HTMLDivElement,
|
|
3490
3541
|
parsed: ParsedVisualization,
|
|
3491
3542
|
palette: PaletteColors,
|
|
3492
3543
|
isDark: boolean,
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
onTagStateChange?: (
|
|
3498
|
-
activeTagGroup: string | null,
|
|
3499
|
-
swimlaneTagGroup: string | null
|
|
3500
|
-
) => void,
|
|
3501
|
-
viewMode?: boolean
|
|
3502
|
-
): void {
|
|
3544
|
+
exportDims: D3ExportDimensions | undefined,
|
|
3545
|
+
activeTagGroup: string | null | undefined,
|
|
3546
|
+
swimlaneTagGroup: string | null | undefined
|
|
3547
|
+
): TimelineSetup | null {
|
|
3503
3548
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
3504
3549
|
const solid = parsed.solidFill === true;
|
|
3505
3550
|
|
|
@@ -3509,55 +3554,46 @@ export function renderTimeline(
|
|
|
3509
3554
|
timelineEras,
|
|
3510
3555
|
timelineMarkers,
|
|
3511
3556
|
timelineSort,
|
|
3512
|
-
timelineScale,
|
|
3513
|
-
timelineSwimlanes,
|
|
3514
3557
|
orientation,
|
|
3515
3558
|
} = parsed;
|
|
3516
|
-
|
|
3517
|
-
if (timelineEvents.length === 0) return;
|
|
3559
|
+
if (timelineEvents.length === 0) return null;
|
|
3518
3560
|
|
|
3519
|
-
|
|
3561
|
+
let resolvedSwimlaneTG: string | null = swimlaneTagGroup ?? null;
|
|
3520
3562
|
if (
|
|
3521
|
-
|
|
3563
|
+
resolvedSwimlaneTG == null &&
|
|
3522
3564
|
timelineSort === 'tag' &&
|
|
3523
3565
|
parsed.timelineDefaultSwimlaneTG
|
|
3524
3566
|
) {
|
|
3525
|
-
|
|
3567
|
+
resolvedSwimlaneTG = parsed.timelineDefaultSwimlaneTG;
|
|
3526
3568
|
}
|
|
3527
3569
|
|
|
3528
3570
|
const tooltip = createTooltip(container, palette, isDark);
|
|
3529
3571
|
|
|
3530
3572
|
const width = exportDims?.width ?? container.clientWidth;
|
|
3531
3573
|
const height = exportDims?.height ?? container.clientHeight;
|
|
3532
|
-
if (width <= 0 || height <= 0) return;
|
|
3574
|
+
if (width <= 0 || height <= 0) return null;
|
|
3533
3575
|
|
|
3534
3576
|
const isVertical = orientation === 'vertical';
|
|
3535
3577
|
|
|
3536
|
-
// Theme colors
|
|
3537
3578
|
const textColor = palette.text;
|
|
3538
3579
|
const mutedColor = palette.border;
|
|
3539
3580
|
const bgColor = palette.bg;
|
|
3540
3581
|
const bg = isDark ? palette.surface : palette.bg;
|
|
3541
3582
|
const colors = getSeriesColors(palette);
|
|
3542
3583
|
|
|
3543
|
-
// Assign colors to groups
|
|
3544
3584
|
const groupColorMap = new Map<string, string>();
|
|
3545
3585
|
timelineGroups.forEach((grp, i) => {
|
|
3546
3586
|
groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]);
|
|
3547
3587
|
});
|
|
3548
3588
|
|
|
3549
|
-
// When tag-based swimlanes are active, compute lanes from tag values
|
|
3550
|
-
// and populate groupColorMap with tag entry colors for lane headers.
|
|
3551
|
-
type Lane = { name: string; events: TimelineEvent[] };
|
|
3552
3589
|
let tagLanes: Lane[] | null = null;
|
|
3553
3590
|
|
|
3554
|
-
if (
|
|
3555
|
-
const tagKey =
|
|
3591
|
+
if (resolvedSwimlaneTG) {
|
|
3592
|
+
const tagKey = resolvedSwimlaneTG.toLowerCase();
|
|
3556
3593
|
const tagGroup = parsed.timelineTagGroups.find(
|
|
3557
3594
|
(g) => g.name.toLowerCase() === tagKey
|
|
3558
3595
|
);
|
|
3559
3596
|
if (tagGroup) {
|
|
3560
|
-
// Collect events per tag value
|
|
3561
3597
|
const buckets = new Map<string, TimelineEvent[]>();
|
|
3562
3598
|
const otherEvents: TimelineEvent[] = [];
|
|
3563
3599
|
for (const ev of timelineEvents) {
|
|
@@ -3571,7 +3607,6 @@ export function renderTimeline(
|
|
|
3571
3607
|
}
|
|
3572
3608
|
}
|
|
3573
3609
|
|
|
3574
|
-
// Order lanes by earliest event date
|
|
3575
3610
|
const laneEntries = [...buckets.entries()].sort((a, b) => {
|
|
3576
3611
|
const aMin = Math.min(...a[1].map((e) => parseTimelineDate(e.date)));
|
|
3577
3612
|
const bMin = Math.min(...b[1].map((e) => parseTimelineDate(e.date)));
|
|
@@ -3583,18 +3618,15 @@ export function renderTimeline(
|
|
|
3583
3618
|
tagLanes.push({ name: '(Other)', events: otherEvents });
|
|
3584
3619
|
}
|
|
3585
3620
|
|
|
3586
|
-
// Populate groupColorMap from tag entry colors
|
|
3587
3621
|
for (const entry of tagGroup.entries) {
|
|
3588
3622
|
groupColorMap.set(entry.value, entry.color);
|
|
3589
3623
|
}
|
|
3590
3624
|
}
|
|
3591
3625
|
}
|
|
3592
3626
|
|
|
3593
|
-
|
|
3594
|
-
const effectiveColorTG = activeTagGroup ?? swimlaneTagGroup ?? null;
|
|
3627
|
+
const effectiveColorTG = activeTagGroup ?? resolvedSwimlaneTG ?? null;
|
|
3595
3628
|
|
|
3596
3629
|
function eventColor(ev: TimelineEvent): string {
|
|
3597
|
-
// Tag color takes priority when a tag group is active
|
|
3598
3630
|
if (effectiveColorTG) {
|
|
3599
3631
|
const tagColor = resolveTagColor(
|
|
3600
3632
|
ev.metadata,
|
|
@@ -3609,7 +3641,6 @@ export function renderTimeline(
|
|
|
3609
3641
|
return textColor;
|
|
3610
3642
|
}
|
|
3611
3643
|
|
|
3612
|
-
// Convert dates to numeric values and find boundary dates
|
|
3613
3644
|
let minDate = Infinity;
|
|
3614
3645
|
let maxDate = -Infinity;
|
|
3615
3646
|
let earliestStartDateStr = '';
|
|
@@ -3629,8 +3660,6 @@ export function renderTimeline(
|
|
|
3629
3660
|
}
|
|
3630
3661
|
}
|
|
3631
3662
|
|
|
3632
|
-
// Eras and markers anchor the time axis — fold their dates into the
|
|
3633
|
-
// domain so out-of-range items still render within the chart.
|
|
3634
3663
|
for (const era of timelineEras) {
|
|
3635
3664
|
const eraStartNum = parseTimelineDate(era.startDate);
|
|
3636
3665
|
const eraEndNum = parseTimelineDate(era.endDate);
|
|
@@ -3657,11 +3686,70 @@ export function renderTimeline(
|
|
|
3657
3686
|
}
|
|
3658
3687
|
const datePadding = (maxDate - minDate) * 0.05 || 0.5;
|
|
3659
3688
|
|
|
3660
|
-
const
|
|
3689
|
+
const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
|
|
3690
|
+
|
|
3691
|
+
return {
|
|
3692
|
+
width,
|
|
3693
|
+
height,
|
|
3694
|
+
isVertical,
|
|
3695
|
+
tooltip,
|
|
3696
|
+
solid,
|
|
3697
|
+
textColor,
|
|
3698
|
+
mutedColor,
|
|
3699
|
+
bgColor,
|
|
3700
|
+
bg,
|
|
3701
|
+
swimlaneTagGroup: resolvedSwimlaneTG,
|
|
3702
|
+
groupColorMap,
|
|
3703
|
+
tagLanes,
|
|
3704
|
+
eventColor,
|
|
3705
|
+
minDate,
|
|
3706
|
+
maxDate,
|
|
3707
|
+
datePadding,
|
|
3708
|
+
earliestStartDateStr,
|
|
3709
|
+
latestEndDateStr,
|
|
3710
|
+
tagLegendReserve,
|
|
3711
|
+
};
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
// ============================================================
|
|
3715
|
+
// Timeline — hover helpers (extracted from renderTimeline)
|
|
3716
|
+
// ============================================================
|
|
3717
|
+
|
|
3718
|
+
type TimelineHoverHelpers = {
|
|
3719
|
+
FADE_OPACITY: number;
|
|
3720
|
+
fadeToGroup: (
|
|
3721
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3722
|
+
groupName: string
|
|
3723
|
+
) => void;
|
|
3724
|
+
fadeToEra: (
|
|
3725
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3726
|
+
eraStart: number,
|
|
3727
|
+
eraEnd: number
|
|
3728
|
+
) => void;
|
|
3729
|
+
fadeToMarker: (
|
|
3730
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3731
|
+
markerDate: number
|
|
3732
|
+
) => void;
|
|
3733
|
+
fadeReset: (
|
|
3734
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
3735
|
+
) => void;
|
|
3736
|
+
fadeToTagValue: (
|
|
3737
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3738
|
+
tagKey: string,
|
|
3739
|
+
tagValue: string
|
|
3740
|
+
) => void;
|
|
3741
|
+
setTagAttrs: (
|
|
3742
|
+
evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3743
|
+
ev: TimelineEvent
|
|
3744
|
+
) => void;
|
|
3745
|
+
};
|
|
3661
3746
|
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3747
|
+
/**
|
|
3748
|
+
* Shared hover helpers for timeline rendering. Operate on CSS classes,
|
|
3749
|
+
* orientation-agnostic. Used by all three rendering branches.
|
|
3750
|
+
*/
|
|
3751
|
+
function makeTimelineHoverHelpers(): TimelineHoverHelpers {
|
|
3752
|
+
const FADE_OPACITY = 0.1;
|
|
3665
3753
|
|
|
3666
3754
|
function fadeToGroup(
|
|
3667
3755
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
@@ -3765,11 +3853,10 @@ export function renderTimeline(
|
|
|
3765
3853
|
'opacity',
|
|
3766
3854
|
FADE_OPACITY
|
|
3767
3855
|
);
|
|
3768
|
-
// Fade legend entry dots/labels that don't match (keep group pill visible)
|
|
3769
3856
|
g.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
|
|
3770
3857
|
const el = d3Selection.select(this);
|
|
3771
3858
|
const entryValue = el.attr('data-legend-entry');
|
|
3772
|
-
if (entryValue === '__group__') return;
|
|
3859
|
+
if (entryValue === '__group__') return;
|
|
3773
3860
|
const entryGroup = el.attr('data-tag-group');
|
|
3774
3861
|
el.attr(
|
|
3775
3862
|
'opacity',
|
|
@@ -3788,632 +3875,1167 @@ export function renderTimeline(
|
|
|
3788
3875
|
}
|
|
3789
3876
|
}
|
|
3790
3877
|
|
|
3791
|
-
|
|
3792
|
-
|
|
3878
|
+
return {
|
|
3879
|
+
FADE_OPACITY,
|
|
3880
|
+
fadeToGroup,
|
|
3881
|
+
fadeToEra,
|
|
3882
|
+
fadeToMarker,
|
|
3883
|
+
fadeReset,
|
|
3884
|
+
fadeToTagValue,
|
|
3885
|
+
setTagAttrs,
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3793
3888
|
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
laneEventsByName = new Map(
|
|
3817
|
-
laneNames.map((name) => [
|
|
3818
|
-
name,
|
|
3819
|
-
timelineEvents.filter((ev) =>
|
|
3820
|
-
name === '(Other)'
|
|
3821
|
-
? ev.group === null || !groupNames.includes(ev.group)
|
|
3822
|
-
: ev.group === name
|
|
3823
|
-
),
|
|
3824
|
-
])
|
|
3825
|
-
);
|
|
3826
|
-
}
|
|
3889
|
+
// ============================================================
|
|
3890
|
+
// Timeline — tag-legend overlay (extracted from renderTimeline)
|
|
3891
|
+
// ============================================================
|
|
3892
|
+
|
|
3893
|
+
function renderTimelineTagLegendOverlay(
|
|
3894
|
+
container: HTMLDivElement,
|
|
3895
|
+
parsed: ParsedVisualization,
|
|
3896
|
+
palette: PaletteColors,
|
|
3897
|
+
isDark: boolean,
|
|
3898
|
+
setup: TimelineSetup,
|
|
3899
|
+
hovers: TimelineHoverHelpers,
|
|
3900
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
3901
|
+
exportDims: D3ExportDimensions | undefined,
|
|
3902
|
+
swimlaneTagGroup: string | null | undefined,
|
|
3903
|
+
activeTagGroup: string | null | undefined,
|
|
3904
|
+
onTagStateChange:
|
|
3905
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
3906
|
+
| undefined,
|
|
3907
|
+
viewMode: boolean | undefined,
|
|
3908
|
+
exportMode?: boolean
|
|
3909
|
+
): void {
|
|
3910
|
+
if (parsed.timelineTagGroups.length === 0) return;
|
|
3827
3911
|
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3912
|
+
const { width, textColor, groupColorMap, solid } = setup;
|
|
3913
|
+
const { FADE_OPACITY, fadeReset, fadeToTagValue } = hovers;
|
|
3914
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
3915
|
+
const { timelineEvents } = parsed;
|
|
3916
|
+
|
|
3917
|
+
const LG_HEIGHT = TL_LEGEND_HEIGHT;
|
|
3918
|
+
const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
|
|
3919
|
+
const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
|
|
3920
|
+
const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
|
|
3921
|
+
const LG_DOT_R = TL_LEGEND_DOT_R;
|
|
3922
|
+
const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
|
|
3923
|
+
const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
|
|
3924
|
+
const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
|
|
3925
|
+
// LG_GROUP_GAP no longer needed — centralized legend handles spacing
|
|
3926
|
+
const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local
|
|
3927
|
+
|
|
3928
|
+
const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
|
|
3929
|
+
const mainG = mainSvg.select<SVGGElement>('g');
|
|
3930
|
+
if (!mainSvg.empty() && !mainG.empty()) {
|
|
3931
|
+
// Position legend at top, below title
|
|
3932
|
+
const legendY = title ? 50 : 10;
|
|
3933
|
+
|
|
3934
|
+
// Pre-compute group widths (minified and expanded)
|
|
3935
|
+
type LegendGroup = {
|
|
3936
|
+
group: TagGroup;
|
|
3937
|
+
minifiedWidth: number;
|
|
3938
|
+
expandedWidth: number;
|
|
3939
|
+
};
|
|
3940
|
+
const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
|
|
3941
|
+
const pillW = measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
3942
|
+
// Expanded: pill + icon (unless viewMode) + entries
|
|
3943
|
+
const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
|
|
3944
|
+
let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
|
|
3945
|
+
for (const entry of g.entries) {
|
|
3946
|
+
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
3947
|
+
entryX =
|
|
3948
|
+
textX +
|
|
3949
|
+
measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) +
|
|
3950
|
+
LG_ENTRY_TRAIL;
|
|
3951
|
+
}
|
|
3952
|
+
return {
|
|
3953
|
+
group: g,
|
|
3954
|
+
minifiedWidth: pillW,
|
|
3955
|
+
expandedWidth: entryX + LG_CAPSULE_PAD,
|
|
3836
3956
|
};
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
3852
|
-
.style('background', bgColor);
|
|
3853
|
-
|
|
3854
|
-
const g = svg
|
|
3957
|
+
});
|
|
3958
|
+
|
|
3959
|
+
// Two independent state axes: swimlane source + color source
|
|
3960
|
+
let currentActiveGroup: string | null = activeTagGroup ?? null;
|
|
3961
|
+
let currentSwimlaneGroup: string | null = swimlaneTagGroup ?? null;
|
|
3962
|
+
|
|
3963
|
+
/** Render the swimlane icon (3 horizontal bars of varying width) */
|
|
3964
|
+
function drawSwimlaneIcon(
|
|
3965
|
+
parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3966
|
+
x: number,
|
|
3967
|
+
y: number,
|
|
3968
|
+
isSwimActive: boolean
|
|
3969
|
+
) {
|
|
3970
|
+
const iconG = parent
|
|
3855
3971
|
.append('g')
|
|
3856
|
-
.attr('
|
|
3972
|
+
.attr('class', 'tl-swimlane-icon')
|
|
3973
|
+
.attr('transform', `translate(${x}, ${y})`)
|
|
3974
|
+
.style('cursor', 'pointer');
|
|
3975
|
+
|
|
3976
|
+
const barColor = isSwimActive ? palette.primary : palette.textMuted;
|
|
3977
|
+
const barOpacity = isSwimActive ? 1 : 0.35;
|
|
3978
|
+
const bars = [
|
|
3979
|
+
{ y: 0, w: 8 },
|
|
3980
|
+
{ y: 4, w: 12 },
|
|
3981
|
+
{ y: 8, w: 6 },
|
|
3982
|
+
];
|
|
3983
|
+
for (const bar of bars) {
|
|
3984
|
+
iconG
|
|
3985
|
+
.append('rect')
|
|
3986
|
+
.attr('x', 0)
|
|
3987
|
+
.attr('y', bar.y)
|
|
3988
|
+
.attr('width', bar.w)
|
|
3989
|
+
.attr('height', 2)
|
|
3990
|
+
.attr('rx', 1)
|
|
3991
|
+
.attr('fill', barColor)
|
|
3992
|
+
.attr('opacity', barOpacity);
|
|
3993
|
+
}
|
|
3994
|
+
return iconG;
|
|
3995
|
+
}
|
|
3857
3996
|
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3997
|
+
/** Full re-render with updated swimlane state */
|
|
3998
|
+
function relayout() {
|
|
3999
|
+
renderTimeline(
|
|
4000
|
+
container,
|
|
4001
|
+
parsed,
|
|
4002
|
+
palette,
|
|
4003
|
+
isDark,
|
|
4004
|
+
onClickItem,
|
|
4005
|
+
exportDims,
|
|
4006
|
+
currentActiveGroup,
|
|
4007
|
+
currentSwimlaneGroup,
|
|
4008
|
+
onTagStateChange,
|
|
4009
|
+
viewMode
|
|
3865
4010
|
);
|
|
4011
|
+
}
|
|
3866
4012
|
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
(
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
4013
|
+
function drawLegend() {
|
|
4014
|
+
// Remove previous legend
|
|
4015
|
+
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
4016
|
+
mainSvg.selectAll('.tl-tag-legend-container').remove();
|
|
4017
|
+
|
|
4018
|
+
// Effective color source: explicit color group > swimlane group
|
|
4019
|
+
const effectiveColorKey =
|
|
4020
|
+
(currentActiveGroup ?? currentSwimlaneGroup)?.toLowerCase() ?? null;
|
|
4021
|
+
|
|
4022
|
+
// In view mode, only show the color-driving tag group (expanded, non-interactive).
|
|
4023
|
+
// Skip the swimlane group if it's separate from the color group (lane headers already label it).
|
|
4024
|
+
const visibleGroups = viewMode
|
|
4025
|
+
? legendGroups.filter(
|
|
4026
|
+
(lg) =>
|
|
4027
|
+
effectiveColorKey != null &&
|
|
4028
|
+
lg.group.name.toLowerCase() === effectiveColorKey
|
|
4029
|
+
)
|
|
4030
|
+
: legendGroups;
|
|
3880
4031
|
|
|
3881
|
-
|
|
3882
|
-
g,
|
|
3883
|
-
timelineMarkers,
|
|
3884
|
-
yScale,
|
|
3885
|
-
true,
|
|
3886
|
-
innerWidth,
|
|
3887
|
-
innerHeight,
|
|
3888
|
-
(d) => fadeToMarker(g, d),
|
|
3889
|
-
() => fadeReset(g),
|
|
3890
|
-
timelineScale,
|
|
3891
|
-
tooltip,
|
|
3892
|
-
palette
|
|
3893
|
-
);
|
|
4032
|
+
if (visibleGroups.length === 0) return;
|
|
3894
4033
|
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
minDate,
|
|
3904
|
-
maxDate,
|
|
3905
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
3906
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4034
|
+
// Legend container for data-legend-active attribute
|
|
4035
|
+
const legendContainer = mainSvg
|
|
4036
|
+
.append('g')
|
|
4037
|
+
.attr('class', 'tl-tag-legend-container');
|
|
4038
|
+
if (currentActiveGroup) {
|
|
4039
|
+
legendContainer.attr(
|
|
4040
|
+
'data-legend-active',
|
|
4041
|
+
currentActiveGroup.toLowerCase()
|
|
3907
4042
|
);
|
|
3908
4043
|
}
|
|
3909
4044
|
|
|
3910
|
-
// Render
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
.attr('y', 0)
|
|
3920
|
-
.attr('width', laneWidth)
|
|
3921
|
-
.attr('height', innerHeight)
|
|
3922
|
-
.attr('fill', fillColor)
|
|
3923
|
-
.attr('opacity', 0.06);
|
|
3924
|
-
});
|
|
3925
|
-
}
|
|
4045
|
+
// Render tag groups via centralized legend system
|
|
4046
|
+
const iconAddon = viewMode ? 0 : LG_ICON_W;
|
|
4047
|
+
const centralGroups = visibleGroups.map((lg) => ({
|
|
4048
|
+
name: lg.group.name,
|
|
4049
|
+
entries: lg.group.entries.map((e) => ({
|
|
4050
|
+
value: e.value,
|
|
4051
|
+
color: e.color,
|
|
4052
|
+
})),
|
|
4053
|
+
}));
|
|
3926
4054
|
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
const laneColor = groupColorMap.get(laneName) ?? textColor;
|
|
3930
|
-
const laneCenter = laneX + laneWidth / 2;
|
|
4055
|
+
// Determine effective active group for centralized renderer
|
|
4056
|
+
const centralActive = viewMode ? effectiveColorKey : currentActiveGroup;
|
|
3931
4057
|
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
4058
|
+
const centralConfig: LegendConfig = {
|
|
4059
|
+
groups: centralGroups,
|
|
4060
|
+
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
4061
|
+
mode: exportMode ? 'export' : 'preview',
|
|
4062
|
+
capsulePillAddonWidth: iconAddon,
|
|
4063
|
+
};
|
|
4064
|
+
const centralState: LegendState = { activeGroup: centralActive };
|
|
4065
|
+
|
|
4066
|
+
const centralCallbacks: LegendCallbacks = viewMode
|
|
4067
|
+
? {}
|
|
4068
|
+
: {
|
|
4069
|
+
onGroupToggle: (groupName) => {
|
|
4070
|
+
currentActiveGroup =
|
|
4071
|
+
currentActiveGroup === groupName.toLowerCase()
|
|
4072
|
+
? null
|
|
4073
|
+
: groupName.toLowerCase();
|
|
4074
|
+
drawLegend();
|
|
4075
|
+
recolorEvents();
|
|
4076
|
+
onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
|
|
4077
|
+
},
|
|
4078
|
+
onEntryHover: (groupName, entryValue) => {
|
|
4079
|
+
const tagKey = groupName.toLowerCase();
|
|
4080
|
+
if (entryValue) {
|
|
4081
|
+
const tagVal = entryValue.toLowerCase();
|
|
4082
|
+
fadeToTagValue(mainG, tagKey, tagVal);
|
|
4083
|
+
mainSvg
|
|
4084
|
+
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
4085
|
+
.each(function () {
|
|
4086
|
+
const el = d3Selection.select(this);
|
|
4087
|
+
const ev = el.attr('data-legend-entry');
|
|
4088
|
+
const eg =
|
|
4089
|
+
el.attr('data-tag-group') ??
|
|
4090
|
+
(el.node() as Element)
|
|
4091
|
+
?.closest?.('[data-tag-group]')
|
|
4092
|
+
?.getAttribute('data-tag-group');
|
|
4093
|
+
el.attr(
|
|
4094
|
+
'opacity',
|
|
4095
|
+
eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY
|
|
4096
|
+
);
|
|
4097
|
+
});
|
|
4098
|
+
} else {
|
|
4099
|
+
fadeReset(mainG);
|
|
4100
|
+
mainSvg
|
|
4101
|
+
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
4102
|
+
.attr('opacity', 1);
|
|
4103
|
+
}
|
|
4104
|
+
},
|
|
4105
|
+
onGroupRendered: (groupName, groupEl, isActive) => {
|
|
4106
|
+
const groupKey = groupName.toLowerCase();
|
|
4107
|
+
groupEl.attr('data-tag-group', groupKey);
|
|
4108
|
+
if (isActive && !viewMode) {
|
|
4109
|
+
const isSwimActive =
|
|
4110
|
+
currentSwimlaneGroup != null &&
|
|
4111
|
+
currentSwimlaneGroup.toLowerCase() === groupKey;
|
|
4112
|
+
const pillWidth =
|
|
4113
|
+
measureLegendText(groupName, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
4114
|
+
const pillXOff = LG_CAPSULE_PAD;
|
|
4115
|
+
const iconX = pillXOff + pillWidth + 5;
|
|
4116
|
+
const iconY = (LG_HEIGHT - 10) / 2;
|
|
4117
|
+
const iconEl = drawSwimlaneIcon(
|
|
4118
|
+
groupEl,
|
|
4119
|
+
iconX,
|
|
4120
|
+
iconY,
|
|
4121
|
+
isSwimActive
|
|
4122
|
+
);
|
|
4123
|
+
iconEl
|
|
4124
|
+
.attr('data-swimlane-toggle', groupKey)
|
|
4125
|
+
.on('click', (event: MouseEvent) => {
|
|
4126
|
+
event.stopPropagation();
|
|
4127
|
+
currentSwimlaneGroup =
|
|
4128
|
+
currentSwimlaneGroup === groupKey ? null : groupKey;
|
|
4129
|
+
onTagStateChange?.(
|
|
4130
|
+
currentActiveGroup,
|
|
4131
|
+
currentSwimlaneGroup
|
|
4132
|
+
);
|
|
4133
|
+
relayout();
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
},
|
|
4137
|
+
};
|
|
4138
|
+
|
|
4139
|
+
const legendInnerG = legendContainer
|
|
4140
|
+
.append('g')
|
|
4141
|
+
.attr('transform', `translate(0, ${legendY})`);
|
|
4142
|
+
renderLegendD3(
|
|
4143
|
+
legendInnerG,
|
|
4144
|
+
centralConfig,
|
|
4145
|
+
centralState,
|
|
4146
|
+
palette,
|
|
4147
|
+
isDark,
|
|
4148
|
+
centralCallbacks,
|
|
4149
|
+
width
|
|
4150
|
+
);
|
|
4151
|
+
}
|
|
3939
4152
|
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
.attr('fill', laneColor)
|
|
3946
|
-
.attr('font-size', '12px')
|
|
3947
|
-
.attr('font-weight', '600')
|
|
3948
|
-
.text(laneName);
|
|
3949
|
-
|
|
3950
|
-
g.append('line')
|
|
3951
|
-
.attr('x1', laneCenter)
|
|
3952
|
-
.attr('y1', 0)
|
|
3953
|
-
.attr('x2', laneCenter)
|
|
3954
|
-
.attr('y2', innerHeight)
|
|
3955
|
-
.attr('stroke', mutedColor)
|
|
3956
|
-
.attr('stroke-width', 1)
|
|
3957
|
-
.attr('stroke-dasharray', '4,4');
|
|
3958
|
-
|
|
3959
|
-
const laneEvents = laneEventsByName.get(laneName) ?? [];
|
|
3960
|
-
|
|
3961
|
-
for (const ev of laneEvents) {
|
|
3962
|
-
const y = yScale(parseTimelineDate(ev.date));
|
|
3963
|
-
const evG = g
|
|
3964
|
-
.append('g')
|
|
3965
|
-
.attr('class', 'tl-event')
|
|
3966
|
-
.attr('data-group', laneName)
|
|
3967
|
-
.attr('data-line-number', String(ev.lineNumber))
|
|
3968
|
-
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
3969
|
-
.attr(
|
|
3970
|
-
'data-end-date',
|
|
3971
|
-
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
3972
|
-
)
|
|
3973
|
-
.style('cursor', 'pointer')
|
|
3974
|
-
.on('mouseenter', function (event: MouseEvent) {
|
|
3975
|
-
fadeToGroup(g, laneName);
|
|
3976
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
3977
|
-
})
|
|
3978
|
-
.on('mouseleave', function () {
|
|
3979
|
-
fadeReset(g);
|
|
3980
|
-
hideTooltip(tooltip);
|
|
3981
|
-
})
|
|
3982
|
-
.on('mousemove', function (event: MouseEvent) {
|
|
3983
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
3984
|
-
})
|
|
3985
|
-
.on('click', () => {
|
|
3986
|
-
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
3987
|
-
});
|
|
3988
|
-
setTagAttrs(evG, ev);
|
|
3989
|
-
|
|
3990
|
-
const evColor = eventColor(ev);
|
|
3991
|
-
|
|
3992
|
-
if (ev.endDate) {
|
|
3993
|
-
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
3994
|
-
const rectH = Math.max(y2 - y, 4);
|
|
3995
|
-
|
|
3996
|
-
let fill: string = shapeFill(palette, evColor, isDark, { solid });
|
|
3997
|
-
let stroke: string = evColor;
|
|
3998
|
-
if (ev.uncertain) {
|
|
3999
|
-
const gradientId = `uncertain-vg-${ev.lineNumber}`;
|
|
4000
|
-
const strokeGradientId = `uncertain-vg-s-${ev.lineNumber}`;
|
|
4001
|
-
const defs =
|
|
4002
|
-
svg.select('defs').node() || svg.append('defs').node();
|
|
4003
|
-
const defsEl = d3Selection.select(defs as Element);
|
|
4004
|
-
defsEl
|
|
4005
|
-
.append('linearGradient')
|
|
4006
|
-
.attr('id', gradientId)
|
|
4007
|
-
.attr('x1', '0%')
|
|
4008
|
-
.attr('y1', '0%')
|
|
4009
|
-
.attr('x2', '0%')
|
|
4010
|
-
.attr('y2', '100%')
|
|
4011
|
-
.selectAll('stop')
|
|
4012
|
-
.data([
|
|
4013
|
-
{ offset: '0%', opacity: 1 },
|
|
4014
|
-
{ offset: '80%', opacity: 1 },
|
|
4015
|
-
{ offset: '100%', opacity: 0 },
|
|
4016
|
-
])
|
|
4017
|
-
.enter()
|
|
4018
|
-
.append('stop')
|
|
4019
|
-
.attr('offset', (d) => d.offset)
|
|
4020
|
-
.attr('stop-color', mix(laneColor, bg, 30))
|
|
4021
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
4022
|
-
defsEl
|
|
4023
|
-
.append('linearGradient')
|
|
4024
|
-
.attr('id', strokeGradientId)
|
|
4025
|
-
.attr('x1', '0%')
|
|
4026
|
-
.attr('y1', '0%')
|
|
4027
|
-
.attr('x2', '0%')
|
|
4028
|
-
.attr('y2', '100%')
|
|
4029
|
-
.selectAll('stop')
|
|
4030
|
-
.data([
|
|
4031
|
-
{ offset: '0%', opacity: 1 },
|
|
4032
|
-
{ offset: '80%', opacity: 1 },
|
|
4033
|
-
{ offset: '100%', opacity: 0 },
|
|
4034
|
-
])
|
|
4035
|
-
.enter()
|
|
4036
|
-
.append('stop')
|
|
4037
|
-
.attr('offset', (d) => d.offset)
|
|
4038
|
-
.attr('stop-color', evColor)
|
|
4039
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
4040
|
-
fill = `url(#${gradientId})`;
|
|
4041
|
-
stroke = `url(#${strokeGradientId})`;
|
|
4042
|
-
}
|
|
4153
|
+
// Build a quick lineNumber→event lookup
|
|
4154
|
+
const eventByLine = new Map<string, TimelineEvent>();
|
|
4155
|
+
for (const ev of timelineEvents) {
|
|
4156
|
+
eventByLine.set(String(ev.lineNumber), ev);
|
|
4157
|
+
}
|
|
4043
4158
|
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
.
|
|
4068
|
-
|
|
4069
|
-
.attr('stroke', evColor)
|
|
4070
|
-
.attr('stroke-width', 2);
|
|
4071
|
-
evG
|
|
4072
|
-
.append('text')
|
|
4073
|
-
.attr('x', laneCenter + 10)
|
|
4074
|
-
.attr('y', y)
|
|
4075
|
-
.attr('dy', '0.35em')
|
|
4076
|
-
.attr('fill', textColor)
|
|
4077
|
-
.attr('font-size', '10px')
|
|
4078
|
-
.text(ev.label);
|
|
4079
|
-
}
|
|
4159
|
+
function recolorEvents() {
|
|
4160
|
+
const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;
|
|
4161
|
+
mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
4162
|
+
const el = d3Selection.select(this);
|
|
4163
|
+
const lineNum = el.attr('data-line-number');
|
|
4164
|
+
const ev = lineNum ? eventByLine.get(lineNum) : undefined;
|
|
4165
|
+
if (!ev) return;
|
|
4166
|
+
|
|
4167
|
+
let color: string;
|
|
4168
|
+
if (colorTG) {
|
|
4169
|
+
const tagColor = resolveTagColor(
|
|
4170
|
+
ev.metadata,
|
|
4171
|
+
parsed.timelineTagGroups,
|
|
4172
|
+
colorTG
|
|
4173
|
+
);
|
|
4174
|
+
color =
|
|
4175
|
+
tagColor ??
|
|
4176
|
+
(ev.group && groupColorMap.has(ev.group)
|
|
4177
|
+
? groupColorMap.get(ev.group)!
|
|
4178
|
+
: textColor);
|
|
4179
|
+
} else {
|
|
4180
|
+
color =
|
|
4181
|
+
ev.group && groupColorMap.has(ev.group)
|
|
4182
|
+
? groupColorMap.get(ev.group)!
|
|
4183
|
+
: textColor;
|
|
4080
4184
|
}
|
|
4185
|
+
el.selectAll('rect')
|
|
4186
|
+
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4187
|
+
.attr('stroke', color);
|
|
4188
|
+
el.selectAll('circle:not(.tl-event-point-outline)')
|
|
4189
|
+
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4190
|
+
.attr('stroke', color);
|
|
4081
4191
|
});
|
|
4082
|
-
}
|
|
4083
|
-
// === TIME SORT, vertical: single vertical axis ===
|
|
4084
|
-
const scaleMargin = timelineScale ? 40 : 0;
|
|
4085
|
-
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
4086
|
-
const margin = {
|
|
4087
|
-
top: 104 + markerMargin + tagLegendReserve,
|
|
4088
|
-
right: 200,
|
|
4089
|
-
bottom: 40,
|
|
4090
|
-
left: 60 + scaleMargin,
|
|
4091
|
-
};
|
|
4092
|
-
const innerWidth = width - margin.left - margin.right;
|
|
4093
|
-
const innerHeight = height - margin.top - margin.bottom;
|
|
4094
|
-
const axisX = 20;
|
|
4192
|
+
}
|
|
4095
4193
|
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
.range([0, innerHeight]);
|
|
4194
|
+
drawLegend();
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4100
4197
|
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4198
|
+
// ============================================================
|
|
4199
|
+
// Timeline — horizontal-time-sort renderer (extracted from renderTimeline)
|
|
4200
|
+
// ============================================================
|
|
4104
4201
|
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4202
|
+
function renderTimelineHorizontalTimeSort(
|
|
4203
|
+
container: HTMLDivElement,
|
|
4204
|
+
parsed: ParsedVisualization,
|
|
4205
|
+
palette: PaletteColors,
|
|
4206
|
+
isDark: boolean,
|
|
4207
|
+
setup: TimelineSetup,
|
|
4208
|
+
hovers: TimelineHoverHelpers,
|
|
4209
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
4210
|
+
_exportDims: D3ExportDimensions | undefined,
|
|
4211
|
+
_swimlaneTagGroup: string | null | undefined,
|
|
4212
|
+
_activeTagGroup: string | null | undefined,
|
|
4213
|
+
_onTagStateChange:
|
|
4214
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
4215
|
+
| undefined,
|
|
4216
|
+
_viewMode: boolean | undefined
|
|
4217
|
+
): void {
|
|
4218
|
+
const {
|
|
4219
|
+
width,
|
|
4220
|
+
height,
|
|
4221
|
+
tooltip,
|
|
4222
|
+
solid,
|
|
4223
|
+
textColor,
|
|
4224
|
+
bgColor,
|
|
4225
|
+
bg,
|
|
4226
|
+
groupColorMap,
|
|
4227
|
+
eventColor,
|
|
4228
|
+
minDate,
|
|
4229
|
+
maxDate,
|
|
4230
|
+
datePadding,
|
|
4231
|
+
earliestStartDateStr,
|
|
4232
|
+
latestEndDateStr,
|
|
4233
|
+
tagLegendReserve,
|
|
4234
|
+
} = setup;
|
|
4235
|
+
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
4236
|
+
hovers;
|
|
4237
|
+
const {
|
|
4238
|
+
timelineEvents,
|
|
4239
|
+
timelineGroups,
|
|
4240
|
+
timelineEras,
|
|
4241
|
+
timelineMarkers,
|
|
4242
|
+
timelineScale,
|
|
4243
|
+
} = parsed;
|
|
4244
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
4112
4245
|
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4246
|
+
const BAR_H = 22;
|
|
4247
|
+
|
|
4248
|
+
// === TIME SORT, horizontal: each event on its own row ===
|
|
4249
|
+
const sorted = timelineEvents
|
|
4250
|
+
.slice()
|
|
4251
|
+
.sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
|
|
4252
|
+
|
|
4253
|
+
const scaleMargin = timelineScale ? 24 : 0;
|
|
4254
|
+
// Per-feature header rows: era + marker each get their own row, reserved
|
|
4255
|
+
// only when present (mirrors the gantt header stack).
|
|
4256
|
+
const ERA_ROW_H = 22;
|
|
4257
|
+
const MARKER_ROW_H = 22;
|
|
4258
|
+
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
4259
|
+
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
4260
|
+
const topScaleH = timelineScale ? 40 : 0;
|
|
4261
|
+
const margin = {
|
|
4262
|
+
top: 104 + topScaleH + eraReserve + markerReserve + tagLegendReserve,
|
|
4263
|
+
right: 40,
|
|
4264
|
+
bottom: 40 + scaleMargin,
|
|
4265
|
+
left: 60,
|
|
4266
|
+
};
|
|
4267
|
+
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
4268
|
+
const eraLabelY = eraReserve
|
|
4269
|
+
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4270
|
+
: 0;
|
|
4271
|
+
const innerWidth = width - margin.left - margin.right;
|
|
4272
|
+
const innerHeight = height - margin.top - margin.bottom;
|
|
4273
|
+
const rowH = Math.min(28, innerHeight / sorted.length);
|
|
4116
4274
|
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
width,
|
|
4122
|
-
textColor,
|
|
4123
|
-
onClickItem
|
|
4124
|
-
);
|
|
4275
|
+
const xScale = d3Scale
|
|
4276
|
+
.scaleLinear()
|
|
4277
|
+
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4278
|
+
.range([0, innerWidth]);
|
|
4125
4279
|
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
innerHeight,
|
|
4133
|
-
(s, e) => fadeToEra(g, s, e),
|
|
4134
|
-
() => fadeReset(g),
|
|
4135
|
-
timelineScale,
|
|
4136
|
-
tooltip,
|
|
4137
|
-
palette
|
|
4138
|
-
);
|
|
4280
|
+
const svg = d3Selection
|
|
4281
|
+
.select(container)
|
|
4282
|
+
.append('svg')
|
|
4283
|
+
.attr('width', width)
|
|
4284
|
+
.attr('height', height)
|
|
4285
|
+
.style('background', bgColor);
|
|
4139
4286
|
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
yScale,
|
|
4144
|
-
true,
|
|
4145
|
-
innerWidth,
|
|
4146
|
-
innerHeight,
|
|
4147
|
-
(d) => fadeToMarker(g, d),
|
|
4148
|
-
() => fadeReset(g),
|
|
4149
|
-
timelineScale,
|
|
4150
|
-
tooltip,
|
|
4151
|
-
palette
|
|
4152
|
-
);
|
|
4287
|
+
const g = svg
|
|
4288
|
+
.append('g')
|
|
4289
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
4153
4290
|
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
minDate,
|
|
4163
|
-
maxDate,
|
|
4164
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
4165
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4166
|
-
);
|
|
4167
|
-
}
|
|
4291
|
+
renderChartTitle(
|
|
4292
|
+
svg,
|
|
4293
|
+
title,
|
|
4294
|
+
parsed.titleLineNumber,
|
|
4295
|
+
width,
|
|
4296
|
+
textColor,
|
|
4297
|
+
onClickItem
|
|
4298
|
+
);
|
|
4168
4299
|
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4300
|
+
renderEras(
|
|
4301
|
+
g,
|
|
4302
|
+
timelineEras,
|
|
4303
|
+
xScale,
|
|
4304
|
+
false,
|
|
4305
|
+
innerWidth,
|
|
4306
|
+
innerHeight,
|
|
4307
|
+
(s, e) => fadeToEra(g, s, e),
|
|
4308
|
+
() => fadeReset(g),
|
|
4309
|
+
timelineScale,
|
|
4310
|
+
tooltip,
|
|
4311
|
+
palette,
|
|
4312
|
+
eraReserve ? eraLabelY : undefined
|
|
4313
|
+
);
|
|
4183
4314
|
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4315
|
+
renderMarkers(
|
|
4316
|
+
g,
|
|
4317
|
+
timelineMarkers,
|
|
4318
|
+
xScale,
|
|
4319
|
+
false,
|
|
4320
|
+
innerWidth,
|
|
4321
|
+
innerHeight,
|
|
4322
|
+
(d) => fadeToMarker(g, d),
|
|
4323
|
+
() => fadeReset(g),
|
|
4324
|
+
timelineScale,
|
|
4325
|
+
tooltip,
|
|
4326
|
+
palette,
|
|
4327
|
+
markerReserve ? markerLabelY : undefined
|
|
4328
|
+
);
|
|
4192
4329
|
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4330
|
+
if (timelineScale) {
|
|
4331
|
+
renderTimeScale(
|
|
4332
|
+
g,
|
|
4333
|
+
xScale,
|
|
4334
|
+
false,
|
|
4335
|
+
innerWidth,
|
|
4336
|
+
innerHeight,
|
|
4337
|
+
textColor,
|
|
4338
|
+
minDate,
|
|
4339
|
+
maxDate,
|
|
4340
|
+
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
4341
|
+
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4342
|
+
);
|
|
4343
|
+
}
|
|
4196
4344
|
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
.on('mouseleave', function () {
|
|
4213
|
-
fadeReset(g);
|
|
4214
|
-
hideTooltip(tooltip);
|
|
4215
|
-
})
|
|
4216
|
-
.on('mousemove', function (event: MouseEvent) {
|
|
4217
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4218
|
-
})
|
|
4219
|
-
.on('click', () => {
|
|
4220
|
-
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
4221
|
-
});
|
|
4222
|
-
setTagAttrs(evG, ev);
|
|
4345
|
+
// Group legend at top-left (pill style)
|
|
4346
|
+
if (timelineGroups.length > 0) {
|
|
4347
|
+
const legendY = timelineScale ? -75 : -55;
|
|
4348
|
+
renderTimelineGroupLegend(
|
|
4349
|
+
g,
|
|
4350
|
+
timelineGroups,
|
|
4351
|
+
groupColorMap,
|
|
4352
|
+
textColor,
|
|
4353
|
+
palette,
|
|
4354
|
+
isDark,
|
|
4355
|
+
legendY,
|
|
4356
|
+
(name) => fadeToGroup(g, name),
|
|
4357
|
+
() => fadeReset(g)
|
|
4358
|
+
);
|
|
4359
|
+
}
|
|
4223
4360
|
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4361
|
+
sorted.forEach((ev, i) => {
|
|
4362
|
+
// Marker labels live in their reserved row above the chart, so the
|
|
4363
|
+
// first event sits at the chart top edge.
|
|
4364
|
+
const y = i * rowH + rowH / 2;
|
|
4365
|
+
const x = xScale(parseTimelineDate(ev.date));
|
|
4366
|
+
const color = eventColor(ev);
|
|
4227
4367
|
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4368
|
+
const evG = g
|
|
4369
|
+
.append('g')
|
|
4370
|
+
.attr('class', 'tl-event')
|
|
4371
|
+
.attr('data-group', ev.group || '')
|
|
4372
|
+
.attr('data-line-number', String(ev.lineNumber))
|
|
4373
|
+
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
4374
|
+
.attr(
|
|
4375
|
+
'data-end-date',
|
|
4376
|
+
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
4377
|
+
)
|
|
4378
|
+
.style('cursor', 'pointer')
|
|
4379
|
+
.on('mouseenter', function (event: MouseEvent) {
|
|
4380
|
+
if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
|
|
4381
|
+
if (timelineScale) {
|
|
4382
|
+
showEventDatesOnScale(
|
|
4383
|
+
g,
|
|
4384
|
+
xScale,
|
|
4385
|
+
ev.date,
|
|
4386
|
+
ev.endDate,
|
|
4387
|
+
innerHeight,
|
|
4388
|
+
color
|
|
4389
|
+
);
|
|
4390
|
+
} else {
|
|
4391
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4392
|
+
}
|
|
4393
|
+
})
|
|
4394
|
+
.on('mouseleave', function () {
|
|
4395
|
+
fadeReset(g);
|
|
4396
|
+
if (timelineScale) {
|
|
4397
|
+
hideEventDatesOnScale(g);
|
|
4398
|
+
} else {
|
|
4399
|
+
hideTooltip(tooltip);
|
|
4400
|
+
}
|
|
4401
|
+
})
|
|
4402
|
+
.on('mousemove', function (event: MouseEvent) {
|
|
4403
|
+
if (!timelineScale) {
|
|
4404
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4405
|
+
}
|
|
4406
|
+
})
|
|
4407
|
+
.on('click', () => {
|
|
4408
|
+
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
4409
|
+
});
|
|
4410
|
+
setTagAttrs(evG, ev);
|
|
4411
|
+
|
|
4412
|
+
if (ev.endDate) {
|
|
4413
|
+
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
4414
|
+
const rectW = Math.max(x2 - x, 4);
|
|
4415
|
+
// Estimate label width (~7px per char at 13px font) + padding
|
|
4416
|
+
const estLabelWidth = ev.label.length * 7 + 16;
|
|
4417
|
+
const labelFitsInside = rectW >= estLabelWidth;
|
|
4418
|
+
|
|
4419
|
+
let fill: string = shapeFill(palette, color, isDark, { solid });
|
|
4420
|
+
let stroke: string = color;
|
|
4421
|
+
if (ev.uncertain) {
|
|
4422
|
+
// Create gradient for uncertain end - fades last 20%
|
|
4423
|
+
const gradientId = `uncertain-ts-${ev.lineNumber}`;
|
|
4424
|
+
const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;
|
|
4425
|
+
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4426
|
+
const defsEl = d3Selection.select(defs as Element);
|
|
4427
|
+
defsEl
|
|
4428
|
+
.append('linearGradient')
|
|
4429
|
+
.attr('id', gradientId)
|
|
4430
|
+
.attr('x1', '0%')
|
|
4431
|
+
.attr('y1', '0%')
|
|
4432
|
+
.attr('x2', '100%')
|
|
4433
|
+
.attr('y2', '0%')
|
|
4434
|
+
.selectAll('stop')
|
|
4435
|
+
.data([
|
|
4436
|
+
{ offset: '0%', opacity: 1 },
|
|
4437
|
+
{ offset: '80%', opacity: 1 },
|
|
4438
|
+
{ offset: '100%', opacity: 0 },
|
|
4439
|
+
])
|
|
4440
|
+
.enter()
|
|
4441
|
+
.append('stop')
|
|
4442
|
+
.attr('offset', (d) => d.offset)
|
|
4443
|
+
.attr('stop-color', mix(color, bg, 30))
|
|
4444
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4445
|
+
defsEl
|
|
4446
|
+
.append('linearGradient')
|
|
4447
|
+
.attr('id', strokeGradientId)
|
|
4448
|
+
.attr('x1', '0%')
|
|
4449
|
+
.attr('y1', '0%')
|
|
4450
|
+
.attr('x2', '100%')
|
|
4451
|
+
.attr('y2', '0%')
|
|
4452
|
+
.selectAll('stop')
|
|
4453
|
+
.data([
|
|
4454
|
+
{ offset: '0%', opacity: 1 },
|
|
4455
|
+
{ offset: '80%', opacity: 1 },
|
|
4456
|
+
{ offset: '100%', opacity: 0 },
|
|
4457
|
+
])
|
|
4458
|
+
.enter()
|
|
4459
|
+
.append('stop')
|
|
4460
|
+
.attr('offset', (d) => d.offset)
|
|
4461
|
+
.attr('stop-color', color)
|
|
4462
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4463
|
+
fill = `url(#${gradientId})`;
|
|
4464
|
+
stroke = `url(#${strokeGradientId})`;
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
evG
|
|
4468
|
+
.append('rect')
|
|
4469
|
+
.attr('x', x)
|
|
4470
|
+
.attr('y', y - BAR_H / 2)
|
|
4471
|
+
.attr('width', rectW)
|
|
4472
|
+
.attr('height', BAR_H)
|
|
4473
|
+
.attr('rx', 4)
|
|
4474
|
+
.attr('fill', fill)
|
|
4475
|
+
.attr('stroke', stroke)
|
|
4476
|
+
.attr('stroke-width', 2);
|
|
4477
|
+
|
|
4478
|
+
if (labelFitsInside) {
|
|
4479
|
+
// Text inside bar - use textColor for readability on muted fill
|
|
4480
|
+
evG
|
|
4481
|
+
.append('text')
|
|
4482
|
+
.attr('x', x + 8)
|
|
4483
|
+
.attr('y', y)
|
|
4484
|
+
.attr('dy', '0.35em')
|
|
4485
|
+
.attr('text-anchor', 'start')
|
|
4486
|
+
.attr('fill', textColor)
|
|
4487
|
+
.attr('font-size', '13px')
|
|
4488
|
+
.text(ev.label);
|
|
4489
|
+
} else {
|
|
4490
|
+
// Text outside bar - check if it fits on left or must go right
|
|
4491
|
+
const wouldFlipLeft = x + rectW > innerWidth * 0.6;
|
|
4492
|
+
const labelFitsLeft = x - 6 - estLabelWidth > 0;
|
|
4493
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4494
|
+
evG
|
|
4495
|
+
.append('text')
|
|
4496
|
+
.attr('x', flipLeft ? x - 6 : x + rectW + 6)
|
|
4497
|
+
.attr('y', y)
|
|
4498
|
+
.attr('dy', '0.35em')
|
|
4499
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4500
|
+
.attr('fill', textColor)
|
|
4501
|
+
.attr('font-size', '13px')
|
|
4502
|
+
.text(ev.label);
|
|
4503
|
+
}
|
|
4504
|
+
} else {
|
|
4505
|
+
// Point event (no end date) - render as circle with label
|
|
4506
|
+
const estLabelWidth = ev.label.length * 7;
|
|
4507
|
+
// Only flip left if past 60% AND label fits without going off-chart
|
|
4508
|
+
const wouldFlipLeft = x > innerWidth * 0.6;
|
|
4509
|
+
const labelFitsLeft = x - 10 - estLabelWidth > 0;
|
|
4510
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4511
|
+
evG
|
|
4512
|
+
.append('circle')
|
|
4513
|
+
.attr('cx', x)
|
|
4514
|
+
.attr('cy', y)
|
|
4515
|
+
.attr('r', 5)
|
|
4516
|
+
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4517
|
+
.attr('stroke', color)
|
|
4518
|
+
.attr('stroke-width', 2);
|
|
4519
|
+
evG
|
|
4520
|
+
.append('text')
|
|
4521
|
+
.attr('x', flipLeft ? x - 10 : x + 10)
|
|
4522
|
+
.attr('y', y)
|
|
4523
|
+
.attr('dy', '0.35em')
|
|
4524
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4525
|
+
.attr('fill', textColor)
|
|
4526
|
+
.attr('font-size', '12px')
|
|
4527
|
+
.text(ev.label);
|
|
4528
|
+
}
|
|
4529
|
+
});
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4532
|
+
// ============================================================
|
|
4533
|
+
// Timeline — horizontal-grouped renderer (extracted from renderTimeline)
|
|
4534
|
+
// ============================================================
|
|
4535
|
+
|
|
4536
|
+
function renderTimelineHorizontalGrouped(
|
|
4537
|
+
container: HTMLDivElement,
|
|
4538
|
+
parsed: ParsedVisualization,
|
|
4539
|
+
palette: PaletteColors,
|
|
4540
|
+
isDark: boolean,
|
|
4541
|
+
setup: TimelineSetup,
|
|
4542
|
+
hovers: TimelineHoverHelpers,
|
|
4543
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
4544
|
+
_exportDims: D3ExportDimensions | undefined,
|
|
4545
|
+
_swimlaneTagGroup: string | null | undefined,
|
|
4546
|
+
_activeTagGroup: string | null | undefined,
|
|
4547
|
+
_onTagStateChange:
|
|
4548
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
4549
|
+
| undefined,
|
|
4550
|
+
_viewMode: boolean | undefined
|
|
4551
|
+
): void {
|
|
4552
|
+
const {
|
|
4553
|
+
width,
|
|
4554
|
+
height,
|
|
4555
|
+
tooltip,
|
|
4556
|
+
solid,
|
|
4557
|
+
textColor,
|
|
4558
|
+
bgColor,
|
|
4559
|
+
bg,
|
|
4560
|
+
groupColorMap,
|
|
4561
|
+
tagLanes,
|
|
4562
|
+
eventColor,
|
|
4563
|
+
minDate,
|
|
4564
|
+
maxDate,
|
|
4565
|
+
datePadding,
|
|
4566
|
+
earliestStartDateStr,
|
|
4567
|
+
latestEndDateStr,
|
|
4568
|
+
tagLegendReserve,
|
|
4569
|
+
} = setup;
|
|
4570
|
+
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
4571
|
+
hovers;
|
|
4572
|
+
const {
|
|
4573
|
+
timelineEvents,
|
|
4574
|
+
timelineGroups,
|
|
4575
|
+
timelineEras,
|
|
4576
|
+
timelineMarkers,
|
|
4577
|
+
timelineScale,
|
|
4578
|
+
timelineSwimlanes,
|
|
4579
|
+
} = parsed;
|
|
4580
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
4581
|
+
|
|
4582
|
+
const BAR_H = 22;
|
|
4583
|
+
const GROUP_GAP = 12;
|
|
4584
|
+
|
|
4585
|
+
// === GROUPED: swim-lanes stacked vertically, events on own rows ===
|
|
4586
|
+
let lanes: Lane[];
|
|
4587
|
+
|
|
4588
|
+
if (tagLanes) {
|
|
4589
|
+
lanes = tagLanes;
|
|
4590
|
+
} else {
|
|
4591
|
+
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
4592
|
+
const ungroupedEvents = timelineEvents.filter(
|
|
4593
|
+
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
4594
|
+
);
|
|
4595
|
+
const laneNames =
|
|
4596
|
+
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
4597
|
+
lanes = laneNames.map((name) => ({
|
|
4598
|
+
name,
|
|
4599
|
+
events: timelineEvents.filter((ev) =>
|
|
4600
|
+
name === '(Other)'
|
|
4601
|
+
? ev.group === null || !groupNames.includes(ev.group)
|
|
4602
|
+
: ev.group === name
|
|
4603
|
+
),
|
|
4604
|
+
}));
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
const totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);
|
|
4608
|
+
const scaleMargin = timelineScale ? 24 : 0;
|
|
4609
|
+
// Per-feature header rows: era + marker each get their own row, reserved
|
|
4610
|
+
// only when present (mirrors the gantt header stack).
|
|
4611
|
+
const ERA_ROW_H = 22;
|
|
4612
|
+
const MARKER_ROW_H = 22;
|
|
4613
|
+
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
4614
|
+
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
4615
|
+
const topScaleH = timelineScale ? 40 : 0;
|
|
4616
|
+
// Calculate left margin based on longest group name (~7px per char + padding)
|
|
4617
|
+
const maxGroupNameLen = Math.max(...lanes.map((l) => l.name.length));
|
|
4618
|
+
const dynamicLeftMargin = Math.max(120, maxGroupNameLen * 7 + 30);
|
|
4619
|
+
// Group-sorted doesn't need legend space (group names shown on left)
|
|
4620
|
+
const baseTopMargin = title ? 50 : 20;
|
|
4621
|
+
const margin = {
|
|
4622
|
+
top:
|
|
4623
|
+
baseTopMargin + topScaleH + eraReserve + markerReserve + tagLegendReserve,
|
|
4624
|
+
right: 40,
|
|
4625
|
+
bottom: 40 + scaleMargin,
|
|
4626
|
+
left: dynamicLeftMargin,
|
|
4627
|
+
};
|
|
4628
|
+
// Y offsets for label rows (negative = above chart's y=0).
|
|
4629
|
+
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
4630
|
+
const eraLabelY = eraReserve
|
|
4631
|
+
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4632
|
+
: 0;
|
|
4633
|
+
const innerWidth = width - margin.left - margin.right;
|
|
4634
|
+
const innerHeight = height - margin.top - margin.bottom;
|
|
4635
|
+
const totalGaps = (lanes.length - 1) * GROUP_GAP;
|
|
4636
|
+
const rowH = Math.min(28, (innerHeight - totalGaps) / totalEventRows);
|
|
4637
|
+
|
|
4638
|
+
const xScale = d3Scale
|
|
4639
|
+
.scaleLinear()
|
|
4640
|
+
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4641
|
+
.range([0, innerWidth]);
|
|
4642
|
+
|
|
4643
|
+
const svg = d3Selection
|
|
4644
|
+
.select(container)
|
|
4645
|
+
.append('svg')
|
|
4646
|
+
.attr('width', width)
|
|
4647
|
+
.attr('height', height)
|
|
4648
|
+
.style('background', bgColor);
|
|
4649
|
+
|
|
4650
|
+
const g = svg
|
|
4651
|
+
.append('g')
|
|
4652
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
4653
|
+
|
|
4654
|
+
renderChartTitle(
|
|
4655
|
+
svg,
|
|
4656
|
+
title,
|
|
4657
|
+
parsed.titleLineNumber,
|
|
4658
|
+
width,
|
|
4659
|
+
textColor,
|
|
4660
|
+
onClickItem
|
|
4661
|
+
);
|
|
4662
|
+
|
|
4663
|
+
renderEras(
|
|
4664
|
+
g,
|
|
4665
|
+
timelineEras,
|
|
4666
|
+
xScale,
|
|
4667
|
+
false,
|
|
4668
|
+
innerWidth,
|
|
4669
|
+
innerHeight,
|
|
4670
|
+
(s, e) => fadeToEra(g, s, e),
|
|
4671
|
+
() => fadeReset(g),
|
|
4672
|
+
timelineScale,
|
|
4673
|
+
tooltip,
|
|
4674
|
+
palette,
|
|
4675
|
+
eraReserve ? eraLabelY : undefined
|
|
4676
|
+
);
|
|
4677
|
+
|
|
4678
|
+
renderMarkers(
|
|
4679
|
+
g,
|
|
4680
|
+
timelineMarkers,
|
|
4681
|
+
xScale,
|
|
4682
|
+
false,
|
|
4683
|
+
innerWidth,
|
|
4684
|
+
innerHeight,
|
|
4685
|
+
(d) => fadeToMarker(g, d),
|
|
4686
|
+
() => fadeReset(g),
|
|
4687
|
+
timelineScale,
|
|
4688
|
+
tooltip,
|
|
4689
|
+
palette,
|
|
4690
|
+
markerReserve ? markerLabelY : undefined
|
|
4691
|
+
);
|
|
4692
|
+
|
|
4693
|
+
if (timelineScale) {
|
|
4694
|
+
renderTimeScale(
|
|
4695
|
+
g,
|
|
4696
|
+
xScale,
|
|
4697
|
+
false,
|
|
4698
|
+
innerWidth,
|
|
4699
|
+
innerHeight,
|
|
4700
|
+
textColor,
|
|
4701
|
+
minDate,
|
|
4702
|
+
maxDate,
|
|
4703
|
+
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
4704
|
+
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4705
|
+
);
|
|
4706
|
+
}
|
|
4707
|
+
|
|
4708
|
+
// Marker labels now live in their reserved row above the chart, so
|
|
4709
|
+
// events can start at y=0 (chart top edge).
|
|
4710
|
+
let curY = 0;
|
|
4711
|
+
|
|
4712
|
+
// Render swimlane backgrounds first (so they appear behind events)
|
|
4713
|
+
// Extend into left margin to include group names
|
|
4714
|
+
if (timelineSwimlanes || tagLanes) {
|
|
4715
|
+
let swimY = 0;
|
|
4716
|
+
lanes.forEach((lane, idx) => {
|
|
4717
|
+
const laneSpan = lane.events.length * rowH;
|
|
4718
|
+
// Alternate between light gray and transparent for visual separation
|
|
4719
|
+
const fillColor = idx % 2 === 0 ? textColor : 'transparent';
|
|
4720
|
+
g.append('rect')
|
|
4721
|
+
.attr('class', 'tl-swimlane')
|
|
4722
|
+
.attr('data-group', lane.name)
|
|
4723
|
+
.attr('x', -margin.left)
|
|
4724
|
+
.attr('y', swimY)
|
|
4725
|
+
.attr('width', innerWidth + margin.left)
|
|
4726
|
+
.attr('height', laneSpan + (idx < lanes.length - 1 ? GROUP_GAP : 0))
|
|
4727
|
+
.attr('fill', fillColor)
|
|
4728
|
+
.attr('opacity', 0.06);
|
|
4729
|
+
swimY += laneSpan + GROUP_GAP;
|
|
4730
|
+
});
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
for (const lane of lanes) {
|
|
4734
|
+
const laneColor = groupColorMap.get(lane.name) ?? textColor;
|
|
4735
|
+
const laneSpan = lane.events.length * rowH;
|
|
4736
|
+
|
|
4737
|
+
// Group label — left of lane, vertically centred
|
|
4738
|
+
const group = timelineGroups.find((grp) => grp.name === lane.name);
|
|
4739
|
+
const headerG = g
|
|
4740
|
+
.append('g')
|
|
4741
|
+
.attr('class', 'tl-lane-header')
|
|
4742
|
+
.attr('data-group', lane.name)
|
|
4743
|
+
.style('cursor', 'pointer')
|
|
4744
|
+
.on('mouseenter', () => fadeToGroup(g, lane.name))
|
|
4745
|
+
.on('mouseleave', () => fadeReset(g))
|
|
4746
|
+
.on('click', () => {
|
|
4747
|
+
if (onClickItem && group?.lineNumber) onClickItem(group.lineNumber);
|
|
4748
|
+
});
|
|
4749
|
+
|
|
4750
|
+
headerG
|
|
4751
|
+
.append('text')
|
|
4752
|
+
.attr('x', -margin.left + 10)
|
|
4753
|
+
.attr('y', curY + laneSpan / 2)
|
|
4754
|
+
.attr('dy', '0.35em')
|
|
4755
|
+
.attr('text-anchor', 'start')
|
|
4756
|
+
.attr('fill', laneColor)
|
|
4757
|
+
.attr('font-size', '12px')
|
|
4758
|
+
.attr('font-weight', '600')
|
|
4759
|
+
.text(lane.name);
|
|
4760
|
+
|
|
4761
|
+
lane.events.forEach((ev, i) => {
|
|
4762
|
+
const y = curY + i * rowH + rowH / 2;
|
|
4763
|
+
const x = xScale(parseTimelineDate(ev.date));
|
|
4764
|
+
|
|
4765
|
+
const evG = g
|
|
4766
|
+
.append('g')
|
|
4767
|
+
.attr('class', 'tl-event')
|
|
4768
|
+
.attr('data-group', lane.name)
|
|
4769
|
+
.attr('data-line-number', String(ev.lineNumber))
|
|
4770
|
+
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
4771
|
+
.attr(
|
|
4772
|
+
'data-end-date',
|
|
4773
|
+
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
4774
|
+
)
|
|
4775
|
+
.style('cursor', 'pointer')
|
|
4776
|
+
.on('mouseenter', function (event: MouseEvent) {
|
|
4777
|
+
fadeToGroup(g, lane.name);
|
|
4778
|
+
if (timelineScale) {
|
|
4779
|
+
showEventDatesOnScale(
|
|
4780
|
+
g,
|
|
4781
|
+
xScale,
|
|
4782
|
+
ev.date,
|
|
4783
|
+
ev.endDate,
|
|
4784
|
+
innerHeight,
|
|
4785
|
+
laneColor
|
|
4786
|
+
);
|
|
4787
|
+
} else {
|
|
4788
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4789
|
+
}
|
|
4790
|
+
})
|
|
4791
|
+
.on('mouseleave', function () {
|
|
4792
|
+
fadeReset(g);
|
|
4793
|
+
if (timelineScale) {
|
|
4794
|
+
hideEventDatesOnScale(g);
|
|
4795
|
+
} else {
|
|
4796
|
+
hideTooltip(tooltip);
|
|
4797
|
+
}
|
|
4798
|
+
})
|
|
4799
|
+
.on('mousemove', function (event: MouseEvent) {
|
|
4800
|
+
if (!timelineScale) {
|
|
4801
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4273
4802
|
}
|
|
4803
|
+
})
|
|
4804
|
+
.on('click', () => {
|
|
4805
|
+
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
4806
|
+
});
|
|
4807
|
+
setTagAttrs(evG, ev);
|
|
4274
4808
|
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4809
|
+
const evColor = eventColor(ev);
|
|
4810
|
+
|
|
4811
|
+
if (ev.endDate) {
|
|
4812
|
+
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
4813
|
+
const rectW = Math.max(x2 - x, 4);
|
|
4814
|
+
// Estimate label width (~7px per char at 13px font) + padding
|
|
4815
|
+
const estLabelWidth = ev.label.length * 7 + 16;
|
|
4816
|
+
const labelFitsInside = rectW >= estLabelWidth;
|
|
4817
|
+
|
|
4818
|
+
let fill: string = shapeFill(palette, evColor, isDark, { solid });
|
|
4819
|
+
let stroke: string = evColor;
|
|
4820
|
+
if (ev.uncertain) {
|
|
4821
|
+
// Create gradient for uncertain end - fades last 20%
|
|
4822
|
+
const gradientId = `uncertain-${ev.lineNumber}`;
|
|
4823
|
+
const strokeGradientId = `uncertain-s-${ev.lineNumber}`;
|
|
4824
|
+
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4825
|
+
const defsEl = d3Selection.select(defs as Element);
|
|
4826
|
+
defsEl
|
|
4827
|
+
.append('linearGradient')
|
|
4828
|
+
.attr('id', gradientId)
|
|
4829
|
+
.attr('x1', '0%')
|
|
4830
|
+
.attr('y1', '0%')
|
|
4831
|
+
.attr('x2', '100%')
|
|
4832
|
+
.attr('y2', '0%')
|
|
4833
|
+
.selectAll('stop')
|
|
4834
|
+
.data([
|
|
4835
|
+
{ offset: '0%', opacity: 1 },
|
|
4836
|
+
{ offset: '80%', opacity: 1 },
|
|
4837
|
+
{ offset: '100%', opacity: 0 },
|
|
4838
|
+
])
|
|
4839
|
+
.enter()
|
|
4840
|
+
.append('stop')
|
|
4841
|
+
.attr('offset', (d) => d.offset)
|
|
4842
|
+
.attr('stop-color', mix(evColor, bg, 30))
|
|
4843
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4844
|
+
defsEl
|
|
4845
|
+
.append('linearGradient')
|
|
4846
|
+
.attr('id', strokeGradientId)
|
|
4847
|
+
.attr('x1', '0%')
|
|
4848
|
+
.attr('y1', '0%')
|
|
4849
|
+
.attr('x2', '100%')
|
|
4850
|
+
.attr('y2', '0%')
|
|
4851
|
+
.selectAll('stop')
|
|
4852
|
+
.data([
|
|
4853
|
+
{ offset: '0%', opacity: 1 },
|
|
4854
|
+
{ offset: '80%', opacity: 1 },
|
|
4855
|
+
{ offset: '100%', opacity: 0 },
|
|
4856
|
+
])
|
|
4857
|
+
.enter()
|
|
4858
|
+
.append('stop')
|
|
4859
|
+
.attr('offset', (d) => d.offset)
|
|
4860
|
+
.attr('stop-color', evColor)
|
|
4861
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4862
|
+
fill = `url(#${gradientId})`;
|
|
4863
|
+
stroke = `url(#${strokeGradientId})`;
|
|
4864
|
+
}
|
|
4865
|
+
|
|
4866
|
+
evG
|
|
4867
|
+
.append('rect')
|
|
4868
|
+
.attr('x', x)
|
|
4869
|
+
.attr('y', y - BAR_H / 2)
|
|
4870
|
+
.attr('width', rectW)
|
|
4871
|
+
.attr('height', BAR_H)
|
|
4872
|
+
.attr('rx', 4)
|
|
4873
|
+
.attr('fill', fill)
|
|
4874
|
+
.attr('stroke', stroke)
|
|
4875
|
+
.attr('stroke-width', 2);
|
|
4876
|
+
|
|
4877
|
+
if (labelFitsInside) {
|
|
4878
|
+
// Text inside bar - use textColor for readability on muted fill
|
|
4285
4879
|
evG
|
|
4286
4880
|
.append('text')
|
|
4287
|
-
.attr('x',
|
|
4288
|
-
.attr('y', y
|
|
4881
|
+
.attr('x', x + 8)
|
|
4882
|
+
.attr('y', y)
|
|
4289
4883
|
.attr('dy', '0.35em')
|
|
4884
|
+
.attr('text-anchor', 'start')
|
|
4290
4885
|
.attr('fill', textColor)
|
|
4291
|
-
.attr('font-size', '
|
|
4886
|
+
.attr('font-size', '13px')
|
|
4292
4887
|
.text(ev.label);
|
|
4293
4888
|
} else {
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
.attr('r', 4)
|
|
4299
|
-
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4300
|
-
.attr('stroke', color)
|
|
4301
|
-
.attr('stroke-width', 2);
|
|
4889
|
+
// Text outside bar - check if it fits on left or must go right
|
|
4890
|
+
const wouldFlipLeft = x + rectW > innerWidth * 0.6;
|
|
4891
|
+
const labelFitsLeft = x - 6 - estLabelWidth > 0;
|
|
4892
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4302
4893
|
evG
|
|
4303
4894
|
.append('text')
|
|
4304
|
-
.attr('x',
|
|
4895
|
+
.attr('x', flipLeft ? x - 6 : x + rectW + 6)
|
|
4305
4896
|
.attr('y', y)
|
|
4306
4897
|
.attr('dy', '0.35em')
|
|
4898
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4307
4899
|
.attr('fill', textColor)
|
|
4308
|
-
.attr('font-size', '
|
|
4900
|
+
.attr('font-size', '13px')
|
|
4309
4901
|
.text(ev.label);
|
|
4310
4902
|
}
|
|
4311
|
-
|
|
4312
|
-
//
|
|
4903
|
+
} else {
|
|
4904
|
+
// Point event (no end date) - render as circle with label
|
|
4905
|
+
const estLabelWidth = ev.label.length * 7;
|
|
4906
|
+
// Only flip left if past 60% AND label fits without colliding with group name area
|
|
4907
|
+
const wouldFlipLeft = x > innerWidth * 0.6;
|
|
4908
|
+
const labelFitsLeft = x - 10 - estLabelWidth > 0;
|
|
4909
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4910
|
+
evG
|
|
4911
|
+
.append('circle')
|
|
4912
|
+
.attr('cx', x)
|
|
4913
|
+
.attr('cy', y)
|
|
4914
|
+
.attr('r', 5)
|
|
4915
|
+
.attr('fill', shapeFill(palette, evColor, isDark, { solid }))
|
|
4916
|
+
.attr('stroke', evColor)
|
|
4917
|
+
.attr('stroke-width', 2);
|
|
4313
4918
|
evG
|
|
4314
4919
|
.append('text')
|
|
4315
|
-
.attr('x',
|
|
4316
|
-
.attr(
|
|
4317
|
-
'y',
|
|
4318
|
-
ev.endDate
|
|
4319
|
-
? yScale(parseTimelineDate(ev.date)) +
|
|
4320
|
-
Math.max(
|
|
4321
|
-
yScale(parseTimelineDate(ev.endDate)) -
|
|
4322
|
-
yScale(parseTimelineDate(ev.date)),
|
|
4323
|
-
4
|
|
4324
|
-
) /
|
|
4325
|
-
2
|
|
4326
|
-
: y
|
|
4327
|
-
)
|
|
4920
|
+
.attr('x', flipLeft ? x - 10 : x + 10)
|
|
4921
|
+
.attr('y', y)
|
|
4328
4922
|
.attr('dy', '0.35em')
|
|
4329
|
-
.attr('text-anchor', 'end')
|
|
4330
|
-
.attr('fill',
|
|
4331
|
-
.attr('font-size', '
|
|
4332
|
-
.text(ev.
|
|
4923
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4924
|
+
.attr('fill', textColor)
|
|
4925
|
+
.attr('font-size', '12px')
|
|
4926
|
+
.text(ev.label);
|
|
4333
4927
|
}
|
|
4334
|
-
}
|
|
4928
|
+
});
|
|
4335
4929
|
|
|
4336
|
-
|
|
4930
|
+
curY += laneSpan + GROUP_GAP;
|
|
4337
4931
|
}
|
|
4932
|
+
}
|
|
4338
4933
|
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
// ================================================================
|
|
4934
|
+
// ============================================================
|
|
4935
|
+
// Timeline — vertical-orientation renderer (extracted from renderTimeline)
|
|
4936
|
+
// ============================================================
|
|
4343
4937
|
|
|
4344
|
-
|
|
4345
|
-
|
|
4938
|
+
function renderTimelineVertical(
|
|
4939
|
+
container: HTMLDivElement,
|
|
4940
|
+
parsed: ParsedVisualization,
|
|
4941
|
+
palette: PaletteColors,
|
|
4942
|
+
isDark: boolean,
|
|
4943
|
+
setup: TimelineSetup,
|
|
4944
|
+
hovers: TimelineHoverHelpers,
|
|
4945
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
4946
|
+
exportDims: D3ExportDimensions | undefined,
|
|
4947
|
+
_swimlaneTagGroup: string | null | undefined,
|
|
4948
|
+
_activeTagGroup: string | null | undefined,
|
|
4949
|
+
_onTagStateChange:
|
|
4950
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
4951
|
+
| undefined,
|
|
4952
|
+
_viewMode: boolean | undefined
|
|
4953
|
+
): void {
|
|
4954
|
+
const {
|
|
4955
|
+
width,
|
|
4956
|
+
height,
|
|
4957
|
+
tooltip,
|
|
4958
|
+
solid,
|
|
4959
|
+
textColor,
|
|
4960
|
+
mutedColor,
|
|
4961
|
+
bgColor,
|
|
4962
|
+
bg,
|
|
4963
|
+
groupColorMap,
|
|
4964
|
+
tagLanes,
|
|
4965
|
+
eventColor,
|
|
4966
|
+
minDate,
|
|
4967
|
+
maxDate,
|
|
4968
|
+
datePadding,
|
|
4969
|
+
earliestStartDateStr,
|
|
4970
|
+
latestEndDateStr,
|
|
4971
|
+
tagLegendReserve,
|
|
4972
|
+
} = setup;
|
|
4973
|
+
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
4974
|
+
hovers;
|
|
4975
|
+
const {
|
|
4976
|
+
timelineEvents,
|
|
4977
|
+
timelineGroups,
|
|
4978
|
+
timelineEras,
|
|
4979
|
+
timelineMarkers,
|
|
4980
|
+
timelineSort,
|
|
4981
|
+
timelineScale,
|
|
4982
|
+
timelineSwimlanes,
|
|
4983
|
+
} = parsed;
|
|
4984
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
4346
4985
|
|
|
4347
|
-
const
|
|
4986
|
+
const useGroupedVertical =
|
|
4348
4987
|
tagLanes != null || (timelineSort === 'group' && timelineGroups.length > 0);
|
|
4349
|
-
if (
|
|
4350
|
-
// === GROUPED:
|
|
4351
|
-
let
|
|
4988
|
+
if (useGroupedVertical) {
|
|
4989
|
+
// === GROUPED: one column/lane per group, vertical ===
|
|
4990
|
+
let laneNames: string[];
|
|
4991
|
+
let laneEventsByName: Map<string, TimelineEvent[]>;
|
|
4352
4992
|
|
|
4353
4993
|
if (tagLanes) {
|
|
4354
|
-
|
|
4994
|
+
laneNames = tagLanes.map((l) => l.name);
|
|
4995
|
+
laneEventsByName = new Map(tagLanes.map((l) => [l.name, l.events]));
|
|
4355
4996
|
} else {
|
|
4356
4997
|
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
4357
4998
|
const ungroupedEvents = timelineEvents.filter(
|
|
4358
4999
|
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
4359
5000
|
);
|
|
4360
|
-
|
|
5001
|
+
laneNames =
|
|
4361
5002
|
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
4362
|
-
|
|
4363
|
-
name
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
5003
|
+
laneEventsByName = new Map(
|
|
5004
|
+
laneNames.map((name) => [
|
|
5005
|
+
name,
|
|
5006
|
+
timelineEvents.filter((ev) =>
|
|
5007
|
+
name === '(Other)'
|
|
5008
|
+
? ev.group === null || !groupNames.includes(ev.group)
|
|
5009
|
+
: ev.group === name
|
|
5010
|
+
),
|
|
5011
|
+
])
|
|
5012
|
+
);
|
|
4370
5013
|
}
|
|
4371
5014
|
|
|
4372
|
-
const
|
|
4373
|
-
const scaleMargin = timelineScale ?
|
|
4374
|
-
|
|
4375
|
-
// only when present (mirrors the gantt header stack).
|
|
4376
|
-
const ERA_ROW_H = 22;
|
|
4377
|
-
const MARKER_ROW_H = 22;
|
|
4378
|
-
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
4379
|
-
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
4380
|
-
const topScaleH = timelineScale ? 40 : 0;
|
|
4381
|
-
// Calculate left margin based on longest group name (~7px per char + padding)
|
|
4382
|
-
const maxGroupNameLen = Math.max(...lanes.map((l) => l.name.length));
|
|
4383
|
-
const dynamicLeftMargin = Math.max(120, maxGroupNameLen * 7 + 30);
|
|
4384
|
-
// Group-sorted doesn't need legend space (group names shown on left)
|
|
4385
|
-
const baseTopMargin = title ? 50 : 20;
|
|
5015
|
+
const laneCount = laneNames.length;
|
|
5016
|
+
const scaleMargin = timelineScale ? 40 : 0;
|
|
5017
|
+
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
4386
5018
|
const margin = {
|
|
4387
|
-
top:
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
markerReserve +
|
|
4392
|
-
tagLegendReserve,
|
|
4393
|
-
right: 40,
|
|
4394
|
-
bottom: 40 + scaleMargin,
|
|
4395
|
-
left: dynamicLeftMargin,
|
|
5019
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
5020
|
+
right: 40 + scaleMargin,
|
|
5021
|
+
bottom: 40,
|
|
5022
|
+
left: 60 + scaleMargin,
|
|
4396
5023
|
};
|
|
4397
|
-
// Y offsets for label rows (negative = above chart's y=0).
|
|
4398
|
-
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
4399
|
-
const eraLabelY = eraReserve
|
|
4400
|
-
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4401
|
-
: 0;
|
|
4402
5024
|
const innerWidth = width - margin.left - margin.right;
|
|
4403
5025
|
const innerHeight = height - margin.top - margin.bottom;
|
|
4404
|
-
const
|
|
4405
|
-
const rowH = Math.min(28, (innerHeight - totalGaps) / totalEventRows);
|
|
5026
|
+
const laneWidth = innerWidth / laneCount;
|
|
4406
5027
|
|
|
4407
|
-
const
|
|
5028
|
+
const yScale = d3Scale
|
|
4408
5029
|
.scaleLinear()
|
|
4409
5030
|
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4410
|
-
.range([0,
|
|
5031
|
+
.range([0, innerHeight]);
|
|
4411
5032
|
|
|
4412
5033
|
const svg = d3Selection
|
|
4413
5034
|
.select(container)
|
|
4414
5035
|
.append('svg')
|
|
4415
|
-
.attr('
|
|
4416
|
-
.attr('
|
|
5036
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
5037
|
+
.attr('width', exportDims ? width : '100%')
|
|
5038
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
4417
5039
|
.style('background', bgColor);
|
|
4418
5040
|
|
|
4419
5041
|
const g = svg
|
|
@@ -4432,38 +5054,36 @@ export function renderTimeline(
|
|
|
4432
5054
|
renderEras(
|
|
4433
5055
|
g,
|
|
4434
5056
|
timelineEras,
|
|
4435
|
-
|
|
4436
|
-
|
|
5057
|
+
yScale,
|
|
5058
|
+
true,
|
|
4437
5059
|
innerWidth,
|
|
4438
5060
|
innerHeight,
|
|
4439
5061
|
(s, e) => fadeToEra(g, s, e),
|
|
4440
5062
|
() => fadeReset(g),
|
|
4441
5063
|
timelineScale,
|
|
4442
5064
|
tooltip,
|
|
4443
|
-
palette
|
|
4444
|
-
eraReserve ? eraLabelY : undefined
|
|
5065
|
+
palette
|
|
4445
5066
|
);
|
|
4446
5067
|
|
|
4447
5068
|
renderMarkers(
|
|
4448
5069
|
g,
|
|
4449
5070
|
timelineMarkers,
|
|
4450
|
-
|
|
4451
|
-
|
|
5071
|
+
yScale,
|
|
5072
|
+
true,
|
|
4452
5073
|
innerWidth,
|
|
4453
5074
|
innerHeight,
|
|
4454
5075
|
(d) => fadeToMarker(g, d),
|
|
4455
5076
|
() => fadeReset(g),
|
|
4456
5077
|
timelineScale,
|
|
4457
5078
|
tooltip,
|
|
4458
|
-
palette
|
|
4459
|
-
markerReserve ? markerLabelY : undefined
|
|
5079
|
+
palette
|
|
4460
5080
|
);
|
|
4461
5081
|
|
|
4462
5082
|
if (timelineScale) {
|
|
4463
5083
|
renderTimeScale(
|
|
4464
5084
|
g,
|
|
4465
|
-
|
|
4466
|
-
|
|
5085
|
+
yScale,
|
|
5086
|
+
true,
|
|
4467
5087
|
innerWidth,
|
|
4468
5088
|
innerHeight,
|
|
4469
5089
|
textColor,
|
|
@@ -4474,67 +5094,63 @@ export function renderTimeline(
|
|
|
4474
5094
|
);
|
|
4475
5095
|
}
|
|
4476
5096
|
|
|
4477
|
-
//
|
|
4478
|
-
// events can start at y=0 (chart top edge).
|
|
4479
|
-
let curY = 0;
|
|
4480
|
-
|
|
4481
|
-
// Render swimlane backgrounds first (so they appear behind events)
|
|
4482
|
-
// Extend into left margin to include group names
|
|
5097
|
+
// Render swimlane backgrounds for vertical lanes
|
|
4483
5098
|
if (timelineSwimlanes || tagLanes) {
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
const
|
|
4487
|
-
// Alternate between light gray and transparent for visual separation
|
|
4488
|
-
const fillColor = idx % 2 === 0 ? textColor : 'transparent';
|
|
5099
|
+
laneNames.forEach((laneName, laneIdx) => {
|
|
5100
|
+
const laneX = laneIdx * laneWidth;
|
|
5101
|
+
const fillColor = laneIdx % 2 === 0 ? textColor : 'transparent';
|
|
4489
5102
|
g.append('rect')
|
|
4490
5103
|
.attr('class', 'tl-swimlane')
|
|
4491
|
-
.attr('data-group',
|
|
4492
|
-
.attr('x',
|
|
4493
|
-
.attr('y',
|
|
4494
|
-
.attr('width',
|
|
4495
|
-
.attr('height',
|
|
5104
|
+
.attr('data-group', laneName)
|
|
5105
|
+
.attr('x', laneX)
|
|
5106
|
+
.attr('y', 0)
|
|
5107
|
+
.attr('width', laneWidth)
|
|
5108
|
+
.attr('height', innerHeight)
|
|
4496
5109
|
.attr('fill', fillColor)
|
|
4497
5110
|
.attr('opacity', 0.06);
|
|
4498
|
-
swimY += laneSpan + GROUP_GAP;
|
|
4499
5111
|
});
|
|
4500
5112
|
}
|
|
4501
5113
|
|
|
4502
|
-
|
|
4503
|
-
const
|
|
4504
|
-
const
|
|
5114
|
+
laneNames.forEach((laneName, laneIdx) => {
|
|
5115
|
+
const laneX = laneIdx * laneWidth;
|
|
5116
|
+
const laneColor = groupColorMap.get(laneName) ?? textColor;
|
|
5117
|
+
const laneCenter = laneX + laneWidth / 2;
|
|
4505
5118
|
|
|
4506
|
-
// Group label — left of lane, vertically centred
|
|
4507
|
-
const group = timelineGroups.find((grp) => grp.name === lane.name);
|
|
4508
5119
|
const headerG = g
|
|
4509
5120
|
.append('g')
|
|
4510
5121
|
.attr('class', 'tl-lane-header')
|
|
4511
|
-
.attr('data-group',
|
|
5122
|
+
.attr('data-group', laneName)
|
|
4512
5123
|
.style('cursor', 'pointer')
|
|
4513
|
-
.on('mouseenter', () => fadeToGroup(g,
|
|
4514
|
-
.on('mouseleave', () => fadeReset(g))
|
|
4515
|
-
.on('click', () => {
|
|
4516
|
-
if (onClickItem && group?.lineNumber) onClickItem(group.lineNumber);
|
|
4517
|
-
});
|
|
5124
|
+
.on('mouseenter', () => fadeToGroup(g, laneName))
|
|
5125
|
+
.on('mouseleave', () => fadeReset(g));
|
|
4518
5126
|
|
|
4519
5127
|
headerG
|
|
4520
5128
|
.append('text')
|
|
4521
|
-
.attr('x',
|
|
4522
|
-
.attr('y',
|
|
4523
|
-
.attr('
|
|
4524
|
-
.attr('text-anchor', 'start')
|
|
5129
|
+
.attr('x', laneCenter)
|
|
5130
|
+
.attr('y', -15)
|
|
5131
|
+
.attr('text-anchor', 'middle')
|
|
4525
5132
|
.attr('fill', laneColor)
|
|
4526
5133
|
.attr('font-size', '12px')
|
|
4527
5134
|
.attr('font-weight', '600')
|
|
4528
|
-
.text(
|
|
5135
|
+
.text(laneName);
|
|
5136
|
+
|
|
5137
|
+
g.append('line')
|
|
5138
|
+
.attr('x1', laneCenter)
|
|
5139
|
+
.attr('y1', 0)
|
|
5140
|
+
.attr('x2', laneCenter)
|
|
5141
|
+
.attr('y2', innerHeight)
|
|
5142
|
+
.attr('stroke', mutedColor)
|
|
5143
|
+
.attr('stroke-width', 1)
|
|
5144
|
+
.attr('stroke-dasharray', '4,4');
|
|
4529
5145
|
|
|
4530
|
-
|
|
4531
|
-
const y = curY + i * rowH + rowH / 2;
|
|
4532
|
-
const x = xScale(parseTimelineDate(ev.date));
|
|
5146
|
+
const laneEvents = laneEventsByName.get(laneName) ?? [];
|
|
4533
5147
|
|
|
5148
|
+
for (const ev of laneEvents) {
|
|
5149
|
+
const y = yScale(parseTimelineDate(ev.date));
|
|
4534
5150
|
const evG = g
|
|
4535
5151
|
.append('g')
|
|
4536
5152
|
.attr('class', 'tl-event')
|
|
4537
|
-
.attr('data-group',
|
|
5153
|
+
.attr('data-group', laneName)
|
|
4538
5154
|
.attr('data-line-number', String(ev.lineNumber))
|
|
4539
5155
|
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
4540
5156
|
.attr(
|
|
@@ -4543,32 +5159,15 @@ export function renderTimeline(
|
|
|
4543
5159
|
)
|
|
4544
5160
|
.style('cursor', 'pointer')
|
|
4545
5161
|
.on('mouseenter', function (event: MouseEvent) {
|
|
4546
|
-
fadeToGroup(g,
|
|
4547
|
-
|
|
4548
|
-
showEventDatesOnScale(
|
|
4549
|
-
g,
|
|
4550
|
-
xScale,
|
|
4551
|
-
ev.date,
|
|
4552
|
-
ev.endDate,
|
|
4553
|
-
innerHeight,
|
|
4554
|
-
laneColor
|
|
4555
|
-
);
|
|
4556
|
-
} else {
|
|
4557
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4558
|
-
}
|
|
5162
|
+
fadeToGroup(g, laneName);
|
|
5163
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4559
5164
|
})
|
|
4560
5165
|
.on('mouseleave', function () {
|
|
4561
5166
|
fadeReset(g);
|
|
4562
|
-
|
|
4563
|
-
hideEventDatesOnScale(g);
|
|
4564
|
-
} else {
|
|
4565
|
-
hideTooltip(tooltip);
|
|
4566
|
-
}
|
|
5167
|
+
hideTooltip(tooltip);
|
|
4567
5168
|
})
|
|
4568
5169
|
.on('mousemove', function (event: MouseEvent) {
|
|
4569
|
-
|
|
4570
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4571
|
-
}
|
|
5170
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4572
5171
|
})
|
|
4573
5172
|
.on('click', () => {
|
|
4574
5173
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
@@ -4578,18 +5177,14 @@ export function renderTimeline(
|
|
|
4578
5177
|
const evColor = eventColor(ev);
|
|
4579
5178
|
|
|
4580
5179
|
if (ev.endDate) {
|
|
4581
|
-
const
|
|
4582
|
-
const
|
|
4583
|
-
// Estimate label width (~7px per char at 13px font) + padding
|
|
4584
|
-
const estLabelWidth = ev.label.length * 7 + 16;
|
|
4585
|
-
const labelFitsInside = rectW >= estLabelWidth;
|
|
5180
|
+
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
5181
|
+
const rectH = Math.max(y2 - y, 4);
|
|
4586
5182
|
|
|
4587
5183
|
let fill: string = shapeFill(palette, evColor, isDark, { solid });
|
|
4588
5184
|
let stroke: string = evColor;
|
|
4589
5185
|
if (ev.uncertain) {
|
|
4590
|
-
|
|
4591
|
-
const
|
|
4592
|
-
const strokeGradientId = `uncertain-s-${ev.lineNumber}`;
|
|
5186
|
+
const gradientId = `uncertain-vg-${ev.lineNumber}`;
|
|
5187
|
+
const strokeGradientId = `uncertain-vg-s-${ev.lineNumber}`;
|
|
4593
5188
|
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4594
5189
|
const defsEl = d3Selection.select(defs as Element);
|
|
4595
5190
|
defsEl
|
|
@@ -4597,8 +5192,8 @@ export function renderTimeline(
|
|
|
4597
5192
|
.attr('id', gradientId)
|
|
4598
5193
|
.attr('x1', '0%')
|
|
4599
5194
|
.attr('y1', '0%')
|
|
4600
|
-
.attr('x2', '
|
|
4601
|
-
.attr('y2', '
|
|
5195
|
+
.attr('x2', '0%')
|
|
5196
|
+
.attr('y2', '100%')
|
|
4602
5197
|
.selectAll('stop')
|
|
4603
5198
|
.data([
|
|
4604
5199
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4608,15 +5203,15 @@ export function renderTimeline(
|
|
|
4608
5203
|
.enter()
|
|
4609
5204
|
.append('stop')
|
|
4610
5205
|
.attr('offset', (d) => d.offset)
|
|
4611
|
-
.attr('stop-color', mix(
|
|
5206
|
+
.attr('stop-color', mix(laneColor, bg, 30))
|
|
4612
5207
|
.attr('stop-opacity', (d) => d.opacity);
|
|
4613
5208
|
defsEl
|
|
4614
5209
|
.append('linearGradient')
|
|
4615
5210
|
.attr('id', strokeGradientId)
|
|
4616
5211
|
.attr('x1', '0%')
|
|
4617
5212
|
.attr('y1', '0%')
|
|
4618
|
-
.attr('x2', '
|
|
4619
|
-
.attr('y2', '
|
|
5213
|
+
.attr('x2', '0%')
|
|
5214
|
+
.attr('y2', '100%')
|
|
4620
5215
|
.selectAll('stop')
|
|
4621
5216
|
.data([
|
|
4622
5217
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4634,108 +5229,71 @@ export function renderTimeline(
|
|
|
4634
5229
|
|
|
4635
5230
|
evG
|
|
4636
5231
|
.append('rect')
|
|
4637
|
-
.attr('x',
|
|
4638
|
-
.attr('y', y
|
|
4639
|
-
.attr('width',
|
|
4640
|
-
.attr('height',
|
|
5232
|
+
.attr('x', laneCenter - 6)
|
|
5233
|
+
.attr('y', y)
|
|
5234
|
+
.attr('width', 12)
|
|
5235
|
+
.attr('height', rectH)
|
|
4641
5236
|
.attr('rx', 4)
|
|
4642
5237
|
.attr('fill', fill)
|
|
4643
5238
|
.attr('stroke', stroke)
|
|
4644
5239
|
.attr('stroke-width', 2);
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
.attr('text-anchor', 'start')
|
|
4654
|
-
.attr('fill', textColor)
|
|
4655
|
-
.attr('font-size', '13px')
|
|
4656
|
-
.text(ev.label);
|
|
4657
|
-
} else {
|
|
4658
|
-
// Text outside bar - check if it fits on left or must go right
|
|
4659
|
-
const wouldFlipLeft = x + rectW > innerWidth * 0.6;
|
|
4660
|
-
const labelFitsLeft = x - 6 - estLabelWidth > 0;
|
|
4661
|
-
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4662
|
-
evG
|
|
4663
|
-
.append('text')
|
|
4664
|
-
.attr('x', flipLeft ? x - 6 : x + rectW + 6)
|
|
4665
|
-
.attr('y', y)
|
|
4666
|
-
.attr('dy', '0.35em')
|
|
4667
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4668
|
-
.attr('fill', textColor)
|
|
4669
|
-
.attr('font-size', '13px')
|
|
4670
|
-
.text(ev.label);
|
|
4671
|
-
}
|
|
5240
|
+
evG
|
|
5241
|
+
.append('text')
|
|
5242
|
+
.attr('x', laneCenter + 14)
|
|
5243
|
+
.attr('y', y + rectH / 2)
|
|
5244
|
+
.attr('dy', '0.35em')
|
|
5245
|
+
.attr('fill', textColor)
|
|
5246
|
+
.attr('font-size', '10px')
|
|
5247
|
+
.text(ev.label);
|
|
4672
5248
|
} else {
|
|
4673
|
-
// Point event (no end date) - render as circle with label
|
|
4674
|
-
const estLabelWidth = ev.label.length * 7;
|
|
4675
|
-
// Only flip left if past 60% AND label fits without colliding with group name area
|
|
4676
|
-
const wouldFlipLeft = x > innerWidth * 0.6;
|
|
4677
|
-
const labelFitsLeft = x - 10 - estLabelWidth > 0;
|
|
4678
|
-
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4679
5249
|
evG
|
|
4680
5250
|
.append('circle')
|
|
4681
|
-
.attr('cx',
|
|
5251
|
+
.attr('cx', laneCenter)
|
|
4682
5252
|
.attr('cy', y)
|
|
4683
|
-
.attr('r',
|
|
5253
|
+
.attr('r', 4)
|
|
4684
5254
|
.attr('fill', shapeFill(palette, evColor, isDark, { solid }))
|
|
4685
5255
|
.attr('stroke', evColor)
|
|
4686
5256
|
.attr('stroke-width', 2);
|
|
4687
5257
|
evG
|
|
4688
5258
|
.append('text')
|
|
4689
|
-
.attr('x',
|
|
5259
|
+
.attr('x', laneCenter + 10)
|
|
4690
5260
|
.attr('y', y)
|
|
4691
5261
|
.attr('dy', '0.35em')
|
|
4692
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4693
5262
|
.attr('fill', textColor)
|
|
4694
|
-
.attr('font-size', '
|
|
5263
|
+
.attr('font-size', '10px')
|
|
4695
5264
|
.text(ev.label);
|
|
4696
5265
|
}
|
|
4697
|
-
}
|
|
4698
|
-
|
|
4699
|
-
curY += laneSpan + GROUP_GAP;
|
|
4700
|
-
}
|
|
5266
|
+
}
|
|
5267
|
+
});
|
|
4701
5268
|
} else {
|
|
4702
|
-
// === TIME SORT,
|
|
4703
|
-
const
|
|
4704
|
-
|
|
4705
|
-
.sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
|
|
4706
|
-
|
|
4707
|
-
const scaleMargin = timelineScale ? 24 : 0;
|
|
4708
|
-
// Per-feature header rows: era + marker each get their own row, reserved
|
|
4709
|
-
// only when present (mirrors the gantt header stack).
|
|
4710
|
-
const ERA_ROW_H = 22;
|
|
4711
|
-
const MARKER_ROW_H = 22;
|
|
4712
|
-
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
4713
|
-
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
4714
|
-
const topScaleH = timelineScale ? 40 : 0;
|
|
5269
|
+
// === TIME SORT, vertical: single vertical axis ===
|
|
5270
|
+
const scaleMargin = timelineScale ? 40 : 0;
|
|
5271
|
+
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
4715
5272
|
const margin = {
|
|
4716
|
-
top: 104 +
|
|
4717
|
-
right:
|
|
4718
|
-
bottom: 40
|
|
4719
|
-
left: 60,
|
|
5273
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
5274
|
+
right: 200,
|
|
5275
|
+
bottom: 40,
|
|
5276
|
+
left: 60 + scaleMargin,
|
|
4720
5277
|
};
|
|
4721
|
-
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
4722
|
-
const eraLabelY = eraReserve
|
|
4723
|
-
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4724
|
-
: 0;
|
|
4725
5278
|
const innerWidth = width - margin.left - margin.right;
|
|
4726
5279
|
const innerHeight = height - margin.top - margin.bottom;
|
|
4727
|
-
const
|
|
5280
|
+
const axisX = 20;
|
|
4728
5281
|
|
|
4729
|
-
const
|
|
5282
|
+
const yScale = d3Scale
|
|
4730
5283
|
.scaleLinear()
|
|
4731
5284
|
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4732
|
-
.range([0,
|
|
5285
|
+
.range([0, innerHeight]);
|
|
5286
|
+
|
|
5287
|
+
const sorted = timelineEvents
|
|
5288
|
+
.slice()
|
|
5289
|
+
.sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
|
|
4733
5290
|
|
|
4734
5291
|
const svg = d3Selection
|
|
4735
5292
|
.select(container)
|
|
4736
5293
|
.append('svg')
|
|
4737
|
-
.attr('
|
|
4738
|
-
.attr('
|
|
5294
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
5295
|
+
.attr('width', exportDims ? width : '100%')
|
|
5296
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
4739
5297
|
.style('background', bgColor);
|
|
4740
5298
|
|
|
4741
5299
|
const g = svg
|
|
@@ -4754,38 +5312,36 @@ export function renderTimeline(
|
|
|
4754
5312
|
renderEras(
|
|
4755
5313
|
g,
|
|
4756
5314
|
timelineEras,
|
|
4757
|
-
|
|
4758
|
-
|
|
5315
|
+
yScale,
|
|
5316
|
+
true,
|
|
4759
5317
|
innerWidth,
|
|
4760
5318
|
innerHeight,
|
|
4761
5319
|
(s, e) => fadeToEra(g, s, e),
|
|
4762
5320
|
() => fadeReset(g),
|
|
4763
5321
|
timelineScale,
|
|
4764
5322
|
tooltip,
|
|
4765
|
-
palette
|
|
4766
|
-
eraReserve ? eraLabelY : undefined
|
|
5323
|
+
palette
|
|
4767
5324
|
);
|
|
4768
5325
|
|
|
4769
5326
|
renderMarkers(
|
|
4770
5327
|
g,
|
|
4771
5328
|
timelineMarkers,
|
|
4772
|
-
|
|
4773
|
-
|
|
5329
|
+
yScale,
|
|
5330
|
+
true,
|
|
4774
5331
|
innerWidth,
|
|
4775
5332
|
innerHeight,
|
|
4776
5333
|
(d) => fadeToMarker(g, d),
|
|
4777
5334
|
() => fadeReset(g),
|
|
4778
5335
|
timelineScale,
|
|
4779
5336
|
tooltip,
|
|
4780
|
-
palette
|
|
4781
|
-
markerReserve ? markerLabelY : undefined
|
|
5337
|
+
palette
|
|
4782
5338
|
);
|
|
4783
5339
|
|
|
4784
5340
|
if (timelineScale) {
|
|
4785
5341
|
renderTimeScale(
|
|
4786
5342
|
g,
|
|
4787
|
-
|
|
4788
|
-
|
|
5343
|
+
yScale,
|
|
5344
|
+
true,
|
|
4789
5345
|
innerWidth,
|
|
4790
5346
|
innerHeight,
|
|
4791
5347
|
textColor,
|
|
@@ -4796,9 +5352,8 @@ export function renderTimeline(
|
|
|
4796
5352
|
);
|
|
4797
5353
|
}
|
|
4798
5354
|
|
|
4799
|
-
// Group legend
|
|
5355
|
+
// Group legend (pill style)
|
|
4800
5356
|
if (timelineGroups.length > 0) {
|
|
4801
|
-
const legendY = timelineScale ? -75 : -55;
|
|
4802
5357
|
renderTimelineGroupLegend(
|
|
4803
5358
|
g,
|
|
4804
5359
|
timelineGroups,
|
|
@@ -4806,17 +5361,23 @@ export function renderTimeline(
|
|
|
4806
5361
|
textColor,
|
|
4807
5362
|
palette,
|
|
4808
5363
|
isDark,
|
|
4809
|
-
|
|
5364
|
+
-55,
|
|
4810
5365
|
(name) => fadeToGroup(g, name),
|
|
4811
5366
|
() => fadeReset(g)
|
|
4812
5367
|
);
|
|
4813
5368
|
}
|
|
4814
5369
|
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
5370
|
+
g.append('line')
|
|
5371
|
+
.attr('x1', axisX)
|
|
5372
|
+
.attr('y1', 0)
|
|
5373
|
+
.attr('x2', axisX)
|
|
5374
|
+
.attr('y2', innerHeight)
|
|
5375
|
+
.attr('stroke', mutedColor)
|
|
5376
|
+
.attr('stroke-width', 1)
|
|
5377
|
+
.attr('stroke-dasharray', '4,4');
|
|
5378
|
+
|
|
5379
|
+
for (const ev of sorted) {
|
|
5380
|
+
const y = yScale(parseTimelineDate(ev.date));
|
|
4820
5381
|
const color = eventColor(ev);
|
|
4821
5382
|
|
|
4822
5383
|
const evG = g
|
|
@@ -4832,31 +5393,14 @@ export function renderTimeline(
|
|
|
4832
5393
|
.style('cursor', 'pointer')
|
|
4833
5394
|
.on('mouseenter', function (event: MouseEvent) {
|
|
4834
5395
|
if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
|
|
4835
|
-
|
|
4836
|
-
showEventDatesOnScale(
|
|
4837
|
-
g,
|
|
4838
|
-
xScale,
|
|
4839
|
-
ev.date,
|
|
4840
|
-
ev.endDate,
|
|
4841
|
-
innerHeight,
|
|
4842
|
-
color
|
|
4843
|
-
);
|
|
4844
|
-
} else {
|
|
4845
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4846
|
-
}
|
|
5396
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4847
5397
|
})
|
|
4848
5398
|
.on('mouseleave', function () {
|
|
4849
5399
|
fadeReset(g);
|
|
4850
|
-
|
|
4851
|
-
hideEventDatesOnScale(g);
|
|
4852
|
-
} else {
|
|
4853
|
-
hideTooltip(tooltip);
|
|
4854
|
-
}
|
|
5400
|
+
hideTooltip(tooltip);
|
|
4855
5401
|
})
|
|
4856
5402
|
.on('mousemove', function (event: MouseEvent) {
|
|
4857
|
-
|
|
4858
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4859
|
-
}
|
|
5403
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4860
5404
|
})
|
|
4861
5405
|
.on('click', () => {
|
|
4862
5406
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
@@ -4864,18 +5408,14 @@ export function renderTimeline(
|
|
|
4864
5408
|
setTagAttrs(evG, ev);
|
|
4865
5409
|
|
|
4866
5410
|
if (ev.endDate) {
|
|
4867
|
-
const
|
|
4868
|
-
const
|
|
4869
|
-
// Estimate label width (~7px per char at 13px font) + padding
|
|
4870
|
-
const estLabelWidth = ev.label.length * 7 + 16;
|
|
4871
|
-
const labelFitsInside = rectW >= estLabelWidth;
|
|
5411
|
+
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
5412
|
+
const rectH = Math.max(y2 - y, 4);
|
|
4872
5413
|
|
|
4873
5414
|
let fill: string = shapeFill(palette, color, isDark, { solid });
|
|
4874
5415
|
let stroke: string = color;
|
|
4875
5416
|
if (ev.uncertain) {
|
|
4876
|
-
|
|
4877
|
-
const
|
|
4878
|
-
const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;
|
|
5417
|
+
const gradientId = `uncertain-v-${ev.lineNumber}`;
|
|
5418
|
+
const strokeGradientId = `uncertain-v-s-${ev.lineNumber}`;
|
|
4879
5419
|
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4880
5420
|
const defsEl = d3Selection.select(defs as Element);
|
|
4881
5421
|
defsEl
|
|
@@ -4883,8 +5423,8 @@ export function renderTimeline(
|
|
|
4883
5423
|
.attr('id', gradientId)
|
|
4884
5424
|
.attr('x1', '0%')
|
|
4885
5425
|
.attr('y1', '0%')
|
|
4886
|
-
.attr('x2', '
|
|
4887
|
-
.attr('y2', '
|
|
5426
|
+
.attr('x2', '0%')
|
|
5427
|
+
.attr('y2', '100%')
|
|
4888
5428
|
.selectAll('stop')
|
|
4889
5429
|
.data([
|
|
4890
5430
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4901,8 +5441,8 @@ export function renderTimeline(
|
|
|
4901
5441
|
.attr('id', strokeGradientId)
|
|
4902
5442
|
.attr('x1', '0%')
|
|
4903
5443
|
.attr('y1', '0%')
|
|
4904
|
-
.attr('x2', '
|
|
4905
|
-
.attr('y2', '
|
|
5444
|
+
.attr('x2', '0%')
|
|
5445
|
+
.attr('y2', '100%')
|
|
4906
5446
|
.selectAll('stop')
|
|
4907
5447
|
.data([
|
|
4908
5448
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4920,353 +5460,169 @@ export function renderTimeline(
|
|
|
4920
5460
|
|
|
4921
5461
|
evG
|
|
4922
5462
|
.append('rect')
|
|
4923
|
-
.attr('x',
|
|
4924
|
-
.attr('y', y
|
|
4925
|
-
.attr('width',
|
|
4926
|
-
.attr('height',
|
|
5463
|
+
.attr('x', axisX - 6)
|
|
5464
|
+
.attr('y', y)
|
|
5465
|
+
.attr('width', 12)
|
|
5466
|
+
.attr('height', rectH)
|
|
4927
5467
|
.attr('rx', 4)
|
|
4928
5468
|
.attr('fill', fill)
|
|
4929
5469
|
.attr('stroke', stroke)
|
|
4930
5470
|
.attr('stroke-width', 2);
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
.attr('text-anchor', 'start')
|
|
4940
|
-
.attr('fill', textColor)
|
|
4941
|
-
.attr('font-size', '13px')
|
|
4942
|
-
.text(ev.label);
|
|
4943
|
-
} else {
|
|
4944
|
-
// Text outside bar - check if it fits on left or must go right
|
|
4945
|
-
const wouldFlipLeft = x + rectW > innerWidth * 0.6;
|
|
4946
|
-
const labelFitsLeft = x - 6 - estLabelWidth > 0;
|
|
4947
|
-
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4948
|
-
evG
|
|
4949
|
-
.append('text')
|
|
4950
|
-
.attr('x', flipLeft ? x - 6 : x + rectW + 6)
|
|
4951
|
-
.attr('y', y)
|
|
4952
|
-
.attr('dy', '0.35em')
|
|
4953
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4954
|
-
.attr('fill', textColor)
|
|
4955
|
-
.attr('font-size', '13px')
|
|
4956
|
-
.text(ev.label);
|
|
4957
|
-
}
|
|
5471
|
+
evG
|
|
5472
|
+
.append('text')
|
|
5473
|
+
.attr('x', axisX + 16)
|
|
5474
|
+
.attr('y', y + rectH / 2)
|
|
5475
|
+
.attr('dy', '0.35em')
|
|
5476
|
+
.attr('fill', textColor)
|
|
5477
|
+
.attr('font-size', '11px')
|
|
5478
|
+
.text(ev.label);
|
|
4958
5479
|
} else {
|
|
4959
|
-
// Point event (no end date) - render as circle with label
|
|
4960
|
-
const estLabelWidth = ev.label.length * 7;
|
|
4961
|
-
// Only flip left if past 60% AND label fits without going off-chart
|
|
4962
|
-
const wouldFlipLeft = x > innerWidth * 0.6;
|
|
4963
|
-
const labelFitsLeft = x - 10 - estLabelWidth > 0;
|
|
4964
|
-
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4965
5480
|
evG
|
|
4966
5481
|
.append('circle')
|
|
4967
|
-
.attr('cx',
|
|
5482
|
+
.attr('cx', axisX)
|
|
4968
5483
|
.attr('cy', y)
|
|
4969
|
-
.attr('r',
|
|
5484
|
+
.attr('r', 4)
|
|
4970
5485
|
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4971
5486
|
.attr('stroke', color)
|
|
4972
5487
|
.attr('stroke-width', 2);
|
|
4973
5488
|
evG
|
|
4974
5489
|
.append('text')
|
|
4975
|
-
.attr('x',
|
|
5490
|
+
.attr('x', axisX + 16)
|
|
4976
5491
|
.attr('y', y)
|
|
4977
5492
|
.attr('dy', '0.35em')
|
|
4978
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4979
5493
|
.attr('fill', textColor)
|
|
4980
|
-
.attr('font-size', '
|
|
5494
|
+
.attr('font-size', '11px')
|
|
4981
5495
|
.text(ev.label);
|
|
4982
5496
|
}
|
|
4983
|
-
});
|
|
4984
|
-
}
|
|
4985
|
-
|
|
4986
|
-
// ── Tag Legend (org-chart-style pills) ──
|
|
4987
|
-
if (parsed.timelineTagGroups.length > 0) {
|
|
4988
|
-
const LG_HEIGHT = TL_LEGEND_HEIGHT;
|
|
4989
|
-
const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
|
|
4990
|
-
const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
|
|
4991
|
-
const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
|
|
4992
|
-
const LG_DOT_R = TL_LEGEND_DOT_R;
|
|
4993
|
-
const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
|
|
4994
|
-
const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
|
|
4995
|
-
const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
|
|
4996
|
-
// LG_GROUP_GAP no longer needed — centralized legend handles spacing
|
|
4997
|
-
const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local
|
|
4998
|
-
|
|
4999
|
-
const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
|
|
5000
|
-
const mainG = mainSvg.select<SVGGElement>('g');
|
|
5001
|
-
if (!mainSvg.empty() && !mainG.empty()) {
|
|
5002
|
-
// Position legend at top, below title
|
|
5003
|
-
const legendY = title ? 50 : 10;
|
|
5004
|
-
|
|
5005
|
-
// Pre-compute group widths (minified and expanded)
|
|
5006
|
-
type LegendGroup = {
|
|
5007
|
-
group: TagGroup;
|
|
5008
|
-
minifiedWidth: number;
|
|
5009
|
-
expandedWidth: number;
|
|
5010
|
-
};
|
|
5011
|
-
const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
|
|
5012
|
-
const pillW =
|
|
5013
|
-
measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
5014
|
-
// Expanded: pill + icon (unless viewMode) + entries
|
|
5015
|
-
const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
|
|
5016
|
-
let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
|
|
5017
|
-
for (const entry of g.entries) {
|
|
5018
|
-
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
5019
|
-
entryX =
|
|
5020
|
-
textX +
|
|
5021
|
-
measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) +
|
|
5022
|
-
LG_ENTRY_TRAIL;
|
|
5023
|
-
}
|
|
5024
|
-
return {
|
|
5025
|
-
group: g,
|
|
5026
|
-
minifiedWidth: pillW,
|
|
5027
|
-
expandedWidth: entryX + LG_CAPSULE_PAD,
|
|
5028
|
-
};
|
|
5029
|
-
});
|
|
5030
|
-
|
|
5031
|
-
// Two independent state axes: swimlane source + color source
|
|
5032
|
-
let currentActiveGroup: string | null = activeTagGroup ?? null;
|
|
5033
|
-
let currentSwimlaneGroup: string | null = swimlaneTagGroup ?? null;
|
|
5034
|
-
|
|
5035
|
-
/** Render the swimlane icon (3 horizontal bars of varying width) */
|
|
5036
|
-
function drawSwimlaneIcon(
|
|
5037
|
-
parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
5038
|
-
x: number,
|
|
5039
|
-
y: number,
|
|
5040
|
-
isSwimActive: boolean
|
|
5041
|
-
) {
|
|
5042
|
-
const iconG = parent
|
|
5043
|
-
.append('g')
|
|
5044
|
-
.attr('class', 'tl-swimlane-icon')
|
|
5045
|
-
.attr('transform', `translate(${x}, ${y})`)
|
|
5046
|
-
.style('cursor', 'pointer');
|
|
5047
|
-
|
|
5048
|
-
const barColor = isSwimActive ? palette.primary : palette.textMuted;
|
|
5049
|
-
const barOpacity = isSwimActive ? 1 : 0.35;
|
|
5050
|
-
const bars = [
|
|
5051
|
-
{ y: 0, w: 8 },
|
|
5052
|
-
{ y: 4, w: 12 },
|
|
5053
|
-
{ y: 8, w: 6 },
|
|
5054
|
-
];
|
|
5055
|
-
for (const bar of bars) {
|
|
5056
|
-
iconG
|
|
5057
|
-
.append('rect')
|
|
5058
|
-
.attr('x', 0)
|
|
5059
|
-
.attr('y', bar.y)
|
|
5060
|
-
.attr('width', bar.w)
|
|
5061
|
-
.attr('height', 2)
|
|
5062
|
-
.attr('rx', 1)
|
|
5063
|
-
.attr('fill', barColor)
|
|
5064
|
-
.attr('opacity', barOpacity);
|
|
5065
|
-
}
|
|
5066
|
-
return iconG;
|
|
5067
|
-
}
|
|
5068
|
-
|
|
5069
|
-
/** Full re-render with updated swimlane state */
|
|
5070
|
-
function relayout() {
|
|
5071
|
-
renderTimeline(
|
|
5072
|
-
container,
|
|
5073
|
-
parsed,
|
|
5074
|
-
palette,
|
|
5075
|
-
isDark,
|
|
5076
|
-
onClickItem,
|
|
5077
|
-
exportDims,
|
|
5078
|
-
currentActiveGroup,
|
|
5079
|
-
currentSwimlaneGroup,
|
|
5080
|
-
onTagStateChange,
|
|
5081
|
-
viewMode
|
|
5082
|
-
);
|
|
5083
|
-
}
|
|
5084
|
-
|
|
5085
|
-
function drawLegend() {
|
|
5086
|
-
// Remove previous legend
|
|
5087
|
-
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
5088
|
-
mainSvg.selectAll('.tl-tag-legend-container').remove();
|
|
5089
|
-
|
|
5090
|
-
// Effective color source: explicit color group > swimlane group
|
|
5091
|
-
const effectiveColorKey =
|
|
5092
|
-
(currentActiveGroup ?? currentSwimlaneGroup)?.toLowerCase() ?? null;
|
|
5093
|
-
|
|
5094
|
-
// In view mode, only show the color-driving tag group (expanded, non-interactive).
|
|
5095
|
-
// Skip the swimlane group if it's separate from the color group (lane headers already label it).
|
|
5096
|
-
const visibleGroups = viewMode
|
|
5097
|
-
? legendGroups.filter(
|
|
5098
|
-
(lg) =>
|
|
5099
|
-
effectiveColorKey != null &&
|
|
5100
|
-
lg.group.name.toLowerCase() === effectiveColorKey
|
|
5101
|
-
)
|
|
5102
|
-
: legendGroups;
|
|
5103
|
-
|
|
5104
|
-
if (visibleGroups.length === 0) return;
|
|
5105
5497
|
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5498
|
+
// Date label to the left
|
|
5499
|
+
evG
|
|
5500
|
+
.append('text')
|
|
5501
|
+
.attr('x', axisX - 14)
|
|
5502
|
+
.attr(
|
|
5503
|
+
'y',
|
|
5504
|
+
ev.endDate
|
|
5505
|
+
? yScale(parseTimelineDate(ev.date)) +
|
|
5506
|
+
Math.max(
|
|
5507
|
+
yScale(parseTimelineDate(ev.endDate)) -
|
|
5508
|
+
yScale(parseTimelineDate(ev.date)),
|
|
5509
|
+
4
|
|
5510
|
+
) /
|
|
5511
|
+
2
|
|
5512
|
+
: y
|
|
5513
|
+
)
|
|
5514
|
+
.attr('dy', '0.35em')
|
|
5515
|
+
.attr('text-anchor', 'end')
|
|
5516
|
+
.attr('fill', mutedColor)
|
|
5517
|
+
.attr('font-size', '10px')
|
|
5518
|
+
.text(ev.date + (ev.endDate ? `→${ev.endDate}` : ''));
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5116
5522
|
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
|
|
5149
|
-
},
|
|
5150
|
-
onEntryHover: (groupName, entryValue) => {
|
|
5151
|
-
const tagKey = groupName.toLowerCase();
|
|
5152
|
-
if (entryValue) {
|
|
5153
|
-
const tagVal = entryValue.toLowerCase();
|
|
5154
|
-
fadeToTagValue(mainG, tagKey, tagVal);
|
|
5155
|
-
mainSvg
|
|
5156
|
-
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
5157
|
-
.each(function () {
|
|
5158
|
-
const el = d3Selection.select(this);
|
|
5159
|
-
const ev = el.attr('data-legend-entry');
|
|
5160
|
-
const eg =
|
|
5161
|
-
el.attr('data-tag-group') ??
|
|
5162
|
-
(el.node() as Element)
|
|
5163
|
-
?.closest?.('[data-tag-group]')
|
|
5164
|
-
?.getAttribute('data-tag-group');
|
|
5165
|
-
el.attr(
|
|
5166
|
-
'opacity',
|
|
5167
|
-
eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY
|
|
5168
|
-
);
|
|
5169
|
-
});
|
|
5170
|
-
} else {
|
|
5171
|
-
fadeReset(mainG);
|
|
5172
|
-
mainSvg
|
|
5173
|
-
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
5174
|
-
.attr('opacity', 1);
|
|
5175
|
-
}
|
|
5176
|
-
},
|
|
5177
|
-
onGroupRendered: (groupName, groupEl, isActive) => {
|
|
5178
|
-
const groupKey = groupName.toLowerCase();
|
|
5179
|
-
groupEl.attr('data-tag-group', groupKey);
|
|
5180
|
-
if (isActive && !viewMode) {
|
|
5181
|
-
const isSwimActive =
|
|
5182
|
-
currentSwimlaneGroup != null &&
|
|
5183
|
-
currentSwimlaneGroup.toLowerCase() === groupKey;
|
|
5184
|
-
const pillWidth =
|
|
5185
|
-
measureLegendText(groupName, LG_PILL_FONT_SIZE) +
|
|
5186
|
-
LG_PILL_PAD;
|
|
5187
|
-
const pillXOff = LG_CAPSULE_PAD;
|
|
5188
|
-
const iconX = pillXOff + pillWidth + 5;
|
|
5189
|
-
const iconY = (LG_HEIGHT - 10) / 2;
|
|
5190
|
-
const iconEl = drawSwimlaneIcon(
|
|
5191
|
-
groupEl,
|
|
5192
|
-
iconX,
|
|
5193
|
-
iconY,
|
|
5194
|
-
isSwimActive
|
|
5195
|
-
);
|
|
5196
|
-
iconEl
|
|
5197
|
-
.attr('data-swimlane-toggle', groupKey)
|
|
5198
|
-
.on('click', (event: MouseEvent) => {
|
|
5199
|
-
event.stopPropagation();
|
|
5200
|
-
currentSwimlaneGroup =
|
|
5201
|
-
currentSwimlaneGroup === groupKey ? null : groupKey;
|
|
5202
|
-
onTagStateChange?.(
|
|
5203
|
-
currentActiveGroup,
|
|
5204
|
-
currentSwimlaneGroup
|
|
5205
|
-
);
|
|
5206
|
-
relayout();
|
|
5207
|
-
});
|
|
5208
|
-
}
|
|
5209
|
-
},
|
|
5210
|
-
};
|
|
5211
|
-
|
|
5212
|
-
const legendInnerG = legendContainer
|
|
5213
|
-
.append('g')
|
|
5214
|
-
.attr('transform', `translate(0, ${legendY})`);
|
|
5215
|
-
renderLegendD3(
|
|
5216
|
-
legendInnerG,
|
|
5217
|
-
centralConfig,
|
|
5218
|
-
centralState,
|
|
5219
|
-
palette,
|
|
5220
|
-
isDark,
|
|
5221
|
-
centralCallbacks,
|
|
5222
|
-
width
|
|
5223
|
-
);
|
|
5224
|
-
}
|
|
5523
|
+
/**
|
|
5524
|
+
* Renders a timeline chart into the given container using D3.
|
|
5525
|
+
* Supports horizontal (default) and vertical orientation.
|
|
5526
|
+
*/
|
|
5527
|
+
export function renderTimeline(
|
|
5528
|
+
container: HTMLDivElement,
|
|
5529
|
+
parsed: ParsedVisualization,
|
|
5530
|
+
palette: PaletteColors,
|
|
5531
|
+
isDark: boolean,
|
|
5532
|
+
onClickItem?: (lineNumber: number) => void,
|
|
5533
|
+
exportDims?: D3ExportDimensions,
|
|
5534
|
+
activeTagGroup?: string | null,
|
|
5535
|
+
swimlaneTagGroup?: string | null,
|
|
5536
|
+
onTagStateChange?: (
|
|
5537
|
+
activeTagGroup: string | null,
|
|
5538
|
+
swimlaneTagGroup: string | null
|
|
5539
|
+
) => void,
|
|
5540
|
+
viewMode?: boolean,
|
|
5541
|
+
exportMode?: boolean
|
|
5542
|
+
): void {
|
|
5543
|
+
const setup = setupTimeline(
|
|
5544
|
+
container,
|
|
5545
|
+
parsed,
|
|
5546
|
+
palette,
|
|
5547
|
+
isDark,
|
|
5548
|
+
exportDims,
|
|
5549
|
+
activeTagGroup,
|
|
5550
|
+
swimlaneTagGroup
|
|
5551
|
+
);
|
|
5552
|
+
if (!setup) return;
|
|
5553
|
+
swimlaneTagGroup = setup.swimlaneTagGroup;
|
|
5225
5554
|
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
for (const ev of timelineEvents) {
|
|
5229
|
-
eventByLine.set(String(ev.lineNumber), ev);
|
|
5230
|
-
}
|
|
5555
|
+
const { isVertical, tagLanes } = setup;
|
|
5556
|
+
const hovers = makeTimelineHoverHelpers();
|
|
5231
5557
|
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
(ev.group && groupColorMap.has(ev.group)
|
|
5250
|
-
? groupColorMap.get(ev.group)!
|
|
5251
|
-
: textColor);
|
|
5252
|
-
} else {
|
|
5253
|
-
color =
|
|
5254
|
-
ev.group && groupColorMap.has(ev.group)
|
|
5255
|
-
? groupColorMap.get(ev.group)!
|
|
5256
|
-
: textColor;
|
|
5257
|
-
}
|
|
5258
|
-
el.selectAll('rect')
|
|
5259
|
-
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
5260
|
-
.attr('stroke', color);
|
|
5261
|
-
el.selectAll('circle:not(.tl-event-point-outline)')
|
|
5262
|
-
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
5263
|
-
.attr('stroke', color);
|
|
5264
|
-
});
|
|
5265
|
-
}
|
|
5558
|
+
if (isVertical) {
|
|
5559
|
+
renderTimelineVertical(
|
|
5560
|
+
container,
|
|
5561
|
+
parsed,
|
|
5562
|
+
palette,
|
|
5563
|
+
isDark,
|
|
5564
|
+
setup,
|
|
5565
|
+
hovers,
|
|
5566
|
+
onClickItem,
|
|
5567
|
+
exportDims,
|
|
5568
|
+
swimlaneTagGroup,
|
|
5569
|
+
activeTagGroup,
|
|
5570
|
+
onTagStateChange,
|
|
5571
|
+
viewMode
|
|
5572
|
+
);
|
|
5573
|
+
return;
|
|
5574
|
+
}
|
|
5266
5575
|
|
|
5267
|
-
|
|
5268
|
-
|
|
5576
|
+
const useGroupedHorizontal =
|
|
5577
|
+
tagLanes != null ||
|
|
5578
|
+
(parsed.timelineSort === 'group' && parsed.timelineGroups.length > 0);
|
|
5579
|
+
if (useGroupedHorizontal) {
|
|
5580
|
+
renderTimelineHorizontalGrouped(
|
|
5581
|
+
container,
|
|
5582
|
+
parsed,
|
|
5583
|
+
palette,
|
|
5584
|
+
isDark,
|
|
5585
|
+
setup,
|
|
5586
|
+
hovers,
|
|
5587
|
+
onClickItem,
|
|
5588
|
+
exportDims,
|
|
5589
|
+
swimlaneTagGroup,
|
|
5590
|
+
activeTagGroup,
|
|
5591
|
+
onTagStateChange,
|
|
5592
|
+
viewMode
|
|
5593
|
+
);
|
|
5594
|
+
} else {
|
|
5595
|
+
renderTimelineHorizontalTimeSort(
|
|
5596
|
+
container,
|
|
5597
|
+
parsed,
|
|
5598
|
+
palette,
|
|
5599
|
+
isDark,
|
|
5600
|
+
setup,
|
|
5601
|
+
hovers,
|
|
5602
|
+
onClickItem,
|
|
5603
|
+
exportDims,
|
|
5604
|
+
swimlaneTagGroup,
|
|
5605
|
+
activeTagGroup,
|
|
5606
|
+
onTagStateChange,
|
|
5607
|
+
viewMode
|
|
5608
|
+
);
|
|
5269
5609
|
}
|
|
5610
|
+
|
|
5611
|
+
renderTimelineTagLegendOverlay(
|
|
5612
|
+
container,
|
|
5613
|
+
parsed,
|
|
5614
|
+
palette,
|
|
5615
|
+
isDark,
|
|
5616
|
+
setup,
|
|
5617
|
+
hovers,
|
|
5618
|
+
onClickItem,
|
|
5619
|
+
exportDims,
|
|
5620
|
+
swimlaneTagGroup,
|
|
5621
|
+
activeTagGroup,
|
|
5622
|
+
onTagStateChange,
|
|
5623
|
+
viewMode,
|
|
5624
|
+
exportMode
|
|
5625
|
+
);
|
|
5270
5626
|
}
|
|
5271
5627
|
|
|
5272
5628
|
// ============================================================
|
|
@@ -5580,7 +5936,7 @@ export function renderVenn(
|
|
|
5580
5936
|
container: HTMLDivElement,
|
|
5581
5937
|
parsed: ParsedVisualization,
|
|
5582
5938
|
palette: PaletteColors,
|
|
5583
|
-
|
|
5939
|
+
_isDark: boolean,
|
|
5584
5940
|
onClickItem?: (lineNumber: number) => void,
|
|
5585
5941
|
exportDims?: D3ExportDimensions
|
|
5586
5942
|
): void {
|
|
@@ -5955,7 +6311,7 @@ export function renderVenn(
|
|
|
5955
6311
|
const gcx = circles.reduce((s, c) => s + c.x, 0) / n;
|
|
5956
6312
|
const gcy = circles.reduce((s, c) => s + c.y, 0) / n;
|
|
5957
6313
|
|
|
5958
|
-
function exclusiveHSpan(
|
|
6314
|
+
function exclusiveHSpan(_px: number, py: number, ci: number): number {
|
|
5959
6315
|
const dy = py - circles[ci].y;
|
|
5960
6316
|
const halfChord = Math.sqrt(
|
|
5961
6317
|
Math.max(0, circles[ci].r * circles[ci].r - dy * dy)
|
|
@@ -7178,8 +7534,10 @@ export async function renderForExport(
|
|
|
7178
7534
|
c4System?: string;
|
|
7179
7535
|
c4Container?: string;
|
|
7180
7536
|
tagGroup?: string;
|
|
7537
|
+
exportMode?: boolean;
|
|
7181
7538
|
}
|
|
7182
7539
|
): Promise<string> {
|
|
7540
|
+
const exportMode = options?.exportMode ?? false;
|
|
7183
7541
|
// Flowchart and org chart use their own parser pipelines — intercept before parseVisualization()
|
|
7184
7542
|
const { parseDgmoChartType } = await import('./dgmo-router');
|
|
7185
7543
|
const detectedType = parseDgmoChartType(content);
|
|
@@ -7233,7 +7591,9 @@ export async function renderForExport(
|
|
|
7233
7591
|
undefined,
|
|
7234
7592
|
{ width: exportWidth, height: exportHeight },
|
|
7235
7593
|
activeTagGroup,
|
|
7236
|
-
hiddenAttributes
|
|
7594
|
+
hiddenAttributes,
|
|
7595
|
+
undefined,
|
|
7596
|
+
exportMode
|
|
7237
7597
|
);
|
|
7238
7598
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7239
7599
|
}
|
|
@@ -7287,7 +7647,8 @@ export async function renderForExport(
|
|
|
7287
7647
|
undefined,
|
|
7288
7648
|
{ width: exportWidth, height: exportHeight },
|
|
7289
7649
|
activeTagGroup,
|
|
7290
|
-
hiddenAttributes
|
|
7650
|
+
hiddenAttributes,
|
|
7651
|
+
exportMode
|
|
7291
7652
|
);
|
|
7292
7653
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7293
7654
|
}
|
|
@@ -7316,6 +7677,7 @@ export async function renderForExport(
|
|
|
7316
7677
|
collapsedLanes: viewState?.cl ? new Set(viewState.cl) : undefined,
|
|
7317
7678
|
collapsedColumns: viewState?.cc ? new Set(viewState.cc) : undefined,
|
|
7318
7679
|
compactMeta: viewState?.cm,
|
|
7680
|
+
exportMode,
|
|
7319
7681
|
});
|
|
7320
7682
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7321
7683
|
}
|
|
@@ -7343,7 +7705,9 @@ export async function renderForExport(
|
|
|
7343
7705
|
effectivePalette,
|
|
7344
7706
|
theme === 'dark',
|
|
7345
7707
|
undefined,
|
|
7346
|
-
{ width: exportWidth, height: exportHeight }
|
|
7708
|
+
{ width: exportWidth, height: exportHeight },
|
|
7709
|
+
undefined,
|
|
7710
|
+
exportMode
|
|
7347
7711
|
);
|
|
7348
7712
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7349
7713
|
}
|
|
@@ -7377,7 +7741,8 @@ export async function renderForExport(
|
|
|
7377
7741
|
erParsed.options['active-tag'],
|
|
7378
7742
|
viewState?.tag ?? options?.tagGroup
|
|
7379
7743
|
),
|
|
7380
|
-
viewState?.sem
|
|
7744
|
+
viewState?.sem,
|
|
7745
|
+
exportMode
|
|
7381
7746
|
);
|
|
7382
7747
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7383
7748
|
}
|
|
@@ -7397,11 +7762,10 @@ export async function renderForExport(
|
|
|
7397
7762
|
}
|
|
7398
7763
|
}
|
|
7399
7764
|
|
|
7400
|
-
const { layoutBoxesAndLines } = await import('./boxes-and-lines/layout');
|
|
7401
7765
|
const { renderBoxesAndLinesForExport } =
|
|
7402
7766
|
await import('./boxes-and-lines/renderer');
|
|
7403
|
-
|
|
7404
|
-
const blLayout = layoutBoxesAndLines(blParsed);
|
|
7767
|
+
const { layoutBoxesAndLines } = await import('./boxes-and-lines/layout');
|
|
7768
|
+
const blLayout = await layoutBoxesAndLines(blParsed);
|
|
7405
7769
|
const PADDING = 20;
|
|
7406
7770
|
const titleOffset = blParsed.title ? 40 : 0;
|
|
7407
7771
|
const exportWidth = blLayout.width + PADDING * 2;
|
|
@@ -7418,6 +7782,7 @@ export async function renderForExport(
|
|
|
7418
7782
|
exportDims: { width: exportWidth, height: exportHeight },
|
|
7419
7783
|
activeTagGroup: viewState?.tag ?? options?.tagGroup,
|
|
7420
7784
|
hiddenTagValues: blHiddenTagValues,
|
|
7785
|
+
exportMode,
|
|
7421
7786
|
}
|
|
7422
7787
|
);
|
|
7423
7788
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
@@ -7477,7 +7842,7 @@ export async function renderForExport(
|
|
|
7477
7842
|
undefined,
|
|
7478
7843
|
hideDescriptions,
|
|
7479
7844
|
colorByDepth ? null : activeTagGroup,
|
|
7480
|
-
colorByDepth ? { colorByDepth: true } :
|
|
7845
|
+
colorByDepth ? { colorByDepth: true, exportMode } : { exportMode }
|
|
7481
7846
|
);
|
|
7482
7847
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7483
7848
|
}
|
|
@@ -7579,7 +7944,8 @@ export async function renderForExport(
|
|
|
7579
7944
|
c4Parsed.tagGroups,
|
|
7580
7945
|
c4Parsed.options['active-tag'],
|
|
7581
7946
|
viewState?.tag ?? options?.tagGroup
|
|
7582
|
-
)
|
|
7947
|
+
),
|
|
7948
|
+
exportMode
|
|
7583
7949
|
);
|
|
7584
7950
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7585
7951
|
}
|
|
@@ -7746,6 +8112,7 @@ export async function renderForExport(
|
|
|
7746
8112
|
resolved.options.activeTag ?? undefined,
|
|
7747
8113
|
viewState?.tag ?? options?.tagGroup
|
|
7748
8114
|
),
|
|
8115
|
+
exportMode,
|
|
7749
8116
|
},
|
|
7750
8117
|
{ width: EXPORT_W, height: EXPORT_H }
|
|
7751
8118
|
);
|
|
@@ -7793,7 +8160,8 @@ export async function renderForExport(
|
|
|
7793
8160
|
effectivePalette,
|
|
7794
8161
|
theme === 'dark',
|
|
7795
8162
|
{ width: RADAR_EXPORT_W, height: RADAR_EXPORT_H },
|
|
7796
|
-
viewState
|
|
8163
|
+
viewState,
|
|
8164
|
+
exportMode
|
|
7797
8165
|
);
|
|
7798
8166
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7799
8167
|
}
|
|
@@ -7820,6 +8188,7 @@ export async function renderForExport(
|
|
|
7820
8188
|
);
|
|
7821
8189
|
renderJourneyMap(container, jmParsed, effectivePalette, theme === 'dark', {
|
|
7822
8190
|
exportDims: { width: jmLayout.totalWidth, height: jmLayout.totalHeight },
|
|
8191
|
+
exportMode,
|
|
7823
8192
|
});
|
|
7824
8193
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7825
8194
|
}
|
|
@@ -7839,7 +8208,8 @@ export async function renderForExport(
|
|
|
7839
8208
|
effectivePalette,
|
|
7840
8209
|
theme === 'dark',
|
|
7841
8210
|
{ width: EXPORT_WIDTH, height: EXPORT_HEIGHT },
|
|
7842
|
-
viewState
|
|
8211
|
+
viewState,
|
|
8212
|
+
exportMode
|
|
7843
8213
|
);
|
|
7844
8214
|
return finalizeSvgExport(container, theme, effectivePalette);
|
|
7845
8215
|
}
|
|
@@ -7978,7 +8348,10 @@ export async function renderForExport(
|
|
|
7978
8348
|
undefined,
|
|
7979
8349
|
viewState?.tag ?? options?.tagGroup
|
|
7980
8350
|
),
|
|
7981
|
-
viewState?.swim
|
|
8351
|
+
viewState?.swim,
|
|
8352
|
+
undefined,
|
|
8353
|
+
undefined,
|
|
8354
|
+
exportMode
|
|
7982
8355
|
);
|
|
7983
8356
|
} else if (parsed.type === 'venn') {
|
|
7984
8357
|
renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
|