Feat: Use G6 to display mind map data. (#16899)

### Summary

Feat: Use G6 to display mind map data.
This commit is contained in:
balibabu
2026-07-14 15:53:37 +08:00
committed by GitHub
parent 3dd454190c
commit ee142794ac
4 changed files with 222 additions and 9 deletions

View File

@@ -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<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(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<IElementEvent>(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 (
<div
ref={containerRef}
className={cn('flex-1 min-h-0 h-full', !show && 'hidden')}
/>
);
}
const MemoizedMindMapG6Graph = memo(MindMapG6Graph) as typeof MindMapG6Graph;
export { MemoizedMindMapG6Graph as MindMapG6Graph };
export default MemoizedMindMapG6Graph;

View File

@@ -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;
}

View File

@@ -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 (
<div className="mt-6 flex-1 min-h-0">
<ArtifactForceGraph
data={adaptKnowledgeGraphToForceGraph(template)}
<MindMapG6Graph
template={template}
show
getNodeId={getArtifactNodeName}
onNodeClick={handleArtifactNodeClick}
onNodeClick={handleMindMapNodeClick}
/>
</div>
);

View File

@@ -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<string, TreeDataItem>(
normalized.map((entity) => [
entity.id,
{
id: entity.id,
name: entity.name,
source_chunk_ids: entity.source_chunk_ids,
},
]),
);
const childIds = new Set<string>();
const parentMap = new Map<string, string>();
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);