feat(graph-ui): focus galaxy graph interactions

This commit is contained in:
youzini
2026-06-04 14:23:15 +00:00
parent 78ebdfa548
commit 2454a92e74
2 changed files with 291 additions and 6 deletions

View File

@@ -568,6 +568,94 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js");
renderer.destroy();
}
{
const graph = createStarSeedGraph({ includeFragment: true });
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
layoutConfig: {
neuralIterations: 8,
cameraFocusDurationMs: 120,
},
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
renderer.highlightNode("star-core");
renderer.destroy();
assertNoPendingRafOrTimers("destroy cancels unsettled camera focus animation");
}
{
const graph = createStarSeedGraph({ includeFragment: true });
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
layoutConfig: {
neuralIterations: 8,
cameraFocusDurationMs: 32,
},
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
renderer.highlightNode("star-core");
const frames = flushRafsUntilIdle({ maxFrames: 10, ms: 16 });
assert.ok(frames > 0, "camera focus uses bounded RAF animation");
assert.ok(renderer.scale >= 1, "camera focus does not zoom out selected node");
assertNoPendingRafOrTimers("camera focus animation settles");
renderer.destroy();
}
{
const graph = createStarSeedGraph({ includeFragment: true });
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
layoutConfig: { neuralIterations: 8 },
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
renderer.zoomIn();
assert.ok(timerCallbacks.size > 0, "edge dim restore timer is scheduled while moving/zooming");
renderer.setEnabled(false);
assertNoPendingRafOrTimers("disable clears edge dim restore timer");
renderer.destroy();
}
{
const graph = createStarSeedGraph({ includeFragment: true });
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
layoutConfig: { neuralIterations: 8 },
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
const darkObjectivePanel = renderer._regionPanels.find((panel) => panel.key === "objective");
renderer.setTheme("paperDawn");
const lightObjectivePanel = renderer._regionPanels.find((panel) => panel.key === "objective");
assert.ok(darkObjectivePanel && lightObjectivePanel, "theme switch keeps objective panel metadata");
assert.notEqual(
Math.round(darkObjectivePanel.w),
Math.round(lightObjectivePanel.w),
"dark/light theme switch recomputes galaxy versus legacy layout regions",
);
renderer.destroy();
}
{
const graph = createLabelBudgetGraph(18);
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: {
graphUseNativeLayout: true,
graphNativeForceDisable: false,
graphNativeLayoutThresholdNodes: 1,
graphNativeLayoutThresholdEdges: 1,
},
layoutConfig: { neuralIterations: 8 },
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
const diagnostics = renderer.getLastLayoutDiagnostics();
assert.notEqual(diagnostics.mode, "native-worker", "dark galaxy mode disables native layout until cross-region spring parity exists");
renderer.destroy();
}
{
const { renderer, restoreMatchMedia } = createAnimatedLayoutRenderer({ reducedMotion: true });
try {

View File

@@ -71,6 +71,11 @@ const DEFAULT_LAYOUT_CONFIG = {
layoutAnimationRestartWindowMs: 5000,
layoutAnimationRestartMax: 2,
layoutAnimationCooldownMs: 9000,
galaxyLayout: true,
cameraFocusAnimation: true,
cameraFocusDurationMs: 360,
cameraFocusScale: 1.35,
hideEdgesOnMove: true,
};
const ADAPTIVE_NEURAL_LAYOUT_POLICY = Object.freeze({
@@ -367,6 +372,10 @@ export class GraphRenderer {
reason: 'not-started',
frameCount: 0,
};
this._cameraAnimId = null;
this._cameraAnimationState = null;
this._edgeInteractionDimUntil = 0;
this._edgeInteractionRestoreTimer = null;
this._lastLayoutDiagnostics = null;
this._lastLayoutReuseStats = { reused: 0, total: 0, ratio: 0 };
this._lastLayoutSeedModeCounts = {
@@ -662,9 +671,22 @@ export class GraphRenderer {
* 切换主题
*/
setTheme(themeName) {
const wasGalaxyMode = this._isDarkGalaxyMode();
this.themeName = themeName;
this.colors = getNodeColors(themeName);
if (this.enabled) this._render();
const nextGalaxyMode = this._isDarkGalaxyMode();
if (!this.enabled) return;
if (wasGalaxyMode !== nextGalaxyMode && this.nodes.length > 0) {
this._nextLayoutSolveRevision();
this._cancelLayoutAnimation('theme-layout-mode-changed');
this._cancelCameraAnimation('theme-layout-mode-changed');
this._nativeLayoutBridge?.cancelPending?.('theme-layout-mode-changed');
const dpr = window.devicePixelRatio || 1;
const W = Math.max(1, this.canvas.width / dpr);
const H = Math.max(1, this.canvas.height / dpr);
this._rebuildLayoutForCurrentViewport(W, H);
}
this._render();
}
setTransientHighlights(payload = {}) {
@@ -762,7 +784,89 @@ export class GraphRenderer {
*/
highlightNode(nodeId) {
this.selectedNode = this.nodeMap.get(nodeId) || null;
if (this.enabled) this._render();
if (this.selectedNode) this._focusCameraOnNode(this.selectedNode);
else if (this.enabled) this._render();
}
_focusCameraOnNode(node) {
if (!this.enabled || !node) return;
const rect = this.canvas.getBoundingClientRect?.() || {};
const width = Math.max(1, Number(rect.width) || Number(this.canvas.width) || 1);
const height = Math.max(1, Number(rect.height) || Number(this.canvas.height) || 1);
const targetScale = Math.max(
0.2,
Math.min(5, Math.max(this.scale, Number(this.config.cameraFocusScale) || 1.35)),
);
const targetOffsetX = width * 0.5 - node.x * targetScale;
const targetOffsetY = height * 0.46 - node.y * targetScale;
if (
this.config.cameraFocusAnimation === false
|| this._isReducedMotion()
) {
this.scale = targetScale;
this.offsetX = targetOffsetX;
this.offsetY = targetOffsetY;
this._render();
return;
}
this._startCameraAnimation({
targetScale,
targetOffsetX,
targetOffsetY,
durationMs: Math.max(120, Math.min(900, Number(this.config.cameraFocusDurationMs) || 360)),
reason: 'node-focus',
});
}
_startCameraAnimation({ targetScale, targetOffsetX, targetOffsetY, durationMs = 360, reason = 'camera' } = {}) {
this._cancelCameraAnimation('replaced');
const startedAt = this._nowMs();
this._cameraAnimationState = {
startedAt,
durationMs,
startScale: this.scale,
startOffsetX: this.offsetX,
startOffsetY: this.offsetY,
targetScale,
targetOffsetX,
targetOffsetY,
reason,
};
this._scheduleCameraAnimationFrame();
}
_scheduleCameraAnimationFrame() {
if (!this.enabled || this._cameraAnimId || !this._cameraAnimationState) return;
this._cameraAnimId = requestAnimationFrame((timestamp) => {
this._cameraAnimId = null;
this._tickCameraAnimation(timestamp);
});
}
_tickCameraAnimation(timestamp = this._nowMs()) {
const state = this._cameraAnimationState;
if (!state || !this.enabled) return;
const elapsed = Math.max(0, Number(timestamp || this._nowMs()) - state.startedAt);
const t = Math.min(1, elapsed / Math.max(1, state.durationMs));
const eased = 1 - Math.pow(1 - t, 3);
const lerp = (a, b) => a + (b - a) * eased;
this.scale = lerp(state.startScale, state.targetScale);
this.offsetX = lerp(state.startOffsetX, state.targetOffsetX);
this.offsetY = lerp(state.startOffsetY, state.targetOffsetY);
this._render();
if (t >= 1) {
this._cameraAnimationState = null;
return;
}
this._scheduleCameraAnimationFrame();
}
_cancelCameraAnimation(_reason = 'cancelled') {
if (this._cameraAnimId) {
cancelAnimationFrame(this._cameraAnimId);
this._cameraAnimId = null;
}
this._cameraAnimationState = null;
}
_clearCanvas() {
@@ -778,6 +882,8 @@ export class GraphRenderer {
if (this.enabled === nextEnabled) {
if (!nextEnabled) {
this._clearTransientHighlights({ cancelAnimation: true });
this._cancelCameraAnimation('renderer-disabled');
this._cancelEdgeInteractionRestoreTimer();
this._clearCanvas();
}
return;
@@ -790,6 +896,8 @@ export class GraphRenderer {
}
this._cancelAnim();
this._cancelLayoutAnimation('renderer-disabled');
this._cancelCameraAnimation('renderer-disabled');
this._cancelEdgeInteractionRestoreTimer();
this.dragNode = null;
this.isDragging = false;
this.isPanning = false;
@@ -815,7 +923,14 @@ export class GraphRenderer {
// ==================== 分区布局 ====================
_isDarkGalaxyMode() {
return !LIGHT_PANEL_THEMES.has(this.themeName) && this.config?.galaxyLayout !== false;
}
_computeRegionPanels(W, H, { objective, userPov, charMap }) {
if (this._isDarkGalaxyMode()) {
return this._computeGalaxyRegionPanels(W, H, { objective, userPov, charMap });
}
const pad = 14;
const gutter = 10;
const topPad = 20;
@@ -937,6 +1052,45 @@ export class GraphRenderer {
return panels;
}
_computeGalaxyRegionPanels(W, H, { objective, userPov, charMap }) {
const width = Math.max(1, Number(W) || 1);
const height = Math.max(1, Number(H) || 1);
const safe = Math.max(18, Math.min(width, height) * 0.05);
const makeRect = (cx, cy, rw, rh) => ({
x: Math.max(safe, Math.min(width - safe - rw, cx - rw / 2)),
y: Math.max(safe, Math.min(height - safe - rh, cy - rh / 2)),
w: Math.max(80, Math.min(width - safe * 2, rw)),
h: Math.max(80, Math.min(height - safe * 2, rh)),
});
const panels = [];
const objectiveRect = makeRect(width * 0.5, height * 0.5, width * 0.82, height * 0.78);
panels.push({ ...objectiveRect, label: '客观层', tint: 'rgba(87, 199, 255, 0.02)', key: 'objective' });
for (const n of objective) n.regionRect = objectiveRect;
if (userPov.length) {
const userRect = makeRect(width * 0.68, height * 0.68, width * 0.44, height * 0.42);
panels.push({ ...userRect, label: '用户 POV', tint: 'rgba(125, 255, 155, 0.02)', key: 'user' });
for (const n of userPov) n.regionRect = userRect;
}
const charEntries = [...charMap.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'zh'));
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const orbit = Math.min(width, height) * 0.26;
charEntries.forEach(([owner, nodes], index) => {
const t = index + 1;
const angle = t * goldenAngle;
const radius = orbit * Math.sqrt(t / Math.max(1, charEntries.length));
const cx = width * 0.5 + Math.cos(angle) * radius;
const cy = height * 0.5 + Math.sin(angle) * radius * 0.78;
const rect = makeRect(cx, cy, width * 0.34, height * 0.36);
const key = `char:${owner || 'unknown'}`;
panels.push({ ...rect, label: `角色 POV · ${owner || '未知角色'}`, tint: 'rgba(255, 179, 71, 0.02)', key });
for (const n of nodes) n.regionRect = rect;
});
return panels;
}
_layoutAllPartitions({ objective, userPov, charMap }) {
const layoutAdjacencyIndex = this._buildLayoutAdjacencyIndex();
this._seedNeuralCloudInRect(
@@ -1417,6 +1571,9 @@ export class GraphRenderer {
}
_shouldTryNativeLayout(nodeCount = 0, edgeCount = 0) {
// Dark galaxy mode currently uses weak cross-region springs in the JS solver.
// Keep native/worker disabled until payload parity supports that spring model.
if (this._isDarkGalaxyMode()) return false;
if (this.runtimeConfig.graphNativeForceDisable) return false;
if (!this.runtimeConfig.graphUseNativeLayout) return false;
const bridge = this._ensureNativeLayoutBridge();
@@ -1794,14 +1951,16 @@ export class GraphRenderer {
for (const edge of this.edges) {
const { from, to, strength } = edge;
if (from.regionKey !== to.regionKey) continue;
const sameRegion = from.regionKey === to.regionKey;
if (!sameRegion && !this._isDarkGalaxyMode()) continue;
const ideal =
springIdeal.get(from.regionKey) ?? 68;
springIdeal.get(sameRegion ? from.regionKey : 'objective') ?? 68;
let dx = to.x - from.x;
let dy = to.y - from.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
const displacement = dist - ideal * (0.82 + 0.18 * strength);
const f = springK * displacement * (0.45 + 0.55 * strength);
const crossScale = sameRegion ? 1 : 0.34;
const f = springK * crossScale * displacement * (0.45 + 0.55 * strength);
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
from._fx += fx;
@@ -1883,6 +2042,7 @@ export class GraphRenderer {
const selectedNode = focus?.selectedNode || null;
const isConnectedToSelection = !!selectedNode && (from === selectedNode || to === selectedNode);
const isDimmed = !!selectedNode && !isConnectedToSelection;
const isMoving = focus?.edgesDimmedOnMove === true;
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
const dx = to.x - from.x;
@@ -1902,7 +2062,8 @@ export class GraphRenderer {
const unselectedColor = isLightTheme ? '#9eb2cf' : edgeColor;
const baseAlpha = sameZone ? 0.04 + strength * 0.06 : 0.03 + strength * 0.05;
const alpha = isDimmed ? (isLightTheme ? 0.012 : 0.01) : (isConnectedToSelection ? 0.35 + strength * 0.25 : baseAlpha);
let alpha = isDimmed ? (isLightTheme ? 0.012 : 0.01) : (isConnectedToSelection ? 0.35 + strength * 0.25 : baseAlpha);
if (isMoving && !isConnectedToSelection) alpha *= 0.22;
if (isConnectedToSelection) {
ctx.beginPath();
@@ -1951,6 +2112,8 @@ export class GraphRenderer {
this._drawGrid(W, H);
const focus = this._buildFocusState();
focus.edgesDimmedOnMove = this.config?.hideEdgesOnMove !== false
&& this._nowMs() < Number(this._edgeInteractionDimUntil || 0);
this.edges.forEach((e, i) => this._drawSynapseEdge(ctx, e, i, focus));
@@ -2423,6 +2586,14 @@ export class GraphRenderer {
this._cancelAnim();
this._cancelLayoutAnimation('stop-animation');
this._cancelHighlightAnimationFrame();
this._cancelCameraAnimation('stop-animation');
}
_cancelEdgeInteractionRestoreTimer() {
if (this._edgeInteractionRestoreTimer) {
clearTimeout(this._edgeInteractionRestoreTimer);
this._edgeInteractionRestoreTimer = null;
}
}
_bindEvents() {
@@ -2486,6 +2657,7 @@ export class GraphRenderer {
this._dragStartMouse = null;
if (!sess.moved && sess.nodeCandidate) {
this.selectedNode = sess.nodeCandidate;
this._focusCameraOnNode(sess.nodeCandidate);
if (this.onNodeSelect) this.onNodeSelect(sess.nodeCandidate);
if (this.onNodeClick) this.onNodeClick(sess.nodeCandidate);
this._render();
@@ -2504,6 +2676,19 @@ export class GraphRenderer {
_markTouchInteraction() {
this._suppressMouseUntil = Date.now() + 650;
this._markGraphMoveInteraction();
}
_markGraphMoveInteraction() {
if (this.config?.hideEdgesOnMove !== false) {
this._edgeInteractionDimUntil = this._nowMs() + 140;
if (this._edgeInteractionRestoreTimer) clearTimeout(this._edgeInteractionRestoreTimer);
this._edgeInteractionRestoreTimer = setTimeout(() => {
this._edgeInteractionRestoreTimer = null;
if (this.enabled) this._scheduleRender();
}, 150);
}
this._cancelCameraAnimation('user-interaction');
}
_shouldIgnoreMouseEvent() {
@@ -2536,10 +2721,12 @@ export class GraphRenderer {
this._dragStartMouse = { x: e.clientX, y: e.clientY };
if (node) {
this._markGraphMoveInteraction();
this.dragNode = node;
node.pinned = true;
this.isDragging = true;
} else {
this._markGraphMoveInteraction();
this.isPanning = true;
}
}
@@ -2549,11 +2736,13 @@ export class GraphRenderer {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
if (this.isDragging && this.dragNode) {
this._markGraphMoveInteraction();
this.dragNode.x = x;
this.dragNode.y = y;
this._clampNodeToRegion(this.dragNode);
this._scheduleRender();
} else if (this.isPanning) {
this._markGraphMoveInteraction();
this.offsetX += e.clientX - this.lastMouse.x;
this.offsetY += e.clientY - this.lastMouse.y;
this._scheduleRender();
@@ -2580,6 +2769,7 @@ export class GraphRenderer {
const movedDistance = Math.sqrt(dx * dx + dy * dy);
if (movedDistance < 6) {
this.selectedNode = this.dragNode;
this._focusCameraOnNode(this.dragNode);
if (this.onNodeSelect) this.onNodeSelect(this.dragNode);
if (this.onNodeClick) this.onNodeClick(this.dragNode);
}
@@ -2595,6 +2785,7 @@ export class GraphRenderer {
_onWheel(e) {
if (!this.enabled) return;
e.preventDefault();
this._markGraphMoveInteraction();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.2, Math.min(5, this.scale * factor));
@@ -2614,6 +2805,7 @@ export class GraphRenderer {
const node = this._findNodeAt(x, y);
if (node) {
this.selectedNode = node;
this._focusCameraOnNode(node);
if (this.onNodeSelect) this.onNodeSelect(node);
if (this.onNodeDoubleClick) this.onNodeDoubleClick(node);
this._render();
@@ -2624,18 +2816,21 @@ export class GraphRenderer {
zoomIn() {
if (!this.enabled) return;
this._markGraphMoveInteraction();
this.scale = Math.min(5, this.scale * 1.2);
this._render();
}
zoomOut() {
if (!this.enabled) return;
this._markGraphMoveInteraction();
this.scale = Math.max(0.2, this.scale * 0.8);
this._render();
}
resetView() {
if (!this.enabled) return;
this._cancelCameraAnimation('reset-view');
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
@@ -2690,6 +2885,8 @@ export class GraphRenderer {
this._nextLayoutSolveRevision();
this._cancelAnim();
this._cancelLayoutAnimation('destroy');
this._cancelCameraAnimation('destroy');
this._cancelEdgeInteractionRestoreTimer();
this._clearTransientHighlights({ cancelAnimation: true });
this._nativeLayoutBridge?.dispose?.();
this._nativeLayoutBridge = null;