mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-14 02:40:45 +08:00
feat(graph-ui): add galaxy visual mode
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user