Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions src/components/LocationMap.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ import { MapContainer, TileLayer, CircleMarker, Popup, Marker } from 'react-leaf
import { useState, useEffect } from 'react';
import L from 'leaflet';
import { eventBus } from '../core/events';
import { getPollutantColor } from '../services/airQualityService';
import PropTypes from "prop-types";

const COMMUNITY_REPORTS_STORAGE_KEY = 'pollution-community-reports';
const SYMPTOM_REPORTS_STORAGE_KEY = 'pollution-symptom-reports';

const POLLUTANT_LAYERS = [
{ key: 'aqi', label: 'AQI' },
{ key: 'pm2_5', label: 'PM2.5', limit: 15 },
{ key: 'pm10', label: 'PM10', limit: 45 },
{ key: 'nitrogen_dioxide', label: 'NO₂', limit: 25 },
{ key: 'ozone', label: 'O₃', limit: 100 },
{ key: 'carbon_monoxide', label: 'CO', limit: 4000 },
];

function readGeotaggedCommunityReports() {
try {
const raw = localStorage.getItem(COMMUNITY_REPORTS_STORAGE_KEY);
Expand Down Expand Up @@ -63,7 +73,7 @@ export default function LocationMap({ center, nearbyPoints, confidenceScore, win
const [communityReports, setCommunityReports] = useState(() => readGeotaggedCommunityReports());
const [showSymptomReports, setShowSymptomReports] = useState(false);
const [symptomReports, setSymptomReports] = useState(() => readGeotaggedSymptomReports());

const [selectedLayer, setSelectedLayer] = useState('aqi');
useEffect(() => {
const updateReports = () => {
setCommunityReports(readGeotaggedCommunityReports());
Expand Down Expand Up @@ -132,6 +142,15 @@ export default function LocationMap({ center, nearbyPoints, confidenceScore, win
iconAnchor: [14, 14],
});

const activeLayer = POLLUTANT_LAYERS.find((l) => l.key === selectedLayer) || POLLUTANT_LAYERS[0];

const getMarkerColor = (point) => {
if (selectedLayer === 'aqi') {
return point.aqi > 150 ? '#b91c1c' : point.aqi > 100 ? '#f97316' : '#16a34a';
}
return getPollutantColor(point.pollutants?.[selectedLayer], activeLayer.limit);
};

return (
<section data-testid="location-map" className="panel">
<div className="panel-head" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '0.75rem' }}>
Expand All @@ -141,6 +160,30 @@ export default function LocationMap({ center, nearbyPoints, confidenceScore, win
{windError && <p className="error-banner">Wind data unavailable: {windError}</p>}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<label
htmlFor="pollutant-layer-select"
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', fontWeight: '600' }}
>
Layer:
<select
id="pollutant-layer-select"
data-testid="pollutant-layer-select"
value={selectedLayer}
onChange={(e) => setSelectedLayer(e.target.value)}
style={{
fontSize: '0.85rem',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
border: '1px solid var(--border-color, #e2e8f0)',
minHeight: '44px',
cursor: 'pointer'
}}
>
{POLLUTANT_LAYERS.map((layer) => (
<option key={layer.key} value={layer.key}>{layer.label}</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setShowSymptomReports(!showSymptomReports)}
Expand Down Expand Up @@ -219,13 +262,18 @@ export default function LocationMap({ center, nearbyPoints, confidenceScore, win
center={[point.lat, point.lon]}
radius={Math.max(12, point.aqi / 8)}
pathOptions={{
color: point.aqi > 150 ? '#b91c1c' : point.aqi > 100 ? '#f97316' : '#16a34a',
color: getMarkerColor(point),
fillOpacity: confidenceScore === 'Low' ? 0.25 : 0.55
}}
>
<Popup>
<strong>{point.areaName}</strong>
<br />AQI: {point.aqi}
{selectedLayer !== 'aqi' && (
<>
<br />{activeLayer.label}: {point.pollutants?.[selectedLayer] ?? 'N/A'} µg/m³
</>
)}
</Popup>
</CircleMarker>
))}
Expand Down Expand Up @@ -331,6 +379,13 @@ LocationMap.propTypes = {
lon: PropTypes.number.isRequired,
areaName: PropTypes.string.isRequired,
aqi: PropTypes.number.isRequired,
pollutants: PropTypes.shape({
pm2_5: PropTypes.number,
pm10: PropTypes.number,
nitrogen_dioxide: PropTypes.number,
ozone: PropTypes.number,
carbon_monoxide: PropTypes.number,
}),
})
).isRequired,

Expand Down
26 changes: 21 additions & 5 deletions src/services/airQualityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,9 @@ function isValidCoord(lat: number, lon: number): boolean {
* @param {AbortSignal} [signal] - Optional signal to abort the fetch request.
* @returns {Promise<number|null>} Calculated AQI rounded to nearest integer, or null on error.
*/
async function fetchGridPointAqi(lat: number, lon: number, signal?: AbortSignal): Promise<number | null> {
async function fetchGridPointAqi(lat: number, lon: number, signal?: AbortSignal): Promise<{ aqi: number; pollutants: GridPoint['pollutants'] } | null> {
if (!isValidCoord(lat, lon)) return null;
const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}&hourly=us_aqi&timezone=auto&forecast_days=1`;
const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}&hourly=us_aqi,pm2_5,pm10,nitrogen_dioxide,ozone,carbon_monoxide&timezone=auto&forecast_days=1`;
const response = await fetch(url, { signal });
if (!response.ok) return null;
const data = await response.json();
Expand All @@ -232,7 +232,16 @@ async function fetchGridPointAqi(lat: number, lon: number, signal?: AbortSignal)
times,
data.utc_offset_seconds ?? 0
);
return Math.round(data.hourly?.us_aqi?.[idx] ?? 0);
return {
aqi: Math.round(data.hourly?.us_aqi?.[idx] ?? 0),
pollutants: {
pm2_5: data.hourly?.pm2_5?.[idx] ?? null,
pm10: data.hourly?.pm10?.[idx] ?? null,
nitrogen_dioxide: data.hourly?.nitrogen_dioxide?.[idx] ?? null,
ozone: data.hourly?.ozone?.[idx] ?? null,
carbon_monoxide: data.hourly?.carbon_monoxide?.[idx] ?? null,
}
};
}

/**
Expand Down Expand Up @@ -277,12 +286,19 @@ export async function fetchLocalGrid(
gridOffsets.map(async ({ dx, dy }, i) => {
const gLat = parseFloat((lat + dy * GRID_STEP).toFixed(4));
const gLon = parseFloat((lon + dx * GRID_STEP).toFixed(4));
const aqi = await fetchGridPointAqi(gLat, gLon, signal);
const point = await fetchGridPointAqi(gLat, gLon, signal);
return {
id: `grid-${i}`,
lat: gLat,
lon: gLon,
aqi: aqi ?? 0,
aqi: point?.aqi ?? 0,
pollutants: point?.pollutants ?? {
pm2_5: null,
pm10: null,
nitrogen_dioxide: null,
ozone: null,
carbon_monoxide: null,
},
areaName: DIRECTION_LABELS[`${dx},${dy}`] || `Zone ${i + 1}`
};
})
Expand Down
7 changes: 7 additions & 0 deletions src/types/airQuality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export interface GridPoint {
lon: number;
aqi: number;
areaName: string;
pollutants: {
pm2_5: number | null;
pm10: number | null;
nitrogen_dioxide: number | null;
ozone: number | null;
carbon_monoxide: number | null;
};
}

/** One entry in the 24h trend series (subset of PollutantMetrics used for charts). */
Expand Down
Loading