@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/objective.ts
CHANGED
|
@@ -112,6 +112,22 @@ export const crossings: CostTerm = {
|
|
|
112
112
|
measure: ({ report }) => report.metrics.crossings,
|
|
113
113
|
};
|
|
114
114
|
|
|
115
|
+
/** Labels overlapping other labels or boxes; as costly as a crossing, since either hides what the reader needs. */
|
|
116
|
+
export const textOverlaps: CostTerm = {
|
|
117
|
+
id: 'text-overlaps',
|
|
118
|
+
description: 'Labels overlapping another label or a box.',
|
|
119
|
+
weight: 3,
|
|
120
|
+
measure: ({ report }) => report.metrics.textOverlaps,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Connector pairs sharing a stretch of line; as costly as a crossing, since the reader cannot pair their ends. */
|
|
124
|
+
export const edgeOverlaps: CostTerm = {
|
|
125
|
+
id: 'edge-overlaps',
|
|
126
|
+
description: 'Connector pairs that run along the same line.',
|
|
127
|
+
weight: 3,
|
|
128
|
+
measure: ({ report }) => report.metrics.edgeOverlaps,
|
|
129
|
+
};
|
|
130
|
+
|
|
115
131
|
export const bends: CostTerm = {
|
|
116
132
|
id: 'bends',
|
|
117
133
|
description: 'Turns taken by connectors.',
|
|
@@ -153,5 +169,5 @@ export const compactness: CostTerm = {
|
|
|
153
169
|
|
|
154
170
|
export const DEFAULT: Objective = {
|
|
155
171
|
constraints: [noHardDefects, framesApart()],
|
|
156
|
-
costs: [crossings, bends, connectorLength, unevenFrameGaps, compactness],
|
|
172
|
+
costs: [crossings, edgeOverlaps, textOverlaps, bends, connectorLength, unevenFrameGaps, compactness],
|
|
157
173
|
};
|
package/src/ortho-router.ts
CHANGED
|
@@ -27,11 +27,98 @@ type Point = Scene.Point;
|
|
|
27
27
|
const DX = [1, 0, -1, 0];
|
|
28
28
|
const DY = [0, 1, 0, -1];
|
|
29
29
|
|
|
30
|
+
/** Offsets past this distance turn the same way as at it, so the table clamps to it. */
|
|
31
|
+
const TURN_RADIUS = 4;
|
|
32
|
+
const TURN_SPAN = TURN_RADIUS * 2 + 1;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fewest turns from a cell heading `dir` to a target `(dx, dy)` away, entered heading `endDir` or
|
|
36
|
+
* paying the search's one-turn penalty for entering off-axis — on an empty grid, so an admissible
|
|
37
|
+
* and consistent lower bound for the search, indexed `[endDir][dir][dx][dy]`. It is exact where the
|
|
38
|
+
* old estimate ignored the arrival heading, which sent every search sweeping the cells behind its
|
|
39
|
+
* target. Computed once, by one backward search per arrival heading over the moves the search makes.
|
|
40
|
+
*/
|
|
41
|
+
const TURN_TABLE = (() => {
|
|
42
|
+
const table = new Uint8Array(4 * 4 * TURN_SPAN * TURN_SPAN);
|
|
43
|
+
// The board leaves room around the radius for the detours an off-axis approach takes.
|
|
44
|
+
const board = TURN_RADIUS + 4;
|
|
45
|
+
const side = board * 2 + 1;
|
|
46
|
+
const stateOf = (x: number, y: number, dir: number) => ((y + board) * side + (x + board)) * 4 + dir;
|
|
47
|
+
const turns = new Uint8Array(side * side * 4);
|
|
48
|
+
// Label-correcting relaxation: steps cost 0, 1 or 2 turns, and a state re-enters the queue
|
|
49
|
+
// whenever it improves, so the queue holds at most a few passes over a small board.
|
|
50
|
+
const queue = new Int32Array(side * side * 4 * 8);
|
|
51
|
+
for (let endDir = 0; endDir < 4; endDir++) {
|
|
52
|
+
turns.fill(255);
|
|
53
|
+
let head = 0;
|
|
54
|
+
let tail = 0;
|
|
55
|
+
// Arriving at the target ends the search whatever the heading.
|
|
56
|
+
for (let dir = 0; dir < 4; dir++) {
|
|
57
|
+
turns[stateOf(0, 0, dir)] = 0;
|
|
58
|
+
queue[tail++] = stateOf(0, 0, dir);
|
|
59
|
+
}
|
|
60
|
+
while (head < tail) {
|
|
61
|
+
const state = queue[head++ % queue.length];
|
|
62
|
+
const arrivedDir = state % 4;
|
|
63
|
+
const cell = (state - arrivedDir) / 4;
|
|
64
|
+
const x = (cell % side) - board;
|
|
65
|
+
const y = Math.floor(cell / side) - board;
|
|
66
|
+
// Every state that steps into (x, y) heading `arrivedDir`, and what that step costs.
|
|
67
|
+
const px = x - DX[arrivedDir];
|
|
68
|
+
const py = y - DY[arrivedDir];
|
|
69
|
+
if (Math.abs(px) > board || Math.abs(py) > board || (px === 0 && py === 0)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const arrival = x === 0 && y === 0 && arrivedDir !== endDir ? 1 : 0;
|
|
73
|
+
for (let dir = 0; dir < 4; dir++) {
|
|
74
|
+
if ((arrivedDir + 2) % 4 === dir) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const previous = stateOf(px, py, dir);
|
|
78
|
+
const total = turns[state] + (arrivedDir === dir ? 0 : 1) + arrival;
|
|
79
|
+
if (total < turns[previous]) {
|
|
80
|
+
turns[previous] = total;
|
|
81
|
+
queue[tail++ % queue.length] = previous;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (let dir = 0; dir < 4; dir++) {
|
|
86
|
+
for (let dx = -TURN_RADIUS; dx <= TURN_RADIUS; dx++) {
|
|
87
|
+
for (let dy = -TURN_RADIUS; dy <= TURN_RADIUS; dy++) {
|
|
88
|
+
const at = ((endDir * 4 + dir) * TURN_SPAN + dx + TURN_RADIUS) * TURN_SPAN + dy + TURN_RADIUS;
|
|
89
|
+
table[at] = dx === 0 && dy === 0 ? 0 : turns[stateOf(-dx, -dy, dir)];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return table;
|
|
95
|
+
})();
|
|
96
|
+
|
|
97
|
+
const clampTurnOffset = (offset: number) => Math.max(-TURN_RADIUS, Math.min(TURN_RADIUS, offset));
|
|
98
|
+
|
|
99
|
+
const turnsBetween = (dx: number, dy: number, dir: number, endDir: number): number =>
|
|
100
|
+
TURN_TABLE[
|
|
101
|
+
((endDir * 4 + dir) * TURN_SPAN + clampTurnOffset(dx) + TURN_RADIUS) * TURN_SPAN + clampTurnOffset(dy) + TURN_RADIUS
|
|
102
|
+
];
|
|
103
|
+
|
|
30
104
|
export type AvoidingRouterOptions = {
|
|
31
105
|
/** Search-cell size (scene px): a fraction of the layout grid, so routes can hug node borders. */
|
|
32
106
|
step: number;
|
|
33
107
|
};
|
|
34
108
|
|
|
109
|
+
/** Paths an avoiding router marked as used, so a later router can replay them; weak, so it holds nothing alive. */
|
|
110
|
+
const marked = new WeakSet<readonly Point[]>();
|
|
111
|
+
|
|
112
|
+
/** The avoiding router plus a way to replay an earlier route's footprint without searching again. */
|
|
113
|
+
export type AvoidingRouter = Router & {
|
|
114
|
+
/**
|
|
115
|
+
* Marks the cells of a path an avoiding router over the same obstacles returned before as used, as
|
|
116
|
+
* routing it did; a path that marked nothing (a fallback route) marks nothing, so a replayed prefix
|
|
117
|
+
* leaves the same state.
|
|
118
|
+
*/
|
|
119
|
+
reserve: (points: readonly Point[]) => void;
|
|
120
|
+
};
|
|
121
|
+
|
|
35
122
|
/**
|
|
36
123
|
* Creates a router that avoids the given node rects. Stateful across edges: earlier routes
|
|
37
124
|
* penalize (not block) the cells they occupy, spreading parallel runs apart. `fallback` handles
|
|
@@ -42,7 +129,7 @@ export const makeAvoidingRouter = (
|
|
|
42
129
|
obstacles: Rect[],
|
|
43
130
|
fallback: Router,
|
|
44
131
|
{ step: STEP }: AvoidingRouterOptions,
|
|
45
|
-
):
|
|
132
|
+
): AvoidingRouter => {
|
|
46
133
|
const xs = obstacles.flatMap((rect) => [rect.x, rect.x + rect.w]);
|
|
47
134
|
const ys = obstacles.flatMap((rect) => [rect.y, rect.y + rect.h]);
|
|
48
135
|
const bounds = {
|
|
@@ -51,14 +138,25 @@ export const makeAvoidingRouter = (
|
|
|
51
138
|
x1: Math.ceil(Math.max(...xs) / STEP) + MARGIN,
|
|
52
139
|
y1: Math.ceil(Math.max(...ys) / STEP) + MARGIN,
|
|
53
140
|
};
|
|
54
|
-
|
|
55
|
-
const
|
|
141
|
+
// The grids carry a one-cell blocked border, so the search's blocked test also keeps it in bounds.
|
|
142
|
+
const width = bounds.x1 - bounds.x0 + 3;
|
|
143
|
+
const height = bounds.y1 - bounds.y0 + 3;
|
|
56
144
|
const inBounds = (x: number, y: number) => x >= bounds.x0 && x <= bounds.x1 && y >= bounds.y0 && y <= bounds.y1;
|
|
57
|
-
const cellIndex = (x: number, y: number) => (y - bounds.y0) * width + (x - bounds.x0);
|
|
145
|
+
const cellIndex = (x: number, y: number) => (y - bounds.y0 + 1) * width + (x - bounds.x0 + 1);
|
|
146
|
+
/** Cell offsets per direction, matching `DX` and `DY`. */
|
|
147
|
+
const STEPS = [1, width, -1, -width];
|
|
58
148
|
|
|
59
149
|
// Flat grids in place of per-cell string keys: the search touches every cell many times over and
|
|
60
150
|
// the string building was half its running time.
|
|
61
151
|
const blockedGrid = new Uint8Array(width * height);
|
|
152
|
+
for (let x = 0; x < width; x++) {
|
|
153
|
+
blockedGrid[x] = 1;
|
|
154
|
+
blockedGrid[(height - 1) * width + x] = 1;
|
|
155
|
+
}
|
|
156
|
+
for (let y = 0; y < height; y++) {
|
|
157
|
+
blockedGrid[y * width] = 1;
|
|
158
|
+
blockedGrid[y * width + width - 1] = 1;
|
|
159
|
+
}
|
|
62
160
|
for (const rect of obstacles) {
|
|
63
161
|
const x0 = Math.floor(rect.x / STEP) - CLEARANCE;
|
|
64
162
|
const y0 = Math.floor(rect.y / STEP) - CLEARANCE;
|
|
@@ -84,140 +182,204 @@ export const makeAvoidingRouter = (
|
|
|
84
182
|
const settledGen = new Int32Array(width * height * 4);
|
|
85
183
|
let generation = 0;
|
|
86
184
|
|
|
87
|
-
/** `f` is cost + estimate, the heap's key. */
|
|
88
|
-
type State = { x: number; y: number; dir: number; cost: number; f: number; prev?: State };
|
|
89
185
|
type Terminal = { point: Point; dir: number };
|
|
90
186
|
type Found = { cost: number; cells: Point[] };
|
|
91
187
|
|
|
188
|
+
// Search states live in a pool of parallel typed arrays, addressed by slot, and the heap holds
|
|
189
|
+
// slots: a search pushes millions of states, and one object per state made GC a third of the run.
|
|
190
|
+
let capacity = 1 << 16;
|
|
191
|
+
let stateKey = new Int32Array(capacity);
|
|
192
|
+
let stateCost = new Float64Array(capacity);
|
|
193
|
+
let statePrev = new Int32Array(capacity);
|
|
194
|
+
let heap = new Int32Array(capacity);
|
|
195
|
+
// Each heap entry's key beside its slot, so sifting compares without chasing the slot into the pool.
|
|
196
|
+
let heapF = new Float64Array(capacity);
|
|
197
|
+
let poolSize = 0;
|
|
198
|
+
let heapSize = 0;
|
|
199
|
+
|
|
200
|
+
const grow = () => {
|
|
201
|
+
capacity *= 2;
|
|
202
|
+
const widen = <T extends Int32Array | Float64Array>(array: T, make: (size: number) => T): T => {
|
|
203
|
+
const next = make(capacity);
|
|
204
|
+
next.set(array);
|
|
205
|
+
return next;
|
|
206
|
+
};
|
|
207
|
+
stateKey = widen(stateKey, (size) => new Int32Array(size));
|
|
208
|
+
stateCost = widen(stateCost, (size) => new Float64Array(size));
|
|
209
|
+
statePrev = widen(statePrev, (size) => new Int32Array(size));
|
|
210
|
+
heap = widen(heap, (size) => new Int32Array(size));
|
|
211
|
+
heapF = widen(heapF, (size) => new Float64Array(size));
|
|
212
|
+
};
|
|
213
|
+
|
|
92
214
|
/** Binary min-heap on cost + estimate, so each pop is O(log n) instead of a frontier scan. */
|
|
93
|
-
const heapPush = (
|
|
94
|
-
|
|
95
|
-
|
|
215
|
+
const heapPush = (key: number, cost: number, f: number, prev: number) => {
|
|
216
|
+
if (poolSize === capacity) {
|
|
217
|
+
grow();
|
|
218
|
+
}
|
|
219
|
+
const slot = poolSize++;
|
|
220
|
+
stateKey[slot] = key;
|
|
221
|
+
stateCost[slot] = cost;
|
|
222
|
+
statePrev[slot] = prev;
|
|
223
|
+
let index = heapSize++;
|
|
96
224
|
while (index > 0) {
|
|
97
225
|
const parent = (index - 1) >> 1;
|
|
98
|
-
if (
|
|
226
|
+
if (heapF[parent] <= f) {
|
|
99
227
|
break;
|
|
100
228
|
}
|
|
101
|
-
|
|
229
|
+
heap[index] = heap[parent];
|
|
230
|
+
heapF[index] = heapF[parent];
|
|
102
231
|
index = parent;
|
|
103
232
|
}
|
|
233
|
+
heap[index] = slot;
|
|
234
|
+
heapF[index] = f;
|
|
104
235
|
};
|
|
105
|
-
const heapPop = (
|
|
236
|
+
const heapPop = (): number => {
|
|
106
237
|
const top = heap[0];
|
|
107
|
-
const last = heap
|
|
108
|
-
if (
|
|
109
|
-
|
|
238
|
+
const last = heap[--heapSize];
|
|
239
|
+
if (heapSize > 0) {
|
|
240
|
+
const lastF = heapF[heapSize];
|
|
110
241
|
let index = 0;
|
|
111
242
|
for (;;) {
|
|
112
243
|
const left = index * 2 + 1;
|
|
113
244
|
const right = left + 1;
|
|
114
245
|
let smallest = index;
|
|
115
|
-
|
|
246
|
+
let smallestF = lastF;
|
|
247
|
+
if (left < heapSize && heapF[left] < smallestF) {
|
|
116
248
|
smallest = left;
|
|
249
|
+
smallestF = heapF[left];
|
|
117
250
|
}
|
|
118
|
-
if (right <
|
|
251
|
+
if (right < heapSize && heapF[right] < smallestF) {
|
|
119
252
|
smallest = right;
|
|
253
|
+
smallestF = heapF[right];
|
|
120
254
|
}
|
|
121
255
|
if (smallest === index) {
|
|
122
256
|
break;
|
|
123
257
|
}
|
|
124
|
-
|
|
258
|
+
heap[index] = heap[smallest];
|
|
259
|
+
heapF[index] = smallestF;
|
|
125
260
|
index = smallest;
|
|
126
261
|
}
|
|
262
|
+
heap[index] = last;
|
|
263
|
+
heapF[index] = lastF;
|
|
127
264
|
}
|
|
128
265
|
return top;
|
|
129
266
|
};
|
|
130
267
|
|
|
131
|
-
|
|
132
|
-
* Fewest turns any path from (x, y) heading `dir` needs to reach the target: none while aligned
|
|
133
|
-
* and heading at it, two when heading away along its axis, otherwise one. Admissible, and since
|
|
134
|
-
* a turn costs as much as a thousand steps it is what makes the search converge on a diagram
|
|
135
|
-
* hundreds of cells across — distance alone leaves it exploring almost uniformly.
|
|
136
|
-
*/
|
|
137
|
-
const turnsNeeded = (x: number, y: number, dir: number, target: Point): number => {
|
|
268
|
+
const estimateFrom = (x: number, y: number, dir: number, target: Point, endDir: number): number => {
|
|
138
269
|
const dx = target.x - x;
|
|
139
270
|
const dy = target.y - y;
|
|
140
|
-
|
|
141
|
-
return 1;
|
|
142
|
-
}
|
|
143
|
-
if (dx === 0 && dy === 0) {
|
|
144
|
-
return 0;
|
|
145
|
-
}
|
|
146
|
-
const towards = dx !== 0 ? (dx > 0 ? 0 : 2) : dy > 0 ? 1 : 3;
|
|
147
|
-
return dir === towards ? 0 : (dir + 2) % 4 === towards ? 2 : 1;
|
|
271
|
+
return Math.abs(dx) + Math.abs(dy) + turnsBetween(dx, dy, dir, endDir) * TURN_COST;
|
|
148
272
|
};
|
|
149
273
|
|
|
150
|
-
const estimateFrom = (x: number, y: number, dir: number, target: Point): number =>
|
|
151
|
-
Math.abs(target.x - x) + Math.abs(target.y - y) + turnsNeeded(x, y, dir, target) * TURN_COST;
|
|
152
|
-
|
|
153
274
|
// Every cell in every heading, so a reachable target is never abandoned on a large diagram.
|
|
154
275
|
const budget = Math.max(50_000, (bounds.x1 - bounds.x0) * (bounds.y1 - bounds.y0) * 4);
|
|
155
276
|
|
|
156
277
|
/**
|
|
157
278
|
* Bounded A* between two stub ends (grid coordinates). Successors already dominated by a
|
|
158
|
-
* settled state are pruned before pushing; undefined when the target is unreachable
|
|
279
|
+
* settled state are pruned before pushing; undefined when the target is unreachable or no path
|
|
280
|
+
* costs less than `bound`.
|
|
159
281
|
*/
|
|
160
|
-
const search = (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
282
|
+
const search = (
|
|
283
|
+
source: Point,
|
|
284
|
+
startDir: number,
|
|
285
|
+
target: Point,
|
|
286
|
+
endDir: number,
|
|
287
|
+
bound = Infinity,
|
|
288
|
+
): Found | undefined => {
|
|
289
|
+
poolSize = 0;
|
|
290
|
+
heapSize = 0;
|
|
291
|
+
heapPush(
|
|
292
|
+
cellIndex(source.x, source.y) * 4 + startDir,
|
|
293
|
+
0,
|
|
294
|
+
estimateFrom(source.x, source.y, startDir, target, endDir),
|
|
295
|
+
-1,
|
|
296
|
+
);
|
|
164
297
|
generation++;
|
|
165
|
-
const
|
|
166
|
-
|
|
298
|
+
const targetCell = inBounds(target.x, target.y) ? cellIndex(target.x, target.y) : -1;
|
|
299
|
+
// Coordinates relative to the padded grid, so the estimate needs no conversion per state.
|
|
300
|
+
const targetX = target.x - bounds.x0 + 1;
|
|
301
|
+
const targetY = target.y - bounds.y0 + 1;
|
|
302
|
+
let found = -1;
|
|
167
303
|
|
|
168
|
-
for (let iterations = 0;
|
|
169
|
-
|
|
170
|
-
|
|
304
|
+
for (let iterations = 0; heapSize > 0 && iterations < budget; iterations++) {
|
|
305
|
+
// The estimate is admissible, so once the cheapest frontier key reaches the bound no path
|
|
306
|
+
// can beat it; without this a losing configuration explores every state up to its own optimum.
|
|
307
|
+
if (heapF[0] >= bound) {
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
const current = heapPop();
|
|
311
|
+
const stateIndex = stateKey[current];
|
|
312
|
+
const currentDir = stateIndex & 3;
|
|
313
|
+
const currentCell = stateIndex >> 2;
|
|
314
|
+
const currentCost = stateCost[current];
|
|
315
|
+
if (currentCell === targetCell) {
|
|
171
316
|
found = current;
|
|
172
317
|
break;
|
|
173
318
|
}
|
|
174
|
-
|
|
175
|
-
const seen = settledAt(stateIndex);
|
|
176
|
-
if (seen !== undefined && seen <= current.cost) {
|
|
319
|
+
if (settledGen[stateIndex] === generation && settledCost[stateIndex] <= currentCost) {
|
|
177
320
|
continue;
|
|
178
321
|
}
|
|
179
322
|
settledGen[stateIndex] = generation;
|
|
180
|
-
settledCost[stateIndex] =
|
|
323
|
+
settledCost[stateIndex] = currentCost;
|
|
324
|
+
const currentY = Math.floor(currentCell / width);
|
|
325
|
+
const currentX = currentCell - currentY * width;
|
|
181
326
|
|
|
182
327
|
for (let dir = 0; dir < 4; dir++) {
|
|
183
|
-
if ((dir + 2) % 4 ===
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
const x = current.x + DX[dir];
|
|
187
|
-
const y = current.y + DY[dir];
|
|
188
|
-
if (!inBounds(x, y)) {
|
|
328
|
+
if ((dir + 2) % 4 === currentDir) {
|
|
189
329
|
continue;
|
|
190
330
|
}
|
|
191
|
-
const cell =
|
|
331
|
+
const cell = currentCell + STEPS[dir];
|
|
192
332
|
if (blockedGrid[cell] !== 0) {
|
|
193
333
|
continue;
|
|
194
334
|
}
|
|
195
335
|
const cost =
|
|
196
|
-
|
|
336
|
+
currentCost +
|
|
197
337
|
1 +
|
|
198
|
-
(dir ===
|
|
338
|
+
(dir === currentDir ? 0 : TURN_COST) +
|
|
199
339
|
(usedGrid[cell] !== 0 ? USED_COST : 0) +
|
|
200
340
|
// Entering the target off-axis forces one more bend at arrival; fold it in.
|
|
201
|
-
(
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
341
|
+
(cell === targetCell && dir !== endDir ? TURN_COST : 0);
|
|
342
|
+
const next = cell * 4 + dir;
|
|
343
|
+
if (settledGen[next] === generation && settledCost[next] <= cost) {
|
|
204
344
|
continue;
|
|
205
345
|
}
|
|
206
|
-
|
|
346
|
+
const dx = targetX - (currentX + DX[dir]);
|
|
347
|
+
const dy = targetY - (currentY + DY[dir]);
|
|
348
|
+
heapPush(
|
|
349
|
+
next,
|
|
350
|
+
cost,
|
|
351
|
+
cost + (Math.abs(dx) + Math.abs(dy) + turnsBetween(dx, dy, dir, endDir) * TURN_COST),
|
|
352
|
+
current,
|
|
353
|
+
);
|
|
207
354
|
}
|
|
208
355
|
}
|
|
209
356
|
|
|
210
|
-
if (
|
|
357
|
+
if (found < 0) {
|
|
211
358
|
return undefined;
|
|
212
359
|
}
|
|
213
360
|
const cells: Point[] = [];
|
|
214
|
-
for (let
|
|
215
|
-
|
|
361
|
+
for (let slot = found; slot >= 0; slot = statePrev[slot]) {
|
|
362
|
+
const cell = stateKey[slot] >> 2;
|
|
363
|
+
cells.unshift({ x: (cell % width) + bounds.x0 - 1, y: Math.floor(cell / width) + bounds.y0 - 1 });
|
|
216
364
|
}
|
|
217
|
-
return { cost: found
|
|
365
|
+
return { cost: stateCost[found], cells };
|
|
218
366
|
};
|
|
219
367
|
|
|
220
|
-
|
|
368
|
+
const markPath = (path: readonly Point[]) => {
|
|
369
|
+
for (let index = 0; index < path.length - 1; index++) {
|
|
370
|
+
const a = path[index];
|
|
371
|
+
const b = path[index + 1];
|
|
372
|
+
const ax = Math.round(a.x / STEP);
|
|
373
|
+
const ay = Math.round(a.y / STEP);
|
|
374
|
+
const steps = Math.max(Math.abs(Math.round(b.x / STEP) - ax), Math.abs(Math.round(b.y / STEP) - ay));
|
|
375
|
+
const dx = Math.sign(b.x - a.x);
|
|
376
|
+
const dy = Math.sign(b.y - a.y);
|
|
377
|
+
for (let step = 0; step <= steps; step++) {
|
|
378
|
+
markUsed(ax + dx * step, ay + dy * step);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const route = (edge: RoutedRelation): Point[] => {
|
|
221
383
|
const { from, to, horizontal, ports } = edge;
|
|
222
384
|
// Flow-axis faces mirror the Z-router (and the port assignment in `emit`).
|
|
223
385
|
const sameLane = horizontal ? from.x === to.x : from.y === to.y;
|
|
@@ -270,6 +432,7 @@ export const makeAvoidingRouter = (
|
|
|
270
432
|
startTerminal.dir,
|
|
271
433
|
stub(endTerminal.point, endTerminal.dir, false),
|
|
272
434
|
endTerminal.dir,
|
|
435
|
+
best?.cost,
|
|
273
436
|
);
|
|
274
437
|
if (result && (!best || result.cost < best.cost)) {
|
|
275
438
|
best = result;
|
|
@@ -355,18 +518,15 @@ export const makeAvoidingRouter = (
|
|
|
355
518
|
}
|
|
356
519
|
|
|
357
520
|
// Mark the final path's cells so later edges route (and center) around it.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const b = simplified[index + 1];
|
|
361
|
-
const ax = Math.round(a.x / STEP);
|
|
362
|
-
const ay = Math.round(a.y / STEP);
|
|
363
|
-
const steps = Math.max(Math.abs(Math.round(b.x / STEP) - ax), Math.abs(Math.round(b.y / STEP) - ay));
|
|
364
|
-
const dx = Math.sign(b.x - a.x);
|
|
365
|
-
const dy = Math.sign(b.y - a.y);
|
|
366
|
-
for (let step = 0; step <= steps; step++) {
|
|
367
|
-
markUsed(ax + dx * step, ay + dy * step);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
521
|
+
markPath(simplified);
|
|
522
|
+
marked.add(simplified);
|
|
370
523
|
return simplified;
|
|
371
524
|
};
|
|
525
|
+
|
|
526
|
+
const reserve = (points: readonly Point[]) => {
|
|
527
|
+
if (marked.has(points)) {
|
|
528
|
+
markPath(points);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
return Object.assign(route, { reserve });
|
|
372
532
|
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { type PortPath, assign, keyOf } from './ports.ts';
|
|
8
|
+
import type * as Scene from './scene.ts';
|
|
9
|
+
import { GRID, type Rect, zRouter } from './uml-grid.ts';
|
|
10
|
+
|
|
11
|
+
const STEP = GRID / 2;
|
|
12
|
+
|
|
13
|
+
type Segment = readonly [Scene.Point, Scene.Point];
|
|
14
|
+
|
|
15
|
+
const segmentsOf = (points: readonly Scene.Point[]): Segment[] =>
|
|
16
|
+
points.slice(1).map((end, index) => [points[index], end] as const);
|
|
17
|
+
|
|
18
|
+
/** Whether two orthogonal polylines cross at a point interior to a segment of each. */
|
|
19
|
+
const crosses = (first: readonly Scene.Point[], second: readonly Scene.Point[]) =>
|
|
20
|
+
segmentsOf(first).some(([a0, a1]) =>
|
|
21
|
+
segmentsOf(second).some(([b0, b1]) => {
|
|
22
|
+
const [vertical, horizontal] =
|
|
23
|
+
a0.x === a1.x
|
|
24
|
+
? [
|
|
25
|
+
[a0, a1],
|
|
26
|
+
[b0, b1],
|
|
27
|
+
]
|
|
28
|
+
: [
|
|
29
|
+
[b0, b1],
|
|
30
|
+
[a0, a1],
|
|
31
|
+
];
|
|
32
|
+
if (vertical[0].x !== vertical[1].x || horizontal[0].y !== horizontal[1].y) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const x = vertical[0].x;
|
|
36
|
+
const y = horizontal[0].y;
|
|
37
|
+
return (
|
|
38
|
+
x > Math.min(horizontal[0].x, horizontal[1].x) &&
|
|
39
|
+
x < Math.max(horizontal[0].x, horizontal[1].x) &&
|
|
40
|
+
y > Math.min(vertical[0].y, vertical[1].y) &&
|
|
41
|
+
y < Math.max(vertical[0].y, vertical[1].y)
|
|
42
|
+
);
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
describe('ports', () => {
|
|
47
|
+
// One box above two targets that sit far apart below it, on its left and its right.
|
|
48
|
+
const source: Rect = { x: 384, y: 0, w: 192, h: 96 };
|
|
49
|
+
const left: Rect = { x: 0, y: 384, w: 192, h: 96 };
|
|
50
|
+
const right: Rect = { x: 768, y: 384, w: 192, h: 96 };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Routes both edges off the bottom face as Zs whose cross runs sit at different heights, as two
|
|
54
|
+
* routes that share a side do once the second steers off the first.
|
|
55
|
+
*/
|
|
56
|
+
const routeBoth = (toLeft: number, toRight: number) => {
|
|
57
|
+
const route = (target: Rect, start: number, offset: number) =>
|
|
58
|
+
zRouter({
|
|
59
|
+
relation: { from: 'source', to: target === left ? 'left' : 'right' },
|
|
60
|
+
from: source,
|
|
61
|
+
to: target,
|
|
62
|
+
horizontal: false,
|
|
63
|
+
offset,
|
|
64
|
+
ports: { start, end: target.x + target.w / 2 },
|
|
65
|
+
});
|
|
66
|
+
return [route(left, toLeft, 0), route(right, toRight, 1)];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
test('two edges leaving one side in crossed order are reordered so they no longer cross', ({ expect }) => {
|
|
70
|
+
// Crossed: the edge to the left target leaves from the right of the side and vice versa.
|
|
71
|
+
const crossed = routeBoth(source.x + source.w - STEP * 2, source.x + STEP * 2);
|
|
72
|
+
expect(crossed.every((points) => points[0].y === source.y + source.h)).toBe(true);
|
|
73
|
+
expect(crosses(crossed[0], crossed[1])).toBe(true);
|
|
74
|
+
|
|
75
|
+
const paths: PortPath[] = [
|
|
76
|
+
{ points: crossed[0], source, target: left },
|
|
77
|
+
{ points: crossed[1], source, target: right },
|
|
78
|
+
];
|
|
79
|
+
const [toLeft, toRight] = assign(paths, { step: STEP });
|
|
80
|
+
expect(toLeft.start).toBeDefined();
|
|
81
|
+
expect(toRight.start).toBeDefined();
|
|
82
|
+
const [leftStart, rightStart] = [toLeft.start ?? 0, toRight.start ?? 0];
|
|
83
|
+
expect(leftStart).toBeLessThan(rightStart);
|
|
84
|
+
for (const coord of [leftStart, rightStart]) {
|
|
85
|
+
expect(coord % STEP).toBe(0);
|
|
86
|
+
expect(coord).toBeGreaterThanOrEqual(source.x + STEP);
|
|
87
|
+
expect(coord).toBeLessThanOrEqual(source.x + source.w - STEP);
|
|
88
|
+
}
|
|
89
|
+
// The far ends were not asked to move.
|
|
90
|
+
expect(toLeft.end).toBeUndefined();
|
|
91
|
+
expect(toRight.end).toBeUndefined();
|
|
92
|
+
|
|
93
|
+
const ordered = routeBoth(leftStart, rightStart);
|
|
94
|
+
expect(crosses(ordered[0], ordered[1])).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('a side already in order is left alone', ({ expect }) => {
|
|
98
|
+
const ordered = routeBoth(source.x + STEP * 2, source.x + source.w - STEP * 2);
|
|
99
|
+
expect(crosses(ordered[0], ordered[1])).toBe(false);
|
|
100
|
+
const assigned = assign(
|
|
101
|
+
[
|
|
102
|
+
{ points: ordered[0], source, target: left },
|
|
103
|
+
{ points: ordered[1], source, target: right },
|
|
104
|
+
],
|
|
105
|
+
{ step: STEP },
|
|
106
|
+
);
|
|
107
|
+
expect(assigned).toEqual([{}, {}]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('free ports are placed around a fixed one in key order', ({ expect }) => {
|
|
111
|
+
const below: Rect = { x: 384, y: 384, w: 192, h: 96 };
|
|
112
|
+
const center = source.x + source.w / 2;
|
|
113
|
+
const straight = [
|
|
114
|
+
{ x: center, y: source.y + source.h },
|
|
115
|
+
{ x: center, y: below.y },
|
|
116
|
+
];
|
|
117
|
+
// Both turning edges start left of the straight one, though one of them heads right.
|
|
118
|
+
const turningRight = [
|
|
119
|
+
{ x: center - STEP, y: 96 },
|
|
120
|
+
{ x: center - STEP, y: 160 },
|
|
121
|
+
{ x: right.x + right.w / 2, y: 160 },
|
|
122
|
+
{ x: right.x + right.w / 2, y: right.y },
|
|
123
|
+
];
|
|
124
|
+
const turningLeft = [
|
|
125
|
+
{ x: center - STEP * 2, y: 96 },
|
|
126
|
+
{ x: center - STEP * 2, y: 192 },
|
|
127
|
+
{ x: left.x + left.w / 2, y: 192 },
|
|
128
|
+
{ x: left.x + left.w / 2, y: left.y },
|
|
129
|
+
];
|
|
130
|
+
const [first, second, third] = assign(
|
|
131
|
+
[
|
|
132
|
+
{ points: straight, source, target: below, fixed: { start: true, end: true } },
|
|
133
|
+
{ points: turningRight, source, target: right },
|
|
134
|
+
{ points: turningLeft, source, target: left },
|
|
135
|
+
],
|
|
136
|
+
{ step: STEP },
|
|
137
|
+
);
|
|
138
|
+
expect(first).toEqual({});
|
|
139
|
+
expect(second.start).toBeGreaterThanOrEqual(center + STEP);
|
|
140
|
+
expect(third.start).toBeLessThanOrEqual(center - STEP);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('keys order turning routes so neither turn-off cuts the other', ({ expect }) => {
|
|
144
|
+
const out = (run: number, towards: number) => [
|
|
145
|
+
{ x: 0, y: 0 },
|
|
146
|
+
{ x: 0, y: run },
|
|
147
|
+
{ x: towards, y: run },
|
|
148
|
+
];
|
|
149
|
+
// Of two routes turning to the high end, the one running out farther belongs nearer the low end.
|
|
150
|
+
expect(keyOf(out(64, 100), 'bottom')).toBeLessThan(keyOf(out(32, 100), 'bottom'));
|
|
151
|
+
// Of two turning to the low end, the one turning sooner belongs nearer the low end.
|
|
152
|
+
expect(keyOf(out(32, -100), 'bottom')).toBeLessThan(keyOf(out(64, -100), 'bottom'));
|
|
153
|
+
// A route that does not turn sits between the two fans.
|
|
154
|
+
expect(keyOf(out(64, -100), 'bottom')).toBeLessThan(keyOf(out(64, 0).slice(0, 2), 'bottom'));
|
|
155
|
+
expect(keyOf(out(64, 0).slice(0, 2), 'bottom')).toBeLessThan(keyOf(out(64, 100), 'bottom'));
|
|
156
|
+
});
|
|
157
|
+
});
|