| … | … | @@ -21,6 +21,14 @@ const DEFAULTS = { |
| 21 | 21 | grid: 20, // routing resolution; also the minimum clearance around a node |
| 22 | 22 | clearance: 10, // how far a route stays off a node's edge |
| 23 | 23 | turnCost: 6, // a turn costs this many grid steps, so paths stay straight |
| 24 | ||
| 25 | // What it costs to run down a lane another wire is already using. | |
| 26 | // | |
| 27 | // Without this every wire takes the same shortest channel and they stack | |
| 28 | // exactly on top of each other, which reads as one wire — or as a short | |
| 29 | // between two circuits that are not connected at all. Siblings are exempt: | |
| 30 | // wires from one source SHOULD share a trunk, that is what makes a junction. | |
| 31 | laneCost: 9, | |
| 24 | 32 | maxCells: 400000 // refuse to search a board this big rather than hang the tab |
| 25 | 33 | } |
| 26 | 34 | |
| … | … | @@ -82,19 +90,44 @@ export function routeEdges (nodes, links, opts = {}) { |
| 82 | 90 | trunkOf.set(l.from, n.x + n.w + O.grid * 2) |
| 83 | 91 | } |
| 84 | 92 | |
| 93 | // Which source last claimed each cell, so a later route can tell "my own | |
| 94 | // trunk" from "somebody else's lane". | |
| 95 | const laneOwner = new Map() | |
| 96 | ||
| 85 | 97 | const routes = [] |
| 86 | 98 | let failed = 0 |
| 87 | 99 | for (const l of valid) { |
| 88 | 100 | const a = byId.get(l.from) |
| 89 | 101 | const b = byId.get(l.to) |
| 90 | const path = routeOne(a, b, trunkOf.get(l.from), l.fromPoint, l.toPoint) | |
| 91 | if (path) routes.push({ from: l.from, to: l.to, points: path }) | |
| 92 | else failed++ | |
| 102 | const path = routeOne(a, b, trunkOf.get(l.from), l.fromPoint, l.toPoint, l.from) | |
| 103 | if (path) { | |
| 104 | routes.push({ from: l.from, to: l.to, points: path }) | |
| 105 | claimLanes(path, l.from) | |
| 106 | } else failed++ | |
| 93 | 107 | } |
| 94 | 108 | return { routes, failed } |
| 95 | 109 | |
| 110 | // Mark every cell a finished route runs through, so the next one prefers a | |
| 111 | // free lane beside it rather than the same one. | |
| 112 | function claimLanes (points, source) { | |
| 113 | for (let i = 1; i < points.length; i++) { | |
| 114 | const from = points[i - 1] | |
| 115 | const to = points[i] | |
| 116 | const steps = Math.max( | |
| 117 | Math.abs(toCellX(to.x) - toCellX(from.x)), | |
| 118 | Math.abs(toCellY(to.y) - toCellY(from.y)) | |
| 119 | ) | |
| 120 | for (let step = 0; step <= steps; step++) { | |
| 121 | const t = steps === 0 ? 0 : step / steps | |
| 122 | const cx = clampCell(toCellX(from.x + (to.x - from.x) * t), cols) | |
| 123 | const cy = clampCell(toCellY(from.y + (to.y - from.y) * t), rows) | |
| 124 | laneOwner.set(cy * cols + cx, source) | |
| 125 | } | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 96 | 129 | // --- one route, as grid cells, then simplified back into world points. |
| 97 | function routeOne (a, b, trunkX, fromPoint, toPoint) { | |
| 130 | function routeOne (a, b, trunkX, fromPoint, toPoint, source) { | |
| 98 | 131 | // A caller with real SOCKETS supplies the exact points the wire must meet; |
| 99 | 132 | // otherwise the ports sit where a header socket would be. Either way the |
| 100 | 133 | // first ROUTABLE cell is a full grid step clear of the node, outside its |
| … | … | @@ -123,7 +156,8 @@ export function routeEdges (nodes, links, opts = {}) { |
| 123 | 156 | } |
| 124 | 157 | |
| 125 | 158 | const trunkCell = trunkX === undefined ? null : clampX(toCellX(trunkX)) |
| 126 | const cells = astar(start, goal, strict, trunkCell) || astar(start, goal, permissive, trunkCell) | |
| 159 | const cells = astar(start, goal, strict, trunkCell, source) || | |
| 160 | astar(start, goal, permissive, trunkCell, source) | |
| 127 | 161 | if (!cells) return null |
| 128 | 162 | |
| 129 | 163 | const points = cells.map(c => ({ x: minX + c.cx * O.grid, y: minY + c.cy * O.grid })) |
| … | … | @@ -142,6 +176,7 @@ export function routeEdges (nodes, links, opts = {}) { |
| 142 | 176 | return simplify([...head, ...points, ...tail]) |
| 143 | 177 | } |
| 144 | 178 | |
| 179 | function clampCell (v, limit) { return Math.max(0, Math.min(limit - 1, v)) } | |
| 145 | 180 | function clampX (cx) { return Math.max(0, Math.min(cols - 1, cx)) } |
| 146 | 181 | function clampY (cy) { return Math.max(0, Math.min(rows - 1, cy)) } |
| 147 | 182 | |
| … | … | @@ -151,7 +186,7 @@ export function routeEdges (nodes, links, opts = {}) { |
| 151 | 186 | * A small bonus for running along the source's trunk keeps sibling edges |
| 152 | 187 | * together so they can share it. |
| 153 | 188 | */ |
| 154 | function astar (start, goal, passable, trunkCx) { | |
| 189 | function astar (start, goal, passable, trunkCx, source) { | |
| 155 | 190 | const startKey = start.cy * cols + start.cx |
| 156 | 191 | const goalKey = goal.cy * cols + goal.cx |
| 157 | 192 | |
| … | … | @@ -181,7 +216,14 @@ export function routeEdges (nodes, links, opts = {}) { |
| 181 | 216 | |
| 182 | 217 | const turn = current.dir !== 0 && current.dir !== d.id ? O.turnCost : 0 |
| 183 | 218 | const trunkBonus = trunkCx !== null && nx === trunkCx ? -0.4 : 0 |
| 184 | const tentative = (gScore.get(current.key + ':' + current.dir) || 0) + 1 + turn + trunkBonus | |
| 219 | ||
| 220 | // Somebody else's lane costs extra; my own source's trunk does not, | |
| 221 | // because sharing it is the point. | |
| 222 | const claimed = laneOwner.get(ny * cols + nx) | |
| 223 | const lane = claimed !== undefined && claimed !== source ? O.laneCost : 0 | |
| 224 | ||
| 225 | const tentative = (gScore.get(current.key + ':' + current.dir) || 0) + | |
| 226 | 1 + turn + trunkBonus + lane | |
| 185 | 227 | |
| 186 | 228 | const nKey = ny * cols + nx |
| 187 | 229 | const stateKey = nKey + ':' + d.id |
| … | … | @@ -77,6 +77,19 @@ export const NODE_VIEW_CSS = ` |
| 77 | 77 | } |
| 78 | 78 | #node-legend label { pointer-events: auto; } |
| 79 | 79 | #node-legend b { color: #c9d1d9; } |
| 80 | #nv-deps { | |
| 81 | position: absolute; top: 12px; right: 12px; width: 300px; max-height: 70%; | |
| 82 | overflow-y: auto; background: #1c1d21; border: 1px solid #2f3136; | |
| 83 | border-radius: 6px; padding: 10px 12px; font-size: 11px; color: #8b949e; | |
| 84 | display: none; line-height: 1.6; | |
| 85 | } | |
| 86 | #nv-deps.open { display: block; } | |
| 87 | #nv-deps h4 { margin: 0 0 6px; color: #e8e8ea; font-size: 11px; } | |
| 88 | #nv-deps .grp { margin-top: 8px; color: #c9d1d9; font-weight: 600; } | |
| 89 | #nv-deps .row { display: flex; justify-content: space-between; gap: 8px; } | |
| 90 | #nv-deps .ext { color: #6e7076; } | |
| 91 | #nv-deps .hops { font-family: ui-monospace, monospace; font-size: 9px; } | |
| 92 | ||
| 80 | 93 | #node-hint { |
| 81 | 94 | position: absolute; bottom: 12px; right: 12px; background: #1c1d21; |
| 82 | 95 | border: 1px solid #2f3136; border-radius: 6px; padding: 8px 12px; |
| … | … | @@ -89,6 +102,7 @@ export const NODE_VIEW_HTML = ` |
| 89 | 102 | <svg id="node-svg"><defs></defs></svg> |
| 90 | 103 | <div id="node-breadcrumb"></div> |
| 91 | 104 | <div id="node-legend"></div> |
| 105 | <div id="nv-deps"></div> | |
| 92 | 106 | <div id="node-hint">drag to pan · scroll to zoom · click a node title to open it</div> |
| 93 | 107 | </div> |
| 94 | 108 | ` |
| … | … | @@ -680,6 +694,8 @@ function nvRender () { |
| 680 | 694 | nvDrawFrames(frameG) |
| 681 | 695 | for (var i = 0; i < NV.nodes.length; i++) nvDrawNode(nodeG, NV.nodes[i]) |
| 682 | 696 | |
| 697 | nvDepsPanel() | |
| 698 | ||
| 683 | 699 | // Dependencies of dependencies: clicking a node traces its WHOLE closure, not |
| 684 | 700 | // one hop, with distance carried by colour. Same shared implementation the |
| 685 | 701 | // floor uses, so both views agree about what "depends on" means. |
| … | … | @@ -803,6 +819,66 @@ function nvDrawNode (parent, n) { |
| 803 | 819 | NV.nodeEls[n.id] = g |
| 804 | 820 | } |
| 805 | 821 | |
| 822 | /** | |
| 823 | * The selected node's FULL dependency list. | |
| 824 | * | |
| 825 | * The graph can only draw an edge when both ends are nodes on screen, and most | |
| 826 | * dependencies are not: measured on this scan, 684 dependencies are declared and | |
| 827 | * only 105 have both ends in the scan. A module like atproto-identity declares | |
| 828 | * ten and has NONE in-scan, so the drawing alone says it depends on nothing. | |
| 829 | * This panel says what the edges cannot. | |
| 830 | */ | |
| 831 | function nvDepsPanel () { | |
| 832 | var panel = document.getElementById('nv-deps') | |
| 833 | if (!panel) return | |
| 834 | var node = null | |
| 835 | for (var i = 0; i < NV.nodes.length; i++) if (NV.nodes[i].id === NV.selected) node = NV.nodes[i] | |
| 836 | if (!node || node.kind !== 'module' || !tree.modules[node.title]) { | |
| 837 | panel.className = '' | |
| 838 | panel.innerHTML = '' | |
| 839 | return | |
| 840 | } | |
| 841 | ||
| 842 | var mod = tree.modules[node.title] | |
| 843 | var direct = Object.keys(mod.dependencies || {}).sort() | |
| 844 | var inScan = {} | |
| 845 | for (var m = 0; m < tree.system.modules.length; m++) inScan[tree.system.modules[m]] = true | |
| 846 | ||
| 847 | var html = '<h4>' + escapeHtml(node.title) + ' dependencies</h4>' | |
| 848 | html += '<div>' + direct.length + ' declared · ' + | |
| 849 | direct.filter(function (d) { return inScan[d] }).length + ' drawn as edges</div>' | |
| 850 | ||
| 851 | html += '<div class="grp">direct</div>' | |
| 852 | for (var d = 0; d < direct.length; d++) { | |
| 853 | var isIn = !!inScan[direct[d]] | |
| 854 | html += '<div class="row"><span' + (isIn ? '' : ' class="ext"') + '>' + | |
| 855 | escapeHtml(direct[d]) + '</span><span class="hops">' + | |
| 856 | (isIn ? 'in scan' : 'external') + '</span></div>' | |
| 857 | } | |
| 858 | ||
| 859 | // Transitive, through the modules the scan actually has. | |
| 860 | if (window.__ct) { | |
| 861 | var closure = window.__ct.dependencyClosure(NV.links, NV.selected) | |
| 862 | var rows = [] | |
| 863 | for (var j = 0; j < NV.nodes.length; j++) { | |
| 864 | var other = NV.nodes[j] | |
| 865 | var depth = closure.nodes.get(other.id) | |
| 866 | if (depth === undefined || depth < 2) continue | |
| 867 | rows.push({ name: other.title, depth: depth }) | |
| 868 | } | |
| 869 | rows.sort(function (a, b) { return a.depth - b.depth || (a.name < b.name ? -1 : 1) }) | |
| 870 | html += '<div class="grp">through those (' + rows.length + ')</div>' | |
| 871 | if (rows.length === 0) html += '<div class="ext">none - every dependency is direct</div>' | |
| 872 | for (var r = 0; r < rows.length; r++) { | |
| 873 | html += '<div class="row"><span>' + escapeHtml(rows[r].name) + | |
| 874 | '</span><span class="hops">' + rows[r].depth + ' hops</span></div>' | |
| 875 | } | |
| 876 | } | |
| 877 | ||
| 878 | panel.innerHTML = html | |
| 879 | panel.className = 'open' | |
| 880 | } | |
| 881 | ||
| 806 | 882 | function nvFocus (node) { |
| 807 | 883 | var connected = {} |
| 808 | 884 | connected[node.id] = true |
| … | … | @@ -122,6 +122,65 @@ test('routing: a backward edge goes around rather than through', (t) => { |
| 122 | 122 | } |
| 123 | 123 | }) |
| 124 | 124 | |
| 125 | // How much of segment A is covered by a collinear segment of B. | |
| 126 | function sharedLength (a1, a2, b1, b2) { | |
| 127 | const aVert = Math.abs(a1.x - a2.x) < 0.01 | |
| 128 | const bVert = Math.abs(b1.x - b2.x) < 0.01 | |
| 129 | if (aVert !== bVert) return 0 | |
| 130 | if (aVert) { | |
| 131 | if (Math.abs(a1.x - b1.x) > 0.01) return 0 | |
| 132 | const lo = Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y)) | |
| 133 | const hi = Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y)) | |
| 134 | return Math.max(0, hi - lo) | |
| 135 | } | |
| 136 | if (Math.abs(a1.y - b1.y) > 0.01) return 0 | |
| 137 | const lo = Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x)) | |
| 138 | const hi = Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x)) | |
| 139 | return Math.max(0, hi - lo) | |
| 140 | } | |
| 141 | ||
| 142 | function overlapBetween (r1, r2) { | |
| 143 | let total = 0 | |
| 144 | for (let i = 1; i < r1.points.length; i++) { | |
| 145 | for (let j = 1; j < r2.points.length; j++) { | |
| 146 | total += sharedLength(r1.points[i - 1], r1.points[i], r2.points[j - 1], r2.points[j]) | |
| 147 | } | |
| 148 | } | |
| 149 | return total | |
| 150 | } | |
| 151 | ||
| 152 | test('routing: wires from DIFFERENT sources get their own lane', (t) => { | |
| 153 | // Unrelated wires drawn down the same channel sit exactly on top of each | |
| 154 | // other, which reads as one wire — or worse, as a short between two circuits | |
| 155 | // that are not connected at all. | |
| 156 | const nodes = [ | |
| 157 | rect('a1', 0, 0), rect('a2', 0, 200), rect('a3', 0, 400), | |
| 158 | rect('b1', 700, 400), rect('b2', 700, 200), rect('b3', 700, 0) | |
| 159 | ] | |
| 160 | const links = [ | |
| 161 | { from: 'a1', to: 'b1' }, { from: 'a2', to: 'b2' }, { from: 'a3', to: 'b3' } | |
| 162 | ] | |
| 163 | const { routes } = routeEdges(nodes, links) | |
| 164 | ||
| 165 | for (let i = 0; i < routes.length; i++) { | |
| 166 | for (let j = i + 1; j < routes.length; j++) { | |
| 167 | const shared = overlapBetween(routes[i], routes[j]) | |
| 168 | t.ok(shared < 60, routes[i].from + ' and ' + routes[j].from + | |
| 169 | ' share only ' + Math.round(shared) + 'px of lane') | |
| 170 | } | |
| 171 | } | |
| 172 | }) | |
| 173 | ||
| 174 | test('routing: wires from the SAME source still share their trunk', (t) => { | |
| 175 | // Lane separation must not break the bus: siblings SHOULD run together, that | |
| 176 | // is what makes a junction a junction rather than a crossing. | |
| 177 | const nodes = [rect('src', 0, 200), rect('t1', 600, 0), rect('t2', 600, 400)] | |
| 178 | const { routes } = routeEdges(nodes, [{ from: 'src', to: 't1' }, { from: 'src', to: 't2' }]) | |
| 179 | ||
| 180 | t.is(routes.length, 2, 'both routed') | |
| 181 | t.ok(overlapBetween(routes[0], routes[1]) > 0, 'siblings do share a trunk') | |
| 182 | }) | |
| 183 | ||
| 125 | 184 | test('routing: deterministic, and degenerate input does not throw', (t) => { |
| 126 | 185 | const nodes = [rect('a', 0, 0), rect('b', 300, 0)] |
| 127 | 186 | const links = [{ from: 'a', to: 'b' }] |