@musnows/scriverse 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,6 +77,194 @@ export function getRelationshipNodeLabelFontSize(viewScale, nodeCount) {
77
77
  return screenFontSize / scale;
78
78
  }
79
79
 
80
+ function normalizeRelationshipNodeSearchText(value) {
81
+ return String(value ?? "").normalize("NFKC").trim().toLocaleLowerCase("zh-CN");
82
+ }
83
+
84
+ export function searchRelationshipNodes(nodes, query, limit = 8) {
85
+ const normalizedQuery = normalizeRelationshipNodeSearchText(query);
86
+ const maximumResults = Math.max(0, Math.floor(Number(limit) || 0));
87
+ if (!normalizedQuery || maximumResults === 0) return [];
88
+ return (Array.isArray(nodes) ? nodes : [])
89
+ .map((node) => {
90
+ const normalizedName = normalizeRelationshipNodeSearchText(node?.name);
91
+ const normalizedAliases = (Array.isArray(node?.aliases) ? node.aliases : [])
92
+ .map(normalizeRelationshipNodeSearchText)
93
+ .filter(Boolean);
94
+ let score = Number.POSITIVE_INFINITY;
95
+ if (normalizedName === normalizedQuery) score = 0;
96
+ else if (normalizedAliases.some((alias) => alias === normalizedQuery)) score = 1;
97
+ else if (normalizedName.startsWith(normalizedQuery)) score = 2;
98
+ else if (normalizedAliases.some((alias) => alias.startsWith(normalizedQuery))) score = 3;
99
+ else if (normalizedName.includes(normalizedQuery)) score = 4;
100
+ else if (normalizedAliases.some((alias) => alias.includes(normalizedQuery))) score = 5;
101
+ return { node, score };
102
+ })
103
+ .filter((item) => Number.isFinite(item.score))
104
+ .sort((left, right) => (
105
+ left.score - right.score
106
+ || String(left.node?.name ?? "").localeCompare(String(right.node?.name ?? ""), "zh-CN")
107
+ || String(left.node?.id ?? "").localeCompare(String(right.node?.id ?? ""))
108
+ ))
109
+ .slice(0, maximumResults)
110
+ .map((item) => item.node);
111
+ }
112
+
113
+ export function getRelationshipSearchActiveIndex(activeIndex, itemCount, direction) {
114
+ const count = Math.max(0, Math.floor(Number(itemCount) || 0));
115
+ if (!count) return -1;
116
+ const step = Number(direction) < 0 ? -1 : 1;
117
+ if (Number(activeIndex) < 0) return step > 0 ? 0 : count - 1;
118
+ return (Math.floor(Number(activeIndex) || 0) + step + count) % count;
119
+ }
120
+
121
+ function createRelationshipNodeSearch(nodes, options = {}) {
122
+ const search = document.createElement("div");
123
+ search.className = `relationship-node-search${options.variant ? ` is-${options.variant}` : ""}`;
124
+ search.setAttribute("role", "search");
125
+ search.setAttribute("aria-label", options.ariaLabel ?? "搜索关系图人物");
126
+ const searchLabel = document.createElement("label");
127
+ searchLabel.className = "sr-only";
128
+ searchLabel.htmlFor = options.id;
129
+ searchLabel.textContent = "搜索人物姓名或别名";
130
+ const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
131
+ searchIcon.classList.add("relationship-node-search-icon");
132
+ searchIcon.setAttribute("viewBox", "0 0 24 24");
133
+ searchIcon.setAttribute("aria-hidden", "true");
134
+ searchIcon.innerHTML = '<circle cx="11" cy="11" r="6.5"></circle><path d="m16 16 4 4"></path>';
135
+ const searchInput = document.createElement("input");
136
+ searchInput.id = options.id;
137
+ searchInput.type = "search";
138
+ searchInput.maxLength = 120;
139
+ searchInput.placeholder = "搜索人物";
140
+ searchInput.autocomplete = "off";
141
+ searchInput.spellcheck = false;
142
+ searchInput.dataset.testid = options.testId ?? "relationship-node-search";
143
+ searchInput.setAttribute("role", "combobox");
144
+ searchInput.setAttribute("aria-autocomplete", "list");
145
+ searchInput.setAttribute("aria-expanded", "false");
146
+ const searchResults = document.createElement("div");
147
+ searchResults.id = `${options.id}-results`;
148
+ searchResults.className = "relationship-node-search-results hidden";
149
+ searchResults.setAttribute("role", "listbox");
150
+ searchResults.dataset.testid = `${options.testId ?? "relationship-node-search"}-results`;
151
+ searchInput.setAttribute("aria-controls", searchResults.id);
152
+ const searchStatus = document.createElement("output");
153
+ searchStatus.className = "sr-only";
154
+ searchStatus.setAttribute("aria-live", "polite");
155
+ search.append(searchLabel, searchIcon, searchInput, searchResults, searchStatus);
156
+
157
+ let matches = [];
158
+ let activeIndex = -1;
159
+ const closeResults = () => {
160
+ activeIndex = -1;
161
+ searchResults.classList.add("hidden");
162
+ searchInput.setAttribute("aria-expanded", "false");
163
+ searchInput.removeAttribute("aria-activedescendant");
164
+ };
165
+ const updateActiveResult = (nextIndex) => {
166
+ if (!matches.length) return;
167
+ activeIndex = (nextIndex + matches.length) % matches.length;
168
+ const buttons = [...searchResults.querySelectorAll("[role=option]")];
169
+ buttons.forEach((button, index) => {
170
+ const active = index === activeIndex;
171
+ button.classList.toggle("is-active", active);
172
+ button.setAttribute("aria-selected", String(active));
173
+ if (active) {
174
+ searchInput.setAttribute("aria-activedescendant", button.id);
175
+ button.scrollIntoView({ block: "nearest" });
176
+ }
177
+ });
178
+ };
179
+ const selectMatch = (node) => {
180
+ searchInput.value = String(node?.name ?? "");
181
+ closeResults();
182
+ searchStatus.value = `已定位到人物 ${String(node?.name ?? "未知角色")}`;
183
+ options.onSelect?.(node);
184
+ };
185
+ const renderResults = () => {
186
+ const query = searchInput.value;
187
+ searchResults.replaceChildren();
188
+ matches = searchRelationshipNodes(nodes, query, options.limit ?? 8);
189
+ activeIndex = -1;
190
+ searchInput.removeAttribute("aria-activedescendant");
191
+ if (!normalizeRelationshipNodeSearchText(query)) {
192
+ closeResults();
193
+ searchStatus.value = "输入人物姓名或别名后开始搜索";
194
+ return;
195
+ }
196
+ searchResults.classList.remove("hidden");
197
+ searchInput.setAttribute("aria-expanded", "true");
198
+ if (!matches.length) {
199
+ const empty = document.createElement("p");
200
+ empty.className = "relationship-node-search-empty";
201
+ empty.textContent = "没有找到匹配人物";
202
+ searchResults.append(empty);
203
+ searchStatus.value = "没有找到匹配人物";
204
+ return;
205
+ }
206
+ matches.forEach((node, index) => {
207
+ const result = document.createElement("button");
208
+ result.id = `${options.id}-option-${index}`;
209
+ result.type = "button";
210
+ result.setAttribute("role", "option");
211
+ result.setAttribute("aria-selected", "false");
212
+ result.dataset.nodeId = node.id;
213
+ result.dataset.testid = `${options.testId ?? "relationship-node-search"}-option`;
214
+ result.setAttribute("aria-label", `定位到人物 ${node.name}`);
215
+ const name = document.createElement("strong");
216
+ name.textContent = node.name;
217
+ const meta = document.createElement("small");
218
+ const aliases = (Array.isArray(node.aliases) ? node.aliases : []).slice(0, 2);
219
+ meta.textContent = [aliases.length ? `别名:${aliases.join("、")}` : "", `${Math.max(0, Number(node.degree) || 0)} 条关系`].filter(Boolean).join(" · ");
220
+ result.append(name, meta);
221
+ result.addEventListener("pointerdown", (event) => event.preventDefault());
222
+ result.addEventListener("click", () => selectMatch(node));
223
+ searchResults.append(result);
224
+ });
225
+ searchStatus.value = `找到 ${matches.length} 个匹配人物`;
226
+ };
227
+ searchInput.addEventListener("input", renderResults);
228
+ searchInput.addEventListener("focus", renderResults);
229
+ searchInput.addEventListener("keydown", (event) => {
230
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
231
+ event.preventDefault();
232
+ if (searchResults.classList.contains("hidden")) renderResults();
233
+ if (matches.length) {
234
+ const nextIndex = getRelationshipSearchActiveIndex(activeIndex, matches.length, event.key === "ArrowDown" ? 1 : -1);
235
+ updateActiveResult(nextIndex);
236
+ }
237
+ return;
238
+ }
239
+ if (event.key === "Enter" && matches.length) {
240
+ event.preventDefault();
241
+ selectMatch(matches[activeIndex >= 0 ? activeIndex : 0]);
242
+ return;
243
+ }
244
+ if (event.key === "Escape") {
245
+ event.preventDefault();
246
+ closeResults();
247
+ }
248
+ });
249
+ search.addEventListener("focusout", (event) => {
250
+ if (!(event.relatedTarget instanceof Node) || !search.contains(event.relatedTarget)) closeResults();
251
+ });
252
+
253
+ return {
254
+ element: search,
255
+ input: searchInput,
256
+ reset() {
257
+ searchInput.value = "";
258
+ matches = [];
259
+ searchStatus.value = "";
260
+ closeResults();
261
+ },
262
+ destroy() {
263
+ search.remove();
264
+ }
265
+ };
266
+ }
267
+
80
268
  const GALAXY_CELESTIAL_PALETTES = Object.freeze([
81
269
  Object.freeze({ key: "solar", hue: 42, saturation: 96, lightness: 68, color: "#ffc95f", core: "#fff8d4", rim: "#9f3c18", atmosphere: "rgba(255,184,72,.58)", ring: "rgba(255,222,151,.72)" }),
82
270
  Object.freeze({ key: "azure", hue: 211, saturation: 94, lightness: 68, color: "#61b8ff", core: "#effaff", rim: "#173b85", atmosphere: "rgba(79,156,255,.56)", ring: "rgba(164,214,255,.68)" }),
@@ -93,8 +281,13 @@ const GALAXY_CELESTIAL_TYPES = Object.freeze({
93
281
  outer: Object.freeze(["rocky", "ocean", "ice", "volcanic", "dwarf", "ringed"])
94
282
  });
95
283
  export const GALAXY_ROTATION_RADIANS_PER_MS = 0.000012;
284
+ export const GALAXY_TARGET_FRAME_RATE = 30;
285
+ export const GALAXY_FRAME_RATE_OPTIONS = Object.freeze([24, 30, 60, 90, 120, 144, 165, 240]);
96
286
  export const GALAXY_BASE_STAR_COUNT = 7200;
97
287
  export const GALAXY_EDGE_STAR_BOOST_RATIO = 1.1 * 1.1 - 1;
288
+ export const GALAXY_MAX_CANVAS_PIXELS = 4_000_000;
289
+ export const GALAXY_NODE_SIZE_GROWTH_THRESHOLD = 3;
290
+ export const GALAXY_NODE_SIZE_FULL_SCALE_DEGREE = 12;
98
291
  export const GALAXY_LAYOUT_CONFIG = Object.freeze({
99
292
  minimumRadius: 220,
100
293
  radialSpan: 830,
@@ -102,6 +295,11 @@ export const GALAXY_LAYOUT_CONFIG = Object.freeze({
102
295
  desiredEdgeLength: 285
103
296
  });
104
297
 
298
+ export function normalizeGalaxyFrameRate(value) {
299
+ const candidate = Number(value);
300
+ return GALAXY_FRAME_RATE_OPTIONS.includes(candidate) ? candidate : GALAXY_TARGET_FRAME_RATE;
301
+ }
302
+
105
303
  export function formatRelationshipLabel(edge, separator = " · ") {
106
304
  const subtype = String(edge?.subtype ?? "").trim();
107
305
  const keywords = Array.isArray(edge?.keywords)
@@ -824,6 +1022,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
824
1022
  return { destroy() { container.replaceChildren(); } };
825
1023
  }
826
1024
 
1025
+ const rendererId = ++relationshipRendererSequence;
827
1026
  const layout = options.expanded ? NETWORK_LAYOUTS.expanded : NETWORK_LAYOUTS.standard;
828
1027
  const laidOut = layoutRelationshipNetwork(graph, options.seed ?? "relationship-network-v3", { expanded: options.expanded });
829
1028
  const positions = new Map(laidOut.nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
@@ -859,6 +1058,13 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
859
1058
  const toolbar = document.createElement("header");
860
1059
  toolbar.className = "relationship-map-toolbar";
861
1060
  toolbar.innerHTML = `<div><strong>人物关系图谱</strong><small>${graph.stats.nodeCount} 个角色 · ${graph.stats.edgeCount} 条关系</small></div>`;
1061
+ let selectRelationshipNodeFromSearch = () => {};
1062
+ const nodeSearch = createRelationshipNodeSearch(graph.nodes, {
1063
+ id: `relationship-node-search-${rendererId}`,
1064
+ testId: "relationship-node-search",
1065
+ ariaLabel: "在关系图中搜索人物",
1066
+ onSelect: (node) => selectRelationshipNodeFromSearch(node)
1067
+ });
862
1068
  const actions = document.createElement("div");
863
1069
  actions.className = "relationship-map-actions";
864
1070
  if (!options.expanded) {
@@ -889,7 +1095,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
889
1095
  fullscreen.dataset.testid = "relationship-galaxy-open";
890
1096
  fullscreen.addEventListener("click", () => options.onOpenGalaxy?.());
891
1097
  actions.append(fit, reset, fullscreen);
892
- toolbar.append(actions);
1098
+ toolbar.append(nodeSearch.element, actions);
893
1099
 
894
1100
  const viewport = document.createElement("div");
895
1101
  viewport.className = "relationship-mindmap relationship-network relationship-obsidian";
@@ -939,7 +1145,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
939
1145
  svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
940
1146
  svg.setAttribute("preserveAspectRatio", "none");
941
1147
  svg.setAttribute("aria-label", "人物关系连线");
942
- const arrowMarkerId = `relationship-edge-arrow-${++relationshipRendererSequence}`;
1148
+ const arrowMarkerId = `relationship-edge-arrow-${rendererId}`;
943
1149
  const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
944
1150
  const arrowMarker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
945
1151
  arrowMarker.id = arrowMarkerId;
@@ -1489,6 +1695,17 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1489
1695
  viewport.dataset.focusedNodeId = nodeId;
1490
1696
  updateViewTransform(true);
1491
1697
  };
1698
+ selectRelationshipNodeFromSearch = (node) => {
1699
+ if (!nodeElements.has(node.id)) return;
1700
+ freezePhysics();
1701
+ selectedEdgeId = null;
1702
+ selectedId = node.id;
1703
+ hoveredId = null;
1704
+ options.onSelect?.(node.id);
1705
+ applyNodeFocus(node.id);
1706
+ focusViewOnNode(node.id);
1707
+ nodeElements.get(node.id)?.focus({ preventScroll: true });
1708
+ };
1492
1709
  const animatePositions = (targets, duration = 650) => {
1493
1710
  freezePhysics();
1494
1711
  if (animationFrame) window.cancelAnimationFrame(animationFrame);
@@ -1590,6 +1807,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1590
1807
  if (geometryFrame) window.cancelAnimationFrame(geometryFrame);
1591
1808
  pendingDragUpdate = null;
1592
1809
  pinnedDrag = null;
1810
+ nodeSearch.destroy();
1593
1811
  container.replaceChildren();
1594
1812
  },
1595
1813
  getState() {
@@ -1768,7 +1986,7 @@ export function stepGalaxyStarfieldPhysics(stars, attractor = null, options = {}
1768
1986
  return energy;
1769
1987
  }
1770
1988
 
1771
- export function projectGalaxyPoint(point, camera, viewport) {
1989
+ export function projectGalaxyPointInto(point, camera, viewport, target) {
1772
1990
  const relativeX = point.x - Number(camera.targetX ?? 0);
1773
1991
  const relativeY = point.y - Number(camera.targetY ?? 0);
1774
1992
  const relativeZ = point.z - Number(camera.targetZ ?? 0);
@@ -1783,13 +2001,24 @@ export function projectGalaxyPoint(point, camera, viewport) {
1783
2001
  const depth = camera.distance + cameraZ;
1784
2002
  const focalLength = Math.min(viewport.width, viewport.height) * camera.focalRatio;
1785
2003
  const scale = depth > 1 ? focalLength / depth * camera.zoom : 0;
1786
- return {
1787
- x: viewport.width / 2 + cameraX * scale,
1788
- y: viewport.height / 2 + cameraY * scale,
1789
- depth,
1790
- scale,
1791
- visible: depth > 80
1792
- };
2004
+ target.x = viewport.width / 2 + cameraX * scale;
2005
+ target.y = viewport.height / 2 + cameraY * scale;
2006
+ target.depth = depth;
2007
+ target.scale = scale;
2008
+ target.visible = depth > 80;
2009
+ return target;
2010
+ }
2011
+
2012
+ export function projectGalaxyPoint(point, camera, viewport) {
2013
+ return projectGalaxyPointInto(point, camera, viewport, {});
2014
+ }
2015
+
2016
+ export function getGalaxyCanvasPixelRatio(devicePixelRatio, width, height, nodeCount = 0) {
2017
+ const requestedRatio = clamp(Number(devicePixelRatio) || 1, 1, 4);
2018
+ const viewportPixels = Math.max(1, Number(width) || 1) * Math.max(1, Number(height) || 1);
2019
+ const pixelBudgetRatio = Math.sqrt(GALAXY_MAX_CANVAS_PIXELS / viewportPixels);
2020
+ const largeGraphRatio = Number(nodeCount) > 180 ? 1.5 : 2;
2021
+ return clamp(Math.min(requestedRatio, pixelBudgetRatio, largeGraphRatio), 1, requestedRatio);
1793
2022
  }
1794
2023
 
1795
2024
  export function getGalaxyNodeFocusCamera(node, camera) {
@@ -1802,9 +2031,27 @@ export function getGalaxyNodeFocusCamera(node, camera) {
1802
2031
  };
1803
2032
  }
1804
2033
 
1805
- export function getGalaxyNodeAppearance(node, maxDegree) {
2034
+ export function getGalaxyNodeDegreeScale(maxDegree, nodeCount = 0) {
2035
+ const count = Math.max(0, Number(nodeCount) || 0);
2036
+ const scaleFloor = count > 180 ? 12 : count > 120 ? 10 : count > 80 ? 8 : 1;
2037
+ return Math.max(1, Number(maxDegree) || 1, scaleFloor);
2038
+ }
2039
+
2040
+ export function getGalaxyNodeSize(node, maxDegree, nodeCount = 0, appearanceScale = 1) {
1806
2041
  const degree = Math.max(0, Number(node?.degree) || 0);
1807
- const normalizedDegree = clamp(degree / Math.max(1, Number(maxDegree) || 1), 0, 1);
2042
+ const degreeScale = Math.max(
2043
+ GALAXY_NODE_SIZE_FULL_SCALE_DEGREE,
2044
+ getGalaxyNodeDegreeScale(maxDegree, nodeCount)
2045
+ );
2046
+ const scalableDegree = Math.max(0, degree - GALAXY_NODE_SIZE_GROWTH_THRESHOLD);
2047
+ const scalableRange = Math.max(1, degreeScale - GALAXY_NODE_SIZE_GROWTH_THRESHOLD);
2048
+ const normalizedDegree = clamp(scalableDegree / scalableRange, 0, 1);
2049
+ return clamp((10 + Math.sqrt(normalizedDegree) * 28) * Math.max(0.1, Number(appearanceScale) || 1), 8, 48);
2050
+ }
2051
+
2052
+ export function getGalaxyNodeAppearance(node, maxDegree, nodeCount = 0) {
2053
+ const degree = Math.max(0, Number(node?.degree) || 0);
2054
+ const normalizedDegree = clamp(degree / getGalaxyNodeDegreeScale(maxDegree, nodeCount), 0, 1);
1808
2055
  const weightedDegree = Math.max(0, Number(node?.weightedDegree) || 0);
1809
2056
  const confidenceBoost = clamp(weightedDegree / Math.max(1, degree) / 1.35, 0, 1);
1810
2057
  const intensity = clamp(normalizedDegree * 0.8 + confidenceBoost * 0.2, 0, 1);
@@ -1846,6 +2093,13 @@ export function getGalaxyNodeMarkerCenterOffset(nodeSize) {
1846
2093
  return 8 + Math.max(0, Number(nodeSize) || 0) / 2;
1847
2094
  }
1848
2095
 
2096
+ export function getGalaxyNodeLabelOffset(nodeSize, visualScale, devicePixelRatio = 1) {
2097
+ const size = Math.max(0, Number(nodeSize) || 0);
2098
+ const scale = Math.max(0.1, Number(visualScale) || 1);
2099
+ const pixelRatio = clamp(Number(devicePixelRatio) || 1, 1, 4);
2100
+ return Math.round(size * (scale - 1) / 2 * pixelRatio) / pixelRatio;
2101
+ }
2102
+
1849
2103
  export function getGalaxyNodeDepthOpacity(depth) {
1850
2104
  return clamp(1.28 - Math.max(0, Number(depth) || 0) / 4800, 0.72, 1);
1851
2105
  }
@@ -1876,20 +2130,43 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1876
2130
  const stats = dialog.querySelector("#galaxy-stats");
1877
2131
  const detail = dialog.querySelector("#galaxy-detail");
1878
2132
  const shell = dialog.querySelector(".galaxy-shell");
2133
+ const targetFrameRate = normalizeGalaxyFrameRate(options.frameRate);
1879
2134
  const seed = `${options.workId ?? "work"}|${graph.nodes.map((node) => node.id).join("|")}|${graph.edges.length}`;
1880
2135
  const layout = layoutGalaxy(graph, seed);
1881
2136
  const stars = createGalaxyStarfield(`${seed}|stars`);
2137
+ stars.sort((left, right) => left.color.localeCompare(right.color));
1882
2138
  const initialNodePositions = new Map(layout.nodes.map((node) => [node.id, { x: node.x, y: node.y, z: node.z }]));
1883
2139
  const initialCamera = Object.freeze({ yaw: -0.38, pitch: 0.72, distance: 1560, focalRatio: 1.72, zoom: 1, targetX: 0, targetY: 0, targetZ: 0 });
1884
2140
  const camera = { ...initialCamera };
2141
+ const viewport = { width: 1, height: 1 };
2142
+ const backgroundContext = background.getContext("2d");
2143
+ const graphContext = canvas.getContext("2d");
2144
+ const starProjection = {};
2145
+ const centerProjection = {};
2146
+ const gridProjections = [{}, {}, {}, {}];
2147
+ const nodeProjections = new Map(layout.nodes.map((node) => [node.id, {}]));
2148
+ const orderedEdges = graph.edges.map((edge) => ({
2149
+ edge,
2150
+ from: nodeProjections.get(edge.source),
2151
+ to: nodeProjections.get(edge.target),
2152
+ depth: 0
2153
+ }));
2154
+ const edgeById = new Map(graph.edges.map((edge) => [edge.id, edge]));
2155
+ const relatedIds = new Set();
2156
+ const highlightedKeywords = [];
2157
+ const highlightedKeywordSet = new Set();
2158
+ const solidLineDash = [];
2159
+ const pendingLineDash = [5, 6];
1885
2160
  const nodeElements = new Map();
1886
2161
  const cleanups = [];
1887
2162
  let selectedId = null;
1888
2163
  let selectedEdgeId = null;
1889
- let projectedEdges = [];
2164
+ const projectedEdges = [];
1890
2165
  let cameraDrag = null;
1891
2166
  let animationFrame = 0;
1892
2167
  let previousFrameTime = 0;
2168
+ let nextFrameTime = 0;
2169
+ let renderedFrameCount = 0;
1893
2170
  let cameraFocus = null;
1894
2171
  let draggedNode = null;
1895
2172
  let starPhysicsEnergy = 0;
@@ -1897,6 +2174,16 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1897
2174
  let starsVisible = true;
1898
2175
  let gridVisible = false;
1899
2176
  let destroyed = false;
2177
+ let resourcesReleased = false;
2178
+ let backdrop = null;
2179
+ let selectGalaxyNodeFromSearch = () => {};
2180
+ const nodeSearch = createRelationshipNodeSearch(graph.nodes, {
2181
+ id: `galaxy-node-search-${++relationshipRendererSequence}`,
2182
+ testId: "galaxy-node-search",
2183
+ variant: "galaxy",
2184
+ ariaLabel: "在银河图中搜索人物",
2185
+ onSelect: (node) => selectGalaxyNodeFromSearch(node)
2186
+ });
1900
2187
 
1901
2188
  shell.classList.add("is-three-dimensional");
1902
2189
  shell.dataset.sceneDimension = "3";
@@ -1904,41 +2191,51 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1904
2191
  shell.dataset.gridVisible = "false";
1905
2192
  shell.dataset.starPhysicsEnergy = "0";
1906
2193
  shell.dataset.rotationSpeed = String(GALAXY_ROTATION_RADIANS_PER_MS);
2194
+ shell.dataset.targetFrameRate = String(targetFrameRate);
1907
2195
  shell.dataset.layoutMinimumRadius = String(GALAXY_LAYOUT_CONFIG.minimumRadius);
1908
2196
  shell.dataset.layoutRadialSpan = String(GALAXY_LAYOUT_CONFIG.radialSpan);
1909
2197
  shell.dataset.layoutDesiredEdgeLength = String(GALAXY_LAYOUT_CONFIG.desiredEdgeLength);
2198
+ shell.append(nodeSearch.element);
1910
2199
 
1911
2200
  const listen = (target, type, handler, settings) => {
1912
2201
  target.addEventListener(type, handler, settings);
1913
2202
  cleanups.push(() => target.removeEventListener(type, handler, settings));
1914
2203
  };
1915
2204
 
1916
- const resizeCanvas = (target) => {
1917
- const ratio = Math.min(window.devicePixelRatio || 1, 2);
2205
+ const resizeCanvases = () => {
1918
2206
  const rect = shell.getBoundingClientRect();
1919
2207
  const width = Math.max(1, rect.width);
1920
2208
  const height = Math.max(1, rect.height);
2209
+ const ratio = getGalaxyCanvasPixelRatio(window.devicePixelRatio, width, height, layout.nodes.length);
1921
2210
  const pixelWidth = Math.max(1, Math.round(width * ratio));
1922
2211
  const pixelHeight = Math.max(1, Math.round(height * ratio));
1923
- if (target.width !== pixelWidth) target.width = pixelWidth;
1924
- if (target.height !== pixelHeight) target.height = pixelHeight;
1925
- if (target.style.width !== `${width}px`) target.style.width = `${width}px`;
1926
- if (target.style.height !== `${height}px`) target.style.height = `${height}px`;
1927
- const context = target.getContext("2d");
1928
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
1929
- return { context, width, height };
2212
+ let resized = viewport.width !== width || viewport.height !== height;
2213
+ for (const [target, context] of [[background, backgroundContext], [canvas, graphContext]]) {
2214
+ if (target.width !== pixelWidth || target.height !== pixelHeight) {
2215
+ target.width = pixelWidth;
2216
+ target.height = pixelHeight;
2217
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
2218
+ resized = true;
2219
+ }
2220
+ if (target.style.width !== `${width}px`) target.style.width = `${width}px`;
2221
+ if (target.style.height !== `${height}px`) target.style.height = `${height}px`;
2222
+ }
2223
+ viewport.width = width;
2224
+ viewport.height = height;
2225
+ shell.dataset.canvasPixelRatio = ratio.toFixed(3);
2226
+ if (resized || !backdrop) {
2227
+ backdrop = backgroundContext.createRadialGradient(width * 0.53, height * 0.48, 0, width * 0.53, height * 0.48, Math.max(width, height) * 0.78);
2228
+ backdrop.addColorStop(0, "#0b1830");
2229
+ backdrop.addColorStop(0.36, "#07101f");
2230
+ backdrop.addColorStop(0.72, "#03070e");
2231
+ backdrop.addColorStop(1, "#010205");
2232
+ }
1930
2233
  };
1931
2234
 
1932
- const project = (point, width, height) => projectGalaxyPoint(point, camera, { width, height });
1933
-
1934
2235
  const drawBackground = () => {
1935
- const { context, width, height } = resizeCanvas(background);
2236
+ const context = backgroundContext;
2237
+ const { width, height } = viewport;
1936
2238
  context.clearRect(0, 0, width, height);
1937
- const backdrop = context.createRadialGradient(width * 0.53, height * 0.48, 0, width * 0.53, height * 0.48, Math.max(width, height) * 0.78);
1938
- backdrop.addColorStop(0, "#0b1830");
1939
- backdrop.addColorStop(0.36, "#07101f");
1940
- backdrop.addColorStop(0.72, "#03070e");
1941
- backdrop.addColorStop(1, "#010205");
1942
2239
  context.fillStyle = backdrop;
1943
2240
  context.fillRect(0, 0, width, height);
1944
2241
 
@@ -1946,10 +2243,10 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1946
2243
  context.lineWidth = 1;
1947
2244
  context.strokeStyle = "rgba(105,142,182,.06)";
1948
2245
  for (let offset = -1200; offset <= 1200; offset += 160) {
1949
- const horizontalStart = project({ x: -1200, y: 0, z: offset }, width, height);
1950
- const horizontalEnd = project({ x: 1200, y: 0, z: offset }, width, height);
1951
- const verticalStart = project({ x: offset, y: 0, z: -1200 }, width, height);
1952
- const verticalEnd = project({ x: offset, y: 0, z: 1200 }, width, height);
2246
+ const horizontalStart = projectGalaxyPointInto({ x: -1200, y: 0, z: offset }, camera, viewport, gridProjections[0]);
2247
+ const horizontalEnd = projectGalaxyPointInto({ x: 1200, y: 0, z: offset }, camera, viewport, gridProjections[1]);
2248
+ const verticalStart = projectGalaxyPointInto({ x: offset, y: 0, z: -1200 }, camera, viewport, gridProjections[2]);
2249
+ const verticalEnd = projectGalaxyPointInto({ x: offset, y: 0, z: 1200 }, camera, viewport, gridProjections[3]);
1953
2250
  if (horizontalStart.visible && horizontalEnd.visible) {
1954
2251
  context.beginPath();
1955
2252
  context.moveTo(horizontalStart.x, horizontalStart.y);
@@ -1965,7 +2262,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1965
2262
  }
1966
2263
  }
1967
2264
 
1968
- const center = project({ x: 0, y: 0, z: 0 }, width, height);
2265
+ const center = projectGalaxyPointInto({ x: 0, y: 0, z: 0 }, camera, viewport, centerProjection);
1969
2266
  const coreRadius = Math.min(width, height) * 0.28 * camera.zoom;
1970
2267
  const core = context.createRadialGradient(center.x, center.y, 0, center.x, center.y, coreRadius);
1971
2268
  core.addColorStop(0, "rgba(235,247,255,.22)");
@@ -1978,15 +2275,20 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1978
2275
  if (!starsVisible) return;
1979
2276
  context.save();
1980
2277
  context.globalCompositeOperation = "lighter";
2278
+ let starColor = "";
1981
2279
  for (let index = 0; index < stars.length; index += 1) {
1982
2280
  const star = stars[index];
1983
- const point = project(star, width, height);
2281
+ const point = projectGalaxyPointInto(star, camera, viewport, starProjection);
1984
2282
  if (!point.visible || point.x < -8 || point.x > width + 8 || point.y < -8 || point.y > height + 8) continue;
1985
2283
  const perspective = clamp(point.scale / 0.95, 0.32, 2.4);
1986
2284
  const radius = star.size * perspective;
1987
2285
  const twinkle = 0.82 + Math.sin(index * 12.9898 + camera.yaw * 5) * 0.18;
1988
2286
  const alpha = clamp(star.brightness * twinkle * perspective, 0.08, 0.92);
1989
- context.fillStyle = `rgba(${star.color},${alpha})`;
2287
+ if (star.color !== starColor) {
2288
+ starColor = star.color;
2289
+ context.fillStyle = `rgb(${starColor})`;
2290
+ }
2291
+ context.globalAlpha = alpha;
1990
2292
  context.beginPath();
1991
2293
  context.arc(point.x, point.y, Math.max(0.28, radius), 0, Math.PI * 2);
1992
2294
  context.fill();
@@ -1996,10 +2298,12 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1996
2298
 
1997
2299
  const drawGraph = () => {
1998
2300
  if (destroyed || !dialog.open) return;
1999
- const { context, width, height } = resizeCanvas(canvas);
2301
+ const context = graphContext;
2302
+ const { width, height } = viewport;
2000
2303
  context.clearRect(0, 0, width, height);
2001
- const projections = new Map(layout.nodes.map((node) => [node.id, project(node, width, height)]));
2002
- const relatedIds = new Set(selectedId ? [selectedId] : []);
2304
+ for (const node of layout.nodes) projectGalaxyPointInto(node, camera, viewport, nodeProjections.get(node.id));
2305
+ relatedIds.clear();
2306
+ if (selectedId) relatedIds.add(selectedId);
2003
2307
  if (selectedId) {
2004
2308
  for (const edge of graph.edges) {
2005
2309
  if (edge.source === selectedId || edge.target === selectedId) {
@@ -2009,19 +2313,17 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2009
2313
  }
2010
2314
  }
2011
2315
 
2012
- const highlightedKeywords = [];
2013
- const orderedEdges = graph.edges.map((edge) => ({
2014
- edge,
2015
- depth: ((projections.get(edge.source)?.depth ?? 0) + (projections.get(edge.target)?.depth ?? 0)) / 2
2016
- })).sort((left, right) => right.depth - left.depth);
2017
- projectedEdges = orderedEdges.flatMap(({ edge, depth }) => {
2018
- const from = projections.get(edge.source);
2019
- const to = projections.get(edge.target);
2020
- return from?.visible && to?.visible ? [{ edge, from, to, depth }] : [];
2021
- });
2022
- for (const { edge } of orderedEdges) {
2023
- const from = projections.get(edge.source);
2024
- const to = projections.get(edge.target);
2316
+ highlightedKeywords.length = 0;
2317
+ highlightedKeywordSet.clear();
2318
+ projectedEdges.length = 0;
2319
+ for (const projected of orderedEdges) {
2320
+ projected.depth = ((projected.from?.depth ?? 0) + (projected.to?.depth ?? 0)) / 2;
2321
+ }
2322
+ orderedEdges.sort((left, right) => right.depth - left.depth);
2323
+ for (const projected of orderedEdges) {
2324
+ if (projected.from?.visible && projected.to?.visible) projectedEdges.push(projected);
2325
+ }
2326
+ for (const { edge, from, to } of orderedEdges) {
2025
2327
  if (!from?.visible || !to?.visible) continue;
2026
2328
  const edgeSelected = edge.id === selectedEdgeId;
2027
2329
  const highlighted = edgeSelected || (Boolean(selectedId) && (edge.source === selectedId || edge.target === selectedId));
@@ -2036,7 +2338,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2036
2338
  context.lineWidth = 9 * clamp(depthFactor, 0.75, 1.4);
2037
2339
  context.shadowColor = RELATION_STYLE[edge.category].color;
2038
2340
  context.shadowBlur = 15;
2039
- context.setLineDash([]);
2341
+ context.setLineDash(solidLineDash);
2040
2342
  context.beginPath();
2041
2343
  context.moveTo(from.x, from.y);
2042
2344
  context.lineTo(to.x, to.y);
@@ -2045,7 +2347,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2045
2347
  }
2046
2348
  context.strokeStyle = edgeColor;
2047
2349
  context.lineWidth = (edgeSelected ? 4 : highlighted ? 2.1 : 0.55 + edge.confidence) * clamp(depthFactor, 0.55, 1.45);
2048
- context.setLineDash(edge.confirmationStatus === "pending" || edge.category === "uncertain" ? [5, 6] : []);
2350
+ context.setLineDash(edge.confirmationStatus === "pending" || edge.category === "uncertain" ? pendingLineDash : solidLineDash);
2049
2351
  context.beginPath();
2050
2352
  context.moveTo(from.x, from.y);
2051
2353
  context.lineTo(to.x, to.y);
@@ -2062,11 +2364,14 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2062
2364
  }
2063
2365
  if (highlighted) {
2064
2366
  const fullLabel = formatRelationshipLabel(edge);
2065
- highlightedKeywords.push(fullLabel);
2367
+ if (!highlightedKeywordSet.has(fullLabel)) {
2368
+ highlightedKeywordSet.add(fullLabel);
2369
+ highlightedKeywords.push(fullLabel);
2370
+ }
2066
2371
  const label = fullLabel.length > 42 ? `${fullLabel.slice(0, 41)}…` : fullLabel;
2067
2372
  const x = (from.x + to.x) / 2;
2068
2373
  const y = (from.y + to.y) / 2 - 9;
2069
- context.setLineDash([]);
2374
+ context.setLineDash(solidLineDash);
2070
2375
  context.font = '10px "SFMono-Regular", "SF Mono", Menlo, Monaco, monospace';
2071
2376
  context.textAlign = "center";
2072
2377
  context.textBaseline = "middle";
@@ -2077,11 +2382,12 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2077
2382
  context.fillText(label, x, y, labelWidth - 8);
2078
2383
  }
2079
2384
  }
2080
- context.setLineDash([]);
2385
+ context.setLineDash(solidLineDash);
2081
2386
 
2082
2387
  const baseScale = Math.min(width, height) * camera.focalRatio / camera.distance * camera.zoom;
2388
+ const selectedEdge = selectedEdgeId ? edgeById.get(selectedEdgeId) : null;
2083
2389
  for (const node of layout.nodes) {
2084
- const point = projections.get(node.id);
2390
+ const point = nodeProjections.get(node.id);
2085
2391
  const element = nodeElements.get(node.id);
2086
2392
  if (!element || !point) continue;
2087
2393
  const perspective = clamp(point.scale / Math.max(baseScale, 0.01), 0.5, 1.8);
@@ -2089,18 +2395,16 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2089
2395
  element.hidden = !point.visible;
2090
2396
  const nodeSize = Number(element.dataset.nodeSize) || 12;
2091
2397
  const markerCenterOffset = getGalaxyNodeMarkerCenterOffset(nodeSize);
2092
- element.style.transformOrigin = `50% ${markerCenterOffset}px`;
2093
- element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -${markerCenterOffset}px) scale(${perspective * selectedScale})`;
2398
+ const visualScale = perspective * selectedScale;
2399
+ element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -${markerCenterOffset}px)`;
2400
+ element.style.setProperty("--node-marker-scale", visualScale.toFixed(5));
2401
+ const labelOffset = `${getGalaxyNodeLabelOffset(nodeSize, visualScale, window.devicePixelRatio)}px`;
2402
+ if (element.style.getPropertyValue("--node-label-offset") !== labelOffset) {
2403
+ element.style.setProperty("--node-label-offset", labelOffset);
2404
+ }
2094
2405
  element.style.zIndex = String(10000 - Math.round(point.depth));
2095
2406
  element.style.setProperty("--depth-opacity", String(getGalaxyNodeDepthOpacity(point.depth)));
2096
- element.dataset.worldX = node.x.toFixed(2);
2097
- element.dataset.worldY = node.y.toFixed(2);
2098
- element.dataset.worldZ = node.z.toFixed(2);
2099
- element.dataset.projectedDepth = point.depth.toFixed(2);
2100
- element.dataset.projectedScale = point.scale.toFixed(4);
2101
- element.dataset.projectedX = point.x.toFixed(3);
2102
- element.dataset.projectedY = point.y.toFixed(3);
2103
- const edgeEndpoint = Boolean(selectedEdgeId) && graph.edges.some((edge) => edge.id === selectedEdgeId && (edge.source === node.id || edge.target === node.id));
2407
+ const edgeEndpoint = Boolean(selectedEdge) && (selectedEdge.source === node.id || selectedEdge.target === node.id);
2104
2408
  element.classList.toggle("is-selected", node.id === selectedId);
2105
2409
  element.classList.toggle("is-related", Boolean(selectedId) && node.id !== selectedId && relatedIds.has(node.id));
2106
2410
  element.classList.toggle("is-edge-endpoint", edgeEndpoint);
@@ -2109,7 +2413,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2109
2413
  }
2110
2414
  shell.dataset.selectedNodeId = selectedId ?? "";
2111
2415
  shell.dataset.selectedEdgeId = selectedEdgeId ?? "";
2112
- shell.dataset.highlightedKeywords = [...new Set(highlightedKeywords)].join("|");
2416
+ shell.dataset.highlightedKeywords = highlightedKeywords.join("|");
2113
2417
  shell.dataset.cameraYaw = camera.yaw.toFixed(5);
2114
2418
  shell.dataset.cameraPitch = camera.pitch.toFixed(5);
2115
2419
  shell.dataset.cameraDistance = camera.distance.toFixed(1);
@@ -2118,15 +2422,28 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2118
2422
  };
2119
2423
 
2120
2424
  const drawScene = () => {
2425
+ resizeCanvases();
2121
2426
  drawBackground();
2122
2427
  drawGraph();
2428
+ renderedFrameCount += 1;
2429
+ shell.dataset.renderedFrameCount = String(renderedFrameCount);
2123
2430
  };
2124
2431
 
2432
+ const shouldAnimate = () => !paused || Boolean(cameraFocus) || Boolean(draggedNode) || starPhysicsEnergy > 0.01;
2433
+
2125
2434
  const renderFrame = (time) => {
2126
2435
  animationFrame = 0;
2127
- if (destroyed || !dialog.open) return;
2436
+ if (destroyed || !dialog.open || document.hidden) return;
2437
+ const frameInterval = 1000 / targetFrameRate;
2438
+ if (nextFrameTime && time + 1 < nextFrameTime) {
2439
+ if (shouldAnimate()) animationFrame = window.requestAnimationFrame(renderFrame);
2440
+ return;
2441
+ }
2128
2442
  const elapsed = previousFrameTime ? Math.min(50, time - previousFrameTime) : 0;
2129
2443
  previousFrameTime = time;
2444
+ if (!nextFrameTime) nextFrameTime = time;
2445
+ do nextFrameTime += frameInterval;
2446
+ while (nextFrameTime <= time);
2130
2447
  if (cameraFocus) {
2131
2448
  const progress = clamp((time - cameraFocus.startedAt) / cameraFocus.duration, 0, 1);
2132
2449
  const eased = 1 - Math.pow(1 - progress, 3);
@@ -2144,15 +2461,23 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2144
2461
  shell.dataset.starPhysicsEnergy = starPhysicsEnergy.toFixed(3);
2145
2462
  }
2146
2463
  drawScene();
2147
- animationFrame = window.requestAnimationFrame(renderFrame);
2464
+ if (shouldAnimate()) animationFrame = window.requestAnimationFrame(renderFrame);
2148
2465
  };
2149
2466
 
2150
2467
  const startAnimation = () => {
2151
2468
  if (animationFrame || destroyed) return;
2152
2469
  previousFrameTime = 0;
2470
+ nextFrameTime = 0;
2153
2471
  animationFrame = window.requestAnimationFrame(renderFrame);
2154
2472
  };
2155
2473
 
2474
+ const stopAnimation = () => {
2475
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
2476
+ animationFrame = 0;
2477
+ previousFrameTime = 0;
2478
+ nextFrameTime = 0;
2479
+ };
2480
+
2156
2481
  const cancelCameraFocus = () => {
2157
2482
  cameraFocus = null;
2158
2483
  shell.classList.remove("is-focusing-node");
@@ -2214,6 +2539,18 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2214
2539
  detail.append(list);
2215
2540
  };
2216
2541
 
2542
+ const selectGalaxyNode = (node, { moveFocus = false } = {}) => {
2543
+ selectedEdgeId = null;
2544
+ delete shell.dataset.selectedEdgeSource;
2545
+ delete shell.dataset.selectedEdgeTarget;
2546
+ selectedId = node.id;
2547
+ renderDetail(node);
2548
+ focusCameraOnNode(node);
2549
+ drawScene();
2550
+ if (moveFocus) nodeElements.get(node.id)?.focus({ preventScroll: true });
2551
+ };
2552
+ selectGalaxyNodeFromSearch = (node) => selectGalaxyNode(node, { moveFocus: true });
2553
+
2217
2554
  const renderEdgeDetail = (edge) => {
2218
2555
  const selection = getRelationshipEdgeSelection(graph, edge.id);
2219
2556
  if (!selection) return;
@@ -2235,7 +2572,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2235
2572
  nodeElements.clear();
2236
2573
  const maxDegree = Math.max(...layout.nodes.map((node) => node.degree), 1);
2237
2574
  for (const node of layout.nodes) {
2238
- const appearance = getGalaxyNodeAppearance(node, maxDegree);
2575
+ const appearance = getGalaxyNodeAppearance(node, maxDegree, layout.nodes.length);
2239
2576
  const button = document.createElement("button");
2240
2577
  button.type = "button";
2241
2578
  button.className = "galaxy-node";
@@ -2243,9 +2580,12 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2243
2580
  button.dataset.relationshipTier = appearance.tier;
2244
2581
  button.dataset.celestialType = appearance.celestialType;
2245
2582
  button.dataset.celestialPalette = appearance.palette;
2246
- const nodeSize = clamp((10 + Math.sqrt(node.degree / maxDegree) * 28) * appearance.sizeScale, 8, 48);
2583
+ const nodeSize = getGalaxyNodeSize(node, maxDegree, layout.nodes.length, appearance.sizeScale);
2247
2584
  button.style.setProperty("--node-size", `${nodeSize}px`);
2248
2585
  button.dataset.nodeSize = nodeSize.toFixed(3);
2586
+ button.dataset.worldX = node.x.toFixed(2);
2587
+ button.dataset.worldY = node.y.toFixed(2);
2588
+ button.dataset.worldZ = node.z.toFixed(2);
2249
2589
  button.style.setProperty("--node-color", appearance.color);
2250
2590
  button.style.setProperty("--node-core", appearance.coreColor);
2251
2591
  button.style.setProperty("--node-rim", appearance.rimColor);
@@ -2274,10 +2614,11 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2274
2614
  originZ: node.z,
2275
2615
  yaw: camera.yaw,
2276
2616
  pitch: camera.pitch,
2277
- scale: Number(button.dataset.projectedScale) || 1,
2617
+ scale: Number(nodeProjections.get(node.id)?.scale) || 1,
2278
2618
  dragged: false
2279
2619
  };
2280
2620
  draggedNode = node;
2621
+ startAnimation();
2281
2622
  button.setPointerCapture(event.pointerId);
2282
2623
  button.classList.add("is-dragging");
2283
2624
  button.setAttribute("aria-grabbed", "true");
@@ -2298,6 +2639,9 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2298
2639
  node.x = nodeDrag.originX + worldX * cosYaw - worldY * sinYaw * sinPitch;
2299
2640
  node.y = nodeDrag.originY + worldY * cosPitch;
2300
2641
  node.z = nodeDrag.originZ - worldX * sinYaw - worldY * cosYaw * sinPitch;
2642
+ button.dataset.worldX = node.x.toFixed(2);
2643
+ button.dataset.worldY = node.y.toFixed(2);
2644
+ button.dataset.worldZ = node.z.toFixed(2);
2301
2645
  shell.dataset.draggedNodeId = node.id;
2302
2646
  drawScene();
2303
2647
  });
@@ -2317,13 +2661,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2317
2661
  suppressClick = false;
2318
2662
  return;
2319
2663
  }
2320
- selectedEdgeId = null;
2321
- delete shell.dataset.selectedEdgeSource;
2322
- delete shell.dataset.selectedEdgeTarget;
2323
- selectedId = node.id;
2324
- renderDetail(node);
2325
- focusCameraOnNode(node);
2326
- drawScene();
2664
+ selectGalaxyNode(node);
2327
2665
  });
2328
2666
  nodeElements.set(node.id, button);
2329
2667
  nodeLayer.append(button);
@@ -2333,16 +2671,25 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2333
2671
  const reset = () => {
2334
2672
  cancelCameraFocus();
2335
2673
  Object.assign(camera, initialCamera);
2336
- for (const node of layout.nodes) Object.assign(node, initialNodePositions.get(node.id));
2674
+ for (const node of layout.nodes) {
2675
+ Object.assign(node, initialNodePositions.get(node.id));
2676
+ const element = nodeElements.get(node.id);
2677
+ if (element) {
2678
+ element.dataset.worldX = node.x.toFixed(2);
2679
+ element.dataset.worldY = node.y.toFixed(2);
2680
+ element.dataset.worldZ = node.z.toFixed(2);
2681
+ }
2682
+ }
2337
2683
  selectedId = null;
2338
2684
  selectedEdgeId = null;
2339
- projectedEdges = [];
2685
+ projectedEdges.length = 0;
2340
2686
  delete shell.dataset.focusedNodeId;
2341
2687
  delete shell.dataset.draggedNodeId;
2342
2688
  delete shell.dataset.selectedEdgeSource;
2343
2689
  delete shell.dataset.selectedEdgeTarget;
2344
2690
  detail.classList.add("hidden");
2345
2691
  detail.replaceChildren();
2692
+ nodeSearch.reset();
2346
2693
  drawScene();
2347
2694
  };
2348
2695
 
@@ -2360,6 +2707,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2360
2707
  };
2361
2708
 
2362
2709
  const open = () => {
2710
+ if (destroyed) return;
2363
2711
  if (!dialog.open) dialog.showModal();
2364
2712
  paused = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2365
2713
  starsVisible = true;
@@ -2371,14 +2719,16 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2371
2719
  dialog.querySelector("#galaxy-grid").setAttribute("aria-pressed", "false");
2372
2720
  dialog.querySelector("#galaxy-grid").textContent = "显示空间网格";
2373
2721
  updateRotationControl();
2722
+ shell.dataset.resourceState = "active";
2374
2723
  stats.value = `${graph.stats.nodeCount} 个节点 / ${graph.stats.edgeCount} 条关系`;
2375
2724
  renderNodes();
2376
2725
  drawScene();
2377
- startAnimation();
2726
+ if (shouldAnimate()) startAnimation();
2378
2727
  dialog.querySelector("#galaxy-close").focus();
2379
2728
  };
2380
2729
 
2381
2730
  const close = () => {
2731
+ if (destroyed) return;
2382
2732
  if (dialog.open) dialog.close();
2383
2733
  };
2384
2734
 
@@ -2403,13 +2753,17 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2403
2753
  listen(dialog.querySelector("#galaxy-rotation"), "click", () => {
2404
2754
  paused = !paused;
2405
2755
  updateRotationControl();
2756
+ drawScene();
2757
+ if (shouldAnimate()) startAnimation();
2758
+ else stopAnimation();
2406
2759
  });
2407
2760
  listen(shell, "wheel", (event) => {
2761
+ if (event.target.closest('[role="search"]')) return;
2408
2762
  event.preventDefault();
2409
2763
  zoom(event.deltaY > 0 ? 0.9 : 1.1);
2410
2764
  }, { passive: false });
2411
2765
  listen(shell, "pointerdown", (event) => {
2412
- if (event.button !== 0 || event.target.closest("button, aside")) return;
2766
+ if (event.button !== 0 || event.target.closest('button, input, aside, [role="search"]')) return;
2413
2767
  cancelCameraFocus();
2414
2768
  cameraDrag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, yaw: camera.yaw, pitch: camera.pitch, dragged: false };
2415
2769
  shell.classList.add("is-rotating-camera");
@@ -2444,10 +2798,56 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2444
2798
  listen(window, "resize", () => {
2445
2799
  if (dialog.open) drawScene();
2446
2800
  });
2801
+ listen(document, "visibilitychange", () => {
2802
+ if (document.hidden) {
2803
+ stopAnimation();
2804
+ return;
2805
+ }
2806
+ if (dialog.open) {
2807
+ drawScene();
2808
+ if (shouldAnimate()) startAnimation();
2809
+ }
2810
+ });
2811
+
2812
+ const releaseResources = () => {
2813
+ if (resourcesReleased) return;
2814
+ resourcesReleased = true;
2815
+ destroyed = true;
2816
+ stopAnimation();
2817
+ cleanups.splice(0).forEach((cleanup) => cleanup());
2818
+ nodeSearch.destroy();
2819
+ nodeElements.clear();
2820
+ nodeLayer.replaceChildren();
2821
+ detail.classList.add("hidden");
2822
+ detail.replaceChildren();
2823
+ for (const target of [background, canvas]) {
2824
+ target.width = 1;
2825
+ target.height = 1;
2826
+ target.style.removeProperty("width");
2827
+ target.style.removeProperty("height");
2828
+ }
2829
+ stars.length = 0;
2830
+ layout.nodes.length = 0;
2831
+ layout.byId.clear();
2832
+ initialNodePositions.clear();
2833
+ nodeProjections.clear();
2834
+ orderedEdges.length = 0;
2835
+ edgeById.clear();
2836
+ relatedIds.clear();
2837
+ highlightedKeywords.length = 0;
2838
+ highlightedKeywordSet.clear();
2839
+ projectedEdges.length = 0;
2840
+ cameraFocus = null;
2841
+ cameraDrag = null;
2842
+ draggedNode = null;
2843
+ backdrop = null;
2844
+ shell.dataset.resourceState = "released";
2845
+ shell.dataset.starCount = "0";
2846
+ shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused", "is-focusing-node");
2847
+ };
2848
+
2447
2849
  listen(dialog, "close", () => {
2448
- if (animationFrame) window.cancelAnimationFrame(animationFrame);
2449
- animationFrame = 0;
2450
- previousFrameTime = 0;
2850
+ releaseResources();
2451
2851
  options.onClose?.();
2452
2852
  });
2453
2853
 
@@ -2456,14 +2856,8 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2456
2856
  close,
2457
2857
  reset,
2458
2858
  destroy() {
2459
- destroyed = true;
2460
- if (animationFrame) window.cancelAnimationFrame(animationFrame);
2461
- animationFrame = 0;
2462
- cleanups.splice(0).forEach((cleanup) => cleanup());
2463
2859
  if (dialog.open) dialog.close();
2464
- nodeElements.clear();
2465
- nodeLayer.replaceChildren();
2466
- shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused");
2860
+ releaseResources();
2467
2861
  }
2468
2862
  };
2469
2863
  }