| … | … | @@ -296,98 +296,138 @@ export function dependencyClosure (links, startId, direction = 'dependencies') { |
| 296 | 296 | } |
| 297 | 297 | |
| 298 | 298 | /** |
| 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. | |
| 300 | 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. | |
| 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: | |
| 304 | 305 | * |
| 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 }). | |
| 307 | 320 | */ |
| 308 | export function gatherSet (links, startId, depth) { | |
| 321 | export function relationRings (links, focusId, depth) { | |
| 309 | 322 | 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') | |
| 313 | 330 | } |
| 314 | for (const l of links) { join(l.from, l.to); join(l.to, l.from) } | |
| 315 | 331 | |
| 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++) { | |
| 319 | 335 | 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 | } | |
| 325 | 348 | } |
| 326 | 349 | } |
| 327 | 350 | frontier = next |
| 328 | 351 | } |
| 329 | return hops | |
| 352 | return rings | |
| 330 | 353 | } |
| 331 | 354 | |
| 332 | 355 | /** |
| 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. | |
| 341 | 357 | * |
| 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. | |
| 345 | 362 | * |
| 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. | |
| 349 | 368 | */ |
| 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 | |
| 369 | export 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) | |
| 371 | 388 | } |
| 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 | }) | |
| 380 | 402 | } |
| 381 | 403 | |
| 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, | |
| 384 | 415 | 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)) | |
| 389 | 423 | |
| 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 | } | |
| 391 | 431 | } |
| 392 | 432 | |
| 393 | 433 | /** |
| … | … | @@ -57,10 +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 | /* 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; } | |
| 64 | 64 | .zone-label { font-size: 9px; fill: #8b949e; font-family: ui-monospace, monospace; } |
| 65 | 65 | .n-open { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; } |
| 66 | 66 | #crumb a { color: #8b949e; text-decoration: none; cursor: pointer; } |
| … | … | @@ -95,7 +95,7 @@ |
| 95 | 95 | <span class="sw" style="background:#1f4a6b"></span>module body |
| 96 | 96 | <span class="sw" style="background:#33405a"></span>zone depth rail |
| 97 | 97 | </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> | |
| 98 | <div id="hint">hover traces edges · click for dependency depth · <b>G</b> shows only what it connects to, <b>[ ]</b> how far · B bundles · R routing · title opens · Esc back</div> | |
| 99 | 99 | </div> |
| 100 | 100 | |
| 101 | 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, applyGather | |
| 23 | dependencyClosure, depthColor, buildRelationView | |
| 24 | 24 | } from '/floor/cards.js' |
| 25 | 25 | |
| 26 | 26 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| … | … | @@ -66,9 +66,12 @@ const view = { |
| 66 | 66 | circuit: true, // orthogonal routes that go AROUND nodes, not behind them |
| 67 | 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 | // 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 | |
| 72 | 75 | hovered: null, // the card under the pointer, so a key can act on it |
| 73 | 76 | linkEls: [], |
| 74 | 77 | cardEls: null, |
| … | … | @@ -163,13 +166,17 @@ function rebuild () { |
| 163 | 166 | const sym = currentSymbol() |
| 164 | 167 | let built = builtLevel() |
| 165 | 168 | |
| 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 | |
| 173 | 180 | } |
| 174 | 181 | |
| 175 | 182 | view.cards = built.cards |
| … | … | @@ -210,14 +217,19 @@ function rebuild () { |
| 210 | 217 | const edgeCount = view.bundled |
| 211 | 218 | ? bundleLinks(built.cards, built.links).length + ' bundled edges' |
| 212 | 219 | : 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 | |
| 219 | 232 | } |
| 220 | document.getElementById('stats').textContent = stats + ' · ' + edgeCount + gathered | |
| 221 | 233 | banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null) |
| 222 | 234 | breadcrumb() |
| 223 | 235 | render() |
| … | … | @@ -237,6 +249,27 @@ function banner (text) { |
| 237 | 249 | b.textContent = text || '' |
| 238 | 250 | } |
| 239 | 251 | |
| 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 | */ | |
| 260 | function 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 | ||
| 240 | 273 | function breadcrumb () { |
| 241 | 274 | const bc = document.getElementById('crumb') |
| 242 | 275 | bc.textContent = '' |
| … | … | @@ -277,9 +310,9 @@ function render () { |
| 277 | 310 | // around whatever positions fell out (that was the old frames bug). |
| 278 | 311 | for (const box of view.laid.containers) { |
| 279 | 312 | 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:' | |
| 281 | 314 | world.appendChild(el('rect', { |
| 282 | class: isGather ? 'zone zone-gather' : 'zone depth' + depth, | |
| 315 | class: isRing ? 'zone zone-ring' : 'zone depth' + depth, | |
| 283 | 316 | x: box.x, y: box.y, width: box.w, height: box.h, rx: 8 |
| 284 | 317 | })) |
| 285 | 318 | const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 }) |
| … | … | @@ -326,7 +359,7 @@ function render () { |
| 326 | 359 | 'M ' + r.points.map(p => p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' L ')) |
| 327 | 360 | } |
| 328 | 361 | 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 | |
| 330 | 363 | // geometries repeatedly, and a single slot makes every swap a fresh A* pass. |
| 331 | 364 | view.routeCache.set(signature, { routedPaths, junctions }) |
| 332 | 365 | while (view.routeCache.size > 6) view.routeCache.delete(view.routeCache.keys().next().value) |
| … | … | @@ -339,9 +372,17 @@ function render () { |
| 339 | 372 | const selectedCard = view.selected |
| 340 | 373 | ? view.cards.find(c => c.id === view.selected) |
| 341 | 374 | : 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 | |
| 345 | 386 | |
| 346 | 387 | for (const l of edges) { |
| 347 | 388 | const a = boxes.get(l.from) |
| … | … | @@ -409,12 +450,12 @@ function render () { |
| 409 | 450 | const card = view.cards.find(c => c.title === wantSelect) |
| 410 | 451 | if (card) { view.selected = card.id; render() } |
| 411 | 452 | } |
| 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))) | |
| 418 | 459 | } |
| 419 | 460 | } |
| 420 | 461 | |
| … | … | @@ -567,18 +608,18 @@ function select (id) { |
| 567 | 608 | } |
| 568 | 609 | |
| 569 | 610 | /** |
| 570 | * Turn gather on, off, or move its depth. | |
| 611 | * Enter the relation view, leave it, or move its depth. | |
| 571 | 612 | * |
| 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. | |
| 575 | 616 | */ |
| 576 | function gather (id, depth) { | |
| 617 | function relate (id, depth) { | |
| 577 | 618 | if (!id || depth < 1) { |
| 578 | if (!view.gather) return | |
| 579 | view.gather = null | |
| 619 | if (!view.relations) return | |
| 620 | view.relations = null | |
| 580 | 621 | } else { |
| 581 | view.gather = { id, depth: Math.min(depth, 8) } | |
| 622 | view.relations = { id, depth: Math.min(depth, 8) } | |
| 582 | 623 | view.selected = id |
| 583 | 624 | } |
| 584 | 625 | unfocus() |
| … | … | @@ -714,7 +755,7 @@ function syncUrl () { |
| 714 | 755 | const card = view.cards.find(c => c.id === view.selected) |
| 715 | 756 | if (card) q.push('select=' + encodeURIComponent(card.title)) |
| 716 | 757 | } |
| 717 | if (view.gather) q.push('gather=' + view.gather.depth) | |
| 758 | if (view.relations) q.push('relations=' + view.relations.depth) | |
| 718 | 759 | history.replaceState(null, '', locationToUrl(loc, '') + (q.length > 0 ? '?' + q.join('&') : '')) |
| 719 | 760 | |
| 720 | 761 | const link = document.getElementById('other-surface') |
| … | … | @@ -796,16 +837,16 @@ function wire () { |
| 796 | 837 | // Hover counts, not just selection. On this board the obvious click target |
| 797 | 838 | // is a card's TITLE, and that opens the card rather than selecting it — so |
| 798 | 839 | // 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') | |
| 802 | 843 | return |
| 803 | 844 | } |
| 804 | gather(target, view.gather ? 0 : 1) | |
| 845 | relate(target, view.relations ? 0 : 1) | |
| 805 | 846 | } 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) | |
| 807 | 848 | } 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) | |
| 809 | 850 | } else if (e.key === 'e' || e.key === 'E') { |
| 810 | 851 | if (!view.model) return // nothing to expand without parsed source |
| 811 | 852 | for (const c of view.cards) view.expanded.add(c.id) |
| … | … | @@ -821,7 +862,7 @@ function wire () { |
| 821 | 862 | syncUrl() |
| 822 | 863 | rebuild() |
| 823 | 864 | } else if (e.key === 'Escape') { |
| 824 | if (view.gather) { gather(null, 0); return } | |
| 865 | if (view.relations) { relate(null, 0); return } | |
| 825 | 866 | if (view.selected) { select(null); return } |
| 826 | 867 | const kind = levelKind() |
| 827 | 868 | if (kind === 'symbol') enter(null) |
| … | … | @@ -287,9 +287,12 @@ function findBackEdges (items, forward) { |
| 287 | 287 | |
| 288 | 288 | /** |
| 289 | 289 | * @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. | |
| 293 | 296 | * @returns {object} { nodes: [{id,x,y,w,h}], containers: [{id,x,y,w,h,depth,label}], |
| 294 | 297 | * width, height } |
| 295 | 298 | */ |
| … | … | @@ -378,11 +381,12 @@ function layoutOnce (input, O, hint) { |
| 378 | 381 | const items = [] |
| 379 | 382 | for (const cid of childGroups.get(gid)) { |
| 380 | 383 | 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 }) | |
| 382 | 386 | } |
| 383 | 387 | for (const nid of childNodes.get(gid)) { |
| 384 | 388 | 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 }) | |
| 386 | 390 | } |
| 387 | 391 | |
| 388 | 392 | // How tall this group may grow before it wraps. |
| … | … | @@ -461,14 +465,27 @@ function layoutOnce (input, O, hint) { |
| 461 | 465 | const backEdges = findBackEdges(items, forward) |
| 462 | 466 | const dagEdges = edges.filter(([a, b]) => !backEdges.has(a + ' -> ' + b)) |
| 463 | 467 | |
| 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 | ||
| 464 | 479 | 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 | |
| 470 | 488 | } |
| 471 | if (!moved) break | |
| 472 | 489 | } |
| 473 | 490 | |
| 474 | 491 | const columns = new Map() |
| … | … | @@ -43,7 +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 | .nv-frame-ring { fill-opacity: 0.1; stroke-width: 1.6; stroke-dasharray: 7 4; stroke-opacity: 0.9; } | |
| 47 | 47 | .nv-group-node .nv-node-body { stroke: #58a6ff; stroke-dasharray: 4,2; } |
| 48 | 48 | .nv-code { font-family: 'SF Mono','Fira Code',monospace; font-size: 8px; fill: #9aa0a6; pointer-events: none; white-space: pre; } |
| 49 | 49 | .nv-code-kw { fill: #ff7b72; } |
| … | … | @@ -124,15 +124,17 @@ var NV = { |
| 124 | 124 | frames: [], |
| 125 | 125 | circuit: true, |
| 126 | 126 | 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, | |
| 131 | 133 | hover: null, // the node under the pointer, so a key can act on it |
| 132 | 134 | 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. | |
| 136 | 138 | routeCache: [], |
| 137 | 139 | linkEls: [], |
| 138 | 140 | nodeEls: null, |
| … | … | @@ -464,6 +466,56 @@ function nvApplyCollapse (nodes, links) { |
| 464 | 466 | return { nodes: kept, links: keptLinks } |
| 465 | 467 | } |
| 466 | 468 | |
| 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 | */ | |
| 480 | function 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 | ||
| 493 | function 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. | |
| 512 | function 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 | ||
| 467 | 519 | window.nvToggleConnected = function (on) { |
| 468 | 520 | NV.onlyConnected = !!on |
| 469 | 521 | nvBuild() |
| … | … | @@ -503,16 +555,18 @@ function nvLayout (nodes, links) { |
| 503 | 555 | }) |
| 504 | 556 | } |
| 505 | 557 | |
| 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 | } | |
| 516 | 570 | } |
| 517 | 571 | |
| 518 | 572 | var out = window.__ctLayout({ nodes: items, links: links, containers: containers }) |
| … | … | @@ -612,13 +666,13 @@ function nvDrawFrames (parent) { |
| 612 | 666 | var frames = NV.frames || [] |
| 613 | 667 | for (var g = 0; g < frames.length; g++) { |
| 614 | 668 | 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] | |
| 617 | 671 | |
| 618 | 672 | 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') | |
| 622 | 676 | r.setAttribute('x', box.x) |
| 623 | 677 | r.setAttribute('y', box.y) |
| 624 | 678 | r.setAttribute('width', box.w) |
| … | … | @@ -750,7 +804,14 @@ function nvRender () { |
| 750 | 804 | // one hop, with distance carried by colour. Same shared implementation the |
| 751 | 805 | // floor uses, so both views agree about what "depends on" means. |
| 752 | 806 | 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) | |
| 754 | 815 | for (var li = 0; li < NV.linkEls.length; li++) { |
| 755 | 816 | var le = NV.linkEls[li] |
| 756 | 817 | var d = closure.edges.get(le.from + ' -> ' + le.to) |
| … | … | @@ -1023,22 +1084,24 @@ function nvBreadcrumb () { |
| 1023 | 1084 | } |
| 1024 | 1085 | |
| 1025 | 1086 | /** |
| 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. | |
| 1027 | 1092 | * |
| 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. | |
| 1031 | 1095 | */ |
| 1032 | function nvGather (id, depth) { | |
| 1096 | function nvRelate (id, depth) { | |
| 1033 | 1097 | if (!id || depth < 1) { |
| 1034 | if (!NV.gather) return | |
| 1035 | NV.gather = null | |
| 1098 | if (!NV.relations) return | |
| 1099 | NV.relations = null | |
| 1036 | 1100 | } else { |
| 1037 | NV.gather = { id: id, depth: Math.min(depth, 8) } | |
| 1101 | NV.relations = { id: id, depth: Math.min(depth, 8) } | |
| 1038 | 1102 | NV.selected = id |
| 1039 | 1103 | } |
| 1040 | nvLayout(NV.nodes, NV.links) | |
| 1041 | nvRender() | |
| 1104 | nvBuild() | |
| 1042 | 1105 | } |
| 1043 | 1106 | |
| 1044 | 1107 | function nvLegend () { |
| … | … | @@ -1056,11 +1119,12 @@ function nvLegend () { |
| 1056 | 1119 | '<span style="color:#f85149">○</span> unresolved' + |
| 1057 | 1120 | '<br>left sockets demand · right sockets supply' + |
| 1058 | 1121 | '<br>hover traces a node · click for its full dependency depth' + |
| 1059 | '<br><b>G</b> gathers what you point at · <b>[ ]</b> its depth · ' + | |
| 1122 | '<br><b>G</b> shows only what it connects to · <b>[ ]</b> how far · ' + | |
| 1060 | 1123 | '<b>F</b> frames · <b>R</b> routing · <b>O</b> only connected · <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') + ' — hiding ' + | |
| 1127 | NV.hidden.nodes + ' nodes, ' + NV.hidden.links + ' links</span>' | |
| 1064 | 1128 | : '') + |
| 1065 | 1129 | (NV.note ? '<br><span style="color:#f0883e">' + escapeHtml(NV.note) + '</span>' : '') + |
| 1066 | 1130 | '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' + |
| … | … | @@ -1091,7 +1155,7 @@ function nvBuild () { |
| 1091 | 1155 | if (lvl.kind === 'path') { |
| 1092 | 1156 | var pn = nvPathNodes(lvl.path) |
| 1093 | 1157 | var pl = nvPathLinks(pn) |
| 1094 | var filtered = nvApplyCollapse(pn, pl) | |
| 1158 | var filtered = nvSlice(pn, pl) | |
| 1095 | 1159 | NV.nodes = filtered.nodes |
| 1096 | 1160 | NV.links = filtered.links |
| 1097 | 1161 | nvLayout(NV.nodes, NV.links) |
| … | … | @@ -1110,7 +1174,7 @@ function nvBuild () { |
| 1110 | 1174 | if (iface.gin) all.unshift(iface.gin) |
| 1111 | 1175 | if (iface.gout) all.push(iface.gout) |
| 1112 | 1176 | var fl = nvFileLinks(built.nodes).concat(iface.links) |
| 1113 | var col = nvApplyCollapse(all, fl) | |
| 1177 | var col = nvSlice(all, fl) | |
| 1114 | 1178 | NV.nodes = col.nodes |
| 1115 | 1179 | NV.links = col.links |
| 1116 | 1180 | nvLayout(NV.nodes, NV.links) |
| … | … | @@ -1126,8 +1190,9 @@ function nvBuild () { |
| 1126 | 1190 | var all = syms.slice() |
| 1127 | 1191 | if (iface.gin) all.unshift(iface.gin) |
| 1128 | 1192 | 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 | |
| 1131 | 1196 | nvLayout(NV.nodes, NV.links) |
| 1132 | 1197 | nvRender() |
| 1133 | 1198 | // The bottom of the ladder: fetch the real source and put it in the nodes. |
| … | … | @@ -1214,17 +1279,17 @@ function nvInit () { |
| 1214 | 1279 | // Hover counts, not just selection. A node's HEAD and TITLE open it — that |
| 1215 | 1280 | // is the obvious thing to click — so only the body selects, and requiring a |
| 1216 | 1281 | // 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) { | |
| 1219 | 1284 | NV.note = 'point at a node, or click its body, then press G' |
| 1220 | 1285 | nvLegend() |
| 1221 | 1286 | return |
| 1222 | 1287 | } |
| 1223 | nvGather(target, NV.gather ? 0 : 1) | |
| 1288 | nvRelate(target, NV.relations ? 0 : 1) | |
| 1224 | 1289 | } 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) | |
| 1226 | 1291 | } 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) | |
| 1228 | 1293 | } else if (e.key === 'f' || e.key === 'F') { |
| 1229 | 1294 | NV.showFrames = !NV.showFrames |
| 1230 | 1295 | nvRender() |
| … | … | @@ -1234,7 +1299,7 @@ function nvInit () { |
| 1234 | 1299 | } else if (e.key === 'o' || e.key === 'O') { |
| 1235 | 1300 | window.nvToggleConnected(!NV.onlyConnected) |
| 1236 | 1301 | } else if (e.key === 'Escape') { |
| 1237 | if (NV.gather) { nvGather(null, 0); return } | |
| 1302 | if (NV.relations) { nvRelate(null, 0); return } | |
| 1238 | 1303 | if (NV.selected) { NV.selected = null; nvRender() } |
| 1239 | 1304 | } |
| 1240 | 1305 | }) |
| … | … | @@ -1268,11 +1333,11 @@ function nvInit () { |
| 1268 | 1333 | } |
| 1269 | 1334 | if (NV.selected) nvRender() |
| 1270 | 1335 | } |
| 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))) | |
| 1276 | 1341 | } |
| 1277 | 1342 | } |
| 1278 | 1343 | ` |
| … | … | @@ -1,119 +0,0 @@ |
| 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 | }) |
| … | … | @@ -29,5 +29,10 @@ Two things to know before believing a run: |
| 29 | 29 | earlier version aimed at the first node in the DOM and "reproduced" a bug that |
| 30 | 30 | was my probe missing the target. |
| 31 | 31 | - **`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 — | |
| 33 | 33 | 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. |
| … | … | @@ -1,3 +1,4 @@ |
| 1 | // See README.md in this directory. Needs a running server, passed as argv[2]. | |
| 1 | 2 | // Drive the real page with real key events over the DevTools protocol. |
| 2 | 3 | // Asserting that a handler LOOKS right is not evidence that a key works. |
| 3 | 4 | import { spawn } from 'child_process' |
| … | … | @@ -64,7 +65,6 @@ const out = [] |
| 64 | 65 | const say = (label, value) => { out.push(label + ': ' + JSON.stringify(value)); console.log(label + ': ' + JSON.stringify(value)) } |
| 65 | 66 | |
| 66 | 67 | say('level', await evaluate('document.getElementById("levelname").textContent')) |
| 67 | say('cards', await evaluate('view.cards.length')) | |
| 68 | 68 | say('containers drawn', await evaluate('document.querySelectorAll(".zone").length')) |
| 69 | 69 | |
| 70 | 70 | const rect = await evaluate(`(() => { |
| … | … | @@ -86,7 +86,7 @@ const curves = () => evaluate( |
| 86 | 86 | '[...document.querySelectorAll("path.link")].filter(p => p.getAttribute("d").indexOf(" C ") >= 0).length') |
| 87 | 87 | |
| 88 | 88 | await key('g') |
| 89 | say('after G -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length')) | |
| 89 | say('after G -> ring boxes', await evaluate('document.querySelectorAll(".zone-ring").length')) | |
| 90 | 90 | say('after G -> url', await evaluate('location.search')) |
| 91 | 91 | say('after G -> stats', await stats()) |
| 92 | 92 | await key(']') |
| … | … | @@ -94,7 +94,7 @@ say('after ] -> url', await evaluate('location.search')) |
| 94 | 94 | say('after ] -> stats', await stats()) |
| 95 | 95 | await key('[') |
| 96 | 96 | await key('[') |
| 97 | say('after [[ -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length')) | |
| 97 | say('after [[ -> ring boxes', await evaluate('document.querySelectorAll(".zone-ring").length')) | |
| 98 | 98 | say('after [[ -> url', await evaluate('location.search')) |
| 99 | 99 | |
| 100 | 100 | // B and R were dead on this rung: the handler began `if (!view.model) return` |
| … | … | @@ -110,7 +110,7 @@ await key('r') |
| 110 | 110 | await evaluate('document.getElementById("path").focus()') |
| 111 | 111 | say('focused', await evaluate('document.activeElement.id')) |
| 112 | 112 | await key('g') |
| 113 | say('G while typing -> gather box', await evaluate('document.querySelectorAll(".zone-gather").length')) | |
| 113 | say('G while typing -> ring boxes', await evaluate('document.querySelectorAll(".zone-ring").length')) | |
| 114 | 114 | |
| 115 | 115 | console.log('---') |
| 116 | 116 | ws.close() |
| … | … | @@ -1,3 +1,4 @@ |
| 1 | // See README.md in this directory. Needs a running server, passed as argv[2]. | |
| 1 | 2 | // Drive the real page with real key events over the DevTools protocol. |
| 2 | 3 | // Asserting that a handler LOOKS right is not evidence that a key works. |
| 3 | 4 | import { spawn } from 'child_process' |
| … | … | @@ -83,18 +84,19 @@ if (rect) await moveTo(rect.x, rect.y) |
| 83 | 84 | say('NV.hover after move', await evaluate('NV.hover')) |
| 84 | 85 | |
| 85 | 86 | await key('g') |
| 86 | say('after G -> gather', await evaluate('NV.gather && NV.gather.depth')) | |
| 87 | say('after G -> gather boxes', await evaluate('document.querySelectorAll(".nv-frame-gather").length')) | |
| 88 | say('after G -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null')) | |
| 87 | say('after G -> relations depth', await evaluate('NV.relations && NV.relations.depth')) | |
| 88 | say('after G -> ring boxes', await evaluate('document.querySelectorAll(".nv-frame-ring").length')) | |
| 89 | say('after G -> nodes drawn now', await evaluate('NV.nodes.length')) | |
| 89 | 90 | |
| 91 | say('ring labels', await evaluate('[...document.querySelectorAll(".nv-frame-label")].map(t => t.textContent)')) | |
| 90 | 92 | await key(']') |
| 91 | say('after ] -> depth', await evaluate('NV.gather && NV.gather.depth')) | |
| 92 | say('after ] -> gathered count', await evaluate('NV.gathered ? NV.gathered.size : null')) | |
| 93 | say('after ] -> depth', await evaluate('NV.relations && NV.relations.depth')) | |
| 94 | say('after ] -> nodes drawn now', await evaluate('NV.nodes.length')) | |
| 93 | 95 | |
| 94 | 96 | await key('[') |
| 95 | say('after [ -> depth', await evaluate('NV.gather && NV.gather.depth')) | |
| 97 | say('after [ -> depth', await evaluate('NV.relations && NV.relations.depth')) | |
| 96 | 98 | await key('[') |
| 97 | say('after [[ -> gather released', await evaluate('NV.gather === null')) | |
| 99 | say('after [[ -> relations left', await evaluate('NV.relations === null')) | |
| 98 | 100 | |
| 99 | 101 | await key('f') |
| 100 | 102 | say('after F -> showFrames', await evaluate('NV.showFrames')) |
| … | … | @@ -107,7 +109,7 @@ await key('r') |
| 107 | 109 | await evaluate(`(() => { const i = document.querySelector('input[type=text], #search, input'); if (i) { i.focus(); return i.id || i.type } return 'none' })()`) |
| 108 | 110 | say('focused input', await evaluate('document.activeElement.tagName')) |
| 109 | 111 | await key('g') |
| 110 | say('G while typing -> gather stays null', await evaluate('NV.gather === null')) | |
| 112 | say('G while typing -> relations stays null', await evaluate('NV.relations === null')) | |
| 111 | 113 | |
| 112 | 114 | console.log('---') |
| 113 | 115 | ws.close() |
| … | … | @@ -0,0 +1,72 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { 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 | ||
| 9 | test('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 | ||
| 26 | test('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 | ||
| 44 | test('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 | ||
| 60 | test('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 | }) |
| … | … | @@ -0,0 +1,122 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { 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 | ||
| 12 | function 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 | ||
| 39 | test('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 | ||
| 47 | test('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 | ||
| 58 | test('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 | ||
| 65 | test('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 | ||
| 78 | test('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 | ||
| 102 | test('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 | ||
| 109 | test('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 | ||
| 118 | test('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 | }) |