mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
1003 lines
34 KiB
JavaScript
1003 lines
34 KiB
JavaScript
// 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._suppressMouseUntil = 0;
|
||
|
||
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();
|
||
this._markTouchInteraction();
|
||
this.dragNode = null;
|
||
this.isDragging = false;
|
||
this.isPanning = false;
|
||
this._dragStartMouse = null;
|
||
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();
|
||
this._markTouchInteraction();
|
||
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;
|
||
this._markTouchInteraction();
|
||
const sess = this._touchSession;
|
||
this._touchSession = null;
|
||
this.dragNode = null;
|
||
this.isDragging = false;
|
||
this.isPanning = false;
|
||
this._dragStartMouse = 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._markTouchInteraction();
|
||
this._touchSession = null;
|
||
this.dragNode = null;
|
||
this.isDragging = false;
|
||
this.isPanning = false;
|
||
this._dragStartMouse = null;
|
||
});
|
||
}
|
||
|
||
_markTouchInteraction() {
|
||
this._suppressMouseUntil = Date.now() + 650;
|
||
}
|
||
|
||
_shouldIgnoreMouseEvent() {
|
||
return Date.now() < this._suppressMouseUntil;
|
||
}
|
||
|
||
_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) {
|
||
if (this._shouldIgnoreMouseEvent()) return;
|
||
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) {
|
||
if (this._shouldIgnoreMouseEvent()) return;
|
||
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._shouldIgnoreMouseEvent()) return;
|
||
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) {
|
||
if (this._shouldIgnoreMouseEvent()) return;
|
||
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();
|
||
}
|
||
}
|