| … | … | @@ -64,6 +64,7 @@ const view = { |
| 64 | 64 | containers: [], |
| 65 | 65 | bundled: false, // one edge per container pair instead of per member |
| 66 | 66 | circuit: true, // orthogonal routes that go AROUND nodes, not behind them |
| 67 | routeCache: null, | |
| 67 | 68 | selected: null, // a card whose whole dependency closure is showing |
| 68 | 69 | linkEls: [], |
| 69 | 70 | cardEls: null, |
| … | … | @@ -280,9 +281,20 @@ function render () { |
| 280 | 281 | // 56-module scan, 105 edges take 3.4s at 20 and 0.4s at 40, both routing |
| 281 | 282 | // every edge. A layout pass that blocks the tab for three seconds on every |
| 282 | 283 | // expand is not worth the finer clearance. |
| 283 | const routedPaths = new Map() | |
| 284 | // Routing depends only on the GEOMETRY, never on the selection, so a render | |
| 285 | // that merely changes highlighting must reuse it. Without this a double-click | |
| 286 | // runs two full A* passes back to back and hangs the view. | |
| 287 | let routedPaths = new Map() | |
| 284 | 288 | let junctions = [] |
| 285 | if (view.circuit) { | |
| 289 | const signature = view.circuit | |
| 290 | ? view.laid.nodes.map(n => n.id + ':' + Math.round(n.x) + ':' + Math.round(n.y)).join('|') + | |
| 291 | '#' + edges.length + (view.bundled ? '#b' : '') | |
| 292 | : null | |
| 293 | ||
| 294 | if (signature && view.routeCache && view.routeCache.signature === signature) { | |
| 295 | routedPaths = view.routeCache.routedPaths | |
| 296 | junctions = view.routeCache.junctions | |
| 297 | } else if (view.circuit) { | |
| 286 | 298 | const boxes = [] |
| 287 | 299 | for (const n of view.laid.nodes) boxes.push({ id: n.id, x: n.x, y: n.y, w: n.w, h: n.h }) |
| 288 | 300 | if (view.bundled) { |
| … | … | @@ -294,6 +306,7 @@ function render () { |
| 294 | 306 | 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L ')) |
| 295 | 307 | } |
| 296 | 308 | junctions = findJunctions(out.routes) |
| 309 | view.routeCache = { signature, routedPaths, junctions } | |
| 297 | 310 | } |
| 298 | 311 | |
| 299 | 312 | // Clicking a node shows its FULL dependency depth, not just one hop, with the |
| … | … | @@ -42,13 +42,75 @@ const DEFAULTS = { |
| 42 | 42 | // is derived per group from its own content area and this aspect; |
| 43 | 43 | // maxColumnHeight overrides it outright. |
| 44 | 44 | aspect: 1.6, |
| 45 | maxColumnHeight: undefined | |
| 45 | maxColumnHeight: undefined, | |
| 46 | ||
| 47 | // 'median' orders each column to uncross the wires and shorten them; 'id' is | |
| 48 | // the old arbitrary order, kept so the difference can be MEASURED rather than | |
| 49 | // asserted. | |
| 50 | order: 'median' | |
| 46 | 51 | } |
| 47 | 52 | |
| 48 | 53 | // The implicit group that holds everything with no container of its own. Not a |
| 49 | 54 | // real container: it is never drawn and gets no padding or label. |
| 50 | 55 | const ROOT = '__layout_root__' |
| 51 | 56 | |
| 57 | /** | |
| 58 | * Median-heuristic ordering, sweeping down then up a few times. | |
| 59 | * | |
| 60 | * For each item, look at the items it connects to in the neighbouring column, | |
| 61 | * take the MEDIAN of their positions, and sort by that. An item with no | |
| 62 | * neighbours keeps its place, so the result stays stable; ties break by id, so | |
| 63 | * the result stays deterministic. | |
| 64 | */ | |
| 65 | function orderColumns (columns, keys, edges) { | |
| 66 | const neighboursUp = new Map() | |
| 67 | const neighboursDown = new Map() | |
| 68 | for (const [a, b] of edges) { | |
| 69 | if (!neighboursDown.has(a)) neighboursDown.set(a, []) | |
| 70 | neighboursDown.get(a).push(b) | |
| 71 | if (!neighboursUp.has(b)) neighboursUp.set(b, []) | |
| 72 | neighboursUp.get(b).push(a) | |
| 73 | } | |
| 74 | ||
| 75 | // Start from a deterministic order so the sweeps are reproducible. | |
| 76 | for (const k of keys) { | |
| 77 | columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) | |
| 78 | } | |
| 79 | ||
| 80 | const SWEEPS = 4 | |
| 81 | for (let pass = 0; pass < SWEEPS; pass++) { | |
| 82 | const order = pass % 2 === 0 ? keys.slice(1) : keys.slice(0, -1).reverse() | |
| 83 | const lookup = pass % 2 === 0 ? neighboursUp : neighboursDown | |
| 84 | ||
| 85 | for (const k of order) { | |
| 86 | const ref = pass % 2 === 0 ? k - 1 : k + 1 | |
| 87 | const refCol = columns.get(ref) | |
| 88 | if (!refCol) continue | |
| 89 | const index = new Map(refCol.map((item, i) => [item.id, i])) | |
| 90 | ||
| 91 | const col = columns.get(k) | |
| 92 | const current = new Map(col.map((item, i) => [item.id, i])) | |
| 93 | const median = new Map() | |
| 94 | for (const item of col) { | |
| 95 | const ns = (lookup.get(item.id) || []) | |
| 96 | .map(id => index.get(id)) | |
| 97 | .filter(v => v !== undefined) | |
| 98 | .sort((a, b) => a - b) | |
| 99 | // No neighbours: keep this item where it is rather than dragging it to | |
| 100 | // the top, which would shuffle unrelated nodes on every pass. | |
| 101 | median.set(item.id, ns.length === 0 | |
| 102 | ? current.get(item.id) | |
| 103 | : ns[Math.floor((ns.length - 1) / 2)] + (ns.length % 2 === 0 ? 0.5 : 0)) | |
| 104 | } | |
| 105 | col.sort((a, b) => { | |
| 106 | const d = median.get(a.id) - median.get(b.id) | |
| 107 | if (d !== 0) return d | |
| 108 | return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 | |
| 109 | }) | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 52 | 114 | function adjacencyOf (edges) { |
| 53 | 115 | const out = new Map() |
| 54 | 116 | for (const [a, b] of edges) { |
| … | … | @@ -224,7 +286,6 @@ export function layout (input, opts = {}) { |
| 224 | 286 | if (!moved) break |
| 225 | 287 | } |
| 226 | 288 | |
| 227 | // Columns, each ordered by id so the result is stable across calls. | |
| 228 | 289 | const columns = new Map() |
| 229 | 290 | for (const i of items) { |
| 230 | 291 | const d = depth.get(i.id) |
| … | … | @@ -233,6 +294,17 @@ export function layout (input, opts = {}) { |
| 233 | 294 | } |
| 234 | 295 | const keys = Array.from(columns.keys()).sort((a, b) => a - b) |
| 235 | 296 | |
| 297 | // Order each column to UNCROSS the wires. | |
| 298 | // | |
| 299 | // Sorting a column by id is arbitrary with respect to the edges, so wires | |
| 300 | // criss-cross for no reason other than the order their endpoints happen to | |
| 301 | // sit in. Crossings are a LAYOUT problem: no amount of clever routing fixes | |
| 302 | // two nodes that are in the wrong order. This is the median heuristic — | |
| 303 | // sweep down, then up, placing each item at the median position of the | |
| 304 | // neighbours it is joined to, which is the standard way to do it. | |
| 305 | if (O.order === 'median') orderColumns(columns, keys, edges) | |
| 306 | else for (const k of keys) columns.get(k).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) | |
| 307 | ||
| 236 | 308 | // Columns run left to right in dependency order, and WRAP into a new row |
| 237 | 309 | // when the board gets too wide — word-wrap, for columns. |
| 238 | 310 | // |
| … | … | @@ -251,7 +323,6 @@ export function layout (input, opts = {}) { |
| 251 | 323 | let maxRight = 0 |
| 252 | 324 | for (const k of keys) { |
| 253 | 325 | const col = columns.get(k) |
| 254 | col.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) | |
| 255 | 326 | |
| 256 | 327 | const colWidth = col.reduce((w, it) => Math.max(w, it.w), 0) |
| 257 | 328 | if (x > 0 && x + colWidth > targetWidth) { |
| … | … | @@ -123,6 +123,7 @@ var NV = { |
| 123 | 123 | frames: [], |
| 124 | 124 | circuit: true, |
| 125 | 125 | selected: null, |
| 126 | routeCache: null, | |
| 126 | 127 | linkEls: [], |
| 127 | 128 | nodeEls: null, |
| 128 | 129 | layoutPending: false, |
| … | … | @@ -633,7 +634,25 @@ function nvRender () { |
| 633 | 634 | var junctions = [] |
| 634 | 635 | NV.linkEls = [] |
| 635 | 636 | NV.nodeEls = {} |
| 637 | ||
| 638 | // Routing is expensive — A* per edge — and depends ONLY on the geometry, not | |
| 639 | // on what is selected or hovered. Recomputing it on every render made a | |
| 640 | // double-click run two full passes back to back and hang the view. Cache it | |
| 641 | // against a signature of the geometry and reuse it for highlight renders. | |
| 642 | var signature = null | |
| 636 | 643 | if (NV.circuit && window.__ctRoute) { |
| 644 | var sigParts = [NV.links.length] | |
| 645 | for (var si2 = 0; si2 < NV.nodes.length; si2++) { | |
| 646 | var sn = NV.nodes[si2] | |
| 647 | sigParts.push(sn.id + ':' + Math.round(sn.x) + ':' + Math.round(sn.y)) | |
| 648 | } | |
| 649 | signature = sigParts.join('|') | |
| 650 | } | |
| 651 | ||
| 652 | if (signature && NV.routeCache && NV.routeCache.signature === signature) { | |
| 653 | routed = NV.routeCache.routed | |
| 654 | junctions = NV.routeCache.junctions | |
| 655 | } else if (NV.circuit && window.__ctRoute) { | |
| 637 | 656 | var boxes = [] |
| 638 | 657 | for (var bi = 0; bi < NV.nodes.length; bi++) { |
| 639 | 658 | var bn = NV.nodes[bi] |
| … | … | @@ -663,6 +682,7 @@ function nvRender () { |
| 663 | 682 | routed[rt.from + ' -> ' + rt.to] = 'M ' + dparts.join(' L ') |
| 664 | 683 | } |
| 665 | 684 | junctions = window.__ctRoute.findJunctions(out.routes) |
| 685 | NV.routeCache = { signature: signature, routed: routed, junctions: junctions } | |
| 666 | 686 | } |
| 667 | 687 | |
| 668 | 688 | for (var l = 0; l < NV.links.length; l++) { |
| … | … | @@ -1823,6 +1823,15 @@ function initGraph () { |
| 1823 | 1823 | } |
| 1824 | 1824 | showGraphSettings() |
| 1825 | 1825 | loadSections() |
| 1826 | ||
| 1827 | // Deep-linkable focus, like the other views, and the only way to review what | |
| 1828 | // focusing actually shows without a mouse: ?focus=<module name>. | |
| 1829 | const wantFocus = new URLSearchParams(location.search).get('focus') | |
| 1830 | if (wantFocus) { | |
| 1831 | for (const node of graphNodes) { | |
| 1832 | if (node.name === wantFocus) { focusModule(node.idx); break } | |
| 1833 | } | |
| 1834 | } | |
| 1826 | 1835 | } |
| 1827 | 1836 | |
| 1828 | 1837 | // --- Focus a module: highlight its connections --- |
| … | … | @@ -1833,23 +1842,40 @@ function focusModule (idx) { |
| 1833 | 1842 | for (const t of n.dependsOn) connected.add(t) |
| 1834 | 1843 | for (const s of n.usedBy) connected.add(s) |
| 1835 | 1844 | |
| 1836 | // Dim all nodes | |
| 1845 | // The FULL dependency closure, not one hop. A graph edge here points consumer | |
| 1846 | // -> supplier, the opposite of the shared convention, so it is flipped on the | |
| 1847 | // way in rather than reasoning about direction twice. | |
| 1848 | let closure = null | |
| 1849 | if (window.__ct) { | |
| 1850 | const asLinks = graphEdges.map(e => ({ from: String(e.target), to: String(e.source) })) | |
| 1851 | closure = window.__ct.dependencyClosure(asLinks, String(idx)) | |
| 1852 | for (const node of graphNodes) { | |
| 1853 | if (closure.nodes.get(String(node.idx)) !== undefined) connected.add(node.idx) | |
| 1854 | } | |
| 1855 | } | |
| 1856 | ||
| 1837 | 1857 | for (const node of graphNodes) { |
| 1838 | 1858 | node.el.classList.toggle('dimmed', !connected.has(node.idx)) |
| 1839 | 1859 | node.el.classList.toggle('focused', node.idx === idx) |
| 1840 | 1860 | } |
| 1841 | 1861 | |
| 1842 | // Color edges | |
| 1843 | 1862 | for (const edge of graphEdges) { |
| 1844 | 1863 | const isDepOf = edge.source === idx // focused depends on target |
| 1845 | 1864 | const isUsedBy = edge.target === idx // source depends on focused |
| 1846 | const isConnected = isDepOf || isUsedBy | |
| 1865 | const depth = closure ? closure.edges.get(String(edge.target) + ' -> ' + String(edge.source)) : undefined | |
| 1866 | const isConnected = isDepOf || isUsedBy || depth !== undefined | |
| 1847 | 1867 | |
| 1848 | 1868 | edge.el.classList.toggle('dimmed', !isConnected) |
| 1849 | 1869 | edge.el.classList.toggle('depends-on', isDepOf) |
| 1850 | 1870 | edge.el.classList.toggle('dep-of', isUsedBy) |
| 1851 | 1871 | |
| 1852 | // Arrow color | |
| 1872 | // Distance in the colour: amber one hop out, cooling as it goes. | |
| 1873 | if (depth !== undefined && depth > 1 && window.__ct) { | |
| 1874 | edge.el.setAttribute('stroke', window.__ct.depthColor(depth, closure.maxDepth)) | |
| 1875 | } else { | |
| 1876 | edge.el.removeAttribute('stroke') | |
| 1877 | } | |
| 1878 | ||
| 1853 | 1879 | if (isDepOf) edge.el.setAttribute('marker-end', 'url(#arrowhead-green)') |
| 1854 | 1880 | else if (isUsedBy) edge.el.setAttribute('marker-end', 'url(#arrowhead-blue)') |
| 1855 | 1881 | else edge.el.setAttribute('marker-end', 'url(#arrowhead)') |
| … | … | @@ -1871,6 +1897,36 @@ function focusModule (idx) { |
| 1871 | 1897 | extra += n.usedBy.map(s => graphNodes[s].name).join('\\n') |
| 1872 | 1898 | extra += '</code></pre></div>' |
| 1873 | 1899 | } |
| 1900 | // The FULL declared list, not just the part with a node on screen. On this | |
| 1901 | // repo's own scan 684 dependencies are declared and only 105 have both ends | |
| 1902 | // in the scan, so the drawing alone says a module with ten external | |
| 1903 | // dependencies depends on nothing. | |
| 1904 | if (n.mod) { | |
| 1905 | const declared = Object.keys(n.mod.dependencies || {}).sort() | |
| 1906 | const inScanSet = new Set(tree.system.modules) | |
| 1907 | const drawn = declared.filter(d => inScanSet.has(d)) | |
| 1908 | extra += '<div class="detail"><h3>All declared dependencies (' + declared.length + ')</h3>' + | |
| 1909 | '<div style="color:#8b949e;font-size:11px;margin-bottom:6px">' + drawn.length + | |
| 1910 | ' drawn as edges · ' + (declared.length - drawn.length) + ' external</div><pre><code>' | |
| 1911 | extra += declared.map(d => (inScanSet.has(d) ? ' ' : ' ~ ') + escapeHtml(d)).join('\\n') | |
| 1912 | extra += '</code></pre></div>' | |
| 1913 | } | |
| 1914 | ||
| 1915 | // And what it reaches THROUGH those, with hop counts. | |
| 1916 | if (closure) { | |
| 1917 | const reached = [] | |
| 1918 | for (const node of graphNodes) { | |
| 1919 | const depth = closure.nodes.get(String(node.idx)) | |
| 1920 | if (depth === undefined || depth < 2) continue | |
| 1921 | reached.push(node.name + ' (' + depth + ' hops)') | |
| 1922 | } | |
| 1923 | if (reached.length > 0) { | |
| 1924 | extra += '<div class="detail"><h3 style="color:#d2a8ff">Reached through those (' + | |
| 1925 | reached.length + ')</h3><pre><code>' + | |
| 1926 | reached.sort().map(escapeHtml).join('\\n') + '</code></pre></div>' | |
| 1927 | } | |
| 1928 | } | |
| 1929 | ||
| 1874 | 1930 | if (extra) panel.innerHTML += extra |
| 1875 | 1931 | } |
| 1876 | 1932 |
| … | … | @@ -320,6 +320,135 @@ test('layout: an explicit wrap height still wins', (t) => { |
| 320 | 320 | t.ok(out.height <= 600, 'honoured: ' + out.height) |
| 321 | 321 | }) |
| 322 | 322 | |
| 323 | // Count how many pairs of edges cross, judging by the vertical order of their | |
| 324 | // endpoints within each column. | |
| 325 | function countCrossings (out, links) { | |
| 326 | const pos = {} | |
| 327 | for (const n of out.nodes) pos[n.id] = n | |
| 328 | let crossings = 0 | |
| 329 | for (let i = 0; i < links.length; i++) { | |
| 330 | for (let j = i + 1; j < links.length; j++) { | |
| 331 | const a = links[i] | |
| 332 | const b = links[j] | |
| 333 | const a1 = pos[a.from]; const a2 = pos[a.to] | |
| 334 | const b1 = pos[b.from]; const b2 = pos[b.to] | |
| 335 | if (!a1 || !a2 || !b1 || !b2) continue | |
| 336 | // only compare edges spanning the same pair of columns | |
| 337 | if (Math.abs(a1.x - b1.x) > 1 || Math.abs(a2.x - b2.x) > 1) continue | |
| 338 | const startOrder = Math.sign(a1.y - b1.y) | |
| 339 | const endOrder = Math.sign(a2.y - b2.y) | |
| 340 | if (startOrder !== 0 && endOrder !== 0 && startOrder !== endOrder) crossings++ | |
| 341 | } | |
| 342 | } | |
| 343 | return crossings | |
| 344 | } | |
| 345 | ||
| 346 | test('layout: orders each column to UNCROSS the wires', (t) => { | |
| 347 | // Sorting a column by id is arbitrary with respect to the edges, so a graph | |
| 348 | // whose natural order is reversed crosses every wire with every other. The | |
| 349 | // fix belongs in layout, not in routing: wires cross because the nodes they | |
| 350 | // join are in the wrong order. | |
| 351 | const nodes = [] | |
| 352 | const links = [] | |
| 353 | for (let i = 0; i < 6; i++) { | |
| 354 | nodes.push(node('a' + i, null, 200, 60)) | |
| 355 | nodes.push(node('b' + i, null, 200, 60)) | |
| 356 | links.push({ from: 'a' + i, to: 'b' + (5 - i) }) // fully reversed | |
| 357 | } | |
| 358 | const out = layout({ nodes, links, containers: [] }) | |
| 359 | t.is(countCrossings(out, links), 0, 'a reversed pairing lays out with no crossings') | |
| 360 | }) | |
| 361 | ||
| 362 | test('layout: a fan-out does not manufacture crossings', (t) => { | |
| 363 | const nodes = [node('src', null, 200, 60)] | |
| 364 | const links = [] | |
| 365 | for (let i = 0; i < 5; i++) { | |
| 366 | nodes.push(node('t' + i, null, 200, 60)) | |
| 367 | links.push({ from: 'src', to: 't' + i }) | |
| 368 | } | |
| 369 | const out = layout({ nodes, links, containers: [] }) | |
| 370 | t.is(countCrossings(out, links), 0, 'a fan from one node never crosses itself') | |
| 371 | }) | |
| 372 | ||
| 373 | test('layout: ordering keeps every other guarantee', (t) => { | |
| 374 | const nodes = [] | |
| 375 | const links = [] | |
| 376 | const containers = [{ id: 'z', parent: null, label: 'z' }] | |
| 377 | for (let i = 0; i < 8; i++) { | |
| 378 | nodes.push(node('n' + i, i < 4 ? 'z' : null, 200, 60)) | |
| 379 | if (i > 0) links.push({ from: 'n' + (i - 1), to: 'n' + i }) | |
| 380 | } | |
| 381 | const out = layout({ nodes, links, containers }) | |
| 382 | for (const a of out.nodes) { | |
| 383 | for (const b of out.nodes) { | |
| 384 | if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint') | |
| 385 | } | |
| 386 | } | |
| 387 | const box = out.containers[0] | |
| 388 | for (const n of out.nodes.filter(n => ['n0', 'n1', 'n2', 'n3'].includes(n.id))) { | |
| 389 | t.ok(box.x <= n.x && box.x + box.w >= n.x + n.w, n.id + ' still inside its container') | |
| 390 | } | |
| 391 | }) | |
| 392 | ||
| 393 | function totalWireLength (out, links) { | |
| 394 | const pos = {} | |
| 395 | for (const n of out.nodes) pos[n.id] = n | |
| 396 | let total = 0 | |
| 397 | for (const l of links) { | |
| 398 | const a = pos[l.from] | |
| 399 | const b = pos[l.to] | |
| 400 | if (!a || !b) continue | |
| 401 | total += Math.abs(a.x - b.x) + Math.abs(a.y - b.y) | |
| 402 | } | |
| 403 | return total | |
| 404 | } | |
| 405 | ||
| 406 | test('layout: densely connected nodes are placed near each other', (t) => { | |
| 407 | // Element placement, as on a board: parts that talk to each other belong | |
| 408 | // close together, so the traces between them are short. Ids here interleave | |
| 409 | // the two clusters deliberately, so an id-ordered layout spreads each cluster | |
| 410 | // across the whole column. | |
| 411 | const nodes = [] | |
| 412 | const links = [] | |
| 413 | for (let i = 0; i < 4; i++) { | |
| 414 | nodes.push(node('a' + (i * 2), null, 200, 60)) // cluster A: a0 a2 a4 a6 | |
| 415 | nodes.push(node('b' + (i * 2 + 1), null, 200, 60)) // cluster B: b1 b3 b5 b7 | |
| 416 | } | |
| 417 | // Each cluster is a chain; the two never touch. | |
| 418 | for (let i = 0; i < 3; i++) { | |
| 419 | links.push({ from: 'a' + (i * 2), to: 'a' + ((i + 1) * 2) }) | |
| 420 | links.push({ from: 'b' + (i * 2 + 1), to: 'b' + ((i + 1) * 2 + 1) }) | |
| 421 | } | |
| 422 | ||
| 423 | const out = layout({ nodes, links, containers: [] }) | |
| 424 | const pos = {} | |
| 425 | for (const n of out.nodes) pos[n.id] = n | |
| 426 | ||
| 427 | // Every wire should be short: a chain link should not jump across the board. | |
| 428 | for (const l of links) { | |
| 429 | const dy = Math.abs(pos[l.from].y - pos[l.to].y) | |
| 430 | t.ok(dy <= 200, l.from + ' -> ' + l.to + ' spans ' + Math.round(dy) + 'px vertically') | |
| 431 | } | |
| 432 | }) | |
| 433 | ||
| 434 | test('layout: placement beats an arbitrary order on total wire length', (t) => { | |
| 435 | const nodes = [] | |
| 436 | const links = [] | |
| 437 | for (let i = 0; i < 8; i++) nodes.push(node('n' + i, null, 200, 60)) | |
| 438 | // A pairing that id-order gets wrong: 0-7, 1-6, 2-5, 3-4 in the next column. | |
| 439 | for (let i = 0; i < 8; i++) nodes.push(node('m' + i, null, 200, 60)) | |
| 440 | for (let i = 0; i < 8; i++) links.push({ from: 'n' + i, to: 'm' + (7 - i) }) | |
| 441 | ||
| 442 | const out = layout({ nodes, links, containers: [] }) | |
| 443 | const length = totalWireLength(out, links) | |
| 444 | ||
| 445 | // With the columns ordered to match, every wire is horizontal: the only | |
| 446 | // distance left is the one column gap each. | |
| 447 | const oneGap = totalWireLength({ nodes: out.nodes.map(n => ({ ...n, y: 0 })) }, links) | |
| 448 | t.ok(length <= oneGap * 1.15, 'wire length is near the horizontal minimum: ' + | |
| 449 | Math.round(length) + ' vs ' + Math.round(oneGap)) | |
| 450 | }) | |
| 451 | ||
| 323 | 452 | test('layout: purity and stability', (t) => { |
| 324 | 453 | const input = { |
| 325 | 454 | nodes: [node('b', 'z'), node('a', 'z'), node('c')], |