@dxos/diagram 0.11.1 → 0.13.0
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/lib/chunk-parse.mjs +9396 -0
- package/dist/lib/chunk-parse.mjs.map +1 -0
- package/dist/lib/extension.mjs +204 -0
- package/dist/lib/extension.mjs.map +1 -0
- package/dist/lib/index.mjs +497 -2846
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/aesthetics.d.ts +12 -0
- package/dist/types/src/aesthetics.d.ts.map +1 -0
- package/dist/types/src/aesthetics.test.d.ts +2 -0
- package/dist/types/src/aesthetics.test.d.ts.map +1 -0
- package/dist/types/src/appeal.d.ts +55 -0
- package/dist/types/src/appeal.d.ts.map +1 -0
- package/dist/types/src/appeal.test.d.ts +2 -0
- package/dist/types/src/appeal.test.d.ts.map +1 -0
- package/dist/types/src/architecture.d.ts +86 -0
- package/dist/types/src/architecture.d.ts.map +1 -0
- package/dist/types/src/architecture.test.d.ts +2 -0
- package/dist/types/src/architecture.test.d.ts.map +1 -0
- package/dist/types/src/compact.d.ts +39 -0
- package/dist/types/src/compact.d.ts.map +1 -0
- package/dist/types/src/compact.test.d.ts +2 -0
- package/dist/types/src/compact.test.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +70 -3
- package/dist/types/src/diagnostics.d.ts.map +1 -1
- package/dist/types/src/dsl/cli.d.ts +2 -0
- package/dist/types/src/dsl/cli.d.ts.map +1 -0
- package/dist/types/src/dsl/compile.d.ts +12 -0
- package/dist/types/src/dsl/compile.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.d.ts +45 -0
- package/dist/types/src/dsl/convert.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.test.d.ts +2 -0
- package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
- package/dist/types/src/dsl/dsl.test.d.ts +2 -0
- package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
- package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
- package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
- package/dist/types/src/dsl/index.d.ts +7 -0
- package/dist/types/src/dsl/index.d.ts.map +1 -0
- package/dist/types/src/dsl/parse.d.ts +49 -0
- package/dist/types/src/dsl/parse.d.ts.map +1 -0
- package/dist/types/src/dsl/print.d.ts +19 -0
- package/dist/types/src/dsl/print.d.ts.map +1 -0
- package/dist/types/src/dsl/semantic.test.d.ts +2 -0
- package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
- package/dist/types/src/dsl/vocabulary.d.ts +61 -0
- package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
- package/dist/types/src/dx-svg.d.ts +12 -0
- package/dist/types/src/dx-svg.d.ts.map +1 -0
- package/dist/types/src/dx-svg.test.d.ts +2 -0
- package/dist/types/src/dx-svg.test.d.ts.map +1 -0
- package/dist/types/src/extension/complete.d.ts +3 -0
- package/dist/types/src/extension/complete.d.ts.map +1 -0
- package/dist/types/src/extension/extension.test.d.ts +2 -0
- package/dist/types/src/extension/extension.test.d.ts.map +1 -0
- package/dist/types/src/extension/highlight.d.ts +14 -0
- package/dist/types/src/extension/highlight.d.ts.map +1 -0
- package/dist/types/src/extension/index.d.ts +6 -0
- package/dist/types/src/extension/index.d.ts.map +1 -0
- package/dist/types/src/extension/language.d.ts +13 -0
- package/dist/types/src/extension/language.d.ts.map +1 -0
- package/dist/types/src/extension/lint.d.ts +21 -0
- package/dist/types/src/extension/lint.d.ts.map +1 -0
- package/dist/types/src/extension/syntax.d.ts +3 -0
- package/dist/types/src/extension/syntax.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +14 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/mermaid-engine.d.ts +40 -1
- package/dist/types/src/mermaid-engine.d.ts.map +1 -1
- package/dist/types/src/mermaid.d.ts +3 -3
- package/dist/types/src/mermaid.d.ts.map +1 -1
- package/dist/types/src/nudge.d.ts +30 -0
- package/dist/types/src/nudge.d.ts.map +1 -0
- package/dist/types/src/nudge.test.d.ts +2 -0
- package/dist/types/src/nudge.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +4 -0
- package/dist/types/src/objective.d.ts.map +1 -1
- package/dist/types/src/ortho-router.d.ts +13 -1
- package/dist/types/src/ortho-router.d.ts.map +1 -1
- package/dist/types/src/ports.d.ts +40 -0
- package/dist/types/src/ports.d.ts.map +1 -0
- package/dist/types/src/ports.test.d.ts +2 -0
- package/dist/types/src/ports.test.d.ts.map +1 -0
- package/dist/types/src/relations.test.d.ts +2 -0
- package/dist/types/src/relations.test.d.ts.map +1 -0
- package/dist/types/src/rules.d.ts +30 -0
- package/dist/types/src/rules.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +99 -65
- package/dist/types/src/scene.d.ts.map +1 -1
- package/dist/types/src/score.d.ts +54 -0
- package/dist/types/src/score.d.ts.map +1 -0
- package/dist/types/src/score.test.d.ts +2 -0
- package/dist/types/src/score.test.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.d.ts +41 -0
- package/dist/types/src/semantic-engine.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.test.d.ts +2 -0
- package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
- package/dist/types/src/semantic-place.d.ts +81 -0
- package/dist/types/src/semantic-place.d.ts.map +1 -0
- package/dist/types/src/semantic-route.d.ts +133 -0
- package/dist/types/src/semantic-route.d.ts.map +1 -0
- package/dist/types/src/semantic.d.ts +164 -0
- package/dist/types/src/semantic.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -1
- package/dist/types/src/uml.d.ts +3 -5
- package/dist/types/src/uml.d.ts.map +1 -1
- package/dist/types/src/view.d.ts +53 -0
- package/dist/types/src/view.d.ts.map +1 -0
- package/dist/types/src/view.test.d.ts +2 -0
- package/dist/types/src/view.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -5
- package/rules/DIAGRAM.mdl +388 -0
- package/src/aesthetics.test.ts +89 -0
- package/src/aesthetics.ts +148 -0
- package/src/appeal.test.ts +144 -0
- package/src/appeal.ts +586 -0
- package/src/architecture.test.ts +94 -0
- package/src/architecture.ts +215 -0
- package/src/compact.test.ts +67 -0
- package/src/compact.ts +219 -0
- package/src/diagnostics.test.ts +207 -1
- package/src/diagnostics.ts +481 -37
- package/src/dsl/cli.ts +46 -0
- package/src/dsl/compile.ts +28 -0
- package/src/dsl/convert.test.ts +51 -0
- package/src/dsl/convert.ts +98 -0
- package/src/dsl/diagram.grammar +195 -0
- package/src/dsl/dsl.test.ts +286 -0
- package/src/dsl/gen/diagram.ts +22 -0
- package/src/dsl/index.ts +44 -0
- package/src/dsl/parse.ts +1054 -0
- package/src/dsl/print.ts +198 -0
- package/src/dsl/semantic.test.ts +200 -0
- package/src/dsl/vocabulary.ts +150 -0
- package/src/dx-svg.test.ts +44 -0
- package/src/dx-svg.ts +77 -0
- package/src/extension/complete.ts +105 -0
- package/src/extension/extension.test.ts +135 -0
- package/src/extension/highlight.ts +23 -0
- package/src/extension/index.ts +9 -0
- package/src/extension/language.ts +31 -0
- package/src/extension/lint.ts +60 -0
- package/src/extension/syntax.ts +62 -0
- package/src/index.ts +14 -0
- package/src/mermaid-engine.test.ts +6 -6
- package/src/mermaid-engine.ts +569 -119
- package/src/mermaid.test.ts +17 -1
- package/src/mermaid.ts +30 -18
- package/src/nudge.test.ts +155 -0
- package/src/nudge.ts +385 -0
- package/src/objective.test.ts +2 -0
- package/src/objective.ts +17 -1
- package/src/ortho-router.ts +240 -80
- package/src/ports.test.ts +157 -0
- package/src/ports.ts +186 -0
- package/src/relations.test.ts +98 -0
- package/src/rules.ts +117 -0
- package/src/scene.ts +64 -3
- package/src/score.test.ts +64 -0
- package/src/score.ts +130 -0
- package/src/semantic-engine.test.ts +635 -0
- package/src/semantic-engine.ts +888 -0
- package/src/semantic-place.ts +1508 -0
- package/src/semantic-route.ts +1136 -0
- package/src/semantic.ts +278 -0
- package/src/typings.d.ts +8 -0
- package/src/uml-grid.ts +6 -1
- package/src/uml.test.ts +5 -1
- package/src/uml.ts +24 -11
- package/src/view.test.ts +139 -0
- package/src/view.ts +376 -0
package/src/mermaid.test.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, test } from 'vitest';
|
|
6
6
|
|
|
7
|
-
import { compile, parse } from './mermaid.ts';
|
|
7
|
+
import { compile, markers, parse } from './mermaid.ts';
|
|
8
8
|
import type * as Scene from './scene.ts';
|
|
9
9
|
|
|
10
10
|
/** Exercises node declarations, a labelled-blank subgraph, fan-out, and a `C <-> Y` cycle. */
|
|
@@ -55,6 +55,22 @@ describe('mermaid', () => {
|
|
|
55
55
|
]);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
test('reads the relation tokens as kinds with their UML markers', ({ expect }) => {
|
|
59
|
+
const graph = parse(
|
|
60
|
+
['flowchart TB', ' B ..|> A', ' C -.-> D', ' E o--> F', ' G --{ H', ' I --|> J', ' K --> L'].join('\n'),
|
|
61
|
+
);
|
|
62
|
+
expect(graph.edges.map(({ kind }) => kind)).toEqual([
|
|
63
|
+
'implements',
|
|
64
|
+
'creates',
|
|
65
|
+
'contains',
|
|
66
|
+
'hasMany',
|
|
67
|
+
'inheritance',
|
|
68
|
+
'reference',
|
|
69
|
+
]);
|
|
70
|
+
expect(markers('implements')).toEqual({ relation: 'implementation', stroke: 'dashed' });
|
|
71
|
+
expect(markers('creates')).toEqual({ relation: 'dependency', stroke: 'dashed' });
|
|
72
|
+
});
|
|
73
|
+
|
|
58
74
|
test('compiles to one object per node plus a frame and an edge object', ({ expect }) => {
|
|
59
75
|
const objects = objectsOf(compile(FLOWCHART));
|
|
60
76
|
|
package/src/mermaid.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type MermaidGroup = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
/** Relationship kind, from the edge token; drawn with the UML end markers in `markers`. */
|
|
32
|
-
export type RelationKind = 'reference' | 'inheritance' | 'hasMany' | 'contains';
|
|
32
|
+
export type RelationKind = 'reference' | 'inheritance' | 'implements' | 'hasMany' | 'contains' | 'creates';
|
|
33
33
|
|
|
34
34
|
export type MermaidEdge = {
|
|
35
35
|
from: string;
|
|
@@ -39,23 +39,30 @@ export type MermaidEdge = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Edge tokens: mermaid's own arrows read as references
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Edge tokens: mermaid's own arrows read as references, except the dotted `-.->`, which reads as
|
|
43
|
+
* creation (UML's dashed «create» dependency). The classDiagram-style `--|>` (hollow triangle) and
|
|
44
|
+
* `..|>` (dashed hollow triangle) and ER-style `--{` (crow's foot) and `o-->` (circle at the owner)
|
|
45
|
+
* extend the flowchart grammar with the UML kinds, at the cost of mermaid.js rejecting those lines.
|
|
45
46
|
*/
|
|
46
47
|
const EDGE_KINDS: Record<string, RelationKind> = {
|
|
47
48
|
'-->': 'reference',
|
|
48
49
|
'---': 'reference',
|
|
49
|
-
'-.->': 'reference',
|
|
50
50
|
'==>': 'reference',
|
|
51
|
+
'-.->': 'creates',
|
|
51
52
|
'--|>': 'inheritance',
|
|
53
|
+
'..|>': 'implements',
|
|
52
54
|
'--{': 'hasMany',
|
|
53
55
|
'o-->': 'contains',
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
/** Reference-render labels for the UML kinds, used when the edge carries none of its own. */
|
|
57
|
-
const UML_LABELS: Record<string, string> = {
|
|
58
|
-
|
|
59
|
+
const UML_LABELS: Record<string, string> = {
|
|
60
|
+
'--|>': 'extends',
|
|
61
|
+
'..|>': 'implements',
|
|
62
|
+
'--{': 'has many',
|
|
63
|
+
'o-->': 'contains',
|
|
64
|
+
};
|
|
65
|
+
const UML_EDGE = /^(\s*\S+)\s*(o-->|--\|>|\.\.\|>|--\{)\s*(?:\|(.*?)\|\s*)?(\S+)\s*$/;
|
|
59
66
|
|
|
60
67
|
/**
|
|
61
68
|
* Rewrite the UML edge tokens into mermaid-legal labelled arrows, for rendering the source with
|
|
@@ -72,15 +79,19 @@ export const toStandard = (source: string): string =>
|
|
|
72
79
|
})
|
|
73
80
|
.join('\n');
|
|
74
81
|
|
|
75
|
-
/**
|
|
76
|
-
export const markers = (kind: RelationKind): Pick<Scene.Arrow, '
|
|
82
|
+
/** The scene relation a relationship kind draws as, with the dash its line needs; a plain reference stays a plain arrow. */
|
|
83
|
+
export const markers = (kind: RelationKind): Pick<Scene.Arrow, 'relation' | 'stroke'> => {
|
|
77
84
|
switch (kind) {
|
|
78
85
|
case 'inheritance':
|
|
79
|
-
return {
|
|
86
|
+
return { relation: 'inheritance' };
|
|
87
|
+
case 'implements':
|
|
88
|
+
return { relation: 'implementation', stroke: 'dashed' };
|
|
80
89
|
case 'hasMany':
|
|
81
|
-
return {
|
|
90
|
+
return { relation: 'one-to-many' };
|
|
82
91
|
case 'contains':
|
|
83
|
-
return {
|
|
92
|
+
return { relation: 'aggregation' };
|
|
93
|
+
case 'creates':
|
|
94
|
+
return { relation: 'dependency', stroke: 'dashed' };
|
|
84
95
|
default:
|
|
85
96
|
return {};
|
|
86
97
|
}
|
|
@@ -97,8 +108,9 @@ const DIRECTIONS: Direction[] = ['TB', 'BT', 'LR', 'RL'];
|
|
|
97
108
|
|
|
98
109
|
// `A[Label]`, `A(Label)`, `A{Label}` or a bare `A`.
|
|
99
110
|
const NODE = /^([A-Za-z0-9_-]+)(?:\[(.*?)\]|\((.*?)\)|\{(.*?)\})?$/;
|
|
100
|
-
// `A --> B`, `A-->|label|B`, `A --- B`, plus the UML kinds `B --|> A`, `X --{ Y
|
|
101
|
-
|
|
111
|
+
// `A --> B`, `A-->|label|B`, `A --- B`, `A -.-> B`, plus the UML kinds `B --|> A`, `B ..|> A`, `X --{ Y`
|
|
112
|
+
// and `A o--> B`.
|
|
113
|
+
const EDGE = /^(.+?)\s*(o-->|--\|>|\.\.\|>|--\{|-->|---|-\.->|==>)\s*(?:\|(.*?)\|\s*)?(.+)$/;
|
|
102
114
|
const SUBGRAPH = /^subgraph\s+([A-Za-z0-9_-]+)(?:\s*\[(.*?)\])?\s*$/;
|
|
103
115
|
|
|
104
116
|
// `%% ref A packages/core/echo` — a comment to mermaid proper, so sources stay portable.
|
|
@@ -114,7 +126,7 @@ export const parse = (source: string): MermaidGraph => {
|
|
|
114
126
|
let direction: Direction = 'TB';
|
|
115
127
|
const stack: string[] = [];
|
|
116
128
|
|
|
117
|
-
const
|
|
129
|
+
const declareNode = (token: string): string | undefined => {
|
|
118
130
|
const match = NODE.exec(token.trim());
|
|
119
131
|
if (!match) {
|
|
120
132
|
return undefined;
|
|
@@ -172,8 +184,8 @@ export const parse = (source: string): MermaidGraph => {
|
|
|
172
184
|
const edge = EDGE.exec(line);
|
|
173
185
|
if (edge) {
|
|
174
186
|
const [, from, token, label, to] = edge;
|
|
175
|
-
const fromId =
|
|
176
|
-
const toId =
|
|
187
|
+
const fromId = declareNode(from);
|
|
188
|
+
const toId = declareNode(to);
|
|
177
189
|
if (fromId && toId) {
|
|
178
190
|
edges.push({
|
|
179
191
|
from: fromId,
|
|
@@ -185,7 +197,7 @@ export const parse = (source: string): MermaidGraph => {
|
|
|
185
197
|
continue;
|
|
186
198
|
}
|
|
187
199
|
|
|
188
|
-
|
|
200
|
+
declareNode(line);
|
|
189
201
|
}
|
|
190
202
|
|
|
191
203
|
// Directives may precede or follow the node they name.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { nudge } from './nudge.ts';
|
|
8
|
+
import type * as Scene from './scene.ts';
|
|
9
|
+
import { type Rect } from './uml-grid.ts';
|
|
10
|
+
|
|
11
|
+
const orthogonal = (points: readonly Scene.Point[]) =>
|
|
12
|
+
points.slice(1).every((point, index) => point.x === points[index].x || point.y === points[index].y);
|
|
13
|
+
|
|
14
|
+
/** Whether two orthogonal polylines share a collinear stretch of positive length. */
|
|
15
|
+
const overlapping = (first: readonly Scene.Point[], second: readonly Scene.Point[]) => {
|
|
16
|
+
const segments = (points: readonly Scene.Point[]) =>
|
|
17
|
+
points.slice(1).map((end, index) => [points[index], end] as const);
|
|
18
|
+
return segments(first).some(([a0, a1]) =>
|
|
19
|
+
segments(second).some(([b0, b1]) => {
|
|
20
|
+
if (a0.x === a1.x && b0.x === b1.x && a0.x === b0.x) {
|
|
21
|
+
const lo = Math.max(Math.min(a0.y, a1.y), Math.min(b0.y, b1.y));
|
|
22
|
+
const hi = Math.min(Math.max(a0.y, a1.y), Math.max(b0.y, b1.y));
|
|
23
|
+
return hi - lo > 0;
|
|
24
|
+
}
|
|
25
|
+
if (a0.y === a1.y && b0.y === b1.y && a0.y === b0.y) {
|
|
26
|
+
const lo = Math.max(Math.min(a0.x, a1.x), Math.min(b0.x, b1.x));
|
|
27
|
+
const hi = Math.min(Math.max(a0.x, a1.x), Math.max(b0.x, b1.x));
|
|
28
|
+
return hi - lo > 0;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const onBorder = (point: Scene.Point, rect: Rect) =>
|
|
36
|
+
((point.y === rect.y || point.y === rect.y + rect.h) && point.x >= rect.x && point.x <= rect.x + rect.w) ||
|
|
37
|
+
((point.x === rect.x || point.x === rect.x + rect.w) && point.y >= rect.y && point.y <= rect.y + rect.h);
|
|
38
|
+
|
|
39
|
+
describe('nudge', () => {
|
|
40
|
+
const source: Rect = { x: 0, y: 0, w: 128, h: 64 };
|
|
41
|
+
const left: Rect = { x: 0, y: 200, w: 128, h: 64 };
|
|
42
|
+
const right: Rect = { x: 200, y: 200, w: 128, h: 64 };
|
|
43
|
+
// A fork: both leave the same port and run down x = 64 together before one turns right.
|
|
44
|
+
const straight = {
|
|
45
|
+
points: [
|
|
46
|
+
{ x: 64, y: 64 },
|
|
47
|
+
{ x: 64, y: 200 },
|
|
48
|
+
],
|
|
49
|
+
source,
|
|
50
|
+
target: left,
|
|
51
|
+
};
|
|
52
|
+
const turning = {
|
|
53
|
+
points: [
|
|
54
|
+
{ x: 64, y: 64 },
|
|
55
|
+
{ x: 64, y: 132 },
|
|
56
|
+
{ x: 264, y: 132 },
|
|
57
|
+
{ x: 264, y: 200 },
|
|
58
|
+
],
|
|
59
|
+
source,
|
|
60
|
+
target: right,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test('two edges sharing a vertical run are separated and stay orthogonal', ({ expect }) => {
|
|
64
|
+
const [first, second] = nudge([straight, turning], { obstacles: [source, left, right] });
|
|
65
|
+
expect(orthogonal(first)).toBe(true);
|
|
66
|
+
expect(orthogonal(second)).toBe(true);
|
|
67
|
+
// The edge that turns right takes the right-hand lane, so its turn-off does not cut the other.
|
|
68
|
+
expect(second[0].x - first[0].x).toBeGreaterThanOrEqual(8);
|
|
69
|
+
expect(second[1].x).toBe(second[0].x);
|
|
70
|
+
expect(onBorder(first[0], source) && onBorder(second[0], source)).toBe(true);
|
|
71
|
+
expect(first[first.length - 1]).toEqual({ x: first[0].x, y: 200 });
|
|
72
|
+
expect(second[second.length - 1]).toEqual({ x: 264, y: 200 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a fork sharing a vertical run and then a horizontal run is fully separated', ({ expect }) => {
|
|
76
|
+
const far: Rect = { x: 400, y: 200, w: 128, h: 64 };
|
|
77
|
+
const near = {
|
|
78
|
+
points: [
|
|
79
|
+
{ x: 64, y: 64 },
|
|
80
|
+
{ x: 64, y: 132 },
|
|
81
|
+
{ x: 264, y: 132 },
|
|
82
|
+
{ x: 264, y: 200 },
|
|
83
|
+
],
|
|
84
|
+
source,
|
|
85
|
+
target: right,
|
|
86
|
+
};
|
|
87
|
+
const away = {
|
|
88
|
+
points: [
|
|
89
|
+
{ x: 64, y: 64 },
|
|
90
|
+
{ x: 64, y: 132 },
|
|
91
|
+
{ x: 464, y: 132 },
|
|
92
|
+
{ x: 464, y: 200 },
|
|
93
|
+
],
|
|
94
|
+
source,
|
|
95
|
+
target: far,
|
|
96
|
+
};
|
|
97
|
+
const [first, second] = nudge([near, away], { obstacles: [source, right, far] });
|
|
98
|
+
expect(orthogonal(first) && orthogonal(second)).toBe(true);
|
|
99
|
+
expect(overlapping(first, second)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a fixed bus never moves; the edge lying on it is pushed off', ({ expect }) => {
|
|
103
|
+
const bus = [
|
|
104
|
+
{ x: 100, y: 100 },
|
|
105
|
+
{ x: 200, y: 100 },
|
|
106
|
+
];
|
|
107
|
+
const edge = {
|
|
108
|
+
points: [
|
|
109
|
+
{ x: 64, y: 64 },
|
|
110
|
+
{ x: 64, y: 100 },
|
|
111
|
+
{ x: 264, y: 100 },
|
|
112
|
+
{ x: 264, y: 200 },
|
|
113
|
+
],
|
|
114
|
+
source,
|
|
115
|
+
target: right,
|
|
116
|
+
};
|
|
117
|
+
const fixed = [bus];
|
|
118
|
+
const [moved] = nudge([edge], { obstacles: [source, right], fixed });
|
|
119
|
+
expect(fixed[0]).toEqual(bus);
|
|
120
|
+
expect(Math.abs(moved[1].y - 100)).toBeGreaterThanOrEqual(8);
|
|
121
|
+
expect(moved[0]).toEqual({ x: 64, y: 64 });
|
|
122
|
+
expect(orthogonal(moved)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('ports stay put when separation would leave the box side', ({ expect }) => {
|
|
126
|
+
const narrow: Rect = { x: 0, y: 0, w: 16, h: 64 };
|
|
127
|
+
const below: Rect = { x: 0, y: 200, w: 16, h: 64 };
|
|
128
|
+
const path = {
|
|
129
|
+
points: [
|
|
130
|
+
{ x: 8, y: 64 },
|
|
131
|
+
{ x: 8, y: 200 },
|
|
132
|
+
],
|
|
133
|
+
source: narrow,
|
|
134
|
+
target: below,
|
|
135
|
+
};
|
|
136
|
+
const result = nudge([path, path], { obstacles: [narrow, below] });
|
|
137
|
+
expect(result).toEqual([path.points, path.points]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('a run is never pushed through a box', ({ expect }) => {
|
|
141
|
+
// Both lanes beside the shared run are blocked by boxes hugging it.
|
|
142
|
+
const wall = (x: number): Rect => ({ x, y: 80, w: 40, h: 100 });
|
|
143
|
+
const edge = (y: number) => ({
|
|
144
|
+
points: [
|
|
145
|
+
{ x: 64, y: 64 },
|
|
146
|
+
{ x: 64, y: 200 },
|
|
147
|
+
],
|
|
148
|
+
source,
|
|
149
|
+
target: { x: 0, y, w: 128, h: 64 },
|
|
150
|
+
});
|
|
151
|
+
const obstacles = [source, wall(20), wall(68), { x: 0, y: 200, w: 128, h: 64 }];
|
|
152
|
+
const result = nudge([edge(200), edge(200)], { obstacles });
|
|
153
|
+
expect(result.map((points) => points[0].x)).toEqual([64, 64]);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/nudge.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import type * as Scene from './scene.ts';
|
|
6
|
+
import { type Rect } from './uml-grid.ts';
|
|
7
|
+
|
|
8
|
+
type Point = Scene.Point;
|
|
9
|
+
type Axis = 'h' | 'v';
|
|
10
|
+
|
|
11
|
+
/** One axis-aligned segment of a line: `points[start] → points[start + 1]`. */
|
|
12
|
+
type Run = { owner: number; start: number; axis: Axis; coord: number; lo: number; hi: number };
|
|
13
|
+
|
|
14
|
+
/** A routed connector: its orthogonal polyline and the boxes its ends are attached to. */
|
|
15
|
+
export type NudgePath = {
|
|
16
|
+
points: readonly Point[];
|
|
17
|
+
/** Box the first point sits on; without it the start port is never moved. */
|
|
18
|
+
source?: Rect;
|
|
19
|
+
/** Box the last point sits on; without it the end port is never moved. */
|
|
20
|
+
target?: Rect;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type NudgeOptions = {
|
|
24
|
+
/** Gap between separated parallel runs (default 8). */
|
|
25
|
+
spacing?: number;
|
|
26
|
+
/** Boxes no run may be pushed through. */
|
|
27
|
+
obstacles?: readonly Rect[];
|
|
28
|
+
/** Lines that never move but that paths must not be pushed onto (e.g. an inheritance bus). */
|
|
29
|
+
fixed?: readonly (readonly Point[])[];
|
|
30
|
+
/** Minimum distance between a moved port and its box's corner (default `spacing`). */
|
|
31
|
+
margin?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const EPSILON = 0.5;
|
|
35
|
+
|
|
36
|
+
const along = (axis: Axis, point: Point) => (axis === 'v' ? point.y : point.x);
|
|
37
|
+
const across = (axis: Axis, point: Point) => (axis === 'v' ? point.x : point.y);
|
|
38
|
+
const sign = (value: number) => (value > EPSILON ? 1 : value < -EPSILON ? -1 : 0);
|
|
39
|
+
|
|
40
|
+
/** Drops repeated waypoints and the midpoints of straight runs, so each run is one maximal segment. */
|
|
41
|
+
const simplify = (points: readonly Point[]): Point[] => {
|
|
42
|
+
const distinct = points.filter(
|
|
43
|
+
(point, index) =>
|
|
44
|
+
index === 0 ||
|
|
45
|
+
Math.abs(point.x - points[index - 1].x) >= EPSILON ||
|
|
46
|
+
Math.abs(point.y - points[index - 1].y) >= EPSILON,
|
|
47
|
+
);
|
|
48
|
+
return distinct.filter((point, index) => {
|
|
49
|
+
if (index === 0 || index === distinct.length - 1) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
const [previous, next] = [distinct[index - 1], distinct[index + 1]];
|
|
53
|
+
const vertical = Math.abs(previous.x - point.x) < EPSILON && Math.abs(point.x - next.x) < EPSILON;
|
|
54
|
+
const horizontal = Math.abs(previous.y - point.y) < EPSILON && Math.abs(point.y - next.y) < EPSILON;
|
|
55
|
+
return !vertical && !horizontal;
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const runsOf = (points: readonly Point[], owner: number): Run[] =>
|
|
60
|
+
points.slice(0, -1).flatMap((from, start): Run[] => {
|
|
61
|
+
const to = points[start + 1];
|
|
62
|
+
const axis: Axis | undefined =
|
|
63
|
+
Math.abs(from.x - to.x) < EPSILON ? 'v' : Math.abs(from.y - to.y) < EPSILON ? 'h' : undefined;
|
|
64
|
+
if (!axis) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const [a, b] = [along(axis, from), along(axis, to)];
|
|
68
|
+
return [{ owner, start, axis, coord: across(axis, from), lo: Math.min(a, b), hi: Math.max(a, b) }];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const shared = (left: Run, right: Run): boolean =>
|
|
72
|
+
left.axis === right.axis &&
|
|
73
|
+
Math.abs(left.coord - right.coord) < EPSILON &&
|
|
74
|
+
Math.min(left.hi, right.hi) - Math.max(left.lo, right.lo) > EPSILON;
|
|
75
|
+
|
|
76
|
+
const crosses = (left: Run, right: Run): boolean => {
|
|
77
|
+
if (left.axis === right.axis) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
right.coord > left.lo + EPSILON &&
|
|
82
|
+
right.coord < left.hi - EPSILON &&
|
|
83
|
+
left.coord > right.lo + EPSILON &&
|
|
84
|
+
left.coord < right.hi - EPSILON
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Length of the run inside the rect's interior; a run along a border or into a port is free. */
|
|
89
|
+
const interiorRun = (run: Run, rect: Rect): number => {
|
|
90
|
+
const [crossLo, crossHi, alongLo, alongHi] =
|
|
91
|
+
run.axis === 'v'
|
|
92
|
+
? [rect.x, rect.x + rect.w, rect.y, rect.y + rect.h]
|
|
93
|
+
: [rect.y, rect.y + rect.h, rect.x, rect.x + rect.w];
|
|
94
|
+
if (run.coord <= crossLo + EPSILON || run.coord >= crossHi - EPSILON) {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
return Math.max(0, Math.min(run.hi, alongHi - EPSILON) - Math.max(run.lo, alongLo + EPSILON));
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pairs `{changed, other}` related by `related`: per line pair, or per run pair with `perRun`, which
|
|
102
|
+
* overlaps need so that separating one of two channels two lines share still lowers the count.
|
|
103
|
+
*/
|
|
104
|
+
const pairCount = (
|
|
105
|
+
lines: readonly Point[][],
|
|
106
|
+
changed: ReadonlySet<number>,
|
|
107
|
+
related: (a: Run, b: Run) => boolean,
|
|
108
|
+
perRun = false,
|
|
109
|
+
) => {
|
|
110
|
+
const runs = lines.map((points, owner) => runsOf(points, owner));
|
|
111
|
+
let count = 0;
|
|
112
|
+
for (const owner of changed) {
|
|
113
|
+
runs.forEach((others, other) => {
|
|
114
|
+
if (other === owner || (changed.has(other) && other < owner)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (perRun) {
|
|
118
|
+
for (const run of runs[owner]) {
|
|
119
|
+
count += others.filter((candidate) => related(run, candidate)).length;
|
|
120
|
+
}
|
|
121
|
+
} else if (runs[owner].some((run) => others.some((candidate) => related(run, candidate)))) {
|
|
122
|
+
count++;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return count;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Collinear runs of different lines that overlap, grouped transitively along each shared line. */
|
|
130
|
+
const channelsOf = (lines: readonly Point[][], movable: number): Run[][] => {
|
|
131
|
+
const runs = lines
|
|
132
|
+
.flatMap((points, owner) => runsOf(points, owner))
|
|
133
|
+
.sort((a, b) =>
|
|
134
|
+
a.axis === b.axis
|
|
135
|
+
? Math.abs(a.coord - b.coord) < EPSILON
|
|
136
|
+
? a.lo - b.lo
|
|
137
|
+
: a.coord - b.coord
|
|
138
|
+
: a.axis < b.axis
|
|
139
|
+
? -1
|
|
140
|
+
: 1,
|
|
141
|
+
);
|
|
142
|
+
const channels: Run[][] = [];
|
|
143
|
+
let current: Run[] = [];
|
|
144
|
+
let reach = -Infinity;
|
|
145
|
+
const flush = () => {
|
|
146
|
+
const owners = new Set(current.map((run) => run.owner));
|
|
147
|
+
// A line that doubles back on itself, or a channel of fixed lines alone, is not ours to separate.
|
|
148
|
+
if (owners.size > 1 && owners.size === current.length && current.some((run) => run.owner < movable)) {
|
|
149
|
+
channels.push(current);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
for (const run of runs) {
|
|
153
|
+
const head = current[0];
|
|
154
|
+
if (head && head.axis === run.axis && Math.abs(head.coord - run.coord) < EPSILON && run.lo < reach - EPSILON) {
|
|
155
|
+
current.push(run);
|
|
156
|
+
reach = Math.max(reach, run.hi);
|
|
157
|
+
} else {
|
|
158
|
+
flush();
|
|
159
|
+
current = [run];
|
|
160
|
+
reach = run.hi;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
flush();
|
|
164
|
+
return channels;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Side (−1, 0, +1 across the run) the line turns toward at the run's low or high end; 0 at a terminal. */
|
|
168
|
+
const turn = (lines: readonly Point[][], run: Run, end: 'lo' | 'hi'): number => {
|
|
169
|
+
const points = lines[run.owner];
|
|
170
|
+
const startIsLow = along(run.axis, points[run.start]) < along(run.axis, points[run.start + 1]);
|
|
171
|
+
const neighbor = (end === 'lo') === startIsLow ? run.start - 1 : run.start + 2;
|
|
172
|
+
return neighbor < 0 || neighbor >= points.length ? 0 : sign(across(run.axis, points[neighbor]) - run.coord);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Order across the channel that keeps each run's turn-offs from cutting the others: a run that
|
|
177
|
+
* leaves the shared stretch early sits on the side it turns toward.
|
|
178
|
+
*/
|
|
179
|
+
const order = (lines: readonly Point[][], channel: readonly Run[]): Run[] => {
|
|
180
|
+
const compare = (left: Run, right: Run): number => {
|
|
181
|
+
let vote = 0;
|
|
182
|
+
if (left.lo > right.lo + EPSILON) {
|
|
183
|
+
vote += turn(lines, left, 'lo');
|
|
184
|
+
} else if (right.lo > left.lo + EPSILON) {
|
|
185
|
+
vote -= turn(lines, right, 'lo');
|
|
186
|
+
} else {
|
|
187
|
+
vote += sign(turn(lines, left, 'lo') - turn(lines, right, 'lo'));
|
|
188
|
+
}
|
|
189
|
+
if (left.hi < right.hi - EPSILON) {
|
|
190
|
+
vote += turn(lines, left, 'hi');
|
|
191
|
+
} else if (right.hi < left.hi - EPSILON) {
|
|
192
|
+
vote -= turn(lines, right, 'hi');
|
|
193
|
+
} else {
|
|
194
|
+
vote += sign(turn(lines, left, 'hi') - turn(lines, right, 'hi'));
|
|
195
|
+
}
|
|
196
|
+
return vote !== 0 ? vote : left.owner - right.owner;
|
|
197
|
+
};
|
|
198
|
+
return [...channel].sort(compare);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Shift one run across its axis by `offset`, dragging the adjacent waypoints with it so the line
|
|
203
|
+
* stays orthogonal; undefined when that would detach a port, reverse a neighbour or crush an end stub.
|
|
204
|
+
*/
|
|
205
|
+
const shift = (
|
|
206
|
+
points: readonly Point[],
|
|
207
|
+
run: Run,
|
|
208
|
+
offset: number,
|
|
209
|
+
path: NudgePath | undefined,
|
|
210
|
+
spacing: number,
|
|
211
|
+
margin: number,
|
|
212
|
+
): Point[] | undefined => {
|
|
213
|
+
const move = (point: Point): Point =>
|
|
214
|
+
run.axis === 'v' ? { x: point.x + offset, y: point.y } : { x: point.x, y: point.y + offset };
|
|
215
|
+
const next = points.map((point, index) => (index === run.start || index === run.start + 1 ? move(point) : point));
|
|
216
|
+
const last = points.length - 1;
|
|
217
|
+
for (const [index, rect] of [
|
|
218
|
+
[0, path?.source],
|
|
219
|
+
[last, path?.target],
|
|
220
|
+
] as const) {
|
|
221
|
+
if (index !== run.start && index !== run.start + 1) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// A port slides along its own side only: the side must lie across the run, and the port must stay clear of the corners.
|
|
225
|
+
const port = points[index];
|
|
226
|
+
if (!rect) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const [sideLo, sideHi, crossLo, crossHi] =
|
|
230
|
+
run.axis === 'v'
|
|
231
|
+
? [rect.y, rect.y + rect.h, rect.x, rect.x + rect.w]
|
|
232
|
+
: [rect.x, rect.x + rect.w, rect.y, rect.y + rect.h];
|
|
233
|
+
const onSide =
|
|
234
|
+
Math.abs(along(run.axis, port) - sideLo) < EPSILON || Math.abs(along(run.axis, port) - sideHi) < EPSILON;
|
|
235
|
+
const moved = across(run.axis, next[index]);
|
|
236
|
+
if (!onSide || moved < crossLo + margin - EPSILON || moved > crossHi - margin + EPSILON) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const segment of [run.start - 1, run.start + 1]) {
|
|
241
|
+
if (segment < 0 || segment >= last) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const before = across(run.axis, points[segment + 1]) - across(run.axis, points[segment]);
|
|
245
|
+
const after = across(run.axis, next[segment + 1]) - across(run.axis, next[segment]);
|
|
246
|
+
if (sign(after) !== 0 && sign(after) !== sign(before)) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
// End stubs carry the arrowhead and the port's exit direction, so they may not shrink below one step.
|
|
250
|
+
const terminal = segment === 0 || segment === last - 1;
|
|
251
|
+
if (terminal && Math.abs(after) < Math.min(Math.abs(before), spacing) - EPSILON) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return next;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
type Candidate = { lines: Point[][]; overlaps: number; crossings: number; reversed: number; travel: number };
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Separates the runs of one channel by `spacing`, preferring the channel's order; tries every window
|
|
262
|
+
* of offsets (and single moves as a fallback) and keeps the one that resolves the most overlaps
|
|
263
|
+
* without adding crossings or cutting through a box.
|
|
264
|
+
*/
|
|
265
|
+
const separate = (
|
|
266
|
+
lines: readonly Point[][],
|
|
267
|
+
channel: readonly Run[],
|
|
268
|
+
paths: readonly NudgePath[],
|
|
269
|
+
obstacles: readonly Rect[],
|
|
270
|
+
spacing: number,
|
|
271
|
+
margin: number,
|
|
272
|
+
): Point[][] | undefined => {
|
|
273
|
+
const movable = paths.length;
|
|
274
|
+
const fixed = channel.filter((run) => run.owner >= movable);
|
|
275
|
+
if (fixed.length > 1) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const sorted = order(lines, channel);
|
|
279
|
+
const assignments: { offsets: Map<Run, number>; reversed: number }[] = [];
|
|
280
|
+
[sorted, [...sorted].reverse()].forEach((ordered, reversed) => {
|
|
281
|
+
const anchor = ordered.findIndex((run) => run.owner >= movable);
|
|
282
|
+
const shifts = anchor >= 0 ? [-anchor] : ordered.map((_, index) => -index);
|
|
283
|
+
for (const base of shifts) {
|
|
284
|
+
assignments.push({
|
|
285
|
+
offsets: new Map(ordered.map((run, index) => [run, (index + base) * spacing])),
|
|
286
|
+
reversed,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
for (const run of channel) {
|
|
291
|
+
if (run.owner < movable) {
|
|
292
|
+
for (const steps of [1, -1, 2, -2]) {
|
|
293
|
+
assignments.push({ offsets: new Map([[run, steps * spacing]]), reversed: 1 });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const changedOf = (offsets: Map<Run, number>) =>
|
|
299
|
+
new Set([...offsets].filter(([, offset]) => offset !== 0).map(([run]) => run.owner));
|
|
300
|
+
const obstructed = (candidate: readonly Point[][], owners: ReadonlySet<number>) =>
|
|
301
|
+
[...owners].reduce(
|
|
302
|
+
(total, owner) =>
|
|
303
|
+
total +
|
|
304
|
+
runsOf(candidate[owner], owner).filter((run) => obstacles.some((rect) => interiorRun(run, rect) > EPSILON))
|
|
305
|
+
.length,
|
|
306
|
+
0,
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
let best: Candidate | undefined;
|
|
310
|
+
for (const { offsets, reversed } of assignments) {
|
|
311
|
+
const changed = changedOf(offsets);
|
|
312
|
+
if (changed.size === 0 || [...changed].some((owner) => owner >= movable)) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const candidate = [...lines];
|
|
316
|
+
let valid = true;
|
|
317
|
+
for (const [run, offset] of offsets) {
|
|
318
|
+
if (offset === 0) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const moved = shift(lines[run.owner], run, offset, paths[run.owner], spacing, margin);
|
|
322
|
+
if (!moved) {
|
|
323
|
+
valid = false;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
candidate[run.owner] = moved;
|
|
327
|
+
}
|
|
328
|
+
if (!valid) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const overlaps = pairCount(candidate, changed, shared, true);
|
|
332
|
+
const crossings = pairCount(candidate, changed, crosses);
|
|
333
|
+
if (
|
|
334
|
+
overlaps >= pairCount(lines, changed, shared, true) ||
|
|
335
|
+
crossings > pairCount(lines, changed, crosses) ||
|
|
336
|
+
obstructed(candidate, changed) > obstructed(lines, changed)
|
|
337
|
+
) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const travel = [...offsets.values()].reduce((total, offset) => total + Math.abs(offset), 0);
|
|
341
|
+
const better =
|
|
342
|
+
!best ||
|
|
343
|
+
overlaps < best.overlaps ||
|
|
344
|
+
(overlaps === best.overlaps &&
|
|
345
|
+
(crossings < best.crossings ||
|
|
346
|
+
(crossings === best.crossings &&
|
|
347
|
+
(reversed < best.reversed || (reversed === best.reversed && travel < best.travel)))));
|
|
348
|
+
if (better) {
|
|
349
|
+
best = { lines: candidate, overlaps, crossings, reversed, travel };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return best?.lines.map((points, owner) => (points === lines[owner] ? points : simplify(points)));
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Nudging (Wybrow, Marriott & Stuckey 2010): distinct connectors that share a collinear run are
|
|
357
|
+
* pulled apart across it by `spacing`, ordered so their turn-offs do not cross, with ports sliding
|
|
358
|
+
* along their box side. Fixed lines (a deliberate bus) never move. Paths that need no change come
|
|
359
|
+
* back as given.
|
|
360
|
+
*/
|
|
361
|
+
export const nudge = (paths: readonly NudgePath[], options: NudgeOptions = {}): Point[][] => {
|
|
362
|
+
const { spacing = 8, obstacles = [], fixed = [], margin = spacing } = options;
|
|
363
|
+
let lines: Point[][] = [...paths.map((path) => simplify(path.points)), ...fixed.map((points) => [...points])];
|
|
364
|
+
const touched = new Set<number>();
|
|
365
|
+
const attempted = new Set<string>();
|
|
366
|
+
const signature = (channel: readonly Run[]) =>
|
|
367
|
+
`${channel[0].axis}:${Math.round(channel[0].coord)}:${channel
|
|
368
|
+
.map((run) => `${run.owner}@${Math.round(run.lo)}-${Math.round(run.hi)}`)
|
|
369
|
+
.sort()
|
|
370
|
+
.join(',')}`;
|
|
371
|
+
// Each separation strictly lowers the overlap count and a failed channel is never retried, so this terminates.
|
|
372
|
+
for (;;) {
|
|
373
|
+
const channel = channelsOf(lines, paths.length).find((candidate) => !attempted.has(signature(candidate)));
|
|
374
|
+
if (!channel) {
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
attempted.add(signature(channel));
|
|
378
|
+
const next = separate(lines, channel, paths, obstacles, spacing, margin);
|
|
379
|
+
if (next) {
|
|
380
|
+
next.forEach((points, owner) => points !== lines[owner] && touched.add(owner));
|
|
381
|
+
lines = next;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return paths.map((path, owner) => (touched.has(owner) ? lines[owner] : [...path.points]));
|
|
385
|
+
};
|
package/src/objective.test.ts
CHANGED