From 1e7cbbd50e8b275629957dfc92f6a728281b476c Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 10 Nov 2026 14:33:27 +0800 Subject: [PATCH 1/2] fix(reactivity): lay out the graph in bands of connected nodes The graph put every node of the app in one set of columns, so unrelated chains shared columns and their edges crossed. Nodes with no edges filled one tall column. Known nodes kept their first order, which undid the crossing sweeps after the first layout. - Each group of connected nodes gets a band, stacked top to bottom. - Nodes with no edges share a grid under the bands. - An edge that skips columns gets a waypoint in each column it passes. - A node with no sources moves to the column before its nearest reader. - Rows are ordered by averaging sweeps, keeping the order with the fewest crossings, then by swapping neighbours. - A band whose nodes and edges did not change keeps its arrangement. Co-Authored-By: Claude Opus 5.5 --- .changeset/graph-bands.md | 10 + src/dev-toolbar/reactivity/index.tsx | 7 +- src/dev-toolbar/reactivity/layout.test.ts | 126 +++++- src/dev-toolbar/reactivity/layout.ts | 481 ++++++++++++++++------ 4 files changed, 491 insertions(+), 133 deletions(-) create mode 100644 .changeset/graph-bands.md diff --git a/.changeset/graph-bands.md b/.changeset/graph-bands.md new file mode 100644 index 0000000..8228a37 --- /dev/null +++ b/.changeset/graph-bands.md @@ -0,0 +1,10 @@ +--- +'@solidjs/start-devtools': patch +--- + +The reactivity graph lays out each group of connected nodes in a band of its own, so unrelated chains no longer share columns. + +- Nodes with no edges share a grid under the bands instead of one tall column. +- An edge that skips columns runs in a lane of its own instead of through other nodes. +- A signal sits in the column right before the node that reads it. +- A band keeps its arrangement while its nodes and edges stay the same. A band whose structure changed is arranged again, so its edges cross as little as possible. diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index 49b8284..8c2afd8 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -13,6 +13,7 @@ import { reactiveReport } from '../report/reactive-report.js'; import { formatValue, typeName } from './format.js'; import { ARROW_SIZE, + edgeKey, edgePath, layoutGraph, NODE_HEIGHT, @@ -640,7 +641,11 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem {(to) => ( { }); it('stacks nodes of one column without overlap', () => { - const layout = layoutGraph([node('a'), node('b')], []); - const first = layout.nodes.get('a')!; - const second = layout.nodes.get('b')!; + const layout = layoutGraph( + [node('a'), node('b'), node('c')], + [ + { from: 'a', to: 'b' }, + { from: 'a', to: 'c' }, + ], + ); + const first = layout.nodes.get('b')!; + const second = layout.nodes.get('c')!; expect(Math.abs(second.y - first.y)).toBeGreaterThanOrEqual(NODE_HEIGHT); }); - it('puts an effect that subscribes to nothing before the signals', () => { + it('puts a node with no edges in the grid under the bands', () => { const layout = layoutGraph([node('a'), effect('lonely'), node('b')], [{ from: 'a', to: 'b' }]); - expect(layout.nodes.get('lonely')!.layer).toBe(0); - expect(layout.nodes.get('a')!.layer).toBe(1); - expect(layout.nodes.get('b')!.layer).toBe(2); + expect(layout.nodes.get('a')!.layer).toBe(0); + expect(layout.nodes.get('b')!.layer).toBe(1); + expect(layout.nodes.get('lonely')!.y).toBeGreaterThan(layout.nodes.get('b')!.y + NODE_HEIGHT); }); it('leaves an effect with sources where its sources put it', () => { @@ -77,10 +83,93 @@ describe('layoutGraph', () => { expect(layout.nodes.get('watcher')!.layer).toBe(1); }); - it('keeps one column when every node is a detached effect', () => { - const layout = layoutGraph([effect('one'), effect('two')], []); + it('spreads nodes with no edges over rows of at least four columns', () => { + const layout = layoutGraph( + ['one', 'two', 'three', 'four', 'five'].map((id) => effect(id)), + [], + ); - expect(layout.layers).toBe(1); + expect(layout.layers).toBe(4); + expect(layout.nodes.get('four')!.layer).toBe(3); + expect(layout.nodes.get('five')!.layer).toBe(0); + expect(layout.nodes.get('five')!.index).toBe(1); + }); + + it('gives each group of connected nodes a band of its own', () => { + const layout = layoutGraph( + [node('a'), node('b'), node('c'), node('d')], + [ + { from: 'a', to: 'b' }, + { from: 'c', to: 'd' }, + ], + ); + const firstBandBottom = Math.max(layout.nodes.get('a')!.y, layout.nodes.get('b')!.y); + + expect(layout.nodes.get('c')!.y).toBeGreaterThan(firstBandBottom + NODE_HEIGHT); + expect(layout.nodes.get('d')!.y).toBe(layout.nodes.get('c')!.y); + }); + + it('routes an edge that skips a column through a waypoint beside the nodes', () => { + const layout = layoutGraph( + [node('a'), node('b'), node('c')], + [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'c' }, + { from: 'a', to: 'c' }, + ], + ); + const route = layout.routes.get('a>c')!; + + expect(route).toHaveLength(1); + expect(route[0]!.layer).toBe(1); + expect(route[0]!.y).not.toBe(layout.nodes.get('b')!.y); + }); + + it('moves a node with no sources to the column before its nearest reader', () => { + const layout = layoutGraph( + [node('a'), node('b'), node('c'), node('late')], + [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'c' }, + { from: 'late', to: 'c' }, + ], + ); + + expect(layout.nodes.get('late')!.layer).toBe(1); + expect(layout.routes.has('late>c')).toBe(false); + }); + + it('orders rows so edges do not cross', () => { + const layout = layoutGraph( + [node('a'), node('b'), node('c'), node('d')], + [ + { from: 'a', to: 'd' }, + { from: 'b', to: 'c' }, + { from: 'a', to: 'c' }, + ], + ); + + expect(layout.nodes.get('a')!.index).toBe(layout.nodes.get('d')!.index); + }); + + it('keeps the arrangement of a band whose structure did not change', () => { + const nodes = [node('a'), node('b'), node('c'), node('d')]; + const edges = [ + { from: 'a', to: 'd' }, + { from: 'b', to: 'c' }, + { from: 'a', to: 'c' }, + ]; + const first = layoutGraph(nodes, edges); + const second = layoutGraph( + [...nodes, node('other'), node('next')], + [...edges, { from: 'other', to: 'next' }], + first, + ); + + for (const id of ['a', 'b', 'c', 'd']) { + expect(second.nodes.get(id)).toEqual(first.nodes.get(id)); + } + expect([...second.bands.values()]).toContain([...first.bands.values()][0]); }); it('keeps a node in its slot when another node appears', () => { @@ -89,15 +178,15 @@ describe('layoutGraph', () => { expect(second.nodes.get('a')!.y).toBe(first.nodes.get('a')!.y); expect(second.nodes.get('b')!.y).toBe(first.nodes.get('b')!.y); - expect(second.nodes.get('c')!.index).toBe(2); + expect(second.nodes.get('c')!.layer).toBe(2); }); it('keeps the previous order when the caller passes the nodes in another order', () => { const first = layoutGraph([node('a'), node('b')], []); const second = layoutGraph([node('b'), node('a')], [], first); - expect(second.nodes.get('a')!.index).toBe(0); - expect(second.nodes.get('b')!.index).toBe(1); + expect(second.nodes.get('a')!.layer).toBe(0); + expect(second.nodes.get('b')!.layer).toBe(1); }); it('does not move one column when another column grows', () => { @@ -146,6 +235,17 @@ describe('edgePath', () => { }); }); +describe('edgePath through waypoints', () => { + it('runs straight through each waypoint', () => { + const from = { id: 'a', layer: 0, index: 0, x: 0, y: 0 }; + const via = [{ id: 'w', layer: 1, index: 1, x: 226, y: 58 }]; + const to = { id: 'c', layer: 2, index: 0, x: 452, y: 0 }; + const middle = 58 + NODE_HEIGHT / 2; + + expect(edgePath(from, to, via)).toContain(`226 ${middle} L ${226 + NODE_WIDTH} ${middle}`); + }); +}); + describe('placeHoverCard', () => { const canvas = { width: 800, height: 600 }; const card = { width: 288, height: 150 }; diff --git a/src/dev-toolbar/reactivity/layout.ts b/src/dev-toolbar/reactivity/layout.ts index ff5d78c..9a3f0d3 100644 --- a/src/dev-toolbar/reactivity/layout.ts +++ b/src/dev-toolbar/reactivity/layout.ts @@ -4,45 +4,110 @@ export const NODE_WIDTH = 150; export const NODE_HEIGHT = 44; export const LAYER_GAP = 76; export const ROW_GAP = 14; +export const BAND_GAP = 40; export const PADDING = 24; export const ARROW_SIZE = 8; +/** Fewest columns the grid of unconnected nodes spreads over. */ +const MIN_GRID_COLUMNS = 4; +/** + * Rank of the first node the previous layout did not have. New nodes rank + * after every known one, in the order the caller passed them. + */ +const NEW_NODE = 1e12; +/** Crossing sweeps run per band. The best order seen wins. */ +const SWEEPS = 6; + export interface LayoutInput { id: string; name: string; kind: ReactiveNodeKind; } -const EFFECT_KINDS = new Set(['effect', 'render-effect', 'tracked-effect']); - export interface LayoutNode { id: string; + /** Column inside the node's band, or inside the grid. */ layer: number; + /** Row inside that column. */ index: number; x: number; y: number; } +/** Arrangement of one band, in columns and rows, before it gets pixels. */ +interface BandLayout { + /** Node ids per column, top to bottom. Includes the waypoints of long edges. */ + columns: string[][]; + /** Waypoint ids of each edge that skips columns, keyed `from>to`. */ + routes: Map; +} + export interface GraphLayout { nodes: Map; + /** Waypoints, left to right, of each edge that skips columns, keyed `from>to`. */ + routes: Map; width: number; height: number; layers: number; + /** Arrangement of each band, keyed by the band's nodes and edges. */ + bands: Map; } -/** Sources of each node, ignoring edges that point outside the given nodes. */ -function incomingEdges(nodes: LayoutInput[], edges: ReactiveEdge[]): Map { - const incoming = new Map(); - for (const node of nodes) incoming.set(node.id, []); +export function edgeKey(edge: ReactiveEdge): string { + return `${edge.from}>${edge.to}`; +} + +function waypointId(edge: ReactiveEdge, layer: number): string { + return `\0${edgeKey(edge)}#${layer}`; +} + +/** Groups node ids into sets that edges connect, in any direction. */ +function connectedGroups(ids: string[], edges: ReactiveEdge[]): string[][] { + const parent = new Map(); + for (const id of ids) parent.set(id, id); + + function rootOf(id: string): string { + let root = id; + while (parent.get(root) !== root) root = parent.get(root)!; + // Point every node on the way straight at the root. + let current = id; + while (current !== root) { + const next = parent.get(current)!; + parent.set(current, root); + current = next; + } + return root; + } + for (const edge of edges) { - if (!incoming.has(edge.from) || !incoming.has(edge.to)) continue; - incoming.get(edge.to)!.push(edge.from); + const a = rootOf(edge.from); + const b = rootOf(edge.to); + if (a !== b) parent.set(a, b); } - return incoming; + + const groups = new Map(); + for (const id of ids) { + const root = rootOf(id); + const group = groups.get(root); + if (group) group.push(id); + else groups.set(root, [id]); + } + return [...groups.values()]; } /** Layer of each node, measured as the longest path from a node with no sources. */ -function assignLayers(nodes: LayoutInput[], incoming: Map): Map { +function assignLayers(ids: string[], edges: ReactiveEdge[]): Map { + const incoming = new Map(); + const outgoing = new Map(); + for (const id of ids) { + incoming.set(id, []); + outgoing.set(id, []); + } + for (const edge of edges) { + incoming.get(edge.to)!.push(edge.from); + outgoing.get(edge.from)!.push(edge.to); + } + const layers = new Map(); const visiting = new Set(); @@ -53,7 +118,7 @@ function assignLayers(nodes: LayoutInput[], incoming: Map): Ma if (visiting.has(id)) return 0; visiting.add(id); let layer = 0; - for (const source of incoming.get(id) ?? []) { + for (const source of incoming.get(id)!) { layer = Math.max(layer, layerOf(source) + 1); } visiting.delete(id); @@ -61,160 +126,326 @@ function assignLayers(nodes: LayoutInput[], incoming: Map): Ma return layer; } - for (const node of nodes) layerOf(node.id); + for (const id of ids) layerOf(id); + + // A signal read only by a deep node would sit in the first column and send + // one long edge across the band. Move each node with no sources to the + // column right before its nearest reader. + for (const id of ids) { + if (incoming.get(id)!.length > 0) continue; + let nearest = Infinity; + for (const target of outgoing.get(id)!) { + const layer = layers.get(target)!; + if (layer > layers.get(id)!) nearest = Math.min(nearest, layer); + } + if (nearest !== Infinity) layers.set(id, nearest - 1); + } + return layers; } +/** Crossings between the edges of two neighbouring columns. */ +function countCrossings(segments: [number, number][]): number { + let crossings = 0; + for (let i = 0; i < segments.length; i++) { + const [a1, b1] = segments[i]!; + for (let j = i + 1; j < segments.length; j++) { + const [a2, b2] = segments[j]!; + if ((a1 - a2) * (b1 - b2) < 0) crossings++; + } + } + return crossings; +} + /** - * Orders nodes inside each layer so edges cross as little as possible. Each - * sweep moves a node towards the average position of its neighbours in the - * previous layer. Ties keep the order the node had in the last layout, so a - * node that gained no neighbours stays where the reader last saw it. + * Arranges one band. Edges that skip columns get a waypoint in each column + * they pass, so they run in a lane of their own instead of through nodes. + * Rows are then ordered to cross as few edges as possible. */ -function orderLayers( - columns: string[][], +function arrangeBand( + ids: string[], edges: ReactiveEdge[], - layers: Map, rank: (id: string) => number, -): void { +): BandLayout { + const layers = assignLayers(ids, edges); + const columnCount = Math.max(...layers.values()) + 1; + const columns: string[][] = Array.from({ length: columnCount }, () => []); + const order = new Map(); + for (const id of ids) { + columns[layers.get(id)!]!.push(id); + order.set(id, rank(id)); + } + + // Each segment joins two neighbouring columns. + const segments: { from: string; to: string }[] = []; + const routes = new Map(); + for (const edge of edges) { + const start = layers.get(edge.from)!; + const end = layers.get(edge.to)!; + // Edges that point back or stay in one column close a cycle. They are drawn + // straight and take no part in the order. + if (end <= start) continue; + let previous = edge.from; + const route: string[] = []; + for (let layer = start + 1; layer < end; layer++) { + const id = waypointId(edge, layer); + columns[layer]!.push(id); + order.set(id, rank(edge.from)); + route.push(id); + segments.push({ from: previous, to: id }); + previous = id; + } + segments.push({ from: previous, to: edge.to }); + if (route.length > 0) routes.set(edgeKey(edge), route); + } + + // Start from the order the reader saw last, so ties keep it. + for (const column of columns) { + column.sort((a, b) => order.get(a)! - order.get(b)! || (a < b ? -1 : a > b ? 1 : 0)); + } + const sourcesOf = new Map(); const targetsOf = new Map(); - for (const edge of edges) { - if (!layers.has(edge.from) || !layers.has(edge.to)) continue; - (targetsOf.get(edge.from) ?? targetsOf.set(edge.from, []).get(edge.from)!).push(edge.to); - (sourcesOf.get(edge.to) ?? sourcesOf.set(edge.to, []).get(edge.to)!).push(edge.from); + for (const segment of segments) { + (targetsOf.get(segment.from) ?? targetsOf.set(segment.from, []).get(segment.from)!).push( + segment.to, + ); + (sourcesOf.get(segment.to) ?? sourcesOf.set(segment.to, []).get(segment.to)!).push( + segment.from, + ); } const positions = new Map(); - for (const column of columns) { - column.forEach((id, index) => positions.set(id, index)); + function recordPositions(): void { + for (const column of columns) { + for (let index = 0; index < column.length; index++) positions.set(column[index]!, index); + } + } + + function totalCrossings(): number { + let total = 0; + for (let layer = 0; layer + 1 < columns.length; layer++) { + const pairs: [number, number][] = []; + for (const id of columns[layer]!) { + for (const target of targetsOf.get(id) ?? []) { + pairs.push([positions.get(id)!, positions.get(target)!]); + } + } + total += countCrossings(pairs); + } + return total; } - function sweep(neighbours: Map, order: number[]): void { - for (const columnIndex of order) { - const column = columns[columnIndex]!; + // Moves each node to the average row of its neighbours in the column swept from. + function sweep(neighbours: Map, layerOrder: number[]): void { + for (const layer of layerOrder) { + const column = columns[layer]!; const scores = new Map(); - column.forEach((id, index) => { + for (let index = 0; index < column.length; index++) { + const id = column[index]!; const related = neighbours.get(id) ?? []; if (related.length === 0) { scores.set(id, index); - return; + continue; } let total = 0; - for (const other of related) total += positions.get(other) ?? 0; + for (const other of related) total += positions.get(other)!; scores.set(id, total / related.length); - }); + } column.sort( - (a, b) => scores.get(a)! - scores.get(b)! || rank(a) - rank(b) || a.localeCompare(b), + (a, b) => scores.get(a)! - scores.get(b)! || positions.get(a)! - positions.get(b)!, ); - column.forEach((id, index) => positions.set(id, index)); + for (let index = 0; index < column.length; index++) positions.set(column[index]!, index); } } - const down = columns.map((_, index) => index); - const up = [...down].reverse(); - for (let pass = 0; pass < 2; pass++) { + recordPositions(); + let best = columns.map((column) => [...column]); + let fewest = totalCrossings(); + const down: number[] = []; + for (let layer = 1; layer < columns.length; layer++) down.push(layer); + const up: number[] = []; + for (let layer = columns.length - 2; layer >= 0; layer--) up.push(layer); + + for (let pass = 0; pass < SWEEPS && fewest > 0; pass++) { sweep(sourcesOf, down); sweep(targetsOf, up); + const crossings = totalCrossings(); + if (crossings < fewest) { + fewest = crossings; + best = columns.map((column) => [...column]); + } } -} -/** - * Restores the order known nodes had, keeping new nodes at the position the - * crossing sweeps chose for them. - */ -function keepKnownOrder(column: string[], rank: (id: string) => number, newNode: number): string[] { - const swept = new Map(column.map((id, index) => [id, index])); - const known = column.filter((id) => rank(id) !== newNode).sort((a, b) => rank(a) - rank(b)); - const fresh = column.filter((id) => rank(id) === newNode); - - const out = [...known]; - for (const id of fresh) { - const target = swept.get(id)!; - let index = 0; - while (index < out.length && swept.get(out[index]!)! < target) index++; - out.splice(index, 0, id); + // Averages leave pairs that a swap would untangle. Swapping two neighbours + // only changes the crossings between their own edges, so compare those. + function crossingsBetween(upper: string, lower: string): number { + let crossings = 0; + for (const neighbours of [sourcesOf, targetsOf]) { + for (const u of neighbours.get(upper) ?? []) { + for (const v of neighbours.get(lower) ?? []) { + if (positions.get(u)! > positions.get(v)!) crossings++; + } + } + } + return crossings; + } + + for (let layer = 0; layer < best.length; layer++) columns[layer] = best[layer]!; + recordPositions(); + let improved = fewest > 0; + for (let round = 0; improved && round < SWEEPS; round++) { + improved = false; + for (const column of columns) { + for (let index = 0; index + 1 < column.length; index++) { + const a = column[index]!; + const b = column[index + 1]!; + if (crossingsBetween(b, a) >= crossingsBetween(a, b)) continue; + column[index] = b; + column[index + 1] = a; + positions.set(a, index + 1); + positions.set(b, index); + improved = true; + } + } } - return out; + + return { columns, routes }; +} + +/** Identity of a band's structure. Equal keys give equal arrangements. */ +function bandKey(ids: string[], edges: ReactiveEdge[]): string { + const keys: string[] = []; + for (const edge of edges) keys.push(edgeKey(edge)); + return `${[...ids].sort().join('|')}\n${keys.sort().join('|')}`; } /** - * Places nodes on a left to right grid, one column per layer. + * Places nodes in bands, one per group of connected nodes, stacked top to + * bottom. Inside a band nodes run left to right, one column per layer. Nodes + * with no edges at all share a grid under the bands. * - * Pass the layout this one replaces to keep the result stable: nodes hold the - * slot they had, and new ones land after them. Without it the graph reshuffles - * on every snapshot, which is the one thing a live view must not do. + * Pass the layout this one replaces to keep the result stable. A band whose + * nodes and edges did not change keeps its arrangement, and bands keep their + * order. Only a band whose structure changed is arranged again, so it can + * shed the crossings it gained instead of piling them up. */ export function layoutGraph( nodes: LayoutInput[], edges: ReactiveEdge[], previous?: GraphLayout, ): GraphLayout { - const incoming = incomingEdges(nodes, edges); - const layers = assignLayers(nodes, incoming); - - // An effect that subscribes to nothing reads nothing, so it does not belong - // in the column the signals start from. It gets a column of its own before - // them, which keeps the first signal column about sources of data. - const detached = nodes.filter( - (node) => EFFECT_KINDS.has(node.kind) && incoming.get(node.id)!.length === 0, - ); - if (detached.length > 0) { - for (const [id, layer] of layers) layers.set(id, layer + 1); - for (const node of detached) layers.set(node.id, 0); + const ids: string[] = []; + const known = new Set(); + for (const node of nodes) { + if (known.has(node.id)) continue; + known.add(node.id); + ids.push(node.id); } - const columnCount = nodes.length === 0 ? 0 : Math.max(...layers.values()) + 1; - const columns: string[][] = Array.from({ length: columnCount }, () => []); - for (const node of nodes) columns[layers.get(node.id)!]!.push(node.id); + // Edges to filtered nodes, self loops and repeats draw nothing useful. + const kept: ReactiveEdge[] = []; + const seen = new Set(); + for (const edge of edges) { + if (edge.from === edge.to || !known.has(edge.from) || !known.has(edge.to)) continue; + const key = edgeKey(edge); + if (seen.has(key)) continue; + seen.add(key); + kept.push(edge); + } - // A node the last layout did not have sorts after the ones it did. - const NEW_NODE = Number.MAX_SAFE_INTEGER; - const previousIndex = new Map(); + // The reading order of the last layout, top to bottom, then left to right. + const previousRank = new Map(); if (previous) { - for (const [id, node] of previous.nodes) previousIndex.set(id, node.index); + const placed = [...previous.nodes.values()].sort((a, b) => a.y - b.y || a.x - b.x); + for (let index = 0; index < placed.length; index++) previousRank.set(placed[index]!.id, index); } - const rank = (id: string) => previousIndex.get(id) ?? NEW_NODE; - - for (const column of columns) { - column.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); + const inputOrder = new Map(); + for (let index = 0; index < ids.length; index++) inputOrder.set(ids[index]!, index); + const rank = (id: string) => previousRank.get(id) ?? NEW_NODE + inputOrder.get(id)!; + const byRank = (a: string, b: string) => rank(a) - rank(b); + + const groupOf = new Map(); + const groups = connectedGroups(ids, kept); + for (let index = 0; index < groups.length; index++) { + for (const id of groups[index]!) groupOf.set(id, index); } + const groupEdges: ReactiveEdge[][] = groups.map(() => []); + for (const edge of kept) groupEdges[groupOf.get(edge.from)!]!.push(edge); + + const bands: { ids: string[]; edges: ReactiveEdge[]; first: number }[] = []; + const loose: string[] = []; + for (let index = 0; index < groups.length; index++) { + const group = groups[index]!; + if (group.length === 1) { + loose.push(group[0]!); + continue; + } + let first = Infinity; + for (const id of group) first = Math.min(first, rank(id)); + bands.push({ ids: group, edges: groupEdges[index]!, first }); + } + // Bands the reader saw keep their order. New ones go under them. + bands.sort((a, b) => a.first - b.first); + loose.sort(byRank); - orderLayers(columns, edges, layers, rank); - - // The sweeps are free to reorder anything, which would move nodes the reader - // is looking at. Keep the order known nodes already had, and slot the new - // ones in where the sweeps put them. - columns.forEach((column, index) => { - columns[index] = keepKnownOrder(column, rank, NEW_NODE); - }); + const placed = new Map(); + const routes = new Map(); + const arrangements = new Map(); + const columnX = (layer: number) => PADDING + layer * (NODE_WIDTH + LAYER_GAP); + const rowY = (top: number, index: number) => top + index * (NODE_HEIGHT + ROW_GAP); + + let top = PADDING; + let widest = 0; + for (const band of bands) { + const key = bandKey(band.ids, band.edges); + const arrangement = previous?.bands.get(key) ?? arrangeBand(band.ids, band.edges, rank); + arrangements.set(key, arrangement); + + const waypoints = new Map(); + let tallest = 0; + for (let layer = 0; layer < arrangement.columns.length; layer++) { + const column = arrangement.columns[layer]!; + tallest = Math.max(tallest, column.length); + for (let index = 0; index < column.length; index++) { + const id = column[index]!; + const position = { id, layer, index, x: columnX(layer), y: rowY(top, index) }; + if (id.startsWith('\0')) waypoints.set(id, position); + else placed.set(id, position); + } + } + for (const [edge, route] of arrangement.routes) { + const points: LayoutNode[] = []; + for (const id of route) points.push(waypoints.get(id)!); + routes.set(edge, points); + } - const tallest = columns.reduce((max, column) => Math.max(max, column.length), 0); - const contentHeight = tallest * NODE_HEIGHT + Math.max(tallest - 1, 0) * ROW_GAP; + widest = Math.max(widest, arrangement.columns.length); + top = rowY(top, tallest) - ROW_GAP + BAND_GAP; + } - // Columns hang from the top. Centring them would move every column whenever - // one of them grew. - const placed = new Map(); - columns.forEach((column, layer) => { - column.forEach((id, index) => { - placed.set(id, { - id, - layer, - index, - x: PADDING + layer * (NODE_WIDTH + LAYER_GAP), - y: PADDING + index * (NODE_HEIGHT + ROW_GAP), - }); - }); - }); - - const width = - columnCount === 0 ? 0 : PADDING * 2 + columnCount * NODE_WIDTH + (columnCount - 1) * LAYER_GAP; + if (loose.length > 0) { + // As wide as the widest band, so the grid does not add width of its own. + const gridColumns = Math.max(widest, MIN_GRID_COLUMNS); + for (let index = 0; index < loose.length; index++) { + const id = loose[index]!; + const layer = index % gridColumns; + const row = Math.floor(index / gridColumns); + placed.set(id, { id, layer, index: row, x: columnX(layer), y: rowY(top, row) }); + } + widest = Math.max(widest, Math.min(gridColumns, loose.length)); + const rows = Math.ceil(loose.length / gridColumns); + top = rowY(top, rows) - ROW_GAP + BAND_GAP; + } + const empty = ids.length === 0; return { nodes: placed, - width, - height: contentHeight + PADDING * 2, - layers: columnCount, + routes, + width: empty ? 0 : PADDING * 2 + widest * NODE_WIDTH + (widest - 1) * LAYER_GAP, + height: empty ? 0 : top - BAND_GAP + PADDING, + layers: widest, + bands: arrangements, }; } @@ -267,12 +498,24 @@ export function placeHoverCard(input: HoverCardInput): HoverCardPlacement { }; } -/** Curve from the right edge of one node to the left edge of another. */ -export function edgePath(from: LayoutNode, to: LayoutNode): string { - const startX = from.x + NODE_WIDTH; - const startY = from.y + NODE_HEIGHT / 2; - const endX = to.x - ARROW_SIZE; - const endY = to.y + NODE_HEIGHT / 2; +function curve(startX: number, startY: number, endX: number, endY: number): string { const distance = Math.max(Math.abs(endX - startX) * 0.5, 40); - return `M ${startX} ${startY} C ${startX + distance} ${startY}, ${endX - distance} ${endY}, ${endX} ${endY}`; + return `C ${startX + distance} ${startY}, ${endX - distance} ${endY}, ${endX} ${endY}`; +} + +/** + * Curve from the right edge of one node to the left edge of another. An edge + * that skips columns runs straight through each of its waypoints. + */ +export function edgePath(from: LayoutNode, to: LayoutNode, via: LayoutNode[] = []): string { + let x = from.x + NODE_WIDTH; + let y = from.y + NODE_HEIGHT / 2; + let path = `M ${x} ${y}`; + for (const point of via) { + const middle = point.y + NODE_HEIGHT / 2; + path += ` ${curve(x, y, point.x, middle)} L ${point.x + NODE_WIDTH} ${middle}`; + x = point.x + NODE_WIDTH; + y = middle; + } + return `${path} ${curve(x, y, to.x - ARROW_SIZE, to.y + NODE_HEIGHT / 2)}`; } From df7c010eb6b5552c1d7bfc5b2a552c5408906d2c Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 10 Nov 2026 18:23:12 +0800 Subject: [PATCH 2/2] test(reactivity): check the band layout in the graph e2e test The test expected effects with no sources in a column of their own before the signals. The band layout puts a node with no sources in the column before its nearest reader, and nodes with no edges in a grid under the bands, so that column no longer exists. The test now checks that the count, doubled and report-doubled chain runs left to right, one column per step. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/devtools.spec.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index df7b925..3feba8f 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -359,20 +359,18 @@ test('maps the reactivity graph', async ({ page }) => { await expect(nodes.filter({ hasText: /^doubled/ }).first()).toBeVisible(); await expect(nodes.filter({ hasText: /^report-doubled/ }).first()).toBeVisible(); - // Effects that subscribe to nothing sit in their own column before the signals. - const columns = await nodes.evaluateAll((elements) => - elements.map((element) => ({ - x: Math.round(element.getBoundingClientRect().x), - kind: (element as HTMLElement).dataset.kind, - })), - ); - const leftmost = Math.min(...columns.map((entry) => entry.x)); - expect( - columns - .filter((entry) => entry.x === leftmost) - .every((entry) => entry.kind?.includes('effect')), - ).toBe(true); - expect(columns.some((entry) => entry.kind === 'signal' && entry.x > leftmost)).toBe(true); + // A chain runs left to right, one column per step. + const countBox = (await count.boundingBox())!; + const doubledBox = (await nodes + .filter({ hasText: /^doubled/ }) + .first() + .boundingBox())!; + const reportBox = (await nodes + .filter({ hasText: /^report-doubled/ }) + .first() + .boundingBox())!; + expect(countBox.x).toBeLessThan(doubledBox.x); + expect(Math.round(doubledBox.x - countBox.x)).toBe(Math.round(reportBox.x - doubledBox.x)); // Hovering a node explains it without selecting it. await count.hover();