2822 lines
101 KiB
TypeScript
2822 lines
101 KiB
TypeScript
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 2:region 元素按 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 2:resize 走 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();
|
||
}
|
||
}
|