mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-18 21:57:27 +08:00
Feat: Use G6 to display mind map data. (#16899)
### Summary Feat: Use G6 to display mind map data.
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user