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' && (
)}
{/* Legend Toggle Button */}
{/* Legend Panel Content - Accordion Style */}
{activePanel === 'legend' && (
)}
{/* Tidy Up Button */}
{/* Fullscreen Button */}
);
});
export default MindmapView;