@dxos/diagram 0.11.1 → 0.14.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 +9406 -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 +108 -74
- 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 +216 -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 +186 -0
- package/src/nudge.ts +387 -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 +66 -5
- 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 +891 -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/nudge.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
const overlapsBefore = pairCount(lines, changed, shared, true);
|
|
334
|
+
// A crossing still reads as two lines and a shared run does not, so each overlap resolved may cost one crossing.
|
|
335
|
+
if (
|
|
336
|
+
overlaps >= overlapsBefore ||
|
|
337
|
+
crossings - pairCount(lines, changed, crosses) > overlapsBefore - overlaps ||
|
|
338
|
+
obstructed(candidate, changed) > obstructed(lines, changed)
|
|
339
|
+
) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const travel = [...offsets.values()].reduce((total, offset) => total + Math.abs(offset), 0);
|
|
343
|
+
const better =
|
|
344
|
+
!best ||
|
|
345
|
+
overlaps < best.overlaps ||
|
|
346
|
+
(overlaps === best.overlaps &&
|
|
347
|
+
(crossings < best.crossings ||
|
|
348
|
+
(crossings === best.crossings &&
|
|
349
|
+
(reversed < best.reversed || (reversed === best.reversed && travel < best.travel)))));
|
|
350
|
+
if (better) {
|
|
351
|
+
best = { lines: candidate, overlaps, crossings, reversed, travel };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return best?.lines.map((points, owner) => (points === lines[owner] ? points : simplify(points)));
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Nudging (Wybrow, Marriott & Stuckey 2010): distinct connectors that share a collinear run are
|
|
359
|
+
* pulled apart across it by `spacing`, ordered so their turn-offs do not cross, with ports sliding
|
|
360
|
+
* along their box side. Fixed lines (a deliberate bus) never move. Paths that need no change come
|
|
361
|
+
* back as given.
|
|
362
|
+
*/
|
|
363
|
+
export const nudge = (paths: readonly NudgePath[], options: NudgeOptions = {}): Point[][] => {
|
|
364
|
+
const { spacing = 8, obstacles = [], fixed = [], margin = spacing } = options;
|
|
365
|
+
let lines: Point[][] = [...paths.map((path) => simplify(path.points)), ...fixed.map((points) => [...points])];
|
|
366
|
+
const touched = new Set<number>();
|
|
367
|
+
const attempted = new Set<string>();
|
|
368
|
+
const signature = (channel: readonly Run[]) =>
|
|
369
|
+
`${channel[0].axis}:${Math.round(channel[0].coord)}:${channel
|
|
370
|
+
.map((run) => `${run.owner}@${Math.round(run.lo)}-${Math.round(run.hi)}`)
|
|
371
|
+
.sort()
|
|
372
|
+
.join(',')}`;
|
|
373
|
+
// Each separation strictly lowers the overlap count and a failed channel is never retried, so this terminates.
|
|
374
|
+
for (;;) {
|
|
375
|
+
const channel = channelsOf(lines, paths.length).find((candidate) => !attempted.has(signature(candidate)));
|
|
376
|
+
if (!channel) {
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
attempted.add(signature(channel));
|
|
380
|
+
const next = separate(lines, channel, paths, obstacles, spacing, margin);
|
|
381
|
+
if (next) {
|
|
382
|
+
next.forEach((points, owner) => points !== lines[owner] && touched.add(owner));
|
|
383
|
+
lines = next;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return paths.map((path, owner) => (touched.has(owner) ? lines[owner] : [...path.points]));
|
|
387
|
+
};
|
package/src/objective.test.ts
CHANGED
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
|
};
|