300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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<HTMLDivElement>(null);
|
|||
|
|
const expandedContentRef = useRef<HTMLDivElement>(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(
|
|||
|
|
<div
|
|||
|
|
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center"
|
|||
|
|
onClick={handleClose}
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
className="bg-white rounded-xl shadow-2xl max-w-[95vw] max-h-[95vh] overflow-hidden flex flex-col"
|
|||
|
|
onClick={e => e.stopPropagation()}
|
|||
|
|
style={{ width: expandedWidth + 40, height: expandedHeight + 160 }}
|
|||
|
|
>
|
|||
|
|
{/* Header */}
|
|||
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-gray-50">
|
|||
|
|
<div>
|
|||
|
|
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
|||
|
|
{subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
{/* Zoom controls */}
|
|||
|
|
<div className="flex items-center gap-1 bg-gray-100 rounded-lg px-2 py-1">
|
|||
|
|
<button
|
|||
|
|
onClick={handleZoomOut}
|
|||
|
|
className="p-1.5 hover:bg-gray-200 rounded text-gray-700"
|
|||
|
|
title="Zoom out"
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<span className="text-sm font-medium text-gray-700 min-w-[3rem] text-center">
|
|||
|
|
{Math.round(zoom * 100)}%
|
|||
|
|
</span>
|
|||
|
|
<button
|
|||
|
|
onClick={handleZoomIn}
|
|||
|
|
className="p-1.5 hover:bg-gray-200 rounded text-gray-700"
|
|||
|
|
title="Zoom in"
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Reset button */}
|
|||
|
|
<button
|
|||
|
|
onClick={handleResetView}
|
|||
|
|
className="p-2 hover:bg-gray-200 rounded-lg text-gray-700"
|
|||
|
|
title="Reset view"
|
|||
|
|
>
|
|||
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{/* Download dropdown */}
|
|||
|
|
<div className="relative group">
|
|||
|
|
<button
|
|||
|
|
className="p-2 hover:bg-gray-200 rounded-lg text-gray-700"
|
|||
|
|
title="Download"
|
|||
|
|
>
|
|||
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 hidden group-hover:block min-w-[120px]">
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleDownload('png')}
|
|||
|
|
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 text-gray-700"
|
|||
|
|
>
|
|||
|
|
Download PNG
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleDownload('svg')}
|
|||
|
|
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 text-gray-700"
|
|||
|
|
>
|
|||
|
|
Download SVG
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Close button */}
|
|||
|
|
<button
|
|||
|
|
onClick={handleClose}
|
|||
|
|
className="p-2 hover:bg-red-100 rounded-lg text-gray-700 hover:text-red-600 ml-2"
|
|||
|
|
title="Close (Esc)"
|
|||
|
|
>
|
|||
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Chart content with zoom/pan */}
|
|||
|
|
<div
|
|||
|
|
className="flex-1 overflow-hidden bg-white cursor-grab active:cursor-grabbing"
|
|||
|
|
onWheel={handleWheel}
|
|||
|
|
onMouseDown={handleMouseDown}
|
|||
|
|
onMouseMove={handleMouseMove}
|
|||
|
|
onMouseUp={handleMouseUp}
|
|||
|
|
onMouseLeave={handleMouseUp}
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
ref={expandedContentRef}
|
|||
|
|
className="w-full h-full flex items-center justify-center p-4"
|
|||
|
|
style={{
|
|||
|
|
transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`,
|
|||
|
|
transformOrigin: 'center center',
|
|||
|
|
transition: isPanning ? 'none' : 'transform 0.1s ease-out'
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{renderExpanded ? renderExpanded(expandedWidth, expandedHeight) : children}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Footer with instructions */}
|
|||
|
|
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 text-xs text-gray-500 flex items-center gap-4">
|
|||
|
|
<span>🖱️ Scroll to zoom</span>
|
|||
|
|
<span>✋ Drag to pan</span>
|
|||
|
|
<span>⌨️ Press Esc to close</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>,
|
|||
|
|
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 (
|
|||
|
|
<div ref={containerRef} className={`relative ${className}`}>
|
|||
|
|
{/* Expand button */}
|
|||
|
|
<button
|
|||
|
|
onClick={handleExpand}
|
|||
|
|
className="absolute top-2 right-2 z-10 p-1.5 bg-white/90 hover:bg-white rounded-lg shadow-sm border border-gray-200 text-gray-600 hover:text-gray-900 transition-colors"
|
|||
|
|
title="Expand chart"
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{/* Original chart content */}
|
|||
|
|
{children}
|
|||
|
|
|
|||
|
|
{/* Modal portal */}
|
|||
|
|
{modal}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|