OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / a07003e

relations: G is a view of what a node connects to, not a regrouping
Replaces gather. Gather kept the whole board and pulled the neighbourhood into one box; the question people actually have is "how is this connected", and 51 unrelated modules are the noise that buries the answer. So G now shows ONLY the focus and what it connects to, and the columns mean something: hop distance and direction, furthest dependency on the left, focus in the middle, furthest dependent on the right. What is hidden is stated rather than implied — "RELATIONS of anproto · within 1 hop · 5 nodes, 7 links · hiding 51 nodes and 98 links" — and each card carries a count of its own links that leave the view, so a node is never quietly presented as having fewer connections than it has. relationRings() is undirected with the first step's side remembered, and that is the point. A purely directional walk misses siblings: with one module supplying both the focus and something else, that something else is neither upstream nor downstream of the focus, yet it is plainly related — it shares a supplier. Undirected search finds it at two hops and the inherited side says how, which also keeps every node in exactly one column. Upstream wins ties, because "what does this depend on" is the question asked first. Rings are placed by an explicit `rank`, new in layout.js: the column index, when the caller already knows it. Layering derives columns from edges, which is right when edges are all there is, but the relation view knows each node's hop and side exactly and re-deriving it would throw that away — a two-hop sibling is not a transitive supplier of the focus, so edge layering would put it wherever its own edges fell and the column would stop meaning distance. All or nothing per group, so a rank is never silently mixed with a relaxed depth. One bug this turned up and fixes: the selection highlight ran through dependencyClosure, which only walks UPSTREAM, so a focus whose relations are all downstream rendered almost entirely grey — 18 of 19 cards dimmed and all 33 links cold on /floor. Everything in a relation view is related by construction, so nothing may be dimmed. The rings are reshaped into what a closure looks like, and every drawing path downstream of it is untouched. Verified with real key events and matching DOM dumps on both surfaces: hyper-rooms, 2 hops both views: 19 nodes, 33 links, 0 dimmed, 0 cold, every wire coloured by hop, rings "focus / 1 hop downstream / 2 hops downstream" anproto, G then ] 56 nodes -> 5 -> 25; url ?select=anproto&relations=1|2 pear-git-v3, 3 hops 37 nodes, rings out to "3 hops upstream" [ [ leaves the view and restores all 56 B, R still work; G while the path field has focus does not Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a07003e7342bec1f795ea98e82e9908a9e3eca7a
by plankatron <fleeky@gmail.com> · 2026-09-21T02:32:05.000Z

Changed files (11) +562 -317

lib/floor/cards.js +110 -70
……@@ -296,98 +296,138 @@ export function dependencyClosure (links, startId, direction = 'dependencies') {
296296}
297297
298298/**
299 * Everything within `depth` hops of a node, ignoring which way the arrows point.
299 * Everything within `depth` hops of a node, with the SIDE it sits on.
300300 *
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.
301 * Breadth-first and undirected, so the hop is the shortest path and a cycle stops
302 * rather than spinning — but each node also records which way the FIRST step out
303 * of the focus went. Links read supplier -> consumer everywhere here, so `from`
304 * is a dependency and `to` is a dependent:
304305 *
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.
306 * side 'up' reached by walking towards what the focus depends on
307 * side 'down' reached by walking towards what depends on the focus
308 * side 'focus' the node itself
309 *
310 * A purely directional walk would miss siblings: with up1 supplying both the
311 * focus and `sib`, sib is not upstream OF the focus nor downstream of it, yet it
312 * is plainly related — it shares a supplier. Undirected search finds it at two
313 * hops, and inheriting the first step's side says HOW: two hops out on the
314 * upstream side. That is true, and it keeps every node in exactly one column.
315 *
316 * Upstream wins a tie at equal hop, because "what does this depend on" is the
317 * question people ask first, and something has to break it deterministically.
318 *
319 * Returns Map(id -> { hop, side }).
307320 */
308export function gatherSet (links, startId, depth) {
321export function relationRings (links, focusId, depth) {
309322 const adjacency = new Map()
310 const join = (a, b) => {
311 if (!adjacency.has(a)) adjacency.set(a, [])
312 adjacency.get(a).push(b)
323 const join = (from, to, side) => {
324 if (!adjacency.has(from)) adjacency.set(from, [])
325 adjacency.get(from).push({ next: to, side })
326 }
327 for (const l of links) {
328 join(l.to, l.from, 'up') // l.from supplies l.to, so it is l.to's dependency
329 join(l.from, l.to, 'down')
313330 }
314 for (const l of links) { join(l.from, l.to); join(l.to, l.from) }
315331
316 const hops = new Map([[startId, 0]])
317 let frontier = [startId]
318 for (let d = 1; d <= depth && frontier.length > 0; d++) {
332 const rings = new Map([[focusId, { hop: 0, side: 'focus' }]])
333 let frontier = [focusId]
334 for (let hop = 1; hop <= depth && frontier.length > 0; hop++) {
319335 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)
336 // Two sweeps per ring, upstream first, so a node reachable both ways at this
337 // hop lands upstream regardless of the order the links happened to be in.
338 for (const want of ['up', 'down']) {
339 for (const id of frontier) {
340 const from = rings.get(id)
341 for (const step of adjacency.get(id) || []) {
342 const side = from.side === 'focus' ? step.side : from.side
343 if (step.side !== want && from.side === 'focus') continue
344 if (rings.has(step.next)) continue
345 rings.set(step.next, { hop, side })
346 next.push(step.next)
347 }
325348 }
326349 }
327350 frontier = next
328351 }
329 return hops
352 return rings
330353}
331354
332355/**
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.
356 * The relation view: the focus and what it is connected to, and nothing else.
341357 *
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.
358 * Everything outside the rings is REMOVED, not dimmed. That is the point of the
359 * mode — the question is "how is this connected", and 55 unrelated modules are
360 * the noise that buries the answer. The cost is real, so `hidden` carries the
361 * counts and the view states them rather than pretending to be the whole graph.
345362 *
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.
363 * Each ring is a container with an explicit `rank`, so the columns read left to
364 * right as furthest-upstream -> focus -> furthest-downstream. Ranking them beats
365 * letting the layout infer columns from edges: a sibling two hops out on the
366 * upstream side is not a transitive supplier of the focus, so edge layering would
367 * put it wherever its own edges fell, and the column would stop meaning distance.
349368 */
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
369export function buildRelationView (built, focusId, depth) {
370 const rings = relationRings(built.links, focusId, depth)
371 const present = new Set(built.cards.map(c => c.id))
372 if (!present.has(focusId)) {
373 return { cards: [], links: [], containers: [], rings: new Map(), hidden: { nodes: 0, links: 0 } }
374 }
375 for (const id of [...rings.keys()]) if (!present.has(id)) rings.delete(id)
376
377 // How many of a card's own links point out of the view. Without this the slice
378 // looks like the whole truth about that node.
379 const offView = new Map()
380 let hiddenLinks = 0
381 for (const l of built.links) {
382 const a = rings.has(l.from)
383 const b = rings.has(l.to)
384 if (a && b) continue
385 hiddenLinks++
386 if (a) offView.set(l.from, (offView.get(l.from) || 0) + 1)
387 if (b) offView.set(l.to, (offView.get(l.to) || 0) + 1)
371388 }
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 }
389
390 const ringIdOf = (r) => (r.side === 'focus' ? 'ring:focus' : 'ring:' + r.side + ':' + r.hop)
391
392 const cards = []
393 for (const c of built.cards) {
394 const r = rings.get(c.id)
395 if (!r) continue
396 const n = offView.get(c.id) || 0
397 cards.push({
398 ...c,
399 container: ringIdOf(r),
400 sub: n > 0 ? (c.sub ? c.sub + ' · ' : '') + n + ' off-view' : c.sub
401 })
380402 }
381403
382 containers.push({
383 id: gid,
404 const links = built.links.filter(l => rings.has(l.from) && rings.has(l.to))
405
406 // Upstream rings count down to the focus, downstream rings count up from it, so
407 // the leftmost column is the furthest dependency.
408 let maxUp = 0
409 for (const r of rings.values()) if (r.side === 'up' && r.hop > maxUp) maxUp = r.hop
410
411 const used = new Map()
412 for (const r of rings.values()) used.set(ringIdOf(r), r)
413 const containers = [...used.entries()].map(([id, r]) => ({
414 id,
384415 parent: null,
385 label: 'gathered · ' + hops.size + ' within ' + depth + (depth === 1 ? ' hop' : ' hops'),
386 shape: 'gather',
387 depth: 1
388 })
416 label: r.side === 'focus'
417 ? 'focus'
418 : r.hop + (r.hop === 1 ? ' hop ' : ' hops ') + (r.side === 'up' ? 'upstream' : 'downstream'),
419 shape: 'ring',
420 depth: 1,
421 rank: r.side === 'focus' ? maxUp : r.side === 'up' ? maxUp - r.hop : maxUp + r.hop
422 })).sort((a, b) => a.rank - b.rank || (a.id < b.id ? -1 : 1))
389423
390 return { cards, links: built.links, containers, gathered: hops }
424 return {
425 cards,
426 links,
427 containers,
428 rings,
429 hidden: { nodes: built.cards.length - cards.length, links: hiddenLinks }
430 }
391431}
392432
393433/**
lib/floor/level.html +5 -5
……@@ -57,10 +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; }
60 /* A relation ring is a COLUMN of hop distance, not part of the structure, so it
61 reads as a dashed amber region rather than one more blue zone. */
62 .zone-ring { fill: #3a2a16; fill-opacity: 0.45; stroke: #ffa657; stroke-width: 1.4;
63 stroke-dasharray: 7 4; stroke-opacity: 0.85; }
6464 .zone-label { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; }
6565 .n-open { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; }
6666 #crumb a { color: #8b949e; text-decoration: none; cursor: pointer; }
……@@ -95,7 +95,7 @@
9595 <span class="sw" style="background:#1f4a6b"></span>module body &nbsp;&nbsp;
9696 <span class="sw" style="background:#33405a"></span>zone depth rail
9797</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>
98<div id="hint">hover traces edges &middot; click for dependency depth &middot; <b>G</b> shows only what it connects to, <b>[ ]</b> how far &middot; B bundles &middot; R routing &middot; title opens &middot; Esc back</div>
9999</div>
100100
101101<script type="module" src="/floor/level.js"></script>
lib/floor/level.js +87 -46
……@@ -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, applyGather
23 dependencyClosure, depthColor, buildRelationView
2424} from '/floor/cards.js'
2525
2626const SVG_NS = 'http://www.w3.org/2000/svg'
……@@ -66,9 +66,12 @@ const view = {
6666 circuit: true, // orthogonal routes that go AROUND nodes, not behind them
6767 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,
69 // The relation view: { id, depth }, depth in hops, or null for the ordinary
70 // board. While it is on, the board shows ONLY the focus and what it is
71 // connected to, in columns of hop distance.
72 relations: null,
73 hidden: null, // what the relation view leaves out, for the HUD
74 ringsFor: null, // id -> { hop, side } while the relation view is on
7275 hovered: null, // the card under the pointer, so a key can act on it
7376 linkEls: [],
7477 cardEls: null,
……@@ -163,13 +166,17 @@ function rebuild () {
163166 const sym = currentSymbol()
164167 let built = builtLevel()
165168
166 // Gather is a REGROUPING of the same board, applied after it is built and
167 // before it is laid out, so every other mode — bundling, expansion, routing —
168 // keeps working while it is on.
169 if (view.gather && built.cards.some(c => c.id === view.gather.id)) {
170 built = applyGather(built, view.gather.id, view.gather.depth)
171 } else if (view.gather) {
172 view.gather = null // the card is not on this rung any more
169 // The relation view REPLACES the board, after it is built and before it is laid
170 // out, so every other mode — bundling, expansion, routing — keeps working
171 // inside it.
172 view.hidden = null
173 view.ringsFor = null
174 if (view.relations && built.cards.some(c => c.id === view.relations.id)) {
175 built = buildRelationView(built, view.relations.id, view.relations.depth)
176 view.hidden = built.hidden
177 view.ringsFor = built.rings
178 } else if (view.relations) {
179 view.relations = null // the card is not on this rung any more
173180 }
174181
175182 view.cards = built.cards
……@@ -210,14 +217,19 @@ function rebuild () {
210217 const edgeCount = view.bundled
211218 ? bundleLinks(built.cards, built.links).length + ' bundled edges'
212219 : built.links.length + ' edges'
213 let gathered = ''
214 if (view.gather && built.gathered) {
215 const card = built.cards.find(c => c.id === view.gather.id)
216 gathered = ' · GATHERED ' + (card ? card.title : view.gather.id) + ' + ' +
217 (built.gathered.size - 1) + ' within ' + view.gather.depth +
218 (view.gather.depth === 1 ? ' hop' : ' hops') + ' [ ]'
220 if (view.relations && built.rings) {
221 // The ordinary counts would describe the slice as if it were the board. Say
222 // what this is instead, and state what it is leaving out.
223 const card = built.cards.find(c => c.id === view.relations.id)
224 document.getElementById('stats').textContent =
225 'RELATIONS of ' + (card ? card.title : view.relations.id) +
226 ' · within ' + view.relations.depth + (view.relations.depth === 1 ? ' hop' : ' hops') +
227 ' · ' + built.cards.length + ' nodes, ' + built.links.length + ' links' +
228 ' · hiding ' + built.hidden.nodes + ' nodes and ' + built.hidden.links + ' links' +
229 ' · [ ] depth, Esc out'
230 } else {
231 document.getElementById('stats').textContent = stats + ' · ' + edgeCount
219232 }
220 document.getElementById('stats').textContent = stats + ' · ' + edgeCount + gathered
221233 banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null)
222234 breadcrumb()
223235 render()
……@@ -237,6 +249,27 @@ function banner (text) {
237249 b.textContent = text || ''
238250}
239251
252/**
253 * The rings, in the shape dependencyClosure returns, so every drawing path that
254 * already understands a closure works unchanged.
255 *
256 * An edge takes the hop of whichever end is further from the focus, which is how
257 * far out you have to go to see it. Every node and every edge in the view gets an
258 * entry, so nothing is dimmed or cold.
259 */
260function ringHighlight (rings, edges, depth) {
261 const nodes = new Map()
262 for (const [id, r] of rings) nodes.set(id, r.hop)
263 const edgeMap = new Map()
264 for (const l of edges) {
265 const a = nodes.get(l.from)
266 const b = nodes.get(l.to)
267 if (a === undefined || b === undefined) continue
268 edgeMap.set(l.from + ' -> ' + l.to, Math.max(a, b))
269 }
270 return { nodes, edges: edgeMap, maxDepth: Math.max(1, depth) }
271}
272
240273function breadcrumb () {
241274 const bc = document.getElementById('crumb')
242275 bc.textContent = ''
……@@ -277,9 +310,9 @@ function render () {
277310 // around whatever positions fell out (that was the old frames bug).
278311 for (const box of view.laid.containers) {
279312 const depth = Math.min(box.depth, 4)
280 const isGather = box.id.slice(0, 7) === 'gather:'
313 const isRing = box.id.slice(0, 5) === 'ring:'
281314 world.appendChild(el('rect', {
282 class: isGather ? 'zone zone-gather' : 'zone depth' + depth,
315 class: isRing ? 'zone zone-ring' : 'zone depth' + depth,
283316 x: box.x, y: box.y, width: box.w, height: box.h, rx: 8
284317 }))
285318 const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 })
……@@ -326,7 +359,7 @@ function render () {
326359 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L '))
327360 }
328361 junctions = findJunctions(out.routes)
329 // A handful of entries, not one: gathering and releasing swaps between two
362 // A handful of entries, not one: entering and leaving the relation view swaps
330363 // geometries repeatedly, and a single slot makes every swap a fresh A* pass.
331364 view.routeCache.set(signature, { routedPaths, junctions })
332365 while (view.routeCache.size > 6) view.routeCache.delete(view.routeCache.keys().next().value)
……@@ -339,9 +372,17 @@ function render () {
339372 const selectedCard = view.selected
340373 ? view.cards.find(c => c.id === view.selected)
341374 : null
342 const closure = selectedCard
343 ? dependencyClosure(edges, edgeKeyOf(selectedCard))
344 : null
375 // In the relation view the highlight comes from the RINGS, not from a directed
376 // closure. dependencyClosure only walks upstream, so for a focus whose
377 // relations are all downstream it matched nothing and the view rendered almost
378 // entirely grey: 18 of 19 cards dimmed and all 33 links cold. Everything drawn
379 // here is related by construction, so nothing may be dimmed — the hop number
380 // is the information, and it is already computed.
381 const closure = view.relations && view.ringsFor
382 ? ringHighlight(view.ringsFor, edges, view.relations.depth)
383 : selectedCard
384 ? dependencyClosure(edges, edgeKeyOf(selectedCard))
385 : null
345386
346387 for (const l of edges) {
347388 const a = boxes.get(l.from)
……@@ -409,12 +450,12 @@ function render () {
409450 const card = view.cards.find(c => c.title === wantSelect)
410451 if (card) { view.selected = card.id; render() }
411452 }
412 // ?gather=<depth> opens already gathered around the selection. `select` names
413 // WHAT, `gather` says how far out — so a gathered board is a link, like every
414 // other state in this view.
415 const wantGather = params.get('gather')
416 if (wantGather && view.selected && !view.gather) {
417 gather(view.selected, Math.max(1, Math.min(8, Number(wantGather) || 1)))
453 // ?relations=<depth> opens straight into the relation view. `select` names
454 // WHAT and `relations` says how far out, so a relation view is a link like
455 // every other state here.
456 const wantRel = params.get('relations')
457 if (wantRel && view.selected && !view.relations) {
458 relate(view.selected, Math.max(1, Math.min(8, Number(wantRel) || 1)))
418459 }
419460}
420461
……@@ -567,18 +608,18 @@ function select (id) {
567608}
568609
569610/**
570 * Turn gather on, off, or move its depth.
611 * Enter the relation view, leave it, or move its depth.
571612 *
572 * Depth is clamped at 1, not 0: at 0 the board is regrouped to put one card in a
573 * box of its own, which looks like a bug rather than a control at its limit.
574 * Going below it releases instead, so [ walks back out and then off.
613 * Depth clamps at 1, not 0: a relation view of one node showing no relations is
614 * not a view at its limit, it is a bug that happens to be reachable. Going below
615 * 1 leaves instead, so [ walks back in and then out.
575616 */
576function gather (id, depth) {
617function relate (id, depth) {
577618 if (!id || depth < 1) {
578 if (!view.gather) return
579 view.gather = null
619 if (!view.relations) return
620 view.relations = null
580621 } else {
581 view.gather = { id, depth: Math.min(depth, 8) }
622 view.relations = { id, depth: Math.min(depth, 8) }
582623 view.selected = id
583624 }
584625 unfocus()
……@@ -714,7 +755,7 @@ function syncUrl () {
714755 const card = view.cards.find(c => c.id === view.selected)
715756 if (card) q.push('select=' + encodeURIComponent(card.title))
716757 }
717 if (view.gather) q.push('gather=' + view.gather.depth)
758 if (view.relations) q.push('relations=' + view.relations.depth)
718759 history.replaceState(null, '', locationToUrl(loc, '') + (q.length > 0 ? '?' + q.join('&') : ''))
719760
720761 const link = document.getElementById('other-surface')
……@@ -796,16 +837,16 @@ function wire () {
796837 // Hover counts, not just selection. On this board the obvious click target
797838 // is a card's TITLE, and that opens the card rather than selecting it — so
798839 // requiring a selection made G look broken.
799 const target = view.gather ? null : (view.selected || view.hovered)
800 if (!view.gather && !target) {
801 banner('point at a node, or click one, then press G to gather it')
840 const target = view.relations ? null : (view.selected || view.hovered)
841 if (!view.relations && !target) {
842 banner('point at a node, or click one, then press G for its relations')
802843 return
803844 }
804 gather(target, view.gather ? 0 : 1)
845 relate(target, view.relations ? 0 : 1)
805846 } else if (e.key === ']' || e.key === '}') {
806 if (view.gather) gather(view.gather.id, view.gather.depth + 1)
847 if (view.relations) relate(view.relations.id, view.relations.depth + 1)
807848 } else if (e.key === '[' || e.key === '{') {
808 if (view.gather) gather(view.gather.id, view.gather.depth - 1)
849 if (view.relations) relate(view.relations.id, view.relations.depth - 1)
809850 } else if (e.key === 'e' || e.key === 'E') {
810851 if (!view.model) return // nothing to expand without parsed source
811852 for (const c of view.cards) view.expanded.add(c.id)
……@@ -821,7 +862,7 @@ function wire () {
821862 syncUrl()
822863 rebuild()
823864 } else if (e.key === 'Escape') {
824 if (view.gather) { gather(null, 0); return }
865 if (view.relations) { relate(null, 0); return }
825866 if (view.selected) { select(null); return }
826867 const kind = levelKind()
827868 if (kind === 'symbol') enter(null)
lib/layout.js +28 -11
……@@ -287,9 +287,12 @@ function findBackEdges (items, forward) {
287287
288288/**
289289 * @param {object} input
290 * nodes: [{ id, container, w, h }] container = a container id or null
291 * links: [{ from, to }] node ids
292 * containers: [{ id, parent, label }] parent = a container id or null
290 * nodes: [{ id, container, w, h, rank? }] container = a container id or null
291 * links: [{ from, to }] node ids
292 * containers: [{ id, parent, label, rank? }] parent = a container id or null
293 *
294 * `rank` is an explicit column index. If any sibling in a group has one, ranks
295 * decide that group's columns outright and edge layering is skipped for it.
293296 * @returns {object} { nodes: [{id,x,y,w,h}], containers: [{id,x,y,w,h,depth,label}],
294297 * width, height }
295298 */
……@@ -378,11 +381,12 @@ function layoutOnce (input, O, hint) {
378381 const items = []
379382 for (const cid of childGroups.get(gid)) {
380383 const box = layoutGroup(cid)
381 items.push({ id: cid, kind: 'container', w: box.w, h: box.h })
384 const c = containerById.get(cid)
385 items.push({ id: cid, kind: 'container', w: box.w, h: box.h, rank: c && c.rank })
382386 }
383387 for (const nid of childNodes.get(gid)) {
384388 const n = nodeById.get(nid)
385 items.push({ id: nid, kind: 'node', w: n.w || 0, h: n.h || 0 })
389 items.push({ id: nid, kind: 'node', w: n.w || 0, h: n.h || 0, rank: n.rank })
386390 }
387391
388392 // How tall this group may grow before it wraps.
……@@ -461,14 +465,27 @@ function layoutOnce (input, O, hint) {
461465 const backEdges = findBackEdges(items, forward)
462466 const dagEdges = edges.filter(([a, b]) => !backEdges.has(a + ' -> ' + b))
463467
468 // An explicit RANK, where the caller already knows which column something
469 // belongs in. Layering derives columns from edges, which is the right thing
470 // when edges are the only information there is — but a caller like the
471 // relation view knows each item's hop distance and side exactly, and
472 // re-deriving that from edges throws it away.
473 //
474 // All or nothing per group: if ANYTHING here is ranked, ranks decide the
475 // whole group and an unranked sibling sits at 0. Mixing a rank with a relaxed
476 // depth would be a silent guess about which the caller meant.
477 const ranked = items.some(i => typeof i.rank === 'number')
478
464479 const depth = new Map()
465 for (const i of items) depth.set(i.id, 0)
466 for (let pass = 0; pass < items.length; pass++) {
467 let moved = false
468 for (const [a, b] of dagEdges) {
469 if (depth.get(b) < depth.get(a) + 1) { depth.set(b, depth.get(a) + 1); moved = true }
480 for (const i of items) depth.set(i.id, ranked ? (typeof i.rank === 'number' ? i.rank : 0) : 0)
481 if (!ranked) {
482 for (let pass = 0; pass < items.length; pass++) {
483 let moved = false
484 for (const [a, b] of dagEdges) {
485 if (depth.get(b) < depth.get(a) + 1) { depth.set(b, depth.get(a) + 1); moved = true }
486 }
487 if (!moved) break
470488 }
471 if (!moved) break
472489 }
473490
474491 const columns = new Map()
lib/render-nodes.js +118 -53
……@@ -43,7 +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; }
46.nv-frame-ring { fill-opacity: 0.1; stroke-width: 1.6; stroke-dasharray: 7 4; stroke-opacity: 0.9; }
4747.nv-group-node .nv-node-body { stroke: #58a6ff; stroke-dasharray: 4,2; }
4848.nv-code { font-family: 'SF Mono','Fira Code',monospace; font-size: 8px; fill: #9aa0a6; pointer-events: none; white-space: pre; }
4949.nv-code-kw { fill: #ff7b72; }
……@@ -124,15 +124,17 @@ var NV = {
124124 frames: [],
125125 circuit: true,
126126 selected: 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,
127 // The relation view: { id, depth } in hops, or null for the ordinary board.
128 // While it is on, only the focus and what it connects to are DRAWN.
129 relations: null,
130 rings: null,
131 ringContainers: null,
132 hidden: null,
131133 hover: null, // the node under the pointer, so a key can act on it
132134 note: null, // a one-line message in the legend
133 // A LIST of cached routings, not one slot: gathering and releasing swaps
134 // between two geometries over and over, and a single slot makes every swap a
135 // fresh A* pass over the whole board.
135 // A LIST of cached routings, not one slot: entering and leaving the relation
136 // view swaps between two geometries over and over, and a single slot makes
137 // every swap a fresh A* pass over the whole board.
136138 routeCache: [],
137139 linkEls: [],
138140 nodeEls: null,
……@@ -464,6 +466,56 @@ function nvApplyCollapse (nodes, links) {
464466 return { nodes: kept, links: keptLinks }
465467}
466468
469/**
470 * The relation view, as a filter on what gets drawn.
471 *
472 * Unlike the floor view, this one does not rebuild from a card list — it holds
473 * NV.nodes directly — so the slice happens here, in the same seam the
474 * only-connected collapse uses, and leaving the view is just another nvBuild().
475 */
476/**
477 * The rings in the shape dependencyClosure returns, so the drawing code below is
478 * untouched. An edge takes the hop of whichever end is further from the focus.
479 */
480function nvRingHighlight (rings, links, depth) {
481 var nodes = new Map()
482 rings.forEach(function (r, id) { nodes.set(id, r.hop) })
483 var edges = new Map()
484 for (var i = 0; i < links.length; i++) {
485 var a = nodes.get(links[i].from)
486 var b = nodes.get(links[i].to)
487 if (a === undefined || b === undefined) continue
488 edges.set(links[i].from + ' -> ' + links[i].to, Math.max(a, b))
489 }
490 return { nodes: nodes, edges: edges, maxDepth: Math.max(1, depth) }
491}
492
493function nvApplyRelations (nodes, links) {
494 NV.rings = null
495 NV.hidden = null
496 if (!NV.relations || !window.__ct || !window.__ct.buildRelationView) {
497 return { nodes: nodes, links: links }
498 }
499 var present = false
500 for (var i = 0; i < nodes.length; i++) if (nodes[i].id === NV.relations.id) present = true
501 if (!present) { NV.relations = null; return { nodes: nodes, links: links } }
502
503 var v = window.__ct.buildRelationView(
504 { cards: nodes, links: links, containers: [] }, NV.relations.id, NV.relations.depth)
505 NV.rings = v.rings
506 NV.hidden = v.hidden
507 return { nodes: v.cards, links: v.links, containers: v.containers }
508}
509
510// Collapse first, then the relation slice: collapsing drops nodes with no wires
511// at all, which can never be part of a relation view anyway.
512function nvSlice (nodes, links) {
513 var c = nvApplyCollapse(nodes, links)
514 var r = nvApplyRelations(c.nodes, c.links)
515 NV.ringContainers = r.containers || null
516 return r
517}
518
467519window.nvToggleConnected = function (on) {
468520 NV.onlyConnected = !!on
469521 nvBuild()
……@@ -503,16 +555,18 @@ function nvLayout (nodes, links) {
503555 })
504556 }
505557
506 // Gather regroups the SAME board: same nodes, same noodles, one extra
507 // container holding the selection's neighbourhood. applyGather works on
508 // anything with .id/.container, so the layout items go straight in.
509 NV.gathered = null
510 if (NV.gather && window.__ct && window.__ct.applyGather) {
511 var gg = window.__ct.applyGather(
512 { cards: items, links: links, containers: containers }, NV.gather.id, NV.gather.depth)
513 items = gg.cards
514 containers = gg.containers
515 NV.gathered = gg.gathered
558 // In the relation view the rings ARE the grouping: a ranked container per hop
559 // distance, so the columns read furthest-upstream to furthest-downstream. The
560 // semantic frames would say something true but irrelevant here, and two
561 // overlapping groupings of the same nodes cannot both be drawn.
562 if (NV.relations && NV.ringContainers && NV.rings) {
563 containers = NV.ringContainers
564 for (var gi = 0; gi < items.length; gi++) {
565 var ring = NV.rings.get(items[gi].id)
566 items[gi].container = ring
567 ? (ring.side === 'focus' ? 'ring:focus' : 'ring:' + ring.side + ':' + ring.hop)
568 : null
569 }
516570 }
517571
518572 var out = window.__ctLayout({ nodes: items, links: links, containers: containers })
……@@ -612,13 +666,13 @@ function nvDrawFrames (parent) {
612666 var frames = NV.frames || []
613667 for (var g = 0; g < frames.length; g++) {
614668 var box = frames[g]
615 var isGather = String(box.id).slice(0, 7) === 'gather:'
616 var color = isGather ? '#ffa657' : NV_FRAME_COLORS[g % NV_FRAME_COLORS.length]
669 var isRing = String(box.id).slice(0, 5) === 'ring:'
670 var color = isRing ? '#ffa657' : NV_FRAME_COLORS[g % NV_FRAME_COLORS.length]
617671
618672 var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
619 // Dashed, because it is temporary. A solid amber box would read as one more
620 // part of the structure rather than something you are holding open.
621 r.setAttribute('class', isGather ? 'nv-frame nv-frame-gather' : 'nv-frame')
673 // Dashed, because a ring is a column of hop distance rather than part of the
674 // structure — a solid box would read as one more semantic frame.
675 r.setAttribute('class', isRing ? 'nv-frame nv-frame-ring' : 'nv-frame')
622676 r.setAttribute('x', box.x)
623677 r.setAttribute('y', box.y)
624678 r.setAttribute('width', box.w)
……@@ -750,7 +804,14 @@ function nvRender () {
750804 // one hop, with distance carried by colour. Same shared implementation the
751805 // floor uses, so both views agree about what "depends on" means.
752806 if (NV.selected && window.__ct) {
753 var closure = window.__ct.dependencyClosure(NV.links, NV.selected)
807 // In the relation view the highlight comes from the RINGS. dependencyClosure
808 // only walks upstream, so for a focus whose relations are all downstream it
809 // matched nothing and the view rendered almost entirely dimmed. Everything
810 // drawn here is related by construction, so nothing may be dimmed — and the
811 // hop number, which is the information, is already computed.
812 var closure = (NV.relations && NV.rings)
813 ? nvRingHighlight(NV.rings, NV.links, NV.relations.depth)
814 : window.__ct.dependencyClosure(NV.links, NV.selected)
754815 for (var li = 0; li < NV.linkEls.length; li++) {
755816 var le = NV.linkEls[li]
756817 var d = closure.edges.get(le.from + ' -> ' + le.to)
……@@ -1023,22 +1084,24 @@ function nvBreadcrumb () {
10231084}
10241085
10251086/**
1026 * Turn gather on, off, or move its depth.
1087 * Enter the relation view, leave it, or move its depth.
1088 *
1089 * Clamped at 1 hop: a relation view of one node showing no relations is not a
1090 * view at its limit, it is a bug that happens to be reachable. Going below 1
1091 * leaves, so [ walks back in and then out.
10271092 *
1028 * Clamped at 1 hop: at 0 the board is regrouped to put one node in a box of its
1029 * own, which looks like a bug rather than a control at its limit. Going below it
1030 * releases, so [ walks back out and then off.
1093 * This goes through nvBuild, not nvLayout: the view changes WHICH nodes exist,
1094 * and leaving it has to bring back the ones it dropped.
10311095 */
1032function nvGather (id, depth) {
1096function nvRelate (id, depth) {
10331097 if (!id || depth < 1) {
1034 if (!NV.gather) return
1035 NV.gather = null
1098 if (!NV.relations) return
1099 NV.relations = null
10361100 } else {
1037 NV.gather = { id: id, depth: Math.min(depth, 8) }
1101 NV.relations = { id: id, depth: Math.min(depth, 8) }
10381102 NV.selected = id
10391103 }
1040 nvLayout(NV.nodes, NV.links)
1041 nvRender()
1104 nvBuild()
10421105}
10431106
10441107function nvLegend () {
……@@ -1056,11 +1119,12 @@ function nvLegend () {
10561119 '<span style="color:#f85149">&#9675;</span> unresolved' +
10571120 '<br>left sockets demand &middot; right sockets supply' +
10581121 '<br>hover traces a node &middot; click for its full dependency depth' +
1059 '<br><b>G</b> gathers what you point at &middot; <b>[ ]</b> its depth &middot; ' +
1122 '<br><b>G</b> shows only what it connects to &middot; <b>[ ]</b> how far &middot; ' +
10601123 '<b>F</b> frames &middot; <b>R</b> routing &middot; <b>O</b> only connected &middot; <b>Esc</b>' +
1061 (NV.gather && NV.gathered
1062 ? '<br><span style="color:#ffa657">gathered ' + (NV.gathered.size - 1) +
1063 ' within ' + NV.gather.depth + (NV.gather.depth === 1 ? ' hop' : ' hops') + '</span>'
1124 (NV.relations && NV.hidden
1125 ? '<br><span style="color:#ffa657">RELATIONS within ' + NV.relations.depth +
1126 (NV.relations.depth === 1 ? ' hop' : ' hops') + ' &mdash; hiding ' +
1127 NV.hidden.nodes + ' nodes, ' + NV.hidden.links + ' links</span>'
10641128 : '') +
10651129 (NV.note ? '<br><span style="color:#f0883e">' + escapeHtml(NV.note) + '</span>' : '') +
10661130 '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
……@@ -1091,7 +1155,7 @@ function nvBuild () {
10911155 if (lvl.kind === 'path') {
10921156 var pn = nvPathNodes(lvl.path)
10931157 var pl = nvPathLinks(pn)
1094 var filtered = nvApplyCollapse(pn, pl)
1158 var filtered = nvSlice(pn, pl)
10951159 NV.nodes = filtered.nodes
10961160 NV.links = filtered.links
10971161 nvLayout(NV.nodes, NV.links)
……@@ -1110,7 +1174,7 @@ function nvBuild () {
11101174 if (iface.gin) all.unshift(iface.gin)
11111175 if (iface.gout) all.push(iface.gout)
11121176 var fl = nvFileLinks(built.nodes).concat(iface.links)
1113 var col = nvApplyCollapse(all, fl)
1177 var col = nvSlice(all, fl)
11141178 NV.nodes = col.nodes
11151179 NV.links = col.links
11161180 nvLayout(NV.nodes, NV.links)
……@@ -1126,8 +1190,9 @@ function nvBuild () {
11261190 var all = syms.slice()
11271191 if (iface.gin) all.unshift(iface.gin)
11281192 if (iface.gout) all.push(iface.gout)
1129 NV.nodes = all
1130 NV.links = iface.links
1193 var sl = nvSlice(all, iface.links)
1194 NV.nodes = sl.nodes
1195 NV.links = sl.links
11311196 nvLayout(NV.nodes, NV.links)
11321197 nvRender()
11331198 // The bottom of the ladder: fetch the real source and put it in the nodes.
……@@ -1214,17 +1279,17 @@ function nvInit () {
12141279 // Hover counts, not just selection. A node's HEAD and TITLE open it — that
12151280 // is the obvious thing to click — so only the body selects, and requiring a
12161281 // selection made G look broken.
1217 var target = NV.gather ? null : (NV.selected || NV.hover)
1218 if (!NV.gather && !target) {
1282 var target = NV.relations ? null : (NV.selected || NV.hover)
1283 if (!NV.relations && !target) {
12191284 NV.note = 'point at a node, or click its body, then press G'
12201285 nvLegend()
12211286 return
12221287 }
1223 nvGather(target, NV.gather ? 0 : 1)
1288 nvRelate(target, NV.relations ? 0 : 1)
12241289 } else if (e.key === ']' || e.key === '}') {
1225 if (NV.gather) nvGather(NV.gather.id, NV.gather.depth + 1)
1290 if (NV.relations) nvRelate(NV.relations.id, NV.relations.depth + 1)
12261291 } else if (e.key === '[' || e.key === '{') {
1227 if (NV.gather) nvGather(NV.gather.id, NV.gather.depth - 1)
1292 if (NV.relations) nvRelate(NV.relations.id, NV.relations.depth - 1)
12281293 } else if (e.key === 'f' || e.key === 'F') {
12291294 NV.showFrames = !NV.showFrames
12301295 nvRender()
……@@ -1234,7 +1299,7 @@ function nvInit () {
12341299 } else if (e.key === 'o' || e.key === 'O') {
12351300 window.nvToggleConnected(!NV.onlyConnected)
12361301 } else if (e.key === 'Escape') {
1237 if (NV.gather) { nvGather(null, 0); return }
1302 if (NV.relations) { nvRelate(null, 0); return }
12381303 if (NV.selected) { NV.selected = null; nvRender() }
12391304 }
12401305 })
……@@ -1268,11 +1333,11 @@ function nvInit () {
12681333 }
12691334 if (NV.selected) nvRender()
12701335 }
1271 // ?gather=<depth> opens already gathered around the selection, so a gathered
1272 // board is a link rather than a thing you have to reproduce by hand.
1273 var wantGather = params.get('gather')
1274 if (wantGather && NV.selected) {
1275 nvGather(NV.selected, Math.max(1, Math.min(8, Number(wantGather) || 1)))
1336 // ?relations=<depth> opens straight into the relation view, so it is a link
1337 // rather than something you reproduce by hand.
1338 var wantRel = params.get('relations')
1339 if (wantRel && NV.selected) {
1340 nvRelate(NV.selected, Math.max(1, Math.min(8, Number(wantRel) || 1)))
12761341 }
12771342}
12781343`
test/gather.test.js +0 -119
……@@ -1,119 +0,0 @@
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})
test/manual/README.md +6 -1
……@@ -29,5 +29,10 @@ Two things to know before believing a run:
2929 earlier version aimed at the first node in the DOM and "reproduced" a bug that
3030 was my probe missing the target.
3131- **`view` is module-scoped in level.js**, so `Runtime.evaluate` cannot read it.
32 The floor probe asserts on the DOM — the gather box, the stats line, the URL —
32 The floor probe asserts on the DOM — the ring boxes, the stats line, the URL —
3333 which is what a user sees anyway.
34- **A regex over the dumped DOM is not a selector.** `class` is set after `d` on a
35 routed wire, so `<path class="nv-noodle"` matches nothing, and `class="nv-node"`
36 misses `class="nv-node dim"`. Both mistakes produced a "regression" that did not
37 exist. Count with `querySelectorAll` through the protocol, or match the whole
38 tag and test for the class inside it.
test/manual/keyprobe-floor.mjs +4 -4
……@@ -1,3 +1,4 @@
1// See README.md in this directory. Needs a running server, passed as argv[2].
12// Drive the real page with real key events over the DevTools protocol.
23// Asserting that a handler LOOKS right is not evidence that a key works.
34import { spawn } from 'child_process'
……@@ -64,7 +65,6 @@ const out = []
6465const say = (label, value) => { out.push(label + ': ' + JSON.stringify(value)); console.log(label + ': ' + JSON.stringify(value)) }
6566
6667say('level', await evaluate('document.getElementById("levelname").textContent'))
67say('cards', await evaluate('view.cards.length'))
6868say('containers drawn', await evaluate('document.querySelectorAll(".zone").length'))
6969
7070const rect = await evaluate(`(() => {
……@@ -86,7 +86,7 @@ const curves = () => evaluate(
8686 '[...document.querySelectorAll("path.link")].filter(p => p.getAttribute("d").indexOf(" C ") >= 0).length')
8787
8888await key('g')
89say('after G -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length'))
89say('after G -> ring boxes', await evaluate('document.querySelectorAll(".zone-ring").length'))
9090say('after G -> url', await evaluate('location.search'))
9191say('after G -> stats', await stats())
9292await key(']')
……@@ -94,7 +94,7 @@ say('after ] -> url', await evaluate('location.search'))
9494say('after ] -> stats', await stats())
9595await key('[')
9696await key('[')
97say('after [[ -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length'))
97say('after [[ -> ring boxes', await evaluate('document.querySelectorAll(".zone-ring").length'))
9898say('after [[ -> url', await evaluate('location.search'))
9999
100100// B and R were dead on this rung: the handler began `if (!view.model) return`
……@@ -110,7 +110,7 @@ await key('r')
110110await evaluate('document.getElementById("path").focus()')
111111say('focused', await evaluate('document.activeElement.id'))
112112await key('g')
113say('G while typing -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length'))
113say('G while typing -> ring boxes', await evaluate('document.querySelectorAll(".zone-ring").length'))
114114
115115console.log('---')
116116ws.close()
test/manual/keyprobe-nodes.mjs +10 -8
……@@ -1,3 +1,4 @@
1// See README.md in this directory. Needs a running server, passed as argv[2].
12// Drive the real page with real key events over the DevTools protocol.
23// Asserting that a handler LOOKS right is not evidence that a key works.
34import { spawn } from 'child_process'
……@@ -83,18 +84,19 @@ if (rect) await moveTo(rect.x, rect.y)
8384say('NV.hover after move', await evaluate('NV.hover'))
8485
8586await key('g')
86say('after G -> gather', await evaluate('NV.gather && NV.gather.depth'))
87say('after G -> gather boxes', await evaluate('document.querySelectorAll(".nv-frame-gather").length'))
88say('after G -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null'))
87say('after G -> relations depth', await evaluate('NV.relations && NV.relations.depth'))
88say('after G -> ring boxes', await evaluate('document.querySelectorAll(".nv-frame-ring").length'))
89say('after G -> nodes drawn now', await evaluate('NV.nodes.length'))
8990
91say('ring labels', await evaluate('[...document.querySelectorAll(".nv-frame-label")].map(t => t.textContent)'))
9092await key(']')
91say('after ] -> depth', await evaluate('NV.gather && NV.gather.depth'))
92say('after ] -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null'))
93say('after ] -> depth', await evaluate('NV.relations && NV.relations.depth'))
94say('after ] -> nodes drawn now', await evaluate('NV.nodes.length'))
9395
9496await key('[')
95say('after [ -> depth', await evaluate('NV.gather && NV.gather.depth'))
97say('after [ -> depth', await evaluate('NV.relations && NV.relations.depth'))
9698await key('[')
97say('after [[ -> gather released', await evaluate('NV.gather === null'))
99say('after [[ -> relations left', await evaluate('NV.relations === null'))
98100
99101await key('f')
100102say('after F -> showFrames', await evaluate('NV.showFrames'))
……@@ -107,7 +109,7 @@ await key('r')
107109await evaluate(`(() => { const i = document.querySelector('input[type=text], #search, input'); if (i) { i.focus(); return i.id || i.type } return 'none' })()`)
108110say('focused input', await evaluate('document.activeElement.tagName'))
109111await key('g')
110say('G while typing -> gather stays null', await evaluate('NV.gather === null'))
112say('G while typing -> relations stays null', await evaluate('NV.relations === null'))
111113
112114console.log('---')
113115ws.close()
test/rank.test.js +72 -0
……@@ -0,0 +1,72 @@
1import { test } from 'brittle'
2import { layout } from '../lib/layout.js'
3
4// An explicit rank: which COLUMN an item belongs in, when the caller already
5// knows. Layering derives columns from edges, which is right when that is the
6// only information there is — but a relation view knows each node's hop distance
7// and side exactly, and re-deriving it from edges loses that.
8
9test('layout: an explicit rank decides the column', (t) => {
10 // Ranks deliberately DISAGREE with the edges: b supplies a, so layering would
11 // put b left of a. The rank says otherwise and must win.
12 const out = layout({
13 nodes: [
14 { id: 'a', container: null, w: 100, h: 40, rank: 0 },
15 { id: 'b', container: null, w: 100, h: 40, rank: 1 },
16 { id: 'c', container: null, w: 100, h: 40, rank: 2 }
17 ],
18 links: [{ from: 'b', to: 'a' }],
19 containers: []
20 })
21 const x = new Map(out.nodes.map(n => [n.id, n.x]))
22 t.ok(x.get('a') < x.get('b'), 'a is left of b, as ranked')
23 t.ok(x.get('b') < x.get('c'), 'b is left of c')
24})
25
26test('layout: ranks and edges are not mixed within one group', (t) => {
27 // Half-ranked columns would be a silent guess about what the caller meant.
28 // If anything in a group is ranked, ranks decide the whole group and an
29 // unranked sibling sits at 0.
30 const out = layout({
31 nodes: [
32 { id: 'a', container: null, w: 100, h: 40, rank: 3 },
33 { id: 'b', container: null, w: 100, h: 40 },
34 { id: 'c', container: null, w: 100, h: 40 }
35 ],
36 links: [{ from: 'b', to: 'c' }],
37 containers: []
38 })
39 const x = new Map(out.nodes.map(n => [n.id, n.x]))
40 t.is(x.get('b'), x.get('c'), 'b and c are both unranked, so both at column 0')
41 t.ok(x.get('a') > x.get('b'), 'the ranked item still lands right of them')
42})
43
44test('layout: a container can be ranked too', (t) => {
45 const out = layout({
46 nodes: [
47 { id: 'a', container: 'far', w: 100, h: 40 },
48 { id: 'b', container: 'near', w: 100, h: 40 }
49 ],
50 links: [],
51 containers: [
52 { id: 'near', parent: null, label: 'near', rank: 0 },
53 { id: 'far', parent: null, label: 'far', rank: 1 }
54 ]
55 })
56 const x = new Map(out.containers.map(c => [c.id, c.x]))
57 t.ok(x.get('near') < x.get('far'), 'near is left of far, with no edges at all')
58})
59
60test('layout: unranked input is unaffected', (t) => {
61 const input = {
62 nodes: [
63 { id: 'a', container: null, w: 100, h: 40 },
64 { id: 'b', container: null, w: 100, h: 40 }
65 ],
66 links: [{ from: 'a', to: 'b' }],
67 containers: []
68 }
69 const out = layout(input)
70 const x = new Map(out.nodes.map(n => [n.id, n.x]))
71 t.ok(x.get('a') < x.get('b'), 'layering still decides when nothing is ranked')
72})
test/relations.test.js +122 -0
……@@ -0,0 +1,122 @@
1import { test } from 'brittle'
2import { relationRings, buildRelationView } from '../lib/floor/cards.js'
3
4// The relation view: G replaces the board with the focus and what it is
5// connected to, laid out so that the COLUMNS mean hop distance and direction —
6// what feeds it on the left, what it feeds on the right. A smaller board would
7// not answer "how is this connected"; a ringed one does.
8//
9// Links read supplier -> consumer everywhere in this codebase, so `from` is the
10// dependency and `to` is the dependent.
11
12function board () {
13 // up2 -> up1 -> F -> down1 -> down2
14 // sib <- up1 (a sibling: shares a supplier with F)
15 // lone (nothing)
16 return {
17 cards: [
18 { id: 'F', container: 'cat:a', sub: 'the focus' },
19 { id: 'up1', container: 'cat:a', sub: '' },
20 { id: 'up2', container: 'cat:b', sub: '' },
21 { id: 'down1', container: 'cat:b', sub: '' },
22 { id: 'down2', container: 'cat:c', sub: '' },
23 { id: 'sib', container: 'cat:c', sub: '' },
24 { id: 'lone', container: 'cat:c', sub: '' }
25 ],
26 links: [
27 { from: 'up2', to: 'up1' }, { from: 'up1', to: 'F' },
28 { from: 'F', to: 'down1' }, { from: 'down1', to: 'down2' },
29 { from: 'up1', to: 'sib' }
30 ],
31 containers: [
32 { id: 'cat:a', parent: null, label: 'a' },
33 { id: 'cat:b', parent: null, label: 'b' },
34 { id: 'cat:c', parent: null, label: 'c' }
35 ]
36 }
37}
38
39test('relations: hop and SIDE, upstream left and downstream right', (t) => {
40 const r = relationRings(board().links, 'F', 1)
41 t.alike([...r.keys()].sort(), ['F', 'down1', 'up1'], 'one hop each way')
42 t.alike(r.get('F'), { hop: 0, side: 'focus' }, 'the focus')
43 t.alike(r.get('up1'), { hop: 1, side: 'up' }, 'what F depends on is upstream')
44 t.alike(r.get('down1'), { hop: 1, side: 'down' }, 'what depends on F is downstream')
45})
46
47test('relations: a sibling keeps the side of its FIRST step', (t) => {
48 // sib is reached F <- up1 -> sib: two hops, and the first step went upstream.
49 // Calling that "2 hops upstream" is truthful about how it is related, which is
50 // the whole point of the view.
51 const r = relationRings(board().links, 'F', 2)
52 t.alike(r.get('sib'), { hop: 2, side: 'up' }, 'sibling via a shared supplier')
53 t.alike(r.get('up2'), { hop: 2, side: 'up' }, 'and the real 2-hop dependency')
54 t.alike(r.get('down2'), { hop: 2, side: 'down' })
55 t.absent(r.get('lone'), 'an unconnected node is not in the view')
56})
57
58test('relations: depth bounds it, and a cycle terminates', (t) => {
59 const links = [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'a' }]
60 t.is(relationRings(links, 'a', 1).size, 3, 'a cycle reaches everything in one hop each way')
61 t.is(relationRings(links, 'a', 9).size, 3, 'and does not spin')
62 t.is(relationRings(board().links, 'F', 0).size, 1, 'depth 0 is the focus alone')
63})
64
65test('relations: the view keeps ONLY the connected set', (t) => {
66 const b = board()
67 const v = buildRelationView(b, 'F', 1)
68
69 t.alike(v.cards.map(c => c.id).sort(), ['F', 'down1', 'up1'], 'three cards')
70 t.alike(v.links.map(l => l.from + '->' + l.to).sort(), ['F->down1', 'up1->F'],
71 'and only the links between them')
72
73 // What is NOT shown has to be counted, or the view lies about being the graph.
74 t.is(v.hidden.nodes, 4, 'lone, sib, up2 and down2 are out of view')
75 t.is(v.hidden.links, 3, 'and three links with an endpoint outside')
76})
77
78test('relations: one container per ring, ranked so columns read left to right', (t) => {
79 const v = buildRelationView(board(), 'F', 2)
80 const byId = new Map(v.containers.map(c => [c.id, c]))
81
82 t.ok(byId.has('ring:up:2'), 'a ring for 2 hops upstream')
83 t.ok(byId.has('ring:up:1'))
84 t.ok(byId.has('ring:focus'))
85 t.ok(byId.has('ring:down:1'))
86 t.ok(byId.has('ring:down:2'))
87 t.is(v.containers.length, 5, 'and nothing else — the categories are gone')
88
89 // Ranks are what put them in order; the layout no longer has to infer it.
90 const rank = (id) => byId.get(id).rank
91 t.is(rank('ring:up:2'), 0, 'furthest upstream is the leftmost column')
92 t.is(rank('ring:up:1'), 1)
93 t.is(rank('ring:focus'), 2, 'the focus sits between the two sides')
94 t.is(rank('ring:down:1'), 3)
95 t.is(rank('ring:down:2'), 4)
96
97 const card = new Map(v.cards.map(c => [c.id, c]))
98 t.is(card.get('sib').container, 'ring:up:2', 'the sibling is in the 2-hop upstream ring')
99 t.is(card.get('F').container, 'ring:focus')
100})
101
102test('relations: with no upstream at all the focus is still the leftmost', (t) => {
103 const v = buildRelationView(board(), 'up2', 1)
104 const byId = new Map(v.containers.map(c => [c.id, c]))
105 t.is(byId.get('ring:focus').rank, 0, 'nothing upstream, so the focus starts the row')
106 t.is(byId.get('ring:down:1').rank, 1)
107})
108
109test('relations: a card says how many of its links are off-view', (t) => {
110 const v = buildRelationView(board(), 'F', 1)
111 const card = new Map(v.cards.map(c => [c.id, c]))
112 // up1 has TWO links out of a depth-1 view: up2 supplies it, and it supplies
113 // sib. Both directions count — an off-view link is an off-view link.
114 t.ok(/2 off-view/.test(card.get('up1').sub), 'up1: ' + card.get('up1').sub)
115 t.absent(/off-view/.test(card.get('F').sub), 'F has none: ' + card.get('F').sub)
116})
117
118test('relations: a focus that is not on the board yields nothing, not a throw', (t) => {
119 const v = buildRelationView(board(), 'nope', 2)
120 t.is(v.cards.length, 0, 'no cards')
121 t.is(v.containers.length, 0, 'and no empty ring boxes either')
122})