import React, { useMemo, useCallback } from 'react'; import ReactFlow, { Controls, MiniMap, Background, useNodesState, useEdgesState, addEdge, ConnectionLineType, Panel, Handle, Position, } from 'reactflow'; import 'reactflow/dist/style.css'; import { Braces, List, Type, Hash, ToggleLeft, FileText, Zap, Copy, Eye, Code, Maximize, Minimize, Sparkles, Settings, ChevronDown, ChevronUp } from 'lucide-react'; // Custom node component for different data types const CustomNode = ({ data, selected }) => { const [renderHtml, setRenderHtml] = React.useState(true); // Check if value contains HTML const isHtmlContent = data.value && typeof data.value === 'string' && (data.value.includes('<') && data.value.includes('>')); // Copy value to clipboard const copyValue = async () => { if (data.value) { try { await navigator.clipboard.writeText(String(data.value)); } catch (err) { console.error('Failed to copy:', err); } } }; const getIcon = () => { switch (data.type) { case 'object': return ; case 'array': return ; case 'string': return ; case 'number': return ; case 'boolean': return ; case 'null': return ; default: return ; } }; const getNodeColor = () => { switch (data.type) { case 'object': return 'bg-blue-100 border-blue-300 text-blue-800 dark:bg-blue-900/20 dark:border-blue-700 dark:text-blue-300'; case 'array': return 'bg-green-100 border-green-300 text-green-800 dark:bg-green-900/20 dark:border-green-700 dark:text-green-300'; case 'string': return 'bg-purple-100 border-purple-300 text-purple-800 dark:bg-purple-900/20 dark:border-purple-700 dark:text-purple-300'; case 'number': return 'bg-orange-100 border-orange-300 text-orange-800 dark:bg-orange-900/20 dark:border-orange-700 dark:text-orange-300'; case 'boolean': return 'bg-yellow-100 border-yellow-300 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-700 dark:text-yellow-300'; case 'null': return 'bg-gray-100 border-gray-300 text-gray-800 dark:bg-gray-900/20 dark:border-gray-700 dark:text-gray-300'; default: return 'bg-gray-100 border-gray-300 text-gray-800 dark:bg-gray-900/20 dark:border-gray-700 dark:text-gray-300'; } }; return (
{/* Input handle (left side) */} {/* Top-right controls - HTML render toggle only */} {isHtmlContent && (
)}
{getIcon()}
{/* Copy button positioned below icon */} {data.value && ( )}
{data.label}
{data.value !== undefined && (
{isHtmlContent && renderHtml ? (
) : (
{String(data.value)}
)}
)} {data.count !== undefined && (
{data.count} items
)}
{/* Output handle (right side) */}
); }; const nodeTypes = { custom: CustomNode, }; const MindmapView = React.memo(({ data }) => { // User preferences state const [edgeType, setEdgeType] = React.useState('default'); // bezier as default const [layoutCompact, setLayoutCompact] = React.useState(true); const [edgeColor, setEdgeColor] = React.useState('#9ca3af'); // gray-400 default const [isFullscreen, setIsFullscreen] = React.useState(false); const [snapToGrid, setSnapToGrid] = React.useState(true); const [activePanel, setActivePanel] = React.useState(null); // 'controls', 'legend', or null // Convert JSON data to React Flow nodes and edges const { nodes: initialNodes, edges: initialEdges } = useMemo(() => { const nodes = []; const edges = []; let nodeId = 0; const nodeInfo = new Map(); // Store node info for positioning // First pass: create nodes and collect structure info const createNodeStructure = (value, key, parentId = null, level = 0) => { const currentId = `node-${nodeId++}`; // Determine node type and properties let nodeType, nodeLabel, nodeValue, nodeCount; if (value === null) { nodeType = 'null'; nodeLabel = key || 'null'; nodeValue = 'null'; } else if (typeof value === 'boolean') { nodeType = 'boolean'; nodeLabel = key || 'boolean'; nodeValue = String(value); } else if (typeof value === 'number') { nodeType = 'number'; nodeLabel = key || 'number'; nodeValue = String(value); } else if (typeof value === 'string') { nodeType = 'string'; nodeLabel = key || 'string'; nodeValue = value; // No truncation } else if (Array.isArray(value)) { nodeType = 'array'; nodeLabel = key || 'Array'; nodeCount = value.length; } else if (typeof value === 'object') { nodeType = 'object'; nodeLabel = key || 'Object'; nodeCount = Object.keys(value).length; } // Store node info nodeInfo.set(currentId, { id: currentId, parentId, level, type: nodeType, label: nodeLabel, value: nodeValue, count: nodeCount, children: [] }); // Add to parent's children if (parentId && nodeInfo.has(parentId)) { nodeInfo.get(parentId).children.push(currentId); } // Create edge from parent if (parentId) { edges.push({ id: `edge-${parentId}-${currentId}`, source: parentId, target: currentId, type: edgeType, style: { stroke: edgeColor, strokeWidth: 2, }, markerEnd: { type: 'arrowclosed', color: edgeColor, }, }); } // Recursively create child nodes if (Array.isArray(value)) { value.forEach((item, idx) => { createNodeStructure(item, `[${idx}]`, currentId, level + 1); }); } else if (typeof value === 'object' && value !== null) { Object.entries(value).forEach(([childKey, childValue]) => { createNodeStructure(childValue, childKey, currentId, level + 1); }); } return currentId; }; // Estimate node height based on content const estimateNodeHeight = (node) => { const baseHeight = 40; // Base node height const lineHeight = 16; // Approximate line height if (node.value && typeof node.value === 'string') { // Estimate lines needed for text wrapping (assuming ~30 chars per line) const estimatedLines = Math.ceil(node.value.length / 30); return baseHeight + (estimatedLines - 1) * lineHeight; } return baseHeight; }; // Second pass: calculate positions with vertical centering and collision avoidance const calculatePositions = (nodeId, startY = 0) => { const node = nodeInfo.get(nodeId); if (!node) return startY; const baseSpacing = layoutCompact ? 120 : 180; const nodeHeight = estimateNodeHeight(node); const minSpacing = Math.max(baseSpacing, nodeHeight + 20); if (node.children.length === 0) { // Leaf node const x = node.level * (layoutCompact ? 250 : 350); nodes.push({ id: nodeId, type: 'custom', position: { x, y: startY }, data: { label: node.label, type: node.type, value: node.value, count: node.count, }, }); return startY + minSpacing; } else { // Parent node - calculate children positions first let childStartY = startY; let childEndY = startY; const childPositions = []; node.children.forEach(childId => { const childNode = nodeInfo.get(childId); const childHeight = estimateNodeHeight(childNode); const childSpacing = Math.max(baseSpacing, childHeight + 20); childEndY = calculatePositions(childId, childStartY); childPositions.push({ start: childStartY, end: childEndY - childSpacing }); childStartY = childEndY; }); // Center parent between first and last child, but ensure minimum spacing let centerY; if (childPositions.length > 0) { const firstChildY = childPositions[0].start; const lastChildY = childPositions[childPositions.length - 1].end; centerY = (firstChildY + lastChildY) / 2; // Ensure parent doesn't overlap with any child const parentHeight = nodeHeight; const parentTop = centerY - parentHeight / 2; const parentBottom = centerY + parentHeight / 2; // Check for overlaps and adjust if needed for (const childPos of childPositions) { if (parentBottom > childPos.start && parentTop < childPos.end) { // Overlap detected, move parent up centerY = childPos.start - parentHeight / 2 - 10; break; } } } else { centerY = startY; } const x = node.level * (layoutCompact ? 250 : 350); nodes.push({ id: nodeId, type: 'custom', position: { x, y: centerY }, data: { label: node.label, type: node.type, value: node.value, count: node.count, }, }); return childEndY; } }; if (data && Object.keys(data).length > 0) { const rootId = createNodeStructure(data, 'root', null, 0); calculatePositions(rootId, 0); } else { // Empty state nodes.push({ id: 'empty', type: 'custom', position: { x: 100, y: 100 }, data: { label: 'No Data', type: 'null', value: 'Add data to see mindmap', }, }); } return { nodes, edges }; }, [data, edgeType, layoutCompact, edgeColor]); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); // React Flow instance ref for fitView const reactFlowInstance = React.useRef(null); // Memoize the tidy up function to prevent unnecessary re-renders const tidyUpNodes = useCallback(() => { setTimeout(() => { setNodes(initialNodes); }, 0); }, [initialNodes, setNodes]); // Accordion panel toggles const toggleControls = () => { setActivePanel(activePanel === 'controls' ? null : 'controls'); }; const toggleLegend = () => { setActivePanel(activePanel === 'legend' ? null : 'legend'); }; // Toggle fullscreen with fitView const toggleFullscreen = () => { setIsFullscreen(!isFullscreen); // Trigger fitView after a short delay to allow DOM to update setTimeout(() => { if (reactFlowInstance.current) { reactFlowInstance.current.fitView({ padding: 0.1, minZoom: 0.5, maxZoom: 1.5, duration: 300 }); } }, 100); }; const onConnect = useCallback( (params) => setEdges((eds) => addEdge(params, eds)), [setEdges] ); // Update nodes when data changes with debouncing to prevent ResizeObserver errors React.useEffect(() => { const timeoutId = setTimeout(() => { setNodes(initialNodes); setEdges(initialEdges); }, 0); return () => clearTimeout(timeoutId); }, [initialNodes, initialEdges, setNodes, setEdges]); // Suppress ResizeObserver errors React.useEffect(() => { const handleResizeObserverError = (e) => { if (e.message === 'ResizeObserver loop completed with undelivered notifications.') { e.stopImmediatePropagation(); } }; window.addEventListener('error', handleResizeObserverError); return () => window.removeEventListener('error', handleResizeObserverError); }, []); return (
{ reactFlowInstance.current = instance; }} nodeTypes={nodeTypes} connectionLineType={ConnectionLineType.SmoothStep} fitView fitViewOptions={{ padding: 0.1, minZoom: 0.5, maxZoom: 1.5, }} minZoom={0.3} maxZoom={2} snapToGrid={snapToGrid} snapGrid={[20, 20]} proOptions={{ hideAttribution: true }} defaultEdgeOptions={{ type: edgeType, style: { stroke: edgeColor, strokeWidth: 2 }, markerEnd: { type: 'arrowclosed', color: edgeColor } }} > { switch (node.data.type) { case 'object': return '#3b82f6'; case 'array': return '#10b981'; case 'string': return '#8b5cf6'; case 'number': return '#f59e0b'; case 'boolean': return '#eab308'; case 'null': return '#6b7280'; default: return '#6b7280'; } }} maskColor="rgb(240, 240, 240, 0.6)" /> {/* Top Right Accordion Panel Stack */}
{/* Controls Toggle Button */}
{/* Controls Panel Content - Accordion Style */} {activePanel === 'controls' && (
setLayoutCompact(e.target.checked)} className="text-xs" />
setSnapToGrid(e.target.checked)} className="text-xs" />
)} {/* Legend Toggle Button */}
{/* Legend Panel Content - Accordion Style */} {activePanel === 'legend' && (
Object
Array
String
Number
Boolean
)} {/* Tidy Up Button */}
{/* Fullscreen Button */}
); }); export default MindmapView;