From ee142794ac19a1257d946ba46f1d698c31238e56 Mon Sep 17 00:00:00 2001 From: balibabu Date: Tue, 14 Jul 2026 15:53:37 +0800 Subject: [PATCH] Feat: Use G6 to display mind map data. (#16899) ### Summary Feat: Use G6 to display mind map data. --- .../components/mindmap-g6-graph/index.tsx | 128 ++++++++++++++++++ .../components/mindmap-g6-graph/types.ts | 13 ++ .../components/representation-renderer.tsx | 17 ++- .../chunk/representation/utils/adapters.ts | 73 +++++++++- 4 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 web/src/pages/chunk/representation/components/mindmap-g6-graph/index.tsx create mode 100644 web/src/pages/chunk/representation/components/mindmap-g6-graph/types.ts diff --git a/web/src/pages/chunk/representation/components/mindmap-g6-graph/index.tsx b/web/src/pages/chunk/representation/components/mindmap-g6-graph/index.tsx new file mode 100644 index 0000000000..926486e0ba --- /dev/null +++ b/web/src/pages/chunk/representation/components/mindmap-g6-graph/index.tsx @@ -0,0 +1,128 @@ +import { cn } from '@/lib/utils'; +import { Graph, IElementEvent, NodeEvent, treeToGraphData } from '@antv/g6'; +import { memo, useEffect, useRef } from 'react'; + +import { adaptMindMapToIndentedTree } from '../../utils/adapters'; +import { type MindMapG6GraphProps, type MindMapNodeValue } from './types'; + +interface MindMapNodeData { + name?: string; + source_chunk_ids?: string[]; +} + +function MindMapG6Graph({ + template, + show = true, + onNodeClick, +}: MindMapG6GraphProps) { + const containerRef = useRef(null); + const graphRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const styles = window.getComputedStyle(container); + const textPrimary = styles.getPropertyValue('--text-primary').trim(); + const textSecondary = styles.getPropertyValue('--text-secondary').trim(); + const accentPrimary = styles.getPropertyValue('--accent-primary').trim(); + + let graph: Graph | null = null; + let observer: ResizeObserver | null = null; + + const createGraph = () => { + if (graph) return; + + const { width, height } = container.getBoundingClientRect(); + if (width === 0 || height === 0) return; + + graph = new Graph({ + container, + width, + height, + autoFit: 'view', + data: treeToGraphData(adaptMindMapToIndentedTree(template)), + node: { + style: { + labelText: (datum) => + (datum.data as MindMapNodeData | undefined)?.name ?? datum.id, + labelFill: textPrimary ? `rgb(${textPrimary})` : '#262626', + fill: accentPrimary ? `rgb(${accentPrimary})` : '#00beb4', + labelBackground: true, + labelBackgroundFill: 'transparent', + labelPlacement: 'top', + }, + }, + edge: { + type: 'cubic-horizontal', + style: { + stroke: textSecondary ? `rgb(${textSecondary})` : '#75787a', + }, + }, + layout: { + type: 'mindmap', + direction: 'H', + preLayout: false, + getHeight: () => 32, + getWidth: (node: { id: string; data?: MindMapNodeData }) => + Math.max(120, String(node.data?.name ?? node.id).length * 7), + getVGap: () => 10, + getHGap: () => 80, + }, + behaviors: ['collapse-expand', 'drag-canvas', 'zoom-canvas'], + }); + + const handleNodeClick = (evt: IElementEvent) => { + const nodeId = evt.target.id as string; + const nodeData = graph!.getNodeData(nodeId); + const data = nodeData.data as MindMapNodeData | undefined; + if (data?.source_chunk_ids?.length) { + const payload: MindMapNodeValue = { + id: nodeId, + name: data.name || nodeId, + source_chunk_ids: data.source_chunk_ids, + }; + onNodeClick?.(payload); + } + }; + + graph.on(NodeEvent.CLICK, handleNodeClick); + graphRef.current = graph; + + void graph.render(); + }; + + observer = new ResizeObserver(() => { + if (!graph) { + createGraph(); + return; + } + + const { width, height } = container.getBoundingClientRect(); + if (width > 0 && height > 0) { + graph.resize(width, height); + } + }); + + observer.observe(container); + createGraph(); + + return () => { + observer?.disconnect(); + graph?.destroy(); + graphRef.current = null; + }; + }, [template, onNodeClick]); + + return ( +
+ ); +} + +const MemoizedMindMapG6Graph = memo(MindMapG6Graph) as typeof MindMapG6Graph; + +export { MemoizedMindMapG6Graph as MindMapG6Graph }; +export default MemoizedMindMapG6Graph; diff --git a/web/src/pages/chunk/representation/components/mindmap-g6-graph/types.ts b/web/src/pages/chunk/representation/components/mindmap-g6-graph/types.ts new file mode 100644 index 0000000000..6a48de778d --- /dev/null +++ b/web/src/pages/chunk/representation/components/mindmap-g6-graph/types.ts @@ -0,0 +1,13 @@ +import { type IStructureGraphTemplate } from '@/interfaces/database/document-structure'; + +export interface MindMapNodeValue { + id: string; + name: string; + source_chunk_ids?: string[]; +} + +export interface MindMapG6GraphProps { + template: IStructureGraphTemplate; + show?: boolean; + onNodeClick?: (node: MindMapNodeValue) => void; +} diff --git a/web/src/pages/chunk/representation/components/representation-renderer.tsx b/web/src/pages/chunk/representation/components/representation-renderer.tsx index 4b16125fed..2aff5143fd 100644 --- a/web/src/pages/chunk/representation/components/representation-renderer.tsx +++ b/web/src/pages/chunk/representation/components/representation-renderer.tsx @@ -15,6 +15,7 @@ import { adaptTreeToTreeData, filterTreeDataByKeyword, } from '../utils/adapters'; +import MindMapG6Graph from './mindmap-g6-graph'; import TimelineX6Graph from './timeline-x6-graph'; export interface ClickableNode { @@ -82,6 +83,15 @@ export function RepresentationRenderer({ [onNodeClick], ); + const handleMindMapNodeClick = useCallback( + (node: ClickableNode) => { + if (node.source_chunk_ids?.length) { + onNodeClick?.(node); + } + }, + [onNodeClick], + ); + const getArtifactNodeName = useCallback( (node: IArtifactGraphEntity) => node.name, [], @@ -153,11 +163,10 @@ export function RepresentationRenderer({ case CompilationTemplateKind.MindMap: return (
-
); diff --git a/web/src/pages/chunk/representation/utils/adapters.ts b/web/src/pages/chunk/representation/utils/adapters.ts index 0e8866b904..09a90aff40 100644 --- a/web/src/pages/chunk/representation/utils/adapters.ts +++ b/web/src/pages/chunk/representation/utils/adapters.ts @@ -64,6 +64,65 @@ function buildTreeDataItems( .filter((item): item is TreeDataItem => item !== undefined); } +function buildUniqueTreeDataItems( + entities: IStructureGraphEntity[], + relations: IStructureGraphRelation[], + relationTypes: string[], +): TreeDataItem[] { + const normalized = [ + ...new Map( + entities + .map(normalizeEntity) + .filter((entity) => entity.id) + .map((entity) => [entity.id, entity]), + ).values(), + ]; + const map = new Map( + normalized.map((entity) => [ + entity.id, + { + id: entity.id, + name: entity.name, + source_chunk_ids: entity.source_chunk_ids, + }, + ]), + ); + const childIds = new Set(); + const parentMap = new Map(); + + for (const relation of relations) { + if (!relationTypes.includes(relation.type ?? '')) continue; + + const parent = map.get(relation.from); + const child = map.get(relation.to); + if (!parent || !child) continue; + if (childIds.has(child.id)) continue; + + // Avoid cycles: do not attach a node under one of its descendants. + let cursor = relation.from; + let wouldCycle = false; + while (parentMap.has(cursor)) { + const ancestor = parentMap.get(cursor)!; + if (ancestor === child.id) { + wouldCycle = true; + break; + } + cursor = ancestor; + } + if (wouldCycle) continue; + + childIds.add(child.id); + parentMap.set(child.id, relation.from); + parent.children = parent.children ?? []; + parent.children.push(child); + } + + return normalized + .filter((entity) => !childIds.has(entity.id)) + .map((entity) => map.get(entity.id)) + .filter((item): item is TreeDataItem => item !== undefined); +} + export function adaptPageIndexToTreeData( template: IStructureGraphTemplate, ): TreeDataItem[] { @@ -143,7 +202,10 @@ export function adaptKnowledgeGraphToForceGraph( function treeDataItemToG6TreeData(item: TreeDataItem): TreeData { const node: TreeData = { id: item.id, - source_chunk_ids: item.source_chunk_ids, + data: { + name: item.name, + source_chunk_ids: item.source_chunk_ids, + }, }; if (item.children && item.children.length > 0) { @@ -242,10 +304,11 @@ export function adaptTimelineToX6Data(template: IStructureGraphTemplate): { export function adaptMindMapToIndentedTree( template: IStructureGraphTemplate, ): TreeData { - const roots = buildTreeDataItems(template.entities, template.relations, [ - 'has_branch', - 'has_sub_branch', - ]); + const roots = buildUniqueTreeDataItems( + template.entities, + template.relations, + ['has_branch', 'has_sub_branch'], + ); const g6Roots = roots.map(treeDataItemToG6TreeData);