fix(web): restore entity type colors in knowledge graphs (#21443)

This commit is contained in:
cht authored and GitHub committed 2026-11-02 18:02:37 +08:00
1 parent 0c7fb3d7d7
commit 30359a7e88
8 files changed
+391 -10

No files matched your search

@@ -1,23 +1,29 @@
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { getKnowledgeGraphNodeLabel } from '@/components/structure-graph/use-knowledge-graph-node-style';
import ArtifactForceGraph from '..';
const mockGraphRender = jest.fn();
const mockZoomToFit = jest.fn();
jest.mock('react-force-graph-2d', () => {
const ReactLib = jest.requireActual<typeof import('react')>('react');
const MockForceGraph = ReactLib.forwardRef<
HTMLDivElement,
{ zoomToFit: typeof mockZoomToFit },
{
graphData: { nodes: Array<{ description?: string }> };
nodeLabel: (node: { description?: string }) => string;
}
>(({ graphData, nodeLabel }, ref) => {
>((props, ref) => {
const { graphData, nodeLabel } = props;
mockGraphRender(props);
ReactLib.useImperativeHandle(ref, () => ({ zoomToFit: mockZoomToFit }));
const label = nodeLabel(graphData.nodes[0]);
if (!label) return null;
// Match the graph library's HTML tooltip sink instead of rendering text.
return ReactLib.createElement('div', {
ref,
'data-testid': 'node-tooltip',
dangerouslySetInnerHTML: { __html: label },
});
@@ -38,7 +44,10 @@ jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function renderGraph(description: string) {
function renderGraph(
description: string,
props: Pick<import('../types').ArtifactForceGraphProps, 'getNodeLabel'> = {},
) {
return render(
createElement(ArtifactForceGraph, {
data: {
@@ -54,6 +63,7 @@ function renderGraph(description: string) {
],
relations: [],
},
...props,
}),
);
}
@@ -76,4 +86,72 @@ describe('ArtifactForceGraph node tooltip', () => {
expect(screen.queryByTestId('node-tooltip')).toBeNull();
});
it('escapes the type and description from a custom plain-text label once', () => {
const type = '<img src=x onerror=alert(1)> &lt;type&gt;';
const description = '<script>bad()</script> &amp; 齿轮';
renderGraph(description, {
getNodeLabel: (node) => getKnowledgeGraphNodeLabel({ ...node, type }),
});
const tooltip = screen.getByTestId('node-tooltip');
expect(tooltip.textContent).toBe(`${type} · ${description}`);
expect(tooltip.querySelector('img, script')).toBeNull();
});
it('shows the knowledge graph type even without a description', () => {
renderGraph('', { getNodeLabel: getKnowledgeGraphNodeLabel });
expect(screen.getByTestId('node-tooltip')).toHaveTextContent('entity');
});
it('keeps graph objects and fitting stable across label, pinned and hover changes', () => {
mockZoomToFit.mockClear();
const data: import('@/interfaces/database/dataset').IArtifactGraph = {
entities: ['a', 'b', 'c'].map((slug) => ({
slug,
name: slug,
aliases: [],
description: slug,
type: 'entity',
weight: 1,
})),
relations: [{ from: 'a', to: 'b' }],
};
const getNodeColor = () => '#4e79a7';
const { rerender } = render(
<ArtifactForceGraph data={data} getNodeColor={getNodeColor} />,
);
const initial = mockGraphRender.mock.calls.at(-1)![0];
const [a, b, c] = initial.graphData.nodes;
act(() => initial.onEngineStop());
expect(mockZoomToFit).toHaveBeenCalledTimes(1);
rerender(
<ArtifactForceGraph
data={data}
getNodeColor={getNodeColor}
getNodeLabel={getKnowledgeGraphNodeLabel}
highlightNodeId="a"
/>,
);
const pinned = mockGraphRender.mock.calls.at(-1)![0];
expect(pinned.graphData).toBe(initial.graphData);
expect(pinned.graphData.nodes[0]).toBe(a);
expect(pinned.nodeColor(a)).toBe('#4e79a7');
expect(pinned.nodeColor(b)).toBe('#4e79a7');
expect(pinned.nodeColor(c)).toBe('#4e79a733');
act(() => pinned.onEngineStop());
expect(mockZoomToFit).toHaveBeenCalledTimes(1);
act(() => pinned.onNodeHover(c));
const hovered = mockGraphRender.mock.calls.at(-1)![0];
expect(hovered.graphData).toBe(initial.graphData);
expect(hovered.nodeColor(c)).toBe('#4e79a7');
expect(hovered.nodeColor(a)).toBe('#4e79a733');
act(() => hovered.onNodeHover(null));
const restored = mockGraphRender.mock.calls.at(-1)![0];
expect(restored.graphData).toBe(initial.graphData);
expect(restored.nodeColor(a)).toBe('#4e79a7');
expect(restored.nodeColor(c)).toBe('#4e79a733');
});
});
@@ -37,9 +37,17 @@ import { useGraphHighlight } from './use-graph-highlight';
import { defaultMapNodeToValue } from './utils';
const defaultGetNodeId = (node: IArtifactGraphEntity) => node.slug;
/** Returns the entity description as the default plain-text tooltip. */
const defaultGetNodeLabel = (node: IArtifactGraphEntity) =>
node.description ?? '';
const nodeCanvasObjectMode = () => 'after' as const;
/**
* Renders an interactive force graph with configurable node styles and labels.
* Escapes tooltip text and coordinates zoom-to-fit, hover and pinned-node
* highlighting, and mapped node-click callbacks.
*/
function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
data,
show = true,
@@ -49,6 +57,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
) => TNodeValue,
getNodeId = defaultGetNodeId,
getNodeColor = defaultGetNodeColor,
getNodeLabel = defaultGetNodeLabel,
getNodeRadius = defaultGetNodeRadius,
highlightNodeId,
totalEntities,
@@ -113,10 +122,10 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
[],
);
// ForceGraph treats tooltip strings as HTML; descriptions are plain text.
const getNodeLabel = useCallback(
(node: ArtifactGraphNode) => escape(node.description ?? ''),
[],
/** Escapes plain-text node labels for ForceGraph's HTML tooltip renderer. */
const getEscapedNodeLabel = useCallback(
(node: ArtifactGraphNode) => escape(getNodeLabel(node)),
[getNodeLabel],
);
// Empty label hides the tooltip, so relations without a type show nothing
@@ -140,7 +149,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
nodeColor={nodeColor}
nodeVal={nodeVal}
cooldownTicks={100}
nodeLabel={getNodeLabel}
nodeLabel={getEscapedNodeLabel}
autoPauseRedraw={false}
onEngineStop={handleEngineStop}
onNodeClick={handleNodeClick}
@@ -40,6 +40,8 @@ export interface ArtifactForceGraphProps<TNodeValue = IArtifactGraphEntity> {
mapNodeToValue?: (node: IArtifactGraphEntity) => TNodeValue;
getNodeId?: (node: IArtifactGraphEntity) => string;
getNodeColor?: (node: IArtifactGraphEntity) => string;
/** Plain tooltip text; escaped once by the graph before rendering as HTML. */
getNodeLabel?: (node: IArtifactGraphEntity) => string;
getNodeRadius?: (
node: IArtifactGraphEntity,
minWeight: number,
@@ -0,0 +1,151 @@
import { renderHook } from '@testing-library/react';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { type IArtifactGraphEntity } from '@/interfaces/database/dataset';
import { withAlpha } from '@/components/artifact-force-graph/utils';
import {
getKnowledgeGraphNodeLabel,
useKnowledgeGraphNodeColor,
} from '../use-knowledge-graph-node-style';
const Css = readFileSync(
resolve(__dirname, '../../../../tailwind.css'),
'utf8',
);
const Tokens = [
...Css.matchAll(/(--graph-node-(?:\d+|other)):\s*(#[\da-f]{6});/g),
];
const entity = (type: unknown, description = ''): IArtifactGraphEntity => ({
slug: 'test',
name: 'test',
aliases: [],
type: type as string,
description,
weight: 1,
});
beforeAll(() => {
Tokens.forEach(([, name, color]) =>
document.documentElement.style.setProperty(name, color),
);
});
afterAll(() => {
Tokens.forEach(([, name]) =>
document.documentElement.style.removeProperty(name),
);
});
describe('knowledge graph type colors', () => {
it('uses concrete CSS palette colors compatible with existing dimming', () => {
expect(Tokens).toHaveLength(11);
const { result } = renderHook(useKnowledgeGraphNodeColor);
const colors = Tokens.map(([, , color]) => color);
const color = result.current(entity('person'));
expect(colors).toContain(color);
expect(withAlpha(color, 0.2)).toBe(`${color}33`);
});
it('stays stable for shuffled, filtered, expanded and newly mounted graphs', () => {
const types = [
'person',
'organization',
'地点',
'time',
'location',
'任意类型',
'🧭',
];
const first = renderHook(useKnowledgeGraphNodeColor);
const initial = new Map(
types.map((type) => [type, first.result.current(entity(type))]),
);
for (const type of [...types].reverse()) {
expect(first.result.current(entity(type))).toBe(initial.get(type));
}
first.result.current(entity('new type'));
const second = renderHook(useKnowledgeGraphNodeColor);
for (const type of types.slice(2, 5)) {
expect(second.result.current(entity(type))).toBe(initial.get(type));
}
});
it('normalizes only whitespace and case for colors, preserving original label text', () => {
const { result } = renderHook(useKnowledgeGraphNodeColor);
expect(result.current(entity(' Person '))).toBe(
result.current(entity('person')),
);
const node = entity(' Person ', 'Original description');
expect(getKnowledgeGraphNodeLabel(node)).toBe(
' Person · Original description',
);
expect(node.type).toBe(' Person ');
expect(node.description).toBe('Original description');
});
it.each([undefined, null, '', ' ', 'other', ' OTHER ', {}, 42])(
'uses the default color for missing/other type %p',
(type) => {
const { result } = renderHook(useKnowledgeGraphNodeColor);
const defaultColor = Tokens.find(
([, name]) => name === '--graph-node-other',
)![2];
expect(result.current(entity(type))).toBe(defaultColor);
},
);
it('keeps arbitrary named types, including unknown, in the categorical palette', () => {
const { result } = renderHook(useKnowledgeGraphNodeColor);
const defaultColor = result.current(entity('other'));
for (const type of ['unknown', 'custom type', '中文类型', '__proto__']) {
expect(result.current(entity(type))).not.toBe(defaultColor);
expect(getKnowledgeGraphNodeLabel(entity(type))).toBe(type);
}
});
it('documents real hash collisions without changing or merging their labels', () => {
const { result } = renderHook(useKnowledgeGraphNodeColor);
// FNV-1a UTF-16 modulo 10 is deliberately bounded, including ordinary types.
for (const [first, second] of [
['organization', '地点'],
['time', 'location'],
]) {
expect(result.current(entity(first))).toBe(
result.current(entity(second)),
);
expect(getKnowledgeGraphNodeLabel(entity(first))).not.toBe(
getKnowledgeGraphNodeLabel(entity(second)),
);
}
expect(result.current(entity('organization'))).toBe(
Tokens.find(([, name]) => name === '--graph-node-3')![2],
);
expect(result.current(entity('time'))).toBe(
Tokens.find(([, name]) => name === '--graph-node-7')![2],
);
// Supplementary characters hash both UTF-16 code units, not one code point.
expect(result.current(entity('🧭'))).toBe(
Tokens.find(([, name]) => name === '--graph-node-5')![2],
);
});
it('reads the palette once and keeps the color callback stable on rerender', () => {
const getStyle = jest.spyOn(window, 'getComputedStyle');
const { result, rerender } = renderHook(useKnowledgeGraphNodeColor);
const callback = result.current;
const calls = getStyle.mock.calls.length;
for (let index = 0; index < 100; index++) callback(entity(`type-${index}`));
rerender();
expect(result.current).toBe(callback);
expect(getStyle.mock.calls).toHaveLength(calls);
expect(calls).toBe(1);
getStyle.mockRestore();
});
it('shows other for blank types and keeps descriptions without rewriting them', () => {
expect(getKnowledgeGraphNodeLabel(entity(''))).toBe('other');
expect(getKnowledgeGraphNodeLabel(entity(undefined, 'A & B'))).toBe(
'other · A & B',
);
expect(getKnowledgeGraphNodeLabel(entity('person'))).toBe('person');
});
});
@@ -0,0 +1,66 @@
import { render } from '@testing-library/react';
import { CompilationTemplateKind } from '@/constants/compilation';
import { RepresentationRenderer } from '../representation-renderer';
const mockGraphRender = jest.fn();
jest.mock('@/components/artifact-force-graph', () => ({
__esModule: true,
default: (props: unknown) => {
mockGraphRender(props);
return null;
},
}));
jest.mock('@/components/ui/tree-view', () => ({ TreeView: () => null }));
jest.mock('../mindmap-g6-graph', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('../timeline-x6-graph', () => ({
__esModule: true,
default: () => null,
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const Template = {
kind: CompilationTemplateKind.KnowledgeGraph,
template_id: 'test',
template_name: 'test',
entities: [
{ id: 'Ada', name: 'Ada', type: 'Person', description: 'A mathematician' },
],
relations: [],
};
describe('KnowledgeGraph styling scope', () => {
it('keeps graph data and style callbacks stable when the highlighted node changes', () => {
const { rerender } = render(<RepresentationRenderer template={Template} />);
const initial = mockGraphRender.mock.calls.at(-1)![0];
expect(initial.getNodeColor).toEqual(expect.any(Function));
expect(initial.getNodeLabel(initial.data.entities[0])).toBe(
'Person · A mathematician',
);
expect(initial.data.entities[0].description).toBe('A mathematician');
rerender(
<RepresentationRenderer template={Template} highlightNodeId="Ada" />,
);
const highlighted = mockGraphRender.mock.calls.at(-1)![0];
expect(highlighted.data).toBe(initial.data);
expect(highlighted.getNodeColor).toBe(initial.getNodeColor);
expect(highlighted.getNodeLabel).toBe(initial.getNodeLabel);
expect(highlighted.highlightNodeId).toBe('Ada');
});
it.each([
CompilationTemplateKind.SessionGraph,
CompilationTemplateKind.Empty,
])('leaves %s on the default graph colors and tooltip', (kind) => {
render(<RepresentationRenderer template={{ ...Template, kind }} />);
const props = mockGraphRender.mock.calls.at(-1)![0];
expect(props.getNodeColor).toBeUndefined();
expect(props.getNodeLabel).toBeUndefined();
});
});
@@ -36,6 +36,10 @@ import {
} from './adapters';
import MindMapG6Graph from './mindmap-g6-graph';
import TimelineX6Graph from './timeline-x6-graph';
import {
getKnowledgeGraphNodeLabel,
useKnowledgeGraphNodeColor,
} from './use-knowledge-graph-node-style';
export interface ClickableNode {
id: string;
@@ -73,6 +77,11 @@ function UnsupportedPlaceholder({ kind }: { kind: StructureTemplateKind }) {
);
}
/**
* Renders a structure template and forwards supported node selections.
* Knowledge graphs receive entity-type colors and labels; other graph views
* retain the shared defaults.
*/
export function RepresentationRenderer({
template,
onNodeClick,
@@ -80,6 +89,7 @@ export function RepresentationRenderer({
totalEntities,
returnedEntities,
}: RepresentationRendererProps) {
const getKnowledgeGraphNodeColor = useKnowledgeGraphNodeColor();
const handleTreeItemClick = useCallback(
(item: TreeDataItem | undefined) => {
if (item?.source_chunk_ids?.length) {
@@ -188,6 +198,8 @@ export function RepresentationRenderer({
data={forceGraphData}
show
getNodeId={getArtifactNodeName}
getNodeColor={getKnowledgeGraphNodeColor}
getNodeLabel={getKnowledgeGraphNodeLabel}
onNodeClick={handleArtifactNodeClick}
highlightNodeId={highlightNodeId}
totalEntities={totalEntities}
@@ -0,0 +1,51 @@
import { EntityNodeColor } from '@/components/artifact-force-graph/node-style';
import { type IArtifactGraphEntity } from '@/interfaces/database/dataset';
import { useMemo } from 'react';
const KnowledgeGraphColorCount = 10;
/**
* Returns plain tooltip text with the original entity type and description.
* Blank types display as `other`; the graph handles HTML escaping.
*/
export const getKnowledgeGraphNodeLabel = (node: IArtifactGraphEntity) => {
const type =
typeof node.type === 'string' && node.type.trim() ? node.type : 'other';
return node.description ? `${type} · ${node.description}` : type;
};
/**
* Returns a stable node-color callback backed by the root CSS palette.
* Trimmed, lowercase entity types select a color; blank and `other` types use
* the neutral token. Different types can share one of the ten palette colors.
*/
export function useKnowledgeGraphNodeColor() {
return useMemo(() => {
// These root tokens use the same colors in both themes. Resolve them once,
// outside node callbacks, so Canvas and withAlpha receive real hex colors.
const style = getComputedStyle(document.documentElement);
const otherColor =
style.getPropertyValue('--graph-node-other').trim() || EntityNodeColor;
const colors = Array.from(
{ length: KnowledgeGraphColorCount },
(_, index) =>
style.getPropertyValue(`--graph-node-${index + 1}`).trim() ||
otherColor,
);
return (node: IArtifactGraphEntity): string => {
// Normalize the color key only. Do not merge synonyms or mutate data.
const type =
typeof node.type === 'string' ? node.type.trim().toLowerCase() : '';
if (!type || type === 'other') return otherColor;
// FNV-1a over UTF-16 code units: stable across ordering and graph subsets.
// Ten slots intentionally allow collisions; the tooltip identifies type.
let hash = 2166136261;
for (let index = 0; index < type.length; index++) {
hash = Math.imul(hash ^ type.charCodeAt(index), 16777619);
}
return colors[(hash >>> 0) % KnowledgeGraphColorCount];
};
}, []);
}
+12
View File
@@ -98,6 +98,18 @@
--border-button: rgba(0, 0, 0, 0.1);
/* Regulators, parsing, switches, variables */
--accent-primary: 0 190 180;
/* Categorical graph fills shared by light and dark themes. */
--graph-node-1: #4e79a7;
--graph-node-2: #b96b1d;
--graph-node-3: #c44e52;
--graph-node-4: #268985;
--graph-node-5: #4e8638;
--graph-node-6: #9a7b00;
--graph-node-7: #9165a0;
--graph-node-8: #b55b89;
--graph-node-9: #8a6f55;
--graph-node-10: #677d8d;
--graph-node-other: #777777;
/* Output Variables Box */
--bg-accent: rgba(76, 164, 231, 0.05);