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