import { SkeletonCard } from '@/components/skeleton-card'; import { cn } from '@/lib/utils'; import { Graph, IElementEvent, NodeEvent, treeToGraphData } from '@antv/g6'; import { memo, useEffect, useRef, useState } from 'react'; import { adaptMindMapToIndentedTree } from '../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); const [loading, setLoading] = useState(true); const [graphData, setGraphData] = useState | null>(null); // Defer heavy data transformation to avoid blocking the render phase. useEffect(() => { setLoading(true); setGraphData(null); const rafId = requestAnimationFrame(() => { setGraphData(treeToGraphData(adaptMindMapToIndentedTree(template))); }); return () => { cancelAnimationFrame(rafId); }; }, [template]); useEffect(() => { const container = containerRef.current; if (!container || !graphData) 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', animation: false, data: graphData, 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: () => 120, 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().then(() => { setLoading(false); }); }; // Defer graph creation so the browser paints the skeleton before the // heavy G6 layout + render starts. const rafId = requestAnimationFrame(() => { 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 () => { cancelAnimationFrame(rafId); observer?.disconnect(); graph?.destroy(); graphRef.current = null; }; }, [graphData, onNodeClick]); return (
{loading && (
)}
); } const MemoizedMindMapG6Graph = memo(MindMapG6Graph) as typeof MindMapG6Graph; export { MemoizedMindMapG6Graph as MindMapG6Graph }; export default MemoizedMindMapG6Graph;