feat(graph-ui): polish memory graph visuals

This commit is contained in:
youzini
2026-06-04 08:07:09 +00:00
parent fe18a05147
commit 05d2c703c8
4 changed files with 389 additions and 54 deletions

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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;

View File

@@ -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: {