@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.
@@ -14,6 +14,8 @@ import {
14
14
  LEGEND_ENTRY_TRAIL,
15
15
  LEGEND_GROUP_GAP,
16
16
  LEGEND_MAX_ENTRY_ROWS,
17
+ LEGEND_GEAR_PILL_W,
18
+ LEGEND_TOGGLE_DOT_R,
17
19
  measureLegendText,
18
20
  } from './legend-constants';
19
21
 
@@ -27,6 +29,8 @@ import type {
27
29
  LegendControlLayout,
28
30
  LegendEntryLayout,
29
31
  LegendControl,
32
+ ControlsGroupLayout,
33
+ ControlsGroupToggleLayout,
30
34
  } from './legend-types';
31
35
 
32
36
  // ── Constants ───────────────────────────────────────────────
@@ -107,14 +111,16 @@ function capsuleWidth(
107
111
  }
108
112
 
109
113
  // Multi-row: compute how many entries fit per row
110
- const rowWidth = maxCapsuleW - LEGEND_CAPSULE_PAD * 2;
114
+ // Right boundary leaves one LEGEND_CAPSULE_PAD for right padding;
115
+ // left padding is already baked into the starting rowX.
116
+ const rowWidth = maxCapsuleW - LEGEND_CAPSULE_PAD;
111
117
  let row = 1;
112
- let rowX = pw + 4;
118
+ let rowX = LEGEND_CAPSULE_PAD + pw + 4 + addonWidth;
113
119
  let visible = 0;
114
120
 
115
121
  for (let i = 0; i < entries.length; i++) {
116
122
  const ew2 = entryWidth(entries[i].value);
117
- if (rowX + ew2 > rowWidth && rowX > pw + 4) {
123
+ if (rowX + ew2 > rowWidth && i > 0) {
118
124
  row++;
119
125
  rowX = 0;
120
126
  if (row > LEGEND_MAX_ENTRY_ROWS) {
@@ -138,6 +144,89 @@ function capsuleWidth(
138
144
  };
139
145
  }
140
146
 
147
+ // ── Controls group layout helpers ───────────────────────────
148
+
149
+ export function controlsGroupCapsuleWidth(
150
+ toggles: Array<{ label: string }>
151
+ ): number {
152
+ let w = LEGEND_CAPSULE_PAD * 2 + LEGEND_GEAR_PILL_W + 4;
153
+ for (const t of toggles) {
154
+ w +=
155
+ LEGEND_TOGGLE_DOT_R * 2 +
156
+ LEGEND_ENTRY_DOT_GAP +
157
+ measureLegendText(t.label, LEGEND_ENTRY_FONT_SIZE) +
158
+ LEGEND_ENTRY_TRAIL;
159
+ }
160
+ return w;
161
+ }
162
+
163
+ function buildControlsGroupLayout(
164
+ config: LegendConfig,
165
+ state: LegendState
166
+ ): ControlsGroupLayout | undefined {
167
+ const cg = config.controlsGroup;
168
+ if (!cg || cg.toggles.length === 0) return undefined;
169
+
170
+ const expanded = !!state.controlsExpanded;
171
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
172
+
173
+ if (!expanded) {
174
+ // Collapsed: just a gear pill
175
+ return {
176
+ x: 0,
177
+ y: 0,
178
+ width: LEGEND_GEAR_PILL_W,
179
+ height: LEGEND_HEIGHT,
180
+ expanded: false,
181
+ pill: { x: 0, y: 0, width: LEGEND_GEAR_PILL_W, height: LEGEND_HEIGHT },
182
+ toggles: [],
183
+ };
184
+ }
185
+
186
+ // Expanded capsule
187
+ const capsuleW = controlsGroupCapsuleWidth(cg.toggles);
188
+ const toggleLayouts: ControlsGroupToggleLayout[] = [];
189
+ let tx = LEGEND_CAPSULE_PAD + LEGEND_GEAR_PILL_W + 4;
190
+
191
+ for (const toggle of cg.toggles) {
192
+ const dotCx = tx + LEGEND_TOGGLE_DOT_R;
193
+ const dotCy = LEGEND_HEIGHT / 2;
194
+ const textX = tx + LEGEND_TOGGLE_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
195
+ const textY = LEGEND_HEIGHT / 2;
196
+
197
+ toggleLayouts.push({
198
+ id: toggle.id,
199
+ label: toggle.label,
200
+ active: toggle.active,
201
+ dotCx,
202
+ dotCy,
203
+ textX,
204
+ textY,
205
+ });
206
+
207
+ tx +=
208
+ LEGEND_TOGGLE_DOT_R * 2 +
209
+ LEGEND_ENTRY_DOT_GAP +
210
+ measureLegendText(toggle.label, LEGEND_ENTRY_FONT_SIZE) +
211
+ LEGEND_ENTRY_TRAIL;
212
+ }
213
+
214
+ return {
215
+ x: 0,
216
+ y: 0,
217
+ width: capsuleW,
218
+ height: LEGEND_HEIGHT,
219
+ expanded: true,
220
+ pill: {
221
+ x: LEGEND_CAPSULE_PAD,
222
+ y: LEGEND_CAPSULE_PAD,
223
+ width: LEGEND_GEAR_PILL_W - LEGEND_CAPSULE_PAD * 2,
224
+ height: pillH,
225
+ },
226
+ toggles: toggleLayouts,
227
+ };
228
+ }
229
+
141
230
  // ── Main layout computation ─────────────────────────────────
142
231
 
143
232
  export function computeLegendLayout(
@@ -151,7 +240,7 @@ export function computeLegendLayout(
151
240
  // Filter groups for export: only active group shown
152
241
  const activeGroupName = state.activeGroup?.toLowerCase() ?? null;
153
242
 
154
- // In export mode with no active group, no legend
243
+ // In export mode with no active group and no groups, no legend
155
244
  if (isExport && !activeGroupName) {
156
245
  return {
157
246
  height: 0,
@@ -163,12 +252,18 @@ export function computeLegendLayout(
163
252
  };
164
253
  }
165
254
 
255
+ // Controls group (strip in export mode)
256
+ const controlsGroupLayout = isExport
257
+ ? undefined
258
+ : buildControlsGroupLayout(config, state);
259
+
166
260
  const visibleGroups = config.showEmptyGroups
167
261
  ? groups
168
262
  : groups.filter((g) => g.entries.length > 0);
169
263
  if (
170
264
  visibleGroups.length === 0 &&
171
- (!configControls || configControls.length === 0)
265
+ (!configControls || configControls.length === 0) &&
266
+ !controlsGroupLayout
172
267
  ) {
173
268
  return {
174
269
  height: 0,
@@ -236,10 +331,13 @@ export function computeLegendLayout(
236
331
  if (totalControlsW > 0) totalControlsW -= CONTROL_GAP;
237
332
  }
238
333
 
239
- // Available width for tag groups (controls anchor right)
334
+ // Available width for tag groups (controls anchor right, gear pill at end of pills)
240
335
  const controlsSpace =
241
336
  totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0;
242
- const groupAvailW = containerWidth - controlsSpace;
337
+ const gearSpace = controlsGroupLayout
338
+ ? controlsGroupLayout.width + LEGEND_GROUP_GAP
339
+ : 0;
340
+ const groupAvailW = containerWidth - controlsSpace - gearSpace;
243
341
 
244
342
  // Build pill/capsule layouts
245
343
  const pills: LegendPillLayout[] = [];
@@ -254,7 +352,7 @@ export function computeLegendLayout(
254
352
  if (isActive) {
255
353
  activeCapsule = buildCapsuleLayout(
256
354
  g,
257
- containerWidth,
355
+ groupAvailW,
258
356
  config.capsulePillAddonWidth ?? 0
259
357
  );
260
358
  } else {
@@ -279,7 +377,8 @@ export function computeLegendLayout(
279
377
  groupAvailW,
280
378
  containerWidth,
281
379
  totalControlsW,
282
- alignLeft
380
+ alignLeft,
381
+ controlsGroupLayout
283
382
  );
284
383
 
285
384
  const height = rows.length * LEGEND_HEIGHT;
@@ -292,6 +391,7 @@ export function computeLegendLayout(
292
391
  activeCapsule,
293
392
  controls: controlLayouts,
294
393
  pills,
394
+ controlsGroup: controlsGroupLayout,
295
395
  };
296
396
  }
297
397
 
@@ -323,7 +423,9 @@ function buildCapsuleLayout(
323
423
  let ex = LEGEND_CAPSULE_PAD + pw + 4 + addonWidth;
324
424
  let ey = 0;
325
425
  let rowX = ex;
326
- const maxRowW = containerWidth - LEGEND_CAPSULE_PAD * 2;
426
+ // Right boundary: one LEGEND_CAPSULE_PAD for right padding.
427
+ // Left padding is already in ex/rowX starting position.
428
+ const maxRowW = containerWidth - LEGEND_CAPSULE_PAD;
327
429
  let currentRow = 0;
328
430
 
329
431
  for (let i = 0; i < info.visibleEntries; i++) {
@@ -382,7 +484,8 @@ function layoutRows(
382
484
  groupAvailW: number,
383
485
  containerWidth: number,
384
486
  totalControlsW: number,
385
- alignLeft = false
487
+ alignLeft = false,
488
+ controlsGroup?: ControlsGroupLayout
386
489
  ): Array<{
387
490
  y: number;
388
491
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
@@ -397,6 +500,9 @@ function layoutRows(
397
500
  if (activeCapsule) groupItems.push(activeCapsule);
398
501
  groupItems.push(...pills);
399
502
 
503
+ // Controls group width for centering offset
504
+ const gearW = controlsGroup ? controlsGroup.width + LEGEND_GROUP_GAP : 0;
505
+
400
506
  // Compute total group items width
401
507
  let currentRowItems: Array<
402
508
  LegendPillLayout | LegendCapsuleLayout | LegendControlLayout
@@ -407,9 +513,16 @@ function layoutRows(
407
513
  for (const item of groupItems) {
408
514
  const itemW = item.width + LEGEND_GROUP_GAP;
409
515
  if (currentRowW + item.width > groupAvailW && currentRowItems.length > 0) {
410
- // Commit current row
411
- if (!alignLeft)
412
- centerRowItems(currentRowItems, containerWidth, totalControlsW);
516
+ // Commit current row (row 0 needs gear space deducted for centering)
517
+ if (!alignLeft) {
518
+ const rowGearW = rows.length === 0 ? gearW : 0;
519
+ centerRowItems(
520
+ currentRowItems,
521
+ containerWidth,
522
+ totalControlsW,
523
+ rowGearW
524
+ );
525
+ }
413
526
  rows.push({ y: rowY, items: currentRowItems });
414
527
  rowY += LEGEND_HEIGHT;
415
528
  currentRowItems = [];
@@ -443,10 +556,26 @@ function layoutRows(
443
556
 
444
557
  // Commit last row
445
558
  if (currentRowItems.length > 0) {
446
- centerRowItems(currentRowItems, containerWidth, totalControlsW);
559
+ centerRowItems(currentRowItems, containerWidth, totalControlsW, gearW);
447
560
  rows.push({ y: rowY, items: currentRowItems });
448
561
  }
449
562
 
563
+ // Position controls group AFTER centering so it follows the shifted items
564
+ if (controlsGroup) {
565
+ const row0Items = rows[0]?.items ?? [];
566
+ const groupItemsInRow0 = row0Items.filter(
567
+ (it) => 'groupName' in it
568
+ ) as Array<LegendPillLayout | LegendCapsuleLayout>;
569
+ if (groupItemsInRow0.length > 0) {
570
+ const last = groupItemsInRow0[groupItemsInRow0.length - 1];
571
+ controlsGroup.x = last.x + last.width + LEGEND_GROUP_GAP;
572
+ } else {
573
+ // No group items — controls group at start
574
+ controlsGroup.x = 0;
575
+ }
576
+ controlsGroup.y = 0;
577
+ }
578
+
450
579
  // Ensure at least one row height
451
580
  if (rows.length === 0) {
452
581
  rows.push({ y: 0, items: [] });
@@ -458,7 +587,8 @@ function layoutRows(
458
587
  function centerRowItems(
459
588
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>,
460
589
  containerWidth: number,
461
- totalControlsW: number
590
+ totalControlsW: number,
591
+ controlsGroupW = 0
462
592
  ): void {
463
593
  // Only center group items (pills and capsules), not controls
464
594
  const groupItems = items.filter((it) => 'groupName' in it) as Array<
@@ -473,7 +603,8 @@ function centerRowItems(
473
603
 
474
604
  const availW =
475
605
  containerWidth -
476
- (totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0);
606
+ (totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0) -
607
+ controlsGroupW;
477
608
  const offset = Math.max(0, (availW - totalGroupW) / 2);
478
609
 
479
610
  let x = offset;
@@ -9,6 +9,7 @@ import type { Selection } from 'd3-selection';
9
9
  export interface LegendState {
10
10
  activeGroup: string | null;
11
11
  hiddenAttributes?: Set<string>;
12
+ controlsExpanded?: boolean;
12
13
  }
13
14
 
14
15
  export interface LegendCallbacks {
@@ -23,6 +24,10 @@ export interface LegendCallbacks {
23
24
  groupEl: D3Sel,
24
25
  isActive: boolean
25
26
  ) => void;
27
+ /** Called when the controls group gear pill is clicked (expand/collapse) */
28
+ onControlsExpand?: () => void;
29
+ /** Called when a controls group toggle entry is clicked */
30
+ onControlsToggle?: (toggleId: string, active: boolean) => void;
26
31
  }
27
32
 
28
33
  // ── Position & Layout ───────────────────────────────────────
@@ -53,12 +58,28 @@ export interface LegendControlEntry {
53
58
  onClick?: () => void;
54
59
  }
55
60
 
61
+ // ── Controls Group ─────────────────────────────────────────
62
+
63
+ export interface ControlsGroupToggle {
64
+ id: string;
65
+ /** Only 'toggle' is implemented in v1. 'select' and 'action' future-proof for Infra playback etc. */
66
+ type: 'toggle' | 'select' | 'action';
67
+ label: string;
68
+ active: boolean;
69
+ onToggle: (active: boolean) => void;
70
+ }
71
+
72
+ export interface ControlsGroupConfig {
73
+ toggles: ControlsGroupToggle[];
74
+ }
75
+
56
76
  // ── Config ──────────────────────────────────────────────────
57
77
 
58
78
  export interface LegendConfig {
59
79
  groups: import('./legend-svg').LegendGroupData[];
60
80
  position: LegendPosition;
61
81
  controls?: LegendControl[];
82
+ controlsGroup?: ControlsGroupConfig;
62
83
  mode: LegendMode;
63
84
  /** Title width in pixels — used for inline-with-title computation */
64
85
  titleWidth?: number;
@@ -131,6 +152,28 @@ export interface LegendControlLayout {
131
152
  }>;
132
153
  }
133
154
 
155
+ export interface ControlsGroupToggleLayout {
156
+ id: string;
157
+ label: string;
158
+ active: boolean;
159
+ dotCx: number;
160
+ dotCy: number;
161
+ textX: number;
162
+ textY: number;
163
+ }
164
+
165
+ export interface ControlsGroupLayout {
166
+ x: number;
167
+ y: number;
168
+ width: number;
169
+ height: number;
170
+ expanded: boolean;
171
+ /** The gear pill layout (collapsed or inside capsule) */
172
+ pill: { x: number; y: number; width: number; height: number };
173
+ /** Toggle entries (only present when expanded) */
174
+ toggles: ControlsGroupToggleLayout[];
175
+ }
176
+
134
177
  export interface LegendRowLayout {
135
178
  y: number;
136
179
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
@@ -149,6 +192,8 @@ export interface LegendLayout {
149
192
  controls: LegendControlLayout[];
150
193
  /** All pill layouts (collapsed groups) */
151
194
  pills: LegendPillLayout[];
195
+ /** Controls group layout (gear pill / capsule) */
196
+ controlsGroup?: ControlsGroupLayout;
152
197
  }
153
198
 
154
199
  // ── Handle ──────────────────────────────────────────────────