@diagrammo/dgmo 0.8.17 → 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.
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
  }
@@ -120,7 +120,8 @@ export function addGanttDuration(
120
120
  duration: Duration,
121
121
  holidays: GanttHolidays,
122
122
  holidaySet: Set<string>,
123
- direction: 1 | -1 = 1
123
+ direction: 1 | -1 = 1,
124
+ opts?: { sprintLength?: Duration }
124
125
  ): Date {
125
126
  const { amount, unit } = duration;
126
127
 
@@ -197,6 +198,19 @@ export function addGanttDuration(
197
198
  result.setTime(result.getTime() + amount * 60000 * direction);
198
199
  return result;
199
200
  }
201
+
202
+ case 's': {
203
+ if (!opts?.sprintLength) {
204
+ throw new Error(
205
+ 'Sprint duration unit "s" requires sprintLength configuration'
206
+ );
207
+ }
208
+ const sl = opts.sprintLength;
209
+ const totalDays = amount * sl.amount * (sl.unit === 'w' ? 7 : 1);
210
+ const result = new Date(startDate);
211
+ result.setDate(result.getDate() + Math.round(totalDays) * direction);
212
+ return result;
213
+ }
200
214
  }
201
215
  }
202
216
 
@@ -204,7 +218,7 @@ export function addGanttDuration(
204
218
  * Parse a duration string like "3bd" or "5d".
205
219
  */
206
220
  export function parseDuration(s: string): Duration | null {
207
- const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)$/);
221
+ const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h|s)$/);
208
222
  if (!match) return null;
209
223
  return { amount: parseFloat(match[1]), unit: match[2] as DurationUnit };
210
224
  }
@@ -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
@@ -9,6 +9,9 @@ import {
9
9
  LEGEND_DOT_R,
10
10
  LEGEND_ENTRY_FONT_SIZE,
11
11
  LEGEND_ENTRY_DOT_GAP,
12
+ LEGEND_TOGGLE_DOT_R,
13
+ LEGEND_TOGGLE_OFF_OPACITY,
14
+ CONTROLS_ICON_PATH,
12
15
  measureLegendText,
13
16
  } from './legend-constants';
14
17
  import { computeLegendLayout } from './legend-layout';
@@ -24,6 +27,7 @@ import type {
24
27
  LegendPillLayout,
25
28
  LegendCapsuleLayout,
26
29
  LegendControlLayout,
30
+ ControlsGroupLayout,
27
31
  D3Sel,
28
32
  } from './legend-types';
29
33
 
@@ -83,6 +87,19 @@ export function renderLegendD3(
83
87
  renderPill(legendG, pill, palette, groupBg, callbacks);
84
88
  }
85
89
 
90
+ // Render controls group (gear pill / capsule)
91
+ if (currentLayout.controlsGroup) {
92
+ renderControlsGroup(
93
+ legendG,
94
+ currentLayout.controlsGroup,
95
+ palette,
96
+ groupBg,
97
+ pillBorder,
98
+ callbacks,
99
+ config
100
+ );
101
+ }
102
+
86
103
  // Render controls
87
104
  for (const ctrl of currentLayout.controls) {
88
105
  renderControl(
@@ -398,3 +415,157 @@ function renderControl(
398
415
  g.on('click', () => onClick());
399
416
  }
400
417
  }
418
+
419
+ // ── Controls group (gear pill / capsule) ───────────────────
420
+
421
+ function renderControlsGroup(
422
+ parent: D3Sel,
423
+ layout: ControlsGroupLayout,
424
+ palette: LegendPalette,
425
+ groupBg: string,
426
+ pillBorder: string,
427
+ callbacks?: LegendCallbacks,
428
+ config?: LegendConfig
429
+ ): void {
430
+ const g = parent
431
+ .append('g')
432
+ .attr('transform', `translate(${layout.x},${layout.y})`)
433
+ .attr('data-legend-controls', layout.expanded ? 'expanded' : 'collapsed')
434
+ .attr('data-export-ignore', 'true')
435
+ .style('cursor', 'pointer');
436
+
437
+ if (!layout.expanded) {
438
+ // Collapsed: gear pill
439
+ g.append('rect')
440
+ .attr('width', layout.width)
441
+ .attr('height', layout.height)
442
+ .attr('rx', layout.height / 2)
443
+ .attr('fill', groupBg);
444
+
445
+ // Gear icon centered
446
+ const iconSize = 14;
447
+ const iconX = (layout.width - iconSize) / 2;
448
+ const iconY = (layout.height - iconSize) / 2;
449
+ g.append('path')
450
+ .attr('d', CONTROLS_ICON_PATH)
451
+ .attr('transform', `translate(${iconX},${iconY})`)
452
+ .attr('fill', palette.textMuted)
453
+ .attr('fill-rule', 'evenodd')
454
+ .attr('pointer-events', 'none');
455
+
456
+ if (callbacks?.onControlsExpand) {
457
+ const cb = callbacks.onControlsExpand;
458
+ g.on('click', () => cb());
459
+ }
460
+ } else {
461
+ // Expanded: capsule with gear pill + toggle entries
462
+ const pill = layout.pill;
463
+
464
+ // Outer capsule background
465
+ g.append('rect')
466
+ .attr('width', layout.width)
467
+ .attr('height', layout.height)
468
+ .attr('rx', LEGEND_HEIGHT / 2)
469
+ .attr('fill', groupBg);
470
+
471
+ // Inner gear pill
472
+ const pillG = g
473
+ .append('g')
474
+ .attr('class', 'controls-gear-pill')
475
+ .style('cursor', 'pointer');
476
+
477
+ pillG
478
+ .append('rect')
479
+ .attr('x', pill.x)
480
+ .attr('y', pill.y)
481
+ .attr('width', pill.width)
482
+ .attr('height', pill.height)
483
+ .attr('rx', pill.height / 2)
484
+ .attr('fill', palette.bg);
485
+
486
+ pillG
487
+ .append('rect')
488
+ .attr('x', pill.x)
489
+ .attr('y', pill.y)
490
+ .attr('width', pill.width)
491
+ .attr('height', pill.height)
492
+ .attr('rx', pill.height / 2)
493
+ .attr('fill', 'none')
494
+ .attr('stroke', pillBorder)
495
+ .attr('stroke-width', 0.75);
496
+
497
+ // Gear icon inside pill
498
+ const iconSize = 14;
499
+ const iconX = pill.x + (pill.width - iconSize) / 2;
500
+ const iconY = pill.y + (pill.height - iconSize) / 2;
501
+ pillG
502
+ .append('path')
503
+ .attr('d', CONTROLS_ICON_PATH)
504
+ .attr('transform', `translate(${iconX},${iconY})`)
505
+ .attr('fill', palette.text)
506
+ .attr('fill-rule', 'evenodd')
507
+ .attr('pointer-events', 'none');
508
+
509
+ // Click on gear pill collapses
510
+ if (callbacks?.onControlsExpand) {
511
+ const cb = callbacks.onControlsExpand;
512
+ pillG.on('click', (event: Event) => {
513
+ event.stopPropagation();
514
+ cb();
515
+ });
516
+ }
517
+
518
+ // Toggle entries
519
+ const toggles = config?.controlsGroup?.toggles ?? [];
520
+ for (const tl of layout.toggles) {
521
+ const toggle = toggles.find((t) => t.id === tl.id);
522
+ const entryG = g
523
+ .append('g')
524
+ .attr('data-controls-toggle', tl.id)
525
+ .style('cursor', 'pointer');
526
+
527
+ if (tl.active) {
528
+ // Filled dot
529
+ entryG
530
+ .append('circle')
531
+ .attr('cx', tl.dotCx)
532
+ .attr('cy', tl.dotCy)
533
+ .attr('r', LEGEND_TOGGLE_DOT_R)
534
+ .attr('fill', palette.primary ?? palette.text);
535
+ } else {
536
+ // Hollow dot
537
+ entryG
538
+ .append('circle')
539
+ .attr('cx', tl.dotCx)
540
+ .attr('cy', tl.dotCy)
541
+ .attr('r', LEGEND_TOGGLE_DOT_R)
542
+ .attr('fill', 'none')
543
+ .attr('stroke', palette.textMuted)
544
+ .attr('stroke-width', 1);
545
+ }
546
+
547
+ // Label
548
+ entryG
549
+ .append('text')
550
+ .attr('x', tl.textX)
551
+ .attr('y', tl.textY)
552
+ .attr('dominant-baseline', 'central')
553
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
554
+ .attr('fill', palette.textMuted)
555
+ .attr('opacity', tl.active ? 1 : LEGEND_TOGGLE_OFF_OPACITY)
556
+ .attr('font-family', FONT_FAMILY)
557
+ .text(tl.label);
558
+
559
+ // Click on toggle entry
560
+ if (callbacks?.onControlsToggle && toggle) {
561
+ const cb = callbacks.onControlsToggle;
562
+ const id = tl.id;
563
+ const newActive = !tl.active;
564
+ entryG.on('click', (event: Event) => {
565
+ event.stopPropagation();
566
+ cb(id, newActive);
567
+ });
568
+ }
569
+ }
570
+ }
571
+ }