/** * ExpandableChart - Wrapper component for charts that can be expanded to full-screen * * Features: * - Expand button to open chart in full-screen modal * - Interactive zoom and pan controls in expanded view * - Download as PNG/SVG * - Reset view button */ import React, { useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; interface ExpandableChartProps { title: string; subtitle?: string; children: React.ReactNode; // Render function for the expanded version with larger dimensions renderExpanded?: (width: number, height: number) => React.ReactNode; className?: string; } export function ExpandableChart({ title, subtitle, children, renderExpanded, className = '' }: ExpandableChartProps) { const [isExpanded, setIsExpanded] = useState(false); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const containerRef = useRef(null); const expandedContentRef = useRef(null); const handleExpand = () => { setIsExpanded(true); setZoom(1); setPan({ x: 0, y: 0 }); }; const handleClose = () => { setIsExpanded(false); }; const handleZoomIn = () => { setZoom(prev => Math.min(prev * 1.25, 5)); }; const handleZoomOut = () => { setZoom(prev => Math.max(prev / 1.25, 0.25)); }; const handleResetView = () => { setZoom(1); setPan({ x: 0, y: 0 }); }; const handleWheel = useCallback((e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; setZoom(prev => Math.min(Math.max(prev * delta, 0.25), 5)); }, []); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button === 0) { // Left click setIsPanning(true); setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); } }, [pan]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (isPanning) { setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); } }, [isPanning, panStart]); const handleMouseUp = useCallback(() => { setIsPanning(false); }, []); const handleDownload = useCallback((format: 'png' | 'svg') => { const content = expandedContentRef.current; if (!content) return; const svg = content.querySelector('svg'); if (!svg) { alert('No SVG found to download'); return; } if (format === 'svg') { const svgData = new XMLSerializer().serializeToString(svg); const blob = new Blob([svgData], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${title.replace(/\s+/g, '_')}.svg`; a.click(); URL.revokeObjectURL(url); } else { // PNG export const svgData = new XMLSerializer().serializeToString(svg); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); // Get actual SVG dimensions const svgWidth = svg.width.baseVal.value || 800; const svgHeight = svg.height.baseVal.value || 600; canvas.width = svgWidth * 2; // 2x for better quality canvas.height = svgHeight * 2; img.onload = () => { if (ctx) { ctx.fillStyle = 'white'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const pngUrl = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = pngUrl; a.download = `${title.replace(/\s+/g, '_')}.png`; a.click(); } }; img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); } }, [title]); // Calculate expanded dimensions const expandedWidth = typeof window !== 'undefined' ? window.innerWidth - 80 : 1200; const expandedHeight = typeof window !== 'undefined' ? window.innerHeight - 200 : 800; const modal = isExpanded ? createPortal(
e.stopPropagation()} style={{ width: expandedWidth + 40, height: expandedHeight + 160 }} > {/* Header */}

{title}

{subtitle &&

{subtitle}

}
{/* Zoom controls */}
{Math.round(zoom * 100)}%
{/* Reset button */} {/* Download dropdown */}
{/* Close button */}
{/* Chart content with zoom/pan */}
{renderExpanded ? renderExpanded(expandedWidth, expandedHeight) : children}
{/* Footer with instructions */}
🖱️ Scroll to zoom ✋ Drag to pan ⌨️ Press Esc to close
, document.body ) : null; // Handle Esc key React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && isExpanded) { handleClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isExpanded]); return (
{/* Expand button */} {/* Original chart content */} {children} {/* Modal portal */} {modal}
); }