@rse/gradia 1.4.3 → 1.4.4

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.
@@ -149,20 +149,43 @@ const fars = (u) => u.hairpin ? (u.mirror ? [u.posIn, u.posOut] : []) : [u.mirro
149
149
  entering one if mirrored) cross the track-following segment of b,
150
150
  and the near-side stubs of b cross the one of a (a stub touching
151
151
  the end of a segment counts too, as it would overlap a corner) */
152
- const trackCrossings = (a, b) => {
152
+ const trackCrossings = (a, b, farsA, nearsB) => {
153
153
  const within = (u, pos) => pos >= Math.min(u.posIn, u.posOut) && pos <= Math.max(u.posIn, u.posOut);
154
- return fars(a).filter((pos) => within(b, pos)).length +
155
- nears(b).filter((pos) => within(a, pos)).length;
154
+ let n = 0;
155
+ for (const pos of farsA)
156
+ if (within(b, pos))
157
+ n++;
158
+ for (const pos of nearsB)
159
+ if (within(a, pos))
160
+ n++;
161
+ return n;
156
162
  };
157
163
  /* the number of collinear overlaps caused by placing user a on a
158
164
  nearer track than user b: a far-side stub of a and a near-side stub
159
165
  of b at the very same cross-axis position run on top of each other
160
166
  between the two tracks (unlike a crossing, which is still readable
161
167
  through its hop, an overlap merges the two edges visually) */
162
- const trackOverlaps = (a, b) => fars(a).filter((pos) => nears(b).includes(pos)).length;
168
+ const trackOverlaps = (farsA, nearsB) => {
169
+ let n = 0;
170
+ for (const pos of farsA)
171
+ for (const near of nearsB)
172
+ if (near === pos) {
173
+ n++;
174
+ break;
175
+ }
176
+ return n;
177
+ };
163
178
  /* the maximum number of users for which the crossing-minimal track
164
179
  order is determined exactly (the effort doubles with every user) */
165
180
  const TRACK_EXACT_MAX = 14;
181
+ /* the maximum number of users for which the local search tries blocks
182
+ of any length and restarts after every improving move (beyond it,
183
+ the search is bounded to blocks of at most TRACK_BLOCK_MAX users and
184
+ at most TRACK_PASSES_MAX passes, as the restarting search grows too
185
+ fast with hundreds of users) */
186
+ const TRACK_SEARCH_MAX = 100;
187
+ const TRACK_BLOCK_MAX = 8;
188
+ const TRACK_PASSES_MAX = 100;
166
189
  /* reorder the seeded track users to the order causing the fewest
167
190
  overlaps and then the fewest crossings, with ties broken toward the
168
191
  seed order: for small user sets exactly, by a dynamic programming
@@ -176,8 +199,18 @@ const minimizeCrossings = (seed) => {
176
199
  /* the weighted cost of placing seed user i before seed user j: the
177
200
  overlaps dominate (one outweighs all crossings possible among the
178
201
  users, at most three per pair), then the crossings, and finally
179
- a seed order inversion breaks ties */
180
- const cost = seed.map((a, i) => seed.map((b, j) => (trackOverlaps(a, b) * 2 * n * n + trackCrossings(a, b)) * n * n + (i > j ? 1 : 0)));
202
+ a seed order inversion breaks ties (the near-side and far-side
203
+ stubs of every user are determined once, as the matrix grows
204
+ quadratically with the users, and the flat matrix is a plain
205
+ array, so its mostly small integers stay unboxed, unlike in a
206
+ "Float64Array", whose doubles slow down the local search) */
207
+ const nearsOf = seed.map(nears);
208
+ const farsOf = seed.map(fars);
209
+ const cost = [];
210
+ for (let i = 0; i < n; i++)
211
+ for (let j = 0; j < n; j++)
212
+ cost.push((trackOverlaps(farsOf[i], nearsOf[j]) * 2 * n * n +
213
+ trackCrossings(seed[i], seed[j], farsOf[i], nearsOf[j])) * n * n + (i > j ? 1 : 0));
181
214
  let order;
182
215
  if (n <= TRACK_EXACT_MAX) {
183
216
  const best = new Float64Array(1 << n).fill(Infinity);
@@ -191,7 +224,7 @@ const minimizeCrossings = (seed) => {
191
224
  let total = best[rest];
192
225
  for (let v = 0; v < n; v++)
193
226
  if ((rest & (1 << v)) !== 0)
194
- total += cost[v][u];
227
+ total += cost[v * n + u];
195
228
  if (total < best[s]) {
196
229
  best[s] = total;
197
230
  last[s] = u;
@@ -210,31 +243,68 @@ const minimizeCrossings = (seed) => {
210
243
  run), until no such move remains (a single-user move is
211
244
  the block move of length one, while the longer blocks let
212
245
  groups of users pass an obstacle which none of its users
213
- could pass on its own) */
246
+ could pass on its own); for large user sets the search is
247
+ bounded instead: per block, the best move over the runs
248
+ behind and ahead of it is applied, and the search continues
249
+ with the next block instead of restarting */
214
250
  order = seed.map((_, i) => i);
215
- for (let improved = true; improved;) {
251
+ /* precompute the cost change of swapping two users once:
252
+ diff[a * n + b] is the cost of placing user a before user b
253
+ minus the cost of the reverse placement */
254
+ const diff = [];
255
+ for (let a = 0; a < n; a++)
256
+ for (let b = 0; b < n; b++)
257
+ diff.push(cost[a * n + b] - cost[b * n + a]);
258
+ const bounded = n > TRACK_SEARCH_MAX;
259
+ const maxLen = bounded ? Math.min(n - 1, TRACK_BLOCK_MAX) : n - 1;
260
+ const passes = bounded ? TRACK_PASSES_MAX : Infinity;
261
+ for (let improved = true, pass = 0; improved && pass < passes; pass++) {
216
262
  improved = false;
217
- for (let len = 1; len < n && !improved; len++) {
218
- for (let i = 0; i + len <= n && !improved; i++) {
263
+ for (let len = 1; len <= maxLen; len++) {
264
+ for (let i = 0; i + len <= n; i++) {
265
+ let best = 0;
266
+ let target = -1;
267
+ let forward = true;
219
268
  let delta = 0;
220
- for (let k = i + len; k < n && !improved; k++) {
269
+ for (let k = i + len; k < n; k++) {
270
+ const row = order[k] * n;
221
271
  for (let b = i; b < i + len; b++)
222
- delta += cost[order[k]][order[b]] - cost[order[b]][order[k]];
223
- if (delta < 0) {
224
- order.splice(k - len + 1, 0, ...order.splice(i, len));
225
- improved = true;
272
+ delta += diff[row + order[b]];
273
+ if (delta < best) {
274
+ best = delta;
275
+ target = k;
276
+ forward = true;
277
+ if (!bounded)
278
+ break;
226
279
  }
227
280
  }
228
281
  delta = 0;
229
- for (let k = i - 1; k >= 0 && !improved; k--) {
282
+ for (let k = i - 1; k >= 0; k--) {
283
+ if (target >= 0 && !bounded)
284
+ break;
285
+ const other = order[k];
230
286
  for (let b = i; b < i + len; b++)
231
- delta += cost[order[b]][order[k]] - cost[order[k]][order[b]];
232
- if (delta < 0) {
233
- order.splice(k, 0, ...order.splice(i, len));
234
- improved = true;
287
+ delta += diff[order[b] * n + other];
288
+ if (delta < best) {
289
+ best = delta;
290
+ target = k;
291
+ forward = false;
292
+ if (!bounded)
293
+ break;
235
294
  }
236
295
  }
296
+ if (target >= 0) {
297
+ if (forward)
298
+ order.splice(target - len + 1, 0, ...order.splice(i, len));
299
+ else
300
+ order.splice(target, 0, ...order.splice(i, len));
301
+ improved = true;
302
+ if (!bounded)
303
+ break;
304
+ }
237
305
  }
306
+ if (improved && !bounded)
307
+ break;
238
308
  }
239
309
  }
240
310
  }
@@ -298,9 +368,14 @@ export const computeHops = (polys) => {
298
368
  const [c, d] = [poly[m], poly[m + 1]];
299
369
  if (c[0] !== d[0])
300
370
  continue;
301
- verts.push({ owner: j, x: c[0], y1: Math.min(c[1], d[1]), y2: Math.max(c[1], d[1]) });
371
+ verts.push({ idx: verts.length, owner: j, x: c[0], y1: Math.min(c[1], d[1]), y2: Math.max(c[1], d[1]) });
302
372
  }
303
373
  });
374
+ /* sweep the vertical segments ordered by their position, so every
375
+ horizontal segment only visits the ones within its own span (found
376
+ by a binary search), and report the crossing ones in their original
377
+ order of collection */
378
+ const byX = verts.slice().sort((a, b) => a.x - b.x);
304
379
  return polys.map((poly, i) => {
305
380
  const result = new Map();
306
381
  for (let k = 0; k < poly.length - 1; k++) {
@@ -308,14 +383,25 @@ export const computeHops = (polys) => {
308
383
  if (a[1] !== b[1])
309
384
  continue;
310
385
  const hy = a[1];
311
- const hx1 = Math.min(a[0], b[0]);
312
- const hx2 = Math.max(a[0], b[0]);
313
- const xs = [];
314
- for (const v of verts)
315
- if (v.owner !== i && v.x > hx1 + 1 && v.x < hx2 - 1 && hy > v.y1 + 1 && hy < v.y2 - 1)
316
- xs.push(v.x);
317
- if (xs.length > 0)
318
- result.set(k, xs);
386
+ const lo = Math.min(a[0], b[0]) + 1;
387
+ const hi = Math.max(a[0], b[0]) - 1;
388
+ let l = 0;
389
+ let h = byX.length;
390
+ while (l < h) {
391
+ const m = (l + h) >>> 1;
392
+ if (byX[m].x > lo)
393
+ h = m;
394
+ else
395
+ l = m + 1;
396
+ }
397
+ const found = [];
398
+ for (let m = l; m < byX.length && byX[m].x < hi; m++) {
399
+ const v = byX[m];
400
+ if (v.owner !== i && hy > v.y1 + 1 && hy < v.y2 - 1)
401
+ found.push(v);
402
+ }
403
+ if (found.length > 0)
404
+ result.set(k, found.sort((a, b) => a.idx - b.idx).map((v) => v.x));
319
405
  }
320
406
  return result;
321
407
  });
@@ -43,6 +43,66 @@ const ARITY_BACK = 3;
43
43
  segment of its route, as a long segment would otherwise offer far
44
44
  more candidate positions than a placement can ever need */
45
45
  const SLIDE_MAX = 16;
46
+ /* a uniform grid of buckets indexing areas, so a query only visits
47
+ the areas of the buckets it covers instead of all areas (an area
48
+ spanning several buckets is visited once only, as every visit
49
+ stamps it, and the bucket indices are clamped, so even far-off
50
+ areas stay indexed, merely sharing the outermost buckets) */
51
+ const BUCKET = 64;
52
+ const GRID_OFF = 1 << 20;
53
+ const GRID_SPAN = 2 * GRID_OFF;
54
+ const areaGrid = () => {
55
+ const areas = [];
56
+ const buckets = new Map();
57
+ let stamps = new Uint32Array(0);
58
+ let stamp = 0;
59
+ const bucketOf = (v) => Math.max(-GRID_OFF, Math.min(GRID_OFF - 1, Math.floor(v / BUCKET)));
60
+ /* index an area and return its identifier */
61
+ const add = (area) => {
62
+ const id = areas.length;
63
+ areas.push(area);
64
+ for (let by = bucketOf(area[1]); by <= bucketOf(area[3]); by++) {
65
+ for (let bx = bucketOf(area[0]); bx <= bucketOf(area[2]); bx++) {
66
+ const bucket = buckets.get(bx * GRID_SPAN + by);
67
+ if (bucket === undefined)
68
+ buckets.set(bx * GRID_SPAN + by, [id]);
69
+ else
70
+ bucket.push(id);
71
+ }
72
+ }
73
+ return id;
74
+ };
75
+ /* drop an indexed area */
76
+ const remove = (id) => {
77
+ areas[id] = undefined;
78
+ };
79
+ /* visit every indexed area sharing a bucket with the queried area
80
+ once, until the visiting function signals to stop */
81
+ const visit = (x1, y1, x2, y2, fn) => {
82
+ if (stamps.length < areas.length) {
83
+ const grown = new Uint32Array(Math.max(areas.length, 2 * stamps.length));
84
+ grown.set(stamps);
85
+ stamps = grown;
86
+ }
87
+ stamp++;
88
+ for (let by = bucketOf(y1); by <= bucketOf(y2); by++) {
89
+ for (let bx = bucketOf(x1); bx <= bucketOf(x2); bx++) {
90
+ const bucket = buckets.get(bx * GRID_SPAN + by);
91
+ if (bucket === undefined)
92
+ continue;
93
+ for (const id of bucket) {
94
+ if (stamps[id] === stamp)
95
+ continue;
96
+ stamps[id] = stamp;
97
+ const area = areas[id];
98
+ if (area !== undefined && fn(id, area))
99
+ return;
100
+ }
101
+ }
102
+ }
103
+ };
104
+ return { add, remove, visit };
105
+ };
46
106
  /* track occupied areas (node boxes and already placed labels) to
47
107
  let subsequent labels dodge into a collision-free position, plus the
48
108
  areas merely reserved for the labels still to be placed, and the
@@ -77,6 +137,19 @@ const labelPlacer = (layout, hops, hopRadius, config) => {
77
137
  return boxes;
78
138
  });
79
139
  const lineCount = lines.reduce((n, boxes) => n + boxes.length, 0);
140
+ /* index the edge line boxes, the occupied areas, and the
141
+ reserved areas, as the placement of every label queries them
142
+ for all of its candidate boxes */
143
+ const lineGrid = areaGrid();
144
+ const owner = [];
145
+ lines.forEach((boxes, i) => {
146
+ for (const o of boxes)
147
+ owner[lineGrid.add(o)] = i;
148
+ });
149
+ const occupiedGrid = areaGrid();
150
+ for (const o of occupied)
151
+ occupiedGrid.add(o);
152
+ const reservedGrid = areaGrid();
80
153
  /* claim the candidate box colliding with the fewest occupied
81
154
  areas and, among those, with the fewest edge lines (earlier
82
155
  candidates win ties, so the first collision-free one is taken),
@@ -87,14 +160,41 @@ const labelPlacer = (layout, hops, hopRadius, config) => {
87
160
  "dodge" unset weighs the lines not at all, as its candidates are
88
161
  ordered by a proximity which matters more than a crossed line,
89
162
  which the halo of the label keeps readable anyway */
90
- const collisions = (boxes, box) => boxes.filter((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]).length;
91
- const crossings = (box, edge) => {
92
- const near = [box[0] - LINE_GAP, box[1] - LINE_GAP, box[2] + LINE_GAP, box[3] + LINE_GAP];
93
- return lines.reduce((n, boxes, i) => n + collisions(boxes, i === edge ? box : near), 0);
163
+ const hit = (o, x1, y1, x2, y2) => x1 < o[2] && x2 > o[0] && y1 < o[3] && y2 > o[1];
164
+ const collisions = (grid, box) => {
165
+ let n = 0;
166
+ grid.visit(box[0], box[1], box[2], box[3], (_, o) => {
167
+ if (hit(o, box[0], box[1], box[2], box[3]))
168
+ n++;
169
+ return false;
170
+ });
171
+ return n;
172
+ };
173
+ /* count the edge line boxes a candidate box crosses, but stop
174
+ counting once the given limit is reached, as the candidate then
175
+ cannot win anymore (the own lines are tested against the box
176
+ itself, the foreign ones against the box grown by the clearance,
177
+ which hence also bounds the buckets to visit) */
178
+ const crossings = (box, edge, limit) => {
179
+ const [x1, y1, x2, y2] = [box[0] - LINE_GAP, box[1] - LINE_GAP, box[2] + LINE_GAP, box[3] + LINE_GAP];
180
+ let n = 0;
181
+ lineGrid.visit(x1, y1, x2, y2, (id, o) => {
182
+ if (owner[id] === edge ? hit(o, box[0], box[1], box[2], box[3]) : hit(o, x1, y1, x2, y2))
183
+ n++;
184
+ return n >= limit;
185
+ });
186
+ return n;
94
187
  };
95
- const reserved = [];
188
+ /* reserve an area, indexed under its very object, so the claim of
189
+ the label it is reserved for can release it again */
190
+ const reserved = new Map();
96
191
  const reserve = (box) => {
97
- reserved.push(box);
192
+ const id = reservedGrid.add(box);
193
+ const ids = reserved.get(box);
194
+ if (ids === undefined)
195
+ reserved.set(box, [id]);
196
+ else
197
+ ids.push(id);
98
198
  };
99
199
  /* a claim counts the areas reserved for the labels still to come
100
200
  like the occupied ones, so an early label yields the position a
@@ -103,15 +203,21 @@ const labelPlacer = (layout, hops, hopRadius, config) => {
103
203
  released, as that is the very area it is about to take */
104
204
  const claim = (candidates, edge, dodge = true, own) => {
105
205
  if (own !== undefined) {
106
- const k = reserved.indexOf(own);
107
- if (k >= 0)
108
- reserved.splice(k, 1);
206
+ const id = reserved.get(own)?.shift();
207
+ if (id !== undefined)
208
+ reservedGrid.remove(id);
109
209
  }
110
210
  let box = candidates[0];
111
211
  let lowest = Infinity;
112
212
  for (const c of candidates) {
113
- const score = (collisions(occupied, c) + collisions(reserved, c)) * (lineCount + 1) +
114
- (dodge ? crossings(c, edge) : 0);
213
+ /* a candidate whose collisions alone already reach the lowest
214
+ score is skipped, as its line crossings, of which there are
215
+ at most "lineCount", can never let it win anymore */
216
+ let score = (collisions(occupiedGrid, c) + collisions(reservedGrid, c)) * (lineCount + 1);
217
+ if (score >= lowest)
218
+ continue;
219
+ if (dodge)
220
+ score += crossings(c, edge, lowest - score);
115
221
  if (score < lowest) {
116
222
  box = c;
117
223
  lowest = score;
@@ -120,6 +226,7 @@ const labelPlacer = (layout, hops, hopRadius, config) => {
120
226
  break;
121
227
  }
122
228
  occupied.push(box);
229
+ occupiedGrid.add(box);
123
230
  return box;
124
231
  };
125
232
  return { claim, reserve, occupied };
@@ -23,6 +23,7 @@ const LOOP_TOP = 52; /* detour of a self-loop above its node box */
23
23
  /* node nudging constants */
24
24
  const NUDGE_GAP = 24; /* min port distance of a nudged node from its gutter tracks */
25
25
  const NUDGE_PASSES = 3; /* max number of node nudging passes */
26
+ const NUDGE_EDGES = 300; /* max number of edges for which nodes are nudged at all */
26
27
  /* snap raw node positions onto a discrete grid by clustering the
27
28
  distinct X coordinates into columns and Y coordinates into rows */
28
29
  const snapToGrid = (nodes, rawX, rawY) => {
@@ -488,7 +489,11 @@ const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level
488
489
  trackPre.gutY(plans[edge].guts[0], edge) : undefined, level.fixedPort);
489
490
  const { chanX, gutY } = assignTrackCoords(edges, plans, portPos, grid, config);
490
491
  const polys = routePolys(edges, plans, portPos, boxH, cy, chanX, gutY);
491
- const hops = computeHops(polys).reduce((sum, segs) => sum + Array.from(segs.values()).reduce((n, xs) => n + xs.length, 0), 0);
492
+ let count;
493
+ const hops = () => {
494
+ count ??= computeHops(polys).reduce((sum, segs) => sum + Array.from(segs.values()).reduce((n, xs) => n + xs.length, 0), 0);
495
+ return count;
496
+ };
492
497
  return { portPos, gutY, polys, hops };
493
498
  };
494
499
  /* nudge nodes vertically off their row centers to straighten the
@@ -530,12 +535,17 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
530
535
  && row.get(edge.source) === row.get(edge.target));
531
536
  const jogs = (routing) => edges.filter((_, i) => direct[i]
532
537
  && routing.portPos.get(`${i}:s`).y !== routing.portPos.get(`${i}:t`).y).length;
533
- const better = (a, b) => a.hops < b.hops || (a.hops === b.hops && jogs(a) < jogs(b));
538
+ const better = (a, b) => a.hops() < b.hops() || (a.hops() === b.hops() && jogs(a) < jogs(b));
534
539
  /* try, in a few passes, for every node the shifts aligning one of
535
540
  its direct edges, and keep the best of the shifts improving the
536
541
  routing (all shifts are relative to the current position, which
537
542
  already includes the shifts of the previous passes) */
538
543
  let current = route();
544
+ /* skip the nudging on large graphs, as every tried shift re-routes
545
+ all edges, so its effort grows with the node count times the
546
+ edge count, while it only straightens a few direct edges */
547
+ if (edges.length > NUDGE_EDGES)
548
+ return current;
539
549
  for (let pass = 0; pass < NUDGE_PASSES; pass++) {
540
550
  let changed = false;
541
551
  for (const node of nodes) {