Files
llm-wiki/packages/graph-engine/test/sigma-global-renderer.test.ts
T
2026-07-12 21:26:08 +08:00

2822 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import GraphologyGraph from "graphology";
import {
SIGMA_GLOBAL_RENDERER_BUNDLE_BOUNDARY,
SIGMA_GLOBAL_RENDERER_ROUTE_MANAGER_OWNER,
createSigmaGlobalRenderer,
type SigmaGlobalGraphologyGraph,
type SigmaGlobalRendererRuntime,
type SigmaGlobalSigmaLike
} from "../src/render/sigma-global-renderer";
import {
buildSigmaGlobalGraphologyGraph,
sigmaGlobalEdgeStyle
} from "../src/render/sigma-graphology-model";
import { createSigmaGlobalHitProjector } from "../src/render/sigma-hit-projector";
import type {
GraphRendererAdapterData
} from "../src";
import { buildGraphRendererAdapterData } from "../src";
import type { GraphData } from "../src/types";
describe("Sigma global renderer production boundary", () => {
it("records route ownership and graph-engine bundle boundary", () => {
assert.equal(SIGMA_GLOBAL_RENDERER_ROUTE_MANAGER_OWNER, "facade");
assert.deepEqual(SIGMA_GLOBAL_RENDERER_BUNDLE_BOUNDARY, {
sigma: "runtime-loaded-by-sigma-global-renderer",
graphology: "runtime-loaded-by-sigma-global-renderer",
workbench: "loads through the graph-engine ESM Sigma runtime boundary when global route manager selects Sigma",
offlineHtml: "loads through the graph-engine IIFE Sigma runtime boundary when offline global route manager selects Sigma"
});
});
it("keeps Sigma and Graphology in runtime dependencies", async () => {
const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
assert.equal(manifest.dependencies.sigma, "^3.0.3");
assert.equal(manifest.dependencies.graphology, "^0.26.0");
assert.equal(manifest.devDependencies.sigma, undefined);
assert.equal(manifest.devDependencies.graphology, undefined);
});
it("requires the lazy Sigma runtime boundary before creating the lifecycle", () => {
assert.throws(
() => createSigmaGlobalRenderer({} as never),
/container|runtime/
);
});
it("builds a Graphology render graph entirely from adapter output", () => {
const adapterData = adapterDataFixture();
const graph = buildSigmaGlobalGraphologyGraph(adapterData, { GraphologyGraph });
assert.equal(graph.order, 2);
assert.equal(graph.size, 1);
assert.deepEqual(graph.getNodeAttributes("render-alpha"), {
x: 111,
y: 222,
label: "Adapter Alpha",
size: 10,
color: "#ef4444",
type: "circle",
graphNodeType: "topic",
communityId: "adapter-community",
sourcePath: "adapter/alpha.md",
selected: true,
searchHit: false,
pinned: false,
communityDimmed: false,
communitySpotlightVisible: true,
aggregationIds: ["adapter-aggregation"],
labelVisible: true,
displayMode: "card",
visualRole: "landmark",
priority: 900,
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: "render-alpha" }
}
});
assert.deepEqual(graph.getNodeAttributes("render-beta"), {
x: 333,
y: 444,
label: "",
size: 10,
color: "#f59e0b",
type: "circle",
graphNodeType: "source",
communityId: "adapter-community",
sourcePath: "adapter/beta.md",
selected: false,
searchHit: true,
pinned: true,
communityDimmed: false,
communitySpotlightVisible: true,
aggregationIds: ["adapter-aggregation"],
labelVisible: false,
displayMode: "point",
visualRole: "map-pin",
priority: 100,
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: "render-beta" }
}
});
assert.deepEqual(graph.getEdgeAttributes("adapter-edge"), {
size: 1.13,
color: "rgba(49, 95, 114, 0.145)",
relationType: "depends-on-adapter",
confidence: "ADAPTER_CONFIDENCE",
weight: 0.75,
sourceCommunityId: "adapter-community",
targetCommunityId: "adapter-community"
});
assert.equal(graph.source("adapter-edge"), "render-alpha");
assert.equal(graph.target("adapter-edge"), "render-beta");
assert.deepEqual(graph.getAttribute("communities"), [
{
id: "adapter-community",
label: "Adapter Community",
color: "#123456",
nodeIds: ["render-alpha", "render-beta"],
nodeCount: 2,
selected: true,
searchResultIds: ["render-beta"],
pinnedNodeIds: ["render-beta"],
aggregationIds: ["adapter-aggregation"],
drawerTarget: {
summaryKind: "community-summary",
object: { kind: "community", communityId: "adapter-community" }
},
commands: [{ kind: "enter-community", communityId: "adapter-community", label: "进入社区" }]
}
]);
assert.deepEqual(graph.getAttribute("aggregations"), [
{
id: "adapter-aggregation",
label: "Adapter Aggregation",
communityId: "adapter-community",
nodeIds: ["render-alpha", "render-beta"],
selectedNodeIds: ["render-alpha"],
searchResultIds: ["render-beta"],
pinnedNodeIds: ["render-beta"],
totalCount: 17,
selected: true,
color: "#abcdef",
point: { x: 222, y: 333 },
radius: 44,
drawerTarget: {
summaryKind: "community-summary",
object: { kind: "community", communityId: "adapter-community" }
},
commands: [
{
kind: "show-this-object",
object: {
kind: "aggregation",
aggregationId: "adapter-aggregation",
nodeIds: ["render-alpha", "render-beta"],
communityId: "adapter-community"
},
label: "显示这个对象"
}
]
}
]);
assert.deepEqual(graph.getAttribute("counts"), adapterData.counts);
assert.deepEqual(graph.getAttribute("selection"), adapterData.selection);
});
it("passes selected communities into Sigma global focus edge styling", () => {
const adapterData = adapterDataFixture({ communityCount: 3, selectedCommunityIds: ["community-1"] });
const graph = buildSigmaGlobalGraphologyGraph(
adapterData,
{ GraphologyGraph },
"shan-shui",
{ semanticEmphasis: false, focusHighlight: true }
);
assert.deepEqual(graph.getEdgeAttributes("adapter-edge"), {
size: 0.62,
color: "rgba(49, 95, 114, 0.05)",
relationType: "depends-on-adapter",
confidence: "ADAPTER_CONFIDENCE",
weight: 0.75,
sourceCommunityId: "adapter-community",
targetCommunityId: "adapter-community"
});
});
it("dims ordinary nodes outside the selected community while keeping priority nodes visible", () => {
const adapterData = nodeSpotlightAdapterData();
const graph = buildSigmaGlobalGraphologyGraph(adapterData, { GraphologyGraph });
assert.equal(graph.getNodeAttribute("alpha-ordinary", "communityDimmed"), false);
assert.equal(graph.getNodeAttribute("beta-ordinary", "communityDimmed"), true);
assert.equal(graph.getNodeAttribute("beta-ordinary", "color"), "rgba(18, 52, 1, 0.2)");
assert.equal(graph.getNodeAttribute("beta-ordinary", "size"), 3.6);
assert.equal(graph.getNodeAttribute("beta-search", "communityDimmed"), false);
assert.equal(graph.getNodeAttribute("beta-search", "communitySpotlightVisible"), true);
assert.equal(graph.getNodeAttribute("beta-pinned", "communityDimmed"), false);
assert.equal(graph.getNodeAttribute("alpha-selected", "communityDimmed"), false);
});
it("does not dim nodes when there is no community selection spotlight", () => {
const adapterData = nodeSpotlightAdapterData({ selectionKind: "node" });
const graph = buildSigmaGlobalGraphologyGraph(adapterData, { GraphologyGraph });
assert.equal(graph.getNodeAttribute("beta-ordinary", "communityDimmed"), false);
assert.equal(graph.getNodeAttribute("beta-ordinary", "color"), "#123401");
assert.equal(graph.getNodeAttribute("beta-ordinary", "size"), 5);
});
it("styles Sigma global edges by relation and community scope without confidence opacity", () => {
const intraNeutral = sigmaGlobalEdgeStyle(sigmaEdgeFixture({
relationType: "依赖",
sourceCommunityId: "c1",
targetCommunityId: "c1",
confidence: "EXTRACTED",
weight: 0
}), "shan-shui");
const bridgeNeutral = sigmaGlobalEdgeStyle(sigmaEdgeFixture({
relationType: "依赖",
sourceCommunityId: "c1",
targetCommunityId: "c2",
confidence: "EXTRACTED",
weight: 0
}), "shan-shui");
const contrast = sigmaGlobalEdgeStyle(sigmaEdgeFixture({
relationType: "对比",
sourceCommunityId: "c1",
targetCommunityId: "c1",
confidence: "EXTRACTED",
weight: 0.5
}), "shan-shui");
const conflictDark = sigmaGlobalEdgeStyle(sigmaEdgeFixture({
relationType: "矛盾",
sourceCommunityId: "c1",
targetCommunityId: "c2",
confidence: "EXTRACTED",
weight: 1
}), "mo-ye");
const neutralDark = sigmaGlobalEdgeStyle(sigmaEdgeFixture({
relationType: "依赖",
sourceCommunityId: "c1",
targetCommunityId: "c1",
confidence: "EXTRACTED",
weight: 0
}), "mo-ye");
assert.deepEqual(intraNeutral, { color: "rgba(49, 95, 114, 0.1)", size: 0.72 });
assert.deepEqual(bridgeNeutral, { color: "rgba(49, 95, 114, 0.34)", size: 1.1 });
assert.deepEqual(contrast, { color: "rgba(183, 121, 31, 0.54)", size: 1.55 });
assert.deepEqual(conflictDark, { color: "rgba(244, 114, 182, 0.66)", size: 2.25 });
assert.deepEqual(neutralDark, { color: "rgba(142, 135, 120, 0.1)", size: 0.72 });
});
it("keeps global confidence out of Sigma edge styling", () => {
const extracted = sigmaGlobalEdgeStyle(sigmaEdgeFixture({
relationType: "依赖",
confidence: "EXTRACTED",
weight: 0.4
}), "shan-shui");
const inferred = sigmaGlobalEdgeStyle(sigmaEdgeFixture({
relationType: "依赖",
confidence: "INFERRED",
weight: 0.4
}), "shan-shui");
assert.deepEqual(inferred, extracted);
});
it("lets semantic emphasis thin neutral edges and lift semantic edges", () => {
const style = { semanticEmphasis: true, focusHighlight: false };
const neutralBase = sigmaGlobalEdgeStyle(sigmaEdgeFixture({ relationType: "依赖", weight: 0.5 }), "shan-shui");
const neutralEmphasis = sigmaGlobalEdgeStyle(sigmaEdgeFixture({ relationType: "依赖", weight: 0.5 }), "shan-shui", style);
const contrastBase = sigmaGlobalEdgeStyle(sigmaEdgeFixture({ relationType: "对比", weight: 0.5 }), "shan-shui");
const contrastEmphasis = sigmaGlobalEdgeStyle(sigmaEdgeFixture({ relationType: "对比", weight: 0.5 }), "shan-shui", style);
assert.ok(edgeStyleAlpha(neutralEmphasis.color) < edgeStyleAlpha(neutralBase.color));
assert.ok(neutralEmphasis.size < neutralBase.size);
assert.ok(edgeStyleAlpha(contrastEmphasis.color) > edgeStyleAlpha(contrastBase.color));
assert.ok(contrastEmphasis.size > contrastBase.size);
});
it("uses focus highlight only when selected communities exist", () => {
const style = { semanticEmphasis: false, focusHighlight: true };
const selectedCommunities = new Set(["c1"]);
const touchedEdge = sigmaEdgeFixture({
relationType: "依赖",
sourceCommunityId: "c1",
targetCommunityId: "c2",
weight: 0.4
});
const untouchedEdge = sigmaEdgeFixture({
relationType: "依赖",
sourceCommunityId: "c2",
targetCommunityId: "c3",
weight: 0.4
});
const touchedBase = sigmaGlobalEdgeStyle(touchedEdge, "shan-shui");
const touchedFocused = sigmaGlobalEdgeStyle(touchedEdge, "shan-shui", style, selectedCommunities);
const untouchedBase = sigmaGlobalEdgeStyle(untouchedEdge, "shan-shui");
const untouchedFocused = sigmaGlobalEdgeStyle(untouchedEdge, "shan-shui", style, selectedCommunities);
const noSelectionFocused = sigmaGlobalEdgeStyle(untouchedEdge, "shan-shui", style, new Set());
assert.ok(edgeStyleAlpha(touchedFocused.color) >= edgeStyleAlpha(touchedBase.color));
assert.ok(touchedFocused.size >= touchedBase.size);
assert.ok(edgeStyleAlpha(untouchedFocused.color) < edgeStyleAlpha(untouchedBase.color));
assert.ok(untouchedFocused.size < untouchedBase.size);
assert.deepEqual(noSelectionFocused, untouchedBase);
});
it("treats missing community ids as non-bridge edges without accidental focus lift", () => {
const style = { semanticEmphasis: false, focusHighlight: true };
const partialCommunityEdge = sigmaEdgeFixture({
sourceCommunityId: null,
targetCommunityId: "c2",
relationType: "依赖",
weight: 0
});
const missingCommunityEdge = sigmaEdgeFixture({
sourceCommunityId: null,
targetCommunityId: null,
relationType: "依赖",
weight: 0
});
assert.deepEqual(
sigmaGlobalEdgeStyle(partialCommunityEdge, "shan-shui"),
{ color: "rgba(49, 95, 114, 0.1)", size: 0.72 }
);
assert.deepEqual(
sigmaGlobalEdgeStyle(missingCommunityEdge, "shan-shui", style, new Set(["c1"])),
{ color: "rgba(49, 95, 114, 0.05)", size: 0.6 }
);
});
it("keeps the production Sigma boundary on GraphRendererAdapterData instead of raw GraphData", async () => {
const modelSource = await readFile(new URL("../src/render/sigma-graphology-model.ts", import.meta.url), "utf8");
const rendererSource = await readFile(new URL("../src/render/sigma-global-renderer.ts", import.meta.url), "utf8");
assert.match(modelSource, /buildSigmaGlobalGraphologyGraph\(\s*adapterData: GraphRendererAdapterData/);
for (const source of [modelSource, rendererSource]) {
assert.doesNotMatch(source, /GraphData/);
assert.doesNotMatch(source, /buildGraphRendererAdapterData/);
assert.doesNotMatch(source, /\bdata\.nodes\b/);
assert.doesNotMatch(source, /\bdata\.edges\b/);
}
});
it("keeps Sigma community overlay styles passive instead of visible circular controls", async () => {
const styles = await readFile(new URL("../src/render/render-styles.ts", import.meta.url), "utf8");
assert.doesNotMatch(styles, /\.sigma-global-community-wash\b/);
assert.doesNotMatch(styles, /\.sigma-global-aggregation-container\b/);
assert.match(styles, /\.sigma-global-community-label\b/);
});
it("keeps mo-ye renderer backgrounds on the shared paper texture layers", async () => {
const styles = await readFile(new URL("../src/render/render-styles.ts", import.meta.url), "utf8");
const block = styles.match(/\.llm-wiki-graph-engine\[data-theme="mo-ye"\]\s*\{[\s\S]*?background:\s*([\s\S]*?);\n\}/)?.[1] ?? "";
assert.match(block, /var\(--paper-glow/);
assert.match(block, /var\(--paper-vignette/);
assert.match(block, /var\(--paper-mottle/);
});
it("hides confidence rows only inside the Sigma global route legend", async () => {
const styles = await readFile(new URL("../src/render/render-styles.ts", import.meta.url), "utf8");
const hiddenConfidenceSelectors = [...styles.matchAll(/^\s*([^{}\n]*\.graph-edge-legend-group:has\(\.graph-edge-legend-confidence\)[^{}\n]*)\s*\{[^}]*display:\s*none/gm)]
.map((match) => match[1].replace(/\s+/g, " ").trim());
assert.match(styles, /\.sigma-global-route\s+\.graph-edge-legend-group:has\(\.graph-edge-legend-confidence\)\s*\{[\s\S]*display:\s*none/);
assert.deepEqual(hiddenConfidenceSelectors, [
".sigma-global-route .graph-edge-legend-group:has(.graph-edge-legend-confidence)"
]);
});
it("projects Sigma node hits before overlapping community regions", () => {
const projector = createSigmaGlobalHitProjector({
adapterData: adapterDataFixture(),
viewport: { x: 0, y: 0, scale: 1 },
viewportSize: { width: 500, height: 500 }
});
assert.deepEqual(
projector.targetFromSigmaHit({ nodeId: "render-alpha", screenPoint: { x: 111, y: 222 } }),
{ kind: "node", id: "render-alpha" }
);
});
it("uses the graph spatial path for Sigma community-region hits", () => {
const projector = createSigmaGlobalHitProjector({
adapterData: adapterDataFixture(),
viewport: { x: 0, y: 0, scale: 1 },
viewportSize: { width: 500, height: 500 }
});
assert.deepEqual(
projector.targetFromSigmaHit({ screenPoint: { x: 250, y: 250 } }),
{ kind: "community-wash", id: "adapter-community" }
);
});
it("renders passive community map labels instead of circular community controls", () => {
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ communityCount: 10, selectedCommunityId: "community-9" }),
theme: "shan-shui",
runtime: fakeRuntime()
});
const communityControls = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-wash");
const communityRegions = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-region");
const labels = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-label");
assert.equal(communityControls.length, 0);
assert.equal(communityRegions.length, 10);
assert.equal(labels.length, 8);
assert.equal(labels[0]?.dataset.communityId, "community-9");
assert.deepEqual(labels.map((label) => label.dataset.communityId), [
"community-9",
"community-10",
"community-8",
"community-7",
"community-6",
"community-5",
"community-4",
"community-3"
]);
for (const label of labels) {
assert.notEqual(label.tagName, "button");
assert.equal(label.getAttribute("role"), null);
assert.equal(label.getAttribute("aria-hidden"), "true");
assert.equal(label.tabIndex, -1);
assert.equal(label.style.pointerEvents, "none");
}
for (const region of communityRegions) {
assert.notEqual(region.tagName, "button");
assert.equal(region.getAttribute("aria-hidden"), "true");
assert.equal(region.tabIndex, -1);
assert.equal(region.style.pointerEvents, "none");
}
renderer.destroy();
});
it("keeps all selected Sigma communities active instead of dimming later selections", () => {
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ communityCount: 3, selectedCommunityIds: ["community-1", "community-2"] }),
theme: "shan-shui",
runtime: fakeRuntime()
});
const selectedOne = renderer.overlayRoot.children.find((child) => child.dataset.communityId === "community-1");
const selectedTwo = renderer.overlayRoot.children.find((child) => child.dataset.communityId === "community-2");
const unselected = renderer.overlayRoot.children.find((child) => child.dataset.communityId === "community-3");
const labels = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-label");
const labelOne = labels.find((child) => child.dataset.communityId === "community-1");
const labelTwo = labels.find((child) => child.dataset.communityId === "community-2");
const labelThree = labels.find((child) => child.dataset.communityId === "community-3");
assert.equal(selectedOne?.dataset.selected, "true");
assert.equal(selectedTwo?.dataset.selected, "true");
assert.equal(unselected?.dataset.selected, "false");
assert.equal(labelOne?.dataset.dim, "false");
assert.equal(labelTwo?.dataset.dim, "false");
assert.equal(labelThree?.dataset.dim, "true");
renderer.destroy();
});
it("keeps community spotlight overlays at community level without expanding every selected node", () => {
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }),
theme: "shan-shui",
runtime: fakeRuntime()
});
const nodeTargets = renderer.overlayRoot.children
.filter((child) => child.className === "sigma-global-node-hit-target")
.map((child) => child.dataset.nodeId);
assert.deepEqual(nodeTargets.sort(), ["beta-pinned", "beta-search"]);
renderer.destroy();
});
it("renders fallback ellipse clouds and routes SVG shape clicks to community selection", () => {
const hits: unknown[] = [];
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime: fakeRuntime(),
onHitTarget: (target) => hits.push(target)
});
const shape = sigmaCommunityCloudShape(renderer, "adapter-community");
assert.equal(shape?.tagName, "ellipse");
assert.equal(shape?.getAttribute("fill"), "#123456");
assert.equal(shape?.getAttribute("fill-opacity"), "0.2");
assert.match(shape?.getAttribute("filter") ?? "", /^url\(#sigma-community-cloud-blur-/);
shape?.dispatchEvent(new Event("click"));
assert.deepEqual(hits.at(-1), { kind: "community-wash", id: "adapter-community" });
renderer.destroy();
});
it("renders polygon clouds from cached community hull points", () => {
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataWithPolygonCommunityCloud(),
theme: "shan-shui",
runtime: fakeRuntime()
});
const shape = sigmaCommunityCloudShape(renderer, "adapter-community");
const points = shape?.getAttribute("points") ?? "";
assert.equal(shape?.tagName, "polygon");
assert.ok(points.split(" ").length >= 3, `polygon should expose hull points, got ${points}`);
renderer.destroy();
});
it("caps polygon clouds to the computed community wash bounds", () => {
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataWithPolygonCommunityOutlier(),
theme: "shan-shui",
runtime: fakeRuntime()
});
const region = sigmaCommunityRegion(renderer, "adapter-community");
const shape = sigmaCommunityCloudShape(renderer, "adapter-community");
assert.equal(shape?.tagName, "polygon");
assert.ok(Number.parseFloat(region?.style.left ?? "0") >= 170);
assert.ok(Number.parseFloat(region?.style.top ?? "0") >= 190);
assert.ok(Number.parseFloat(region?.style.width ?? "0") <= 160);
assert.ok(Number.parseFloat(region?.style.height ?? "0") <= 120);
renderer.destroy();
});
it("refreshes community cloud geometry after a node drag commits", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataWithPolygonCommunityCloud(),
theme: "shan-shui",
runtime,
pins: {},
onPinsChanged: () => undefined
});
const sigma = runtime.instances[0];
const initialRegion = sigmaCommunityRegion(renderer, "adapter-community");
const initialLeft = Number.parseFloat(initialRegion?.style.left ?? "0");
sigma.emit("downNode", sigmaEventPayload("render-alpha", 111, 222));
sigma.emit("moveBody", sigmaEventPayload(null, 151, 222));
sigma.emit("upStage", sigmaEventPayload(null, 171, 222));
const movedRegion = sigmaCommunityRegion(renderer, "adapter-community");
const movedLeft = Number.parseFloat(movedRegion?.style.left ?? "0");
// Phase 2region 元素按 id 复用(同一实例),拖拽提交后只更新位置/几何。
assert.equal(movedRegion, initialRegion);
assert.ok(movedLeft > initialLeft, `expected cloud to follow dragged node, got ${initialLeft} -> ${movedLeft}`);
renderer.destroy();
});
it("refreshes community cloud geometry across same-id data updates", () => {
const runtime = fakeRuntime();
const initialData = adapterDataWithPolygonCommunityCloud();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: initialData,
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const initialRegion = sigmaCommunityRegion(renderer, "adapter-community");
const initialLeft = Number.parseFloat(initialRegion?.style.left ?? "0");
const movedData: GraphRendererAdapterData = {
...initialData,
nodes: initialData.nodes.map((node) => node.id === "render-alpha"
? { ...node, point: { x: 171, y: node.point.y } }
: node)
};
renderer.update({ adapterData: movedData });
const movedRegion = sigmaCommunityRegion(renderer, "adapter-community");
const movedLeft = Number.parseFloat(movedRegion?.style.left ?? "0");
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "x"), 171);
assert.equal(sigma.setGraphCalls.length, 0);
assert.equal(movedRegion, initialRegion);
assert.ok(movedLeft > initialLeft, `expected cloud to follow updated node, got ${initialLeft} -> ${movedLeft}`);
renderer.destroy();
});
it("rebuilds the Sigma graph when node and edge counts change", () => {
const runtime = fakeRuntime();
const initialData = adapterDataFixture();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: initialData,
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const originalGraph = renderer.graph;
renderer.update({ adapterData: adapterDataWithAddedNodeAndEdge(initialData) });
assert.notEqual(renderer.graph, originalGraph);
assert.equal(sigma.setGraphCalls.length, 1);
assert.equal(renderer.graph.order, 3);
assert.equal(renderer.graph.size, 2);
assert.equal(sigma.graph, renderer.graph);
renderer.destroy();
});
it("patches edge style changes in place without swapping the Sigma graph", () => {
const runtime = fakeRuntime();
const adapterData = adapterDataFixture();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData,
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const originalGraph = renderer.graph;
const originalEdge = { ...renderer.graph.getEdgeAttributes("adapter-edge") };
renderer.update({
adapterData,
edgeStyle: { semanticEmphasis: true, focusHighlight: false }
});
const updatedEdge = renderer.graph.getEdgeAttributes("adapter-edge");
assert.equal(renderer.graph, originalGraph);
assert.equal(sigma.setGraphCalls.length, 0);
assert.notDeepEqual(updatedEdge, originalEdge);
assert.ok(edgeStyleAlpha(updatedEdge.color) < edgeStyleAlpha(originalEdge.color));
assert.ok(updatedEdge.size < originalEdge.size);
renderer.destroy();
});
it("refreshes the Sigma canvas and overlays when the host resizes", () => {
let resizeCallback: ResizeObserverCallback | null = null;
let observedElement: Element | null = null;
let disconnected = false;
class FakeResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe(target: Element): void {
observedElement = target;
}
unobserve(): void {}
disconnect(): void {
disconnected = true;
}
}
const container = fakeContainer({ ResizeObserver: FakeResizeObserver as typeof ResizeObserver });
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container,
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
let refreshCount = 0;
sigma.refresh = () => {
refreshCount += 1;
sigma.settings.refreshed = true;
};
const previousOverlayElement = renderer.overlayRoot.children[0];
assert.equal(observedElement, renderer.root);
assert.equal(sigma.settings.refreshed, undefined);
resizeCallback?.([resizeObserverEntry(480, 320)], {} as ResizeObserver);
assert.equal(sigma.settings.refreshed, true);
assert.equal(refreshCount, 1);
// Phase 2resize 走 reposition 路径,复用已有覆盖层元素(不再重建)。
assert.equal(renderer.overlayRoot.children[0], previousOverlayElement);
const resizedOverlayElement = renderer.overlayRoot.children[0];
resizeCallback?.([resizeObserverEntry(480, 320)], {} as ResizeObserver);
assert.equal(refreshCount, 1);
assert.equal(renderer.overlayRoot.children[0], resizedOverlayElement);
renderer.destroy();
assert.equal(disconnected, true);
});
it("repositions overlays on camera updates without rebuilding DOM or rebinding listeners", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const doc = renderer.root.ownerDocument as unknown as {
createElement: (tag: string) => HTMLElement;
createElementNS: (ns: string, tag: string) => HTMLElement;
};
let created = 0;
const originalCreate = doc.createElement;
const originalCreateNS = doc.createElementNS;
doc.createElement = (tag) => {
created += 1;
return originalCreate(tag);
};
doc.createElementNS = (ns, tag) => {
created += 1;
return originalCreateNS(ns, tag);
};
const overlay = renderer.overlayRoot as unknown as { replaceChildren: (...items: HTMLElement[]) => void };
let replaced = 0;
const originalReplace = overlay.replaceChildren.bind(overlay);
overlay.replaceChildren = (...items) => {
replaced += 1;
originalReplace(...items);
};
const before = [...renderer.overlayRoot.children];
const nodeBefore = before.find((child) => child.dataset.nodeId);
assert.ok(nodeBefore, "fixture should render at least one node hit target");
sigma.emit("afterRender");
sigma.emit("afterRender");
sigma.emit("afterRender");
assert.equal(created, 0, "camera updates must not create overlay elements");
assert.equal(replaced, 0, "camera updates must not replace overlay children");
assert.equal(renderer.overlayRoot.children.length, before.length);
assert.ok(
before.every((child, index) => renderer.overlayRoot.children[index] === child),
"overlay element instances must be reused across camera updates"
);
assert.equal(
renderer.overlayRoot.children.find((child) => child.dataset.nodeId === nodeBefore?.dataset.nodeId),
nodeBefore
);
renderer.destroy();
});
it("uses the overlay animation fast path while the Sigma camera is animated and settles exactly afterward", () => {
const runtime = fakeRuntime({ worldScale: 200 });
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: nodeSpotlightAdapterData({ selectionKind: null }),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.update({ adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }) });
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
sigma.camera.finishAnimation();
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
assert.equal(renderer.overlayRoot.style.willChange, "");
renderer.destroy();
});
it("keeps exact overlay reposition after wheel setState until a prior camera animation settles", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.zoomIn();
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
sigma.mouseCaptor.emitWheel({ x: 240, y: 160, deltaY: 80, deltaMode: 0 });
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
assert.equal(renderer.overlayRoot.style.willChange, "");
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
sigma.camera.finishAnimation();
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
renderer.destroy();
});
it("keeps exact overlay reposition after resetView until an active camera animation settles", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.zoomIn();
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
renderer.resetView();
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
sigma.camera.finishAnimation();
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
renderer.destroy();
});
it("settles exact overlay geometry even when no afterRender fires after animation completion", () => {
const animationFrames: FrameRequestCallback[] = [];
const container = fakeContainer({
requestAnimationFrame: (callback: FrameRequestCallback) => {
animationFrames.push(callback);
return animationFrames.length;
},
cancelAnimationFrame: () => undefined
});
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container,
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.zoomIn();
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
assert.equal(animationFrames.length, 1);
sigma.camera.finishAnimation();
animationFrames.shift()?.(0);
assert.equal(renderer.overlayRoot.style.transform, "");
assert.equal(renderer.overlayRoot.style.willChange, "");
renderer.destroy();
});
it("disables the overlay animation fast path while a node drag is active", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ betaPinned: false }),
theme: "shan-shui",
runtime,
pins: {},
onPinsChanged: () => undefined
});
const sigma = runtime.instances[0];
renderer.zoomIn();
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
sigma.emit("downNode", sigmaEventPayload("render-alpha", 111, 222));
sigma.emit("moveBody", sigmaEventPayload(null, 151, 262));
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
sigma.emit("upStage", sigmaEventPayload(null, 171, 282));
renderer.destroy();
});
it("clears the animation transform when data updates during an active camera animation", () => {
const runtime = fakeRuntime();
const initialData = adapterDataFixture();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: initialData,
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.zoomIn();
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
renderer.update({ adapterData: adapterDataWithAddedNodeAndEdge(initialData) });
assert.equal(renderer.overlayRoot.style.transform, "");
assert.equal(renderer.overlayRoot.style.willChange, "");
renderer.destroy();
});
it("suppresses the overlay animation fast path after resize until the active camera animation settles", () => {
let resizeCallback: ResizeObserverCallback | null = null;
class FakeResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer({ ResizeObserver: FakeResizeObserver as typeof ResizeObserver }),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.zoomIn();
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
resizeCallback?.([resizeObserverEntry(480, 320)], {} as ResizeObserver);
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
sigma.emit("afterRender");
assert.equal(renderer.overlayRoot.style.transform, "");
renderer.destroy();
});
it("can destroy the renderer while an overlay animation transform is active", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.zoomIn();
sigma.emit("afterRender");
assert.match(renderer.overlayRoot.style.transform || "", /^translate\(/);
renderer.destroy();
assert.equal(renderer.overlayRoot.style.transform, "");
assert.equal(renderer.overlayRoot.children.length, 0);
});
it("reuses overlay elements across data updates and prunes removed communities", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ communityCount: 3 }),
theme: "shan-shui",
runtime
});
const regionsBefore = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-region");
const alphaBefore = renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-alpha");
assert.equal(regionsBefore.length, 3);
assert.ok(alphaBefore, "alpha node hit target should render initially");
// Update: collapse to a single community (community-1..3 removed, adapter-community added),
// keep alpha qualifying for the overlay via searchHit so its element can be reused.
renderer.update({
adapterData: adapterDataFixture({ communityCount: 1, selectedNodeId: "render-beta", searchResultIds: ["render-alpha"] })
});
const regionsAfter = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-region");
assert.equal(regionsAfter.length, 1, "removed community regions must be pruned");
assert.equal(regionsAfter[0].dataset.communityId, "adapter-community");
assert.ok(
!renderer.overlayRoot.children.some((child) => child.dataset.communityId === "community-1"),
"stale community-1 region must be gone from the DOM"
);
const alphaAfter = renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-alpha");
assert.equal(alphaAfter, alphaBefore, "node hit target must be reused across update, not recreated");
assert.equal(alphaAfter?.dataset.selected, "false", "reused element must refresh data-derived attributes");
assert.equal(alphaAfter?.dataset.searchHit, "true");
const betaAfter = renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-beta");
assert.equal(betaAfter?.dataset.selected, "true");
renderer.destroy();
});
it("coalesces rapid host resize notifications into one animation frame", () => {
let resizeCallback: ResizeObserverCallback | null = null;
const animationFrames: FrameRequestCallback[] = [];
class FakeResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
const container = fakeContainer({
ResizeObserver: FakeResizeObserver as typeof ResizeObserver,
requestAnimationFrame: (callback: FrameRequestCallback) => {
animationFrames.push(callback);
return animationFrames.length;
},
cancelAnimationFrame: () => undefined
});
const runtime = fakeRuntime();
createSigmaGlobalRenderer({
container,
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
let refreshCount = 0;
sigma.refresh = () => {
refreshCount += 1;
};
resizeCallback?.([resizeObserverEntry(480, 320)], {} as ResizeObserver);
resizeCallback?.([resizeObserverEntry(520, 340)], {} as ResizeObserver);
resizeCallback?.([resizeObserverEntry(560, 360)], {} as ResizeObserver);
assert.equal(refreshCount, 0);
assert.equal(animationFrames.length, 1);
animationFrames.shift()?.(0);
assert.equal(refreshCount, 1);
});
it("keeps dense accepted global data visibly mapped as a capped point map", () => {
const data = densePointMapGraph();
const adapterData = buildGraphRendererAdapterData(data, {
theme: "shan-shui",
selection: { kind: "node", id: "dense-1999" },
searchResultIds: Array.from({ length: 260 }, (_, index) => `dense-${index * 3}`),
pins: Object.fromEntries(
data.nodes.slice(300, 560).map((node, index) => [
node.source_path || `wiki/dense/${node.id}.md`,
{ x: 760 + (index % 24) * 4, y: 440 + Math.floor(index / 24) * 4, coordinateSpace: "world" as const }
])
),
aggregationMarkers: [
{
id: "dense-aggregation",
label: "Dense aggregation should stay hidden",
communityId: "dense-community-0",
nodeIds: data.nodes.slice(0, 200).map((node) => node.id),
totalCount: 200
}
]
});
const graph = buildSigmaGlobalGraphologyGraph(adapterData, { GraphologyGraph });
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData,
theme: "shan-shui",
runtime: fakeRuntime()
});
const ordinarySize = graph.getNodeAttribute("dense-1000", "size");
const selectedSize = graph.getNodeAttribute("dense-1999", "size");
const searchSize = graph.getNodeAttribute("dense-0", "size");
const pinnedSize = graph.getNodeAttribute("dense-300", "size");
const weakEdge = graph.getEdgeAttributes("dense-selected-weak");
const strongEdge = graph.getEdgeAttributes("dense-selected-strong");
const labelCount = graph.filterNodes((nodeId) => graph.getNodeAttribute(nodeId, "labelVisible")).length;
const hitTargets = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-node-hit-target");
const aggregationOverlays = renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-aggregation-container");
assert.equal(adapterData.renderable.counts.visibleNodes, 2000);
assert.equal(adapterData.renderable.counts.totalEdges, 3996);
assert.equal(adapterData.renderable.aggregationContainers.length, 0);
assert.equal(graph.getAttribute("aggregations").length, 1);
assert.equal(graph.order, 2000);
assert.equal(graph.size, 1000);
assert.ok(ordinarySize < selectedSize);
assert.ok(ordinarySize < searchSize);
assert.ok(ordinarySize < pinnedSize);
assert.ok(edgeStyleAlpha(weakEdge.color) < edgeStyleAlpha(strongEdge.color));
assert.ok(weakEdge.size < strongEdge.size);
assert.ok(labelCount <= adapterData.renderable.budget.limits.maxLabels);
assert.equal(aggregationOverlays.length, 0);
assert.ok(hitTargets.length <= 160, `hit target overlays should stay capped, got ${hitTargets.length}`);
assert.ok(hitTargets.some((element) => element.dataset.nodeId === "dense-1999"), "selected anchor should keep a hit target");
assert.ok(hitTargets.some((element) => element.dataset.searchHit === "true"), "search anchors should keep hit targets");
assert.ok(hitTargets.some((element) => element.dataset.pinned === "true"), "pinned anchors should keep hit targets");
renderer.destroy();
});
it("lets passive community label coordinates resolve through the underlying spatial region", () => {
const projector = createSigmaGlobalHitProjector({
adapterData: adapterDataFixture(),
viewport: { x: 0, y: 0, scale: 1 },
viewportSize: { width: 500, height: 500 }
});
assert.deepEqual(
projector.targetFromSigmaHit({ screenPoint: { x: 250, y: 216 } }),
{ kind: "community-wash", id: "adapter-community" }
);
});
it("projects Sigma blank screen hits without inventing graph semantics in the callback", () => {
const projector = createSigmaGlobalHitProjector({
adapterData: adapterDataFixture(),
viewport: { x: 0, y: 0, scale: 1 },
viewportSize: { width: 500, height: 500 }
});
assert.deepEqual(
projector.targetFromSigmaHit({ screenPoint: { x: 490, y: 490 } }),
{ kind: "graph-blank" }
);
});
it("creates, updates, preserves camera state, and destroys the Sigma lifecycle", () => {
const container = fakeContainer();
const runtime = fakeRuntime();
const hits: unknown[] = [];
const renderer = createSigmaGlobalRenderer({
container,
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime,
onHitTarget: (target) => hits.push(target)
});
const sigma = runtime.instances[0];
assert.equal(renderer.id, "sigma-global");
assert.equal(renderer.updateStrategy, "rebuild-graph-preserve-camera");
assert.equal(container.children.length, 1);
assert.equal(renderer.overlayRoot.children.length, 4);
assert.equal(renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-node-hit-target").length, 2);
assert.equal(renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-region").length, 1);
assert.equal(renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-community-label").length, 1);
assert.equal(renderer.overlayRoot.children.filter((child) => child.className === "sigma-global-aggregation-container").length, 0);
assert.equal(sigma.graph.order, 2);
sigma.camera.setState({ x: 12, y: 34, angle: 0.25, ratio: 1.8 });
const originalGraph = renderer.graph;
const nextAdapterData = adapterDataFixture({
selectedNodeId: "render-beta",
searchResultIds: ["render-alpha"],
betaPinned: false
});
renderer.update({ adapterData: nextAdapterData, theme: "mo-ye" });
assert.equal(runtime.instances.length, 1);
assert.notEqual(originalGraph, renderer.graph);
assert.equal(sigma.graph, renderer.graph);
assert.equal(sigma.setGraphCalls.length, 1);
assert.deepEqual(sigma.camera.getState(), { x: 12, y: 34, angle: 0.25, ratio: 1.8 });
assert.equal(renderer.graph.getAttribute("selection").selectedNodeIds[0], "render-beta");
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "searchHit"), true);
assert.equal(renderer.graph.getNodeAttribute("render-beta", "pinned"), false);
assert.equal(renderer.root.dataset.theme, "mo-ye");
sigma.emit("clickNode", { node: "render-beta" });
assert.deepEqual(renderer.lastHitTarget, { kind: "node", id: "render-beta" });
assert.deepEqual(hits.at(-1), { kind: "node", id: "render-beta" });
renderer.destroy();
assert.equal(sigma.killed, true);
assert.equal(container.children.length, 0);
assert.throws(() => renderer.update({ adapterData: adapterDataFixture() }), /destroyed/);
sigma.emit("clickNode", { node: "render-alpha" });
assert.deepEqual(renderer.lastHitTarget, { kind: "node", id: "render-beta" });
});
it("passes Shift-click additive context from Sigma node clicks", () => {
const runtime = fakeRuntime();
const hits: Array<{ target: unknown; additive: boolean }> = [];
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime,
onHitTarget: (target, context) => hits.push({ target, additive: Boolean(context?.additive) })
});
const sigma = runtime.instances[0];
sigma.emit("clickNode", { node: "render-beta", event: { shiftKey: true } });
sigma.emit("clickNode", { node: "render-alpha", event: { shiftKey: false } });
assert.deepEqual(hits, [
{ target: { kind: "node", id: "render-beta" }, additive: true },
{ target: { kind: "node", id: "render-alpha" }, additive: false }
]);
renderer.destroy();
});
it("patches node spotlight attributes in place when community selection changes", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const originalGraph = renderer.graph;
const overlay = renderer.overlayRoot as unknown as { replaceChildren: (...items: HTMLElement[]) => void };
let replaced = 0;
const originalReplace = overlay.replaceChildren.bind(overlay);
overlay.replaceChildren = (...items) => {
replaced += 1;
originalReplace(...items);
};
const originalChildren = [...renderer.overlayRoot.children];
assert.equal(renderer.graph.getNodeAttribute("beta-ordinary", "communityDimmed"), true);
renderer.update({
adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-2" })
});
assert.equal(renderer.graph, originalGraph);
assert.equal(sigma.setGraphCalls.length, 0);
assert.equal(renderer.graph.getNodeAttribute("alpha-ordinary", "communityDimmed"), true);
assert.equal(renderer.graph.getNodeAttribute("beta-ordinary", "communityDimmed"), false);
assert.deepEqual(renderer.graph.getAttribute("selection").input, { kind: "community", id: "community-2" });
assert.equal(replaced, 1);
assert.equal(renderer.overlayRoot.children.length, originalChildren.length);
renderer.destroy();
});
it("animates the Sigma camera to the latest selected community spotlight in Sigma camera coordinates", () => {
const runtime = fakeRuntime({ worldScale: 200 });
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: nodeSpotlightAdapterData({ selectionKind: null }),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.update({ adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }) });
renderer.update({ adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-2" }) });
assert.equal(sigma.camera.animateCalls.length, 2);
assert.deepEqual(sigma.camera.animateCalls.at(0), {
state: { x: 0.48, y: 0.6, angle: 0, ratio: 0.92 },
options: { duration: 380, easing: "quadraticInOut" }
});
assert.deepEqual(sigma.camera.activeAnimationTarget, { x: 0.63, y: 0.7, angle: 0, ratio: 0.846 });
assert.deepEqual(sigma.camera.getState(), { x: 0.63, y: 0.7, angle: 0, ratio: 0.846 });
renderer.destroy();
});
it("sets the Sigma camera instantly when reduced motion is requested", () => {
const runtime = fakeRuntime({ worldScale: 200 });
const renderer = createSigmaGlobalRenderer({
container: fakeContainer({
matchMedia: () => ({ matches: true }) as MediaQueryList
} as FakeDefaultView),
adapterData: nodeSpotlightAdapterData({ selectionKind: null }),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.update({ adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }) });
assert.equal(sigma.camera.animateCalls.length, 0);
assert.deepEqual(sigma.camera.setStateCalls.at(-1), { x: 0.48, y: 0.6, angle: 0, ratio: 0.92 });
assert.deepEqual(sigma.camera.getState(), { x: 0.48, y: 0.6, angle: 0, ratio: 0.92 });
renderer.destroy();
});
it("does not move the Sigma camera when the selected community is already framed", () => {
const runtime = fakeRuntime({ worldScale: 200 });
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: nodeSpotlightAdapterData({ selectionKind: null }),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
sigma.camera.setState({ x: 0.48, y: 0.6, ratio: 0.92 });
renderer.update({ adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }) });
assert.equal(sigma.camera.animateCalls.length, 0);
assert.deepEqual(sigma.camera.getState(), { x: 0.48, y: 0.6, angle: 0, ratio: 0.92 });
renderer.destroy();
});
it("resets the Sigma camera back to the full global composition", () => {
const runtime = fakeRuntime({ worldScale: 200 });
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: nodeSpotlightAdapterData({ selectionKind: null }),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.update({ adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }) });
renderer.resetView();
assert.deepEqual(sigma.camera.setStateCalls.at(-1), { x: 0.5, y: 0.5, angle: 0, ratio: 1 });
assert.deepEqual(sigma.camera.getState(), { x: 0.5, y: 0.5, angle: 0, ratio: 1 });
renderer.destroy();
});
it("keeps the full graph rebuild path for theme changes", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-1" }),
theme: "shan-shui",
runtime
});
renderer.update({
adapterData: nodeSpotlightAdapterData({ selectedCommunityId: "community-2" }),
theme: "mo-ye"
});
assert.equal(runtime.instances[0].setGraphCalls.length, 1);
renderer.destroy();
});
it("reports Sigma initialization failure to the route layer", () => {
const failure = new Error("webgl unavailable");
const errors: unknown[] = [];
assert.throws(
() => createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime: fakeRuntime({ constructError: failure }),
onFatalError: (error) => errors.push(error)
}),
/webgl unavailable/
);
assert.deepEqual(errors, [failure]);
});
it("suppresses stale events after replacement and update-after-destroy", () => {
const firstRuntime = fakeRuntime();
const secondRuntime = fakeRuntime();
const first = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime: firstRuntime
});
const firstSigma = firstRuntime.instances[0];
first.destroy();
const second = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ selectedNodeId: "render-beta" }),
theme: "shan-shui",
runtime: secondRuntime
});
const secondSigma = secondRuntime.instances[0];
firstSigma.emit("clickNode", { node: "render-alpha" });
assert.equal(first.lastHitTarget, null);
assert.equal(second.lastHitTarget, null);
secondSigma.emit("clickNode", { node: "render-beta" });
assert.deepEqual(second.lastHitTarget, { kind: "node", id: "render-beta" });
second.destroy();
assert.throws(() => second.update({ adapterData: adapterDataFixture() }), /destroyed/);
});
it("reports unrecoverable update and destroy errors without choosing fallback UI", () => {
const errors: unknown[] = [];
const runtime = fakeRuntime({ setGraphError: new Error("graph swap failed"), killError: new Error("kill failed") });
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime,
onFatalError: (error) => errors.push(error)
});
renderer.update({ adapterData: adapterDataFixture({ selectedNodeId: "render-beta" }), theme: "mo-ye" });
renderer.destroy();
assert.deepEqual(errors.map((error) => String(error)), ["Error: graph swap failed", "Error: kill failed"]);
});
it("drags a Sigma global node and writes a world-space pin on release", () => {
const runtime = fakeRuntime();
const pins: unknown[] = [];
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ betaPinned: false }),
theme: "shan-shui",
runtime,
pins: {},
onPinsChanged: (nextPins) => pins.push(nextPins)
});
const sigma = runtime.instances[0];
const down = sigmaEventPayload("render-alpha", 116, 228);
const move = sigmaEventPayload(null, 156, 268);
const up = sigmaEventPayload(null, 176, 288);
sigma.emit("downNode", down);
sigma.emit("moveBody", move);
sigma.emit("upStage", up);
assert.equal(down.prevented, true);
assert.equal(move.prevented, true);
assert.equal(up.prevented, true);
assert.deepEqual(pins, [
{
"adapter/alpha.md": {
x: 171,
y: 282,
coordinateSpace: "world"
}
}
]);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "x"), 171);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "y"), 282);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "pinned"), true);
assert.equal(sigma.settings.enableCameraPanning, true);
renderer.destroy();
});
it("keeps selected search and pinned metadata intact while dragging a Sigma global node", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({
selectedNodeId: "render-alpha",
searchResultIds: ["render-alpha"],
betaPinned: true
}),
theme: "shan-shui",
runtime,
pins: {
"adapter/beta.md": { x: 333, y: 444, coordinateSpace: "world" }
},
onPinsChanged: () => undefined
});
const sigma = runtime.instances[0];
sigma.emit("downNode", sigmaEventPayload("render-alpha", 111, 222));
sigma.emit("moveBody", sigmaEventPayload(null, 141, 252));
sigma.emit("upNode", sigmaEventPayload("render-alpha", 151, 262));
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "selected"), true);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "searchHit"), true);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "pinned"), true);
assert.equal(renderer.graph.getNodeAttribute("render-beta", "pinned"), true);
const alphaOverlay = renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-alpha");
const betaOverlay = renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-beta");
assert.equal(alphaOverlay?.dataset.selected, "true");
assert.equal(alphaOverlay?.dataset.searchHit, "true");
assert.equal(alphaOverlay?.dataset.pinned, "true");
assert.equal(betaOverlay?.dataset.pinned, "true");
renderer.destroy();
});
it("keeps an already pinned Sigma global node pinned throughout drag", () => {
const runtime = fakeRuntime();
const pins: unknown[] = [];
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({
selectedNodeId: "render-alpha",
searchResultIds: ["render-alpha"],
alphaPinned: true,
betaPinned: true
}),
theme: "shan-shui",
runtime,
pins: {
"adapter/alpha.md": { x: 111, y: 222, coordinateSpace: "world" },
"adapter/beta.md": { x: 333, y: 444, coordinateSpace: "world" }
},
onPinsChanged: (nextPins) => pins.push(nextPins)
});
const sigma = runtime.instances[0];
sigma.emit("downNode", sigmaEventPayload("render-alpha", 111, 222));
sigma.emit("moveBody", sigmaEventPayload(null, 151, 262));
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "pinned"), true);
assert.equal(renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-alpha")?.dataset.pinned, "true");
sigma.emit("upStage", sigmaEventPayload(null, 171, 282));
assert.deepEqual(pins, [
{
"adapter/alpha.md": { x: 171, y: 282, coordinateSpace: "world" },
"adapter/beta.md": { x: 333, y: 444, coordinateSpace: "world" }
}
]);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "pinned"), true);
renderer.destroy();
});
it("cancels active Sigma node drag on destroy without stale pin writes", () => {
const runtime = fakeRuntime();
const pins: unknown[] = [];
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ betaPinned: false }),
theme: "shan-shui",
runtime,
pins: {},
onPinsChanged: (nextPins) => pins.push(nextPins)
});
const sigma = runtime.instances[0];
sigma.emit("downNode", sigmaEventPayload("render-alpha", 111, 222));
sigma.emit("moveBody", sigmaEventPayload(null, 151, 262));
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "x"), 151);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "y"), 262);
renderer.destroy();
sigma.emit("upStage", sigmaEventPayload(null, 171, 282));
assert.deepEqual(pins, []);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "x"), 111);
assert.equal(renderer.graph.getNodeAttribute("render-alpha", "y"), 222);
assert.equal(sigma.listeners.get("downNode")?.size ?? 0, 0);
assert.equal(sigma.listeners.get("moveBody")?.size ?? 0, 0);
assert.equal(sigma.listeners.get("upStage")?.size ?? 0, 0);
assert.equal(sigma.listeners.get("upNode")?.size ?? 0, 0);
assert.equal(sigma.settings.enableCameraPanning, true);
});
it("does not replace overlays or swallow clicks for an un-moved drag candidate", () => {
const runtime = fakeRuntime();
const hits: unknown[] = [];
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture({ betaPinned: false }),
theme: "shan-shui",
runtime,
pins: {},
onHitTarget: (target) => hits.push(target)
});
const sigma = runtime.instances[0];
const alphaOverlay = renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-alpha");
sigma.emit("downNode", sigmaEventPayload("render-alpha", 111, 222));
sigma.emit("upNode", sigmaEventPayload("render-alpha", 111, 222));
sigma.emit("clickNode", sigmaEventPayload("render-alpha", 111, 222));
assert.equal(renderer.overlayRoot.children.find((child) => child.dataset.nodeId === "render-alpha"), alphaOverlay);
assert.deepEqual(hits, [{ kind: "node", id: "render-alpha" }]);
renderer.destroy();
});
it("configures Sigma camera bounds and a conservative fallback wheel ratio", () => {
const runtime = fakeRuntime();
createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
assert.equal(sigma.settings.minCameraRatio, 0.3);
assert.equal(sigma.settings.maxCameraRatio, 3);
assert.equal(sigma.settings.zoomingRatio, 1.18);
assert.equal(sigma.settings.zoomDuration, 120);
});
it("uses continuous wheel delta to zoom the Sigma camera without default Sigma jumping", () => {
const runtime = fakeRuntime();
createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const small = sigma.mouseCaptor.emitWheel({ x: 240, y: 160, deltaY: 4, deltaMode: 0 });
assert.equal(small.prevented, true);
assert.equal(sigma.camera.animateCalls.length, 0);
assert.deepEqual(sigma.zoomTargets.at(-1)?.point, { x: 240, y: 160 });
assertClose(sigma.zoomTargets.at(-1)?.ratio ?? 0, Math.exp(4 * 0.0016));
assertClose(sigma.camera.getState().ratio, Math.exp(4 * 0.0016));
assert.notEqual(sigma.camera.getState().x, 240, "camera x is not the raw pointer x");
const larger = sigma.mouseCaptor.emitWheel({ x: 240, y: 160, deltaY: 80, deltaMode: 0 });
assert.equal(larger.prevented, true);
assert.equal(sigma.camera.getState().ratio > Math.exp(4 * 0.0016), true);
});
it("zooms in for negative wheel deltas and respects camera bounds", () => {
const runtime = fakeRuntime();
createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
sigma.camera.setState({ ratio: 0.31 });
const wheel = sigma.mouseCaptor.emitWheel({ x: 120, y: 90, deltaY: -1000, deltaMode: 0 });
assert.equal(wheel.prevented, true);
assert.equal(sigma.camera.getState().ratio, 0.3);
});
it("falls back to the viewport center when a wheel delta lacks pointer coordinates", () => {
const runtime = fakeRuntime();
createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const wheel = sigma.mouseCaptor.emitWheel({ deltaY: 80, deltaMode: 0 });
assert.equal(wheel.prevented, true);
assert.deepEqual(sigma.zoomTargets.at(-1)?.point, { x: 500, y: 340 });
assert.equal(sigma.camera.getState().ratio > 1, true);
});
it("prevents Sigma zoom when the wheel starts over the zoom controls", () => {
const runtime = fakeRuntime();
createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
const wheel = sigma.mouseCaptor.emitWheel({
x: 120,
y: 90,
deltaY: 80,
deltaMode: 0,
target: fakeClosestTarget("[data-control=\"sigma-zoom\"]")
});
assert.equal(wheel.prevented, true);
assert.equal(sigma.camera.getState().ratio, 1);
assert.equal(sigma.zoomTargets.length, 0);
const textTargetWheel = sigma.mouseCaptor.emitWheel({
x: 120,
y: 90,
deltaY: 80,
deltaMode: 0,
target: fakeTextTargetInside("[data-control=\"sigma-zoom\"]")
});
assert.equal(textTargetWheel.prevented, true);
assert.equal(sigma.camera.getState().ratio, 1);
assert.equal(sigma.zoomTargets.length, 0);
});
it("exposes button zoom methods and lets wheel zoom override an active button animation without queuing", () => {
const runtime = fakeRuntime();
const renderer = createSigmaGlobalRenderer({
container: fakeContainer(),
adapterData: adapterDataFixture(),
theme: "shan-shui",
runtime
});
const sigma = runtime.instances[0];
renderer.zoomIn();
assert.deepEqual(sigma.zoomTargets.at(-1)?.point, { x: 500, y: 340 });
assertClose(sigma.zoomTargets.at(-1)?.ratio ?? 0, 1 / 1.18);
assert.equal(sigma.camera.getState().x, 0);
assert.equal(sigma.camera.getState().y, 0);
assert.equal(sigma.camera.animateCalls.at(-1)?.options?.duration, 140);
renderer.zoomOut();
assertClose(sigma.zoomTargets.at(-1)?.ratio ?? 0, 1);
// 按钮动画仍在进行(FakeCamera.animated=true),但滚轮必须直接 setState
// 不再排队 animate(duration:1)——这是触控板连续手感的关键(设计 §5)。
const animateCallsBeforeWheel = sigma.camera.animateCalls.length;
const setStateCallsBeforeWheel = sigma.camera.setStateCalls.length;
const takeoverWheel = sigma.mouseCaptor.emitWheel({ x: 240, y: 160, deltaY: 80, deltaMode: 0 });
assert.equal(takeoverWheel.prevented, true);
assert.equal(sigma.camera.animateCalls.length, animateCallsBeforeWheel, "wheel must not queue a new animation");
assert.ok(sigma.camera.setStateCalls.length > setStateCallsBeforeWheel, "wheel must apply via direct setState");
});
function assertClose(actual: number, expected: number, tolerance = 0.000001): void {
assert.ok(Math.abs(actual - expected) <= tolerance, `expected ${actual} to be within ${tolerance} of ${expected}`);
}
});
function adapterDataFixture(options: {
selectedNodeId?: string;
searchResultIds?: string[];
alphaPinned?: boolean;
betaPinned?: boolean;
communityCount?: number;
selectedCommunityId?: string;
selectedCommunityIds?: string[];
} = {}): GraphRendererAdapterData {
const selectedNodeId = options.selectedNodeId ?? "render-alpha";
const searchResultIds = options.searchResultIds ?? ["render-beta"];
const alphaPinned = options.alphaPinned ?? false;
const betaPinned = options.betaPinned ?? true;
const communityCount = options.communityCount ?? 1;
const selectedCommunityIds = options.selectedCommunityIds ?? [options.selectedCommunityId ?? "adapter-community"];
const renderableCommunities = renderableCommunityFixture(communityCount);
return {
counts: {
nodes: 2,
edges: 1,
communities: 1,
hidden: 0,
renderedNodes: 2,
renderedEdges: 1,
aggregationContainers: 1
},
selection: {
input: { kind: "node", id: selectedNodeId },
selectionId: `node:${selectedNodeId}`,
selectedNodeIds: [selectedNodeId],
selectedCommunityIds,
containsCurrentObject: true
},
nodes: [
{
id: "render-alpha",
object: { kind: "node", nodeId: "render-alpha" },
label: "Adapter Alpha",
type: "topic",
communityId: "adapter-community",
sourcePath: "adapter/alpha.md",
point: { x: 111, y: 222 },
selected: selectedNodeId === "render-alpha",
searchHit: searchResultIds.includes("render-alpha"),
pinHint: {
nodeId: "render-alpha",
wikiPath: "adapter/alpha.md",
pinned: alphaPinned,
position: alphaPinned ? { x: 111, y: 222, coordinateSpace: "world" } : null
},
aggregationIds: ["adapter-aggregation"],
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: "render-alpha" }
},
render: {
displayMode: "card",
visualRole: "landmark",
priority: 900,
labelVisible: true
}
},
{
id: "render-beta",
object: { kind: "node", nodeId: "render-beta" },
label: "Adapter Beta",
type: "source",
communityId: "adapter-community",
sourcePath: "adapter/beta.md",
point: { x: 333, y: 444 },
selected: selectedNodeId === "render-beta",
searchHit: searchResultIds.includes("render-beta"),
pinHint: {
nodeId: "render-beta",
wikiPath: "adapter/beta.md",
pinned: betaPinned,
position: betaPinned ? { x: 333, y: 444, coordinateSpace: "world" } : null
},
aggregationIds: ["adapter-aggregation"],
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: "render-beta" }
},
render: {
displayMode: "point",
visualRole: "map-pin",
priority: 100,
labelVisible: false
}
}
],
edges: [
{
id: "adapter-edge",
sourceNodeId: "render-alpha",
targetNodeId: "render-beta",
sourceCommunityId: "adapter-community",
targetCommunityId: "adapter-community",
relationType: "depends-on-adapter",
confidence: "ADAPTER_CONFIDENCE",
weight: 0.75,
render: {
strokeWidth: 3,
opacity: 0.42
}
}
],
communities: [
{
id: "adapter-community",
object: { kind: "community", communityId: "adapter-community" },
label: "Adapter Community",
nodeIds: ["render-alpha", "render-beta"],
nodeCount: 2,
selected: selectedCommunityIds.includes("adapter-community"),
searchResultIds,
pinHints: betaPinned ? [
{
nodeId: "render-beta",
wikiPath: "adapter/beta.md",
pinned: true,
position: { x: 333, y: 444, coordinateSpace: "world" }
}
] : [],
aggregationIds: ["adapter-aggregation"],
drawerTarget: {
summaryKind: "community-summary",
object: { kind: "community", communityId: "adapter-community" }
},
commands: [{ kind: "enter-community", communityId: "adapter-community", label: "进入社区" }]
},
...renderableCommunities
.filter((community) => community.id !== "adapter-community")
.map((community) => ({
id: community.id,
object: { kind: "community" as const, communityId: community.id },
label: community.label,
nodeIds: [],
nodeCount: community.nodeCount,
selected: selectedCommunityIds.includes(community.id),
searchResultIds: [],
pinHints: [],
aggregationIds: [],
drawerTarget: {
summaryKind: "community-summary" as const,
object: { kind: "community" as const, communityId: community.id }
},
commands: [{ kind: "enter-community" as const, communityId: community.id, label: "进入社区" }]
}))
],
aggregations: [
{
id: "adapter-aggregation",
object: {
kind: "aggregation",
aggregationId: "adapter-aggregation",
nodeIds: ["render-alpha", "render-beta"],
communityId: "adapter-community"
},
label: "Adapter Aggregation",
communityId: "adapter-community",
nodeIds: ["render-alpha", "render-beta"],
selectedNodeIds: ["render-alpha"],
searchResultIds,
pinnedNodeIds: betaPinned ? ["render-beta"] : [],
totalCount: 17,
selected: true,
pinHints: betaPinned ? [
{
nodeId: "render-beta",
wikiPath: "adapter/beta.md",
pinned: true,
position: { x: 333, y: 444, coordinateSpace: "world" }
}
] : [],
drawerTarget: {
summaryKind: "community-summary",
object: { kind: "community", communityId: "adapter-community" }
},
commands: [
{
kind: "show-this-object",
object: {
kind: "aggregation",
aggregationId: "adapter-aggregation",
nodeIds: ["render-alpha", "render-beta"],
communityId: "adapter-community"
},
label: "显示这个对象"
}
]
}
],
renderable: {
nodes: [],
edges: [],
communities: renderableCommunities,
aggregationContainers: [
{
id: "adapter-aggregation",
role: "aggregation-container",
label: "Adapter Aggregation",
communityId: "adapter-community",
nodeIds: ["render-alpha", "render-beta"],
nodeCount: 17,
searchHitCount: 1,
pinnedCount: betaPinned ? 1 : 0,
selectedCount: 1,
selected: true,
searchResultIds,
pinnedNodeIds: betaPinned ? ["render-beta"] : [],
selectedNodeIds: [selectedNodeId],
pinHints: betaPinned ? [
{
nodeId: "render-beta",
wikiPath: "adapter/beta.md",
pinned: true,
position: { x: 333, y: 444, coordinateSpace: "world" }
}
] : [],
point: { x: 222, y: 333 },
x: 22,
y: 33,
radius: 44,
color: "#abcdef"
}
],
minimap: { path: "", nodes: [] },
relationLegend: [],
selectedNodeId,
selectedCommunityId: selectedCommunityIds[0] ?? null,
selectedNodeIds: [selectedNodeId],
hiddenNodeIds: new Set(),
searchResultIds,
worldBounds: { minX: 0, maxX: 500, minY: 0, maxY: 500 },
budgets: {
limits: {
maxNodes: 2,
maxEdges: 1,
maxLabels: 1,
maxCards: 1,
maxInteractionUpdates: 3,
maxVisibleCommunities: 1
},
usage: {
nodes: 2,
edges: 1,
labels: 1,
cards: 1,
interactionUpdate: 3,
activeInteraction: 3,
communities: 1,
aggregationContainers: 1
}
},
qualityNotice: null,
communityFocus: null,
communityQuality: {
boundaryCertainty: "high",
skeletonLabel: "stable",
hiddenNodeCount: 0,
hiddenEdgeCount: 0,
stableCoreNodeIds: ["render-alpha"],
stableSkeletonEdgeIds: ["adapter-edge"],
temporaryBoostNodeIds: []
}
}
};
}
function adapterDataWithAddedNodeAndEdge(data: GraphRendererAdapterData = adapterDataFixture()): GraphRendererAdapterData {
const seedNode = data.nodes[0];
const seedEdge = data.edges[0];
if (!seedNode || !seedEdge) throw new Error("adapterDataWithAddedNodeAndEdge requires the default adapter fixture shape");
const addedNode: GraphRendererAdapterData["nodes"][number] = {
...seedNode,
id: "render-gamma",
object: { kind: "node", nodeId: "render-gamma" },
label: "Adapter Gamma",
sourcePath: "adapter/gamma.md",
point: { x: 222, y: 111 },
selected: false,
searchHit: false,
pinHint: {
nodeId: "render-gamma",
wikiPath: "adapter/gamma.md",
pinned: false,
position: null
},
aggregationIds: [],
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: "render-gamma" }
},
render: {
...seedNode.render,
displayMode: "point",
labelVisible: false,
priority: 50
}
};
const addedEdge: GraphRendererAdapterData["edges"][number] = {
...seedEdge,
id: "adapter-edge-added",
sourceNodeId: "render-beta",
targetNodeId: "render-gamma"
};
return {
...data,
counts: {
...data.counts,
nodes: data.counts.nodes + 1,
edges: data.counts.edges + 1,
renderedNodes: data.counts.renderedNodes + 1,
renderedEdges: data.counts.renderedEdges + 1
},
nodes: [...data.nodes, addedNode],
edges: [...data.edges, addedEdge],
communities: data.communities.map((community) => community.id === "adapter-community"
? {
...community,
nodeIds: [...community.nodeIds, addedNode.id],
nodeCount: community.nodeCount + 1
}
: community),
renderable: {
...data.renderable,
budgets: {
...data.renderable.budgets,
limits: {
...data.renderable.budgets.limits,
maxNodes: data.renderable.budgets.limits.maxNodes + 1,
maxEdges: data.renderable.budgets.limits.maxEdges + 1
},
usage: {
...data.renderable.budgets.usage,
nodes: data.renderable.budgets.usage.nodes + 1,
edges: data.renderable.budgets.usage.edges + 1
}
},
communityQuality: {
...data.renderable.communityQuality,
stableCoreNodeIds: [...data.renderable.communityQuality.stableCoreNodeIds, addedNode.id],
stableSkeletonEdgeIds: [...data.renderable.communityQuality.stableSkeletonEdgeIds, addedEdge.id]
}
}
};
}
function nodeSpotlightAdapterData(options: {
selectedCommunityId?: "community-1" | "community-2";
selectionKind?: "community" | "node" | null;
} = {}): GraphRendererAdapterData {
const selectedCommunityId = options.selectedCommunityId ?? "community-1";
const selectionKind = options.selectionKind === undefined ? "community" : options.selectionKind;
const renderableCommunities = renderableCommunityFixture(2);
const nodes: GraphRendererAdapterData["nodes"] = [
spotlightNodeFixture("alpha-ordinary", "community-1", { point: { x: 10, y: 20 } }),
spotlightNodeFixture("alpha-selected", "community-1", { selected: true, point: { x: 20, y: 30 } }),
spotlightNodeFixture("beta-ordinary", "community-2", { point: { x: 110, y: 120 } }),
spotlightNodeFixture("beta-search", "community-2", { searchHit: true, point: { x: 120, y: 130 } }),
spotlightNodeFixture("beta-pinned", "community-2", { pinned: true, point: { x: 130, y: 140 } })
];
const selectedNodeIds = nodes.filter((node) => node.selected).map((node) => node.id);
const searchResultIds = nodes.filter((node) => node.searchHit).map((node) => node.id);
const pinnedNodeIds = nodes.filter((node) => node.pinHint.pinned).map((node) => node.id);
const selectedCommunityIds = selectionKind === "community"
? [selectedCommunityId]
: selectionKind === "node"
? ["community-1"]
: [];
const selectionInput = selectionKind === "community"
? { kind: "community" as const, id: selectedCommunityId }
: selectionKind === "node"
? { kind: "node" as const, id: "alpha-selected" }
: null;
return {
counts: {
nodes: nodes.length,
edges: 0,
communities: 2,
hidden: 0,
renderedNodes: nodes.length,
renderedEdges: 0,
aggregationContainers: 0
},
selection: {
input: selectionInput,
selectionId: selectionInput ? `${selectionInput.kind}:${selectionInput.id}` : null,
selectedNodeIds,
selectedCommunityIds,
containsCurrentObject: Boolean(selectionInput)
},
nodes,
edges: [],
communities: renderableCommunities.map((community) => ({
id: community.id,
object: { kind: "community", communityId: community.id },
label: community.label,
nodeIds: nodes.filter((node) => node.communityId === community.id).map((node) => node.id),
nodeCount: nodes.filter((node) => node.communityId === community.id).length,
selected: selectedCommunityIds.includes(community.id),
searchResultIds: searchResultIds.filter((id) => nodes.find((node) => node.id === id)?.communityId === community.id),
pinHints: nodes
.filter((node) => node.communityId === community.id && node.pinHint.pinned)
.map((node) => node.pinHint),
aggregationIds: [],
drawerTarget: {
summaryKind: "community-summary",
object: { kind: "community", communityId: community.id }
},
commands: [{ kind: "enter-community", communityId: community.id, label: "进入社区" }]
})),
aggregations: [],
renderable: {
nodes: [],
edges: [],
communities: renderableCommunities,
aggregationContainers: [],
minimap: { path: "", nodes: [] },
relationLegend: [],
selectedNodeId: selectedNodeIds[0] ?? null,
selectedCommunityId,
selectedNodeIds,
hiddenNodeIds: new Set(),
searchResultIds,
worldBounds: { minX: 0, maxX: 200, minY: 0, maxY: 200 },
budgets: {
limits: {
maxNodes: nodes.length,
maxEdges: 0,
maxLabels: 0,
maxCards: 0,
maxInteractionUpdates: nodes.length,
maxVisibleCommunities: 2
},
usage: {
nodes: nodes.length,
edges: 0,
labels: 0,
cards: 0,
interactionUpdate: nodes.length,
activeInteraction: nodes.length,
communities: 2,
aggregationContainers: 0
}
},
qualityNotice: null,
communityFocus: null,
communityQuality: {
boundaryCertainty: "high",
skeletonLabel: "stable",
hiddenNodeCount: 0,
hiddenEdgeCount: 0,
stableCoreNodeIds: [],
stableSkeletonEdgeIds: [],
temporaryBoostNodeIds: []
}
}
};
}
function spotlightNodeFixture(
id: string,
communityId: string,
options: {
point: { x: number; y: number };
selected?: boolean;
searchHit?: boolean;
pinned?: boolean;
}
): GraphRendererAdapterData["nodes"][number] {
return {
id,
object: { kind: "node", nodeId: id },
label: id,
type: "topic",
communityId,
sourcePath: `spotlight/${id}.md`,
point: options.point,
selected: Boolean(options.selected),
searchHit: Boolean(options.searchHit),
pinHint: {
nodeId: id,
wikiPath: `spotlight/${id}.md`,
pinned: Boolean(options.pinned),
position: options.pinned ? { x: options.point.x, y: options.point.y, coordinateSpace: "world" } : null
},
aggregationIds: [],
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: id }
},
render: {
displayMode: "point",
visualRole: "map-pin",
priority: 10,
labelVisible: false
}
};
}
function sigmaEdgeFixture(
overrides: Partial<GraphRendererAdapterData["edges"][number]> = {}
): GraphRendererAdapterData["edges"][number] {
return {
id: "style-edge",
sourceNodeId: "style-source",
targetNodeId: "style-target",
sourceCommunityId: "c1",
targetCommunityId: "c1",
relationType: "依赖",
confidence: "EXTRACTED",
weight: 0,
render: {
strokeWidth: 1,
opacity: 1
},
...overrides
};
}
function edgeStyleAlpha(color: string): number {
const match = color.match(/,\s*([0-9.]+)\)$/);
return match ? Number(match[1]) : Number.NaN;
}
function adapterDataWithPolygonCommunityCloud(): GraphRendererAdapterData {
const data = adapterDataFixture({ betaPinned: false });
const thirdNode: GraphRendererAdapterData["nodes"][number] = {
id: "render-gamma",
object: { kind: "node", nodeId: "render-gamma" },
label: "Adapter Gamma",
type: "entity",
communityId: "adapter-community",
sourcePath: "adapter/gamma.md",
point: { x: 180, y: 420 },
selected: false,
searchHit: false,
pinHint: {
nodeId: "render-gamma",
wikiPath: "adapter/gamma.md",
pinned: false,
position: null
},
aggregationIds: [],
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: "render-gamma" }
},
render: {
displayMode: "point",
visualRole: "map-pin",
priority: 50,
labelVisible: false
}
};
return {
...data,
counts: { ...data.counts, nodes: 3, renderedNodes: 3 },
nodes: [...data.nodes, thirdNode],
communities: data.communities.map((community) => community.id === "adapter-community"
? {
...community,
nodeIds: [...community.nodeIds, thirdNode.id],
nodeCount: community.nodeCount + 1
}
: community)
};
}
function adapterDataWithPolygonCommunityOutlier(): GraphRendererAdapterData {
const data = adapterDataWithPolygonCommunityCloud();
const outlierNode: GraphRendererAdapterData["nodes"][number] = {
id: "render-outlier",
object: { kind: "node", nodeId: "render-outlier" },
label: "Adapter Outlier",
type: "entity",
communityId: "adapter-community",
sourcePath: "adapter/outlier.md",
point: { x: 1200, y: 1200 },
selected: false,
searchHit: false,
pinHint: {
nodeId: "render-outlier",
wikiPath: "adapter/outlier.md",
pinned: false,
position: null
},
aggregationIds: [],
drawerTarget: {
summaryKind: "node-summary",
object: { kind: "node", nodeId: "render-outlier" }
},
render: {
displayMode: "point",
visualRole: "map-pin",
priority: 1,
labelVisible: false
}
};
return {
...data,
counts: { ...data.counts, nodes: 4, renderedNodes: 4 },
nodes: [...data.nodes, outlierNode],
communities: data.communities.map((community) => community.id === "adapter-community"
? {
...community,
nodeIds: [...community.nodeIds, outlierNode.id],
nodeCount: community.nodeCount + 1
}
: community)
};
}
function sigmaCommunityCloudShape(renderer: { overlayRoot: HTMLElement & { children: HTMLElement[] } }, communityId: string): HTMLElement | undefined {
const region = renderer.overlayRoot.children.find((child) => child.dataset.communityId === communityId);
const svg = region?.children[0] as HTMLElement | undefined;
return svg?.children[0] as HTMLElement | undefined;
}
function sigmaCommunityRegion(renderer: { overlayRoot: HTMLElement & { children: HTMLElement[] } }, communityId: string): HTMLElement | undefined {
return renderer.overlayRoot.children.find((child) => child.dataset.communityId === communityId);
}
function densePointMapGraph(): GraphData {
const nodeCount = 2000;
const edgeTarget = 3996;
const communityCount = 16;
const nodes = Array.from({ length: nodeCount }, (_, index) => ({
id: `dense-${index}`,
label: `Dense node ${index}`,
type: index % 17 === 0 ? "topic" : index % 29 === 0 ? "source" : "entity",
community: `dense-community-${index % communityCount}`,
source_path: `wiki/dense/dense-${index}.md`,
weight: 100 - (index % 97),
x: (index * 37) % 100,
y: (index * 53) % 100
}));
const edges: NonNullable<GraphData["edges"]> = [
{
id: "dense-selected-weak",
from: "dense-1999",
to: "dense-1",
type: "INFERRED",
confidence: "INFERRED",
relation_type: "依赖",
weight: 0
},
{
id: "dense-selected-strong",
from: "dense-1999",
to: "dense-2",
type: "EXTRACTED",
confidence: "EXTRACTED",
relation_type: "实现",
weight: 1
}
];
for (let index = 0; edges.length < edgeTarget; index += 1) {
const source = index % nodeCount;
const target = (source + 1 + (index % 113)) % nodeCount;
if (source === target) continue;
edges.push({
id: `dense-edge-${index}`,
from: `dense-${source}`,
to: `dense-${target}`,
type: index % 5 === 0 ? "INFERRED" : "EXTRACTED",
confidence: index % 5 === 0 ? "INFERRED" : "EXTRACTED",
relation_type: index % 7 === 0 ? "对比" : "依赖",
weight: (index % 11) / 10
});
}
return {
meta: {
build_date: "2026-06-21T00:00:00.000Z",
wiki_title: "Dense Point Map Fixture",
total_nodes: nodes.length,
total_edges: edges.length
},
nodes,
edges,
learning: {
version: 1,
entry: { recommended_start_node_id: "dense-0", recommended_start_reason: "dense_fixture", default_mode: "global" },
views: {
path: { enabled: false, start_node_id: null, node_ids: [], degraded: true },
community: { enabled: false, community_id: null, label: null, node_ids: [], is_weak: false, degraded: true },
global: { enabled: true, node_ids: nodes.map((node) => node.id), degraded: false }
},
communities: Array.from({ length: communityCount }, (_, index) => ({
id: `dense-community-${index}`,
label: `Dense Community ${index}`,
node_count: nodes.filter((node) => node.community === `dense-community-${index}`).length,
color_index: index,
recommended_start_node_id: index === 0 ? "dense-0" : null
}))
}
};
}
function renderableCommunityFixture(count: number): GraphRendererAdapterData["renderable"]["communities"] {
if (count <= 1) {
return [
{
id: "adapter-community",
label: "Adapter Community",
color: "#123456",
nodeCount: 2,
boundaryCertainty: "high",
wash: { cx: 250, cy: 250, rx: 80, ry: 60, opacity: 0.2 }
}
];
}
return Array.from({ length: count }, (_, index) => {
const communityNumber = index + 1;
return {
id: `community-${communityNumber}`,
label: `Community ${communityNumber}`,
color: `#1234${String(index).padStart(2, "0")}`,
nodeCount: communityNumber,
boundaryCertainty: "high",
wash: { cx: 80 + index * 30, cy: 120 + index * 20, rx: 35 + index, ry: 24 + index, opacity: 0.2 }
};
});
}
type FakeDefaultView = Partial<Pick<Window, "ResizeObserver" | "requestAnimationFrame" | "cancelAnimationFrame">> & {
matchMedia?: Window["matchMedia"];
};
function fakeContainer(defaultView?: FakeDefaultView): HTMLElement & { children: HTMLElement[] } {
const children: HTMLElement[] = [];
const container = {
ownerDocument: {
createElement: (tagName: string) => fakeElement(tagName, defaultView),
createElementNS: (_ns: string, tagName: string) => fakeElement(tagName, defaultView),
defaultView
},
append: (child: HTMLElement) => {
children.push(child);
},
children
} as unknown as HTMLElement & { children: HTMLElement[] };
containerRegistry.push(container);
return container;
}
function resizeObserverEntry(width: number, height: number): ResizeObserverEntry {
return { contentRect: { width, height } as DOMRectReadOnly } as ResizeObserverEntry;
}
function fakeElement(_tagName: string, defaultView?: FakeDefaultView): HTMLElement {
const children: HTMLElement[] = [];
const attributes = new Map<string, string>();
const listeners = new Map<string, EventListenerOrEventListenerObject[]>();
const element = {
tagName: _tagName,
className: "",
dataset: {} as Record<string, string>,
style: {} as Record<string, string>,
children,
tabIndex: -1,
textContent: "",
ownerDocument: null as unknown as Document,
append: (...items: HTMLElement[]) => {
children.push(...items);
},
prepend: (...items: HTMLElement[]) => {
children.unshift(...items);
},
replaceChildren: (...items: HTMLElement[]) => {
children.splice(0, children.length, ...items);
},
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
const list = listeners.get(type) ?? [];
list.push(listener);
listeners.set(type, list);
},
dispatchEvent: (event: Event) => {
for (const listener of listeners.get(event.type) ?? []) {
if (typeof listener === "function") listener.call(element, event);
else listener.handleEvent(event);
}
return true;
},
setAttribute: (name: string, value: string) => {
attributes.set(name, String(value));
},
getAttribute: (name: string) => attributes.get(name) ?? null,
querySelector: () => null,
remove: () => undefined
};
element.ownerDocument = {
createElement: (tagName: string) => {
const child = fakeElement(tagName, defaultView);
child.ownerDocument = element.ownerDocument;
return child;
},
createElementNS: (_ns: string, tagName: string) => {
const child = fakeElement(tagName, defaultView);
child.ownerDocument = element.ownerDocument;
return child;
},
defaultView
} as unknown as Document;
element.remove = () => {
// The fake container owns removal by filtering on object identity below.
for (const container of fakeContainersWith(element as unknown as HTMLElement)) {
const index = container.children.indexOf(element as unknown as HTMLElement);
if (index >= 0) container.children.splice(index, 1);
}
};
return element as unknown as HTMLElement;
}
const containerRegistry: Array<HTMLElement & { children: HTMLElement[] }> = [];
function fakeContainersWith(child: HTMLElement): Array<HTMLElement & { children: HTMLElement[] }> {
return containerRegistry.filter((container) => container.children.includes(child));
}
function fakeRuntime(options: {
constructError?: Error;
setGraphError?: Error;
killError?: Error;
worldScale?: number;
} = {}): SigmaGlobalRendererRuntime & { instances: FakeSigma[] } {
const instances: FakeSigma[] = [];
class RuntimeSigma extends FakeSigma {
constructor(graph: SigmaGlobalGraphologyGraph, container: HTMLElement, settings?: Record<string, unknown>) {
if (options.constructError) throw options.constructError;
super(graph, container, settings, options);
instances.push(this);
}
}
return {
Sigma: RuntimeSigma,
GraphologyGraph,
instances
};
}
function sigmaEventPayload(node: string | null, x: number, y: number): {
node?: string;
event: { x: number; y: number };
prevented: boolean;
preventSigmaDefault: () => void;
} {
const payload = {
...(node ? { node } : {}),
event: { x, y },
prevented: false,
preventSigmaDefault: () => {
payload.prevented = true;
}
};
return payload;
}
function fakeClosestTarget(selector: string): { closest: (query: string) => unknown } {
return {
closest(query: string) {
return query === selector ? this : null;
}
};
}
function fakeTextTargetInside(selector: string): { parentElement: { closest: (query: string) => unknown } } {
return {
parentElement: fakeClosestTarget(selector)
};
}
class FakeMouseCaptor {
private readonly listeners = new Map<string, Set<(payload?: unknown) => void>>();
on(event: string, listener: (payload?: unknown) => void): void {
const listeners = this.listeners.get(event) ?? new Set();
listeners.add(listener);
this.listeners.set(event, listeners);
}
off(event: string, listener: (payload?: unknown) => void): void {
this.listeners.get(event)?.delete(listener);
}
emitWheel(input: {
x?: number;
y?: number;
deltaY: number;
deltaMode?: number;
target?: unknown;
}): { prevented: boolean } {
const payload = {
x: input.x,
y: input.y,
delta: -input.deltaY / 120,
original: {
deltaY: input.deltaY,
deltaMode: input.deltaMode ?? 0,
target: input.target
},
prevented: false,
preventSigmaDefault() {
payload.prevented = true;
}
};
for (const listener of this.listeners.get("wheel") ?? []) listener(payload);
return payload;
}
}
class FakeSigma implements SigmaGlobalSigmaLike {
graph: SigmaGlobalGraphologyGraph;
readonly container: HTMLElement;
readonly settings: Record<string, unknown>;
readonly camera = new FakeCamera();
readonly listeners = new Map<string, Set<(payload?: unknown) => void>>();
readonly setGraphCalls: SigmaGlobalGraphologyGraph[] = [];
readonly mouseCaptor = new FakeMouseCaptor();
readonly zoomTargets: Array<{ point: { x: number; y: number }; ratio: number }> = [];
killed = false;
constructor(
graph: SigmaGlobalGraphologyGraph,
container: HTMLElement,
settings: Record<string, unknown> = {},
private readonly options: { setGraphError?: Error; killError?: Error; worldScale?: number } = {}
) {
this.graph = graph;
this.container = container;
this.settings = settings;
}
getCamera(): FakeCamera {
return this.camera;
}
getGraph(): SigmaGlobalGraphologyGraph {
return this.graph;
}
setGraph(graph: SigmaGlobalGraphologyGraph): void {
if (this.options.setGraphError) throw this.options.setGraphError;
this.graph = graph;
this.setGraphCalls.push(graph);
}
setSetting(key: string, value: unknown): void {
this.settings[key] = value;
}
refresh(): void {
this.settings.refreshed = true;
}
viewportToGraph(point: { x: number; y: number }): { x: number; y: number } {
const scale = this.options.worldScale ?? 1;
return { x: point.x * scale, y: point.y * scale };
}
viewportToFramedGraph(point: { x: number; y: number }): { x: number; y: number } {
return { x: point.x, y: point.y };
}
graphToViewport(point: { x: number; y: number }): { x: number; y: number } {
const scale = this.options.worldScale ?? 1;
return { x: point.x / scale, y: point.y / scale };
}
getMouseCaptor(): FakeMouseCaptor {
return this.mouseCaptor;
}
getViewportZoomedState(point: { x: number; y: number }, newRatio: number): {
x: number;
y: number;
angle: number;
ratio: number;
} {
const current = this.camera.getState();
const ratioDiff = newRatio / current.ratio;
const center = { x: 500, y: 340 };
const graphMousePosition = this.viewportToFramedGraph(point);
const graphCenterPosition = this.viewportToFramedGraph(center);
this.zoomTargets.push({ point: { x: point.x, y: point.y }, ratio: newRatio });
return {
x: (graphMousePosition.x - graphCenterPosition.x) * (1 - ratioDiff) + current.x,
y: (graphMousePosition.y - graphCenterPosition.y) * (1 - ratioDiff) + current.y,
angle: current.angle,
ratio: newRatio
};
}
on(event: string, listener: (payload?: unknown) => void): void {
const listeners = this.listeners.get(event) ?? new Set();
listeners.add(listener);
this.listeners.set(event, listeners);
}
off(event: string, listener: (payload?: unknown) => void): void {
this.listeners.get(event)?.delete(listener);
}
emit(event: string, payload?: unknown): void {
for (const listener of this.listeners.get(event) ?? []) listener(payload);
}
kill(): void {
this.killed = true;
if (this.options.killError) throw this.options.killError;
}
}
class FakeCamera {
private state = { x: 0, y: 0, angle: 0, ratio: 1 };
readonly setStateCalls: Array<Partial<{ x: number; y: number; angle: number; ratio: number }>> = [];
readonly animateCalls: Array<{
state: Partial<{ x: number; y: number; angle: number; ratio: number }>;
options?: { duration?: number; easing?: string };
}> = [];
activeAnimationTarget: Partial<{ x: number; y: number; angle: number; ratio: number }> | null = null;
animated = false;
getState(): { x: number; y: number; angle: number; ratio: number } {
return { ...this.state };
}
setState(state: Partial<{ x: number; y: number; angle: number; ratio: number }>): void {
this.setStateCalls.push({ ...state });
this.state = { ...this.state, ...state };
}
isAnimated(): boolean {
return this.animated;
}
finishAnimation(): void {
this.animated = false;
}
// 乐观同步模拟:animate 立刻 setState 到目标,animated 仅在 duration>1 时为 true。
// 这不反映真实 Sigma 3.x camera.animate 的逐帧 rAF 插值(见 sigma.esm.js),因此
// "wheel 不积压动画""按钮动画被滚轮即时接管"等设计 §5 语义在单测层无法真正证伪
// ——FakeCamera 抹平了动画的进行中状态。这类交互由浏览器回归(smallMove<largeMove
// 与实机手感兜底。
animate(
state: Partial<{ x: number; y: number; angle: number; ratio: number }>,
options?: { duration?: number; easing?: string }
): Promise<void> {
this.animated = Boolean(options?.duration && options.duration > 1);
this.animateCalls.push({ state: { ...state }, options: options ? { ...options } : undefined });
this.activeAnimationTarget = { ...state };
this.setState(state);
return Promise.resolve();
}
}