fix: stabilize mobile graph dragging and resize

This commit is contained in:
Youzini-afk
2026-04-11 23:18:23 +08:00
parent ba09d3b31b
commit fdfc15303b
4 changed files with 164 additions and 9 deletions

View File

@@ -0,0 +1,46 @@
function clampUnit(value) {
if (!Number.isFinite(Number(value))) return 0;
return Math.min(1, Math.max(0, Number(value)));
}
export function isUsableGraphCanvasSize(width = 0, height = 0, minDimension = 48) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
const threshold = Number.isFinite(Number(minDimension))
? Math.max(1, Number(minDimension))
: 48;
return (
Number.isFinite(normalizedWidth) &&
Number.isFinite(normalizedHeight) &&
normalizedWidth >= threshold &&
normalizedHeight >= threshold
);
}
export function remapPositionBetweenRects(x = 0, y = 0, prevRect = null, nextRect = null) {
const pointX = Number.isFinite(Number(x)) ? Number(x) : 0;
const pointY = Number.isFinite(Number(y)) ? Number(y) : 0;
if (!prevRect || !nextRect) {
return {
x: pointX,
y: pointY,
};
}
const prevX = Number.isFinite(Number(prevRect.x)) ? Number(prevRect.x) : 0;
const prevY = Number.isFinite(Number(prevRect.y)) ? Number(prevRect.y) : 0;
const prevW = Math.max(1, Number.isFinite(Number(prevRect.w)) ? Number(prevRect.w) : 0);
const prevH = Math.max(1, Number.isFinite(Number(prevRect.h)) ? Number(prevRect.h) : 0);
const nextX = Number.isFinite(Number(nextRect.x)) ? Number(nextRect.x) : 0;
const nextY = Number.isFinite(Number(nextRect.y)) ? Number(nextRect.y) : 0;
const nextW = Math.max(1, Number.isFinite(Number(nextRect.w)) ? Number(nextRect.w) : 0);
const nextH = Math.max(1, Number.isFinite(Number(nextRect.h)) ? Number(nextRect.h) : 0);
const relX = clampUnit((pointX - prevX) / prevW);
const relY = clampUnit((pointY - prevY) / prevH);
return {
x: nextX + relX * nextW,
y: nextY + relY * nextH,
};
}

View File

@@ -2,6 +2,10 @@
// 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定,无帧循环抖动
import { getNodeColors } from './themes.js';
import {
isUsableGraphCanvasSize,
remapPositionBetweenRects,
} from './graph-renderer-utils.js';
import { getGraphNodeLabel, getNodeDisplayName } from '../graph/node-labels.js';
import { normalizeMemoryScope } from '../graph/memory-scope.js';
import {
@@ -40,6 +44,8 @@ const DEFAULT_LAYOUT_CONFIG = {
neuralMinGap: 12,
};
const MIN_USABLE_CANVAS_DIMENSION = 48;
/** 兼容旧版 forceConfig召回卡片等 */
function layoutKeysFromForceConfig(fc) {
if (!fc || typeof fc !== 'object') return {};
@@ -192,6 +198,10 @@ export class GraphRenderer {
this._regionPanels = [];
this._lastGraph = null;
this._lastLayoutHints = {};
this._lastCanvasCssWidth = 0;
this._lastCanvasCssHeight = 0;
this._lastDevicePixelRatio = window.devicePixelRatio || 1;
// View transform
this.scale = 1;
@@ -234,6 +244,9 @@ export class GraphRenderer {
const prevSelectedId = this.selectedNode?.id || null;
this.nodeMap.clear();
this._lastGraph = graph;
this._lastLayoutHints = layoutHints && typeof layoutHints === 'object'
? { ...layoutHints }
: {};
if (layoutHints && Object.prototype.hasOwnProperty.call(layoutHints, 'userPovAliases')) {
this._userPovAliasSet = buildUserPovAliasNormalizedSet(
layoutHints.userPovAliases,
@@ -438,6 +451,40 @@ export class GraphRenderer {
}
}
_rebuildLayoutForCurrentViewport(W, H) {
const previousRectsByRegion = new Map();
for (const node of this.nodes) {
if (!node?.regionKey || previousRectsByRegion.has(node.regionKey) || !node.regionRect) {
continue;
}
previousRectsByRegion.set(node.regionKey, {
x: node.regionRect.x,
y: node.regionRect.y,
w: node.regionRect.w,
h: node.regionRect.h,
});
}
const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet);
this._regionPanels = this._computeRegionPanels(W, H, parts);
for (const node of this.nodes) {
const nextRect = node.regionRect;
const previousRect = previousRectsByRegion.get(node.regionKey) || nextRect;
const nextPosition = remapPositionBetweenRects(
node.x,
node.y,
previousRect,
nextRect,
);
node.x = nextPosition.x;
node.y = nextPosition.y;
node.vx = 0;
node.vy = 0;
this._clampNodeToRegion(node);
}
}
/**
* 椭圆 Vogel 螺旋初值有机疏密Deterministic无网格感
*/
@@ -634,9 +681,10 @@ export class GraphRenderer {
const W = this.canvas.width / dpr;
const H = this.canvas.height / dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(this.offsetX, this.offsetY);
ctx.scale(this.scale, this.scale);
@@ -703,6 +751,14 @@ export class GraphRenderer {
ctx.restore();
}
_scheduleRender() {
if (this.animId) return;
this.animId = requestAnimationFrame(() => {
this.animId = null;
this._render();
});
}
_drawGrid(W, H) {
const sp = this.config.gridSpacing;
if (!sp || sp <= 0) return;
@@ -813,7 +869,7 @@ export class GraphRenderer {
this.offsetY += dy;
this._touchSession.lastX = t.clientX;
this._touchSession.lastY = t.clientY;
this._render();
this._scheduleRender();
}, { passive: false });
c.addEventListener('touchend', (e) => {
if (!this._touchSession) return;
@@ -891,17 +947,17 @@ export class GraphRenderer {
this.dragNode.x = x;
this.dragNode.y = y;
this._clampNodeToRegion(this.dragNode);
this._render();
this._scheduleRender();
} else if (this.isPanning) {
this.offsetX += e.clientX - this.lastMouse.x;
this.offsetY += e.clientY - this.lastMouse.y;
this._render();
this._scheduleRender();
} else {
const node = this._findNodeAt(x, y);
if (node !== this.hoveredNode) {
this.hoveredNode = node;
this.canvas.style.cursor = node ? 'pointer' : 'grab';
this._render();
this._scheduleRender();
}
}
this.lastMouse = { x: e.clientX, y: e.clientY };
@@ -981,15 +1037,34 @@ export class GraphRenderer {
const dpr = window.devicePixelRatio || 1;
const parent = this.canvas.parentElement;
if (!parent) return;
const w = parent.clientWidth;
const h = parent.clientHeight;
const w = Math.round(parent.clientWidth || 0);
const h = Math.round(parent.clientHeight || 0);
if (!isUsableGraphCanvasSize(w, h, MIN_USABLE_CANVAS_DIMENSION)) {
return;
}
if (
w === this._lastCanvasCssWidth
&& h === this._lastCanvasCssHeight
&& dpr === this._lastDevicePixelRatio
) {
return;
}
this._lastCanvasCssWidth = w;
this._lastCanvasCssHeight = h;
this._lastDevicePixelRatio = dpr;
this.canvas.width = w * dpr;
this.canvas.height = h * dpr;
this.canvas.style.width = w + 'px';
this.canvas.style.height = h + 'px';
if (this._lastGraph) {
this.loadGraph(this._lastGraph);
if (this.nodes.length > 0 && this._regionPanels.length > 0) {
this._rebuildLayoutForCurrentViewport(w, h);
this._render();
} else if (this._lastGraph) {
this.loadGraph(this._lastGraph, this._lastLayoutHints);
} else {
this._render();
}