@musnows/scriverse 0.6.13 → 0.7.1

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,11 @@ 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]);
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;
98
289
  export const GALAXY_LAYOUT_CONFIG = Object.freeze({
99
290
  minimumRadius: 220,
100
291
  radialSpan: 830,
@@ -102,6 +293,11 @@ export const GALAXY_LAYOUT_CONFIG = Object.freeze({
102
293
  desiredEdgeLength: 285
103
294
  });
104
295
 
296
+ export function normalizeGalaxyFrameRate(value) {
297
+ const candidate = Number(value);
298
+ return GALAXY_FRAME_RATE_OPTIONS.includes(candidate) ? candidate : GALAXY_TARGET_FRAME_RATE;
299
+ }
300
+
105
301
  export function formatRelationshipLabel(edge, separator = " · ") {
106
302
  const subtype = String(edge?.subtype ?? "").trim();
107
303
  const keywords = Array.isArray(edge?.keywords)
@@ -824,6 +1020,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
824
1020
  return { destroy() { container.replaceChildren(); } };
825
1021
  }
826
1022
 
1023
+ const rendererId = ++relationshipRendererSequence;
827
1024
  const layout = options.expanded ? NETWORK_LAYOUTS.expanded : NETWORK_LAYOUTS.standard;
828
1025
  const laidOut = layoutRelationshipNetwork(graph, options.seed ?? "relationship-network-v3", { expanded: options.expanded });
829
1026
  const positions = new Map(laidOut.nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
@@ -859,6 +1056,13 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
859
1056
  const toolbar = document.createElement("header");
860
1057
  toolbar.className = "relationship-map-toolbar";
861
1058
  toolbar.innerHTML = `<div><strong>人物关系图谱</strong><small>${graph.stats.nodeCount} 个角色 · ${graph.stats.edgeCount} 条关系</small></div>`;
1059
+ let selectRelationshipNodeFromSearch = () => {};
1060
+ const nodeSearch = createRelationshipNodeSearch(graph.nodes, {
1061
+ id: `relationship-node-search-${rendererId}`,
1062
+ testId: "relationship-node-search",
1063
+ ariaLabel: "在关系图中搜索人物",
1064
+ onSelect: (node) => selectRelationshipNodeFromSearch(node)
1065
+ });
862
1066
  const actions = document.createElement("div");
863
1067
  actions.className = "relationship-map-actions";
864
1068
  if (!options.expanded) {
@@ -889,7 +1093,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
889
1093
  fullscreen.dataset.testid = "relationship-galaxy-open";
890
1094
  fullscreen.addEventListener("click", () => options.onOpenGalaxy?.());
891
1095
  actions.append(fit, reset, fullscreen);
892
- toolbar.append(actions);
1096
+ toolbar.append(nodeSearch.element, actions);
893
1097
 
894
1098
  const viewport = document.createElement("div");
895
1099
  viewport.className = "relationship-mindmap relationship-network relationship-obsidian";
@@ -939,7 +1143,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
939
1143
  svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
940
1144
  svg.setAttribute("preserveAspectRatio", "none");
941
1145
  svg.setAttribute("aria-label", "人物关系连线");
942
- const arrowMarkerId = `relationship-edge-arrow-${++relationshipRendererSequence}`;
1146
+ const arrowMarkerId = `relationship-edge-arrow-${rendererId}`;
943
1147
  const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
944
1148
  const arrowMarker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
945
1149
  arrowMarker.id = arrowMarkerId;
@@ -1489,6 +1693,17 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1489
1693
  viewport.dataset.focusedNodeId = nodeId;
1490
1694
  updateViewTransform(true);
1491
1695
  };
1696
+ selectRelationshipNodeFromSearch = (node) => {
1697
+ if (!nodeElements.has(node.id)) return;
1698
+ freezePhysics();
1699
+ selectedEdgeId = null;
1700
+ selectedId = node.id;
1701
+ hoveredId = null;
1702
+ options.onSelect?.(node.id);
1703
+ applyNodeFocus(node.id);
1704
+ focusViewOnNode(node.id);
1705
+ nodeElements.get(node.id)?.focus({ preventScroll: true });
1706
+ };
1492
1707
  const animatePositions = (targets, duration = 650) => {
1493
1708
  freezePhysics();
1494
1709
  if (animationFrame) window.cancelAnimationFrame(animationFrame);
@@ -1590,6 +1805,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1590
1805
  if (geometryFrame) window.cancelAnimationFrame(geometryFrame);
1591
1806
  pendingDragUpdate = null;
1592
1807
  pinnedDrag = null;
1808
+ nodeSearch.destroy();
1593
1809
  container.replaceChildren();
1594
1810
  },
1595
1811
  getState() {
@@ -1768,7 +1984,7 @@ export function stepGalaxyStarfieldPhysics(stars, attractor = null, options = {}
1768
1984
  return energy;
1769
1985
  }
1770
1986
 
1771
- export function projectGalaxyPoint(point, camera, viewport) {
1987
+ export function projectGalaxyPointInto(point, camera, viewport, target) {
1772
1988
  const relativeX = point.x - Number(camera.targetX ?? 0);
1773
1989
  const relativeY = point.y - Number(camera.targetY ?? 0);
1774
1990
  const relativeZ = point.z - Number(camera.targetZ ?? 0);
@@ -1783,13 +1999,24 @@ export function projectGalaxyPoint(point, camera, viewport) {
1783
1999
  const depth = camera.distance + cameraZ;
1784
2000
  const focalLength = Math.min(viewport.width, viewport.height) * camera.focalRatio;
1785
2001
  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
- };
2002
+ target.x = viewport.width / 2 + cameraX * scale;
2003
+ target.y = viewport.height / 2 + cameraY * scale;
2004
+ target.depth = depth;
2005
+ target.scale = scale;
2006
+ target.visible = depth > 80;
2007
+ return target;
2008
+ }
2009
+
2010
+ export function projectGalaxyPoint(point, camera, viewport) {
2011
+ return projectGalaxyPointInto(point, camera, viewport, {});
2012
+ }
2013
+
2014
+ export function getGalaxyCanvasPixelRatio(devicePixelRatio, width, height, nodeCount = 0) {
2015
+ const requestedRatio = clamp(Number(devicePixelRatio) || 1, 1, 4);
2016
+ const viewportPixels = Math.max(1, Number(width) || 1) * Math.max(1, Number(height) || 1);
2017
+ const pixelBudgetRatio = Math.sqrt(GALAXY_MAX_CANVAS_PIXELS / viewportPixels);
2018
+ const largeGraphRatio = Number(nodeCount) > 180 ? 1.5 : 2;
2019
+ return clamp(Math.min(requestedRatio, pixelBudgetRatio, largeGraphRatio), 1, requestedRatio);
1793
2020
  }
1794
2021
 
1795
2022
  export function getGalaxyNodeFocusCamera(node, camera) {
@@ -1802,9 +2029,21 @@ export function getGalaxyNodeFocusCamera(node, camera) {
1802
2029
  };
1803
2030
  }
1804
2031
 
1805
- export function getGalaxyNodeAppearance(node, maxDegree) {
2032
+ export function getGalaxyNodeDegreeScale(maxDegree, nodeCount = 0) {
2033
+ const count = Math.max(0, Number(nodeCount) || 0);
2034
+ const scaleFloor = count > 180 ? 12 : count > 120 ? 10 : count > 80 ? 8 : 1;
2035
+ return Math.max(1, Number(maxDegree) || 1, scaleFloor);
2036
+ }
2037
+
2038
+ export function getGalaxyNodeSize(node, maxDegree, nodeCount = 0, appearanceScale = 1) {
1806
2039
  const degree = Math.max(0, Number(node?.degree) || 0);
1807
- const normalizedDegree = clamp(degree / Math.max(1, Number(maxDegree) || 1), 0, 1);
2040
+ const normalizedDegree = clamp(degree / getGalaxyNodeDegreeScale(maxDegree, nodeCount), 0, 1);
2041
+ return clamp((10 + Math.sqrt(normalizedDegree) * 28) * Math.max(0.1, Number(appearanceScale) || 1), 8, 48);
2042
+ }
2043
+
2044
+ export function getGalaxyNodeAppearance(node, maxDegree, nodeCount = 0) {
2045
+ const degree = Math.max(0, Number(node?.degree) || 0);
2046
+ const normalizedDegree = clamp(degree / getGalaxyNodeDegreeScale(maxDegree, nodeCount), 0, 1);
1808
2047
  const weightedDegree = Math.max(0, Number(node?.weightedDegree) || 0);
1809
2048
  const confidenceBoost = clamp(weightedDegree / Math.max(1, degree) / 1.35, 0, 1);
1810
2049
  const intensity = clamp(normalizedDegree * 0.8 + confidenceBoost * 0.2, 0, 1);
@@ -1876,20 +2115,43 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1876
2115
  const stats = dialog.querySelector("#galaxy-stats");
1877
2116
  const detail = dialog.querySelector("#galaxy-detail");
1878
2117
  const shell = dialog.querySelector(".galaxy-shell");
2118
+ const targetFrameRate = normalizeGalaxyFrameRate(options.frameRate);
1879
2119
  const seed = `${options.workId ?? "work"}|${graph.nodes.map((node) => node.id).join("|")}|${graph.edges.length}`;
1880
2120
  const layout = layoutGalaxy(graph, seed);
1881
2121
  const stars = createGalaxyStarfield(`${seed}|stars`);
2122
+ stars.sort((left, right) => left.color.localeCompare(right.color));
1882
2123
  const initialNodePositions = new Map(layout.nodes.map((node) => [node.id, { x: node.x, y: node.y, z: node.z }]));
1883
2124
  const initialCamera = Object.freeze({ yaw: -0.38, pitch: 0.72, distance: 1560, focalRatio: 1.72, zoom: 1, targetX: 0, targetY: 0, targetZ: 0 });
1884
2125
  const camera = { ...initialCamera };
2126
+ const viewport = { width: 1, height: 1 };
2127
+ const backgroundContext = background.getContext("2d");
2128
+ const graphContext = canvas.getContext("2d");
2129
+ const starProjection = {};
2130
+ const centerProjection = {};
2131
+ const gridProjections = [{}, {}, {}, {}];
2132
+ const nodeProjections = new Map(layout.nodes.map((node) => [node.id, {}]));
2133
+ const orderedEdges = graph.edges.map((edge) => ({
2134
+ edge,
2135
+ from: nodeProjections.get(edge.source),
2136
+ to: nodeProjections.get(edge.target),
2137
+ depth: 0
2138
+ }));
2139
+ const edgeById = new Map(graph.edges.map((edge) => [edge.id, edge]));
2140
+ const relatedIds = new Set();
2141
+ const highlightedKeywords = [];
2142
+ const highlightedKeywordSet = new Set();
2143
+ const solidLineDash = [];
2144
+ const pendingLineDash = [5, 6];
1885
2145
  const nodeElements = new Map();
1886
2146
  const cleanups = [];
1887
2147
  let selectedId = null;
1888
2148
  let selectedEdgeId = null;
1889
- let projectedEdges = [];
2149
+ const projectedEdges = [];
1890
2150
  let cameraDrag = null;
1891
2151
  let animationFrame = 0;
1892
2152
  let previousFrameTime = 0;
2153
+ let nextFrameTime = 0;
2154
+ let renderedFrameCount = 0;
1893
2155
  let cameraFocus = null;
1894
2156
  let draggedNode = null;
1895
2157
  let starPhysicsEnergy = 0;
@@ -1897,6 +2159,16 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1897
2159
  let starsVisible = true;
1898
2160
  let gridVisible = false;
1899
2161
  let destroyed = false;
2162
+ let resourcesReleased = false;
2163
+ let backdrop = null;
2164
+ let selectGalaxyNodeFromSearch = () => {};
2165
+ const nodeSearch = createRelationshipNodeSearch(graph.nodes, {
2166
+ id: `galaxy-node-search-${++relationshipRendererSequence}`,
2167
+ testId: "galaxy-node-search",
2168
+ variant: "galaxy",
2169
+ ariaLabel: "在银河图中搜索人物",
2170
+ onSelect: (node) => selectGalaxyNodeFromSearch(node)
2171
+ });
1900
2172
 
1901
2173
  shell.classList.add("is-three-dimensional");
1902
2174
  shell.dataset.sceneDimension = "3";
@@ -1904,41 +2176,51 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1904
2176
  shell.dataset.gridVisible = "false";
1905
2177
  shell.dataset.starPhysicsEnergy = "0";
1906
2178
  shell.dataset.rotationSpeed = String(GALAXY_ROTATION_RADIANS_PER_MS);
2179
+ shell.dataset.targetFrameRate = String(targetFrameRate);
1907
2180
  shell.dataset.layoutMinimumRadius = String(GALAXY_LAYOUT_CONFIG.minimumRadius);
1908
2181
  shell.dataset.layoutRadialSpan = String(GALAXY_LAYOUT_CONFIG.radialSpan);
1909
2182
  shell.dataset.layoutDesiredEdgeLength = String(GALAXY_LAYOUT_CONFIG.desiredEdgeLength);
2183
+ shell.append(nodeSearch.element);
1910
2184
 
1911
2185
  const listen = (target, type, handler, settings) => {
1912
2186
  target.addEventListener(type, handler, settings);
1913
2187
  cleanups.push(() => target.removeEventListener(type, handler, settings));
1914
2188
  };
1915
2189
 
1916
- const resizeCanvas = (target) => {
1917
- const ratio = Math.min(window.devicePixelRatio || 1, 2);
2190
+ const resizeCanvases = () => {
1918
2191
  const rect = shell.getBoundingClientRect();
1919
2192
  const width = Math.max(1, rect.width);
1920
2193
  const height = Math.max(1, rect.height);
2194
+ const ratio = getGalaxyCanvasPixelRatio(window.devicePixelRatio, width, height, layout.nodes.length);
1921
2195
  const pixelWidth = Math.max(1, Math.round(width * ratio));
1922
2196
  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 };
2197
+ let resized = viewport.width !== width || viewport.height !== height;
2198
+ for (const [target, context] of [[background, backgroundContext], [canvas, graphContext]]) {
2199
+ if (target.width !== pixelWidth || target.height !== pixelHeight) {
2200
+ target.width = pixelWidth;
2201
+ target.height = pixelHeight;
2202
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
2203
+ resized = true;
2204
+ }
2205
+ if (target.style.width !== `${width}px`) target.style.width = `${width}px`;
2206
+ if (target.style.height !== `${height}px`) target.style.height = `${height}px`;
2207
+ }
2208
+ viewport.width = width;
2209
+ viewport.height = height;
2210
+ shell.dataset.canvasPixelRatio = ratio.toFixed(3);
2211
+ if (resized || !backdrop) {
2212
+ backdrop = backgroundContext.createRadialGradient(width * 0.53, height * 0.48, 0, width * 0.53, height * 0.48, Math.max(width, height) * 0.78);
2213
+ backdrop.addColorStop(0, "#0b1830");
2214
+ backdrop.addColorStop(0.36, "#07101f");
2215
+ backdrop.addColorStop(0.72, "#03070e");
2216
+ backdrop.addColorStop(1, "#010205");
2217
+ }
1930
2218
  };
1931
2219
 
1932
- const project = (point, width, height) => projectGalaxyPoint(point, camera, { width, height });
1933
-
1934
2220
  const drawBackground = () => {
1935
- const { context, width, height } = resizeCanvas(background);
2221
+ const context = backgroundContext;
2222
+ const { width, height } = viewport;
1936
2223
  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
2224
  context.fillStyle = backdrop;
1943
2225
  context.fillRect(0, 0, width, height);
1944
2226
 
@@ -1946,10 +2228,10 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1946
2228
  context.lineWidth = 1;
1947
2229
  context.strokeStyle = "rgba(105,142,182,.06)";
1948
2230
  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);
2231
+ const horizontalStart = projectGalaxyPointInto({ x: -1200, y: 0, z: offset }, camera, viewport, gridProjections[0]);
2232
+ const horizontalEnd = projectGalaxyPointInto({ x: 1200, y: 0, z: offset }, camera, viewport, gridProjections[1]);
2233
+ const verticalStart = projectGalaxyPointInto({ x: offset, y: 0, z: -1200 }, camera, viewport, gridProjections[2]);
2234
+ const verticalEnd = projectGalaxyPointInto({ x: offset, y: 0, z: 1200 }, camera, viewport, gridProjections[3]);
1953
2235
  if (horizontalStart.visible && horizontalEnd.visible) {
1954
2236
  context.beginPath();
1955
2237
  context.moveTo(horizontalStart.x, horizontalStart.y);
@@ -1965,7 +2247,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1965
2247
  }
1966
2248
  }
1967
2249
 
1968
- const center = project({ x: 0, y: 0, z: 0 }, width, height);
2250
+ const center = projectGalaxyPointInto({ x: 0, y: 0, z: 0 }, camera, viewport, centerProjection);
1969
2251
  const coreRadius = Math.min(width, height) * 0.28 * camera.zoom;
1970
2252
  const core = context.createRadialGradient(center.x, center.y, 0, center.x, center.y, coreRadius);
1971
2253
  core.addColorStop(0, "rgba(235,247,255,.22)");
@@ -1978,15 +2260,20 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1978
2260
  if (!starsVisible) return;
1979
2261
  context.save();
1980
2262
  context.globalCompositeOperation = "lighter";
2263
+ let starColor = "";
1981
2264
  for (let index = 0; index < stars.length; index += 1) {
1982
2265
  const star = stars[index];
1983
- const point = project(star, width, height);
2266
+ const point = projectGalaxyPointInto(star, camera, viewport, starProjection);
1984
2267
  if (!point.visible || point.x < -8 || point.x > width + 8 || point.y < -8 || point.y > height + 8) continue;
1985
2268
  const perspective = clamp(point.scale / 0.95, 0.32, 2.4);
1986
2269
  const radius = star.size * perspective;
1987
2270
  const twinkle = 0.82 + Math.sin(index * 12.9898 + camera.yaw * 5) * 0.18;
1988
2271
  const alpha = clamp(star.brightness * twinkle * perspective, 0.08, 0.92);
1989
- context.fillStyle = `rgba(${star.color},${alpha})`;
2272
+ if (star.color !== starColor) {
2273
+ starColor = star.color;
2274
+ context.fillStyle = `rgb(${starColor})`;
2275
+ }
2276
+ context.globalAlpha = alpha;
1990
2277
  context.beginPath();
1991
2278
  context.arc(point.x, point.y, Math.max(0.28, radius), 0, Math.PI * 2);
1992
2279
  context.fill();
@@ -1996,10 +2283,12 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1996
2283
 
1997
2284
  const drawGraph = () => {
1998
2285
  if (destroyed || !dialog.open) return;
1999
- const { context, width, height } = resizeCanvas(canvas);
2286
+ const context = graphContext;
2287
+ const { width, height } = viewport;
2000
2288
  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] : []);
2289
+ for (const node of layout.nodes) projectGalaxyPointInto(node, camera, viewport, nodeProjections.get(node.id));
2290
+ relatedIds.clear();
2291
+ if (selectedId) relatedIds.add(selectedId);
2003
2292
  if (selectedId) {
2004
2293
  for (const edge of graph.edges) {
2005
2294
  if (edge.source === selectedId || edge.target === selectedId) {
@@ -2009,19 +2298,17 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2009
2298
  }
2010
2299
  }
2011
2300
 
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);
2301
+ highlightedKeywords.length = 0;
2302
+ highlightedKeywordSet.clear();
2303
+ projectedEdges.length = 0;
2304
+ for (const projected of orderedEdges) {
2305
+ projected.depth = ((projected.from?.depth ?? 0) + (projected.to?.depth ?? 0)) / 2;
2306
+ }
2307
+ orderedEdges.sort((left, right) => right.depth - left.depth);
2308
+ for (const projected of orderedEdges) {
2309
+ if (projected.from?.visible && projected.to?.visible) projectedEdges.push(projected);
2310
+ }
2311
+ for (const { edge, from, to } of orderedEdges) {
2025
2312
  if (!from?.visible || !to?.visible) continue;
2026
2313
  const edgeSelected = edge.id === selectedEdgeId;
2027
2314
  const highlighted = edgeSelected || (Boolean(selectedId) && (edge.source === selectedId || edge.target === selectedId));
@@ -2036,7 +2323,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2036
2323
  context.lineWidth = 9 * clamp(depthFactor, 0.75, 1.4);
2037
2324
  context.shadowColor = RELATION_STYLE[edge.category].color;
2038
2325
  context.shadowBlur = 15;
2039
- context.setLineDash([]);
2326
+ context.setLineDash(solidLineDash);
2040
2327
  context.beginPath();
2041
2328
  context.moveTo(from.x, from.y);
2042
2329
  context.lineTo(to.x, to.y);
@@ -2045,7 +2332,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2045
2332
  }
2046
2333
  context.strokeStyle = edgeColor;
2047
2334
  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] : []);
2335
+ context.setLineDash(edge.confirmationStatus === "pending" || edge.category === "uncertain" ? pendingLineDash : solidLineDash);
2049
2336
  context.beginPath();
2050
2337
  context.moveTo(from.x, from.y);
2051
2338
  context.lineTo(to.x, to.y);
@@ -2062,11 +2349,14 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2062
2349
  }
2063
2350
  if (highlighted) {
2064
2351
  const fullLabel = formatRelationshipLabel(edge);
2065
- highlightedKeywords.push(fullLabel);
2352
+ if (!highlightedKeywordSet.has(fullLabel)) {
2353
+ highlightedKeywordSet.add(fullLabel);
2354
+ highlightedKeywords.push(fullLabel);
2355
+ }
2066
2356
  const label = fullLabel.length > 42 ? `${fullLabel.slice(0, 41)}…` : fullLabel;
2067
2357
  const x = (from.x + to.x) / 2;
2068
2358
  const y = (from.y + to.y) / 2 - 9;
2069
- context.setLineDash([]);
2359
+ context.setLineDash(solidLineDash);
2070
2360
  context.font = '10px "SFMono-Regular", "SF Mono", Menlo, Monaco, monospace';
2071
2361
  context.textAlign = "center";
2072
2362
  context.textBaseline = "middle";
@@ -2077,11 +2367,12 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2077
2367
  context.fillText(label, x, y, labelWidth - 8);
2078
2368
  }
2079
2369
  }
2080
- context.setLineDash([]);
2370
+ context.setLineDash(solidLineDash);
2081
2371
 
2082
2372
  const baseScale = Math.min(width, height) * camera.focalRatio / camera.distance * camera.zoom;
2373
+ const selectedEdge = selectedEdgeId ? edgeById.get(selectedEdgeId) : null;
2083
2374
  for (const node of layout.nodes) {
2084
- const point = projections.get(node.id);
2375
+ const point = nodeProjections.get(node.id);
2085
2376
  const element = nodeElements.get(node.id);
2086
2377
  if (!element || !point) continue;
2087
2378
  const perspective = clamp(point.scale / Math.max(baseScale, 0.01), 0.5, 1.8);
@@ -2089,18 +2380,10 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2089
2380
  element.hidden = !point.visible;
2090
2381
  const nodeSize = Number(element.dataset.nodeSize) || 12;
2091
2382
  const markerCenterOffset = getGalaxyNodeMarkerCenterOffset(nodeSize);
2092
- element.style.transformOrigin = `50% ${markerCenterOffset}px`;
2093
2383
  element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -${markerCenterOffset}px) scale(${perspective * selectedScale})`;
2094
2384
  element.style.zIndex = String(10000 - Math.round(point.depth));
2095
2385
  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));
2386
+ const edgeEndpoint = Boolean(selectedEdge) && (selectedEdge.source === node.id || selectedEdge.target === node.id);
2104
2387
  element.classList.toggle("is-selected", node.id === selectedId);
2105
2388
  element.classList.toggle("is-related", Boolean(selectedId) && node.id !== selectedId && relatedIds.has(node.id));
2106
2389
  element.classList.toggle("is-edge-endpoint", edgeEndpoint);
@@ -2109,7 +2392,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2109
2392
  }
2110
2393
  shell.dataset.selectedNodeId = selectedId ?? "";
2111
2394
  shell.dataset.selectedEdgeId = selectedEdgeId ?? "";
2112
- shell.dataset.highlightedKeywords = [...new Set(highlightedKeywords)].join("|");
2395
+ shell.dataset.highlightedKeywords = highlightedKeywords.join("|");
2113
2396
  shell.dataset.cameraYaw = camera.yaw.toFixed(5);
2114
2397
  shell.dataset.cameraPitch = camera.pitch.toFixed(5);
2115
2398
  shell.dataset.cameraDistance = camera.distance.toFixed(1);
@@ -2118,15 +2401,28 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2118
2401
  };
2119
2402
 
2120
2403
  const drawScene = () => {
2404
+ resizeCanvases();
2121
2405
  drawBackground();
2122
2406
  drawGraph();
2407
+ renderedFrameCount += 1;
2408
+ shell.dataset.renderedFrameCount = String(renderedFrameCount);
2123
2409
  };
2124
2410
 
2411
+ const shouldAnimate = () => !paused || Boolean(cameraFocus) || Boolean(draggedNode) || starPhysicsEnergy > 0.01;
2412
+
2125
2413
  const renderFrame = (time) => {
2126
2414
  animationFrame = 0;
2127
- if (destroyed || !dialog.open) return;
2415
+ if (destroyed || !dialog.open || document.hidden) return;
2416
+ const frameInterval = 1000 / targetFrameRate;
2417
+ if (nextFrameTime && time + 1 < nextFrameTime) {
2418
+ if (shouldAnimate()) animationFrame = window.requestAnimationFrame(renderFrame);
2419
+ return;
2420
+ }
2128
2421
  const elapsed = previousFrameTime ? Math.min(50, time - previousFrameTime) : 0;
2129
2422
  previousFrameTime = time;
2423
+ if (!nextFrameTime) nextFrameTime = time;
2424
+ do nextFrameTime += frameInterval;
2425
+ while (nextFrameTime <= time);
2130
2426
  if (cameraFocus) {
2131
2427
  const progress = clamp((time - cameraFocus.startedAt) / cameraFocus.duration, 0, 1);
2132
2428
  const eased = 1 - Math.pow(1 - progress, 3);
@@ -2144,15 +2440,23 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2144
2440
  shell.dataset.starPhysicsEnergy = starPhysicsEnergy.toFixed(3);
2145
2441
  }
2146
2442
  drawScene();
2147
- animationFrame = window.requestAnimationFrame(renderFrame);
2443
+ if (shouldAnimate()) animationFrame = window.requestAnimationFrame(renderFrame);
2148
2444
  };
2149
2445
 
2150
2446
  const startAnimation = () => {
2151
2447
  if (animationFrame || destroyed) return;
2152
2448
  previousFrameTime = 0;
2449
+ nextFrameTime = 0;
2153
2450
  animationFrame = window.requestAnimationFrame(renderFrame);
2154
2451
  };
2155
2452
 
2453
+ const stopAnimation = () => {
2454
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
2455
+ animationFrame = 0;
2456
+ previousFrameTime = 0;
2457
+ nextFrameTime = 0;
2458
+ };
2459
+
2156
2460
  const cancelCameraFocus = () => {
2157
2461
  cameraFocus = null;
2158
2462
  shell.classList.remove("is-focusing-node");
@@ -2214,6 +2518,18 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2214
2518
  detail.append(list);
2215
2519
  };
2216
2520
 
2521
+ const selectGalaxyNode = (node, { moveFocus = false } = {}) => {
2522
+ selectedEdgeId = null;
2523
+ delete shell.dataset.selectedEdgeSource;
2524
+ delete shell.dataset.selectedEdgeTarget;
2525
+ selectedId = node.id;
2526
+ renderDetail(node);
2527
+ focusCameraOnNode(node);
2528
+ drawScene();
2529
+ if (moveFocus) nodeElements.get(node.id)?.focus({ preventScroll: true });
2530
+ };
2531
+ selectGalaxyNodeFromSearch = (node) => selectGalaxyNode(node, { moveFocus: true });
2532
+
2217
2533
  const renderEdgeDetail = (edge) => {
2218
2534
  const selection = getRelationshipEdgeSelection(graph, edge.id);
2219
2535
  if (!selection) return;
@@ -2235,7 +2551,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2235
2551
  nodeElements.clear();
2236
2552
  const maxDegree = Math.max(...layout.nodes.map((node) => node.degree), 1);
2237
2553
  for (const node of layout.nodes) {
2238
- const appearance = getGalaxyNodeAppearance(node, maxDegree);
2554
+ const appearance = getGalaxyNodeAppearance(node, maxDegree, layout.nodes.length);
2239
2555
  const button = document.createElement("button");
2240
2556
  button.type = "button";
2241
2557
  button.className = "galaxy-node";
@@ -2243,9 +2559,13 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2243
2559
  button.dataset.relationshipTier = appearance.tier;
2244
2560
  button.dataset.celestialType = appearance.celestialType;
2245
2561
  button.dataset.celestialPalette = appearance.palette;
2246
- const nodeSize = clamp((10 + Math.sqrt(node.degree / maxDegree) * 28) * appearance.sizeScale, 8, 48);
2562
+ const nodeSize = getGalaxyNodeSize(node, maxDegree, layout.nodes.length, appearance.sizeScale);
2247
2563
  button.style.setProperty("--node-size", `${nodeSize}px`);
2248
2564
  button.dataset.nodeSize = nodeSize.toFixed(3);
2565
+ button.dataset.worldX = node.x.toFixed(2);
2566
+ button.dataset.worldY = node.y.toFixed(2);
2567
+ button.dataset.worldZ = node.z.toFixed(2);
2568
+ button.style.transformOrigin = `50% ${getGalaxyNodeMarkerCenterOffset(nodeSize)}px`;
2249
2569
  button.style.setProperty("--node-color", appearance.color);
2250
2570
  button.style.setProperty("--node-core", appearance.coreColor);
2251
2571
  button.style.setProperty("--node-rim", appearance.rimColor);
@@ -2274,10 +2594,11 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2274
2594
  originZ: node.z,
2275
2595
  yaw: camera.yaw,
2276
2596
  pitch: camera.pitch,
2277
- scale: Number(button.dataset.projectedScale) || 1,
2597
+ scale: Number(nodeProjections.get(node.id)?.scale) || 1,
2278
2598
  dragged: false
2279
2599
  };
2280
2600
  draggedNode = node;
2601
+ startAnimation();
2281
2602
  button.setPointerCapture(event.pointerId);
2282
2603
  button.classList.add("is-dragging");
2283
2604
  button.setAttribute("aria-grabbed", "true");
@@ -2298,6 +2619,9 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2298
2619
  node.x = nodeDrag.originX + worldX * cosYaw - worldY * sinYaw * sinPitch;
2299
2620
  node.y = nodeDrag.originY + worldY * cosPitch;
2300
2621
  node.z = nodeDrag.originZ - worldX * sinYaw - worldY * cosYaw * sinPitch;
2622
+ button.dataset.worldX = node.x.toFixed(2);
2623
+ button.dataset.worldY = node.y.toFixed(2);
2624
+ button.dataset.worldZ = node.z.toFixed(2);
2301
2625
  shell.dataset.draggedNodeId = node.id;
2302
2626
  drawScene();
2303
2627
  });
@@ -2317,13 +2641,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2317
2641
  suppressClick = false;
2318
2642
  return;
2319
2643
  }
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();
2644
+ selectGalaxyNode(node);
2327
2645
  });
2328
2646
  nodeElements.set(node.id, button);
2329
2647
  nodeLayer.append(button);
@@ -2333,16 +2651,25 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2333
2651
  const reset = () => {
2334
2652
  cancelCameraFocus();
2335
2653
  Object.assign(camera, initialCamera);
2336
- for (const node of layout.nodes) Object.assign(node, initialNodePositions.get(node.id));
2654
+ for (const node of layout.nodes) {
2655
+ Object.assign(node, initialNodePositions.get(node.id));
2656
+ const element = nodeElements.get(node.id);
2657
+ if (element) {
2658
+ element.dataset.worldX = node.x.toFixed(2);
2659
+ element.dataset.worldY = node.y.toFixed(2);
2660
+ element.dataset.worldZ = node.z.toFixed(2);
2661
+ }
2662
+ }
2337
2663
  selectedId = null;
2338
2664
  selectedEdgeId = null;
2339
- projectedEdges = [];
2665
+ projectedEdges.length = 0;
2340
2666
  delete shell.dataset.focusedNodeId;
2341
2667
  delete shell.dataset.draggedNodeId;
2342
2668
  delete shell.dataset.selectedEdgeSource;
2343
2669
  delete shell.dataset.selectedEdgeTarget;
2344
2670
  detail.classList.add("hidden");
2345
2671
  detail.replaceChildren();
2672
+ nodeSearch.reset();
2346
2673
  drawScene();
2347
2674
  };
2348
2675
 
@@ -2360,6 +2687,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2360
2687
  };
2361
2688
 
2362
2689
  const open = () => {
2690
+ if (destroyed) return;
2363
2691
  if (!dialog.open) dialog.showModal();
2364
2692
  paused = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2365
2693
  starsVisible = true;
@@ -2371,14 +2699,16 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2371
2699
  dialog.querySelector("#galaxy-grid").setAttribute("aria-pressed", "false");
2372
2700
  dialog.querySelector("#galaxy-grid").textContent = "显示空间网格";
2373
2701
  updateRotationControl();
2702
+ shell.dataset.resourceState = "active";
2374
2703
  stats.value = `${graph.stats.nodeCount} 个节点 / ${graph.stats.edgeCount} 条关系`;
2375
2704
  renderNodes();
2376
2705
  drawScene();
2377
- startAnimation();
2706
+ if (shouldAnimate()) startAnimation();
2378
2707
  dialog.querySelector("#galaxy-close").focus();
2379
2708
  };
2380
2709
 
2381
2710
  const close = () => {
2711
+ if (destroyed) return;
2382
2712
  if (dialog.open) dialog.close();
2383
2713
  };
2384
2714
 
@@ -2403,13 +2733,17 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2403
2733
  listen(dialog.querySelector("#galaxy-rotation"), "click", () => {
2404
2734
  paused = !paused;
2405
2735
  updateRotationControl();
2736
+ drawScene();
2737
+ if (shouldAnimate()) startAnimation();
2738
+ else stopAnimation();
2406
2739
  });
2407
2740
  listen(shell, "wheel", (event) => {
2741
+ if (event.target.closest('[role="search"]')) return;
2408
2742
  event.preventDefault();
2409
2743
  zoom(event.deltaY > 0 ? 0.9 : 1.1);
2410
2744
  }, { passive: false });
2411
2745
  listen(shell, "pointerdown", (event) => {
2412
- if (event.button !== 0 || event.target.closest("button, aside")) return;
2746
+ if (event.button !== 0 || event.target.closest('button, input, aside, [role="search"]')) return;
2413
2747
  cancelCameraFocus();
2414
2748
  cameraDrag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, yaw: camera.yaw, pitch: camera.pitch, dragged: false };
2415
2749
  shell.classList.add("is-rotating-camera");
@@ -2444,10 +2778,56 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2444
2778
  listen(window, "resize", () => {
2445
2779
  if (dialog.open) drawScene();
2446
2780
  });
2781
+ listen(document, "visibilitychange", () => {
2782
+ if (document.hidden) {
2783
+ stopAnimation();
2784
+ return;
2785
+ }
2786
+ if (dialog.open) {
2787
+ drawScene();
2788
+ if (shouldAnimate()) startAnimation();
2789
+ }
2790
+ });
2791
+
2792
+ const releaseResources = () => {
2793
+ if (resourcesReleased) return;
2794
+ resourcesReleased = true;
2795
+ destroyed = true;
2796
+ stopAnimation();
2797
+ cleanups.splice(0).forEach((cleanup) => cleanup());
2798
+ nodeSearch.destroy();
2799
+ nodeElements.clear();
2800
+ nodeLayer.replaceChildren();
2801
+ detail.classList.add("hidden");
2802
+ detail.replaceChildren();
2803
+ for (const target of [background, canvas]) {
2804
+ target.width = 1;
2805
+ target.height = 1;
2806
+ target.style.removeProperty("width");
2807
+ target.style.removeProperty("height");
2808
+ }
2809
+ stars.length = 0;
2810
+ layout.nodes.length = 0;
2811
+ layout.byId.clear();
2812
+ initialNodePositions.clear();
2813
+ nodeProjections.clear();
2814
+ orderedEdges.length = 0;
2815
+ edgeById.clear();
2816
+ relatedIds.clear();
2817
+ highlightedKeywords.length = 0;
2818
+ highlightedKeywordSet.clear();
2819
+ projectedEdges.length = 0;
2820
+ cameraFocus = null;
2821
+ cameraDrag = null;
2822
+ draggedNode = null;
2823
+ backdrop = null;
2824
+ shell.dataset.resourceState = "released";
2825
+ shell.dataset.starCount = "0";
2826
+ shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused", "is-focusing-node");
2827
+ };
2828
+
2447
2829
  listen(dialog, "close", () => {
2448
- if (animationFrame) window.cancelAnimationFrame(animationFrame);
2449
- animationFrame = 0;
2450
- previousFrameTime = 0;
2830
+ releaseResources();
2451
2831
  options.onClose?.();
2452
2832
  });
2453
2833
 
@@ -2456,14 +2836,8 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2456
2836
  close,
2457
2837
  reset,
2458
2838
  destroy() {
2459
- destroyed = true;
2460
- if (animationFrame) window.cancelAnimationFrame(animationFrame);
2461
- animationFrame = 0;
2462
- cleanups.splice(0).forEach((cleanup) => cleanup());
2463
2839
  if (dialog.open) dialog.close();
2464
- nodeElements.clear();
2465
- nodeLayer.replaceChildren();
2466
- shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused");
2840
+ releaseResources();
2467
2841
  }
2468
2842
  };
2469
2843
  }