| … | … | @@ -18,7 +18,14 @@ |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | const DEFAULTS = { |
| 21 | | grid: 20, // routing resolution; also the minimum clearance around a node |
| 21 | // Routing resolution, and the minimum clearance around a node. |
| 22 | // |
| 23 | // Left undefined it is CHOSEN from the work the board actually needs: cost is |
| 24 | // edges x cells, and cells is area/grid^2, so a big board routes coarsely |
| 25 | // rather than not at all. Measured before this existed, one 226-node module |
| 26 | // took 55 SECONDS at a fixed grid of 40 — a frozen tab, not a slow render. |
| 27 | grid: undefined, |
| 28 | targetVisits: 700000, // the work to aim for when choosing a grid |
| 22 | 29 | clearance: 10, // how far a route stays off a node's edge |
| 23 | 30 | turnCost: 6, // a turn costs this many grid steps, so paths stay straight |
| 24 | 31 | |
| … | … | @@ -29,7 +36,15 @@ const DEFAULTS = { |
| 29 | 36 | // between two circuits that are not connected at all. Siblings are exempt: |
| 30 | 37 | // wires from one source SHOULD share a trunk, that is what makes a junction. |
| 31 | 38 | laneCost: 9, |
| 32 | | maxCells: 400000 // refuse to search a board this big rather than hang the tab |
| 39 | maxCells: 400000, // refuse to search a board this big rather than hang the tab |
| 40 | |
| 41 | // The work budget, in cell-visits across ALL edges. |
| 42 | // |
| 43 | // Cost is edges x search area, and it runs away: measured on a real scan, |
| 44 | // 108 edges over 3k cells took 0.8s but 316 edges over 22k cells took 55 |
| 45 | // SECONDS, which is not a slow render, it is a frozen tab. Past this budget |
| 46 | // the caller is told to draw curves instead. |
| 47 | budget: 4000000 |
| 33 | 48 | } |
| 34 | 49 | |
| 35 | 50 | /** |
| … | … | @@ -41,6 +56,8 @@ export function routeEdges (nodes, links, opts = {}) { |
| 41 | 56 | const O = { ...DEFAULTS, ...opts } |
| 42 | 57 | const byId = new Map(nodes.map(n => [n.id, n])) |
| 43 | 58 | |
| 59 | if (O.grid === undefined) O.grid = chooseGrid(nodes, links, O) |
| 60 | |
| 44 | 61 | const valid = links.filter(l => byId.has(l.from) && byId.has(l.to)) |
| 45 | 62 | if (valid.length === 0) return { routes: [], failed: 0 } |
| 46 | 63 | |
| … | … | @@ -63,6 +80,11 @@ export function routeEdges (nodes, links, opts = {}) { |
| 63 | 80 | return { routes: [], failed: valid.length, tooLarge: true } |
| 64 | 81 | } |
| 65 | 82 | |
| 83 | // Refuse the job rather than freeze: the caller falls back to curves. |
| 84 | if (valid.length * cols * rows > O.budget) { |
| 85 | return { routes: [], failed: valid.length, overBudget: true } |
| 86 | } |
| 87 | |
| 66 | 88 | const blocked = new Uint8Array(cols * rows) |
| 67 | 89 | const owner = new Int32Array(cols * rows).fill(-1) |
| 68 | 90 | const idx = (cx, cy) => cy * cols + cx |
| … | … | @@ -105,6 +127,11 @@ export function routeEdges (nodes, links, opts = {}) { |
| 105 | 127 | claimLanes(path, l.from) |
| 106 | 128 | } else failed++ |
| 107 | 129 | } |
| 130 | // All or nothing: a board where many edges could not be routed looks worse |
| 131 | // half-orthogonal than it does as plain curves. |
| 132 | if (failed > routes.length * 0.15) { |
| 133 | return { routes: [], failed: valid.length, mixedRefused: true } |
| 134 | } |
| 108 | 135 | return { routes, failed } |
| 109 | 136 | |
| 110 | 137 | // Mark every cell a finished route runs through, so the next one prefers a |
| … | … | @@ -190,9 +217,16 @@ export function routeEdges (nodes, links, opts = {}) { |
| 190 | 217 | const startKey = start.cy * cols + start.cx |
| 191 | 218 | const goalKey = goal.cy * cols + goal.cx |
| 192 | 219 | |
| 193 | | const gScore = new Map([[startKey + ':0', 0]]) |
| 220 | // State is (cell, direction), keyed as a NUMBER rather than a string: the |
| 221 | // string concatenation was allocating once per expansion, and the open list |
| 222 | // was scanned linearly for its minimum, which is O(n) per pop. Between them |
| 223 | // they were most of the 55 seconds a big level used to take. |
| 224 | const stateOf = (cellKey, dir) => cellKey * 5 + dir |
| 225 | |
| 226 | const gScore = new Map([[stateOf(startKey, 0), 0]]) |
| 194 | 227 | const came = new Map() |
| 195 | | const open = [{ key: startKey, dir: 0, f: heuristic(start), cx: start.cx, cy: start.cy }] |
| 228 | const open = new MinHeap() |
| 229 | open.push(heuristic(start), { key: startKey, dir: 0, cx: start.cx, cy: start.cy }) |
| 196 | 230 | |
| 197 | 231 | const DIRS = [ |
| 198 | 232 | { dx: 1, dy: 0, id: 1 }, { dx: -1, dy: 0, id: 2 }, |
| … | … | @@ -200,11 +234,9 @@ export function routeEdges (nodes, links, opts = {}) { |
| 200 | 234 | ] |
| 201 | 235 | |
| 202 | 236 | let guard = 0 |
| 203 | | while (open.length > 0) { |
| 237 | while (open.size > 0) { |
| 204 | 238 | if (++guard > O.maxCells) return null |
| 205 | | let bestAt = 0 |
| 206 | | for (let i = 1; i < open.length; i++) if (open[i].f < open[bestAt].f) bestAt = i |
| 207 | | const current = open.splice(bestAt, 1)[0] |
| 239 | const current = open.pop() |
| 208 | 240 | |
| 209 | 241 | if (current.key === goalKey) return reconstruct(current) |
| 210 | 242 | |
| … | … | @@ -222,18 +254,15 @@ export function routeEdges (nodes, links, opts = {}) { |
| 222 | 254 | const claimed = laneOwner.get(ny * cols + nx) |
| 223 | 255 | const lane = claimed !== undefined && claimed !== source ? O.laneCost : 0 |
| 224 | 256 | |
| 225 | | const tentative = (gScore.get(current.key + ':' + current.dir) || 0) + |
| 257 | const tentative = (gScore.get(stateOf(current.key, current.dir)) || 0) + |
| 226 | 258 | 1 + turn + trunkBonus + lane |
| 227 | 259 | |
| 228 | 260 | const nKey = ny * cols + nx |
| 229 | | const stateKey = nKey + ':' + d.id |
| 261 | const stateKey = stateOf(nKey, d.id) |
| 230 | 262 | if (gScore.has(stateKey) && gScore.get(stateKey) <= tentative) continue |
| 231 | 263 | gScore.set(stateKey, tentative) |
| 232 | 264 | came.set(stateKey, { key: current.key, dir: current.dir, cx: current.cx, cy: current.cy }) |
| 233 | | open.push({ |
| 234 | | key: nKey, dir: d.id, cx: nx, cy: ny, |
| 235 | | f: tentative + heuristic({ cx: nx, cy: ny }) |
| 236 | | }) |
| 265 | open.push(tentative + heuristic({ cx: nx, cy: ny }), { key: nKey, dir: d.id, cx: nx, cy: ny }) |
| 237 | 266 | } |
| 238 | 267 | } |
| 239 | 268 | return null |
| … | … | @@ -246,9 +275,9 @@ export function routeEdges (nodes, links, opts = {}) { |
| 246 | 275 | const out = [{ cx: end.cx, cy: end.cy }] |
| 247 | 276 | let cursor = { key: end.key, dir: end.dir, cx: end.cx, cy: end.cy } |
| 248 | 277 | let steps = 0 |
| 249 | | while (came.has(cursor.key + ':' + cursor.dir)) { |
| 278 | while (came.has(stateOf(cursor.key, cursor.dir))) { |
| 250 | 279 | if (++steps > O.maxCells) break |
| 251 | | cursor = came.get(cursor.key + ':' + cursor.dir) |
| 280 | cursor = came.get(stateOf(cursor.key, cursor.dir)) |
| 252 | 281 | out.push({ cx: cursor.cx, cy: cursor.cy }) |
| 253 | 282 | } |
| 254 | 283 | return out.reverse() |
| … | … | @@ -256,6 +285,80 @@ export function routeEdges (nodes, links, opts = {}) { |
| 256 | 285 | } |
| 257 | 286 | } |
| 258 | 287 | |
| 288 | /** |
| 289 | * Pick a routing resolution from the work the board needs. |
| 290 | * |
| 291 | * Cost is (edges x cells) and cells is area/grid^2, so grid ~ sqrt(edges x area |
| 292 | * / target). A small board gets a fine grid and tight clearance; a large one |
| 293 | * gets a coarse grid and still gets routed, which beats falling back to curves. |
| 294 | */ |
| 295 | function chooseGrid (nodes, links, O) { |
| 296 | if (nodes.length === 0 || links.length === 0) return 20 |
| 297 | let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity |
| 298 | for (const n of nodes) { |
| 299 | minX = Math.min(minX, n.x); minY = Math.min(minY, n.y) |
| 300 | maxX = Math.max(maxX, n.x + n.w); maxY = Math.max(maxY, n.y + n.h) |
| 301 | } |
| 302 | const area = Math.max(1, (maxX - minX) * (maxY - minY)) |
| 303 | // Capped at 45 on purpose. A coarser grid is faster but the clearance halo |
| 304 | // around each node is measured in CELLS, so past roughly the row gap it |
| 305 | // swallows the channels between stacked nodes and A* can find no path at all. |
| 306 | // Measured at grid ~120: favela-web routed 84 of 189 edges and the rest fell |
| 307 | // back to curves, which looks worse than either choice made consistently. |
| 308 | // Past what a routable grid can afford, the budget refuses the whole job. |
| 309 | const grid = Math.sqrt(links.length * area / Math.max(1, O.targetVisits)) |
| 310 | return Math.max(20, Math.min(45, Math.round(grid / 5) * 5)) |
| 311 | } |
| 312 | |
| 313 | /** A binary min-heap: A* pops the cheapest state once per expansion. */ |
| 314 | class MinHeap { |
| 315 | constructor () { |
| 316 | this.keys = [] |
| 317 | this.values = [] |
| 318 | } |
| 319 | |
| 320 | get size () { return this.keys.length } |
| 321 | |
| 322 | push (priority, value) { |
| 323 | this.keys.push(priority) |
| 324 | this.values.push(value) |
| 325 | let i = this.keys.length - 1 |
| 326 | while (i > 0) { |
| 327 | const parent = (i - 1) >> 1 |
| 328 | if (this.keys[parent] <= this.keys[i]) break |
| 329 | this.swap(parent, i) |
| 330 | i = parent |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | pop () { |
| 335 | const top = this.values[0] |
| 336 | const lastKey = this.keys.pop() |
| 337 | const lastValue = this.values.pop() |
| 338 | if (this.keys.length > 0) { |
| 339 | this.keys[0] = lastKey |
| 340 | this.values[0] = lastValue |
| 341 | let i = 0 |
| 342 | for (;;) { |
| 343 | const l = i * 2 + 1 |
| 344 | const r = l + 1 |
| 345 | let small = i |
| 346 | if (l < this.keys.length && this.keys[l] < this.keys[small]) small = l |
| 347 | if (r < this.keys.length && this.keys[r] < this.keys[small]) small = r |
| 348 | if (small === i) break |
| 349 | this.swap(small, i) |
| 350 | i = small |
| 351 | } |
| 352 | } |
| 353 | return top |
| 354 | } |
| 355 | |
| 356 | swap (a, b) { |
| 357 | const k = this.keys[a]; this.keys[a] = this.keys[b]; this.keys[b] = k |
| 358 | const v = this.values[a]; this.values[a] = this.values[b]; this.values[b] = v |
| 359 | } |
| 360 | } |
| 361 | |
| 259 | 362 | /** Drop every point that does not change direction. */ |
| 260 | 363 | function simplify (points) { |
| 261 | 364 | if (points.length <= 2) return points |