| … | … | @@ -47,7 +47,31 @@ const DEFAULTS = { |
| 47 | 47 | // 'median' orders each column to uncross the wires and shorten them; 'id' is |
| 48 | 48 | // the old arbitrary order, kept so the difference can be MEASURED rather than |
| 49 | 49 | // asserted. |
| 50 | | order: 'median' |
| 50 | order: 'median', |
| 51 | |
| 52 | // 'affinity' re-sequences a column that is about to WRAP so that items sharing |
| 53 | // an outside neighbour stay adjacent. OFF by default: it was written first and |
| 54 | // then measured against the second layout pass below, which knows where those |
| 55 | // neighbours actually ARE rather than merely that they are shared. The two |
| 56 | // fight, and the pass wins — with affinity on, favela-web's routed wires came |
| 57 | // out 5% shorter instead of 16%. Kept because it is the only tool for a board |
| 58 | // where positions cannot help, and so the comparison stays reproducible. |
| 59 | sequence: 'none', |
| 60 | |
| 61 | // How many times to lay the whole thing out. |
| 62 | // |
| 63 | // Groups are laid out BOTTOM-UP — a directory is sized before the parent |
| 64 | // decides where the directory goes — so while a group is being laid out, the |
| 65 | // position of everything OUTSIDE it is unknown. That is fatal for ordering, |
| 66 | // because nearly every wire in a directory leaves the directory: the first |
| 67 | // pass can only order members against each other, which for most groups means |
| 68 | // it cannot order them at all. |
| 69 | // |
| 70 | // A second pass fixes that by using the FIRST pass's absolute positions as the |
| 71 | // hint for where each member's outside neighbours will be. Two passes is |
| 72 | // enough — a third moved nothing measurable — and it stays pure: same input, |
| 73 | // same output. |
| 74 | passes: 2 |
| 51 | 75 | } |
| 52 | 76 | |
| 53 | 77 | // The implicit group that holds everything with no container of its own. Not a |
| … | … | @@ -111,6 +135,117 @@ function orderColumns (columns, keys, edges) { |
| 111 | 135 | } |
| 112 | 136 | } |
| 113 | 137 | |
| 138 | /** |
| 139 | * Sequence ONE column's items so that items sharing connections end up next to |
| 140 | * each other — done BEFORE the column is wrapped into sub-columns. |
| 141 | * |
| 142 | * Median ordering puts an item at the median y of its neighbours in the adjacent |
| 143 | * column. That is the right order while the column stays ONE column, because |
| 144 | * then position-in-the-list is y and y is comparable across columns. A column |
| 145 | * that overflows `wrapHeight` is cut into several sub-columns, and at that point |
| 146 | * the list position stops meaning y: items 1 and 40 can end up side by side |
| 147 | * while 39 and 40 sit a sub-column apart. So the median order is not merely |
| 148 | * useless after a wrap, it scatters clusters — sorting keet-cli's 207 files by a |
| 149 | * median almost none of them have falls back to id order, i.e. alphabetical, and |
| 150 | * files that all import the same module land in five different sub-columns. |
| 151 | * |
| 152 | * For a column that will wrap, sequence by affinity instead: greedily grow runs |
| 153 | * of items that are connected. Affinity is a direct edge (strong — they really |
| 154 | * are joined) plus one per neighbour they have in COMMON, which is what makes a |
| 155 | * cluster a cluster when the members are not joined to each other. Ties break by |
| 156 | * degree then id, so the result is deterministic. |
| 157 | * |
| 158 | * The common neighbours are mostly OUTSIDE the group. Layering only ever needs |
| 159 | * edges between siblings, so that is all `edges` holds — and with only those, a |
| 160 | * directory of twelve files that all import the same module looks completely |
| 161 | * unconnected and this pass does nothing (measured: 0% on every level). Hence |
| 162 | * `external`, the [member, outside-endpoint] pairs for links that leave the |
| 163 | * group: two files pointing at the same outside node are what makes them |
| 164 | * neighbours here. |
| 165 | */ |
| 166 | function sequenceByAffinity (col, edges, external) { |
| 167 | if (col.length < 3) return col |
| 168 | const inCol = new Map(col.map((it, i) => [it.id, i])) |
| 169 | |
| 170 | const affinity = col.map(() => new Map()) |
| 171 | const bump = (i, j, w) => { |
| 172 | if (i === j) return |
| 173 | affinity[i].set(j, (affinity[i].get(j) || 0) + w) |
| 174 | affinity[j].set(i, (affinity[j].get(i) || 0) + w) |
| 175 | } |
| 176 | |
| 177 | const fan = new Map() // a neighbour outside the column -> members attached to it |
| 178 | const attach = (member, outside) => { |
| 179 | const i = inCol.get(member) |
| 180 | if (i === undefined) return |
| 181 | if (!fan.has(outside)) fan.set(outside, []) |
| 182 | fan.get(outside).push(i) |
| 183 | } |
| 184 | for (const [a, b] of edges) { |
| 185 | const ia = inCol.get(a) |
| 186 | const ib = inCol.get(b) |
| 187 | if (ia !== undefined && ib !== undefined) { bump(ia, ib, 3); continue } |
| 188 | if (ia !== undefined) attach(a, b) |
| 189 | else if (ib !== undefined) attach(b, a) |
| 190 | } |
| 191 | for (const [member, outside] of external) attach(member, outside) |
| 192 | |
| 193 | // A neighbour that most of the column touches cannot tell you which members |
| 194 | // belong together, and pairing it up is what makes this pass quadratic. |
| 195 | const HUB = 64 |
| 196 | for (const raw of fan.values()) { |
| 197 | // The same member can reach one outside node through several links; counting |
| 198 | // it twice would let one heavy importer dominate a pair's affinity. |
| 199 | const members = Array.from(new Set(raw)) |
| 200 | if (members.length > HUB) continue |
| 201 | for (let i = 0; i < members.length; i++) { |
| 202 | for (let j = i + 1; j < members.length; j++) bump(members[i], members[j], 1) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // This is a REFINEMENT of the incoming order, not a replacement for it. |
| 207 | // |
| 208 | // Seeding runs by degree and breaking ties by id throws the median order away, |
| 209 | // and the median order is not worthless after a wrap — within one sub-column, |
| 210 | // list position is still y. Measured, discarding it cost favela-web 4% more |
| 211 | // wire and 6% more crossings while helping elsewhere: a wash bought with a |
| 212 | // regression. So walk the incoming order and, each time an item is placed, |
| 213 | // DRAG ITS CLUSTER ALONG. Where affinity says nothing the median order |
| 214 | // survives untouched. |
| 215 | const order = [] |
| 216 | const taken = new Array(col.length).fill(false) |
| 217 | let cursor = 0 |
| 218 | while (order.length < col.length) { |
| 219 | while (taken[cursor]) cursor++ |
| 220 | const seed = cursor |
| 221 | taken[seed] = true |
| 222 | order.push(seed) |
| 223 | |
| 224 | for (;;) { |
| 225 | const tail = order[order.length - 1] |
| 226 | const prev = order.length > 1 ? order[order.length - 2] : -1 |
| 227 | let pick = -1 |
| 228 | let best = 0 |
| 229 | for (let i = 0; i < col.length; i++) { |
| 230 | if (taken[i]) continue |
| 231 | // Weight the tail heavily but let the one before it break ties, so a run |
| 232 | // follows the cluster rather than wandering off down a single strong edge. |
| 233 | const score = (affinity[tail].get(i) || 0) * 4 + |
| 234 | (prev === -1 ? 0 : (affinity[prev].get(i) || 0)) |
| 235 | if (score === 0) continue |
| 236 | // Ties go to the earlier item in the incoming order, so the median order |
| 237 | // still decides everything affinity is silent about. |
| 238 | if (score > best) { pick = i; best = score } |
| 239 | } |
| 240 | if (pick === -1) break // this run is exhausted; the next seed starts another |
| 241 | taken[pick] = true |
| 242 | order.push(pick) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | return order.map(i => col[i]) |
| 247 | } |
| 248 | |
| 114 | 249 | function adjacencyOf (edges) { |
| 115 | 250 | const out = new Map() |
| 116 | 251 | for (const [a, b] of edges) { |
| … | … | @@ -160,6 +295,17 @@ function findBackEdges (items, forward) { |
| 160 | 295 | */ |
| 161 | 296 | export function layout (input, opts = {}) { |
| 162 | 297 | const O = { ...DEFAULTS, ...opts } |
| 298 | const first = layoutOnce(input, O, null) |
| 299 | if (O.passes < 2) return first |
| 300 | |
| 301 | // What pass one learned: where each node actually ended up. Feed that back as |
| 302 | // the hint for outside neighbours and lay the whole thing out again. |
| 303 | const hint = new Map() |
| 304 | for (const n of first.nodes) hint.set(n.id, n.y + n.h / 2) |
| 305 | return layoutOnce(input, O, hint) |
| 306 | } |
| 307 | |
| 308 | function layoutOnce (input, O, hint) { |
| 163 | 309 | const nodes = input.nodes || [] |
| 164 | 310 | const links = input.links || [] |
| 165 | 311 | const containers = input.containers || [] |
| … | … | @@ -254,12 +400,51 @@ export function layout (input, opts = {}) { |
| 254 | 400 | // same item is internal to that item and says nothing about ordering here. |
| 255 | 401 | const itemIds = new Set(items.map(i => i.id)) |
| 256 | 402 | const edges = [] |
| 403 | // Links that LEAVE this group, as [member, outside endpoint]. Useless for |
| 404 | // layering — an outside node has no column here — but they are what tells |
| 405 | // the sequencing pass which members belong together. |
| 406 | const external = [] |
| 257 | 407 | for (const l of links) { |
| 258 | 408 | const a = resolveItem(gid, l.from) |
| 259 | 409 | const b = resolveItem(gid, l.to) |
| 260 | | if (a === null || b === null || a === b) continue |
| 261 | | if (!itemIds.has(a) || !itemIds.has(b)) continue |
| 262 | | edges.push([a, b]) |
| 410 | const inA = a !== null && itemIds.has(a) |
| 411 | const inB = b !== null && itemIds.has(b) |
| 412 | if (inA && inB) { |
| 413 | if (a !== b) edges.push([a, b]) |
| 414 | continue |
| 415 | } |
| 416 | if (inA) external.push([a, l.to]) |
| 417 | else if (inB) external.push([b, l.from]) |
| 418 | } |
| 419 | |
| 420 | // Where each member's OUTSIDE neighbours sat last pass, as one median y per |
| 421 | // member. This is the only thing a group knows about the rest of the board, |
| 422 | // and without it a directory whose every import crosses a directory boundary |
| 423 | // has nothing whatever to sort by. |
| 424 | // |
| 425 | // Three variants were measured on four real modules; this one wins and the |
| 426 | // two that sound better both lose: |
| 427 | // - hinting from EVERY neighbour, internal ones included, reordered |
| 428 | // favela-web enough to widen its board from 4328 to 5140 and the router |
| 429 | // REFUSED the result outright; |
| 430 | // - hinting only the members with no sibling edges — "never override the |
| 431 | // median with less information" — gave -4%/-2%/+4% against this one's |
| 432 | // -10%/-16%/+7%. |
| 433 | // So the hint is applied to every member that has an outside neighbour, even |
| 434 | // where the median heuristic also has an opinion. |
| 435 | const hintY = new Map() |
| 436 | if (hint) { |
| 437 | const acc = new Map() |
| 438 | for (const [member, outside] of external) { |
| 439 | const y = hint.get(outside) |
| 440 | if (y === undefined) continue |
| 441 | if (!acc.has(member)) acc.set(member, []) |
| 442 | acc.get(member).push(y) |
| 443 | } |
| 444 | for (const [member, ys] of acc) { |
| 445 | ys.sort((a, b) => a - b) |
| 446 | hintY.set(member, ys[Math.floor((ys.length - 1) / 2)]) |
| 447 | } |
| 263 | 448 | } |
| 264 | 449 | |
| 265 | 450 | // Layering, with BACK EDGES REMOVED first. |
| … | … | @@ -305,6 +490,43 @@ export function layout (input, opts = {}) { |
| 305 | 490 | if (O.order === 'median') orderColumns(columns, keys, edges) |
| 306 | 491 | else for (const k of keys) columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) |
| 307 | 492 | |
| 493 | // Pull each column into the order its OUTSIDE neighbours sit in. |
| 494 | // |
| 495 | // Only the items that have a hint move, and they move only into the slots |
| 496 | // those items already occupy between them, so an unconnected item never gets |
| 497 | // dragged across the column by a number that does not apply to it. |
| 498 | if (hint) { |
| 499 | for (const k of keys) { |
| 500 | const col = columns.get(k) |
| 501 | const slots = [] |
| 502 | const movable = [] |
| 503 | for (let i = 0; i < col.length; i++) { |
| 504 | if (hintY.has(col[i].id)) { slots.push(i); movable.push(col[i]) } |
| 505 | } |
| 506 | if (movable.length < 2) continue |
| 507 | movable.sort((a, b) => { |
| 508 | const d = hintY.get(a.id) - hintY.get(b.id) |
| 509 | if (d !== 0) return d |
| 510 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 |
| 511 | }) |
| 512 | for (let i = 0; i < slots.length; i++) col[slots[i]] = movable[i] |
| 513 | } |
| 514 | } |
| 515 | |
| 516 | // Connectivity-aware sequencing, BEFORE the wrap — and only for the columns |
| 517 | // that are actually going to wrap. A column that fits keeps its median |
| 518 | // order, because there y is still comparable to the next column's. |
| 519 | if (O.sequence === 'affinity') { |
| 520 | for (const k of keys) { |
| 521 | const col = columns.get(k) |
| 522 | let stack = 0 |
| 523 | for (const it of col) { |
| 524 | stack += it.h + (it.kind === 'container' ? O.groupGap : O.rowGap) |
| 525 | } |
| 526 | if (stack > wrapHeight) columns.set(k, sequenceByAffinity(col, edges, external)) |
| 527 | } |
| 528 | } |
| 529 | |
| 308 | 530 | // Columns run left to right in dependency order, and WRAP into a new row |
| 309 | 531 | // when the board gets too wide — word-wrap, for columns. |
| 310 | 532 | // |
| … | … | @@ -314,6 +536,23 @@ export function layout (input, opts = {}) { |
| 314 | 536 | // order and packs the board toward something a screen can hold. |
| 315 | 537 | const targetWidth = Math.max(900, Math.sqrt(contentArea * Math.max(0.2, O.aspect)) * 1.7) |
| 316 | 538 | |
| 539 | // How strongly each column is tied to the one before it, so the wrap can |
| 540 | // break where the board is WEAKLY connected instead of wherever the width |
| 541 | // happens to run out. Breaking through the middle of a tightly connected |
| 542 | // run is what scatters a cluster across two rows. |
| 543 | const seamWeight = new Map() |
| 544 | for (let ki = 1; ki < keys.length; ki++) { |
| 545 | const prev = new Set(columns.get(keys[ki - 1]).map(it => it.id)) |
| 546 | const here = new Set(columns.get(keys[ki]).map(it => it.id)) |
| 547 | let weight = 0 |
| 548 | for (const [a, b] of edges) { |
| 549 | if ((prev.has(a) && here.has(b)) || (prev.has(b) && here.has(a))) weight++ |
| 550 | } |
| 551 | seamWeight.set(keys[ki], weight) |
| 552 | } |
| 553 | const weights = Array.from(seamWeight.values()).sort((a, b) => a - b) |
| 554 | const medianSeam = weights.length > 0 ? weights[Math.floor(weights.length / 2)] : 0 |
| 555 | |
| 317 | 556 | const placed = [] |
| 318 | 557 | let x = 0 |
| 319 | 558 | let rowY = 0 |
| … | … | @@ -325,7 +564,12 @@ export function layout (input, opts = {}) { |
| 325 | 564 | const col = columns.get(k) |
| 326 | 565 | |
| 327 | 566 | const colWidth = col.reduce((w, it) => Math.max(w, it.w), 0) |
| 328 | | if (x > 0 && x + colWidth > targetWidth) { |
| 567 | |
| 568 | // A heavy seam is worth some overflow to keep intact; a light one is |
| 569 | // where the row should break. |
| 570 | const seam = seamWeight.get(k) || 0 |
| 571 | const allowance = seam > medianSeam ? 1.45 : 1.0 |
| 572 | if (x > 0 && x + colWidth > targetWidth * allowance) { |
| 329 | 573 | x = 0 |
| 330 | 574 | rowY += rowH + (col.some(it => it.kind === 'container') ? O.groupGap : O.rowGap) |
| 331 | 575 | rowH = 0 |