Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/web/src/lib/__tests__/layoutAlgorithms.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
70 changes: 70 additions & 0 deletions packages/web/src/lib/layoutAlgorithms.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> {
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<string, string[]>();
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<string, number>();
const visiting = new Set<string>();
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<string, number>,
viewport: { width: number; height: number },
verticalPadding = 60,
): Map<number, number> {
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<number, number>();
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';
}
Loading