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
78 changes: 78 additions & 0 deletions packages/web/src/lib/__tests__/edgeLOD.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 58 additions & 0 deletions packages/web/src/lib/edgeLOD.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading