From fdfc15303b1a25a6ba6565c6634e00ca115a8805 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sat, 11 Apr 2026 23:18:23 +0800 Subject: [PATCH] fix: stabilize mobile graph dragging and resize --- style.css | 5 ++ tests/graph-renderer-utils.mjs | 29 +++++++++++ ui/graph-renderer-utils.js | 46 +++++++++++++++++ ui/graph-renderer.js | 93 ++++++++++++++++++++++++++++++---- 4 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 tests/graph-renderer-utils.mjs create mode 100644 ui/graph-renderer-utils.js diff --git a/style.css b/style.css index 77b999e..52b1d6e 100644 --- a/style.css +++ b/style.css @@ -3977,6 +3977,11 @@ overflow: auto; } +#bme-mobile-graph-pane { + overflow: hidden; + overscroll-behavior: contain; +} + .bme-mobile-graph-pane.active { display: flex; } diff --git a/tests/graph-renderer-utils.mjs b/tests/graph-renderer-utils.mjs new file mode 100644 index 0000000..26396a6 --- /dev/null +++ b/tests/graph-renderer-utils.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; + +import { + isUsableGraphCanvasSize, + remapPositionBetweenRects, +} from "../ui/graph-renderer-utils.js"; + +assert.equal(isUsableGraphCanvasSize(0, 0), false); +assert.equal(isUsableGraphCanvasSize(47, 120), false); +assert.equal(isUsableGraphCanvasSize(120, 47), false); +assert.equal(isUsableGraphCanvasSize(48, 48), true); +assert.equal(isUsableGraphCanvasSize(320, 180), true); + +assert.deepEqual( + remapPositionBetweenRects(60, 35, { x: 10, y: 10, w: 100, h: 50 }, { x: 20, y: 20, w: 200, h: 100 }), + { x: 120, y: 70 }, +); + +assert.deepEqual( + remapPositionBetweenRects(-50, 300, { x: 10, y: 10, w: 100, h: 50 }, { x: 20, y: 20, w: 200, h: 100 }), + { x: 20, y: 120 }, +); + +assert.deepEqual( + remapPositionBetweenRects(42, 84, null, { x: 20, y: 20, w: 200, h: 100 }), + { x: 42, y: 84 }, +); + +console.log("graph-renderer-utils tests passed"); diff --git a/ui/graph-renderer-utils.js b/ui/graph-renderer-utils.js new file mode 100644 index 0000000..7efd0d8 --- /dev/null +++ b/ui/graph-renderer-utils.js @@ -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, + }; +} diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 08360fb..05e0d1e 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -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(); }