Files
ST-Bionic-Memory-Ecology/ui/graph-renderer.js
2026-04-10 23:47:39 +08:00

974 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ST-BME: Canvas 图谱渲染器 — 分区「神经视图」布局
// 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定,无帧循环抖动
import { getNodeColors } from './themes.js';
import { getGraphNodeLabel, getNodeDisplayName } from '../graph/node-labels.js';
import { normalizeMemoryScope } from '../graph/memory-scope.js';
import {
aliasSetMatchesValue,
buildUserPovAliasNormalizedSet,
} from '../runtime/user-alias-utils.js';
/**
* @typedef {Object} GraphNode
* @property {string} id
* @property {string} type
* @property {string} name
* @property {number} importance
* @property {number} x
* @property {number} y
* @property {number} vx
* @property {number} vy
* @property {boolean} pinned
*/
const DEFAULT_LAYOUT_CONFIG = {
minNodeRadius: 6,
maxNodeRadius: 17,
labelFontSize: 10,
gridSpacing: 48,
gridColor: 'rgba(255,255,255,0.028)',
/** 主画布左侧客观区占比(余下为右侧 POV 列) */
objectiveWidthRatio: 0.62,
/** 分区内类神经布局:力导向迭代次数(无持续动画,仅一次性稳定) */
neuralIterations: 120,
neuralRepulsion: 2800,
neuralSpringK: 0.048,
neuralDamping: 0.88,
neuralCenterGravity: 0.014,
/** 节点最小间距(除半径外) */
neuralMinGap: 12,
};
/** 兼容旧版 forceConfig召回卡片等 */
function layoutKeysFromForceConfig(fc) {
if (!fc || typeof fc !== 'object') return {};
const o = {};
if (fc.minNodeRadius != null) o.minNodeRadius = fc.minNodeRadius;
if (fc.maxNodeRadius != null) o.maxNodeRadius = fc.maxNodeRadius;
if (fc.labelFontSize != null) o.labelFontSize = fc.labelFontSize;
if (fc.gridSpacing != null) o.gridSpacing = fc.gridSpacing;
if (fc.gridColor != null) o.gridColor = fc.gridColor;
if (fc.maxIterations != null) {
o.neuralIterations = Math.min(
160,
Math.max(32, Math.round(fc.maxIterations * 0.85)),
);
}
return o;
}
function roundRectPath(ctx, x, y, w, h, r) {
const W = Math.max(0, Number(w) || 0);
const H = Math.max(0, Number(h) || 0);
const rr = Math.max(0, Number(r) || 0);
const radius = Math.min(rr, W / 2, H / 2);
if (W < 1 || H < 1) {
ctx.rect(x, y, Math.max(1, W), Math.max(1, H));
return;
}
if (radius < 1e-6) {
ctx.rect(x, y, W, H);
return;
}
ctx.moveTo(x + radius, y);
ctx.arcTo(x + W, y, x + W, y + H, radius);
ctx.arcTo(x + W, y + H, x, y + H, radius);
ctx.arcTo(x, y + H, x, y, radius);
ctx.arcTo(x, y, x + W, y, radius);
ctx.closePath();
}
const SCOPE_OUTLINE_COLORS = {
objective: '#57c7ff',
character: '#ffb347',
user: '#7dff9b',
};
function hashId(id) {
let h = 0;
const s = String(id || '');
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
}
return h;
}
/** 与 memory-scope 中 normalizeKey 一致,用于分区键(模块内未导出故本地复制) */
function normalizeKeyForPartition(value) {
return String(value ?? '').trim().toLowerCase();
}
function scopeMatchesHostUserAliases(scope, aliasSet) {
if (!(aliasSet instanceof Set) || aliasSet.size === 0) return false;
for (const field of [scope.ownerName, scope.ownerId]) {
if (aliasSetMatchesValue(aliasSet, field)) return true;
}
return false;
}
function characterPovLabelFromNodes(arr) {
if (!arr?.length) return '·';
for (const n of arr) {
const s = normalizeMemoryScope(n.raw?.scope);
if (s.ownerName) return s.ownerName;
}
for (const n of arr) {
const s = normalizeMemoryScope(n.raw?.scope);
if (s.ownerId) return s.ownerId;
}
return '·';
}
function partitionNodesByScope(nodes, userPovAliasSet = null) {
const objective = [];
const userPov = [];
const charMap = new Map();
const aliasSet =
userPovAliasSet instanceof Set ? userPovAliasSet : new Set();
for (const node of nodes) {
const scope = normalizeMemoryScope(node.raw?.scope);
if (scope.layer !== 'pov') {
objective.push(node);
node.regionKey = 'objective';
continue;
}
// 优先:宿主用户显示名与 ownerName/ownerId 一致时一律归用户 POV修正提取阶段误标 character
if (scopeMatchesHostUserAliases(scope, aliasSet)) {
userPov.push(node);
node.regionKey = 'user';
continue;
}
if (scope.ownerType === 'user') {
userPov.push(node);
node.regionKey = 'user';
continue;
}
if (scope.ownerType === 'character') {
// 与 UUID+姓名、仅姓名 等存法兼容:优先用展示名归并,避免同一角色拆成多个 POV 区
const nameKey = normalizeKeyForPartition(scope.ownerName);
const idKey = normalizeKeyForPartition(scope.ownerId);
const key = nameKey || idKey || '·';
if (!charMap.has(key)) charMap.set(key, []);
charMap.get(key).push(node);
node.regionKey = `char:${key}`;
continue;
}
objective.push(node);
node.regionKey = 'objective';
}
return { objective, userPov, charMap };
}
export class GraphRenderer {
/**
* @param {HTMLCanvasElement} canvas
* @param {string|object} [options] - 主题名称字符串(向后兼容)或配置对象
* options.theme {string} - 主题名称
* options.layoutConfig {object} - 布局参数覆盖
* options.forceConfig {object} - 兼容旧力导向配置(仅读取节点半径、网格、局部松弛次数等)
* options.onNodeClick {function} - 节点点击回调
* options.onNodeDoubleClick {function} - 节点双击回调
*/
constructor(canvas, options = 'crimson') {
const isLegacy = typeof options === 'string';
const themeName = isLegacy ? options : (options?.theme || 'crimson');
const layoutOverride = isLegacy ? {} : (options?.layoutConfig || {});
const fromForce = isLegacy ? {} : layoutKeysFromForceConfig(options?.forceConfig);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.nodes = [];
this.edges = [];
this.nodeMap = new Map();
this.colors = getNodeColors(themeName);
this.themeName = themeName;
this.config = { ...DEFAULT_LAYOUT_CONFIG, ...fromForce, ...layoutOverride };
this._userPovAliasSet = buildUserPovAliasNormalizedSet(
isLegacy ? null : options?.userPovAliases,
);
this._regionPanels = [];
this._lastGraph = null;
// View transform
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
// Interaction state
this.dragNode = null;
this.hoveredNode = null;
this.selectedNode = null;
this.isDragging = false;
this.isPanning = false;
this.lastMouse = { x: 0, y: 0 };
/** @type {{ startX: number, startY: number, lastX: number, lastY: number, nodeCandidate: object|null, moved: boolean } | null} */
this._touchSession = null;
this.animId = null;
// Callbacks
this.onNodeSelect = isLegacy ? null : (options?.onNodeSelect || null);
this.onNodeClick = isLegacy ? null : (options?.onNodeClick || null);
this.onNodeDoubleClick = isLegacy ? null : (options?.onNodeDoubleClick || null);
this._bindEvents();
this._resizeObserver = new ResizeObserver(() => this._resize());
this._resizeObserver.observe(canvas.parentElement);
this._resize();
}
/**
* 加载图谱数据
* @param {object} graph - 完整的 graph state
*/
/**
* @param {object} graph
* @param {{ userPovAliases?: string|string[]|object }} [layoutHints]
*/
loadGraph(graph, layoutHints = {}) {
const prevSelectedId = this.selectedNode?.id || null;
this.nodeMap.clear();
this._lastGraph = graph;
if (layoutHints && Object.prototype.hasOwnProperty.call(layoutHints, 'userPovAliases')) {
this._userPovAliasSet = buildUserPovAliasNormalizedSet(
layoutHints.userPovAliases,
);
}
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.width / dpr;
const H = this.canvas.height / dpr;
const activeNodes = graph.nodes.filter(n => !n.archived);
this.nodes = activeNodes.map((n) => {
const node = {
id: n.id,
type: n.type || 'event',
name: getNodeDisplayName(n),
label: getGraphNodeLabel(n),
importance: n.importance || 5,
x: 0,
y: 0,
vx: 0,
vy: 0,
pinned: false,
raw: n,
regionKey: 'objective',
regionRect: null,
};
this.nodeMap.set(n.id, node);
return node;
});
this.edges = graph.edges
.filter(e => !e.invalidAt && !e.expiredAt && this.nodeMap.has(e.fromId) && this.nodeMap.has(e.toId))
.map(e => ({
from: this.nodeMap.get(e.fromId),
to: this.nodeMap.get(e.toId),
strength: e.strength || 0.5,
relation: e.relation || 'related',
}));
const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet);
this._regionPanels = this._computeRegionPanels(W, H, parts);
this._layoutAllPartitions(parts);
this._simulateNeuralWithinRegions(this.config.neuralIterations);
if (prevSelectedId) {
this.selectedNode = this.nodeMap.get(prevSelectedId) || null;
}
this._cancelAnim();
this._render();
}
/**
* 切换主题
*/
setTheme(themeName) {
this.themeName = themeName;
this.colors = getNodeColors(themeName);
this._render();
}
/**
* 高亮指定节点
*/
highlightNode(nodeId) {
this.selectedNode = this.nodeMap.get(nodeId) || null;
this._render();
}
// ==================== 分区布局 ====================
_computeRegionPanels(W, H, { objective, userPov, charMap }) {
const pad = 14;
const gutter = 10;
const topPad = 20;
const hasRight = userPov.length > 0 || charMap.size > 0;
const splitX = hasRight ? W * this.config.objectiveWidthRatio : W;
const panels = [];
const objectivePanel = {
x: pad,
y: pad + 6,
w: Math.max(
0,
(hasRight ? splitX : W) - pad * 2 - (hasRight ? gutter / 2 : 0),
),
h: Math.max(0, H - pad * 2 - 6),
label: '客观层',
tint: 'rgba(26, 35, 50, 0.42)',
key: 'objective',
};
panels.push(objectivePanel);
const innerObjective = {
x: objectivePanel.x + 10,
y: objectivePanel.y + topPad,
w: Math.max(1, objectivePanel.w - 20),
h: Math.max(1, objectivePanel.h - topPad - 10),
};
for (const n of objective) n.regionRect = innerObjective;
if (!hasRight) return panels;
const rightX = splitX + gutter / 2;
const rightW = Math.max(0, W - pad - rightX);
const yBottom = H - pad;
let yTop = pad + 6;
const charEntries = [...charMap.entries()].sort((a, b) =>
String(a[0]).localeCompare(String(b[0]), 'zh'),
);
const charCount = charEntries.length;
const hasUserStrip = userPov.length > 0;
if (charCount === 0 && hasUserStrip) {
const fullH = yBottom - yTop;
panels.push({
x: rightX,
y: yTop,
w: rightW,
h: fullH,
label: '用户 POV',
tint: 'rgba(32, 48, 40, 0.42)',
key: 'user',
});
const innerU = {
x: rightX + 10,
y: yTop + topPad,
w: Math.max(1, rightW - 20),
h: Math.max(1, fullH - topPad - 8),
};
for (const n of userPov) n.regionRect = innerU;
return panels;
}
const userStripH = hasUserStrip
? Math.max(72, Math.min(108, (yBottom - yTop) * 0.2))
: 0;
const charZoneBottom = yBottom - (hasUserStrip ? userStripH + 8 : 0);
const gap = 6;
const charZoneH = charZoneBottom - yTop;
const slice = charCount > 0
? (charZoneH - gap * Math.max(0, charCount - 1)) / charCount
: 0;
let yc = yTop;
for (let i = 0; i < charCount; i++) {
const [key, arr] = charEntries[i];
const ph = Math.max(52, slice);
const displayName = characterPovLabelFromNodes(arr);
panels.push({
x: rightX,
y: yc,
w: rightW,
h: ph,
label: `角色 POV · ${displayName}`,
tint: 'rgba(55, 42, 28, 0.38)',
key: `char:${key}`,
});
const inner = {
x: rightX + 10,
y: yc + topPad,
w: Math.max(1, rightW - 20),
h: Math.max(1, ph - topPad - 8),
};
for (const n of arr) n.regionRect = inner;
yc += ph + gap;
}
if (hasUserStrip) {
const uy = yBottom - userStripH;
panels.push({
x: rightX,
y: uy,
w: rightW,
h: userStripH,
label: '用户 POV',
tint: 'rgba(32, 48, 40, 0.42)',
key: 'user',
});
const innerU = {
x: rightX + 10,
y: uy + topPad,
w: Math.max(1, rightW - 20),
h: Math.max(1, userStripH - topPad - 8),
};
for (const n of userPov) n.regionRect = innerU;
}
return panels;
}
_layoutAllPartitions({ objective, userPov, charMap }) {
this._seedNeuralCloudInRect(objective, objective[0]?.regionRect);
if (userPov.length) {
this._seedNeuralCloudInRect(userPov, userPov[0]?.regionRect);
}
for (const [, arr] of charMap) {
this._seedNeuralCloudInRect(arr, arr[0]?.regionRect);
}
}
/**
* 椭圆 Vogel 螺旋初值有机疏密Deterministic无网格感
*/
_seedNeuralCloudInRect(nodes, rect) {
if (!rect || !nodes.length) return;
const pad = Math.max(10, this.config.neuralMinGap);
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
const rx = Math.max(14, rect.w / 2 - pad);
const ry = Math.max(14, rect.h / 2 - pad);
const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
const n = sorted.length;
const golden = Math.PI * (3 - Math.sqrt(5));
sorted.forEach((node, i) => {
const t = (i + 0.5) / Math.max(n, 1);
const radScale = Math.sqrt(t) * 0.9;
const phase = ((hashId(node.id) & 0x3ff) / 1024) * 0.62;
const theta = i * golden + phase;
node.x = cx + Math.cos(theta) * radScale * rx;
node.y = cy + Math.sin(theta) * radScale * ry;
node.vx = 0;
node.vy = 0;
});
}
_idealSpringLengthsByRegion() {
const countBy = new Map();
for (const n of this.nodes) {
const k = n.regionKey;
countBy.set(k, (countBy.get(k) || 0) + 1);
}
const ideal = new Map();
for (const n of this.nodes) {
if (ideal.has(n.regionKey)) continue;
const rect = n.regionRect;
const c = Math.max(1, countBy.get(n.regionKey) || 1);
const area = (rect?.w || 1) * (rect?.h || 1);
const len = Math.max(
36,
Math.min(92, 0.78 * Math.sqrt(area / c)),
);
ideal.set(n.regionKey, len);
}
return ideal;
}
/**
* 分区内一次性力导向:斥力 + 同区边弹簧 + 弱向心,稳定后停止(无帧循环)
*/
_simulateNeuralWithinRegions(iterations) {
const iters = Math.max(8, Math.min(220, iterations || 80));
const repulsion = this.config.neuralRepulsion ?? 2800;
const springK = this.config.neuralSpringK ?? 0.048;
const damping = this.config.neuralDamping ?? 0.88;
const cg = this.config.neuralCenterGravity ?? 0.014;
const extraGap = this.config.neuralMinGap ?? 12;
const springIdeal = this._idealSpringLengthsByRegion();
const nodes = this.nodes;
for (let it = 0; it < iters; it++) {
for (const n of nodes) {
n._fx = 0;
n._fy = 0;
}
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
if (a.regionKey !== b.regionKey) continue;
let dx = b.x - a.x;
let dy = b.y - a.y;
let distSq = dx * dx + dy * dy;
if (distSq < 0.25) distSq = 0.25;
const dist = Math.sqrt(distSq);
const minSep =
this._nodeRadius(a) + this._nodeRadius(b) + extraGap;
let f = repulsion / distSq;
if (dist < minSep) {
f += (minSep - dist) * 0.22;
}
const fx = (dx / dist) * f;
const fy = (dy / dist) * f;
a._fx -= fx;
a._fy -= fy;
b._fx += fx;
b._fy += fy;
}
}
for (const edge of this.edges) {
const { from, to, strength } = edge;
if (from.regionKey !== to.regionKey) continue;
const ideal =
springIdeal.get(from.regionKey) ?? 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 fx = (dx / dist) * f;
const fy = (dy / dist) * f;
from._fx += fx;
from._fy += fy;
to._fx -= fx;
to._fy -= fy;
}
for (const node of nodes) {
const rect = node.regionRect;
if (!rect) continue;
const ccx = rect.x + rect.w / 2;
const ccy = rect.y + rect.h / 2;
node._fx += (ccx - node.x) * cg;
node._fy += (ccy - node.y) * cg;
}
for (const node of nodes) {
node.vx = (node.vx + node._fx) * damping;
node.vy = (node.vy + node._fy) * damping;
const sp = Math.hypot(node.vx, node.vy);
const cap = 3.8;
if (sp > cap) {
node.vx = (node.vx / sp) * cap;
node.vy = (node.vy / sp) * cap;
}
node.x += node.vx;
node.y += node.vy;
delete node._fx;
delete node._fy;
this._clampNodeToRegion(node);
}
}
}
_clampNodeToRegion(node) {
const rect = node.regionRect;
if (!rect) return;
const r = this._nodeRadius(node) + 6;
node.x = Math.max(rect.x + r, Math.min(rect.x + rect.w - r, node.x));
node.y = Math.max(rect.y + r, Math.min(rect.y + rect.h - r, node.y));
}
// ==================== 渲染 ====================
_drawRegionPanels(ctx) {
for (const p of this._regionPanels) {
const pw = Number(p.w) || 0;
const ph = Number(p.h) || 0;
if (pw < 2 || ph < 2) continue;
ctx.beginPath();
roundRectPath(ctx, p.x, p.y, pw, ph, 12);
ctx.fillStyle = p.tint;
ctx.fill();
ctx.strokeStyle = 'rgba(87, 199, 255, 0.12)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = 'rgba(228, 225, 230, 0.55)';
ctx.font = '600 10px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(p.label, p.x + 12, p.y + 16);
}
}
_drawSynapseEdge(ctx, edge, idx) {
const { from, to, strength } = edge;
const sameZone = from.regionKey === to.regionKey;
const mx = (from.x + to.x) / 2;
const my = (from.y + to.y) / 2;
const dx = to.x - from.x;
const dy = to.y - from.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const sign = idx % 2 === 0 ? 1 : -1;
let bend = sameZone ? 16 + strength * 22 : 32 + strength * 36;
bend *= sign;
const cx = mx + nx * bend;
const cy = my + ny * bend;
const alpha = sameZone ? 0.06 + strength * 0.14 : 0.05 + strength * 0.1;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.quadraticCurveTo(cx, cy, to.x, to.y);
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
ctx.lineWidth = 0.45 + strength * 1.35;
ctx.stroke();
}
_render() {
const ctx = this.ctx;
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.width / dpr;
const H = this.canvas.height / dpr;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
ctx.translate(this.offsetX, this.offsetY);
ctx.scale(this.scale, this.scale);
if (this._regionPanels.length) {
this._drawRegionPanels(ctx);
}
this._drawGrid(W, H);
this.edges.forEach((e, i) => this._drawSynapseEdge(ctx, e, i));
for (const node of this.nodes) {
const r = this._nodeRadius(node);
const color = this.colors[node.type] || this.colors.event;
const isSelected = node === this.selectedNode;
const isHovered = node === this.hoveredNode;
const scope = normalizeMemoryScope(node.raw?.scope);
const outlineColor = scope.layer === 'pov'
? (scope.ownerType === 'user'
? SCOPE_OUTLINE_COLORS.user
: SCOPE_OUTLINE_COLORS.character)
: SCOPE_OUTLINE_COLORS.objective;
if (isSelected || isHovered) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 9, 0, Math.PI * 2);
const glow = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 9);
glow.addColorStop(0, color + '55');
glow.addColorStop(1, color + '00');
ctx.fillStyle = glow;
ctx.fill();
}
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? color : color + 'dd';
ctx.fill();
ctx.strokeStyle = isSelected ? '#fff' : outlineColor;
ctx.lineWidth = isSelected ? 2.25 : 1.35;
ctx.stroke();
ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.94 : 0.66})`;
ctx.font = `${this.config.labelFontSize}px Inter, sans-serif`;
ctx.textAlign = 'center';
const rect = node.regionRect;
let maxLabelW = 118;
if (rect) {
const frac =
node.regionKey === 'user' ? 0.4
: node.regionKey.startsWith('char:') ? 0.46
: 0.52;
maxLabelW = Math.max(36, Math.min(220, rect.w * frac));
}
const labelDraw = this._ellipsisLabel(
ctx,
node.label || node.name,
maxLabelW,
);
ctx.fillText(labelDraw, node.x, node.y + r + 14);
}
ctx.restore();
}
_drawGrid(W, H) {
const sp = this.config.gridSpacing;
if (!sp || sp <= 0) return;
const ctx = this.ctx;
ctx.strokeStyle = this.config.gridColor;
ctx.lineWidth = 0.5;
const startX = Math.floor(-this.offsetX / this.scale / sp) * sp;
const startY = Math.floor(-this.offsetY / this.scale / sp) * sp;
const endX = startX + W / this.scale + sp * 2;
const endY = startY + H / this.scale + sp * 2;
for (let x = startX; x < endX; x += sp) {
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
ctx.stroke();
}
for (let y = startY; y < endY; y += sp) {
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
}
_nodeRadius(node) {
const min = this.config.minNodeRadius;
const max = this.config.maxNodeRadius;
return min + ((node.importance || 5) / 10) * (max - min);
}
_ellipsisLabel(ctx, text, maxW) {
const s = String(text ?? "").trim() || "—";
if (!maxW || maxW < 12) return s;
if (ctx.measureText(s).width <= maxW) return s;
const ell = "…";
let lo = 0;
let hi = s.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
const trial = s.slice(0, mid) + ell;
if (ctx.measureText(trial).width <= maxW) lo = mid;
else hi = mid - 1;
}
return lo <= 0 ? ell : s.slice(0, lo) + ell;
}
_cancelAnim() {
if (this.animId) {
cancelAnimationFrame(this.animId);
this.animId = null;
}
}
/** @deprecated 力导向动画已移除;保留空实现以兼容旧调用 */
stopAnimation() {
this._cancelAnim();
}
// ==================== 交互 ====================
_bindEvents() {
const c = this.canvas;
c.addEventListener('mousedown', (e) => this._onMouseDown(e));
c.addEventListener('mousemove', (e) => this._onMouseMove(e));
c.addEventListener('mouseup', (e) => this._onMouseUp(e));
c.addEventListener('wheel', (e) => this._onWheel(e), { passive: false });
c.addEventListener('dblclick', (e) => this._onDoubleClick(e));
// 触摸:单指始终平移画布,松手时在未移动过的情况下视为「点击」选中节点(避免拖动画布时误拖节点)
c.addEventListener('touchstart', (e) => {
if (e.touches.length !== 1) {
this._touchSession = null;
return;
}
e.preventDefault();
const t = e.touches[0];
const { x, y } = this._canvasToWorld(t.clientX, t.clientY);
this._touchSession = {
startX: t.clientX,
startY: t.clientY,
lastX: t.clientX,
lastY: t.clientY,
nodeCandidate: this._findNodeAt(x, y),
moved: false,
};
}, { passive: false });
c.addEventListener('touchmove', (e) => {
if (!this._touchSession || e.touches.length !== 1) return;
e.preventDefault();
const t = e.touches[0];
const dx = t.clientX - this._touchSession.lastX;
const dy = t.clientY - this._touchSession.lastY;
const fromStartX = t.clientX - this._touchSession.startX;
const fromStartY = t.clientY - this._touchSession.startY;
if (Math.abs(fromStartX) > 5 || Math.abs(fromStartY) > 5) {
this._touchSession.moved = true;
}
this.offsetX += dx;
this.offsetY += dy;
this._touchSession.lastX = t.clientX;
this._touchSession.lastY = t.clientY;
this._render();
}, { passive: false });
c.addEventListener('touchend', (e) => {
if (!this._touchSession) return;
const sess = this._touchSession;
this._touchSession = null;
if (!sess.moved && sess.nodeCandidate) {
this.selectedNode = sess.nodeCandidate;
if (this.onNodeSelect) this.onNodeSelect(sess.nodeCandidate);
if (this.onNodeClick) this.onNodeClick(sess.nodeCandidate);
this._render();
}
});
c.addEventListener('touchcancel', () => {
this._touchSession = null;
});
}
_canvasToWorld(clientX, clientY) {
const rect = this.canvas.getBoundingClientRect();
const x = (clientX - rect.left - this.offsetX) / this.scale;
const y = (clientY - rect.top - this.offsetY) / this.scale;
return { x, y };
}
_findNodeAt(wx, wy) {
for (let i = this.nodes.length - 1; i >= 0; i--) {
const n = this.nodes[i];
const r = this._nodeRadius(n);
const dx = n.x - wx;
const dy = n.y - wy;
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
}
return null;
}
_onMouseDown(e) {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
const node = this._findNodeAt(x, y);
this.lastMouse = { x: e.clientX, y: e.clientY };
this._dragStartMouse = { x: e.clientX, y: e.clientY };
if (node) {
this.dragNode = node;
node.pinned = true;
this.isDragging = true;
} else {
this.isPanning = true;
}
}
_onMouseMove(e) {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
if (this.isDragging && this.dragNode) {
this.dragNode.x = x;
this.dragNode.y = y;
this._clampNodeToRegion(this.dragNode);
this._render();
} else if (this.isPanning) {
this.offsetX += e.clientX - this.lastMouse.x;
this.offsetY += e.clientY - this.lastMouse.y;
this._render();
} else {
const node = this._findNodeAt(x, y);
if (node !== this.hoveredNode) {
this.hoveredNode = node;
this.canvas.style.cursor = node ? 'pointer' : 'grab';
this._render();
}
}
this.lastMouse = { x: e.clientX, y: e.clientY };
}
_onMouseUp() {
if (this.dragNode) {
this._clampNodeToRegion(this.dragNode);
this.dragNode.pinned = false;
if (this.isDragging) {
const start = this._dragStartMouse || { x: 0, y: 0 };
const dx = (this.lastMouse.x - start.x);
const dy = (this.lastMouse.y - start.y);
const movedDistance = Math.sqrt(dx * dx + dy * dy);
if (movedDistance < 6) {
this.selectedNode = this.dragNode;
if (this.onNodeSelect) this.onNodeSelect(this.dragNode);
if (this.onNodeClick) this.onNodeClick(this.dragNode);
}
}
}
this.dragNode = null;
this.isDragging = false;
this.isPanning = false;
this._dragStartMouse = null;
this._render();
}
_onWheel(e) {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.2, Math.min(5, this.scale * factor));
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
this.offsetX = mx - (mx - this.offsetX) * (newScale / this.scale);
this.offsetY = my - (my - this.offsetY) * (newScale / this.scale);
this.scale = newScale;
this._render();
}
_onDoubleClick(e) {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
const node = this._findNodeAt(x, y);
if (node) {
this.selectedNode = node;
if (this.onNodeSelect) this.onNodeSelect(node);
if (this.onNodeDoubleClick) this.onNodeDoubleClick(node);
this._render();
}
}
// ==================== 工具 ====================
zoomIn() {
this.scale = Math.min(5, this.scale * 1.2);
this._render();
}
zoomOut() {
this.scale = Math.max(0.2, this.scale * 0.8);
this._render();
}
resetView() {
this.scale = 1;
this.offsetX = 0;
this.offsetY = 0;
this._render();
}
_resize() {
const dpr = window.devicePixelRatio || 1;
const parent = this.canvas.parentElement;
if (!parent) return;
const w = parent.clientWidth;
const h = parent.clientHeight;
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);
} else {
this._render();
}
}
destroy() {
this._cancelAnim();
this._resizeObserver?.disconnect();
}
}