@adecore/diagram 0.39.0 → 0.40.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/layout.js +24 -24
- package/dist/order.js +2 -3
- package/dist/route.d.ts +1 -4
- package/dist/route.js +5 -7
- package/package.json +5 -5
- package/src/across.ts +52 -0
- package/src/layout.ts +183 -25
- package/src/order.ts +49 -3
- package/src/protocol.ts +14 -0
- package/src/reading-order.ts +5 -0
- package/src/route.ts +53 -8
- package/src/shapes.ts +8 -0
- package/src/svg.ts +7 -0
- package/src/text.ts +19 -0
package/src/order.ts
CHANGED
|
@@ -15,23 +15,29 @@ export function orderLayers(layering: Layering): void {
|
|
|
15
15
|
const position = new Array<number>(units.length).fill(0);
|
|
16
16
|
const bandPosition = bands.map((band) => new Array<number>(band.last - band.first + 1).fill(0));
|
|
17
17
|
const hopsByLayer: number[][] = items.map(() => []);
|
|
18
|
+
|
|
18
19
|
hops.forEach((hop, index) => {
|
|
19
20
|
hopsByLayer[units[hop.from]!.layer]!.push(index);
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
const flatten = (layer: number): void => {
|
|
23
24
|
let at = 0;
|
|
25
|
+
|
|
24
26
|
for (const code of items[layer]!) {
|
|
25
27
|
if (code >= 0) {
|
|
26
28
|
position[code] = at++;
|
|
29
|
+
|
|
27
30
|
continue;
|
|
28
31
|
}
|
|
32
|
+
|
|
29
33
|
const band = bands[bandOfCode(code)]!;
|
|
30
34
|
const members = band.members[layer - band.first]!;
|
|
31
35
|
bandPosition[bandOfCode(code)]![layer - band.first] = at;
|
|
36
|
+
|
|
32
37
|
if (members.length === 0) {
|
|
33
38
|
at++;
|
|
34
39
|
}
|
|
40
|
+
|
|
35
41
|
for (const member of members) {
|
|
36
42
|
position[member] = at++;
|
|
37
43
|
}
|
|
@@ -42,13 +48,16 @@ export function orderLayers(layering: Layering): void {
|
|
|
42
48
|
const row = items[layer]!;
|
|
43
49
|
const slots: number[] = [];
|
|
44
50
|
const present: number[] = [];
|
|
51
|
+
|
|
45
52
|
row.forEach((code, index) => {
|
|
46
53
|
if (code < 0) {
|
|
47
54
|
slots.push(index);
|
|
48
55
|
present.push(bandOfCode(code));
|
|
49
56
|
}
|
|
50
57
|
});
|
|
58
|
+
|
|
51
59
|
present.sort((left, right) => bands[left]!.rank - bands[right]!.rank);
|
|
60
|
+
|
|
52
61
|
slots.forEach((slot, index) => {
|
|
53
62
|
row[slot] = bandCode(present[index]!);
|
|
54
63
|
});
|
|
@@ -57,27 +66,35 @@ export function orderLayers(layering: Layering): void {
|
|
|
57
66
|
const sortLayer = (layer: number, towardsUp: boolean): void => {
|
|
58
67
|
const keyOf = (unit: number): number => {
|
|
59
68
|
const neighbors = towardsUp ? units[unit]!.up : units[unit]!.down;
|
|
69
|
+
|
|
60
70
|
if (neighbors.length === 0) {
|
|
61
71
|
return position[unit]!;
|
|
62
72
|
}
|
|
73
|
+
|
|
63
74
|
let sum = 0;
|
|
75
|
+
|
|
64
76
|
for (const neighbor of neighbors) {
|
|
65
77
|
sum += position[neighbor]!;
|
|
66
78
|
}
|
|
79
|
+
|
|
67
80
|
return sum / neighbors.length;
|
|
68
81
|
};
|
|
82
|
+
|
|
69
83
|
const keyed = items[layer]!.map((code, index) => {
|
|
70
84
|
if (code >= 0) {
|
|
71
85
|
return { code, index, key: keyOf(code) };
|
|
72
86
|
}
|
|
87
|
+
|
|
73
88
|
const band = bands[bandOfCode(code)]!;
|
|
74
89
|
const slot = layer - band.first;
|
|
75
90
|
const members = band.members[slot]!.map((unit) => ({ unit, key: keyOf(unit), at: position[unit]! }));
|
|
76
91
|
members.sort((left, right) => left.key - right.key || left.at - right.at);
|
|
77
92
|
band.members[slot] = members.map((member) => member.unit);
|
|
78
93
|
const key = members.length > 0 ? members.reduce((sum, member) => sum + member.key, 0) / members.length : bandPosition[bandOfCode(code)]![slot]!;
|
|
94
|
+
|
|
79
95
|
return { code, index, key };
|
|
80
96
|
});
|
|
97
|
+
|
|
81
98
|
keyed.sort((left, right) => left.key - right.key || left.index - right.index);
|
|
82
99
|
items[layer] = keyed.map((entry) => entry.code);
|
|
83
100
|
enforceBandOrder(layer);
|
|
@@ -87,13 +104,17 @@ export function orderLayers(layering: Layering): void {
|
|
|
87
104
|
const updateRanks = (): void => {
|
|
88
105
|
const scores = bands.map((band, index) => {
|
|
89
106
|
let sum = 0;
|
|
107
|
+
|
|
90
108
|
for (let layer = band.first; layer <= band.last; layer++) {
|
|
91
109
|
const row = items[layer]!;
|
|
92
110
|
sum += row.indexOf(bandCode(index)) / row.length;
|
|
93
111
|
}
|
|
112
|
+
|
|
94
113
|
return sum / (band.last - band.first + 1);
|
|
95
114
|
});
|
|
115
|
+
|
|
96
116
|
const order = bands.map((_, index) => index).sort((left, right) => scores[left]! - scores[right]! || bands[left]!.rank - bands[right]!.rank);
|
|
117
|
+
|
|
97
118
|
order.forEach((band, rank) => {
|
|
98
119
|
bands[band]!.rank = rank;
|
|
99
120
|
});
|
|
@@ -105,47 +126,59 @@ export function orderLayers(layering: Layering): void {
|
|
|
105
126
|
if (!items[layer]!.some((code) => code < 0)) {
|
|
106
127
|
continue;
|
|
107
128
|
}
|
|
129
|
+
|
|
108
130
|
const previousIndex = new Map<number, number>();
|
|
109
131
|
(items[layer - 1] ?? []).forEach((code, index) => previousIndex.set(code, index));
|
|
110
132
|
const free = items[layer]!.filter((code) => code >= 0);
|
|
133
|
+
|
|
111
134
|
for (const unit of free) {
|
|
112
135
|
const row = items[layer]!;
|
|
113
136
|
const present = row.filter((code) => code < 0).map(bandOfCode);
|
|
114
137
|
let lower = 0;
|
|
115
138
|
let upper = present.length;
|
|
139
|
+
|
|
116
140
|
const narrow = (source: number, sourceLayer: number): void => {
|
|
117
141
|
let low = 0;
|
|
118
142
|
let high = present.length;
|
|
143
|
+
|
|
119
144
|
present.forEach((band, slot) => {
|
|
120
145
|
const target = bands[band]!;
|
|
121
146
|
const sourceBand = units[source]!.band;
|
|
147
|
+
|
|
122
148
|
if (sourceLayer < target.first || sourceLayer > target.last || sourceBand === band) {
|
|
123
149
|
return;
|
|
124
150
|
}
|
|
151
|
+
|
|
125
152
|
const before =
|
|
126
153
|
sourceBand >= 0 ? bands[sourceBand]!.rank < target.rank : previousIndex.get(source)! < previousIndex.get(bandCode(band))!;
|
|
154
|
+
|
|
127
155
|
if (before) {
|
|
128
156
|
high = Math.min(high, slot);
|
|
129
157
|
} else {
|
|
130
158
|
low = Math.max(low, slot + 1);
|
|
131
159
|
}
|
|
132
160
|
});
|
|
161
|
+
|
|
133
162
|
if (Math.max(lower, low) <= Math.min(upper, high)) {
|
|
134
163
|
lower = Math.max(lower, low);
|
|
135
164
|
upper = Math.min(upper, high);
|
|
136
165
|
}
|
|
137
166
|
};
|
|
167
|
+
|
|
138
168
|
for (const source of units[unit]!.up) {
|
|
139
169
|
narrow(source, layer - 1);
|
|
140
170
|
}
|
|
171
|
+
|
|
141
172
|
// A member of a group in the next layer cannot move, so the free unit follows it instead.
|
|
142
173
|
for (const target of units[unit]!.down) {
|
|
143
174
|
if (units[target]!.band >= 0) {
|
|
144
175
|
narrow(target, layer + 1);
|
|
145
176
|
}
|
|
146
177
|
}
|
|
178
|
+
|
|
147
179
|
const at = row.indexOf(unit);
|
|
148
180
|
const slot = row.slice(0, at).filter((code) => code < 0).length;
|
|
181
|
+
|
|
149
182
|
if (slot < lower) {
|
|
150
183
|
row.splice(at, 1);
|
|
151
184
|
row.splice(row.indexOf(bandCode(present[lower - 1]!)) + 1, 0, unit);
|
|
@@ -159,26 +192,34 @@ export function orderLayers(layering: Layering): void {
|
|
|
159
192
|
|
|
160
193
|
const crossings = (): number => {
|
|
161
194
|
let total = 0;
|
|
195
|
+
|
|
162
196
|
for (let layer = 0; layer + 1 < items.length; layer++) {
|
|
163
197
|
const pairs = hopsByLayer[layer]!.map((index) => [position[hops[index]!.from]!, position[hops[index]!.to]!] as const);
|
|
198
|
+
|
|
164
199
|
if (pairs.length < 2) {
|
|
165
200
|
continue;
|
|
166
201
|
}
|
|
202
|
+
|
|
167
203
|
pairs.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
|
|
168
204
|
// A Fenwick tree over the positions of the next layer counts the pairs that come in out of order.
|
|
169
205
|
const size = pairs.reduce((largest, pair) => Math.max(largest, pair[1]), 0) + 2;
|
|
170
206
|
const tree = new Array<number>(size + 1).fill(0);
|
|
207
|
+
|
|
171
208
|
pairs.forEach(([, to], seen) => {
|
|
172
209
|
let atMost = 0;
|
|
210
|
+
|
|
173
211
|
for (let i = to + 1; i > 0; i -= i & -i) {
|
|
174
212
|
atMost += tree[i]!;
|
|
175
213
|
}
|
|
214
|
+
|
|
176
215
|
total += seen - atMost;
|
|
216
|
+
|
|
177
217
|
for (let i = to + 1; i <= size; i += i & -i) {
|
|
178
218
|
tree[i]! += 1;
|
|
179
219
|
}
|
|
180
220
|
});
|
|
181
221
|
}
|
|
222
|
+
|
|
182
223
|
return total;
|
|
183
224
|
};
|
|
184
225
|
|
|
@@ -186,6 +227,7 @@ export function orderLayers(layering: Layering): void {
|
|
|
186
227
|
items.forEach((_, layer) => enforceBandOrder(layer));
|
|
187
228
|
keepSides();
|
|
188
229
|
items.forEach((_, layer) => flatten(layer));
|
|
230
|
+
|
|
189
231
|
return crossings();
|
|
190
232
|
};
|
|
191
233
|
|
|
@@ -196,10 +238,10 @@ export function orderLayers(layering: Layering): void {
|
|
|
196
238
|
});
|
|
197
239
|
|
|
198
240
|
items.forEach((_, layer) => flatten(layer));
|
|
241
|
+
|
|
199
242
|
// A band's first layer always holds a member, which is what made it the first.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
.slice()
|
|
243
|
+
bands
|
|
244
|
+
.map((band, index) => [band.first, position[band.members[0]![0]!]!, index] as const)
|
|
203
245
|
.sort((left, right) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2])
|
|
204
246
|
.forEach(([, , band], rank) => {
|
|
205
247
|
bands[band]!.rank = rank;
|
|
@@ -207,6 +249,7 @@ export function orderLayers(layering: Layering): void {
|
|
|
207
249
|
|
|
208
250
|
let bestCount = settle();
|
|
209
251
|
let best = snapshot();
|
|
252
|
+
|
|
210
253
|
for (let sweep = 0; sweep < SWEEPS && bestCount > 0; sweep++) {
|
|
211
254
|
if (sweep % 2 === 0) {
|
|
212
255
|
for (let layer = 1; layer < items.length; layer++) {
|
|
@@ -217,8 +260,10 @@ export function orderLayers(layering: Layering): void {
|
|
|
217
260
|
sortLayer(layer, false);
|
|
218
261
|
}
|
|
219
262
|
}
|
|
263
|
+
|
|
220
264
|
updateRanks();
|
|
221
265
|
const count = settle();
|
|
266
|
+
|
|
222
267
|
if (count < bestCount) {
|
|
223
268
|
bestCount = count;
|
|
224
269
|
best = snapshot();
|
|
@@ -228,6 +273,7 @@ export function orderLayers(layering: Layering): void {
|
|
|
228
273
|
best.items.forEach((row, layer) => {
|
|
229
274
|
items[layer] = row;
|
|
230
275
|
});
|
|
276
|
+
|
|
231
277
|
bands.forEach((band, index) => {
|
|
232
278
|
band.members = best.members[index]!;
|
|
233
279
|
band.rank = best.ranks[index]!;
|
package/src/protocol.ts
CHANGED
|
@@ -83,6 +83,7 @@ export const EMPTY_DIAGRAM: DiagramDocument = { version: 1, rev: 0, meta: { titl
|
|
|
83
83
|
/* One version so far, so reading is a parse. A file that is not a diagram at all reads as null. */
|
|
84
84
|
export function migrateDiagram(value: unknown): DiagramDocument | null {
|
|
85
85
|
const parsed = DiagramDocumentSchema.safeParse(value);
|
|
86
|
+
|
|
86
87
|
return parsed.success ? parsed.data : null;
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -95,35 +96,47 @@ export function diagramProblemIn(content: Pick<DiagramContent, 'nodes' | 'groups
|
|
|
95
96
|
if (content.nodes.length > DIAGRAM_LIMITS.nodes) {
|
|
96
97
|
return `A diagram holds at most ${DIAGRAM_LIMITS.nodes} nodes, and this one has ${content.nodes.length}`;
|
|
97
98
|
}
|
|
99
|
+
|
|
98
100
|
if (content.edges.length > DIAGRAM_LIMITS.edges) {
|
|
99
101
|
return `A diagram holds at most ${DIAGRAM_LIMITS.edges} edges, and this one has ${content.edges.length}`;
|
|
100
102
|
}
|
|
103
|
+
|
|
101
104
|
// Nodes and groups share one namespace, so an id always says which box it means.
|
|
102
105
|
const nodes = new Set<string>();
|
|
106
|
+
|
|
103
107
|
for (const node of content.nodes) {
|
|
104
108
|
if (nodes.has(node.id)) {
|
|
105
109
|
return `Two nodes share the id "${node.id}"`;
|
|
106
110
|
}
|
|
111
|
+
|
|
107
112
|
nodes.add(node.id);
|
|
108
113
|
}
|
|
114
|
+
|
|
109
115
|
const groups = new Set<string>();
|
|
110
116
|
const groupOf = new Map<string, string>();
|
|
117
|
+
|
|
111
118
|
for (const group of content.groups) {
|
|
112
119
|
if (nodes.has(group.id) || groups.has(group.id)) {
|
|
113
120
|
return `The group id "${group.id}" is already taken`;
|
|
114
121
|
}
|
|
122
|
+
|
|
115
123
|
groups.add(group.id);
|
|
124
|
+
|
|
116
125
|
for (const id of group.wraps) {
|
|
117
126
|
if (!nodes.has(id)) {
|
|
118
127
|
return `The group "${group.id}" wraps "${id}", which is not a node`;
|
|
119
128
|
}
|
|
129
|
+
|
|
120
130
|
const other = groupOf.get(id);
|
|
131
|
+
|
|
121
132
|
if (other !== undefined) {
|
|
122
133
|
return `The node "${id}" is in two groups, "${other}" and "${group.id}"`;
|
|
123
134
|
}
|
|
135
|
+
|
|
124
136
|
groupOf.set(id, group.id);
|
|
125
137
|
}
|
|
126
138
|
}
|
|
139
|
+
|
|
127
140
|
for (const edge of content.edges) {
|
|
128
141
|
for (const end of [edge.from, edge.to]) {
|
|
129
142
|
if (!nodes.has(end)) {
|
|
@@ -131,5 +144,6 @@ export function diagramProblemIn(content: Pick<DiagramContent, 'nodes' | 'groups
|
|
|
131
144
|
}
|
|
132
145
|
}
|
|
133
146
|
}
|
|
147
|
+
|
|
134
148
|
return null;
|
|
135
149
|
}
|
package/src/reading-order.ts
CHANGED
|
@@ -13,14 +13,19 @@ function nameOf(node: DiagramNode | undefined, id: string): string {
|
|
|
13
13
|
export function readingOrder(document: Pick<DiagramDocument, 'nodes' | 'groups' | 'edges'>): string[] {
|
|
14
14
|
const byId = new Map(document.nodes.map((node) => [node.id, node]));
|
|
15
15
|
const layers = layersOf(document.nodes, document.edges);
|
|
16
|
+
|
|
16
17
|
const nodes = document.nodes
|
|
17
18
|
.map((node, index) => ({ node, index, layer: layers.get(node.id) ?? 0 }))
|
|
18
19
|
.sort((left, right) => left.layer - right.layer || left.index - right.index)
|
|
19
20
|
.map(({ node }) => (node.sub ? `${nameOf(node, node.id)} (${node.sub})` : nameOf(node, node.id)));
|
|
21
|
+
|
|
20
22
|
const edges = document.edges.map((edge) => {
|
|
21
23
|
const line = `${nameOf(byId.get(edge.from), edge.from)} -> ${nameOf(byId.get(edge.to), edge.to)}`;
|
|
24
|
+
|
|
22
25
|
return edge.label ? `${line}: ${edge.label}` : line;
|
|
23
26
|
});
|
|
27
|
+
|
|
24
28
|
const groups = document.groups.map((group) => `${group.label || group.id} wraps: ${group.wraps.map((id) => nameOf(byId.get(id), id)).join(', ')}`);
|
|
29
|
+
|
|
25
30
|
return [...nodes, ...edges, ...groups];
|
|
26
31
|
}
|
package/src/route.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { intersects } from '@adecore/drawing';
|
|
1
2
|
import type { Point, Rect } from './layout.ts';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const TRACK_GAP = 12;
|
|
4
5
|
/* Space between the lane of labels and the first track. */
|
|
5
6
|
const LANE_GAP = 12;
|
|
6
7
|
/* Space between two labels side by side in a lane. */
|
|
7
8
|
const LABEL_GAP = 8;
|
|
8
9
|
/* How far a label keeps from the line it belongs to. */
|
|
9
10
|
const LABEL_OFFSET = 4;
|
|
10
|
-
|
|
11
|
+
const MIN_CHANNEL = 64;
|
|
11
12
|
/* Two horizontal lines closer than this read as one. */
|
|
12
13
|
const COLLINEAR = 4;
|
|
13
14
|
/* Two vertical ranges closer than this need tracks of their own. */
|
|
@@ -48,10 +49,6 @@ export interface ChannelResult {
|
|
|
48
49
|
labels: Map<number, Rect>;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
function overlaps(left: Rect, right: Rect): boolean {
|
|
52
|
-
return left.x < right.x + right.w && right.x < left.x + left.w && left.y < right.y + right.h && right.y < left.y + left.h;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
52
|
function inflate(rect: Rect, by: number): Rect {
|
|
56
53
|
return { x: rect.x - by, y: rect.y - by, w: rect.w + by * 2, h: rect.h + by * 2 };
|
|
57
54
|
}
|
|
@@ -60,31 +57,39 @@ function inflate(rect: Rect, by: number): Rect {
|
|
|
60
57
|
* How badly two orthogonal polylines get in each other's way: a crossing counts once, a stretch where
|
|
61
58
|
* they run on top of each other counts four times, since that is the one a reader cannot follow.
|
|
62
59
|
*/
|
|
63
|
-
|
|
60
|
+
function conflictsOf(left: readonly Point[], right: readonly Point[]): number {
|
|
64
61
|
let total = 0;
|
|
62
|
+
|
|
65
63
|
for (let i = 1; i < left.length; i++) {
|
|
66
64
|
const a0 = left[i - 1]!;
|
|
67
65
|
const a1 = left[i]!;
|
|
66
|
+
|
|
68
67
|
for (let j = 1; j < right.length; j++) {
|
|
69
68
|
const b0 = right[j - 1]!;
|
|
70
69
|
const b1 = right[j]!;
|
|
71
70
|
const aFlat = a0.y === a1.y;
|
|
72
71
|
const bFlat = b0.y === b1.y;
|
|
72
|
+
|
|
73
73
|
if (aFlat !== bFlat) {
|
|
74
74
|
const [h0, h1, v0, v1] = aFlat ? [a0, a1, b0, b1] : [b0, b1, a0, a1];
|
|
75
|
+
|
|
75
76
|
if (v0.x > Math.min(h0.x, h1.x) && v0.x < Math.max(h0.x, h1.x) && h0.y > Math.min(v0.y, v1.y) && h0.y < Math.max(v0.y, v1.y)) {
|
|
76
77
|
total += 1;
|
|
77
78
|
}
|
|
79
|
+
|
|
78
80
|
continue;
|
|
79
81
|
}
|
|
82
|
+
|
|
80
83
|
const [along0, along1, other0, other1, gap] = aFlat
|
|
81
84
|
? [Math.min(a0.x, a1.x), Math.max(a0.x, a1.x), Math.min(b0.x, b1.x), Math.max(b0.x, b1.x), Math.abs(a0.y - b0.y)]
|
|
82
85
|
: [Math.min(a0.y, a1.y), Math.max(a0.y, a1.y), Math.min(b0.y, b1.y), Math.max(b0.y, b1.y), Math.abs(a0.x - b0.x)];
|
|
86
|
+
|
|
83
87
|
if (gap < COLLINEAR && Math.min(along1, other1) > Math.max(along0, other0)) {
|
|
84
88
|
total += 4;
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
91
|
}
|
|
92
|
+
|
|
88
93
|
return total;
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -107,11 +112,14 @@ function sketchOf(hop: ChannelHop, track: number): Point[] {
|
|
|
107
112
|
export function routeChannel(input: ChannelInput): ChannelResult {
|
|
108
113
|
const vertical = input.hops.filter((hop) => hop.ya !== hop.yb);
|
|
109
114
|
const count = vertical.length;
|
|
115
|
+
|
|
110
116
|
const near = (left: ChannelHop, right: ChannelHop): boolean =>
|
|
111
117
|
Math.min(Math.max(left.ya, left.yb), Math.max(right.ya, right.yb)) + RANGE_CLEARANCE >
|
|
112
118
|
Math.max(Math.min(left.ya, left.yb), Math.min(right.ya, right.yb));
|
|
119
|
+
|
|
113
120
|
// cost[i * count + j]: how much i and j conflict when i's track is left of j's.
|
|
114
121
|
const cost = new Array<number>(count * count).fill(0);
|
|
122
|
+
|
|
115
123
|
for (let i = 0; i < count; i++) {
|
|
116
124
|
for (let j = 0; j < count; j++) {
|
|
117
125
|
if (i !== j && near(vertical[i]!, vertical[j]!)) {
|
|
@@ -119,39 +127,52 @@ export function routeChannel(input: ChannelInput): ChannelResult {
|
|
|
119
127
|
}
|
|
120
128
|
}
|
|
121
129
|
}
|
|
130
|
+
|
|
122
131
|
const score = vertical.map((_, i) => {
|
|
123
132
|
let sum = 0;
|
|
133
|
+
|
|
124
134
|
for (let j = 0; j < count; j++) {
|
|
125
135
|
sum += cost[i * count + j]! - cost[j * count + i]!;
|
|
126
136
|
}
|
|
137
|
+
|
|
127
138
|
return sum;
|
|
128
139
|
});
|
|
140
|
+
|
|
129
141
|
const order = vertical.map((_, i) => i).sort((left, right) => score[left]! - score[right]! || left - right);
|
|
142
|
+
|
|
130
143
|
for (let pass = 0; pass < count; pass++) {
|
|
131
144
|
let swapped = false;
|
|
145
|
+
|
|
132
146
|
for (let slot = 0; slot + 1 < count; slot++) {
|
|
133
147
|
const left = order[slot]!;
|
|
134
148
|
const right = order[slot + 1]!;
|
|
149
|
+
|
|
135
150
|
if (cost[right * count + left]! < cost[left * count + right]!) {
|
|
136
151
|
order[slot] = right;
|
|
137
152
|
order[slot + 1] = left;
|
|
138
153
|
swapped = true;
|
|
139
154
|
}
|
|
140
155
|
}
|
|
156
|
+
|
|
141
157
|
if (!swapped) {
|
|
142
158
|
break;
|
|
143
159
|
}
|
|
144
160
|
}
|
|
161
|
+
|
|
145
162
|
const track = new Array<number>(count).fill(0);
|
|
146
163
|
let tracks = 0;
|
|
164
|
+
|
|
147
165
|
order.forEach((index, slot) => {
|
|
148
166
|
let at = 0;
|
|
167
|
+
|
|
149
168
|
for (let earlier = 0; earlier < slot; earlier++) {
|
|
150
169
|
const other = order[earlier]!;
|
|
170
|
+
|
|
151
171
|
if (near(vertical[other]!, vertical[index]!)) {
|
|
152
172
|
at = Math.max(at, track[other]! + 1);
|
|
153
173
|
}
|
|
154
174
|
}
|
|
175
|
+
|
|
155
176
|
track[index] = at;
|
|
156
177
|
tracks = Math.max(tracks, at + 1);
|
|
157
178
|
});
|
|
@@ -171,25 +192,31 @@ export function routeChannel(input: ChannelInput): ChannelResult {
|
|
|
171
192
|
|
|
172
193
|
const touches = (x0: number, x1: number, y0: number, y1: number, rect: Rect): boolean =>
|
|
173
194
|
x1 >= rect.x - 3 && x0 <= rect.x + rect.w + 3 && y1 >= rect.y - 3 && y0 <= rect.y + rect.h + 3;
|
|
195
|
+
|
|
174
196
|
const blocked = (zone: Zone, rect: Rect): boolean =>
|
|
175
|
-
placed.some((other) => other.zone === zone &&
|
|
197
|
+
placed.some((other) => other.zone === zone && intersects(inflate(other.rect, 4), rect)) ||
|
|
176
198
|
input.obstacles.some((obstacle) => obstacle.y < rect.y + rect.h && rect.y < obstacle.y + obstacle.h);
|
|
199
|
+
|
|
177
200
|
const onLine = (zone: Zone, rect: Rect, label: ChannelLabel): boolean =>
|
|
178
201
|
input.hops.some((hop) => {
|
|
179
202
|
if (zone === 'before') {
|
|
180
203
|
return hop.hop !== label.hop && hop.ya > rect.y - 3 && hop.ya < rect.y + rect.h + 3;
|
|
181
204
|
}
|
|
205
|
+
|
|
182
206
|
const own = hop.hop === label.hop;
|
|
183
207
|
const at = trackOf.get(hop.hop);
|
|
208
|
+
|
|
184
209
|
if (at === undefined) {
|
|
185
210
|
return !own && touches(-Infinity, Infinity, hop.ya, hop.ya, rect);
|
|
186
211
|
}
|
|
212
|
+
|
|
187
213
|
return (
|
|
188
214
|
(!own && touches(-Infinity, at, hop.ya, hop.ya, rect)) ||
|
|
189
215
|
(!own && touches(at, at, Math.min(hop.ya, hop.yb), Math.max(hop.ya, hop.yb), rect)) ||
|
|
190
216
|
touches(at, Infinity, hop.yb, hop.yb, rect)
|
|
191
217
|
);
|
|
192
218
|
});
|
|
219
|
+
|
|
193
220
|
const inLane = (label: ChannelLabel, x: number, y: number, above: boolean): Rect => ({
|
|
194
221
|
x: x + Math.floor((slotAlong - label.along) / 2),
|
|
195
222
|
y: above ? y - LABEL_OFFSET - label.across : y + LABEL_OFFSET,
|
|
@@ -199,10 +226,14 @@ export function routeChannel(input: ChannelInput): ChannelResult {
|
|
|
199
226
|
|
|
200
227
|
for (const label of input.labels) {
|
|
201
228
|
const hop = input.hops.find((candidate) => candidate.hop === label.hop);
|
|
229
|
+
|
|
202
230
|
const before = (slot: number) => [true, false].map((above) => ({ zone: 'before' as Zone, rect: inLane(label, slot * slotStep, label.y, above) }));
|
|
231
|
+
|
|
203
232
|
const after = (slot: number) =>
|
|
204
233
|
hop ? [true, false].map((above) => ({ zone: 'middle' as Zone, rect: inLane(label, afterStart + slot * slotStep, hop.yb, above) })) : [];
|
|
234
|
+
|
|
205
235
|
const at = trackOf.get(label.hop);
|
|
236
|
+
|
|
206
237
|
const beside =
|
|
207
238
|
hop && at !== undefined
|
|
208
239
|
? [
|
|
@@ -212,32 +243,42 @@ export function routeChannel(input: ChannelInput): ChannelResult {
|
|
|
212
243
|
}
|
|
213
244
|
]
|
|
214
245
|
: [];
|
|
246
|
+
|
|
215
247
|
const existing = [
|
|
216
248
|
...Array.from({ length: slotsBefore }, (_, slot) => before(slot)).flat(),
|
|
217
249
|
...beside,
|
|
218
250
|
...Array.from({ length: slotsAfter }, (_, slot) => after(slot)).flat()
|
|
219
251
|
];
|
|
252
|
+
|
|
220
253
|
const clear = (candidate: { zone: Zone; rect: Rect }): boolean =>
|
|
221
254
|
!blocked(candidate.zone, candidate.rect) && !onLine(candidate.zone, candidate.rect, label);
|
|
255
|
+
|
|
222
256
|
let choice = existing.find(clear);
|
|
257
|
+
|
|
223
258
|
if (!choice) {
|
|
224
259
|
choice = before(slotsBefore).find(clear);
|
|
260
|
+
|
|
225
261
|
if (choice) {
|
|
226
262
|
slotsBefore += 1;
|
|
227
263
|
}
|
|
228
264
|
}
|
|
265
|
+
|
|
229
266
|
if (!choice) {
|
|
230
267
|
choice = after(slotsAfter).find(clear);
|
|
268
|
+
|
|
231
269
|
if (choice) {
|
|
232
270
|
slotsAfter += 1;
|
|
233
271
|
}
|
|
234
272
|
}
|
|
273
|
+
|
|
235
274
|
// A place beside no line may not exist, since every line in a lane runs its whole width; a free place is enough.
|
|
236
275
|
choice ??= existing.find((candidate) => !blocked(candidate.zone, candidate.rect));
|
|
276
|
+
|
|
237
277
|
if (!choice) {
|
|
238
278
|
choice = before(slotsBefore)[0]!;
|
|
239
279
|
slotsBefore += 1;
|
|
240
280
|
}
|
|
281
|
+
|
|
241
282
|
placed.push(choice);
|
|
242
283
|
chosen.set(label.edge, choice);
|
|
243
284
|
}
|
|
@@ -246,11 +287,13 @@ export function routeChannel(input: ChannelInput): ChannelResult {
|
|
|
246
287
|
const laneWidth = Math.max(0, ...placed.filter((entry) => entry.zone === 'before').map((entry) => entry.rect.x + entry.rect.w));
|
|
247
288
|
const middleLabels = placed.filter((entry) => entry.zone === 'middle');
|
|
248
289
|
const hasMiddle = tracks > 0 || middleLabels.length > 0;
|
|
290
|
+
|
|
249
291
|
const middleWidth = Math.max(
|
|
250
292
|
tracksSpan,
|
|
251
293
|
slotsAfter > 0 ? afterStart + slotsAfter * slotStep - LABEL_GAP : 0,
|
|
252
294
|
...middleLabels.map((entry) => entry.rect.x + entry.rect.w)
|
|
253
295
|
);
|
|
296
|
+
|
|
254
297
|
const laneGap = laneWidth > 0 && hasMiddle ? LANE_GAP : 0;
|
|
255
298
|
const needed = input.clearBefore + laneWidth + laneGap + (hasMiddle ? middleWidth : 0) + input.clearAfter;
|
|
256
299
|
const width = Math.max(MIN_CHANNEL, needed);
|
|
@@ -259,8 +302,10 @@ export function routeChannel(input: ChannelInput): ChannelResult {
|
|
|
259
302
|
const trackX = new Map<number, number>();
|
|
260
303
|
trackOf.forEach((x, hop) => trackX.set(hop, middleStart + x));
|
|
261
304
|
const labels = new Map<number, Rect>();
|
|
305
|
+
|
|
262
306
|
chosen.forEach((entry, edge) => {
|
|
263
307
|
labels.set(edge, { ...entry.rect, x: entry.rect.x + (entry.zone === 'before' ? laneStart : middleStart) });
|
|
264
308
|
});
|
|
309
|
+
|
|
265
310
|
return { width, trackX, labels };
|
|
266
311
|
}
|
package/src/shapes.ts
CHANGED
|
@@ -18,6 +18,7 @@ function roundedRect(box: Rect, radius: number): string {
|
|
|
18
18
|
const corner = Math.round(Math.min(radius, box.w / 2, box.h / 2));
|
|
19
19
|
const right = box.x + box.w;
|
|
20
20
|
const bottom = box.y + box.h;
|
|
21
|
+
|
|
21
22
|
return [
|
|
22
23
|
`M${box.x + corner} ${box.y}`,
|
|
23
24
|
`H${right - corner}`,
|
|
@@ -37,6 +38,7 @@ export function shapePaths(shape: DiagramShape | undefined, box: Rect): ShapePat
|
|
|
37
38
|
const bottom = box.y + box.h;
|
|
38
39
|
const middleX = Math.round(box.x + box.w / 2);
|
|
39
40
|
const middleY = Math.round(box.y + box.h / 2);
|
|
41
|
+
|
|
40
42
|
switch (shape ?? 'rect') {
|
|
41
43
|
case 'round':
|
|
42
44
|
return { body: roundedRect(box, ROUND_RADIUS), detail: null };
|
|
@@ -50,6 +52,7 @@ export function shapePaths(shape: DiagramShape | undefined, box: Rect): ShapePat
|
|
|
50
52
|
const ry = Math.round(lid / 2);
|
|
51
53
|
const top = box.y + ry;
|
|
52
54
|
const base = bottom - ry;
|
|
55
|
+
|
|
53
56
|
return {
|
|
54
57
|
body: `M${box.x} ${top} A${rx} ${ry} 0 0 1 ${right} ${top} V${base} A${rx} ${ry} 0 0 1 ${box.x} ${base} Z`,
|
|
55
58
|
detail: `M${box.x} ${top} A${rx} ${ry} 0 0 0 ${right} ${top}`
|
|
@@ -77,11 +80,13 @@ export function textLinesOf(box: Pick<NodeBox, 'x' | 'y' | 'w' | 'h' | 'label' |
|
|
|
77
80
|
const label = box.label.map((text, index) => ({ text, x, y: top + index * LABEL_LINE + 14, size: LABEL_SIZE, bold: true, muted: false }));
|
|
78
81
|
const subTop = top + box.label.length * LABEL_LINE;
|
|
79
82
|
const sub = box.sub.map((text, index) => ({ text, x, y: subTop + index * SUB_LINE + 12, size: SUB_SIZE, bold: false, muted: true }));
|
|
83
|
+
|
|
80
84
|
return [...label, ...sub];
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
export function edgeLabelLinesOf(label: EdgeLabelBox): TextLine[] {
|
|
84
88
|
const x = Math.round(label.x + label.w / 2);
|
|
89
|
+
|
|
85
90
|
return label.lines.map((text, index) => ({
|
|
86
91
|
text,
|
|
87
92
|
x,
|
|
@@ -105,6 +110,7 @@ export function arrowHeadPath(points: readonly Point[]): string {
|
|
|
105
110
|
const baseX = tip.x - ux * HEAD_LENGTH;
|
|
106
111
|
const baseY = tip.y - uy * HEAD_LENGTH;
|
|
107
112
|
const round = Math.round;
|
|
113
|
+
|
|
108
114
|
return `M${tip.x} ${tip.y} L${round(baseX - uy * HEAD_WIDTH)} ${round(baseY + ux * HEAD_WIDTH)} L${round(baseX + uy * HEAD_WIDTH)} ${round(baseY - ux * HEAD_WIDTH)} Z`;
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -112,8 +118,10 @@ export function dashOf(style: DiagramEdgeStyle | undefined): string | null {
|
|
|
112
118
|
if (style === 'dashed') {
|
|
113
119
|
return '8 6';
|
|
114
120
|
}
|
|
121
|
+
|
|
115
122
|
if (style === 'dotted') {
|
|
116
123
|
return '2 5';
|
|
117
124
|
}
|
|
125
|
+
|
|
118
126
|
return null;
|
|
119
127
|
}
|
package/src/svg.ts
CHANGED
|
@@ -41,6 +41,7 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
|
|
|
41
41
|
const w = layout.bounds.w + margin * 2;
|
|
42
42
|
const h = layout.bounds.h + margin * 2;
|
|
43
43
|
const parts: string[] = [];
|
|
44
|
+
|
|
44
45
|
if (options.background) {
|
|
45
46
|
parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${options.background}"/>`);
|
|
46
47
|
}
|
|
@@ -48,6 +49,7 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
|
|
|
48
49
|
for (const box of layout.groups) {
|
|
49
50
|
const group = groups.get(box.id)!;
|
|
50
51
|
const tone = palette[group.tone ?? DEFAULT_GROUP_TONE];
|
|
52
|
+
|
|
51
53
|
parts.push(
|
|
52
54
|
`<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" rx="12" fill="none" stroke="${tone}" stroke-width="1" stroke-dasharray="6 4"/>`,
|
|
53
55
|
`<text x="${box.labelBox.x}" y="${box.y + Math.round(GROUP_LABEL_BAND * 0.7)}" font-size="${SUB_SIZE}" font-weight="600" fill="${tone}">${escapeXml(group.label)}</text>`
|
|
@@ -58,12 +60,15 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
|
|
|
58
60
|
const edge = document.edges[route.index]!;
|
|
59
61
|
const tone = palette[edge.tone ?? DEFAULT_EDGE_TONE];
|
|
60
62
|
const dash = dashOf(edge.style);
|
|
63
|
+
|
|
61
64
|
parts.push(
|
|
62
65
|
`<path d="${edgePath(route.points)}" fill="none" stroke="${tone}" stroke-width="2" stroke-linejoin="round"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`,
|
|
63
66
|
`<path d="${arrowHeadPath(route.points)}" fill="${tone}"/>`
|
|
64
67
|
);
|
|
68
|
+
|
|
65
69
|
if (route.label) {
|
|
66
70
|
const halo = options.background ? ` stroke="${options.background}" stroke-width="4" paint-order="stroke"` : '';
|
|
71
|
+
|
|
67
72
|
for (const line of edgeLabelLinesOf(route.label)) {
|
|
68
73
|
parts.push(
|
|
69
74
|
`<text x="${line.x}" y="${line.y}" font-size="${line.size}" text-anchor="middle" fill="${tone}"${halo}>${escapeXml(line.text)}</text>`
|
|
@@ -77,9 +82,11 @@ export function toSvg(document: Pick<DiagramDocument, 'meta' | 'nodes' | 'groups
|
|
|
77
82
|
const tone = node.tone ?? DEFAULT_NODE_TONE;
|
|
78
83
|
const paths = shapePaths(node.shape, box);
|
|
79
84
|
parts.push(`<path d="${paths.body}" fill="${paper[tone]}" stroke="${palette[tone]}" stroke-width="2"/>`);
|
|
85
|
+
|
|
80
86
|
if (paths.detail) {
|
|
81
87
|
parts.push(`<path d="${paths.detail}" fill="none" stroke="${palette[tone]}" stroke-width="2"/>`);
|
|
82
88
|
}
|
|
89
|
+
|
|
83
90
|
for (const line of textLinesOf(box, node.shape)) {
|
|
84
91
|
parts.push(
|
|
85
92
|
`<text x="${line.x}" y="${line.y}" font-size="${line.size}"${line.bold ? ' font-weight="600"' : ''} text-anchor="middle" fill="${line.muted ? palette.muted : palette.ink}">${escapeXml(line.text)}</text>`
|