@diagrammo/dgmo 0.8.8 → 0.8.10
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/AGENTS.md +3 -0
- package/dist/cli.cjs +181 -179
- package/dist/index.cjs +1425 -933
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +147 -1
- package/dist/index.d.ts +147 -1
- package/dist/index.js +1421 -933
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +28 -2
- package/gallery/fixtures/sitemap-full.dgmo +1 -0
- package/package.json +1 -1
- package/src/boxes-and-lines/layout.ts +48 -8
- package/src/boxes-and-lines/parser.ts +59 -13
- package/src/boxes-and-lines/renderer.ts +33 -137
- package/src/c4/renderer.ts +25 -138
- package/src/class/renderer.ts +185 -186
- package/src/d3.ts +114 -191
- package/src/echarts.ts +99 -214
- package/src/er/renderer.ts +52 -245
- package/src/gantt/renderer.ts +140 -182
- package/src/index.ts +21 -1
- package/src/infra/renderer.ts +91 -244
- package/src/kanban/renderer.ts +22 -129
- package/src/org/renderer.ts +103 -170
- package/src/render.ts +39 -9
- package/src/sequence/renderer.ts +31 -151
- package/src/sitemap/layout.ts +180 -38
- package/src/sitemap/parser.ts +64 -23
- package/src/sitemap/renderer.ts +73 -161
- package/src/utils/legend-constants.ts +6 -0
- package/src/utils/legend-d3.ts +400 -0
- package/src/utils/legend-layout.ts +495 -0
- package/src/utils/legend-svg.ts +26 -0
- package/src/utils/legend-types.ts +169 -0
|
@@ -716,6 +716,20 @@ Home
|
|
|
716
716
|
-login-> Login
|
|
717
717
|
```
|
|
718
718
|
|
|
719
|
+
Arrows can target containers using bracket syntax:
|
|
720
|
+
|
|
721
|
+
```
|
|
722
|
+
Home
|
|
723
|
+
-> [Port Market]
|
|
724
|
+
[Port Market]
|
|
725
|
+
Shop
|
|
726
|
+
-> [Warehouse]
|
|
727
|
+
[Warehouse]
|
|
728
|
+
Storage
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
All permutations supported: node→group, group→node, group→group. Brackets required to distinguish group targets from page targets.
|
|
732
|
+
|
|
719
733
|
### 11.4 Containers
|
|
720
734
|
|
|
721
735
|
```
|
|
@@ -898,14 +912,26 @@ Nested groups (max depth 2):
|
|
|
898
912
|
|
|
899
913
|
Group metadata cascades to children (node metadata overrides). Nodes already declared above can be referenced inside groups to assign membership.
|
|
900
914
|
|
|
901
|
-
### 13.5 Group-
|
|
915
|
+
### 13.5 Group-Targeted Edges
|
|
916
|
+
|
|
917
|
+
Node-to-group and group-to-group edges use bracket syntax `[Group Name]`:
|
|
902
918
|
|
|
903
919
|
```
|
|
904
|
-
|
|
920
|
+
API -> [Backend]
|
|
921
|
+
[Backend] -> [Frontend]
|
|
905
922
|
[Region A] <-> [Region B]
|
|
906
923
|
[Region A] -VPN-> [Region B]
|
|
907
924
|
```
|
|
908
925
|
|
|
926
|
+
Indented shorthand also supports groups (place arrow directly after group header):
|
|
927
|
+
|
|
928
|
+
```
|
|
929
|
+
[Backend]
|
|
930
|
+
-> [Frontend]
|
|
931
|
+
DB
|
|
932
|
+
Cache
|
|
933
|
+
```
|
|
934
|
+
|
|
909
935
|
### 13.6 Directives
|
|
910
936
|
|
|
911
937
|
- `direction TB` — top-to-bottom layout (default: `LR`)
|
package/package.json
CHANGED
|
@@ -5,6 +5,31 @@
|
|
|
5
5
|
import dagre from '@dagrejs/dagre';
|
|
6
6
|
import type { ParsedBoxesAndLines, BLNode } from './types';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Clip a point at (cx, cy) to the border of a rectangle centered at (cx, cy)
|
|
10
|
+
* with given width/height, along the direction toward (tx, ty).
|
|
11
|
+
* Returns the intersection point on the rectangle border.
|
|
12
|
+
*/
|
|
13
|
+
function clipToRectBorder(
|
|
14
|
+
cx: number,
|
|
15
|
+
cy: number,
|
|
16
|
+
w: number,
|
|
17
|
+
h: number,
|
|
18
|
+
tx: number,
|
|
19
|
+
ty: number
|
|
20
|
+
): { x: number; y: number } {
|
|
21
|
+
const dx = tx - cx;
|
|
22
|
+
const dy = ty - cy;
|
|
23
|
+
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
|
24
|
+
const hw = w / 2;
|
|
25
|
+
const hh = h / 2;
|
|
26
|
+
// Scale factor to reach the border along the direction (dx, dy)
|
|
27
|
+
const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
|
|
28
|
+
const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
|
|
29
|
+
const s = Math.min(sx, sy);
|
|
30
|
+
return { x: cx + dx * s, y: cy + dy * s };
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
// ── Constants ──────────────────────────────────────────────
|
|
9
34
|
const NODESEP = 60;
|
|
10
35
|
const RANKSEP = 100;
|
|
@@ -38,6 +63,8 @@ export interface BLLayoutEdge {
|
|
|
38
63
|
yOffset: number;
|
|
39
64
|
parallelCount: number;
|
|
40
65
|
metadata: Record<string, string>;
|
|
66
|
+
/** True for edges deferred from dagre (group endpoints) — use linear curve */
|
|
67
|
+
deferred?: boolean;
|
|
41
68
|
}
|
|
42
69
|
|
|
43
70
|
export interface BLLayoutGroup {
|
|
@@ -257,17 +284,29 @@ export function layoutBoxesAndLines(
|
|
|
257
284
|
let points: { x: number; y: number }[];
|
|
258
285
|
|
|
259
286
|
if (deferredSet.has(i)) {
|
|
260
|
-
// Deferred edge (compound parent endpoint) — compute points
|
|
287
|
+
// Deferred edge (compound parent endpoint) — compute points clipped to border
|
|
261
288
|
const srcNode = g.node(edge.source);
|
|
262
289
|
const tgtNode = g.node(edge.target);
|
|
263
290
|
if (!srcNode || !tgtNode) continue;
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
291
|
+
const srcPt = clipToRectBorder(
|
|
292
|
+
srcNode.x,
|
|
293
|
+
srcNode.y,
|
|
294
|
+
srcNode.width,
|
|
295
|
+
srcNode.height,
|
|
296
|
+
tgtNode.x,
|
|
297
|
+
tgtNode.y
|
|
298
|
+
);
|
|
299
|
+
const tgtPt = clipToRectBorder(
|
|
300
|
+
tgtNode.x,
|
|
301
|
+
tgtNode.y,
|
|
302
|
+
tgtNode.width,
|
|
303
|
+
tgtNode.height,
|
|
304
|
+
srcNode.x,
|
|
305
|
+
srcNode.y
|
|
306
|
+
);
|
|
307
|
+
const midX = (srcPt.x + tgtPt.x) / 2;
|
|
308
|
+
const midY = (srcPt.y + tgtPt.y) / 2;
|
|
309
|
+
points = [srcPt, { x: midX, y: midY }, tgtPt];
|
|
271
310
|
} else {
|
|
272
311
|
const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
|
|
273
312
|
points = dagreEdge?.points ?? [];
|
|
@@ -294,6 +333,7 @@ export function layoutBoxesAndLines(
|
|
|
294
333
|
yOffset: edgeYOffsets[i],
|
|
295
334
|
parallelCount: edgeParallelCounts[i],
|
|
296
335
|
metadata: edge.metadata,
|
|
336
|
+
deferred: deferredSet.has(i) || undefined,
|
|
297
337
|
});
|
|
298
338
|
}
|
|
299
339
|
|
|
@@ -94,6 +94,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
94
94
|
const nodeLabels = new Set<string>();
|
|
95
95
|
const groupLabels = new Set<string>();
|
|
96
96
|
let lastNodeLabel: string | null = null;
|
|
97
|
+
let lastSourceIsGroup = false;
|
|
97
98
|
|
|
98
99
|
// Group stack for nesting
|
|
99
100
|
interface GroupState {
|
|
@@ -393,6 +394,8 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
393
394
|
|
|
394
395
|
groupLabels.add(label);
|
|
395
396
|
groupStack.push({ group, indent, depth: currentDepth });
|
|
397
|
+
lastNodeLabel = label;
|
|
398
|
+
lastSourceIsGroup = true;
|
|
396
399
|
continue;
|
|
397
400
|
}
|
|
398
401
|
|
|
@@ -414,7 +417,10 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
414
417
|
);
|
|
415
418
|
continue;
|
|
416
419
|
}
|
|
417
|
-
|
|
420
|
+
const sourcePrefix = lastSourceIsGroup
|
|
421
|
+
? `[${lastNodeLabel}]`
|
|
422
|
+
: lastNodeLabel;
|
|
423
|
+
edgeText = `${sourcePrefix} ${trimmed}`;
|
|
418
424
|
}
|
|
419
425
|
|
|
420
426
|
const edge = parseEdgeLine(
|
|
@@ -442,6 +448,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
442
448
|
continue;
|
|
443
449
|
}
|
|
444
450
|
lastNodeLabel = node.label;
|
|
451
|
+
lastSourceIsGroup = false;
|
|
445
452
|
|
|
446
453
|
const gs = currentGroupState();
|
|
447
454
|
const isGroupChild = gs && indent > gs.indent;
|
|
@@ -478,16 +485,47 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
|
|
|
478
485
|
result.groups.push(gs.group);
|
|
479
486
|
}
|
|
480
487
|
|
|
481
|
-
//
|
|
488
|
+
// Validate group references and implicitly create node endpoints
|
|
489
|
+
const validEdges: BLEdge[] = [];
|
|
482
490
|
for (const edge of result.edges) {
|
|
483
|
-
|
|
484
|
-
|
|
491
|
+
let valid = true;
|
|
492
|
+
|
|
493
|
+
// Check group references exist
|
|
494
|
+
if (edge.source.startsWith('__group_')) {
|
|
495
|
+
const label = edge.source.slice('__group_'.length);
|
|
496
|
+
const found = [...groupLabels].some(
|
|
497
|
+
(g) => g.toLowerCase() === label.toLowerCase()
|
|
498
|
+
);
|
|
499
|
+
if (!found) {
|
|
500
|
+
result.diagnostics.push(
|
|
501
|
+
makeDgmoError(edge.lineNumber, `Group '[${label}]' not found`)
|
|
502
|
+
);
|
|
503
|
+
valid = false;
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
485
506
|
ensureNode(edge.source, edge.lineNumber);
|
|
486
507
|
}
|
|
487
|
-
|
|
508
|
+
|
|
509
|
+
if (edge.target.startsWith('__group_')) {
|
|
510
|
+
const label = edge.target.slice('__group_'.length);
|
|
511
|
+
const found = [...groupLabels].some(
|
|
512
|
+
(g) => g.toLowerCase() === label.toLowerCase()
|
|
513
|
+
);
|
|
514
|
+
if (!found) {
|
|
515
|
+
result.diagnostics.push(
|
|
516
|
+
makeDgmoError(edge.lineNumber, `Group '[${label}]' not found`)
|
|
517
|
+
);
|
|
518
|
+
valid = false;
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
488
521
|
ensureNode(edge.target, edge.lineNumber);
|
|
489
522
|
}
|
|
523
|
+
|
|
524
|
+
if (valid) {
|
|
525
|
+
validEdges.push(edge);
|
|
526
|
+
}
|
|
490
527
|
}
|
|
528
|
+
result.edges = validEdges;
|
|
491
529
|
|
|
492
530
|
// Post-parse: inject default tag metadata and validate tag values
|
|
493
531
|
if (result.tagGroups.length > 0) {
|
|
@@ -540,6 +578,12 @@ function parseNodeLine(
|
|
|
540
578
|
};
|
|
541
579
|
}
|
|
542
580
|
|
|
581
|
+
/** Convert `[Group Name]` to `__group_Group Name`, or return as-is for plain nodes */
|
|
582
|
+
function resolveEndpoint(name: string): string {
|
|
583
|
+
const m = name.match(/^\[(.+)\]$/);
|
|
584
|
+
return m ? groupId(m[1].trim()) : name;
|
|
585
|
+
}
|
|
586
|
+
|
|
543
587
|
/**
|
|
544
588
|
* Parse an edge line. Supports:
|
|
545
589
|
* - `Source -> Target`
|
|
@@ -548,6 +592,8 @@ function parseNodeLine(
|
|
|
548
592
|
* - `Source <-> Target`
|
|
549
593
|
* - `Source <-label-> Target`
|
|
550
594
|
* - `Source -label-> Target | key: value`
|
|
595
|
+
*
|
|
596
|
+
* `[Group Name]` in source or target position is resolved to `__group_Group Name`.
|
|
551
597
|
*/
|
|
552
598
|
function parseEdgeLine(
|
|
553
599
|
trimmed: string,
|
|
@@ -558,7 +604,7 @@ function parseEdgeLine(
|
|
|
558
604
|
// Check for bidirectional labeled: `Source <-label-> Target`
|
|
559
605
|
const biLabeledMatch = trimmed.match(/^(.+?)\s*<-(.+)->\s*(.+)$/);
|
|
560
606
|
if (biLabeledMatch) {
|
|
561
|
-
const source = biLabeledMatch[1].trim();
|
|
607
|
+
const source = resolveEndpoint(biLabeledMatch[1].trim());
|
|
562
608
|
const label = biLabeledMatch[2].trim();
|
|
563
609
|
let rest = biLabeledMatch[3].trim();
|
|
564
610
|
|
|
@@ -582,7 +628,7 @@ function parseEdgeLine(
|
|
|
582
628
|
|
|
583
629
|
return {
|
|
584
630
|
source,
|
|
585
|
-
target: rest,
|
|
631
|
+
target: resolveEndpoint(rest),
|
|
586
632
|
label: label || undefined,
|
|
587
633
|
bidirectional: true,
|
|
588
634
|
lineNumber: lineNum,
|
|
@@ -593,7 +639,7 @@ function parseEdgeLine(
|
|
|
593
639
|
// Check for bidirectional plain: `Source <-> Target`
|
|
594
640
|
const biIdx = trimmed.indexOf('<->');
|
|
595
641
|
if (biIdx >= 0) {
|
|
596
|
-
const source = trimmed.slice(0, biIdx).trim();
|
|
642
|
+
const source = resolveEndpoint(trimmed.slice(0, biIdx).trim());
|
|
597
643
|
let rest = trimmed.slice(biIdx + 3).trim();
|
|
598
644
|
|
|
599
645
|
let metadata: Record<string, string> = {};
|
|
@@ -616,7 +662,7 @@ function parseEdgeLine(
|
|
|
616
662
|
|
|
617
663
|
return {
|
|
618
664
|
source,
|
|
619
|
-
target: rest,
|
|
665
|
+
target: resolveEndpoint(rest),
|
|
620
666
|
bidirectional: true,
|
|
621
667
|
lineNumber: lineNum,
|
|
622
668
|
metadata,
|
|
@@ -626,7 +672,7 @@ function parseEdgeLine(
|
|
|
626
672
|
// Check for labeled arrow: `Source -label-> Target`
|
|
627
673
|
const labeledMatch = trimmed.match(/^(.+?)\s+-(.+)->\s*(.+)$/);
|
|
628
674
|
if (labeledMatch) {
|
|
629
|
-
const source = labeledMatch[1].trim();
|
|
675
|
+
const source = resolveEndpoint(labeledMatch[1].trim());
|
|
630
676
|
const label = labeledMatch[2].trim();
|
|
631
677
|
let rest = labeledMatch[3].trim();
|
|
632
678
|
|
|
@@ -651,7 +697,7 @@ function parseEdgeLine(
|
|
|
651
697
|
|
|
652
698
|
return {
|
|
653
699
|
source,
|
|
654
|
-
target: rest,
|
|
700
|
+
target: resolveEndpoint(rest),
|
|
655
701
|
label,
|
|
656
702
|
bidirectional: false,
|
|
657
703
|
lineNumber: lineNum,
|
|
@@ -664,7 +710,7 @@ function parseEdgeLine(
|
|
|
664
710
|
const arrowIdx = trimmed.indexOf('->');
|
|
665
711
|
if (arrowIdx < 0) return null;
|
|
666
712
|
|
|
667
|
-
const source = trimmed.slice(0, arrowIdx).trim();
|
|
713
|
+
const source = resolveEndpoint(trimmed.slice(0, arrowIdx).trim());
|
|
668
714
|
let rest = trimmed.slice(arrowIdx + 2).trim();
|
|
669
715
|
|
|
670
716
|
if (!source || !rest) {
|
|
@@ -689,7 +735,7 @@ function parseEdgeLine(
|
|
|
689
735
|
|
|
690
736
|
return {
|
|
691
737
|
source,
|
|
692
|
-
target: rest,
|
|
738
|
+
target: resolveEndpoint(rest),
|
|
693
739
|
bidirectional: false,
|
|
694
740
|
lineNumber: lineNum,
|
|
695
741
|
metadata,
|
|
@@ -5,18 +5,9 @@
|
|
|
5
5
|
import * as d3Selection from 'd3-selection';
|
|
6
6
|
import * as d3Shape from 'd3-shape';
|
|
7
7
|
import { FONT_FAMILY } from '../fonts';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
LEGEND_PILL_FONT_SIZE,
|
|
12
|
-
LEGEND_CAPSULE_PAD,
|
|
13
|
-
LEGEND_DOT_R,
|
|
14
|
-
LEGEND_ENTRY_FONT_SIZE,
|
|
15
|
-
LEGEND_ENTRY_DOT_GAP,
|
|
16
|
-
LEGEND_ENTRY_TRAIL,
|
|
17
|
-
LEGEND_GROUP_GAP,
|
|
18
|
-
measureLegendText,
|
|
19
|
-
} from '../utils/legend-constants';
|
|
8
|
+
import { LEGEND_HEIGHT } from '../utils/legend-constants';
|
|
9
|
+
import { renderLegendD3 } from '../utils/legend-d3';
|
|
10
|
+
import type { LegendConfig, LegendState } from '../utils/legend-types';
|
|
20
11
|
import {
|
|
21
12
|
TITLE_FONT_SIZE,
|
|
22
13
|
TITLE_FONT_WEIGHT,
|
|
@@ -62,6 +53,12 @@ const lineGeneratorTB = d3Shape
|
|
|
62
53
|
.y((d) => d.y)
|
|
63
54
|
.curve(d3Shape.curveMonotoneY);
|
|
64
55
|
|
|
56
|
+
const lineGeneratorLinear = d3Shape
|
|
57
|
+
.line<{ x: number; y: number }>()
|
|
58
|
+
.x((d) => d.x)
|
|
59
|
+
.y((d) => d.y)
|
|
60
|
+
.curve(d3Shape.curveLinear);
|
|
61
|
+
|
|
65
62
|
// ── Text fitting ───────────────────────────────────────────
|
|
66
63
|
|
|
67
64
|
function splitCamelCase(word: string): string[] {
|
|
@@ -526,15 +523,15 @@ export function renderBoxesAndLines(
|
|
|
526
523
|
edgeGroups.set(i, edgeG as unknown as D3G);
|
|
527
524
|
|
|
528
525
|
const markerId = `bl-arrow-${color.replace('#', '')}`;
|
|
526
|
+
const gen = le.deferred
|
|
527
|
+
? lineGeneratorLinear
|
|
528
|
+
: parsed.direction === 'TB'
|
|
529
|
+
? lineGeneratorTB
|
|
530
|
+
: lineGeneratorLR;
|
|
529
531
|
const path = edgeG
|
|
530
532
|
.append('path')
|
|
531
533
|
.attr('class', 'bl-edge')
|
|
532
|
-
.attr(
|
|
533
|
-
'd',
|
|
534
|
-
(parsed.direction === 'TB' ? lineGeneratorTB : lineGeneratorLR)(
|
|
535
|
-
points
|
|
536
|
-
) ?? ''
|
|
537
|
-
)
|
|
534
|
+
.attr('d', gen(points) ?? '')
|
|
538
535
|
.attr('fill', 'none')
|
|
539
536
|
.attr('stroke', color)
|
|
540
537
|
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
@@ -709,126 +706,25 @@ export function renderBoxesAndLines(
|
|
|
709
706
|
|
|
710
707
|
// ── Render legend ──────────────────────────────────────
|
|
711
708
|
if (parsed.tagGroups.length > 0) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
svg: D3Svg,
|
|
720
|
-
parsed: ParsedBoxesAndLines,
|
|
721
|
-
palette: PaletteColors,
|
|
722
|
-
isDark: boolean,
|
|
723
|
-
activeGroup: string | null,
|
|
724
|
-
svgWidth: number,
|
|
725
|
-
titleOffset: number
|
|
726
|
-
): void {
|
|
727
|
-
const groupBg = isDark
|
|
728
|
-
? mix(palette.surface, palette.bg, 50)
|
|
729
|
-
: mix(palette.surface, palette.bg, 30);
|
|
730
|
-
const pillBorder = mix(palette.textMuted, palette.bg, 50);
|
|
731
|
-
|
|
732
|
-
// ── Pre-compute total legend width for centering ──
|
|
733
|
-
let totalW = 0;
|
|
734
|
-
for (const tg of parsed.tagGroups) {
|
|
735
|
-
const isActive = activeGroup?.toLowerCase() === tg.name.toLowerCase();
|
|
736
|
-
totalW +=
|
|
737
|
-
measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
738
|
-
if (isActive) {
|
|
739
|
-
totalW += 6;
|
|
740
|
-
for (const entry of tg.entries) {
|
|
741
|
-
totalW +=
|
|
742
|
-
LEGEND_DOT_R * 2 +
|
|
743
|
-
LEGEND_ENTRY_DOT_GAP +
|
|
744
|
-
measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
745
|
-
LEGEND_ENTRY_TRAIL;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
totalW += LEGEND_GROUP_GAP;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
const legendX = Math.max(LEGEND_CAPSULE_PAD, (svgWidth - totalW) / 2);
|
|
752
|
-
const legendY = titleOffset + 4;
|
|
753
|
-
const legendG = svg
|
|
754
|
-
.append('g')
|
|
755
|
-
.attr('transform', `translate(${legendX},${legendY})`);
|
|
756
|
-
|
|
757
|
-
let x = 0;
|
|
758
|
-
|
|
759
|
-
// ── Tag group pills (collapsed when inactive, expanded when active) ──
|
|
760
|
-
for (const tg of parsed.tagGroups) {
|
|
761
|
-
const isActiveGroup = activeGroup?.toLowerCase() === tg.name.toLowerCase();
|
|
762
|
-
|
|
763
|
-
const groupG = legendG
|
|
709
|
+
const legendConfig: LegendConfig = {
|
|
710
|
+
groups: parsed.tagGroups,
|
|
711
|
+
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
712
|
+
mode: 'fixed',
|
|
713
|
+
};
|
|
714
|
+
const legendState: LegendState = { activeGroup };
|
|
715
|
+
const legendG = svg
|
|
764
716
|
.append('g')
|
|
765
|
-
.attr('
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
.attr('width', nameW)
|
|
777
|
-
.attr('height', LEGEND_HEIGHT)
|
|
778
|
-
.attr('rx', LEGEND_HEIGHT / 2)
|
|
779
|
-
.attr('fill', groupBg);
|
|
780
|
-
|
|
781
|
-
if (isActiveGroup) {
|
|
782
|
-
tagPill.attr('stroke', pillBorder).attr('stroke-width', 0.75);
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
groupG
|
|
786
|
-
.append('text')
|
|
787
|
-
.attr('x', x + nameW / 2)
|
|
788
|
-
.attr('y', LEGEND_HEIGHT / 2)
|
|
789
|
-
.attr('text-anchor', 'middle')
|
|
790
|
-
.attr('dominant-baseline', 'central')
|
|
791
|
-
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
792
|
-
.attr('font-weight', 500)
|
|
793
|
-
.attr('fill', isActiveGroup ? palette.text : palette.textMuted)
|
|
794
|
-
.attr('pointer-events', 'none')
|
|
795
|
-
.text(tg.name);
|
|
796
|
-
|
|
797
|
-
x += nameW;
|
|
798
|
-
|
|
799
|
-
// Entries — only rendered when this group is active
|
|
800
|
-
if (isActiveGroup) {
|
|
801
|
-
x += 6;
|
|
802
|
-
for (const entry of tg.entries) {
|
|
803
|
-
const entryColor = entry.color || palette.textMuted;
|
|
804
|
-
const ew = measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE);
|
|
805
|
-
|
|
806
|
-
const entryG = groupG
|
|
807
|
-
.append('g')
|
|
808
|
-
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
809
|
-
.style('cursor', 'pointer');
|
|
810
|
-
|
|
811
|
-
entryG
|
|
812
|
-
.append('circle')
|
|
813
|
-
.attr('cx', x + LEGEND_DOT_R)
|
|
814
|
-
.attr('cy', LEGEND_HEIGHT / 2)
|
|
815
|
-
.attr('r', LEGEND_DOT_R)
|
|
816
|
-
.attr('fill', entryColor);
|
|
817
|
-
|
|
818
|
-
entryG
|
|
819
|
-
.append('text')
|
|
820
|
-
.attr('x', x + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
|
|
821
|
-
.attr('y', LEGEND_HEIGHT / 2)
|
|
822
|
-
.attr('dominant-baseline', 'central')
|
|
823
|
-
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
824
|
-
.attr('fill', palette.textMuted)
|
|
825
|
-
.text(entry.value);
|
|
826
|
-
|
|
827
|
-
x += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + ew + LEGEND_ENTRY_TRAIL;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
x += LEGEND_GROUP_GAP;
|
|
717
|
+
.attr('transform', `translate(0,${titleOffset + 4})`);
|
|
718
|
+
renderLegendD3(
|
|
719
|
+
legendG,
|
|
720
|
+
legendConfig,
|
|
721
|
+
legendState,
|
|
722
|
+
palette,
|
|
723
|
+
isDark,
|
|
724
|
+
undefined,
|
|
725
|
+
width
|
|
726
|
+
);
|
|
727
|
+
legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
|
|
832
728
|
}
|
|
833
729
|
}
|
|
834
730
|
|