- Global data labels toggle in header (works on Dashboard & Comparison pages) - Labels show formatted values (K/M suffix, max 2 decimals) with white pill background - Capture Rate chart now shows pilgrims as curved line on right Y-axis - Revenue Trends toggle moved to top-right corner of chart container - Mobile: bottom navigation bar with Dashboard, Compare, Labels toggle - Mobile: top nav simplified to brand only, bottom nav is thumb-friendly
713 lines
27 KiB
JavaScript
713 lines
27 KiB
JavaScript
import React, { useState, useMemo, useCallback, useRef } from 'react';
|
|
import { Line, Bar } from 'react-chartjs-2';
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
BarElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
} from 'chart.js';
|
|
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
|
import {
|
|
filterDataByDateRange,
|
|
calculateMetrics,
|
|
formatCurrency,
|
|
formatCompact,
|
|
formatCompactCurrency,
|
|
umrahData,
|
|
getUniqueDistricts,
|
|
getDistrictMuseumMap,
|
|
getMuseumsForDistrict,
|
|
getLatestYear
|
|
} from '../services/dataService';
|
|
|
|
ChartJS.register(
|
|
CategoryScale, LinearScale, PointElement, LineElement,
|
|
BarElement, Title, Tooltip, Legend, Filler, ChartDataLabels
|
|
);
|
|
|
|
const chartColors = {
|
|
primary: '#2563eb',
|
|
muted: '#94a3b8',
|
|
grid: '#f1f5f9'
|
|
};
|
|
|
|
// Generate preset dates for a given year
|
|
const generatePresetDates = (year) => ({
|
|
'jan': { start: `${year}-01-01`, end: `${year}-01-31` },
|
|
'feb': { start: `${year}-02-01`, end: `${year}-02-28` },
|
|
'mar': { start: `${year}-03-01`, end: `${year}-03-31` },
|
|
'apr': { start: `${year}-04-01`, end: `${year}-04-30` },
|
|
'may': { start: `${year}-05-01`, end: `${year}-05-31` },
|
|
'jun': { start: `${year}-06-01`, end: `${year}-06-30` },
|
|
'jul': { start: `${year}-07-01`, end: `${year}-07-31` },
|
|
'aug': { start: `${year}-08-01`, end: `${year}-08-31` },
|
|
'sep': { start: `${year}-09-01`, end: `${year}-09-30` },
|
|
'oct': { start: `${year}-10-01`, end: `${year}-10-31` },
|
|
'nov': { start: `${year}-11-01`, end: `${year}-11-30` },
|
|
'dec': { start: `${year}-12-01`, end: `${year}-12-31` },
|
|
'q1': { start: `${year}-01-01`, end: `${year}-03-31` },
|
|
'q2': { start: `${year}-04-01`, end: `${year}-06-30` },
|
|
'q3': { start: `${year}-07-01`, end: `${year}-09-30` },
|
|
'q4': { start: `${year}-10-01`, end: `${year}-12-31` },
|
|
'h1': { start: `${year}-01-01`, end: `${year}-06-30` },
|
|
'h2': { start: `${year}-07-01`, end: `${year}-12-31` },
|
|
'full': { start: `${year}-01-01`, end: `${year}-12-31` }
|
|
});
|
|
|
|
function Comparison({ data, showDataLabels }) {
|
|
// Get latest year from data for default presets
|
|
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
|
|
|
const [preset, setPreset] = useState('jan');
|
|
const [startDate, setStartDate] = useState(`${latestYear}-01-01`);
|
|
const [endDate, setEndDate] = useState(`${latestYear}-01-31`);
|
|
const [filters, setFilters] = useState({ district: 'all', museum: 'all' });
|
|
const [chartMetric, setChartMetric] = useState('revenue');
|
|
const [chartGranularity, setChartGranularity] = useState('week');
|
|
const [controlsExpanded, setControlsExpanded] = useState(true);
|
|
const [activeChart, setActiveChart] = useState(0);
|
|
const [activeCard, setActiveCard] = useState(0);
|
|
|
|
const charts = [
|
|
{ id: 'timeseries', label: 'Trend' },
|
|
{ id: 'museum', label: 'By Museum' }
|
|
];
|
|
|
|
// Touch swipe handlers
|
|
const touchStartChart = useRef(null);
|
|
const touchStartCard = useRef(null);
|
|
|
|
const handleChartTouchStart = (e) => {
|
|
touchStartChart.current = e.touches[0].clientX;
|
|
};
|
|
const handleChartTouchEnd = (e) => {
|
|
if (!touchStartChart.current) return;
|
|
const diff = touchStartChart.current - e.changedTouches[0].clientX;
|
|
if (Math.abs(diff) > 50) {
|
|
if (diff > 0 && activeChart < charts.length - 1) {
|
|
setActiveChart(activeChart + 1);
|
|
} else if (diff < 0 && activeChart > 0) {
|
|
setActiveChart(activeChart - 1);
|
|
}
|
|
}
|
|
touchStartChart.current = null;
|
|
};
|
|
|
|
const granularityOptions = [
|
|
{ value: 'day', label: 'Daily' },
|
|
{ value: 'week', label: 'Weekly' }
|
|
];
|
|
|
|
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' }
|
|
];
|
|
|
|
const getMetricValue = useCallback((rows, metric) => {
|
|
if (metric === 'avgRevenue') {
|
|
const revenue = rows.reduce((s, r) => s + parseFloat(r.revenue_incl_tax || 0), 0);
|
|
const visitors = rows.reduce((s, r) => s + parseInt(r.visits || 0), 0);
|
|
return visitors > 0 ? revenue / visitors : 0;
|
|
}
|
|
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
|
const field = fieldMap[metric];
|
|
return rows.reduce((s, r) => s + parseFloat(r[field] || 0), 0);
|
|
}, []);
|
|
|
|
// Dynamic lists from data
|
|
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
|
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
|
const availableMuseums = useMemo(() => getMuseumsForDistrict(districtMuseumMap, filters.district), [districtMuseumMap, filters.district]);
|
|
|
|
// Generate presets based on latest year
|
|
const presetDates = useMemo(() => generatePresetDates(latestYear), [latestYear]);
|
|
|
|
const handlePresetChange = (newPreset) => {
|
|
setPreset(newPreset);
|
|
if (newPreset !== 'custom' && presetDates[newPreset]) {
|
|
setStartDate(presetDates[newPreset].start);
|
|
setEndDate(presetDates[newPreset].end);
|
|
}
|
|
};
|
|
|
|
// Year-over-year comparison: same dates, previous year
|
|
const ranges = useMemo(() => ({
|
|
curr: { start: startDate, end: endDate },
|
|
prev: {
|
|
start: startDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1),
|
|
end: endDate.replace(/^(\d{4})/, (_, y) => parseInt(y) - 1)
|
|
}
|
|
}), [startDate, endDate]);
|
|
|
|
const prevData = useMemo(() =>
|
|
filterDataByDateRange(data, ranges.prev.start, ranges.prev.end, filters),
|
|
[data, ranges.prev, filters]
|
|
);
|
|
|
|
const currData = useMemo(() =>
|
|
filterDataByDateRange(data, ranges.curr.start, ranges.curr.end, filters),
|
|
[data, ranges.curr, filters]
|
|
);
|
|
|
|
const prevMetrics = useMemo(() => calculateMetrics(prevData), [prevData]);
|
|
const currMetrics = useMemo(() => calculateMetrics(currData), [currData]);
|
|
|
|
const calcChange = (prev, curr) => prev === 0 ? (curr > 0 ? Infinity : 0) : ((curr - prev) / prev * 100);
|
|
|
|
// Get quarter from date range (returns null if not a clean quarter)
|
|
const getQuarterFromRange = (start, end) => {
|
|
const quarterRanges = {
|
|
1: { start: '-01-01', end: '-03-31' },
|
|
2: { start: '-04-01', end: '-06-30' },
|
|
3: { start: '-07-01', end: '-09-30' },
|
|
4: { start: '-10-01', end: '-12-31' }
|
|
};
|
|
for (let q = 1; q <= 4; q++) {
|
|
if (start.endsWith(quarterRanges[q].start) && end.endsWith(quarterRanges[q].end)) {
|
|
return q;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Calculate capture rate and pilgrim data for quarters
|
|
const quarterData = useMemo(() => {
|
|
const prevYear = parseInt(ranges.prev.start.substring(0, 4));
|
|
const currYear = parseInt(ranges.curr.start.substring(0, 4));
|
|
const prevQ = getQuarterFromRange(ranges.prev.start, ranges.prev.end);
|
|
const currQ = getQuarterFromRange(ranges.curr.start, ranges.curr.end);
|
|
|
|
if (!prevQ || !currQ) return null; // Only show for quarter comparisons
|
|
|
|
const prevPilgrims = umrahData[prevYear]?.[prevQ];
|
|
const currPilgrims = umrahData[currYear]?.[currQ];
|
|
|
|
if (!prevPilgrims && !currPilgrims) return null;
|
|
|
|
const prevRate = prevPilgrims ? (prevMetrics.visitors / prevPilgrims * 100) : null;
|
|
const currRate = currPilgrims ? (currMetrics.visitors / currPilgrims * 100) : null;
|
|
|
|
return {
|
|
pilgrims: { prev: prevPilgrims, curr: currPilgrims },
|
|
captureRate: { prev: prevRate, curr: currRate }
|
|
};
|
|
}, [ranges, prevMetrics.visitors, currMetrics.visitors]);
|
|
|
|
const captureRates = quarterData?.captureRate || null;
|
|
const pilgrimCounts = quarterData?.pilgrims || null;
|
|
|
|
const changes = {
|
|
revenue: calcChange(prevMetrics.revenue, currMetrics.revenue),
|
|
visitors: calcChange(prevMetrics.visitors, currMetrics.visitors),
|
|
tickets: calcChange(prevMetrics.tickets, currMetrics.tickets),
|
|
avgRev: calcChange(prevMetrics.avgRevPerVisitor, currMetrics.avgRevPerVisitor),
|
|
pilgrims: pilgrimCounts ? calcChange(pilgrimCounts.prev || 0, pilgrimCounts.curr || 0) : null,
|
|
captureRate: captureRates ? calcChange(captureRates.prev || 0, captureRates.curr || 0) : null
|
|
};
|
|
|
|
// Build cards array dynamically
|
|
const metricCards = useMemo(() => {
|
|
const cards = [
|
|
{ title: 'Revenue', prev: prevMetrics.revenue, curr: currMetrics.revenue, change: changes.revenue, isCurrency: true },
|
|
{ title: 'Visitors', prev: prevMetrics.visitors, curr: currMetrics.visitors, change: changes.visitors },
|
|
{ title: 'Tickets', prev: prevMetrics.tickets, curr: currMetrics.tickets, change: changes.tickets },
|
|
{ title: 'Avg Rev/Visitor', prev: prevMetrics.avgRevPerVisitor, curr: currMetrics.avgRevPerVisitor, change: changes.avgRev, isCurrency: true }
|
|
];
|
|
if (pilgrimCounts) {
|
|
cards.push({ title: 'Pilgrims', prev: pilgrimCounts.prev, curr: pilgrimCounts.curr, change: changes.pilgrims, pendingMessage: 'Data not published yet' });
|
|
}
|
|
if (captureRates) {
|
|
cards.push({ title: 'Capture Rate', prev: captureRates.prev, curr: captureRates.curr, change: changes.captureRate, isPercent: true, pendingMessage: 'Data not published yet' });
|
|
}
|
|
return cards;
|
|
}, [prevMetrics, currMetrics, changes, pilgrimCounts, captureRates]);
|
|
|
|
const handleCardTouchStart = (e) => {
|
|
touchStartCard.current = e.touches[0].clientX;
|
|
};
|
|
const handleCardTouchEnd = (e) => {
|
|
if (!touchStartCard.current) return;
|
|
const diff = touchStartCard.current - e.changedTouches[0].clientX;
|
|
if (Math.abs(diff) > 50) {
|
|
if (diff > 0 && activeCard < metricCards.length - 1) {
|
|
setActiveCard(activeCard + 1);
|
|
} else if (diff < 0 && activeCard > 0) {
|
|
setActiveCard(activeCard - 1);
|
|
}
|
|
}
|
|
touchStartCard.current = null;
|
|
};
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '';
|
|
const [year, month, day] = dateStr.split('-').map(Number);
|
|
const d = new Date(year, month - 1, day);
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
};
|
|
|
|
// Time series chart (daily or weekly)
|
|
const timeSeriesChart = useMemo(() => {
|
|
const groupByPeriod = (periodData, periodStart, metric, granularity) => {
|
|
const start = new Date(periodStart);
|
|
const groupedRows = {};
|
|
|
|
periodData.forEach(row => {
|
|
if (!row.date) return;
|
|
const rowDate = new Date(row.date);
|
|
const daysDiff = Math.floor((rowDate - start) / (1000 * 60 * 60 * 24));
|
|
|
|
let key;
|
|
if (granularity === 'week') {
|
|
key = Math.floor(daysDiff / 7) + 1;
|
|
} else {
|
|
key = daysDiff + 1; // day number from start
|
|
}
|
|
|
|
if (!groupedRows[key]) groupedRows[key] = [];
|
|
groupedRows[key].push(row);
|
|
});
|
|
|
|
const result = {};
|
|
Object.keys(groupedRows).forEach(key => {
|
|
result[key] = getMetricValue(groupedRows[key], metric);
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const prevGrouped = groupByPeriod(prevData, ranges.prev.start, chartMetric, chartGranularity);
|
|
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 prevYear = ranges.prev.start.substring(0, 4);
|
|
const currYear = ranges.curr.start.substring(0, 4);
|
|
|
|
return {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: prevYear,
|
|
data: labels.map((_, i) => prevGrouped[i + 1] || 0),
|
|
borderColor: chartColors.muted,
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
pointRadius: chartGranularity === 'week' ? 3 : 1,
|
|
pointBackgroundColor: chartColors.muted
|
|
},
|
|
{
|
|
label: currYear,
|
|
data: labels.map((_, i) => currGrouped[i + 1] || 0),
|
|
borderColor: chartColors.primary,
|
|
backgroundColor: chartColors.primary + '10',
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
fill: true,
|
|
pointRadius: chartGranularity === 'week' ? 4 : 2,
|
|
pointBackgroundColor: chartColors.primary
|
|
}
|
|
]
|
|
};
|
|
}, [prevData, currData, ranges, chartMetric, chartGranularity, getMetricValue]);
|
|
|
|
// Museum chart - only show museums with data
|
|
const museumChart = useMemo(() => {
|
|
const prevYear = ranges.prev.start.substring(0, 4);
|
|
const currYear = ranges.curr.start.substring(0, 4);
|
|
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean);
|
|
const prevByMuseum = {};
|
|
const currByMuseum = {};
|
|
allMuseums.forEach(m => {
|
|
const prevRows = prevData.filter(r => r.museum_name === m);
|
|
const currRows = currData.filter(r => r.museum_name === m);
|
|
prevByMuseum[m] = getMetricValue(prevRows, chartMetric);
|
|
currByMuseum[m] = getMetricValue(currRows, chartMetric);
|
|
});
|
|
// Only include museums that have data in either period
|
|
const museums = allMuseums.filter(m => prevByMuseum[m] > 0 || currByMuseum[m] > 0);
|
|
return {
|
|
labels: museums,
|
|
datasets: [
|
|
{ label: prevYear, data: museums.map(m => prevByMuseum[m]), backgroundColor: chartColors.muted, borderRadius: 4 },
|
|
{ label: currYear, data: museums.map(m => currByMuseum[m]), backgroundColor: chartColors.primary, borderRadius: 4 }
|
|
]
|
|
};
|
|
}, [data, prevData, currData, ranges, chartMetric, getMetricValue]);
|
|
|
|
const chartOptions = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: 'top', align: 'end', labels: { boxWidth: 12, padding: 12, font: { size: 11 } } },
|
|
tooltip: { backgroundColor: '#1e293b', padding: 12, cornerRadius: 8 },
|
|
datalabels: {
|
|
display: showDataLabels,
|
|
color: '#1e293b',
|
|
font: { size: 10, weight: 600 },
|
|
anchor: 'end',
|
|
align: 'end',
|
|
offset: 4,
|
|
padding: 4,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
|
borderRadius: 3,
|
|
formatter: (value) => {
|
|
if (value == null) return '';
|
|
if (value >= 1000000) return (value / 1000000).toFixed(2) + 'M';
|
|
if (value >= 1000) return (value / 1000).toFixed(2) + 'K';
|
|
if (value < 100 && value > 0) return value.toFixed(2);
|
|
return Math.round(value).toLocaleString();
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { font: { size: 10 }, color: '#94a3b8' } },
|
|
y: { grid: { color: chartColors.grid }, ticks: { font: { size: 10 }, color: '#94a3b8' }, border: { display: false } }
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="comparison">
|
|
<div className="page-title">
|
|
<h1>Period Comparison</h1>
|
|
<p>Year-over-year analysis — same period, different years</p>
|
|
</div>
|
|
|
|
<div className={`controls ${controlsExpanded ? 'expanded' : 'collapsed'}`}>
|
|
<div className="controls-header" onClick={() => setControlsExpanded(!controlsExpanded)}>
|
|
<h3>Select Period</h3>
|
|
<button className="controls-toggle">
|
|
{controlsExpanded ? '▲ Hide' : '▼ Show'}
|
|
</button>
|
|
</div>
|
|
<div className="controls-body">
|
|
<div className="control-row">
|
|
<div className="control-group">
|
|
<label>Preset</label>
|
|
<select value={preset} onChange={e => handlePresetChange(e.target.value)}>
|
|
<option value="custom">Custom</option>
|
|
<option value="jan">January</option>
|
|
<option value="feb">February</option>
|
|
<option value="mar">March</option>
|
|
<option value="apr">April</option>
|
|
<option value="may">May</option>
|
|
<option value="jun">June</option>
|
|
<option value="jul">July</option>
|
|
<option value="aug">August</option>
|
|
<option value="sep">September</option>
|
|
<option value="oct">October</option>
|
|
<option value="nov">November</option>
|
|
<option value="dec">December</option>
|
|
<option value="q1">Q1</option>
|
|
<option value="q2">Q2</option>
|
|
<option value="q3">Q3</option>
|
|
<option value="q4">Q4</option>
|
|
<option value="h1">H1</option>
|
|
<option value="h2">H2</option>
|
|
<option value="full">Full Year</option>
|
|
</select>
|
|
</div>
|
|
{preset === 'custom' && (
|
|
<>
|
|
<div className="control-group">
|
|
<label>From</label>
|
|
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
|
</div>
|
|
<div className="control-group">
|
|
<label>To</label>
|
|
<input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="control-group">
|
|
<label>District</label>
|
|
<select value={filters.district} onChange={e => setFilters({...filters, district: e.target.value, museum: 'all'})}>
|
|
<option value="all">All Districts</option>
|
|
{districts.map(d => (
|
|
<option key={d} value={d}>{d}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="control-group">
|
|
<label>Museum</label>
|
|
<select value={filters.museum} onChange={e => setFilters({...filters, museum: e.target.value})}>
|
|
<option value="all">All Museums</option>
|
|
{availableMuseums.map(m => (
|
|
<option key={m} value={m}>{m}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="period-display">
|
|
<div className="period-box">
|
|
<div className="label">{ranges.prev.start.substring(0, 4)}</div>
|
|
<div className="dates">{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}</div>
|
|
</div>
|
|
<div className="period-box">
|
|
<div className="label">{ranges.curr.start.substring(0, 4)}</div>
|
|
<div className="dates">{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop: Grid layout */}
|
|
<div className="comparison-grid desktop-only">
|
|
{metricCards.map((card, i) => (
|
|
<MetricCard
|
|
key={i}
|
|
title={card.title}
|
|
prev={card.prev}
|
|
curr={card.curr}
|
|
change={card.change}
|
|
isCurrency={card.isCurrency}
|
|
isPercent={card.isPercent}
|
|
pendingMessage={card.pendingMessage}
|
|
prevYear={ranges.prev.start.substring(0, 4)}
|
|
currYear={ranges.curr.start.substring(0, 4)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Mobile: Carousel layout */}
|
|
<div className="cards-carousel mobile-only">
|
|
<div className="carousel-container">
|
|
<div className="carousel-viewport">
|
|
<div
|
|
className="carousel-track"
|
|
style={{ transform: `translateX(-${activeCard * 100}%)` }}
|
|
onTouchStart={handleCardTouchStart}
|
|
onTouchEnd={handleCardTouchEnd}
|
|
>
|
|
{metricCards.map((card, i) => (
|
|
<div className="carousel-slide" key={i}>
|
|
<MetricCard
|
|
title={card.title}
|
|
prev={card.prev}
|
|
curr={card.curr}
|
|
change={card.change}
|
|
isCurrency={card.isCurrency}
|
|
isPercent={card.isPercent}
|
|
pendingMessage={card.pendingMessage}
|
|
prevYear={ranges.prev.start.substring(0, 4)}
|
|
currYear={ranges.curr.start.substring(0, 4)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-dots labeled">
|
|
{metricCards.map((card, i) => (
|
|
<button
|
|
key={i}
|
|
className={`carousel-dot ${activeCard === i ? 'active' : ''}`}
|
|
onClick={() => setActiveCard(i)}
|
|
>
|
|
<span className="dot-label">{card.title}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop: Show both charts */}
|
|
<div className="charts-grid desktop-only">
|
|
<div className="chart-section">
|
|
<div className="chart-header">
|
|
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
|
<div className="chart-selectors">
|
|
<div className="toggle-switch">
|
|
{granularityOptions.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
className={chartGranularity === opt.value ? 'active' : ''}
|
|
onClick={() => setChartGranularity(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="chart-metric-selector">
|
|
{metricOptions.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
className={chartMetric === opt.value ? 'active' : ''}
|
|
onClick={() => setChartMetric(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="chart-container">
|
|
<Line data={timeSeriesChart} options={chartOptions} />
|
|
</div>
|
|
</div>
|
|
<div className="chart-section">
|
|
<div className="chart-header">
|
|
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
|
<div className="chart-selectors">
|
|
<div className="chart-metric-selector">
|
|
{metricOptions.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
className={chartMetric === opt.value ? 'active' : ''}
|
|
onClick={() => setChartMetric(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="chart-container">
|
|
<Bar data={museumChart} options={chartOptions} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile: Carousel */}
|
|
<div className="charts-carousel mobile-only">
|
|
<div className="carousel-container">
|
|
<div className="carousel-viewport">
|
|
<div
|
|
className="carousel-track"
|
|
style={{ transform: `translateX(-${activeChart * 100}%)` }}
|
|
onTouchStart={handleChartTouchStart}
|
|
onTouchEnd={handleChartTouchEnd}
|
|
>
|
|
<div className="carousel-slide">
|
|
<div className="chart-section">
|
|
<div className="chart-header">
|
|
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} Trend</h2>
|
|
<div className="toggle-switch">
|
|
{granularityOptions.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
className={chartGranularity === opt.value ? 'active' : ''}
|
|
onClick={() => setChartGranularity(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="chart-selectors-inline">
|
|
<div className="chart-metric-selector">
|
|
{metricOptions.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
className={chartMetric === opt.value ? 'active' : ''}
|
|
onClick={() => setChartMetric(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="chart-container">
|
|
<Line data={timeSeriesChart} options={chartOptions} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="carousel-slide">
|
|
<div className="chart-section">
|
|
<div className="chart-header">
|
|
<h2>{metricOptions.find(m => m.value === chartMetric)?.label} by Museum</h2>
|
|
</div>
|
|
<div className="chart-selectors-inline">
|
|
<div className="chart-metric-selector">
|
|
{metricOptions.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
className={chartMetric === opt.value ? 'active' : ''}
|
|
onClick={() => setChartMetric(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="chart-container">
|
|
<Bar data={museumChart} options={chartOptions} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="carousel-dots labeled">
|
|
{charts.map((chart, i) => (
|
|
<button
|
|
key={chart.id}
|
|
className={`carousel-dot ${activeChart === i ? 'active' : ''}`}
|
|
onClick={() => setActiveChart(i)}
|
|
>
|
|
<span className="dot-label">{chart.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricCard({ title, prev, curr, change, isCurrency, isPercent, pendingMessage, prevYear, currYear }) {
|
|
const hasPending = prev === null || curr === null;
|
|
const isPositive = change >= 0;
|
|
const changeText = (hasPending && pendingMessage) ? null : (change === Infinity || change === null ? '—' : `${isPositive ? '+' : ''}${change.toFixed(1)}%`);
|
|
|
|
const formatValue = (val) => {
|
|
if (val === null || val === undefined) return '—';
|
|
if (isPercent) return val.toFixed(2) + '%';
|
|
if (isCurrency) return formatCompactCurrency(val);
|
|
return formatCompact(val);
|
|
};
|
|
|
|
const diff = (curr || 0) - (prev || 0);
|
|
const diffText = (hasPending && pendingMessage) ? pendingMessage : (isPercent
|
|
? (diff >= 0 ? '+' : '') + diff.toFixed(2) + 'pp'
|
|
: (isCurrency ? formatCompactCurrency(diff) : formatCompact(diff)));
|
|
|
|
return (
|
|
<div className="metric-card">
|
|
<h4>{title}</h4>
|
|
<div className="metric-values">
|
|
<div className="metric-period previous">
|
|
<div className="year">{prevYear}</div>
|
|
<div className="value">{formatValue(prev)}</div>
|
|
</div>
|
|
<div className={`metric-change ${hasPending && pendingMessage ? 'pending' : (isPositive ? 'positive' : 'negative')}`}>
|
|
{hasPending && pendingMessage ? (
|
|
<div className="pending-msg">{pendingMessage}</div>
|
|
) : (
|
|
<>
|
|
<div className="pct">{changeText}</div>
|
|
<div className="abs">{diff >= 0 ? '+' : ''}{diffText}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="metric-period current">
|
|
<div className="year">{currYear}</div>
|
|
<div className="value">{formatValue(curr)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Comparison;
|