diff --git a/ui/src/lib/__tests__/dagLayout.test.ts b/ui/src/lib/__tests__/dagLayout.test.ts index a7ea114..655e05f 100644 --- a/ui/src/lib/__tests__/dagLayout.test.ts +++ b/ui/src/lib/__tests__/dagLayout.test.ts @@ -1,132 +1,13 @@ import { describe, expect, it } from 'vitest' import { - GAP_X, - NODE_W, compareIds, - edgePath, edgeWidth, - fitTransform, - graphSetHash, - layoutGraph, neighborsOf, - walkFrom, - zoomAt, type GraphEdgeRef, } from '../dagLayout' const E = (source: string, target: string): GraphEdgeRef => ({ source, target }) -describe('layoutGraph — layering', () => { - it('lays a chain into successive layers', () => { - const layout = layoutGraph(['a', 'b', 'c'], [E('a', 'b'), E('b', 'c')]) - expect(layout.layers).toEqual([['a'], ['b'], ['c']]) - }) - - it('uses longest-path layering (skip edge does not pull the node left)', () => { - const layout = layoutGraph( - ['a', 'b', 'c'], - [E('a', 'b'), E('b', 'c'), E('a', 'c')], - ) - expect(layout.nodes.get('c')?.layer).toBe(2) - }) - - it('places diamond join below both branches', () => { - const layout = layoutGraph( - ['a', 'b', 'c', 'd'], - [E('a', 'b'), E('a', 'c'), E('b', 'd'), E('c', 'd')], - ) - expect(layout.nodes.get('d')?.layer).toBe(2) - expect(layout.layers[1]).toHaveLength(2) - }) - - it('terminates on cycles and keeps every node', () => { - const layout = layoutGraph(['a', 'b'], [E('a', 'b'), E('b', 'a')]) - expect(layout.nodes.size).toBe(2) - expect(layout.nodes.get('a')?.layer).toBe(0) - expect(layout.nodes.get('b')?.layer).toBe(1) - }) - - it('handles a fully cyclic graph (no natural roots)', () => { - const layout = layoutGraph( - ['x', 'y', 'z'], - [E('x', 'y'), E('y', 'z'), E('z', 'x')], - ) - expect(layout.nodes.size).toBe(3) - expect(layout.layers.flat().sort((a, b) => a.localeCompare(b))).toEqual(['x', 'y', 'z']) - }) - - it('drops self-loops and edges to unknown nodes', () => { - const layout = layoutGraph(['a', 'b'], [E('a', 'a'), E('a', 'ghost'), E('a', 'b')]) - expect(layout.nodes.get('b')?.layer).toBe(1) - }) - - it('places disconnected nodes in layer 0', () => { - const layout = layoutGraph(['a', 'b', 'lonely'], [E('a', 'b')]) - expect(layout.nodes.get('lonely')?.layer).toBe(0) - }) - - it('handles the empty graph', () => { - const layout = layoutGraph([], []) - expect(layout.layers).toEqual([]) - expect(layout.width).toBe(0) - expect(layout.height).toBe(0) - }) -}) - -describe('layoutGraph — barycenter ordering', () => { - it('orders a layer by mean predecessor position (one pass)', () => { - // layer0 = [a, b] (alphabetical); y's caller is a (row 0), x's is b (row 1) - // → barycenter pass must flip the alphabetical [x, y] into [y, x]. - const layout = layoutGraph( - ['a', 'b', 'x', 'y'], - [E('a', 'y'), E('b', 'x')], - ) - expect(layout.layers[1]).toEqual(['y', 'x']) - }) - - it('is deterministic regardless of input order', () => { - const nodes = ['gw', 'auth', 'pay', 'db', 'cache'] - const edges = [E('gw', 'auth'), E('gw', 'pay'), E('pay', 'db'), E('auth', 'cache'), E('pay', 'cache')] - const a = layoutGraph(nodes, edges) - const b = layoutGraph([...nodes].reverse(), [...edges].reverse()) - expect(a.layers).toEqual(b.layers) - expect([...a.nodes.entries()]).toEqual([...b.nodes.entries()]) - }) -}) - -describe('layoutGraph — positions', () => { - it('spaces layers along x and centers short layers vertically', () => { - const layout = layoutGraph( - ['a', 'b', 'c', 'd'], - [E('a', 'b'), E('a', 'c'), E('b', 'd'), E('c', 'd')], - ) - const a = layout.nodes.get('a')! - const b = layout.nodes.get('b')! - const d = layout.nodes.get('d')! - expect(b.x - a.x).toBe(NODE_W + GAP_X) - expect(d.x - a.x).toBe(2 * (NODE_W + GAP_X)) - // single-node layers (a, d) sit at the vertical center of the 2-row layer - expect(a.y).toBeGreaterThan(0) - expect(a.y).toBe(d.y) - expect(layout.width).toBeGreaterThan(0) - expect(layout.height).toBeGreaterThan(0) - }) -}) - -describe('graphSetHash', () => { - it('is order-insensitive over nodes and edges', () => { - const h1 = graphSetHash(['a', 'b'], [E('a', 'b')]) - const h2 = graphSetHash(['b', 'a'], [E('a', 'b')]) - expect(h1).toBe(h2) - }) - - it('changes when the node or edge set changes', () => { - const base = graphSetHash(['a', 'b'], [E('a', 'b')]) - expect(graphSetHash(['a', 'b', 'c'], [E('a', 'b')])).not.toBe(base) - expect(graphSetHash(['a', 'b'], [])).not.toBe(base) - }) -}) - describe('edgeWidth', () => { it('grows with log of call count and clamps to [1, 5]', () => { expect(edgeWidth(0)).toBe(1) @@ -135,100 +16,7 @@ describe('edgeWidth', () => { }) }) -describe('fitTransform / zoomAt', () => { - it('fits content into the viewport with padding', () => { - const t = fitTransform({ width: 1000, height: 500 }, { width: 520, height: 270 }, 10) - expect(t.k).toBeCloseTo(0.5, 5) - // content centered: x offset = (520 - 1000*0.5)/2 = 10 - expect(t.x).toBeCloseTo(10, 5) - expect(t.y).toBeCloseTo(10, 5) - }) - - it('never scales small graphs above 1', () => { - const t = fitTransform({ width: 100, height: 50 }, { width: 1000, height: 800 }) - expect(t.k).toBe(1) - }) - - it('handles empty content without dividing by zero', () => { - const t = fitTransform({ width: 0, height: 0 }, { width: 800, height: 600 }) - expect(t.k).toBe(1) - expect(Number.isFinite(t.x)).toBe(true) - }) - - it('floors the scale at minScale (small field on a narrow viewport)', () => { - // Contain-fit would shrink to ~0.18 on a phone-width viewport; the floor - // keeps it legible and the content then overflows, staying centered. - const t = fitTransform({ width: 1100, height: 1100 }, { width: 360, height: 640 }, 24, 0.5) - expect(t.k).toBe(0.5) - // Centered overflow: the field spills symmetrically (negative offset). - expect(t.x).toBeCloseTo((360 - 1100 * 0.5) / 2, 5) - }) - - it('minScale never upscales past the contain fit when content already fits', () => { - const t = fitTransform({ width: 100, height: 100 }, { width: 1000, height: 800 }, 24, 0.5) - expect(t.k).toBe(1) - }) - - it('zoomAt keeps the anchor point stationary', () => { - const t = { x: 20, y: 30, k: 1 } - const t2 = zoomAt(t, 100, 100, 1.5) - // world point under (100,100) before: (80, 70); after must map back to (100,100) - expect(80 * t2.k + t2.x).toBeCloseTo(100, 5) - expect(70 * t2.k + t2.y).toBeCloseTo(100, 5) - }) - - it('zoomAt clamps the scale range', () => { - const t = { x: 0, y: 0, k: 2.4 } - expect(zoomAt(t, 0, 0, 10).k).toBeLessThanOrEqual(2.5) - expect(zoomAt({ ...t, k: 0.25 }, 0, 0, 0.01).k).toBeGreaterThanOrEqual(0.2) - }) -}) - -describe('walkFrom', () => { - const nodes = ['gw', 'auth', 'pay', 'db'] - const edges = [E('gw', 'auth'), E('gw', 'pay'), E('pay', 'db')] - const layout = layoutGraph(nodes, edges) - - it('starts at the first node of the first layer when nothing is focused', () => { - expect(walkFrom(layout, edges, null, 'right')).toBe('gw') - }) - - it('walks right to a callee and left back to the caller', () => { - expect(walkFrom(layout, edges, 'pay', 'right')).toBe('db') - expect(walkFrom(layout, edges, 'pay', 'left')).toBe('gw') - }) - - it('walks up/down between layer siblings', () => { - const first = layout.layers[1][0] - const second = layout.layers[1][1] - expect(walkFrom(layout, edges, first, 'down')).toBe(second) - expect(walkFrom(layout, edges, second, 'up')).toBe(first) - }) - - it('returns null at boundaries', () => { - expect(walkFrom(layout, edges, 'gw', 'left')).toBeNull() - expect(walkFrom(layout, edges, 'db', 'right')).toBeNull() - expect(walkFrom(layout, edges, 'gw', 'up')).toBeNull() - }) - - it('returns null for an unknown focus id', () => { - expect(walkFrom(layout, edges, 'ghost', 'right')).toBeNull() - }) - - it('prefers the callee nearest in y when several exist', () => { - // gw has two callees: walkFrom picks the one closest to gw's own y. - const target = walkFrom(layout, edges, 'gw', 'right') - expect(['auth', 'pay']).toContain(target) - }) -}) - -describe('edgePath / neighborsOf', () => { - it('emits a cubic bezier from source right edge to target left edge', () => { - const layout = layoutGraph(['a', 'b'], [E('a', 'b')]) - const path = edgePath(layout.nodes.get('a')!, layout.nodes.get('b')!) - expect(path).toMatch(/^M [\d.-]+ [\d.-]+ C /) - }) - +describe('neighborsOf', () => { it('collects 1-hop neighbors in both directions', () => { const edges = [E('a', 'b'), E('c', 'a'), E('b', 'd')] expect(neighborsOf(edges, 'a')).toEqual(new Set(['b', 'c'])) diff --git a/ui/src/lib/__tests__/radialLayout.test.ts b/ui/src/lib/__tests__/radialLayout.test.ts index 212bd01..1c6bf9f 100644 --- a/ui/src/lib/__tests__/radialLayout.test.ts +++ b/ui/src/lib/__tests__/radialLayout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { layoutRadial, walkRadial } from '../radialLayout' +import { layoutRadial } from '../radialLayout' import { NODE_H, NODE_W, type GraphEdgeRef, type Layout } from '../dagLayout' const E = (source: string, target: string): GraphEdgeRef => ({ source, target }) @@ -192,102 +192,3 @@ describe('layoutRadial — determinism', () => { expect([...a.nodes.entries()]).toEqual([...b.nodes.entries()]) }) }) - -describe('walkRadial — null focus', () => { - const layout = layoutRadial( - ['core', 'a', 'b', 'c', 'd'], - [E('a', 'core'), E('b', 'core'), E('c', 'core')], - ) - - it("null focus + 'right' returns the most-critical node (ring 0, first)", () => { - const target = walkRadial(layout, null, 'right') - expect(target).toBe(layout.layers[0][0]) - expect(layout.nodes.get(target!)!.layer).toBe(0) - }) - - // Cold-start bootstrap: a null focus seeds the most-critical entry node for - // ANY arrow key (not just 'right'), so Tabbing into the field and pressing - // the intuitive ArrowDown is never keyboard-inert. The seed is deterministic. - it('null focus seeds the SAME ring-0 entry node in every direction', () => { - const seed = layout.layers[0][0] - for (const dir of ['left', 'right', 'up', 'down'] as const) { - const target = walkRadial(layout, null, dir) - expect(target).toBe(seed) - expect(layout.nodes.get(target!)!.layer).toBe(0) - } - }) -}) - -describe('walkRadial — unknown focus', () => { - const layout = layoutRadial(['a', 'b'], [E('a', 'b')]) - it('returns null for an unknown focus id in every direction', () => { - for (const dir of ['left', 'right', 'up', 'down'] as const) { - expect(walkRadial(layout, 'ghost', dir)).toBeNull() - } - }) -}) - -describe('walkRadial — angular movement (left/right)', () => { - // Several singletons land on the same outermost ring, spread by angle. - const ids = ['a', 'b', 'c', 'd', 'e', 'f'] - const layout = layoutRadial(ids, []) - - it('right then left returns to the start (inverse on the same ring)', () => { - // pick a node that is not at a ring boundary - const ring = layout.layers.find((r) => r.length >= 3)! - const start = ring[1] - const right = walkRadial(layout, start, 'right') - expect(right).toBe(ring[2]) - const back = walkRadial(layout, right, 'left') - expect(back).toBe(start) - }) - - it('returns null at the angular ring boundaries (no wraparound)', () => { - const ring = layout.layers.find((r) => r.length >= 2)! - expect(walkRadial(layout, ring[0], 'left')).toBeNull() - expect(walkRadial(layout, ring[ring.length - 1], 'right')).toBeNull() - }) - - it('returns null moving angularly on a single-node ring', () => { - const callers = Array.from({ length: 10 }, (_, i) => `c${i}`) - const l = layoutRadial(['solo', ...callers], callers.map((c) => E(c, 'solo'))) - // solo is alone on ring 0 - expect(l.layers[0]).toEqual(['solo']) - expect(walkRadial(l, 'solo', 'left')).toBeNull() - expect(walkRadial(l, 'solo', 'right')).toBeNull() - }) -}) - -describe('walkRadial — ring movement (up/down)', () => { - // hub on inner ring, callers on outer ring. - const callers = Array.from({ length: 8 }, (_, i) => `c${i}`) - const layout = layoutRadial(['hub', ...callers], callers.map((c) => E(c, 'hub'))) - - it('down moves outward to a higher ring index', () => { - const target = walkRadial(layout, 'hub', 'down') - expect(target).not.toBeNull() - expect(layout.nodes.get(target!)!.layer).toBeGreaterThan(layout.nodes.get('hub')!.layer) - }) - - it('up from the outermost-ring node reaches a lower ring index', () => { - const outer = callers.find((c) => layout.nodes.get(c)!.layer === layout.layers.length - 1)! - const target = walkRadial(layout, outer, 'up') - expect(target).not.toBeNull() - expect(layout.nodes.get(target!)!.layer).toBeLessThan(layout.nodes.get(outer)!.layer) - }) - - it('up from ring 0 returns null (innermost boundary)', () => { - expect(walkRadial(layout, 'hub', 'up')).toBeNull() - }) - - it('down from the outermost ring returns null', () => { - const outer = callers.find((c) => layout.nodes.get(c)!.layer === layout.layers.length - 1)! - expect(walkRadial(layout, outer, 'down')).toBeNull() - }) - - it('ring movement is deterministic (picks nearest by angle, compareIds tiebreak)', () => { - const a = walkRadial(layout, 'hub', 'down') - const b = walkRadial(layout, 'hub', 'down') - expect(a).toBe(b) - }) -}) diff --git a/ui/src/lib/dagLayout.ts b/ui/src/lib/dagLayout.ts index 1bc6659..39d66ac 100644 --- a/ui/src/lib/dagLayout.ts +++ b/ui/src/lib/dagLayout.ts @@ -1,16 +1,11 @@ -// Deterministic layered DAG layout for the service flow map. +// Shared primitives for the service flow map. // -// Replaces the cytoscape/COSE-Bilkent physics layout with ~150 lines of our -// own: identical input → identical output, every time. Pipeline: -// 1. sanitize edges (drop self-loops/unknown endpoints, dedupe) -// 2. break cycles with a DFS over sorted ids (back-edges dropped — only -// for layout; they still render) -// 3. longest-path layering from root callers -// 4. one stable barycenter pass for within-layer order -// 5. grid positions, layers centered vertically -// -// Pan/zoom math (fitTransform/zoomAt) and keyboard walking (walkFrom) live -// here too so they are unit-testable without a DOM. +// Node box dimensions, the deterministic id ordering, the shared Layout / +// LayoutNode shape (produced by radialLayout's layoutRadial), edge-stroke +// scaling and 1-hop neighbour lookup. The hand-rolled layered-DAG layout, +// pan/zoom math and keyboard-walk helpers that used to live here were removed +// when the map moved to React Flow (which owns layout interaction); only the +// primitives the map and radialLayout still consume remain. export interface GraphEdgeRef { source: string @@ -32,19 +27,9 @@ export interface Layout { height: number } -export interface Transform { - x: number - y: number - k: number -} - export const NODE_W = 168 export const NODE_H = 40 export const GAP_X = 56 -export const GAP_Y = 16 - -export const MIN_ZOOM = 0.2 -export const MAX_ZOOM = 2.5 /** * Locale-independent string compare (UTF-16 code units). The layout must be @@ -55,239 +40,12 @@ export function compareIds(a: string, b: string): number { return a > b ? 1 : 0 } -/** Order-insensitive identity of the node/edge SET (metrics excluded). */ -export function graphSetHash( - nodeIds: readonly string[], - edges: readonly GraphEdgeRef[], -): string { - const ns = [...nodeIds].sort(compareIds).join(',') - const es = edges - .map((e) => `${e.source}>${e.target}`) - .sort(compareIds) - .join(',') - return `${ns}|${es}` -} - -/** Keep edges whose endpoints both exist, no self-loops, deduped. Sorted. */ -function sanitizeEdges(ids: ReadonlySet, edges: readonly GraphEdgeRef[]): GraphEdgeRef[] { - const seen = new Set() - const out: GraphEdgeRef[] = [] - for (const e of edges) { - if (e.source === e.target || !ids.has(e.source) || !ids.has(e.target)) continue - const key = `${e.source}>${e.target}` - if (seen.has(key)) continue - seen.add(key) - out.push({ source: e.source, target: e.target }) - } - out.sort((a, b) => (a.source + a.target < b.source + b.target ? -1 : 1)) - return out -} - -/** Iterative DFS from one root: keeps forward edges, drops back-edges. */ -function dfsKeepForwardEdges( - root: string, - out: ReadonlyMap, - state: Map, - kept: GraphEdgeRef[], -): void { - const stack: Array<{ id: string; next: number }> = [{ id: root, next: 0 }] - state.set(root, 1) - while (stack.length > 0) { - const frame = stack[stack.length - 1] - const targets = out.get(frame.id) ?? [] - if (frame.next >= targets.length) { - state.set(frame.id, 2) - stack.pop() - continue - } - const target = targets[frame.next++] - const s = state.get(target) - if (s === 1) continue // back-edge → drop for layering - kept.push({ source: frame.id, target }) - if (s === undefined) { - state.set(target, 1) - stack.push({ id: target, next: 0 }) - } - } -} - -/** Drop back-edges via iterative DFS from sorted ids — deterministic. */ -function breakCycles(sortedIds: readonly string[], edges: readonly GraphEdgeRef[]): GraphEdgeRef[] { - const out = new Map() - for (const e of edges) { - const list = out.get(e.source) - if (list) list.push(e.target) - else out.set(e.source, [e.target]) - } - const state = new Map() // 1 = on stack, 2 = done - const kept: GraphEdgeRef[] = [] - for (const root of sortedIds) { - if (!state.has(root)) dfsKeepForwardEdges(root, out, state, kept) - } - return kept -} - -/** Longest path from root callers over the (acyclic) edge list. */ -function assignLayers(sortedIds: readonly string[], dag: readonly GraphEdgeRef[]): Map { - const layer = new Map() - for (const id of sortedIds) layer.set(id, 0) - // Relax in passes; the DAG's longest path bounds the pass count. - let changed = true - while (changed) { - changed = false - for (const e of dag) { - const next = (layer.get(e.source) ?? 0) + 1 - if (next > (layer.get(e.target) ?? 0)) { - layer.set(e.target, next) - changed = true - } - } - } - return layer -} - -/** One barycenter pass, left → right, stable on ties. */ -function orderLayers(layers: string[][], dag: readonly GraphEdgeRef[]): string[][] { - const preds = new Map() - for (const e of dag) { - const list = preds.get(e.target) - if (list) list.push(e.source) - else preds.set(e.target, [e.source]) - } - const row = new Map() - layers[0]?.forEach((id, i) => row.set(id, i)) - for (let k = 1; k < layers.length; k++) { - const scored = layers[k].map((id, i) => { - const ps = (preds.get(id) ?? []).map((p) => row.get(p)).filter((r): r is number => r !== undefined) - const bary = ps.length > 0 ? ps.reduce((a, b) => a + b, 0) / ps.length : i - return { id, bary, i } - }) - scored.sort((a, b) => a.bary - b.bary || a.i - b.i) - layers[k] = scored.map((s) => s.id) - layers[k].forEach((id, i) => row.set(id, i)) - } - return layers -} - -export function layoutGraph( - nodeIds: readonly string[], - edges: readonly GraphEdgeRef[], -): Layout { - const sortedIds = [...new Set(nodeIds)].sort(compareIds) - const idSet = new Set(sortedIds) - const clean = sanitizeEdges(idSet, edges) - const dag = breakCycles(sortedIds, clean) - const layerOf = assignLayers(sortedIds, dag) - - const layerCount = sortedIds.length === 0 ? 0 : Math.max(...layerOf.values()) + 1 - const layers: string[][] = Array.from({ length: layerCount }, () => []) - for (const id of sortedIds) layers[layerOf.get(id) ?? 0].push(id) - orderLayers(layers, dag) - - const maxRows = layers.reduce((m, l) => Math.max(m, l.length), 0) - const height = maxRows > 0 ? maxRows * (NODE_H + GAP_Y) - GAP_Y : 0 - const nodes = new Map() - layers.forEach((layerIds, layer) => { - const layerH = layerIds.length * (NODE_H + GAP_Y) - GAP_Y - const offsetY = (height - layerH) / 2 - layerIds.forEach((id, rowIdx) => { - nodes.set(id, { - id, - x: layer * (NODE_W + GAP_X), - y: offsetY + rowIdx * (NODE_H + GAP_Y), - layer, - row: rowIdx, - }) - }) - }) - const width = layerCount > 0 ? layerCount * (NODE_W + GAP_X) - GAP_X : 0 - return { nodes, layers, width, height } -} - /** Edge stroke width: log of call count, clamped to [1, 5]. */ export function edgeWidth(callCount: number): number { const w = 1 + 1.2 * Math.log10(1 + Math.max(0, callCount)) return Math.min(5, Math.max(1, w)) } -/** - * Transform that fits the content bbox into the viewport (never upscales). - * `minScale` floors the zoom: on a very narrow viewport (a phone) the contain - * scale can shrink a small field to illegible dust, so callers pass a floor to - * keep nodes readable — the content then overflows the constrained axis and is - * reached by pan/pinch. The result stays centered, so floored overflow spills - * symmetrically. Floor is ignored for content that genuinely needs to zoom out - * past it (callers gate the floor on a small node count). - */ -export function fitTransform( - content: { width: number; height: number }, - viewport: { width: number; height: number }, - padding = 24, - minScale = 0, -): Transform { - if (content.width <= 0 || content.height <= 0) return { x: padding, y: padding, k: 1 } - let k = Math.min( - 1, - (viewport.width - padding * 2) / content.width, - (viewport.height - padding * 2) / content.height, - ) - if (minScale > 0) k = Math.max(k, minScale) - return { - k, - x: (viewport.width - content.width * k) / 2, - y: (viewport.height - content.height * k) / 2, - } -} - -/** Zoom by `factor` keeping the viewport point (cx, cy) stationary. */ -export function zoomAt(t: Transform, cx: number, cy: number, factor: number): Transform { - const k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, t.k * factor)) - const ratio = k / t.k - return { - k, - x: cx - (cx - t.x) * ratio, - y: cy - (cy - t.y) * ratio, - } -} - -export type WalkDir = 'left' | 'right' | 'up' | 'down' - -/** Keyboard walk: ←/→ caller/callee (nearest in y), ↑/↓ layer siblings. */ -export function walkFrom( - layout: Layout, - edges: readonly GraphEdgeRef[], - current: string | null, - dir: WalkDir, -): string | null { - if (current === null || !layout.nodes.has(current)) { - return current === null ? (layout.layers[0]?.[0] ?? null) : null - } - const node = layout.nodes.get(current)! - if (dir === 'up' || dir === 'down') { - const siblings = layout.layers[node.layer] - return siblings[node.row + (dir === 'down' ? 1 : -1)] ?? null - } - const candidates = edges - .filter((e) => (dir === 'right' ? e.source === current : e.target === current)) - .map((e) => layout.nodes.get(dir === 'right' ? e.target : e.source)) - .filter((n): n is LayoutNode => n !== undefined) - if (candidates.length === 0) return null - candidates.sort( - (a, b) => Math.abs(a.y - node.y) - Math.abs(b.y - node.y) || (a.id < b.id ? -1 : 1), - ) - return candidates[0].id -} - -/** Cubic bezier from the source's right edge to the target's left edge. */ -export function edgePath(from: LayoutNode, to: LayoutNode): string { - const x1 = from.x + NODE_W - const y1 = from.y + NODE_H / 2 - const x2 = to.x - const y2 = to.y + NODE_H / 2 - const dx = Math.max(24, Math.abs(x2 - x1) / 2) - return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}` -} - /** 1-hop neighborhood (both directions) for selection dimming. */ export function neighborsOf(edges: readonly GraphEdgeRef[], id: string): Set { const out = new Set() diff --git a/ui/src/lib/radialLayout.ts b/ui/src/lib/radialLayout.ts index 28837e4..b06e680 100644 --- a/ui/src/lib/radialLayout.ts +++ b/ui/src/lib/radialLayout.ts @@ -1,7 +1,7 @@ // Deterministic phyllotaxis ("Sunflower Galaxy") layout for the service map. // -// Same `Layout` shape as dagLayout's layoutGraph (nodes / layers / width / -// height) so the renderer and pan/zoom math are shared. The mapping: +// Produces the shared `Layout` shape (nodes / layers / width / height) defined +// in dagLayout, consumed by the React Flow map renderer. The mapping: // // radius = criticality RANK — the most-critical node lands near center, the // least-critical at the rim. Ranked node i is placed on a Fermat/sunflower @@ -26,7 +26,6 @@ import { type GraphEdgeRef, type Layout, type LayoutNode, - type WalkDir, } from './dagLayout' // Criticality weights. distinctCallerIndegree dominates (blast potential), @@ -193,75 +192,3 @@ export function layoutRadial( return { nodes, layers, width: span, height: span } } - -/** - * Polar keyboard walk over a phyllotaxis Layout. - * left/right → angular neighbor in the same band (by spiral angle) - * up → band inward (smaller radius / lower band index) - * down → band outward (larger radius / higher band index) - * Returns null at boundaries or for an unknown focus. A null focus seeds at the - * most-critical entry node (band 0, first by angular order) for ANY arrow key, - * so Tabbing into the field and pressing any direction bootstraps focus — the - * field is never keyboard-inert on entry. Subsequent presses refine from the - * seed. The seed is deterministic (always layers[0][0]). - */ -export function walkRadial(layout: Layout, focusId: string | null, dir: WalkDir): string | null { - if (focusId === null) { - void dir - return layout.layers[0]?.[0] ?? null - } - const node = layout.nodes.get(focusId) - if (node === undefined) return null - - if (dir === 'up' || dir === 'down') { - // Step inward (up) or outward (down) to the nearest NON-EMPTY band. - const step = dir === 'up' ? -1 : 1 - for (let r = node.layer + step; r >= 0 && r < layout.layers.length; r += step) { - if (layout.layers[r].length > 0) return nearestOnRing(layout, node, r) - } - return null - } - - // left / right: move to the angular neighbor in the same band. - const ring = layout.layers[node.layer] - if (ring === undefined || ring.length <= 1) return null - const idx = ring.indexOf(focusId) - if (idx < 0) return null - // Band ids are stored in increasing angular order; right = next, left = prev. - // No wraparound (boundary returns null), matching the DAG walk semantics. - const nextIdx = idx + (dir === 'right' ? 1 : -1) - if (nextIdx < 0 || nextIdx >= ring.length) return null - return ring[nextIdx] -} - -/** Center of a Layout's coordinate space. */ -function center(layout: Layout): { cx: number; cy: number } { - return { cx: layout.width / 2, cy: layout.height / 2 } -} - -/** Angle of a node around the layout center, in [0, 2π). */ -function angleOf(layout: Layout, node: LayoutNode): number { - const { cx, cy } = center(layout) - const a = Math.atan2(node.y + NODE_H / 2 - cy, node.x + NODE_W / 2 - cx) - return a < 0 ? a + 2 * Math.PI : a -} - -/** Closest node (by angle) on a target band to the focus node. */ -function nearestOnRing(layout: Layout, focus: LayoutNode, ring: number): string | null { - const ids = layout.layers[ring] - if (ids === undefined || ids.length === 0) return null - const target = angleOf(layout, focus) - let best: string | null = null - let bestDelta = Infinity - for (const id of ids) { - const n = layout.nodes.get(id) - if (n === undefined) continue - let delta = Math.abs(angleOf(layout, n) - target) - if (delta > Math.PI) delta = 2 * Math.PI - delta // shortest arc - if (delta < bestDelta || (delta === bestDelta && (best === null || compareIds(id, best) < 0))) { - bestDelta = delta - best = id - } - } - return best -}