| … | … | @@ -45,6 +45,8 @@ |
| 45 | 45 | .link.bundled { stroke: #58a6ff; opacity: 0.45; } |
| 46 | 46 | .link.hot { stroke: #ffa657; opacity: 0.95; } |
| 47 | 47 | .link.cold { opacity: 0.04; } |
| 48 | .junction { fill: #4d5866; stroke: none; } | |
| 49 | .link { stroke-linejoin: round; stroke-linecap: round; } | |
| 48 | 50 | /* A selected node's dependency closure: near edges hot, far ones cooling off, |
| 49 | 51 | so depth is readable without counting hops. */ |
| 50 | 52 | .link.dep { opacity: 0.92; } |
| … | … | @@ -89,7 +91,7 @@ |
| 89 | 91 | <span class="sw" style="background:#1f4a6b"></span>module body |
| 90 | 92 | <span class="sw" style="background:#33405a"></span>zone depth rail |
| 91 | 93 | </div> |
| 92 | <div id="hint">hover traces edges · click a node for its dependency depth · B bundles · title opens · Esc back</div> | |
| 94 | <div id="hint">hover traces edges · click for dependency depth · B bundles · R routing · title opens · Esc back</div> | |
| 93 | 95 | </div> |
| 94 | 96 | |
| 95 | 97 | <script type="module" src="/floor/level.js"></script> |
| … | … | @@ -16,6 +16,7 @@ import { layout } from '/floor/layout.js' |
| 16 | 16 | import { |
| 17 | 17 | parseLocation, locationToUrl, otherSurface, splitFilePath, joinFilePath |
| 18 | 18 | } from '/floor/location.js' |
| 19 | import { routeEdges, findJunctions } from '/floor/routing.js' | |
| 19 | 20 | import { |
| 20 | 21 | buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel, |
| 21 | 22 | resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks, |
| … | … | @@ -62,6 +63,7 @@ const view = { |
| 62 | 63 | links: [], |
| 63 | 64 | containers: [], |
| 64 | 65 | bundled: false, // one edge per container pair instead of per member |
| 66 | circuit: true, // orthogonal routes that go AROUND nodes, not behind them | |
| 65 | 67 | selected: null, // a card whose whole dependency closure is showing |
| 66 | 68 | linkEls: [], |
| 67 | 69 | cardEls: null, |
| … | … | @@ -270,6 +272,30 @@ function render () { |
| 270 | 272 | view.linkEls = [] |
| 271 | 273 | const edges = view.bundled ? bundleLinks(view.cards, view.links) : view.links |
| 272 | 274 | |
| 275 | // Orthogonal routing: wires leave a node's edge, run in the free channels and | |
| 276 | // go AROUND whatever is in the way, instead of a bezier drawn straight from A | |
| 277 | // to B and passing behind everything between them. | |
| 278 | // | |
| 279 | // The grid is 40, not the module's default 20: measured on this repo's own | |
| 280 | // 56-module scan, 105 edges take 3.4s at 20 and 0.4s at 40, both routing | |
| 281 | // every edge. A layout pass that blocks the tab for three seconds on every | |
| 282 | // expand is not worth the finer clearance. | |
| 283 | const routedPaths = new Map() | |
| 284 | let junctions = [] | |
| 285 | if (view.circuit) { | |
| 286 | const boxes = [] | |
| 287 | for (const n of view.laid.nodes) boxes.push({ id: n.id, x: n.x, y: n.y, w: n.w, h: n.h }) | |
| 288 | if (view.bundled) { | |
| 289 | for (const c of view.laid.containers) boxes.push({ id: c.id, x: c.x, y: c.y, w: c.w, h: c.h }) | |
| 290 | } | |
| 291 | const out = routeEdges(boxes, edges.map(e => ({ from: e.from, to: e.to })), { grid: 40 }) | |
| 292 | for (const r of out.routes) { | |
| 293 | routedPaths.set(r.from + ' -> ' + r.to, | |
| 294 | 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L ')) | |
| 295 | } | |
| 296 | junctions = findJunctions(out.routes) | |
| 297 | } | |
| 298 | ||
| 273 | 299 | // Clicking a node shows its FULL dependency depth, not just one hop, with the |
| 274 | 300 | // distance carried by colour. Measured on a 56-module scan: 10 modules have a |
| 275 | 301 | // transitive dependency and the deepest reaches 14 modules three hops out — |
| … | … | @@ -290,10 +316,11 @@ function render () { |
| 290 | 316 | const x1 = a.x + a.w |
| 291 | 317 | const x2 = b.x |
| 292 | 318 | const dx = Math.max(30, Math.abs(x2 - x1) * 0.5) |
| 319 | const routed = routedPaths.get(l.from + ' -> ' + l.to) | |
| 293 | 320 | const path = el('path', { |
| 294 | 321 | class: 'link' + (view.bundled ? ' bundled' : ''), |
| 295 | d: 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 + | |
| 296 | ', ' + x2 + ' ' + y2 | |
| 322 | d: routed || ('M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 + | |
| 323 | ', ' + x2 + ' ' + y2) | |
| 297 | 324 | }) |
| 298 | 325 | if (closure) { |
| 299 | 326 | const depth = closure.edges.get(l.from + ' -> ' + l.to) |
| … | … | @@ -318,6 +345,12 @@ function render () { |
| 318 | 345 | } |
| 319 | 346 | } |
| 320 | 347 | |
| 348 | // Junction dots where branches leave a shared trunk — a bus with taps, the | |
| 349 | // way a circuit diagram marks a connection rather than a crossing. | |
| 350 | for (const j of junctions) { | |
| 351 | world.appendChild(el('circle', { class: 'junction', cx: j.x, cy: j.y, r: 3 })) | |
| 352 | } | |
| 353 | ||
| 321 | 354 | view.cardEls = new Map() |
| 322 | 355 | for (const card of view.cards) { |
| 323 | 356 | const pos = placed.get(card.id) |
| … | … | @@ -684,6 +717,9 @@ function wire () { |
| 684 | 717 | } else if (e.key === 'c' || e.key === 'C') { |
| 685 | 718 | view.expanded.clear() |
| 686 | 719 | rebuild() |
| 720 | } else if (e.key === 'r' || e.key === 'R') { | |
| 721 | view.circuit = !view.circuit | |
| 722 | rebuild() | |
| 687 | 723 | } else if (e.key === 'b' || e.key === 'B') { |
| 688 | 724 | view.bundled = !view.bundled |
| 689 | 725 | syncUrl() |
| … | … | @@ -0,0 +1,257 @@ |
| 1 | /** | |
| 2 | * Orthogonal edge routing — wires that behave like circuitry. | |
| 3 | * | |
| 4 | * Edges used to be bezier curves drawn straight from A to B, so they ran BEHIND | |
| 5 | * whatever sat between them. At 105 edges on the system level that is a wall of | |
| 6 | * spaghetti that hides the layout underneath it. | |
| 7 | * | |
| 8 | * These routes are axis-aligned and obstacle-aware: they leave a node's right | |
| 9 | * edge, run in the free channel between columns, and enter the target's left | |
| 10 | * edge. A route never crosses a node it does not belong to, which is the | |
| 11 | * invariant the tests check. | |
| 12 | * | |
| 13 | * Edges leaving the SAME node share a trunk, and where a branch turns off the | |
| 14 | * trunk there is a JUNCTION — one wire with taps, rather than several wires | |
| 15 | * drawn on top of each other. | |
| 16 | * | |
| 17 | * Pure: geometry in, geometry out. No DOM. | |
| 18 | */ | |
| 19 | ||
| 20 | const DEFAULTS = { | |
| 21 | grid: 20, // routing resolution; also the minimum clearance around a node | |
| 22 | clearance: 10, // how far a route stays off a node's edge | |
| 23 | turnCost: 6, // a turn costs this many grid steps, so paths stay straight | |
| 24 | maxCells: 400000 // refuse to search a board this big rather than hang the tab | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * @param {Array} nodes [{ id, x, y, w, h }] | |
| 29 | * @param {Array} links [{ from, to }] | |
| 30 | * @returns {object} { routes: [{ from, to, points: [{x,y}] }], failed } | |
| 31 | */ | |
| 32 | export function routeEdges (nodes, links, opts = {}) { | |
| 33 | const O = { ...DEFAULTS, ...opts } | |
| 34 | const byId = new Map(nodes.map(n => [n.id, n])) | |
| 35 | ||
| 36 | const valid = links.filter(l => byId.has(l.from) && byId.has(l.to)) | |
| 37 | if (valid.length === 0) return { routes: [], failed: 0 } | |
| 38 | ||
| 39 | // --- the board: a grid where a cell is blocked if a node covers it. | |
| 40 | let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity | |
| 41 | for (const n of nodes) { | |
| 42 | minX = Math.min(minX, n.x) | |
| 43 | minY = Math.min(minY, n.y) | |
| 44 | maxX = Math.max(maxX, n.x + n.w) | |
| 45 | maxY = Math.max(maxY, n.y + n.h) | |
| 46 | } | |
| 47 | // Margin so a route can always go around the outside. | |
| 48 | const pad = O.grid * 6 | |
| 49 | minX -= pad; minY -= pad; maxX += pad; maxY += pad | |
| 50 | ||
| 51 | const cols = Math.ceil((maxX - minX) / O.grid) + 1 | |
| 52 | const rows = Math.ceil((maxY - minY) / O.grid) + 1 | |
| 53 | if (cols * rows > O.maxCells) { | |
| 54 | // Too big to search: say so rather than hang, and let the caller fall back. | |
| 55 | return { routes: [], failed: valid.length, tooLarge: true } | |
| 56 | } | |
| 57 | ||
| 58 | const blocked = new Uint8Array(cols * rows) | |
| 59 | const owner = new Int32Array(cols * rows).fill(-1) | |
| 60 | const idx = (cx, cy) => cy * cols + cx | |
| 61 | const toCellX = (x) => Math.round((x - minX) / O.grid) | |
| 62 | const toCellY = (y) => Math.round((y - minY) / O.grid) | |
| 63 | ||
| 64 | nodes.forEach((n, i) => { | |
| 65 | const x0 = toCellX(n.x - O.clearance) | |
| 66 | const x1 = toCellX(n.x + n.w + O.clearance) | |
| 67 | const y0 = toCellY(n.y - O.clearance) | |
| 68 | const y1 = toCellY(n.y + n.h + O.clearance) | |
| 69 | for (let cy = Math.max(0, y0); cy <= Math.min(rows - 1, y1); cy++) { | |
| 70 | for (let cx = Math.max(0, x0); cx <= Math.min(cols - 1, x1); cx++) { | |
| 71 | blocked[idx(cx, cy)] = 1 | |
| 72 | owner[idx(cx, cy)] = i | |
| 73 | } | |
| 74 | } | |
| 75 | }) | |
| 76 | ||
| 77 | // Edges from one source share a trunk: the channel just right of that node. | |
| 78 | const trunkOf = new Map() | |
| 79 | for (const l of valid) { | |
| 80 | if (trunkOf.has(l.from)) continue | |
| 81 | const n = byId.get(l.from) | |
| 82 | trunkOf.set(l.from, n.x + n.w + O.grid * 2) | |
| 83 | } | |
| 84 | ||
| 85 | const routes = [] | |
| 86 | let failed = 0 | |
| 87 | for (const l of valid) { | |
| 88 | const a = byId.get(l.from) | |
| 89 | const b = byId.get(l.to) | |
| 90 | const path = routeOne(a, b, trunkOf.get(l.from)) | |
| 91 | if (path) routes.push({ from: l.from, to: l.to, points: path }) | |
| 92 | else failed++ | |
| 93 | } | |
| 94 | return { routes, failed } | |
| 95 | ||
| 96 | // --- one route, as grid cells, then simplified back into world points. | |
| 97 | function routeOne (a, b, trunkX) { | |
| 98 | // Ports sit near the top of each node, where a header socket would be, and | |
| 99 | // a full grid step CLEAR of it — far enough that the first routable cell is | |
| 100 | // outside the node's blocked halo. | |
| 101 | const startWorld = { x: a.x + a.w + O.grid * 2, y: a.y + Math.min(a.h / 2, 10) } | |
| 102 | const endWorld = { x: b.x - O.grid * 2, y: b.y + Math.min(b.h / 2, 10) } | |
| 103 | ||
| 104 | const start = { cx: clampX(toCellX(startWorld.x)), cy: clampY(toCellY(startWorld.y)) } | |
| 105 | const goal = { cx: clampX(toCellX(endWorld.x)), cy: clampY(toCellY(endWorld.y)) } | |
| 106 | ||
| 107 | const aIdx = nodes.indexOf(a) | |
| 108 | const bIdx = nodes.indexOf(b) | |
| 109 | ||
| 110 | // Nothing is passable, not even the route's own endpoints: letting a route | |
| 111 | // cross its own source is how a backward edge ended up running straight | |
| 112 | // back THROUGH the node it came from. | |
| 113 | const strict = (cx, cy) => !blocked[idx(cx, cy)] | |
| 114 | ||
| 115 | // Only if that fails — a node boxed in on every side — allow the route to | |
| 116 | // pass through its own endpoints rather than dropping the edge. | |
| 117 | const permissive = (cx, cy) => { | |
| 118 | const i = idx(cx, cy) | |
| 119 | return !blocked[i] || owner[i] === aIdx || owner[i] === bIdx | |
| 120 | } | |
| 121 | ||
| 122 | const trunkCell = trunkX === undefined ? null : clampX(toCellX(trunkX)) | |
| 123 | const cells = astar(start, goal, strict, trunkCell) || astar(start, goal, permissive, trunkCell) | |
| 124 | if (!cells) return null | |
| 125 | ||
| 126 | const points = cells.map(c => ({ x: minX + c.cx * O.grid, y: minY + c.cy * O.grid })) | |
| 127 | ||
| 128 | // The stubs into the node edges take their y FROM THE GRID, not from the | |
| 129 | // socket: a port at an unsnapped y makes the first segment diagonal, which | |
| 130 | // is the one thing an orthogonal route must never be. | |
| 131 | points.unshift({ x: a.x + a.w, y: points[0].y }) | |
| 132 | points.push({ x: b.x, y: points[points.length - 1].y }) | |
| 133 | return simplify(points) | |
| 134 | } | |
| 135 | ||
| 136 | function clampX (cx) { return Math.max(0, Math.min(cols - 1, cx)) } | |
| 137 | function clampY (cy) { return Math.max(0, Math.min(rows - 1, cy)) } | |
| 138 | ||
| 139 | /** | |
| 140 | * A* with a TURN COST, which is what makes the result look like circuitry | |
| 141 | * rather than a staircase: going straight is free, changing direction is not. | |
| 142 | * A small bonus for running along the source's trunk keeps sibling edges | |
| 143 | * together so they can share it. | |
| 144 | */ | |
| 145 | function astar (start, goal, passable, trunkCx) { | |
| 146 | const startKey = start.cy * cols + start.cx | |
| 147 | const goalKey = goal.cy * cols + goal.cx | |
| 148 | ||
| 149 | const gScore = new Map([[startKey + ':0', 0]]) | |
| 150 | const came = new Map() | |
| 151 | const open = [{ key: startKey, dir: 0, f: heuristic(start), cx: start.cx, cy: start.cy }] | |
| 152 | ||
| 153 | const DIRS = [ | |
| 154 | { dx: 1, dy: 0, id: 1 }, { dx: -1, dy: 0, id: 2 }, | |
| 155 | { dx: 0, dy: 1, id: 3 }, { dx: 0, dy: -1, id: 4 } | |
| 156 | ] | |
| 157 | ||
| 158 | let guard = 0 | |
| 159 | while (open.length > 0) { | |
| 160 | if (++guard > O.maxCells) return null | |
| 161 | let bestAt = 0 | |
| 162 | for (let i = 1; i < open.length; i++) if (open[i].f < open[bestAt].f) bestAt = i | |
| 163 | const current = open.splice(bestAt, 1)[0] | |
| 164 | ||
| 165 | if (current.key === goalKey) return reconstruct(current) | |
| 166 | ||
| 167 | for (const d of DIRS) { | |
| 168 | const nx = current.cx + d.dx | |
| 169 | const ny = current.cy + d.dy | |
| 170 | if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue | |
| 171 | if (!passable(nx, ny)) continue | |
| 172 | ||
| 173 | const turn = current.dir !== 0 && current.dir !== d.id ? O.turnCost : 0 | |
| 174 | const trunkBonus = trunkCx !== null && nx === trunkCx ? -0.4 : 0 | |
| 175 | const tentative = (gScore.get(current.key + ':' + current.dir) || 0) + 1 + turn + trunkBonus | |
| 176 | ||
| 177 | const nKey = ny * cols + nx | |
| 178 | const stateKey = nKey + ':' + d.id | |
| 179 | if (gScore.has(stateKey) && gScore.get(stateKey) <= tentative) continue | |
| 180 | gScore.set(stateKey, tentative) | |
| 181 | came.set(stateKey, { key: current.key, dir: current.dir, cx: current.cx, cy: current.cy }) | |
| 182 | open.push({ | |
| 183 | key: nKey, dir: d.id, cx: nx, cy: ny, | |
| 184 | f: tentative + heuristic({ cx: nx, cy: ny }) | |
| 185 | }) | |
| 186 | } | |
| 187 | } | |
| 188 | return null | |
| 189 | ||
| 190 | function heuristic (c) { | |
| 191 | return Math.abs(c.cx - goal.cx) + Math.abs(c.cy - goal.cy) | |
| 192 | } | |
| 193 | ||
| 194 | function reconstruct (end) { | |
| 195 | const out = [{ cx: end.cx, cy: end.cy }] | |
| 196 | let cursor = { key: end.key, dir: end.dir, cx: end.cx, cy: end.cy } | |
| 197 | let steps = 0 | |
| 198 | while (came.has(cursor.key + ':' + cursor.dir)) { | |
| 199 | if (++steps > O.maxCells) break | |
| 200 | cursor = came.get(cursor.key + ':' + cursor.dir) | |
| 201 | out.push({ cx: cursor.cx, cy: cursor.cy }) | |
| 202 | } | |
| 203 | return out.reverse() | |
| 204 | } | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | /** Drop every point that does not change direction. */ | |
| 209 | function simplify (points) { | |
| 210 | if (points.length <= 2) return points | |
| 211 | const out = [points[0]] | |
| 212 | for (let i = 1; i < points.length - 1; i++) { | |
| 213 | const prev = out[out.length - 1] | |
| 214 | const next = points[i + 1] | |
| 215 | const straightX = Math.abs(prev.x - points[i].x) < 0.01 && Math.abs(points[i].x - next.x) < 0.01 | |
| 216 | const straightY = Math.abs(prev.y - points[i].y) < 0.01 && Math.abs(points[i].y - next.y) < 0.01 | |
| 217 | if (straightX || straightY) continue | |
| 218 | out.push(points[i]) | |
| 219 | } | |
| 220 | out.push(points[points.length - 1]) | |
| 221 | return out | |
| 222 | } | |
| 223 | ||
| 224 | /** | |
| 225 | * Where routes from the same source part company. | |
| 226 | * | |
| 227 | * Three wires leaving one node and fanning out should read as a bus with taps, | |
| 228 | * so the point a branch leaves the shared trunk gets a dot — the same thing a | |
| 229 | * circuit diagram does, and for the same reason. | |
| 230 | */ | |
| 231 | export function findJunctions (routes) { | |
| 232 | const bySource = new Map() | |
| 233 | for (const r of routes) { | |
| 234 | if (!bySource.has(r.from)) bySource.set(r.from, []) | |
| 235 | bySource.get(r.from).push(r) | |
| 236 | } | |
| 237 | ||
| 238 | const junctions = [] | |
| 239 | for (const group of bySource.values()) { | |
| 240 | if (group.length < 2) continue | |
| 241 | const counts = new Map() | |
| 242 | for (const r of group) { | |
| 243 | // Every bend on this route is a candidate tap. | |
| 244 | for (let i = 1; i < r.points.length - 1; i++) { | |
| 245 | const key = Math.round(r.points[i].x) + ',' + Math.round(r.points[i].y) | |
| 246 | counts.set(key, (counts.get(key) || 0) + 1) | |
| 247 | } | |
| 248 | } | |
| 249 | for (const [key, degree] of counts) { | |
| 250 | if (degree < 2) continue | |
| 251 | const [x, y] = key.split(',').map(Number) | |
| 252 | junctions.push({ x, y, degree }) | |
| 253 | } | |
| 254 | } | |
| 255 | junctions.sort((a, b) => a.x - b.x || a.y - b.y) | |
| 256 | return junctions | |
| 257 | } |
| … | … | @@ -278,10 +278,12 @@ export function startServer (tree, opts = {}) { |
| 278 | 278 | // --- the floor: a level of the node ladder, drawn from the derivation. |
| 279 | 279 | // /floor and every place under it serve the same shell; the client reads |
| 280 | 280 | // location.pathname, exactly as the SPA does. One scheme, two surfaces. |
| 281 | if (url === '/floor' || url === '/floor/' || | |
| 282 | (url.startsWith('/floor/') && !url.startsWith('/floor/level.js') && | |
| 283 | !url.startsWith('/floor/cards.js') && !url.startsWith('/floor/layout.js') && | |
| 284 | !url.startsWith('/floor/location.js'))) { | |
| 281 | // Every place under /floor serves the same shell, EXCEPT the modules it | |
| 282 | // loads. Forgetting one here means a module request gets HTML back and the | |
| 283 | // whole view fails to load with a blank page and a syntax error. | |
| 284 | const FLOOR_MODULES = ['level.js', 'cards.js', 'layout.js', 'location.js', 'routing.js'] | |
| 285 | const isFloorModule = FLOOR_MODULES.some(m => url === '/floor/' + m) | |
| 286 | if (url === '/floor' || url === '/floor/' || (url.startsWith('/floor/') && !isFloorModule)) { | |
| 285 | 287 | sendFile(req, res, 'floor/level.html', 'text/html; charset=utf-8') |
| 286 | 288 | return |
| 287 | 289 | } |
| … | … | @@ -291,6 +293,10 @@ export function startServer (tree, opts = {}) { |
| 291 | 293 | } |
| 292 | 294 | // The view imports the SAME layout module the tests run against, rather than |
| 293 | 295 | // a copy of it that can drift. |
| 296 | if (url === '/floor/routing.js') { | |
| 297 | sendFile(req, res, 'floor/routing.js', 'application/javascript; charset=utf-8') | |
| 298 | return | |
| 299 | } | |
| 294 | 300 | if (url === '/floor/location.js') { |
| 295 | 301 | sendFile(req, res, 'floor/location.js', 'application/javascript; charset=utf-8') |
| 296 | 302 | return |
| … | … | @@ -0,0 +1,133 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { routeEdges, findJunctions } from '../lib/floor/routing.js' | |
| 3 | ||
| 4 | // Edges were drawn as bezier curves straight from A to B, so they ran BEHIND | |
| 5 | // whatever happened to be in between. At 105 edges that is a wall of spaghetti. | |
| 6 | // These routes are orthogonal and obstacle-aware: they look like circuitry | |
| 7 | // because they behave like it. | |
| 8 | ||
| 9 | function rect (id, x, y, w = 200, h = 60) { | |
| 10 | return { id, x, y, w, h } | |
| 11 | } | |
| 12 | function pointInRect (p, r) { | |
| 13 | return p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h | |
| 14 | } | |
| 15 | // Does a horizontal/vertical segment pass through a rectangle? | |
| 16 | function segmentHitsRect (a, b, r) { | |
| 17 | const minX = Math.min(a.x, b.x) | |
| 18 | const maxX = Math.max(a.x, b.x) | |
| 19 | const minY = Math.min(a.y, b.y) | |
| 20 | const maxY = Math.max(a.y, b.y) | |
| 21 | return minX < r.x + r.w && maxX > r.x && minY < r.y + r.h && maxY > r.y | |
| 22 | } | |
| 23 | ||
| 24 | test('routing: a route is orthogonal — every segment is horizontal or vertical', (t) => { | |
| 25 | const nodes = [rect('a', 0, 0), rect('b', 400, 200)] | |
| 26 | const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }]) | |
| 27 | ||
| 28 | t.is(routes.length, 1, 'one route') | |
| 29 | const pts = routes[0].points | |
| 30 | t.ok(pts.length >= 2, 'with at least two points') | |
| 31 | for (let i = 1; i < pts.length; i++) { | |
| 32 | const dx = Math.abs(pts[i].x - pts[i - 1].x) | |
| 33 | const dy = Math.abs(pts[i].y - pts[i - 1].y) | |
| 34 | t.ok(dx < 0.01 || dy < 0.01, 'segment ' + i + ' is axis-aligned') | |
| 35 | } | |
| 36 | }) | |
| 37 | ||
| 38 | test('routing: NO route passes through a node — the whole point', (t) => { | |
| 39 | // c sits directly between a and b, which is exactly where a straight line | |
| 40 | // would go. | |
| 41 | const nodes = [rect('a', 0, 200), rect('c', 300, 180, 200, 100), rect('b', 700, 200)] | |
| 42 | const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }]) | |
| 43 | ||
| 44 | const pts = routes[0].points | |
| 45 | t.ok(pts.length > 2, 'it had to bend: ' + pts.length + ' points') | |
| 46 | for (let i = 1; i < pts.length; i++) { | |
| 47 | t.absent(segmentHitsRect(pts[i - 1], pts[i], nodes[1]), 'segment ' + i + ' clears the obstacle') | |
| 48 | } | |
| 49 | }) | |
| 50 | ||
| 51 | test('routing: a clear shot stays straight, no gratuitous bends', (t) => { | |
| 52 | const nodes = [rect('a', 0, 100), rect('b', 400, 100)] | |
| 53 | const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }]) | |
| 54 | const pts = routes[0].points | |
| 55 | ||
| 56 | const ys = new Set(pts.map(p => Math.round(p.y))) | |
| 57 | t.is(ys.size, 1, 'a clear horizontal run has one y: ' + [...ys].join(',')) | |
| 58 | }) | |
| 59 | ||
| 60 | test('routing: every route clears every node, on a crowded board', (t) => { | |
| 61 | const nodes = [] | |
| 62 | for (let col = 0; col < 5; col++) { | |
| 63 | for (let row = 0; row < 4; row++) { | |
| 64 | nodes.push(rect('n' + col + row, col * 330, row * 120)) | |
| 65 | } | |
| 66 | } | |
| 67 | const links = [ | |
| 68 | { from: 'n00', to: 'n43' }, { from: 'n01', to: 'n40' }, | |
| 69 | { from: 'n03', to: 'n20' }, { from: 'n10', to: 'n33' }, | |
| 70 | { from: 'n21', to: 'n02' } | |
| 71 | ] | |
| 72 | const { routes } = routeEdges(nodes, links) | |
| 73 | t.is(routes.length, links.length, 'every link routed') | |
| 74 | ||
| 75 | let hits = 0 | |
| 76 | for (const route of routes) { | |
| 77 | const endpoints = new Set([route.from, route.to]) | |
| 78 | for (let i = 1; i < route.points.length; i++) { | |
| 79 | for (const n of nodes) { | |
| 80 | if (endpoints.has(n.id)) continue | |
| 81 | if (segmentHitsRect(route.points[i - 1], route.points[i], n)) hits++ | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
| 85 | t.is(hits, 0, 'no segment crosses a node it does not belong to') | |
| 86 | }) | |
| 87 | ||
| 88 | test('routing: edges leaving one node share a trunk and get a JUNCTION', (t) => { | |
| 89 | // One source feeding three targets should look like a bus with taps, not | |
| 90 | // three separate wires drawn over each other. | |
| 91 | const nodes = [rect('src', 0, 200), rect('t1', 400, 0), rect('t2', 400, 200), rect('t3', 400, 400)] | |
| 92 | const links = [{ from: 'src', to: 't1' }, { from: 'src', to: 't2' }, { from: 'src', to: 't3' }] | |
| 93 | const { routes } = routeEdges(nodes, links) | |
| 94 | const junctions = findJunctions(routes) | |
| 95 | ||
| 96 | t.ok(junctions.length > 0, 'there are junctions: ' + junctions.length) | |
| 97 | for (const j of junctions) { | |
| 98 | t.ok(j.degree >= 2, 'a junction joins at least two routes') | |
| 99 | t.ok(Number.isFinite(j.x) && Number.isFinite(j.y), 'and has a position') | |
| 100 | } | |
| 101 | ||
| 102 | // Every route that BENDS leaves through the same trunk. One that has a clear | |
| 103 | // straight shot does not detour to join it — adding bends to a wire that does | |
| 104 | // not need them is the opposite of the goal. | |
| 105 | const bending = routes.filter(r => r.points.length > 2) | |
| 106 | t.ok(bending.length >= 2, 'at least two routes bend: ' + bending.length) | |
| 107 | const trunkXs = new Set(bending.map(r => Math.round(r.points[1].x))) | |
| 108 | t.is(trunkXs.size, 1, 'and they share one trunk: ' + [...trunkXs].join(',')) | |
| 109 | }) | |
| 110 | ||
| 111 | test('routing: a backward edge goes around rather than through', (t) => { | |
| 112 | const nodes = [rect('a', 600, 100), rect('b', 0, 100)] | |
| 113 | const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b' }]) | |
| 114 | const pts = routes[0].points | |
| 115 | ||
| 116 | t.ok(pts.length >= 4, 'it takes a path around: ' + pts.length + ' points') | |
| 117 | for (let i = 1; i < pts.length; i++) { | |
| 118 | for (const n of nodes) { | |
| 119 | if (n.id === 'a' || n.id === 'b') continue | |
| 120 | t.absent(segmentHitsRect(pts[i - 1], pts[i], n), 'clears everything') | |
| 121 | } | |
| 122 | } | |
| 123 | }) | |
| 124 | ||
| 125 | test('routing: deterministic, and degenerate input does not throw', (t) => { | |
| 126 | const nodes = [rect('a', 0, 0), rect('b', 300, 0)] | |
| 127 | const links = [{ from: 'a', to: 'b' }] | |
| 128 | t.alike(routeEdges(nodes, links), routeEdges(nodes, links), 'same in, same out') | |
| 129 | ||
| 130 | t.alike(routeEdges([], []).routes, [], 'empty') | |
| 131 | t.alike(routeEdges(nodes, [{ from: 'a', to: 'nope' }]).routes, [], 'a link to nothing is dropped') | |
| 132 | t.alike(findJunctions([]), [], 'no routes, no junctions') | |
| 133 | }) |