@diagrammo/dgmo 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/d3.ts CHANGED
@@ -65,7 +65,7 @@ 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;
@@ -156,6 +156,7 @@ export interface ParsedD3 {
156
156
  timelineMarkers: TimelineMarker[];
157
157
  timelineTagGroups: TagGroup[];
158
158
  timelineSort: TimelineSort;
159
+ timelineDefaultSwimlaneTG?: string;
159
160
  timelineScale: boolean;
160
161
  timelineSwimlanes: boolean;
161
162
  vennSets: VennSet[];
@@ -815,10 +816,19 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
815
816
  if (key === 'sort') {
816
817
  const v = line
817
818
  .substring(colonIndex + 1)
818
- .trim()
819
- .toLowerCase();
820
- if (v === 'time' || v === 'group') {
821
- 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
+ }
822
832
  }
823
833
  continue;
824
834
  }
@@ -1026,6 +1036,30 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
1026
1036
  }
1027
1037
  }
1028
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
+
1029
1063
  return result;
1030
1064
  }
1031
1065
 
@@ -2781,7 +2815,9 @@ export function renderTimeline(
2781
2815
  isDark: boolean,
2782
2816
  onClickItem?: (lineNumber: number) => void,
2783
2817
  exportDims?: D3ExportDimensions,
2784
- activeTagGroup?: string | null
2818
+ activeTagGroup?: string | null,
2819
+ swimlaneTagGroup?: string | null,
2820
+ onTagStateChange?: (activeTagGroup: string | null, swimlaneTagGroup: string | null) => void
2785
2821
  ): void {
2786
2822
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
2787
2823
 
@@ -2798,6 +2834,11 @@ export function renderTimeline(
2798
2834
  } = parsed;
2799
2835
  if (timelineEvents.length === 0) return;
2800
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
+
2801
2842
  const tooltip = createTooltip(container, palette, isDark);
2802
2843
 
2803
2844
  const width = exportDims?.width ?? container.clientWidth;
@@ -2818,10 +2859,61 @@ export function renderTimeline(
2818
2859
  groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]);
2819
2860
  });
2820
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
+
2821
2913
  function eventColor(ev: TimelineEvent): string {
2822
2914
  // Tag color takes priority when a tag group is active
2823
- if (activeTagGroup) {
2824
- const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups, activeTagGroup);
2915
+ if (effectiveColorTG) {
2916
+ const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups, effectiveColorTG);
2825
2917
  if (tagColor) return tagColor;
2826
2918
  }
2827
2919
  if (ev.group && groupColorMap.has(ev.group)) {
@@ -2962,14 +3054,34 @@ export function renderTimeline(
2962
3054
  // VERTICAL orientation (time flows top→bottom)
2963
3055
  // ================================================================
2964
3056
  if (isVertical) {
2965
- if (timelineSort === 'group' && timelineGroups.length > 0) {
3057
+ const useGroupedVertical = tagLanes != null ||
3058
+ (timelineSort === 'group' && timelineGroups.length > 0);
3059
+ if (useGroupedVertical) {
2966
3060
  // === GROUPED: one column/lane per group, vertical ===
2967
- const groupNames = timelineGroups.map((gr) => gr.name);
2968
- const ungroupedEvents = timelineEvents.filter(
2969
- (ev) => ev.group === null || !groupNames.includes(ev.group)
2970
- );
2971
- const laneNames =
2972
- 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
+ }
2973
3085
 
2974
3086
  const laneCount = laneNames.length;
2975
3087
  const scaleMargin = timelineScale ? 40 : 0;
@@ -3043,6 +3155,23 @@ export function renderTimeline(
3043
3155
  );
3044
3156
  }
3045
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
+
3046
3175
  laneNames.forEach((laneName, laneIdx) => {
3047
3176
  const laneX = laneIdx * laneWidth;
3048
3177
  const laneColor = groupColorMap.get(laneName) ?? textColor;
@@ -3075,11 +3204,7 @@ export function renderTimeline(
3075
3204
  .attr('stroke-width', 1)
3076
3205
  .attr('stroke-dasharray', '4,4');
3077
3206
 
3078
- const laneEvents = timelineEvents.filter((ev) =>
3079
- laneName === '(Other)'
3080
- ? ev.group === null || !groupNames.includes(ev.group)
3081
- : ev.group === laneName
3082
- );
3207
+ const laneEvents = laneEventsByName.get(laneName) ?? [];
3083
3208
 
3084
3209
  for (const ev of laneEvents) {
3085
3210
  const y = yScale(parseTimelineDate(ev.date));
@@ -3110,11 +3235,13 @@ export function renderTimeline(
3110
3235
  });
3111
3236
  setTagAttrs(evG, ev);
3112
3237
 
3238
+ const evColor = eventColor(ev);
3239
+
3113
3240
  if (ev.endDate) {
3114
3241
  const y2 = yScale(parseTimelineDate(ev.endDate));
3115
3242
  const rectH = Math.max(y2 - y, 4);
3116
3243
 
3117
- let fill: string = laneColor;
3244
+ let fill: string = evColor;
3118
3245
  if (ev.uncertain) {
3119
3246
  const gradientId = `uncertain-vg-${ev.lineNumber}`;
3120
3247
  const defs =
@@ -3163,7 +3290,7 @@ export function renderTimeline(
3163
3290
  .attr('cx', laneCenter)
3164
3291
  .attr('cy', y)
3165
3292
  .attr('r', 4)
3166
- .attr('fill', laneColor)
3293
+ .attr('fill', evColor)
3167
3294
  .attr('stroke', bgColor)
3168
3295
  .attr('stroke-width', 1.5);
3169
3296
  evG
@@ -3429,24 +3556,30 @@ export function renderTimeline(
3429
3556
  const BAR_H = 22; // range bar thickness (tall enough for text inside)
3430
3557
  const GROUP_GAP = 12; // vertical gap between group swim-lanes
3431
3558
 
3432
- if (timelineSort === 'group' && timelineGroups.length > 0) {
3559
+ const useGroupedHorizontal = tagLanes != null ||
3560
+ (timelineSort === 'group' && timelineGroups.length > 0);
3561
+ if (useGroupedHorizontal) {
3433
3562
  // === GROUPED: swim-lanes stacked vertically, events on own rows ===
3434
- const groupNames = timelineGroups.map((gr) => gr.name);
3435
- const ungroupedEvents = timelineEvents.filter(
3436
- (ev) => ev.group === null || !groupNames.includes(ev.group)
3437
- );
3438
- const laneNames =
3439
- ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
3440
-
3441
- // Build lane data
3442
- const lanes = laneNames.map((name) => ({
3443
- name,
3444
- events: timelineEvents.filter((ev) =>
3445
- name === '(Other)'
3446
- ? ev.group === null || !groupNames.includes(ev.group)
3447
- : ev.group === name
3448
- ),
3449
- }));
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
+ }
3450
3583
 
3451
3584
  const totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);
3452
3585
  const scaleMargin = timelineScale ? 24 : 0;
@@ -3531,7 +3664,7 @@ export function renderTimeline(
3531
3664
 
3532
3665
  // Render swimlane backgrounds first (so they appear behind events)
3533
3666
  // Extend into left margin to include group names
3534
- if (timelineSwimlanes) {
3667
+ if (timelineSwimlanes || tagLanes) {
3535
3668
  let swimY = markerMargin;
3536
3669
  lanes.forEach((lane, idx) => {
3537
3670
  const laneSpan = lane.events.length * rowH;
@@ -3626,6 +3759,8 @@ export function renderTimeline(
3626
3759
  });
3627
3760
  setTagAttrs(evG, ev);
3628
3761
 
3762
+ const evColor = eventColor(ev);
3763
+
3629
3764
  if (ev.endDate) {
3630
3765
  const x2 = xScale(parseTimelineDate(ev.endDate));
3631
3766
  const rectW = Math.max(x2 - x, 4);
@@ -3633,7 +3768,7 @@ export function renderTimeline(
3633
3768
  const estLabelWidth = ev.label.length * 7 + 16;
3634
3769
  const labelFitsInside = rectW >= estLabelWidth;
3635
3770
 
3636
- let fill: string = laneColor;
3771
+ let fill: string = evColor;
3637
3772
  if (ev.uncertain) {
3638
3773
  // Create gradient for uncertain end - fades last 20%
3639
3774
  const gradientId = `uncertain-${ev.lineNumber}`;
@@ -3655,7 +3790,7 @@ export function renderTimeline(
3655
3790
  .enter()
3656
3791
  .append('stop')
3657
3792
  .attr('offset', (d) => d.offset)
3658
- .attr('stop-color', laneColor)
3793
+ .attr('stop-color', evColor)
3659
3794
  .attr('stop-opacity', (d) => d.opacity);
3660
3795
  fill = `url(#${gradientId})`;
3661
3796
  }
@@ -3708,7 +3843,7 @@ export function renderTimeline(
3708
3843
  .attr('cx', x)
3709
3844
  .attr('cy', y)
3710
3845
  .attr('r', 5)
3711
- .attr('fill', laneColor)
3846
+ .attr('fill', evColor)
3712
3847
  .attr('stroke', bgColor)
3713
3848
  .attr('stroke-width', 1.5);
3714
3849
  evG
@@ -3997,14 +4132,11 @@ export function renderTimeline(
3997
4132
  const LG_ENTRY_DOT_GAP = 4;
3998
4133
  const LG_ENTRY_TRAIL = 8;
3999
4134
  const LG_GROUP_GAP = 12;
4135
+ const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space)
4000
4136
 
4001
4137
  const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
4002
4138
  const mainG = mainSvg.select<SVGGElement>('g');
4003
4139
  if (!mainSvg.empty() && !mainG.empty()) {
4004
- // Legend goes in the reserved space between title and chart content.
4005
- // Title is at y=30 in SVG coords; we place the legend centered in the
4006
- // tagLegendReserve gap just above where g starts (margin.top).
4007
- // Render legend directly on SVG (not inside g) for clean centering.
4008
4140
  const legendY = title ? 50 : 10;
4009
4141
 
4010
4142
  const groupBg = isDark
@@ -4019,7 +4151,8 @@ export function renderTimeline(
4019
4151
  };
4020
4152
  const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
4021
4153
  const pillW = g.name.length * LG_PILL_FONT_W + LG_PILL_PAD;
4022
- let entryX = LG_CAPSULE_PAD + pillW + 4;
4154
+ // Expanded: pill + icon + entries
4155
+ let entryX = LG_CAPSULE_PAD + pillW + LG_ICON_W + 4;
4023
4156
  for (const entry of g.entries) {
4024
4157
  const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
4025
4158
  entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
@@ -4031,8 +4164,49 @@ export function renderTimeline(
4031
4164
  };
4032
4165
  });
4033
4166
 
4034
- // Current active state (for standalone interactivity)
4167
+ // Two independent state axes: swimlane source + color source
4035
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
+ }
4036
4210
 
4037
4211
  function drawLegend() {
4038
4212
  // Remove previous legend
@@ -4048,8 +4222,11 @@ export function renderTimeline(
4048
4222
  let cx = (width - totalW) / 2;
4049
4223
 
4050
4224
  for (const lg of legendGroups) {
4225
+ const groupKey = lg.group.name.toLowerCase();
4051
4226
  const isActive = currentActiveGroup != null &&
4052
- lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
4227
+ currentActiveGroup.toLowerCase() === groupKey;
4228
+ const isSwimActive = currentSwimlaneGroup != null &&
4229
+ currentSwimlaneGroup.toLowerCase() === groupKey;
4053
4230
 
4054
4231
  const pillLabel = lg.group.name;
4055
4232
  const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
@@ -4058,15 +4235,15 @@ export function renderTimeline(
4058
4235
  .append('g')
4059
4236
  .attr('transform', `translate(${cx}, ${legendY})`)
4060
4237
  .attr('class', 'tl-tag-legend-group tl-tag-legend-entry')
4061
- .attr('data-legend-group', lg.group.name.toLowerCase())
4062
- .attr('data-tag-group', lg.group.name.toLowerCase())
4238
+ .attr('data-legend-group', groupKey)
4239
+ .attr('data-tag-group', groupKey)
4063
4240
  .attr('data-legend-entry', '__group__')
4064
4241
  .style('cursor', 'pointer')
4065
4242
  .on('click', () => {
4066
- const groupKey = lg.group.name.toLowerCase();
4067
4243
  currentActiveGroup = currentActiveGroup === groupKey ? null : groupKey;
4068
4244
  drawLegend();
4069
4245
  recolorEvents();
4246
+ onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
4070
4247
  });
4071
4248
 
4072
4249
  // Outer capsule background (active only)
@@ -4115,9 +4292,22 @@ export function renderTimeline(
4115
4292
  .attr('text-anchor', 'middle')
4116
4293
  .text(pillLabel);
4117
4294
 
4118
- // Entries inside capsule (active only)
4295
+ // Entries + swimlane icon inside capsule (active only)
4119
4296
  if (isActive) {
4120
- let entryX = pillXOff + pillWidth + 4;
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;
4121
4311
  for (const entry of lg.group.entries) {
4122
4312
  const tagKey = lg.group.name.toLowerCase();
4123
4313
  const tagVal = entry.value.toLowerCase();
@@ -4179,6 +4369,7 @@ export function renderTimeline(
4179
4369
  }
4180
4370
 
4181
4371
  function recolorEvents() {
4372
+ const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;
4182
4373
  mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
4183
4374
  const el = d3Selection.select(this);
4184
4375
  const lineNum = el.attr('data-line-number');
@@ -4186,9 +4377,9 @@ export function renderTimeline(
4186
4377
  if (!ev) return;
4187
4378
 
4188
4379
  let color: string;
4189
- if (currentActiveGroup) {
4380
+ if (colorTG) {
4190
4381
  const tagColor = resolveTagColor(
4191
- ev.metadata, parsed.timelineTagGroups, currentActiveGroup
4382
+ ev.metadata, parsed.timelineTagGroups, colorTG
4192
4383
  );
4193
4384
  color = tagColor ?? (ev.group && groupColorMap.has(ev.group)
4194
4385
  ? groupColorMap.get(ev.group)! : textColor);
@@ -5525,6 +5716,7 @@ export async function renderD3ForExport(
5525
5716
  collapsedNodes?: Set<string>;
5526
5717
  activeTagGroup?: string | null;
5527
5718
  hiddenAttributes?: Set<string>;
5719
+ swimlaneTagGroup?: string | null;
5528
5720
  },
5529
5721
  options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string; scenario?: string }
5530
5722
  ): Promise<string> {
@@ -5830,7 +6022,8 @@ export async function renderD3ForExport(
5830
6022
  } else if (parsed.type === 'arc') {
5831
6023
  renderArcDiagram(container, parsed, effectivePalette, isDark, undefined, dims);
5832
6024
  } else if (parsed.type === 'timeline') {
5833
- renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims);
6025
+ renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims,
6026
+ orgExportState?.activeTagGroup, orgExportState?.swimlaneTagGroup);
5834
6027
  } else if (parsed.type === 'venn') {
5835
6028
  renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
5836
6029
  } else if (parsed.type === 'quadrant') {
package/src/sharing.ts CHANGED
@@ -9,6 +9,7 @@ const COMPRESSED_SIZE_LIMIT = 8192; // 8 KB
9
9
  export interface DiagramViewState {
10
10
  activeTagGroup?: string;
11
11
  collapsedGroups?: string[];
12
+ swimlaneTagGroup?: string;
12
13
  }
13
14
 
14
15
  export interface DecodedDiagramUrl {
@@ -52,6 +53,10 @@ export function encodeDiagramUrl(
52
53
  hash += `&cg=${encodeURIComponent(options.viewState.collapsedGroups.join(','))}`;
53
54
  }
54
55
 
56
+ if (options?.viewState?.swimlaneTagGroup) {
57
+ hash += `&swim=${encodeURIComponent(options.viewState.swimlaneTagGroup)}`;
58
+ }
59
+
55
60
  // Encode in both query param AND hash fragment — some share mechanisms
56
61
  // strip one or the other (iOS share sheet strips #, AirDrop strips ?)
57
62
  return { url: `${baseUrl}?${hash}#${hash}` };
@@ -97,6 +102,9 @@ export function decodeDiagramUrl(hash: string): DecodedDiagramUrl {
97
102
  if (key === 'cg' && val) {
98
103
  viewState.collapsedGroups = val.split(',').filter(Boolean);
99
104
  }
105
+ if (key === 'swim' && val) {
106
+ viewState.swimlaneTagGroup = val;
107
+ }
100
108
  }
101
109
 
102
110
  // Strip 'dgmo=' prefix