feat(graph-ui): add galaxy visual mode

This commit is contained in:
youzini
2026-06-04 14:09:44 +00:00
parent 758374881e
commit 38dece4d85
2 changed files with 114 additions and 63 deletions

View File

@@ -35,6 +35,7 @@ const canvasMockStats = {
radialGradientCalls: 0, radialGradientCalls: 0,
linearGradientCalls: 0, linearGradientCalls: 0,
strokeCalls: 0, strokeCalls: 0,
fillTextCalls: 0,
shadowBlurValues: [], shadowBlurValues: [],
arcRadii: [], arcRadii: [],
}; };
@@ -96,7 +97,9 @@ function createNoopContext() {
moveTo: noop, moveTo: noop,
lineTo: noop, lineTo: noop,
quadraticCurveTo: noop, quadraticCurveTo: noop,
fillText: noop, fillText: () => {
canvasMockStats.fillTextCalls += 1;
},
closePath: noop, closePath: noop,
rect: noop, rect: noop,
fillRect: noop, fillRect: noop,
@@ -183,6 +186,24 @@ function createStarSeedGraph({ includeFragment = false } = {}) {
return graph; 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) { function assertInputUnchanged(graph, beforeJson) {
assert.equal(JSON.stringify(graph), beforeJson); assert.equal(JSON.stringify(graph), beforeJson);
for (const node of graph.nodes) { for (const node of graph.nodes) {
@@ -196,6 +217,7 @@ function resetCanvasStats() {
canvasMockStats.radialGradientCalls = 0; canvasMockStats.radialGradientCalls = 0;
canvasMockStats.linearGradientCalls = 0; canvasMockStats.linearGradientCalls = 0;
canvasMockStats.strokeCalls = 0; canvasMockStats.strokeCalls = 0;
canvasMockStats.fillTextCalls = 0;
canvasMockStats.shadowBlurValues = []; canvasMockStats.shadowBlurValues = [];
canvasMockStats.arcRadii = []; canvasMockStats.arcRadii = [];
} }
@@ -375,7 +397,6 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js");
renderer.highlightNode("char-1"); renderer.highlightNode("char-1");
assertInputUnchanged(graph, before); assertInputUnchanged(graph, before);
assert.ok(canvasMockStats.radialGradientCalls > 0); assert.ok(canvasMockStats.radialGradientCalls > 0);
assert.ok(canvasMockStats.linearGradientCalls > 0);
assert.equal( assert.equal(
Math.max(0, ...canvasMockStats.shadowBlurValues), Math.max(0, ...canvasMockStats.shadowBlurValues),
0, 0,
@@ -384,6 +405,24 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js");
renderer.destroy(); 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 graph = createGraphFixture();
const before = JSON.stringify(graph); const before = JSON.stringify(graph);

View File

@@ -17,6 +17,17 @@ import {
normalizeGraphNativeRuntimeOptions, normalizeGraphNativeRuntimeOptions,
} from './graph-native-bridge.js'; } 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 * @typedef {Object} GraphNode
* @property {string} id * @property {string} id
@@ -1836,6 +1847,7 @@ export class GraphRenderer {
// ==================== 渲染 ==================== // ==================== 渲染 ====================
_drawRegionPanels(ctx) { _drawRegionPanels(ctx) {
if (!LIGHT_PANEL_THEMES.has(this.themeName)) return;
for (const p of this._regionPanels) { for (const p of this._regionPanels) {
const pw = Number(p.w) || 0; const pw = Number(p.w) || 0;
const ph = Number(p.h) || 0; const ph = Number(p.h) || 0;
@@ -1884,15 +1896,19 @@ export class GraphRenderer {
const cx = mx + nx * bend; const cx = mx + nx * bend;
const cy = my + ny * bend; const cy = my + ny * bend;
const isLightTheme = LIGHT_PANEL_THEMES.has(this.themeName);
const relationColor = edgeColorForRelation(edge.relation); const relationColor = edgeColorForRelation(edge.relation);
const baseAlpha = sameZone ? 0.026 + strength * 0.052 : 0.02 + strength * 0.045; const edgeColor = isLightTheme ? relationColor : (GALAXY_COLORS[from.type] || GALAXY_COLORS.default);
const alpha = isDimmed ? 0.012 : (isConnectedToSelection ? 0.28 + strength * 0.14 : baseAlpha); 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) { if (isConnectedToSelection) {
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 = 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.lineWidth = 1.35 + strength * 0.95;
ctx.stroke(); ctx.stroke();
} }
@@ -1900,7 +1916,7 @@ export class GraphRenderer {
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 = colorWithAlpha(isConnectedToSelection ? relationColor : '#9eb2cf', alpha); ctx.strokeStyle = colorWithAlpha(isConnectedToSelection ? edgeColor : unselectedColor, alpha);
ctx.lineWidth = isConnectedToSelection ctx.lineWidth = isConnectedToSelection
? 0.7 + strength * 0.72 ? 0.7 + strength * 0.72
: 0.28 + strength * 0.44; : 0.28 + strength * 0.44;
@@ -1938,9 +1954,18 @@ export class GraphRenderer {
this.edges.forEach((e, i) => this._drawSynapseEdge(ctx, e, i, focus)); 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) { for (const node of this.nodes) {
const baseRadius = this._nodeVisualRadius(node); 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 isSelected = node === this.selectedNode;
const isHovered = node === this.hoveredNode; const isHovered = node === this.hoveredNode;
const isDimmed = focus.selectedNode && !focus.connectedNodes.has(node); const isDimmed = focus.selectedNode && !focus.connectedNodes.has(node);
@@ -2005,29 +2030,32 @@ export class GraphRenderer {
: 0.52; : 0.52;
maxLabelW = Math.max(36, Math.min(220, rect.w * frac)); maxLabelW = Math.max(36, Math.min(220, rect.w * frac));
} }
const labelDraw = this._ellipsisLabel( const showLabel = isLightTheme || isHovered || isSelected || coreLabelNodes.has(node);
ctx, if (showLabel) {
node.label || node.name, const labelDraw = this._ellipsisLabel(
maxLabelW, ctx,
); node.label || node.name,
if (isHovered || isSelected) { maxLabelW,
const metrics = ctx.measureText(labelDraw); );
const pillW = Math.min(maxLabelW + 10, metrics.width + 12); if (isHovered || isSelected) {
const pillH = 16; const metrics = ctx.measureText(labelDraw);
const pillX = node.x - pillW / 2; const pillW = Math.min(maxLabelW + 10, metrics.width + 12);
const pillY = node.y + r + 6.5; const pillH = 16;
ctx.beginPath(); const pillX = node.x - pillW / 2;
roundRectPath(ctx, pillX, pillY, pillW, pillH, 5); const pillY = node.y + r + 6.5;
ctx.fillStyle = isSelected ctx.beginPath();
? 'rgba(8, 10, 16, 0.64)' roundRectPath(ctx, pillX, pillY, pillW, pillH, 5);
: 'rgba(8, 10, 16, 0.52)'; ctx.fillStyle = isSelected
ctx.fill(); ? 'rgba(8, 10, 16, 0.64)'
ctx.strokeStyle = 'rgba(238, 246, 255, 0.09)'; : 'rgba(8, 10, 16, 0.52)';
ctx.lineWidth = 1; ctx.fill();
ctx.stroke(); 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(); ctx.restore();
} }
@@ -2181,40 +2209,19 @@ export class GraphRenderer {
ctx, ctx,
'createRadialGradient', 'createRadialGradient',
[ [
width * 0.52, width * 0.5,
height * 0.36, height * 0.5,
0, 0,
width * 0.52, width * 0.5,
height * 0.36, height * 0.5,
Math.max(width, height) * 0.82, Math.max(width, height) * 0.8,
], ],
[ [
[0, colorWithAlpha(theme.primary || '#224b84', 0.12)], [0, '#0a0a10'],
[0.34, colorWithAlpha(theme.secondary || '#141c48', 0.075)], [0.5, '#08080d'],
[0.72, colorWithAlpha(theme.surfaceLow || '#070b1a', 0.58)], [1, '#06060a'],
[1, theme.surfaceLowest || 'rgba(2, 4, 12, 0.96)'],
], ],
theme.surfaceLowest || 'rgba(2, 4, 12, 0.96)', '#06060a'
);
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)',
); );
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
} }
@@ -2340,6 +2347,7 @@ export class GraphRenderer {
} }
_drawGrid(W, H) { _drawGrid(W, H) {
if (!LIGHT_PANEL_THEMES.has(this.themeName)) return;
const sp = this.config.gridSpacing; const sp = this.config.gridSpacing;
if (!sp || sp <= 0) return; if (!sp || sp <= 0) return;
@@ -2375,13 +2383,17 @@ export class GraphRenderer {
const base = this._nodeRadius(node); const base = this._nodeRadius(node);
const importance = Number(node?.importance || 5); const importance = Number(node?.importance || 5);
const type = String(node?.type || '').toLowerCase(); const type = String(node?.type || '').toLowerCase();
let r;
if (type === 'character' || importance >= 9) { 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) { if (importance >= 6) {
return Math.min(7, Math.max(4.2, base * 0.52)); 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) { _ellipsisLabel(ctx, text, maxW) {