@jonobr1/force-directed-graph
Version:
GPU supercharged attraction-graph visualizations for the web built on top of Three.js
339 lines (303 loc) • 9.7 kB
JavaScript
import { describe, expect, it } from 'vitest';
import {
ClampToEdgeWrapping,
LinearFilter,
LinearMipmapLinearFilter,
Matrix4,
PerspectiveCamera,
} from 'three';
import { __TEST__ } from './labels.js';
describe('label placement helpers', () => {
it('maps obscurity to a visible label quota', () => {
expect(__TEST__.getVisibleQuota(0, 100)).toBe(100);
expect(__TEST__.getVisibleQuota(0.75, 100)).toBe(25);
expect(__TEST__.getVisibleQuota(1, 100)).toBe(0);
expect(__TEST__.getVisibleQuota(-1, 8)).toBe(8);
expect(__TEST__.getVisibleQuota(2, 8)).toBe(0);
expect(__TEST__.sanitizeLabelFontSize(0)).toBe(0.01);
expect(__TEST__.sanitizeLabelFontSize(Number.NaN)).toBe(1);
expect(__TEST__.sanitizeLabelNearDistance(-1)).toBe(0);
expect(__TEST__.sanitizeLabelNearDistance(Number.NaN)).toBe(0);
expect(__TEST__.getLabelAlignmentOffset(0)).toBe(0);
expect(__TEST__.getLabelAlignmentOffset(1)).toBe(1);
expect(__TEST__.getLabelAlignmentOffset(-1)).toBe(-1);
expect(__TEST__.getLabelBaselineOffset(1)).toBe(1);
expect(__TEST__.getLabelBaselineOffset(0)).toBe(0);
expect(__TEST__.getLabelBaselineOffset(-1)).toBe(-1);
expect(__TEST__.getNodeColorComponents({ color: '#ff0000' })).toEqual([1, 0, 0]);
expect(__TEST__.getNodeColorComponents({})).toEqual([1, 1, 1]);
});
it('derives label priority deterministically', () => {
expect(__TEST__.getLabelBasePriority({
labelPriority: 9,
size: 4,
}, 2)).toBe(9);
expect(__TEST__.getLabelBasePriority({
size: 4,
}, 7)).toBe(4);
expect(__TEST__.getLabelBasePriority({}, 7)).toBe(7);
const entries = [
{ basePriority: 3, stableId: 9 },
{ basePriority: 5, stableId: 4 },
{ basePriority: 5, stableId: 2 },
];
entries.sort(__TEST__.compareLabelEntries);
expect(entries).toEqual([
{ basePriority: 5, stableId: 2 },
{ basePriority: 5, stableId: 4 },
{ basePriority: 3, stableId: 9 },
]);
const projectedEntries = [
{
entry: { basePriority: 5, stableId: 1, persistence: 0 },
depthPriority: 4,
},
{
entry: { basePriority: 5, stableId: 2, persistence: 3 },
depthPriority: 2,
},
{
entry: { basePriority: 5, stableId: 3, persistence: 3 },
depthPriority: 6,
},
];
projectedEntries.sort(__TEST__.compareProjectedEntries);
expect(projectedEntries.map((projected) => projected.entry.stableId)).toEqual([
3,
2,
1,
]);
});
it('projects label bounds using the render-time size math', () => {
const camera = new PerspectiveCamera(50, 2, 0.1, 1000);
camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);
camera.updateMatrixWorld();
camera.updateProjectionMatrix();
const bounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: 0 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: false,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
});
expect(bounds).not.toBeNull();
expect(bounds.width).toBeGreaterThan(bounds.height * 3.5);
expect(bounds.centerY).toBeLessThan(100);
expect(bounds.clipped).toBe(false);
expect(bounds.depthPriority).toBeGreaterThan(0);
const leftBottomBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: 0 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: false,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
labelAlignment: 1,
labelBaseline: -1,
labelFontSize: 2,
});
expect(leftBottomBounds.centerX).toBeGreaterThan(bounds.centerX);
expect(leftBottomBounds.centerY).toBeGreaterThan(bounds.centerY);
expect(leftBottomBounds.height).toBeGreaterThan(bounds.height);
const offsetBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: 0 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: false,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
labelOffset: { x: 1, y: -0.5 },
});
expect(offsetBounds.centerX).toBeGreaterThan(bounds.centerX);
expect(offsetBounds.centerY).toBeGreaterThan(bounds.centerY);
const largePointBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: 0 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: false,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
pointSize: 2,
});
expect(largePointBounds.height).toBeGreaterThan(bounds.height);
const culledBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: 0 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: false,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
labelNear: 10,
});
expect(culledBounds).toBeNull();
const nearFixedBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: 0 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: false,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
});
const farFixedBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: -10 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: false,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
});
expect(nearFixedBounds.height).toBeCloseTo(farFixedBounds.height, 6);
const nearAttenuatedBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: 0 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: true,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
});
const farAttenuatedBounds = __TEST__.projectLabelBounds({
nodePosition: { x: 0, y: 0, z: -10 },
objectMatrixWorld: new Matrix4(),
camera,
viewportWidth: 400,
viewportHeight: 200,
frustumSize: 100,
is2D: false,
sizeAttenuation: true,
nodeRadius: 1,
nodeScale: 10,
aspectRatio: 4,
});
expect(nearAttenuatedBounds.height).toBeGreaterThan(farAttenuatedBounds.height);
});
it('packs collision cells and sort tuples consistently', () => {
expect(__TEST__.packCollisionCellKey(2, 3, 10)).toBe(32);
expect(__TEST__.getCollisionCellBounds({
minX: 15,
minY: 15,
maxX: 47,
maxY: 63,
}, 16, 8, 8)).toEqual({
minCellX: 0,
maxCellX: 2,
minCellY: 0,
maxCellY: 3,
});
expect(__TEST__.buildSortTuple(12, {
basePriority: 8,
stableId: 4,
labelId: 1,
})).toEqual({
cellId: 12,
priorityKey: -8,
stableId: 4,
labelId: 1,
});
});
it('builds a stable graph-topology label order', () => {
const entries = [
{ labelId: 0, nodeIndex: 0, stableId: 0 },
{ labelId: 1, nodeIndex: 1, stableId: 1 },
{ labelId: 2, nodeIndex: 2, stableId: 2 },
{ labelId: 3, nodeIndex: 3, stableId: 3 },
{ labelId: 4, nodeIndex: 4, stableId: 4 },
];
const nodes = entries.map((entry) => ({ id: entry.nodeIndex }));
nodes[4].labelPriority = 10;
const adjacency = [
[1],
[0, 2],
[1, 3],
[2, 4],
[3],
];
const degrees = [1, 2, 2, 2, 1];
const order = __TEST__.buildLabelSelectionOrder(
entries,
adjacency,
nodes,
degrees,
3,
);
expect(order.map((entry) => entry.nodeIndex)).toEqual([4, 0, 2, 1, 3]);
expect(Array.from(__TEST__.buildSelectionRanks(entries, order))).toEqual([
1, 3, 2, 4, 0,
]);
});
it('configures atlas textures for smoother sampling', () => {
const texture = __TEST__.configureAtlasTexture({});
expect(texture.minFilter).toBe(LinearFilter);
expect(texture.magFilter).toBe(LinearFilter);
expect(texture.wrapS).toBe(ClampToEdgeWrapping);
expect(texture.wrapT).toBe(ClampToEdgeWrapping);
expect(texture.generateMipmaps).toBe(false);
expect(texture.needsUpdate).toBe(true);
});
it('configures atlas textures with mipmaps when requested', () => {
const texture = __TEST__.configureAtlasTexture({}, { useMipmaps: true });
expect(texture.minFilter).toBe(LinearMipmapLinearFilter);
expect(texture.magFilter).toBe(LinearFilter);
expect(texture.generateMipmaps).toBe(true);
expect(texture.needsUpdate).toBe(true);
});
it('caps label atlas canvas dimensions for mobile-safe uploads', () => {
expect(
__TEST__.getLabelAtlasMaxTextureSize({
maxTextureSize: 16384,
}),
).toBe(4096);
expect(
__TEST__.getLabelAtlasMaxTextureSize({
maxTextureSize: 2048,
}),
).toBe(2048);
expect(
__TEST__.getLabelAtlasMaxTextureSize({
maxTextureSize: 16384,
maxCanvasTextureSize: 8192,
}),
).toBe(8192);
});
});