Add PNG export for charts
- Hover any chart to reveal download button - Exports with white background and padding - Works on Dashboard and Comparison pages
This commit is contained in:
38
src/App.css
38
src/App.css
@@ -1774,3 +1774,41 @@ table tbody tr:hover {
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chart Export Button */
|
||||||
|
.exportable-chart {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-export-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exportable-chart:hover .chart-export-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-export-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-canvas-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
57
src/components/ChartExport.js
Normal file
57
src/components/ChartExport.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
|
||||||
|
// Wrapper component that adds PNG export to any chart
|
||||||
|
export function ExportableChart({ children, filename = 'chart', className = '' }) {
|
||||||
|
const chartRef = useRef(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
|
||||||
|
const exportCanvas = document.createElement('canvas');
|
||||||
|
const ctx = exportCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Set dimensions with padding
|
||||||
|
const padding = 20;
|
||||||
|
exportCanvas.width = canvas.width + (padding * 2);
|
||||||
|
exportCanvas.height = canvas.height + (padding * 2);
|
||||||
|
|
||||||
|
// Fill white background
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||||
|
|
||||||
|
// Draw the chart
|
||||||
|
ctx.drawImage(canvas, padding, padding);
|
||||||
|
|
||||||
|
// 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 ${className}`}>
|
||||||
|
<button
|
||||||
|
className="chart-export-btn"
|
||||||
|
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>
|
||||||
|
<div ref={chartRef} className="chart-canvas-wrapper">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExportableChart;
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useMemo, useCallback, useRef } from 'react';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Line, Bar } from 'react-chartjs-2';
|
import { Line, Bar } from 'react-chartjs-2';
|
||||||
import { EmptyState, FilterControls } from './shared';
|
import { EmptyState, FilterControls } from './shared';
|
||||||
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import {
|
import {
|
||||||
filterDataByDateRange,
|
filterDataByDateRange,
|
||||||
@@ -603,9 +604,9 @@ function Comparison({ data, showDataLabels }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-container">
|
<ExportableChart filename="trend-comparison" className="chart-container">
|
||||||
<Line data={timeSeriesChart} options={chartOptions} />
|
<Line data={timeSeriesChart} options={chartOptions} />
|
||||||
</div>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-section">
|
<div className="chart-section">
|
||||||
<div className="chart-header">
|
<div className="chart-header">
|
||||||
@@ -624,9 +625,9 @@ function Comparison({ data, showDataLabels }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-container">
|
<ExportableChart filename="museum-comparison" className="chart-container">
|
||||||
<Bar data={museumChart} options={chartOptions} />
|
<Bar data={museumChart} options={chartOptions} />
|
||||||
</div>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
||||||
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
import { Carousel, EmptyState, FilterControls, StatCard } from './shared';
|
||||||
|
import { ExportableChart } from './ChartExport';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||||
import {
|
import {
|
||||||
filterData,
|
filterData,
|
||||||
@@ -435,41 +436,41 @@ function Dashboard({ data, showDataLabels }) {
|
|||||||
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
<button className={trendGranularity === 'day' ? 'active' : ''} onClick={() => setTrendGranularity('day')}>Daily</button>
|
||||||
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
<button className={trendGranularity === 'week' ? 'active' : ''} onClick={() => setTrendGranularity('week')}>Weekly</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-container">
|
<ExportableChart filename="revenue-trend" className="chart-container">
|
||||||
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
<Line data={trendData} options={{...baseOptions, scales: {...baseOptions.scales, x: {...baseOptions.scales.x, ticks: {...baseOptions.scales.x.ticks, maxTicksLimit: trendGranularity === 'week' ? 15 : 20}}}}} />
|
||||||
</div>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
{filters.museum === 'all' && (
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<h2>Visitors by Museum</h2>
|
<h2>Visitors by Museum</h2>
|
||||||
<div className="chart-container">
|
<ExportableChart filename="visitors-by-museum" className="chart-container">
|
||||||
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 11}}}}}} />
|
<Doughnut data={museumData.visitors} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'bottom', labels: {boxWidth: 12, padding: 16, font: {size: 11}}}}}} />
|
||||||
</div>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filters.museum === 'all' && (
|
{filters.museum === 'all' && (
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<h2>Revenue by Museum</h2>
|
<h2>Revenue by Museum</h2>
|
||||||
<div className="chart-container">
|
<ExportableChart filename="revenue-by-museum" className="chart-container">
|
||||||
<Bar data={museumData.revenue} options={baseOptions} />
|
<Bar data={museumData.revenue} options={baseOptions} />
|
||||||
</div>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<h2>Quarterly Revenue (YoY)</h2>
|
<h2>Quarterly Revenue (YoY)</h2>
|
||||||
<div className="chart-container">
|
<ExportableChart filename="quarterly-yoy" className="chart-container">
|
||||||
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 11}}}}}} />
|
<Bar data={quarterlyYoYData} options={{...baseOptions, plugins: {...baseOptions.plugins, legend: {display: true, position: 'top', align: 'end', labels: {boxWidth: 12, padding: 12, font: {size: 11}}}}}} />
|
||||||
</div>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card half-width">
|
<div className="chart-card half-width">
|
||||||
<h2>District Performance</h2>
|
<h2>District Performance</h2>
|
||||||
<div className="chart-container">
|
<ExportableChart filename="district-performance" className="chart-container">
|
||||||
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
<Bar data={districtData} options={{...baseOptions, indexAxis: 'y'}} />
|
||||||
</div>
|
</ExportableChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-card full-width">
|
<div className="chart-card full-width">
|
||||||
|
|||||||
Reference in New Issue
Block a user