From 38dece4d852b47a53353ab69c0025fcaabeb35fd Mon Sep 17 00:00:00 2001 From: youzini Date: Thu, 4 Jun 2026 14:09:44 +0000 Subject: [PATCH] feat(graph-ui): add galaxy visual mode --- tests/graph-renderer-guardrails.mjs | 43 ++++++++- ui/graph-renderer.js | 134 +++++++++++++++------------- 2 files changed, 114 insertions(+), 63 deletions(-) diff --git a/tests/graph-renderer-guardrails.mjs b/tests/graph-renderer-guardrails.mjs index da0cb45..490e277 100644 --- a/tests/graph-renderer-guardrails.mjs +++ b/tests/graph-renderer-guardrails.mjs @@ -35,6 +35,7 @@ const canvasMockStats = { radialGradientCalls: 0, linearGradientCalls: 0, strokeCalls: 0, + fillTextCalls: 0, shadowBlurValues: [], arcRadii: [], }; @@ -96,7 +97,9 @@ function createNoopContext() { moveTo: noop, lineTo: noop, quadraticCurveTo: noop, - fillText: noop, + fillText: () => { + canvasMockStats.fillTextCalls += 1; + }, closePath: noop, rect: noop, fillRect: noop, @@ -183,6 +186,24 @@ function createStarSeedGraph({ includeFragment = false } = {}) { return graph; } +function createLabelBudgetGraph(count = 12) { + const nodes = []; + const edges = []; + for (let i = 0; i < count; i += 1) { + nodes.push({ + id: `label-${i}`, + type: i % 3 === 0 ? "character" : (i % 3 === 1 ? "event" : "reflection"), + name: `Label Node ${i}`, + importance: count - i, + scope: { layer: "objective" }, + }); + if (i > 0) { + edges.push({ fromId: "label-0", toId: `label-${i}`, relation: "related", strength: 0.5 }); + } + } + return { nodes, edges }; +} + function assertInputUnchanged(graph, beforeJson) { assert.equal(JSON.stringify(graph), beforeJson); for (const node of graph.nodes) { @@ -196,6 +217,7 @@ function resetCanvasStats() { canvasMockStats.radialGradientCalls = 0; canvasMockStats.linearGradientCalls = 0; canvasMockStats.strokeCalls = 0; + canvasMockStats.fillTextCalls = 0; canvasMockStats.shadowBlurValues = []; canvasMockStats.arcRadii = []; } @@ -375,7 +397,6 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js"); renderer.highlightNode("char-1"); assertInputUnchanged(graph, before); assert.ok(canvasMockStats.radialGradientCalls > 0); - assert.ok(canvasMockStats.linearGradientCalls > 0); assert.equal( Math.max(0, ...canvasMockStats.shadowBlurValues), 0, @@ -384,6 +405,24 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js"); renderer.destroy(); } +{ + resetCanvasStats(); + const graph = createLabelBudgetGraph(12); + const before = JSON.stringify(graph); + const renderer = new GraphRenderer(createCanvas(), { + runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true }, + layoutConfig: { neuralIterations: 8 }, + }); + + renderer.loadGraph(graph); + assertInputUnchanged(graph, before); + assert.ok( + canvasMockStats.fillTextCalls <= 7, + "dark galaxy mode limits default labels to a small core budget", + ); + renderer.destroy(); +} + { const graph = createGraphFixture(); const before = JSON.stringify(graph); diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 14574a5..551e32a 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -17,6 +17,17 @@ import { normalizeGraphNativeRuntimeOptions, } from './graph-native-bridge.js'; +const GALAXY_COLORS = { + character: '#ff4f8b', + event: '#438cff', + location: '#10b981', + thread: '#8b5cf6', + rule: '#f59e0b', + synopsis: '#d946ef', + reflection: '#06b6d4', + default: '#64748b', +}; + /** * @typedef {Object} GraphNode * @property {string} id @@ -1836,6 +1847,7 @@ export class GraphRenderer { // ==================== 渲染 ==================== _drawRegionPanels(ctx) { + if (!LIGHT_PANEL_THEMES.has(this.themeName)) return; for (const p of this._regionPanels) { const pw = Number(p.w) || 0; const ph = Number(p.h) || 0; @@ -1884,15 +1896,19 @@ export class GraphRenderer { const cx = mx + nx * bend; const cy = my + ny * bend; + const isLightTheme = LIGHT_PANEL_THEMES.has(this.themeName); const relationColor = edgeColorForRelation(edge.relation); - const baseAlpha = sameZone ? 0.026 + strength * 0.052 : 0.02 + strength * 0.045; - const alpha = isDimmed ? 0.012 : (isConnectedToSelection ? 0.28 + strength * 0.14 : baseAlpha); + const edgeColor = isLightTheme ? relationColor : (GALAXY_COLORS[from.type] || GALAXY_COLORS.default); + const unselectedColor = isLightTheme ? '#9eb2cf' : edgeColor; + + const baseAlpha = sameZone ? 0.04 + strength * 0.06 : 0.03 + strength * 0.05; + const alpha = isDimmed ? (isLightTheme ? 0.012 : 0.01) : (isConnectedToSelection ? 0.35 + strength * 0.25 : baseAlpha); if (isConnectedToSelection) { ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.quadraticCurveTo(cx, cy, to.x, to.y); - ctx.strokeStyle = colorWithAlpha(relationColor, 0.055 + strength * 0.055); + ctx.strokeStyle = colorWithAlpha(edgeColor, isLightTheme ? 0.055 + strength * 0.055 : 0.15 + strength * 0.15); ctx.lineWidth = 1.35 + strength * 0.95; ctx.stroke(); } @@ -1900,7 +1916,7 @@ export class GraphRenderer { ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.quadraticCurveTo(cx, cy, to.x, to.y); - ctx.strokeStyle = colorWithAlpha(isConnectedToSelection ? relationColor : '#9eb2cf', alpha); + ctx.strokeStyle = colorWithAlpha(isConnectedToSelection ? edgeColor : unselectedColor, alpha); ctx.lineWidth = isConnectedToSelection ? 0.7 + strength * 0.72 : 0.28 + strength * 0.44; @@ -1938,9 +1954,18 @@ export class GraphRenderer { this.edges.forEach((e, i) => this._drawSynapseEdge(ctx, e, i, focus)); + const isLightTheme = LIGHT_PANEL_THEMES.has(this.themeName); + const coreLabelNodes = isLightTheme + ? null + : new Set( + [...this.nodes] + .sort((a, b) => (b.importance || 0) - (a.importance || 0)) + .slice(0, 7) + ); + for (const node of this.nodes) { const baseRadius = this._nodeVisualRadius(node); - const color = this.colors[node.type] || this.colors.event; + const color = isLightTheme ? (this.colors[node.type] || this.colors.event) : (GALAXY_COLORS[node.type] || GALAXY_COLORS.default); const isSelected = node === this.selectedNode; const isHovered = node === this.hoveredNode; const isDimmed = focus.selectedNode && !focus.connectedNodes.has(node); @@ -2005,29 +2030,32 @@ export class GraphRenderer { : 0.52; maxLabelW = Math.max(36, Math.min(220, rect.w * frac)); } - const labelDraw = this._ellipsisLabel( - ctx, - node.label || node.name, - maxLabelW, - ); - if (isHovered || isSelected) { - const metrics = ctx.measureText(labelDraw); - const pillW = Math.min(maxLabelW + 10, metrics.width + 12); - const pillH = 16; - const pillX = node.x - pillW / 2; - const pillY = node.y + r + 6.5; - ctx.beginPath(); - roundRectPath(ctx, pillX, pillY, pillW, pillH, 5); - ctx.fillStyle = isSelected - ? 'rgba(8, 10, 16, 0.64)' - : 'rgba(8, 10, 16, 0.52)'; - ctx.fill(); - ctx.strokeStyle = 'rgba(238, 246, 255, 0.09)'; - ctx.lineWidth = 1; - ctx.stroke(); + const showLabel = isLightTheme || isHovered || isSelected || coreLabelNodes.has(node); + if (showLabel) { + const labelDraw = this._ellipsisLabel( + ctx, + node.label || node.name, + maxLabelW, + ); + if (isHovered || isSelected) { + const metrics = ctx.measureText(labelDraw); + const pillW = Math.min(maxLabelW + 10, metrics.width + 12); + const pillH = 16; + const pillX = node.x - pillW / 2; + const pillY = node.y + r + 6.5; + ctx.beginPath(); + roundRectPath(ctx, pillX, pillY, pillW, pillH, 5); + ctx.fillStyle = isSelected + ? 'rgba(8, 10, 16, 0.64)' + : 'rgba(8, 10, 16, 0.52)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(238, 246, 255, 0.09)'; + ctx.lineWidth = 1; + ctx.stroke(); + } + ctx.fillStyle = `rgba(218,229,242,${isHovered || isSelected ? 0.88 : 0.52})`; + ctx.fillText(labelDraw, node.x, node.y + r + 14); } - ctx.fillStyle = `rgba(218,229,242,${isHovered || isSelected ? 0.88 : 0.52})`; - ctx.fillText(labelDraw, node.x, node.y + r + 14); ctx.restore(); } @@ -2181,40 +2209,19 @@ export class GraphRenderer { ctx, 'createRadialGradient', [ - width * 0.52, - height * 0.36, + width * 0.5, + height * 0.5, 0, - width * 0.52, - height * 0.36, - Math.max(width, height) * 0.82, + width * 0.5, + height * 0.5, + Math.max(width, height) * 0.8, ], [ - [0, colorWithAlpha(theme.primary || '#224b84', 0.12)], - [0.34, colorWithAlpha(theme.secondary || '#141c48', 0.075)], - [0.72, colorWithAlpha(theme.surfaceLow || '#070b1a', 0.58)], - [1, theme.surfaceLowest || 'rgba(2, 4, 12, 0.96)'], + [0, '#0a0a10'], + [0.5, '#08080d'], + [1, '#06060a'], ], - 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.055)], - [0.5, colorWithAlpha(theme.accent2 || '#57c7ff', 0.032)], - [1, 'rgba(0, 0, 0, 0)'], - ], - 'rgba(0, 0, 0, 0)', + '#06060a' ); ctx.fillRect(0, 0, width, height); } @@ -2340,6 +2347,7 @@ export class GraphRenderer { } _drawGrid(W, H) { + if (!LIGHT_PANEL_THEMES.has(this.themeName)) return; const sp = this.config.gridSpacing; if (!sp || sp <= 0) return; @@ -2375,13 +2383,17 @@ export class GraphRenderer { const base = this._nodeRadius(node); const importance = Number(node?.importance || 5); const type = String(node?.type || '').toLowerCase(); + let r; if (type === 'character' || importance >= 9) { - return Math.min(8, Math.max(4.8, base * 0.58)); + r = base * 0.58; + return Math.min(8, r); } - if (type === 'event' || importance >= 6) { - return Math.min(7, Math.max(4.2, base * 0.52)); + if (importance >= 6) { + r = base * 0.52; + return Math.min(6.5, r); } - return Math.min(6.2, Math.max(3.4, base * 0.46)); + r = base * 0.45; + return Math.min(4.5, r); } _ellipsisLabel(ctx, text, maxW) {