diff --git a/style.css b/style.css index 670c3a6..8a10fbe 100644 --- a/style.css +++ b/style.css @@ -553,6 +553,35 @@ flex-direction: column; min-height: 0; 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 { diff --git a/tests/graph-renderer-guardrails.mjs b/tests/graph-renderer-guardrails.mjs index 79169cc..68f7ad1 100644 --- a/tests/graph-renderer-guardrails.mjs +++ b/tests/graph-renderer-guardrails.mjs @@ -12,6 +12,11 @@ globalThis.ResizeObserver = class ResizeObserver { disconnect() {} }; +const canvasMockStats = { + radialGradientCalls: 0, + linearGradientCalls: 0, +}; + function createNoopContext() { const noop = () => {}; return { @@ -35,7 +40,14 @@ function createNoopContext() { fillRect: noop, strokeRect: noop, 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 strokeStyle(_value) {}, set lineWidth(_value) {}, @@ -162,4 +174,41 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js"); 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"); diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index ea1cf14..990549f 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -1,7 +1,7 @@ // ST-BME: Canvas 图谱渲染器 — 分区「神经视图」布局 // 零依赖:客观层 / 角色 POV / 用户 POV 分区内 Vogel 初值 + 一次性力导向稳定,无帧循环抖动 -import { getNodeColors } from './themes.js'; +import { getNodeColors, LIGHT_PANEL_THEMES, THEMES } from './themes.js'; import { isUsableGraphCanvasSize, remapPositionBetweenRects, @@ -139,6 +139,56 @@ const SCOPE_OUTLINE_COLORS = { 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) { let h = 0; const s = String(id || ''); @@ -1253,23 +1303,36 @@ export class GraphRenderer { 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; + roundRectPath(ctx, p.x, p.y, pw, ph, 16); + 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.strokeStyle = 'rgba(87, 199, 255, 0.12)'; + ctx.strokeStyle = 'rgba(141, 213, 255, 0.14)'; ctx.lineWidth = 1; ctx.stroke(); - ctx.fillStyle = 'rgba(228, 225, 230, 0.55)'; - ctx.font = '600 10px Inter, sans-serif'; + ctx.fillStyle = 'rgba(222, 239, 255, 0.64)'; + ctx.font = '700 10px Inter, sans-serif'; ctx.textAlign = 'left'; 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 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 my = (from.y + to.y) / 2; const dx = to.x - from.x; @@ -1283,12 +1346,26 @@ export class GraphRenderer { const cx = mx + nx * 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.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.strokeStyle = colorWithAlpha(isConnectedToSelection ? relationColor : '#c9dcff', alpha); + ctx.lineWidth = isConnectedToSelection + ? 1.35 + strength * 2.15 + : 0.35 + strength * 0.82; ctx.stroke(); } @@ -1307,6 +1384,8 @@ export class GraphRenderer { ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this._drawDeepSpaceBackground(ctx, W, H); + ctx.translate(this.offsetX, this.offsetY); ctx.scale(this.scale, this.scale); @@ -1316,13 +1395,17 @@ export class GraphRenderer { 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) { - const r = this._nodeRadius(node); + const baseRadius = this._nodeVisualRadius(node); const color = this.colors[node.type] || this.colors.event; const isSelected = node === this.selectedNode; 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 outlineColor = scope.layer === 'pov' ? (scope.ownerType === 'user' @@ -1330,26 +1413,67 @@ export class GraphRenderer { : SCOPE_OUTLINE_COLORS.character) : SCOPE_OUTLINE_COLORS.objective; + ctx.save(); + if (isDimmed) ctx.globalAlpha = 0.2; + if (isSelected || isHovered) { + const glowRadius = r + (isSelected ? 20 : 13); 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.arc(node.x, node.y, glowRadius, 0, Math.PI * 2); + ctx.fillStyle = createCanvasGradient( + ctx, + 'createRadialGradient', + [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.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.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.strokeStyle = isSelected ? '#fff' : outlineColor; - ctx.lineWidth = isSelected ? 2.25 : 1.35; + ctx.strokeStyle = isSelected ? '#fff' : colorWithAlpha(outlineColor, isHovered ? 0.9 : 0.64); + ctx.lineWidth = isSelected ? 2.8 : (isHovered ? 1.8 : 1.1); 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.textAlign = 'center'; const rect = node.regionRect; @@ -1366,12 +1490,132 @@ export class GraphRenderer { node.label || node.name, 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.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() { if (!this.enabled || this.animId) return; this.animId = requestAnimationFrame(() => { @@ -1412,6 +1656,19 @@ export class GraphRenderer { 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) { const s = String(text ?? "").trim() || "—"; if (!maxW || maxW < 12) return s; diff --git a/ui/themes.js b/ui/themes.js index 9b33de4..0acf3ae 100644 --- a/ui/themes.js +++ b/ui/themes.js @@ -16,19 +16,19 @@ export const THEMES = { surfaceHigh: '#2a2a2d', surfaceHighest: '#353438', surfaceLow: '#1b1b1e', - surfaceLowest: '#0e0e11', + surfaceLowest: '#050814', onSurface: '#e4e1e6', onSurfaceDim: 'rgba(228, 225, 230, 0.6)', border: 'rgba(255, 255, 255, 0.08)', borderActive: 'rgba(233, 69, 96, 0.4)', // 节点颜色 - nodeCharacter: '#e94560', - nodeEvent: '#4fc3f7', - nodeLocation: '#66bb6a', - nodeThread: '#ffd54f', - nodeRule: '#ab47bc', - nodeSynopsis: '#b388ff', - nodeReflection: '#80deea', + nodeCharacter: '#ff4f8b', + nodeEvent: '#4fd8ff', + nodeLocation: '#75ffb1', + nodeThread: '#ffe66d', + nodeRule: '#c778ff', + nodeSynopsis: '#bca7ff', + nodeReflection: '#86f7ff', }, cyan: { name: 'Neon Cyan', @@ -44,18 +44,18 @@ export const THEMES = { surfaceHigh: '#222a2d', surfaceHighest: '#2d3538', surfaceLow: '#171d1e', - surfaceLowest: '#0e1111', + surfaceLowest: '#031019', onSurface: '#e0f7fa', onSurfaceDim: 'rgba(224, 247, 250, 0.6)', border: 'rgba(0, 229, 255, 0.1)', borderActive: 'rgba(0, 229, 255, 0.4)', - nodeCharacter: '#00e5ff', - nodeEvent: '#2979ff', - nodeLocation: '#00bfa5', - nodeThread: '#ffab40', - nodeRule: '#7c4dff', - nodeSynopsis: '#18ffff', - nodeReflection: '#84ffff', + nodeCharacter: '#62f3ff', + nodeEvent: '#438cff', + nodeLocation: '#22f0c6', + nodeThread: '#ffc46b', + nodeRule: '#9a75ff', + nodeSynopsis: '#58ffff', + nodeReflection: '#b0ffff', }, amber: { name: 'Amber Console', @@ -71,18 +71,18 @@ export const THEMES = { surfaceHigh: '#2a2822', surfaceHighest: '#35322a', surfaceLow: '#1b1a17', - surfaceLowest: '#0e0d0b', + surfaceLowest: '#100b03', onSurface: '#e4e1d6', onSurfaceDim: 'rgba(228, 225, 214, 0.6)', border: 'rgba(255, 179, 0, 0.1)', borderActive: 'rgba(255, 179, 0, 0.4)', - nodeCharacter: '#ffb300', - nodeEvent: '#e65100', - nodeLocation: '#00d2fe', - nodeThread: '#ff6e40', - nodeRule: '#9e9d24', - nodeSynopsis: '#ffd740', - nodeReflection: '#ffab40', + nodeCharacter: '#ffc247', + nodeEvent: '#ff7a22', + nodeLocation: '#4fe4ff', + nodeThread: '#ff8f68', + nodeRule: '#d0cf4a', + nodeSynopsis: '#ffe66d', + nodeReflection: '#ffc46b', }, violet: { name: 'Violet Haze', @@ -98,18 +98,18 @@ export const THEMES = { surfaceHigh: '#28222d', surfaceHighest: '#332b38', surfaceLow: '#1a171e', - surfaceLowest: '#0e0c11', + surfaceLowest: '#090615', onSurface: '#e8e0f0', onSurfaceDim: 'rgba(232, 224, 240, 0.6)', border: 'rgba(179, 136, 255, 0.1)', borderActive: 'rgba(179, 136, 255, 0.4)', - nodeCharacter: '#ea80fc', - nodeEvent: '#7c4dff', - nodeLocation: '#80cbc4', - nodeThread: '#ff80ab', - nodeRule: '#b388ff', - nodeSynopsis: '#ce93d8', - nodeReflection: '#80deea', + nodeCharacter: '#f19cff', + nodeEvent: '#9572ff', + nodeLocation: '#98f0e8', + nodeThread: '#ff9cbd', + nodeRule: '#c7a8ff', + nodeSynopsis: '#dfabeb', + nodeReflection: '#9cf4ff', }, /** 亮色 · 晨光纸感(暖纸面 + 青绿主色 + 琥珀强调) */ paperDawn: {