@adecore/diagram 0.0.1 → 0.17.0-beta.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/src/order.ts ADDED
@@ -0,0 +1,235 @@
1
+ import { bandCode, bandOfCode, type Layering } from './graph.ts';
2
+
3
+ /* Sweeps of the barycenter heuristic, alternating down and up; the best order any of them reached is kept. */
4
+ const SWEEPS = 12;
5
+
6
+ /*
7
+ * The order inside every layer, in place. A unit moves to the average position of the units it is
8
+ * joined to in the layer it was compared with, a tie keeps the order it had (file order to begin
9
+ * with), and the members of a group stay together as one block. The blocks of groups keep one order
10
+ * in every layer, and a unit that is not in a group stays on the same side of a group from one layer
11
+ * to the next inside it, so no hop has to cross a group it does not belong to.
12
+ */
13
+ export function orderLayers(layering: Layering): void {
14
+ const { units, hops, bands, items } = layering;
15
+ const position = new Array<number>(units.length).fill(0);
16
+ const bandPosition = bands.map((band) => new Array<number>(band.last - band.first + 1).fill(0));
17
+ const hopsByLayer: number[][] = items.map(() => []);
18
+ hops.forEach((hop, index) => {
19
+ hopsByLayer[units[hop.from]!.layer]!.push(index);
20
+ });
21
+
22
+ const flatten = (layer: number): void => {
23
+ let at = 0;
24
+ for (const code of items[layer]!) {
25
+ if (code >= 0) {
26
+ position[code] = at++;
27
+ continue;
28
+ }
29
+ const band = bands[bandOfCode(code)]!;
30
+ const members = band.members[layer - band.first]!;
31
+ bandPosition[bandOfCode(code)]![layer - band.first] = at;
32
+ if (members.length === 0) {
33
+ at++;
34
+ }
35
+ for (const member of members) {
36
+ position[member] = at++;
37
+ }
38
+ }
39
+ };
40
+
41
+ const enforceBandOrder = (layer: number): void => {
42
+ const row = items[layer]!;
43
+ const slots: number[] = [];
44
+ const present: number[] = [];
45
+ row.forEach((code, index) => {
46
+ if (code < 0) {
47
+ slots.push(index);
48
+ present.push(bandOfCode(code));
49
+ }
50
+ });
51
+ present.sort((left, right) => bands[left]!.rank - bands[right]!.rank);
52
+ slots.forEach((slot, index) => {
53
+ row[slot] = bandCode(present[index]!);
54
+ });
55
+ };
56
+
57
+ const sortLayer = (layer: number, towardsUp: boolean): void => {
58
+ const keyOf = (unit: number): number => {
59
+ const neighbors = towardsUp ? units[unit]!.up : units[unit]!.down;
60
+ if (neighbors.length === 0) {
61
+ return position[unit]!;
62
+ }
63
+ let sum = 0;
64
+ for (const neighbor of neighbors) {
65
+ sum += position[neighbor]!;
66
+ }
67
+ return sum / neighbors.length;
68
+ };
69
+ const keyed = items[layer]!.map((code, index) => {
70
+ if (code >= 0) {
71
+ return { code, index, key: keyOf(code) };
72
+ }
73
+ const band = bands[bandOfCode(code)]!;
74
+ const slot = layer - band.first;
75
+ const members = band.members[slot]!.map((unit) => ({ unit, key: keyOf(unit), at: position[unit]! }));
76
+ members.sort((left, right) => left.key - right.key || left.at - right.at);
77
+ band.members[slot] = members.map((member) => member.unit);
78
+ const key = members.length > 0 ? members.reduce((sum, member) => sum + member.key, 0) / members.length : bandPosition[bandOfCode(code)]![slot]!;
79
+ return { code, index, key };
80
+ });
81
+ keyed.sort((left, right) => left.key - right.key || left.index - right.index);
82
+ items[layer] = keyed.map((entry) => entry.code);
83
+ enforceBandOrder(layer);
84
+ flatten(layer);
85
+ };
86
+
87
+ const updateRanks = (): void => {
88
+ const scores = bands.map((band, index) => {
89
+ let sum = 0;
90
+ for (let layer = band.first; layer <= band.last; layer++) {
91
+ const row = items[layer]!;
92
+ sum += row.indexOf(bandCode(index)) / row.length;
93
+ }
94
+ return sum / (band.last - band.first + 1);
95
+ });
96
+ const order = bands.map((_, index) => index).sort((left, right) => scores[left]! - scores[right]! || bands[left]!.rank - bands[right]!.rank);
97
+ order.forEach((band, rank) => {
98
+ bands[band]!.rank = rank;
99
+ });
100
+ };
101
+
102
+ // A free unit takes the side of every group its neighbors are on, as far as they agree.
103
+ const keepSides = (): void => {
104
+ for (let layer = 0; layer < items.length; layer++) {
105
+ if (!items[layer]!.some((code) => code < 0)) {
106
+ continue;
107
+ }
108
+ const previousIndex = new Map<number, number>();
109
+ (items[layer - 1] ?? []).forEach((code, index) => previousIndex.set(code, index));
110
+ const free = items[layer]!.filter((code) => code >= 0);
111
+ for (const unit of free) {
112
+ const row = items[layer]!;
113
+ const present = row.filter((code) => code < 0).map(bandOfCode);
114
+ let lower = 0;
115
+ let upper = present.length;
116
+ const narrow = (source: number, sourceLayer: number): void => {
117
+ let low = 0;
118
+ let high = present.length;
119
+ present.forEach((band, slot) => {
120
+ const target = bands[band]!;
121
+ const sourceBand = units[source]!.band;
122
+ if (sourceLayer < target.first || sourceLayer > target.last || sourceBand === band) {
123
+ return;
124
+ }
125
+ const before =
126
+ sourceBand >= 0 ? bands[sourceBand]!.rank < target.rank : previousIndex.get(source)! < previousIndex.get(bandCode(band))!;
127
+ if (before) {
128
+ high = Math.min(high, slot);
129
+ } else {
130
+ low = Math.max(low, slot + 1);
131
+ }
132
+ });
133
+ if (Math.max(lower, low) <= Math.min(upper, high)) {
134
+ lower = Math.max(lower, low);
135
+ upper = Math.min(upper, high);
136
+ }
137
+ };
138
+ for (const source of units[unit]!.up) {
139
+ narrow(source, layer - 1);
140
+ }
141
+ // A member of a group in the next layer cannot move, so the free unit follows it instead.
142
+ for (const target of units[unit]!.down) {
143
+ if (units[target]!.band >= 0) {
144
+ narrow(target, layer + 1);
145
+ }
146
+ }
147
+ const at = row.indexOf(unit);
148
+ const slot = row.slice(0, at).filter((code) => code < 0).length;
149
+ if (slot < lower) {
150
+ row.splice(at, 1);
151
+ row.splice(row.indexOf(bandCode(present[lower - 1]!)) + 1, 0, unit);
152
+ } else if (slot > upper) {
153
+ row.splice(at, 1);
154
+ row.splice(row.indexOf(bandCode(present[upper]!)), 0, unit);
155
+ }
156
+ }
157
+ }
158
+ };
159
+
160
+ const crossings = (): number => {
161
+ let total = 0;
162
+ for (let layer = 0; layer + 1 < items.length; layer++) {
163
+ const pairs = hopsByLayer[layer]!.map((index) => [position[hops[index]!.from]!, position[hops[index]!.to]!] as const);
164
+ if (pairs.length < 2) {
165
+ continue;
166
+ }
167
+ pairs.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
168
+ // A Fenwick tree over the positions of the next layer counts the pairs that come in out of order.
169
+ const size = pairs.reduce((largest, pair) => Math.max(largest, pair[1]), 0) + 2;
170
+ const tree = new Array<number>(size + 1).fill(0);
171
+ pairs.forEach(([, to], seen) => {
172
+ let atMost = 0;
173
+ for (let i = to + 1; i > 0; i -= i & -i) {
174
+ atMost += tree[i]!;
175
+ }
176
+ total += seen - atMost;
177
+ for (let i = to + 1; i <= size; i += i & -i) {
178
+ tree[i]! += 1;
179
+ }
180
+ });
181
+ }
182
+ return total;
183
+ };
184
+
185
+ const settle = (): number => {
186
+ items.forEach((_, layer) => enforceBandOrder(layer));
187
+ keepSides();
188
+ items.forEach((_, layer) => flatten(layer));
189
+ return crossings();
190
+ };
191
+
192
+ const snapshot = () => ({
193
+ items: items.map((row) => row.slice()),
194
+ members: bands.map((band) => band.members.map((slot) => slot.slice())),
195
+ ranks: bands.map((band) => band.rank)
196
+ });
197
+
198
+ items.forEach((_, layer) => flatten(layer));
199
+ // A band's first layer always holds a member, which is what made it the first.
200
+ const firstSeen = bands.map((band, index) => [band.first, position[band.members[0]![0]!]!, index] as const);
201
+ firstSeen
202
+ .slice()
203
+ .sort((left, right) => left[0] - right[0] || left[1] - right[1] || left[2] - right[2])
204
+ .forEach(([, , band], rank) => {
205
+ bands[band]!.rank = rank;
206
+ });
207
+
208
+ let bestCount = settle();
209
+ let best = snapshot();
210
+ for (let sweep = 0; sweep < SWEEPS && bestCount > 0; sweep++) {
211
+ if (sweep % 2 === 0) {
212
+ for (let layer = 1; layer < items.length; layer++) {
213
+ sortLayer(layer, true);
214
+ }
215
+ } else {
216
+ for (let layer = items.length - 2; layer >= 0; layer--) {
217
+ sortLayer(layer, false);
218
+ }
219
+ }
220
+ updateRanks();
221
+ const count = settle();
222
+ if (count < bestCount) {
223
+ bestCount = count;
224
+ best = snapshot();
225
+ }
226
+ }
227
+
228
+ best.items.forEach((row, layer) => {
229
+ items[layer] = row;
230
+ });
231
+ bands.forEach((band, index) => {
232
+ band.members = best.members[index]!;
233
+ band.rank = best.ranks[index]!;
234
+ });
235
+ }
@@ -0,0 +1,135 @@
1
+ import { z } from 'zod';
2
+ import { DrawingColorSchema, DrawingStrokeStyleSchema } from '@adecore/drawing/protocol';
3
+
4
+ /*
5
+ * A diagram is a directed graph with groups, written rather than drawn: the file says what is
6
+ * connected to what and the layout is computed from that. Colors are the drawing palette's names,
7
+ * so a diagram follows the theme the way a drawing does.
8
+ */
9
+ export const DiagramShapeSchema = z.enum(['rect', 'round', 'pill', 'diamond', 'cylinder']);
10
+ export type DiagramShape = z.infer<typeof DiagramShapeSchema>;
11
+
12
+ export const DIAGRAM_SHAPES = DiagramShapeSchema.options;
13
+
14
+ export const DiagramDirectionSchema = z.enum(['right', 'down']);
15
+ export type DiagramDirection = z.infer<typeof DiagramDirectionSchema>;
16
+
17
+ export const DiagramEdgeStyleSchema = DrawingStrokeStyleSchema;
18
+ export type DiagramEdgeStyle = z.infer<typeof DiagramEdgeStyleSchema>;
19
+
20
+ export const DiagramNodeSchema = z.object({
21
+ id: z.string().min(1),
22
+ label: z.string(),
23
+ sub: z.string().optional(),
24
+ // Absent reads as 'rect'.
25
+ shape: DiagramShapeSchema.optional(),
26
+ tone: DrawingColorSchema.optional(),
27
+ // Only on a node a person dragged; every other node goes where the layout puts it.
28
+ pos: z.tuple([z.number(), z.number()]).optional()
29
+ });
30
+ export type DiagramNode = z.infer<typeof DiagramNodeSchema>;
31
+
32
+ export const DiagramGroupSchema = z.object({
33
+ id: z.string().min(1),
34
+ label: z.string(),
35
+ // Node ids, never group ids: a group is a box around nodes and does not nest.
36
+ wraps: z.array(z.string().min(1)),
37
+ tone: DrawingColorSchema.optional()
38
+ });
39
+ export type DiagramGroup = z.infer<typeof DiagramGroupSchema>;
40
+
41
+ export const DiagramEdgeSchema = z.object({
42
+ from: z.string().min(1),
43
+ to: z.string().min(1),
44
+ label: z.string().optional(),
45
+ // Absent reads as 'solid'.
46
+ style: DiagramEdgeStyleSchema.optional(),
47
+ tone: DrawingColorSchema.optional()
48
+ });
49
+ export type DiagramEdge = z.infer<typeof DiagramEdgeSchema>;
50
+
51
+ export const DiagramMetaSchema = z.object({
52
+ title: z.string(),
53
+ direction: DiagramDirectionSchema
54
+ });
55
+ export type DiagramMeta = z.infer<typeof DiagramMetaSchema>;
56
+
57
+ export const DIAGRAM_VERSION = 1;
58
+
59
+ // Edges spanning many layers make layout costs grow quickly, so validation caps graph size.
60
+ export const DIAGRAM_LIMITS = {
61
+ nodes: 100,
62
+ edges: 120
63
+ };
64
+
65
+ export const DiagramContentSchema = z.object({
66
+ meta: DiagramMetaSchema,
67
+ // In file order, which is also the order inside a layer of the layout.
68
+ nodes: z.array(DiagramNodeSchema),
69
+ groups: z.array(DiagramGroupSchema),
70
+ edges: z.array(DiagramEdgeSchema)
71
+ });
72
+ export type DiagramContent = z.infer<typeof DiagramContentSchema>;
73
+
74
+ export const DiagramDocumentSchema = DiagramContentSchema.extend({
75
+ version: z.literal(DIAGRAM_VERSION),
76
+ // Goes up by one on every write; a save that names an older rev is a conflict.
77
+ rev: z.number().int().nonnegative()
78
+ });
79
+ export type DiagramDocument = z.infer<typeof DiagramDocumentSchema>;
80
+
81
+ export const EMPTY_DIAGRAM: DiagramDocument = { version: 1, rev: 0, meta: { title: '', direction: 'right' }, nodes: [], groups: [], edges: [] };
82
+
83
+ /* One version so far, so reading is a parse. A file that is not a diagram at all reads as null. */
84
+ export function migrateDiagram(value: unknown): DiagramDocument | null {
85
+ const parsed = DiagramDocumentSchema.safeParse(value);
86
+ return parsed.success ? parsed.data : null;
87
+ }
88
+
89
+ /*
90
+ * The first rule of the graph the content breaks, as a sentence that names the id, or null. The
91
+ * schema cannot say this, and an agent that wrote the file needs the id to repair it: a silent drop
92
+ * would leave it believing the edge is there.
93
+ */
94
+ export function diagramProblemIn(content: Pick<DiagramContent, 'nodes' | 'groups' | 'edges'>): string | null {
95
+ if (content.nodes.length > DIAGRAM_LIMITS.nodes) {
96
+ return `A diagram holds at most ${DIAGRAM_LIMITS.nodes} nodes, and this one has ${content.nodes.length}`;
97
+ }
98
+ if (content.edges.length > DIAGRAM_LIMITS.edges) {
99
+ return `A diagram holds at most ${DIAGRAM_LIMITS.edges} edges, and this one has ${content.edges.length}`;
100
+ }
101
+ // Nodes and groups share one namespace, so an id always says which box it means.
102
+ const nodes = new Set<string>();
103
+ for (const node of content.nodes) {
104
+ if (nodes.has(node.id)) {
105
+ return `Two nodes share the id "${node.id}"`;
106
+ }
107
+ nodes.add(node.id);
108
+ }
109
+ const groups = new Set<string>();
110
+ const groupOf = new Map<string, string>();
111
+ for (const group of content.groups) {
112
+ if (nodes.has(group.id) || groups.has(group.id)) {
113
+ return `The group id "${group.id}" is already taken`;
114
+ }
115
+ groups.add(group.id);
116
+ for (const id of group.wraps) {
117
+ if (!nodes.has(id)) {
118
+ return `The group "${group.id}" wraps "${id}", which is not a node`;
119
+ }
120
+ const other = groupOf.get(id);
121
+ if (other !== undefined) {
122
+ return `The node "${id}" is in two groups, "${other}" and "${group.id}"`;
123
+ }
124
+ groupOf.set(id, group.id);
125
+ }
126
+ }
127
+ for (const edge of content.edges) {
128
+ for (const end of [edge.from, edge.to]) {
129
+ if (!nodes.has(end)) {
130
+ return `The edge from "${edge.from}" to "${edge.to}" names "${end}", which is not a node`;
131
+ }
132
+ }
133
+ }
134
+ return null;
135
+ }
@@ -0,0 +1,26 @@
1
+ import type { DiagramDocument, DiagramNode } from './protocol.ts';
2
+ import { layersOf } from './layout.ts';
3
+
4
+ function nameOf(node: DiagramNode | undefined, id: string): string {
5
+ return node && node.label.trim() !== '' ? node.label : id;
6
+ }
7
+
8
+ /*
9
+ * A diagram as lines an agent can read: every node layer by layer (file order inside a layer), then
10
+ * every edge as the two names it joins, then what each group holds. Unlike a drawing nothing has to
11
+ * be guessed, since the file already says what points at what.
12
+ */
13
+ export function readingOrder(document: Pick<DiagramDocument, 'nodes' | 'groups' | 'edges'>): string[] {
14
+ const byId = new Map(document.nodes.map((node) => [node.id, node]));
15
+ const layers = layersOf(document.nodes, document.edges);
16
+ const nodes = document.nodes
17
+ .map((node, index) => ({ node, index, layer: layers.get(node.id) ?? 0 }))
18
+ .sort((left, right) => left.layer - right.layer || left.index - right.index)
19
+ .map(({ node }) => (node.sub ? `${nameOf(node, node.id)} (${node.sub})` : nameOf(node, node.id)));
20
+ const edges = document.edges.map((edge) => {
21
+ const line = `${nameOf(byId.get(edge.from), edge.from)} -> ${nameOf(byId.get(edge.to), edge.to)}`;
22
+ return edge.label ? `${line}: ${edge.label}` : line;
23
+ });
24
+ const groups = document.groups.map((group) => `${group.label || group.id} wraps: ${group.wraps.map((id) => nameOf(byId.get(id), id)).join(', ')}`);
25
+ return [...nodes, ...edges, ...groups];
26
+ }
package/src/route.ts ADDED
@@ -0,0 +1,266 @@
1
+ import type { Point, Rect } from './layout.ts';
2
+
3
+ export const TRACK_GAP = 12;
4
+ /* Space between the lane of labels and the first track. */
5
+ const LANE_GAP = 12;
6
+ /* Space between two labels side by side in a lane. */
7
+ const LABEL_GAP = 8;
8
+ /* How far a label keeps from the line it belongs to. */
9
+ const LABEL_OFFSET = 4;
10
+ export const MIN_CHANNEL = 64;
11
+ /* Two horizontal lines closer than this read as one. */
12
+ const COLLINEAR = 4;
13
+ /* Two vertical ranges closer than this need tracks of their own. */
14
+ const RANGE_CLEARANCE = 8;
15
+
16
+ /* A hop through a channel: it leaves at `ya` and arrives at `yb`, both across the flow. */
17
+ export interface ChannelHop {
18
+ hop: number;
19
+ ya: number;
20
+ yb: number;
21
+ }
22
+
23
+ export interface ChannelLabel {
24
+ edge: number;
25
+ /* The hop it belongs to, whose own line never counts against it. */
26
+ hop: number;
27
+ /* The line it sits beside. */
28
+ y: number;
29
+ along: number;
30
+ across: number;
31
+ }
32
+
33
+ export interface ChannelInput {
34
+ /* Where the channel begins along the flow, right after the widest box of its layer. */
35
+ start: number;
36
+ /* Room kept free at the start and the end, for a group's border and an arrowhead. */
37
+ clearBefore: number;
38
+ clearAfter: number;
39
+ hops: ChannelHop[];
40
+ labels: ChannelLabel[];
41
+ /* Stretches across the flow no label may cover, over the whole channel: group borders and group labels. */
42
+ obstacles: { y: number; h: number }[];
43
+ }
44
+
45
+ export interface ChannelResult {
46
+ width: number;
47
+ trackX: Map<number, number>;
48
+ labels: Map<number, Rect>;
49
+ }
50
+
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
+ function inflate(rect: Rect, by: number): Rect {
56
+ return { x: rect.x - by, y: rect.y - by, w: rect.w + by * 2, h: rect.h + by * 2 };
57
+ }
58
+
59
+ /*
60
+ * How badly two orthogonal polylines get in each other's way: a crossing counts once, a stretch where
61
+ * they run on top of each other counts four times, since that is the one a reader cannot follow.
62
+ */
63
+ export function conflictsOf(left: readonly Point[], right: readonly Point[]): number {
64
+ let total = 0;
65
+ for (let i = 1; i < left.length; i++) {
66
+ const a0 = left[i - 1]!;
67
+ const a1 = left[i]!;
68
+ for (let j = 1; j < right.length; j++) {
69
+ const b0 = right[j - 1]!;
70
+ const b1 = right[j]!;
71
+ const aFlat = a0.y === a1.y;
72
+ const bFlat = b0.y === b1.y;
73
+ if (aFlat !== bFlat) {
74
+ const [h0, h1, v0, v1] = aFlat ? [a0, a1, b0, b1] : [b0, b1, a0, a1];
75
+ 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
+ total += 1;
77
+ }
78
+ continue;
79
+ }
80
+ const [along0, along1, other0, other1, gap] = aFlat
81
+ ? [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
+ : [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)];
83
+ if (gap < COLLINEAR && Math.min(along1, other1) > Math.max(along0, other0)) {
84
+ total += 4;
85
+ }
86
+ }
87
+ }
88
+ return total;
89
+ }
90
+
91
+ /* A hop drawn on a track in a channel of unit tracks, only to compare two orders of the tracks. */
92
+ function sketchOf(hop: ChannelHop, track: number): Point[] {
93
+ return [
94
+ { x: 0, y: hop.ya },
95
+ { x: track * TRACK_GAP, y: hop.ya },
96
+ { x: track * TRACK_GAP, y: hop.yb },
97
+ { x: TRACK_GAP * 3, y: hop.yb }
98
+ ];
99
+ }
100
+
101
+ /*
102
+ * The inside of one channel between two layers. Every hop that changes place across the flow gets a
103
+ * vertical track of its own where its range meets another's, in the order that crosses the fewest
104
+ * lines; labels go in a lane before the tracks, beside the line their hop leaves on, and the lane
105
+ * grows a column where two labels would otherwise cover each other.
106
+ */
107
+ export function routeChannel(input: ChannelInput): ChannelResult {
108
+ const vertical = input.hops.filter((hop) => hop.ya !== hop.yb);
109
+ const count = vertical.length;
110
+ const near = (left: ChannelHop, right: ChannelHop): boolean =>
111
+ Math.min(Math.max(left.ya, left.yb), Math.max(right.ya, right.yb)) + RANGE_CLEARANCE >
112
+ Math.max(Math.min(left.ya, left.yb), Math.min(right.ya, right.yb));
113
+ // cost[i * count + j]: how much i and j conflict when i's track is left of j's.
114
+ const cost = new Array<number>(count * count).fill(0);
115
+ for (let i = 0; i < count; i++) {
116
+ for (let j = 0; j < count; j++) {
117
+ if (i !== j && near(vertical[i]!, vertical[j]!)) {
118
+ cost[i * count + j] = conflictsOf(sketchOf(vertical[i]!, 1), sketchOf(vertical[j]!, 2));
119
+ }
120
+ }
121
+ }
122
+ const score = vertical.map((_, i) => {
123
+ let sum = 0;
124
+ for (let j = 0; j < count; j++) {
125
+ sum += cost[i * count + j]! - cost[j * count + i]!;
126
+ }
127
+ return sum;
128
+ });
129
+ const order = vertical.map((_, i) => i).sort((left, right) => score[left]! - score[right]! || left - right);
130
+ for (let pass = 0; pass < count; pass++) {
131
+ let swapped = false;
132
+ for (let p = 0; p + 1 < count; p++) {
133
+ const left = order[p]!;
134
+ const right = order[p + 1]!;
135
+ if (cost[right * count + left]! < cost[left * count + right]!) {
136
+ order[p] = right;
137
+ order[p + 1] = left;
138
+ swapped = true;
139
+ }
140
+ }
141
+ if (!swapped) {
142
+ break;
143
+ }
144
+ }
145
+ const track = new Array<number>(count).fill(0);
146
+ let tracks = 0;
147
+ order.forEach((index, p) => {
148
+ let at = 0;
149
+ for (let q = 0; q < p; q++) {
150
+ const other = order[q]!;
151
+ if (near(vertical[other]!, vertical[index]!)) {
152
+ at = Math.max(at, track[other]! + 1);
153
+ }
154
+ }
155
+ track[index] = at;
156
+ tracks = Math.max(tracks, at + 1);
157
+ });
158
+
159
+ // Labels are placed in two frames before the width is known: the lane before the tracks, and the tracks with the lane after them.
160
+ const slotAlong = input.labels.reduce((widest, label) => Math.max(widest, label.along), 0);
161
+ const slotStep = slotAlong + LABEL_GAP;
162
+ const tracksSpan = Math.max(0, tracks - 1) * TRACK_GAP;
163
+ const afterStart = tracks > 0 ? tracksSpan + LANE_GAP : 0;
164
+ const trackOf = new Map<number, number>();
165
+ vertical.forEach((hop, index) => trackOf.set(hop.hop, track[index]! * TRACK_GAP));
166
+ type Zone = 'before' | 'middle';
167
+ const placed: { zone: Zone; rect: Rect }[] = [];
168
+ const chosen = new Map<number, { zone: Zone; rect: Rect }>();
169
+ let slotsBefore = input.labels.length > 0 ? 1 : 0;
170
+ let slotsAfter = 0;
171
+
172
+ const touches = (x0: number, x1: number, y0: number, y1: number, rect: Rect): boolean =>
173
+ x1 >= rect.x - 3 && x0 <= rect.x + rect.w + 3 && y1 >= rect.y - 3 && y0 <= rect.y + rect.h + 3;
174
+ const blocked = (zone: Zone, rect: Rect): boolean =>
175
+ placed.some((other) => other.zone === zone && overlaps(inflate(other.rect, 4), rect)) ||
176
+ input.obstacles.some((obstacle) => obstacle.y < rect.y + rect.h && rect.y < obstacle.y + obstacle.h);
177
+ const onLine = (zone: Zone, rect: Rect, label: ChannelLabel): boolean =>
178
+ input.hops.some((hop) => {
179
+ if (zone === 'before') {
180
+ return hop.hop !== label.hop && hop.ya > rect.y - 3 && hop.ya < rect.y + rect.h + 3;
181
+ }
182
+ const own = hop.hop === label.hop;
183
+ const at = trackOf.get(hop.hop);
184
+ if (at === undefined) {
185
+ return !own && touches(-Infinity, Infinity, hop.ya, hop.ya, rect);
186
+ }
187
+ return (
188
+ (!own && touches(-Infinity, at, hop.ya, hop.ya, rect)) ||
189
+ (!own && touches(at, at, Math.min(hop.ya, hop.yb), Math.max(hop.ya, hop.yb), rect)) ||
190
+ touches(at, Infinity, hop.yb, hop.yb, rect)
191
+ );
192
+ });
193
+ const inLane = (label: ChannelLabel, x: number, y: number, above: boolean): Rect => ({
194
+ x: x + Math.floor((slotAlong - label.along) / 2),
195
+ y: above ? y - LABEL_OFFSET - label.across : y + LABEL_OFFSET,
196
+ w: label.along,
197
+ h: label.across
198
+ });
199
+
200
+ for (const label of input.labels) {
201
+ const hop = input.hops.find((candidate) => candidate.hop === label.hop);
202
+ const before = (slot: number) => [true, false].map((above) => ({ zone: 'before' as Zone, rect: inLane(label, slot * slotStep, label.y, above) }));
203
+ const after = (slot: number) =>
204
+ hop ? [true, false].map((above) => ({ zone: 'middle' as Zone, rect: inLane(label, afterStart + slot * slotStep, hop.yb, above) })) : [];
205
+ const at = trackOf.get(label.hop);
206
+ const beside =
207
+ hop && at !== undefined
208
+ ? [
209
+ {
210
+ zone: 'middle' as Zone,
211
+ rect: { x: at + LABEL_OFFSET, y: Math.round((hop.ya + hop.yb) / 2 - label.across / 2), w: label.along, h: label.across }
212
+ }
213
+ ]
214
+ : [];
215
+ const existing = [
216
+ ...Array.from({ length: slotsBefore }, (_, slot) => before(slot)).flat(),
217
+ ...beside,
218
+ ...Array.from({ length: slotsAfter }, (_, slot) => after(slot)).flat()
219
+ ];
220
+ const clear = (candidate: { zone: Zone; rect: Rect }): boolean =>
221
+ !blocked(candidate.zone, candidate.rect) && !onLine(candidate.zone, candidate.rect, label);
222
+ let choice = existing.find(clear);
223
+ if (!choice) {
224
+ choice = before(slotsBefore).find(clear);
225
+ if (choice) {
226
+ slotsBefore += 1;
227
+ }
228
+ }
229
+ if (!choice) {
230
+ choice = after(slotsAfter).find(clear);
231
+ if (choice) {
232
+ slotsAfter += 1;
233
+ }
234
+ }
235
+ // A place beside no line may not exist, since every line in a lane runs its whole width; a free place is enough.
236
+ choice ??= existing.find((candidate) => !blocked(candidate.zone, candidate.rect));
237
+ if (!choice) {
238
+ choice = before(slotsBefore)[0]!;
239
+ slotsBefore += 1;
240
+ }
241
+ placed.push(choice);
242
+ chosen.set(label.edge, choice);
243
+ }
244
+
245
+ // Only what was put in it: a channel whose labels all found room beside their tracks keeps no lane.
246
+ const laneWidth = Math.max(0, ...placed.filter((entry) => entry.zone === 'before').map((entry) => entry.rect.x + entry.rect.w));
247
+ const middleLabels = placed.filter((entry) => entry.zone === 'middle');
248
+ const hasMiddle = tracks > 0 || middleLabels.length > 0;
249
+ const middleWidth = Math.max(
250
+ tracksSpan,
251
+ slotsAfter > 0 ? afterStart + slotsAfter * slotStep - LABEL_GAP : 0,
252
+ ...middleLabels.map((entry) => entry.rect.x + entry.rect.w)
253
+ );
254
+ const laneGap = laneWidth > 0 && hasMiddle ? LANE_GAP : 0;
255
+ const needed = input.clearBefore + laneWidth + laneGap + (hasMiddle ? middleWidth : 0) + input.clearAfter;
256
+ const width = Math.max(MIN_CHANNEL, needed);
257
+ const laneStart = input.start + input.clearBefore;
258
+ const middleStart = laneStart + laneWidth + laneGap + Math.floor((width - needed) / 2);
259
+ const trackX = new Map<number, number>();
260
+ trackOf.forEach((x, hop) => trackX.set(hop, middleStart + x));
261
+ const labels = new Map<number, Rect>();
262
+ chosen.forEach((entry, edge) => {
263
+ labels.set(edge, { ...entry.rect, x: entry.rect.x + (entry.zone === 'before' ? laneStart : middleStart) });
264
+ });
265
+ return { width, trackX, labels };
266
+ }