@diagrammo/dgmo 0.5.0 → 0.5.2

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.2",
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,10 @@ 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,
2821
+ viewMode?: boolean
2785
2822
  ): void {
2786
2823
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
2787
2824
 
@@ -2798,6 +2835,11 @@ export function renderTimeline(
2798
2835
  } = parsed;
2799
2836
  if (timelineEvents.length === 0) return;
2800
2837
 
2838
+ // When sort: tag is set and no explicit swimlane param, use the default
2839
+ if (swimlaneTagGroup == null && timelineSort === 'tag' && parsed.timelineDefaultSwimlaneTG) {
2840
+ swimlaneTagGroup = parsed.timelineDefaultSwimlaneTG;
2841
+ }
2842
+
2801
2843
  const tooltip = createTooltip(container, palette, isDark);
2802
2844
 
2803
2845
  const width = exportDims?.width ?? container.clientWidth;
@@ -2818,10 +2860,61 @@ export function renderTimeline(
2818
2860
  groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]);
2819
2861
  });
2820
2862
 
2863
+ // When tag-based swimlanes are active, compute lanes from tag values
2864
+ // and populate groupColorMap with tag entry colors for lane headers.
2865
+ type Lane = { name: string; events: TimelineEvent[] };
2866
+ let tagLanes: Lane[] | null = null;
2867
+
2868
+ if (swimlaneTagGroup) {
2869
+ const tagKey = swimlaneTagGroup.toLowerCase();
2870
+ const tagGroup = parsed.timelineTagGroups.find(
2871
+ (g) => g.name.toLowerCase() === tagKey
2872
+ );
2873
+ if (tagGroup) {
2874
+ // Collect events per tag value
2875
+ const buckets = new Map<string, TimelineEvent[]>();
2876
+ const otherEvents: TimelineEvent[] = [];
2877
+ for (const ev of timelineEvents) {
2878
+ const val = ev.metadata[tagKey];
2879
+ if (val) {
2880
+ const list = buckets.get(val) ?? [];
2881
+ list.push(ev);
2882
+ buckets.set(val, list);
2883
+ } else {
2884
+ otherEvents.push(ev);
2885
+ }
2886
+ }
2887
+
2888
+ // Order lanes by earliest event date
2889
+ const laneEntries = [...buckets.entries()].sort((a, b) => {
2890
+ const aMin = Math.min(
2891
+ ...a[1].map((e) => parseTimelineDate(e.date))
2892
+ );
2893
+ const bMin = Math.min(
2894
+ ...b[1].map((e) => parseTimelineDate(e.date))
2895
+ );
2896
+ return aMin - bMin;
2897
+ });
2898
+
2899
+ tagLanes = laneEntries.map(([name, events]) => ({ name, events }));
2900
+ if (otherEvents.length > 0) {
2901
+ tagLanes.push({ name: '(Other)', events: otherEvents });
2902
+ }
2903
+
2904
+ // Populate groupColorMap from tag entry colors
2905
+ for (const entry of tagGroup.entries) {
2906
+ groupColorMap.set(entry.value, entry.color);
2907
+ }
2908
+ }
2909
+ }
2910
+
2911
+ // Determine effective color source: explicit colorTG > swimlaneTG > group
2912
+ const effectiveColorTG = activeTagGroup ?? swimlaneTagGroup ?? null;
2913
+
2821
2914
  function eventColor(ev: TimelineEvent): string {
2822
2915
  // Tag color takes priority when a tag group is active
2823
- if (activeTagGroup) {
2824
- const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups, activeTagGroup);
2916
+ if (effectiveColorTG) {
2917
+ const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups, effectiveColorTG);
2825
2918
  if (tagColor) return tagColor;
2826
2919
  }
2827
2920
  if (ev.group && groupColorMap.has(ev.group)) {
@@ -2962,14 +3055,34 @@ export function renderTimeline(
2962
3055
  // VERTICAL orientation (time flows top→bottom)
2963
3056
  // ================================================================
2964
3057
  if (isVertical) {
2965
- if (timelineSort === 'group' && timelineGroups.length > 0) {
3058
+ const useGroupedVertical = tagLanes != null ||
3059
+ (timelineSort === 'group' && timelineGroups.length > 0);
3060
+ if (useGroupedVertical) {
2966
3061
  // === 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;
3062
+ let laneNames: string[];
3063
+ let laneEventsByName: Map<string, TimelineEvent[]>;
3064
+
3065
+ if (tagLanes) {
3066
+ laneNames = tagLanes.map((l) => l.name);
3067
+ laneEventsByName = new Map(tagLanes.map((l) => [l.name, l.events]));
3068
+ } else {
3069
+ const groupNames = timelineGroups.map((gr) => gr.name);
3070
+ const ungroupedEvents = timelineEvents.filter(
3071
+ (ev) => ev.group === null || !groupNames.includes(ev.group)
3072
+ );
3073
+ laneNames =
3074
+ ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
3075
+ laneEventsByName = new Map(
3076
+ laneNames.map((name) => [
3077
+ name,
3078
+ timelineEvents.filter((ev) =>
3079
+ name === '(Other)'
3080
+ ? ev.group === null || !groupNames.includes(ev.group)
3081
+ : ev.group === name
3082
+ ),
3083
+ ])
3084
+ );
3085
+ }
2973
3086
 
2974
3087
  const laneCount = laneNames.length;
2975
3088
  const scaleMargin = timelineScale ? 40 : 0;
@@ -3043,6 +3156,23 @@ export function renderTimeline(
3043
3156
  );
3044
3157
  }
3045
3158
 
3159
+ // Render swimlane backgrounds for vertical lanes
3160
+ if (timelineSwimlanes || tagLanes) {
3161
+ laneNames.forEach((laneName, laneIdx) => {
3162
+ const laneX = laneIdx * laneWidth;
3163
+ const fillColor = laneIdx % 2 === 0 ? textColor : 'transparent';
3164
+ g.append('rect')
3165
+ .attr('class', 'tl-swimlane')
3166
+ .attr('data-group', laneName)
3167
+ .attr('x', laneX)
3168
+ .attr('y', 0)
3169
+ .attr('width', laneWidth)
3170
+ .attr('height', innerHeight)
3171
+ .attr('fill', fillColor)
3172
+ .attr('opacity', 0.06);
3173
+ });
3174
+ }
3175
+
3046
3176
  laneNames.forEach((laneName, laneIdx) => {
3047
3177
  const laneX = laneIdx * laneWidth;
3048
3178
  const laneColor = groupColorMap.get(laneName) ?? textColor;
@@ -3075,11 +3205,7 @@ export function renderTimeline(
3075
3205
  .attr('stroke-width', 1)
3076
3206
  .attr('stroke-dasharray', '4,4');
3077
3207
 
3078
- const laneEvents = timelineEvents.filter((ev) =>
3079
- laneName === '(Other)'
3080
- ? ev.group === null || !groupNames.includes(ev.group)
3081
- : ev.group === laneName
3082
- );
3208
+ const laneEvents = laneEventsByName.get(laneName) ?? [];
3083
3209
 
3084
3210
  for (const ev of laneEvents) {
3085
3211
  const y = yScale(parseTimelineDate(ev.date));
@@ -3110,11 +3236,13 @@ export function renderTimeline(
3110
3236
  });
3111
3237
  setTagAttrs(evG, ev);
3112
3238
 
3239
+ const evColor = eventColor(ev);
3240
+
3113
3241
  if (ev.endDate) {
3114
3242
  const y2 = yScale(parseTimelineDate(ev.endDate));
3115
3243
  const rectH = Math.max(y2 - y, 4);
3116
3244
 
3117
- let fill: string = laneColor;
3245
+ let fill: string = evColor;
3118
3246
  if (ev.uncertain) {
3119
3247
  const gradientId = `uncertain-vg-${ev.lineNumber}`;
3120
3248
  const defs =
@@ -3163,7 +3291,7 @@ export function renderTimeline(
3163
3291
  .attr('cx', laneCenter)
3164
3292
  .attr('cy', y)
3165
3293
  .attr('r', 4)
3166
- .attr('fill', laneColor)
3294
+ .attr('fill', evColor)
3167
3295
  .attr('stroke', bgColor)
3168
3296
  .attr('stroke-width', 1.5);
3169
3297
  evG
@@ -3429,24 +3557,30 @@ export function renderTimeline(
3429
3557
  const BAR_H = 22; // range bar thickness (tall enough for text inside)
3430
3558
  const GROUP_GAP = 12; // vertical gap between group swim-lanes
3431
3559
 
3432
- if (timelineSort === 'group' && timelineGroups.length > 0) {
3560
+ const useGroupedHorizontal = tagLanes != null ||
3561
+ (timelineSort === 'group' && timelineGroups.length > 0);
3562
+ if (useGroupedHorizontal) {
3433
3563
  // === 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
- }));
3564
+ let lanes: Lane[];
3565
+
3566
+ if (tagLanes) {
3567
+ lanes = tagLanes;
3568
+ } else {
3569
+ const groupNames = timelineGroups.map((gr) => gr.name);
3570
+ const ungroupedEvents = timelineEvents.filter(
3571
+ (ev) => ev.group === null || !groupNames.includes(ev.group)
3572
+ );
3573
+ const laneNames =
3574
+ ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
3575
+ lanes = laneNames.map((name) => ({
3576
+ name,
3577
+ events: timelineEvents.filter((ev) =>
3578
+ name === '(Other)'
3579
+ ? ev.group === null || !groupNames.includes(ev.group)
3580
+ : ev.group === name
3581
+ ),
3582
+ }));
3583
+ }
3450
3584
 
3451
3585
  const totalEventRows = lanes.reduce((s, l) => s + l.events.length, 0);
3452
3586
  const scaleMargin = timelineScale ? 24 : 0;
@@ -3531,7 +3665,7 @@ export function renderTimeline(
3531
3665
 
3532
3666
  // Render swimlane backgrounds first (so they appear behind events)
3533
3667
  // Extend into left margin to include group names
3534
- if (timelineSwimlanes) {
3668
+ if (timelineSwimlanes || tagLanes) {
3535
3669
  let swimY = markerMargin;
3536
3670
  lanes.forEach((lane, idx) => {
3537
3671
  const laneSpan = lane.events.length * rowH;
@@ -3626,6 +3760,8 @@ export function renderTimeline(
3626
3760
  });
3627
3761
  setTagAttrs(evG, ev);
3628
3762
 
3763
+ const evColor = eventColor(ev);
3764
+
3629
3765
  if (ev.endDate) {
3630
3766
  const x2 = xScale(parseTimelineDate(ev.endDate));
3631
3767
  const rectW = Math.max(x2 - x, 4);
@@ -3633,7 +3769,7 @@ export function renderTimeline(
3633
3769
  const estLabelWidth = ev.label.length * 7 + 16;
3634
3770
  const labelFitsInside = rectW >= estLabelWidth;
3635
3771
 
3636
- let fill: string = laneColor;
3772
+ let fill: string = evColor;
3637
3773
  if (ev.uncertain) {
3638
3774
  // Create gradient for uncertain end - fades last 20%
3639
3775
  const gradientId = `uncertain-${ev.lineNumber}`;
@@ -3655,7 +3791,7 @@ export function renderTimeline(
3655
3791
  .enter()
3656
3792
  .append('stop')
3657
3793
  .attr('offset', (d) => d.offset)
3658
- .attr('stop-color', laneColor)
3794
+ .attr('stop-color', evColor)
3659
3795
  .attr('stop-opacity', (d) => d.opacity);
3660
3796
  fill = `url(#${gradientId})`;
3661
3797
  }
@@ -3708,7 +3844,7 @@ export function renderTimeline(
3708
3844
  .attr('cx', x)
3709
3845
  .attr('cy', y)
3710
3846
  .attr('r', 5)
3711
- .attr('fill', laneColor)
3847
+ .attr('fill', evColor)
3712
3848
  .attr('stroke', bgColor)
3713
3849
  .attr('stroke-width', 1.5);
3714
3850
  evG
@@ -3997,14 +4133,11 @@ export function renderTimeline(
3997
4133
  const LG_ENTRY_DOT_GAP = 4;
3998
4134
  const LG_ENTRY_TRAIL = 8;
3999
4135
  const LG_GROUP_GAP = 12;
4136
+ const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space)
4000
4137
 
4001
4138
  const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
4002
4139
  const mainG = mainSvg.select<SVGGElement>('g');
4003
4140
  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
4141
  const legendY = title ? 50 : 10;
4009
4142
 
4010
4143
  const groupBg = isDark
@@ -4019,7 +4152,9 @@ export function renderTimeline(
4019
4152
  };
4020
4153
  const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
4021
4154
  const pillW = g.name.length * LG_PILL_FONT_W + LG_PILL_PAD;
4022
- let entryX = LG_CAPSULE_PAD + pillW + 4;
4155
+ // Expanded: pill + icon (unless viewMode) + entries
4156
+ const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
4157
+ let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
4023
4158
  for (const entry of g.entries) {
4024
4159
  const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
4025
4160
  entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
@@ -4031,25 +4166,80 @@ export function renderTimeline(
4031
4166
  };
4032
4167
  });
4033
4168
 
4034
- // Current active state (for standalone interactivity)
4169
+ // Two independent state axes: swimlane source + color source
4035
4170
  let currentActiveGroup: string | null = activeTagGroup ?? null;
4171
+ let currentSwimlaneGroup: string | null = swimlaneTagGroup ?? null;
4172
+
4173
+ /** Render the swimlane icon (3 horizontal bars of varying width) */
4174
+ function drawSwimlaneIcon(
4175
+ parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
4176
+ x: number,
4177
+ y: number,
4178
+ isSwimActive: boolean
4179
+ ) {
4180
+ const iconG = parent.append('g')
4181
+ .attr('class', 'tl-swimlane-icon')
4182
+ .attr('transform', `translate(${x}, ${y})`)
4183
+ .style('cursor', 'pointer');
4184
+
4185
+ const barColor = isSwimActive ? palette.primary : palette.textMuted;
4186
+ const barOpacity = isSwimActive ? 1 : 0.35;
4187
+ const bars = [
4188
+ { y: 0, w: 8 },
4189
+ { y: 4, w: 12 },
4190
+ { y: 8, w: 6 },
4191
+ ];
4192
+ for (const bar of bars) {
4193
+ iconG.append('rect')
4194
+ .attr('x', 0)
4195
+ .attr('y', bar.y)
4196
+ .attr('width', bar.w)
4197
+ .attr('height', 2)
4198
+ .attr('rx', 1)
4199
+ .attr('fill', barColor)
4200
+ .attr('opacity', barOpacity);
4201
+ }
4202
+ return iconG;
4203
+ }
4204
+
4205
+ /** Full re-render with updated swimlane state */
4206
+ function relayout() {
4207
+ renderTimeline(
4208
+ container, parsed, palette, isDark, onClickItem, exportDims,
4209
+ currentActiveGroup, currentSwimlaneGroup, onTagStateChange, viewMode
4210
+ );
4211
+ }
4036
4212
 
4037
4213
  function drawLegend() {
4038
4214
  // Remove previous legend
4039
4215
  mainSvg.selectAll('.tl-tag-legend-group').remove();
4040
4216
 
4217
+ // In view mode, only show the active color tag group (expanded, non-interactive)
4218
+ const visibleGroups = viewMode
4219
+ ? legendGroups.filter(
4220
+ (lg) =>
4221
+ currentActiveGroup != null &&
4222
+ lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase()
4223
+ )
4224
+ : legendGroups;
4225
+
4226
+ if (visibleGroups.length === 0) return;
4227
+
4041
4228
  // Compute total width and center horizontally in SVG
4042
- const totalW = legendGroups.reduce((s, lg) => {
4229
+ const totalW = visibleGroups.reduce((s, lg) => {
4043
4230
  const isActive = currentActiveGroup != null &&
4044
4231
  lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
4045
4232
  return s + (isActive ? lg.expandedWidth : lg.minifiedWidth);
4046
- }, 0) + (legendGroups.length - 1) * LG_GROUP_GAP;
4233
+ }, 0) + (visibleGroups.length - 1) * LG_GROUP_GAP;
4047
4234
 
4048
4235
  let cx = (width - totalW) / 2;
4049
4236
 
4050
- for (const lg of legendGroups) {
4237
+ for (const lg of visibleGroups) {
4238
+ const groupKey = lg.group.name.toLowerCase();
4051
4239
  const isActive = currentActiveGroup != null &&
4052
- lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
4240
+ currentActiveGroup.toLowerCase() === groupKey;
4241
+ const isSwimActive = currentSwimlaneGroup != null &&
4242
+ currentSwimlaneGroup.toLowerCase() === groupKey;
4053
4243
 
4054
4244
  const pillLabel = lg.group.name;
4055
4245
  const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
@@ -4058,16 +4248,20 @@ export function renderTimeline(
4058
4248
  .append('g')
4059
4249
  .attr('transform', `translate(${cx}, ${legendY})`)
4060
4250
  .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())
4063
- .attr('data-legend-entry', '__group__')
4064
- .style('cursor', 'pointer')
4065
- .on('click', () => {
4066
- const groupKey = lg.group.name.toLowerCase();
4067
- currentActiveGroup = currentActiveGroup === groupKey ? null : groupKey;
4068
- drawLegend();
4069
- recolorEvents();
4070
- });
4251
+ .attr('data-legend-group', groupKey)
4252
+ .attr('data-tag-group', groupKey)
4253
+ .attr('data-legend-entry', '__group__');
4254
+
4255
+ if (!viewMode) {
4256
+ gEl
4257
+ .style('cursor', 'pointer')
4258
+ .on('click', () => {
4259
+ currentActiveGroup = currentActiveGroup === groupKey ? null : groupKey;
4260
+ drawLegend();
4261
+ recolorEvents();
4262
+ onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
4263
+ });
4264
+ }
4071
4265
 
4072
4266
  // Outer capsule background (active only)
4073
4267
  if (isActive) {
@@ -4115,9 +4309,27 @@ export function renderTimeline(
4115
4309
  .attr('text-anchor', 'middle')
4116
4310
  .text(pillLabel);
4117
4311
 
4118
- // Entries inside capsule (active only)
4312
+ // Entries + swimlane icon inside capsule (active only)
4119
4313
  if (isActive) {
4120
- let entryX = pillXOff + pillWidth + 4;
4314
+ // Swimlane icon (skip in view mode — non-interactive)
4315
+ let entryX: number;
4316
+ if (!viewMode) {
4317
+ const iconX = pillXOff + pillWidth + 5;
4318
+ const iconY = (LG_HEIGHT - 10) / 2; // vertically centered
4319
+ const iconEl = drawSwimlaneIcon(gEl, iconX, iconY, isSwimActive);
4320
+ iconEl
4321
+ .attr('data-swimlane-toggle', groupKey)
4322
+ .on('click', (event: MouseEvent) => {
4323
+ event.stopPropagation();
4324
+ currentSwimlaneGroup = currentSwimlaneGroup === groupKey ? null : groupKey;
4325
+ onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
4326
+ relayout();
4327
+ });
4328
+ entryX = pillXOff + pillWidth + LG_ICON_W + 4;
4329
+ } else {
4330
+ entryX = pillXOff + pillWidth + 8;
4331
+ }
4332
+
4121
4333
  for (const entry of lg.group.entries) {
4122
4334
  const tagKey = lg.group.name.toLowerCase();
4123
4335
  const tagVal = entry.value.toLowerCase();
@@ -4125,29 +4337,32 @@ export function renderTimeline(
4125
4337
  const entryG = gEl.append('g')
4126
4338
  .attr('class', 'tl-tag-legend-entry')
4127
4339
  .attr('data-tag-group', tagKey)
4128
- .attr('data-legend-entry', tagVal)
4129
- .style('cursor', 'pointer')
4130
- .on('mouseenter', (event: MouseEvent) => {
4131
- event.stopPropagation();
4132
- fadeToTagValue(mainG, tagKey, tagVal);
4133
- // Also fade legend entries on the SVG level
4134
- mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
4135
- const el = d3Selection.select(this);
4136
- const ev = el.attr('data-legend-entry');
4137
- if (ev === '__group__') return;
4138
- const eg = el.attr('data-tag-group');
4139
- el.attr('opacity', eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY);
4340
+ .attr('data-legend-entry', tagVal);
4341
+
4342
+ if (!viewMode) {
4343
+ entryG
4344
+ .style('cursor', 'pointer')
4345
+ .on('mouseenter', (event: MouseEvent) => {
4346
+ event.stopPropagation();
4347
+ fadeToTagValue(mainG, tagKey, tagVal);
4348
+ mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
4349
+ const el = d3Selection.select(this);
4350
+ const ev = el.attr('data-legend-entry');
4351
+ if (ev === '__group__') return;
4352
+ const eg = el.attr('data-tag-group');
4353
+ el.attr('opacity', eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY);
4354
+ });
4355
+ })
4356
+ .on('mouseleave', (event: MouseEvent) => {
4357
+ event.stopPropagation();
4358
+ fadeReset(mainG);
4359
+ mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry')
4360
+ .attr('opacity', 1);
4361
+ })
4362
+ .on('click', (event: MouseEvent) => {
4363
+ event.stopPropagation();
4140
4364
  });
4141
- })
4142
- .on('mouseleave', (event: MouseEvent) => {
4143
- event.stopPropagation();
4144
- fadeReset(mainG);
4145
- mainSvg.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry')
4146
- .attr('opacity', 1);
4147
- })
4148
- .on('click', (event: MouseEvent) => {
4149
- event.stopPropagation(); // don't toggle group
4150
- });
4365
+ }
4151
4366
 
4152
4367
  entryG.append('circle')
4153
4368
  .attr('cx', entryX + LG_DOT_R)
@@ -4179,6 +4394,7 @@ export function renderTimeline(
4179
4394
  }
4180
4395
 
4181
4396
  function recolorEvents() {
4397
+ const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;
4182
4398
  mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
4183
4399
  const el = d3Selection.select(this);
4184
4400
  const lineNum = el.attr('data-line-number');
@@ -4186,9 +4402,9 @@ export function renderTimeline(
4186
4402
  if (!ev) return;
4187
4403
 
4188
4404
  let color: string;
4189
- if (currentActiveGroup) {
4405
+ if (colorTG) {
4190
4406
  const tagColor = resolveTagColor(
4191
- ev.metadata, parsed.timelineTagGroups, currentActiveGroup
4407
+ ev.metadata, parsed.timelineTagGroups, colorTG
4192
4408
  );
4193
4409
  color = tagColor ?? (ev.group && groupColorMap.has(ev.group)
4194
4410
  ? groupColorMap.get(ev.group)! : textColor);
@@ -5525,6 +5741,7 @@ export async function renderD3ForExport(
5525
5741
  collapsedNodes?: Set<string>;
5526
5742
  activeTagGroup?: string | null;
5527
5743
  hiddenAttributes?: Set<string>;
5744
+ swimlaneTagGroup?: string | null;
5528
5745
  },
5529
5746
  options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string; scenario?: string }
5530
5747
  ): Promise<string> {
@@ -5830,7 +6047,8 @@ export async function renderD3ForExport(
5830
6047
  } else if (parsed.type === 'arc') {
5831
6048
  renderArcDiagram(container, parsed, effectivePalette, isDark, undefined, dims);
5832
6049
  } else if (parsed.type === 'timeline') {
5833
- renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims);
6050
+ renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims,
6051
+ orgExportState?.activeTagGroup, orgExportState?.swimlaneTagGroup);
5834
6052
  } else if (parsed.type === 'venn') {
5835
6053
  renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
5836
6054
  } 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