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)}`; } 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();