@musnows/scriverse 0.7.13 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.en.md +4 -0
  2. package/README.md +5 -0
  3. package/dist/ai-chat-tab-limit.js +14 -0
  4. package/dist/ai-chat-tab-limit.js.map +1 -0
  5. package/dist/ai-protocol.js +10 -2
  6. package/dist/ai-protocol.js.map +1 -1
  7. package/dist/ai-retry.js +52 -0
  8. package/dist/ai-retry.js.map +1 -0
  9. package/dist/ai.js +547 -119
  10. package/dist/ai.js.map +1 -1
  11. package/dist/app.js +80 -22
  12. package/dist/app.js.map +1 -1
  13. package/dist/cli-contract.js +2 -1
  14. package/dist/cli-contract.js.map +1 -1
  15. package/dist/database.js +127 -2
  16. package/dist/database.js.map +1 -1
  17. package/dist/domain.js +1 -0
  18. package/dist/domain.js.map +1 -1
  19. package/dist/public/ai-chat-tabs.js +75 -0
  20. package/dist/public/ai-request-manager.js +32 -15
  21. package/dist/public/app.js +1305 -298
  22. package/dist/public/background-task-center.d.ts +9 -0
  23. package/dist/public/background-task-center.js +18 -0
  24. package/dist/public/chapter-search.d.ts +6 -0
  25. package/dist/public/chapter-search.js +23 -0
  26. package/dist/public/character-filters.d.ts +3 -1
  27. package/dist/public/character-filters.js +17 -2
  28. package/dist/public/character-version.js +1 -0
  29. package/dist/public/display-labels.d.ts +1 -0
  30. package/dist/public/display-labels.js +5 -1
  31. package/dist/public/index.html +67 -26
  32. package/dist/public/model-config.d.ts +3 -1
  33. package/dist/public/model-config.js +13 -0
  34. package/dist/public/relationship-filters.d.ts +5 -0
  35. package/dist/public/relationship-filters.js +31 -0
  36. package/dist/public/relationship-graph.js +289 -58
  37. package/dist/public/stream-typewriter.d.ts +1 -0
  38. package/dist/public/stream-typewriter.js +12 -0
  39. package/dist/public/styles.css +185 -41
  40. package/dist/public/work-permissions.d.ts +1 -1
  41. package/dist/public/work-permissions.js +13 -1
  42. package/dist/server-runtime.js +6 -0
  43. package/dist/server-runtime.js.map +1 -1
  44. package/dist/store.js +127 -44
  45. package/dist/store.js.map +1 -1
  46. package/dist/user-auth.js +34 -7
  47. package/dist/user-auth.js.map +1 -1
  48. package/dist/version.js +6 -1
  49. package/dist/version.js.map +1 -1
  50. package/dist/work-permissions.js +13 -2
  51. package/dist/work-permissions.js.map +1 -1
  52. package/package.json +1 -1
@@ -40,8 +40,27 @@ const NETWORK_LAYOUTS = Object.freeze({
40
40
  expanded: Object.freeze({ width: 1600, height: 900, marginX: 64, marginY: 56, desiredEdgeLength: 236, repulsionStrength: 22800 })
41
41
  });
42
42
  const RELATIONSHIP_EDGE_GAP = 24;
43
+ export const RELATIONSHIP_CANVAS_NODE_THRESHOLD = 180;
44
+ export const RELATIONSHIP_CANVAS_EDGE_THRESHOLD = 260;
45
+ export const RELATIONSHIP_MAX_CANVAS_PIXELS = 2_000_000;
43
46
  let relationshipRendererSequence = 0;
44
47
 
48
+ export function getRelationshipGraphRenderProfile(nodeCount, edgeCount) {
49
+ const nodes = Math.max(0, Math.floor(Number(nodeCount) || 0));
50
+ const edges = Math.max(0, Math.floor(Number(edgeCount) || 0));
51
+ const dense = nodes > RELATIONSHIP_CANVAS_NODE_THRESHOLD || edges > RELATIONSHIP_CANVAS_EDGE_THRESHOLD;
52
+ return {
53
+ dense,
54
+ edgeMode: dense ? "canvas" : "svg"
55
+ };
56
+ }
57
+
58
+ export function getRelationshipCanvasPixelRatio(devicePixelRatio, width, height) {
59
+ const ratio = clamp(Number(devicePixelRatio) || 1, 1, 1.25);
60
+ const area = Math.max(1, Number(width) || 1) * Math.max(1, Number(height) || 1);
61
+ return Math.max(1, Math.min(ratio, Math.sqrt(RELATIONSHIP_MAX_CANVAS_PIXELS / area)));
62
+ }
63
+
45
64
  export function getRelationshipNetworkInitialScale(nodeCount, expanded = false) {
46
65
  if (expanded) return 1;
47
66
  const count = Math.max(0, Number(nodeCount) || 0);
@@ -66,8 +85,9 @@ export function getRelationshipNodeFocusView(position, viewport, layout, current
66
85
 
67
86
  export function shouldShowRelationshipNodeLabel(nodeIndex, degree, nodeCount, viewScale, expanded = false) {
68
87
  const count = Math.max(0, Number(nodeCount) || 0);
69
- const defaultLabelLimit = count > 180 ? 36 : count > 120 ? 48 : count > 80 ? 64 : count;
70
- return Boolean(expanded) || Number(nodeIndex) < defaultLabelLimit || Number(degree) >= 4 || Number(viewScale) > 1.35;
88
+ const defaultLabelLimit = count > 720 ? 24 : count > 360 ? 30 : count > 180 ? 36 : count > 120 ? 48 : count > 80 ? 64 : count;
89
+ const degreeLabelThreshold = count > 720 ? 12 : count > 360 ? 8 : 4;
90
+ return Boolean(expanded) || Number(nodeIndex) < defaultLabelLimit || Number(degree) >= degreeLabelThreshold || Number(viewScale) > 1.35;
71
91
  }
72
92
 
73
93
  export function getRelationshipNodeLabelFontSize(viewScale, nodeCount) {
@@ -283,6 +303,9 @@ const GALAXY_CELESTIAL_TYPES = Object.freeze({
283
303
  export const GALAXY_ROTATION_RADIANS_PER_MS = 0.000012;
284
304
  export const GALAXY_TARGET_FRAME_RATE = 30;
285
305
  export const GALAXY_FRAME_RATE_OPTIONS = Object.freeze([24, 30, 60, 90, 120, 144, 165, 240]);
306
+ export const GALAXY_MOTION_MODES = Object.freeze(["auto", "on", "reduced", "off"]);
307
+ export const GALAXY_REDUCED_MOTION_NODE_THRESHOLD = 80;
308
+ export const GALAXY_REDUCED_MOTION_EDGE_THRESHOLD = 120;
286
309
  export const GALAXY_BASE_STAR_COUNT = 7200;
287
310
  export const GALAXY_EDGE_STAR_BOOST_RATIO = 1.1 * 1.1 - 1;
288
311
  export const GALAXY_MAX_CANVAS_PIXELS = 4_000_000;
@@ -300,6 +323,31 @@ export function normalizeGalaxyFrameRate(value) {
300
323
  return GALAXY_FRAME_RATE_OPTIONS.includes(candidate) ? candidate : GALAXY_TARGET_FRAME_RATE;
301
324
  }
302
325
 
326
+ export function normalizeGalaxyMotionMode(value) {
327
+ return GALAXY_MOTION_MODES.includes(value) ? value : "auto";
328
+ }
329
+
330
+ export function getGalaxyMotionProfile(mode, nodeCount, edgeCount, prefersReducedMotion = false) {
331
+ const requestedMode = normalizeGalaxyMotionMode(mode);
332
+ const thresholdExceeded = Math.max(0, Number(nodeCount) || 0) > GALAXY_REDUCED_MOTION_NODE_THRESHOLD
333
+ || Math.max(0, Number(edgeCount) || 0) > GALAXY_REDUCED_MOTION_EDGE_THRESHOLD;
334
+ const reducedBySystem = requestedMode === "auto" && Boolean(prefersReducedMotion);
335
+ const reducedByThreshold = requestedMode === "auto" && thresholdExceeded;
336
+ const effectiveMode = requestedMode === "off"
337
+ ? "off"
338
+ : requestedMode === "reduced" || reducedBySystem || reducedByThreshold ? "reduced" : "full";
339
+ return {
340
+ requestedMode,
341
+ effectiveMode,
342
+ reducedBySystem,
343
+ reducedByThreshold,
344
+ starfieldPhysics: effectiveMode === "full",
345
+ focusAnimation: effectiveMode !== "off",
346
+ cssMotion: effectiveMode !== "off",
347
+ maximumFrameRate: effectiveMode === "reduced" ? 24 : null
348
+ };
349
+ }
350
+
303
351
  export function formatRelationshipLabel(edge, separator = " · ") {
304
352
  const subtype = String(edge?.subtype ?? "").trim();
305
353
  const keywords = Array.isArray(edge?.keywords)
@@ -405,7 +453,11 @@ export function getRelationshipEdgeGeometry(from, to, sourceRadius = 0, targetRa
405
453
  : `M ${start.x.toFixed(1)} ${start.y.toFixed(1)} L ${end.x.toFixed(1)} ${end.y.toFixed(1)}`,
406
454
  labelX: labelPoint.x,
407
455
  labelY: labelPoint.y,
408
- angle: Math.atan2(end.y - start.y, end.x - start.x) * 180 / Math.PI
456
+ angle: Math.atan2(end.y - start.y, end.x - start.x) * 180 / Math.PI,
457
+ start,
458
+ control,
459
+ end,
460
+ curved
409
461
  };
410
462
  }
411
463
 
@@ -604,31 +656,49 @@ export function buildRelationshipGraph(characters, relationships) {
604
656
  function separateOverlappingNetworkNodes(nodes, layout) {
605
657
  const iterations = nodes.length > 150 ? 44 : 24;
606
658
  const padding = nodes.length > 120 ? 12 : 9;
659
+ const cellSize = 96;
660
+ const cellKey = (x, y) => `${Math.floor(x / cellSize)}:${Math.floor(y / cellSize)}`;
607
661
  for (let iteration = 0; iteration < iterations; iteration += 1) {
608
662
  let overlapCount = 0;
663
+ const grid = new Map();
664
+ nodes.forEach((node, index) => {
665
+ const key = cellKey(node.x, node.y);
666
+ const bucket = grid.get(key) ?? [];
667
+ bucket.push(index);
668
+ grid.set(key, bucket);
669
+ });
609
670
  for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
610
671
  const left = nodes[leftIndex];
611
- for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
612
- const right = nodes[rightIndex];
613
- let dx = right.x - left.x;
614
- let dy = right.y - left.y;
615
- let distance = Math.hypot(dx, dy);
616
- if (distance < 0.001) {
617
- const angle = (hashString(`${left.id}:${right.id}`) / 4294967296) * Math.PI * 2;
618
- dx = Math.cos(angle);
619
- dy = Math.sin(angle);
620
- distance = 1;
672
+ const cellX = Math.floor(left.x / cellSize);
673
+ const cellY = Math.floor(left.y / cellSize);
674
+ for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
675
+ for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
676
+ const bucket = grid.get(`${cellX + offsetX}:${cellY + offsetY}`);
677
+ if (!bucket) continue;
678
+ for (const rightIndex of bucket) {
679
+ if (rightIndex <= leftIndex) continue;
680
+ const right = nodes[rightIndex];
681
+ let dx = right.x - left.x;
682
+ let dy = right.y - left.y;
683
+ let distance = Math.hypot(dx, dy);
684
+ if (distance < 0.001) {
685
+ const angle = (hashString(`${left.id}:${right.id}`) / 4294967296) * Math.PI * 2;
686
+ dx = Math.cos(angle);
687
+ dy = Math.sin(angle);
688
+ distance = 1;
689
+ }
690
+ const minimumDistance = left.radius + right.radius + padding;
691
+ if (distance >= minimumDistance) continue;
692
+ overlapCount += 1;
693
+ const correction = (minimumDistance - distance) * 0.52;
694
+ const nx = dx / distance;
695
+ const ny = dy / distance;
696
+ left.x -= nx * correction;
697
+ left.y -= ny * correction;
698
+ right.x += nx * correction;
699
+ right.y += ny * correction;
700
+ }
621
701
  }
622
- const minimumDistance = left.radius + right.radius + padding;
623
- if (distance >= minimumDistance) continue;
624
- overlapCount += 1;
625
- const correction = (minimumDistance - distance) * 0.52;
626
- const nx = dx / distance;
627
- const ny = dy / distance;
628
- left.x -= nx * correction;
629
- left.y -= ny * correction;
630
- right.x += nx * correction;
631
- right.y += ny * correction;
632
702
  }
633
703
  }
634
704
  for (const node of nodes) {
@@ -1024,6 +1094,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1024
1094
 
1025
1095
  const rendererId = ++relationshipRendererSequence;
1026
1096
  const layout = options.expanded ? NETWORK_LAYOUTS.expanded : NETWORK_LAYOUTS.standard;
1097
+ const renderProfile = getRelationshipGraphRenderProfile(graph.stats.nodeCount, graph.stats.edgeCount);
1027
1098
  const laidOut = layoutRelationshipNetwork(graph, options.seed ?? "relationship-network-v3", { expanded: options.expanded });
1028
1099
  const positions = new Map(laidOut.nodes.map((node) => [node.id, { x: node.x, y: node.y }]));
1029
1100
  const originalPositions = new Map([...positions].map(([id, position]) => [id, { ...position }]));
@@ -1098,16 +1169,23 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1098
1169
  toolbar.append(nodeSearch.element, actions);
1099
1170
 
1100
1171
  const viewport = document.createElement("div");
1101
- viewport.className = "relationship-mindmap relationship-network relationship-obsidian";
1172
+ viewport.className = `relationship-mindmap relationship-network relationship-obsidian${renderProfile.dense ? " is-dense" : ""}`;
1102
1173
  viewport.dataset.testid = "relationship-network";
1103
1174
  viewport.dataset.layoutWidth = String(layout.width);
1104
1175
  viewport.dataset.layoutHeight = String(layout.height);
1105
1176
  viewport.dataset.interaction = "idle";
1106
1177
  viewport.dataset.renderStrategy = "obsidian-force-graph";
1178
+ viewport.dataset.edgeRenderStrategy = renderProfile.edgeMode;
1107
1179
  viewport.dataset.edgeLabelStrategy = "selected-only";
1108
1180
  const stage = document.createElement("div");
1109
1181
  stage.className = "relationship-mindmap-stage relationship-network-stage";
1110
1182
  viewport.append(stage);
1183
+ const edgeCanvas = renderProfile.dense ? document.createElement("canvas") : null;
1184
+ if (edgeCanvas) {
1185
+ edgeCanvas.className = "relationship-network-edge-canvas";
1186
+ edgeCanvas.setAttribute("aria-hidden", "true");
1187
+ stage.append(edgeCanvas);
1188
+ }
1111
1189
 
1112
1190
  const focusBadge = document.createElement("div");
1113
1191
  focusBadge.className = "relationship-network-focus";
@@ -1167,6 +1245,15 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1167
1245
  const edgeElementsByNode = new Map(graph.nodes.map((node) => [node.id, []]));
1168
1246
  const edgeCurveOffsets = assignRelationshipEdgeCurves(graph.edges);
1169
1247
  const nodeVisualRadii = new Map(graph.nodes.map((node) => [node.id, Math.max(4, Number(node.nodeSize) / 2 || 4)]));
1248
+ const edgeCanvasContext = edgeCanvas?.getContext("2d") ?? null;
1249
+ const edgeCanvasPixelRatio = edgeCanvas
1250
+ ? getRelationshipCanvasPixelRatio(window.devicePixelRatio, layout.width, layout.height)
1251
+ : 1;
1252
+ if (edgeCanvas && edgeCanvasContext) {
1253
+ edgeCanvas.width = Math.round(layout.width * edgeCanvasPixelRatio);
1254
+ edgeCanvas.height = Math.round(layout.height * edgeCanvasPixelRatio);
1255
+ edgeCanvasContext.setTransform(edgeCanvasPixelRatio, 0, 0, edgeCanvasPixelRatio, 0, 0);
1256
+ }
1170
1257
  const updateEdgeGeometry = ({ edge, hitPath, path }, { includeHit = true } = {}) => {
1171
1258
  const from = positions.get(edge.source);
1172
1259
  const to = positions.get(edge.target);
@@ -1178,8 +1265,9 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1178
1265
  nodeVisualRadii.get(edge.target),
1179
1266
  edgeCurveOffsets.get(edge.id)
1180
1267
  );
1181
- path.setAttribute("d", geometry.path);
1268
+ path?.setAttribute("d", geometry.path);
1182
1269
  if (includeHit) hitPath.setAttribute("d", geometry.path);
1270
+ return geometry;
1183
1271
  };
1184
1272
  const updateLabelGeometry = (edge) => {
1185
1273
  const from = positions.get(edge.source);
@@ -1209,6 +1297,68 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1209
1297
  label.setAttribute("y", middleY.toFixed(2));
1210
1298
  label.setAttribute("transform", `rotate(${angle.toFixed(2)} ${middleX.toFixed(2)} ${middleY.toFixed(2)})`);
1211
1299
  };
1300
+ const edgeCanvasColors = edgeCanvas
1301
+ ? (() => {
1302
+ const styles = getComputedStyle(container);
1303
+ return {
1304
+ normal: styles.getPropertyValue("--relationship-edge").trim() || "rgba(255,255,255,.26)",
1305
+ directed: styles.getPropertyValue("--relationship-edge-directed").trim() || "rgba(214,218,232,.5)",
1306
+ highlight: styles.getPropertyValue("--relationship-edge-highlight").trim() || "#9fc8ee",
1307
+ selected: styles.getPropertyValue("--relationship-edge-selected").trim() || "#f0b47d"
1308
+ };
1309
+ })()
1310
+ : null;
1311
+ const drawCanvasEdges = () => {
1312
+ if (!edgeCanvasContext || !edgeCanvasColors) return;
1313
+ edgeCanvasContext.clearRect(0, 0, layout.width, layout.height);
1314
+ edgeCanvasContext.lineCap = "round";
1315
+ edgeCanvasContext.lineJoin = "round";
1316
+ for (const edgeElement of edgeElements) {
1317
+ const geometry = updateEdgeGeometry(edgeElement, { includeHit: false });
1318
+ if (!geometry) continue;
1319
+ const selected = edgeElement.edge.id === selectedEdgeId;
1320
+ const focusedNodeId = hoveredId ?? selectedId;
1321
+ const highlighted = Boolean(focusedNodeId && (edgeElement.edge.source === focusedNodeId || edgeElement.edge.target === focusedNodeId));
1322
+ const dimmed = Boolean(selectedEdgeId ? !selected : focusedNodeId && !highlighted);
1323
+ edgeCanvasContext.globalAlpha = dimmed ? 0.06 : selected ? 1 : highlighted ? 1 : 0.92;
1324
+ edgeCanvasContext.strokeStyle = selected
1325
+ ? edgeCanvasColors.selected
1326
+ : highlighted
1327
+ ? edgeCanvasColors.highlight
1328
+ : edgeElement.edge.directed
1329
+ ? edgeCanvasColors.directed
1330
+ : edgeCanvasColors.normal;
1331
+ edgeCanvasContext.lineWidth = selected ? 1.8 : highlighted ? 1.35 : 1;
1332
+ edgeCanvasContext.setLineDash(edgeElement.edge.confirmationStatus === "pending" ? [4, 6] : []);
1333
+ edgeCanvasContext.beginPath();
1334
+ edgeCanvasContext.moveTo(geometry.start.x, geometry.start.y);
1335
+ if (geometry.curved) edgeCanvasContext.quadraticCurveTo(geometry.control.x, geometry.control.y, geometry.end.x, geometry.end.y);
1336
+ else edgeCanvasContext.lineTo(geometry.end.x, geometry.end.y);
1337
+ edgeCanvasContext.stroke();
1338
+ if (edgeElement.edge.directed) {
1339
+ const tangentX = geometry.end.x - (geometry.curved ? geometry.control.x : geometry.start.x);
1340
+ const tangentY = geometry.end.y - (geometry.curved ? geometry.control.y : geometry.start.y);
1341
+ const angle = Math.atan2(tangentY, tangentX);
1342
+ const arrowLength = selected ? 7 : 5.5;
1343
+ const arrowWidth = selected ? 3.2 : 2.6;
1344
+ edgeCanvasContext.fillStyle = edgeCanvasContext.strokeStyle;
1345
+ edgeCanvasContext.beginPath();
1346
+ edgeCanvasContext.moveTo(geometry.end.x, geometry.end.y);
1347
+ edgeCanvasContext.lineTo(
1348
+ geometry.end.x - Math.cos(angle) * arrowLength + Math.sin(angle) * arrowWidth,
1349
+ geometry.end.y - Math.sin(angle) * arrowLength - Math.cos(angle) * arrowWidth
1350
+ );
1351
+ edgeCanvasContext.lineTo(
1352
+ geometry.end.x - Math.cos(angle) * arrowLength - Math.sin(angle) * arrowWidth,
1353
+ geometry.end.y - Math.sin(angle) * arrowLength + Math.cos(angle) * arrowWidth
1354
+ );
1355
+ edgeCanvasContext.closePath();
1356
+ edgeCanvasContext.fill();
1357
+ }
1358
+ }
1359
+ edgeCanvasContext.setLineDash([]);
1360
+ edgeCanvasContext.globalAlpha = 1;
1361
+ };
1212
1362
  for (const edge of graph.edges) {
1213
1363
  const hitPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
1214
1364
  hitPath.classList.add("mind-edge-hit");
@@ -1216,28 +1366,32 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1216
1366
  hitPath.setAttribute("role", "button");
1217
1367
  hitPath.setAttribute("tabindex", "0");
1218
1368
  hitPath.setAttribute("aria-label", `选择 ${graph.nodeById.get(edge.source)?.name ?? "未知角色"} 与 ${graph.nodeById.get(edge.target)?.name ?? "未知角色"} 的关系:${formatRelationshipLabel(edge)}`);
1219
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1220
- path.classList.add("mind-edge", "obsidian-edge");
1221
- path.dataset.edgeId = edge.id;
1222
- path.dataset.edgeSource = edge.source;
1223
- path.dataset.edgeTarget = edge.target;
1224
- path.style.setProperty("--edge-opacity", "0.24");
1225
- path.style.setProperty("--edge-width", "1");
1226
- if (edge.confirmationStatus === "pending") path.classList.add("is-pending");
1227
- if (edge.directed) {
1228
- path.classList.add("is-directed");
1229
- path.setAttribute("marker-end", `url(#${arrowMarkerId})`);
1369
+ const path = renderProfile.dense ? null : document.createElementNS("http://www.w3.org/2000/svg", "path");
1370
+ path?.classList.add("mind-edge", "obsidian-edge");
1371
+ if (path) {
1372
+ path.dataset.edgeId = edge.id;
1373
+ path.dataset.edgeSource = edge.source;
1374
+ path.dataset.edgeTarget = edge.target;
1375
+ path.style.setProperty("--edge-opacity", "0.24");
1376
+ path.style.setProperty("--edge-width", "1");
1377
+ if (edge.confirmationStatus === "pending") path.classList.add("is-pending");
1378
+ if (edge.directed) {
1379
+ path.classList.add("is-directed");
1380
+ path.setAttribute("marker-end", `url(#${arrowMarkerId})`);
1381
+ }
1230
1382
  }
1231
1383
  const sourceName = graph.nodeById.get(edge.source)?.name ?? "未知角色";
1232
1384
  const targetName = graph.nodeById.get(edge.target)?.name ?? "未知角色";
1233
1385
  hitPath.setAttribute("aria-label", `选择 ${sourceName}${edge.directed ? " 指向 " : " 与 "}${targetName} 的关系:${formatRelationshipLabel(edge)}`);
1234
- svg.append(hitPath, path);
1386
+ if (renderProfile.dense) svg.append(hitPath);
1387
+ else svg.append(hitPath, path);
1235
1388
  const edgeElement = { edge, hitPath, path };
1236
1389
  edgeElements.push(edgeElement);
1237
1390
  edgeElementsByNode.get(edge.source)?.push(edgeElement);
1238
1391
  edgeElementsByNode.get(edge.target)?.push(edgeElement);
1239
1392
  updateEdgeGeometry(edgeElement);
1240
1393
  }
1394
+ drawCanvasEdges();
1241
1395
  const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
1242
1396
  label.setAttribute("text-anchor", "middle");
1243
1397
  label.classList.add("mind-edge-label", "is-edge-selected", "hidden");
@@ -1254,6 +1408,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1254
1408
  const updateAllGeometry = () => {
1255
1409
  nodeElements.forEach((_, nodeId) => updateNodePosition(nodeId));
1256
1410
  edgeElements.forEach((edgeElement) => updateEdgeGeometry(edgeElement));
1411
+ drawCanvasEdges();
1257
1412
  };
1258
1413
  const updateAdjacentGeometry = (nodeIds, { includeHit = false } = {}) => {
1259
1414
  const dirtyEdges = new Set();
@@ -1264,6 +1419,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1264
1419
  for (let index = 0; index < connected.length; index += 1) dirtyEdges.add(connected[index]);
1265
1420
  }
1266
1421
  dirtyEdges.forEach((edgeElement) => updateEdgeGeometry(edgeElement, { includeHit }));
1422
+ drawCanvasEdges();
1267
1423
  };
1268
1424
  const stopPhysicsLoop = () => {
1269
1425
  if (physicsFrame) window.cancelAnimationFrame(physicsFrame);
@@ -1455,7 +1611,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1455
1611
  const clearEdgeSelectionClasses = () => {
1456
1612
  nodeElements.forEach((button) => button.classList.remove("is-edge-endpoint"));
1457
1613
  edgeElements.forEach((item) => {
1458
- item.path.classList.remove("is-edge-selected");
1614
+ item.path?.classList.remove("is-edge-selected");
1459
1615
  item.hitPath.setAttribute("aria-pressed", "false");
1460
1616
  });
1461
1617
  label.classList.add("hidden");
@@ -1467,7 +1623,7 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1467
1623
  button.classList.remove("is-selected", "is-related", "is-dimmed", "is-hovered");
1468
1624
  });
1469
1625
  edgeElements.forEach((item) => {
1470
- item.path.classList.remove("is-highlighted", "is-dimmed");
1626
+ item.path?.classList.remove("is-highlighted", "is-dimmed");
1471
1627
  });
1472
1628
  focusText.textContent = "力导向布局 · 稳定均衡配色";
1473
1629
  edgeDetail.classList.add("hidden");
@@ -1488,9 +1644,10 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1488
1644
  });
1489
1645
  edgeElements.forEach((item) => {
1490
1646
  const active = item.edge.source === nodeId || item.edge.target === nodeId;
1491
- item.path.classList.toggle("is-highlighted", active);
1492
- item.path.classList.toggle("is-dimmed", !active);
1647
+ item.path?.classList.toggle("is-highlighted", active);
1648
+ item.path?.classList.toggle("is-dimmed", !active);
1493
1649
  });
1650
+ drawCanvasEdges();
1494
1651
  const name = graph.nodeById.get(nodeId)?.name ?? "未知角色";
1495
1652
  focusText.textContent = hover ? `悬浮:${name}` : `聚焦:${name}`;
1496
1653
  edgeDetail.classList.add("hidden");
@@ -1524,11 +1681,12 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1524
1681
  });
1525
1682
  edgeElements.forEach((item) => {
1526
1683
  const active = item.edge.id === selection.edgeId;
1527
- item.path.classList.toggle("is-highlighted", false);
1528
- item.path.classList.toggle("is-edge-selected", active);
1529
- item.path.classList.toggle("is-dimmed", !active);
1684
+ item.path?.classList.toggle("is-highlighted", false);
1685
+ item.path?.classList.toggle("is-edge-selected", active);
1686
+ item.path?.classList.toggle("is-dimmed", !active);
1530
1687
  item.hitPath.setAttribute("aria-pressed", String(active));
1531
1688
  });
1689
+ drawCanvasEdges();
1532
1690
  const fullLabel = selection.label;
1533
1691
  label.dataset.edgeId = selection.edgeId;
1534
1692
  label.dataset.fullLabel = fullLabel;
@@ -2130,7 +2288,13 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2130
2288
  const stats = dialog.querySelector("#galaxy-stats");
2131
2289
  const detail = dialog.querySelector("#galaxy-detail");
2132
2290
  const shell = dialog.querySelector(".galaxy-shell");
2133
- const targetFrameRate = normalizeGalaxyFrameRate(options.frameRate);
2291
+ const motionModeControl = dialog.querySelector("#galaxy-motion-mode");
2292
+ const motionStatus = dialog.querySelector("#galaxy-motion-status");
2293
+ const configuredFrameRate = normalizeGalaxyFrameRate(options.frameRate);
2294
+ const reducedMotionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
2295
+ let requestedMotionMode = normalizeGalaxyMotionMode(options.motionMode);
2296
+ let motionProfile = getGalaxyMotionProfile(requestedMotionMode, graph.stats.nodeCount, graph.stats.edgeCount, reducedMotionMedia.matches);
2297
+ let targetFrameRate = motionProfile.maximumFrameRate ?? configuredFrameRate;
2134
2298
  const seed = `${options.workId ?? "work"}|${graph.nodes.map((node) => node.id).join("|")}|${graph.edges.length}`;
2135
2299
  const layout = layoutGalaxy(graph, seed);
2136
2300
  const stars = createGalaxyStarfield(`${seed}|stars`);
@@ -2170,7 +2334,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2170
2334
  let cameraFocus = null;
2171
2335
  let draggedNode = null;
2172
2336
  let starPhysicsEnergy = 0;
2173
- let paused = false;
2337
+ let rotationPaused = false;
2174
2338
  let starsVisible = true;
2175
2339
  let gridVisible = false;
2176
2340
  let destroyed = false;
@@ -2192,6 +2356,8 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2192
2356
  shell.dataset.starPhysicsEnergy = "0";
2193
2357
  shell.dataset.rotationSpeed = String(GALAXY_ROTATION_RADIANS_PER_MS);
2194
2358
  shell.dataset.targetFrameRate = String(targetFrameRate);
2359
+ shell.dataset.motionNodeThreshold = String(GALAXY_REDUCED_MOTION_NODE_THRESHOLD);
2360
+ shell.dataset.motionEdgeThreshold = String(GALAXY_REDUCED_MOTION_EDGE_THRESHOLD);
2195
2361
  shell.dataset.layoutMinimumRadius = String(GALAXY_LAYOUT_CONFIG.minimumRadius);
2196
2362
  shell.dataset.layoutRadialSpan = String(GALAXY_LAYOUT_CONFIG.radialSpan);
2197
2363
  shell.dataset.layoutDesiredEdgeLength = String(GALAXY_LAYOUT_CONFIG.desiredEdgeLength);
@@ -2429,7 +2595,10 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2429
2595
  shell.dataset.renderedFrameCount = String(renderedFrameCount);
2430
2596
  };
2431
2597
 
2432
- const shouldAnimate = () => !paused || Boolean(cameraFocus) || Boolean(draggedNode) || starPhysicsEnergy > 0.01;
2598
+ const shouldRotate = () => !rotationPaused;
2599
+ const shouldAnimate = () => shouldRotate()
2600
+ || Boolean(cameraFocus)
2601
+ || (motionProfile.starfieldPhysics && (Boolean(draggedNode) || starPhysicsEnergy > 0.01));
2433
2602
 
2434
2603
  const renderFrame = (time) => {
2435
2604
  animationFrame = 0;
@@ -2455,8 +2624,8 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2455
2624
  shell.classList.remove("is-focusing-node");
2456
2625
  }
2457
2626
  }
2458
- if (!paused && !cameraDrag) camera.yaw += elapsed * GALAXY_ROTATION_RADIANS_PER_MS;
2459
- if (draggedNode || starPhysicsEnergy > 0.01) {
2627
+ if (shouldRotate() && !cameraDrag) camera.yaw += elapsed * GALAXY_ROTATION_RADIANS_PER_MS;
2628
+ if (motionProfile.starfieldPhysics && (draggedNode || starPhysicsEnergy > 0.01)) {
2460
2629
  starPhysicsEnergy = stepGalaxyStarfieldPhysics(stars, draggedNode, { deltaMs: elapsed || 16.667 });
2461
2630
  shell.dataset.starPhysicsEnergy = starPhysicsEnergy.toFixed(3);
2462
2631
  }
@@ -2485,9 +2654,8 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2485
2654
 
2486
2655
  const focusCameraOnNode = (node) => {
2487
2656
  const target = getGalaxyNodeFocusCamera(node, camera);
2488
- const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2489
2657
  shell.dataset.focusedNodeId = node.id;
2490
- if (reducedMotion) {
2658
+ if (!motionProfile.focusAnimation) {
2491
2659
  Object.assign(camera, target);
2492
2660
  drawScene();
2493
2661
  return;
@@ -2618,7 +2786,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2618
2786
  dragged: false
2619
2787
  };
2620
2788
  draggedNode = node;
2621
- startAnimation();
2789
+ if (motionProfile.starfieldPhysics) startAnimation();
2622
2790
  button.setPointerCapture(event.pointerId);
2623
2791
  button.classList.add("is-dragging");
2624
2792
  button.setAttribute("aria-grabbed", "true");
@@ -2699,17 +2867,75 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2699
2867
  drawScene();
2700
2868
  };
2701
2869
 
2870
+ const resetStarfieldPhysics = () => {
2871
+ for (const star of stars) {
2872
+ star.x = star.originX;
2873
+ star.z = star.originZ;
2874
+ star.vx = 0;
2875
+ star.vz = 0;
2876
+ }
2877
+ starPhysicsEnergy = 0;
2878
+ shell.dataset.starPhysicsEnergy = "0";
2879
+ };
2880
+
2702
2881
  const updateRotationControl = () => {
2703
- shell.classList.toggle("is-paused", paused);
2882
+ shell.classList.toggle("is-paused", rotationPaused);
2704
2883
  const control = dialog.querySelector("#galaxy-rotation");
2705
- control.setAttribute("aria-pressed", String(paused));
2706
- control.textContent = paused ? "继续旋转" : "暂停旋转";
2884
+ control.disabled = false;
2885
+ control.setAttribute("aria-pressed", String(rotationPaused));
2886
+ control.textContent = rotationPaused ? "继续旋转" : "暂停旋转";
2887
+ };
2888
+
2889
+ const updateMotionControls = () => {
2890
+ const automaticallyReduced = motionProfile.reducedBySystem || motionProfile.reducedByThreshold;
2891
+ motionModeControl.value = requestedMotionMode;
2892
+ motionModeControl.dataset.effectiveMode = motionProfile.effectiveMode;
2893
+ shell.dataset.motionRequestedMode = requestedMotionMode;
2894
+ shell.dataset.motionMode = motionProfile.effectiveMode;
2895
+ shell.dataset.motionAutoReduced = String(automaticallyReduced);
2896
+ shell.classList.toggle("is-motion-reduced", motionProfile.effectiveMode !== "full");
2897
+ shell.classList.toggle("is-motion-off", motionProfile.effectiveMode === "off");
2898
+ motionStatus.value = motionProfile.effectiveMode === "off"
2899
+ ? "全部动效已关闭"
2900
+ : requestedMotionMode === "on"
2901
+ ? "动效始终开启"
2902
+ : requestedMotionMode === "reduced"
2903
+ ? "持续动效已减少"
2904
+ : motionProfile.reducedByThreshold
2905
+ ? "图谱较大,已自动减少"
2906
+ : motionProfile.reducedBySystem
2907
+ ? "跟随系统,已减少"
2908
+ : "当前为完整动效";
2909
+ motionStatus.title = `超过 ${GALAXY_REDUCED_MOTION_NODE_THRESHOLD} 个节点或 ${GALAXY_REDUCED_MOTION_EDGE_THRESHOLD} 条关系时自动减少动效`;
2910
+ updateRotationControl();
2911
+ };
2912
+
2913
+ const applyMotionMode = (mode, { persist = true, redraw = true } = {}) => {
2914
+ requestedMotionMode = normalizeGalaxyMotionMode(mode);
2915
+ motionProfile = getGalaxyMotionProfile(
2916
+ requestedMotionMode,
2917
+ graph.stats.nodeCount,
2918
+ graph.stats.edgeCount,
2919
+ reducedMotionMedia.matches
2920
+ );
2921
+ targetFrameRate = motionProfile.maximumFrameRate ?? configuredFrameRate;
2922
+ shell.dataset.targetFrameRate = String(targetFrameRate);
2923
+ if (!motionProfile.starfieldPhysics) resetStarfieldPhysics();
2924
+ if (!motionProfile.focusAnimation && cameraFocus) {
2925
+ Object.assign(camera, cameraFocus.to);
2926
+ cancelCameraFocus();
2927
+ }
2928
+ updateMotionControls();
2929
+ if (redraw) drawScene();
2930
+ if (shouldAnimate()) startAnimation();
2931
+ else stopAnimation();
2932
+ if (persist) options.onMotionModeChange?.(requestedMotionMode);
2707
2933
  };
2708
2934
 
2709
2935
  const open = () => {
2710
2936
  if (destroyed) return;
2711
2937
  if (!dialog.open) dialog.showModal();
2712
- paused = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2938
+ rotationPaused = false;
2713
2939
  starsVisible = true;
2714
2940
  gridVisible = false;
2715
2941
  background.classList.remove("hidden-stars");
@@ -2722,6 +2948,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2722
2948
  shell.dataset.resourceState = "active";
2723
2949
  stats.value = `${graph.stats.nodeCount} 个节点 / ${graph.stats.edgeCount} 条关系`;
2724
2950
  renderNodes();
2951
+ applyMotionMode(requestedMotionMode, { persist: false, redraw: false });
2725
2952
  drawScene();
2726
2953
  if (shouldAnimate()) startAnimation();
2727
2954
  dialog.querySelector("#galaxy-close").focus();
@@ -2736,6 +2963,10 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2736
2963
  listen(dialog.querySelector("#galaxy-reset"), "click", reset);
2737
2964
  listen(dialog.querySelector("#galaxy-zoom-in"), "click", () => zoom(1.18));
2738
2965
  listen(dialog.querySelector("#galaxy-zoom-out"), "click", () => zoom(1 / 1.18));
2966
+ listen(motionModeControl, "change", (event) => applyMotionMode(event.currentTarget.value));
2967
+ listen(reducedMotionMedia, "change", () => {
2968
+ if (requestedMotionMode === "auto") applyMotionMode(requestedMotionMode, { persist: false });
2969
+ });
2739
2970
  listen(dialog.querySelector("#galaxy-stars"), "click", (event) => {
2740
2971
  starsVisible = !starsVisible;
2741
2972
  background.classList.toggle("hidden-stars", !starsVisible);
@@ -2751,7 +2982,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2751
2982
  drawScene();
2752
2983
  });
2753
2984
  listen(dialog.querySelector("#galaxy-rotation"), "click", () => {
2754
- paused = !paused;
2985
+ rotationPaused = !rotationPaused;
2755
2986
  updateRotationControl();
2756
2987
  drawScene();
2757
2988
  if (shouldAnimate()) startAnimation();
@@ -2843,7 +3074,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
2843
3074
  backdrop = null;
2844
3075
  shell.dataset.resourceState = "released";
2845
3076
  shell.dataset.starCount = "0";
2846
- shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused", "is-focusing-node");
3077
+ shell.classList.remove("is-three-dimensional", "is-rotating-camera", "is-paused", "is-focusing-node", "is-motion-reduced", "is-motion-off");
2847
3078
  };
2848
3079
 
2849
3080
  listen(dialog, "close", () => {
@@ -5,6 +5,7 @@ export type StreamTypewriterProgress = {
5
5
 
6
6
  export type StreamTypewriter = {
7
7
  append(value: unknown): void;
8
+ replace(value: unknown): string;
8
9
  finish(): Promise<string>;
9
10
  reveal(): string;
10
11
  };
@@ -122,6 +122,18 @@ export function createStreamTypewriter({
122
122
  pendingCharacters.push(...characters);
123
123
  schedule();
124
124
  },
125
+ replace(value) {
126
+ if (scheduledFrame !== null) {
127
+ cancelFrame(scheduledFrame);
128
+ scheduledFrame = null;
129
+ }
130
+ visibleCharacters.splice(0, visibleCharacters.length, ...Array.from(String(value ?? "")));
131
+ pendingCharacters.splice(0);
132
+ finishing = false;
133
+ render();
134
+ resolveIdle();
135
+ return snapshot();
136
+ },
125
137
  finish() {
126
138
  if (!pendingCharacters.length && scheduledFrame === null) return Promise.resolve(snapshot());
127
139
  finishing = true;