@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/dist/cli.cjs +146 -146
- package/dist/index.cjs +235 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +235 -66
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/d3.ts +308 -90
- package/src/sharing.ts +8 -0
package/package.json
CHANGED
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
|
-
|
|
820
|
-
if (
|
|
821
|
-
result.timelineSort =
|
|
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 (
|
|
2824
|
-
const tagColor = resolveTagColor(ev.metadata, parsed.timelineTagGroups,
|
|
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
|
-
|
|
3058
|
+
const useGroupedVertical = tagLanes != null ||
|
|
3059
|
+
(timelineSort === 'group' && timelineGroups.length > 0);
|
|
3060
|
+
if (useGroupedVertical) {
|
|
2966
3061
|
// === GROUPED: one column/lane per group, vertical ===
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
)
|
|
2971
|
-
|
|
2972
|
-
|
|
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 =
|
|
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 =
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
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 =
|
|
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',
|
|
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',
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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) + (
|
|
4233
|
+
}, 0) + (visibleGroups.length - 1) * LG_GROUP_GAP;
|
|
4047
4234
|
|
|
4048
4235
|
let cx = (width - totalW) / 2;
|
|
4049
4236
|
|
|
4050
|
-
for (const lg of
|
|
4237
|
+
for (const lg of visibleGroups) {
|
|
4238
|
+
const groupKey = lg.group.name.toLowerCase();
|
|
4051
4239
|
const isActive = currentActiveGroup != null &&
|
|
4052
|
-
|
|
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',
|
|
4062
|
-
.attr('data-tag-group',
|
|
4063
|
-
.attr('data-legend-entry', '__group__')
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
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 (
|
|
4405
|
+
if (colorTG) {
|
|
4190
4406
|
const tagColor = resolveTagColor(
|
|
4191
|
-
ev.metadata, parsed.timelineTagGroups,
|
|
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
|