@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.
- package/dist/app.js +11 -2
- package/dist/app.js.map +1 -1
- package/dist/database.js +19 -1
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +225 -16
- package/dist/public/display-labels.js +1 -1
- package/dist/public/entity-version.js +2 -1
- package/dist/public/index.html +32 -4
- package/dist/public/relationship-graph.js +469 -95
- package/dist/public/styles.css +74 -9
- package/dist/store.js +104 -4
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +3 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -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-${
|
|
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
|
|
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
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
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
|
|
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 /
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
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
|
|
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 =
|
|
1950
|
-
const horizontalEnd =
|
|
1951
|
-
const verticalStart =
|
|
1952
|
-
const verticalEnd =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2286
|
+
const context = graphContext;
|
|
2287
|
+
const { width, height } = viewport;
|
|
2000
2288
|
context.clearRect(0, 0, width, height);
|
|
2001
|
-
const
|
|
2002
|
-
|
|
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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
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" ?
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2465
|
-
nodeLayer.replaceChildren();
|
|
2466
|
-
shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused");
|
|
2840
|
+
releaseResources();
|
|
2467
2841
|
}
|
|
2468
2842
|
};
|
|
2469
2843
|
}
|