@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.
@@ -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
+ }
@@ -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 ───────────────────────────────────────────────
@@ -140,6 +144,89 @@ function capsuleWidth(
140
144
  };
141
145
  }
142
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
+
143
230
  // ── Main layout computation ─────────────────────────────────
144
231
 
145
232
  export function computeLegendLayout(
@@ -153,7 +240,7 @@ export function computeLegendLayout(
153
240
  // Filter groups for export: only active group shown
154
241
  const activeGroupName = state.activeGroup?.toLowerCase() ?? null;
155
242
 
156
- // In export mode with no active group, no legend
243
+ // In export mode with no active group and no groups, no legend
157
244
  if (isExport && !activeGroupName) {
158
245
  return {
159
246
  height: 0,
@@ -165,12 +252,18 @@ export function computeLegendLayout(
165
252
  };
166
253
  }
167
254
 
255
+ // Controls group (strip in export mode)
256
+ const controlsGroupLayout = isExport
257
+ ? undefined
258
+ : buildControlsGroupLayout(config, state);
259
+
168
260
  const visibleGroups = config.showEmptyGroups
169
261
  ? groups
170
262
  : groups.filter((g) => g.entries.length > 0);
171
263
  if (
172
264
  visibleGroups.length === 0 &&
173
- (!configControls || configControls.length === 0)
265
+ (!configControls || configControls.length === 0) &&
266
+ !controlsGroupLayout
174
267
  ) {
175
268
  return {
176
269
  height: 0,
@@ -238,10 +331,13 @@ export function computeLegendLayout(
238
331
  if (totalControlsW > 0) totalControlsW -= CONTROL_GAP;
239
332
  }
240
333
 
241
- // Available width for tag groups (controls anchor right)
334
+ // Available width for tag groups (controls anchor right, gear pill at end of pills)
242
335
  const controlsSpace =
243
336
  totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0;
244
- const groupAvailW = containerWidth - controlsSpace;
337
+ const gearSpace = controlsGroupLayout
338
+ ? controlsGroupLayout.width + LEGEND_GROUP_GAP
339
+ : 0;
340
+ const groupAvailW = containerWidth - controlsSpace - gearSpace;
245
341
 
246
342
  // Build pill/capsule layouts
247
343
  const pills: LegendPillLayout[] = [];
@@ -256,7 +352,7 @@ export function computeLegendLayout(
256
352
  if (isActive) {
257
353
  activeCapsule = buildCapsuleLayout(
258
354
  g,
259
- containerWidth,
355
+ groupAvailW,
260
356
  config.capsulePillAddonWidth ?? 0
261
357
  );
262
358
  } else {
@@ -281,7 +377,8 @@ export function computeLegendLayout(
281
377
  groupAvailW,
282
378
  containerWidth,
283
379
  totalControlsW,
284
- alignLeft
380
+ alignLeft,
381
+ controlsGroupLayout
285
382
  );
286
383
 
287
384
  const height = rows.length * LEGEND_HEIGHT;
@@ -294,6 +391,7 @@ export function computeLegendLayout(
294
391
  activeCapsule,
295
392
  controls: controlLayouts,
296
393
  pills,
394
+ controlsGroup: controlsGroupLayout,
297
395
  };
298
396
  }
299
397
 
@@ -386,7 +484,8 @@ function layoutRows(
386
484
  groupAvailW: number,
387
485
  containerWidth: number,
388
486
  totalControlsW: number,
389
- alignLeft = false
487
+ alignLeft = false,
488
+ controlsGroup?: ControlsGroupLayout
390
489
  ): Array<{
391
490
  y: number;
392
491
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
@@ -401,6 +500,9 @@ function layoutRows(
401
500
  if (activeCapsule) groupItems.push(activeCapsule);
402
501
  groupItems.push(...pills);
403
502
 
503
+ // Controls group width for centering offset
504
+ const gearW = controlsGroup ? controlsGroup.width + LEGEND_GROUP_GAP : 0;
505
+
404
506
  // Compute total group items width
405
507
  let currentRowItems: Array<
406
508
  LegendPillLayout | LegendCapsuleLayout | LegendControlLayout
@@ -411,9 +513,16 @@ function layoutRows(
411
513
  for (const item of groupItems) {
412
514
  const itemW = item.width + LEGEND_GROUP_GAP;
413
515
  if (currentRowW + item.width > groupAvailW && currentRowItems.length > 0) {
414
- // Commit current row
415
- if (!alignLeft)
416
- 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
+ }
417
526
  rows.push({ y: rowY, items: currentRowItems });
418
527
  rowY += LEGEND_HEIGHT;
419
528
  currentRowItems = [];
@@ -447,10 +556,26 @@ function layoutRows(
447
556
 
448
557
  // Commit last row
449
558
  if (currentRowItems.length > 0) {
450
- centerRowItems(currentRowItems, containerWidth, totalControlsW);
559
+ centerRowItems(currentRowItems, containerWidth, totalControlsW, gearW);
451
560
  rows.push({ y: rowY, items: currentRowItems });
452
561
  }
453
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
+
454
579
  // Ensure at least one row height
455
580
  if (rows.length === 0) {
456
581
  rows.push({ y: 0, items: [] });
@@ -462,7 +587,8 @@ function layoutRows(
462
587
  function centerRowItems(
463
588
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>,
464
589
  containerWidth: number,
465
- totalControlsW: number
590
+ totalControlsW: number,
591
+ controlsGroupW = 0
466
592
  ): void {
467
593
  // Only center group items (pills and capsules), not controls
468
594
  const groupItems = items.filter((it) => 'groupName' in it) as Array<
@@ -477,7 +603,8 @@ function centerRowItems(
477
603
 
478
604
  const availW =
479
605
  containerWidth -
480
- (totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0);
606
+ (totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0) -
607
+ controlsGroupW;
481
608
  const offset = Math.max(0, (availW - totalGroupW) / 2);
482
609
 
483
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 ──────────────────────────────────────────────────