diff --git a/packages/web/src/lib/__tests__/edgeLOD.test.ts b/packages/web/src/lib/__tests__/edgeLOD.test.ts new file mode 100644 index 00000000..b5ac2f57 --- /dev/null +++ b/packages/web/src/lib/__tests__/edgeLOD.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { + EDGE_LOD, arrowVisibility, labelVisibility, shouldBundleEdges, + directionStrategy, perpendicularOffset, +} from '../edgeLOD'; + +describe('directionStrategy', () => { + it('hides direction when very zoomed out, minimal mid, full when close', () => { + expect(directionStrategy(0.2)).toBe('hidden'); + expect(directionStrategy(0.4)).toBe('minimal'); + expect(directionStrategy(0.8)).toBe('full'); + }); + it('uses the FAR/VERY_FAR thresholds as boundaries', () => { + expect(directionStrategy(EDGE_LOD.VERY_FAR - 0.01)).toBe('hidden'); + expect(directionStrategy(EDGE_LOD.FAR + 0.01)).toBe('full'); + }); +}); + +describe('arrowVisibility', () => { + it('invisible below FAR, full opacity at/above CLOSE', () => { + expect(arrowVisibility(0.3).visible).toBe(false); + expect(arrowVisibility(0.3).opacity).toBe(0); + expect(arrowVisibility(0.9).visible).toBe(true); + expect(arrowVisibility(0.9).opacity).toBe(1); + }); + it('fades in across the FAR→CLOSE band', () => { + const mid = arrowVisibility((EDGE_LOD.FAR + EDGE_LOD.CLOSE) / 2).opacity; + expect(mid).toBeGreaterThan(0); + expect(mid).toBeLessThan(1); + }); + it('shrinks arrow size when several parallel edges share the pair', () => { + expect(arrowVisibility(1, 1).scale).toBe(1); + expect(arrowVisibility(1, 4).scale).toBeLessThan(1); + expect(arrowVisibility(1, 4).scale).toBeGreaterThanOrEqual(0.5); + }); +}); + +describe('labelVisibility', () => { + it('returns an opacity in [0,1], invisible when far', () => { + expect(labelVisibility(0.2)).toBe(0); + const o = labelVisibility(0.7); + expect(o).toBeGreaterThan(0); + expect(o).toBeLessThanOrEqual(1); + }); + it('reduces opacity as edges crowd the pair', () => { + expect(labelVisibility(1, 5)).toBeLessThan(labelVisibility(1, 1)); + }); +}); + +describe('shouldBundleEdges', () => { + it('never bundles a single edge', () => { + expect(shouldBundleEdges(0.1, 1, 50)).toBe(false); + }); + it('bundles parallel edges when zoomed out or nodes very close', () => { + expect(shouldBundleEdges(0.3, 3, 400)).toBe(true); // zoomed out + expect(shouldBundleEdges(1.0, 3, 40)).toBe(true); // nodes too close + }); + it('separates parallel edges when close + well-spaced', () => { + expect(shouldBundleEdges(1.0, 3, 400)).toBe(false); + }); +}); + +describe('perpendicularOffset', () => { + it('is 0 for a single edge', () => { + expect(perpendicularOffset(0, 1, 1)).toBe(0); + }); + it('spreads symmetrically around 0 for parallel edges', () => { + const a = perpendicularOffset(0, 3, 1); + const c = perpendicularOffset(2, 3, 1); + expect(a).toBeCloseTo(-c, 5); + expect(perpendicularOffset(1, 3, 1)).toBeCloseTo(0, 5); // middle edge centred + }); + it('collapses toward 0 as you zoom out', () => { + const close = Math.abs(perpendicularOffset(0, 3, 1)); + const far = Math.abs(perpendicularOffset(0, 3, 0.3)); + expect(far).toBeLessThan(close); + }); +}); diff --git a/packages/web/src/lib/edgeLOD.ts b/packages/web/src/lib/edgeLOD.ts new file mode 100644 index 00000000..276fed57 --- /dev/null +++ b/packages/web/src/lib/edgeLOD.ts @@ -0,0 +1,58 @@ +/** + * Edge level-of-detail (#22): pure, zoom-aware rules for how edges render — + * direction indication, arrow/label fade, and parallel-edge bundling/offset — + * so multiple edges stay legible at every zoom level. No D3/DOM here; the + * renderer calls these during tick/zoom updates. + */ + +// Zoom thresholds (mirror the node LOD bands so edges degrade in step). +export const EDGE_LOD = { + VERY_FAR: 0.3, // below: hide direction entirely + FAR: 0.5, // below: arrows hidden; minimal direction marker + CLOSE: 0.6, // at/above: full arrows + labels + LABEL_IN: 0.4, // labels start fading in here + LABEL_FULL: 0.8, // labels at full opacity here + MIN_PAIR_DIST: 80, // node-centre distance under which parallels must bundle + PARALLEL_GAP: 14, // base perpendicular spacing between parallel edges (px, graph units) +} as const; + +const clamp01 = (n: number) => Math.max(0, Math.min(1, n)); + +/** How to indicate edge direction at this zoom: hidden / minimal marker / full arrow. */ +export function directionStrategy(scale: number): 'hidden' | 'minimal' | 'full' { + if (scale < EDGE_LOD.VERY_FAR) return 'hidden'; + if (scale < EDGE_LOD.FAR) return 'minimal'; + return 'full'; +} + +/** Arrow head visibility/fade + size (smaller when many parallels share a pair). */ +export function arrowVisibility(scale: number, edgeCount = 1): { visible: boolean; opacity: number; scale: number } { + const opacity = scale < EDGE_LOD.FAR ? 0 : clamp01((scale - EDGE_LOD.FAR) / (EDGE_LOD.CLOSE - EDGE_LOD.FAR)); + const size = edgeCount > 1 ? Math.max(0.5, 1 - (edgeCount - 1) * 0.1) : 1; + return { visible: opacity > 0, opacity, scale: size }; +} + +/** Label opacity (0..1): fades over LABEL_IN→LABEL_FULL, dimmed when edges crowd. */ +export function labelVisibility(scale: number, edgeCount = 1): number { + const base = clamp01((scale - EDGE_LOD.LABEL_IN) / (EDGE_LOD.LABEL_FULL - EDGE_LOD.LABEL_IN)); + const crowd = edgeCount > 1 ? 1 / Math.sqrt(edgeCount) : 1; + return clamp01(base * crowd); +} + +/** Bundle parallel edges (collapse to one path) when zoomed out or nodes too close. */ +export function shouldBundleEdges(scale: number, edgeCount: number, nodeDistance: number): boolean { + if (edgeCount <= 1) return false; + return scale < EDGE_LOD.FAR - 0.05 || nodeDistance < EDGE_LOD.MIN_PAIR_DIST; +} + +/** + * Perpendicular offset for edge `edgeIndex` of `totalCount` parallels: a signed + * distance (graph units) to shift the edge off the centre line so siblings don't + * overlap. Symmetric around 0; collapses toward 0 as you zoom out. + */ +export function perpendicularOffset(edgeIndex: number, totalCount: number, scale: number): number { + if (totalCount <= 1) return 0; + const centred = edgeIndex - (totalCount - 1) / 2; // …,-1,0,1,… + const zoomFactor = clamp01((scale - EDGE_LOD.VERY_FAR) / (EDGE_LOD.CLOSE - EDGE_LOD.VERY_FAR)); + return centred * EDGE_LOD.PARALLEL_GAP * zoomFactor; +}