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,
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);

View File

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