| import React, { useState, useCallback, useEffect } from 'react';
|
| import ReactFlow, {
|
| Background,
|
| Controls,
|
| MiniMap,
|
| useNodesState,
|
| useEdgesState,
|
| addEdge,
|
| MarkerType
|
| } from 'reactflow';
|
| import 'reactflow/dist/style.css';
|
| import { X, Network, Maximize2, Sparkles } from 'lucide-react';
|
|
|
| const MindMapModal = ({ isOpen, onClose, data, onExplainNode }) => {
|
| if (!isOpen) return null;
|
|
|
| const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
| const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
| const [processingNode, setProcessingNode] = useState(null);
|
|
|
|
|
| useEffect(() => {
|
| if (!data) return;
|
| try {
|
| const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
|
|
|
|
|
| const rootNode = {
|
| id: 'root',
|
| type: 'default',
|
| data: {
|
| label: parsedData.label || "Main Topic",
|
| expandable: true,
|
| expanded: true
|
| },
|
| position: { x: 0, y: 0 },
|
| style: {
|
| background: '#fff',
|
| border: '2px solid #3b82f6',
|
| borderRadius: '8px',
|
| padding: '12px',
|
| width: 180,
|
| fontSize: '14px',
|
| fontWeight: '600',
|
| color: '#1e293b',
|
| boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'
|
| }
|
| };
|
|
|
| const initialNodes = [rootNode];
|
| const initialEdges = [];
|
|
|
|
|
| if (parsedData.children) {
|
| parsedData.children.forEach((child, i) => {
|
| const childId = child.id || `child-${i}`;
|
| initialNodes.push({
|
| id: childId,
|
| type: 'default',
|
| data: {
|
| label: child.label,
|
| expandable: child.expandable !== false,
|
| expanded: false,
|
| has_children: child.has_children
|
| },
|
| position: { x: 300, y: (i - (parsedData.children.length - 1) / 2) * 150 },
|
| style: {
|
| background: '#fff',
|
| border: '1px solid #e2e8f0',
|
| borderRadius: '8px',
|
| padding: '10px',
|
| width: 160,
|
| fontSize: '12px',
|
| fontWeight: '500',
|
| color: '#334155',
|
| boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'
|
| }
|
| });
|
|
|
| initialEdges.push({
|
| id: `root-${childId}`,
|
| source: 'root',
|
| target: childId,
|
| type: 'smoothstep',
|
| markerEnd: { type: MarkerType.ArrowClosed, color: '#94a3b8' },
|
| style: { stroke: '#94a3b8', strokeWidth: 1.5 }
|
| });
|
| });
|
| }
|
|
|
| setNodes(initialNodes);
|
| setEdges(initialEdges);
|
|
|
| } catch (e) {
|
| console.error("Failed to parse mind map data", e);
|
| }
|
| }, [data, setNodes, setEdges]);
|
|
|
| const onNodeClick = useCallback(async (event, node) => {
|
|
|
| if (processingNode) return;
|
|
|
| const isLeaf = !node.data.expandable && !node.data.has_children;
|
| const isExpanded = node.data.expanded;
|
|
|
|
|
| if (isLeaf) {
|
| onExplainNode(node.data.label);
|
| return;
|
| }
|
|
|
|
|
| if (!isExpanded && (node.data.expandable || node.data.has_children)) {
|
| setProcessingNode(node.id);
|
|
|
|
|
| setNodes(nds => nds.map(n => {
|
| if (n.id === node.id) {
|
| return { ...n, style: { ...n.style, borderColor: '#f59e0b' }, data: { ...n.data, label: 'Loading...' } };
|
| }
|
| return n;
|
| }));
|
|
|
| try {
|
|
|
| const { expandMindMapNode } = await import('../api');
|
| const result = await expandMindMapNode(node.data.label);
|
|
|
|
|
| const childrenData = typeof result.answer === 'string' ? JSON.parse(result.answer).children : result.answer.children;
|
|
|
| if (!childrenData || childrenData.length === 0) {
|
|
|
| onExplainNode(node.data.label);
|
| setNodes(nds => nds.map(n => n.id === node.id ? { ...n, data: { ...n.data, label: node.data.label, expandable: false } } : n));
|
| return;
|
| }
|
|
|
|
|
| const newNodes = [];
|
| const newEdges = [];
|
| const parentX = node.position.x;
|
| const parentY = node.position.y;
|
|
|
| childrenData.forEach((child, i) => {
|
| const childId = child.id || `${node.id}-child-${i}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
| newNodes.push({
|
| id: childId,
|
| type: 'default',
|
| data: {
|
| label: child.label,
|
| expandable: child.has_children !== false,
|
| expanded: false,
|
| has_children: child.has_children
|
| },
|
|
|
| position: {
|
| x: parentX + 300,
|
| y: parentY + (i - (childrenData.length - 1) / 2) * 120
|
| },
|
| style: {
|
| background: '#fff',
|
| border: '1px solid #e2e8f0',
|
| borderRadius: '8px',
|
| padding: '10px',
|
| width: 160,
|
| fontSize: '12px',
|
| fontWeight: '500',
|
| color: '#334155',
|
| boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'
|
| }
|
| });
|
|
|
| newEdges.push({
|
| id: `${node.id}-${childId}`,
|
| source: node.id,
|
| target: childId,
|
| type: 'smoothstep',
|
| markerEnd: { type: MarkerType.ArrowClosed, color: '#94a3b8' },
|
| style: { stroke: '#94a3b8', strokeWidth: 1.5 }
|
| });
|
| });
|
|
|
|
|
| setNodes(nds => nds.map(n => {
|
| if (n.id === node.id) {
|
| return {
|
| ...n,
|
| style: { ...n.style, borderColor: '#3b82f6' },
|
| data: { ...n.data, label: node.data.label, expanded: true }
|
| };
|
| }
|
| return n;
|
| }).concat(newNodes));
|
|
|
| setEdges(eds => eds.concat(newEdges));
|
|
|
| } catch (error) {
|
| console.error("Error expanding node:", error);
|
|
|
| setNodes(nds => nds.map(n => n.id === node.id ? { ...n, style: { ...n.style, borderColor: '#ef4444' }, data: { ...n.data, label: node.data.label } } : n));
|
| } finally {
|
| setProcessingNode(null);
|
| }
|
| } else {
|
|
|
|
|
| onExplainNode(node.data.label);
|
| }
|
|
|
| }, [nodes, processingNode, setNodes, setEdges, onExplainNode]);
|
|
|
| return (
|
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
| <div className="bg-white w-full h-full rounded-2xl shadow-2xl flex flex-col overflow-hidden relative border border-white/20">
|
| {/* Header */}
|
| <div className="bg-white border-b border-slate-100 p-4 flex justify-between items-center z-10 shadow-sm shrink-0">
|
| <div className="flex items-center gap-2 text-slate-700">
|
| <div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
|
| <Network size={20} />
|
| </div>
|
| <div>
|
| <h2 className="font-bold text-lg leading-tight">Interactive Mind Map</h2>
|
| <p className="text-xs text-slate-500">
|
| {processingNode ? "Expanding concept..." : "Click to expand • Leaf nodes explain concept"}
|
| </p>
|
| </div>
|
| </div>
|
| <button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full text-slate-500 transition-colors">
|
| <X size={24} />
|
| </button>
|
| </div>
|
|
|
| {/* Canvas */}
|
| <div className="flex-1 bg-slate-50 relative">
|
| <ReactFlow
|
| nodes={nodes}
|
| edges={edges}
|
| onNodesChange={onNodesChange}
|
| onEdgesChange={onEdgesChange}
|
| onNodeClick={onNodeClick}
|
| fitView
|
| attributionPosition="bottom-right"
|
| minZoom={0.1}
|
| maxZoom={2}
|
| >
|
| <Background color="#cbd5e1" gap={20} size={1} />
|
| <Controls className="bg-white border border-slate-200 shadow-sm text-slate-600" />
|
| <MiniMap
|
| className="border border-slate-200 shadow-sm rounded-lg overflow-hidden"
|
| nodeColor={n => n.type === 'input' ? '#3b82f6' : '#fff'}
|
| />
|
| </ReactFlow>
|
|
|
| {/* Loading Overlay if needed, or just rely on node state */}
|
| </div>
|
| </div>
|
| </div>
|
| );
|
| };
|
|
|
| export default MindMapModal;
|
|
|