Files
ST-Bionic-Memory-Ecology/graph-renderer.js
2026-04-06 15:49:01 +08:00

752 lines
24 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 分区排布,稳定无持续力导向抖动
import { getNodeColors } from './themes.js';
import { getGraphNodeLabel, getNodeDisplayName } from './node-labels.js';
import { normalizeMemoryScope } from './memory-scope.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,
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.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._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 };
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
*/
loadGraph(graph) {
const prevSelectedId = this.selectedNode?.id || null;
this.nodeMap.clear();
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) => {
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._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();
}
/**
* 切换主题
*/
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: (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;
}
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;
}
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;
}
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;
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';
ctx.fillText(node.label || node.name, 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);
}
_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) {
const t = e.touches[0];
this._onMouseDown({ clientX: t.clientX, clientY: t.clientY, button: 0 });
}
}, { passive: true });
c.addEventListener('touchmove', (e) => {
if (e.touches.length === 1) {
const t = e.touches[0];
this._onMouseMove({ clientX: t.clientX, clientY: t.clientY });
}
}, { passive: true });
c.addEventListener('touchend', () => this._onMouseUp({}));
}
_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();
}
}