@diagrammo/dgmo 0.8.18 → 0.8.19

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.
@@ -21,6 +21,8 @@ import type {
21
21
  SequenceParticipant,
22
22
  } from './parser';
23
23
  import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
24
+ import { applyCollapseProjection } from './collapse';
25
+ import type { CollapsedView } from './collapse';
24
26
  import { resolveSequenceTags } from './tag-resolution';
25
27
  import type { ResolvedTagMap } from './tag-resolution';
26
28
  import { resolveActiveTagGroup } from '../utils/tag-groups';
@@ -533,6 +535,7 @@ export interface SectionMessageGroup {
533
535
 
534
536
  export interface SequenceRenderOptions {
535
537
  collapsedSections?: Set<number>; // keyed by section lineNumber
538
+ collapsedGroups?: Set<number>; // keyed by group lineNumber
536
539
  expandedNoteLines?: Set<number>; // keyed by note lineNumber; undefined = all expanded (CLI default)
537
540
  exportWidth?: number; // Explicit width for CLI/export rendering (bypasses getBoundingClientRect)
538
541
  activeTagGroup?: string | null; // Active tag group name for tag-driven recoloring; null = explicitly none
@@ -900,7 +903,37 @@ export function renderSequenceDiagram(
900
903
  // Clear previous content
901
904
  d3Selection.select(container).selectAll('*').remove();
902
905
 
903
- const { title, messages, elements, groups, options: parsedOptions } = parsed;
906
+ const { title, options: parsedOptions } = parsed;
907
+
908
+ // Compute effective collapsed groups: union of syntax-declared and runtime-toggled
909
+ const effectiveCollapsedGroups = new Set<number>();
910
+ for (const group of parsed.groups) {
911
+ if (group.collapsed) effectiveCollapsedGroups.add(group.lineNumber);
912
+ }
913
+ if (options?.collapsedGroups) {
914
+ for (const ln of options.collapsedGroups) {
915
+ // Toggle: if already in the set (from syntax), remove it (user expanded);
916
+ // if not in the set, add it (user collapsed)
917
+ if (effectiveCollapsedGroups.has(ln)) {
918
+ effectiveCollapsedGroups.delete(ln);
919
+ } else {
920
+ effectiveCollapsedGroups.add(ln);
921
+ }
922
+ }
923
+ }
924
+
925
+ // Apply collapse projection before participant ordering
926
+ const collapsed: CollapsedView | null =
927
+ effectiveCollapsedGroups.size > 0
928
+ ? applyCollapseProjection(parsed, effectiveCollapsedGroups)
929
+ : null;
930
+
931
+ const messages = collapsed ? collapsed.messages : parsed.messages;
932
+ const elements = collapsed ? collapsed.elements : parsed.elements;
933
+ const groups = collapsed ? collapsed.groups : parsed.groups;
934
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
935
+ const collapsedGroupIds = collapsed?.collapsedGroupIds ?? new Map();
936
+
904
937
  const collapsedSections = options?.collapsedSections;
905
938
  const expandedNoteLines = options?.expandedNoteLines;
906
939
  const collapseNotesDisabled =
@@ -911,8 +944,12 @@ export function renderSequenceDiagram(
911
944
  expandedNoteLines === undefined ||
912
945
  collapseNotesDisabled ||
913
946
  expandedNoteLines.has(note.lineNumber);
947
+
948
+ const sourceParticipants = collapsed
949
+ ? collapsed.participants
950
+ : parsed.participants;
914
951
  const participants = applyPositionOverrides(
915
- applyGroupOrdering(parsed.participants, groups, messages)
952
+ applyGroupOrdering(sourceParticipants, groups, messages)
916
953
  );
917
954
  if (participants.length === 0) return;
918
955
 
@@ -1158,6 +1195,15 @@ export function renderSequenceDiagram(
1158
1195
  const preSectionMsgIndices: number[] = [];
1159
1196
  const sectionRegions: SectionRegion[] = [];
1160
1197
  {
1198
+ // Build lineNumber → message index lookup. This is used instead of
1199
+ // messages.indexOf() because collapse projection creates spread copies
1200
+ // of messages, breaking reference equality.
1201
+ const msgLineToIndex = new Map<number, number>();
1202
+ messages.forEach((m, i) => msgLineToIndex.set(m.lineNumber, i));
1203
+
1204
+ const findMsgIndex = (child: SequenceElement): number =>
1205
+ msgLineToIndex.get(child.lineNumber) ?? -1;
1206
+
1161
1207
  const collectMsgIndicesFromBlock = (
1162
1208
  block: import('./parser').SequenceBlock
1163
1209
  ): number[] => {
@@ -1166,7 +1212,7 @@ export function renderSequenceDiagram(
1166
1212
  if (isSequenceBlock(child)) {
1167
1213
  indices.push(...collectMsgIndicesFromBlock(child));
1168
1214
  } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1169
- const idx = messages.indexOf(child as SequenceMessage);
1215
+ const idx = findMsgIndex(child);
1170
1216
  if (idx >= 0) indices.push(idx);
1171
1217
  }
1172
1218
  }
@@ -1176,7 +1222,7 @@ export function renderSequenceDiagram(
1176
1222
  if (isSequenceBlock(child)) {
1177
1223
  indices.push(...collectMsgIndicesFromBlock(child));
1178
1224
  } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1179
- const idx = messages.indexOf(child as SequenceMessage);
1225
+ const idx = findMsgIndex(child);
1180
1226
  if (idx >= 0) indices.push(idx);
1181
1227
  }
1182
1228
  }
@@ -1186,7 +1232,7 @@ export function renderSequenceDiagram(
1186
1232
  if (isSequenceBlock(child)) {
1187
1233
  indices.push(...collectMsgIndicesFromBlock(child));
1188
1234
  } else if (!isSequenceSection(child) && !isSequenceNote(child)) {
1189
- const idx = messages.indexOf(child as SequenceMessage);
1235
+ const idx = findMsgIndex(child);
1190
1236
  if (idx >= 0) indices.push(idx);
1191
1237
  }
1192
1238
  }
@@ -1202,7 +1248,7 @@ export function renderSequenceDiagram(
1202
1248
  } else if (isSequenceBlock(el)) {
1203
1249
  currentTarget.push(...collectMsgIndicesFromBlock(el));
1204
1250
  } else {
1205
- const idx = messages.indexOf(el as SequenceMessage);
1251
+ const idx = findMsgIndex(el);
1206
1252
  if (idx >= 0) currentTarget.push(idx);
1207
1253
  }
1208
1254
  }
@@ -1285,8 +1331,10 @@ export function renderSequenceDiagram(
1285
1331
  const LEGEND_FIXED_GAP = 8;
1286
1332
  const legendTopSpace =
1287
1333
  parsed.tagGroups.length > 0 ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
1334
+ // Use parsed.groups (not projected groups) to keep vertical space consistent
1335
+ // even when all groups are collapsed into virtual participants
1288
1336
  const groupOffset =
1289
- groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1337
+ parsed.groups.length > 0 ? GROUP_PADDING_TOP + GROUP_LABEL_SIZE : 0;
1290
1338
  const participantStartY =
1291
1339
  TOP_MARGIN +
1292
1340
  titleOffset +
@@ -1618,7 +1666,23 @@ export function renderSequenceDiagram(
1618
1666
  );
1619
1667
  }
1620
1668
 
1621
- // Render group boxes (behind participant shapes)
1669
+ // Build set of collapsed group names for drill-bar rendering
1670
+ const collapsedGroupNames = new Set<string>();
1671
+ const collapsedGroupMeta = new Map<
1672
+ string,
1673
+ { lineNumber: number; metadata?: Record<string, string> }
1674
+ >();
1675
+ for (const group of parsed.groups) {
1676
+ if (effectiveCollapsedGroups.has(group.lineNumber)) {
1677
+ collapsedGroupNames.add(group.name);
1678
+ collapsedGroupMeta.set(group.name, {
1679
+ lineNumber: group.lineNumber,
1680
+ metadata: group.metadata,
1681
+ });
1682
+ }
1683
+ }
1684
+
1685
+ // Render group boxes (behind participant shapes) — skip collapsed groups
1622
1686
  for (const group of groups) {
1623
1687
  if (group.participantIds.length === 0) continue;
1624
1688
 
@@ -1650,7 +1714,15 @@ export function renderSequenceDiagram(
1650
1714
  : palette.bg;
1651
1715
  const strokeColor = groupTagColor || palette.textMuted;
1652
1716
 
1653
- svg
1717
+ const groupG = svg
1718
+ .append('g')
1719
+ .attr('class', 'group-box-wrapper')
1720
+ .attr('data-group-toggle', '')
1721
+ .attr('data-group-line', String(group.lineNumber))
1722
+ .attr('cursor', 'pointer');
1723
+ groupG.append('title').text('Click to collapse');
1724
+
1725
+ groupG
1654
1726
  .append('rect')
1655
1727
  .attr('x', minX)
1656
1728
  .attr('y', boxY)
@@ -1661,11 +1733,10 @@ export function renderSequenceDiagram(
1661
1733
  .attr('stroke', strokeColor)
1662
1734
  .attr('stroke-width', 1)
1663
1735
  .attr('stroke-opacity', 0.5)
1664
- .attr('class', 'group-box')
1665
- .attr('data-group-line', String(group.lineNumber));
1736
+ .attr('class', 'group-box');
1666
1737
 
1667
1738
  // Group label
1668
- svg
1739
+ groupG
1669
1740
  .append('text')
1670
1741
  .attr('x', minX + 8)
1671
1742
  .attr('y', boxY + GROUP_LABEL_SIZE + 4)
@@ -1674,7 +1745,6 @@ export function renderSequenceDiagram(
1674
1745
  .attr('font-weight', 'bold')
1675
1746
  .attr('opacity', 0.7)
1676
1747
  .attr('class', 'group-label')
1677
- .attr('data-group-line', String(group.lineNumber))
1678
1748
  .text(group.name);
1679
1749
  }
1680
1750
 
@@ -1690,6 +1760,16 @@ export function renderSequenceDiagram(
1690
1760
  tagKey && pTagValue
1691
1761
  ? { key: tagKey, value: pTagValue.toLowerCase() }
1692
1762
  : undefined;
1763
+ // For collapsed group participants, resolve tag color from group metadata
1764
+ const isCollapsedGroup = collapsedGroupNames.has(participant.id);
1765
+ let effectiveTagColor = pTagColor;
1766
+ if (isCollapsedGroup && !effectiveTagColor) {
1767
+ const meta = collapsedGroupMeta.get(participant.id);
1768
+ if (meta?.metadata && tagKey) {
1769
+ effectiveTagColor = getTagColor(meta.metadata[tagKey]);
1770
+ }
1771
+ }
1772
+
1693
1773
  renderParticipant(
1694
1774
  svg,
1695
1775
  participant,
@@ -1697,18 +1777,104 @@ export function renderSequenceDiagram(
1697
1777
  cy,
1698
1778
  palette,
1699
1779
  isDark,
1700
- pTagColor,
1780
+ effectiveTagColor,
1701
1781
  pTagAttr
1702
1782
  );
1703
1783
 
1704
- // Render lifeline
1784
+ // Collapsed group: re-render participant box at full group height + drill-bar
1785
+ if (isCollapsedGroup) {
1786
+ const meta = collapsedGroupMeta.get(participant.id)!;
1787
+ const drillColor = effectiveTagColor || palette.textMuted;
1788
+ const drillBarH = 6;
1789
+ const boxW = PARTICIPANT_BOX_WIDTH;
1790
+ // Match the group box dimensions
1791
+ const fullH =
1792
+ PARTICIPANT_BOX_HEIGHT + GROUP_PADDING_TOP + GROUP_PADDING_BOTTOM;
1793
+ const clipId = `clip-drill-group-${participant.id.replace(/[^a-zA-Z0-9-]/g, '-')}`;
1794
+
1795
+ // Add toggle attributes to the participant <g> so any click on it
1796
+ // (overlay rect, label, drill-bar) walks up and triggers the toggle
1797
+ const participantG = svg.select<SVGGElement>(
1798
+ `.participant[data-participant-id="${participant.id}"]`
1799
+ );
1800
+ participantG
1801
+ .attr('data-group-toggle', '')
1802
+ .attr('data-group-line', String(meta.lineNumber))
1803
+ .attr('cursor', 'pointer');
1804
+ participantG.append('title').text('Click to expand');
1805
+
1806
+ // Overlay a taller rect to replace the standard participant box
1807
+ const pFill = effectiveTagColor
1808
+ ? mix(
1809
+ effectiveTagColor,
1810
+ isDark ? palette.surface : palette.bg,
1811
+ isDark ? 30 : 40
1812
+ )
1813
+ : isDark
1814
+ ? mix(palette.overlay, palette.surface, 50)
1815
+ : mix(palette.bg, palette.surface, 50);
1816
+ const pStroke = effectiveTagColor || palette.border;
1817
+
1818
+ // Taller box inside the participant <g> (local coords, y=0 is participant cy)
1819
+ participantG
1820
+ .append('rect')
1821
+ .attr('x', -boxW / 2)
1822
+ .attr('y', -GROUP_PADDING_TOP)
1823
+ .attr('width', boxW)
1824
+ .attr('height', fullH)
1825
+ .attr('rx', 6)
1826
+ .attr('fill', pFill)
1827
+ .attr('stroke', pStroke)
1828
+ .attr('stroke-width', 1.5);
1829
+
1830
+ // Re-render label centered in the taller box (local coords)
1831
+ participantG
1832
+ .append('text')
1833
+ .attr('x', 0)
1834
+ .attr('y', -GROUP_PADDING_TOP + fullH / 2)
1835
+ .attr('text-anchor', 'middle')
1836
+ .attr('dominant-baseline', 'central')
1837
+ .attr('fill', palette.text)
1838
+ .attr('font-size', 13)
1839
+ .attr('font-weight', 500)
1840
+ .text(participant.label);
1841
+
1842
+ // Drill-bar at bottom (local coords)
1843
+ participantG
1844
+ .append('clipPath')
1845
+ .attr('id', clipId)
1846
+ .append('rect')
1847
+ .attr('x', -boxW / 2)
1848
+ .attr('y', -GROUP_PADDING_TOP)
1849
+ .attr('width', boxW)
1850
+ .attr('height', fullH)
1851
+ .attr('rx', 6);
1852
+
1853
+ participantG
1854
+ .append('rect')
1855
+ .attr('class', 'sequence-drill-bar')
1856
+ .attr('x', -boxW / 2)
1857
+ .attr('y', -GROUP_PADDING_TOP + fullH - drillBarH)
1858
+ .attr('width', boxW)
1859
+ .attr('height', drillBarH)
1860
+ .attr('fill', drillColor)
1861
+ .attr('clip-path', `url(#${clipId})`);
1862
+ }
1863
+
1864
+ // Render lifeline — collapsed groups start below the taller box
1865
+ const llY = isCollapsedGroup
1866
+ ? lifelineStartY + GROUP_PADDING_BOTTOM
1867
+ : lifelineStartY;
1868
+ const llColor = isCollapsedGroup
1869
+ ? effectiveTagColor || palette.textMuted
1870
+ : pTagColor || palette.textMuted;
1705
1871
  const lifelineEl = svg
1706
1872
  .append('line')
1707
1873
  .attr('x1', cx)
1708
- .attr('y1', lifelineStartY)
1874
+ .attr('y1', llY)
1709
1875
  .attr('x2', cx)
1710
1876
  .attr('y2', lifelineStartY + lifelineLength)
1711
- .attr('stroke', pTagColor || palette.textMuted)
1877
+ .attr('stroke', llColor)
1712
1878
  .attr('stroke-width', 1)
1713
1879
  .attr('stroke-dasharray', '6 4')
1714
1880
  .attr('class', 'lifeline')
@@ -2104,43 +2270,14 @@ export function renderSequenceDiagram(
2104
2270
  ? `${sec.label} (${msgCount} ${msgCount === 1 ? 'message' : 'messages'})`
2105
2271
  : sec.label;
2106
2272
 
2107
- // Collapsed sections use white text for contrast against the darker band
2108
- const labelColor = isCollapsed ? '#ffffff' : lineColor;
2109
-
2110
- // Chevron indicator
2111
- const chevronSpace = 14;
2112
- const labelX = (sectionLineX1 + sectionLineX2) / 2;
2113
- const chevronX = labelX - (labelText.length * 3.5 + 8 + chevronSpace / 2);
2114
- const chevronY = secY;
2115
- if (isCollapsed) {
2116
- // Right-pointing triangle ▶
2117
- sectionG
2118
- .append('path')
2119
- .attr(
2120
- 'd',
2121
- `M ${chevronX} ${chevronY - 4} L ${chevronX + 6} ${chevronY} L ${chevronX} ${chevronY + 4} Z`
2122
- )
2123
- .attr('fill', labelColor)
2124
- .attr('class', 'section-chevron');
2125
- } else {
2126
- // Down-pointing triangle ▼
2127
- sectionG
2128
- .append('path')
2129
- .attr(
2130
- 'd',
2131
- `M ${chevronX - 1} ${chevronY - 3} L ${chevronX + 7} ${chevronY - 3} L ${chevronX + 3} ${chevronY + 3} Z`
2132
- )
2133
- .attr('fill', labelColor)
2134
- .attr('class', 'section-chevron');
2135
- }
2136
-
2137
2273
  // Centered label text
2274
+ const labelX = (sectionLineX1 + sectionLineX2) / 2;
2138
2275
  sectionG
2139
2276
  .append('text')
2140
- .attr('x', labelX + chevronSpace / 2)
2277
+ .attr('x', labelX)
2141
2278
  .attr('y', secY + 4)
2142
2279
  .attr('text-anchor', 'middle')
2143
- .attr('fill', labelColor)
2280
+ .attr('fill', lineColor)
2144
2281
  .attr('font-size', 11)
2145
2282
  .attr('font-weight', 'bold')
2146
2283
  .attr('class', 'section-label')
package/src/sharing.ts CHANGED
@@ -6,24 +6,45 @@ import {
6
6
  const DEFAULT_BASE_URL = 'https://online.diagrammo.app';
7
7
  const COMPRESSED_SIZE_LIMIT = 8192; // 8 KB
8
8
 
9
- export interface DiagramViewState {
10
- activeTagGroup?: string;
11
- collapsedGroups?: string[];
12
- swimlaneTagGroup?: string;
13
- collapsedLanes?: string[];
14
- palette?: string;
15
- theme?: 'light' | 'dark';
9
+ /**
10
+ * Compact view state schema (ADR-6).
11
+ * All fields optional. Only non-default values are encoded.
12
+ * `tag: null` means "user chose none"; absent `tag` means "use DSL default" (ADR-5).
13
+ */
14
+ export interface CompactViewState {
15
+ tag?: string | null; // active tag override (null = "none")
16
+ cs?: number[]; // collapsed sections (sequence line numbers)
17
+ cg?: string[]; // collapsed groups/nodes (IDs or names)
18
+ swim?: string | null; // swimlane tag group
19
+ cl?: string[]; // collapsed lanes
20
+ cc?: string[]; // collapsed columns (kanban)
21
+ rm?: string; // render mode override
22
+ htv?: Record<string, string[]>; // hidden tag values
23
+ ha?: string[]; // hidden attributes
24
+ enl?: number[]; // expanded note lines (sequence)
25
+ sem?: boolean; // semantic colors (ER)
26
+ cm?: boolean; // compact meta (kanban)
27
+ c4l?: string; // C4 level
28
+ c4s?: string; // C4 system
29
+ c4c?: string; // C4 container
30
+ rps?: number; // RPS multiplier (infra)
31
+ spd?: number; // playback speed (infra)
32
+ io?: Record<string, number>; // instance overrides (infra)
16
33
  }
17
34
 
18
35
  export interface DecodedDiagramUrl {
19
36
  dsl: string;
20
- viewState: DiagramViewState;
37
+ viewState: CompactViewState;
38
+ palette?: string;
39
+ theme?: 'light' | 'dark';
21
40
  filename?: string;
22
41
  }
23
42
 
24
43
  export interface EncodeDiagramUrlOptions {
25
44
  baseUrl?: string;
26
- viewState?: DiagramViewState;
45
+ viewState?: CompactViewState;
46
+ palette?: string;
47
+ theme?: 'light' | 'dark';
27
48
  filename?: string;
28
49
  }
29
50
 
@@ -36,6 +57,34 @@ export type EncodeDiagramUrlResult =
36
57
  limit: number;
37
58
  };
38
59
 
60
+ /**
61
+ * Encode a CompactViewState to a compressed string for URL embedding.
62
+ * Returns empty string if state has no keys (ADR-4).
63
+ */
64
+ export function encodeViewState(state: CompactViewState): string {
65
+ const keys = Object.keys(state);
66
+ if (keys.length === 0) return '';
67
+ return compressToEncodedURIComponent(JSON.stringify(state));
68
+ }
69
+
70
+ /**
71
+ * Decode a compressed view state string back to CompactViewState.
72
+ * Returns empty object on failure (no crash).
73
+ */
74
+ export function decodeViewState(encoded: string): CompactViewState {
75
+ if (!encoded) return {};
76
+ try {
77
+ const json = decompressFromEncodedURIComponent(encoded);
78
+ if (!json) return {};
79
+ const parsed = JSON.parse(json);
80
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
81
+ return {};
82
+ return parsed as CompactViewState;
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+
39
88
  /**
40
89
  * Compress a DGMO DSL string into a shareable URL.
41
90
  * Returns `{ url }` on success, or `{ error: 'too-large', compressedSize, limit }` if the
@@ -59,28 +108,21 @@ export function encodeDiagramUrl(
59
108
 
60
109
  let hash = `dgmo=${compressed}`;
61
110
 
62
- if (options?.viewState?.activeTagGroup) {
63
- hash += `&tag=${encodeURIComponent(options.viewState.activeTagGroup)}`;
64
- }
65
-
66
- if (options?.viewState?.collapsedGroups?.length) {
67
- hash += `&cg=${encodeURIComponent(options.viewState.collapsedGroups.join(','))}`;
68
- }
69
-
70
- if (options?.viewState?.swimlaneTagGroup) {
71
- hash += `&swim=${encodeURIComponent(options.viewState.swimlaneTagGroup)}`;
72
- }
73
-
74
- if (options?.viewState?.collapsedLanes?.length) {
75
- hash += `&cl=${encodeURIComponent(options.viewState.collapsedLanes.join(','))}`;
111
+ // View state as single compressed blob (ADR-1)
112
+ if (options?.viewState) {
113
+ const vsEncoded = encodeViewState(options.viewState);
114
+ if (vsEncoded) {
115
+ hash += `&vs=${vsEncoded}`;
116
+ }
76
117
  }
77
118
 
78
- if (options?.viewState?.palette && options.viewState.palette !== 'nord') {
79
- hash += `&pal=${encodeURIComponent(options.viewState.palette)}`;
119
+ // Palette and theme are app-level, kept as separate params
120
+ if (options?.palette && options.palette !== 'nord') {
121
+ hash += `&pal=${encodeURIComponent(options.palette)}`;
80
122
  }
81
123
 
82
- if (options?.viewState?.theme && options.viewState.theme !== 'dark') {
83
- hash += `&th=${encodeURIComponent(options.viewState.theme)}`;
124
+ if (options?.theme && options.theme !== 'dark') {
125
+ hash += `&th=${encodeURIComponent(options.theme)}`;
84
126
  }
85
127
 
86
128
  if (options?.filename) {
@@ -95,8 +137,8 @@ export function encodeDiagramUrl(
95
137
  /**
96
138
  * Decode a DGMO DSL string and view state from a URL query string or hash.
97
139
  * Accepts any of:
98
- * - `?dgmo=<payload>&tag=<name>`
99
- * - `#dgmo=<payload>&tag=<name>` (backwards compat)
140
+ * - `?dgmo=<payload>&vs=<state>`
141
+ * - `#dgmo=<payload>&vs=<state>` (backwards compat)
100
142
  * - `dgmo=<payload>`
101
143
  * - `<bare payload>`
102
144
  *
@@ -105,6 +147,8 @@ export function encodeDiagramUrl(
105
147
  export function decodeDiagramUrl(hash: string): DecodedDiagramUrl {
106
148
  const empty: DecodedDiagramUrl = { dsl: '', viewState: {} };
107
149
  let filename: string | undefined;
150
+ let palette: string | undefined;
151
+ let theme: 'light' | 'dark' | undefined;
108
152
  if (!hash) return empty;
109
153
 
110
154
  let raw = hash;
@@ -120,29 +164,22 @@ export function decodeDiagramUrl(hash: string): DecodedDiagramUrl {
120
164
  const parts = raw.split('&');
121
165
  let payload = parts[0];
122
166
 
123
- // Parse extra params (e.g. tag=Location)
124
- const viewState: DiagramViewState = {};
167
+ // Parse extra params
168
+ let viewState: CompactViewState = {};
125
169
  for (let i = 1; i < parts.length; i++) {
126
170
  const eq = parts[i].indexOf('=');
127
171
  if (eq === -1) continue;
128
172
  const key = parts[i].slice(0, eq);
129
- const val = decodeURIComponent(parts[i].slice(eq + 1));
130
- if (key === 'tag' && val) {
131
- viewState.activeTagGroup = val;
132
- }
133
- if (key === 'cg' && val) {
134
- viewState.collapsedGroups = val.split(',').filter(Boolean);
135
- }
136
- if (key === 'swim' && val) {
137
- viewState.swimlaneTagGroup = val;
173
+ const val = parts[i].slice(eq + 1);
174
+ if (key === 'vs' && val) {
175
+ viewState = decodeViewState(val);
138
176
  }
139
- if (key === 'cl' && val) {
140
- viewState.collapsedLanes = val.split(',').filter(Boolean);
177
+ if (key === 'pal' && val) palette = decodeURIComponent(val);
178
+ if (key === 'th') {
179
+ const decoded = decodeURIComponent(val);
180
+ if (decoded === 'light' || decoded === 'dark') theme = decoded;
141
181
  }
142
- if (key === 'pal' && val) viewState.palette = val;
143
- if (key === 'th' && (val === 'light' || val === 'dark'))
144
- viewState.theme = val;
145
- if (key === 'fn' && val) filename = val;
182
+ if (key === 'fn' && val) filename = decodeURIComponent(val);
146
183
  }
147
184
 
148
185
  // Strip 'dgmo=' prefix
@@ -150,12 +187,12 @@ export function decodeDiagramUrl(hash: string): DecodedDiagramUrl {
150
187
  payload = payload.slice(5);
151
188
  }
152
189
 
153
- if (!payload) return { dsl: '', viewState, filename };
190
+ if (!payload) return { dsl: '', viewState, palette, theme, filename };
154
191
 
155
192
  try {
156
193
  const result = decompressFromEncodedURIComponent(payload);
157
- return { dsl: result ?? '', viewState, filename };
194
+ return { dsl: result ?? '', viewState, palette, theme, filename };
158
195
  } catch {
159
- return { dsl: '', viewState, filename };
196
+ return { dsl: '', viewState, palette, theme, filename };
160
197
  }
161
198
  }
@@ -54,3 +54,14 @@ export const EYE_OPEN_PATH =
54
54
  'M1 7s2.5-5 6-5 6 5 6 5-2.5 5-6 5-6-5-6-5z M7 9.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z';
55
55
  export const EYE_CLOSED_PATH =
56
56
  'M2.5 2.5l9 9 M1.5 7s2.2-4 5.5-4c1.2 0 2.2.5 3 1.1 M12.5 7s-2.2 4-5.5 4c-1.2 0-2.2-.5-3-1.1';
57
+
58
+ // ── Controls group constants ────────────────────────────────
59
+ // Gear/cog icon (14×14 viewBox) — 6 flat teeth with center hole
60
+ // Computed from polar coordinates: outerR=5.5, innerR=3.5, holeR=2, center=(7,7)
61
+ // Uses evenodd fill-rule for the center hole
62
+ export const CONTROLS_ICON_PATH =
63
+ 'M5.6 1.7L8.4 1.7L7.9 3.6L9.5 4.5L10.9 3.1L12.3 5.6L10.4 6.1L10.4 7.9L12.3 8.4L10.9 10.9L9.5 9.5L7.9 10.4L8.4 12.3L5.6 12.3L6.1 10.4L4.5 9.5L3.1 10.9L1.7 8.4L3.6 7.9L3.6 6.1L1.7 5.6L3.1 3.1L4.5 4.5L6.1 3.6Z' +
64
+ 'M5 7a2 2 0 1 0 4 0a2 2 0 1 0-4 0Z';
65
+ export const LEGEND_TOGGLE_DOT_R = LEGEND_DOT_R;
66
+ export const LEGEND_TOGGLE_OFF_OPACITY = 0.4;
67
+ export const LEGEND_GEAR_PILL_W = 14 + LEGEND_PILL_PAD; // gear icon (14) + padding