@diagrammo/dgmo 0.14.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/advanced.cjs +53069 -0
- package/dist/advanced.d.cts +4691 -0
- package/dist/advanced.d.ts +4691 -0
- package/dist/advanced.js +52823 -0
- package/dist/auto.cjs +1557 -1295
- package/dist/auto.js +132 -713
- package/dist/auto.mjs +1553 -1291
- package/dist/cli.cjs +173 -150
- package/dist/editor.cjs +1 -0
- package/dist/editor.js +1 -0
- package/dist/highlight.cjs +1 -0
- package/dist/highlight.js +1 -0
- package/dist/index.cjs +2031 -4722
- package/dist/index.d.cts +96 -4464
- package/dist/index.d.ts +96 -4464
- package/dist/index.js +2024 -4475
- package/dist/internal.cjs +51930 -553
- package/dist/internal.d.cts +4526 -102
- package/dist/internal.d.ts +4526 -102
- package/dist/internal.js +51721 -548
- package/dist/pert.cjs +1 -1
- package/dist/pert.js +1 -1
- package/docs/language-reference.md +67 -17
- package/package.json +18 -3
- package/src/advanced.ts +731 -0
- package/src/auto/index.ts +14 -13
- package/src/boxes-and-lines/layout.ts +481 -445
- package/src/c4/parser.ts +7 -7
- package/src/chart-types.ts +0 -5
- package/src/class/parser.ts +1 -9
- package/src/cli.ts +9 -7
- package/src/completion-types.ts +28 -0
- package/src/completion.ts +15 -18
- package/src/cycle/layout.ts +2 -2
- package/src/d3.ts +1455 -1122
- package/src/echarts.ts +11 -11
- package/src/editor/keywords.ts +1 -0
- package/src/er/parser.ts +1 -9
- package/src/er/renderer.ts +1 -1
- package/src/gantt/calculator.ts +1 -11
- package/src/gantt/parser.ts +16 -16
- package/src/gantt/renderer.ts +2 -2
- package/src/graph/flowchart-parser.ts +1 -1
- package/src/graph/flowchart-renderer.ts +1 -1
- package/src/graph/state-renderer.ts +1 -1
- package/src/index.ts +213 -690
- package/src/infra/parser.ts +57 -25
- package/src/infra/renderer.ts +2 -2
- package/src/internal.ts +11 -17
- package/src/kanban/parser.ts +2 -2
- package/src/mindmap/layout.ts +1 -1
- package/src/mindmap/parser.ts +1 -1
- package/src/org/parser.ts +1 -1
- package/src/org/renderer.ts +1 -1
- package/src/palettes/index.ts +39 -0
- package/src/pert/layout.ts +1 -1
- package/src/pert/monte-carlo.ts +2 -2
- package/src/pert/parser.ts +3 -3
- package/src/raci/parser.ts +4 -4
- package/src/raci/renderer.ts +1 -1
- package/src/render.ts +17 -1
- package/src/sequence/renderer.ts +1 -4
- package/src/sitemap/parser.ts +1 -1
- package/src/tech-radar/interactive.ts +1 -1
- package/src/tech-radar/renderer.ts +1 -1
- package/src/themes.ts +22 -0
- package/src/utils/tag-groups.ts +11 -12
- package/src/wireframe/layout.ts +11 -7
- package/src/wireframe/parser.ts +2 -2
- package/src/wireframe/renderer.ts +5 -2
package/src/d3.ts
CHANGED
|
@@ -3481,25 +3481,51 @@ function renderTimelineGroupLegend(
|
|
|
3481
3481
|
}
|
|
3482
3482
|
}
|
|
3483
3483
|
|
|
3484
|
+
// ============================================================
|
|
3485
|
+
// Timeline — setup helper (extracted from renderTimeline)
|
|
3486
|
+
// ============================================================
|
|
3487
|
+
|
|
3488
|
+
type Lane = { name: string; events: TimelineEvent[] };
|
|
3489
|
+
|
|
3490
|
+
type TimelineSetup = {
|
|
3491
|
+
width: number;
|
|
3492
|
+
height: number;
|
|
3493
|
+
isVertical: boolean;
|
|
3494
|
+
tooltip: HTMLDivElement;
|
|
3495
|
+
solid: boolean;
|
|
3496
|
+
textColor: string;
|
|
3497
|
+
mutedColor: string;
|
|
3498
|
+
bgColor: string;
|
|
3499
|
+
bg: string;
|
|
3500
|
+
swimlaneTagGroup: string | null;
|
|
3501
|
+
groupColorMap: Map<string, string>;
|
|
3502
|
+
tagLanes: Lane[] | null;
|
|
3503
|
+
eventColor: (ev: TimelineEvent) => string;
|
|
3504
|
+
minDate: number;
|
|
3505
|
+
maxDate: number;
|
|
3506
|
+
datePadding: number;
|
|
3507
|
+
earliestStartDateStr: string;
|
|
3508
|
+
latestEndDateStr: string;
|
|
3509
|
+
tagLegendReserve: number;
|
|
3510
|
+
};
|
|
3511
|
+
|
|
3484
3512
|
/**
|
|
3485
|
-
*
|
|
3486
|
-
*
|
|
3513
|
+
* Computes layout context (dimensions, colors, date domain, tag lanes,
|
|
3514
|
+
* event-color resolver) for a timeline before the orientation-specific
|
|
3515
|
+
* rendering branch runs. Returns null when there is nothing to render
|
|
3516
|
+
* (empty events or zero-sized container).
|
|
3517
|
+
*
|
|
3518
|
+
* Side effects: clears the container and creates the tooltip element.
|
|
3487
3519
|
*/
|
|
3488
|
-
|
|
3520
|
+
function setupTimeline(
|
|
3489
3521
|
container: HTMLDivElement,
|
|
3490
3522
|
parsed: ParsedVisualization,
|
|
3491
3523
|
palette: PaletteColors,
|
|
3492
3524
|
isDark: boolean,
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
onTagStateChange?: (
|
|
3498
|
-
activeTagGroup: string | null,
|
|
3499
|
-
swimlaneTagGroup: string | null
|
|
3500
|
-
) => void,
|
|
3501
|
-
viewMode?: boolean
|
|
3502
|
-
): void {
|
|
3525
|
+
exportDims: D3ExportDimensions | undefined,
|
|
3526
|
+
activeTagGroup: string | null | undefined,
|
|
3527
|
+
swimlaneTagGroup: string | null | undefined
|
|
3528
|
+
): TimelineSetup | null {
|
|
3503
3529
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
3504
3530
|
const solid = parsed.solidFill === true;
|
|
3505
3531
|
|
|
@@ -3509,55 +3535,46 @@ export function renderTimeline(
|
|
|
3509
3535
|
timelineEras,
|
|
3510
3536
|
timelineMarkers,
|
|
3511
3537
|
timelineSort,
|
|
3512
|
-
timelineScale,
|
|
3513
|
-
timelineSwimlanes,
|
|
3514
3538
|
orientation,
|
|
3515
3539
|
} = parsed;
|
|
3516
|
-
|
|
3517
|
-
if (timelineEvents.length === 0) return;
|
|
3540
|
+
if (timelineEvents.length === 0) return null;
|
|
3518
3541
|
|
|
3519
|
-
|
|
3542
|
+
let resolvedSwimlaneTG: string | null = swimlaneTagGroup ?? null;
|
|
3520
3543
|
if (
|
|
3521
|
-
|
|
3544
|
+
resolvedSwimlaneTG == null &&
|
|
3522
3545
|
timelineSort === 'tag' &&
|
|
3523
3546
|
parsed.timelineDefaultSwimlaneTG
|
|
3524
3547
|
) {
|
|
3525
|
-
|
|
3548
|
+
resolvedSwimlaneTG = parsed.timelineDefaultSwimlaneTG;
|
|
3526
3549
|
}
|
|
3527
3550
|
|
|
3528
3551
|
const tooltip = createTooltip(container, palette, isDark);
|
|
3529
3552
|
|
|
3530
3553
|
const width = exportDims?.width ?? container.clientWidth;
|
|
3531
3554
|
const height = exportDims?.height ?? container.clientHeight;
|
|
3532
|
-
if (width <= 0 || height <= 0) return;
|
|
3555
|
+
if (width <= 0 || height <= 0) return null;
|
|
3533
3556
|
|
|
3534
3557
|
const isVertical = orientation === 'vertical';
|
|
3535
3558
|
|
|
3536
|
-
// Theme colors
|
|
3537
3559
|
const textColor = palette.text;
|
|
3538
3560
|
const mutedColor = palette.border;
|
|
3539
3561
|
const bgColor = palette.bg;
|
|
3540
3562
|
const bg = isDark ? palette.surface : palette.bg;
|
|
3541
3563
|
const colors = getSeriesColors(palette);
|
|
3542
3564
|
|
|
3543
|
-
// Assign colors to groups
|
|
3544
3565
|
const groupColorMap = new Map<string, string>();
|
|
3545
3566
|
timelineGroups.forEach((grp, i) => {
|
|
3546
3567
|
groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]);
|
|
3547
3568
|
});
|
|
3548
3569
|
|
|
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
3570
|
let tagLanes: Lane[] | null = null;
|
|
3553
3571
|
|
|
3554
|
-
if (
|
|
3555
|
-
const tagKey =
|
|
3572
|
+
if (resolvedSwimlaneTG) {
|
|
3573
|
+
const tagKey = resolvedSwimlaneTG.toLowerCase();
|
|
3556
3574
|
const tagGroup = parsed.timelineTagGroups.find(
|
|
3557
3575
|
(g) => g.name.toLowerCase() === tagKey
|
|
3558
3576
|
);
|
|
3559
3577
|
if (tagGroup) {
|
|
3560
|
-
// Collect events per tag value
|
|
3561
3578
|
const buckets = new Map<string, TimelineEvent[]>();
|
|
3562
3579
|
const otherEvents: TimelineEvent[] = [];
|
|
3563
3580
|
for (const ev of timelineEvents) {
|
|
@@ -3571,7 +3588,6 @@ export function renderTimeline(
|
|
|
3571
3588
|
}
|
|
3572
3589
|
}
|
|
3573
3590
|
|
|
3574
|
-
// Order lanes by earliest event date
|
|
3575
3591
|
const laneEntries = [...buckets.entries()].sort((a, b) => {
|
|
3576
3592
|
const aMin = Math.min(...a[1].map((e) => parseTimelineDate(e.date)));
|
|
3577
3593
|
const bMin = Math.min(...b[1].map((e) => parseTimelineDate(e.date)));
|
|
@@ -3583,18 +3599,15 @@ export function renderTimeline(
|
|
|
3583
3599
|
tagLanes.push({ name: '(Other)', events: otherEvents });
|
|
3584
3600
|
}
|
|
3585
3601
|
|
|
3586
|
-
// Populate groupColorMap from tag entry colors
|
|
3587
3602
|
for (const entry of tagGroup.entries) {
|
|
3588
3603
|
groupColorMap.set(entry.value, entry.color);
|
|
3589
3604
|
}
|
|
3590
3605
|
}
|
|
3591
3606
|
}
|
|
3592
3607
|
|
|
3593
|
-
|
|
3594
|
-
const effectiveColorTG = activeTagGroup ?? swimlaneTagGroup ?? null;
|
|
3608
|
+
const effectiveColorTG = activeTagGroup ?? resolvedSwimlaneTG ?? null;
|
|
3595
3609
|
|
|
3596
3610
|
function eventColor(ev: TimelineEvent): string {
|
|
3597
|
-
// Tag color takes priority when a tag group is active
|
|
3598
3611
|
if (effectiveColorTG) {
|
|
3599
3612
|
const tagColor = resolveTagColor(
|
|
3600
3613
|
ev.metadata,
|
|
@@ -3609,7 +3622,6 @@ export function renderTimeline(
|
|
|
3609
3622
|
return textColor;
|
|
3610
3623
|
}
|
|
3611
3624
|
|
|
3612
|
-
// Convert dates to numeric values and find boundary dates
|
|
3613
3625
|
let minDate = Infinity;
|
|
3614
3626
|
let maxDate = -Infinity;
|
|
3615
3627
|
let earliestStartDateStr = '';
|
|
@@ -3629,8 +3641,6 @@ export function renderTimeline(
|
|
|
3629
3641
|
}
|
|
3630
3642
|
}
|
|
3631
3643
|
|
|
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
3644
|
for (const era of timelineEras) {
|
|
3635
3645
|
const eraStartNum = parseTimelineDate(era.startDate);
|
|
3636
3646
|
const eraEndNum = parseTimelineDate(era.endDate);
|
|
@@ -3657,11 +3667,70 @@ export function renderTimeline(
|
|
|
3657
3667
|
}
|
|
3658
3668
|
const datePadding = (maxDate - minDate) * 0.05 || 0.5;
|
|
3659
3669
|
|
|
3660
|
-
const
|
|
3670
|
+
const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
|
|
3671
|
+
|
|
3672
|
+
return {
|
|
3673
|
+
width,
|
|
3674
|
+
height,
|
|
3675
|
+
isVertical,
|
|
3676
|
+
tooltip,
|
|
3677
|
+
solid,
|
|
3678
|
+
textColor,
|
|
3679
|
+
mutedColor,
|
|
3680
|
+
bgColor,
|
|
3681
|
+
bg,
|
|
3682
|
+
swimlaneTagGroup: resolvedSwimlaneTG,
|
|
3683
|
+
groupColorMap,
|
|
3684
|
+
tagLanes,
|
|
3685
|
+
eventColor,
|
|
3686
|
+
minDate,
|
|
3687
|
+
maxDate,
|
|
3688
|
+
datePadding,
|
|
3689
|
+
earliestStartDateStr,
|
|
3690
|
+
latestEndDateStr,
|
|
3691
|
+
tagLegendReserve,
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
// ============================================================
|
|
3696
|
+
// Timeline — hover helpers (extracted from renderTimeline)
|
|
3697
|
+
// ============================================================
|
|
3698
|
+
|
|
3699
|
+
type TimelineHoverHelpers = {
|
|
3700
|
+
FADE_OPACITY: number;
|
|
3701
|
+
fadeToGroup: (
|
|
3702
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3703
|
+
groupName: string
|
|
3704
|
+
) => void;
|
|
3705
|
+
fadeToEra: (
|
|
3706
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3707
|
+
eraStart: number,
|
|
3708
|
+
eraEnd: number
|
|
3709
|
+
) => void;
|
|
3710
|
+
fadeToMarker: (
|
|
3711
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3712
|
+
markerDate: number
|
|
3713
|
+
) => void;
|
|
3714
|
+
fadeReset: (
|
|
3715
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
3716
|
+
) => void;
|
|
3717
|
+
fadeToTagValue: (
|
|
3718
|
+
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3719
|
+
tagKey: string,
|
|
3720
|
+
tagValue: string
|
|
3721
|
+
) => void;
|
|
3722
|
+
setTagAttrs: (
|
|
3723
|
+
evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3724
|
+
ev: TimelineEvent
|
|
3725
|
+
) => void;
|
|
3726
|
+
};
|
|
3661
3727
|
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3728
|
+
/**
|
|
3729
|
+
* Shared hover helpers for timeline rendering. Operate on CSS classes,
|
|
3730
|
+
* orientation-agnostic. Used by all three rendering branches.
|
|
3731
|
+
*/
|
|
3732
|
+
function makeTimelineHoverHelpers(): TimelineHoverHelpers {
|
|
3733
|
+
const FADE_OPACITY = 0.1;
|
|
3665
3734
|
|
|
3666
3735
|
function fadeToGroup(
|
|
3667
3736
|
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
@@ -3765,11 +3834,10 @@ export function renderTimeline(
|
|
|
3765
3834
|
'opacity',
|
|
3766
3835
|
FADE_OPACITY
|
|
3767
3836
|
);
|
|
3768
|
-
// Fade legend entry dots/labels that don't match (keep group pill visible)
|
|
3769
3837
|
g.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
|
|
3770
3838
|
const el = d3Selection.select(this);
|
|
3771
3839
|
const entryValue = el.attr('data-legend-entry');
|
|
3772
|
-
if (entryValue === '__group__') return;
|
|
3840
|
+
if (entryValue === '__group__') return;
|
|
3773
3841
|
const entryGroup = el.attr('data-tag-group');
|
|
3774
3842
|
el.attr(
|
|
3775
3843
|
'opacity',
|
|
@@ -3788,632 +3856,1166 @@ export function renderTimeline(
|
|
|
3788
3856
|
}
|
|
3789
3857
|
}
|
|
3790
3858
|
|
|
3791
|
-
|
|
3792
|
-
|
|
3859
|
+
return {
|
|
3860
|
+
FADE_OPACITY,
|
|
3861
|
+
fadeToGroup,
|
|
3862
|
+
fadeToEra,
|
|
3863
|
+
fadeToMarker,
|
|
3864
|
+
fadeReset,
|
|
3865
|
+
fadeToTagValue,
|
|
3866
|
+
setTagAttrs,
|
|
3867
|
+
};
|
|
3868
|
+
}
|
|
3793
3869
|
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
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
|
-
}
|
|
3870
|
+
// ============================================================
|
|
3871
|
+
// Timeline — tag-legend overlay (extracted from renderTimeline)
|
|
3872
|
+
// ============================================================
|
|
3873
|
+
|
|
3874
|
+
function renderTimelineTagLegendOverlay(
|
|
3875
|
+
container: HTMLDivElement,
|
|
3876
|
+
parsed: ParsedVisualization,
|
|
3877
|
+
palette: PaletteColors,
|
|
3878
|
+
isDark: boolean,
|
|
3879
|
+
setup: TimelineSetup,
|
|
3880
|
+
hovers: TimelineHoverHelpers,
|
|
3881
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
3882
|
+
exportDims: D3ExportDimensions | undefined,
|
|
3883
|
+
swimlaneTagGroup: string | null | undefined,
|
|
3884
|
+
activeTagGroup: string | null | undefined,
|
|
3885
|
+
onTagStateChange:
|
|
3886
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
3887
|
+
| undefined,
|
|
3888
|
+
viewMode: boolean | undefined
|
|
3889
|
+
): void {
|
|
3890
|
+
if (parsed.timelineTagGroups.length === 0) return;
|
|
3827
3891
|
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3892
|
+
const { width, textColor, groupColorMap, solid } = setup;
|
|
3893
|
+
const { FADE_OPACITY, fadeReset, fadeToTagValue } = hovers;
|
|
3894
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
3895
|
+
const { timelineEvents } = parsed;
|
|
3896
|
+
|
|
3897
|
+
const LG_HEIGHT = TL_LEGEND_HEIGHT;
|
|
3898
|
+
const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
|
|
3899
|
+
const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
|
|
3900
|
+
const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
|
|
3901
|
+
const LG_DOT_R = TL_LEGEND_DOT_R;
|
|
3902
|
+
const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
|
|
3903
|
+
const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
|
|
3904
|
+
const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
|
|
3905
|
+
// LG_GROUP_GAP no longer needed — centralized legend handles spacing
|
|
3906
|
+
const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local
|
|
3907
|
+
|
|
3908
|
+
const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
|
|
3909
|
+
const mainG = mainSvg.select<SVGGElement>('g');
|
|
3910
|
+
if (!mainSvg.empty() && !mainG.empty()) {
|
|
3911
|
+
// Position legend at top, below title
|
|
3912
|
+
const legendY = title ? 50 : 10;
|
|
3913
|
+
|
|
3914
|
+
// Pre-compute group widths (minified and expanded)
|
|
3915
|
+
type LegendGroup = {
|
|
3916
|
+
group: TagGroup;
|
|
3917
|
+
minifiedWidth: number;
|
|
3918
|
+
expandedWidth: number;
|
|
3919
|
+
};
|
|
3920
|
+
const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
|
|
3921
|
+
const pillW = measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
3922
|
+
// Expanded: pill + icon (unless viewMode) + entries
|
|
3923
|
+
const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
|
|
3924
|
+
let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
|
|
3925
|
+
for (const entry of g.entries) {
|
|
3926
|
+
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
3927
|
+
entryX =
|
|
3928
|
+
textX +
|
|
3929
|
+
measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) +
|
|
3930
|
+
LG_ENTRY_TRAIL;
|
|
3931
|
+
}
|
|
3932
|
+
return {
|
|
3933
|
+
group: g,
|
|
3934
|
+
minifiedWidth: pillW,
|
|
3935
|
+
expandedWidth: entryX + LG_CAPSULE_PAD,
|
|
3836
3936
|
};
|
|
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
|
|
3937
|
+
});
|
|
3938
|
+
|
|
3939
|
+
// Two independent state axes: swimlane source + color source
|
|
3940
|
+
let currentActiveGroup: string | null = activeTagGroup ?? null;
|
|
3941
|
+
let currentSwimlaneGroup: string | null = swimlaneTagGroup ?? null;
|
|
3942
|
+
|
|
3943
|
+
/** Render the swimlane icon (3 horizontal bars of varying width) */
|
|
3944
|
+
function drawSwimlaneIcon(
|
|
3945
|
+
parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
3946
|
+
x: number,
|
|
3947
|
+
y: number,
|
|
3948
|
+
isSwimActive: boolean
|
|
3949
|
+
) {
|
|
3950
|
+
const iconG = parent
|
|
3855
3951
|
.append('g')
|
|
3856
|
-
.attr('
|
|
3952
|
+
.attr('class', 'tl-swimlane-icon')
|
|
3953
|
+
.attr('transform', `translate(${x}, ${y})`)
|
|
3954
|
+
.style('cursor', 'pointer');
|
|
3955
|
+
|
|
3956
|
+
const barColor = isSwimActive ? palette.primary : palette.textMuted;
|
|
3957
|
+
const barOpacity = isSwimActive ? 1 : 0.35;
|
|
3958
|
+
const bars = [
|
|
3959
|
+
{ y: 0, w: 8 },
|
|
3960
|
+
{ y: 4, w: 12 },
|
|
3961
|
+
{ y: 8, w: 6 },
|
|
3962
|
+
];
|
|
3963
|
+
for (const bar of bars) {
|
|
3964
|
+
iconG
|
|
3965
|
+
.append('rect')
|
|
3966
|
+
.attr('x', 0)
|
|
3967
|
+
.attr('y', bar.y)
|
|
3968
|
+
.attr('width', bar.w)
|
|
3969
|
+
.attr('height', 2)
|
|
3970
|
+
.attr('rx', 1)
|
|
3971
|
+
.attr('fill', barColor)
|
|
3972
|
+
.attr('opacity', barOpacity);
|
|
3973
|
+
}
|
|
3974
|
+
return iconG;
|
|
3975
|
+
}
|
|
3857
3976
|
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3977
|
+
/** Full re-render with updated swimlane state */
|
|
3978
|
+
function relayout() {
|
|
3979
|
+
renderTimeline(
|
|
3980
|
+
container,
|
|
3981
|
+
parsed,
|
|
3982
|
+
palette,
|
|
3983
|
+
isDark,
|
|
3984
|
+
onClickItem,
|
|
3985
|
+
exportDims,
|
|
3986
|
+
currentActiveGroup,
|
|
3987
|
+
currentSwimlaneGroup,
|
|
3988
|
+
onTagStateChange,
|
|
3989
|
+
viewMode
|
|
3865
3990
|
);
|
|
3991
|
+
}
|
|
3866
3992
|
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
(
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3993
|
+
function drawLegend() {
|
|
3994
|
+
// Remove previous legend
|
|
3995
|
+
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
3996
|
+
mainSvg.selectAll('.tl-tag-legend-container').remove();
|
|
3997
|
+
|
|
3998
|
+
// Effective color source: explicit color group > swimlane group
|
|
3999
|
+
const effectiveColorKey =
|
|
4000
|
+
(currentActiveGroup ?? currentSwimlaneGroup)?.toLowerCase() ?? null;
|
|
4001
|
+
|
|
4002
|
+
// In view mode, only show the color-driving tag group (expanded, non-interactive).
|
|
4003
|
+
// Skip the swimlane group if it's separate from the color group (lane headers already label it).
|
|
4004
|
+
const visibleGroups = viewMode
|
|
4005
|
+
? legendGroups.filter(
|
|
4006
|
+
(lg) =>
|
|
4007
|
+
effectiveColorKey != null &&
|
|
4008
|
+
lg.group.name.toLowerCase() === effectiveColorKey
|
|
4009
|
+
)
|
|
4010
|
+
: legendGroups;
|
|
3880
4011
|
|
|
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
|
-
);
|
|
4012
|
+
if (visibleGroups.length === 0) return;
|
|
3894
4013
|
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
minDate,
|
|
3904
|
-
maxDate,
|
|
3905
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
3906
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4014
|
+
// Legend container for data-legend-active attribute
|
|
4015
|
+
const legendContainer = mainSvg
|
|
4016
|
+
.append('g')
|
|
4017
|
+
.attr('class', 'tl-tag-legend-container');
|
|
4018
|
+
if (currentActiveGroup) {
|
|
4019
|
+
legendContainer.attr(
|
|
4020
|
+
'data-legend-active',
|
|
4021
|
+
currentActiveGroup.toLowerCase()
|
|
3907
4022
|
);
|
|
3908
4023
|
}
|
|
3909
4024
|
|
|
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
|
-
}
|
|
4025
|
+
// Render tag groups via centralized legend system
|
|
4026
|
+
const iconAddon = viewMode ? 0 : LG_ICON_W;
|
|
4027
|
+
const centralGroups = visibleGroups.map((lg) => ({
|
|
4028
|
+
name: lg.group.name,
|
|
4029
|
+
entries: lg.group.entries.map((e) => ({
|
|
4030
|
+
value: e.value,
|
|
4031
|
+
color: e.color,
|
|
4032
|
+
})),
|
|
4033
|
+
}));
|
|
3926
4034
|
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
const laneColor = groupColorMap.get(laneName) ?? textColor;
|
|
3930
|
-
const laneCenter = laneX + laneWidth / 2;
|
|
4035
|
+
// Determine effective active group for centralized renderer
|
|
4036
|
+
const centralActive = viewMode ? effectiveColorKey : currentActiveGroup;
|
|
3931
4037
|
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
4038
|
+
const centralConfig: LegendConfig = {
|
|
4039
|
+
groups: centralGroups,
|
|
4040
|
+
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
4041
|
+
mode: 'fixed',
|
|
4042
|
+
capsulePillAddonWidth: iconAddon,
|
|
4043
|
+
};
|
|
4044
|
+
const centralState: LegendState = { activeGroup: centralActive };
|
|
4045
|
+
|
|
4046
|
+
const centralCallbacks: LegendCallbacks = viewMode
|
|
4047
|
+
? {}
|
|
4048
|
+
: {
|
|
4049
|
+
onGroupToggle: (groupName) => {
|
|
4050
|
+
currentActiveGroup =
|
|
4051
|
+
currentActiveGroup === groupName.toLowerCase()
|
|
4052
|
+
? null
|
|
4053
|
+
: groupName.toLowerCase();
|
|
4054
|
+
drawLegend();
|
|
4055
|
+
recolorEvents();
|
|
4056
|
+
onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
|
|
4057
|
+
},
|
|
4058
|
+
onEntryHover: (groupName, entryValue) => {
|
|
4059
|
+
const tagKey = groupName.toLowerCase();
|
|
4060
|
+
if (entryValue) {
|
|
4061
|
+
const tagVal = entryValue.toLowerCase();
|
|
4062
|
+
fadeToTagValue(mainG, tagKey, tagVal);
|
|
4063
|
+
mainSvg
|
|
4064
|
+
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
4065
|
+
.each(function () {
|
|
4066
|
+
const el = d3Selection.select(this);
|
|
4067
|
+
const ev = el.attr('data-legend-entry');
|
|
4068
|
+
const eg =
|
|
4069
|
+
el.attr('data-tag-group') ??
|
|
4070
|
+
(el.node() as Element)
|
|
4071
|
+
?.closest?.('[data-tag-group]')
|
|
4072
|
+
?.getAttribute('data-tag-group');
|
|
4073
|
+
el.attr(
|
|
4074
|
+
'opacity',
|
|
4075
|
+
eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY
|
|
4076
|
+
);
|
|
4077
|
+
});
|
|
4078
|
+
} else {
|
|
4079
|
+
fadeReset(mainG);
|
|
4080
|
+
mainSvg
|
|
4081
|
+
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
4082
|
+
.attr('opacity', 1);
|
|
4083
|
+
}
|
|
4084
|
+
},
|
|
4085
|
+
onGroupRendered: (groupName, groupEl, isActive) => {
|
|
4086
|
+
const groupKey = groupName.toLowerCase();
|
|
4087
|
+
groupEl.attr('data-tag-group', groupKey);
|
|
4088
|
+
if (isActive && !viewMode) {
|
|
4089
|
+
const isSwimActive =
|
|
4090
|
+
currentSwimlaneGroup != null &&
|
|
4091
|
+
currentSwimlaneGroup.toLowerCase() === groupKey;
|
|
4092
|
+
const pillWidth =
|
|
4093
|
+
measureLegendText(groupName, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
4094
|
+
const pillXOff = LG_CAPSULE_PAD;
|
|
4095
|
+
const iconX = pillXOff + pillWidth + 5;
|
|
4096
|
+
const iconY = (LG_HEIGHT - 10) / 2;
|
|
4097
|
+
const iconEl = drawSwimlaneIcon(
|
|
4098
|
+
groupEl,
|
|
4099
|
+
iconX,
|
|
4100
|
+
iconY,
|
|
4101
|
+
isSwimActive
|
|
4102
|
+
);
|
|
4103
|
+
iconEl
|
|
4104
|
+
.attr('data-swimlane-toggle', groupKey)
|
|
4105
|
+
.on('click', (event: MouseEvent) => {
|
|
4106
|
+
event.stopPropagation();
|
|
4107
|
+
currentSwimlaneGroup =
|
|
4108
|
+
currentSwimlaneGroup === groupKey ? null : groupKey;
|
|
4109
|
+
onTagStateChange?.(
|
|
4110
|
+
currentActiveGroup,
|
|
4111
|
+
currentSwimlaneGroup
|
|
4112
|
+
);
|
|
4113
|
+
relayout();
|
|
4114
|
+
});
|
|
4115
|
+
}
|
|
4116
|
+
},
|
|
4117
|
+
};
|
|
4118
|
+
|
|
4119
|
+
const legendInnerG = legendContainer
|
|
4120
|
+
.append('g')
|
|
4121
|
+
.attr('transform', `translate(0, ${legendY})`);
|
|
4122
|
+
renderLegendD3(
|
|
4123
|
+
legendInnerG,
|
|
4124
|
+
centralConfig,
|
|
4125
|
+
centralState,
|
|
4126
|
+
palette,
|
|
4127
|
+
isDark,
|
|
4128
|
+
centralCallbacks,
|
|
4129
|
+
width
|
|
4130
|
+
);
|
|
4131
|
+
}
|
|
3939
4132
|
|
|
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
|
-
}
|
|
4133
|
+
// Build a quick lineNumber→event lookup
|
|
4134
|
+
const eventByLine = new Map<string, TimelineEvent>();
|
|
4135
|
+
for (const ev of timelineEvents) {
|
|
4136
|
+
eventByLine.set(String(ev.lineNumber), ev);
|
|
4137
|
+
}
|
|
4043
4138
|
|
|
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
|
-
}
|
|
4139
|
+
function recolorEvents() {
|
|
4140
|
+
const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;
|
|
4141
|
+
mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
4142
|
+
const el = d3Selection.select(this);
|
|
4143
|
+
const lineNum = el.attr('data-line-number');
|
|
4144
|
+
const ev = lineNum ? eventByLine.get(lineNum) : undefined;
|
|
4145
|
+
if (!ev) return;
|
|
4146
|
+
|
|
4147
|
+
let color: string;
|
|
4148
|
+
if (colorTG) {
|
|
4149
|
+
const tagColor = resolveTagColor(
|
|
4150
|
+
ev.metadata,
|
|
4151
|
+
parsed.timelineTagGroups,
|
|
4152
|
+
colorTG
|
|
4153
|
+
);
|
|
4154
|
+
color =
|
|
4155
|
+
tagColor ??
|
|
4156
|
+
(ev.group && groupColorMap.has(ev.group)
|
|
4157
|
+
? groupColorMap.get(ev.group)!
|
|
4158
|
+
: textColor);
|
|
4159
|
+
} else {
|
|
4160
|
+
color =
|
|
4161
|
+
ev.group && groupColorMap.has(ev.group)
|
|
4162
|
+
? groupColorMap.get(ev.group)!
|
|
4163
|
+
: textColor;
|
|
4080
4164
|
}
|
|
4165
|
+
el.selectAll('rect')
|
|
4166
|
+
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4167
|
+
.attr('stroke', color);
|
|
4168
|
+
el.selectAll('circle:not(.tl-event-point-outline)')
|
|
4169
|
+
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4170
|
+
.attr('stroke', color);
|
|
4081
4171
|
});
|
|
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;
|
|
4172
|
+
}
|
|
4095
4173
|
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
.range([0, innerHeight]);
|
|
4174
|
+
drawLegend();
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4100
4177
|
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4178
|
+
// ============================================================
|
|
4179
|
+
// Timeline — horizontal-time-sort renderer (extracted from renderTimeline)
|
|
4180
|
+
// ============================================================
|
|
4104
4181
|
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4182
|
+
function renderTimelineHorizontalTimeSort(
|
|
4183
|
+
container: HTMLDivElement,
|
|
4184
|
+
parsed: ParsedVisualization,
|
|
4185
|
+
palette: PaletteColors,
|
|
4186
|
+
isDark: boolean,
|
|
4187
|
+
setup: TimelineSetup,
|
|
4188
|
+
hovers: TimelineHoverHelpers,
|
|
4189
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
4190
|
+
_exportDims: D3ExportDimensions | undefined,
|
|
4191
|
+
_swimlaneTagGroup: string | null | undefined,
|
|
4192
|
+
_activeTagGroup: string | null | undefined,
|
|
4193
|
+
_onTagStateChange:
|
|
4194
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
4195
|
+
| undefined,
|
|
4196
|
+
_viewMode: boolean | undefined
|
|
4197
|
+
): void {
|
|
4198
|
+
const {
|
|
4199
|
+
width,
|
|
4200
|
+
height,
|
|
4201
|
+
tooltip,
|
|
4202
|
+
solid,
|
|
4203
|
+
textColor,
|
|
4204
|
+
bgColor,
|
|
4205
|
+
bg,
|
|
4206
|
+
groupColorMap,
|
|
4207
|
+
eventColor,
|
|
4208
|
+
minDate,
|
|
4209
|
+
maxDate,
|
|
4210
|
+
datePadding,
|
|
4211
|
+
earliestStartDateStr,
|
|
4212
|
+
latestEndDateStr,
|
|
4213
|
+
tagLegendReserve,
|
|
4214
|
+
} = setup;
|
|
4215
|
+
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
4216
|
+
hovers;
|
|
4217
|
+
const {
|
|
4218
|
+
timelineEvents,
|
|
4219
|
+
timelineGroups,
|
|
4220
|
+
timelineEras,
|
|
4221
|
+
timelineMarkers,
|
|
4222
|
+
timelineScale,
|
|
4223
|
+
} = parsed;
|
|
4224
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
4112
4225
|
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4226
|
+
const BAR_H = 22;
|
|
4227
|
+
|
|
4228
|
+
// === TIME SORT, horizontal: each event on its own row ===
|
|
4229
|
+
const sorted = timelineEvents
|
|
4230
|
+
.slice()
|
|
4231
|
+
.sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
|
|
4232
|
+
|
|
4233
|
+
const scaleMargin = timelineScale ? 24 : 0;
|
|
4234
|
+
// Per-feature header rows: era + marker each get their own row, reserved
|
|
4235
|
+
// only when present (mirrors the gantt header stack).
|
|
4236
|
+
const ERA_ROW_H = 22;
|
|
4237
|
+
const MARKER_ROW_H = 22;
|
|
4238
|
+
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
4239
|
+
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
4240
|
+
const topScaleH = timelineScale ? 40 : 0;
|
|
4241
|
+
const margin = {
|
|
4242
|
+
top: 104 + topScaleH + eraReserve + markerReserve + tagLegendReserve,
|
|
4243
|
+
right: 40,
|
|
4244
|
+
bottom: 40 + scaleMargin,
|
|
4245
|
+
left: 60,
|
|
4246
|
+
};
|
|
4247
|
+
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
4248
|
+
const eraLabelY = eraReserve
|
|
4249
|
+
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4250
|
+
: 0;
|
|
4251
|
+
const innerWidth = width - margin.left - margin.right;
|
|
4252
|
+
const innerHeight = height - margin.top - margin.bottom;
|
|
4253
|
+
const rowH = Math.min(28, innerHeight / sorted.length);
|
|
4116
4254
|
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
width,
|
|
4122
|
-
textColor,
|
|
4123
|
-
onClickItem
|
|
4124
|
-
);
|
|
4255
|
+
const xScale = d3Scale
|
|
4256
|
+
.scaleLinear()
|
|
4257
|
+
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4258
|
+
.range([0, innerWidth]);
|
|
4125
4259
|
|
|
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
|
-
);
|
|
4260
|
+
const svg = d3Selection
|
|
4261
|
+
.select(container)
|
|
4262
|
+
.append('svg')
|
|
4263
|
+
.attr('width', width)
|
|
4264
|
+
.attr('height', height)
|
|
4265
|
+
.style('background', bgColor);
|
|
4139
4266
|
|
|
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
|
-
);
|
|
4267
|
+
const g = svg
|
|
4268
|
+
.append('g')
|
|
4269
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
4153
4270
|
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
minDate,
|
|
4163
|
-
maxDate,
|
|
4164
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
4165
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4166
|
-
);
|
|
4167
|
-
}
|
|
4271
|
+
renderChartTitle(
|
|
4272
|
+
svg,
|
|
4273
|
+
title,
|
|
4274
|
+
parsed.titleLineNumber,
|
|
4275
|
+
width,
|
|
4276
|
+
textColor,
|
|
4277
|
+
onClickItem
|
|
4278
|
+
);
|
|
4168
4279
|
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4280
|
+
renderEras(
|
|
4281
|
+
g,
|
|
4282
|
+
timelineEras,
|
|
4283
|
+
xScale,
|
|
4284
|
+
false,
|
|
4285
|
+
innerWidth,
|
|
4286
|
+
innerHeight,
|
|
4287
|
+
(s, e) => fadeToEra(g, s, e),
|
|
4288
|
+
() => fadeReset(g),
|
|
4289
|
+
timelineScale,
|
|
4290
|
+
tooltip,
|
|
4291
|
+
palette,
|
|
4292
|
+
eraReserve ? eraLabelY : undefined
|
|
4293
|
+
);
|
|
4183
4294
|
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4295
|
+
renderMarkers(
|
|
4296
|
+
g,
|
|
4297
|
+
timelineMarkers,
|
|
4298
|
+
xScale,
|
|
4299
|
+
false,
|
|
4300
|
+
innerWidth,
|
|
4301
|
+
innerHeight,
|
|
4302
|
+
(d) => fadeToMarker(g, d),
|
|
4303
|
+
() => fadeReset(g),
|
|
4304
|
+
timelineScale,
|
|
4305
|
+
tooltip,
|
|
4306
|
+
palette,
|
|
4307
|
+
markerReserve ? markerLabelY : undefined
|
|
4308
|
+
);
|
|
4192
4309
|
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4310
|
+
if (timelineScale) {
|
|
4311
|
+
renderTimeScale(
|
|
4312
|
+
g,
|
|
4313
|
+
xScale,
|
|
4314
|
+
false,
|
|
4315
|
+
innerWidth,
|
|
4316
|
+
innerHeight,
|
|
4317
|
+
textColor,
|
|
4318
|
+
minDate,
|
|
4319
|
+
maxDate,
|
|
4320
|
+
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
4321
|
+
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4322
|
+
);
|
|
4323
|
+
}
|
|
4196
4324
|
|
|
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);
|
|
4325
|
+
// Group legend at top-left (pill style)
|
|
4326
|
+
if (timelineGroups.length > 0) {
|
|
4327
|
+
const legendY = timelineScale ? -75 : -55;
|
|
4328
|
+
renderTimelineGroupLegend(
|
|
4329
|
+
g,
|
|
4330
|
+
timelineGroups,
|
|
4331
|
+
groupColorMap,
|
|
4332
|
+
textColor,
|
|
4333
|
+
palette,
|
|
4334
|
+
isDark,
|
|
4335
|
+
legendY,
|
|
4336
|
+
(name) => fadeToGroup(g, name),
|
|
4337
|
+
() => fadeReset(g)
|
|
4338
|
+
);
|
|
4339
|
+
}
|
|
4223
4340
|
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4341
|
+
sorted.forEach((ev, i) => {
|
|
4342
|
+
// Marker labels live in their reserved row above the chart, so the
|
|
4343
|
+
// first event sits at the chart top edge.
|
|
4344
|
+
const y = i * rowH + rowH / 2;
|
|
4345
|
+
const x = xScale(parseTimelineDate(ev.date));
|
|
4346
|
+
const color = eventColor(ev);
|
|
4227
4347
|
|
|
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
|
-
|
|
4348
|
+
const evG = g
|
|
4349
|
+
.append('g')
|
|
4350
|
+
.attr('class', 'tl-event')
|
|
4351
|
+
.attr('data-group', ev.group || '')
|
|
4352
|
+
.attr('data-line-number', String(ev.lineNumber))
|
|
4353
|
+
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
4354
|
+
.attr(
|
|
4355
|
+
'data-end-date',
|
|
4356
|
+
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
4357
|
+
)
|
|
4358
|
+
.style('cursor', 'pointer')
|
|
4359
|
+
.on('mouseenter', function (event: MouseEvent) {
|
|
4360
|
+
if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
|
|
4361
|
+
if (timelineScale) {
|
|
4362
|
+
showEventDatesOnScale(
|
|
4363
|
+
g,
|
|
4364
|
+
xScale,
|
|
4365
|
+
ev.date,
|
|
4366
|
+
ev.endDate,
|
|
4367
|
+
innerHeight,
|
|
4368
|
+
color
|
|
4369
|
+
);
|
|
4370
|
+
} else {
|
|
4371
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4372
|
+
}
|
|
4373
|
+
})
|
|
4374
|
+
.on('mouseleave', function () {
|
|
4375
|
+
fadeReset(g);
|
|
4376
|
+
if (timelineScale) {
|
|
4377
|
+
hideEventDatesOnScale(g);
|
|
4378
|
+
} else {
|
|
4379
|
+
hideTooltip(tooltip);
|
|
4380
|
+
}
|
|
4381
|
+
})
|
|
4382
|
+
.on('mousemove', function (event: MouseEvent) {
|
|
4383
|
+
if (!timelineScale) {
|
|
4384
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4385
|
+
}
|
|
4386
|
+
})
|
|
4387
|
+
.on('click', () => {
|
|
4388
|
+
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
4389
|
+
});
|
|
4390
|
+
setTagAttrs(evG, ev);
|
|
4391
|
+
|
|
4392
|
+
if (ev.endDate) {
|
|
4393
|
+
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
4394
|
+
const rectW = Math.max(x2 - x, 4);
|
|
4395
|
+
// Estimate label width (~7px per char at 13px font) + padding
|
|
4396
|
+
const estLabelWidth = ev.label.length * 7 + 16;
|
|
4397
|
+
const labelFitsInside = rectW >= estLabelWidth;
|
|
4398
|
+
|
|
4399
|
+
let fill: string = shapeFill(palette, color, isDark, { solid });
|
|
4400
|
+
let stroke: string = color;
|
|
4401
|
+
if (ev.uncertain) {
|
|
4402
|
+
// Create gradient for uncertain end - fades last 20%
|
|
4403
|
+
const gradientId = `uncertain-ts-${ev.lineNumber}`;
|
|
4404
|
+
const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;
|
|
4405
|
+
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4406
|
+
const defsEl = d3Selection.select(defs as Element);
|
|
4407
|
+
defsEl
|
|
4408
|
+
.append('linearGradient')
|
|
4409
|
+
.attr('id', gradientId)
|
|
4410
|
+
.attr('x1', '0%')
|
|
4411
|
+
.attr('y1', '0%')
|
|
4412
|
+
.attr('x2', '100%')
|
|
4413
|
+
.attr('y2', '0%')
|
|
4414
|
+
.selectAll('stop')
|
|
4415
|
+
.data([
|
|
4416
|
+
{ offset: '0%', opacity: 1 },
|
|
4417
|
+
{ offset: '80%', opacity: 1 },
|
|
4418
|
+
{ offset: '100%', opacity: 0 },
|
|
4419
|
+
])
|
|
4420
|
+
.enter()
|
|
4421
|
+
.append('stop')
|
|
4422
|
+
.attr('offset', (d) => d.offset)
|
|
4423
|
+
.attr('stop-color', mix(color, bg, 30))
|
|
4424
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4425
|
+
defsEl
|
|
4426
|
+
.append('linearGradient')
|
|
4427
|
+
.attr('id', strokeGradientId)
|
|
4428
|
+
.attr('x1', '0%')
|
|
4429
|
+
.attr('y1', '0%')
|
|
4430
|
+
.attr('x2', '100%')
|
|
4431
|
+
.attr('y2', '0%')
|
|
4432
|
+
.selectAll('stop')
|
|
4433
|
+
.data([
|
|
4434
|
+
{ offset: '0%', opacity: 1 },
|
|
4435
|
+
{ offset: '80%', opacity: 1 },
|
|
4436
|
+
{ offset: '100%', opacity: 0 },
|
|
4437
|
+
])
|
|
4438
|
+
.enter()
|
|
4439
|
+
.append('stop')
|
|
4440
|
+
.attr('offset', (d) => d.offset)
|
|
4441
|
+
.attr('stop-color', color)
|
|
4442
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4443
|
+
fill = `url(#${gradientId})`;
|
|
4444
|
+
stroke = `url(#${strokeGradientId})`;
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
evG
|
|
4448
|
+
.append('rect')
|
|
4449
|
+
.attr('x', x)
|
|
4450
|
+
.attr('y', y - BAR_H / 2)
|
|
4451
|
+
.attr('width', rectW)
|
|
4452
|
+
.attr('height', BAR_H)
|
|
4453
|
+
.attr('rx', 4)
|
|
4454
|
+
.attr('fill', fill)
|
|
4455
|
+
.attr('stroke', stroke)
|
|
4456
|
+
.attr('stroke-width', 2);
|
|
4457
|
+
|
|
4458
|
+
if (labelFitsInside) {
|
|
4459
|
+
// Text inside bar - use textColor for readability on muted fill
|
|
4460
|
+
evG
|
|
4461
|
+
.append('text')
|
|
4462
|
+
.attr('x', x + 8)
|
|
4463
|
+
.attr('y', y)
|
|
4464
|
+
.attr('dy', '0.35em')
|
|
4465
|
+
.attr('text-anchor', 'start')
|
|
4466
|
+
.attr('fill', textColor)
|
|
4467
|
+
.attr('font-size', '13px')
|
|
4468
|
+
.text(ev.label);
|
|
4469
|
+
} else {
|
|
4470
|
+
// Text outside bar - check if it fits on left or must go right
|
|
4471
|
+
const wouldFlipLeft = x + rectW > innerWidth * 0.6;
|
|
4472
|
+
const labelFitsLeft = x - 6 - estLabelWidth > 0;
|
|
4473
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4474
|
+
evG
|
|
4475
|
+
.append('text')
|
|
4476
|
+
.attr('x', flipLeft ? x - 6 : x + rectW + 6)
|
|
4477
|
+
.attr('y', y)
|
|
4478
|
+
.attr('dy', '0.35em')
|
|
4479
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4480
|
+
.attr('fill', textColor)
|
|
4481
|
+
.attr('font-size', '13px')
|
|
4482
|
+
.text(ev.label);
|
|
4483
|
+
}
|
|
4484
|
+
} else {
|
|
4485
|
+
// Point event (no end date) - render as circle with label
|
|
4486
|
+
const estLabelWidth = ev.label.length * 7;
|
|
4487
|
+
// Only flip left if past 60% AND label fits without going off-chart
|
|
4488
|
+
const wouldFlipLeft = x > innerWidth * 0.6;
|
|
4489
|
+
const labelFitsLeft = x - 10 - estLabelWidth > 0;
|
|
4490
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4491
|
+
evG
|
|
4492
|
+
.append('circle')
|
|
4493
|
+
.attr('cx', x)
|
|
4494
|
+
.attr('cy', y)
|
|
4495
|
+
.attr('r', 5)
|
|
4496
|
+
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4497
|
+
.attr('stroke', color)
|
|
4498
|
+
.attr('stroke-width', 2);
|
|
4499
|
+
evG
|
|
4500
|
+
.append('text')
|
|
4501
|
+
.attr('x', flipLeft ? x - 10 : x + 10)
|
|
4502
|
+
.attr('y', y)
|
|
4503
|
+
.attr('dy', '0.35em')
|
|
4504
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4505
|
+
.attr('fill', textColor)
|
|
4506
|
+
.attr('font-size', '12px')
|
|
4507
|
+
.text(ev.label);
|
|
4508
|
+
}
|
|
4509
|
+
});
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
// ============================================================
|
|
4513
|
+
// Timeline — horizontal-grouped renderer (extracted from renderTimeline)
|
|
4514
|
+
// ============================================================
|
|
4515
|
+
|
|
4516
|
+
function renderTimelineHorizontalGrouped(
|
|
4517
|
+
container: HTMLDivElement,
|
|
4518
|
+
parsed: ParsedVisualization,
|
|
4519
|
+
palette: PaletteColors,
|
|
4520
|
+
isDark: boolean,
|
|
4521
|
+
setup: TimelineSetup,
|
|
4522
|
+
hovers: TimelineHoverHelpers,
|
|
4523
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
4524
|
+
_exportDims: D3ExportDimensions | undefined,
|
|
4525
|
+
_swimlaneTagGroup: string | null | undefined,
|
|
4526
|
+
_activeTagGroup: string | null | undefined,
|
|
4527
|
+
_onTagStateChange:
|
|
4528
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
4529
|
+
| undefined,
|
|
4530
|
+
_viewMode: boolean | undefined
|
|
4531
|
+
): void {
|
|
4532
|
+
const {
|
|
4533
|
+
width,
|
|
4534
|
+
height,
|
|
4535
|
+
tooltip,
|
|
4536
|
+
solid,
|
|
4537
|
+
textColor,
|
|
4538
|
+
bgColor,
|
|
4539
|
+
bg,
|
|
4540
|
+
groupColorMap,
|
|
4541
|
+
tagLanes,
|
|
4542
|
+
eventColor,
|
|
4543
|
+
minDate,
|
|
4544
|
+
maxDate,
|
|
4545
|
+
datePadding,
|
|
4546
|
+
earliestStartDateStr,
|
|
4547
|
+
latestEndDateStr,
|
|
4548
|
+
tagLegendReserve,
|
|
4549
|
+
} = setup;
|
|
4550
|
+
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
4551
|
+
hovers;
|
|
4552
|
+
const {
|
|
4553
|
+
timelineEvents,
|
|
4554
|
+
timelineGroups,
|
|
4555
|
+
timelineEras,
|
|
4556
|
+
timelineMarkers,
|
|
4557
|
+
timelineScale,
|
|
4558
|
+
timelineSwimlanes,
|
|
4559
|
+
} = parsed;
|
|
4560
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
4561
|
+
|
|
4562
|
+
const BAR_H = 22;
|
|
4563
|
+
const GROUP_GAP = 12;
|
|
4564
|
+
|
|
4565
|
+
// === GROUPED: swim-lanes stacked vertically, events on own rows ===
|
|
4566
|
+
let lanes: Lane[];
|
|
4567
|
+
|
|
4568
|
+
if (tagLanes) {
|
|
4569
|
+
lanes = tagLanes;
|
|
4570
|
+
} else {
|
|
4571
|
+
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
4572
|
+
const ungroupedEvents = timelineEvents.filter(
|
|
4573
|
+
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
4574
|
+
);
|
|
4575
|
+
const laneNames =
|
|
4576
|
+
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
4577
|
+
lanes = laneNames.map((name) => ({
|
|
4578
|
+
name,
|
|
4579
|
+
events: timelineEvents.filter((ev) =>
|
|
4580
|
+
name === '(Other)'
|
|
4581
|
+
? ev.group === null || !groupNames.includes(ev.group)
|
|
4582
|
+
: ev.group === name
|
|
4583
|
+
),
|
|
4584
|
+
}));
|
|
4585
|
+
}
|
|
4586
|
+
|
|
4587
|
+
const totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);
|
|
4588
|
+
const scaleMargin = timelineScale ? 24 : 0;
|
|
4589
|
+
// Per-feature header rows: era + marker each get their own row, reserved
|
|
4590
|
+
// only when present (mirrors the gantt header stack).
|
|
4591
|
+
const ERA_ROW_H = 22;
|
|
4592
|
+
const MARKER_ROW_H = 22;
|
|
4593
|
+
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
4594
|
+
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
4595
|
+
const topScaleH = timelineScale ? 40 : 0;
|
|
4596
|
+
// Calculate left margin based on longest group name (~7px per char + padding)
|
|
4597
|
+
const maxGroupNameLen = Math.max(...lanes.map((l) => l.name.length));
|
|
4598
|
+
const dynamicLeftMargin = Math.max(120, maxGroupNameLen * 7 + 30);
|
|
4599
|
+
// Group-sorted doesn't need legend space (group names shown on left)
|
|
4600
|
+
const baseTopMargin = title ? 50 : 20;
|
|
4601
|
+
const margin = {
|
|
4602
|
+
top:
|
|
4603
|
+
baseTopMargin + topScaleH + eraReserve + markerReserve + tagLegendReserve,
|
|
4604
|
+
right: 40,
|
|
4605
|
+
bottom: 40 + scaleMargin,
|
|
4606
|
+
left: dynamicLeftMargin,
|
|
4607
|
+
};
|
|
4608
|
+
// Y offsets for label rows (negative = above chart's y=0).
|
|
4609
|
+
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
4610
|
+
const eraLabelY = eraReserve
|
|
4611
|
+
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4612
|
+
: 0;
|
|
4613
|
+
const innerWidth = width - margin.left - margin.right;
|
|
4614
|
+
const innerHeight = height - margin.top - margin.bottom;
|
|
4615
|
+
const totalGaps = (lanes.length - 1) * GROUP_GAP;
|
|
4616
|
+
const rowH = Math.min(28, (innerHeight - totalGaps) / totalEventRows);
|
|
4617
|
+
|
|
4618
|
+
const xScale = d3Scale
|
|
4619
|
+
.scaleLinear()
|
|
4620
|
+
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4621
|
+
.range([0, innerWidth]);
|
|
4622
|
+
|
|
4623
|
+
const svg = d3Selection
|
|
4624
|
+
.select(container)
|
|
4625
|
+
.append('svg')
|
|
4626
|
+
.attr('width', width)
|
|
4627
|
+
.attr('height', height)
|
|
4628
|
+
.style('background', bgColor);
|
|
4629
|
+
|
|
4630
|
+
const g = svg
|
|
4631
|
+
.append('g')
|
|
4632
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
4633
|
+
|
|
4634
|
+
renderChartTitle(
|
|
4635
|
+
svg,
|
|
4636
|
+
title,
|
|
4637
|
+
parsed.titleLineNumber,
|
|
4638
|
+
width,
|
|
4639
|
+
textColor,
|
|
4640
|
+
onClickItem
|
|
4641
|
+
);
|
|
4642
|
+
|
|
4643
|
+
renderEras(
|
|
4644
|
+
g,
|
|
4645
|
+
timelineEras,
|
|
4646
|
+
xScale,
|
|
4647
|
+
false,
|
|
4648
|
+
innerWidth,
|
|
4649
|
+
innerHeight,
|
|
4650
|
+
(s, e) => fadeToEra(g, s, e),
|
|
4651
|
+
() => fadeReset(g),
|
|
4652
|
+
timelineScale,
|
|
4653
|
+
tooltip,
|
|
4654
|
+
palette,
|
|
4655
|
+
eraReserve ? eraLabelY : undefined
|
|
4656
|
+
);
|
|
4657
|
+
|
|
4658
|
+
renderMarkers(
|
|
4659
|
+
g,
|
|
4660
|
+
timelineMarkers,
|
|
4661
|
+
xScale,
|
|
4662
|
+
false,
|
|
4663
|
+
innerWidth,
|
|
4664
|
+
innerHeight,
|
|
4665
|
+
(d) => fadeToMarker(g, d),
|
|
4666
|
+
() => fadeReset(g),
|
|
4667
|
+
timelineScale,
|
|
4668
|
+
tooltip,
|
|
4669
|
+
palette,
|
|
4670
|
+
markerReserve ? markerLabelY : undefined
|
|
4671
|
+
);
|
|
4672
|
+
|
|
4673
|
+
if (timelineScale) {
|
|
4674
|
+
renderTimeScale(
|
|
4675
|
+
g,
|
|
4676
|
+
xScale,
|
|
4677
|
+
false,
|
|
4678
|
+
innerWidth,
|
|
4679
|
+
innerHeight,
|
|
4680
|
+
textColor,
|
|
4681
|
+
minDate,
|
|
4682
|
+
maxDate,
|
|
4683
|
+
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
4684
|
+
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
4685
|
+
);
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
// Marker labels now live in their reserved row above the chart, so
|
|
4689
|
+
// events can start at y=0 (chart top edge).
|
|
4690
|
+
let curY = 0;
|
|
4691
|
+
|
|
4692
|
+
// Render swimlane backgrounds first (so they appear behind events)
|
|
4693
|
+
// Extend into left margin to include group names
|
|
4694
|
+
if (timelineSwimlanes || tagLanes) {
|
|
4695
|
+
let swimY = 0;
|
|
4696
|
+
lanes.forEach((lane, idx) => {
|
|
4697
|
+
const laneSpan = lane.events.length * rowH;
|
|
4698
|
+
// Alternate between light gray and transparent for visual separation
|
|
4699
|
+
const fillColor = idx % 2 === 0 ? textColor : 'transparent';
|
|
4700
|
+
g.append('rect')
|
|
4701
|
+
.attr('class', 'tl-swimlane')
|
|
4702
|
+
.attr('data-group', lane.name)
|
|
4703
|
+
.attr('x', -margin.left)
|
|
4704
|
+
.attr('y', swimY)
|
|
4705
|
+
.attr('width', innerWidth + margin.left)
|
|
4706
|
+
.attr('height', laneSpan + (idx < lanes.length - 1 ? GROUP_GAP : 0))
|
|
4707
|
+
.attr('fill', fillColor)
|
|
4708
|
+
.attr('opacity', 0.06);
|
|
4709
|
+
swimY += laneSpan + GROUP_GAP;
|
|
4710
|
+
});
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
for (const lane of lanes) {
|
|
4714
|
+
const laneColor = groupColorMap.get(lane.name) ?? textColor;
|
|
4715
|
+
const laneSpan = lane.events.length * rowH;
|
|
4716
|
+
|
|
4717
|
+
// Group label — left of lane, vertically centred
|
|
4718
|
+
const group = timelineGroups.find((grp) => grp.name === lane.name);
|
|
4719
|
+
const headerG = g
|
|
4720
|
+
.append('g')
|
|
4721
|
+
.attr('class', 'tl-lane-header')
|
|
4722
|
+
.attr('data-group', lane.name)
|
|
4723
|
+
.style('cursor', 'pointer')
|
|
4724
|
+
.on('mouseenter', () => fadeToGroup(g, lane.name))
|
|
4725
|
+
.on('mouseleave', () => fadeReset(g))
|
|
4726
|
+
.on('click', () => {
|
|
4727
|
+
if (onClickItem && group?.lineNumber) onClickItem(group.lineNumber);
|
|
4728
|
+
});
|
|
4729
|
+
|
|
4730
|
+
headerG
|
|
4731
|
+
.append('text')
|
|
4732
|
+
.attr('x', -margin.left + 10)
|
|
4733
|
+
.attr('y', curY + laneSpan / 2)
|
|
4734
|
+
.attr('dy', '0.35em')
|
|
4735
|
+
.attr('text-anchor', 'start')
|
|
4736
|
+
.attr('fill', laneColor)
|
|
4737
|
+
.attr('font-size', '12px')
|
|
4738
|
+
.attr('font-weight', '600')
|
|
4739
|
+
.text(lane.name);
|
|
4740
|
+
|
|
4741
|
+
lane.events.forEach((ev, i) => {
|
|
4742
|
+
const y = curY + i * rowH + rowH / 2;
|
|
4743
|
+
const x = xScale(parseTimelineDate(ev.date));
|
|
4744
|
+
|
|
4745
|
+
const evG = g
|
|
4746
|
+
.append('g')
|
|
4747
|
+
.attr('class', 'tl-event')
|
|
4748
|
+
.attr('data-group', lane.name)
|
|
4749
|
+
.attr('data-line-number', String(ev.lineNumber))
|
|
4750
|
+
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
4751
|
+
.attr(
|
|
4752
|
+
'data-end-date',
|
|
4753
|
+
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
4754
|
+
)
|
|
4755
|
+
.style('cursor', 'pointer')
|
|
4756
|
+
.on('mouseenter', function (event: MouseEvent) {
|
|
4757
|
+
fadeToGroup(g, lane.name);
|
|
4758
|
+
if (timelineScale) {
|
|
4759
|
+
showEventDatesOnScale(
|
|
4760
|
+
g,
|
|
4761
|
+
xScale,
|
|
4762
|
+
ev.date,
|
|
4763
|
+
ev.endDate,
|
|
4764
|
+
innerHeight,
|
|
4765
|
+
laneColor
|
|
4766
|
+
);
|
|
4767
|
+
} else {
|
|
4768
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4273
4769
|
}
|
|
4770
|
+
})
|
|
4771
|
+
.on('mouseleave', function () {
|
|
4772
|
+
fadeReset(g);
|
|
4773
|
+
if (timelineScale) {
|
|
4774
|
+
hideEventDatesOnScale(g);
|
|
4775
|
+
} else {
|
|
4776
|
+
hideTooltip(tooltip);
|
|
4777
|
+
}
|
|
4778
|
+
})
|
|
4779
|
+
.on('mousemove', function (event: MouseEvent) {
|
|
4780
|
+
if (!timelineScale) {
|
|
4781
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4782
|
+
}
|
|
4783
|
+
})
|
|
4784
|
+
.on('click', () => {
|
|
4785
|
+
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
4786
|
+
});
|
|
4787
|
+
setTagAttrs(evG, ev);
|
|
4274
4788
|
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4789
|
+
const evColor = eventColor(ev);
|
|
4790
|
+
|
|
4791
|
+
if (ev.endDate) {
|
|
4792
|
+
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
4793
|
+
const rectW = Math.max(x2 - x, 4);
|
|
4794
|
+
// Estimate label width (~7px per char at 13px font) + padding
|
|
4795
|
+
const estLabelWidth = ev.label.length * 7 + 16;
|
|
4796
|
+
const labelFitsInside = rectW >= estLabelWidth;
|
|
4797
|
+
|
|
4798
|
+
let fill: string = shapeFill(palette, evColor, isDark, { solid });
|
|
4799
|
+
let stroke: string = evColor;
|
|
4800
|
+
if (ev.uncertain) {
|
|
4801
|
+
// Create gradient for uncertain end - fades last 20%
|
|
4802
|
+
const gradientId = `uncertain-${ev.lineNumber}`;
|
|
4803
|
+
const strokeGradientId = `uncertain-s-${ev.lineNumber}`;
|
|
4804
|
+
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4805
|
+
const defsEl = d3Selection.select(defs as Element);
|
|
4806
|
+
defsEl
|
|
4807
|
+
.append('linearGradient')
|
|
4808
|
+
.attr('id', gradientId)
|
|
4809
|
+
.attr('x1', '0%')
|
|
4810
|
+
.attr('y1', '0%')
|
|
4811
|
+
.attr('x2', '100%')
|
|
4812
|
+
.attr('y2', '0%')
|
|
4813
|
+
.selectAll('stop')
|
|
4814
|
+
.data([
|
|
4815
|
+
{ offset: '0%', opacity: 1 },
|
|
4816
|
+
{ offset: '80%', opacity: 1 },
|
|
4817
|
+
{ offset: '100%', opacity: 0 },
|
|
4818
|
+
])
|
|
4819
|
+
.enter()
|
|
4820
|
+
.append('stop')
|
|
4821
|
+
.attr('offset', (d) => d.offset)
|
|
4822
|
+
.attr('stop-color', mix(evColor, bg, 30))
|
|
4823
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4824
|
+
defsEl
|
|
4825
|
+
.append('linearGradient')
|
|
4826
|
+
.attr('id', strokeGradientId)
|
|
4827
|
+
.attr('x1', '0%')
|
|
4828
|
+
.attr('y1', '0%')
|
|
4829
|
+
.attr('x2', '100%')
|
|
4830
|
+
.attr('y2', '0%')
|
|
4831
|
+
.selectAll('stop')
|
|
4832
|
+
.data([
|
|
4833
|
+
{ offset: '0%', opacity: 1 },
|
|
4834
|
+
{ offset: '80%', opacity: 1 },
|
|
4835
|
+
{ offset: '100%', opacity: 0 },
|
|
4836
|
+
])
|
|
4837
|
+
.enter()
|
|
4838
|
+
.append('stop')
|
|
4839
|
+
.attr('offset', (d) => d.offset)
|
|
4840
|
+
.attr('stop-color', evColor)
|
|
4841
|
+
.attr('stop-opacity', (d) => d.opacity);
|
|
4842
|
+
fill = `url(#${gradientId})`;
|
|
4843
|
+
stroke = `url(#${strokeGradientId})`;
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4846
|
+
evG
|
|
4847
|
+
.append('rect')
|
|
4848
|
+
.attr('x', x)
|
|
4849
|
+
.attr('y', y - BAR_H / 2)
|
|
4850
|
+
.attr('width', rectW)
|
|
4851
|
+
.attr('height', BAR_H)
|
|
4852
|
+
.attr('rx', 4)
|
|
4853
|
+
.attr('fill', fill)
|
|
4854
|
+
.attr('stroke', stroke)
|
|
4855
|
+
.attr('stroke-width', 2);
|
|
4856
|
+
|
|
4857
|
+
if (labelFitsInside) {
|
|
4858
|
+
// Text inside bar - use textColor for readability on muted fill
|
|
4285
4859
|
evG
|
|
4286
4860
|
.append('text')
|
|
4287
|
-
.attr('x',
|
|
4288
|
-
.attr('y', y
|
|
4861
|
+
.attr('x', x + 8)
|
|
4862
|
+
.attr('y', y)
|
|
4289
4863
|
.attr('dy', '0.35em')
|
|
4864
|
+
.attr('text-anchor', 'start')
|
|
4290
4865
|
.attr('fill', textColor)
|
|
4291
|
-
.attr('font-size', '
|
|
4866
|
+
.attr('font-size', '13px')
|
|
4292
4867
|
.text(ev.label);
|
|
4293
4868
|
} 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);
|
|
4869
|
+
// Text outside bar - check if it fits on left or must go right
|
|
4870
|
+
const wouldFlipLeft = x + rectW > innerWidth * 0.6;
|
|
4871
|
+
const labelFitsLeft = x - 6 - estLabelWidth > 0;
|
|
4872
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4302
4873
|
evG
|
|
4303
4874
|
.append('text')
|
|
4304
|
-
.attr('x',
|
|
4875
|
+
.attr('x', flipLeft ? x - 6 : x + rectW + 6)
|
|
4305
4876
|
.attr('y', y)
|
|
4306
4877
|
.attr('dy', '0.35em')
|
|
4878
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4307
4879
|
.attr('fill', textColor)
|
|
4308
|
-
.attr('font-size', '
|
|
4880
|
+
.attr('font-size', '13px')
|
|
4309
4881
|
.text(ev.label);
|
|
4310
4882
|
}
|
|
4311
|
-
|
|
4312
|
-
//
|
|
4883
|
+
} else {
|
|
4884
|
+
// Point event (no end date) - render as circle with label
|
|
4885
|
+
const estLabelWidth = ev.label.length * 7;
|
|
4886
|
+
// Only flip left if past 60% AND label fits without colliding with group name area
|
|
4887
|
+
const wouldFlipLeft = x > innerWidth * 0.6;
|
|
4888
|
+
const labelFitsLeft = x - 10 - estLabelWidth > 0;
|
|
4889
|
+
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
4890
|
+
evG
|
|
4891
|
+
.append('circle')
|
|
4892
|
+
.attr('cx', x)
|
|
4893
|
+
.attr('cy', y)
|
|
4894
|
+
.attr('r', 5)
|
|
4895
|
+
.attr('fill', shapeFill(palette, evColor, isDark, { solid }))
|
|
4896
|
+
.attr('stroke', evColor)
|
|
4897
|
+
.attr('stroke-width', 2);
|
|
4313
4898
|
evG
|
|
4314
4899
|
.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
|
-
)
|
|
4900
|
+
.attr('x', flipLeft ? x - 10 : x + 10)
|
|
4901
|
+
.attr('y', y)
|
|
4328
4902
|
.attr('dy', '0.35em')
|
|
4329
|
-
.attr('text-anchor', 'end')
|
|
4330
|
-
.attr('fill',
|
|
4331
|
-
.attr('font-size', '
|
|
4332
|
-
.text(ev.
|
|
4903
|
+
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4904
|
+
.attr('fill', textColor)
|
|
4905
|
+
.attr('font-size', '12px')
|
|
4906
|
+
.text(ev.label);
|
|
4333
4907
|
}
|
|
4334
|
-
}
|
|
4908
|
+
});
|
|
4335
4909
|
|
|
4336
|
-
|
|
4910
|
+
curY += laneSpan + GROUP_GAP;
|
|
4337
4911
|
}
|
|
4912
|
+
}
|
|
4338
4913
|
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
// ================================================================
|
|
4914
|
+
// ============================================================
|
|
4915
|
+
// Timeline — vertical-orientation renderer (extracted from renderTimeline)
|
|
4916
|
+
// ============================================================
|
|
4343
4917
|
|
|
4344
|
-
|
|
4345
|
-
|
|
4918
|
+
function renderTimelineVertical(
|
|
4919
|
+
container: HTMLDivElement,
|
|
4920
|
+
parsed: ParsedVisualization,
|
|
4921
|
+
palette: PaletteColors,
|
|
4922
|
+
isDark: boolean,
|
|
4923
|
+
setup: TimelineSetup,
|
|
4924
|
+
hovers: TimelineHoverHelpers,
|
|
4925
|
+
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
4926
|
+
exportDims: D3ExportDimensions | undefined,
|
|
4927
|
+
_swimlaneTagGroup: string | null | undefined,
|
|
4928
|
+
_activeTagGroup: string | null | undefined,
|
|
4929
|
+
_onTagStateChange:
|
|
4930
|
+
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
4931
|
+
| undefined,
|
|
4932
|
+
_viewMode: boolean | undefined
|
|
4933
|
+
): void {
|
|
4934
|
+
const {
|
|
4935
|
+
width,
|
|
4936
|
+
height,
|
|
4937
|
+
tooltip,
|
|
4938
|
+
solid,
|
|
4939
|
+
textColor,
|
|
4940
|
+
mutedColor,
|
|
4941
|
+
bgColor,
|
|
4942
|
+
bg,
|
|
4943
|
+
groupColorMap,
|
|
4944
|
+
tagLanes,
|
|
4945
|
+
eventColor,
|
|
4946
|
+
minDate,
|
|
4947
|
+
maxDate,
|
|
4948
|
+
datePadding,
|
|
4949
|
+
earliestStartDateStr,
|
|
4950
|
+
latestEndDateStr,
|
|
4951
|
+
tagLegendReserve,
|
|
4952
|
+
} = setup;
|
|
4953
|
+
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
4954
|
+
hovers;
|
|
4955
|
+
const {
|
|
4956
|
+
timelineEvents,
|
|
4957
|
+
timelineGroups,
|
|
4958
|
+
timelineEras,
|
|
4959
|
+
timelineMarkers,
|
|
4960
|
+
timelineSort,
|
|
4961
|
+
timelineScale,
|
|
4962
|
+
timelineSwimlanes,
|
|
4963
|
+
} = parsed;
|
|
4964
|
+
const title = parsed.noTitle ? null : parsed.title;
|
|
4346
4965
|
|
|
4347
|
-
const
|
|
4966
|
+
const useGroupedVertical =
|
|
4348
4967
|
tagLanes != null || (timelineSort === 'group' && timelineGroups.length > 0);
|
|
4349
|
-
if (
|
|
4350
|
-
// === GROUPED:
|
|
4351
|
-
let
|
|
4968
|
+
if (useGroupedVertical) {
|
|
4969
|
+
// === GROUPED: one column/lane per group, vertical ===
|
|
4970
|
+
let laneNames: string[];
|
|
4971
|
+
let laneEventsByName: Map<string, TimelineEvent[]>;
|
|
4352
4972
|
|
|
4353
4973
|
if (tagLanes) {
|
|
4354
|
-
|
|
4974
|
+
laneNames = tagLanes.map((l) => l.name);
|
|
4975
|
+
laneEventsByName = new Map(tagLanes.map((l) => [l.name, l.events]));
|
|
4355
4976
|
} else {
|
|
4356
4977
|
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
4357
4978
|
const ungroupedEvents = timelineEvents.filter(
|
|
4358
4979
|
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
4359
4980
|
);
|
|
4360
|
-
|
|
4981
|
+
laneNames =
|
|
4361
4982
|
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
4362
|
-
|
|
4363
|
-
name
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4983
|
+
laneEventsByName = new Map(
|
|
4984
|
+
laneNames.map((name) => [
|
|
4985
|
+
name,
|
|
4986
|
+
timelineEvents.filter((ev) =>
|
|
4987
|
+
name === '(Other)'
|
|
4988
|
+
? ev.group === null || !groupNames.includes(ev.group)
|
|
4989
|
+
: ev.group === name
|
|
4990
|
+
),
|
|
4991
|
+
])
|
|
4992
|
+
);
|
|
4370
4993
|
}
|
|
4371
4994
|
|
|
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;
|
|
4995
|
+
const laneCount = laneNames.length;
|
|
4996
|
+
const scaleMargin = timelineScale ? 40 : 0;
|
|
4997
|
+
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
4386
4998
|
const margin = {
|
|
4387
|
-
top:
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
markerReserve +
|
|
4392
|
-
tagLegendReserve,
|
|
4393
|
-
right: 40,
|
|
4394
|
-
bottom: 40 + scaleMargin,
|
|
4395
|
-
left: dynamicLeftMargin,
|
|
4999
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
5000
|
+
right: 40 + scaleMargin,
|
|
5001
|
+
bottom: 40,
|
|
5002
|
+
left: 60 + scaleMargin,
|
|
4396
5003
|
};
|
|
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
5004
|
const innerWidth = width - margin.left - margin.right;
|
|
4403
5005
|
const innerHeight = height - margin.top - margin.bottom;
|
|
4404
|
-
const
|
|
4405
|
-
const rowH = Math.min(28, (innerHeight - totalGaps) / totalEventRows);
|
|
5006
|
+
const laneWidth = innerWidth / laneCount;
|
|
4406
5007
|
|
|
4407
|
-
const
|
|
5008
|
+
const yScale = d3Scale
|
|
4408
5009
|
.scaleLinear()
|
|
4409
5010
|
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4410
|
-
.range([0,
|
|
5011
|
+
.range([0, innerHeight]);
|
|
4411
5012
|
|
|
4412
5013
|
const svg = d3Selection
|
|
4413
5014
|
.select(container)
|
|
4414
5015
|
.append('svg')
|
|
4415
|
-
.attr('
|
|
4416
|
-
.attr('
|
|
5016
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
5017
|
+
.attr('width', exportDims ? width : '100%')
|
|
5018
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
4417
5019
|
.style('background', bgColor);
|
|
4418
5020
|
|
|
4419
5021
|
const g = svg
|
|
@@ -4432,38 +5034,36 @@ export function renderTimeline(
|
|
|
4432
5034
|
renderEras(
|
|
4433
5035
|
g,
|
|
4434
5036
|
timelineEras,
|
|
4435
|
-
|
|
4436
|
-
|
|
5037
|
+
yScale,
|
|
5038
|
+
true,
|
|
4437
5039
|
innerWidth,
|
|
4438
5040
|
innerHeight,
|
|
4439
5041
|
(s, e) => fadeToEra(g, s, e),
|
|
4440
5042
|
() => fadeReset(g),
|
|
4441
5043
|
timelineScale,
|
|
4442
5044
|
tooltip,
|
|
4443
|
-
palette
|
|
4444
|
-
eraReserve ? eraLabelY : undefined
|
|
5045
|
+
palette
|
|
4445
5046
|
);
|
|
4446
5047
|
|
|
4447
5048
|
renderMarkers(
|
|
4448
5049
|
g,
|
|
4449
5050
|
timelineMarkers,
|
|
4450
|
-
|
|
4451
|
-
|
|
5051
|
+
yScale,
|
|
5052
|
+
true,
|
|
4452
5053
|
innerWidth,
|
|
4453
5054
|
innerHeight,
|
|
4454
5055
|
(d) => fadeToMarker(g, d),
|
|
4455
5056
|
() => fadeReset(g),
|
|
4456
5057
|
timelineScale,
|
|
4457
5058
|
tooltip,
|
|
4458
|
-
palette
|
|
4459
|
-
markerReserve ? markerLabelY : undefined
|
|
5059
|
+
palette
|
|
4460
5060
|
);
|
|
4461
5061
|
|
|
4462
5062
|
if (timelineScale) {
|
|
4463
5063
|
renderTimeScale(
|
|
4464
5064
|
g,
|
|
4465
|
-
|
|
4466
|
-
|
|
5065
|
+
yScale,
|
|
5066
|
+
true,
|
|
4467
5067
|
innerWidth,
|
|
4468
5068
|
innerHeight,
|
|
4469
5069
|
textColor,
|
|
@@ -4474,67 +5074,63 @@ export function renderTimeline(
|
|
|
4474
5074
|
);
|
|
4475
5075
|
}
|
|
4476
5076
|
|
|
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
|
|
5077
|
+
// Render swimlane backgrounds for vertical lanes
|
|
4483
5078
|
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';
|
|
5079
|
+
laneNames.forEach((laneName, laneIdx) => {
|
|
5080
|
+
const laneX = laneIdx * laneWidth;
|
|
5081
|
+
const fillColor = laneIdx % 2 === 0 ? textColor : 'transparent';
|
|
4489
5082
|
g.append('rect')
|
|
4490
5083
|
.attr('class', 'tl-swimlane')
|
|
4491
|
-
.attr('data-group',
|
|
4492
|
-
.attr('x',
|
|
4493
|
-
.attr('y',
|
|
4494
|
-
.attr('width',
|
|
4495
|
-
.attr('height',
|
|
5084
|
+
.attr('data-group', laneName)
|
|
5085
|
+
.attr('x', laneX)
|
|
5086
|
+
.attr('y', 0)
|
|
5087
|
+
.attr('width', laneWidth)
|
|
5088
|
+
.attr('height', innerHeight)
|
|
4496
5089
|
.attr('fill', fillColor)
|
|
4497
5090
|
.attr('opacity', 0.06);
|
|
4498
|
-
swimY += laneSpan + GROUP_GAP;
|
|
4499
5091
|
});
|
|
4500
5092
|
}
|
|
4501
5093
|
|
|
4502
|
-
|
|
4503
|
-
const
|
|
4504
|
-
const
|
|
5094
|
+
laneNames.forEach((laneName, laneIdx) => {
|
|
5095
|
+
const laneX = laneIdx * laneWidth;
|
|
5096
|
+
const laneColor = groupColorMap.get(laneName) ?? textColor;
|
|
5097
|
+
const laneCenter = laneX + laneWidth / 2;
|
|
4505
5098
|
|
|
4506
|
-
// Group label — left of lane, vertically centred
|
|
4507
|
-
const group = timelineGroups.find((grp) => grp.name === lane.name);
|
|
4508
5099
|
const headerG = g
|
|
4509
5100
|
.append('g')
|
|
4510
5101
|
.attr('class', 'tl-lane-header')
|
|
4511
|
-
.attr('data-group',
|
|
5102
|
+
.attr('data-group', laneName)
|
|
4512
5103
|
.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
|
-
});
|
|
5104
|
+
.on('mouseenter', () => fadeToGroup(g, laneName))
|
|
5105
|
+
.on('mouseleave', () => fadeReset(g));
|
|
4518
5106
|
|
|
4519
5107
|
headerG
|
|
4520
5108
|
.append('text')
|
|
4521
|
-
.attr('x',
|
|
4522
|
-
.attr('y',
|
|
4523
|
-
.attr('
|
|
4524
|
-
.attr('text-anchor', 'start')
|
|
5109
|
+
.attr('x', laneCenter)
|
|
5110
|
+
.attr('y', -15)
|
|
5111
|
+
.attr('text-anchor', 'middle')
|
|
4525
5112
|
.attr('fill', laneColor)
|
|
4526
5113
|
.attr('font-size', '12px')
|
|
4527
5114
|
.attr('font-weight', '600')
|
|
4528
|
-
.text(
|
|
5115
|
+
.text(laneName);
|
|
5116
|
+
|
|
5117
|
+
g.append('line')
|
|
5118
|
+
.attr('x1', laneCenter)
|
|
5119
|
+
.attr('y1', 0)
|
|
5120
|
+
.attr('x2', laneCenter)
|
|
5121
|
+
.attr('y2', innerHeight)
|
|
5122
|
+
.attr('stroke', mutedColor)
|
|
5123
|
+
.attr('stroke-width', 1)
|
|
5124
|
+
.attr('stroke-dasharray', '4,4');
|
|
4529
5125
|
|
|
4530
|
-
|
|
4531
|
-
const y = curY + i * rowH + rowH / 2;
|
|
4532
|
-
const x = xScale(parseTimelineDate(ev.date));
|
|
5126
|
+
const laneEvents = laneEventsByName.get(laneName) ?? [];
|
|
4533
5127
|
|
|
5128
|
+
for (const ev of laneEvents) {
|
|
5129
|
+
const y = yScale(parseTimelineDate(ev.date));
|
|
4534
5130
|
const evG = g
|
|
4535
5131
|
.append('g')
|
|
4536
5132
|
.attr('class', 'tl-event')
|
|
4537
|
-
.attr('data-group',
|
|
5133
|
+
.attr('data-group', laneName)
|
|
4538
5134
|
.attr('data-line-number', String(ev.lineNumber))
|
|
4539
5135
|
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
4540
5136
|
.attr(
|
|
@@ -4543,32 +5139,15 @@ export function renderTimeline(
|
|
|
4543
5139
|
)
|
|
4544
5140
|
.style('cursor', 'pointer')
|
|
4545
5141
|
.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
|
-
}
|
|
5142
|
+
fadeToGroup(g, laneName);
|
|
5143
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4559
5144
|
})
|
|
4560
5145
|
.on('mouseleave', function () {
|
|
4561
5146
|
fadeReset(g);
|
|
4562
|
-
|
|
4563
|
-
hideEventDatesOnScale(g);
|
|
4564
|
-
} else {
|
|
4565
|
-
hideTooltip(tooltip);
|
|
4566
|
-
}
|
|
5147
|
+
hideTooltip(tooltip);
|
|
4567
5148
|
})
|
|
4568
5149
|
.on('mousemove', function (event: MouseEvent) {
|
|
4569
|
-
|
|
4570
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4571
|
-
}
|
|
5150
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4572
5151
|
})
|
|
4573
5152
|
.on('click', () => {
|
|
4574
5153
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
@@ -4578,18 +5157,14 @@ export function renderTimeline(
|
|
|
4578
5157
|
const evColor = eventColor(ev);
|
|
4579
5158
|
|
|
4580
5159
|
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;
|
|
5160
|
+
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
5161
|
+
const rectH = Math.max(y2 - y, 4);
|
|
4586
5162
|
|
|
4587
5163
|
let fill: string = shapeFill(palette, evColor, isDark, { solid });
|
|
4588
5164
|
let stroke: string = evColor;
|
|
4589
5165
|
if (ev.uncertain) {
|
|
4590
|
-
|
|
4591
|
-
const
|
|
4592
|
-
const strokeGradientId = `uncertain-s-${ev.lineNumber}`;
|
|
5166
|
+
const gradientId = `uncertain-vg-${ev.lineNumber}`;
|
|
5167
|
+
const strokeGradientId = `uncertain-vg-s-${ev.lineNumber}`;
|
|
4593
5168
|
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4594
5169
|
const defsEl = d3Selection.select(defs as Element);
|
|
4595
5170
|
defsEl
|
|
@@ -4597,8 +5172,8 @@ export function renderTimeline(
|
|
|
4597
5172
|
.attr('id', gradientId)
|
|
4598
5173
|
.attr('x1', '0%')
|
|
4599
5174
|
.attr('y1', '0%')
|
|
4600
|
-
.attr('x2', '
|
|
4601
|
-
.attr('y2', '
|
|
5175
|
+
.attr('x2', '0%')
|
|
5176
|
+
.attr('y2', '100%')
|
|
4602
5177
|
.selectAll('stop')
|
|
4603
5178
|
.data([
|
|
4604
5179
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4608,15 +5183,15 @@ export function renderTimeline(
|
|
|
4608
5183
|
.enter()
|
|
4609
5184
|
.append('stop')
|
|
4610
5185
|
.attr('offset', (d) => d.offset)
|
|
4611
|
-
.attr('stop-color', mix(
|
|
5186
|
+
.attr('stop-color', mix(laneColor, bg, 30))
|
|
4612
5187
|
.attr('stop-opacity', (d) => d.opacity);
|
|
4613
5188
|
defsEl
|
|
4614
5189
|
.append('linearGradient')
|
|
4615
5190
|
.attr('id', strokeGradientId)
|
|
4616
5191
|
.attr('x1', '0%')
|
|
4617
5192
|
.attr('y1', '0%')
|
|
4618
|
-
.attr('x2', '
|
|
4619
|
-
.attr('y2', '
|
|
5193
|
+
.attr('x2', '0%')
|
|
5194
|
+
.attr('y2', '100%')
|
|
4620
5195
|
.selectAll('stop')
|
|
4621
5196
|
.data([
|
|
4622
5197
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4634,108 +5209,71 @@ export function renderTimeline(
|
|
|
4634
5209
|
|
|
4635
5210
|
evG
|
|
4636
5211
|
.append('rect')
|
|
4637
|
-
.attr('x',
|
|
4638
|
-
.attr('y', y
|
|
4639
|
-
.attr('width',
|
|
4640
|
-
.attr('height',
|
|
5212
|
+
.attr('x', laneCenter - 6)
|
|
5213
|
+
.attr('y', y)
|
|
5214
|
+
.attr('width', 12)
|
|
5215
|
+
.attr('height', rectH)
|
|
4641
5216
|
.attr('rx', 4)
|
|
4642
5217
|
.attr('fill', fill)
|
|
4643
5218
|
.attr('stroke', stroke)
|
|
4644
5219
|
.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
|
-
}
|
|
5220
|
+
evG
|
|
5221
|
+
.append('text')
|
|
5222
|
+
.attr('x', laneCenter + 14)
|
|
5223
|
+
.attr('y', y + rectH / 2)
|
|
5224
|
+
.attr('dy', '0.35em')
|
|
5225
|
+
.attr('fill', textColor)
|
|
5226
|
+
.attr('font-size', '10px')
|
|
5227
|
+
.text(ev.label);
|
|
4672
5228
|
} 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
5229
|
evG
|
|
4680
5230
|
.append('circle')
|
|
4681
|
-
.attr('cx',
|
|
5231
|
+
.attr('cx', laneCenter)
|
|
4682
5232
|
.attr('cy', y)
|
|
4683
|
-
.attr('r',
|
|
5233
|
+
.attr('r', 4)
|
|
4684
5234
|
.attr('fill', shapeFill(palette, evColor, isDark, { solid }))
|
|
4685
5235
|
.attr('stroke', evColor)
|
|
4686
5236
|
.attr('stroke-width', 2);
|
|
4687
5237
|
evG
|
|
4688
5238
|
.append('text')
|
|
4689
|
-
.attr('x',
|
|
5239
|
+
.attr('x', laneCenter + 10)
|
|
4690
5240
|
.attr('y', y)
|
|
4691
5241
|
.attr('dy', '0.35em')
|
|
4692
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4693
5242
|
.attr('fill', textColor)
|
|
4694
|
-
.attr('font-size', '
|
|
5243
|
+
.attr('font-size', '10px')
|
|
4695
5244
|
.text(ev.label);
|
|
4696
5245
|
}
|
|
4697
|
-
}
|
|
4698
|
-
|
|
4699
|
-
curY += laneSpan + GROUP_GAP;
|
|
4700
|
-
}
|
|
5246
|
+
}
|
|
5247
|
+
});
|
|
4701
5248
|
} 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;
|
|
5249
|
+
// === TIME SORT, vertical: single vertical axis ===
|
|
5250
|
+
const scaleMargin = timelineScale ? 40 : 0;
|
|
5251
|
+
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
4715
5252
|
const margin = {
|
|
4716
|
-
top: 104 +
|
|
4717
|
-
right:
|
|
4718
|
-
bottom: 40
|
|
4719
|
-
left: 60,
|
|
5253
|
+
top: 104 + markerMargin + tagLegendReserve,
|
|
5254
|
+
right: 200,
|
|
5255
|
+
bottom: 40,
|
|
5256
|
+
left: 60 + scaleMargin,
|
|
4720
5257
|
};
|
|
4721
|
-
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
4722
|
-
const eraLabelY = eraReserve
|
|
4723
|
-
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
4724
|
-
: 0;
|
|
4725
5258
|
const innerWidth = width - margin.left - margin.right;
|
|
4726
5259
|
const innerHeight = height - margin.top - margin.bottom;
|
|
4727
|
-
const
|
|
5260
|
+
const axisX = 20;
|
|
4728
5261
|
|
|
4729
|
-
const
|
|
5262
|
+
const yScale = d3Scale
|
|
4730
5263
|
.scaleLinear()
|
|
4731
5264
|
.domain([minDate - datePadding, maxDate + datePadding])
|
|
4732
|
-
.range([0,
|
|
5265
|
+
.range([0, innerHeight]);
|
|
5266
|
+
|
|
5267
|
+
const sorted = timelineEvents
|
|
5268
|
+
.slice()
|
|
5269
|
+
.sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
|
|
4733
5270
|
|
|
4734
5271
|
const svg = d3Selection
|
|
4735
5272
|
.select(container)
|
|
4736
5273
|
.append('svg')
|
|
4737
|
-
.attr('
|
|
4738
|
-
.attr('
|
|
5274
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
5275
|
+
.attr('width', exportDims ? width : '100%')
|
|
5276
|
+
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
4739
5277
|
.style('background', bgColor);
|
|
4740
5278
|
|
|
4741
5279
|
const g = svg
|
|
@@ -4754,38 +5292,36 @@ export function renderTimeline(
|
|
|
4754
5292
|
renderEras(
|
|
4755
5293
|
g,
|
|
4756
5294
|
timelineEras,
|
|
4757
|
-
|
|
4758
|
-
|
|
5295
|
+
yScale,
|
|
5296
|
+
true,
|
|
4759
5297
|
innerWidth,
|
|
4760
5298
|
innerHeight,
|
|
4761
5299
|
(s, e) => fadeToEra(g, s, e),
|
|
4762
5300
|
() => fadeReset(g),
|
|
4763
5301
|
timelineScale,
|
|
4764
5302
|
tooltip,
|
|
4765
|
-
palette
|
|
4766
|
-
eraReserve ? eraLabelY : undefined
|
|
5303
|
+
palette
|
|
4767
5304
|
);
|
|
4768
5305
|
|
|
4769
5306
|
renderMarkers(
|
|
4770
5307
|
g,
|
|
4771
5308
|
timelineMarkers,
|
|
4772
|
-
|
|
4773
|
-
|
|
5309
|
+
yScale,
|
|
5310
|
+
true,
|
|
4774
5311
|
innerWidth,
|
|
4775
5312
|
innerHeight,
|
|
4776
5313
|
(d) => fadeToMarker(g, d),
|
|
4777
5314
|
() => fadeReset(g),
|
|
4778
5315
|
timelineScale,
|
|
4779
5316
|
tooltip,
|
|
4780
|
-
palette
|
|
4781
|
-
markerReserve ? markerLabelY : undefined
|
|
5317
|
+
palette
|
|
4782
5318
|
);
|
|
4783
5319
|
|
|
4784
5320
|
if (timelineScale) {
|
|
4785
5321
|
renderTimeScale(
|
|
4786
5322
|
g,
|
|
4787
|
-
|
|
4788
|
-
|
|
5323
|
+
yScale,
|
|
5324
|
+
true,
|
|
4789
5325
|
innerWidth,
|
|
4790
5326
|
innerHeight,
|
|
4791
5327
|
textColor,
|
|
@@ -4796,9 +5332,8 @@ export function renderTimeline(
|
|
|
4796
5332
|
);
|
|
4797
5333
|
}
|
|
4798
5334
|
|
|
4799
|
-
// Group legend
|
|
5335
|
+
// Group legend (pill style)
|
|
4800
5336
|
if (timelineGroups.length > 0) {
|
|
4801
|
-
const legendY = timelineScale ? -75 : -55;
|
|
4802
5337
|
renderTimelineGroupLegend(
|
|
4803
5338
|
g,
|
|
4804
5339
|
timelineGroups,
|
|
@@ -4806,17 +5341,23 @@ export function renderTimeline(
|
|
|
4806
5341
|
textColor,
|
|
4807
5342
|
palette,
|
|
4808
5343
|
isDark,
|
|
4809
|
-
|
|
5344
|
+
-55,
|
|
4810
5345
|
(name) => fadeToGroup(g, name),
|
|
4811
5346
|
() => fadeReset(g)
|
|
4812
5347
|
);
|
|
4813
5348
|
}
|
|
4814
5349
|
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
5350
|
+
g.append('line')
|
|
5351
|
+
.attr('x1', axisX)
|
|
5352
|
+
.attr('y1', 0)
|
|
5353
|
+
.attr('x2', axisX)
|
|
5354
|
+
.attr('y2', innerHeight)
|
|
5355
|
+
.attr('stroke', mutedColor)
|
|
5356
|
+
.attr('stroke-width', 1)
|
|
5357
|
+
.attr('stroke-dasharray', '4,4');
|
|
5358
|
+
|
|
5359
|
+
for (const ev of sorted) {
|
|
5360
|
+
const y = yScale(parseTimelineDate(ev.date));
|
|
4820
5361
|
const color = eventColor(ev);
|
|
4821
5362
|
|
|
4822
5363
|
const evG = g
|
|
@@ -4832,31 +5373,14 @@ export function renderTimeline(
|
|
|
4832
5373
|
.style('cursor', 'pointer')
|
|
4833
5374
|
.on('mouseenter', function (event: MouseEvent) {
|
|
4834
5375
|
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
|
-
}
|
|
5376
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4847
5377
|
})
|
|
4848
5378
|
.on('mouseleave', function () {
|
|
4849
5379
|
fadeReset(g);
|
|
4850
|
-
|
|
4851
|
-
hideEventDatesOnScale(g);
|
|
4852
|
-
} else {
|
|
4853
|
-
hideTooltip(tooltip);
|
|
4854
|
-
}
|
|
5380
|
+
hideTooltip(tooltip);
|
|
4855
5381
|
})
|
|
4856
5382
|
.on('mousemove', function (event: MouseEvent) {
|
|
4857
|
-
|
|
4858
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4859
|
-
}
|
|
5383
|
+
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
4860
5384
|
})
|
|
4861
5385
|
.on('click', () => {
|
|
4862
5386
|
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
@@ -4864,18 +5388,14 @@ export function renderTimeline(
|
|
|
4864
5388
|
setTagAttrs(evG, ev);
|
|
4865
5389
|
|
|
4866
5390
|
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;
|
|
5391
|
+
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
5392
|
+
const rectH = Math.max(y2 - y, 4);
|
|
4872
5393
|
|
|
4873
5394
|
let fill: string = shapeFill(palette, color, isDark, { solid });
|
|
4874
5395
|
let stroke: string = color;
|
|
4875
5396
|
if (ev.uncertain) {
|
|
4876
|
-
|
|
4877
|
-
const
|
|
4878
|
-
const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;
|
|
5397
|
+
const gradientId = `uncertain-v-${ev.lineNumber}`;
|
|
5398
|
+
const strokeGradientId = `uncertain-v-s-${ev.lineNumber}`;
|
|
4879
5399
|
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
4880
5400
|
const defsEl = d3Selection.select(defs as Element);
|
|
4881
5401
|
defsEl
|
|
@@ -4883,8 +5403,8 @@ export function renderTimeline(
|
|
|
4883
5403
|
.attr('id', gradientId)
|
|
4884
5404
|
.attr('x1', '0%')
|
|
4885
5405
|
.attr('y1', '0%')
|
|
4886
|
-
.attr('x2', '
|
|
4887
|
-
.attr('y2', '
|
|
5406
|
+
.attr('x2', '0%')
|
|
5407
|
+
.attr('y2', '100%')
|
|
4888
5408
|
.selectAll('stop')
|
|
4889
5409
|
.data([
|
|
4890
5410
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4901,8 +5421,8 @@ export function renderTimeline(
|
|
|
4901
5421
|
.attr('id', strokeGradientId)
|
|
4902
5422
|
.attr('x1', '0%')
|
|
4903
5423
|
.attr('y1', '0%')
|
|
4904
|
-
.attr('x2', '
|
|
4905
|
-
.attr('y2', '
|
|
5424
|
+
.attr('x2', '0%')
|
|
5425
|
+
.attr('y2', '100%')
|
|
4906
5426
|
.selectAll('stop')
|
|
4907
5427
|
.data([
|
|
4908
5428
|
{ offset: '0%', opacity: 1 },
|
|
@@ -4920,353 +5440,167 @@ export function renderTimeline(
|
|
|
4920
5440
|
|
|
4921
5441
|
evG
|
|
4922
5442
|
.append('rect')
|
|
4923
|
-
.attr('x',
|
|
4924
|
-
.attr('y', y
|
|
4925
|
-
.attr('width',
|
|
4926
|
-
.attr('height',
|
|
5443
|
+
.attr('x', axisX - 6)
|
|
5444
|
+
.attr('y', y)
|
|
5445
|
+
.attr('width', 12)
|
|
5446
|
+
.attr('height', rectH)
|
|
4927
5447
|
.attr('rx', 4)
|
|
4928
5448
|
.attr('fill', fill)
|
|
4929
5449
|
.attr('stroke', stroke)
|
|
4930
5450
|
.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
|
-
}
|
|
5451
|
+
evG
|
|
5452
|
+
.append('text')
|
|
5453
|
+
.attr('x', axisX + 16)
|
|
5454
|
+
.attr('y', y + rectH / 2)
|
|
5455
|
+
.attr('dy', '0.35em')
|
|
5456
|
+
.attr('fill', textColor)
|
|
5457
|
+
.attr('font-size', '11px')
|
|
5458
|
+
.text(ev.label);
|
|
4958
5459
|
} 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
5460
|
evG
|
|
4966
5461
|
.append('circle')
|
|
4967
|
-
.attr('cx',
|
|
5462
|
+
.attr('cx', axisX)
|
|
4968
5463
|
.attr('cy', y)
|
|
4969
|
-
.attr('r',
|
|
5464
|
+
.attr('r', 4)
|
|
4970
5465
|
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
4971
5466
|
.attr('stroke', color)
|
|
4972
5467
|
.attr('stroke-width', 2);
|
|
4973
5468
|
evG
|
|
4974
5469
|
.append('text')
|
|
4975
|
-
.attr('x',
|
|
5470
|
+
.attr('x', axisX + 16)
|
|
4976
5471
|
.attr('y', y)
|
|
4977
5472
|
.attr('dy', '0.35em')
|
|
4978
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
4979
5473
|
.attr('fill', textColor)
|
|
4980
|
-
.attr('font-size', '
|
|
5474
|
+
.attr('font-size', '11px')
|
|
4981
5475
|
.text(ev.label);
|
|
4982
5476
|
}
|
|
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
5477
|
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5478
|
+
// Date label to the left
|
|
5479
|
+
evG
|
|
5480
|
+
.append('text')
|
|
5481
|
+
.attr('x', axisX - 14)
|
|
5482
|
+
.attr(
|
|
5483
|
+
'y',
|
|
5484
|
+
ev.endDate
|
|
5485
|
+
? yScale(parseTimelineDate(ev.date)) +
|
|
5486
|
+
Math.max(
|
|
5487
|
+
yScale(parseTimelineDate(ev.endDate)) -
|
|
5488
|
+
yScale(parseTimelineDate(ev.date)),
|
|
5489
|
+
4
|
|
5490
|
+
) /
|
|
5491
|
+
2
|
|
5492
|
+
: y
|
|
5493
|
+
)
|
|
5494
|
+
.attr('dy', '0.35em')
|
|
5495
|
+
.attr('text-anchor', 'end')
|
|
5496
|
+
.attr('fill', mutedColor)
|
|
5497
|
+
.attr('font-size', '10px')
|
|
5498
|
+
.text(ev.date + (ev.endDate ? `→${ev.endDate}` : ''));
|
|
5499
|
+
}
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5116
5502
|
|
|
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
|
-
recolorEvents();
|
|
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
|
-
}
|
|
5503
|
+
/**
|
|
5504
|
+
* Renders a timeline chart into the given container using D3.
|
|
5505
|
+
* Supports horizontal (default) and vertical orientation.
|
|
5506
|
+
*/
|
|
5507
|
+
export function renderTimeline(
|
|
5508
|
+
container: HTMLDivElement,
|
|
5509
|
+
parsed: ParsedVisualization,
|
|
5510
|
+
palette: PaletteColors,
|
|
5511
|
+
isDark: boolean,
|
|
5512
|
+
onClickItem?: (lineNumber: number) => void,
|
|
5513
|
+
exportDims?: D3ExportDimensions,
|
|
5514
|
+
activeTagGroup?: string | null,
|
|
5515
|
+
swimlaneTagGroup?: string | null,
|
|
5516
|
+
onTagStateChange?: (
|
|
5517
|
+
activeTagGroup: string | null,
|
|
5518
|
+
swimlaneTagGroup: string | null
|
|
5519
|
+
) => void,
|
|
5520
|
+
viewMode?: boolean
|
|
5521
|
+
): void {
|
|
5522
|
+
const setup = setupTimeline(
|
|
5523
|
+
container,
|
|
5524
|
+
parsed,
|
|
5525
|
+
palette,
|
|
5526
|
+
isDark,
|
|
5527
|
+
exportDims,
|
|
5528
|
+
activeTagGroup,
|
|
5529
|
+
swimlaneTagGroup
|
|
5530
|
+
);
|
|
5531
|
+
if (!setup) return;
|
|
5532
|
+
swimlaneTagGroup = setup.swimlaneTagGroup;
|
|
5225
5533
|
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
for (const ev of timelineEvents) {
|
|
5229
|
-
eventByLine.set(String(ev.lineNumber), ev);
|
|
5230
|
-
}
|
|
5534
|
+
const { isVertical, tagLanes } = setup;
|
|
5535
|
+
const hovers = makeTimelineHoverHelpers();
|
|
5231
5536
|
|
|
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
|
-
}
|
|
5537
|
+
if (isVertical) {
|
|
5538
|
+
renderTimelineVertical(
|
|
5539
|
+
container,
|
|
5540
|
+
parsed,
|
|
5541
|
+
palette,
|
|
5542
|
+
isDark,
|
|
5543
|
+
setup,
|
|
5544
|
+
hovers,
|
|
5545
|
+
onClickItem,
|
|
5546
|
+
exportDims,
|
|
5547
|
+
swimlaneTagGroup,
|
|
5548
|
+
activeTagGroup,
|
|
5549
|
+
onTagStateChange,
|
|
5550
|
+
viewMode
|
|
5551
|
+
);
|
|
5552
|
+
return;
|
|
5553
|
+
}
|
|
5266
5554
|
|
|
5267
|
-
|
|
5268
|
-
|
|
5555
|
+
const useGroupedHorizontal =
|
|
5556
|
+
tagLanes != null ||
|
|
5557
|
+
(parsed.timelineSort === 'group' && parsed.timelineGroups.length > 0);
|
|
5558
|
+
if (useGroupedHorizontal) {
|
|
5559
|
+
renderTimelineHorizontalGrouped(
|
|
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
|
+
} else {
|
|
5574
|
+
renderTimelineHorizontalTimeSort(
|
|
5575
|
+
container,
|
|
5576
|
+
parsed,
|
|
5577
|
+
palette,
|
|
5578
|
+
isDark,
|
|
5579
|
+
setup,
|
|
5580
|
+
hovers,
|
|
5581
|
+
onClickItem,
|
|
5582
|
+
exportDims,
|
|
5583
|
+
swimlaneTagGroup,
|
|
5584
|
+
activeTagGroup,
|
|
5585
|
+
onTagStateChange,
|
|
5586
|
+
viewMode
|
|
5587
|
+
);
|
|
5269
5588
|
}
|
|
5589
|
+
|
|
5590
|
+
renderTimelineTagLegendOverlay(
|
|
5591
|
+
container,
|
|
5592
|
+
parsed,
|
|
5593
|
+
palette,
|
|
5594
|
+
isDark,
|
|
5595
|
+
setup,
|
|
5596
|
+
hovers,
|
|
5597
|
+
onClickItem,
|
|
5598
|
+
exportDims,
|
|
5599
|
+
swimlaneTagGroup,
|
|
5600
|
+
activeTagGroup,
|
|
5601
|
+
onTagStateChange,
|
|
5602
|
+
viewMode
|
|
5603
|
+
);
|
|
5270
5604
|
}
|
|
5271
5605
|
|
|
5272
5606
|
// ============================================================
|
|
@@ -5580,7 +5914,7 @@ export function renderVenn(
|
|
|
5580
5914
|
container: HTMLDivElement,
|
|
5581
5915
|
parsed: ParsedVisualization,
|
|
5582
5916
|
palette: PaletteColors,
|
|
5583
|
-
|
|
5917
|
+
_isDark: boolean,
|
|
5584
5918
|
onClickItem?: (lineNumber: number) => void,
|
|
5585
5919
|
exportDims?: D3ExportDimensions
|
|
5586
5920
|
): void {
|
|
@@ -5955,7 +6289,7 @@ export function renderVenn(
|
|
|
5955
6289
|
const gcx = circles.reduce((s, c) => s + c.x, 0) / n;
|
|
5956
6290
|
const gcy = circles.reduce((s, c) => s + c.y, 0) / n;
|
|
5957
6291
|
|
|
5958
|
-
function exclusiveHSpan(
|
|
6292
|
+
function exclusiveHSpan(_px: number, py: number, ci: number): number {
|
|
5959
6293
|
const dy = py - circles[ci].y;
|
|
5960
6294
|
const halfChord = Math.sqrt(
|
|
5961
6295
|
Math.max(0, circles[ci].r * circles[ci].r - dy * dy)
|
|
@@ -7397,11 +7731,10 @@ export async function renderForExport(
|
|
|
7397
7731
|
}
|
|
7398
7732
|
}
|
|
7399
7733
|
|
|
7400
|
-
const { layoutBoxesAndLines } = await import('./boxes-and-lines/layout');
|
|
7401
7734
|
const { renderBoxesAndLinesForExport } =
|
|
7402
7735
|
await import('./boxes-and-lines/renderer');
|
|
7403
|
-
|
|
7404
|
-
const blLayout = layoutBoxesAndLines(blParsed);
|
|
7736
|
+
const { layoutBoxesAndLines } = await import('./boxes-and-lines/layout');
|
|
7737
|
+
const blLayout = await layoutBoxesAndLines(blParsed);
|
|
7405
7738
|
const PADDING = 20;
|
|
7406
7739
|
const titleOffset = blParsed.title ? 40 : 0;
|
|
7407
7740
|
const exportWidth = blLayout.width + PADDING * 2;
|