@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.
- package/dist/cli.cjs +101 -101
- package/dist/index.cjs +521 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +107 -12
- package/dist/index.d.ts +107 -12
- package/dist/index.js +518 -121
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/gantt/renderer.ts +151 -89
- package/src/index.ts +10 -2
- package/src/sequence/collapse.ts +169 -0
- package/src/sequence/parser.ts +14 -2
- package/src/sequence/renderer.ts +186 -49
- package/src/sharing.ts +86 -49
- package/src/utils/legend-constants.ts +11 -0
- package/src/utils/legend-d3.ts +171 -0
- package/src/utils/legend-layout.ts +140 -13
- package/src/utils/legend-types.ts +45 -0
package/src/utils/legend-d3.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────────
|