This commit is contained in:
2026-07-12 21:26:08 +08:00
commit 9dd41afd48
502 changed files with 129901 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
buildRenderableGraph,
centerRendererViewportOnPoint,
createViewportFrameCommitter,
fitRendererViewportToPoints,
normalizeWheelDelta,
panRendererViewport,
rendererViewportToMinimapRect,
screenPointToWorldPoint,
visibleWorldRectForViewport,
visibleWorldRectToMinimapRect,
rendererViewportToTransform,
viewportAfterResize,
viewportAfterWheelZoom,
worldPointToScreenPoint
} from "../src/render";
import type { GraphData, PinMap } from "../src/types";
function sampleGraph(): GraphData {
return {
meta: {
build_date: "2026-06-13T00:00:00.000Z",
wiki_title: "Viewport Fixture",
total_nodes: 2,
total_edges: 1
},
nodes: [
{ id: "a", label: "A", type: "topic", community: "c1", source_path: "wiki/a.md", x: 20, y: 30 },
{ id: "b", label: "B", type: "entity", community: "c1", source_path: "wiki/b.md", x: 60, y: 55 }
],
edges: [{ id: "ab", from: "a", to: "b", type: "EXTRACTED", weight: 1 }]
};
}
describe("renderer viewport state", () => {
it("serializes pan and zoom as one content-layer transform", () => {
assert.equal(
rendererViewportToTransform({ x: 32.1254, y: -18.8754, scale: 1.4567 }),
"translate(32.125px, -18.875px) scale(1.457)"
);
});
it("clamps focus-fit zoom so a tiny community does not blow up to fullscreen", () => {
const size = { width: 1440, height: 900 };
// 极小包围盒:聚焦一个只有几个聚集节点的小社区
const tightCluster = [
{ x: 500, y: 400 },
{ x: 520, y: 415 },
{ x: 510, y: 430 }
];
// 默认 fit 会把这么小的簇放大到上限(远超可读尺寸)——这正是"切社区节点爆大"的来源
const defaultFit = fitRendererViewportToPoints(tightCluster, size);
assert.ok(defaultFit.scale > 1.5, `sanity: default fit over-zooms a tiny cluster (got ${defaultFit.scale})`);
// focusCommunity 传保守 maxScale 后,缩放被钳住,节点保持可读、社区居中留白
const clamped = fitRendererViewportToPoints(tightCluster, size, { maxScale: 1.5 });
assert.ok(clamped.scale <= 1.5, `focus fit must clamp zoom to <= 1.5 (got ${clamped.scale})`);
});
it("keeps pin coordinates separate from viewport transforms", () => {
const pins: PinMap = {
"wiki/a.md": { x: 420, y: 210 }
};
const beforePins = structuredClone(pins);
const beforeGraph = buildRenderableGraph(sampleGraph(), { pins });
rendererViewportToTransform({ x: 120, y: -64, scale: 2.25 });
const afterGraph = buildRenderableGraph(sampleGraph(), { pins });
assert.deepEqual(pins, beforePins);
assert.deepEqual(
afterGraph.nodes.map((node) => [node.id, node.point]),
beforeGraph.nodes.map((node) => [node.id, node.point])
);
assert.deepEqual(afterGraph.nodes.find((node) => node.id === "a")?.point, { x: 420, y: 210 });
});
it("normalizes pixel, line, and page wheel deltas separately", () => {
assert.equal(normalizeWheelDelta({ deltaY: 12, deltaMode: 0 }), 12);
assert.equal(normalizeWheelDelta({ deltaY: 2, deltaMode: 1 }), 36);
assert.equal(normalizeWheelDelta({ deltaY: 1, deltaMode: 2 }), 720);
});
it("zooms around the pointer and pans without changing scale", () => {
const viewport = { x: 0, y: 0, scale: 1 };
const zoomed = viewportAfterWheelZoom(
viewport,
{ deltaY: -120, deltaMode: 0 },
{ x: 250, y: 150 },
{ width: 1000, height: 680 }
);
const panned = panRendererViewport(zoomed, { x: 40, y: -25 }, { width: 1000, height: 680 });
assert.ok(zoomed.scale > 1);
assert.ok(zoomed.x < 0, `zoom should move x around pointer, got ${zoomed.x}`);
assert.ok(zoomed.y < 0, `zoom should move y around pointer, got ${zoomed.y}`);
assert.equal(panned.scale, zoomed.scale);
assert.equal(panned.x, zoomed.x + 40);
assert.equal(panned.y, zoomed.y - 25);
});
it("keeps the world point under the pointer stable during wheel zoom", () => {
const viewport = { x: -180, y: 64, scale: 1.25 };
const size = { width: 1170, height: 856 };
const pointer = { x: 420, y: 315 };
const anchoredWorldPoint = screenPointToWorldPoint(pointer, viewport, size);
const zoomed = viewportAfterWheelZoom(viewport, { deltaY: -100, deltaMode: 0 }, pointer, size);
assertPointNear(worldPointToScreenPoint(anchoredWorldPoint, zoomed, size), pointer);
});
it("fits graph points into the viewport", () => {
const fitted = fitRendererViewportToPoints(
[{ x: 120, y: 80 }, { x: 880, y: 600 }],
{ width: 1000, height: 680 }
);
assert.ok(fitted.scale >= 0.5);
assert.ok(fitted.scale <= 4);
assert.notDeepEqual(fitted, { x: 0, y: 0, scale: 1 });
});
it("centers a model point while preserving the current zoom scale", () => {
const centered = centerRendererViewportOnPoint(
{ x: 500, y: 340 },
{ x: -20, y: -30, scale: 2 },
{ width: 1000, height: 680 }
);
assert.equal(centered.scale, 2);
assert.deepEqual(centered, { x: -500, y: -340, scale: 2 });
});
it("keeps a selected model point visually anchored when the viewport host resizes", () => {
const point = { x: 760, y: 340 };
const beforeSize = { width: 1170, height: 856 };
const afterSize = { width: 750, height: 856 };
const before = centerRendererViewportOnPoint(point, { x: 0, y: 0, scale: 1.5 }, beforeSize);
const resized = viewportAfterResize(before, beforeSize, afterSize, { anchorPoint: point });
const screenRatio = (
resized.x + resized.scale * (point.x / 1000 * afterSize.width)
) / afterSize.width;
assert.equal(resized.scale, before.scale);
assert.ok(screenRatio >= 0.18, `anchored point should not hug the left edge, got ${screenRatio}`);
assert.ok(screenRatio <= 0.78, `anchored point should stay clear of the drawer edge, got ${screenRatio}`);
});
it("maps the current viewport to a minimap rectangle", () => {
const viewport = { x: -250, y: -170, scale: 2 };
const size = { width: 1000, height: 680 };
const rect = rendererViewportToMinimapRect(
viewport,
size
);
const geometryRect = visibleWorldRectToMinimapRect(visibleWorldRectForViewport(viewport, size));
assert.ok(rect.x > 0);
assert.ok(rect.y > 0);
assert.ok(rect.width > 2);
assert.ok(rect.height > 2);
assertPointNear(rect, geometryRect);
});
it("maps viewport and minimap rectangles through expanded world bounds", () => {
const viewport = centerRendererViewportOnPoint(
{ x: 1240, y: 816 },
{ x: 0, y: 0, scale: 2 },
{ width: 1000, height: 680 },
{ worldBounds: { minX: 0, minY: 0, maxX: 1320, maxY: 896, width: 1320, height: 896 } }
);
const rect = rendererViewportToMinimapRect(
viewport,
{ width: 1000, height: 680 },
{ worldBounds: { minX: 0, minY: 0, maxX: 1320, maxY: 896, width: 1320, height: 896 } }
);
assert.ok(rect.x > 90, `expanded-world minimap rect should move near the outlier, got ${rect.x}`);
assert.ok(rect.y > 30, `expanded-world minimap rect should move near the outlier, got ${rect.y}`);
assert.ok(rect.width > 2);
assert.ok(rect.height > 2);
});
it("coalesces viewport writes to one requestAnimationFrame callback", () => {
const callbacks: Array<() => void> = [];
const writes: Array<{ x: number; y: number; scale: number }> = [];
const committer = createViewportFrameCommitter((viewport) => writes.push(viewport), {
requestAnimationFrame(callback) {
callbacks.push(callback);
return callbacks.length;
}
});
committer.schedule({ x: 10, y: 0, scale: 1 });
committer.schedule({ x: 20, y: 0, scale: 1 });
committer.schedule({ x: 30, y: 0, scale: 1 });
assert.equal(callbacks.length, 1);
assert.deepEqual(writes, []);
callbacks[0]();
assert.deepEqual(writes, [{ x: 30, y: 0, scale: 1 }]);
});
});
function assertNear(actual: number, expected: number, message?: string): void {
assert.ok(Math.abs(actual - expected) < 0.01, `${message ?? "number"}: expected ${expected}, got ${actual}`);
}
function assertPointNear(actual: { x: number; y: number }, expected: { x: number; y: number }): void {
assertNear(actual.x, expected.x, "x");
assertNear(actual.y, expected.y, "y");
}