@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.
Files changed (71) hide show
  1. package/README.md +14 -1
  2. package/dist/advanced.cjs +53069 -0
  3. package/dist/advanced.d.cts +4691 -0
  4. package/dist/advanced.d.ts +4691 -0
  5. package/dist/advanced.js +52823 -0
  6. package/dist/auto.cjs +1557 -1295
  7. package/dist/auto.js +132 -713
  8. package/dist/auto.mjs +1553 -1291
  9. package/dist/cli.cjs +173 -150
  10. package/dist/editor.cjs +1 -0
  11. package/dist/editor.js +1 -0
  12. package/dist/highlight.cjs +1 -0
  13. package/dist/highlight.js +1 -0
  14. package/dist/index.cjs +2031 -4722
  15. package/dist/index.d.cts +96 -4464
  16. package/dist/index.d.ts +96 -4464
  17. package/dist/index.js +2024 -4475
  18. package/dist/internal.cjs +51930 -553
  19. package/dist/internal.d.cts +4526 -102
  20. package/dist/internal.d.ts +4526 -102
  21. package/dist/internal.js +51721 -548
  22. package/dist/pert.cjs +1 -1
  23. package/dist/pert.js +1 -1
  24. package/docs/language-reference.md +67 -17
  25. package/package.json +18 -3
  26. package/src/advanced.ts +731 -0
  27. package/src/auto/index.ts +14 -13
  28. package/src/boxes-and-lines/layout.ts +481 -445
  29. package/src/c4/parser.ts +7 -7
  30. package/src/chart-types.ts +0 -5
  31. package/src/class/parser.ts +1 -9
  32. package/src/cli.ts +9 -7
  33. package/src/completion-types.ts +28 -0
  34. package/src/completion.ts +15 -18
  35. package/src/cycle/layout.ts +2 -2
  36. package/src/d3.ts +1455 -1122
  37. package/src/echarts.ts +11 -11
  38. package/src/editor/keywords.ts +1 -0
  39. package/src/er/parser.ts +1 -9
  40. package/src/er/renderer.ts +1 -1
  41. package/src/gantt/calculator.ts +1 -11
  42. package/src/gantt/parser.ts +16 -16
  43. package/src/gantt/renderer.ts +2 -2
  44. package/src/graph/flowchart-parser.ts +1 -1
  45. package/src/graph/flowchart-renderer.ts +1 -1
  46. package/src/graph/state-renderer.ts +1 -1
  47. package/src/index.ts +213 -690
  48. package/src/infra/parser.ts +57 -25
  49. package/src/infra/renderer.ts +2 -2
  50. package/src/internal.ts +11 -17
  51. package/src/kanban/parser.ts +2 -2
  52. package/src/mindmap/layout.ts +1 -1
  53. package/src/mindmap/parser.ts +1 -1
  54. package/src/org/parser.ts +1 -1
  55. package/src/org/renderer.ts +1 -1
  56. package/src/palettes/index.ts +39 -0
  57. package/src/pert/layout.ts +1 -1
  58. package/src/pert/monte-carlo.ts +2 -2
  59. package/src/pert/parser.ts +3 -3
  60. package/src/raci/parser.ts +4 -4
  61. package/src/raci/renderer.ts +1 -1
  62. package/src/render.ts +17 -1
  63. package/src/sequence/renderer.ts +1 -4
  64. package/src/sitemap/parser.ts +1 -1
  65. package/src/tech-radar/interactive.ts +1 -1
  66. package/src/tech-radar/renderer.ts +1 -1
  67. package/src/themes.ts +22 -0
  68. package/src/utils/tag-groups.ts +11 -12
  69. package/src/wireframe/layout.ts +11 -7
  70. package/src/wireframe/parser.ts +2 -2
  71. 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
- * Renders a timeline chart into the given container using D3.
3486
- * Supports horizontal (default) and vertical orientation.
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
- export function renderTimeline(
3520
+ function setupTimeline(
3489
3521
  container: HTMLDivElement,
3490
3522
  parsed: ParsedVisualization,
3491
3523
  palette: PaletteColors,
3492
3524
  isDark: boolean,
3493
- onClickItem?: (lineNumber: number) => void,
3494
- exportDims?: D3ExportDimensions,
3495
- activeTagGroup?: string | null,
3496
- swimlaneTagGroup?: string | null,
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
- const title = parsed.noTitle ? null : parsed.title;
3517
- if (timelineEvents.length === 0) return;
3540
+ if (timelineEvents.length === 0) return null;
3518
3541
 
3519
- // When sort: tag is set and no explicit swimlane param, use the default
3542
+ let resolvedSwimlaneTG: string | null = swimlaneTagGroup ?? null;
3520
3543
  if (
3521
- swimlaneTagGroup == null &&
3544
+ resolvedSwimlaneTG == null &&
3522
3545
  timelineSort === 'tag' &&
3523
3546
  parsed.timelineDefaultSwimlaneTG
3524
3547
  ) {
3525
- swimlaneTagGroup = parsed.timelineDefaultSwimlaneTG;
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 (swimlaneTagGroup) {
3555
- const tagKey = swimlaneTagGroup.toLowerCase();
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
- // Determine effective color source: explicit colorTG > swimlaneTG > group
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 FADE_OPACITY = 0.1;
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
- // Shared hover helpers (operate on CSS classes, orientation-agnostic)
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; // keep group pill at full opacity
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
- // Reserve space for tag legend at the top of chart content (below title/headers)
3792
- const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
3859
+ return {
3860
+ FADE_OPACITY,
3861
+ fadeToGroup,
3862
+ fadeToEra,
3863
+ fadeToMarker,
3864
+ fadeReset,
3865
+ fadeToTagValue,
3866
+ setTagAttrs,
3867
+ };
3868
+ }
3793
3869
 
3794
- // ================================================================
3795
- // VERTICAL orientation (time flows top→bottom)
3796
- // ================================================================
3797
- if (isVertical) {
3798
- const useGroupedVertical =
3799
- tagLanes != null ||
3800
- (timelineSort === 'group' && timelineGroups.length > 0);
3801
- if (useGroupedVertical) {
3802
- // === GROUPED: one column/lane per group, vertical ===
3803
- let laneNames: string[];
3804
- let laneEventsByName: Map<string, TimelineEvent[]>;
3805
-
3806
- if (tagLanes) {
3807
- laneNames = tagLanes.map((l) => l.name);
3808
- laneEventsByName = new Map(tagLanes.map((l) => [l.name, l.events]));
3809
- } else {
3810
- const groupNames = timelineGroups.map((gr) => gr.name);
3811
- const ungroupedEvents = timelineEvents.filter(
3812
- (ev) => ev.group === null || !groupNames.includes(ev.group)
3813
- );
3814
- laneNames =
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
- const laneCount = laneNames.length;
3829
- const scaleMargin = timelineScale ? 40 : 0;
3830
- const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3831
- const margin = {
3832
- top: 104 + markerMargin + tagLegendReserve,
3833
- right: 40 + scaleMargin,
3834
- bottom: 40,
3835
- left: 60 + scaleMargin,
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
- const innerWidth = width - margin.left - margin.right;
3838
- const innerHeight = height - margin.top - margin.bottom;
3839
- const laneWidth = innerWidth / laneCount;
3840
-
3841
- const yScale = d3Scale
3842
- .scaleLinear()
3843
- .domain([minDate - datePadding, maxDate + datePadding])
3844
- .range([0, innerHeight]);
3845
-
3846
- const svg = d3Selection
3847
- .select(container)
3848
- .append('svg')
3849
- .attr('viewBox', `0 0 ${width} ${height}`)
3850
- .attr('width', exportDims ? width : '100%')
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('transform', `translate(${margin.left},${margin.top})`);
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
- renderChartTitle(
3859
- svg,
3860
- title,
3861
- parsed.titleLineNumber,
3862
- width,
3863
- textColor,
3864
- onClickItem
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
- renderEras(
3868
- g,
3869
- timelineEras,
3870
- yScale,
3871
- true,
3872
- innerWidth,
3873
- innerHeight,
3874
- (s, e) => fadeToEra(g, s, e),
3875
- () => fadeReset(g),
3876
- timelineScale,
3877
- tooltip,
3878
- palette
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
- renderMarkers(
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
- if (timelineScale) {
3896
- renderTimeScale(
3897
- g,
3898
- yScale,
3899
- true,
3900
- innerWidth,
3901
- innerHeight,
3902
- textColor,
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 swimlane backgrounds for vertical lanes
3911
- if (timelineSwimlanes || tagLanes) {
3912
- laneNames.forEach((laneName, laneIdx) => {
3913
- const laneX = laneIdx * laneWidth;
3914
- const fillColor = laneIdx % 2 === 0 ? textColor : 'transparent';
3915
- g.append('rect')
3916
- .attr('class', 'tl-swimlane')
3917
- .attr('data-group', laneName)
3918
- .attr('x', laneX)
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
- laneNames.forEach((laneName, laneIdx) => {
3928
- const laneX = laneIdx * laneWidth;
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
- const headerG = g
3933
- .append('g')
3934
- .attr('class', 'tl-lane-header')
3935
- .attr('data-group', laneName)
3936
- .style('cursor', 'pointer')
3937
- .on('mouseenter', () => fadeToGroup(g, laneName))
3938
- .on('mouseleave', () => fadeReset(g));
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
- headerG
3941
- .append('text')
3942
- .attr('x', laneCenter)
3943
- .attr('y', -15)
3944
- .attr('text-anchor', 'middle')
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
- evG
4045
- .append('rect')
4046
- .attr('x', laneCenter - 6)
4047
- .attr('y', y)
4048
- .attr('width', 12)
4049
- .attr('height', rectH)
4050
- .attr('rx', 4)
4051
- .attr('fill', fill)
4052
- .attr('stroke', stroke)
4053
- .attr('stroke-width', 2);
4054
- evG
4055
- .append('text')
4056
- .attr('x', laneCenter + 14)
4057
- .attr('y', y + rectH / 2)
4058
- .attr('dy', '0.35em')
4059
- .attr('fill', textColor)
4060
- .attr('font-size', '10px')
4061
- .text(ev.label);
4062
- } else {
4063
- evG
4064
- .append('circle')
4065
- .attr('cx', laneCenter)
4066
- .attr('cy', y)
4067
- .attr('r', 4)
4068
- .attr('fill', shapeFill(palette, evColor, isDark, { solid }))
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
- } else {
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
- const yScale = d3Scale
4097
- .scaleLinear()
4098
- .domain([minDate - datePadding, maxDate + datePadding])
4099
- .range([0, innerHeight]);
4174
+ drawLegend();
4175
+ }
4176
+ }
4100
4177
 
4101
- const sorted = timelineEvents
4102
- .slice()
4103
- .sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
4178
+ // ============================================================
4179
+ // Timeline — horizontal-time-sort renderer (extracted from renderTimeline)
4180
+ // ============================================================
4104
4181
 
4105
- const svg = d3Selection
4106
- .select(container)
4107
- .append('svg')
4108
- .attr('viewBox', `0 0 ${width} ${height}`)
4109
- .attr('width', exportDims ? width : '100%')
4110
- .attr('preserveAspectRatio', 'xMidYMin meet')
4111
- .style('background', bgColor);
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
- const g = svg
4114
- .append('g')
4115
- .attr('transform', `translate(${margin.left},${margin.top})`);
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
- renderChartTitle(
4118
- svg,
4119
- title,
4120
- parsed.titleLineNumber,
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
- renderEras(
4127
- g,
4128
- timelineEras,
4129
- yScale,
4130
- true,
4131
- innerWidth,
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
- renderMarkers(
4141
- g,
4142
- timelineMarkers,
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
- if (timelineScale) {
4155
- renderTimeScale(
4156
- g,
4157
- yScale,
4158
- true,
4159
- innerWidth,
4160
- innerHeight,
4161
- textColor,
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
- // Group legend (pill style)
4170
- if (timelineGroups.length > 0) {
4171
- renderTimelineGroupLegend(
4172
- g,
4173
- timelineGroups,
4174
- groupColorMap,
4175
- textColor,
4176
- palette,
4177
- isDark,
4178
- -55,
4179
- (name) => fadeToGroup(g, name),
4180
- () => fadeReset(g)
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
- g.append('line')
4185
- .attr('x1', axisX)
4186
- .attr('y1', 0)
4187
- .attr('x2', axisX)
4188
- .attr('y2', innerHeight)
4189
- .attr('stroke', mutedColor)
4190
- .attr('stroke-width', 1)
4191
- .attr('stroke-dasharray', '4,4');
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
- for (const ev of sorted) {
4194
- const y = yScale(parseTimelineDate(ev.date));
4195
- const color = eventColor(ev);
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
- const evG = g
4198
- .append('g')
4199
- .attr('class', 'tl-event')
4200
- .attr('data-group', ev.group || '')
4201
- .attr('data-line-number', String(ev.lineNumber))
4202
- .attr('data-date', String(parseTimelineDate(ev.date)))
4203
- .attr(
4204
- 'data-end-date',
4205
- ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
4206
- )
4207
- .style('cursor', 'pointer')
4208
- .on('mouseenter', function (event: MouseEvent) {
4209
- if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
4210
- showTooltip(tooltip, buildEventTooltipHtml(ev), event);
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
- if (ev.endDate) {
4225
- const y2 = yScale(parseTimelineDate(ev.endDate));
4226
- const rectH = Math.max(y2 - y, 4);
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
- let fill: string = shapeFill(palette, color, isDark, { solid });
4229
- let stroke: string = color;
4230
- if (ev.uncertain) {
4231
- const gradientId = `uncertain-v-${ev.lineNumber}`;
4232
- const strokeGradientId = `uncertain-v-s-${ev.lineNumber}`;
4233
- const defs = svg.select('defs').node() || svg.append('defs').node();
4234
- const defsEl = d3Selection.select(defs as Element);
4235
- defsEl
4236
- .append('linearGradient')
4237
- .attr('id', gradientId)
4238
- .attr('x1', '0%')
4239
- .attr('y1', '0%')
4240
- .attr('x2', '0%')
4241
- .attr('y2', '100%')
4242
- .selectAll('stop')
4243
- .data([
4244
- { offset: '0%', opacity: 1 },
4245
- { offset: '80%', opacity: 1 },
4246
- { offset: '100%', opacity: 0 },
4247
- ])
4248
- .enter()
4249
- .append('stop')
4250
- .attr('offset', (d) => d.offset)
4251
- .attr('stop-color', mix(color, bg, 30))
4252
- .attr('stop-opacity', (d) => d.opacity);
4253
- defsEl
4254
- .append('linearGradient')
4255
- .attr('id', strokeGradientId)
4256
- .attr('x1', '0%')
4257
- .attr('y1', '0%')
4258
- .attr('x2', '0%')
4259
- .attr('y2', '100%')
4260
- .selectAll('stop')
4261
- .data([
4262
- { offset: '0%', opacity: 1 },
4263
- { offset: '80%', opacity: 1 },
4264
- { offset: '100%', opacity: 0 },
4265
- ])
4266
- .enter()
4267
- .append('stop')
4268
- .attr('offset', (d) => d.offset)
4269
- .attr('stop-color', color)
4270
- .attr('stop-opacity', (d) => d.opacity);
4271
- fill = `url(#${gradientId})`;
4272
- stroke = `url(#${strokeGradientId})`;
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
- evG
4276
- .append('rect')
4277
- .attr('x', axisX - 6)
4278
- .attr('y', y)
4279
- .attr('width', 12)
4280
- .attr('height', rectH)
4281
- .attr('rx', 4)
4282
- .attr('fill', fill)
4283
- .attr('stroke', stroke)
4284
- .attr('stroke-width', 2);
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', axisX + 16)
4288
- .attr('y', y + rectH / 2)
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', '11px')
4866
+ .attr('font-size', '13px')
4292
4867
  .text(ev.label);
4293
4868
  } else {
4294
- evG
4295
- .append('circle')
4296
- .attr('cx', axisX)
4297
- .attr('cy', y)
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', axisX + 16)
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', '11px')
4880
+ .attr('font-size', '13px')
4309
4881
  .text(ev.label);
4310
4882
  }
4311
-
4312
- // Date label to the left
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', axisX - 14)
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', mutedColor)
4331
- .attr('font-size', '10px')
4332
- .text(ev.date + (ev.endDate ? `→${ev.endDate}` : ''));
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
- return; // vertical done
4910
+ curY += laneSpan + GROUP_GAP;
4337
4911
  }
4912
+ }
4338
4913
 
4339
- // ================================================================
4340
- // HORIZONTAL orientation (default time flows left→right)
4341
- // Each event gets its own row, stacked vertically.
4342
- // ================================================================
4914
+ // ============================================================
4915
+ // Timeline — vertical-orientation renderer (extracted from renderTimeline)
4916
+ // ============================================================
4343
4917
 
4344
- const BAR_H = 22; // range bar thickness (tall enough for text inside)
4345
- const GROUP_GAP = 12; // vertical gap between group swim-lanes
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 useGroupedHorizontal =
4966
+ const useGroupedVertical =
4348
4967
  tagLanes != null || (timelineSort === 'group' && timelineGroups.length > 0);
4349
- if (useGroupedHorizontal) {
4350
- // === GROUPED: swim-lanes stacked vertically, events on own rows ===
4351
- let lanes: Lane[];
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
- lanes = tagLanes;
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
- const laneNames =
4981
+ laneNames =
4361
4982
  ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
4362
- lanes = laneNames.map((name) => ({
4363
- name,
4364
- events: timelineEvents.filter((ev) =>
4365
- name === '(Other)'
4366
- ? ev.group === null || !groupNames.includes(ev.group)
4367
- : ev.group === name
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 totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);
4373
- const scaleMargin = timelineScale ? 24 : 0;
4374
- // Per-feature header rows: era + marker each get their own row, reserved
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
- baseTopMargin +
4389
- topScaleH +
4390
- eraReserve +
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 totalGaps = (lanes.length - 1) * GROUP_GAP;
4405
- const rowH = Math.min(28, (innerHeight - totalGaps) / totalEventRows);
5006
+ const laneWidth = innerWidth / laneCount;
4406
5007
 
4407
- const xScale = d3Scale
5008
+ const yScale = d3Scale
4408
5009
  .scaleLinear()
4409
5010
  .domain([minDate - datePadding, maxDate + datePadding])
4410
- .range([0, innerWidth]);
5011
+ .range([0, innerHeight]);
4411
5012
 
4412
5013
  const svg = d3Selection
4413
5014
  .select(container)
4414
5015
  .append('svg')
4415
- .attr('width', width)
4416
- .attr('height', height)
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
- xScale,
4436
- false,
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
- xScale,
4451
- false,
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
- xScale,
4466
- false,
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
- // Marker labels now live in their reserved row above the chart, so
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
- let swimY = 0;
4485
- lanes.forEach((lane, idx) => {
4486
- const laneSpan = lane.events.length * rowH;
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', lane.name)
4492
- .attr('x', -margin.left)
4493
- .attr('y', swimY)
4494
- .attr('width', innerWidth + margin.left)
4495
- .attr('height', laneSpan + (idx < lanes.length - 1 ? GROUP_GAP : 0))
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
- for (const lane of lanes) {
4503
- const laneColor = groupColorMap.get(lane.name) ?? textColor;
4504
- const laneSpan = lane.events.length * rowH;
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', lane.name)
5102
+ .attr('data-group', laneName)
4512
5103
  .style('cursor', 'pointer')
4513
- .on('mouseenter', () => fadeToGroup(g, lane.name))
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', -margin.left + 10)
4522
- .attr('y', curY + laneSpan / 2)
4523
- .attr('dy', '0.35em')
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(lane.name);
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
- lane.events.forEach((ev, i) => {
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', lane.name)
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, lane.name);
4547
- if (timelineScale) {
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
- if (timelineScale) {
4563
- hideEventDatesOnScale(g);
4564
- } else {
4565
- hideTooltip(tooltip);
4566
- }
5147
+ hideTooltip(tooltip);
4567
5148
  })
4568
5149
  .on('mousemove', function (event: MouseEvent) {
4569
- if (!timelineScale) {
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 x2 = xScale(parseTimelineDate(ev.endDate));
4582
- const rectW = Math.max(x2 - x, 4);
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
- // Create gradient for uncertain end - fades last 20%
4591
- const gradientId = `uncertain-${ev.lineNumber}`;
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', '100%')
4601
- .attr('y2', '0%')
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(evColor, bg, 30))
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', '100%')
4619
- .attr('y2', '0%')
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', x)
4638
- .attr('y', y - BAR_H / 2)
4639
- .attr('width', rectW)
4640
- .attr('height', BAR_H)
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
- if (labelFitsInside) {
4647
- // Text inside bar - use textColor for readability on muted fill
4648
- evG
4649
- .append('text')
4650
- .attr('x', x + 8)
4651
- .attr('y', y)
4652
- .attr('dy', '0.35em')
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', x)
5231
+ .attr('cx', laneCenter)
4682
5232
  .attr('cy', y)
4683
- .attr('r', 5)
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', flipLeft ? x - 10 : x + 10)
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', '12px')
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, horizontal: each event on its own row ===
4703
- const sorted = timelineEvents
4704
- .slice()
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 + topScaleH + eraReserve + markerReserve + tagLegendReserve,
4717
- right: 40,
4718
- bottom: 40 + scaleMargin,
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 rowH = Math.min(28, innerHeight / sorted.length);
5260
+ const axisX = 20;
4728
5261
 
4729
- const xScale = d3Scale
5262
+ const yScale = d3Scale
4730
5263
  .scaleLinear()
4731
5264
  .domain([minDate - datePadding, maxDate + datePadding])
4732
- .range([0, innerWidth]);
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('width', width)
4738
- .attr('height', height)
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
- xScale,
4758
- false,
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
- xScale,
4773
- false,
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
- xScale,
4788
- false,
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 at top-left (pill style)
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
- legendY,
5344
+ -55,
4810
5345
  (name) => fadeToGroup(g, name),
4811
5346
  () => fadeReset(g)
4812
5347
  );
4813
5348
  }
4814
5349
 
4815
- sorted.forEach((ev, i) => {
4816
- // Marker labels live in their reserved row above the chart, so the
4817
- // first event sits at the chart top edge.
4818
- const y = i * rowH + rowH / 2;
4819
- const x = xScale(parseTimelineDate(ev.date));
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
- if (timelineScale) {
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
- if (timelineScale) {
4851
- hideEventDatesOnScale(g);
4852
- } else {
4853
- hideTooltip(tooltip);
4854
- }
5380
+ hideTooltip(tooltip);
4855
5381
  })
4856
5382
  .on('mousemove', function (event: MouseEvent) {
4857
- if (!timelineScale) {
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 x2 = xScale(parseTimelineDate(ev.endDate));
4868
- const rectW = Math.max(x2 - x, 4);
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
- // Create gradient for uncertain end - fades last 20%
4877
- const gradientId = `uncertain-ts-${ev.lineNumber}`;
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', '100%')
4887
- .attr('y2', '0%')
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', '100%')
4905
- .attr('y2', '0%')
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', x)
4924
- .attr('y', y - BAR_H / 2)
4925
- .attr('width', rectW)
4926
- .attr('height', BAR_H)
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
- if (labelFitsInside) {
4933
- // Text inside bar - use textColor for readability on muted fill
4934
- evG
4935
- .append('text')
4936
- .attr('x', x + 8)
4937
- .attr('y', y)
4938
- .attr('dy', '0.35em')
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', x)
5462
+ .attr('cx', axisX)
4968
5463
  .attr('cy', y)
4969
- .attr('r', 5)
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', flipLeft ? x - 10 : x + 10)
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', '12px')
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
- // Legend container for data-legend-active attribute
5107
- const legendContainer = mainSvg
5108
- .append('g')
5109
- .attr('class', 'tl-tag-legend-container');
5110
- if (currentActiveGroup) {
5111
- legendContainer.attr(
5112
- 'data-legend-active',
5113
- currentActiveGroup.toLowerCase()
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
- // Render tag groups via centralized legend system
5118
- const iconAddon = viewMode ? 0 : LG_ICON_W;
5119
- const centralGroups = visibleGroups.map((lg) => ({
5120
- name: lg.group.name,
5121
- entries: lg.group.entries.map((e) => ({
5122
- value: e.value,
5123
- color: e.color,
5124
- })),
5125
- }));
5126
-
5127
- // Determine effective active group for centralized renderer
5128
- const centralActive = viewMode ? effectiveColorKey : currentActiveGroup;
5129
-
5130
- const centralConfig: LegendConfig = {
5131
- groups: centralGroups,
5132
- position: { placement: 'top-center', titleRelation: 'below-title' },
5133
- mode: 'fixed',
5134
- capsulePillAddonWidth: iconAddon,
5135
- };
5136
- const centralState: LegendState = { activeGroup: centralActive };
5137
-
5138
- const centralCallbacks: LegendCallbacks = viewMode
5139
- ? {}
5140
- : {
5141
- onGroupToggle: (groupName) => {
5142
- currentActiveGroup =
5143
- currentActiveGroup === groupName.toLowerCase()
5144
- ? null
5145
- : groupName.toLowerCase();
5146
- drawLegend();
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
- // Build a quick lineNumber→event lookup
5227
- const eventByLine = new Map<string, TimelineEvent>();
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
- function recolorEvents() {
5233
- const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;
5234
- mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
5235
- const el = d3Selection.select(this);
5236
- const lineNum = el.attr('data-line-number');
5237
- const ev = lineNum ? eventByLine.get(lineNum) : undefined;
5238
- if (!ev) return;
5239
-
5240
- let color: string;
5241
- if (colorTG) {
5242
- const tagColor = resolveTagColor(
5243
- ev.metadata,
5244
- parsed.timelineTagGroups,
5245
- colorTG
5246
- );
5247
- color =
5248
- tagColor ??
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
- drawLegend();
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
- isDark: boolean,
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(px: number, py: number, ci: number): number {
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;