@musnows/scriverse 0.7.13 → 0.8.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.
- package/README.en.md +4 -0
- package/README.md +5 -0
- package/dist/ai-chat-tab-limit.js +14 -0
- package/dist/ai-chat-tab-limit.js.map +1 -0
- package/dist/ai-protocol.js +10 -2
- package/dist/ai-protocol.js.map +1 -1
- package/dist/ai-retry.js +52 -0
- package/dist/ai-retry.js.map +1 -0
- package/dist/ai.js +525 -117
- package/dist/ai.js.map +1 -1
- package/dist/app.js +41 -12
- package/dist/app.js.map +1 -1
- package/dist/cli-contract.js +2 -1
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +127 -2
- package/dist/database.js.map +1 -1
- package/dist/domain.js +1 -0
- package/dist/domain.js.map +1 -1
- package/dist/public/ai-chat-tabs.js +75 -0
- package/dist/public/ai-request-manager.js +32 -15
- package/dist/public/app.js +1151 -252
- package/dist/public/background-task-center.d.ts +9 -0
- package/dist/public/background-task-center.js +18 -0
- package/dist/public/chapter-search.d.ts +6 -0
- package/dist/public/chapter-search.js +23 -0
- package/dist/public/character-filters.d.ts +3 -1
- package/dist/public/character-filters.js +17 -2
- package/dist/public/character-version.js +1 -0
- package/dist/public/display-labels.d.ts +1 -0
- package/dist/public/display-labels.js +5 -1
- package/dist/public/index.html +66 -25
- package/dist/public/model-config.d.ts +3 -1
- package/dist/public/model-config.js +13 -0
- package/dist/public/relationship-filters.d.ts +5 -0
- package/dist/public/relationship-filters.js +31 -0
- package/dist/public/relationship-graph.js +289 -58
- package/dist/public/styles.css +170 -32
- package/dist/server-runtime.js +6 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +77 -34
- package/dist/store.js.map +1 -1
- package/dist/version.js +6 -1
- package/dist/version.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
let
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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 =
|
|
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
|
|
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
|
|
1221
|
-
path
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1492
|
-
item.path
|
|
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
|
|
1528
|
-
item.path
|
|
1529
|
-
item.path
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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",
|
|
2882
|
+
shell.classList.toggle("is-paused", rotationPaused);
|
|
2704
2883
|
const control = dialog.querySelector("#galaxy-rotation");
|
|
2705
|
-
control.
|
|
2706
|
-
control.
|
|
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
|
-
|
|
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
|
-
|
|
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", () => {
|