@diagrammo/dgmo 0.31.0 → 0.32.1
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/.cursorrules +4 -1
- package/.github/copilot-instructions.md +4 -1
- package/.windsurfrules +4 -1
- package/SKILL.md +4 -1
- package/dist/advanced.cjs +1297 -358
- package/dist/advanced.d.cts +117 -15
- package/dist/advanced.d.ts +117 -15
- package/dist/advanced.js +1291 -358
- package/dist/auto.cjs +1087 -316
- package/dist/auto.js +98 -98
- package/dist/auto.mjs +1087 -316
- package/dist/cli.cjs +140 -140
- package/dist/index.cjs +1090 -397
- package/dist/index.js +1090 -397
- package/docs/ai-integration.md +4 -1
- package/docs/language-reference.md +282 -27
- package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
- package/gallery/fixtures/c4-full.dgmo +4 -5
- package/gallery/fixtures/c4.dgmo +2 -3
- package/package.json +7 -1
- package/src/advanced.ts +7 -0
- package/src/boxes-and-lines/focus.ts +257 -0
- package/src/boxes-and-lines/layout-search.ts +131 -65
- package/src/boxes-and-lines/layout.ts +7 -1
- package/src/boxes-and-lines/parser.ts +19 -4
- package/src/boxes-and-lines/renderer.ts +54 -3
- package/src/c4/parser.ts +8 -7
- package/src/chart-type-registry.ts +129 -4
- package/src/chart-types.ts +4 -4
- package/src/chart.ts +18 -1
- package/src/colors.ts +225 -2
- package/src/cycle/parser.ts +2 -7
- package/src/d3.ts +67 -54
- package/src/diagnostics.ts +17 -0
- package/src/dimensions.ts +9 -13
- package/src/echarts.ts +42 -14
- package/src/er/parser.ts +6 -1
- package/src/gantt/parser.ts +44 -7
- package/src/graph/flowchart-parser.ts +77 -3
- package/src/graph/state-renderer.ts +2 -2
- package/src/infra/parser.ts +80 -0
- package/src/journey-map/parser.ts +8 -7
- package/src/kanban/parser.ts +8 -7
- package/src/map/context-labels.ts +134 -27
- package/src/map/geo.ts +10 -2
- package/src/map/layout.ts +259 -4
- package/src/map/parser.ts +2 -0
- package/src/map/renderer.ts +22 -11
- package/src/map/resolver.ts +68 -19
- package/src/mindmap/parser.ts +15 -7
- package/src/mindmap/renderer.ts +50 -12
- package/src/org/parser.ts +8 -7
- package/src/org/renderer.ts +22 -7
- package/src/palettes/color-utils.ts +12 -2
- package/src/palettes/index.ts +1 -0
- package/src/pert/renderer.ts +2 -2
- package/src/pyramid/parser.ts +2 -7
- package/src/quadrant/renderer.ts +2 -2
- package/src/raci/parser.ts +2 -7
- package/src/raci/renderer.ts +4 -4
- package/src/ring/parser.ts +2 -7
- package/src/sequence/parser.ts +18 -7
- package/src/sequence/renderer.ts +4 -4
- package/src/sitemap/parser.ts +8 -7
- package/src/sitemap/renderer.ts +2 -2
- package/src/tech-radar/parser.ts +2 -7
- package/src/timeline/renderer.ts +15 -5
- package/src/utils/parsing.ts +13 -1
- package/src/utils/scaling.ts +38 -81
- package/src/utils/tag-groups.ts +38 -0
- package/src/visualizations/parse.ts +6 -1
- package/src/wireframe/parser.ts +6 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
boxes-and-lines E-Commerce Platform
|
|
2
2
|
|
|
3
|
-
tag Team t Backend blue, Frontend green, Platform purple
|
|
4
|
-
tag Priority p High red, Medium orange, Low gray
|
|
3
|
+
tag Team as t Backend blue, Frontend green, Platform purple
|
|
4
|
+
tag Priority as p High red, Medium orange, Low gray
|
|
5
5
|
|
|
6
6
|
active-tag Team
|
|
7
7
|
hide priority:Low
|
|
@@ -14,12 +14,16 @@ tag Team as t
|
|
|
14
14
|
|
|
15
15
|
Customer is a person t: Frontend
|
|
16
16
|
description: Browses and purchases books online
|
|
17
|
+
-Browses catalog-> Bookstore
|
|
17
18
|
|
|
18
19
|
Admin is a person t: Backend
|
|
19
20
|
description: Manages inventory and orders
|
|
21
|
+
-Manages inventory-> Bookstore
|
|
20
22
|
|
|
21
23
|
Bookstore is a system t: Backend
|
|
22
24
|
description: Core e-commerce platform for book sales
|
|
25
|
+
-Processes payments via-> PaymentGW tech: REST
|
|
26
|
+
~Sends order confirmations~> EmailSvc tech: SMTP
|
|
23
27
|
containers
|
|
24
28
|
WebApp is a container tech: React, t: Frontend
|
|
25
29
|
description: Single-page storefront application
|
|
@@ -45,8 +49,3 @@ PaymentGW is a system t: Platform
|
|
|
45
49
|
|
|
46
50
|
EmailSvc is a system t: Platform
|
|
47
51
|
description: SendGrid — transactional email delivery
|
|
48
|
-
|
|
49
|
-
Customer -Browses catalog-> Bookstore
|
|
50
|
-
Admin -Manages inventory-> Bookstore
|
|
51
|
-
Bookstore -Processes payments via-> PaymentGW tech: REST
|
|
52
|
-
Bookstore ~Sends order confirmations~> EmailSvc tech: SMTP
|
package/gallery/fixtures/c4.dgmo
CHANGED
|
@@ -2,9 +2,11 @@ c4 Internet Banking System
|
|
|
2
2
|
|
|
3
3
|
Customer is a person
|
|
4
4
|
description: A customer of the bank
|
|
5
|
+
-Uses-> Banking
|
|
5
6
|
|
|
6
7
|
Banking is a system
|
|
7
8
|
description: Core internet banking system
|
|
9
|
+
-Sends emails via-> Email
|
|
8
10
|
containers
|
|
9
11
|
WebApp is a container tech: React
|
|
10
12
|
API is a container tech: Node.js
|
|
@@ -12,6 +14,3 @@ Banking is a system
|
|
|
12
14
|
|
|
13
15
|
Email is a system
|
|
14
16
|
description: External email delivery service
|
|
15
|
-
|
|
16
|
-
Customer -Uses-> Banking
|
|
17
|
-
Banking -Sends emails via-> Email
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diagrammo/dgmo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"description": "DGMO diagram markup language — parser, renderer, and color system",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -111,6 +111,7 @@
|
|
|
111
111
|
"dev": "DGMO_DEV_RELOAD=1 tsup --watch",
|
|
112
112
|
"pretest": "pnpm codegen",
|
|
113
113
|
"test": "vitest run --coverage",
|
|
114
|
+
"bench:perf": "vitest run --config vitest.perf.config.ts tests/perf-bench.ts",
|
|
114
115
|
"bench:bl": "vitest run --config vitest.bench.config.ts tests/bl-layout-bench.ts",
|
|
115
116
|
"bench:bl-collapse": "vitest run --config vitest.bench.config.ts tests/bl-collapse-bench.ts",
|
|
116
117
|
"bench:bl-options": "vitest run --config vitest.bench.config.ts tests/bl-options.ts",
|
|
@@ -157,6 +158,11 @@
|
|
|
157
158
|
"lz-string": "^1.5.0",
|
|
158
159
|
"topojson-client": "^3.1.0"
|
|
159
160
|
},
|
|
161
|
+
"pnpm": {
|
|
162
|
+
"overrides": {
|
|
163
|
+
"undici": "^7.28.0"
|
|
164
|
+
}
|
|
165
|
+
},
|
|
160
166
|
"devDependencies": {
|
|
161
167
|
"@arethetypeswrong/cli": "^0.18.3",
|
|
162
168
|
"@codemirror/language": "^6.12.3",
|
package/src/advanced.ts
CHANGED
|
@@ -278,6 +278,8 @@ export {
|
|
|
278
278
|
|
|
279
279
|
export { collapseBoxesAndLines } from './boxes-and-lines/collapse';
|
|
280
280
|
export type { BLCollapseResult } from './boxes-and-lines/collapse';
|
|
281
|
+
export { focusBoxesAndLines } from './boxes-and-lines/focus';
|
|
282
|
+
export type { FocusTarget, FocusResult } from './boxes-and-lines/focus';
|
|
281
283
|
|
|
282
284
|
export { parseSitemap, looksLikeSitemap } from './sitemap/parser';
|
|
283
285
|
|
|
@@ -705,6 +707,11 @@ export {
|
|
|
705
707
|
seriesColors,
|
|
706
708
|
RECOGNIZED_COLOR_NAMES,
|
|
707
709
|
isRecognizedColorName,
|
|
710
|
+
INVALID_COLOR_CODE,
|
|
711
|
+
nearestNamedColor,
|
|
712
|
+
isInvalidColorToken,
|
|
713
|
+
invalidColorDiagnostic,
|
|
714
|
+
INVALID_CSS_COLOR_HEX,
|
|
708
715
|
} from './colors';
|
|
709
716
|
|
|
710
717
|
export {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Boxes and Lines — Focus (1-hop neighborhood) Transform
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Pure transform that filters a parsed boxes-and-lines diagram down to a single
|
|
6
|
+
// focused element plus its direct (1-hop) graph neighbours, mirroring the shape
|
|
7
|
+
// of `org/collapse.ts#focusOrgTree` but for a general graph instead of a tree.
|
|
8
|
+
//
|
|
9
|
+
// Unlike the org tree (subtree extraction), boxes-and-lines is a general graph,
|
|
10
|
+
// so "focus" = the focused element + everything one edge away. Neighbour groups
|
|
11
|
+
// come back COLLAPSED (reusing `collapseBoxesAndLines`'s edge-redirect + dedup),
|
|
12
|
+
// the focused group comes back EXPANDED, and everything else is hidden.
|
|
13
|
+
//
|
|
14
|
+
// Composition: this owns ALL collapse decisions for the focused view, so it is
|
|
15
|
+
// fed the un-(manually-)collapsed parsed model — focus supersedes the user's
|
|
16
|
+
// manual collapse selection for its duration (Decision 12). It runs after
|
|
17
|
+
// tag-hide in the app pipeline (FM10).
|
|
18
|
+
|
|
19
|
+
import type { ParsedBoxesAndLines, BLGroup, BLEdge } from './types';
|
|
20
|
+
import { collapseBoxesAndLines } from './collapse';
|
|
21
|
+
|
|
22
|
+
const GROUP_PREFIX = '__group_';
|
|
23
|
+
const groupKey = (label: string): string => `${GROUP_PREFIX}${label}`;
|
|
24
|
+
const isGroupKey = (k: string): boolean => k.startsWith(GROUP_PREFIX);
|
|
25
|
+
const groupLabelOf = (k: string): string => k.slice(GROUP_PREFIX.length);
|
|
26
|
+
|
|
27
|
+
export interface FocusTarget {
|
|
28
|
+
readonly kind: 'box' | 'group';
|
|
29
|
+
/** Canonical endpoint key the parser uses for edges: a node label for a box,
|
|
30
|
+
* or `__group_<label>` for a group. */
|
|
31
|
+
readonly id: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FocusResult {
|
|
35
|
+
/** Filtered model to lay out + render (neighbour groups already collapsed via
|
|
36
|
+
* `collapseBoxesAndLines`; `nodePositions` cleared so the subset auto-lays). */
|
|
37
|
+
readonly parsed: ParsedBoxesAndLines;
|
|
38
|
+
/** Canonical keys of the 1-hop neighbours kept in view (box labels +
|
|
39
|
+
* `__group_<label>` for neighbour groups). */
|
|
40
|
+
readonly neighborIds: Set<string>;
|
|
41
|
+
/** Group LABELS of neighbours rendered collapsed. */
|
|
42
|
+
readonly collapsedNeighborGroupIds: Set<string>;
|
|
43
|
+
/** GLOBAL value-ramp domain computed from the ORIGINAL model before filtering
|
|
44
|
+
* (Decision 20 / FM1); null when the diagram has no `value:` data. */
|
|
45
|
+
readonly rampDomain: { min: number; max: number } | null;
|
|
46
|
+
/** Collapse metadata for `layoutBoxesAndLines` so neighbour groups materialise
|
|
47
|
+
* as collapsed boxes — mirrors the manual-collapse path's `collapseInfo`. */
|
|
48
|
+
readonly collapseInfo: {
|
|
49
|
+
collapsedChildCounts: Map<string, number>;
|
|
50
|
+
originalGroups: readonly BLGroup[];
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Filter `parsed` to the focused element + its 1-hop neighbours.
|
|
56
|
+
*
|
|
57
|
+
* Pure, synchronous, no I/O. Tolerant of dangling/alias endpoints (skips them,
|
|
58
|
+
* never throws). For an edge-less target it returns the lone element (the app
|
|
59
|
+
* decides the "no connections" affordance, Decision 19).
|
|
60
|
+
*/
|
|
61
|
+
export function focusBoxesAndLines(
|
|
62
|
+
parsed: ParsedBoxesAndLines,
|
|
63
|
+
target: FocusTarget
|
|
64
|
+
): FocusResult {
|
|
65
|
+
// ── Step 1: GLOBAL ramp domain, computed BEFORE any filtering (Dec 20/FM1) ──
|
|
66
|
+
const allValues = parsed.nodes
|
|
67
|
+
.filter((n) => n.value !== undefined)
|
|
68
|
+
.map((n) => n.value!);
|
|
69
|
+
const rampDomain =
|
|
70
|
+
allValues.length > 0
|
|
71
|
+
? { min: Math.min(...allValues), max: Math.max(...allValues) }
|
|
72
|
+
: null;
|
|
73
|
+
|
|
74
|
+
// ── Lookups ──
|
|
75
|
+
const nodeLabelSet = new Set(parsed.nodes.map((n) => n.label));
|
|
76
|
+
const groupByLabel = new Map<string, BLGroup>();
|
|
77
|
+
for (const g of parsed.groups) groupByLabel.set(g.label, g);
|
|
78
|
+
// child label (node or sub-group) → its direct parent group label
|
|
79
|
+
const parentOf = new Map<string, string>();
|
|
80
|
+
for (const g of parsed.groups)
|
|
81
|
+
for (const child of g.children) parentOf.set(child, g.label);
|
|
82
|
+
|
|
83
|
+
/** Top-most ancestor group of a node/group label (undefined if top-level). */
|
|
84
|
+
const topAncestor = (label: string): string | undefined => {
|
|
85
|
+
let p = parentOf.get(label);
|
|
86
|
+
if (p === undefined) return undefined;
|
|
87
|
+
for (;;) {
|
|
88
|
+
const up = parentOf.get(p);
|
|
89
|
+
if (up === undefined) return p;
|
|
90
|
+
p = up;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** All descendant node + sub-group labels of a group (recursive, cycle-safe). */
|
|
95
|
+
const descendantsOf = (
|
|
96
|
+
groupLabel: string
|
|
97
|
+
): { nodes: Set<string>; groups: Set<string> } => {
|
|
98
|
+
const nodes = new Set<string>();
|
|
99
|
+
const groups = new Set<string>();
|
|
100
|
+
const seen = new Set<string>([groupLabel]);
|
|
101
|
+
const stack = [groupLabel];
|
|
102
|
+
while (stack.length) {
|
|
103
|
+
const cur = stack.pop()!;
|
|
104
|
+
const g = groupByLabel.get(cur);
|
|
105
|
+
if (!g) continue;
|
|
106
|
+
for (const child of g.children) {
|
|
107
|
+
if (groupByLabel.has(child)) {
|
|
108
|
+
if (!seen.has(child)) {
|
|
109
|
+
seen.add(child);
|
|
110
|
+
groups.add(child);
|
|
111
|
+
stack.push(child);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
nodes.add(child);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { nodes, groups };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ── Step 2: resolve the focused element ──
|
|
122
|
+
// focusNodeLabels — focus boxes shown standalone (focused box, or a group's members)
|
|
123
|
+
// focusGroupLabels — groups kept EXPANDED (focused group + its sub-groups)
|
|
124
|
+
// focusEndpointSet — canonical keys that count as "the focus" for edge adjacency
|
|
125
|
+
const focusNodeLabels = new Set<string>();
|
|
126
|
+
const focusGroupLabels = new Set<string>();
|
|
127
|
+
const focusEndpointSet = new Set<string>();
|
|
128
|
+
|
|
129
|
+
if (target.kind === 'group') {
|
|
130
|
+
const gl = isGroupKey(target.id) ? groupLabelOf(target.id) : target.id;
|
|
131
|
+
if (groupByLabel.has(gl)) {
|
|
132
|
+
focusGroupLabels.add(gl);
|
|
133
|
+
focusEndpointSet.add(groupKey(gl));
|
|
134
|
+
const desc = descendantsOf(gl);
|
|
135
|
+
for (const n of desc.nodes) {
|
|
136
|
+
focusNodeLabels.add(n);
|
|
137
|
+
focusEndpointSet.add(n);
|
|
138
|
+
}
|
|
139
|
+
for (const sg of desc.groups) {
|
|
140
|
+
focusGroupLabels.add(sg);
|
|
141
|
+
focusEndpointSet.add(groupKey(sg));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// box — shown standalone even if it belongs to a group (framing stripped).
|
|
146
|
+
if (nodeLabelSet.has(target.id)) {
|
|
147
|
+
focusNodeLabels.add(target.id);
|
|
148
|
+
focusEndpointSet.add(target.id);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Step 3: walk edges → neighbours + kept (focus-incident / internal) edges ──
|
|
153
|
+
const neighborIds = new Set<string>();
|
|
154
|
+
const collapsedNeighborGroupIds = new Set<string>();
|
|
155
|
+
const neighborBoxes = new Set<string>();
|
|
156
|
+
const keepGroupLabels = new Set<string>(focusGroupLabels);
|
|
157
|
+
const keptEdges: BLEdge[] = [];
|
|
158
|
+
const selfLoops: BLEdge[] = [];
|
|
159
|
+
|
|
160
|
+
const keepGroupAndSubgroups = (gl: string): void => {
|
|
161
|
+
keepGroupLabels.add(gl);
|
|
162
|
+
for (const sg of descendantsOf(gl).groups) keepGroupLabels.add(sg);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Classify a neighbour endpoint; returns whether the incident edge survives. */
|
|
166
|
+
const classifyNeighbor = (key: string): boolean => {
|
|
167
|
+
if (isGroupKey(key)) {
|
|
168
|
+
const gl = groupLabelOf(key);
|
|
169
|
+
if (!groupByLabel.has(gl)) return false; // dangling group ref (FM7)
|
|
170
|
+
if (focusGroupLabels.has(gl)) return true; // part of focus (internal)
|
|
171
|
+
neighborIds.add(key);
|
|
172
|
+
collapsedNeighborGroupIds.add(gl);
|
|
173
|
+
keepGroupAndSubgroups(gl);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (focusNodeLabels.has(key)) return true; // part of focus
|
|
177
|
+
if (!nodeLabelSet.has(key)) return false; // dangling box endpoint (FM7)
|
|
178
|
+
// A neighbour box inside a group → collapse that group so the edge redirects
|
|
179
|
+
// to a single collapsed box (AC3 / Dec 11); never expand the group (FM4).
|
|
180
|
+
const top = topAncestor(key);
|
|
181
|
+
if (top !== undefined && !focusGroupLabels.has(top)) {
|
|
182
|
+
neighborIds.add(groupKey(top));
|
|
183
|
+
collapsedNeighborGroupIds.add(top);
|
|
184
|
+
keepGroupAndSubgroups(top);
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
// Standalone neighbour box (no group, or already inside the focused group).
|
|
188
|
+
neighborIds.add(key);
|
|
189
|
+
neighborBoxes.add(key);
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
for (const edge of parsed.edges) {
|
|
194
|
+
const inS = focusEndpointSet.has(edge.source);
|
|
195
|
+
const inT = focusEndpointSet.has(edge.target);
|
|
196
|
+
if (edge.source === edge.target) {
|
|
197
|
+
// Self-loop: kept iff incident to focus, re-added after the collapse pass
|
|
198
|
+
// (which drops src===tgt) — FM5.
|
|
199
|
+
if (inS) selfLoops.push(edge);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (inS && inT) {
|
|
203
|
+
keptEdges.push(edge); // internal (member↔member inside focused group) — FM3
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!inS && !inT) continue; // unrelated to focus
|
|
207
|
+
const other = inS ? edge.target : edge.source;
|
|
208
|
+
if (classifyNeighbor(other)) keptEdges.push(edge);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Step 4: build the filtered model ──
|
|
212
|
+
const keepNodeLabels = new Set<string>([
|
|
213
|
+
...focusNodeLabels,
|
|
214
|
+
...neighborBoxes,
|
|
215
|
+
]);
|
|
216
|
+
const nodes = parsed.nodes.filter((n) => keepNodeLabels.has(n.label));
|
|
217
|
+
const groups = parsed.groups.filter((g) => keepGroupLabels.has(g.label));
|
|
218
|
+
// Notes follow their owner: kept iff the owning box survives (FM9).
|
|
219
|
+
const notes = parsed.notes?.filter((note) => keepNodeLabels.has(note.ref));
|
|
220
|
+
|
|
221
|
+
const filtered: ParsedBoxesAndLines = {
|
|
222
|
+
...parsed,
|
|
223
|
+
nodes,
|
|
224
|
+
edges: keptEdges,
|
|
225
|
+
groups,
|
|
226
|
+
...(notes !== undefined && { notes }),
|
|
227
|
+
};
|
|
228
|
+
// Clear pinned positions so the subset auto-lays-out (Dec 21 / FM2). Pins are
|
|
229
|
+
// restored on exit because exit re-renders the original unfiltered model.
|
|
230
|
+
delete (filtered as { nodePositions?: unknown }).nodePositions;
|
|
231
|
+
|
|
232
|
+
// ── Step 5: collapse neighbour groups via the shared redirect+dedup (ADR-3) ──
|
|
233
|
+
const collapsed = collapseBoxesAndLines(filtered, collapsedNeighborGroupIds);
|
|
234
|
+
|
|
235
|
+
// ── Step 6: re-add self-loops the collapse pass dropped (FM5) ──
|
|
236
|
+
let resultParsed = collapsed.parsed;
|
|
237
|
+
if (selfLoops.length > 0) {
|
|
238
|
+
const visible = new Set(resultParsed.nodes.map((n) => n.label));
|
|
239
|
+
const survivors = selfLoops.filter((e) => visible.has(e.source));
|
|
240
|
+
if (survivors.length > 0)
|
|
241
|
+
resultParsed = {
|
|
242
|
+
...resultParsed,
|
|
243
|
+
edges: [...resultParsed.edges, ...survivors],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
parsed: resultParsed,
|
|
249
|
+
neighborIds,
|
|
250
|
+
collapsedNeighborGroupIds,
|
|
251
|
+
rampDomain,
|
|
252
|
+
collapseInfo: {
|
|
253
|
+
collapsedChildCounts: collapsed.collapsedChildCounts,
|
|
254
|
+
originalGroups: collapsed.originalGroups,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -60,9 +60,13 @@ const splineGen = d3line<Pt>()
|
|
|
60
60
|
.y((d) => d.y)
|
|
61
61
|
.curve(curveBasis);
|
|
62
62
|
|
|
63
|
+
// Path tokenizer for `flatten` — module-scope so it isn't recompiled per call.
|
|
64
|
+
// String.match with a /g regex is stateless (ignores lastIndex), so a shared
|
|
65
|
+
// instance is safe to reuse.
|
|
66
|
+
const PATH_TOKEN_RE = /[MLQC]|-?\d*\.?\d+(?:e-?\d+)?/gi;
|
|
63
67
|
// flatten an SVG path "d" (M/L/Q/C) into a polyline for crossing detection
|
|
64
68
|
function flatten(d: string): Pt[] {
|
|
65
|
-
const toks = d.match(
|
|
69
|
+
const toks = d.match(PATH_TOKEN_RE) ?? [];
|
|
66
70
|
const pts: Pt[] = [];
|
|
67
71
|
let i = 0,
|
|
68
72
|
cx = 0,
|
|
@@ -121,6 +125,34 @@ function flatten(d: string): Pt[] {
|
|
|
121
125
|
}
|
|
122
126
|
return pts;
|
|
123
127
|
}
|
|
128
|
+
// Flattened edge polyline + its bbox. Building it requires an SVG-string
|
|
129
|
+
// round-trip (d3 spline → regex parse), so it's memoized per layout: every
|
|
130
|
+
// candidate is scored by countSplineCrossings + countEdgeOverlaps +
|
|
131
|
+
// countEdgeNodePierces, which would otherwise each re-flatten all edges.
|
|
132
|
+
type FlatPoly = { pts: Pt[]; x0: number; y0: number; x1: number; y1: number };
|
|
133
|
+
const FLAT_CACHE = new WeakMap<object, FlatPoly[]>();
|
|
134
|
+
function flatPolys(layout: BLLayoutResult): FlatPoly[] {
|
|
135
|
+
const key = layout.edges as unknown as object;
|
|
136
|
+
const hit = FLAT_CACHE.get(key);
|
|
137
|
+
if (hit) return hit;
|
|
138
|
+
const polys = layout.edges.map((e) => {
|
|
139
|
+
const pts =
|
|
140
|
+
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
141
|
+
let x0 = Infinity,
|
|
142
|
+
y0 = Infinity,
|
|
143
|
+
x1 = -Infinity,
|
|
144
|
+
y1 = -Infinity;
|
|
145
|
+
for (const p of pts) {
|
|
146
|
+
if (p.x < x0) x0 = p.x;
|
|
147
|
+
if (p.x > x1) x1 = p.x;
|
|
148
|
+
if (p.y < y0) y0 = p.y;
|
|
149
|
+
if (p.y > y1) y1 = p.y;
|
|
150
|
+
}
|
|
151
|
+
return { pts, x0, y0, x1, y1 };
|
|
152
|
+
});
|
|
153
|
+
FLAT_CACHE.set(key, polys);
|
|
154
|
+
return polys;
|
|
155
|
+
}
|
|
124
156
|
function segPoint(p1: Pt, p2: Pt, p3: Pt, p4: Pt): Pt | null {
|
|
125
157
|
const den = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);
|
|
126
158
|
if (Math.abs(den) < 1e-9) return null;
|
|
@@ -136,28 +168,21 @@ function segPoint(p1: Pt, p2: Pt, p3: Pt, p4: Pt): Pt | null {
|
|
|
136
168
|
// near a genuinely shared endpoint node; cluster near-duplicate hits.
|
|
137
169
|
// Exported so the playground + benchmark score with the SAME counter the
|
|
138
170
|
// engine optimizes against.
|
|
139
|
-
export function countSplineCrossings(
|
|
171
|
+
export function countSplineCrossings(
|
|
172
|
+
layout: BLLayoutResult,
|
|
173
|
+
/** Abort early once the running total exceeds this (the caller only needs to
|
|
174
|
+
* know X has passed the best-so-far badness — the exact value no longer
|
|
175
|
+
* matters). Returns a value > floor in that case; identical otherwise. */
|
|
176
|
+
floor = Infinity
|
|
177
|
+
): number {
|
|
140
178
|
const center = new Map<string, Pt>();
|
|
141
179
|
for (const n of layout.nodes) center.set(n.label, { x: n.x, y: n.y });
|
|
142
180
|
// collapsed group boxes are edge endpoints too (`__group_<label>`); without
|
|
143
181
|
// them, edges meeting AT a collapsed box are miscounted as crossings.
|
|
144
182
|
for (const g of layout.groups)
|
|
145
183
|
if (g.collapsed) center.set('__group_' + g.label, { x: g.x, y: g.y });
|
|
146
|
-
const polys = layout
|
|
147
|
-
|
|
148
|
-
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
149
|
-
let x0 = Infinity,
|
|
150
|
-
y0 = Infinity,
|
|
151
|
-
x1 = -Infinity,
|
|
152
|
-
y1 = -Infinity;
|
|
153
|
-
for (const p of pts) {
|
|
154
|
-
if (p.x < x0) x0 = p.x;
|
|
155
|
-
if (p.x > x1) x1 = p.x;
|
|
156
|
-
if (p.y < y0) y0 = p.y;
|
|
157
|
-
if (p.y > y1) y1 = p.y;
|
|
158
|
-
}
|
|
159
|
-
return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
|
|
160
|
-
});
|
|
184
|
+
const polys = flatPolys(layout);
|
|
185
|
+
const edges = layout.edges;
|
|
161
186
|
const R = 34;
|
|
162
187
|
let total = 0;
|
|
163
188
|
for (let a = 0; a < polys.length; a++)
|
|
@@ -166,26 +191,43 @@ export function countSplineCrossings(layout: BLLayoutResult): number {
|
|
|
166
191
|
B = polys[b]!;
|
|
167
192
|
if (A.pts.length < 2 || B.pts.length < 2) continue;
|
|
168
193
|
if (A.x1 < B.x0 || B.x1 < A.x0 || A.y1 < B.y0 || B.y1 < A.y0) continue; // bbox disjoint
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
const ea = edges[a]!,
|
|
195
|
+
eb = edges[b]!;
|
|
196
|
+
// Shared-endpoint centres (≤2) — inlined to avoid allocating a filter/map
|
|
197
|
+
// array on every one of the O(E²) edge pairs.
|
|
198
|
+
let sh0: Pt | undefined, sh1: Pt | undefined;
|
|
199
|
+
if (ea.source === eb.source || ea.source === eb.target)
|
|
200
|
+
sh0 = center.get(ea.source);
|
|
201
|
+
if (ea.target === eb.source || ea.target === eb.target)
|
|
202
|
+
sh1 = center.get(ea.target);
|
|
173
203
|
const hits: Pt[] = [];
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
204
|
+
const ap = A.pts,
|
|
205
|
+
bp = B.pts;
|
|
206
|
+
for (let i = 1; i < ap.length; i++) {
|
|
207
|
+
const a0 = ap[i - 1]!,
|
|
208
|
+
a1 = ap[i]!;
|
|
209
|
+
const axMin = a0.x < a1.x ? a0.x : a1.x,
|
|
210
|
+
axMax = a0.x > a1.x ? a0.x : a1.x,
|
|
211
|
+
ayMin = a0.y < a1.y ? a0.y : a1.y,
|
|
212
|
+
ayMax = a0.y > a1.y ? a0.y : a1.y;
|
|
213
|
+
for (let j = 1; j < bp.length; j++) {
|
|
214
|
+
const b0 = bp[j - 1]!,
|
|
215
|
+
b1 = bp[j]!;
|
|
216
|
+
// per-segment bbox reject — disjoint segments can't cross
|
|
217
|
+
if (axMax < (b0.x < b1.x ? b0.x : b1.x)) continue;
|
|
218
|
+
if ((b0.x > b1.x ? b0.x : b1.x) < axMin) continue;
|
|
219
|
+
if (ayMax < (b0.y < b1.y ? b0.y : b1.y)) continue;
|
|
220
|
+
if ((b0.y > b1.y ? b0.y : b1.y) < ayMin) continue;
|
|
221
|
+
const p = segPoint(a0, a1, b0, b1);
|
|
182
222
|
if (!p) continue;
|
|
183
|
-
if (
|
|
184
|
-
|
|
223
|
+
if (sh0 && Math.hypot(p.x - sh0.x, p.y - sh0.y) < R) continue;
|
|
224
|
+
if (sh1 && Math.hypot(p.x - sh1.x, p.y - sh1.y) < R) continue;
|
|
185
225
|
if (!hits.some((h) => Math.hypot(h.x - p.x, h.y - p.y) < 6))
|
|
186
226
|
hits.push(p);
|
|
187
227
|
}
|
|
228
|
+
}
|
|
188
229
|
total += hits.length;
|
|
230
|
+
if (total > floor) return total; // can't win — stop counting
|
|
189
231
|
}
|
|
190
232
|
return total;
|
|
191
233
|
}
|
|
@@ -251,21 +293,8 @@ export function detectEdgeOverlaps(
|
|
|
251
293
|
h: g.height,
|
|
252
294
|
});
|
|
253
295
|
|
|
254
|
-
const polys = layout
|
|
255
|
-
|
|
256
|
-
e.points.length >= 2 ? flatten(splineGen(e.points as Pt[]) ?? '') : [];
|
|
257
|
-
let x0 = Infinity,
|
|
258
|
-
y0 = Infinity,
|
|
259
|
-
x1 = -Infinity,
|
|
260
|
-
y1 = -Infinity;
|
|
261
|
-
for (const p of pts) {
|
|
262
|
-
if (p.x < x0) x0 = p.x;
|
|
263
|
-
if (p.x > x1) x1 = p.x;
|
|
264
|
-
if (p.y < y0) y0 = p.y;
|
|
265
|
-
if (p.y > y1) y1 = p.y;
|
|
266
|
-
}
|
|
267
|
-
return { pts, s: e.source, t: e.target, x0, y0, x1, y1 };
|
|
268
|
-
});
|
|
296
|
+
const polys = flatPolys(layout);
|
|
297
|
+
const edges = layout.edges;
|
|
269
298
|
|
|
270
299
|
const runs: OverlapRun[] = [];
|
|
271
300
|
for (let a = 0; a < polys.length; a++)
|
|
@@ -280,10 +309,14 @@ export function detectEdgeOverlaps(
|
|
|
280
309
|
B.y1 + dist < A.y0
|
|
281
310
|
)
|
|
282
311
|
continue;
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
312
|
+
const ea = edges[a]!,
|
|
313
|
+
eb = edges[b]!;
|
|
314
|
+
// Shared-endpoint rects (≤2) — inlined to avoid a per-pair filter/map array.
|
|
315
|
+
let shr0: Rect | undefined, shr1: Rect | undefined;
|
|
316
|
+
if (ea.source === eb.source || ea.source === eb.target)
|
|
317
|
+
shr0 = rect.get(ea.source);
|
|
318
|
+
if (ea.target === eb.source || ea.target === eb.target)
|
|
319
|
+
shr1 = rect.get(ea.target);
|
|
287
320
|
// Walk A; accumulate contiguous "covered" runs (close to B, off any shared
|
|
288
321
|
// node). A run counts once if it reaches minLen.
|
|
289
322
|
let run: Pt[] = [];
|
|
@@ -299,7 +332,9 @@ export function detectEdgeOverlaps(
|
|
|
299
332
|
runLen = 0;
|
|
300
333
|
};
|
|
301
334
|
for (const p of A.pts) {
|
|
302
|
-
const nearShared =
|
|
335
|
+
const nearShared =
|
|
336
|
+
(shr0 !== undefined && pointRectDist(p, shr0) < nodeClear) ||
|
|
337
|
+
(shr1 !== undefined && pointRectDist(p, shr1) < nodeClear);
|
|
303
338
|
const covered = !nearShared && distToPoly(p, B.pts) < dist;
|
|
304
339
|
if (covered) {
|
|
305
340
|
if (run.length)
|
|
@@ -358,9 +393,10 @@ export function detectEdgeNodePierces(
|
|
|
358
393
|
Math.abs(p.x - r.x) < r.w / 2 - inset &&
|
|
359
394
|
Math.abs(p.y - r.y) < r.h / 2 - inset;
|
|
360
395
|
const out: NodePierce[] = [];
|
|
396
|
+
const polys = flatPolys(layout);
|
|
361
397
|
layout.edges.forEach((e, idx) => {
|
|
362
398
|
if (e.points.length < 2) return;
|
|
363
|
-
const poly =
|
|
399
|
+
const poly = polys[idx]!.pts;
|
|
364
400
|
for (const r of rects) {
|
|
365
401
|
if (
|
|
366
402
|
r.key === e.source ||
|
|
@@ -860,7 +896,7 @@ function edgeLength(layout: BLLayoutResult): number {
|
|
|
860
896
|
return total;
|
|
861
897
|
}
|
|
862
898
|
|
|
863
|
-
export function layoutBoxesAndLinesSearch(
|
|
899
|
+
export async function layoutBoxesAndLinesSearch(
|
|
864
900
|
parsed: ParsedBoxesAndLines,
|
|
865
901
|
collapseInfo?: {
|
|
866
902
|
collapsedChildCounts: Map<string, number>;
|
|
@@ -875,9 +911,21 @@ export function layoutBoxesAndLinesSearch(
|
|
|
875
911
|
lambda?: number;
|
|
876
912
|
/** How many top candidates to re-rank with the exact counter (default 6). */
|
|
877
913
|
refineK?: number;
|
|
914
|
+
/** Progress hook for the interactive path. When provided, the search yields
|
|
915
|
+
* to a macrotask after each candidate so the host UI can paint a progress
|
|
916
|
+
* indicator. Omit it (CLI/export) and the search runs straight through with
|
|
917
|
+
* no added latency. */
|
|
918
|
+
onProgress?: (done: number, total: number, phase: string) => void;
|
|
878
919
|
}
|
|
879
|
-
): BLLayoutResult {
|
|
920
|
+
): Promise<BLLayoutResult> {
|
|
880
921
|
const hideDescriptions = opts?.hideDescriptions ?? false;
|
|
922
|
+
const onProgress = opts?.onProgress;
|
|
923
|
+
// Yield to a macrotask (lets the browser repaint between heavy placements);
|
|
924
|
+
// no-op when there's no progress observer so non-interactive callers pay
|
|
925
|
+
// nothing.
|
|
926
|
+
const tick = onProgress
|
|
927
|
+
? (): Promise<void> => new Promise<void>((r) => setTimeout(r))
|
|
928
|
+
: (): undefined => undefined;
|
|
881
929
|
|
|
882
930
|
// collapsed group labels (shown as plain boxes) — mirrors the ELK path
|
|
883
931
|
const collapsedGroupLabels = new Set<string>();
|
|
@@ -1427,7 +1475,7 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1427
1475
|
// `floor` lets callers skip the expensive O/P passes once X alone already
|
|
1428
1476
|
// exceeds the best badness found so far (it can't win, return Infinity).
|
|
1429
1477
|
const badness = (lay: BLLayoutResult, floor: number): number => {
|
|
1430
|
-
const x = countSplineCrossings(lay);
|
|
1478
|
+
const x = countSplineCrossings(lay, floor);
|
|
1431
1479
|
if (x > floor) return Infinity;
|
|
1432
1480
|
return (
|
|
1433
1481
|
x +
|
|
@@ -1442,6 +1490,18 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1442
1490
|
const objective = (lay: BLLayoutResult, viol: number) =>
|
|
1443
1491
|
viol * 1e6 + edgeLength(lay) + lambda * meanDrift(lay, prev) * 10;
|
|
1444
1492
|
|
|
1493
|
+
// Progress is reported over the two dominant phases: building the dagre
|
|
1494
|
+
// candidate pool, then exact-scoring the top few. `refineK` is clamped below,
|
|
1495
|
+
// so estimate the total here for a smooth bar.
|
|
1496
|
+
const progressTotal =
|
|
1497
|
+
configs.length + Math.min(opts?.refineK ?? 6, configs.length);
|
|
1498
|
+
let progressDone = 0;
|
|
1499
|
+
const step = async (phase: string): Promise<void> => {
|
|
1500
|
+
if (!onProgress) return;
|
|
1501
|
+
onProgress(++progressDone, progressTotal, phase);
|
|
1502
|
+
await tick();
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1445
1505
|
// Build the candidate pool.
|
|
1446
1506
|
const pool: BLLayoutResult[] = [];
|
|
1447
1507
|
for (const cfg of configs) {
|
|
@@ -1450,6 +1510,7 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1450
1510
|
} catch {
|
|
1451
1511
|
/* some rankers choke on odd graphs */
|
|
1452
1512
|
}
|
|
1513
|
+
await step('Optimizing layout');
|
|
1453
1514
|
}
|
|
1454
1515
|
if (!pool.length)
|
|
1455
1516
|
return place({ ranker: 'network-simplex', nodesep: 50, ranksep: 60 });
|
|
@@ -1470,10 +1531,13 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1470
1531
|
// Stage 1: rank the dagre pool with the cheap straight-segment counter — a
|
|
1471
1532
|
// cheap proxy to pick which candidates are worth the expensive exact scoring.
|
|
1472
1533
|
// Widen REFINE_K a little since the proxy only sees crossings, not O/P.
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1534
|
+
// Pre-score each candidate ONCE (countCrossingsFast is O(E²)) into a key map,
|
|
1535
|
+
// then sort by the stored key — the comparator otherwise recomputes the score
|
|
1536
|
+
// for both operands on every comparison (O(C log C) calls vs C). The score is a
|
|
1537
|
+
// pure function of the layout, so the resulting order is identical.
|
|
1538
|
+
const fastKey = new Map<BLLayoutResult, number>();
|
|
1539
|
+
for (const lay of pool) fastKey.set(lay, objective(lay, countCrossingsFast(lay)));
|
|
1540
|
+
pool.sort((a, b) => fastKey.get(a)! - fastKey.get(b)!);
|
|
1477
1541
|
const refineK = Math.min(REFINE_K, pool.length);
|
|
1478
1542
|
|
|
1479
1543
|
// Stage 2: exact-score the top-K dagre candidates on the FULL badness (X+O+P)
|
|
@@ -1492,7 +1556,10 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1492
1556
|
best = lay;
|
|
1493
1557
|
}
|
|
1494
1558
|
};
|
|
1495
|
-
for (const lay of pool.slice(0, refineK))
|
|
1559
|
+
for (const lay of pool.slice(0, refineK)) {
|
|
1560
|
+
consider(lay);
|
|
1561
|
+
await step('Refining layout');
|
|
1562
|
+
}
|
|
1496
1563
|
|
|
1497
1564
|
// Adaptive escalation: a still-high badness after the base seed budget means
|
|
1498
1565
|
// the graph is genuinely hard — dense layouts (e.g. the marketplace) need many
|
|
@@ -1517,11 +1584,10 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1517
1584
|
/* ignore choking rankers */
|
|
1518
1585
|
}
|
|
1519
1586
|
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
);
|
|
1587
|
+
const extraKey = new Map<BLLayoutResult, number>();
|
|
1588
|
+
for (const lay of extra)
|
|
1589
|
+
extraKey.set(lay, objective(lay, countCrossingsFast(lay)));
|
|
1590
|
+
extra.sort((a, b) => extraKey.get(a)! - extraKey.get(b)!);
|
|
1525
1591
|
for (const lay of extra.slice(0, ESCALATE_REFINE)) consider(lay);
|
|
1526
1592
|
}
|
|
1527
1593
|
|