@diagrammo/dgmo 0.15.0 → 0.16.0

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