@diagrammo/dgmo 0.4.4 → 0.5.1

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