diff --git a/frontend/package.json b/frontend/package.json index 76c1cf3..f3482da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,9 @@ "name": "frontend", "version": "0.1.0", "private": true, + "engines": { + "node": "24.x" + }, "scripts": { "dev": "next dev", "build": "next build", diff --git a/frontend/src/app/categorize/page.tsx b/frontend/src/app/categorize/page.tsx index 5488e35..7dbb853 100644 --- a/frontend/src/app/categorize/page.tsx +++ b/frontend/src/app/categorize/page.tsx @@ -1168,7 +1168,7 @@ function SiteCategoryContent() { )} - {loading &&
{includeSatellite ? "Processing satellite-enriched source metadata..." : "Processing OSM site categorization..."}
} + {loading &&
{includeSatellite ? "Processing satellite-enriched source metadata..." : "Processing OSM site categorization..."}
} ) } diff --git a/frontend/src/app/locate/page.tsx b/frontend/src/app/locate/page.tsx index 67b6d3e..05478e0 100644 --- a/frontend/src/app/locate/page.tsx +++ b/frontend/src/app/locate/page.tsx @@ -203,15 +203,15 @@ export default function Index() {

-
+

Boundary

{boundaryReady ? "Ready" : "Required"}

-
+

Priority

{mustHaveLocations.length}

-
+

Suggested

{suggestedLocations.length}

@@ -236,7 +236,7 @@ export default function Index() {
-
+
-
+

Map legend

@@ -339,7 +339,7 @@ export default function Index() {
{hasResults && showResultsNotice ? ( -
+
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 69e960c..0b7820c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,236 +1,737 @@ "use client" -import type React from "react" -import Link from "next/link" + +import { useEffect, useMemo, useState } from "react" import Image from "next/image" -import { ArrowRight, MapPin, Wind, BarChart3, BrainCircuit, Shield, Database, Cpu, LineChart } from "lucide-react" +import Link from "next/link" +import { + ArrowRight, + BarChart3, + BrainCircuit, + ChevronRight, + Locate, + LocateFixed, + Map as MapIcon, + MapPin, + Pause, + Play, + Radio, + ShieldCheck, + Sparkles, + Wind, + X, +} from "lucide-react" import Navigation from "@/components/navigation/navigation" -import { FeatureCard } from "@/components/feature-card" +import { + getDailyForecastCollection, + getMapNodes, + type DailyForecastEntry, + type DailyForecastResponse, + type MapNode, +} from "@/services/apiService" +import { + isBrowserApiCacheFresh, + readBrowserApiCache, + writeBrowserApiCache, + type BrowserApiCacheEntry, +} from "@/lib/browserApiCache" + +type ForecastTickerItem = { + id: string + siteName: string + country: string + forecast: DailyForecastEntry +} + +type LocationState = "idle" | "requesting" | "ready" | "denied" | "unavailable" + +const FORECAST_TICKER_COUNTRY_KEY = "daily-forecast-country" +const FORECAST_TICKER_LOCATION_STATE_KEY = "daily-forecast-location-state" +const FORECAST_TICKER_ACTIVE_KEY = "daily-forecast-active" +const FORECAST_TICKER_VISIBLE_KEY = "daily-forecast-visible" +const FORECAST_TICKER_COORDINATES_KEY = "daily-forecast-coordinates" +const DAILY_FORECAST_CACHE_KEY = "map-daily-forecast" +const DAILY_FORECAST_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000 + +type MapboxCountryResponse = { + features?: Array<{ + properties?: { + name?: string + context?: { + country?: { + name?: string + } + } + } + }> +} + +const capabilityCards = [ + { + title: "Explore live air quality", + description: "See monitoring sites, pollution levels, heatmaps, and forecasts in one interactive view.", + href: "/map", + image: "/images/homemap.webp", + icon: MapIcon, + accent: "bg-blue-600", + }, + { + title: "Plan monitoring networks", + description: "Use spatial intelligence to identify high-value locations for new air quality sensors.", + href: "/locate", + image: "/images/model/locate.webp", + icon: LocateFixed, + accent: "bg-emerald-600", + }, + { + title: "Understand pollution sources", + description: "Combine land use, satellite evidence, and nearby source context for clearer decisions.", + href: "/categorize", + image: "/images/model/categorisemap.webp", + icon: BrainCircuit, + accent: "bg-violet-600", + }, +] + +const workflowSteps = [ + { + icon: Radio, + label: "Observe", + title: "Measurements arrive", + description: "AirQo monitoring networks provide a continuous view of changing air quality.", + }, + { + icon: BrainCircuit, + label: "Understand", + title: "AI finds the signal", + description: "Models clean, enrich, forecast, and explain patterns hidden in environmental data.", + }, + { + icon: ShieldCheck, + label: "Act", + title: "Teams make decisions", + description: "Clear maps, reports, and recommendations turn complex evidence into practical action.", + }, +] + +function normalizeColor(color?: string) { + if (!color) return "#2563eb" + return color.startsWith("#") ? color : `#${color}` +} + +function getAqiImageByCategory(category?: string) { + switch ((category || "").toLowerCase()) { + case "good": + return "/images/GoodAir.png" + case "moderate": + return "/images/Moderate.png" + case "unhealthy for sensitive groups": + return "/images/UnhealthySG.png" + case "unhealthy": + return "/images/Unhealthy.png" + case "very unhealthy": + return "/images/VeryUnhealthy.png" + case "hazardous": + return "/images/Hazardous.png" + default: + return "/images/Invalid.png" + } +} + +function formatForecastDate(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "Daily outlook" + return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) +} + +function getDistanceInKm(latitude: number, longitude: number, node: MapNode) { + const siteLatitude = node.siteDetails?.approximate_latitude + const siteLongitude = node.siteDetails?.approximate_longitude + if (typeof siteLatitude !== "number" || typeof siteLongitude !== "number") return Number.POSITIVE_INFINITY + + const earthRadiusKm = 6371 + const latitudeDelta = ((siteLatitude - latitude) * Math.PI) / 180 + const longitudeDelta = ((siteLongitude - longitude) * Math.PI) / 180 + const originLatitude = (latitude * Math.PI) / 180 + const siteLatitudeRadians = (siteLatitude * Math.PI) / 180 + const value = + Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(originLatitude) * Math.cos(siteLatitudeRadians) * Math.sin(longitudeDelta / 2) ** 2 + + return earthRadiusKm * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value)) +} + +function getCoordinatesForCountry(country: string, mapNodes: MapNode[]) { + const matchingNodes = mapNodes.filter( + (node) => + node.siteDetails?.country?.trim().toLowerCase() === country.trim().toLowerCase() && + typeof node.siteDetails.approximate_latitude === "number" && + typeof node.siteDetails.approximate_longitude === "number", + ) + if (!matchingNodes.length) return null + + return { + latitude: + matchingNodes.reduce((sum, node) => sum + node.siteDetails.approximate_latitude!, 0) / + matchingNodes.length, + longitude: + matchingNodes.reduce((sum, node) => sum + node.siteDetails.approximate_longitude!, 0) / + matchingNodes.length, + } +} + +async function resolveCountry(latitude: number, longitude: number, mapNodes: MapNode[]) { + const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN + if (mapboxToken) { + const url = new URL("https://api.mapbox.com/search/geocode/v6/reverse") + url.searchParams.set("longitude", String(longitude)) + url.searchParams.set("latitude", String(latitude)) + url.searchParams.set("types", "country") + url.searchParams.set("limit", "1") + url.searchParams.set("access_token", mapboxToken) + + try { + const response = await fetch(url, { cache: "no-store" }) + if (response.ok) { + const data = (await response.json()) as MapboxCountryResponse + const country = + data.features?.[0]?.properties?.name || + data.features?.[0]?.properties?.context?.country?.name + if (country) return country + } + } catch (error) { + console.error("Unable to reverse geocode visitor country:", error) + } + } + + return mapNodes + .filter((node) => Boolean(node.siteDetails?.country)) + .reduce<{ node: MapNode; distance: number } | null>((nearest, node) => { + const distance = getDistanceInKm(latitude, longitude, node) + return !nearest || distance < nearest.distance ? { node, distance } : nearest + }, null) + ?.node.siteDetails?.country?.trim() +} + +function DailyForecastTicker() { + const [collection, setCollection] = useState(null) + const [mapNodes, setMapNodes] = useState([]) + const [loading, setLoading] = useState(true) + const [locationState, setLocationState] = useState("idle") + const [visitorCountry, setVisitorCountry] = useState("") + const [visitorCoordinates, setVisitorCoordinates] = useState<{ latitude: number; longitude: number } | null>(null) + const [isTickerActive, setIsTickerActive] = useState(true) + const [isTickerVisible, setIsTickerVisible] = useState(true) + const [preferencesLoaded, setPreferencesLoaded] = useState(false) + + useEffect(() => { + const savedCountry = window.localStorage.getItem(FORECAST_TICKER_COUNTRY_KEY)?.trim() || "" + const savedLocationState = window.localStorage.getItem(FORECAST_TICKER_LOCATION_STATE_KEY) + const savedActive = window.localStorage.getItem(FORECAST_TICKER_ACTIVE_KEY) + const savedVisible = window.localStorage.getItem(FORECAST_TICKER_VISIBLE_KEY) + const savedCoordinates = window.localStorage.getItem(FORECAST_TICKER_COORDINATES_KEY) + + if (savedCountry) { + setVisitorCountry(savedCountry) + setLocationState("ready") + } else if (savedLocationState === "denied" || savedLocationState === "unavailable") { + setLocationState(savedLocationState) + } + + if (savedActive !== null) setIsTickerActive(savedActive === "true") + if (savedVisible !== null) setIsTickerVisible(savedVisible === "true") + if (savedCoordinates) { + try { + const coordinates = JSON.parse(savedCoordinates) as { latitude?: number; longitude?: number } + if (typeof coordinates.latitude === "number" && typeof coordinates.longitude === "number") { + setVisitorCoordinates({ latitude: coordinates.latitude, longitude: coordinates.longitude }) + } + } catch { + window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY) + } + } + setPreferencesLoaded(true) + }, []) + + useEffect(() => { + if (!preferencesLoaded) return + window.localStorage.setItem(FORECAST_TICKER_ACTIVE_KEY, String(isTickerActive)) + }, [isTickerActive, preferencesLoaded]) + + useEffect(() => { + if (!preferencesLoaded) return + window.localStorage.setItem(FORECAST_TICKER_VISIBLE_KEY, String(isTickerVisible)) + }, [isTickerVisible, preferencesLoaded]) + + useEffect(() => { + let active = true + + const loadDailyForecast = async () => { + const cached = await readBrowserApiCache>(DAILY_FORECAST_CACHE_KEY) + if ( + isBrowserApiCacheFresh(cached, DAILY_FORECAST_CACHE_MAX_AGE_MS) && + cached.data.forecasts?.length + ) { + return cached.data + } + + const forecastData = await getDailyForecastCollection() + if (forecastData?.forecasts?.length) { + writeBrowserApiCache(DAILY_FORECAST_CACHE_KEY, forecastData).catch((error) => { + console.warn("Unable to cache daily forecast ticker data:", error) + }) + } + return forecastData + } + + Promise.all([loadDailyForecast(), getMapNodes()]) + .then(([forecastData, nodes]) => { + if (!active) return + setCollection(forecastData) + setMapNodes(nodes || []) + }) + .finally(() => { + if (active) setLoading(false) + }) + + return () => { + active = false + } + }, []) + + function requestLocation() { + if (!navigator.geolocation) { + setLocationState("unavailable") + window.localStorage.removeItem(FORECAST_TICKER_COUNTRY_KEY) + window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY) + window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "unavailable") + return + } + + setLocationState("requesting") + navigator.geolocation.getCurrentPosition( + async ({ coords }) => { + const country = await resolveCountry(coords.latitude, coords.longitude, mapNodes) + if (country) { + const coordinates = { latitude: coords.latitude, longitude: coords.longitude } + setVisitorCountry(country) + setVisitorCoordinates(coordinates) + setLocationState("ready") + window.localStorage.setItem(FORECAST_TICKER_COUNTRY_KEY, country) + window.localStorage.setItem(FORECAST_TICKER_COORDINATES_KEY, JSON.stringify(coordinates)) + window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "ready") + } else { + setLocationState("unavailable") + window.localStorage.removeItem(FORECAST_TICKER_COUNTRY_KEY) + window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY) + window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "unavailable") + } + }, + () => { + setLocationState("denied") + window.localStorage.removeItem(FORECAST_TICKER_COUNTRY_KEY) + window.localStorage.removeItem(FORECAST_TICKER_COORDINATES_KEY) + window.localStorage.setItem(FORECAST_TICKER_LOCATION_STATE_KEY, "denied") + }, + { enableHighAccuracy: false, timeout: 10000, maximumAge: 30 * 60 * 1000 }, + ) + } + + const tickerItems = useMemo(() => { + if (!collection?.forecasts?.length || !visitorCountry) return [] + + const nodeBySiteId = new Map( + mapNodes + .filter((node) => node.site_id) + .map((node) => [node.site_id, node]), + ) + const visitorCountryKey = visitorCountry.trim().toLowerCase() + const sitesInVisitorCountry = collection.forecasts.filter( + (site) => + nodeBySiteId.get(site.site_details.site_id)?.siteDetails?.country?.trim().toLowerCase() === + visitorCountryKey, + ) + + let selectedSites = sitesInVisitorCountry + let selectedCountry = visitorCountry + + if (!selectedSites.length) { + const origin = visitorCoordinates || getCoordinatesForCountry(visitorCountry, mapNodes) + const nearestForecastNode = origin + ? collection.forecasts.reduce<{ node: MapNode; distance: number } | null>((nearest, site) => { + const node = nodeBySiteId.get(site.site_details.site_id) + if (!node?.siteDetails?.country) return nearest + const distance = getDistanceInKm(origin.latitude, origin.longitude, node) + if (!Number.isFinite(distance)) return nearest + return !nearest || distance < nearest.distance ? { node, distance } : nearest + }, null) + : null + const nearestCountry = nearestForecastNode?.node.siteDetails.country?.trim() + + if (nearestCountry) { + selectedCountry = nearestCountry + selectedSites = collection.forecasts.filter( + (site) => + nodeBySiteId.get(site.site_details.site_id)?.siteDetails?.country?.trim().toLowerCase() === + nearestCountry.toLowerCase(), + ) + } + } + + return selectedSites + .map((site) => { + const forecast = site.forecasts?.[0] + if (!forecast) return null + return { + id: `${site.site_details.site_id}-${forecast.date}`, + siteName: site.site_details.site_name || "AirQo site", + country: selectedCountry, + forecast, + } + }) + .filter((item): item is ForecastTickerItem => Boolean(item)) + .slice(0, 24) + }, [collection, mapNodes, visitorCoordinates, visitorCountry]) + + const renderedItems = tickerItems.length > 0 ? [...tickerItems, ...tickerItems] : [] + + if (!isTickerVisible) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+ + + + + Daily forecast +
+ +
+ {loading ? ( +

Loading today's air quality outlook...

+ ) : locationState !== "ready" ? ( +
+
+

+ {locationState === "denied" + ? "Location access was not granted" + : locationState === "unavailable" + ? "We could not determine your country" + : "See forecasts for your country"} +

+

+ Your location is used only to choose country-specific AirQo forecast sites. +

+
+ +
+ ) : renderedItems.length > 0 ? ( +
+ {renderedItems.map((item, index) => { + const pm25 = item.forecast.forecast.pm2_5_mean + const temperature = item.forecast.met?.air_temperature + const label = + item.forecast.aqi.aqi_category || + item.forecast.aqi.aqi_color_name || + item.forecast.aqi.label || + "Air quality outlook" + const color = normalizeColor(item.forecast.aqi.aqi_color) + + return ( +
+ + {item.siteName} + {item.country} + + {formatForecastDate(item.forecast.date)} + + + {label} + {typeof pm25 === "number" && ( + + PM2.5 {pm25.toFixed(1)} ug/m3 + + )} + {typeof temperature === "number" && ( + Temp {temperature.toFixed(0)} C + )} +
+ ) + })} +
+ ) : ( +

+ No daily forecasts are currently available for {visitorCountry}. Open the map for nearby measurements. +

+ )} +
-const Home: React.FC = () => { +
+ {renderedItems.length > 0 && ( + + )} + +
+
+
+ ) +} + +export default function Home() { return ( -
+
+ + +
+
+
- {/* Hero Section */} -
-
-
-
-
-

- AI-Powered Air Quality Monitoring +
+
+
+ + AI for cleaner African cities +
+

+ Turn air quality data into decisions that matter.

-

- AirQo AI provides advanced tools for monitoring, analyzing, and optimizing air quality across African - cities using artificial intelligence. +

+ Monitor pollution, anticipate tomorrow's conditions, and plan stronger sensor networks with AirQo's + AI-powered environmental intelligence platform.

-
+ +
- Explore Air Quality Map + Explore the live map - Try Site Locator + View air quality reports
+ +
+
+

Live

+

Monitoring context

+
+
+

Daily

+

Forecast outlooks

+
+
+

AI

+

Decision support

+
+
-
-
(window.location.href = "/map")} - > + + +
Air quality monitoring dashboard +
+
+ + + Live network view + + + + +
+
+
+

Across African communities

+

See what the air is saying now.

+
+ + + +
-
-
-
-

- - {/* Key Features Section */} -
-
-
-

Powered by Artificial Intelligence

-

- Our platform leverages cutting-edge AI to provide accurate, real-time air quality data and insights for - researchers, policymakers, and citizens. -

+
+
-
- - - - - - -
-
-
- - {/* How It Works Section - Redesigned */} -
-
-
-

How AirQo AI Works

-

- Our platform combines low-cost sensors, advanced algorithms, and user-friendly interfaces to democratize - air quality monitoring. -

+
+
+
+
+

One connected platform

+

+ From street-level evidence to city-level action. +

+
+

+ Choose a workflow and move from observation to a practical environmental decision without leaving AirQo AI. +

+
+ +
+ {capabilityCards.map((card) => { + const Icon = card.icon + return ( + +
+ +
+
+ +
+
+
+

{card.title}

+

{card.description}

+ + Open tool + +
+ + ) + })} +
+
+ +
+
+
+
+
+ +
+

Built for the full decision journey.

+

+ Air quality work is more than a number on a map. AirQo AI connects measurements, models, and local context + so teams can understand what is happening and decide what to do next. +

+ + Learn about AirQo + +
-
- {/* Connection lines for desktop */} -
- -
- } - number="01" - title="Data Collection" - description="Our network of sensors continuously collects air quality data across multiple locations." - imageSrc="/images/model/calibration-header.webp" - /> - } - number="02" - title="AI Processing" - description="Advanced algorithms clean, analyze, and interpret the data to generate insights." - imageSrc="/images/model/modelapi.webp" - /> - } - number="03" - title="Actionable Insights" - description="Users access visualizations, reports, and recommendations through our various platform." - imageSrc="/images/model/analyticsHome.webp" - /> +
+ {workflowSteps.map((step, index) => { + const Icon = step.icon + return ( +
+ 0{index + 1} +
+ +
+

{step.label}

+

{step.title}

+

{step.description}

+
+ ) + })} +
-
-
- - {/* CTA Section */} -
-
-

Ready to Improve Air Quality?

-

- Start using our AI-powered tools to make data-driven decisions for cleaner air. -

-
- - Explore the Map - +
+ +
+
+
+
+ + Evidence for action +
+

+ Make your next air quality decision with clearer evidence. +

+
- Try Site Locator + Explore reports
-
-
+ +
- {/* Footer */} -
-
- © {new Date().getFullYear()} AirQo. All rights reserved. -
-
-
- ) -} - -// Process Card Component - Redesigned -const ProcessCard = ({ - icon, - number, - title, - description, - imageSrc, -}: { - icon: React.ReactNode - number: string - title: string - description: string - imageSrc?: string -}) => { - return ( -
- {imageSrc && ( -
- {title} -
- )} -
-
{icon}
- - {/* Enhanced Number Display */} -
-
-
-
- {number} -
-
-
+
+
+

{new Date().getFullYear()} AirQo. Clean air intelligence for African cities.

+
+ About + Map + Reports
- -

{title}

-

{description}

-
+
) -} - -export default Home +} \ No newline at end of file diff --git a/frontend/src/app/reports/page.tsx b/frontend/src/app/reports/page.tsx index ffa116d..0a69b57 100644 --- a/frontend/src/app/reports/page.tsx +++ b/frontend/src/app/reports/page.tsx @@ -892,9 +892,9 @@ function ReportContent() { } return ( -
+
-

Air Quality Reports

+

Air Quality Reports

Real-time insights and analytics on air quality across different site categories

@@ -911,7 +911,7 @@ function ReportContent() {
{/* Filters */} -
+

Filter report visuals

@@ -1002,7 +1002,7 @@ function ReportContent() { {/* Selected Devices Counter */} {selectedDevices.length > 0 && (
-
+
{selectedDevices.length} @@ -1020,7 +1020,7 @@ function ReportContent() { @@ -1045,7 +1045,7 @@ function ReportContent() { )}
-
+

Generate a report with your selected devices

-
+
{reportGenerating ? ( <> @@ -1253,7 +1253,7 @@ function ReportContent() {
{/* Report Header */}
-

{getReportTitle()}

+

{getReportTitle()}

{getLocationInfo().city}, {getLocationInfo().country}

@@ -1290,7 +1290,7 @@ function ReportContent() { -
+
{mapSites.length > 0 ? ( - + PM2.5 Levels by Site
-
+
Sites:
-
+
Type:
-
+
Sort:
-
+
Export: setChartType(v as "pie" | "bar")} defaultValue="pie"> @@ -384,7 +384,7 @@ export function AQICategoryChart({ sites }: { sites: SiteData[] }) {
-
+
Export: