OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 2cbd21f

gather: pull a node's neighbourhood together, temporarily
A container in layout.js already means "these belong together, place them as one unit" — siblings cannot interleave and the parent encloses them by construction. So gather needs no new layout mode and no animation: applyGather() puts the neighbourhood in one synthetic container and the ordinary layout does the rest. G gathers the selection at one hop, [ and ] walk the depth in and out, Esc or G again releases. Depth clamps at 1 because a "gather" of one node in a box of its own reads as a bug rather than a control at its limit, so [ walks out and then off. Nothing is hidden and no link is dropped. Filtering the rest of the board away would remove the only thing that makes the gather legible — what you pulled these nodes away from. Groups the gather empties are dropped, or they would render as labelled boxes with nothing in them; a group whose own cards all left is kept if a child group survives inside it, because it is that child's frame. Both surfaces, one implementation: gatherSet() and applyGather() live in cards.js and the Nodes tab hands its layout items straight to them. Measured on the 56-module system level, mean distance between the gathered nodes: pear-git-v3 1 hop 14 nodes 1146 -> 330 px (-71%), bbox 2014x1679 -> 390x496 3 hops 37 nodes 1202 -> 648 px (-46%) hyper-overlay 1 hop 11 nodes 1172 -> 329 px (-72%) favela-web 1 hop 10 nodes 999 -> 425 px (-57%) Unlike dependencyClosure this walks BOTH directions: pulling a neighbourhood together while leaving out what points AT it would show half a neighbourhood. Deep-linkable, like every other state here: ?select=<title> names what and ?gather=<depth> says how far, on / and /floor alike. That is also how this was verified without a browser-automation harness — both surfaces report the same 14 nodes at depth 1 and 37 at depth 3, and 15 frames falling to 11 then 7. Two fixes it depends on: - keydown in level.js began `if (!view.model) return`, and view.model is null on the system and module rungs — they are built from the tree, not a parsed file. So B, R, E, C and Esc were all dead on the two upper rungs while the hint bar went on promising them. - the route cache was a single slot, which made gathering and releasing re-run A* over the whole board on every swap. It is a 6-entry LRU in both views now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2cbd21feaa2c2d62d17bd3cca6bfd6c3c479b6e4
by plankatron <fleeky@gmail.com> · 2026-09-21T02:03:46.000Z

Changed files (5) +373 -25

lib/floor/cards.js +95 -0
……@@ -295,6 +295,101 @@ export function dependencyClosure (links, startId, direction = 'dependencies') {
295295 return { nodes, edges, maxDepth: Math.max(0, ...nodes.values()) }
296296}
297297
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 */
308export 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 */
350export 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
298393/**
299394 * A colour for an edge at distance `depth` out of `maxDepth`.
300395 *
lib/floor/level.html +5 -1
……@@ -57,6 +57,10 @@
5757 .n-body, .n-head, .n-title { transition: opacity .12s ease; }
5858 g.dim { opacity: 0.25; }
5959 .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; }
6064 .zone-label { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; }
6165 .n-open { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; }
6266 #crumb a { color: #8b949e; text-decoration: none; cursor: pointer; }
……@@ -91,7 +95,7 @@
9195 <span class="sw" style="background:#1f4a6b"></span>module body &nbsp;&nbsp;
9296 <span class="sw" style="background:#33405a"></span>zone depth rail
9397</div>
94<div id="hint">hover traces edges &middot; click for dependency depth &middot; B bundles &middot; R routing &middot; title opens &middot; Esc back</div>
98<div id="hint">hover traces edges &middot; click for dependency depth &middot; <b>G gathers</b> the selection, <b>[ ]</b> its depth &middot; B bundles &middot; R routing &middot; title opens &middot; Esc back</div>
9599</div>
96100
97101<script type="module" src="/floor/level.js"></script>
lib/floor/level.js +77 -15
……@@ -20,7 +20,7 @@ import { routeEdges, findJunctions } from '/floor/routing.js'
2020import {
2121 buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel,
2222 resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks,
23 dependencyClosure, depthColor
23 dependencyClosure, depthColor, applyGather
2424} from '/floor/cards.js'
2525
2626const SVG_NS = 'http://www.w3.org/2000/svg'
……@@ -64,8 +64,11 @@ const view = {
6464 containers: [],
6565 bundled: false, // one edge per container pair instead of per member
6666 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
6868 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,
6972 linkEls: [],
7073 cardEls: null,
7174 laid: null,
……@@ -157,7 +160,17 @@ function builtLevel () {
157160function rebuild () {
158161 const kind = levelKind()
159162 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
161174 view.cards = built.cards
162175 view.links = built.links
163176 view.containers = built.containers
……@@ -196,7 +209,14 @@ function rebuild () {
196209 const edgeCount = view.bundled
197210 ? bundleLinks(built.cards, built.links).length + ' bundled edges'
198211 : 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
200220 banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null)
201221 breadcrumb()
202222 render()
……@@ -256,8 +276,9 @@ function render () {
256276 // around whatever positions fell out (that was the old frames bug).
257277 for (const box of view.laid.containers) {
258278 const depth = Math.min(box.depth, 4)
279 const isGather = box.id.slice(0, 7) === 'gather:'
259280 world.appendChild(el('rect', {
260 class: 'zone depth' + depth,
281 class: isGather ? 'zone zone-gather' : 'zone depth' + depth,
261282 x: box.x, y: box.y, width: box.w, height: box.h, rx: 8
262283 }))
263284 const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 })
……@@ -277,10 +298,6 @@ function render () {
277298 // go AROUND whatever is in the way, instead of a bezier drawn straight from A
278299 // to B and passing behind everything between them.
279300 //
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.
284301 // Routing depends only on the GEOMETRY, never on the selection, so a render
285302 // that merely changes highlighting must reuse it. Without this a double-click
286303 // runs two full A* passes back to back and hangs the view.
……@@ -291,9 +308,10 @@ function render () {
291308 '#' + edges.length + (view.bundled ? '#b' : '')
292309 : null
293310
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
297315 } else if (view.circuit) {
298316 const boxes = []
299317 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 () {
307325 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L '))
308326 }
309327 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)
311332 }
312333
313334 // Clicking a node shows its FULL dependency depth, not just one hop, with the
……@@ -387,6 +408,13 @@ function render () {
387408 const card = view.cards.find(c => c.title === wantSelect)
388409 if (card) { view.selected = card.id; render() }
389410 }
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 }
390418}
391419
392420function drawCard (world, card, pos, closure) {
……@@ -531,6 +559,26 @@ function select (id) {
531559 render()
532560}
533561
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 */
569function 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
534582function unfocus () {
535583 for (const link of view.linkEls) link.el.classList.remove('hot', 'cold')
536584 if (view.cardEls) for (const [, g] of view.cardEls) g.classList.remove('dim')
……@@ -659,6 +707,7 @@ function syncUrl () {
659707 const card = view.cards.find(c => c.id === view.selected)
660708 if (card) q.push('select=' + encodeURIComponent(card.title))
661709 }
710 if (view.gather) q.push('gather=' + view.gather.depth)
662711 history.replaceState(null, '', locationToUrl(loc, '') + (q.length > 0 ? '?' + q.join('&') : ''))
663712
664713 const link = document.getElementById('other-surface')
……@@ -724,8 +773,20 @@ function wire () {
724773 })
725774
726775 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
729790 for (const c of view.cards) view.expanded.add(c.id)
730791 rebuild()
731792 } else if (e.key === 'c' || e.key === 'C') {
……@@ -739,6 +800,7 @@ function wire () {
739800 syncUrl()
740801 rebuild()
741802 } else if (e.key === 'Escape') {
803 if (view.gather) { gather(null, 0); return }
742804 if (view.selected) { select(null); return }
743805 const kind = levelKind()
744806 if (kind === 'symbol') enter(null)
lib/render-nodes.js +77 -9
……@@ -43,6 +43,7 @@ export const NODE_VIEW_CSS = `
4343.nv-sock-label.unmated { fill: #6e7076; font-style: italic; }
4444.nv-noodle { fill: none; stroke-width: 1.6; pointer-events: none; }
4545.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; }
4647.nv-group-node .nv-node-body { stroke: #58a6ff; stroke-dasharray: 4,2; }
4748.nv-code { font-family: 'SF Mono','Fira Code',monospace; font-size: 8px; fill: #9aa0a6; pointer-events: none; white-space: pre; }
4849.nv-code-kw { fill: #ff7b72; }
……@@ -123,7 +124,14 @@ var NV = {
123124 frames: [],
124125 circuit: true,
125126 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: [],
127135 linkEls: [],
128136 nodeEls: null,
129137 layoutPending: false,
……@@ -493,6 +501,18 @@ function nvLayout (nodes, links) {
493501 })
494502 }
495503
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
496516 var out = window.__ctLayout({ nodes: items, links: links, containers: containers })
497517
498518 var pos = {}
……@@ -590,10 +610,13 @@ function nvDrawFrames (parent) {
590610 var frames = NV.frames || []
591611 for (var g = 0; g < frames.length; g++) {
592612 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]
594615
595616 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')
597620 r.setAttribute('x', box.x)
598621 r.setAttribute('y', box.y)
599622 r.setAttribute('width', box.w)
……@@ -649,9 +672,13 @@ function nvRender () {
649672 signature = sigParts.join('|')
650673 }
651674
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
655682 } else if (NV.circuit && window.__ctRoute) {
656683 var boxes = []
657684 for (var bi = 0; bi < NV.nodes.length; bi++) {
……@@ -682,7 +709,8 @@ function nvRender () {
682709 routed[rt.from + ' -> ' + rt.to] = 'M ' + dparts.join(' L ')
683710 }
684711 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()
686714 }
687715
688716 for (var l = 0; l < NV.links.length; l++) {
……@@ -986,6 +1014,25 @@ function nvBreadcrumb () {
9861014 }
9871015}
9881016
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 */
1024function 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
9891036function nvLegend () {
9901037 var el = document.getElementById('node-legend')
9911038 if (!el) return
……@@ -1001,6 +1048,11 @@ function nvLegend () {
10011048 '<span style="color:#f85149">&#9675;</span> unresolved' +
10021049 '<br>left sockets demand &middot; right sockets supply' +
10031050 '<br>hover traces a node &middot; click for its full dependency depth' +
1051 '<br><b>G</b> gathers the selection &middot; <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 : '') +
10041056 '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
10051057 '<input type="checkbox" id="nv-only-connected"' + (NV.onlyConnected ? ' checked' : '') +
10061058 ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' +
……@@ -1139,7 +1191,16 @@ function nvInit () {
11391191 })
11401192
11411193 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 }
11431204 })
11441205
11451206 svg.addEventListener('mousedown', function (e) {
……@@ -1163,12 +1224,19 @@ function nvInit () {
11631224
11641225 // Deep-linkable, like every other view here, and the only way to review a
11651226 // 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')
11671229 if (want) {
11681230 for (var si = 0; si < NV.nodes.length; si++) {
11691231 if (NV.nodes[si].title === want) { NV.selected = NV.nodes[si].id; break }
11701232 }
11711233 if (NV.selected) nvRender()
11721234 }
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 }
11731241}
11741242`
test/gather.test.js +119 -0
……@@ -0,0 +1,119 @@
1import { test } from 'brittle'
2import { 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
9function 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
31test('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
41test('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
50test('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
61test('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
83test('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
92test('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
113test('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})