| … | … | @@ -295,6 +295,101 @@ export function dependencyClosure (links, startId, direction = 'dependencies') { |
| 295 | 295 | return { nodes, edges, maxDepth: Math.max(0, ...nodes.values()) } |
| 296 | 296 | } |
| 297 | 297 | |
| 298 | /** | |
| 299 | * Everything within `depth` hops of a node, ignoring which way the arrows point. | |
| 300 | * | |
| 301 | * dependencyClosure walks ONE direction, because "what does this pull in" is a | |
| 302 | * directed question. Gather is not: pulling a node's neighbourhood together and | |
| 303 | * leaving out the things that point AT it would show half a neighbourhood. | |
| 304 | * | |
| 305 | * Breadth-first, so the hop recorded is the shortest path and a cycle stops | |
| 306 | * instead of spinning. Returns Map(id -> hop) with the start at 0. | |
| 307 | */ | |
| 308 | export function gatherSet (links, startId, depth) { | |
| 309 | const adjacency = new Map() | |
| 310 | const join = (a, b) => { | |
| 311 | if (!adjacency.has(a)) adjacency.set(a, []) | |
| 312 | adjacency.get(a).push(b) | |
| 313 | } | |
| 314 | for (const l of links) { join(l.from, l.to); join(l.to, l.from) } | |
| 315 | ||
| 316 | const hops = new Map([[startId, 0]]) | |
| 317 | let frontier = [startId] | |
| 318 | for (let d = 1; d <= depth && frontier.length > 0; d++) { | |
| 319 | const next = [] | |
| 320 | for (const id of frontier) { | |
| 321 | for (const n of adjacency.get(id) || []) { | |
| 322 | if (hops.has(n)) continue | |
| 323 | hops.set(n, d) | |
| 324 | next.push(n) | |
| 325 | } | |
| 326 | } | |
| 327 | frontier = next | |
| 328 | } | |
| 329 | return hops | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * Gather: temporarily regroup a board so a node's neighbourhood is laid out | |
| 334 | * TOGETHER. | |
| 335 | * | |
| 336 | * A container in the layout already means "these belong together, place them as | |
| 337 | * one unit" — siblings cannot interleave and the parent encloses them by | |
| 338 | * construction. So gather needs no new layout mode and no animation: it is one | |
| 339 | * synthetic container holding the neighbourhood, and the ordinary layout does | |
| 340 | * the rest. | |
| 341 | * | |
| 342 | * Nothing is hidden and no link is dropped. Filtering the rest of the board away | |
| 343 | * would remove the only thing that makes the gather legible — what you pulled | |
| 344 | * these nodes AWAY from. | |
| 345 | * | |
| 346 | * A group the gather empties is dropped, or it would render as a labelled box | |
| 347 | * with nothing in it. A group whose own cards all left is still kept if a child | |
| 348 | * group survives inside it, because it is that child's frame. | |
| 349 | */ | |
| 350 | export function applyGather (built, startId, depth) { | |
| 351 | const gid = 'gather:' + startId | |
| 352 | const hops = gatherSet(built.links, startId, depth) | |
| 353 | ||
| 354 | const cards = built.cards.map(c => (hops.has(c.id) ? { ...c, container: gid } : c)) | |
| 355 | ||
| 356 | const holdsCard = new Set() | |
| 357 | for (const c of cards) if (c.container) holdsCard.add(c.container) | |
| 358 | ||
| 359 | // Walk children before parents so a surviving grandchild keeps the whole chain. | |
| 360 | const kept = new Set() | |
| 361 | const byId = new Map(built.containers.map(c => [c.id, c])) | |
| 362 | const survives = (id, guard) => { | |
| 363 | if (kept.has(id)) return true | |
| 364 | if (guard.has(id)) return false // malformed parent chain | |
| 365 | guard.add(id) | |
| 366 | if (holdsCard.has(id)) { kept.add(id); return true } | |
| 367 | for (const c of built.containers) { | |
| 368 | if (c.parent === id && survives(c.id, guard)) { kept.add(id); return true } | |
| 369 | } | |
| 370 | return false | |
| 371 | } | |
| 372 | for (const c of built.containers) survives(c.id, new Set()) | |
| 373 | ||
| 374 | const containers = built.containers.filter(c => kept.has(c.id)) | |
| 375 | // Reparent a kept container whose parent was dropped, so it does not vanish. | |
| 376 | for (let i = 0; i < containers.length; i++) { | |
| 377 | let parent = containers[i].parent | |
| 378 | while (parent && !kept.has(parent)) parent = (byId.get(parent) || {}).parent || null | |
| 379 | if (parent !== containers[i].parent) containers[i] = { ...containers[i], parent: parent || null } | |
| 380 | } | |
| 381 | ||
| 382 | containers.push({ | |
| 383 | id: gid, | |
| 384 | parent: null, | |
| 385 | label: 'gathered · ' + hops.size + ' within ' + depth + (depth === 1 ? ' hop' : ' hops'), | |
| 386 | shape: 'gather', | |
| 387 | depth: 1 | |
| 388 | }) | |
| 389 | ||
| 390 | return { cards, links: built.links, containers, gathered: hops } | |
| 391 | } | |
| 392 | ||
| 298 | 393 | /** |
| 299 | 394 | * A colour for an edge at distance `depth` out of `maxDepth`. |
| 300 | 395 | * |
| … | … | @@ -57,6 +57,10 @@ |
| 57 | 57 | .n-body, .n-head, .n-title { transition: opacity .12s ease; } |
| 58 | 58 | g.dim { opacity: 0.25; } |
| 59 | 59 | .zone { fill-opacity: 0.16; stroke-opacity: 0.5; stroke: #58a6ff; stroke-width: 1.2; } |
| 60 | /* The gather box is temporary, so it says so: a dashed amber region rather than | |
| 61 | one more blue zone that looks like part of the structure. */ | |
| 62 | .zone-gather { fill: #3a2a16; fill-opacity: 0.5; stroke: #ffa657; stroke-width: 1.6; | |
| 63 | stroke-dasharray: 7 4; stroke-opacity: 0.9; } | |
| 60 | 64 | .zone-label { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; } |
| 61 | 65 | .n-open { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; } |
| 62 | 66 | #crumb a { color: #8b949e; text-decoration: none; cursor: pointer; } |
| … | … | @@ -91,7 +95,7 @@ |
| 91 | 95 | <span class="sw" style="background:#1f4a6b"></span>module body |
| 92 | 96 | <span class="sw" style="background:#33405a"></span>zone depth rail |
| 93 | 97 | </div> |
| 94 | <div id="hint">hover traces edges · click for dependency depth · B bundles · R routing · title opens · Esc back</div> | |
| 98 | <div id="hint">hover traces edges · click for dependency depth · <b>G gathers</b> the selection, <b>[ ]</b> its depth · B bundles · R routing · title opens · Esc back</div> | |
| 95 | 99 | </div> |
| 96 | 100 | |
| 97 | 101 | <script type="module" src="/floor/level.js"></script> |
| … | … | @@ -20,7 +20,7 @@ import { routeEdges, findJunctions } from '/floor/routing.js' |
| 20 | 20 | import { |
| 21 | 21 | buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel, |
| 22 | 22 | resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks, |
| 23 | dependencyClosure, depthColor | |
| 23 | dependencyClosure, depthColor, applyGather | |
| 24 | 24 | } from '/floor/cards.js' |
| 25 | 25 | |
| 26 | 26 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| … | … | @@ -64,8 +64,11 @@ 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 | routeCache: new Map(), // signature -> routes, so toggling a mode is instant | |
| 68 | 68 | selected: null, // a card whose whole dependency closure is showing |
| 69 | // Temporarily regroup the board so the selected card's neighbourhood is laid | |
| 70 | // out together: { id, depth }, depth in hops. Null is the ordinary board. | |
| 71 | gather: null, | |
| 69 | 72 | linkEls: [], |
| 70 | 73 | cardEls: null, |
| 71 | 74 | laid: null, |
| … | … | @@ -157,7 +160,17 @@ function builtLevel () { |
| 157 | 160 | function rebuild () { |
| 158 | 161 | const kind = levelKind() |
| 159 | 162 | const sym = currentSymbol() |
| 160 | const built = builtLevel() | |
| 163 | let built = builtLevel() | |
| 164 | ||
| 165 | // Gather is a REGROUPING of the same board, applied after it is built and | |
| 166 | // before it is laid out, so every other mode — bundling, expansion, routing — | |
| 167 | // keeps working while it is on. | |
| 168 | if (view.gather && built.cards.some(c => c.id === view.gather.id)) { | |
| 169 | built = applyGather(built, view.gather.id, view.gather.depth) | |
| 170 | } else if (view.gather) { | |
| 171 | view.gather = null // the card is not on this rung any more | |
| 172 | } | |
| 173 | ||
| 161 | 174 | view.cards = built.cards |
| 162 | 175 | view.links = built.links |
| 163 | 176 | view.containers = built.containers |
| … | … | @@ -196,7 +209,14 @@ function rebuild () { |
| 196 | 209 | const edgeCount = view.bundled |
| 197 | 210 | ? bundleLinks(built.cards, built.links).length + ' bundled edges' |
| 198 | 211 | : built.links.length + ' edges' |
| 199 | document.getElementById('stats').textContent = stats + ' · ' + edgeCount | |
| 212 | let gathered = '' | |
| 213 | if (view.gather && built.gathered) { | |
| 214 | const card = built.cards.find(c => c.id === view.gather.id) | |
| 215 | gathered = ' · GATHERED ' + (card ? card.title : view.gather.id) + ' + ' + | |
| 216 | (built.gathered.size - 1) + ' within ' + view.gather.depth + | |
| 217 | (view.gather.depth === 1 ? ' hop' : ' hops') + ' [ ]' | |
| 218 | } | |
| 219 | document.getElementById('stats').textContent = stats + ' · ' + edgeCount + gathered | |
| 200 | 220 | banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null) |
| 201 | 221 | breadcrumb() |
| 202 | 222 | render() |
| … | … | @@ -256,8 +276,9 @@ function render () { |
| 256 | 276 | // around whatever positions fell out (that was the old frames bug). |
| 257 | 277 | for (const box of view.laid.containers) { |
| 258 | 278 | const depth = Math.min(box.depth, 4) |
| 279 | const isGather = box.id.slice(0, 7) === 'gather:' | |
| 259 | 280 | world.appendChild(el('rect', { |
| 260 | class: 'zone depth' + depth, | |
| 281 | class: isGather ? 'zone zone-gather' : 'zone depth' + depth, | |
| 261 | 282 | x: box.x, y: box.y, width: box.w, height: box.h, rx: 8 |
| 262 | 283 | })) |
| 263 | 284 | const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 }) |
| … | … | @@ -277,10 +298,6 @@ function render () { |
| 277 | 298 | // go AROUND whatever is in the way, instead of a bezier drawn straight from A |
| 278 | 299 | // to B and passing behind everything between them. |
| 279 | 300 | // |
| 280 | // The grid is 40, not the module's default 20: measured on this repo's own | |
| 281 | // 56-module scan, 105 edges take 3.4s at 20 and 0.4s at 40, both routing | |
| 282 | // every edge. A layout pass that blocks the tab for three seconds on every | |
| 283 | // expand is not worth the finer clearance. | |
| 284 | 301 | // Routing depends only on the GEOMETRY, never on the selection, so a render |
| 285 | 302 | // that merely changes highlighting must reuse it. Without this a double-click |
| 286 | 303 | // runs two full A* passes back to back and hangs the view. |
| … | … | @@ -291,9 +308,10 @@ function render () { |
| 291 | 308 | '#' + edges.length + (view.bundled ? '#b' : '') |
| 292 | 309 | : null |
| 293 | 310 | |
| 294 | if (signature && view.routeCache && view.routeCache.signature === signature) { | |
| 295 | routedPaths = view.routeCache.routedPaths | |
| 296 | junctions = view.routeCache.junctions | |
| 311 | const cached = signature ? view.routeCache.get(signature) : null | |
| 312 | if (cached) { | |
| 313 | routedPaths = cached.routedPaths | |
| 314 | junctions = cached.junctions | |
| 297 | 315 | } else if (view.circuit) { |
| 298 | 316 | const boxes = [] |
| 299 | 317 | for (const n of view.laid.nodes) boxes.push({ id: n.id, x: n.x, y: n.y, w: n.w, h: n.h }) |
| … | … | @@ -307,7 +325,10 @@ function render () { |
| 307 | 325 | 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L ')) |
| 308 | 326 | } |
| 309 | 327 | junctions = findJunctions(out.routes) |
| 310 | view.routeCache = { signature, routedPaths, junctions } | |
| 328 | // A handful of entries, not one: gathering and releasing swaps between two | |
| 329 | // geometries repeatedly, and a single slot makes every swap a fresh A* pass. | |
| 330 | view.routeCache.set(signature, { routedPaths, junctions }) | |
| 331 | while (view.routeCache.size > 6) view.routeCache.delete(view.routeCache.keys().next().value) | |
| 311 | 332 | } |
| 312 | 333 | |
| 313 | 334 | // Clicking a node shows its FULL dependency depth, not just one hop, with the |
| … | … | @@ -387,6 +408,13 @@ function render () { |
| 387 | 408 | const card = view.cards.find(c => c.title === wantSelect) |
| 388 | 409 | if (card) { view.selected = card.id; render() } |
| 389 | 410 | } |
| 411 | // ?gather=<depth> opens already gathered around the selection. `select` names | |
| 412 | // WHAT, `gather` says how far out — so a gathered board is a link, like every | |
| 413 | // other state in this view. | |
| 414 | const wantGather = params.get('gather') | |
| 415 | if (wantGather && view.selected && !view.gather) { | |
| 416 | gather(view.selected, Math.max(1, Math.min(8, Number(wantGather) || 1))) | |
| 417 | } | |
| 390 | 418 | } |
| 391 | 419 | |
| 392 | 420 | function drawCard (world, card, pos, closure) { |
| … | … | @@ -531,6 +559,26 @@ function select (id) { |
| 531 | 559 | render() |
| 532 | 560 | } |
| 533 | 561 | |
| 562 | /** | |
| 563 | * Turn gather on, off, or move its depth. | |
| 564 | * | |
| 565 | * Depth is clamped at 1, not 0: at 0 the board is regrouped to put one card in a | |
| 566 | * box of its own, which looks like a bug rather than a control at its limit. | |
| 567 | * Going below it releases instead, so [ walks back out and then off. | |
| 568 | */ | |
| 569 | function gather (id, depth) { | |
| 570 | if (!id || depth < 1) { | |
| 571 | if (!view.gather) return | |
| 572 | view.gather = null | |
| 573 | } else { | |
| 574 | view.gather = { id, depth: Math.min(depth, 8) } | |
| 575 | view.selected = id | |
| 576 | } | |
| 577 | unfocus() | |
| 578 | syncUrl() | |
| 579 | rebuild() | |
| 580 | } | |
| 581 | ||
| 534 | 582 | function unfocus () { |
| 535 | 583 | for (const link of view.linkEls) link.el.classList.remove('hot', 'cold') |
| 536 | 584 | if (view.cardEls) for (const [, g] of view.cardEls) g.classList.remove('dim') |
| … | … | @@ -659,6 +707,7 @@ function syncUrl () { |
| 659 | 707 | const card = view.cards.find(c => c.id === view.selected) |
| 660 | 708 | if (card) q.push('select=' + encodeURIComponent(card.title)) |
| 661 | 709 | } |
| 710 | if (view.gather) q.push('gather=' + view.gather.depth) | |
| 662 | 711 | history.replaceState(null, '', locationToUrl(loc, '') + (q.length > 0 ? '?' + q.join('&') : '')) |
| 663 | 712 | |
| 664 | 713 | const link = document.getElementById('other-surface') |
| … | … | @@ -724,8 +773,20 @@ function wire () { |
| 724 | 773 | }) |
| 725 | 774 | |
| 726 | 775 | document.addEventListener('keydown', (e) => { |
| 727 | if (!view.model) return | |
| 728 | if (e.key === 'e' || e.key === 'E') { | |
| 776 | // NOT `if (!view.model) return`. view.model is null on the system and module | |
| 777 | // rungs — those levels are built from the tree, not from a parsed file — so | |
| 778 | // that guard silently killed B, R, E, C and Escape on the two upper rungs, | |
| 779 | // while the hint bar went on promising them. | |
| 780 | if (!view.tree && !view.model) return | |
| 781 | ||
| 782 | if (e.key === 'g' || e.key === 'G') { | |
| 783 | gather(view.gather ? null : view.selected, view.gather ? 0 : 1) | |
| 784 | } else if (e.key === ']' || e.key === '}') { | |
| 785 | if (view.gather) gather(view.gather.id, view.gather.depth + 1) | |
| 786 | } else if (e.key === '[' || e.key === '{') { | |
| 787 | if (view.gather) gather(view.gather.id, view.gather.depth - 1) | |
| 788 | } else if (e.key === 'e' || e.key === 'E') { | |
| 789 | if (!view.model) return // nothing to expand without parsed source | |
| 729 | 790 | for (const c of view.cards) view.expanded.add(c.id) |
| 730 | 791 | rebuild() |
| 731 | 792 | } else if (e.key === 'c' || e.key === 'C') { |
| … | … | @@ -739,6 +800,7 @@ function wire () { |
| 739 | 800 | syncUrl() |
| 740 | 801 | rebuild() |
| 741 | 802 | } else if (e.key === 'Escape') { |
| 803 | if (view.gather) { gather(null, 0); return } | |
| 742 | 804 | if (view.selected) { select(null); return } |
| 743 | 805 | const kind = levelKind() |
| 744 | 806 | if (kind === 'symbol') enter(null) |
| … | … | @@ -43,6 +43,7 @@ export const NODE_VIEW_CSS = ` |
| 43 | 43 | .nv-sock-label.unmated { fill: #6e7076; font-style: italic; } |
| 44 | 44 | .nv-noodle { fill: none; stroke-width: 1.6; pointer-events: none; } |
| 45 | 45 | .nv-node.selected .nv-node-body { stroke: #ffa657; stroke-width: 2; } |
| 46 | .nv-frame-gather { fill-opacity: 0.1; stroke-width: 1.6; stroke-dasharray: 7 4; stroke-opacity: 0.9; } | |
| 46 | 47 | .nv-group-node .nv-node-body { stroke: #58a6ff; stroke-dasharray: 4,2; } |
| 47 | 48 | .nv-code { font-family: 'SF Mono','Fira Code',monospace; font-size: 8px; fill: #9aa0a6; pointer-events: none; white-space: pre; } |
| 48 | 49 | .nv-code-kw { fill: #ff7b72; } |
| … | … | @@ -123,7 +124,14 @@ var NV = { |
| 123 | 124 | frames: [], |
| 124 | 125 | circuit: true, |
| 125 | 126 | selected: null, |
| 126 | routeCache: null, | |
| 127 | // Temporarily regroup so the selection's neighbourhood lays out together: | |
| 128 | // { id, depth } in hops, null for the ordinary board. | |
| 129 | gather: null, | |
| 130 | gathered: null, | |
| 131 | // A LIST of cached routings, not one slot: gathering and releasing swaps | |
| 132 | // between two geometries over and over, and a single slot makes every swap a | |
| 133 | // fresh A* pass over the whole board. | |
| 134 | routeCache: [], | |
| 127 | 135 | linkEls: [], |
| 128 | 136 | nodeEls: null, |
| 129 | 137 | layoutPending: false, |
| … | … | @@ -493,6 +501,18 @@ function nvLayout (nodes, links) { |
| 493 | 501 | }) |
| 494 | 502 | } |
| 495 | 503 | |
| 504 | // Gather regroups the SAME board: same nodes, same noodles, one extra | |
| 505 | // container holding the selection's neighbourhood. applyGather works on | |
| 506 | // anything with .id/.container, so the layout items go straight in. | |
| 507 | NV.gathered = null | |
| 508 | if (NV.gather && window.__ct && window.__ct.applyGather) { | |
| 509 | var gg = window.__ct.applyGather( | |
| 510 | { cards: items, links: links, containers: containers }, NV.gather.id, NV.gather.depth) | |
| 511 | items = gg.cards | |
| 512 | containers = gg.containers | |
| 513 | NV.gathered = gg.gathered | |
| 514 | } | |
| 515 | ||
| 496 | 516 | var out = window.__ctLayout({ nodes: items, links: links, containers: containers }) |
| 497 | 517 | |
| 498 | 518 | var pos = {} |
| … | … | @@ -590,10 +610,13 @@ function nvDrawFrames (parent) { |
| 590 | 610 | var frames = NV.frames || [] |
| 591 | 611 | for (var g = 0; g < frames.length; g++) { |
| 592 | 612 | var box = frames[g] |
| 593 | var color = NV_FRAME_COLORS[g % NV_FRAME_COLORS.length] | |
| 613 | var isGather = String(box.id).slice(0, 7) === 'gather:' | |
| 614 | var color = isGather ? '#ffa657' : NV_FRAME_COLORS[g % NV_FRAME_COLORS.length] | |
| 594 | 615 | |
| 595 | 616 | var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect') |
| 596 | r.setAttribute('class', 'nv-frame') | |
| 617 | // Dashed, because it is temporary. A solid amber box would read as one more | |
| 618 | // part of the structure rather than something you are holding open. | |
| 619 | r.setAttribute('class', isGather ? 'nv-frame nv-frame-gather' : 'nv-frame') | |
| 597 | 620 | r.setAttribute('x', box.x) |
| 598 | 621 | r.setAttribute('y', box.y) |
| 599 | 622 | r.setAttribute('width', box.w) |
| … | … | @@ -649,9 +672,13 @@ function nvRender () { |
| 649 | 672 | signature = sigParts.join('|') |
| 650 | 673 | } |
| 651 | 674 | |
| 652 | if (signature && NV.routeCache && NV.routeCache.signature === signature) { | |
| 653 | routed = NV.routeCache.routed | |
| 654 | junctions = NV.routeCache.junctions | |
| 675 | var hit = null | |
| 676 | for (var ci = 0; signature && ci < NV.routeCache.length; ci++) { | |
| 677 | if (NV.routeCache[ci].signature === signature) hit = NV.routeCache[ci] | |
| 678 | } | |
| 679 | if (hit) { | |
| 680 | routed = hit.routed | |
| 681 | junctions = hit.junctions | |
| 655 | 682 | } else if (NV.circuit && window.__ctRoute) { |
| 656 | 683 | var boxes = [] |
| 657 | 684 | for (var bi = 0; bi < NV.nodes.length; bi++) { |
| … | … | @@ -682,7 +709,8 @@ function nvRender () { |
| 682 | 709 | routed[rt.from + ' -> ' + rt.to] = 'M ' + dparts.join(' L ') |
| 683 | 710 | } |
| 684 | 711 | junctions = window.__ctRoute.findJunctions(out.routes) |
| 685 | NV.routeCache = { signature: signature, routed: routed, junctions: junctions } | |
| 712 | NV.routeCache.push({ signature: signature, routed: routed, junctions: junctions }) | |
| 713 | while (NV.routeCache.length > 6) NV.routeCache.shift() | |
| 686 | 714 | } |
| 687 | 715 | |
| 688 | 716 | for (var l = 0; l < NV.links.length; l++) { |
| … | … | @@ -986,6 +1014,25 @@ function nvBreadcrumb () { |
| 986 | 1014 | } |
| 987 | 1015 | } |
| 988 | 1016 | |
| 1017 | /** | |
| 1018 | * Turn gather on, off, or move its depth. | |
| 1019 | * | |
| 1020 | * Clamped at 1 hop: at 0 the board is regrouped to put one node in a box of its | |
| 1021 | * own, which looks like a bug rather than a control at its limit. Going below it | |
| 1022 | * releases, so [ walks back out and then off. | |
| 1023 | */ | |
| 1024 | function nvGather (id, depth) { | |
| 1025 | if (!id || depth < 1) { | |
| 1026 | if (!NV.gather) return | |
| 1027 | NV.gather = null | |
| 1028 | } else { | |
| 1029 | NV.gather = { id: id, depth: Math.min(depth, 8) } | |
| 1030 | NV.selected = id | |
| 1031 | } | |
| 1032 | nvLayout(NV.nodes, NV.links) | |
| 1033 | nvRender() | |
| 1034 | } | |
| 1035 | ||
| 989 | 1036 | function nvLegend () { |
| 990 | 1037 | var el = document.getElementById('node-legend') |
| 991 | 1038 | if (!el) return |
| … | … | @@ -1001,6 +1048,11 @@ function nvLegend () { |
| 1001 | 1048 | '<span style="color:#f85149">○</span> unresolved' + |
| 1002 | 1049 | '<br>left sockets demand · right sockets supply' + |
| 1003 | 1050 | '<br>hover traces a node · click for its full dependency depth' + |
| 1051 | '<br><b>G</b> gathers the selection · <b>[ ]</b> its depth' + | |
| 1052 | (NV.gather && NV.gathered | |
| 1053 | ? '<br><span style="color:#ffa657">gathered ' + (NV.gathered.size - 1) + | |
| 1054 | ' within ' + NV.gather.depth + (NV.gather.depth === 1 ? ' hop' : ' hops') + '</span>' | |
| 1055 | : '') + | |
| 1004 | 1056 | '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' + |
| 1005 | 1057 | '<input type="checkbox" id="nv-only-connected"' + (NV.onlyConnected ? ' checked' : '') + |
| 1006 | 1058 | ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' + |
| … | … | @@ -1139,7 +1191,16 @@ function nvInit () { |
| 1139 | 1191 | }) |
| 1140 | 1192 | |
| 1141 | 1193 | document.addEventListener('keydown', function (e) { |
| 1142 | if (e.key === 'Escape' && NV.selected) { NV.selected = null; nvRender() } | |
| 1194 | if (e.key === 'g' || e.key === 'G') { | |
| 1195 | nvGather(NV.gather ? null : NV.selected, NV.gather ? 0 : 1) | |
| 1196 | } else if (e.key === ']' || e.key === '}') { | |
| 1197 | if (NV.gather) nvGather(NV.gather.id, NV.gather.depth + 1) | |
| 1198 | } else if (e.key === '[' || e.key === '{') { | |
| 1199 | if (NV.gather) nvGather(NV.gather.id, NV.gather.depth - 1) | |
| 1200 | } else if (e.key === 'Escape') { | |
| 1201 | if (NV.gather) { nvGather(null, 0); return } | |
| 1202 | if (NV.selected) { NV.selected = null; nvRender() } | |
| 1203 | } | |
| 1143 | 1204 | }) |
| 1144 | 1205 | |
| 1145 | 1206 | svg.addEventListener('mousedown', function (e) { |
| … | … | @@ -1163,12 +1224,19 @@ function nvInit () { |
| 1163 | 1224 | |
| 1164 | 1225 | // Deep-linkable, like every other view here, and the only way to review a |
| 1165 | 1226 | // selection without a mouse: ?select=<node title>. |
| 1166 | var want = new URLSearchParams(location.search).get('select') | |
| 1227 | var params = new URLSearchParams(location.search) | |
| 1228 | var want = params.get('select') | |
| 1167 | 1229 | if (want) { |
| 1168 | 1230 | for (var si = 0; si < NV.nodes.length; si++) { |
| 1169 | 1231 | if (NV.nodes[si].title === want) { NV.selected = NV.nodes[si].id; break } |
| 1170 | 1232 | } |
| 1171 | 1233 | if (NV.selected) nvRender() |
| 1172 | 1234 | } |
| 1235 | // ?gather=<depth> opens already gathered around the selection, so a gathered | |
| 1236 | // board is a link rather than a thing you have to reproduce by hand. | |
| 1237 | var wantGather = params.get('gather') | |
| 1238 | if (wantGather && NV.selected) { | |
| 1239 | nvGather(NV.selected, Math.max(1, Math.min(8, Number(wantGather) || 1))) | |
| 1240 | } | |
| 1173 | 1241 | } |
| 1174 | 1242 | ` |
| … | … | @@ -0,0 +1,119 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { gatherSet, applyGather } from '../lib/floor/cards.js' | |
| 3 | ||
| 4 | // Gather: pull a node's DIRECT links in beside it, temporarily, with a depth | |
| 5 | // control to reach further out. A container is already "these belong together, | |
| 6 | // place them as one unit", so gather is a synthetic container — not a new layout | |
| 7 | // mode. | |
| 8 | ||
| 9 | function board () { | |
| 10 | // a -> b -> c -> d, plus e off on its own and f wired INTO a. | |
| 11 | return { | |
| 12 | cards: [ | |
| 13 | { id: 'a', container: 'cat:x' }, | |
| 14 | { id: 'b', container: 'cat:x' }, | |
| 15 | { id: 'c', container: 'cat:y' }, | |
| 16 | { id: 'd', container: 'cat:y' }, | |
| 17 | { id: 'e', container: 'cat:y' }, | |
| 18 | { id: 'f', container: null } | |
| 19 | ], | |
| 20 | links: [ | |
| 21 | { from: 'a', to: 'b' }, { from: 'b', to: 'c' }, | |
| 22 | { from: 'c', to: 'd' }, { from: 'f', to: 'a' } | |
| 23 | ], | |
| 24 | containers: [ | |
| 25 | { id: 'cat:x', parent: null, label: 'x' }, | |
| 26 | { id: 'cat:y', parent: null, label: 'y' } | |
| 27 | ] | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | test('gather: depth 1 is the direct links, in BOTH directions', (t) => { | |
| 32 | const { links } = board() | |
| 33 | const set = gatherSet(links, 'a', 1) | |
| 34 | ||
| 35 | t.alike([...set.keys()].sort(), ['a', 'b', 'f'], 'a, plus what it links to and what links to it') | |
| 36 | t.is(set.get('a'), 0, 'the start is hop 0') | |
| 37 | t.is(set.get('b'), 1, 'b is one hop') | |
| 38 | t.is(set.get('f'), 1, 'f is one hop, against the arrow') | |
| 39 | }) | |
| 40 | ||
| 41 | test('gather: the depth control reaches further out', (t) => { | |
| 42 | const { links } = board() | |
| 43 | t.is(gatherSet(links, 'a', 2).size, 4, 'depth 2 adds c') | |
| 44 | t.is(gatherSet(links, 'a', 3).size, 5, 'depth 3 adds d') | |
| 45 | t.is(gatherSet(links, 'a', 9).size, 5, 'past the end it stops rather than spinning') | |
| 46 | t.is(gatherSet(links, 'a', 0).size, 1, 'depth 0 is the node alone') | |
| 47 | t.is(gatherSet(links, 'e', 1).size, 1, 'an unconnected node gathers nothing') | |
| 48 | }) | |
| 49 | ||
| 50 | test('gather: hop is the SHORTEST path, and a cycle terminates', (t) => { | |
| 51 | const links = [ | |
| 52 | { from: 'a', to: 'b' }, { from: 'b', to: 'c' }, | |
| 53 | { from: 'c', to: 'a' }, // cycle | |
| 54 | { from: 'a', to: 'c' } // and a short way round | |
| 55 | ] | |
| 56 | const set = gatherSet(links, 'a', 5) | |
| 57 | t.is(set.get('c'), 1, 'c is one hop, not two') | |
| 58 | t.is(set.size, 3, 'the cycle does not spin') | |
| 59 | }) | |
| 60 | ||
| 61 | test('gather: gathered cards move into ONE synthetic container', (t) => { | |
| 62 | const b = board() | |
| 63 | const out = applyGather(b, 'a', 1) | |
| 64 | ||
| 65 | const byId = new Map(out.cards.map(c => [c.id, c])) | |
| 66 | const gid = 'gather:a' | |
| 67 | for (const id of ['a', 'b', 'f']) { | |
| 68 | t.is(byId.get(id).container, gid, id + ' is in the gather container') | |
| 69 | } | |
| 70 | t.ok(out.containers.find(c => c.id === gid), 'the gather container exists') | |
| 71 | t.is(out.containers.find(c => c.id === gid).parent, null, 'at the top level') | |
| 72 | ||
| 73 | // Untouched cards keep their own group. | |
| 74 | t.is(byId.get('c').container, 'cat:y', 'c is left where it was') | |
| 75 | t.is(byId.get('e').container, 'cat:y', 'e is left where it was') | |
| 76 | ||
| 77 | // Same cards and same links: gather is a REGROUPING, not a filter. Hiding the | |
| 78 | // rest of the board would make it impossible to see what you pulled away from. | |
| 79 | t.is(out.cards.length, b.cards.length, 'no card is dropped') | |
| 80 | t.alike(out.links, b.links, 'links are untouched') | |
| 81 | }) | |
| 82 | ||
| 83 | test('gather: a container emptied by the gather is dropped', (t) => { | |
| 84 | const b = board() | |
| 85 | // Gathering a at depth 1 takes a and b — all of cat:x. | |
| 86 | const out = applyGather(b, 'a', 1) | |
| 87 | t.absent(out.containers.find(c => c.id === 'cat:x'), | |
| 88 | 'cat:x held only a and b, so it would render as an empty box') | |
| 89 | t.ok(out.containers.find(c => c.id === 'cat:y'), 'cat:y still holds c, d and e') | |
| 90 | }) | |
| 91 | ||
| 92 | test('gather: an emptied PARENT is dropped only if no child survives', (t) => { | |
| 93 | const b = { | |
| 94 | cards: [ | |
| 95 | { id: 'p1', container: 'dir:lib/deep' }, | |
| 96 | { id: 'p2', container: 'dir:lib/keep' }, | |
| 97 | { id: 'q', container: null } | |
| 98 | ], | |
| 99 | links: [{ from: 'q', to: 'p1' }], | |
| 100 | containers: [ | |
| 101 | { id: 'dir:lib', parent: null, label: 'lib/' }, | |
| 102 | { id: 'dir:lib/deep', parent: 'dir:lib', label: 'lib/deep/' }, | |
| 103 | { id: 'dir:lib/keep', parent: 'dir:lib', label: 'lib/keep/' } | |
| 104 | ] | |
| 105 | } | |
| 106 | const out = applyGather(b, 'q', 1) | |
| 107 | t.absent(out.containers.find(c => c.id === 'dir:lib/deep'), 'deep is emptied') | |
| 108 | t.ok(out.containers.find(c => c.id === 'dir:lib'), | |
| 109 | 'lib holds no cards of its own but still has keep inside it') | |
| 110 | t.ok(out.containers.find(c => c.id === 'dir:lib/keep'), 'keep survives') | |
| 111 | }) | |
| 112 | ||
| 113 | test('gather: releasing is just not applying it', (t) => { | |
| 114 | const b = board() | |
| 115 | const out = applyGather(b, 'a', 0) | |
| 116 | t.is(out.cards.find(c => c.id === 'a').container, 'gather:a', | |
| 117 | 'depth 0 still gathers the node itself, so the mode is visible') | |
| 118 | t.ok(applyGather(b, 'nope', 2), 'a start id that is not on the board does not throw') | |
| 119 | }) |