chore: migrate to TypeScript
- Convert all .js files to .tsx/.ts - Add types for data structures (MuseumRecord, Metrics, etc.) - Add type declarations for react-chartjs-2 - Configure tsconfig with relaxed strictness for gradual adoption - All components now use TypeScript
This commit is contained in:
184
src/components/ChartExport.tsx
Normal file
184
src/components/ChartExport.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useRef, useState, ReactNode } from 'react';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
interface ExportableChartProps {
|
||||
children: ReactNode;
|
||||
filename?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
controls?: ReactNode;
|
||||
}
|
||||
|
||||
// Wrapper component that adds PNG export to any chart
|
||||
export function ExportableChart({
|
||||
children,
|
||||
filename = 'chart',
|
||||
title = '',
|
||||
className = '',
|
||||
controls = null
|
||||
}: ExportableChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const exportAsPNG = () => {
|
||||
const chartContainer = chartRef.current;
|
||||
if (!chartContainer) return;
|
||||
|
||||
const canvas = chartContainer.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Create a new canvas with white background and title
|
||||
const exportCanvas = document.createElement('canvas');
|
||||
const ctx = exportCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set dimensions with padding and title space
|
||||
const padding = 24;
|
||||
const titleHeight = title ? 48 : 0;
|
||||
exportCanvas.width = canvas.width + (padding * 2);
|
||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
||||
|
||||
// Fill white background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
|
||||
// Draw title if provided (left-aligned, matching on-screen style)
|
||||
if (title) {
|
||||
ctx.fillStyle = '#1e293b';
|
||||
ctx.font = '600 20px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(title, padding, padding + 24);
|
||||
}
|
||||
|
||||
// Draw the chart
|
||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
||||
|
||||
// Export
|
||||
const link = document.createElement('a');
|
||||
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.png`;
|
||||
link.href = exportCanvas.toDataURL('image/png', 1.0);
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="exportable-chart-wrapper">
|
||||
{/* Download button - positioned absolutely in corner */}
|
||||
<button
|
||||
className="chart-export-btn visible"
|
||||
onClick={exportAsPNG}
|
||||
title="Download as PNG"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{title && (
|
||||
<div className="chart-header-with-export">
|
||||
<h2>{title}</h2>
|
||||
{controls && <div className="chart-header-actions">{controls}</div>}
|
||||
</div>
|
||||
)}
|
||||
{!title && controls && <div className="chart-controls">{controls}</div>}
|
||||
<div className={`exportable-chart ${className}`}>
|
||||
<div ref={chartRef} className="chart-canvas-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility function to export all charts from a container as a ZIP
|
||||
export async function exportAllCharts(containerSelector: string, zipFilename: string = 'charts'): Promise<void> {
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
const zip = new JSZip();
|
||||
const chartWrappers = container.querySelectorAll('.exportable-chart-wrapper');
|
||||
|
||||
for (let i = 0; i < chartWrappers.length; i++) {
|
||||
const wrapper = chartWrappers[i];
|
||||
const canvas = wrapper.querySelector('canvas');
|
||||
const titleEl = wrapper.querySelector('.chart-header-with-export h2');
|
||||
const title = titleEl?.textContent || `chart-${i + 1}`;
|
||||
|
||||
if (!canvas) continue;
|
||||
|
||||
// Create export canvas with white background and title
|
||||
const exportCanvas = document.createElement('canvas');
|
||||
const ctx = exportCanvas.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
|
||||
const padding = 32;
|
||||
const titleHeight = 56;
|
||||
exportCanvas.width = canvas.width + (padding * 2);
|
||||
exportCanvas.height = canvas.height + (padding * 2) + titleHeight;
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
|
||||
// Draw title
|
||||
ctx.fillStyle = '#1e293b';
|
||||
ctx.font = '600 24px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(title, padding, padding + 28);
|
||||
|
||||
// Draw chart
|
||||
ctx.drawImage(canvas, padding, padding + titleHeight);
|
||||
|
||||
// Convert to blob and add to zip
|
||||
const dataUrl = exportCanvas.toDataURL('image/png', 1.0);
|
||||
const base64Data = dataUrl.split(',')[1];
|
||||
const safeFilename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF\s-]/g, '').replace(/\s+/g, '-');
|
||||
zip.file(`${String(i + 1).padStart(2, '0')}-${safeFilename}.png`, base64Data, { base64: true });
|
||||
}
|
||||
|
||||
// Generate and download ZIP
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${zipFilename}-${new Date().toISOString().split('T')[0]}.zip`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
interface ExportAllButtonProps {
|
||||
containerSelector: string;
|
||||
zipFilename?: string;
|
||||
label: string;
|
||||
loadingLabel: string;
|
||||
}
|
||||
|
||||
// Button component for exporting all charts
|
||||
export function ExportAllButton({ containerSelector, zipFilename = 'charts', label, loadingLabel }: ExportAllButtonProps) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportAllCharts(containerSelector, zipFilename);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn-export-all"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{exporting ? loadingLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportableChart;
|
||||
Reference in New Issue
Block a user