mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
refactor(graph): partition neural layout, curved edges, no force jitter
Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// ST-BME: Canvas 力导向图谱渲染器
|
||||
// 零依赖,纯 Canvas 2D 实现
|
||||
// ST-BME: Canvas 图谱渲染器 — 分区「神经视图」布局
|
||||
// 零依赖:按客观层 / 角色 POV / 用户 POV 分区排布,稳定无持续力导向抖动
|
||||
|
||||
import { getNodeColors } from './themes.js';
|
||||
import { getGraphNodeLabel, getNodeDisplayName } from './node-labels.js';
|
||||
@@ -18,39 +18,111 @@ import { normalizeMemoryScope } from './memory-scope.js';
|
||||
* @property {boolean} pinned
|
||||
*/
|
||||
|
||||
const DEFAULT_FORCE_CONFIG = {
|
||||
repulsion: 500, // 库仑斥力常数
|
||||
springLength: 120, // 弹簧自然长度
|
||||
springK: 0.08, // 弹簧刚度
|
||||
damping: 0.85, // 阻尼系数
|
||||
centerGravity: 0.01, // 向心引力
|
||||
maxIterations: 300, // 力导向最大迭代帧
|
||||
minNodeRadius: 6, // 最小节点半径
|
||||
maxNodeRadius: 18, // 最大节点半径
|
||||
const DEFAULT_LAYOUT_CONFIG = {
|
||||
minNodeRadius: 6,
|
||||
maxNodeRadius: 17,
|
||||
labelFontSize: 10,
|
||||
gridSpacing: 40,
|
||||
gridColor: 'rgba(255,255,255,0.03)',
|
||||
gridSpacing: 48,
|
||||
gridColor: 'rgba(255,255,255,0.028)',
|
||||
/** 主画布左侧客观区占比(余下为右侧 POV 列) */
|
||||
objectiveWidthRatio: 0.62,
|
||||
localRelaxIterations: 22,
|
||||
};
|
||||
|
||||
/** 兼容旧版 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.localRelaxIterations = Math.min(60, Math.max(6, Math.round(fc.maxIterations * 0.25)));
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function roundRectPath(ctx, x, y, w, h, r) {
|
||||
const radius = Math.min(r, w / 2, h / 2);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 由 id 导出的微小偏移,避免网格完全对齐,且无帧间随机抖动 */
|
||||
function deterministicJitter(id, mag) {
|
||||
const h = hashId(id);
|
||||
const nx = ((h & 0xff) / 255 - 0.5) * 2;
|
||||
const ny = (((h >> 8) & 0xff) / 255 - 0.5) * 2;
|
||||
return { x: nx * mag * 0.45, y: ny * mag * 0.45 };
|
||||
}
|
||||
|
||||
function partitionNodesByScope(nodes) {
|
||||
const objective = [];
|
||||
const userPov = [];
|
||||
const charMap = new Map();
|
||||
|
||||
for (const node of nodes) {
|
||||
const scope = normalizeMemoryScope(node.raw?.scope);
|
||||
if (scope.layer !== 'pov') {
|
||||
objective.push(node);
|
||||
node.regionKey = 'objective';
|
||||
continue;
|
||||
}
|
||||
if (scope.ownerType === 'user') {
|
||||
userPov.push(node);
|
||||
node.regionKey = 'user';
|
||||
continue;
|
||||
}
|
||||
if (scope.ownerType === 'character') {
|
||||
const key = scope.ownerId || scope.ownerName || '·';
|
||||
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.forceConfig {object} - 力导向参数覆盖
|
||||
* 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 forceOverride = isLegacy ? {} : (options?.forceConfig || {});
|
||||
const layoutOverride = isLegacy ? {} : (options?.layoutConfig || {});
|
||||
const fromForce = isLegacy ? {} : layoutKeysFromForceConfig(options?.forceConfig);
|
||||
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
@@ -59,7 +131,10 @@ export class GraphRenderer {
|
||||
this.nodeMap = new Map();
|
||||
this.colors = getNodeColors(themeName);
|
||||
this.themeName = themeName;
|
||||
this.config = { ...DEFAULT_FORCE_CONFIG, ...forceOverride };
|
||||
this.config = { ...DEFAULT_LAYOUT_CONFIG, ...fromForce, ...layoutOverride };
|
||||
|
||||
this._regionPanels = [];
|
||||
this._lastGraph = null;
|
||||
|
||||
// View transform
|
||||
this.scale = 1;
|
||||
@@ -74,9 +149,6 @@ export class GraphRenderer {
|
||||
this.isPanning = false;
|
||||
this.lastMouse = { x: 0, y: 0 };
|
||||
|
||||
// Animation
|
||||
this.iteration = 0;
|
||||
this.animating = false;
|
||||
this.animId = null;
|
||||
|
||||
// Callbacks
|
||||
@@ -95,34 +167,35 @@ export class GraphRenderer {
|
||||
* @param {object} graph - 完整的 graph state
|
||||
*/
|
||||
loadGraph(graph) {
|
||||
const prevSelectedId = this.selectedNode?.id || null;
|
||||
this.nodeMap.clear();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const viewportWidth = this.canvas.width / dpr;
|
||||
const viewportHeight = this.canvas.height / dpr;
|
||||
this._lastGraph = graph;
|
||||
|
||||
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, i) => {
|
||||
const angle = (2 * Math.PI * i) / activeNodes.length;
|
||||
const r = Math.min(viewportWidth, viewportHeight) * 0.3;
|
||||
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: viewportWidth / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 40,
|
||||
y: viewportHeight / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 40,
|
||||
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 => ({
|
||||
@@ -132,8 +205,17 @@ export class GraphRenderer {
|
||||
relation: e.relation || 'related',
|
||||
}));
|
||||
|
||||
this.iteration = 0;
|
||||
this.startAnimation();
|
||||
const parts = partitionNodesByScope(this.nodes);
|
||||
this._regionPanels = this._computeRegionPanels(W, H, parts);
|
||||
this._layoutAllPartitions(parts);
|
||||
this._relaxWithinRegions(this.config.localRelaxIterations);
|
||||
|
||||
if (prevSelectedId) {
|
||||
this.selectedNode = this.nodeMap.get(prevSelectedId) || null;
|
||||
}
|
||||
|
||||
this._cancelAnim();
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,63 +235,242 @@ export class GraphRenderer {
|
||||
this._render();
|
||||
}
|
||||
|
||||
// ==================== 力导向计算 ====================
|
||||
// ==================== 分区布局 ====================
|
||||
|
||||
_applyForces() {
|
||||
const { nodes, edges, config } = this;
|
||||
const W = this.canvas.width / window.devicePixelRatio;
|
||||
const H = this.canvas.height / window.devicePixelRatio;
|
||||
const cx = W / 2, cy = H / 2;
|
||||
_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;
|
||||
|
||||
// 斥力(节点间排斥)
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i], b = nodes[j];
|
||||
let dx = b.x - a.x, dy = b.y - a.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
let force = config.repulsion / (dist * dist);
|
||||
let fx = (dx / dist) * force;
|
||||
let fy = (dy / dist) * force;
|
||||
if (!a.pinned) { a.vx -= fx; a.vy -= fy; }
|
||||
if (!b.pinned) { b.vx += fx; b.vy += fy; }
|
||||
}
|
||||
const panels = [];
|
||||
|
||||
const objectivePanel = {
|
||||
x: pad,
|
||||
y: pad + 6,
|
||||
w: (hasRight ? splitX : W) - pad * 2 - (hasRight ? gutter / 2 : 0),
|
||||
h: 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: objectivePanel.w - 20,
|
||||
h: objectivePanel.h - topPad - 10,
|
||||
};
|
||||
for (const n of objective) n.regionRect = innerObjective;
|
||||
|
||||
if (!hasRight) return panels;
|
||||
|
||||
const rightX = splitX + gutter / 2;
|
||||
const rightW = 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: rightW - 20,
|
||||
h: fullH - topPad - 8,
|
||||
};
|
||||
for (const n of userPov) n.regionRect = innerU;
|
||||
return panels;
|
||||
}
|
||||
|
||||
// 弹簧力(边的引力)
|
||||
for (const edge of edges) {
|
||||
const { from, to, strength } = edge;
|
||||
let dx = to.x - from.x, dy = to.y - from.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
let displacement = dist - config.springLength;
|
||||
let force = config.springK * displacement * strength;
|
||||
let fx = (dx / dist) * force;
|
||||
let fy = (dy / dist) * force;
|
||||
if (!from.pinned) { from.vx += fx; from.vy += fy; }
|
||||
if (!to.pinned) { to.vx -= fx; to.vy -= fy; }
|
||||
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 scope0 = normalizeMemoryScope(arr[0]?.raw?.scope);
|
||||
const displayName = scope0.ownerName || key;
|
||||
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: rightW - 20,
|
||||
h: ph - topPad - 8,
|
||||
};
|
||||
for (const n of arr) n.regionRect = inner;
|
||||
yc += ph + gap;
|
||||
}
|
||||
|
||||
// 向心力
|
||||
for (const node of nodes) {
|
||||
if (node.pinned) continue;
|
||||
node.vx += (cx - node.x) * config.centerGravity;
|
||||
node.vy += (cy - node.y) * config.centerGravity;
|
||||
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: rightW - 20,
|
||||
h: userStripH - topPad - 8,
|
||||
};
|
||||
for (const n of userPov) n.regionRect = innerU;
|
||||
}
|
||||
|
||||
// 更新位置
|
||||
for (const node of nodes) {
|
||||
if (node.pinned) continue;
|
||||
node.vx *= config.damping;
|
||||
node.vy *= config.damping;
|
||||
node.x += node.vx;
|
||||
node.y += node.vy;
|
||||
// 边界约束
|
||||
node.x = Math.max(20, Math.min(W - 20, node.x));
|
||||
node.y = Math.max(20, Math.min(H - 20, node.y));
|
||||
return panels;
|
||||
}
|
||||
|
||||
_layoutAllPartitions({ objective, userPov, charMap }) {
|
||||
this._layoutGridInRect(objective, objective[0]?.regionRect);
|
||||
for (const list of userPov.length ? [userPov] : []) {
|
||||
this._layoutGridInRect(list, list[0]?.regionRect);
|
||||
}
|
||||
for (const [, arr] of charMap) {
|
||||
this._layoutGridInRect(arr, arr[0]?.regionRect);
|
||||
}
|
||||
}
|
||||
|
||||
_layoutGridInRect(nodes, rect) {
|
||||
if (!rect || nodes.length === 0) return;
|
||||
const n = nodes.length;
|
||||
const pad = 8;
|
||||
const innerW = Math.max(24, rect.w - 2 * pad);
|
||||
const innerH = Math.max(24, rect.h - 2 * pad);
|
||||
const aspect = innerW / innerH;
|
||||
const cols = Math.max(1, Math.round(Math.sqrt(n * aspect)));
|
||||
const rows = Math.ceil(n / cols);
|
||||
const cellW = innerW / cols;
|
||||
const cellH = innerH / rows;
|
||||
const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
|
||||
const jitterMag = Math.min(cellW, cellH) * 0.09;
|
||||
|
||||
sorted.forEach((node, i) => {
|
||||
const r = Math.floor(i / cols);
|
||||
const c = i % cols;
|
||||
const cx = rect.x + pad + cellW * (c + 0.5);
|
||||
const cy = rect.y + pad + cellH * (r + 0.5);
|
||||
const j = deterministicJitter(node.id, jitterMag);
|
||||
node.x = cx + j.x;
|
||||
node.y = cy + j.y;
|
||||
});
|
||||
}
|
||||
|
||||
_relaxWithinRegions(iterations) {
|
||||
const minDist = 26;
|
||||
for (let it = 0; it < iterations; it++) {
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
for (let j = i + 1; j < this.nodes.length; j++) {
|
||||
const a = this.nodes[i];
|
||||
const b = this.nodes[j];
|
||||
if (a.regionKey !== b.regionKey) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
if (dist >= minDist) continue;
|
||||
const push = (minDist - dist) * 0.42;
|
||||
const fx = (dx / dist) * push;
|
||||
const fy = (dy / dist) * push;
|
||||
a.x -= fx;
|
||||
a.y -= fy;
|
||||
b.x += fx;
|
||||
b.y += fy;
|
||||
}
|
||||
}
|
||||
for (const node of this.nodes) {
|
||||
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) {
|
||||
ctx.beginPath();
|
||||
roundRectPath(ctx, p.x, p.y, p.w, p.h, 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;
|
||||
@@ -220,24 +481,17 @@ export class GraphRenderer {
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// 应用视图变换
|
||||
ctx.translate(this.offsetX, this.offsetY);
|
||||
ctx.scale(this.scale, this.scale);
|
||||
|
||||
// 背景网格
|
||||
this._drawGrid(W, H);
|
||||
|
||||
// 边
|
||||
for (const edge of this.edges) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(edge.from.x, edge.from.y);
|
||||
ctx.lineTo(edge.to.x, edge.to.y);
|
||||
ctx.strokeStyle = `rgba(255,255,255,${0.05 + edge.strength * 0.15})`;
|
||||
ctx.lineWidth = 0.5 + edge.strength * 1.5;
|
||||
ctx.stroke();
|
||||
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;
|
||||
@@ -250,30 +504,26 @@ export class GraphRenderer {
|
||||
: SCOPE_OUTLINE_COLORS.character)
|
||||
: SCOPE_OUTLINE_COLORS.objective;
|
||||
|
||||
// 发光效果
|
||||
if (isSelected || isHovered) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, r + 8, 0, Math.PI * 2);
|
||||
const glow = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 8);
|
||||
glow.addColorStop(0, color + '60');
|
||||
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 + 'cc';
|
||||
ctx.fillStyle = isSelected ? color : color + 'dd';
|
||||
ctx.fill();
|
||||
|
||||
// 边框
|
||||
ctx.strokeStyle = isSelected ? '#fff' : outlineColor;
|
||||
ctx.lineWidth = isSelected ? 2.5 : 1.5;
|
||||
ctx.lineWidth = isSelected ? 2.25 : 1.35;
|
||||
ctx.stroke();
|
||||
|
||||
// 标签
|
||||
ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.95 : 0.65})`;
|
||||
ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.94 : 0.66})`;
|
||||
ctx.font = `${this.config.labelFontSize}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(node.label || node.name, node.x, node.y + r + 14);
|
||||
@@ -314,27 +564,16 @@ export class GraphRenderer {
|
||||
return min + ((node.importance || 5) / 10) * (max - min);
|
||||
}
|
||||
|
||||
// ==================== 动画 ====================
|
||||
|
||||
startAnimation() {
|
||||
if (this.animating) return;
|
||||
this.animating = true;
|
||||
this._tick();
|
||||
}
|
||||
|
||||
stopAnimation() {
|
||||
this.animating = false;
|
||||
if (this.animId) cancelAnimationFrame(this.animId);
|
||||
}
|
||||
|
||||
_tick() {
|
||||
if (!this.animating) return;
|
||||
if (this.iteration < this.config.maxIterations) {
|
||||
this._applyForces();
|
||||
this.iteration++;
|
||||
_cancelAnim() {
|
||||
if (this.animId) {
|
||||
cancelAnimationFrame(this.animId);
|
||||
this.animId = null;
|
||||
}
|
||||
this._render();
|
||||
this.animId = requestAnimationFrame(() => this._tick());
|
||||
}
|
||||
|
||||
/** @deprecated 力导向动画已移除;保留空实现以兼容旧调用 */
|
||||
stopAnimation() {
|
||||
this._cancelAnim();
|
||||
}
|
||||
|
||||
// ==================== 交互 ====================
|
||||
@@ -348,7 +587,6 @@ export class GraphRenderer {
|
||||
c.addEventListener('wheel', (e) => this._onWheel(e), { passive: false });
|
||||
c.addEventListener('dblclick', (e) => this._onDoubleClick(e));
|
||||
|
||||
// Touch support
|
||||
c.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
const t = e.touches[0];
|
||||
@@ -375,7 +613,8 @@ export class GraphRenderer {
|
||||
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, dy = n.y - wy;
|
||||
const dx = n.x - wx;
|
||||
const dy = n.y - wy;
|
||||
if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n;
|
||||
}
|
||||
return null;
|
||||
@@ -402,14 +641,13 @@ export class GraphRenderer {
|
||||
if (this.isDragging && this.dragNode) {
|
||||
this.dragNode.x = x;
|
||||
this.dragNode.y = y;
|
||||
this.iteration = 0; // restart physics
|
||||
this.startAnimation();
|
||||
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 {
|
||||
// hover detection
|
||||
const node = this._findNodeAt(x, y);
|
||||
if (node !== this.hoveredNode) {
|
||||
this.hoveredNode = node;
|
||||
@@ -422,13 +660,13 @@ export class GraphRenderer {
|
||||
|
||||
_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);
|
||||
@@ -440,6 +678,7 @@ export class GraphRenderer {
|
||||
this.isDragging = false;
|
||||
this.isPanning = false;
|
||||
this._dragStartMouse = null;
|
||||
this._render();
|
||||
}
|
||||
|
||||
_onWheel(e) {
|
||||
@@ -447,7 +686,6 @@ export class GraphRenderer {
|
||||
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;
|
||||
@@ -471,8 +709,16 @@ export class GraphRenderer {
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
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(); }
|
||||
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;
|
||||
@@ -490,11 +736,16 @@ export class GraphRenderer {
|
||||
this.canvas.height = h * dpr;
|
||||
this.canvas.style.width = w + 'px';
|
||||
this.canvas.style.height = h + 'px';
|
||||
this._render();
|
||||
|
||||
if (this._lastGraph) {
|
||||
this.loadGraph(this._lastGraph);
|
||||
} else {
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stopAnimation();
|
||||
this._cancelAnim();
|
||||
this._resizeObserver?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user