- {children}
+
+ {title && (
+
+
{title}
+
+ {controls}
+
+
+
+ )}
+ {!title && controls &&
{controls}
}
+
);
}
+// Utility function to export all charts from a container as a ZIP
+export async function exportAllCharts(containerSelector, zipFilename = 'charts') {
+ 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');
+
+ 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);
+}
+
+// Button component for exporting all charts
+export function ExportAllButton({ containerSelector, zipFilename, label, loadingLabel }) {
+ const [exporting, setExporting] = React.useState(false);
+
+ const handleExport = async () => {
+ setExporting(true);
+ try {
+ await exportAllCharts(containerSelector, zipFilename);
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
export default ExportableChart;
diff --git a/src/components/Comparison.js b/src/components/Comparison.js
index 0018fba..eea6a24 100644
--- a/src/components/Comparison.js
+++ b/src/components/Comparison.js
@@ -4,6 +4,7 @@ import { Line, Bar } from 'react-chartjs-2';
import { EmptyState, FilterControls } from './shared';
import { ExportableChart } from './ChartExport';
import { chartColors, createBaseOptions } from '../config/chartConfig';
+import { useLanguage } from '../contexts/LanguageContext';
import {
filterDataByDateRange,
calculateMetrics,
@@ -39,7 +40,8 @@ const generatePresetDates = (year) => ({
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
});
-function Comparison({ data, showDataLabels }) {
+function Comparison({ data, showDataLabels, setShowDataLabels }) {
+ const { t } = useLanguage();
const [searchParams, setSearchParams] = useSearchParams();
// Get available years from data
@@ -140,8 +142,8 @@ function Comparison({ data, showDataLabels }) {
};
const charts = [
- { id: 'timeseries', label: 'Trend' },
- { id: 'museum', label: 'By Museum' }
+ { id: 'timeseries', label: t('comparison.trend') },
+ { id: 'museum', label: t('comparison.byMuseum') }
];
// Touch swipe handlers
@@ -165,15 +167,16 @@ function Comparison({ data, showDataLabels }) {
};
const granularityOptions = [
- { value: 'day', label: 'Daily' },
- { value: 'week', label: 'Weekly' }
+ { value: 'day', label: t('time.daily') },
+ { value: 'week', label: t('time.weekly') },
+ { value: 'month', label: t('time.monthly') }
];
const metricOptions = [
- { value: 'revenue', label: 'Revenue', field: 'revenue_incl_tax', format: 'currency' },
- { value: 'visitors', label: 'Visitors', field: 'visits', format: 'number' },
- { value: 'tickets', label: 'Tickets', field: 'tickets', format: 'number' },
- { value: 'avgRevenue', label: 'Avg Rev/Visitor', field: null, format: 'currency' }
+ { value: 'revenue', label: t('metrics.revenue'), field: 'revenue_incl_tax', format: 'currency' },
+ { value: 'visitors', label: t('metrics.visitors'), field: 'visits', format: 'number' },
+ { value: 'tickets', label: t('metrics.tickets'), field: 'tickets', format: 'number' },
+ { value: 'avgRevenue', label: t('metrics.avgRevenue'), field: null, format: 'currency' }
];
const getMetricValue = useCallback((rows, metric) => {
@@ -271,19 +274,19 @@ function Comparison({ data, showDataLabels }) {
const captureRateChange = captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null;
const cards = [
- { title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
- { title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
- { title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
- { title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
+ { title: t('metrics.revenue'), prev: prevMetrics.revenue, curr: currMetrics.revenue, change: revenueChange, isCurrency: true },
+ { title: t('metrics.visitors'), prev: prevMetrics.visitors, curr: currMetrics.visitors, change: visitorsChange },
+ { title: t('metrics.tickets'), prev: prevMetrics.tickets, curr: currMetrics.tickets, change: ticketsChange },
+ { title: t('metrics.avgRevenue'), prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: avgRevChange, isCurrency: true }
];
if (pilgrimCounts) {
- cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: 'Data not published yet' });
+ cards.push({ title: t('metrics.pilgrims'), prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: pilgrimsChange, pendingMessage: t('comparison.pendingData') });
}
if (captureRates) {
- cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: 'Data not published yet' });
+ cards.push({ title: t('metrics.captureRate'), prev: captureRates.prev, curr: captureRates.curr, change: captureRateChange, isPercent: true, pendingMessage: t('comparison.pendingData') });
}
return cards;
- }, [prevMetrics, currMetrics, pilgrimCounts, captureRates]);
+ }, [prevMetrics, currMetrics, pilgrimCounts, captureRates, t]);
const handleCardTouchStart = (e) => {
touchStartCard.current = e.touches[0].clientX;
@@ -338,7 +341,11 @@ function Comparison({ data, showDataLabels }) {
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
let key;
- if (granularity === 'week') {
+ if (granularity === 'month') {
+ // Group by month number (relative to start)
+ const monthsDiff = (rowDate.getFullYear() - start.getFullYear()) * 12 + (rowDate.getMonth() - start.getMonth());
+ key = monthsDiff + 1;
+ } else if (granularity === 'week') {
key = Math.floor(daysDiff / 7) + 1;
} else {
key = daysDiff + 1; // day number from start
@@ -359,9 +366,15 @@ function Comparison({ data, showDataLabels }) {
const currGrouped = groupByPeriod(currData, ranges.curr.start, chartMetric, chartGranularity);
const maxKey = Math.max(...Object.keys(prevGrouped).map(Number), ...Object.keys(currGrouped).map(Number), 1);
- const labels = Array.from({ length: maxKey }, (_, i) =>
- chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}`
- );
+ const labels = Array.from({ length: maxKey }, (_, i) => {
+ if (chartGranularity === 'month') {
+ const startDate = new Date(ranges.curr.start);
+ const monthNum = ((startDate.getMonth() + i) % 12) + 1;
+ return String(monthNum);
+ }
+ if (chartGranularity === 'week') return `W${i + 1}`;
+ return `D${i + 1}`;
+ });
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
@@ -423,45 +436,54 @@ function Comparison({ data, showDataLabels }) {
...baseOptions,
plugins: {
...baseOptions.plugins,
- legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } }
+ legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 13 } } }
}
};
return (
-
-
-
Period Comparison
-
Select a period and year — automatically compares with the same period in the previous year
+
+
+
+
{t('comparison.title')}
+
{t('comparison.subtitle')}
+
+
+
{t('nav.labels')}
+
+
+
+
+
-
+
-
+
{preset !== 'custom' && (
-
+
-
-
-
{getPeriodLabel(ranges.prev.start, ranges.prev.end)}
-
{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}
-
-
-
{getPeriodLabel(ranges.curr.start, ranges.curr.end)}
-
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
-
-
+
+
+
{t('comparison.previousPeriod')}
+
{getPeriodLabel(ranges.prev.start, ranges.prev.end)}
+
{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}
+
+
{t('comparison.vs')}
+
+
{t('comparison.currentPeriod')}
+
{getPeriodLabel(ranges.curr.start, ranges.curr.end)}
+
{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}
+
+
+
{!hasData ? (
) : (
<>
{/* Desktop: Grid layout */}
-
+
{metricCards.map((card, i) => (
{/* Desktop: Show both charts */}
-
+
-
-
{metricOptions.find(m => m.value === chartMetric)?.label} Trend
-
-
- {granularityOptions.map(opt => (
-
- ))}
-
-
- {metricOptions.map(opt => (
-
- ))}
-
-
-
-
+ m.value === chartMetric)?.label} - ${t('comparison.trend')}`}
+ className="chart-container"
+ controls={
+ <>
+
+ {granularityOptions.map(opt => (
+
+ ))}
+
+
+ {metricOptions.map(opt => (
+
+ ))}
+
+ >
+ }
+ >
-
-
{metricOptions.find(m => m.value === chartMetric)?.label} by Museum
-
+
m.value === chartMetric)?.label} - ${t('comparison.byMuseum')}`}
+ className="chart-container"
+ controls={
{metricOptions.map(opt => (
-
-
-
+ }
+ >
@@ -644,7 +674,7 @@ function Comparison({ data, showDataLabels }) {
-
{metricOptions.find(m => m.value === chartMetric)?.label} Trend
+
{metricOptions.find(m => m.value === chartMetric)?.label} - {t('comparison.trend')}
{granularityOptions.map(opt => (