diff --git a/src/App.jsx b/src/App.jsx index cfac13b..6ab51a4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import QuizSection from "./components/QuizSection"; import SolutionsAwareness from "./components/SolutionsAwareness"; import ScenarioSimulator from "./components/ScenarioSimulator"; import HistoricalAnalysis from "./components/HistoricalAnalysis"; +import HistoricalPollutionExplorer from "./components/HistoricalPollutionExplorer"; import Factoid from "./components/Factoid"; import HistoricalData from "./components/HistoricalData"; import LocationSearch from "./components/LocationSearch"; @@ -327,6 +328,7 @@ function SectionNav({ activeSection, onSectionChange }) { { id: "exposure-tracker", label: "Exposure Score" }, { id: "history", label: "History" }, { id: "historical-data", label: "Data Explorer" }, + { id: "historical-explorer", label: "Pollution Explorer" }, { id: "Commute", label: "Commute" }, { id: "CarbonCalculator", label: "Carbon Calculator" }, { id: "glossary", label: "Glossary" }, @@ -1223,11 +1225,16 @@ function AppContent() { )} - {activeSection === "historical-data" && ( -
- -
- )} + {activeSection === "historical-data" && ( +
+ +
+ )} + {activeSection === "historical-explorer" && ( +
+ +
+ )} {activeSection === "quiz" && (
@@ -1358,4 +1365,4 @@ export default function App() { ); -} \ No newline at end of file +} diff --git a/src/components/HistoricalPollutionExplorer.jsx b/src/components/HistoricalPollutionExplorer.jsx new file mode 100644 index 0000000..5257b9e --- /dev/null +++ b/src/components/HistoricalPollutionExplorer.jsx @@ -0,0 +1,646 @@ +// src/components/HistoricalPollutionExplorer.jsx +// @ts-nocheck +// ----------------------------------------------------------------------------- +// Issue #892 — Historical Pollution Explorer +// +// The interactive "investigate pollution over long periods" UI. +// +// Capabilities (mirrors the issue's acceptance criteria): +// 1. Historical data storage — reuses historicalDataService's +// IndexedDB cache (already done). +// 2. Timestamped pollutant readings — Open-Meteo hourly API (already +// done); we aggregate to daily. +// 3. Date-range selector — start/end date inputs + presets. +// 4. City/location selector — multi-select from CITY_COORDINATES, +// enabling single-city view + multi- +// city comparison overlay. +// 5. Pollutant selector — PM2.5, PM10, NO₂, Ozone, CO, AQI. +// 6. Line charts, bar charts, moving averages +// — recharts LineChart/BarChart with +// a 7-day moving average overlay. +// 7. Daily/weekly/monthly/yearly views — view toggle. +// 8. Identify highest pollution periods +// — top-5 worst buckets rendered as +// a ranked list. +// 9. Calculate percentage changes — half-range change between first +// and second half of selected range. +// 10. Export to CSV — reuses formatHistoricalCSV for +// daily; buildExplorerCsv for +// aggregated views. +// 11. Comparison between locations — overlay line chart for up to 4 +// cities on the same axis. +// ----------------------------------------------------------------------------- + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer, ReferenceLine, +} from 'recharts'; +import { CITY_COORDINATES, SAFE_LIMITS } from '../constants/cities'; +import { + POLLUTANTS, VIEWS, getPollutantByKey, resampleToView, + computeMovingAverage, computePercentageChange, + computeHalfRangeChange, identifyHighestPeriods, buildExplorerCsv, +} from '../utils/historicalExplorer'; +import { fetchHistoricalForLocations } from '../services/historicalExplorerService'; +import { formatHistoricalCSV } from '../services/historicalDataService'; + +const CHART_COLORS = ['#ef4444', '#10b981', '#3b82f6', '#f59e0b']; + +const DATE_PRESETS = [ + { id: '30d', label: 'Last 30 days', years: 0, daysBack: 30 }, + { id: '3mo', label: 'Last 3 months', years: 0, daysBack: 90 }, + { id: '1y', label: 'Last 1 year', years: 1, daysBack: 0 }, + { id: '3y', label: 'Last 3 years', years: 3, daysBack: 0 }, + { id: 'janAug', label: 'Jan – Aug (this year)', years: 0, daysBack: 0, janAug: true }, +]; + +export default function HistoricalPollutionExplorer({ position }) { + const { t } = useTranslation(); + + const [selectedCityNames, setSelectedCityNames] = useState(() => { + const match = position?.cityName + ? CITY_COORDINATES.find((c) => c.name === position.cityName) + : null; + return match ? [match.name] : [CITY_COORDINATES[0].name]; + }); + + const [pollutantKey, setPollutantKey] = useState('pm25'); + const [view, setView] = useState('monthly'); + const [years, setYears] = useState(3); + const [activePreset, setActivePreset] = useState('3y'); + + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [locationsData, setLocationsData] = useState([]); + const [chartType, setChartType] = useState('line'); + + const selectedCities = useMemo( + () => CITY_COORDINATES.filter((c) => selectedCityNames.includes(c.name)), + [selectedCityNames], + ); + + const load = useCallback(async () => { + if (selectedCities.length === 0) return; + setLoading(true); + setError(null); + try { + const results = await fetchHistoricalForLocations(selectedCities, years); + setLocationsData(results); + const anyError = results.find((r) => r.error); + if (anyError && results.every((r) => r.error)) { + setError(anyError.error); + } + } catch (err) { + setError(err?.message || 'Failed to load historical data'); + } finally { + setLoading(false); + } + }, [selectedCities, years]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!startDate && !endDate && locationsData.length > 0) { + const firstDaily = locationsData[0]?.data?.daily; + if (firstDaily && firstDaily.length > 0) { + setStartDate(firstDaily[0].date); + setEndDate(firstDaily[firstDaily.length - 1].date); + } + } + }, [locationsData, startDate, endDate]); + + const applyPreset = useCallback((presetId) => { + const preset = DATE_PRESETS.find((p) => p.id === presetId); + if (!preset) return; + setActivePreset(presetId); + if (preset.years) { + setYears(preset.years); + } else if (preset.janAug) { + const now = new Date(); + const y = now.getUTCFullYear(); + setStartDate(`${y}-01-01`); + setEndDate(`${y}-08-31`); + setYears(1); + } else if (preset.daysBack) { + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - preset.daysBack); + setStartDate(start.toISOString().split('T')[0]); + setEndDate(end.toISOString().split('T')[0]); + setYears(1); + } + }, []); + + const primary = locationsData[0]; + const primaryDaily = useMemo(() => { + if (!primary?.data?.daily) return []; + let rows = primary.data.daily; + if (startDate) rows = rows.filter((r) => r.date >= startDate); + if (endDate) rows = rows.filter((r) => r.date <= endDate); + return rows; + }, [primary, startDate, endDate]); + + const primaryResampled = useMemo( + () => resampleToView(primaryDaily, view), + [primaryDaily, view], + ); + + const pollutant = getPollutantByKey(pollutantKey); + + const movingAverage = useMemo( + () => computeMovingAverage(primaryResampled, pollutant.out, 7), + [primaryResampled, pollutant.out], + ); + + const chartData = useMemo(() => { + return primaryResampled.map((row, i) => ({ + ...row, + ma: movingAverage[i], + })); + }, [primaryResampled, movingAverage]); + + const halfRangeChange = useMemo( + () => computeHalfRangeChange(primaryResampled, pollutant.out), + [primaryResampled, pollutant.out], + ); + + const highestPeriods = useMemo( + () => identifyHighestPeriods(primaryResampled, pollutant.out, 5), + [primaryResampled, pollutant.out], + ); + + const overallStats = useMemo(() => { + const values = primaryResampled + .map((r) => r[pollutant.out]) + .filter((v) => typeof v === 'number' && Number.isFinite(v)); + if (values.length === 0) { + return { mean: null, min: null, max: null }; + } + const sum = values.reduce((a, b) => a + b, 0); + return { + mean: Math.round((sum / values.length) * 10) / 10, + min: Math.min(...values), + max: Math.max(...values), + }; + }, [primaryResampled, pollutant.out]); + + const compareData = useMemo(() => { + if (locationsData.length <= 1) return []; + const byLabel = new Map(); + for (const result of locationsData) { + if (!result?.data?.daily) continue; + let rows = result.data.daily; + if (startDate) rows = rows.filter((r) => r.date >= startDate); + if (endDate) rows = rows.filter((r) => r.date <= endDate); + const resampled = resampleToView(rows, view); + for (const row of resampled) { + if (!byLabel.has(row.label)) { + byLabel.set(row.label, { label: row.label }); + } + const entry = byLabel.get(row.label); + entry[result.location.name] = row[pollutant.out]; + } + } + return Array.from(byLabel.values()).sort((a, b) => + a.label.localeCompare(b.label), + ); + }, [locationsData, view, pollutant.out, startDate, endDate]); + + const handleExportCSV = useCallback(() => { + if (locationsData.length === 0) return; + let csv; + if (view === 'daily' && primary?.data?.daily) { + csv = formatHistoricalCSV(primary.data.daily, startDate, endDate); + } else { + csv = buildExplorerCsv(primaryResampled, pollutantKey, view); + } + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + const citySlug = + (primary?.location?.name || 'historical') + .toLowerCase() + .replace(/[^a-z0-9]/g, '_'); + link.download = `${citySlug}_${pollutantKey}_${view}_${startDate}_to_${endDate}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [locationsData, view, primary, primaryResampled, pollutantKey, startDate, endDate]); + + const toggleCity = useCallback((name) => { + setSelectedCityNames((prev) => { + if (prev.includes(name)) { + if (prev.length === 1) return prev; + return prev.filter((n) => n !== name); + } + if (prev.length >= 4) return prev; + return [...prev, name]; + }); + }, []); + + if (loading) { + return ( +
+
+

{t('historicalExplorer.loading', 'Loading historical pollution data…')}

+
+ ); + } + + if (error) { + return ( +
+

{t('historicalExplorer.error', 'Error: {{error}}', { error })}

+
+ ); + } + + return ( +
+
+

+ {t('historicalExplorer.title', 'Historical Pollution Explorer')} +

+

+ {t('historicalExplorer.subtitle', 'Compare pollution across years, cities, and pollutants.')} +

+
+ + {/* ── Control row ───────────────────────────────────────────── */} +
+
+ + +
+ {selectedCityNames.map((name) => ( + + ))} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* ── Date preset row ──────────────────────────────────────── */} +
+ {DATE_PRESETS.map((p) => ( + + ))} +
+ + {/* ── Date range inputs ────────────────────────────────────── */} +
+
+ + { setStartDate(e.target.value); setActivePreset(''); }} + data-testid="explorer-start-date" + style={inputStyle} + /> +
+
+ + { setEndDate(e.target.value); setActivePreset(''); }} + data-testid="explorer-end-date" + style={inputStyle} + /> +
+ +
+ + {/* ── Stats strip ──────────────────────────────────────────── */} +
+ + + + 0 ? '+' : ''}${halfRangeChange}%`} + tone={halfRangeChange == null ? 'neutral' : halfRangeChange > 0 ? 'bad' : 'good'} + /> +
+ + {/* ── Main chart ───────────────────────────────────────────── */} +

+ {t('historicalExplorer.chartTitle', '{{pollutant}} — {{view}} view', { + pollutant: pollutant.label, + view, + })} +

+
+ + {chartType === 'line' ? ( + + + + + + + + {view === 'daily' && ( + + )} + {pollutant.safeLimit != null && ( + + )} + + ) : ( + + + + + + + {pollutant.safeLimit != null && ( + + )} + + )} + +
+ + {/* ── Comparison chart (only when >1 city) ────────────────── */} + {selectedCities.length > 1 && compareData.length > 0 && ( + <> +

+ {t('historicalExplorer.compareTitle', 'Multi-city comparison — {{pollutant}}', { + pollutant: pollutant.label, + })} +

+
+ + + + + + + + {selectedCities.map((city, i) => ( + + ))} + + +
+ + )} + + {/* ── Highest periods ─────────────────────────────────────── */} + {highestPeriods.length > 0 && ( +
+

+ {t('historicalExplorer.highestPeriods', 'Highest {{pollutant}} periods', { + pollutant: pollutant.label, + })} +

+
    + {highestPeriods.map((p) => ( +
  1. + {p.label} — {p.value} {pollutant.unit} + + ({p.days} {p.days === 1 ? 'day' : 'days'}) + +
  2. + ))} +
+
+ )} +
+ ); +} + +const inputStyle = { + padding: '0.5rem', + borderRadius: '6px', + border: '1px solid var(--line, #e2e8f0)', + background: 'var(--card, #fff)', + color: 'var(--ink, #0f172a)', + fontSize: '0.9rem', + fontFamily: 'inherit', + outline: 'none', +}; + +const sectionHeaderStyle = { + fontSize: '1.1rem', + fontWeight: 500, + margin: '0 0 1rem 0', + color: 'var(--ink, #0f172a)', +}; + +function StatBox({ label, value, tone = 'neutral' }) { + const toneColor = + tone === 'bad' ? '#ef4444' : tone === 'good' ? '#10b981' : 'inherit'; + return ( +
+

{label}

+

+ {value} +

+
+ ); +} diff --git a/src/components/HistoricalPollutionExplorer.test.jsx b/src/components/HistoricalPollutionExplorer.test.jsx new file mode 100644 index 0000000..730a91f --- /dev/null +++ b/src/components/HistoricalPollutionExplorer.test.jsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import HistoricalPollutionExplorer from './HistoricalPollutionExplorer'; + +vi.mock('../services/historicalExplorerService', () => ({ + fetchHistoricalForLocations: vi.fn(), +})); + +vi.mock('recharts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ResponsiveContainer: ({ children }) => ( +
{children}
+ ), + }; +}); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, fallbackOrTemplate) => { + if (typeof fallbackOrTemplate === 'string') return fallbackOrTemplate; + if (typeof fallbackOrTemplate === 'function') return fallbackOrTemplate(); + return key; + }, + }), +})); + +import { fetchHistoricalForLocations } from '../services/historicalExplorerService'; + +function makeDailyData(startDateStr, n) { + const start = new Date(startDateStr + 'T00:00:00Z'); + const daily = []; + for (let i = 0; i < n; i++) { + const d = new Date(start); + d.setUTCDate(start.getUTCDate() + i); + const date = d.toISOString().split('T')[0]; + daily.push({ + date, avgAqi: 50 + (i % 50), maxAqi: 60 + (i % 50), + pm25: 20 + (i % 20), pm10: 40 + (i % 40), no2: 15 + (i % 15), + ozone: 30 + (i % 30), co: 500 + (i % 200), + hasReading: true, hoursMeasured: 24, + }); + } + return { daily, monthly: [], overallAvg: 60, daysInRange: n, daysWithReadings: n }; +} + +describe('HistoricalPollutionExplorer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders a loading state initially', async () => { + fetchHistoricalForLocations.mockReturnValueOnce(new Promise(() => {})); + render(); + expect(screen.getByTestId('historical-explorer-loading')).toBeTruthy(); + }); + + it('renders the controls + main chart after data loads', async () => { + fetchHistoricalForLocations.mockResolvedValueOnce([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: makeDailyData('2024-01-01', 90), + error: null, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('historical-explorer')).toBeTruthy(); + }); + + expect(screen.getByTestId('explorer-cities')).toBeTruthy(); + expect(screen.getByTestId('explorer-pollutant')).toBeTruthy(); + expect(screen.getByTestId('explorer-view')).toBeTruthy(); + expect(screen.getByTestId('explorer-chart-type')).toBeTruthy(); + expect(screen.getByTestId('explorer-main-chart')).toBeTruthy(); + expect(screen.getByTestId('explorer-stats')).toBeTruthy(); + }); + + it('renders an error state when the fetch fails', async () => { + fetchHistoricalForLocations.mockResolvedValueOnce([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: null, + error: 'Network down', + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('historical-explorer-error')).toBeTruthy(); + }); + expect(screen.getByTestId('historical-explorer-error').textContent).toContain('Network down'); + }); + + it('renders the comparison chart when more than one city is selected', async () => { + fetchHistoricalForLocations.mockResolvedValue([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: makeDailyData('2024-01-01', 60), + error: null, + }, + { + location: { name: 'Delhi', lat: 28.6139, lon: 77.209 }, + data: makeDailyData('2024-01-01', 60), + error: null, + }, + ]); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('historical-explorer')).toBeTruthy(); + }); + + const citiesSelect = screen.getByTestId('explorer-cities'); + fireEvent.change(citiesSelect, { target: { value: ['Pune', 'Delhi'] } }); + + await waitFor(() => { + expect(fetchHistoricalForLocations).toHaveBeenCalledTimes(2); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('explorer-compare-chart')).toBeTruthy(); + }); + }); + + it('calls export on CSV button click', async () => { + const mockData = makeDailyData('2024-01-01', 60); + fetchHistoricalForLocations.mockResolvedValueOnce([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: mockData, + error: null, + }, + ]); + + global.URL.createObjectURL = vi.fn(() => 'blob:mock'); + global.URL.revokeObjectURL = vi.fn(); + const clickSpy = vi.fn(); + HTMLAnchorElement.prototype.click = clickSpy; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('explorer-export-csv')).toBeTruthy(); + }); + + fireEvent.click(screen.getByTestId('explorer-export-csv')); + expect(global.URL.createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('changes the pollutant and updates the chart label', async () => { + fetchHistoricalForLocations.mockResolvedValue([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: makeDailyData('2024-01-01', 60), + error: null, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('historical-explorer')).toBeTruthy(); + }); + + expect(screen.getByTestId('explorer-main-chart').textContent).toContain('PM2.5'); + + fireEvent.change(screen.getByTestId('explorer-pollutant'), { target: { value: 'no2' } }); + + expect(screen.getByTestId('explorer-main-chart').textContent).toContain('NO'); + }); + + it('changes the view granularity and re-renders the chart', async () => { + fetchHistoricalForLocations.mockResolvedValue([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: makeDailyData('2024-01-01', 180), + error: null, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('historical-explorer')).toBeTruthy(); + }); + + expect(screen.getByTestId('explorer-main-chart').textContent).toContain('monthly'); + + fireEvent.change(screen.getByTestId('explorer-view'), { target: { value: 'yearly' } }); + expect(screen.getByTestId('explorer-main-chart').textContent).toContain('yearly'); + }); + + it('renders the highest-pollution periods list when data is present', async () => { + fetchHistoricalForLocations.mockResolvedValueOnce([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: makeDailyData('2024-01-01', 180), + error: null, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('historical-explorer')).toBeTruthy(); + }); + + expect(screen.getByTestId('explorer-highest-periods')).toBeTruthy(); + const items = screen.getByTestId('explorer-highest-periods').querySelectorAll('li'); + expect(items.length).toBeGreaterThan(0); + }); + + it('renders a chip for the selected city', async () => { + fetchHistoricalForLocations.mockResolvedValueOnce([ + { + location: { name: 'Pune', lat: 18.5204, lon: 73.8567 }, + data: makeDailyData('2024-01-01', 30), + error: null, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('city-chip-Pune')).toBeTruthy(); + }); + }); +}); diff --git a/src/services/historicalExplorerService.js b/src/services/historicalExplorerService.js new file mode 100644 index 0000000..ec02031 --- /dev/null +++ b/src/services/historicalExplorerService.js @@ -0,0 +1,39 @@ +// src/services/historicalExplorerService.js +// ----------------------------------------------------------------------------- +// Issue #892 — Historical Pollution Explorer +// +// Thin wrapper around `historicalDataService.fetchHistoricalData` and +// `historicalAggregate.aggregateHourlyToDaily` that adds: +// +// - fetchHistoricalDaily(lat, lon, years) — one-shot fetch + aggregate +// - fetchHistoricalForLocations(locations, years) — parallel fetch + +// aggregate for multiple cities, used by the comparison view. +// ----------------------------------------------------------------------------- + +import { fetchHistoricalData } from './historicalDataService'; +import { aggregateHourlyToDaily } from '../utils/historicalAggregate'; + +export async function fetchHistoricalDaily(lat, lon, years = 1) { + const raw = await fetchHistoricalData(lat, lon, years); + return aggregateHourlyToDaily(raw); +} + +export async function fetchHistoricalForLocations(locations, years = 1) { + if (!Array.isArray(locations) || locations.length === 0) return []; + + const settled = await Promise.allSettled( + locations.map((loc) => fetchHistoricalDaily(loc.lat, loc.lon, years)), + ); + + return settled.map((res, i) => { + const location = locations[i]; + if (res.status === 'fulfilled') { + return { location, data: res.value, error: null }; + } + const msg = + res.reason instanceof Error + ? res.reason.message + : 'Failed to fetch historical data'; + return { location, data: null, error: msg }; + }); +} diff --git a/src/utils/historicalExplorer.js b/src/utils/historicalExplorer.js new file mode 100644 index 0000000..f46adf0 --- /dev/null +++ b/src/utils/historicalExplorer.js @@ -0,0 +1,201 @@ +// src/utils/historicalExplorer.js +// ----------------------------------------------------------------------------- +// Issue #892 — Historical Pollution Explorer +// +// Pure helpers that turn daily aggregated pollution data into the +// shapes the Explorer UI needs: +// - resampleToView() — daily → weekly / monthly / yearly rolls +// - computeMovingAverage() — N-day sliding window over a series +// - computePercentageChange() — period-over-period delta % +// - identifyHighestPeriods() — top-N worst intervals +// - buildExplorerCsv() — CSV export with the user's selected +// pollutant + view granularity +// +// All functions are pure (no React, no fetch, no DOM) so they can be +// unit-tested in isolation, mirroring the pattern in +// `src/utils/historicalAggregate.js`. +// ----------------------------------------------------------------------------- + +export const POLLUTANTS = [ + { key: 'aqi', label: 'AQI', unit: '', in: 'us_aqi', out: 'avgAqi', safeLimit: null }, + { key: 'pm25', label: 'PM2.5', unit: 'µg/m³', in: 'pm2_5', out: 'pm25', safeLimit: 15 }, + { key: 'pm10', label: 'PM10', unit: 'µg/m³', in: 'pm10', out: 'pm10', safeLimit: 45 }, + { key: 'no2', label: 'NO₂', unit: 'µg/m³', in: 'nitrogen_dioxide', out: 'no2', safeLimit: 25 }, + { key: 'ozone', label: 'Ozone', unit: 'µg/m³', in: 'ozone', out: 'ozone', safeLimit: 100 }, + { key: 'co', label: 'CO', unit: 'µg/m³', in: 'carbon_monoxide', out: 'co', safeLimit: 4000 }, +]; + +export function getPollutantByKey(key) { + return POLLUTANTS.find((p) => p.key === key) ?? POLLUTANTS[0]; +} + +export const VIEWS = ['daily', 'weekly', 'monthly', 'yearly']; + +function isReading(value) { + return typeof value === 'number' && Number.isFinite(value); +} + +function mean(sum, count, decimals = 1) { + if (count <= 0) return null; + const factor = 10 ** decimals; + return Math.round((sum / count) * factor) / factor; +} + +function isoWeekKey(dateStr) { + const d = new Date(dateStr + 'T00:00:00Z'); + if (Number.isNaN(d.getTime())) return null; + const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + const dayNum = tmp.getUTCDay() || 7; + tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1)); + const weekNum = Math.ceil(((tmp - yearStart) / 86400000 + 1) / 7); + return `${tmp.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`; +} + +export function resampleToView(dailyRows, view = 'daily') { + if (!Array.isArray(dailyRows) || dailyRows.length === 0) return []; + if (!VIEWS.includes(view)) { + throw new Error(`Unknown view: ${view}. Must be one of ${VIEWS.join(', ')}`); + } + + if (view === 'daily') { + return dailyRows.map((row) => ({ ...row, label: row.date, start: row.date })); + } + + const buckets = new Map(); + + for (const row of dailyRows) { + if (!row || !row.date) continue; + let key, label, start; + if (view === 'weekly') { + key = isoWeekKey(row.date); + if (!key) continue; + label = key; + const [yStr, wStr] = key.split('-W'); + const year = Number(yStr); + const week = Number(wStr); + const jan4 = new Date(Date.UTC(year, 0, 4)); + const jan4Day = jan4.getUTCDay() || 7; + const week1Monday = new Date(jan4); + week1Monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1)); + const monday = new Date(week1Monday); + monday.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7); + start = monday.toISOString().split('T')[0]; + } else if (view === 'monthly') { + key = row.date.substring(0, 7); + label = key; + start = `${key}-01`; + } else { + key = row.date.substring(0, 4); + label = key; + start = `${key}-01-01`; + } + + if (!buckets.has(key)) { + const stats = { label, start, days: 0 }; + for (const p of POLLUTANTS) { + stats[p.out] = { sum: 0, count: 0, max: -Infinity }; + } + buckets.set(key, stats); + } + const bucket = buckets.get(key); + bucket.days += 1; + for (const p of POLLUTANTS) { + const v = row[p.out]; + if (isReading(v)) { + bucket[p.out].sum += v; + bucket[p.out].count += 1; + if (v > bucket[p.out].max) bucket[p.out].max = v; + } + } + } + + const out = []; + for (const stats of buckets.values()) { + const row = { label: stats.label, start: stats.start, days: stats.days }; + for (const p of POLLUTANTS) { + const { sum, count, max } = stats[p.out]; + row[p.out] = mean(sum, count, 1); + row[`${p.out}_max`] = count > 0 ? Math.round(max) : null; + } + out.push(row); + } + out.sort((a, b) => a.start.localeCompare(b.start)); + return out; +} + +export function computeMovingAverage(rows, field, window = 7) { + if (!Array.isArray(rows) || rows.length === 0) return []; + if (typeof window !== 'number' || window < 1) { + throw new Error('window must be a positive integer'); + } + const out = new Array(rows.length).fill(null); + if (window > rows.length) return out; + let sum = 0; + let count = 0; + for (let i = 0; i < rows.length; i++) { + const incoming = rows[i]?.[field]; + if (isReading(incoming)) { + sum += incoming; + count += 1; + } + if (i >= window) { + const outgoing = rows[i - window]?.[field]; + if (isReading(outgoing)) { + sum -= outgoing; + count -= 1; + } + } + if (i >= window - 1 && count > 0) { + out[i] = Math.round((sum / count) * 10) / 10; + } + } + return out; +} + +export function computePercentageChange(oldValue, newValue) { + if (!isReading(oldValue) || !isReading(newValue)) return null; + if (oldValue === 0) return null; + const pct = ((newValue - oldValue) / Math.abs(oldValue)) * 100; + return Math.round(pct * 10) / 10; +} + +export function computeHalfRangeChange(rows, field) { + if (!Array.isArray(rows) || rows.length < 2) return null; + const mid = Math.floor(rows.length / 2); + const firstHalf = rows.slice(0, mid).map((r) => r?.[field]).filter(isReading); + const secondHalf = rows.slice(mid).map((r) => r?.[field]).filter(isReading); + if (firstHalf.length === 0 || secondHalf.length === 0) return null; + const firstMean = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length; + const secondMean = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length; + return computePercentageChange(firstMean, secondMean); +} + +export function identifyHighestPeriods(rows, field, topN = 5) { + if (!Array.isArray(rows) || rows.length === 0) return []; + const withValue = rows + .filter((r) => isReading(r?.[field])) + .map((r) => ({ + label: r.label, + start: r.start, + days: r.days ?? 1, + value: r[field], + })); + withValue.sort((a, b) => { + if (b.value !== a.value) return b.value - a.value; + return b.days - a.days; + }); + return withValue.slice(0, topN); +} + +export function buildExplorerCsv(rows, pollutantKey, view) { + const p = getPollutantByKey(pollutantKey); + const header = ['Period', 'Start', 'Days', `${p.label} Mean`, `${p.label} Max`]; + if (!Array.isArray(rows) || rows.length === 0) return header.join(','); + const lines = rows.map((r) => { + const meanVal = r[p.out] ?? ''; + const maxVal = r[`${p.out}_max`] ?? ''; + return [r.label, r.start, r.days ?? '', meanVal, maxVal].join(','); + }); + return [header.join(','), ...lines].join('\n'); +} diff --git a/src/utils/historicalExplorer.test.js b/src/utils/historicalExplorer.test.js new file mode 100644 index 0000000..604b95b --- /dev/null +++ b/src/utils/historicalExplorer.test.js @@ -0,0 +1,287 @@ +import { describe, it, expect } from 'vitest'; +import { + POLLUTANTS, VIEWS, getPollutantByKey, + resampleToView, computeMovingAverage, computePercentageChange, + computeHalfRangeChange, identifyHighestPeriods, buildExplorerCsv, +} from './historicalExplorer'; + +function makeDailyRow(date, overrides = {}) { + return { + date, avgAqi: 50, maxAqi: 60, pm25: 20, pm10: 40, + no2: 15, ozone: 30, co: 500, hasReading: true, hoursMeasured: 24, + ...overrides, + }; +} + +function makeDays(startDateStr, n) { + const start = new Date(startDateStr + 'T00:00:00Z'); + const out = []; + for (let i = 0; i < n; i++) { + const d = new Date(start); + d.setUTCDate(start.getUTCDate() + i); + out.push(makeDailyRow(d.toISOString().split('T')[0])); + } + return out; +} + +describe('POLLUTANTS / VIEWS / getPollutantByKey', () => { + it('exposes the expected pollutant set', () => { + const keys = POLLUTANTS.map((p) => p.key); + expect(keys).toEqual(['aqi', 'pm25', 'pm10', 'no2', 'ozone', 'co']); + }); + + it('exposes the four view granularities', () => { + expect(VIEWS).toEqual(['daily', 'weekly', 'monthly', 'yearly']); + }); + + it('looks up a pollutant by key', () => { + expect(getPollutantByKey('pm25').label).toBe('PM2.5'); + expect(getPollutantByKey('no2').unit).toBe('µg/m³'); + }); + + it('falls back to AQI for an unknown key', () => { + expect(getPollutantByKey('not-a-real-key').key).toBe('aqi'); + }); +}); + +describe('resampleToView', () => { + it('returns an empty array for empty input', () => { + expect(resampleToView([], 'daily')).toEqual([]); + expect(resampleToView([], 'monthly')).toEqual([]); + }); + + it('throws on an unknown view', () => { + expect(() => resampleToView([{ date: '2024-01-01' }], 'hourly')).toThrow(); + }); + + it('daily view is a pass-through with label/start injected', () => { + const rows = [makeDailyRow('2024-01-01'), makeDailyRow('2024-01-02')]; + const out = resampleToView(rows, 'daily'); + expect(out).toHaveLength(2); + expect(out[0].label).toBe('2024-01-01'); + expect(out[0].start).toBe('2024-01-01'); + expect(out[0].pm25).toBe(20); + }); + + it('monthly view buckets by YYYY-MM', () => { + const rows = makeDays('2024-01-01', 60); + const out = resampleToView(rows, 'monthly'); + expect(out).toHaveLength(2); + expect(out[0].label).toBe('2024-01'); + expect(out[0].start).toBe('2024-01-01'); + expect(out[1].label).toBe('2024-02'); + expect(out[1].start).toBe('2024-02-01'); + expect(out[0].days).toBe(31); + expect(out[1].days).toBe(29); + expect(out[0].pm25).toBe(20); + expect(out[0].pm25_max).toBe(20); + }); + + it('yearly view buckets by YYYY', () => { + const rows = makeDays('2023-06-01', 365 + 180); + const out = resampleToView(rows, 'yearly'); + expect(out).toHaveLength(2); + expect(out[0].label).toBe('2023'); + expect(out[1].label).toBe('2024'); + expect(out[0].days + out[1].days).toBe(365 + 180); + }); + + it('weekly view produces ISO week keys', () => { + const rows = makeDays('2024-01-01', 14); + const out = resampleToView(rows, 'weekly'); + expect(out).toHaveLength(2); + expect(out[0].label).toBe('2024-W01'); + expect(out[1].label).toBe('2024-W02'); + expect(out[0].days).toBe(7); + }); + + it('ignores null readings in the mean', () => { + const rows = [ + makeDailyRow('2024-01-01', { pm25: 10 }), + makeDailyRow('2024-01-02', { pm25: null }), + makeDailyRow('2024-01-03', { pm25: 30 }), + ]; + const out = resampleToView(rows, 'monthly'); + expect(out[0].pm25).toBe(20); + }); + + it('sorts output ascending by start date regardless of input order', () => { + const rows = [ + makeDailyRow('2024-03-01'), + makeDailyRow('2024-01-01'), + makeDailyRow('2024-02-01'), + ]; + const out = resampleToView(rows, 'monthly'); + expect(out.map((r) => r.label)).toEqual(['2024-01', '2024-02', '2024-03']); + }); +}); + +describe('computeMovingAverage', () => { + it('returns an empty array for empty input', () => { + expect(computeMovingAverage([], 'pm25', 7)).toEqual([]); + }); + + it('throws on a non-positive window', () => { + expect(() => computeMovingAverage([{ pm25: 1 }], 'pm25', 0)).toThrow(); + expect(() => computeMovingAverage([{ pm25: 1 }], 'pm25', -3)).toThrow(); + }); + + it('emits null for the first window-1 points', () => { + const rows = makeDays('2024-01-01', 10).map((r, i) => ({ ...r, pm25: 10 + i })); + const ma = computeMovingAverage(rows, 'pm25', 7); + expect(ma.slice(0, 6)).toEqual([null, null, null, null, null, null]); + expect(ma[6]).not.toBeNull(); + }); + + it('returns a smaller-than-window series of all nulls', () => { + const rows = makeDays('2024-01-01', 3); + const ma = computeMovingAverage(rows, 'pm25', 7); + expect(ma).toEqual([null, null, null]); + }); + + it('computes the correct mean over a sliding window', () => { + const rows = [{ pm25: 10 }, { pm25: 20 }, { pm25: 30 }, { pm25: 40 }]; + const ma = computeMovingAverage(rows, 'pm25', 2); + expect(ma).toEqual([null, 15, 25, 35]); + }); + + it('skips null values without shrinking the window count', () => { + const rows = [{ pm25: 10 }, { pm25: null }, { pm25: 30 }, { pm25: 40 }]; + const ma = computeMovingAverage(rows, 'pm25', 3); + expect(ma[2]).toBe(20); + }); +}); + +describe('computePercentageChange', () => { + it('returns null for null or undefined inputs', () => { + expect(computePercentageChange(null, 10)).toBeNull(); + expect(computePercentageChange(10, null)).toBeNull(); + expect(computePercentageChange(undefined, 10)).toBeNull(); + }); + + it('returns null for a non-finite input', () => { + expect(computePercentageChange(NaN, 10)).toBeNull(); + expect(computePercentageChange(10, Infinity)).toBeNull(); + }); + + it('returns null when the old value is 0', () => { + expect(computePercentageChange(0, 10)).toBeNull(); + }); + + it('returns +X% for a positive change', () => { + expect(computePercentageChange(100, 120)).toBe(20); + }); + + it('returns -X% for a negative change', () => { + expect(computePercentageChange(120, 100)).toBe(-16.7); + }); + + it('returns 0 for no change', () => { + expect(computePercentageChange(50, 50)).toBe(0); + }); + + it('handles a negative old value correctly', () => { + expect(computePercentageChange(-20, -10)).toBe(50); + }); +}); + +describe('computeHalfRangeChange', () => { + it('returns null for an empty series', () => { + expect(computeHalfRangeChange([], 'pm25')).toBeNull(); + }); + + it('returns null for a single-row series', () => { + expect(computeHalfRangeChange([{ pm25: 10 }], 'pm25')).toBeNull(); + }); + + it('returns null when one half has no readings', () => { + const rows = [{ pm25: 10 }, { pm25: 20 }, { pm25: null }, { pm25: null }]; + expect(computeHalfRangeChange(rows, 'pm25')).toBeNull(); + }); + + it('returns the percentage change between halves', () => { + const rows = [{ pm25: 10 }, { pm25: 20 }, { pm25: 30 }, { pm25: 30 }]; + expect(computeHalfRangeChange(rows, 'pm25')).toBe(100); + }); +}); + +describe('identifyHighestPeriods', () => { + it('returns an empty array for empty input', () => { + expect(identifyHighestPeriods([], 'pm25', 5)).toEqual([]); + }); + + it('returns the top-N highest by value', () => { + const rows = [ + { label: 'A', start: '2024-01-01', days: 30, pm25: 10 }, + { label: 'B', start: '2024-02-01', days: 28, pm25: 80 }, + { label: 'C', start: '2024-03-01', days: 31, pm25: 50 }, + { label: 'D', start: '2024-04-01', days: 30, pm25: 90 }, + { label: 'E', start: '2024-05-01', days: 31, pm25: 60 }, + ]; + const out = identifyHighestPeriods(rows, 'pm25', 3); + expect(out).toHaveLength(3); + expect(out[0].label).toBe('D'); + expect(out[1].label).toBe('B'); + expect(out[2].label).toBe('E'); + }); + + it('breaks ties by days (sustained > spike)', () => { + const rows = [ + { label: 'A', start: '2024-01-01', days: 7, pm25: 50 }, + { label: 'B', start: '2024-02-01', days: 30, pm25: 50 }, + ]; + const out = identifyHighestPeriods(rows, 'pm25', 2); + expect(out[0].label).toBe('B'); + }); + + it('filters out rows with null values for the field', () => { + const rows = [ + { label: 'A', start: '2024-01-01', days: 30, pm25: 10 }, + { label: 'B', start: '2024-02-01', days: 28, pm25: null }, + { label: 'C', start: '2024-03-01', days: 31, pm25: 40 }, + ]; + const out = identifyHighestPeriods(rows, 'pm25', 5); + expect(out).toHaveLength(2); + expect(out.map((r) => r.label)).toEqual(['C', 'A']); + }); + + it('respects the topN cap', () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ + label: `L${i}`, start: `2024-0${(i % 9) + 1}-01`, days: 30, pm25: i, + })); + expect(identifyHighestPeriods(rows, 'pm25', 5)).toHaveLength(5); + }); +}); + +describe('buildExplorerCsv', () => { + it('returns a header-only CSV for empty input', () => { + const csv = buildExplorerCsv([], 'pm25', 'monthly'); + expect(csv).toBe('Period,Start,Days,PM2.5 Mean,PM2.5 Max'); + }); + + it('renders one row per input entry', () => { + const rows = [ + { label: '2024-01', start: '2024-01-01', days: 31, pm25: 22.5, pm25_max: 60 }, + { label: '2024-02', start: '2024-02-01', days: 29, pm25: 18.2, pm25_max: 45 }, + ]; + const csv = buildExplorerCsv(rows, 'pm25', 'monthly'); + const lines = csv.split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('Period,Start,Days,PM2.5 Mean,PM2.5 Max'); + expect(lines[1]).toBe('2024-01,2024-01-01,31,22.5,60'); + expect(lines[2]).toBe('2024-02,2024-02-01,29,18.2,45'); + }); + + it('adapts the header to the selected pollutant', () => { + const rows = [{ label: '2024', start: '2024-01-01', days: 365, avgAqi: 80, avgAqi_max: 200 }]; + const csv = buildExplorerCsv(rows, 'aqi', 'yearly'); + expect(csv.split('\n')[0]).toBe('Period,Start,Days,AQI Mean,AQI Max'); + }); + + it('emits empty strings for null values', () => { + const rows = [{ label: '2024-01', start: '2024-01-01', days: 31, pm25: null, pm25_max: null }]; + const csv = buildExplorerCsv(rows, 'pm25', 'monthly'); + const lines = csv.split('\n'); + expect(lines[1]).toBe('2024-01,2024-01-01,31,,'); + }); +});