| … | … | @@ -87,19 +87,22 @@ export function routeEdges (nodes, links, opts = {}) { |
| 87 | 87 | for (const l of valid) { |
| 88 | 88 | const a = byId.get(l.from) |
| 89 | 89 | const b = byId.get(l.to) |
| 90 | const path = routeOne(a, b, trunkOf.get(l.from)) | |
| 90 | const path = routeOne(a, b, trunkOf.get(l.from), l.fromPoint, l.toPoint) | |
| 91 | 91 | if (path) routes.push({ from: l.from, to: l.to, points: path }) |
| 92 | 92 | else failed++ |
| 93 | 93 | } |
| 94 | 94 | return { routes, failed } |
| 95 | 95 | |
| 96 | 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) } | |
| 97 | function routeOne (a, b, trunkX, fromPoint, toPoint) { | |
| 98 | // A caller with real SOCKETS supplies the exact points the wire must meet; | |
| 99 | // otherwise the ports sit where a header socket would be. Either way the | |
| 100 | // first ROUTABLE cell is a full grid step clear of the node, outside its | |
| 101 | // blocked halo. | |
| 102 | const startY = fromPoint ? fromPoint.y : a.y + Math.min(a.h / 2, 10) | |
| 103 | const endY = toPoint ? toPoint.y : b.y + Math.min(b.h / 2, 10) | |
| 104 | const startWorld = { x: a.x + a.w + O.grid * 2, y: startY } | |
| 105 | const endWorld = { x: b.x - O.grid * 2, y: endY } | |
| 103 | 106 | |
| 104 | 107 | const start = { cx: clampX(toCellX(startWorld.x)), cy: clampY(toCellY(startWorld.y)) } |
| 105 | 108 | const goal = { cx: clampX(toCellX(endWorld.x)), cy: clampY(toCellY(endWorld.y)) } |
| … | … | @@ -125,12 +128,18 @@ export function routeEdges (nodes, links, opts = {}) { |
| 125 | 128 | |
| 126 | 129 | const points = cells.map(c => ({ x: minX + c.cx * O.grid, y: minY + c.cy * O.grid })) |
| 127 | 130 | |
| 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) | |
| 131 | // Meet the exact socket, without ever drawing a diagonal: step out | |
| 132 | // horizontally from the socket, then take the grid's y in the free channel. | |
| 133 | const exitX = points[0].x | |
| 134 | const entryX = points[points.length - 1].x | |
| 135 | const head = fromPoint | |
| 136 | ? [{ x: fromPoint.x, y: fromPoint.y }, { x: exitX, y: fromPoint.y }] | |
| 137 | : [{ x: a.x + a.w, y: points[0].y }] | |
| 138 | const tail = toPoint | |
| 139 | ? [{ x: entryX, y: toPoint.y }, { x: toPoint.x, y: toPoint.y }] | |
| 140 | : [{ x: b.x, y: points[points.length - 1].y }] | |
| 141 | ||
| 142 | return simplify([...head, ...points, ...tail]) | |
| 134 | 143 | } |
| 135 | 144 | |
| 136 | 145 | function clampX (cx) { return Math.max(0, Math.min(cols - 1, cx)) } |
| … | … | @@ -33,19 +33,61 @@ const DEFAULTS = { |
| 33 | 33 | // occupy its own area of the page. |
| 34 | 34 | groupGap: 90, |
| 35 | 35 | |
| 36 | // How tall one layer may grow before it wraps into another column. | |
| 36 | // The shape to aim for, width:height, when nothing says otherwise. | |
| 37 | 37 | // |
| 38 | // Layering decides x, but a layer with many members and no edges to spread | |
| 39 | // them has to wrap or it becomes an endless column: measured on a real scan, | |
| 40 | // keet-cli has 207 files, 117 directories and exactly ONE import edge, which | |
| 41 | // put every item at depth 0 and produced a column 26,012px tall. | |
| 42 | maxColumnHeight: 1600 | |
| 38 | // A layer with many members and no edges to spread them has to wrap or it | |
| 39 | // becomes an endless column — keet-cli has 207 files, 117 directories and ONE | |
| 40 | // import edge, which produced a column 26,012px tall. But wrapping at a FIXED | |
| 41 | // height makes every group the same shape whatever it holds. The wrap height | |
| 42 | // is derived per group from its own content area and this aspect; | |
| 43 | // maxColumnHeight overrides it outright. | |
| 44 | aspect: 1.6, | |
| 45 | maxColumnHeight: undefined | |
| 43 | 46 | } |
| 44 | 47 | |
| 45 | 48 | // The implicit group that holds everything with no container of its own. Not a |
| 46 | 49 | // real container: it is never drawn and gets no padding or label. |
| 47 | 50 | const ROOT = '__layout_root__' |
| 48 | 51 | |
| 52 | function adjacencyOf (edges) { | |
| 53 | const out = new Map() | |
| 54 | for (const [a, b] of edges) { | |
| 55 | if (!out.has(a)) out.set(a, []) | |
| 56 | out.get(a).push(b) | |
| 57 | } | |
| 58 | return out | |
| 59 | } | |
| 60 | ||
| 61 | /** Edges that point back at a node still on the DFS stack: a cycle's edges. */ | |
| 62 | function findBackEdges (items, forward) { | |
| 63 | const back = new Set() | |
| 64 | const state = new Map() // 0 unvisited, 1 on stack, 2 done | |
| 65 | ||
| 66 | for (const item of items) { | |
| 67 | if (state.get(item.id)) continue | |
| 68 | // Iterative DFS: a deep graph must not blow the call stack. | |
| 69 | const stack = [{ id: item.id, next: 0 }] | |
| 70 | state.set(item.id, 1) | |
| 71 | while (stack.length > 0) { | |
| 72 | const top = stack[stack.length - 1] | |
| 73 | const kids = forward.get(top.id) || [] | |
| 74 | if (top.next >= kids.length) { | |
| 75 | state.set(top.id, 2) | |
| 76 | stack.pop() | |
| 77 | continue | |
| 78 | } | |
| 79 | const child = kids[top.next++] | |
| 80 | const seen = state.get(child) || 0 | |
| 81 | if (seen === 1) back.add(top.id + ' -> ' + child) | |
| 82 | else if (seen === 0) { | |
| 83 | state.set(child, 1) | |
| 84 | stack.push({ id: child, next: 0 }) | |
| 85 | } | |
| 86 | } | |
| 87 | } | |
| 88 | return back | |
| 89 | } | |
| 90 | ||
| 49 | 91 | /** |
| 50 | 92 | * @param {object} input |
| 51 | 93 | * nodes: [{ id, container, w, h }] container = a container id or null |
| … | … | @@ -135,6 +177,17 @@ export function layout (input, opts = {}) { |
| 135 | 177 | items.push({ id: nid, kind: 'node', w: n.w || 0, h: n.h || 0 }) |
| 136 | 178 | } |
| 137 | 179 | |
| 180 | // How tall this group may grow before it wraps. | |
| 181 | // | |
| 182 | // A single fixed height made every group the same shape regardless of what | |
| 183 | // it held: a group of 26 modules came out 1892x950, a slab. Target a square | |
| 184 | // -ish box for the CONTENT THIS GROUP ACTUALLY HAS, so a big group grows in | |
| 185 | // both directions instead of only sideways. An explicit option still wins. | |
| 186 | const contentArea = items.reduce((sum, it) => sum + it.w * it.h, 0) | |
| 187 | const wrapHeight = O.maxColumnHeight !== undefined | |
| 188 | ? O.maxColumnHeight | |
| 189 | : Math.max(240, Math.sqrt(contentArea / Math.max(0.2, O.aspect)) * 1.6) | |
| 190 | ||
| 138 | 191 | // Edges between items of THIS group. A link whose endpoints resolve to the |
| 139 | 192 | // same item is internal to that item and says nothing about ordering here. |
| 140 | 193 | const itemIds = new Set(items.map(i => i.id)) |
| … | … | @@ -147,14 +200,25 @@ export function layout (input, opts = {}) { |
| 147 | 200 | edges.push([a, b]) |
| 148 | 201 | } |
| 149 | 202 | |
| 150 | // Longest-path layering, bounded, so a dependency cycle settles instead of | |
| 151 | // spinning: once a pass stops moving anything, the cycle has stopped | |
| 152 | // deepening. | |
| 203 | // Layering, with BACK EDGES REMOVED first. | |
| 204 | // | |
| 205 | // Bounded longest-path relaxation looks safe but is not: around a cycle it | |
| 206 | // keeps relaxing until the iteration cap, so on the real system level 15 | |
| 207 | // groups landed at depths 0, 1, 47, 48, 49, 50. Depth 50 on a 15-item graph | |
| 208 | // is a runaway, not a layer, and it is what spread the board into a strip. | |
| 209 | // | |
| 210 | // A depth-first pass marks every edge that points back at a node still on | |
| 211 | // the stack; those edges are what a cycle is made of, and they carry no | |
| 212 | // layering information. The rest is a DAG and relaxes in one pass per layer. | |
| 213 | const forward = adjacencyOf(edges) | |
| 214 | const backEdges = findBackEdges(items, forward) | |
| 215 | const dagEdges = edges.filter(([a, b]) => !backEdges.has(a + ' -> ' + b)) | |
| 216 | ||
| 153 | 217 | const depth = new Map() |
| 154 | 218 | for (const i of items) depth.set(i.id, 0) |
| 155 | for (let pass = 0; pass < items.length + 1; pass++) { | |
| 219 | for (let pass = 0; pass < items.length; pass++) { | |
| 156 | 220 | let moved = false |
| 157 | for (const [a, b] of edges) { | |
| 221 | for (const [a, b] of dagEdges) { | |
| 158 | 222 | if (depth.get(b) < depth.get(a) + 1) { depth.set(b, depth.get(a) + 1); moved = true } |
| 159 | 223 | } |
| 160 | 224 | if (!moved) break |
| … | … | @@ -169,13 +233,34 @@ export function layout (input, opts = {}) { |
| 169 | 233 | } |
| 170 | 234 | const keys = Array.from(columns.keys()).sort((a, b) => a - b) |
| 171 | 235 | |
| 236 | // Columns run left to right in dependency order, and WRAP into a new row | |
| 237 | // when the board gets too wide — word-wrap, for columns. | |
| 238 | // | |
| 239 | // Without this the level is a strip: the real system level put 15 groups | |
| 240 | // across 6 dependency layers and came out 6374x1408, over 4:1, so most of it | |
| 241 | // is off-screen one way while the other is empty. Wrapping keeps the reading | |
| 242 | // order and packs the board toward something a screen can hold. | |
| 243 | const targetWidth = Math.max(900, Math.sqrt(contentArea * Math.max(0.2, O.aspect)) * 1.7) | |
| 244 | ||
| 172 | 245 | const placed = [] |
| 173 | 246 | let x = 0 |
| 247 | let rowY = 0 | |
| 248 | let rowH = 0 | |
| 249 | let rowIndex = 0 | |
| 174 | 250 | let maxH = 0 |
| 251 | let maxRight = 0 | |
| 175 | 252 | for (const k of keys) { |
| 176 | 253 | const col = columns.get(k) |
| 177 | 254 | col.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) |
| 178 | 255 | |
| 256 | const colWidth = col.reduce((w, it) => Math.max(w, it.w), 0) | |
| 257 | if (x > 0 && x + colWidth > targetWidth) { | |
| 258 | x = 0 | |
| 259 | rowY += rowH + (col.some(it => it.kind === 'container') ? O.groupGap : O.rowGap) | |
| 260 | rowH = 0 | |
| 261 | rowIndex++ | |
| 262 | } | |
| 263 | ||
| 179 | 264 | // One LAYER may occupy several columns. Wrapping keeps x monotonic in |
| 180 | 265 | // dependency depth — every member of this layer is placed before the next |
| 181 | 266 | // layer starts — so a supplier still sits left of its consumer. |
| … | … | @@ -183,6 +268,7 @@ export function layout (input, opts = {}) { |
| 183 | 268 | let colW = 0 |
| 184 | 269 | let holdsGroup = false |
| 185 | 270 | let prev = null |
| 271 | const wrapAt = wrapHeight | |
| 186 | 272 | for (const item of col) { |
| 187 | 273 | // The gap BEFORE this item: a group on either side of the boundary earns |
| 188 | 274 | // the group gap, otherwise it is a card gap. |
| … | … | @@ -192,22 +278,27 @@ export function layout (input, opts = {}) { |
| 192 | 278 | : O.rowGap |
| 193 | 279 | y += gap |
| 194 | 280 | } |
| 195 | if (y > 0 && y + item.h > O.maxColumnHeight) { | |
| 281 | if (y > 0 && y + item.h > wrapAt) { | |
| 196 | 282 | x += colW + (holdsGroup ? O.groupGap : O.colGap) |
| 197 | 283 | y = 0 |
| 198 | 284 | colW = 0 |
| 199 | 285 | holdsGroup = false |
| 200 | 286 | } |
| 201 | placed.push({ id: item.id, kind: item.kind, dx: x, dy: y, w: item.w, h: item.h }) | |
| 287 | // `row` is reported because once columns wrap, "left of" is no longer | |
| 288 | // the whole story: order is row-major, and a caller that wants to reason | |
| 289 | // about it needs to know which row an item landed in. | |
| 290 | placed.push({ id: item.id, kind: item.kind, dx: x, dy: rowY + y, w: item.w, h: item.h, row: rowIndex }) | |
| 202 | 291 | y += item.h |
| 203 | 292 | if (item.w > colW) colW = item.w |
| 204 | 293 | if (item.kind === 'container') holdsGroup = true |
| 205 | if (y > maxH) maxH = y | |
| 294 | if (y > rowH) rowH = y | |
| 295 | if (rowY + y > maxH) maxH = rowY + y | |
| 206 | 296 | prev = item |
| 207 | 297 | } |
| 208 | 298 | x += colW + (holdsGroup ? O.groupGap : O.colGap) |
| 299 | if (x > maxRight) maxRight = x | |
| 209 | 300 | } |
| 210 | const contentW = Math.max(0, x - Math.min(O.colGap, O.groupGap)) | |
| 301 | const contentW = Math.max(0, maxRight - Math.min(O.colGap, O.groupGap)) | |
| 211 | 302 | |
| 212 | 303 | const isRoot = gid === ROOT |
| 213 | 304 | const padL = isRoot ? 0 : O.pad |
| … | … | @@ -238,7 +329,7 @@ export function layout (input, opts = {}) { |
| 238 | 329 | const x = baseX + p.dx |
| 239 | 330 | const y = baseY + p.dy |
| 240 | 331 | if (p.kind === 'node') { |
| 241 | outNodes.push({ id: p.id, x, y, w: p.w, h: p.h }) | |
| 332 | outNodes.push({ id: p.id, x, y, w: p.w, h: p.h, row: p.row }) | |
| 242 | 333 | } else { |
| 243 | 334 | const c = containerById.get(p.id) |
| 244 | 335 | outContainers.push({ |
| … | … | @@ -248,6 +339,7 @@ export function layout (input, opts = {}) { |
| 248 | 339 | y, |
| 249 | 340 | w: p.w, |
| 250 | 341 | h: p.h, |
| 342 | row: p.row, | |
| 251 | 343 | depth: depth + 1 |
| 252 | 344 | }) |
| 253 | 345 | emit(p.id, x, y, depth + 1) |
| … | … | @@ -50,6 +50,8 @@ export const NODE_VIEW_CSS = ` |
| 50 | 50 | |
| 51 | 51 | .nv-frame { fill-opacity: 0.10; stroke-opacity: 0.55; stroke-width: 1.5; rx: 8; } |
| 52 | 52 | .nv-frame-label { font-size: 12px; font-weight: bold; fill-opacity: 0.95; pointer-events: none; } |
| 53 | .nv-junction { fill: #5a6570; stroke: none; } | |
| 54 | .nv-noodle { stroke-linejoin: round; stroke-linecap: round; } | |
| 53 | 55 | |
| 54 | 56 | #node-breadcrumb { |
| 55 | 57 | position: absolute; top: 12px; left: 12px; display: flex; gap: 4px; |
| … | … | @@ -100,6 +102,7 @@ var NV = { |
| 100 | 102 | initialized: false, |
| 101 | 103 | sourceCache: {}, |
| 102 | 104 | frames: [], |
| 105 | circuit: true, | |
| 103 | 106 | layoutPending: false, |
| 104 | 107 | selected: null |
| 105 | 108 | } |
| … | … | @@ -601,6 +604,43 @@ function nvRender () { |
| 601 | 604 | var byId = {} |
| 602 | 605 | for (var i = 0; i < NV.nodes.length; i++) byId[NV.nodes[i].id] = NV.nodes[i] |
| 603 | 606 | |
| 607 | // Orthogonal routing, anchored to the actual SOCKETS: a wire leaves the socket | |
| 608 | // it belongs to, runs in the free channels and goes AROUND whatever is in the | |
| 609 | // way, rather than a bezier drawn straight through everything between. | |
| 610 | var routed = {} | |
| 611 | var junctions = [] | |
| 612 | if (NV.circuit && window.__ctRoute) { | |
| 613 | var boxes = [] | |
| 614 | for (var bi = 0; bi < NV.nodes.length; bi++) { | |
| 615 | var bn = NV.nodes[bi] | |
| 616 | boxes.push({ id: bn.id, x: bn.x, y: bn.y, w: NV_W, h: nvNodeHeight(bn) }) | |
| 617 | } | |
| 618 | var routeLinks = [] | |
| 619 | for (var ri = 0; ri < NV.links.length; ri++) { | |
| 620 | var rl = NV.links[ri] | |
| 621 | var ra = byId[rl.from], rb = byId[rl.to] | |
| 622 | if (!ra || !rb) continue | |
| 623 | routeLinks.push({ | |
| 624 | from: rl.from, | |
| 625 | to: rl.to, | |
| 626 | fromPoint: nvSocketPos(ra, rl.fromSide, rl.fromSock), | |
| 627 | toPoint: nvSocketPos(rb, rl.toSide, rl.toSock) | |
| 628 | }) | |
| 629 | } | |
| 630 | // Grid 40: measured on a 56-module scan, 105 edges route in 0.4s at 40 and | |
| 631 | // 3.4s at 20, both routing every edge. | |
| 632 | var out = window.__ctRoute.routeEdges(boxes, routeLinks, { grid: 40 }) | |
| 633 | for (var oi = 0; oi < out.routes.length; oi++) { | |
| 634 | var rt = out.routes[oi] | |
| 635 | var dparts = [] | |
| 636 | for (var pi = 0; pi < rt.points.length; pi++) { | |
| 637 | dparts.push(rt.points[pi].x.toFixed(1) + ' ' + rt.points[pi].y.toFixed(1)) | |
| 638 | } | |
| 639 | routed[rt.from + ' -> ' + rt.to] = 'M ' + dparts.join(' L ') | |
| 640 | } | |
| 641 | junctions = window.__ctRoute.findJunctions(out.routes) | |
| 642 | } | |
| 643 | ||
| 604 | 644 | for (var l = 0; l < NV.links.length; l++) { |
| 605 | 645 | var lk = NV.links[l] |
| 606 | 646 | var a = byId[lk.from], b = byId[lk.to] |
| … | … | @@ -609,14 +649,23 @@ function nvRender () { |
| 609 | 649 | var p2 = nvSocketPos(b, lk.toSide, lk.toSock) |
| 610 | 650 | var dx = Math.max(40, Math.abs(p2.x - p1.x) * 0.5) |
| 611 | 651 | var path = document.createElementNS('http://www.w3.org/2000/svg', 'path') |
| 612 | path.setAttribute('d', 'M ' + p1.x + ' ' + p1.y + | |
| 652 | path.setAttribute('d', routed[lk.from + ' -> ' + lk.to] || ('M ' + p1.x + ' ' + p1.y + | |
| 613 | 653 | ' C ' + (p1.x + dx) + ' ' + p1.y + ', ' + (p2.x - dx) + ' ' + p2.y + |
| 614 | ', ' + p2.x + ' ' + p2.y) | |
| 654 | ', ' + p2.x + ' ' + p2.y)) | |
| 615 | 655 | path.setAttribute('class', 'nv-noodle') |
| 616 | 656 | path.setAttribute('stroke', '#5a6570') |
| 617 | 657 | noodleG.appendChild(path) |
| 618 | 658 | } |
| 619 | 659 | |
| 660 | for (var ji = 0; ji < junctions.length; ji++) { | |
| 661 | var jc = document.createElementNS('http://www.w3.org/2000/svg', 'circle') | |
| 662 | jc.setAttribute('class', 'nv-junction') | |
| 663 | jc.setAttribute('cx', junctions[ji].x) | |
| 664 | jc.setAttribute('cy', junctions[ji].y) | |
| 665 | jc.setAttribute('r', 3) | |
| 666 | noodleG.appendChild(jc) | |
| 667 | } | |
| 668 | ||
| 620 | 669 | nvDrawFrames(frameG) |
| 621 | 670 | for (var i = 0; i < NV.nodes.length; i++) nvDrawNode(nodeG, NV.nodes[i]) |
| 622 | 671 | |
| … | … | @@ -783,12 +832,17 @@ function nvLegend () { |
| 783 | 832 | ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' + |
| 784 | 833 | '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' + |
| 785 | 834 | '<input type="checkbox" id="nv-frames"' + (NV.showFrames ? ' checked' : '') + |
| 786 | ' style="vertical-align:-1px;accent-color:#58a6ff"> semantic frames</label>' | |
| 835 | ' style="vertical-align:-1px;accent-color:#58a6ff"> semantic frames</label>' + | |
| 836 | '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' + | |
| 837 | '<input type="checkbox" id="nv-circuit"' + (NV.circuit ? ' checked' : '') + | |
| 838 | ' style="vertical-align:-1px;accent-color:#58a6ff"> circuit routing</label>' | |
| 787 | 839 | |
| 788 | 840 | var cb = document.getElementById('nv-only-connected') |
| 789 | 841 | if (cb) cb.addEventListener('change', function () { nvToggleConnected(this.checked) }) |
| 790 | 842 | var fb = document.getElementById('nv-frames') |
| 791 | 843 | if (fb) fb.addEventListener('change', function () { nvToggleFrames(this.checked) }) |
| 844 | var cb2 = document.getElementById('nv-circuit') | |
| 845 | if (cb2) cb2.addEventListener('change', function () { NV.circuit = this.checked; nvRender() }) | |
| 792 | 846 | } |
| 793 | 847 | |
| 794 | 848 | // ---- build per level ---- |
| … | … | @@ -3921,14 +3921,16 @@ ${NODE_VIEW_JS} |
| 3921 | 3921 | ;(async function boot () { |
| 3922 | 3922 | if (!window.__ct) { |
| 3923 | 3923 | try { |
| 3924 | const [cards, layoutMod, locationMod] = await Promise.all([ | |
| 3924 | const [cards, layoutMod, locationMod, routingMod] = await Promise.all([ | |
| 3925 | 3925 | import(API + '/floor/cards.js'), |
| 3926 | 3926 | import(API + '/floor/layout.js'), |
| 3927 | import(API + '/floor/location.js') | |
| 3927 | import(API + '/floor/location.js'), | |
| 3928 | import(API + '/floor/routing.js') | |
| 3928 | 3929 | ]) |
| 3929 | 3930 | window.__ct = cards |
| 3930 | 3931 | window.__ctLayout = layoutMod.layout |
| 3931 | 3932 | window.__ctLoc = locationMod |
| 3933 | window.__ctRoute = routingMod | |
| 3932 | 3934 | } catch (err) { |
| 3933 | 3935 | document.getElementById('detail').innerHTML = |
| 3934 | 3936 | '<div style="padding:20px;color:#ff7b72">Could not load the shared view modules: ' + |
| … | … | @@ -106,10 +106,19 @@ test('layout: sibling containers get their OWN breathing room, not the card gap' |
| 106 | 106 | ] |
| 107 | 107 | }, { groupGap: 90, rowGap: 26 }) |
| 108 | 108 | |
| 109 | const boxes = out.containers.slice().sort((p, q) => p.y - q.y) | |
| 110 | for (let i = 1; i < boxes.length; i++) { | |
| 111 | const gap = boxes[i].y - (boxes[i - 1].y + boxes[i - 1].h) | |
| 112 | t.ok(gap >= 90, 'gap between group ' + (i - 1) + ' and ' + i + ' is ' + gap + ', not a card gap') | |
| 109 | // Groups may be arranged in a row or a column depending on how they pack, so | |
| 110 | // the claim is about SEPARATION, not about one axis: any two must be clear of | |
| 111 | // each other by the group gap in whichever direction separates them. | |
| 112 | const boxes = out.containers | |
| 113 | for (let i = 0; i < boxes.length; i++) { | |
| 114 | for (let j = i + 1; j < boxes.length; j++) { | |
| 115 | const a = boxes[i] | |
| 116 | const b = boxes[j] | |
| 117 | const gapX = Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w) | |
| 118 | const gapY = Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h) | |
| 119 | t.ok(Math.max(gapX, gapY) >= 90, | |
| 120 | a.id + '/' + b.id + ' separated by ' + Math.round(Math.max(gapX, gapY))) | |
| 121 | } | |
| 113 | 122 | } |
| 114 | 123 | }) |
| 115 | 124 | |
| … | … | @@ -218,9 +227,11 @@ test('layout: a link-less group WRAPS instead of forming one endless column', (t |
| 218 | 227 | } |
| 219 | 228 | }) |
| 220 | 229 | |
| 221 | test('layout: wrapping does not break dependency order', (t) => { | |
| 222 | // Two layers, each too tall for the budget. Everything in layer 0 must still | |
| 223 | // sit left of everything in layer 1. | |
| 230 | test('layout: wrapping keeps dependency order in READING order', (t) => { | |
| 231 | // Columns wrap into rows to keep the board packed, so "supplier is always to | |
| 232 | // the left" cannot hold — a later layer may begin the next row. What holds is | |
| 233 | // reading order: a supplier is never on a LATER row, and on the same row it | |
| 234 | // is to the left. That is the honest claim once the board wraps. | |
| 224 | 235 | const nodes = [] |
| 225 | 236 | const links = [] |
| 226 | 237 | for (let i = 0; i < 12; i++) { |
| … | … | @@ -232,13 +243,81 @@ test('layout: wrapping does not break dependency order', (t) => { |
| 232 | 243 | const byId = {} |
| 233 | 244 | for (const n of out.nodes) byId[n.id] = n |
| 234 | 245 | |
| 235 | let maxA = -Infinity | |
| 236 | let minB = Infinity | |
| 237 | 246 | for (let i = 0; i < 12; i++) { |
| 238 | maxA = Math.max(maxA, byId['a' + i].x + byId['a' + i].w) | |
| 239 | minB = Math.min(minB, byId['b' + i].x) | |
| 247 | const a = byId['a' + i] | |
| 248 | const b = byId['b' + i] | |
| 249 | t.ok(Number.isInteger(a.row), 'the layout says which row a' + i + ' is in') | |
| 250 | if (a.row === b.row) t.ok(a.x + a.w <= b.x, 'a' + i + ' is left of b' + i + ' on their row') | |
| 251 | else t.ok(a.row < b.row, 'a' + i + ' is on an earlier row than b' + i) | |
| 252 | } | |
| 253 | }) | |
| 254 | ||
| 255 | test('layout: a cycle does not inflate depth into nonsense columns', (t) => { | |
| 256 | // Measured on the real system level: 15 groups came out at depths 0, 1, 47, | |
| 257 | // 48, 49, 50, because longest-path layering keeps relaxing around a cycle | |
| 258 | // until the iteration cap. Depth 50 on a 15-item graph is not a layer, it is | |
| 259 | // a runaway. | |
| 260 | const nodes = [] | |
| 261 | const links = [] | |
| 262 | for (let i = 0; i < 8; i++) nodes.push(node('n' + i, null, 200, 60)) | |
| 263 | for (let i = 0; i < 8; i++) links.push({ from: 'n' + i, to: 'n' + ((i + 1) % 8) }) // a ring | |
| 264 | ||
| 265 | const out = layout({ nodes, links, containers: [] }) | |
| 266 | const xs = [...new Set(out.nodes.map(n => Math.round(n.x)))].sort((a, b) => a - b) | |
| 267 | t.ok(xs.length <= nodes.length, 'no more columns than items: ' + xs.length) | |
| 268 | ||
| 269 | // A ring, once one back edge is dropped, IS a chain of eight — so eight | |
| 270 | // columns is right. What must not happen is the runaway: fifty columns for | |
| 271 | // eight items. | |
| 272 | t.ok(out.width <= nodes.length * (200 + 130) + 200, | |
| 273 | 'width matches the chain, not a runaway: ' + Math.round(out.width)) | |
| 274 | }) | |
| 275 | ||
| 276 | test('layout: a big group packs squarely, not as a slab', (t) => { | |
| 277 | // "b4a users" held 26 modules and came out 1892x950 — a wide slab, because | |
| 278 | // every group wrapped at the same fixed height whatever it contained. | |
| 279 | const nodes = [] | |
| 280 | for (let i = 0; i < 26; i++) nodes.push(node('m' + i, 'big', 260, 48)) | |
| 281 | const out = layout({ | |
| 282 | nodes, links: [], containers: [{ id: 'big', parent: null, label: 'big' }] | |
| 283 | }) | |
| 284 | ||
| 285 | const box = out.containers[0] | |
| 286 | const ratio = box.w / box.h | |
| 287 | t.ok(ratio > 0.4 && ratio < 2.6, 'the group is squarish: ' + | |
| 288 | Math.round(box.w) + 'x' + Math.round(box.h) + ' = ' + ratio.toFixed(2) + ':1') | |
| 289 | }) | |
| 290 | ||
| 291 | test('layout: packs toward a readable aspect instead of a long strip', (t) => { | |
| 292 | // A fixed wrap height makes the whole board a strip: the real system level | |
| 293 | // came out 5594x1460, nearly 4:1, so most of it is off-screen in one | |
| 294 | // direction while the other is empty. | |
| 295 | const nodes = [] | |
| 296 | const containers = [] | |
| 297 | for (let i = 0; i < 24; i++) { | |
| 298 | const id = 'g' + String(i).padStart(2, '0') | |
| 299 | containers.push({ id, parent: null, label: id }) | |
| 300 | nodes.push(node('n' + i, id, 240, 90)) | |
| 301 | } | |
| 302 | const out = layout({ nodes, links: [], containers }) | |
| 303 | const ratio = out.width / out.height | |
| 304 | ||
| 305 | t.ok(ratio > 0.6 && ratio < 3, 'aspect is in a usable band: ' + | |
| 306 | Math.round(out.width) + 'x' + Math.round(out.height) + ' = ' + ratio.toFixed(2) + ':1') | |
| 307 | ||
| 308 | // Still no overlaps — packing must not trade correctness for shape. | |
| 309 | for (const a of out.containers) { | |
| 310 | for (const b of out.containers) { | |
| 311 | if (a.id < b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint') | |
| 312 | } | |
| 240 | 313 | } |
| 241 | t.ok(maxA <= minB, 'every supplier is left of every consumer') | |
| 314 | }) | |
| 315 | ||
| 316 | test('layout: an explicit wrap height still wins', (t) => { | |
| 317 | const nodes = [] | |
| 318 | for (let i = 0; i < 30; i++) nodes.push(node('n' + i, null, 200, 60)) | |
| 319 | const out = layout({ nodes, links: [], containers: [] }, { maxColumnHeight: 500 }) | |
| 320 | t.ok(out.height <= 600, 'honoured: ' + out.height) | |
| 242 | 321 | }) |
| 243 | 322 | |
| 244 | 323 | test('layout: purity and stability', (t) => { |
| … | … | @@ -131,3 +131,22 @@ test('routing: deterministic, and degenerate input does not throw', (t) => { |
| 131 | 131 | t.alike(routeEdges(nodes, [{ from: 'a', to: 'nope' }]).routes, [], 'a link to nothing is dropped') |
| 132 | 132 | t.alike(findJunctions([]), [], 'no routes, no junctions') |
| 133 | 133 | }) |
| 134 | ||
| 135 | test('routing: explicit socket ports are met exactly, still with no diagonal', (t) => { | |
| 136 | // The Nodes tab anchors edges to SOCKETS, not to node edges, so a router that | |
| 137 | // only knows about rectangles would move every wire off its connector. | |
| 138 | const nodes = [rect('a', 0, 0, 200, 120), rect('b', 500, 200, 200, 120)] | |
| 139 | const from = { x: 200, y: 44 } // a's third output socket | |
| 140 | const to = { x: 500, y: 266 } // b's second input socket | |
| 141 | const { routes } = routeEdges(nodes, [{ from: 'a', to: 'b', fromPoint: from, toPoint: to }]) | |
| 142 | ||
| 143 | const pts = routes[0].points | |
| 144 | t.alike(pts[0], from, 'starts exactly on the source socket') | |
| 145 | t.alike(pts[pts.length - 1], to, 'ends exactly on the target socket') | |
| 146 | ||
| 147 | for (let i = 1; i < pts.length; i++) { | |
| 148 | const dx = Math.abs(pts[i].x - pts[i - 1].x) | |
| 149 | const dy = Math.abs(pts[i].y - pts[i - 1].y) | |
| 150 | t.ok(dx < 0.01 || dy < 0.01, 'segment ' + i + ' is axis-aligned') | |
| 151 | } | |
| 152 | }) |