@musnows/scriverse 0.2.0 → 0.3.0

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.
Files changed (70) hide show
  1. package/README.en.md +13 -4
  2. package/README.md +13 -4
  3. package/dist/ai.js +3374 -0
  4. package/dist/ai.js.map +1 -0
  5. package/dist/app.js +1046 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/cli-core.js +147 -20
  8. package/dist/cli-core.js.map +1 -1
  9. package/dist/credential-vault.js +40 -0
  10. package/dist/credential-vault.js.map +1 -0
  11. package/dist/database.js +1122 -0
  12. package/dist/database.js.map +1 -0
  13. package/dist/docx-security.js +184 -0
  14. package/dist/docx-security.js.map +1 -0
  15. package/dist/domain.js +21 -0
  16. package/dist/domain.js.map +1 -0
  17. package/dist/errors.js +16 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/image-captcha.js +173 -0
  20. package/dist/image-captcha.js.map +1 -0
  21. package/dist/image-metadata.js +128 -0
  22. package/dist/image-metadata.js.map +1 -0
  23. package/dist/import-security.js +46 -0
  24. package/dist/import-security.js.map +1 -0
  25. package/dist/parser.js +223 -0
  26. package/dist/parser.js.map +1 -0
  27. package/dist/public/ai-context-meter.js +10 -0
  28. package/dist/public/ai-conversation.js +3 -0
  29. package/dist/public/ai-mentions.js +55 -0
  30. package/dist/public/ai-message-actions.js +27 -0
  31. package/dist/public/ai-message-meta.js +15 -0
  32. package/dist/public/ai-message-time.js +23 -0
  33. package/dist/public/ai-prompt-keyboard.js +3 -0
  34. package/dist/public/app.js +4255 -0
  35. package/dist/public/character-profile.d.ts +9 -0
  36. package/dist/public/character-profile.js +65 -0
  37. package/dist/public/character-version.d.ts +2 -0
  38. package/dist/public/character-version.js +31 -0
  39. package/dist/public/entity-version.js +34 -0
  40. package/dist/public/icon.svg +10 -0
  41. package/dist/public/index.html +547 -0
  42. package/dist/public/line-number-layout.js +15 -0
  43. package/dist/public/markdown.js +199 -0
  44. package/dist/public/model-config.d.ts +17 -0
  45. package/dist/public/model-config.js +57 -0
  46. package/dist/public/page-route.d.ts +11 -0
  47. package/dist/public/page-route.js +81 -0
  48. package/dist/public/relationship-graph.js +2017 -0
  49. package/dist/public/site.webmanifest +17 -0
  50. package/dist/public/styles.css +1399 -0
  51. package/dist/public/text-formatting.d.ts +2 -0
  52. package/dist/public/text-formatting.js +20 -0
  53. package/dist/public/theme-init.js +8 -0
  54. package/dist/public/theme.js +13 -0
  55. package/dist/public/whitespace-visualization.js +20 -0
  56. package/dist/request-context.js +9 -0
  57. package/dist/request-context.js.map +1 -0
  58. package/dist/security.js +235 -0
  59. package/dist/security.js.map +1 -0
  60. package/dist/server-runtime.js +77 -0
  61. package/dist/server-runtime.js.map +1 -0
  62. package/dist/server.js +15 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/store.js +2492 -0
  65. package/dist/store.js.map +1 -0
  66. package/dist/user-auth.js +489 -0
  67. package/dist/user-auth.js.map +1 -0
  68. package/dist/utils.js +50 -0
  69. package/dist/utils.js.map +1 -0
  70. package/package.json +4 -9
@@ -0,0 +1,2017 @@
1
+ const RELATION_STYLE = Object.freeze({
2
+ family: { label: "亲属", color: "#43e39a" },
3
+ social: { label: "社交", color: "#438cff" },
4
+ emotional: { label: "情感", color: "#ff5f69" },
5
+ conflict: { label: "冲突", color: "#ffad42" },
6
+ uncertain: { label: "未确定", color: "#9aa5b5" }
7
+ });
8
+
9
+ /** Obsidian Graph View 风格:低饱和度分组配色 */
10
+ export const OBSIDIAN_NODE_PALETTE = Object.freeze([
11
+ Object.freeze({ key: "blue", color: "#7a9bb5", glow: "rgba(122,155,181,.48)" }),
12
+ Object.freeze({ key: "lavender", color: "#9a8fb5", glow: "rgba(154,143,181,.48)" }),
13
+ Object.freeze({ key: "slate", color: "#8b9099", glow: "rgba(139,144,153,.42)" }),
14
+ Object.freeze({ key: "rose", color: "#b58a9a", glow: "rgba(181,138,154,.48)" }),
15
+ Object.freeze({ key: "mist", color: "#8aa8a3", glow: "rgba(138,168,163,.45)" }),
16
+ Object.freeze({ key: "sand", color: "#a89a88", glow: "rgba(168,154,136,.42)" })
17
+ ]);
18
+
19
+ const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, value));
20
+ const NETWORK_LAYOUTS = Object.freeze({
21
+ standard: Object.freeze({ width: 1200, height: 640, marginX: 48, marginY: 42, desiredEdgeLength: 196, repulsionStrength: 16800 }),
22
+ expanded: Object.freeze({ width: 1600, height: 900, marginX: 64, marginY: 56, desiredEdgeLength: 236, repulsionStrength: 22800 })
23
+ });
24
+ export const GALAXY_ROTATION_RADIANS_PER_MS = 0.000012;
25
+ export const GALAXY_LAYOUT_CONFIG = Object.freeze({
26
+ minimumRadius: 165,
27
+ radialSpan: 690,
28
+ repulsionStrength: 5200,
29
+ desiredEdgeLength: 210
30
+ });
31
+
32
+ export function formatRelationshipLabel(edge, separator = " · ") {
33
+ const subtype = String(edge?.subtype ?? "").trim();
34
+ const keywords = Array.isArray(edge?.keywords)
35
+ ? edge.keywords.map(String).map((value) => value.trim()).filter(Boolean)
36
+ : [];
37
+ return [subtype, ...keywords].filter(Boolean).join(separator) || "关系";
38
+ }
39
+
40
+ export function groupRelationshipDetailsByCharacterName(graph, nodeId) {
41
+ const groups = new Map();
42
+ for (const edge of graph.edges) {
43
+ if (edge.source !== nodeId && edge.target !== nodeId) continue;
44
+ const other = graph.nodeById.get(edge.source === nodeId ? edge.target : edge.source);
45
+ const name = String(other?.name ?? "未知角色").normalize("NFKC").trim() || "未知角色";
46
+ const key = name.toLocaleLowerCase("zh-CN");
47
+ const existing = groups.get(key);
48
+ if (existing) existing.edges.push(edge);
49
+ else groups.set(key, { name, edges: [edge] });
50
+ }
51
+ return [...groups.values()];
52
+ }
53
+
54
+ export function getRelationshipEdgeSelection(graph, edgeId) {
55
+ const edge = graph.edges.find((item) => item.id === edgeId);
56
+ if (!edge) return null;
57
+ return {
58
+ edgeId: edge.id,
59
+ endpointIds: [edge.source, edge.target],
60
+ endpointNames: [graph.nodeById.get(edge.source)?.name ?? "未知角色", graph.nodeById.get(edge.target)?.name ?? "未知角色"],
61
+ label: formatRelationshipLabel(edge)
62
+ };
63
+ }
64
+
65
+ export function resolveRelationshipNodeGroup(node) {
66
+ const organizations = Array.isArray(node?.organizations) ? node.organizations : [];
67
+ const orgName = organizations
68
+ .map((item) => String(item?.name ?? item ?? "").trim())
69
+ .find(Boolean);
70
+ const species = String(node?.species ?? "").trim();
71
+ const identity = String(node?.identity ?? "").trim();
72
+ if (orgName) return { type: "organization", key: `org:${orgName}`, label: orgName };
73
+ if (species) return { type: "species", key: `species:${species}`, label: species };
74
+ if (identity) return { type: "identity", key: `identity:${identity}`, label: identity };
75
+ return { type: "default", key: "default", label: "未分组" };
76
+ }
77
+
78
+ export function getObsidianNodeAppearance(node, maxDegree = 1) {
79
+ const group = resolveRelationshipNodeGroup(node);
80
+ const degree = Math.max(0, Number(node?.degree) || 0);
81
+ const normalizedDegree = clamp(degree / Math.max(1, Number(maxDegree) || 1), 0, 1);
82
+ const paletteIndex = group.key === "default" ? 2 : hashString(group.key) % OBSIDIAN_NODE_PALETTE.length;
83
+ const palette = OBSIDIAN_NODE_PALETTE[paletteIndex];
84
+ return {
85
+ group,
86
+ color: palette.color,
87
+ glow: palette.glow,
88
+ size: clamp(8 + Math.sqrt(degree) * 4.8 + normalizedDegree * 4, 8, 38),
89
+ degree,
90
+ normalizedDegree
91
+ };
92
+ }
93
+
94
+ function hashString(value) {
95
+ let hash = 2166136261;
96
+ for (let index = 0; index < value.length; index += 1) {
97
+ hash ^= value.charCodeAt(index);
98
+ hash = Math.imul(hash, 16777619);
99
+ }
100
+ return hash >>> 0;
101
+ }
102
+
103
+ function seededRandom(seed) {
104
+ let value = seed || 1;
105
+ return () => {
106
+ value += 0x6d2b79f5;
107
+ let next = value;
108
+ next = Math.imul(next ^ (next >>> 15), next | 1);
109
+ next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
110
+ return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
111
+ };
112
+ }
113
+
114
+ export function buildRelationshipGraph(characters, relationships) {
115
+ const nodes = characters.map((character) => {
116
+ const organizations = Array.isArray(character.organizations)
117
+ ? character.organizations.map((item) => ({
118
+ id: String(item?.id ?? ""),
119
+ name: String(item?.name ?? item ?? "").trim()
120
+ })).filter((item) => item.name)
121
+ : Array.isArray(character.organizationIds)
122
+ ? character.organizationIds.map((id) => ({ id: String(id), name: String(id) }))
123
+ : [];
124
+ const node = {
125
+ id: String(character.id),
126
+ name: String(character.name),
127
+ aliases: Array.isArray(character.aliases) ? character.aliases : [],
128
+ species: String(character.species ?? ""),
129
+ identity: String(character.attributes?.identity ?? ""),
130
+ organizations,
131
+ locked: Array.isArray(character.lockedFields) && character.lockedFields.length > 0,
132
+ degree: 0,
133
+ weightedDegree: 0,
134
+ importance: 0
135
+ };
136
+ const group = resolveRelationshipNodeGroup(node);
137
+ node.groupKey = group.key;
138
+ node.groupLabel = group.label;
139
+ node.groupType = group.type;
140
+ return node;
141
+ });
142
+ const nodeById = new Map(nodes.map((node) => [node.id, node]));
143
+ const warnings = [];
144
+ const edges = [];
145
+ for (const relationship of relationships) {
146
+ if (String(relationship.confirmationStatus ?? "pending") === "rejected") {
147
+ warnings.push({ relationshipId: relationship.id, reason: "关系候选已拒绝" });
148
+ continue;
149
+ }
150
+ const source = nodeById.get(String(relationship.fromCharacterId));
151
+ const target = nodeById.get(String(relationship.toCharacterId));
152
+ if (!source || !target || source === target) {
153
+ warnings.push({ relationshipId: relationship.id, reason: "关系端点不存在" });
154
+ continue;
155
+ }
156
+ const confidence = clamp(Number(relationship.confidence) || 0, 0, 1);
157
+ const edge = {
158
+ id: String(relationship.id),
159
+ source: source.id,
160
+ target: target.id,
161
+ category: RELATION_STYLE[relationship.category] ? relationship.category : "uncertain",
162
+ subtype: String(relationship.subtype || relationship.category || "关系"),
163
+ keywords: Array.isArray(relationship.keywords)
164
+ ? [...new Set(relationship.keywords.map(String).map((value) => value.trim()).filter(Boolean))].slice(0, 8)
165
+ : [],
166
+ directed: Boolean(relationship.directed),
167
+ confidence,
168
+ evidenceCount: Array.isArray(relationship.evidence) ? relationship.evidence.length : 0,
169
+ confirmationStatus: String(relationship.confirmationStatus ?? "pending"),
170
+ currentStatus: String(relationship.currentStatus ?? "active"),
171
+ locked: Boolean(relationship.locked)
172
+ };
173
+ edges.push(edge);
174
+ const weight = confidence * (edge.confirmationStatus === "confirmed" ? 1.35 : 1) * (1 + Math.min(edge.evidenceCount, 4) * 0.08);
175
+ for (const node of [source, target]) {
176
+ node.degree += 1;
177
+ node.weightedDegree += weight;
178
+ }
179
+ }
180
+ const maxDegree = Math.max(1, ...nodes.map((node) => node.degree));
181
+ for (const node of nodes) {
182
+ node.importance = node.weightedDegree + Math.sqrt(node.degree) * 0.8;
183
+ const appearance = getObsidianNodeAppearance(node, maxDegree);
184
+ node.color = appearance.color;
185
+ node.glow = appearance.glow;
186
+ node.nodeSize = appearance.size;
187
+ }
188
+ nodes.sort((left, right) => right.importance - left.importance || left.name.localeCompare(right.name, "zh-CN"));
189
+ return { nodes, edges, nodeById, warnings, stats: { nodeCount: nodes.length, edgeCount: edges.length, maxDegree } };
190
+ }
191
+
192
+ export function layoutRelationshipNetwork(graph, seed = "relationship-network", options = {}) {
193
+ const layout = options.expanded ? NETWORK_LAYOUTS.expanded : NETWORK_LAYOUTS.standard;
194
+ const random = seededRandom(hashString(`${seed}:${graph.nodes.map((node) => node.id).join("|")}`));
195
+ const maxImportance = Math.max(1, ...graph.nodes.map((node) => Number(node.importance) || 0));
196
+ const centerX = layout.width / 2;
197
+ const centerY = layout.height / 2;
198
+ const nodes = graph.nodes.map((node, index) => {
199
+ const centrality = clamp((Number(node.importance) || 0) / maxImportance, 0, 1);
200
+ const angle = index * 2.399963229728653 + random() * 0.42;
201
+ const radialScale = (0.2 + Math.sqrt(random()) * 0.34) * (1 - centrality * 0.45);
202
+ const radius = Math.min(layout.width, layout.height) * radialScale;
203
+ return {
204
+ ...node,
205
+ x: centerX + Math.cos(angle) * radius,
206
+ y: centerY + Math.sin(angle) * radius * 0.78,
207
+ vx: 0,
208
+ vy: 0,
209
+ radius: clamp(Number(node.nodeSize) || (8 + Math.sqrt(Math.max(0, node.degree)) * 4.8), 8, 38)
210
+ };
211
+ });
212
+ const byId = new Map(nodes.map((node) => [node.id, node]));
213
+ const exactRepulsion = nodes.length <= 150;
214
+ const iterations = exactRepulsion ? 88 : 64;
215
+ const repel = (left, right) => {
216
+ let dx = right.x - left.x;
217
+ let dy = right.y - left.y;
218
+ if (Math.abs(dx) + Math.abs(dy) < 0.01) {
219
+ dx = random() - 0.5;
220
+ dy = random() - 0.5;
221
+ }
222
+ const minimumDistance = left.radius + right.radius + 9;
223
+ const distanceSquared = Math.max(36, dx * dx + dy * dy);
224
+ const distance = Math.sqrt(distanceSquared);
225
+ const collisionBoost = distance < minimumDistance ? (minimumDistance - distance) * 0.22 : 0;
226
+ const force = layout.repulsionStrength / distanceSquared + collisionBoost;
227
+ left.vx -= dx / distance * force;
228
+ left.vy -= dy / distance * force;
229
+ right.vx += dx / distance * force;
230
+ right.vy += dy / distance * force;
231
+ };
232
+ for (let iteration = 0; iteration < iterations; iteration += 1) {
233
+ const cooling = 1 - iteration / (iterations + 24);
234
+ if (exactRepulsion) {
235
+ for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
236
+ for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) repel(nodes[leftIndex], nodes[rightIndex]);
237
+ }
238
+ } else {
239
+ const sampleCount = Math.min(32, nodes.length - 1);
240
+ const stride = 19 + iteration % 13;
241
+ for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
242
+ for (let sample = 1; sample <= sampleCount; sample += 1) {
243
+ const rightIndex = (leftIndex + sample * stride) % nodes.length;
244
+ if (rightIndex !== leftIndex) repel(nodes[leftIndex], nodes[rightIndex]);
245
+ }
246
+ }
247
+ }
248
+ for (const edge of graph.edges) {
249
+ const source = byId.get(edge.source);
250
+ const target = byId.get(edge.target);
251
+ if (!source || !target) continue;
252
+ const dx = target.x - source.x;
253
+ const dy = target.y - source.y;
254
+ const distance = Math.max(1, Math.hypot(dx, dy));
255
+ const desiredLength = layout.desiredEdgeLength + Math.sqrt(Math.max(source.degree, target.degree, 1)) * 10;
256
+ const force = (distance - desiredLength) * (0.0022 + edge.confidence * 0.0018);
257
+ source.vx += dx / distance * force;
258
+ source.vy += dy / distance * force;
259
+ target.vx -= dx / distance * force;
260
+ target.vy -= dy / distance * force;
261
+ }
262
+ for (const node of nodes) {
263
+ const centrality = clamp((Number(node.importance) || 0) / maxImportance, 0, 1);
264
+ node.vx += (centerX - node.x) * (0.00018 + centrality * 0.00055);
265
+ node.vy += (centerY - node.y) * (0.00018 + centrality * 0.00055);
266
+ node.vx = clamp(node.vx * 0.82, -10, 10);
267
+ node.vy = clamp(node.vy * 0.82, -10, 10);
268
+ node.x += node.vx * cooling;
269
+ node.y += node.vy * cooling;
270
+ }
271
+ }
272
+ if (nodes.length === 1) {
273
+ nodes[0].x = centerX;
274
+ nodes[0].y = centerY;
275
+ } else if (nodes.length > 1) {
276
+ const minX = Math.min(...nodes.map((node) => node.x));
277
+ const maxX = Math.max(...nodes.map((node) => node.x));
278
+ const minY = Math.min(...nodes.map((node) => node.y));
279
+ const maxY = Math.max(...nodes.map((node) => node.y));
280
+ const width = Math.max(1, maxX - minX);
281
+ const height = Math.max(1, maxY - minY);
282
+ for (const node of nodes) {
283
+ node.x = layout.marginX + (node.x - minX) / width * (layout.width - layout.marginX * 2);
284
+ node.y = layout.marginY + (node.y - minY) / height * (layout.height - layout.marginY * 2);
285
+ }
286
+ }
287
+ return { nodes, byId, width: layout.width, height: layout.height };
288
+ }
289
+
290
+ export const DRAG_PHYSICS_CONFIG = Object.freeze({
291
+ springStrength: 0.048,
292
+ desiredEdgeLength: 188,
293
+ repulsionStrength: 3200,
294
+ collisionPadding: 14,
295
+ damping: 0.9,
296
+ maxSpeed: 18,
297
+ stepsPerFrame: 2,
298
+ coastDamping: 0.9,
299
+ coastSettleSpeed: 0.35,
300
+ coastMaxMs: 800
301
+ });
302
+
303
+ export function stepRelationshipInertiaCoast(state, options = {}) {
304
+ const positions = state.positions;
305
+ const velocities = state.velocities;
306
+ const nodeRadii = state.nodeRadii;
307
+ const bounds = state.bounds ?? {};
308
+ const activeNodeIds = state.activeNodeIds instanceof Set && state.activeNodeIds.size
309
+ ? [...state.activeNodeIds].filter((nodeId) => positions.has(nodeId))
310
+ : [...positions.keys()];
311
+ const minimumX = Number(bounds.minimumX ?? -Infinity);
312
+ const maximumX = Number(bounds.maximumX ?? Infinity);
313
+ const minimumY = Number(bounds.minimumY ?? -Infinity);
314
+ const maximumY = Number(bounds.maximumY ?? Infinity);
315
+ const fitBounds = (position) => ({
316
+ x: clamp(Number(position.x) || 0, minimumX, maximumX),
317
+ y: clamp(Number(position.y) || 0, minimumY, maximumY)
318
+ });
319
+ const radiusOf = (nodeId) => Math.max(1, Number(nodeRadii?.get(nodeId)) || 18);
320
+ const damping = clamp(Number(options.damping ?? DRAG_PHYSICS_CONFIG.coastDamping), 0.7, 0.97);
321
+ const collisionPadding = Number(options.collisionPadding ?? DRAG_PHYSICS_CONFIG.collisionPadding);
322
+ const dt = clamp(Number(options.dt ?? 1), 0.2, 2);
323
+ const changedNodeIds = new Set();
324
+ let energy = 0;
325
+
326
+ // 仅做轻量互斥,避免惯性滑行时重叠,不引入弹簧以免再次全图漂移
327
+ for (let leftIndex = 0; leftIndex < activeNodeIds.length; leftIndex += 1) {
328
+ const leftId = activeNodeIds[leftIndex];
329
+ const left = positions.get(leftId);
330
+ const leftVelocity = velocities.get(leftId) ?? { vx: 0, vy: 0 };
331
+ for (let rightIndex = leftIndex + 1; rightIndex < activeNodeIds.length; rightIndex += 1) {
332
+ const rightId = activeNodeIds[rightIndex];
333
+ const right = positions.get(rightId);
334
+ const rightVelocity = velocities.get(rightId) ?? { vx: 0, vy: 0 };
335
+ let dx = right.x - left.x;
336
+ let dy = right.y - left.y;
337
+ let distance = Math.hypot(dx, dy);
338
+ const minimumDistance = radiusOf(leftId) + radiusOf(rightId) + collisionPadding;
339
+ if (distance >= minimumDistance || distance < 0.001) continue;
340
+ if (distance < 0.001) {
341
+ dx = 0.01;
342
+ dy = 0;
343
+ distance = 0.01;
344
+ }
345
+ const overlap = (minimumDistance - distance) * 0.18;
346
+ const nx = dx / distance;
347
+ const ny = dy / distance;
348
+ leftVelocity.vx -= nx * overlap;
349
+ leftVelocity.vy -= ny * overlap;
350
+ rightVelocity.vx += nx * overlap;
351
+ rightVelocity.vy += ny * overlap;
352
+ velocities.set(leftId, leftVelocity);
353
+ velocities.set(rightId, rightVelocity);
354
+ }
355
+ }
356
+
357
+ for (const nodeId of activeNodeIds) {
358
+ const position = positions.get(nodeId);
359
+ let velocity = velocities.get(nodeId);
360
+ if (!position) continue;
361
+ if (!velocity) {
362
+ velocity = { vx: 0, vy: 0 };
363
+ velocities.set(nodeId, velocity);
364
+ }
365
+ velocity.vx *= damping;
366
+ velocity.vy *= damping;
367
+ if (Math.abs(velocity.vx) < 0.02) velocity.vx = 0;
368
+ if (Math.abs(velocity.vy) < 0.02) velocity.vy = 0;
369
+ if (!velocity.vx && !velocity.vy) continue;
370
+ const next = fitBounds({
371
+ x: position.x + velocity.vx * dt,
372
+ y: position.y + velocity.vy * dt
373
+ });
374
+ positions.set(nodeId, next);
375
+ changedNodeIds.add(nodeId);
376
+ energy += Math.hypot(velocity.vx, velocity.vy);
377
+ }
378
+ return { changedNodeIds, energy };
379
+ }
380
+
381
+ export function stepRelationshipDragPhysics(state, options = {}) {
382
+ const positions = state.positions;
383
+ const velocities = state.velocities;
384
+ const edges = state.edges ?? [];
385
+ const nodeRadii = state.nodeRadii;
386
+ const bounds = state.bounds ?? {};
387
+ const pinnedNodeId = state.pinnedNodeId ?? null;
388
+ const pinnedPosition = state.pinnedPosition ?? null;
389
+ const activeNodeIds = state.activeNodeIds instanceof Set && state.activeNodeIds.size
390
+ ? state.activeNodeIds
391
+ : null;
392
+ const minimumX = Number(bounds.minimumX ?? -Infinity);
393
+ const maximumX = Number(bounds.maximumX ?? Infinity);
394
+ const minimumY = Number(bounds.minimumY ?? -Infinity);
395
+ const maximumY = Number(bounds.maximumY ?? Infinity);
396
+ const fitBounds = (position) => ({
397
+ x: clamp(Number(position.x) || 0, minimumX, maximumX),
398
+ y: clamp(Number(position.y) || 0, minimumY, maximumY)
399
+ });
400
+ const radiusOf = (nodeId) => Math.max(1, Number(nodeRadii?.get(nodeId)) || 18);
401
+ const springStrength = Number(options.springStrength ?? DRAG_PHYSICS_CONFIG.springStrength);
402
+ const desiredEdgeLength = Number(options.desiredEdgeLength ?? DRAG_PHYSICS_CONFIG.desiredEdgeLength);
403
+ const repulsionStrength = Number(options.repulsionStrength ?? DRAG_PHYSICS_CONFIG.repulsionStrength);
404
+ const collisionPadding = Number(options.collisionPadding ?? DRAG_PHYSICS_CONFIG.collisionPadding);
405
+ const damping = clamp(Number(options.damping ?? DRAG_PHYSICS_CONFIG.damping), 0.5, 0.98);
406
+ const maxSpeed = Number(options.maxSpeed ?? DRAG_PHYSICS_CONFIG.maxSpeed);
407
+ const dt = clamp(Number(options.dt ?? 1), 0.2, 2);
408
+ const nodeIds = activeNodeIds ? [...activeNodeIds].filter((nodeId) => positions.has(nodeId)) : [...positions.keys()];
409
+ const activeSet = activeNodeIds ?? new Set(nodeIds);
410
+ const forces = new Map(nodeIds.map((nodeId) => [nodeId, { fx: 0, fy: 0 }]));
411
+
412
+ if (pinnedNodeId && pinnedPosition && positions.has(pinnedNodeId)) {
413
+ const pinned = fitBounds(pinnedPosition);
414
+ positions.set(pinnedNodeId, pinned);
415
+ velocities.set(pinnedNodeId, { vx: 0, vy: 0 });
416
+ }
417
+
418
+ for (const edge of edges) {
419
+ if (!activeSet.has(edge.source) && !activeSet.has(edge.target)) continue;
420
+ if (!forces.has(edge.source)) forces.set(edge.source, { fx: 0, fy: 0 });
421
+ if (!forces.has(edge.target)) forces.set(edge.target, { fx: 0, fy: 0 });
422
+ const source = positions.get(edge.source);
423
+ const target = positions.get(edge.target);
424
+ if (!source || !target) continue;
425
+ let dx = target.x - source.x;
426
+ let dy = target.y - source.y;
427
+ let distance = Math.hypot(dx, dy);
428
+ if (distance < 0.01) {
429
+ dx = 0.01;
430
+ dy = 0;
431
+ distance = 0.01;
432
+ }
433
+ const sourceDegree = Math.max(1, Number(state.degrees?.get(edge.source)) || 1);
434
+ const targetDegree = Math.max(1, Number(state.degrees?.get(edge.target)) || 1);
435
+ const restLength = desiredEdgeLength + Math.sqrt(Math.max(sourceDegree, targetDegree)) * 8;
436
+ const stretch = distance - restLength;
437
+ const force = stretch * springStrength;
438
+ const fx = dx / distance * force;
439
+ const fy = dy / distance * force;
440
+ if (edge.source !== pinnedNodeId) {
441
+ forces.get(edge.source).fx += fx;
442
+ forces.get(edge.source).fy += fy;
443
+ }
444
+ if (edge.target !== pinnedNodeId) {
445
+ forces.get(edge.target).fx -= fx;
446
+ forces.get(edge.target).fy -= fy;
447
+ }
448
+ }
449
+
450
+ const repulsionIds = [...new Set([...nodeIds, ...(pinnedNodeId ? [pinnedNodeId] : [])])];
451
+ for (let leftIndex = 0; leftIndex < repulsionIds.length; leftIndex += 1) {
452
+ const leftId = repulsionIds[leftIndex];
453
+ const left = positions.get(leftId);
454
+ if (!left) continue;
455
+ for (let rightIndex = leftIndex + 1; rightIndex < repulsionIds.length; rightIndex += 1) {
456
+ const rightId = repulsionIds[rightIndex];
457
+ if (!activeSet.has(leftId) && !activeSet.has(rightId)) continue;
458
+ const right = positions.get(rightId);
459
+ if (!right) continue;
460
+ let dx = right.x - left.x;
461
+ let dy = right.y - left.y;
462
+ let distanceSquared = dx * dx + dy * dy;
463
+ if (distanceSquared < 0.0001) {
464
+ dx = (hashString(`${leftId}:${rightId}`) / 4294967296 - 0.5) || 0.01;
465
+ dy = (hashString(`${rightId}:${leftId}`) / 4294967296 - 0.5) || 0.01;
466
+ distanceSquared = dx * dx + dy * dy;
467
+ }
468
+ const distance = Math.sqrt(distanceSquared);
469
+ const minimumDistance = radiusOf(leftId) + radiusOf(rightId) + collisionPadding;
470
+ let force = repulsionStrength / distanceSquared;
471
+ if (distance < minimumDistance) force += (minimumDistance - distance) * 0.42;
472
+ else if (distance > minimumDistance * 3.8) continue;
473
+ const fx = dx / distance * force;
474
+ const fy = dy / distance * force;
475
+ if (!forces.has(leftId)) forces.set(leftId, { fx: 0, fy: 0 });
476
+ if (!forces.has(rightId)) forces.set(rightId, { fx: 0, fy: 0 });
477
+ if (leftId !== pinnedNodeId) {
478
+ forces.get(leftId).fx -= fx;
479
+ forces.get(leftId).fy -= fy;
480
+ }
481
+ if (rightId !== pinnedNodeId) {
482
+ forces.get(rightId).fx += fx;
483
+ forces.get(rightId).fy += fy;
484
+ }
485
+ }
486
+ }
487
+
488
+ const changedNodeIds = new Set();
489
+ let energy = 0;
490
+ const movableIds = new Set([...forces.keys(), ...nodeIds]);
491
+ for (const nodeId of movableIds) {
492
+ const position = positions.get(nodeId);
493
+ if (!position) continue;
494
+ const force = forces.get(nodeId) ?? { fx: 0, fy: 0 };
495
+ let velocity = velocities.get(nodeId);
496
+ if (!velocity) {
497
+ velocity = { vx: 0, vy: 0 };
498
+ velocities.set(nodeId, velocity);
499
+ }
500
+ if (nodeId === pinnedNodeId) {
501
+ changedNodeIds.add(nodeId);
502
+ continue;
503
+ }
504
+ if (activeNodeIds && !activeNodeIds.has(nodeId) && Math.hypot(force.fx, force.fy) < 0.01) continue;
505
+ velocity.vx = clamp((velocity.vx + force.fx * dt) * damping, -maxSpeed, maxSpeed);
506
+ velocity.vy = clamp((velocity.vy + force.fy * dt) * damping, -maxSpeed, maxSpeed);
507
+ const next = fitBounds({
508
+ x: position.x + velocity.vx * dt,
509
+ y: position.y + velocity.vy * dt
510
+ });
511
+ if (next.x !== position.x || next.y !== position.y || Math.abs(velocity.vx) > 0.01 || Math.abs(velocity.vy) > 0.01) {
512
+ positions.set(nodeId, next);
513
+ changedNodeIds.add(nodeId);
514
+ }
515
+ energy += Math.hypot(velocity.vx, velocity.vy);
516
+ }
517
+ return { changedNodeIds, energy };
518
+ }
519
+
520
+ export function applyRelationshipDragInfluence(positions, draggedNodeId, nextPosition, neighborIds, nodeRadii, bounds, options = {}) {
521
+ const velocities = options.velocities ?? new Map([...positions.keys()].map((nodeId) => [nodeId, { vx: 0, vy: 0 }]));
522
+ const neighborSet = new Set([...(neighborIds ?? [])].map(String));
523
+ const activeNodeIds = new Set([draggedNodeId, ...neighborSet]);
524
+ // 邻近未连接节点也参与局部避让,避免被拖拽簇硬撞开后到处漂移
525
+ for (const [nodeId, position] of positions) {
526
+ if (activeNodeIds.has(nodeId)) continue;
527
+ const pinned = nextPosition;
528
+ if (Math.hypot(position.x - pinned.x, position.y - pinned.y) < 120) activeNodeIds.add(nodeId);
529
+ }
530
+ const syntheticEdges = [...neighborSet].map((neighborId, index) => ({
531
+ id: `drag-edge-${index}`,
532
+ source: draggedNodeId,
533
+ target: neighborId
534
+ }));
535
+ const degrees = new Map([...positions.keys()].map((nodeId) => [nodeId, nodeId === draggedNodeId ? neighborSet.size : neighborSet.has(nodeId) ? 1 : 0]));
536
+ const steps = Math.max(1, Math.round(Number(options.steps ?? 8)));
537
+ let changedNodeIds = new Set([draggedNodeId]);
538
+ for (let step = 0; step < steps; step += 1) {
539
+ const result = stepRelationshipDragPhysics({
540
+ positions,
541
+ velocities,
542
+ edges: syntheticEdges,
543
+ nodeRadii,
544
+ bounds,
545
+ degrees,
546
+ activeNodeIds,
547
+ pinnedNodeId: draggedNodeId,
548
+ pinnedPosition: nextPosition
549
+ }, {
550
+ springStrength: Number(options.springStrength ?? 0.08),
551
+ desiredEdgeLength: Number(options.desiredEdgeLength ?? DRAG_PHYSICS_CONFIG.desiredEdgeLength),
552
+ repulsionStrength: Number(options.repulsionStrength ?? 3200),
553
+ collisionPadding: Number(options.avoidancePadding ?? 16),
554
+ damping: Number(options.damping ?? 0.86),
555
+ maxSpeed: Number(options.maxSpeed ?? 20),
556
+ dt: Number(options.dt ?? 1)
557
+ });
558
+ result.changedNodeIds.forEach((nodeId) => changedNodeIds.add(nodeId));
559
+ }
560
+ if (options.velocities) options.velocities = velocities;
561
+ return changedNodeIds;
562
+ }
563
+
564
+ export function renderRelationshipMindMap(container, graph, options = {}) {
565
+ container.replaceChildren();
566
+ if (!graph.nodes.length) {
567
+ const empty = document.createElement("div");
568
+ empty.className = "empty-state";
569
+ empty.innerHTML = "<b>还没有角色档案</b>先创建角色,关系图会在这里出现。";
570
+ container.append(empty);
571
+ return { destroy() { container.replaceChildren(); } };
572
+ }
573
+
574
+ const layout = options.expanded ? NETWORK_LAYOUTS.expanded : NETWORK_LAYOUTS.standard;
575
+ const laidOut = layoutRelationshipNetwork(graph, options.seed ?? "relationship-network-v3", { expanded: options.expanded });
576
+ const positions = new Map(laidOut.nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
577
+ const originalPositions = new Map([...positions].map(([id, position]) => [id, { ...position }]));
578
+ const velocities = new Map(graph.nodes.map((node) => [node.id, { vx: 0, vy: 0 }]));
579
+ const nodeInfluenceRadii = new Map(graph.nodes.map((node) => [node.id, clamp(Number(node.nodeSize) || (8 + Math.sqrt(Math.max(0, node.degree)) * 4.8), 8, 38)]));
580
+ const nodeDegrees = new Map(graph.nodes.map((node) => [node.id, node.degree]));
581
+ const dragBounds = { minimumX: layout.marginX, maximumX: layout.width - layout.marginX, minimumY: layout.marginY, maximumY: layout.height - layout.marginY };
582
+ const neighbors = new Map(graph.nodes.map((node) => [node.id, new Set()]));
583
+ graph.edges.forEach((edge) => {
584
+ neighbors.get(edge.source)?.add(edge.target);
585
+ neighbors.get(edge.target)?.add(edge.source);
586
+ });
587
+ let selectedId = null;
588
+ let selectedEdgeId = null;
589
+ let hoveredId = null;
590
+ let viewScale = 1;
591
+ let viewX = 0;
592
+ let viewY = 0;
593
+ let animationFrame = null;
594
+ let geometryFrame = null;
595
+ let physicsFrame = null;
596
+ let pendingDragUpdate = null;
597
+ let pinnedDrag = null;
598
+ let coastActiveIds = null;
599
+ let coastStartedAt = 0;
600
+ let destroyed = false;
601
+ let previousPhysicsTime = 0;
602
+
603
+ const shell = document.createElement("section");
604
+ shell.className = `relationship-map-card relationship-network-card relationship-obsidian-card${options.expanded ? " is-expanded" : ""}`;
605
+ shell.dataset.testid = "relationship-mindmap";
606
+ const toolbar = document.createElement("header");
607
+ toolbar.className = "relationship-map-toolbar";
608
+ toolbar.innerHTML = `<div><strong>人物关系图谱</strong><small>${graph.stats.nodeCount} 个角色 · ${graph.stats.edgeCount} 条关系</small></div>`;
609
+ const actions = document.createElement("div");
610
+ actions.className = "relationship-map-actions";
611
+ if (!options.expanded) {
612
+ const expand = document.createElement("button");
613
+ expand.type = "button";
614
+ expand.className = "ghost-button";
615
+ expand.textContent = "放大预览";
616
+ expand.setAttribute("aria-label", "放大关系图");
617
+ expand.dataset.testid = "relationship-map-expand";
618
+ expand.addEventListener("click", () => options.onOpenExpanded?.());
619
+ actions.append(expand);
620
+ }
621
+ const fit = document.createElement("button");
622
+ fit.type = "button";
623
+ fit.className = "ghost-button";
624
+ fit.textContent = "适配";
625
+ fit.dataset.testid = "relationship-network-fit";
626
+ const reset = document.createElement("button");
627
+ reset.type = "button";
628
+ reset.className = "ghost-button";
629
+ reset.textContent = "重置布局";
630
+ reset.dataset.testid = "relationship-network-reset";
631
+ const fullscreen = document.createElement("button");
632
+ fullscreen.type = "button";
633
+ fullscreen.className = "ghost-button relationship-galaxy-button";
634
+ fullscreen.innerHTML = '<svg class="relationship-galaxy-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11.5" cy="12" r="4.2"/><ellipse cx="11.5" cy="12" rx="9" ry="3.2" transform="rotate(-18 11.5 12)"/><path d="M19 2.8v3.4M17.3 4.5h3.4"/></svg><span>银河图</span>';
635
+ fullscreen.setAttribute("aria-label", "全屏银河图");
636
+ fullscreen.dataset.testid = "relationship-galaxy-open";
637
+ fullscreen.addEventListener("click", () => options.onOpenGalaxy?.());
638
+ actions.append(fit, reset, fullscreen);
639
+ toolbar.append(actions);
640
+
641
+ const viewport = document.createElement("div");
642
+ viewport.className = "relationship-mindmap relationship-network relationship-obsidian";
643
+ viewport.dataset.testid = "relationship-network";
644
+ viewport.dataset.layoutWidth = String(layout.width);
645
+ viewport.dataset.layoutHeight = String(layout.height);
646
+ viewport.dataset.interaction = "idle";
647
+ viewport.dataset.renderStrategy = "obsidian-force-graph";
648
+ viewport.dataset.edgeLabelStrategy = "selected-only";
649
+ const stage = document.createElement("div");
650
+ stage.className = "relationship-mindmap-stage relationship-network-stage";
651
+ viewport.append(stage);
652
+
653
+ const focusBadge = document.createElement("div");
654
+ focusBadge.className = "relationship-network-focus";
655
+ focusBadge.innerHTML = "<strong>人物关系图谱</strong><span>力导向布局 · 按阵营/种族着色</span>";
656
+ const focusText = focusBadge.querySelector("span");
657
+ const help = document.createElement("div");
658
+ help.className = "relationship-network-help";
659
+ help.textContent = "滚轮缩放 · 拖拽空白平移 · 拖拽节点固定 · 悬浮高亮关联";
660
+ viewport.append(focusBadge, help);
661
+
662
+ const updateViewTransform = (animate = false) => {
663
+ stage.classList.toggle("is-view-animating", animate);
664
+ stage.style.transform = `translate(${viewX}px, ${viewY}px) scale(${viewScale})`;
665
+ viewport.dataset.graphScale = viewScale.toFixed(3);
666
+ viewport.dataset.viewX = viewX.toFixed(1);
667
+ viewport.dataset.viewY = viewY.toFixed(1);
668
+ if (animate) window.setTimeout(() => stage.classList.remove("is-view-animating"), 360);
669
+ };
670
+ updateViewTransform();
671
+
672
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
673
+ svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
674
+ svg.setAttribute("preserveAspectRatio", "none");
675
+ svg.setAttribute("aria-label", "人物关系连线");
676
+ const edgeElements = [];
677
+ const edgeElementsByNode = new Map(graph.nodes.map((node) => [node.id, []]));
678
+ const updateEdgeGeometry = ({ edge, hitPath, path }, { includeHit = true } = {}) => {
679
+ const from = positions.get(edge.source);
680
+ const to = positions.get(edge.target);
681
+ if (!from || !to) return;
682
+ const geometry = `M ${from.x.toFixed(1)} ${from.y.toFixed(1)} L ${to.x.toFixed(1)} ${to.y.toFixed(1)}`;
683
+ path.setAttribute("d", geometry);
684
+ if (includeHit) hitPath.setAttribute("d", geometry);
685
+ };
686
+ const updateLabelGeometry = (edge) => {
687
+ const from = positions.get(edge.source);
688
+ const to = positions.get(edge.target);
689
+ if (!from || !to) return;
690
+ const dx = to.x - from.x;
691
+ const dy = to.y - from.y;
692
+ const distance = Math.max(1, Math.hypot(dx, dy));
693
+ const middleX = (from.x + to.x) / 2;
694
+ const middleY = (from.y + to.y) / 2 - 4;
695
+ let angle = Math.atan2(dy, dx) * 180 / Math.PI;
696
+ if (angle > 90 || angle < -90) angle += 180;
697
+ const fullLabel = label.dataset.fullLabel || label.textContent || "";
698
+ // 按连线长度截断,短边只显示极短摘要,完整内容在底部详情
699
+ const maxChars = clamp(Math.floor(distance / 18), 4, 18);
700
+ const shortLabel = fullLabel.length > maxChars ? `${fullLabel.slice(0, Math.max(1, maxChars - 1))}…` : fullLabel;
701
+ label.textContent = shortLabel;
702
+ label.setAttribute("font-size", String(clamp(distance / 36, 6.5, 8)));
703
+ label.setAttribute("x", middleX.toFixed(2));
704
+ label.setAttribute("y", middleY.toFixed(2));
705
+ label.setAttribute("transform", `rotate(${angle.toFixed(2)} ${middleX.toFixed(2)} ${middleY.toFixed(2)})`);
706
+ };
707
+ for (const edge of graph.edges) {
708
+ const hitPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
709
+ hitPath.classList.add("mind-edge-hit");
710
+ hitPath.dataset.edgeId = edge.id;
711
+ hitPath.setAttribute("role", "button");
712
+ hitPath.setAttribute("tabindex", "0");
713
+ hitPath.setAttribute("aria-label", `选择 ${graph.nodeById.get(edge.source)?.name ?? "未知角色"} 与 ${graph.nodeById.get(edge.target)?.name ?? "未知角色"} 的关系:${formatRelationshipLabel(edge)}`);
714
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
715
+ path.classList.add("mind-edge", "obsidian-edge");
716
+ path.dataset.edgeId = edge.id;
717
+ path.dataset.edgeSource = edge.source;
718
+ path.dataset.edgeTarget = edge.target;
719
+ path.style.setProperty("--edge-opacity", "0.24");
720
+ path.style.setProperty("--edge-width", "1");
721
+ if (edge.confirmationStatus === "pending") path.classList.add("is-pending");
722
+ svg.append(hitPath, path);
723
+ const edgeElement = { edge, hitPath, path };
724
+ edgeElements.push(edgeElement);
725
+ edgeElementsByNode.get(edge.source)?.push(edgeElement);
726
+ edgeElementsByNode.get(edge.target)?.push(edgeElement);
727
+ updateEdgeGeometry(edgeElement);
728
+ }
729
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
730
+ label.setAttribute("text-anchor", "middle");
731
+ label.classList.add("mind-edge-label", "is-edge-selected", "hidden");
732
+ svg.append(label);
733
+ stage.append(svg);
734
+
735
+ const nodeElements = new Map();
736
+ const updateNodePosition = (nodeId) => {
737
+ const position = positions.get(nodeId);
738
+ const button = nodeElements.get(nodeId);
739
+ if (!position || !button) return;
740
+ button.style.left = `${position.x / layout.width * 100}%`;
741
+ button.style.top = `${position.y / layout.height * 100}%`;
742
+ };
743
+ const updateAllGeometry = () => {
744
+ nodeElements.forEach((_, nodeId) => updateNodePosition(nodeId));
745
+ edgeElements.forEach((edgeElement) => updateEdgeGeometry(edgeElement));
746
+ };
747
+ const updateAdjacentGeometry = (nodeIds, { includeHit = false } = {}) => {
748
+ const dirtyEdges = new Set();
749
+ for (const nodeId of nodeIds) {
750
+ updateNodePosition(nodeId);
751
+ const connected = edgeElementsByNode.get(nodeId);
752
+ if (!connected) continue;
753
+ for (let index = 0; index < connected.length; index += 1) dirtyEdges.add(connected[index]);
754
+ }
755
+ dirtyEdges.forEach((edgeElement) => updateEdgeGeometry(edgeElement, { includeHit }));
756
+ };
757
+ const stopPhysicsLoop = () => {
758
+ if (physicsFrame) window.cancelAnimationFrame(physicsFrame);
759
+ physicsFrame = null;
760
+ previousPhysicsTime = 0;
761
+ viewport.dataset.physics = "idle";
762
+ };
763
+ const freezePhysics = () => {
764
+ stopPhysicsLoop();
765
+ pinnedDrag = null;
766
+ pendingDragUpdate = null;
767
+ coastActiveIds = null;
768
+ coastStartedAt = 0;
769
+ velocities.forEach((velocity) => {
770
+ velocity.vx = 0;
771
+ velocity.vy = 0;
772
+ });
773
+ viewport.dataset.interaction = "idle";
774
+ viewport.dataset.physics = "idle";
775
+ };
776
+ const getDragActiveNodes = (nodeId, anchorPosition = null) => {
777
+ const active = new Set([nodeId, ...(neighbors.get(nodeId) ?? [])]);
778
+ const anchor = anchorPosition ?? pinnedDrag?.target ?? positions.get(nodeId);
779
+ if (!anchor) return active;
780
+ for (const [id, position] of positions) {
781
+ if (active.has(id)) continue;
782
+ if (Math.hypot(position.x - anchor.x, position.y - anchor.y) < 140) active.add(id);
783
+ }
784
+ return active;
785
+ };
786
+ const runPhysicsStep = (dt) => {
787
+ if (!pinnedDrag) return 0;
788
+ const steps = DRAG_PHYSICS_CONFIG.stepsPerFrame;
789
+ let changedNodeIds = new Set();
790
+ let energy = 0;
791
+ const activeNodeIds = getDragActiveNodes(pinnedDrag.nodeId);
792
+ for (let step = 0; step < steps; step += 1) {
793
+ const result = stepRelationshipDragPhysics({
794
+ positions,
795
+ velocities,
796
+ edges: graph.edges,
797
+ nodeRadii: nodeInfluenceRadii,
798
+ bounds: dragBounds,
799
+ degrees: nodeDegrees,
800
+ activeNodeIds,
801
+ pinnedNodeId: pinnedDrag.nodeId,
802
+ pinnedPosition: pinnedDrag.target
803
+ }, { dt: dt / steps });
804
+ result.changedNodeIds.forEach((nodeId) => changedNodeIds.add(nodeId));
805
+ energy = result.energy;
806
+ }
807
+ if (changedNodeIds.size) updateAdjacentGeometry(changedNodeIds, { includeHit: false });
808
+ viewport.dataset.influencedNodeCount = String(changedNodeIds.size);
809
+ viewport.dataset.physicsEnergy = energy.toFixed(2);
810
+ return energy;
811
+ };
812
+ const runCoastStep = (dt) => {
813
+ if (!coastActiveIds?.size) return 0;
814
+ const result = stepRelationshipInertiaCoast({
815
+ positions,
816
+ velocities,
817
+ nodeRadii: nodeInfluenceRadii,
818
+ bounds: dragBounds,
819
+ activeNodeIds: coastActiveIds
820
+ }, { dt });
821
+ if (result.changedNodeIds.size) updateAdjacentGeometry(result.changedNodeIds, { includeHit: false });
822
+ viewport.dataset.influencedNodeCount = String(result.changedNodeIds.size);
823
+ viewport.dataset.physicsEnergy = result.energy.toFixed(2);
824
+ return result.energy;
825
+ };
826
+ const startPhysicsLoop = () => {
827
+ if (physicsFrame || destroyed || !pinnedDrag) return;
828
+ viewport.dataset.physics = "dragging";
829
+ previousPhysicsTime = 0;
830
+ const tick = (now) => {
831
+ physicsFrame = 0;
832
+ if (destroyed) return;
833
+ if (!pinnedDrag) {
834
+ freezePhysics();
835
+ return;
836
+ }
837
+ const elapsed = previousPhysicsTime ? Math.min(34, now - previousPhysicsTime) : 16;
838
+ previousPhysicsTime = now;
839
+ const dt = clamp(elapsed / 16.67, 0.5, 1.8);
840
+ if (pendingDragUpdate) {
841
+ const previous = pinnedDrag.target;
842
+ pinnedDrag.target = {
843
+ x: clamp(pendingDragUpdate.nextPosition.x, dragBounds.minimumX, dragBounds.maximumX),
844
+ y: clamp(pendingDragUpdate.nextPosition.y, dragBounds.minimumY, dragBounds.maximumY)
845
+ };
846
+ const moveDt = Math.max(elapsed, 8);
847
+ pinnedDrag.vx = (pinnedDrag.target.x - previous.x) / (moveDt / 16.67);
848
+ pinnedDrag.vy = (pinnedDrag.target.y - previous.y) / (moveDt / 16.67);
849
+ pendingDragUpdate = null;
850
+ }
851
+ runPhysicsStep(dt);
852
+ physicsFrame = window.requestAnimationFrame(tick);
853
+ };
854
+ physicsFrame = window.requestAnimationFrame(tick);
855
+ };
856
+ const startCoastLoop = (activeNodeIds) => {
857
+ if (destroyed || !activeNodeIds?.size) {
858
+ freezePhysics();
859
+ return;
860
+ }
861
+ stopPhysicsLoop();
862
+ pinnedDrag = null;
863
+ pendingDragUpdate = null;
864
+ coastActiveIds = activeNodeIds;
865
+ coastStartedAt = performance.now();
866
+ viewport.dataset.interaction = "settling";
867
+ viewport.dataset.physics = "coasting";
868
+ previousPhysicsTime = 0;
869
+ const tick = (now) => {
870
+ physicsFrame = 0;
871
+ if (destroyed) return;
872
+ const elapsed = previousPhysicsTime ? Math.min(34, now - previousPhysicsTime) : 16;
873
+ previousPhysicsTime = now;
874
+ const dt = clamp(elapsed / 16.67, 0.5, 1.8);
875
+ const energy = runCoastStep(dt);
876
+ const timedOut = now - coastStartedAt > DRAG_PHYSICS_CONFIG.coastMaxMs;
877
+ if (!timedOut && energy > DRAG_PHYSICS_CONFIG.coastSettleSpeed) {
878
+ physicsFrame = window.requestAnimationFrame(tick);
879
+ return;
880
+ }
881
+ const touched = coastActiveIds ?? new Set();
882
+ freezePhysics();
883
+ if (touched.size) updateAdjacentGeometry(touched, { includeHit: true });
884
+ };
885
+ physicsFrame = window.requestAnimationFrame(tick);
886
+ };
887
+ const scheduleDragGeometry = (nodeId, nextPosition) => {
888
+ pendingDragUpdate = { nodeId, nextPosition };
889
+ if (!pinnedDrag || pinnedDrag.nodeId !== nodeId) {
890
+ pinnedDrag = {
891
+ nodeId,
892
+ target: {
893
+ x: clamp(nextPosition.x, dragBounds.minimumX, dragBounds.maximumX),
894
+ y: clamp(nextPosition.y, dragBounds.minimumY, dragBounds.maximumY)
895
+ },
896
+ vx: 0,
897
+ vy: 0
898
+ };
899
+ }
900
+ startPhysicsLoop();
901
+ };
902
+ const flushPendingDrag = () => {
903
+ if (!pendingDragUpdate || !pinnedDrag) return;
904
+ const previous = pinnedDrag.target;
905
+ pinnedDrag.target = {
906
+ x: clamp(pendingDragUpdate.nextPosition.x, dragBounds.minimumX, dragBounds.maximumX),
907
+ y: clamp(pendingDragUpdate.nextPosition.y, dragBounds.minimumY, dragBounds.maximumY)
908
+ };
909
+ pinnedDrag.vx = pinnedDrag.target.x - previous.x;
910
+ pinnedDrag.vy = pinnedDrag.target.y - previous.y;
911
+ pendingDragUpdate = null;
912
+ runPhysicsStep(1);
913
+ };
914
+ const releasePinnedDrag = () => {
915
+ flushPendingDrag();
916
+ if (!pinnedDrag) {
917
+ freezePhysics();
918
+ return;
919
+ }
920
+ const releasedId = pinnedDrag.nodeId;
921
+ const releaseVelocity = {
922
+ vx: clamp(Number(pinnedDrag.vx) || 0, -DRAG_PHYSICS_CONFIG.maxSpeed, DRAG_PHYSICS_CONFIG.maxSpeed),
923
+ vy: clamp(Number(pinnedDrag.vy) || 0, -DRAG_PHYSICS_CONFIG.maxSpeed, DRAG_PHYSICS_CONFIG.maxSpeed)
924
+ };
925
+ const touched = getDragActiveNodes(releasedId, pinnedDrag.target);
926
+ // 把拖拽手感速度注入被拖节点,邻居保留拖拽过程中的弹簧速度,然后短暂惯性滑行
927
+ velocities.set(releasedId, releaseVelocity);
928
+ for (const nodeId of touched) {
929
+ if (nodeId === releasedId) continue;
930
+ const velocity = velocities.get(nodeId) ?? { vx: 0, vy: 0 };
931
+ velocity.vx = clamp(velocity.vx * 0.85 + releaseVelocity.vx * 0.12, -DRAG_PHYSICS_CONFIG.maxSpeed, DRAG_PHYSICS_CONFIG.maxSpeed);
932
+ velocity.vy = clamp(velocity.vy * 0.85 + releaseVelocity.vy * 0.12, -DRAG_PHYSICS_CONFIG.maxSpeed, DRAG_PHYSICS_CONFIG.maxSpeed);
933
+ velocities.set(nodeId, velocity);
934
+ }
935
+ pinnedDrag = null;
936
+ pendingDragUpdate = null;
937
+ startCoastLoop(touched);
938
+ };
939
+
940
+ const edgeDetail = document.createElement("div");
941
+ edgeDetail.className = "mind-edge-detail hidden";
942
+ edgeDetail.setAttribute("aria-live", "polite");
943
+ viewport.append(edgeDetail);
944
+ const clearEdgeSelectionClasses = () => {
945
+ nodeElements.forEach((button) => button.classList.remove("is-edge-endpoint"));
946
+ edgeElements.forEach((item) => {
947
+ item.path.classList.remove("is-edge-selected");
948
+ item.hitPath.setAttribute("aria-pressed", "false");
949
+ });
950
+ label.classList.add("hidden");
951
+ label.removeAttribute("data-edge-id");
952
+ };
953
+ const clearGraphHighlight = () => {
954
+ clearEdgeSelectionClasses();
955
+ nodeElements.forEach((button) => {
956
+ button.classList.remove("is-selected", "is-related", "is-dimmed", "is-hovered");
957
+ });
958
+ edgeElements.forEach((item) => {
959
+ item.path.classList.remove("is-highlighted", "is-dimmed");
960
+ });
961
+ focusText.textContent = "力导向布局 · 按阵营/种族着色";
962
+ edgeDetail.classList.add("hidden");
963
+ edgeDetail.replaceChildren();
964
+ };
965
+ const applyNodeFocus = (nodeId, { hover = false } = {}) => {
966
+ if (!nodeId) {
967
+ clearGraphHighlight();
968
+ return;
969
+ }
970
+ clearEdgeSelectionClasses();
971
+ const relatedIds = new Set([nodeId, ...(neighbors.get(nodeId) ?? [])]);
972
+ nodeElements.forEach((button, id) => {
973
+ button.classList.toggle("is-hovered", hover && id === nodeId);
974
+ button.classList.toggle("is-selected", !hover && id === nodeId);
975
+ button.classList.toggle("is-related", id !== nodeId && relatedIds.has(id));
976
+ button.classList.toggle("is-dimmed", !relatedIds.has(id));
977
+ });
978
+ edgeElements.forEach((item) => {
979
+ const active = item.edge.source === nodeId || item.edge.target === nodeId;
980
+ item.path.classList.toggle("is-highlighted", active);
981
+ item.path.classList.toggle("is-dimmed", !active);
982
+ });
983
+ const name = graph.nodeById.get(nodeId)?.name ?? "未知角色";
984
+ focusText.textContent = hover ? `悬浮:${name}` : `聚焦:${name}`;
985
+ edgeDetail.classList.add("hidden");
986
+ edgeDetail.replaceChildren();
987
+ };
988
+ const refreshHighlight = () => {
989
+ if (selectedEdgeId) {
990
+ const selected = edgeElements.find((edgeElement) => edgeElement.edge.id === selectedEdgeId);
991
+ if (selected) applyEdgeSelection(selected);
992
+ return;
993
+ }
994
+ if (hoveredId) {
995
+ applyNodeFocus(hoveredId, { hover: true });
996
+ return;
997
+ }
998
+ if (selectedId) {
999
+ applyNodeFocus(selectedId);
1000
+ return;
1001
+ }
1002
+ clearGraphHighlight();
1003
+ };
1004
+ const applyEdgeSelection = (edgeElement) => {
1005
+ const selection = getRelationshipEdgeSelection(graph, edgeElement.edge.id);
1006
+ if (!selection) return;
1007
+ clearEdgeSelectionClasses();
1008
+ const endpointIds = new Set(selection.endpointIds);
1009
+ nodeElements.forEach((button, id) => {
1010
+ button.classList.remove("is-selected", "is-related", "is-hovered");
1011
+ button.classList.toggle("is-edge-endpoint", endpointIds.has(id));
1012
+ button.classList.toggle("is-dimmed", !endpointIds.has(id));
1013
+ });
1014
+ edgeElements.forEach((item) => {
1015
+ const active = item.edge.id === selection.edgeId;
1016
+ item.path.classList.toggle("is-highlighted", false);
1017
+ item.path.classList.toggle("is-edge-selected", active);
1018
+ item.path.classList.toggle("is-dimmed", !active);
1019
+ item.hitPath.setAttribute("aria-pressed", String(active));
1020
+ });
1021
+ const fullLabel = selection.label;
1022
+ label.dataset.edgeId = selection.edgeId;
1023
+ label.dataset.fullLabel = fullLabel;
1024
+ label.classList.remove("hidden");
1025
+ updateLabelGeometry(edgeElement.edge);
1026
+ focusText.textContent = `关系:${selection.endpointNames[0]} ↔ ${selection.endpointNames[1]}`;
1027
+ const heading = document.createElement("b");
1028
+ heading.textContent = `${selection.endpointNames[0]}与${selection.endpointNames[1]}`;
1029
+ const detailText = document.createElement("span");
1030
+ detailText.textContent = selection.label;
1031
+ edgeDetail.replaceChildren(heading, detailText);
1032
+ edgeDetail.classList.remove("hidden");
1033
+ };
1034
+ edgeElements.forEach((edgeElement) => {
1035
+ const selectEdge = (event) => {
1036
+ event.preventDefault();
1037
+ event.stopPropagation();
1038
+ selectedEdgeId = edgeElement.edge.id;
1039
+ applyEdgeSelection(edgeElement);
1040
+ if (typeof event.currentTarget?.blur === "function") event.currentTarget.blur();
1041
+ };
1042
+ edgeElement.hitPath.addEventListener("click", selectEdge);
1043
+ edgeElement.hitPath.addEventListener("keydown", (event) => {
1044
+ if (event.key === "Enter" || event.key === " ") selectEdge(event);
1045
+ });
1046
+ });
1047
+
1048
+ for (const node of graph.nodes) {
1049
+ const button = document.createElement("button");
1050
+ const appearance = getObsidianNodeAppearance(node, graph.stats.maxDegree ?? 1);
1051
+ const nodeSize = appearance.size;
1052
+ button.type = "button";
1053
+ button.className = `mind-node network-node obsidian-node${node.locked ? " is-locked" : ""}${node.degree === 0 ? " is-isolated" : ""}`;
1054
+ button.dataset.nodeId = node.id;
1055
+ button.dataset.groupKey = node.groupKey || appearance.group.key;
1056
+ button.style.setProperty("--node-size", `${nodeSize}px`);
1057
+ button.style.setProperty("--node-color", node.color || appearance.color);
1058
+ button.style.setProperty("--node-glow", node.glow || appearance.glow);
1059
+ const labelEl = document.createElement("span");
1060
+ labelEl.textContent = node.name;
1061
+ button.append(labelEl);
1062
+ button.title = [
1063
+ node.groupLabel ? `分组:${node.groupLabel}` : "",
1064
+ node.species ? `种族:${node.species}` : "",
1065
+ node.identity,
1066
+ node.aliases.length ? `别名:${node.aliases.join("、")}` : "",
1067
+ `${node.degree} 条关系`
1068
+ ].filter(Boolean).join("\n");
1069
+ button.setAttribute("aria-label", `${node.name},${node.degree} 条关系${node.aliases.length ? `,别名 ${node.aliases.join("、")}` : ""}`);
1070
+ button.setAttribute("aria-grabbed", "false");
1071
+ nodeElements.set(node.id, button);
1072
+ stage.append(button);
1073
+ updateNodePosition(node.id);
1074
+
1075
+ let dragState = null;
1076
+ let suppressClick = false;
1077
+ button.addEventListener("pointerenter", () => {
1078
+ if (viewport.dataset.interaction === "dragging" || viewport.dataset.interaction === "panning") return;
1079
+ hoveredId = node.id;
1080
+ if (!selectedEdgeId) applyNodeFocus(node.id, { hover: true });
1081
+ });
1082
+ button.addEventListener("pointerleave", () => {
1083
+ if (hoveredId !== node.id) return;
1084
+ hoveredId = null;
1085
+ if (viewport.dataset.interaction === "dragging") return;
1086
+ refreshHighlight();
1087
+ });
1088
+ button.addEventListener("focus", () => {
1089
+ if (!selectedEdgeId) {
1090
+ selectedId = node.id;
1091
+ applyNodeFocus(node.id);
1092
+ }
1093
+ });
1094
+ button.addEventListener("pointerdown", (event) => {
1095
+ if (event.button !== 0) return;
1096
+ event.stopPropagation();
1097
+ const rect = viewport.getBoundingClientRect();
1098
+ dragState = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, rect, dragged: false };
1099
+ selectedEdgeId = null;
1100
+ selectedId = node.id;
1101
+ hoveredId = node.id;
1102
+ applyNodeFocus(node.id, { hover: true });
1103
+ try { button.setPointerCapture(event.pointerId); } catch { /* 非标准指针环境仍允许拖拽事件继续执行。 */ }
1104
+ button.classList.add("is-dragging");
1105
+ button.setAttribute("aria-grabbed", "true");
1106
+ for (const neighborId of neighbors.get(node.id) ?? []) nodeElements.get(neighborId)?.classList.add("is-drag-neighbor");
1107
+ viewport.dataset.interaction = "dragging";
1108
+ viewport.dataset.draggedNodeId = node.id;
1109
+ });
1110
+ button.addEventListener("pointermove", (event) => {
1111
+ if (!dragState || event.pointerId !== dragState.pointerId) return;
1112
+ if (Math.hypot(event.clientX - dragState.startX, event.clientY - dragState.startY) >= 3) dragState.dragged = true;
1113
+ if (!dragState.dragged) return;
1114
+ event.preventDefault();
1115
+ const next = {
1116
+ x: clamp(((event.clientX - dragState.rect.left - viewX) / viewScale) / Math.max(dragState.rect.width, 1) * layout.width, layout.marginX, layout.width - layout.marginX),
1117
+ y: clamp(((event.clientY - dragState.rect.top - viewY) / viewScale) / Math.max(dragState.rect.height, 1) * layout.height, layout.marginY, layout.height - layout.marginY)
1118
+ };
1119
+ scheduleDragGeometry(node.id, next);
1120
+ });
1121
+ const endDrag = (event) => {
1122
+ if (!dragState || event.pointerId !== dragState.pointerId) return;
1123
+ suppressClick = dragState.dragged;
1124
+ dragState = null;
1125
+ button.classList.remove("is-dragging");
1126
+ for (const neighborId of neighbors.get(node.id) ?? []) nodeElements.get(neighborId)?.classList.remove("is-drag-neighbor");
1127
+ button.setAttribute("aria-grabbed", "false");
1128
+ try { if (button.hasPointerCapture(event.pointerId)) button.releasePointerCapture(event.pointerId); } catch { /* 指针已释放时无需重复处理。 */ }
1129
+ if (suppressClick) {
1130
+ releasePinnedDrag();
1131
+ options.onSelect?.(node.id);
1132
+ refreshHighlight();
1133
+ } else {
1134
+ pinnedDrag = null;
1135
+ pendingDragUpdate = null;
1136
+ viewport.dataset.interaction = "idle";
1137
+ }
1138
+ };
1139
+ button.addEventListener("pointerup", endDrag);
1140
+ button.addEventListener("pointercancel", endDrag);
1141
+ button.addEventListener("click", () => {
1142
+ if (suppressClick) {
1143
+ suppressClick = false;
1144
+ return;
1145
+ }
1146
+ selectedEdgeId = null;
1147
+ selectedId = node.id;
1148
+ options.onSelect?.(node.id);
1149
+ applyNodeFocus(node.id);
1150
+ });
1151
+ }
1152
+
1153
+ const fitView = () => {
1154
+ viewScale = 1;
1155
+ viewX = 0;
1156
+ viewY = 0;
1157
+ updateViewTransform(true);
1158
+ };
1159
+ const animatePositions = (targets, duration = 650) => {
1160
+ freezePhysics();
1161
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
1162
+ const starts = new Map([...positions].map(([id, position]) => [id, { ...position }]));
1163
+ const startedAt = performance.now();
1164
+ viewport.classList.add("is-layout-animating");
1165
+ viewport.dataset.interaction = "settling";
1166
+ viewport.dataset.layoutAnimation = "running";
1167
+ const tick = (now) => {
1168
+ if (destroyed) return;
1169
+ const progress = clamp((now - startedAt) / duration, 0, 1);
1170
+ const eased = 1 - Math.pow(1 - progress, 3);
1171
+ targets.forEach((target, id) => {
1172
+ const start = starts.get(id) ?? target;
1173
+ positions.set(id, { x: start.x + (target.x - start.x) * eased, y: start.y + (target.y - start.y) * eased });
1174
+ const velocity = velocities.get(id);
1175
+ if (velocity) {
1176
+ velocity.vx = 0;
1177
+ velocity.vy = 0;
1178
+ }
1179
+ });
1180
+ updateAllGeometry();
1181
+ if (progress < 1) animationFrame = window.requestAnimationFrame(tick);
1182
+ else {
1183
+ animationFrame = null;
1184
+ viewport.classList.remove("is-layout-animating");
1185
+ viewport.dataset.interaction = "idle";
1186
+ viewport.dataset.layoutAnimation = "complete";
1187
+ }
1188
+ };
1189
+ animationFrame = window.requestAnimationFrame(tick);
1190
+ };
1191
+ fit.addEventListener("click", fitView);
1192
+ reset.addEventListener("click", () => {
1193
+ selectedEdgeId = null;
1194
+ hoveredId = null;
1195
+ freezePhysics();
1196
+ refreshHighlight();
1197
+ fitView();
1198
+ animatePositions(originalPositions);
1199
+ });
1200
+
1201
+ let panState = null;
1202
+ viewport.addEventListener("pointerdown", (event) => {
1203
+ if (event.button !== 0 || ![viewport, stage, svg].includes(event.target)) return;
1204
+ panState = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, viewX, viewY };
1205
+ viewport.setPointerCapture(event.pointerId);
1206
+ viewport.classList.add("is-panning");
1207
+ viewport.dataset.interaction = "panning";
1208
+ });
1209
+ viewport.addEventListener("pointermove", (event) => {
1210
+ if (!panState || event.pointerId !== panState.pointerId) return;
1211
+ viewX = panState.viewX + event.clientX - panState.startX;
1212
+ viewY = panState.viewY + event.clientY - panState.startY;
1213
+ updateViewTransform();
1214
+ });
1215
+ const endPan = (event) => {
1216
+ if (!panState || event.pointerId !== panState.pointerId) return;
1217
+ panState = null;
1218
+ viewport.classList.remove("is-panning");
1219
+ viewport.dataset.interaction = "idle";
1220
+ if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
1221
+ };
1222
+ viewport.addEventListener("pointerup", endPan);
1223
+ viewport.addEventListener("pointercancel", endPan);
1224
+ viewport.addEventListener("wheel", (event) => {
1225
+ event.preventDefault();
1226
+ const rect = viewport.getBoundingClientRect();
1227
+ const pointerX = event.clientX - rect.left;
1228
+ const pointerY = event.clientY - rect.top;
1229
+ const nextScale = clamp(viewScale * (event.deltaY > 0 ? 0.9 : 1.1), 0.45, 3.2);
1230
+ const ratio = nextScale / viewScale;
1231
+ viewX = pointerX - (pointerX - viewX) * ratio;
1232
+ viewY = pointerY - (pointerY - viewY) * ratio;
1233
+ viewScale = nextScale;
1234
+ updateViewTransform();
1235
+ }, { passive: false });
1236
+ viewport.addEventListener("click", (event) => {
1237
+ if (![viewport, stage, svg].includes(event.target)) return;
1238
+ selectedEdgeId = null;
1239
+ selectedId = null;
1240
+ hoveredId = null;
1241
+ clearGraphHighlight();
1242
+ });
1243
+
1244
+ updateAllGeometry();
1245
+ clearGraphHighlight();
1246
+ shell.append(toolbar, viewport);
1247
+ container.append(shell);
1248
+ return {
1249
+ destroy() {
1250
+ destroyed = true;
1251
+ freezePhysics();
1252
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
1253
+ if (geometryFrame) window.cancelAnimationFrame(geometryFrame);
1254
+ pendingDragUpdate = null;
1255
+ pinnedDrag = null;
1256
+ container.replaceChildren();
1257
+ },
1258
+ getState() {
1259
+ return { selectedId, selectedEdgeId, hoveredId, viewScale, viewX, viewY, positions: new Map(positions) };
1260
+ }
1261
+ };
1262
+ }
1263
+
1264
+ export function layoutGalaxy(graph, seed) {
1265
+ const random = seededRandom(hashString(seed));
1266
+ const nodes = graph.nodes.map((node, index) => {
1267
+ const angle = random() * Math.PI * 2;
1268
+ const radius = GALAXY_LAYOUT_CONFIG.minimumRadius + Math.sqrt(random()) * GALAXY_LAYOUT_CONFIG.radialSpan;
1269
+ const thickness = 18 + radius * 0.12;
1270
+ return {
1271
+ ...node,
1272
+ x: Math.cos(angle) * radius,
1273
+ y: (random() - 0.5) * thickness,
1274
+ z: Math.sin(angle) * radius,
1275
+ vx: 0,
1276
+ vy: 0,
1277
+ vz: 0,
1278
+ index
1279
+ };
1280
+ });
1281
+ const byId = new Map(nodes.map((node) => [node.id, node]));
1282
+ const exactRepulsion = nodes.length <= 180;
1283
+ const iterations = exactRepulsion ? 130 : 82;
1284
+ const applyRepulsion = (left, right) => {
1285
+ let dx = right.x - left.x;
1286
+ let dz = right.z - left.z;
1287
+ const distanceSquared = Math.max(80, dx * dx + dz * dz);
1288
+ const force = GALAXY_LAYOUT_CONFIG.repulsionStrength / distanceSquared;
1289
+ const distance = Math.sqrt(distanceSquared);
1290
+ dx /= distance;
1291
+ dz /= distance;
1292
+ left.vx -= dx * force;
1293
+ left.vz -= dz * force;
1294
+ right.vx += dx * force;
1295
+ right.vz += dz * force;
1296
+ };
1297
+ for (let iteration = 0; iteration < iterations; iteration += 1) {
1298
+ const cooling = 1 - iteration / (iterations + 20);
1299
+ if (exactRepulsion) {
1300
+ for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
1301
+ const left = nodes[leftIndex];
1302
+ for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
1303
+ applyRepulsion(left, nodes[rightIndex]);
1304
+ }
1305
+ }
1306
+ } else {
1307
+ const sampleCount = Math.min(28, nodes.length - 1);
1308
+ const stride = 17 + iteration % 11;
1309
+ for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
1310
+ const left = nodes[leftIndex];
1311
+ for (let sample = 1; sample <= sampleCount; sample += 1) {
1312
+ const rightIndex = (leftIndex + sample * stride) % nodes.length;
1313
+ if (rightIndex !== leftIndex) applyRepulsion(left, nodes[rightIndex]);
1314
+ }
1315
+ }
1316
+ }
1317
+ for (const edge of graph.edges) {
1318
+ const source = byId.get(edge.source);
1319
+ const target = byId.get(edge.target);
1320
+ if (!source || !target) continue;
1321
+ const dx = target.x - source.x;
1322
+ const dz = target.z - source.z;
1323
+ const distance = Math.max(1, Math.hypot(dx, dz));
1324
+ const force = (distance - GALAXY_LAYOUT_CONFIG.desiredEdgeLength) * 0.0028 * (0.5 + edge.confidence);
1325
+ source.vx += dx / distance * force;
1326
+ source.vz += dz / distance * force;
1327
+ target.vx -= dx / distance * force;
1328
+ target.vz -= dz / distance * force;
1329
+ }
1330
+ for (const node of nodes) {
1331
+ const centrality = clamp(node.importance / Math.max(graph.nodes[0]?.importance || 1, 1), 0, 1);
1332
+ node.vx += -node.x * (0.00052 + centrality * 0.0011);
1333
+ node.vy += -node.y * 0.0014;
1334
+ node.vz += -node.z * (0.00052 + centrality * 0.0011);
1335
+ node.vx *= 0.84;
1336
+ node.vy *= 0.84;
1337
+ node.vz *= 0.84;
1338
+ node.x += node.vx * cooling;
1339
+ node.y += node.vy * cooling;
1340
+ node.z += node.vz * cooling;
1341
+ }
1342
+ }
1343
+ return { nodes, byId };
1344
+ }
1345
+
1346
+ export function createGalaxyStarfield(seed, count = 3600) {
1347
+ const random = seededRandom(hashString(seed));
1348
+ const stars = [];
1349
+ const armCount = 4;
1350
+ for (let index = 0; index < count; index += 1) {
1351
+ const radius = 55 + Math.pow(random(), 0.62) * 1120;
1352
+ const arm = index % armCount;
1353
+ const armAngle = arm / armCount * Math.PI * 2;
1354
+ const angle = armAngle + radius * 0.0065 + (random() - 0.5) * (0.42 + radius / 1100);
1355
+ const thickness = 22 + radius * 0.105;
1356
+ stars.push({
1357
+ x: Math.cos(angle) * radius + (random() - 0.5) * 62,
1358
+ y: (random() + random() + random() - 1.5) * thickness,
1359
+ z: Math.sin(angle) * radius + (random() - 0.5) * 62,
1360
+ size: random() > 0.965 ? 1.7 + random() * 1.4 : 0.45 + random() * 0.85,
1361
+ brightness: 0.22 + random() * 0.78
1362
+ });
1363
+ }
1364
+ return stars;
1365
+ }
1366
+
1367
+ export function projectGalaxyPoint(point, camera, viewport) {
1368
+ const relativeX = point.x - Number(camera.targetX ?? 0);
1369
+ const relativeY = point.y - Number(camera.targetY ?? 0);
1370
+ const relativeZ = point.z - Number(camera.targetZ ?? 0);
1371
+ const cosYaw = Math.cos(camera.yaw);
1372
+ const sinYaw = Math.sin(camera.yaw);
1373
+ const cosPitch = Math.cos(camera.pitch);
1374
+ const sinPitch = Math.sin(camera.pitch);
1375
+ const cameraX = relativeX * cosYaw - relativeZ * sinYaw;
1376
+ const yawedZ = relativeX * sinYaw + relativeZ * cosYaw;
1377
+ const cameraY = relativeY * cosPitch - yawedZ * sinPitch;
1378
+ const cameraZ = relativeY * sinPitch + yawedZ * cosPitch;
1379
+ const depth = camera.distance + cameraZ;
1380
+ const focalLength = Math.min(viewport.width, viewport.height) * camera.focalRatio;
1381
+ const scale = depth > 1 ? focalLength / depth * camera.zoom : 0;
1382
+ return {
1383
+ x: viewport.width / 2 + cameraX * scale,
1384
+ y: viewport.height / 2 + cameraY * scale,
1385
+ depth,
1386
+ scale,
1387
+ visible: depth > 80
1388
+ };
1389
+ }
1390
+
1391
+ export function getGalaxyNodeFocusCamera(node, camera) {
1392
+ return {
1393
+ targetX: Number(node?.x ?? 0),
1394
+ targetY: Number(node?.y ?? 0),
1395
+ targetZ: Number(node?.z ?? 0),
1396
+ distance: Math.min(Number(camera?.distance ?? 1420), 940),
1397
+ zoom: Math.max(Number(camera?.zoom ?? 1), 1.65)
1398
+ };
1399
+ }
1400
+
1401
+ export function getGalaxyNodeAppearance(node, maxDegree) {
1402
+ const degree = Math.max(0, Number(node?.degree) || 0);
1403
+ const normalizedDegree = clamp(degree / Math.max(1, Number(maxDegree) || 1), 0, 1);
1404
+ const weightedDegree = Math.max(0, Number(node?.weightedDegree) || 0);
1405
+ const confidenceBoost = clamp(weightedDegree / Math.max(1, degree) / 1.35, 0, 1);
1406
+ const intensity = clamp(normalizedDegree * 0.8 + confidenceBoost * 0.2, 0, 1);
1407
+ const hue = Math.round(218 - intensity * 166);
1408
+ const saturation = Math.round(58 + intensity * 35);
1409
+ const lightness = Math.round(47 + intensity * 27);
1410
+ const brightness = (0.7 + intensity * 0.68).toFixed(3);
1411
+ const glow = (0.26 + intensity * 0.74).toFixed(3);
1412
+ const tier = intensity >= 0.7 ? "core" : intensity >= 0.34 ? "active" : "outer";
1413
+ return {
1414
+ degree,
1415
+ intensity,
1416
+ hue,
1417
+ saturation,
1418
+ lightness,
1419
+ brightness,
1420
+ glow,
1421
+ tier,
1422
+ color: `hsl(${hue} ${saturation}% ${lightness}%)`
1423
+ };
1424
+ }
1425
+
1426
+ export function getGalaxyNodeMarkerCenterOffset(nodeSize) {
1427
+ return 8 + Math.max(0, Number(nodeSize) || 0) / 2;
1428
+ }
1429
+
1430
+ export function distanceToGalaxyEdge(point, from, to) {
1431
+ const deltaX = to.x - from.x;
1432
+ const deltaY = to.y - from.y;
1433
+ const lengthSquared = deltaX * deltaX + deltaY * deltaY;
1434
+ if (lengthSquared === 0) return Math.hypot(point.x - from.x, point.y - from.y);
1435
+ const ratio = clamp(((point.x - from.x) * deltaX + (point.y - from.y) * deltaY) / lengthSquared, 0, 1);
1436
+ return Math.hypot(point.x - (from.x + deltaX * ratio), point.y - (from.y + deltaY * ratio));
1437
+ }
1438
+
1439
+ export function findNearestGalaxyEdge(projectedEdges, point, threshold = 9) {
1440
+ let nearest = null;
1441
+ for (const projected of projectedEdges) {
1442
+ const distance = distanceToGalaxyEdge(point, projected.from, projected.to);
1443
+ if (distance > threshold || (nearest && distance >= nearest.distance)) continue;
1444
+ nearest = { edge: projected.edge, distance };
1445
+ }
1446
+ return nearest;
1447
+ }
1448
+
1449
+ export function createGalaxyRenderer(dialog, graph, options = {}) {
1450
+ const background = dialog.querySelector("#galaxy-background");
1451
+ const canvas = dialog.querySelector("#galaxy-graph");
1452
+ const nodeLayer = dialog.querySelector("#galaxy-node-layer");
1453
+ const stats = dialog.querySelector("#galaxy-stats");
1454
+ const detail = dialog.querySelector("#galaxy-detail");
1455
+ const shell = dialog.querySelector(".galaxy-shell");
1456
+ const seed = `${options.workId ?? "work"}|${graph.nodes.map((node) => node.id).join("|")}|${graph.edges.length}`;
1457
+ const layout = layoutGalaxy(graph, seed);
1458
+ const stars = createGalaxyStarfield(`${seed}|stars`);
1459
+ const initialNodePositions = new Map(layout.nodes.map((node) => [node.id, { x: node.x, y: node.y, z: node.z }]));
1460
+ const initialCamera = Object.freeze({ yaw: -0.38, pitch: 0.72, distance: 1420, focalRatio: 1.72, zoom: 1, targetX: 0, targetY: 0, targetZ: 0 });
1461
+ const camera = { ...initialCamera };
1462
+ const nodeElements = new Map();
1463
+ const cleanups = [];
1464
+ let selectedId = null;
1465
+ let selectedEdgeId = null;
1466
+ let projectedEdges = [];
1467
+ let cameraDrag = null;
1468
+ let animationFrame = 0;
1469
+ let previousFrameTime = 0;
1470
+ let cameraFocus = null;
1471
+ let paused = false;
1472
+ let starsVisible = true;
1473
+ let destroyed = false;
1474
+
1475
+ shell.classList.add("is-three-dimensional");
1476
+ shell.dataset.sceneDimension = "3";
1477
+ shell.dataset.starCount = String(stars.length);
1478
+ shell.dataset.rotationSpeed = String(GALAXY_ROTATION_RADIANS_PER_MS);
1479
+ shell.dataset.layoutMinimumRadius = String(GALAXY_LAYOUT_CONFIG.minimumRadius);
1480
+ shell.dataset.layoutRadialSpan = String(GALAXY_LAYOUT_CONFIG.radialSpan);
1481
+ shell.dataset.layoutDesiredEdgeLength = String(GALAXY_LAYOUT_CONFIG.desiredEdgeLength);
1482
+
1483
+ const listen = (target, type, handler, settings) => {
1484
+ target.addEventListener(type, handler, settings);
1485
+ cleanups.push(() => target.removeEventListener(type, handler, settings));
1486
+ };
1487
+
1488
+ const resizeCanvas = (target) => {
1489
+ const ratio = Math.min(window.devicePixelRatio || 1, 2);
1490
+ const rect = shell.getBoundingClientRect();
1491
+ const width = Math.max(1, rect.width);
1492
+ const height = Math.max(1, rect.height);
1493
+ const pixelWidth = Math.max(1, Math.round(width * ratio));
1494
+ const pixelHeight = Math.max(1, Math.round(height * ratio));
1495
+ if (target.width !== pixelWidth) target.width = pixelWidth;
1496
+ if (target.height !== pixelHeight) target.height = pixelHeight;
1497
+ if (target.style.width !== `${width}px`) target.style.width = `${width}px`;
1498
+ if (target.style.height !== `${height}px`) target.style.height = `${height}px`;
1499
+ const context = target.getContext("2d");
1500
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
1501
+ return { context, width, height };
1502
+ };
1503
+
1504
+ const project = (point, width, height) => projectGalaxyPoint(point, camera, { width, height });
1505
+
1506
+ const drawBackground = () => {
1507
+ const { context, width, height } = resizeCanvas(background);
1508
+ context.clearRect(0, 0, width, height);
1509
+ const backdrop = context.createRadialGradient(width * 0.53, height * 0.48, 0, width * 0.53, height * 0.48, Math.max(width, height) * 0.78);
1510
+ backdrop.addColorStop(0, "#0b1830");
1511
+ backdrop.addColorStop(0.36, "#07101f");
1512
+ backdrop.addColorStop(0.72, "#03070e");
1513
+ backdrop.addColorStop(1, "#010205");
1514
+ context.fillStyle = backdrop;
1515
+ context.fillRect(0, 0, width, height);
1516
+
1517
+ context.lineWidth = 1;
1518
+ context.strokeStyle = "rgba(105,142,182,.06)";
1519
+ for (let offset = -1200; offset <= 1200; offset += 160) {
1520
+ const horizontalStart = project({ x: -1200, y: 0, z: offset }, width, height);
1521
+ const horizontalEnd = project({ x: 1200, y: 0, z: offset }, width, height);
1522
+ const verticalStart = project({ x: offset, y: 0, z: -1200 }, width, height);
1523
+ const verticalEnd = project({ x: offset, y: 0, z: 1200 }, width, height);
1524
+ if (horizontalStart.visible && horizontalEnd.visible) {
1525
+ context.beginPath();
1526
+ context.moveTo(horizontalStart.x, horizontalStart.y);
1527
+ context.lineTo(horizontalEnd.x, horizontalEnd.y);
1528
+ context.stroke();
1529
+ }
1530
+ if (verticalStart.visible && verticalEnd.visible) {
1531
+ context.beginPath();
1532
+ context.moveTo(verticalStart.x, verticalStart.y);
1533
+ context.lineTo(verticalEnd.x, verticalEnd.y);
1534
+ context.stroke();
1535
+ }
1536
+ }
1537
+
1538
+ const center = project({ x: 0, y: 0, z: 0 }, width, height);
1539
+ const coreRadius = Math.min(width, height) * 0.28 * camera.zoom;
1540
+ const core = context.createRadialGradient(center.x, center.y, 0, center.x, center.y, coreRadius);
1541
+ core.addColorStop(0, "rgba(235,247,255,.22)");
1542
+ core.addColorStop(0.12, "rgba(100,166,230,.14)");
1543
+ core.addColorStop(0.42, "rgba(52,160,126,.06)");
1544
+ core.addColorStop(1, "rgba(0,0,0,0)");
1545
+ context.fillStyle = core;
1546
+ context.fillRect(0, 0, width, height);
1547
+
1548
+ if (!starsVisible) return;
1549
+ context.save();
1550
+ context.globalCompositeOperation = "lighter";
1551
+ for (let index = 0; index < stars.length; index += 1) {
1552
+ const star = stars[index];
1553
+ const point = project(star, width, height);
1554
+ if (!point.visible || point.x < -8 || point.x > width + 8 || point.y < -8 || point.y > height + 8) continue;
1555
+ const perspective = clamp(point.scale / 0.95, 0.32, 2.4);
1556
+ const radius = star.size * perspective;
1557
+ const twinkle = 0.82 + Math.sin(index * 12.9898 + camera.yaw * 5) * 0.18;
1558
+ const alpha = clamp(star.brightness * twinkle * perspective, 0.08, 0.92);
1559
+ context.fillStyle = `rgba(216,235,255,${alpha})`;
1560
+ context.beginPath();
1561
+ context.arc(point.x, point.y, Math.max(0.28, radius), 0, Math.PI * 2);
1562
+ context.fill();
1563
+ }
1564
+ context.restore();
1565
+ };
1566
+
1567
+ const drawGraph = () => {
1568
+ if (destroyed || !dialog.open) return;
1569
+ const { context, width, height } = resizeCanvas(canvas);
1570
+ context.clearRect(0, 0, width, height);
1571
+ const projections = new Map(layout.nodes.map((node) => [node.id, project(node, width, height)]));
1572
+ const relatedIds = new Set(selectedId ? [selectedId] : []);
1573
+ if (selectedId) {
1574
+ for (const edge of graph.edges) {
1575
+ if (edge.source === selectedId || edge.target === selectedId) {
1576
+ relatedIds.add(edge.source);
1577
+ relatedIds.add(edge.target);
1578
+ }
1579
+ }
1580
+ }
1581
+
1582
+ const highlightedKeywords = [];
1583
+ const orderedEdges = graph.edges.map((edge) => ({
1584
+ edge,
1585
+ depth: ((projections.get(edge.source)?.depth ?? 0) + (projections.get(edge.target)?.depth ?? 0)) / 2
1586
+ })).sort((left, right) => right.depth - left.depth);
1587
+ projectedEdges = orderedEdges.flatMap(({ edge, depth }) => {
1588
+ const from = projections.get(edge.source);
1589
+ const to = projections.get(edge.target);
1590
+ return from?.visible && to?.visible ? [{ edge, from, to, depth }] : [];
1591
+ });
1592
+ for (const { edge } of orderedEdges) {
1593
+ const from = projections.get(edge.source);
1594
+ const to = projections.get(edge.target);
1595
+ if (!from?.visible || !to?.visible) continue;
1596
+ const edgeSelected = edge.id === selectedEdgeId;
1597
+ const highlighted = edgeSelected || (Boolean(selectedId) && (edge.source === selectedId || edge.target === selectedId));
1598
+ const dimmed = selectedEdgeId ? !edgeSelected : Boolean(selectedId) && !highlighted;
1599
+ const depthFactor = clamp((from.scale + to.scale) / 1.9, 0.25, 1.6);
1600
+ const opacity = dimmed ? 0.018 : edgeSelected ? 1 : highlighted ? 0.9 : (0.08 + edge.confidence * 0.22) * depthFactor;
1601
+ const alpha = Math.round(clamp(opacity, 0, 1) * 255).toString(16).padStart(2, "0");
1602
+ const edgeColor = `${RELATION_STYLE[edge.category].color}${alpha}`;
1603
+ if (edgeSelected) {
1604
+ context.save();
1605
+ context.strokeStyle = `${RELATION_STYLE[edge.category].color}52`;
1606
+ context.lineWidth = 9 * clamp(depthFactor, 0.75, 1.4);
1607
+ context.shadowColor = RELATION_STYLE[edge.category].color;
1608
+ context.shadowBlur = 15;
1609
+ context.setLineDash([]);
1610
+ context.beginPath();
1611
+ context.moveTo(from.x, from.y);
1612
+ context.lineTo(to.x, to.y);
1613
+ context.stroke();
1614
+ context.restore();
1615
+ }
1616
+ context.strokeStyle = edgeColor;
1617
+ context.lineWidth = (edgeSelected ? 4 : highlighted ? 2.1 : 0.55 + edge.confidence) * clamp(depthFactor, 0.55, 1.45);
1618
+ context.setLineDash(edge.confirmationStatus === "pending" || edge.category === "uncertain" ? [5, 6] : []);
1619
+ context.beginPath();
1620
+ context.moveTo(from.x, from.y);
1621
+ context.lineTo(to.x, to.y);
1622
+ context.stroke();
1623
+ if (edge.directed) {
1624
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
1625
+ const arrowSize = 6 * clamp(depthFactor, 0.7, 1.5);
1626
+ context.fillStyle = edgeColor;
1627
+ context.beginPath();
1628
+ context.moveTo(to.x, to.y);
1629
+ context.lineTo(to.x - Math.cos(angle - 0.45) * arrowSize, to.y - Math.sin(angle - 0.45) * arrowSize);
1630
+ context.lineTo(to.x - Math.cos(angle + 0.45) * arrowSize, to.y - Math.sin(angle + 0.45) * arrowSize);
1631
+ context.fill();
1632
+ }
1633
+ if (highlighted) {
1634
+ const fullLabel = formatRelationshipLabel(edge);
1635
+ highlightedKeywords.push(fullLabel);
1636
+ const label = fullLabel.length > 42 ? `${fullLabel.slice(0, 41)}…` : fullLabel;
1637
+ const x = (from.x + to.x) / 2;
1638
+ const y = (from.y + to.y) / 2 - 9;
1639
+ context.setLineDash([]);
1640
+ context.font = '10px "SFMono-Regular", "SF Mono", Menlo, Monaco, monospace';
1641
+ context.textAlign = "center";
1642
+ context.textBaseline = "middle";
1643
+ const labelWidth = Math.min(300, context.measureText(label).width + 14);
1644
+ context.fillStyle = "rgba(3,7,14,.86)";
1645
+ context.fillRect(x - labelWidth / 2, y - 9, labelWidth, 18);
1646
+ context.fillStyle = "rgba(238,246,255,.96)";
1647
+ context.fillText(label, x, y, labelWidth - 8);
1648
+ }
1649
+ }
1650
+ context.setLineDash([]);
1651
+
1652
+ const baseScale = Math.min(width, height) * camera.focalRatio / camera.distance * camera.zoom;
1653
+ for (const node of layout.nodes) {
1654
+ const point = projections.get(node.id);
1655
+ const element = nodeElements.get(node.id);
1656
+ if (!element || !point) continue;
1657
+ const perspective = clamp(point.scale / Math.max(baseScale, 0.01), 0.5, 1.8);
1658
+ const selectedScale = node.id === selectedId ? clamp(camera.zoom, 1, 1.8) : 1;
1659
+ element.hidden = !point.visible;
1660
+ const nodeSize = Number(element.dataset.nodeSize) || 12;
1661
+ const markerCenterOffset = getGalaxyNodeMarkerCenterOffset(nodeSize);
1662
+ element.style.transformOrigin = `50% ${markerCenterOffset}px`;
1663
+ element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -${markerCenterOffset}px) scale(${perspective * selectedScale})`;
1664
+ element.style.zIndex = String(10000 - Math.round(point.depth));
1665
+ element.style.setProperty("--depth-opacity", String(clamp(1.45 - point.depth / 2300, 0.38, 1)));
1666
+ element.dataset.worldX = node.x.toFixed(2);
1667
+ element.dataset.worldY = node.y.toFixed(2);
1668
+ element.dataset.worldZ = node.z.toFixed(2);
1669
+ element.dataset.projectedDepth = point.depth.toFixed(2);
1670
+ element.dataset.projectedScale = point.scale.toFixed(4);
1671
+ element.dataset.projectedX = point.x.toFixed(3);
1672
+ element.dataset.projectedY = point.y.toFixed(3);
1673
+ const edgeEndpoint = Boolean(selectedEdgeId) && graph.edges.some((edge) => edge.id === selectedEdgeId && (edge.source === node.id || edge.target === node.id));
1674
+ element.classList.toggle("is-selected", node.id === selectedId);
1675
+ element.classList.toggle("is-related", Boolean(selectedId) && node.id !== selectedId && relatedIds.has(node.id));
1676
+ element.classList.toggle("is-edge-endpoint", edgeEndpoint);
1677
+ element.classList.toggle("is-dimmed", selectedEdgeId ? !edgeEndpoint : Boolean(selectedId) && !relatedIds.has(node.id));
1678
+ element.classList.toggle("show-label", edgeEndpoint || node.index < 26 || relatedIds.has(node.id) || camera.zoom > 1.35);
1679
+ }
1680
+ shell.dataset.selectedNodeId = selectedId ?? "";
1681
+ shell.dataset.selectedEdgeId = selectedEdgeId ?? "";
1682
+ shell.dataset.highlightedKeywords = [...new Set(highlightedKeywords)].join("|");
1683
+ shell.dataset.cameraYaw = camera.yaw.toFixed(5);
1684
+ shell.dataset.cameraPitch = camera.pitch.toFixed(5);
1685
+ shell.dataset.cameraDistance = camera.distance.toFixed(1);
1686
+ shell.dataset.graphScale = camera.zoom.toFixed(3);
1687
+ shell.dataset.cameraTarget = [camera.targetX, camera.targetY, camera.targetZ].map((value) => value.toFixed(2)).join(",");
1688
+ };
1689
+
1690
+ const drawScene = () => {
1691
+ drawBackground();
1692
+ drawGraph();
1693
+ };
1694
+
1695
+ const renderFrame = (time) => {
1696
+ animationFrame = 0;
1697
+ if (destroyed || !dialog.open) return;
1698
+ const elapsed = previousFrameTime ? Math.min(50, time - previousFrameTime) : 0;
1699
+ previousFrameTime = time;
1700
+ if (cameraFocus) {
1701
+ const progress = clamp((time - cameraFocus.startedAt) / cameraFocus.duration, 0, 1);
1702
+ const eased = 1 - Math.pow(1 - progress, 3);
1703
+ for (const key of ["targetX", "targetY", "targetZ", "distance", "zoom"]) {
1704
+ camera[key] = cameraFocus.from[key] + (cameraFocus.to[key] - cameraFocus.from[key]) * eased;
1705
+ }
1706
+ if (progress >= 1) {
1707
+ cameraFocus = null;
1708
+ shell.classList.remove("is-focusing-node");
1709
+ }
1710
+ }
1711
+ if (!paused && !cameraDrag) camera.yaw += elapsed * GALAXY_ROTATION_RADIANS_PER_MS;
1712
+ drawScene();
1713
+ animationFrame = window.requestAnimationFrame(renderFrame);
1714
+ };
1715
+
1716
+ const startAnimation = () => {
1717
+ if (animationFrame || destroyed) return;
1718
+ previousFrameTime = 0;
1719
+ animationFrame = window.requestAnimationFrame(renderFrame);
1720
+ };
1721
+
1722
+ const cancelCameraFocus = () => {
1723
+ cameraFocus = null;
1724
+ shell.classList.remove("is-focusing-node");
1725
+ };
1726
+
1727
+ const focusCameraOnNode = (node) => {
1728
+ const target = getGalaxyNodeFocusCamera(node, camera);
1729
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1730
+ shell.dataset.focusedNodeId = node.id;
1731
+ if (reducedMotion) {
1732
+ Object.assign(camera, target);
1733
+ drawScene();
1734
+ return;
1735
+ }
1736
+ cameraFocus = {
1737
+ from: Object.fromEntries(["targetX", "targetY", "targetZ", "distance", "zoom"].map((key) => [key, camera[key]])),
1738
+ to: target,
1739
+ startedAt: performance.now(),
1740
+ duration: 650
1741
+ };
1742
+ shell.classList.add("is-focusing-node");
1743
+ startAnimation();
1744
+ };
1745
+
1746
+ const renderDetail = (node) => {
1747
+ const relationGroups = groupRelationshipDetailsByCharacterName(graph, node.id);
1748
+ detail.classList.remove("hidden");
1749
+ detail.replaceChildren();
1750
+ const heading = document.createElement("strong");
1751
+ heading.textContent = node.name;
1752
+ detail.append(heading);
1753
+ if (node.aliases.length) {
1754
+ const aliases = document.createElement("small");
1755
+ aliases.textContent = `别名:${node.aliases.join("、")}`;
1756
+ detail.append(aliases);
1757
+ }
1758
+ if (node.species) {
1759
+ const species = document.createElement("small");
1760
+ species.textContent = `种族:${node.species}`;
1761
+ detail.append(species);
1762
+ }
1763
+ if (node.identity) {
1764
+ const identity = document.createElement("p");
1765
+ identity.textContent = node.identity;
1766
+ detail.append(identity);
1767
+ }
1768
+ const list = document.createElement("ul");
1769
+ for (const group of relationGroups.slice(0, 12)) {
1770
+ const item = document.createElement("li");
1771
+ const categories = group.edges.map((edge) => {
1772
+ const category = document.createElement("i");
1773
+ category.className = edge.category;
1774
+ return category;
1775
+ });
1776
+ const labels = [...new Set(group.edges.map((edge) => formatRelationshipLabel(edge)))];
1777
+ item.append(...categories, document.createTextNode(`${group.name} · ${labels.join(";")}`));
1778
+ list.append(item);
1779
+ }
1780
+ detail.append(list);
1781
+ };
1782
+
1783
+ const renderEdgeDetail = (edge) => {
1784
+ const selection = getRelationshipEdgeSelection(graph, edge.id);
1785
+ if (!selection) return;
1786
+ detail.classList.remove("hidden");
1787
+ detail.replaceChildren();
1788
+ const heading = document.createElement("strong");
1789
+ heading.textContent = selection.endpointNames.join(" ↔ ");
1790
+ const category = document.createElement("small");
1791
+ category.textContent = RELATION_STYLE[edge.category].label;
1792
+ const description = document.createElement("p");
1793
+ description.textContent = selection.label;
1794
+ detail.append(heading, category, description);
1795
+ shell.dataset.selectedEdgeSource = selection.endpointIds[0];
1796
+ shell.dataset.selectedEdgeTarget = selection.endpointIds[1];
1797
+ };
1798
+
1799
+ const renderNodes = () => {
1800
+ nodeLayer.replaceChildren();
1801
+ nodeElements.clear();
1802
+ const maxDegree = Math.max(...layout.nodes.map((node) => node.degree), 1);
1803
+ for (const node of layout.nodes) {
1804
+ const appearance = getGalaxyNodeAppearance(node, maxDegree);
1805
+ const button = document.createElement("button");
1806
+ button.type = "button";
1807
+ button.className = "galaxy-node";
1808
+ button.dataset.galaxyNode = node.id;
1809
+ button.dataset.relationshipTier = appearance.tier;
1810
+ const nodeSize = 10 + Math.sqrt(node.degree / maxDegree) * 28;
1811
+ button.style.setProperty("--node-size", `${nodeSize}px`);
1812
+ button.dataset.nodeSize = nodeSize.toFixed(3);
1813
+ button.style.setProperty("--node-color", appearance.color);
1814
+ button.style.setProperty("--node-brightness", appearance.brightness);
1815
+ button.style.setProperty("--node-glow", appearance.glow);
1816
+ const marker = document.createElement("i");
1817
+ const label = document.createElement("span");
1818
+ label.textContent = node.name;
1819
+ button.append(marker, label);
1820
+ button.title = `${node.degree} 条关系 · ${appearance.tier === "core" ? "核心高亮" : appearance.tier === "active" ? "活跃连接" : "外围连接"}`;
1821
+ button.setAttribute("aria-label", `${node.name},${node.degree} 条关系,${appearance.tier === "core" ? "核心高亮" : appearance.tier === "active" ? "活跃连接" : "外围连接"}${node.aliases.length ? `,别名 ${node.aliases.join("、")}` : ""}`);
1822
+ button.setAttribute("aria-grabbed", "false");
1823
+ let nodeDrag = null;
1824
+ let suppressClick = false;
1825
+ listen(button, "pointerdown", (event) => {
1826
+ if (event.button !== 0) return;
1827
+ event.stopPropagation();
1828
+ cancelCameraFocus();
1829
+ nodeDrag = {
1830
+ pointerId: event.pointerId,
1831
+ startX: event.clientX,
1832
+ startY: event.clientY,
1833
+ originX: node.x,
1834
+ originY: node.y,
1835
+ originZ: node.z,
1836
+ yaw: camera.yaw,
1837
+ pitch: camera.pitch,
1838
+ scale: Number(button.dataset.projectedScale) || 1,
1839
+ dragged: false
1840
+ };
1841
+ button.setPointerCapture(event.pointerId);
1842
+ button.classList.add("is-dragging");
1843
+ button.setAttribute("aria-grabbed", "true");
1844
+ });
1845
+ listen(button, "pointermove", (event) => {
1846
+ if (!nodeDrag || event.pointerId !== nodeDrag.pointerId) return;
1847
+ const deltaX = event.clientX - nodeDrag.startX;
1848
+ const deltaY = event.clientY - nodeDrag.startY;
1849
+ if (Math.hypot(deltaX, deltaY) >= 3) nodeDrag.dragged = true;
1850
+ if (!nodeDrag.dragged) return;
1851
+ event.preventDefault();
1852
+ const worldX = deltaX / Math.max(nodeDrag.scale, 0.16);
1853
+ const worldY = deltaY / Math.max(nodeDrag.scale, 0.16);
1854
+ const cosYaw = Math.cos(nodeDrag.yaw);
1855
+ const sinYaw = Math.sin(nodeDrag.yaw);
1856
+ const cosPitch = Math.cos(nodeDrag.pitch);
1857
+ const sinPitch = Math.sin(nodeDrag.pitch);
1858
+ node.x = nodeDrag.originX + worldX * cosYaw - worldY * sinYaw * sinPitch;
1859
+ node.y = nodeDrag.originY + worldY * cosPitch;
1860
+ node.z = nodeDrag.originZ - worldX * sinYaw - worldY * cosYaw * sinPitch;
1861
+ shell.dataset.draggedNodeId = node.id;
1862
+ drawScene();
1863
+ });
1864
+ const endNodeDrag = (event) => {
1865
+ if (!nodeDrag || event.pointerId !== nodeDrag.pointerId) return;
1866
+ suppressClick = nodeDrag.dragged;
1867
+ nodeDrag = null;
1868
+ button.classList.remove("is-dragging");
1869
+ button.setAttribute("aria-grabbed", "false");
1870
+ if (button.hasPointerCapture(event.pointerId)) button.releasePointerCapture(event.pointerId);
1871
+ };
1872
+ listen(button, "pointerup", endNodeDrag);
1873
+ listen(button, "pointercancel", endNodeDrag);
1874
+ listen(button, "click", () => {
1875
+ if (suppressClick) {
1876
+ suppressClick = false;
1877
+ return;
1878
+ }
1879
+ selectedEdgeId = null;
1880
+ delete shell.dataset.selectedEdgeSource;
1881
+ delete shell.dataset.selectedEdgeTarget;
1882
+ selectedId = node.id;
1883
+ renderDetail(node);
1884
+ focusCameraOnNode(node);
1885
+ drawScene();
1886
+ });
1887
+ nodeElements.set(node.id, button);
1888
+ nodeLayer.append(button);
1889
+ }
1890
+ };
1891
+
1892
+ const reset = () => {
1893
+ cancelCameraFocus();
1894
+ Object.assign(camera, initialCamera);
1895
+ for (const node of layout.nodes) Object.assign(node, initialNodePositions.get(node.id));
1896
+ selectedId = null;
1897
+ selectedEdgeId = null;
1898
+ projectedEdges = [];
1899
+ delete shell.dataset.focusedNodeId;
1900
+ delete shell.dataset.draggedNodeId;
1901
+ delete shell.dataset.selectedEdgeSource;
1902
+ delete shell.dataset.selectedEdgeTarget;
1903
+ detail.classList.add("hidden");
1904
+ detail.replaceChildren();
1905
+ drawScene();
1906
+ };
1907
+
1908
+ const zoom = (factor) => {
1909
+ cancelCameraFocus();
1910
+ camera.zoom = clamp(camera.zoom * factor, 0.45, 2.8);
1911
+ drawScene();
1912
+ };
1913
+
1914
+ const updateRotationControl = () => {
1915
+ shell.classList.toggle("is-paused", paused);
1916
+ const control = dialog.querySelector("#galaxy-rotation");
1917
+ control.setAttribute("aria-pressed", String(paused));
1918
+ control.textContent = paused ? "继续旋转" : "暂停旋转";
1919
+ };
1920
+
1921
+ const open = () => {
1922
+ if (!dialog.open) dialog.showModal();
1923
+ paused = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1924
+ starsVisible = true;
1925
+ background.classList.remove("hidden-stars");
1926
+ dialog.querySelector("#galaxy-stars").setAttribute("aria-pressed", "true");
1927
+ dialog.querySelector("#galaxy-stars").textContent = "隐藏背景星点";
1928
+ updateRotationControl();
1929
+ stats.value = `${graph.stats.nodeCount} 个节点 / ${graph.stats.edgeCount} 条关系`;
1930
+ renderNodes();
1931
+ drawScene();
1932
+ startAnimation();
1933
+ dialog.querySelector("#galaxy-close").focus();
1934
+ };
1935
+
1936
+ const close = () => {
1937
+ if (dialog.open) dialog.close();
1938
+ };
1939
+
1940
+ listen(dialog.querySelector("#galaxy-close"), "click", close);
1941
+ listen(dialog.querySelector("#galaxy-reset"), "click", reset);
1942
+ listen(dialog.querySelector("#galaxy-zoom-in"), "click", () => zoom(1.18));
1943
+ listen(dialog.querySelector("#galaxy-zoom-out"), "click", () => zoom(1 / 1.18));
1944
+ listen(dialog.querySelector("#galaxy-stars"), "click", (event) => {
1945
+ starsVisible = !starsVisible;
1946
+ background.classList.toggle("hidden-stars", !starsVisible);
1947
+ event.currentTarget.setAttribute("aria-pressed", String(starsVisible));
1948
+ event.currentTarget.textContent = starsVisible ? "隐藏背景星点" : "显示背景星点";
1949
+ drawScene();
1950
+ });
1951
+ listen(dialog.querySelector("#galaxy-rotation"), "click", () => {
1952
+ paused = !paused;
1953
+ updateRotationControl();
1954
+ });
1955
+ listen(shell, "wheel", (event) => {
1956
+ event.preventDefault();
1957
+ zoom(event.deltaY > 0 ? 0.9 : 1.1);
1958
+ }, { passive: false });
1959
+ listen(shell, "pointerdown", (event) => {
1960
+ if (event.button !== 0 || event.target.closest("button, aside")) return;
1961
+ cancelCameraFocus();
1962
+ cameraDrag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, yaw: camera.yaw, pitch: camera.pitch, dragged: false };
1963
+ shell.classList.add("is-rotating-camera");
1964
+ shell.setPointerCapture(event.pointerId);
1965
+ });
1966
+ listen(shell, "pointermove", (event) => {
1967
+ if (!cameraDrag || event.pointerId !== cameraDrag.pointerId) return;
1968
+ if (Math.hypot(event.clientX - cameraDrag.x, event.clientY - cameraDrag.y) >= 3) cameraDrag.dragged = true;
1969
+ if (!cameraDrag.dragged) return;
1970
+ camera.yaw = cameraDrag.yaw + (event.clientX - cameraDrag.x) * 0.006;
1971
+ camera.pitch = clamp(cameraDrag.pitch + (event.clientY - cameraDrag.y) * 0.004, 0.16, 1.38);
1972
+ drawScene();
1973
+ });
1974
+ const endCameraDrag = (event) => {
1975
+ if (!cameraDrag || event.pointerId !== cameraDrag.pointerId) return;
1976
+ if (!cameraDrag.dragged) {
1977
+ const rect = shell.getBoundingClientRect();
1978
+ const nearest = findNearestGalaxyEdge(projectedEdges, { x: event.clientX - rect.left, y: event.clientY - rect.top });
1979
+ if (nearest) {
1980
+ selectedId = null;
1981
+ selectedEdgeId = nearest.edge.id;
1982
+ renderEdgeDetail(nearest.edge);
1983
+ drawScene();
1984
+ }
1985
+ }
1986
+ cameraDrag = null;
1987
+ shell.classList.remove("is-rotating-camera");
1988
+ if (shell.hasPointerCapture(event.pointerId)) shell.releasePointerCapture(event.pointerId);
1989
+ };
1990
+ listen(shell, "pointerup", endCameraDrag);
1991
+ listen(shell, "pointercancel", endCameraDrag);
1992
+ listen(window, "resize", () => {
1993
+ if (dialog.open) drawScene();
1994
+ });
1995
+ listen(dialog, "close", () => {
1996
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
1997
+ animationFrame = 0;
1998
+ previousFrameTime = 0;
1999
+ options.onClose?.();
2000
+ });
2001
+
2002
+ return {
2003
+ open,
2004
+ close,
2005
+ reset,
2006
+ destroy() {
2007
+ destroyed = true;
2008
+ if (animationFrame) window.cancelAnimationFrame(animationFrame);
2009
+ animationFrame = 0;
2010
+ cleanups.splice(0).forEach((cleanup) => cleanup());
2011
+ if (dialog.open) dialog.close();
2012
+ nodeElements.clear();
2013
+ nodeLayer.replaceChildren();
2014
+ shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused");
2015
+ }
2016
+ };
2017
+ }