From 30359a7e88833d26b6db659a276470cea67cf39c Mon Sep 17 00:00:00 2001 From: cht <56440889+okht@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:02:37 +0800 Subject: [PATCH] fix(web): restore entity type colors in knowledge graphs (#21443) --- .../__tests__/index.test.tsx | 88 +++++++++- .../components/artifact-force-graph/index.tsx | 19 ++- .../components/artifact-force-graph/types.ts | 2 + .../knowledge-graph-node-style.test.ts | 151 ++++++++++++++++++ .../representation-renderer.test.tsx | 66 ++++++++ .../representation-renderer.tsx | 12 ++ .../use-knowledge-graph-node-style.ts | 51 ++++++ web/tailwind.css | 12 ++ 8 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 web/src/components/structure-graph/__tests__/knowledge-graph-node-style.test.ts create mode 100644 web/src/components/structure-graph/__tests__/representation-renderer.test.tsx create mode 100644 web/src/components/structure-graph/use-knowledge-graph-node-style.ts diff --git a/web/src/components/artifact-force-graph/__tests__/index.test.tsx b/web/src/components/artifact-force-graph/__tests__/index.test.tsx index 285b69c3ea..b8e2be6aff 100644 --- a/web/src/components/artifact-force-graph/__tests__/index.test.tsx +++ b/web/src/components/artifact-force-graph/__tests__/index.test.tsx @@ -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('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 = {}, +) { 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 = ' <type>'; + const description = ' & 齿轮'; + 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( + , + ); + const initial = mockGraphRender.mock.calls.at(-1)![0]; + const [a, b, c] = initial.graphData.nodes; + act(() => initial.onEngineStop()); + expect(mockZoomToFit).toHaveBeenCalledTimes(1); + + rerender( + , + ); + 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'); + }); }); diff --git a/web/src/components/artifact-force-graph/index.tsx b/web/src/components/artifact-force-graph/index.tsx index c3b36b4478..fc7b861291 100644 --- a/web/src/components/artifact-force-graph/index.tsx +++ b/web/src/components/artifact-force-graph/index.tsx @@ -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({ data, show = true, @@ -49,6 +57,7 @@ function ArtifactForceGraph({ ) => TNodeValue, getNodeId = defaultGetNodeId, getNodeColor = defaultGetNodeColor, + getNodeLabel = defaultGetNodeLabel, getNodeRadius = defaultGetNodeRadius, highlightNodeId, totalEntities, @@ -113,10 +122,10 @@ function ArtifactForceGraph({ [], ); - // 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({ nodeColor={nodeColor} nodeVal={nodeVal} cooldownTicks={100} - nodeLabel={getNodeLabel} + nodeLabel={getEscapedNodeLabel} autoPauseRedraw={false} onEngineStop={handleEngineStop} onNodeClick={handleNodeClick} diff --git a/web/src/components/artifact-force-graph/types.ts b/web/src/components/artifact-force-graph/types.ts index ede2787444..fc8b0656ac 100644 --- a/web/src/components/artifact-force-graph/types.ts +++ b/web/src/components/artifact-force-graph/types.ts @@ -40,6 +40,8 @@ export interface ArtifactForceGraphProps { 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, diff --git a/web/src/components/structure-graph/__tests__/knowledge-graph-node-style.test.ts b/web/src/components/structure-graph/__tests__/knowledge-graph-node-style.test.ts new file mode 100644 index 0000000000..bb8d6377cd --- /dev/null +++ b/web/src/components/structure-graph/__tests__/knowledge-graph-node-style.test.ts @@ -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'); + }); +}); diff --git a/web/src/components/structure-graph/__tests__/representation-renderer.test.tsx b/web/src/components/structure-graph/__tests__/representation-renderer.test.tsx new file mode 100644 index 0000000000..9243f2fa87 --- /dev/null +++ b/web/src/components/structure-graph/__tests__/representation-renderer.test.tsx @@ -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(); + 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( + , + ); + 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(); + const props = mockGraphRender.mock.calls.at(-1)![0]; + expect(props.getNodeColor).toBeUndefined(); + expect(props.getNodeLabel).toBeUndefined(); + }); +}); diff --git a/web/src/components/structure-graph/representation-renderer.tsx b/web/src/components/structure-graph/representation-renderer.tsx index 6a6f0a90c8..62ac6b72a0 100644 --- a/web/src/components/structure-graph/representation-renderer.tsx +++ b/web/src/components/structure-graph/representation-renderer.tsx @@ -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} diff --git a/web/src/components/structure-graph/use-knowledge-graph-node-style.ts b/web/src/components/structure-graph/use-knowledge-graph-node-style.ts new file mode 100644 index 0000000000..27f45e5840 --- /dev/null +++ b/web/src/components/structure-graph/use-knowledge-graph-node-style.ts @@ -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]; + }; + }, []); +} diff --git a/web/tailwind.css b/web/tailwind.css index a00a13cd27..7329b6eba3 100644 --- a/web/tailwind.css +++ b/web/tailwind.css @@ -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);