mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-14 02:40:45 +08:00
feat(graph-ui): polish memory graph visuals
This commit is contained in:
29
style.css
29
style.css
@@ -553,6 +553,35 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 52% 34%, var(--bme-primary-dim, rgba(44, 92, 162, 0.16)), transparent 42%),
|
||||||
|
radial-gradient(circle at 86% 16%, rgba(150, 91, 255, 0.08), transparent 34%),
|
||||||
|
radial-gradient(circle at 12% 82%, rgba(0, 229, 255, 0.05), transparent 36%),
|
||||||
|
var(--bme-surface-lowest, #0e0e11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bme-graph-workspace::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle, rgba(255, 255, 255, 0.52) 0 1px, transparent 1.4px),
|
||||||
|
radial-gradient(circle, rgba(124, 248, 255, 0.28) 0 1px, transparent 1.6px);
|
||||||
|
background-position: 18px 24px, 68px 86px;
|
||||||
|
background-size: 134px 134px, 211px 211px;
|
||||||
|
opacity: 0.16;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bme-graph-workspace > .bme-graph-toolbar,
|
||||||
|
.bme-graph-workspace > #bme-graph-canvas,
|
||||||
|
.bme-graph-workspace > .bme-graph-legend,
|
||||||
|
.bme-graph-workspace > .bme-graph-statusbar,
|
||||||
|
.bme-graph-workspace > .bme-cognition-workspace,
|
||||||
|
.bme-graph-workspace > #bme-summary-workspace {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bme-graph-overlay {
|
.bme-graph-overlay {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ globalThis.ResizeObserver = class ResizeObserver {
|
|||||||
disconnect() {}
|
disconnect() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canvasMockStats = {
|
||||||
|
radialGradientCalls: 0,
|
||||||
|
linearGradientCalls: 0,
|
||||||
|
};
|
||||||
|
|
||||||
function createNoopContext() {
|
function createNoopContext() {
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
return {
|
return {
|
||||||
@@ -35,7 +40,14 @@ function createNoopContext() {
|
|||||||
fillRect: noop,
|
fillRect: noop,
|
||||||
strokeRect: noop,
|
strokeRect: noop,
|
||||||
measureText: (text = "") => ({ width: String(text).length * 6 }),
|
measureText: (text = "") => ({ width: String(text).length * 6 }),
|
||||||
createRadialGradient: () => ({ addColorStop: noop }),
|
createRadialGradient: () => {
|
||||||
|
canvasMockStats.radialGradientCalls += 1;
|
||||||
|
return { addColorStop: noop };
|
||||||
|
},
|
||||||
|
createLinearGradient: () => {
|
||||||
|
canvasMockStats.linearGradientCalls += 1;
|
||||||
|
return { addColorStop: noop };
|
||||||
|
},
|
||||||
set fillStyle(_value) {},
|
set fillStyle(_value) {},
|
||||||
set strokeStyle(_value) {},
|
set strokeStyle(_value) {},
|
||||||
set lineWidth(_value) {},
|
set lineWidth(_value) {},
|
||||||
@@ -162,4 +174,41 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js");
|
|||||||
renderer.destroy();
|
renderer.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const graph = createGraphFixture();
|
||||||
|
const before = JSON.stringify(graph);
|
||||||
|
const renderer = new GraphRenderer(createCanvas(), {
|
||||||
|
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
|
||||||
|
layoutConfig: {
|
||||||
|
minNodeRadius: 4,
|
||||||
|
maxNodeRadius: 14,
|
||||||
|
neuralIterations: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const radius = renderer._nodeRadius({ type: "character", importance: 10 });
|
||||||
|
assert.equal(radius, 14);
|
||||||
|
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
|
||||||
|
renderer.highlightNode("char-1");
|
||||||
|
assertInputUnchanged(graph, before);
|
||||||
|
assert.ok(canvasMockStats.radialGradientCalls > 0);
|
||||||
|
assert.ok(canvasMockStats.linearGradientCalls > 0);
|
||||||
|
renderer.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const graph = createGraphFixture();
|
||||||
|
const before = JSON.stringify(graph);
|
||||||
|
const renderer = new GraphRenderer(createCanvas(), {
|
||||||
|
theme: "paperDawn",
|
||||||
|
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
|
||||||
|
layoutConfig: { neuralIterations: 8 },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => renderer.loadGraph(graph, { userPovAliases: ["Host"] }));
|
||||||
|
renderer.highlightNode("objective-1");
|
||||||
|
assertInputUnchanged(graph, before);
|
||||||
|
renderer.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
console.log("graph-renderer guardrail tests passed");
|
console.log("graph-renderer guardrail tests passed");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// ST-BME: Canvas 图谱渲染器 — 分区「神经视图」布局
|
// ST-BME: Canvas 图谱渲染器 — 分区「神经视图」布局
|
||||||
// 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定,无帧循环抖动
|
// 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定,无帧循环抖动
|
||||||
|
|
||||||
import { getNodeColors } from './themes.js';
|
import { getNodeColors, LIGHT_PANEL_THEMES, THEMES } from './themes.js';
|
||||||
import {
|
import {
|
||||||
isUsableGraphCanvasSize,
|
isUsableGraphCanvasSize,
|
||||||
remapPositionBetweenRects,
|
remapPositionBetweenRects,
|
||||||
@@ -139,6 +139,56 @@ const SCOPE_OUTLINE_COLORS = {
|
|||||||
user: '#7dff9b',
|
user: '#7dff9b',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EDGE_RELATION_COLORS = {
|
||||||
|
updates: '#7cf8ff',
|
||||||
|
temporal_update: '#7cf8ff',
|
||||||
|
evolves: '#b79cff',
|
||||||
|
same: '#8fffd2',
|
||||||
|
related: '#7aa7ff',
|
||||||
|
};
|
||||||
|
|
||||||
|
function colorWithAlpha(color, alpha = 1) {
|
||||||
|
const a = Math.max(0, Math.min(1, Number(alpha) || 0));
|
||||||
|
const hex = String(color || '').trim();
|
||||||
|
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);
|
||||||
|
if (match) {
|
||||||
|
let body = match[1];
|
||||||
|
if (body.length === 3) {
|
||||||
|
body = body.split('').map((c) => c + c).join('');
|
||||||
|
}
|
||||||
|
const value = Number.parseInt(body, 16);
|
||||||
|
const r = (value >> 16) & 255;
|
||||||
|
const g = (value >> 8) & 255;
|
||||||
|
const b = value & 255;
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||||
|
}
|
||||||
|
if (hex.startsWith('rgb(')) {
|
||||||
|
return hex.replace(/^rgb\((.*)\)$/i, `rgba($1, ${a})`);
|
||||||
|
}
|
||||||
|
if (hex.startsWith('rgba(')) return hex;
|
||||||
|
return `rgba(255, 255, 255, ${a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeColorForRelation(relation) {
|
||||||
|
const key = String(relation || 'related').trim().toLowerCase();
|
||||||
|
return EDGE_RELATION_COLORS[key] || EDGE_RELATION_COLORS.related;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCanvasGradient(ctx, methodName, args = [], stops = [], fallback = 'rgba(0, 0, 0, 0)') {
|
||||||
|
if (ctx && typeof ctx[methodName] === 'function') {
|
||||||
|
try {
|
||||||
|
const gradient = ctx[methodName](...args);
|
||||||
|
if (gradient && typeof gradient.addColorStop === 'function') {
|
||||||
|
for (const [offset, color] of stops) {
|
||||||
|
gradient.addColorStop(offset, color);
|
||||||
|
}
|
||||||
|
return gradient;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function hashId(id) {
|
function hashId(id) {
|
||||||
let h = 0;
|
let h = 0;
|
||||||
const s = String(id || '');
|
const s = String(id || '');
|
||||||
@@ -1253,23 +1303,36 @@ export class GraphRenderer {
|
|||||||
const ph = Number(p.h) || 0;
|
const ph = Number(p.h) || 0;
|
||||||
if (pw < 2 || ph < 2) continue;
|
if (pw < 2 || ph < 2) continue;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
roundRectPath(ctx, p.x, p.y, pw, ph, 12);
|
roundRectPath(ctx, p.x, p.y, pw, ph, 16);
|
||||||
ctx.fillStyle = p.tint;
|
ctx.fillStyle = createCanvasGradient(
|
||||||
|
ctx,
|
||||||
|
'createLinearGradient',
|
||||||
|
[p.x, p.y, p.x + pw, p.y + ph],
|
||||||
|
[
|
||||||
|
[0, p.tint],
|
||||||
|
[0.62, 'rgba(6, 10, 22, 0.22)'],
|
||||||
|
[1, 'rgba(87, 199, 255, 0.035)'],
|
||||||
|
],
|
||||||
|
p.tint,
|
||||||
|
);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = 'rgba(87, 199, 255, 0.12)';
|
ctx.strokeStyle = 'rgba(141, 213, 255, 0.14)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(228, 225, 230, 0.55)';
|
ctx.fillStyle = 'rgba(222, 239, 255, 0.64)';
|
||||||
ctx.font = '600 10px Inter, sans-serif';
|
ctx.font = '700 10px Inter, sans-serif';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(p.label, p.x + 12, p.y + 16);
|
ctx.fillText(p.label, p.x + 12, p.y + 16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_drawSynapseEdge(ctx, edge, idx) {
|
_drawSynapseEdge(ctx, edge, idx, focus = null) {
|
||||||
const { from, to, strength } = edge;
|
const { from, to, strength } = edge;
|
||||||
const sameZone = from.regionKey === to.regionKey;
|
const sameZone = from.regionKey === to.regionKey;
|
||||||
|
const selectedNode = focus?.selectedNode || null;
|
||||||
|
const isConnectedToSelection = !!selectedNode && (from === selectedNode || to === selectedNode);
|
||||||
|
const isDimmed = !!selectedNode && !isConnectedToSelection;
|
||||||
const mx = (from.x + to.x) / 2;
|
const mx = (from.x + to.x) / 2;
|
||||||
const my = (from.y + to.y) / 2;
|
const my = (from.y + to.y) / 2;
|
||||||
const dx = to.x - from.x;
|
const dx = to.x - from.x;
|
||||||
@@ -1283,12 +1346,26 @@ export class GraphRenderer {
|
|||||||
const cx = mx + nx * bend;
|
const cx = mx + nx * bend;
|
||||||
const cy = my + ny * bend;
|
const cy = my + ny * bend;
|
||||||
|
|
||||||
const alpha = sameZone ? 0.06 + strength * 0.14 : 0.05 + strength * 0.1;
|
const relationColor = edgeColorForRelation(edge.relation);
|
||||||
|
const baseAlpha = sameZone ? 0.035 + strength * 0.09 : 0.026 + strength * 0.07;
|
||||||
|
const alpha = isDimmed ? 0.018 : (isConnectedToSelection ? 0.38 + strength * 0.28 : baseAlpha);
|
||||||
|
|
||||||
|
if (isConnectedToSelection) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(from.x, from.y);
|
||||||
|
ctx.quadraticCurveTo(cx, cy, to.x, to.y);
|
||||||
|
ctx.strokeStyle = colorWithAlpha(relationColor, 0.12 + strength * 0.12);
|
||||||
|
ctx.lineWidth = 2.8 + strength * 2.2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(from.x, from.y);
|
ctx.moveTo(from.x, from.y);
|
||||||
ctx.quadraticCurveTo(cx, cy, to.x, to.y);
|
ctx.quadraticCurveTo(cx, cy, to.x, to.y);
|
||||||
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
|
ctx.strokeStyle = colorWithAlpha(isConnectedToSelection ? relationColor : '#c9dcff', alpha);
|
||||||
ctx.lineWidth = 0.45 + strength * 1.35;
|
ctx.lineWidth = isConnectedToSelection
|
||||||
|
? 1.35 + strength * 2.15
|
||||||
|
: 0.35 + strength * 0.82;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,6 +1384,8 @@ export class GraphRenderer {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
this._drawDeepSpaceBackground(ctx, W, H);
|
||||||
|
|
||||||
ctx.translate(this.offsetX, this.offsetY);
|
ctx.translate(this.offsetX, this.offsetY);
|
||||||
ctx.scale(this.scale, this.scale);
|
ctx.scale(this.scale, this.scale);
|
||||||
|
|
||||||
@@ -1316,13 +1395,17 @@ export class GraphRenderer {
|
|||||||
|
|
||||||
this._drawGrid(W, H);
|
this._drawGrid(W, H);
|
||||||
|
|
||||||
this.edges.forEach((e, i) => this._drawSynapseEdge(ctx, e, i));
|
const focus = this._buildFocusState();
|
||||||
|
|
||||||
|
this.edges.forEach((e, i) => this._drawSynapseEdge(ctx, e, i, focus));
|
||||||
|
|
||||||
for (const node of this.nodes) {
|
for (const node of this.nodes) {
|
||||||
const r = this._nodeRadius(node);
|
const baseRadius = this._nodeVisualRadius(node);
|
||||||
const color = this.colors[node.type] || this.colors.event;
|
const color = this.colors[node.type] || this.colors.event;
|
||||||
const isSelected = node === this.selectedNode;
|
const isSelected = node === this.selectedNode;
|
||||||
const isHovered = node === this.hoveredNode;
|
const isHovered = node === this.hoveredNode;
|
||||||
|
const isDimmed = focus.selectedNode && !focus.connectedNodes.has(node);
|
||||||
|
const r = (isSelected ? baseRadius * 1.12 : baseRadius) * (isDimmed ? 0.6 : 1);
|
||||||
const scope = normalizeMemoryScope(node.raw?.scope);
|
const scope = normalizeMemoryScope(node.raw?.scope);
|
||||||
const outlineColor = scope.layer === 'pov'
|
const outlineColor = scope.layer === 'pov'
|
||||||
? (scope.ownerType === 'user'
|
? (scope.ownerType === 'user'
|
||||||
@@ -1330,26 +1413,67 @@ export class GraphRenderer {
|
|||||||
: SCOPE_OUTLINE_COLORS.character)
|
: SCOPE_OUTLINE_COLORS.character)
|
||||||
: SCOPE_OUTLINE_COLORS.objective;
|
: SCOPE_OUTLINE_COLORS.objective;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (isDimmed) ctx.globalAlpha = 0.2;
|
||||||
|
|
||||||
if (isSelected || isHovered) {
|
if (isSelected || isHovered) {
|
||||||
|
const glowRadius = r + (isSelected ? 20 : 13);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(node.x, node.y, r + 9, 0, Math.PI * 2);
|
ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
|
||||||
const glow = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r + 9);
|
ctx.fillStyle = createCanvasGradient(
|
||||||
glow.addColorStop(0, color + '55');
|
ctx,
|
||||||
glow.addColorStop(1, color + '00');
|
'createRadialGradient',
|
||||||
ctx.fillStyle = glow;
|
[node.x, node.y, Math.max(1, r * 0.45), node.x, node.y, glowRadius],
|
||||||
|
[
|
||||||
|
[0, colorWithAlpha(color, isSelected ? 0.52 : 0.34)],
|
||||||
|
[0.55, colorWithAlpha(color, isSelected ? 0.22 : 0.14)],
|
||||||
|
[1, colorWithAlpha(color, 0)],
|
||||||
|
],
|
||||||
|
colorWithAlpha(color, isSelected ? 0.24 : 0.14),
|
||||||
|
);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(node.x, node.y, r + (isSelected ? 6 : 4), 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = colorWithAlpha(isSelected ? '#ffffff' : color, isSelected ? 0.72 : 0.5);
|
||||||
|
ctx.lineWidth = isSelected ? 2.4 : 1.6;
|
||||||
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = isSelected ? color : color + 'dd';
|
ctx.fillStyle = createCanvasGradient(
|
||||||
|
ctx,
|
||||||
|
'createRadialGradient',
|
||||||
|
[
|
||||||
|
node.x - r * 0.32,
|
||||||
|
node.y - r * 0.34,
|
||||||
|
Math.max(1, r * 0.16),
|
||||||
|
node.x,
|
||||||
|
node.y,
|
||||||
|
r,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, colorWithAlpha('#ffffff', isSelected ? 0.86 : 0.68)],
|
||||||
|
[0.18, colorWithAlpha(color, isSelected ? 1 : 0.94)],
|
||||||
|
[1, colorWithAlpha(color, isSelected ? 0.68 : 0.52)],
|
||||||
|
],
|
||||||
|
colorWithAlpha(color, isSelected ? 1 : 0.86),
|
||||||
|
);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.strokeStyle = isSelected ? '#fff' : outlineColor;
|
ctx.strokeStyle = isSelected ? '#fff' : colorWithAlpha(outlineColor, isHovered ? 0.9 : 0.64);
|
||||||
ctx.lineWidth = isSelected ? 2.25 : 1.35;
|
ctx.lineWidth = isSelected ? 2.8 : (isHovered ? 1.8 : 1.1);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.94 : 0.66})`;
|
ctx.shadowColor = colorWithAlpha(color, isSelected ? 0.5 : 0.2);
|
||||||
|
ctx.shadowBlur = isSelected ? 16 : 7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(node.x - r * 0.28, node.y - r * 0.34, Math.max(1.4, r * 0.16), 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = colorWithAlpha('#ffffff', isSelected ? 0.7 : 0.5);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
ctx.font = `${this.config.labelFontSize}px Inter, sans-serif`;
|
ctx.font = `${this.config.labelFontSize}px Inter, sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
const rect = node.regionRect;
|
const rect = node.regionRect;
|
||||||
@@ -1366,12 +1490,132 @@ export class GraphRenderer {
|
|||||||
node.label || node.name,
|
node.label || node.name,
|
||||||
maxLabelW,
|
maxLabelW,
|
||||||
);
|
);
|
||||||
|
if (isHovered || isSelected) {
|
||||||
|
const metrics = ctx.measureText(labelDraw);
|
||||||
|
const pillW = Math.min(maxLabelW + 12, metrics.width + 14);
|
||||||
|
const pillH = 17;
|
||||||
|
const pillX = node.x - pillW / 2;
|
||||||
|
const pillY = node.y + r + 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
roundRectPath(ctx, pillX, pillY, pillW, pillH, 8);
|
||||||
|
ctx.fillStyle = isSelected
|
||||||
|
? 'rgba(7, 14, 32, 0.88)'
|
||||||
|
: 'rgba(7, 14, 32, 0.72)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = colorWithAlpha(color, isSelected ? 0.48 : 0.32);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.fillStyle = `rgba(238,247,255,${isHovered || isSelected ? 0.96 : 0.62})`;
|
||||||
ctx.fillText(labelDraw, node.x, node.y + r + 14);
|
ctx.fillText(labelDraw, node.x, node.y + r + 14);
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_drawDeepSpaceBackground(ctx, W, H) {
|
||||||
|
const width = Math.max(1, Number(W) || 1);
|
||||||
|
const height = Math.max(1, Number(H) || 1);
|
||||||
|
const theme = THEMES[this.themeName] || THEMES.crimson;
|
||||||
|
const isLightTheme = LIGHT_PANEL_THEMES.has(this.themeName);
|
||||||
|
if (isLightTheme) {
|
||||||
|
ctx.fillStyle = createCanvasGradient(
|
||||||
|
ctx,
|
||||||
|
'createRadialGradient',
|
||||||
|
[
|
||||||
|
width * 0.52,
|
||||||
|
height * 0.36,
|
||||||
|
0,
|
||||||
|
width * 0.52,
|
||||||
|
height * 0.36,
|
||||||
|
Math.max(width, height) * 0.82,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, colorWithAlpha(theme.primary, 0.08)],
|
||||||
|
[0.42, colorWithAlpha(theme.secondary, 0.045)],
|
||||||
|
[1, theme.surfaceLowest || theme.surfaceLow || '#f8fafc'],
|
||||||
|
],
|
||||||
|
theme.surfaceLowest || theme.surfaceLow || '#f8fafc',
|
||||||
|
);
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = createCanvasGradient(
|
||||||
|
ctx,
|
||||||
|
'createRadialGradient',
|
||||||
|
[
|
||||||
|
width * 0.82,
|
||||||
|
height * 0.18,
|
||||||
|
0,
|
||||||
|
width * 0.82,
|
||||||
|
height * 0.18,
|
||||||
|
Math.max(width, height) * 0.46,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, colorWithAlpha(theme.accent2 || theme.primary, 0.055)],
|
||||||
|
[1, 'rgba(255, 255, 255, 0)'],
|
||||||
|
],
|
||||||
|
'rgba(255, 255, 255, 0)',
|
||||||
|
);
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = createCanvasGradient(
|
||||||
|
ctx,
|
||||||
|
'createRadialGradient',
|
||||||
|
[
|
||||||
|
width * 0.52,
|
||||||
|
height * 0.36,
|
||||||
|
0,
|
||||||
|
width * 0.52,
|
||||||
|
height * 0.36,
|
||||||
|
Math.max(width, height) * 0.82,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, colorWithAlpha(theme.primary || '#224b84', 0.2)],
|
||||||
|
[0.34, colorWithAlpha(theme.secondary || '#141c48', 0.12)],
|
||||||
|
[0.72, colorWithAlpha(theme.surfaceLow || '#070b1a', 0.5)],
|
||||||
|
[1, theme.surfaceLowest || 'rgba(2, 4, 12, 0.96)'],
|
||||||
|
],
|
||||||
|
theme.surfaceLowest || 'rgba(2, 4, 12, 0.96)',
|
||||||
|
);
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = createCanvasGradient(
|
||||||
|
ctx,
|
||||||
|
'createRadialGradient',
|
||||||
|
[
|
||||||
|
width * 0.82,
|
||||||
|
height * 0.18,
|
||||||
|
0,
|
||||||
|
width * 0.82,
|
||||||
|
height * 0.18,
|
||||||
|
Math.max(width, height) * 0.46,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, colorWithAlpha(theme.accent3 || '#b073ff', 0.12)],
|
||||||
|
[0.5, colorWithAlpha(theme.accent2 || '#57c7ff', 0.055)],
|
||||||
|
[1, 'rgba(0, 0, 0, 0)'],
|
||||||
|
],
|
||||||
|
'rgba(0, 0, 0, 0)',
|
||||||
|
);
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildFocusState() {
|
||||||
|
const selectedNode = this.selectedNode || null;
|
||||||
|
const connectedNodes = new Set();
|
||||||
|
if (selectedNode) {
|
||||||
|
connectedNodes.add(selectedNode);
|
||||||
|
for (const edge of this.edges) {
|
||||||
|
if (edge.from === selectedNode && edge.to) connectedNodes.add(edge.to);
|
||||||
|
if (edge.to === selectedNode && edge.from) connectedNodes.add(edge.from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { selectedNode, connectedNodes };
|
||||||
|
}
|
||||||
|
|
||||||
_scheduleRender() {
|
_scheduleRender() {
|
||||||
if (!this.enabled || this.animId) return;
|
if (!this.enabled || this.animId) return;
|
||||||
this.animId = requestAnimationFrame(() => {
|
this.animId = requestAnimationFrame(() => {
|
||||||
@@ -1412,6 +1656,19 @@ export class GraphRenderer {
|
|||||||
return min + ((node.importance || 5) / 10) * (max - min);
|
return min + ((node.importance || 5) / 10) * (max - min);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_nodeVisualRadius(node) {
|
||||||
|
const base = this._nodeRadius(node);
|
||||||
|
const importance = Number(node?.importance || 5);
|
||||||
|
const type = String(node?.type || '').toLowerCase();
|
||||||
|
if (type === 'character' || importance >= 9) {
|
||||||
|
return Math.min(base * 1.18, base + 4);
|
||||||
|
}
|
||||||
|
if (type === 'event' || importance >= 6) {
|
||||||
|
return Math.min(base * 1.08, base + 2);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
_ellipsisLabel(ctx, text, maxW) {
|
_ellipsisLabel(ctx, text, maxW) {
|
||||||
const s = String(text ?? "").trim() || "—";
|
const s = String(text ?? "").trim() || "—";
|
||||||
if (!maxW || maxW < 12) return s;
|
if (!maxW || maxW < 12) return s;
|
||||||
|
|||||||
64
ui/themes.js
64
ui/themes.js
@@ -16,19 +16,19 @@ export const THEMES = {
|
|||||||
surfaceHigh: '#2a2a2d',
|
surfaceHigh: '#2a2a2d',
|
||||||
surfaceHighest: '#353438',
|
surfaceHighest: '#353438',
|
||||||
surfaceLow: '#1b1b1e',
|
surfaceLow: '#1b1b1e',
|
||||||
surfaceLowest: '#0e0e11',
|
surfaceLowest: '#050814',
|
||||||
onSurface: '#e4e1e6',
|
onSurface: '#e4e1e6',
|
||||||
onSurfaceDim: 'rgba(228, 225, 230, 0.6)',
|
onSurfaceDim: 'rgba(228, 225, 230, 0.6)',
|
||||||
border: 'rgba(255, 255, 255, 0.08)',
|
border: 'rgba(255, 255, 255, 0.08)',
|
||||||
borderActive: 'rgba(233, 69, 96, 0.4)',
|
borderActive: 'rgba(233, 69, 96, 0.4)',
|
||||||
// 节点颜色
|
// 节点颜色
|
||||||
nodeCharacter: '#e94560',
|
nodeCharacter: '#ff4f8b',
|
||||||
nodeEvent: '#4fc3f7',
|
nodeEvent: '#4fd8ff',
|
||||||
nodeLocation: '#66bb6a',
|
nodeLocation: '#75ffb1',
|
||||||
nodeThread: '#ffd54f',
|
nodeThread: '#ffe66d',
|
||||||
nodeRule: '#ab47bc',
|
nodeRule: '#c778ff',
|
||||||
nodeSynopsis: '#b388ff',
|
nodeSynopsis: '#bca7ff',
|
||||||
nodeReflection: '#80deea',
|
nodeReflection: '#86f7ff',
|
||||||
},
|
},
|
||||||
cyan: {
|
cyan: {
|
||||||
name: 'Neon Cyan',
|
name: 'Neon Cyan',
|
||||||
@@ -44,18 +44,18 @@ export const THEMES = {
|
|||||||
surfaceHigh: '#222a2d',
|
surfaceHigh: '#222a2d',
|
||||||
surfaceHighest: '#2d3538',
|
surfaceHighest: '#2d3538',
|
||||||
surfaceLow: '#171d1e',
|
surfaceLow: '#171d1e',
|
||||||
surfaceLowest: '#0e1111',
|
surfaceLowest: '#031019',
|
||||||
onSurface: '#e0f7fa',
|
onSurface: '#e0f7fa',
|
||||||
onSurfaceDim: 'rgba(224, 247, 250, 0.6)',
|
onSurfaceDim: 'rgba(224, 247, 250, 0.6)',
|
||||||
border: 'rgba(0, 229, 255, 0.1)',
|
border: 'rgba(0, 229, 255, 0.1)',
|
||||||
borderActive: 'rgba(0, 229, 255, 0.4)',
|
borderActive: 'rgba(0, 229, 255, 0.4)',
|
||||||
nodeCharacter: '#00e5ff',
|
nodeCharacter: '#62f3ff',
|
||||||
nodeEvent: '#2979ff',
|
nodeEvent: '#438cff',
|
||||||
nodeLocation: '#00bfa5',
|
nodeLocation: '#22f0c6',
|
||||||
nodeThread: '#ffab40',
|
nodeThread: '#ffc46b',
|
||||||
nodeRule: '#7c4dff',
|
nodeRule: '#9a75ff',
|
||||||
nodeSynopsis: '#18ffff',
|
nodeSynopsis: '#58ffff',
|
||||||
nodeReflection: '#84ffff',
|
nodeReflection: '#b0ffff',
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
name: 'Amber Console',
|
name: 'Amber Console',
|
||||||
@@ -71,18 +71,18 @@ export const THEMES = {
|
|||||||
surfaceHigh: '#2a2822',
|
surfaceHigh: '#2a2822',
|
||||||
surfaceHighest: '#35322a',
|
surfaceHighest: '#35322a',
|
||||||
surfaceLow: '#1b1a17',
|
surfaceLow: '#1b1a17',
|
||||||
surfaceLowest: '#0e0d0b',
|
surfaceLowest: '#100b03',
|
||||||
onSurface: '#e4e1d6',
|
onSurface: '#e4e1d6',
|
||||||
onSurfaceDim: 'rgba(228, 225, 214, 0.6)',
|
onSurfaceDim: 'rgba(228, 225, 214, 0.6)',
|
||||||
border: 'rgba(255, 179, 0, 0.1)',
|
border: 'rgba(255, 179, 0, 0.1)',
|
||||||
borderActive: 'rgba(255, 179, 0, 0.4)',
|
borderActive: 'rgba(255, 179, 0, 0.4)',
|
||||||
nodeCharacter: '#ffb300',
|
nodeCharacter: '#ffc247',
|
||||||
nodeEvent: '#e65100',
|
nodeEvent: '#ff7a22',
|
||||||
nodeLocation: '#00d2fe',
|
nodeLocation: '#4fe4ff',
|
||||||
nodeThread: '#ff6e40',
|
nodeThread: '#ff8f68',
|
||||||
nodeRule: '#9e9d24',
|
nodeRule: '#d0cf4a',
|
||||||
nodeSynopsis: '#ffd740',
|
nodeSynopsis: '#ffe66d',
|
||||||
nodeReflection: '#ffab40',
|
nodeReflection: '#ffc46b',
|
||||||
},
|
},
|
||||||
violet: {
|
violet: {
|
||||||
name: 'Violet Haze',
|
name: 'Violet Haze',
|
||||||
@@ -98,18 +98,18 @@ export const THEMES = {
|
|||||||
surfaceHigh: '#28222d',
|
surfaceHigh: '#28222d',
|
||||||
surfaceHighest: '#332b38',
|
surfaceHighest: '#332b38',
|
||||||
surfaceLow: '#1a171e',
|
surfaceLow: '#1a171e',
|
||||||
surfaceLowest: '#0e0c11',
|
surfaceLowest: '#090615',
|
||||||
onSurface: '#e8e0f0',
|
onSurface: '#e8e0f0',
|
||||||
onSurfaceDim: 'rgba(232, 224, 240, 0.6)',
|
onSurfaceDim: 'rgba(232, 224, 240, 0.6)',
|
||||||
border: 'rgba(179, 136, 255, 0.1)',
|
border: 'rgba(179, 136, 255, 0.1)',
|
||||||
borderActive: 'rgba(179, 136, 255, 0.4)',
|
borderActive: 'rgba(179, 136, 255, 0.4)',
|
||||||
nodeCharacter: '#ea80fc',
|
nodeCharacter: '#f19cff',
|
||||||
nodeEvent: '#7c4dff',
|
nodeEvent: '#9572ff',
|
||||||
nodeLocation: '#80cbc4',
|
nodeLocation: '#98f0e8',
|
||||||
nodeThread: '#ff80ab',
|
nodeThread: '#ff9cbd',
|
||||||
nodeRule: '#b388ff',
|
nodeRule: '#c7a8ff',
|
||||||
nodeSynopsis: '#ce93d8',
|
nodeSynopsis: '#dfabeb',
|
||||||
nodeReflection: '#80deea',
|
nodeReflection: '#9cf4ff',
|
||||||
},
|
},
|
||||||
/** 亮色 · 晨光纸感(暖纸面 + 青绿主色 + 琥珀强调) */
|
/** 亮色 · 晨光纸感(暖纸面 + 青绿主色 + 琥珀强调) */
|
||||||
paperDawn: {
|
paperDawn: {
|
||||||
|
|||||||
Reference in New Issue
Block a user