diff --git a/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts b/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts new file mode 100644 index 00000000..0552dc9e --- /dev/null +++ b/packages/web/src/lib/__tests__/layoutAlgorithms.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + assignLayersByDependencyDirection, computeLayerYPositions, isHierarchicalLayout, +} from '../layoutAlgorithms'; + +const n = (id: string) => ({ id }); +const dep = (source: string, target: string) => ({ source, target, type: 'DEPENDS_ON' }); + +describe('assignLayersByDependencyDirection', () => { + it('puts dependency-free nodes at layer 0 and dependents below', () => { + // A depends on B → B is the prerequisite (layer 0), A is layer 1. + const layers = assignLayersByDependencyDirection([n('A'), n('B')], [dep('A', 'B')]); + expect(layers.get('B')).toBe(0); + expect(layers.get('A')).toBe(1); + }); + + it('layers a chain by longest dependency path', () => { + const layers = assignLayersByDependencyDirection( + [n('A'), n('B'), n('C')], [dep('A', 'B'), dep('B', 'C')], + ); + expect(layers.get('C')).toBe(0); + expect(layers.get('B')).toBe(1); + expect(layers.get('A')).toBe(2); + }); + + it('isolated nodes are layer 0', () => { + const layers = assignLayersByDependencyDirection([n('A'), n('X')], [dep('A', 'A2')]); + expect(layers.get('X')).toBe(0); + }); + + it('ignores non-DEPENDS_ON edges', () => { + const layers = assignLayersByDependencyDirection( + [n('A'), n('B')], [{ source: 'A', target: 'B', type: 'RELATES_TO' }], + ); + expect(layers.get('A')).toBe(0); + expect(layers.get('B')).toBe(0); + }); + + it('accepts edges whose source/target are node objects', () => { + const layers = assignLayersByDependencyDirection( + [n('A'), n('B')], [{ source: { id: 'A' }, target: { id: 'B' }, type: 'DEPENDS_ON' }], + ); + expect(layers.get('A')).toBe(1); + }); + + it('is cycle-safe (does not infinite-loop)', () => { + const layers = assignLayersByDependencyDirection( + [n('A'), n('B')], [dep('A', 'B'), dep('B', 'A')], + ); + expect(layers.size).toBe(2); // returns finite layers + }); +}); + +describe('computeLayerYPositions', () => { + it('maps layer 0 near the top and higher layers monotonically lower', () => { + const layers = new Map([['A', 2], ['B', 1], ['C', 0]]); + const ys = computeLayerYPositions(layers, { width: 1000, height: 900 }, 80); + expect(ys.get(0)!).toBeLessThan(ys.get(1)!); + expect(ys.get(1)!).toBeLessThan(ys.get(2)!); + expect(ys.get(0)!).toBeGreaterThanOrEqual(0); + expect(ys.get(2)!).toBeLessThanOrEqual(900); + }); + + it('single layer collapses to one row without dividing by zero', () => { + const ys = computeLayerYPositions(new Map([['A', 0]]), { width: 500, height: 500 }, 50); + expect(Number.isFinite(ys.get(0)!)).toBe(true); + }); +}); + +describe('isHierarchicalLayout', () => { + it('true only for "hierarchical"', () => { + expect(isHierarchicalLayout('hierarchical')).toBe(true); + expect(isHierarchicalLayout('force')).toBe(false); + expect(isHierarchicalLayout(undefined)).toBe(false); + }); +}); diff --git a/packages/web/src/lib/layoutAlgorithms.ts b/packages/web/src/lib/layoutAlgorithms.ts new file mode 100644 index 00000000..ac6e1184 --- /dev/null +++ b/packages/web/src/lib/layoutAlgorithms.ts @@ -0,0 +1,70 @@ +/** + * Structured layout helpers (#30): pure, dependency-aware node layering for an + * optional hierarchical arrangement (vs the organic force layout). No D3/DOM — + * the simulation calls these to seed/pin Y positions by layer. + */ + +type NodeLike = { id: string }; +type EdgeLike = { source: string | { id: string }; target: string | { id: string }; type?: string }; + +const idOf = (v: string | { id: string }): string => (typeof v === 'string' ? v : v?.id); + +/** + * Assign each node a layer index by DEPENDS_ON direction: a node that depends on + * nothing is layer 0; a node is one layer below the deepest thing it depends on + * (longest dependency path). Non-DEPENDS_ON edges are ignored. Cycle-safe. + * + * Edge semantics: `A DEPENDS_ON B` means A needs B first ⇒ B is the prerequisite + * (higher, smaller layer) and A sits below it. + */ +export function assignLayersByDependencyDirection(nodes: NodeLike[], edges: EdgeLike[]): Map { + const ids = new Set(nodes.map((n) => n.id)); + // deps: node -> the prerequisites it DEPENDS_ON (both endpoints must be real nodes) + const deps = new Map(); + for (const n of nodes) deps.set(n.id, []); + for (const e of edges) { + if (e.type !== 'DEPENDS_ON') continue; + const s = idOf(e.source), t = idOf(e.target); + if (ids.has(s) && ids.has(t)) deps.get(s)!.push(t); + } + + const layer = new Map(); + const visiting = new Set(); + const resolve = (id: string): number => { + if (layer.has(id)) return layer.get(id)!; + if (visiting.has(id)) return 0; // cycle guard — break at current node + visiting.add(id); + let max = -1; + for (const p of deps.get(id) || []) max = Math.max(max, resolve(p)); + visiting.delete(id); + const v = max + 1; // 0 if it depends on nothing + layer.set(id, v); + return v; + }; + for (const n of nodes) resolve(n.id); + return layer; +} + +/** + * Map each layer index to a Y coordinate, spreading layers top→bottom within the + * viewport (layer 0 nearest the top). Single-layer graphs collapse to one row. + */ +export function computeLayerYPositions( + layers: Map, + viewport: { width: number; height: number }, + verticalPadding = 60, +): Map { + const indices = [...new Set(layers.values())].sort((a, b) => a - b); + const maxLayer = indices.length ? indices[indices.length - 1] : 0; + const usable = Math.max(1, viewport.height - 2 * verticalPadding); + const gap = maxLayer > 0 ? usable / maxLayer : 0; + const out = new Map(); + for (const i of indices) out.set(i, verticalPadding + i * gap); + if (!out.has(0)) out.set(0, verticalPadding); + return out; +} + +/** Whether the configured layout mode is the hierarchical (structured) one. */ +export function isHierarchicalLayout(mode: string | undefined | null): boolean { + return mode === 'hierarchical'; +}