| … | … | @@ -23,7 +23,15 @@ const DEFAULTS = { |
| 23 | 23 | colGap: 130, // horizontal space between dependency columns |
| 24 | 24 | rowGap: 26, // vertical space between siblings in a column |
| 25 | 25 | pad: 16, // container padding around its contents |
| 26 | labelH: 18 // extra headroom inside a container for its label | |
| 26 | labelH: 18, // extra headroom inside a container for its label | |
| 27 | ||
| 28 | // How tall one layer may grow before it wraps into another column. | |
| 29 | // | |
| 30 | // Layering decides x, but a layer with many members and no edges to spread | |
| 31 | // them has to wrap or it becomes an endless column: measured on a real scan, | |
| 32 | // keet-cli has 207 files, 117 directories and exactly ONE import edge, which | |
| 33 | // put every item at depth 0 and produced a column 26,012px tall. | |
| 34 | maxColumnHeight: 1600 | |
| 27 | 35 | } |
| 28 | 36 | |
| 29 | 37 | // The implicit group that holds everything with no container of its own. Not a |
| … | … | @@ -159,15 +167,26 @@ export function layout (input, opts = {}) { |
| 159 | 167 | for (const k of keys) { |
| 160 | 168 | const col = columns.get(k) |
| 161 | 169 | col.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) |
| 170 | ||
| 171 | // One LAYER may occupy several columns. Wrapping keeps x monotonic in | |
| 172 | // dependency depth — every member of this layer is placed before the next | |
| 173 | // layer starts — so a supplier still sits left of its consumer. | |
| 162 | 174 | let y = 0 |
| 163 | 175 | let colW = 0 |
| 176 | let layerW = 0 | |
| 164 | 177 | for (const item of col) { |
| 178 | if (y > 0 && y + item.h > O.maxColumnHeight) { | |
| 179 | x += colW + O.colGap | |
| 180 | layerW += colW + O.colGap | |
| 181 | y = 0 | |
| 182 | colW = 0 | |
| 183 | } | |
| 165 | 184 | placed.push({ id: item.id, kind: item.kind, dx: x, dy: y, w: item.w, h: item.h }) |
| 166 | 185 | y += item.h + O.rowGap |
| 167 | 186 | if (item.w > colW) colW = item.w |
| 187 | if (y - O.rowGap > maxH) maxH = y - O.rowGap | |
| 168 | 188 | } |
| 169 | const used = Math.max(0, y - O.rowGap) | |
| 170 | if (used > maxH) maxH = used | |
| 189 | void layerW | |
| 171 | 190 | x += colW + O.colGap |
| 172 | 191 | } |
| 173 | 192 | const contentW = Math.max(0, x - O.colGap) |
| … | … | @@ -162,6 +162,49 @@ test('layout: a dependency cycle terminates', (t) => { |
| 162 | 162 | } |
| 163 | 163 | }) |
| 164 | 164 | |
| 165 | test('layout: a link-less group WRAPS instead of forming one endless column', (t) => { | |
| 166 | // Found by running this on a real scan: keet-cli has 207 files, 117 | |
| 167 | // directories and exactly ONE import edge, so every item layered to depth 0 | |
| 168 | // and stacked into a single column 26,012px tall. Layering gives x; within one | |
| 169 | // layer, height has to wrap. | |
| 170 | const nodes = [] | |
| 171 | for (let i = 0; i < 60; i++) nodes.push(node('n' + String(i).padStart(2, '0'), null, 200, 60)) | |
| 172 | const out = layout({ nodes, links: [], containers: [] }, { maxColumnHeight: 600 }) | |
| 173 | ||
| 174 | const xs = new Set(out.nodes.map(n => n.x)) | |
| 175 | t.ok(xs.size > 1, 'it used more than one column: ' + xs.size) | |
| 176 | t.ok(out.height <= 700, 'and stayed inside the height budget: ' + out.height) | |
| 177 | ||
| 178 | for (const a of out.nodes) { | |
| 179 | for (const b of out.nodes) { | |
| 180 | if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint') | |
| 181 | } | |
| 182 | } | |
| 183 | }) | |
| 184 | ||
| 185 | test('layout: wrapping does not break dependency order', (t) => { | |
| 186 | // Two layers, each too tall for the budget. Everything in layer 0 must still | |
| 187 | // sit left of everything in layer 1. | |
| 188 | const nodes = [] | |
| 189 | const links = [] | |
| 190 | for (let i = 0; i < 12; i++) { | |
| 191 | nodes.push(node('a' + i, null, 200, 100)) | |
| 192 | nodes.push(node('b' + i, null, 200, 100)) | |
| 193 | links.push({ from: 'a' + i, to: 'b' + i }) | |
| 194 | } | |
| 195 | const out = layout({ nodes, links, containers: [] }, { maxColumnHeight: 400 }) | |
| 196 | const byId = {} | |
| 197 | for (const n of out.nodes) byId[n.id] = n | |
| 198 | ||
| 199 | let maxA = -Infinity | |
| 200 | let minB = Infinity | |
| 201 | for (let i = 0; i < 12; i++) { | |
| 202 | maxA = Math.max(maxA, byId['a' + i].x + byId['a' + i].w) | |
| 203 | minB = Math.min(minB, byId['b' + i].x) | |
| 204 | } | |
| 205 | t.ok(maxA <= minB, 'every supplier is left of every consumer') | |
| 206 | }) | |
| 207 | ||
| 165 | 208 | test('layout: purity and stability', (t) => { |
| 166 | 209 | const input = { |
| 167 | 210 | nodes: [node('b', 'z'), node('a', 'z'), node('c')], |