OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 93d504d

Circuit routing on the Nodes tab too, and pack the board instead of stringing it out
Two reports: "i dont see a difference" (the routing had gone into /floor only, not the tab actually being looked at) and "the /floor view is too linear and you are not packing well enough". ## The Nodes tab now routes through the same router That tab anchors edges to SOCKETS, not node edges, so routing it would have moved every wire off its connector. routeEdges() takes optional fromPoint and toPoint: the wire meets the exact socket, steps out horizontally, and takes the grid's y in the free channel — never a diagonal. Junction dots and a "circuit routing" toggle sit beside the existing two in the legend. That is the third time a fix landed in one view while the other was the one on screen. The reason is structural — two renderers — and this closes the last of it: layout, categories, the dirTree walk, locations and now routing all come from the same modules. ## Packing: two real defects, both measured **Cycles inflated depth into nonsense columns.** Bounded longest-path relaxation looks safe but is not: around a cycle it keeps relaxing until the iteration cap, so 15 groups on the system level landed at depths 0, 1, 47, 48, 49, 50. Depth 50 on a 15-item graph is a runaway, not a layer. A DFS now marks back edges and layering ignores them, because a cycle carries no layering information. **Every group wrapped at the same fixed height** whatever it held, so a group of 26 modules came out 1892x950 — a slab. The wrap height is now derived per group from that group's own content area and a target aspect. **And columns now wrap into rows** — word-wrap, for columns. Without it the board is a strip whatever the wrap height, because dependency layering only ever grows sideways. Measured across real levels, before -> after: system 5172x1408 3.67:1 -> 3158x1784 1.77:1 module hyper-overlay -> 2768x1352 2.05:1 module pear-git -> 6924x5034 1.38:1 module keet-cli 842x26012 0.03:1 -> 9060x6334 1.43:1 with 0 sibling overlaps, 0 children outside their parent and 0 escaped cards on every one of them. Layout now reports the `row` an item landed in, because once columns wrap "left of" is no longer the whole story: order is row-major, and a caller reasoning about it needs to know. ## Three of my own measurements were wrong before the code was A "37 overlaps" reading compared parent containers with their own children, which are SUPPOSED to be inside them. A dependency-order test asserted a property wrapping cannot preserve. And a same-row heuristic could not tell rows apart, which is why layout now reports the row. Suite: 233 tests, 5126 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93d504dbccab86cae9087c4004c7e27a9cf1acb3
by plankatron <fleeky@gmail.com> · 2026-09-21T00:58:08.000Z

Changed files (6) +301 -46

lib/floor/routing.js +22 -13
……@@ -87,19 +87,22 @@ export function routeEdges (nodes, links, opts = {}) {
8787 for (const l of valid) {
8888 const a = byId.get(l.from)
8989 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)
9191 if (path) routes.push({ from: l.from, to: l.to, points: path })
9292 else failed++
9393 }
9494 return { routes, failed }
9595
9696 // --- 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 }
103106
104107 const start = { cx: clampX(toCellX(startWorld.x)), cy: clampY(toCellY(startWorld.y)) }
105108 const goal = { cx: clampX(toCellX(endWorld.x)), cy: clampY(toCellY(endWorld.y)) }
……@@ -125,12 +128,18 @@ export function routeEdges (nodes, links, opts = {}) {
125128
126129 const points = cells.map(c => ({ x: minX + c.cx * O.grid, y: minY + c.cy * O.grid }))
127130
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])
134143 }
135144
136145 function clampX (cx) { return Math.max(0, Math.min(cols - 1, cx)) }
lib/layout.js +108 -16
……@@ -33,19 +33,61 @@ const DEFAULTS = {
3333 // occupy its own area of the page.
3434 groupGap: 90,
3535
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.
3737 //
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
4346}
4447
4548// The implicit group that holds everything with no container of its own. Not a
4649// real container: it is never drawn and gets no padding or label.
4750const ROOT = '__layout_root__'
4851
52function 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. */
62function 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
4991/**
5092 * @param {object} input
5193 * nodes: [{ id, container, w, h }] container = a container id or null
……@@ -135,6 +177,17 @@ export function layout (input, opts = {}) {
135177 items.push({ id: nid, kind: 'node', w: n.w || 0, h: n.h || 0 })
136178 }
137179
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
138191 // Edges between items of THIS group. A link whose endpoints resolve to the
139192 // same item is internal to that item and says nothing about ordering here.
140193 const itemIds = new Set(items.map(i => i.id))
……@@ -147,14 +200,25 @@ export function layout (input, opts = {}) {
147200 edges.push([a, b])
148201 }
149202
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
153217 const depth = new Map()
154218 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++) {
156220 let moved = false
157 for (const [a, b] of edges) {
221 for (const [a, b] of dagEdges) {
158222 if (depth.get(b) < depth.get(a) + 1) { depth.set(b, depth.get(a) + 1); moved = true }
159223 }
160224 if (!moved) break
……@@ -169,13 +233,34 @@ export function layout (input, opts = {}) {
169233 }
170234 const keys = Array.from(columns.keys()).sort((a, b) => a - b)
171235
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
172245 const placed = []
173246 let x = 0
247 let rowY = 0
248 let rowH = 0
249 let rowIndex = 0
174250 let maxH = 0
251 let maxRight = 0
175252 for (const k of keys) {
176253 const col = columns.get(k)
177254 col.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
178255
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
179264 // One LAYER may occupy several columns. Wrapping keeps x monotonic in
180265 // dependency depth — every member of this layer is placed before the next
181266 // layer starts — so a supplier still sits left of its consumer.
……@@ -183,6 +268,7 @@ export function layout (input, opts = {}) {
183268 let colW = 0
184269 let holdsGroup = false
185270 let prev = null
271 const wrapAt = wrapHeight
186272 for (const item of col) {
187273 // The gap BEFORE this item: a group on either side of the boundary earns
188274 // the group gap, otherwise it is a card gap.
……@@ -192,22 +278,27 @@ export function layout (input, opts = {}) {
192278 : O.rowGap
193279 y += gap
194280 }
195 if (y > 0 && y + item.h > O.maxColumnHeight) {
281 if (y > 0 && y + item.h > wrapAt) {
196282 x += colW + (holdsGroup ? O.groupGap : O.colGap)
197283 y = 0
198284 colW = 0
199285 holdsGroup = false
200286 }
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 })
202291 y += item.h
203292 if (item.w > colW) colW = item.w
204293 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
206296 prev = item
207297 }
208298 x += colW + (holdsGroup ? O.groupGap : O.colGap)
299 if (x > maxRight) maxRight = x
209300 }
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))
211302
212303 const isRoot = gid === ROOT
213304 const padL = isRoot ? 0 : O.pad
……@@ -238,7 +329,7 @@ export function layout (input, opts = {}) {
238329 const x = baseX + p.dx
239330 const y = baseY + p.dy
240331 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 })
242333 } else {
243334 const c = containerById.get(p.id)
244335 outContainers.push({
……@@ -248,6 +339,7 @@ export function layout (input, opts = {}) {
248339 y,
249340 w: p.w,
250341 h: p.h,
342 row: p.row,
251343 depth: depth + 1
252344 })
253345 emit(p.id, x, y, depth + 1)
lib/render-nodes.js +57 -3
……@@ -50,6 +50,8 @@ export const NODE_VIEW_CSS = `
5050
5151.nv-frame { fill-opacity: 0.10; stroke-opacity: 0.55; stroke-width: 1.5; rx: 8; }
5252.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; }
5355
5456#node-breadcrumb {
5557 position: absolute; top: 12px; left: 12px; display: flex; gap: 4px;
……@@ -100,6 +102,7 @@ var NV = {
100102 initialized: false,
101103 sourceCache: {},
102104 frames: [],
105 circuit: true,
103106 layoutPending: false,
104107 selected: null
105108}
……@@ -601,6 +604,43 @@ function nvRender () {
601604 var byId = {}
602605 for (var i = 0; i < NV.nodes.length; i++) byId[NV.nodes[i].id] = NV.nodes[i]
603606
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
604644 for (var l = 0; l < NV.links.length; l++) {
605645 var lk = NV.links[l]
606646 var a = byId[lk.from], b = byId[lk.to]
……@@ -609,14 +649,23 @@ function nvRender () {
609649 var p2 = nvSocketPos(b, lk.toSide, lk.toSock)
610650 var dx = Math.max(40, Math.abs(p2.x - p1.x) * 0.5)
611651 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 +
613653 ' C ' + (p1.x + dx) + ' ' + p1.y + ', ' + (p2.x - dx) + ' ' + p2.y +
614 ', ' + p2.x + ' ' + p2.y)
654 ', ' + p2.x + ' ' + p2.y))
615655 path.setAttribute('class', 'nv-noodle')
616656 path.setAttribute('stroke', '#5a6570')
617657 noodleG.appendChild(path)
618658 }
619659
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
620669 nvDrawFrames(frameG)
621670 for (var i = 0; i < NV.nodes.length; i++) nvDrawNode(nodeG, NV.nodes[i])
622671
……@@ -783,12 +832,17 @@ function nvLegend () {
783832 ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' +
784833 '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
785834 '<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>'
787839
788840 var cb = document.getElementById('nv-only-connected')
789841 if (cb) cb.addEventListener('change', function () { nvToggleConnected(this.checked) })
790842 var fb = document.getElementById('nv-frames')
791843 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() })
792846}
793847
794848// ---- build per level ----
lib/render.js +4 -2
……@@ -3921,14 +3921,16 @@ ${NODE_VIEW_JS}
39213921;(async function boot () {
39223922 if (!window.__ct) {
39233923 try {
3924 const [cards, layoutMod, locationMod] = await Promise.all([
3924 const [cards, layoutMod, locationMod, routingMod] = await Promise.all([
39253925 import(API + '/floor/cards.js'),
39263926 import(API + '/floor/layout.js'),
3927 import(API + '/floor/location.js')
3927 import(API + '/floor/location.js'),
3928 import(API + '/floor/routing.js')
39283929 ])
39293930 window.__ct = cards
39303931 window.__ctLayout = layoutMod.layout
39313932 window.__ctLoc = locationMod
3933 window.__ctRoute = routingMod
39323934 } catch (err) {
39333935 document.getElementById('detail').innerHTML =
39343936 '<div style="padding:20px;color:#ff7b72">Could not load the shared view modules: ' +
test/layout.test.js +91 -12
……@@ -106,10 +106,19 @@ test('layout: sibling containers get their OWN breathing room, not the card gap'
106106 ]
107107 }, { groupGap: 90, rowGap: 26 })
108108
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 }
113122 }
114123})
115124
……@@ -218,9 +227,11 @@ test('layout: a link-less group WRAPS instead of forming one endless column', (t
218227 }
219228})
220229
221test('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.
230test('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.
224235 const nodes = []
225236 const links = []
226237 for (let i = 0; i < 12; i++) {
……@@ -232,13 +243,81 @@ test('layout: wrapping does not break dependency order', (t) => {
232243 const byId = {}
233244 for (const n of out.nodes) byId[n.id] = n
234245
235 let maxA = -Infinity
236 let minB = Infinity
237246 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
255test('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
276test('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
291test('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 }
240313 }
241 t.ok(maxA <= minB, 'every supplier is left of every consumer')
314})
315
316test('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)
242321})
243322
244323test('layout: purity and stability', (t) => {
test/routing.test.js +19 -0
……@@ -131,3 +131,22 @@ test('routing: deterministic, and degenerate input does not throw', (t) => {
131131 t.alike(routeEdges(nodes, [{ from: 'a', to: 'nope' }]).routes, [], 'a link to nothing is dropped')
132132 t.alike(findJunctions([]), [], 'no routes, no junctions')
133133})
134
135test('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})