From 2764d2f96bce28ab760eb7995c0be9c19ddae3b0 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Tue, 17 Mar 2026 13:07:33 -0400 Subject: [PATCH 1/4] V0.0.5 - Reorder 24 hour weather tabs and add tab for AQI --- SWS_App/android/app/build.gradle | 4 +- SWS_App/package.json | 2 +- SWS_App/src/components/Hourly24.tsx | 240 +++++++++++++++++++++++----- SWS_App/src/hooks/useAQI.ts | 41 +++++ SWS_App/src/pages/HomePage.tsx | 2 +- SWS_App/src/services/aqi.ts | 68 ++++++++ SWS_App/src/services/weather.ts | 26 +-- SWS_App/src/types/weather.ts | 8 + 8 files changed, 342 insertions(+), 49 deletions(-) create mode 100644 SWS_App/src/hooks/useAQI.ts create mode 100644 SWS_App/src/services/aqi.ts diff --git a/SWS_App/android/app/build.gradle b/SWS_App/android/app/build.gradle index 638e822..7958064 100644 --- a/SWS_App/android/app/build.gradle +++ b/SWS_App/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.cph5236.simpleweatherservice" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 3 - versionName "0.0.4" + versionCode 4 + versionName "0.0.5" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/SWS_App/package.json b/SWS_App/package.json index fa59d0e..5365ca2 100644 --- a/SWS_App/package.json +++ b/SWS_App/package.json @@ -1,7 +1,7 @@ { "name": "sws-app", "private": true, - "version": "0.0.4", + "version": "0.0.5", "type": "module", "scripts": { "dev": "vite", diff --git a/SWS_App/src/components/Hourly24.tsx b/SWS_App/src/components/Hourly24.tsx index 6ad8289..db4c151 100644 --- a/SWS_App/src/components/Hourly24.tsx +++ b/SWS_App/src/components/Hourly24.tsx @@ -1,12 +1,23 @@ import { useState } from 'react' import { getWeatherEmoji } from '../types/weather' import type { HourlyForecast, Units } from '../types/weather' +import { useAQI } from '../hooks/useAQI' interface Hourly24Props { hours: HourlyForecast[] units: Units + lat: number + lon: number } +type ForecastTab = 'uv' | 'aqi' | 'wind' + +const TABS: { id: ForecastTab; label: string }[] = [ + { id: 'uv', label: 'UV Index' }, + { id: 'aqi', label: 'Air Quality' }, + { id: 'wind', label: 'Wind' }, +] + function formatHour(isoTime: string): string { const date = new Date(isoTime) return date.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true }) @@ -28,6 +39,35 @@ function uvLabel(uv: number): string { return 'Extreme' } +function windColor(speed: number, units: Units): string { + const thresholds = units === 'metric' ? [20, 40, 60] : [12, 25, 37] + if (speed < thresholds[0]) return '#64748b' + if (speed < thresholds[1]) return '#3b82f6' + if (speed < thresholds[2]) return '#f59e0b' + return '#dc2626' +} + +function degreesToCardinal(deg: number): string { + const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] + return dirs[Math.round(deg / 22.5) % 16] +} + +function aqiColor(aqi: number): string { + if (aqi <= 50) return '#16a34a' + if (aqi <= 100) return '#d97706' + if (aqi <= 150) return '#ea580c' + if (aqi <= 200) return '#dc2626' + return '#7c3aed' +} + +function aqiLabel(aqi: number): string { + if (aqi <= 50) return 'Good' + if (aqi <= 100) return 'Moderate' + if (aqi <= 150) return 'Sensitive' + if (aqi <= 200) return 'Unhealthy' + return 'Very Unhealthy' +} + const UV_LEGEND = [ { label: 'Low', color: '#16a34a' }, { label: 'Moderate', color: '#d97706' }, @@ -38,11 +78,19 @@ const UV_LEGEND = [ const BAR_MAX_HEIGHT = 60 -export function Hourly24({ hours, units }: Hourly24Props) { - const [expanded, setExpanded] = useState(false) +export function Hourly24({ hours, units, lat, lon }: Hourly24Props) { + const [activeTab, setActiveTab] = useState('uv') + const { data: aqiData, loading: aqiLoading, error: aqiError, fetch: fetchAQI } = useAQI(lat, lon) + const tempUnit = units === 'metric' ? '°C' : '°F' + const windUnit = units === 'metric' ? 'km/h' : 'mph' const maxUV = Math.max(...hours.map((h) => h.uvIndex), 1) + function handleTabClick(tab: ForecastTab) { + setActiveTab(tab) + if (tab === 'aqi') fetchAQI() + } + return (
-
-

24-Hour Forecast

- -
+

24-Hour Forecast

+ {/* Main cards row */}
- {expanded && ( -
-
- UV Index -
-
+
+ + {/* Tab strip */} +
+ {TABS.map((tab) => { + const isActive = activeTab === tab.id + return ( + + ) + })} +
+ + {/* Detail row */} +
+ {activeTab === 'uv' && ( + <> {hours.map((hour) => { const uv = Math.round(hour.uvIndex) const barHeight = Math.max(4, Math.round((uv / maxUV) * BAR_MAX_HEIGHT)) @@ -151,15 +213,121 @@ export function Hourly24({ hours, units }: Hourly24Props) {
) })} -
-
- {UV_LEGEND.map(({ label, color }) => ( -
-
- {label} + + )} + + {activeTab === 'aqi' && ( + <> + {aqiLoading && ( +
+
+ Loading air quality data… +
- ))} -
+ )} + {aqiError && ( +
{aqiError}
+ )} + {!aqiLoading && !aqiError && aqiData.length > 0 && hours.map((hour, i) => { + const entry = aqiData[i] + const aqi = entry?.usAqi ?? 0 + const color = aqiColor(aqi) + return ( +
+
{aqi}
+
+
+ {aqiLabel(aqi)} +
+
+ {formatHour(hour.time)} +
+
+ ) + })} + + )} + + {activeTab === 'wind' && ( + <> + {hours.map((hour) => { + const color = windColor(hour.windSpeed, units) + const cardinal = degreesToCardinal(hour.windDirection) + return ( +
+ + + + +
+ {cardinal} +
+
+ + {Math.round(hour.windSpeed)} + + {windUnit} +
+
+ {formatHour(hour.time)} +
+
+ ) + })} + + )} +
+ + {/* Wind intensity legend */} + {activeTab === 'wind' && ( +
+ {[ + { label: units === 'metric' ? 'Calm <20 km/h' : 'Calm <12 mph', color: '#64748b' }, + { label: 'Moderate', color: '#3b82f6' }, + { label: 'Strong', color: '#f59e0b' }, + { label: 'Gale', color: '#dc2626' }, + ].map(({ label, color }) => ( +
+
+ {label} +
+ ))} +
+ )} + + {/* UV legend shown when on UV tab */} + {activeTab === 'uv' && ( +
+ {UV_LEGEND.map(({ label, color }) => ( +
+
+ {label} +
+ ))}
)}
diff --git a/SWS_App/src/hooks/useAQI.ts b/SWS_App/src/hooks/useAQI.ts new file mode 100644 index 0000000..28cde4c --- /dev/null +++ b/SWS_App/src/hooks/useAQI.ts @@ -0,0 +1,41 @@ +import { useState, useEffect, useCallback } from 'react' +import type { HourlyAQI } from '../types/weather' +import { getHourlyAQI } from '../services/aqi' + +interface AQIState { + data: HourlyAQI[] + loading: boolean + error: string | null + fetched: boolean +} + +const INITIAL_STATE: AQIState = { + data: [], + loading: false, + error: null, + fetched: false, +} + +export function useAQI(lat: number | null, lon: number | null): AQIState & { fetch: () => void } { + const [state, setState] = useState(INITIAL_STATE) + + // Reset when location changes — never auto-fetch + useEffect(() => { + setState(INITIAL_STATE) + }, [lat, lon]) + + const fetch = useCallback(() => { + if (lat == null || lon == null) return + + setState((s) => ({ ...s, loading: true, error: null })) + + getHourlyAQI(lat, lon) + .then((data) => setState({ data, loading: false, error: null, fetched: true })) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to load air quality data' + setState({ data: [], loading: false, error: message, fetched: true }) + }) + }, [lat, lon]) + + return { ...state, fetch } +} diff --git a/SWS_App/src/pages/HomePage.tsx b/SWS_App/src/pages/HomePage.tsx index 6eba962..9baa632 100644 --- a/SWS_App/src/pages/HomePage.tsx +++ b/SWS_App/src/pages/HomePage.tsx @@ -121,7 +121,7 @@ export function HomePage() { onRefresh={refetchCurrent} lastRefreshed={lastCurrentFetch} /> - {hourly.length > 0 && } + {hourly.length > 0 && } {daily.length > 0 && }
diff --git a/SWS_App/src/services/aqi.ts b/SWS_App/src/services/aqi.ts new file mode 100644 index 0000000..605fc9a --- /dev/null +++ b/SWS_App/src/services/aqi.ts @@ -0,0 +1,68 @@ +import type { HourlyAQI } from '../types/weather' + +const AQI_BASE_URL = 'https://air-quality-api.open-meteo.com/v1/air-quality' +export const AQI_TTL_MS = 60 * 60 * 1000 + +interface CacheEntry { + data: T + expiresAt: number +} + +const cache = new Map>() + +function getCached(key: string): T | null { + const entry = cache.get(key) + if (!entry) return null + if (Date.now() > entry.expiresAt) { + cache.delete(key) + return null + } + return entry.data as T +} + +function setCached(key: string, data: T, ttl: number): void { + cache.set(key, { data, expiresAt: Date.now() + ttl }) +} + +export function clearAQICache(lat: number, lon: number): void { + cache.delete(`aqi,${lat},${lon}`) +} + +export async function getHourlyAQI(lat: number, lon: number): Promise { + const key = `aqi,${lat},${lon}` + + const cached = getCached(key) + if (cached) return cached + + const url = + `${AQI_BASE_URL}?latitude=${lat}&longitude=${lon}` + + `&hourly=us_aqi,pm10,pm2_5&timezone=auto&forecast_days=2` + + const res = await fetch(url) + if (!res.ok) throw new Error(`AQI fetch failed: ${res.status}`) + + const json = await res.json() as { + utc_offset_seconds: number + hourly: { + time: string[] + us_aqi: number[] + pm10: number[] + pm2_5: number[] + } + } + + // Slice to the next 24 hours from the current local time (mirrors getHourlyForecast) + const currentHour = new Date(Date.now() + json.utc_offset_seconds * 1000).toISOString().slice(0, 13) + const startIndex = json.hourly.time.findIndex((t) => t >= currentHour) + const sliceStart = startIndex === -1 ? 0 : startIndex + + const data = json.hourly.time.slice(sliceStart, sliceStart + 24).map((time, i) => ({ + time, + usAqi: json.hourly.us_aqi[sliceStart + i] ?? 0, + pm10: json.hourly.pm10[sliceStart + i] ?? 0, + pm25: json.hourly.pm2_5[sliceStart + i] ?? 0, + })) + + setCached(key, data, AQI_TTL_MS) + return data +} diff --git a/SWS_App/src/services/weather.ts b/SWS_App/src/services/weather.ts index 566c4bd..213d5e4 100644 --- a/SWS_App/src/services/weather.ts +++ b/SWS_App/src/services/weather.ts @@ -170,6 +170,7 @@ interface HourlyRawData { temperature_2m: number[] precipitation_probability: number[] wind_speed_10m: number[] + wind_direction_10m: number[] weather_code: number[] uv_index: number[] } @@ -179,10 +180,13 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): let raw = getCached(key) + // Invalidate cache entries that predate the wind_direction_10m field addition. + if (raw && !raw.wind_direction_10m) raw = null + if (!raw) { const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}` + - `&hourly=temperature_2m,precipitation_probability,wind_speed_10m,weather_code,uv_index&forecast_days=2` + + `&hourly=temperature_2m,precipitation_probability,wind_speed_10m,wind_direction_10m,weather_code,uv_index&forecast_days=2` + unitParams(units) + '&timezone=auto' const json = await fetchWeather(url) @@ -193,6 +197,7 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): temperature_2m: number[] precipitation_probability: number[] wind_speed_10m: number[] + wind_direction_10m: number[] weather_code: number[] uv_index: number[] } @@ -204,6 +209,7 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): temperature_2m: data.hourly.temperature_2m, precipitation_probability: data.hourly.precipitation_probability, wind_speed_10m: data.hourly.wind_speed_10m, + wind_direction_10m: data.hourly.wind_direction_10m, weather_code: data.hourly.weather_code, uv_index: data.hourly.uv_index, } @@ -212,16 +218,18 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): } // Always recompute the slice from current local time so past hours are trimmed. - const currentHour = new Date(Date.now() + raw.utcOffsetSeconds * 1000).toISOString().slice(0, 13) - const startIndex = raw.time.findIndex((t) => t >= currentHour) + const resolved = raw! + const currentHour = new Date(Date.now() + resolved.utcOffsetSeconds * 1000).toISOString().slice(0, 13) + const startIndex = resolved.time.findIndex((t) => t >= currentHour) const sliceStart = startIndex === -1 ? 0 : startIndex - return raw.time.slice(sliceStart, sliceStart + 24).map((time, i) => ({ + return resolved.time.slice(sliceStart, sliceStart + 24).map((time, i) => ({ time, - temperature: raw.temperature_2m[sliceStart + i], - precipProbability: raw.precipitation_probability[sliceStart + i] ?? 0, - windSpeed: raw.wind_speed_10m[sliceStart + i], - weatherCode: raw.weather_code[sliceStart + i], - uvIndex: raw.uv_index[sliceStart + i] ?? 0, + temperature: resolved.temperature_2m[sliceStart + i], + precipProbability: resolved.precipitation_probability[sliceStart + i] ?? 0, + windSpeed: resolved.wind_speed_10m[sliceStart + i], + windDirection: resolved.wind_direction_10m[sliceStart + i] ?? 0, + weatherCode: resolved.weather_code[sliceStart + i], + uvIndex: resolved.uv_index[sliceStart + i] ?? 0, })) } diff --git a/SWS_App/src/types/weather.ts b/SWS_App/src/types/weather.ts index ae04a1b..8072746 100644 --- a/SWS_App/src/types/weather.ts +++ b/SWS_App/src/types/weather.ts @@ -24,10 +24,18 @@ export interface HourlyForecast { temperature: number precipProbability: number windSpeed: number + windDirection: number weatherCode: number uvIndex: number } +export interface HourlyAQI { + time: string + usAqi: number + pm10: number + pm25: number +} + export interface DailyForecast { date: string tempMax: number From 7e74b4e4481bbc70452339ec058106e2eb697347 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Tue, 17 Mar 2026 15:12:22 -0400 Subject: [PATCH 2/4] Base Radar Map functionality --- CLAUDE.md | 1 - SWS_App/package-lock.json | 59 +++++- SWS_App/package.json | 3 + SWS_App/src/components/MapPlaceholder.tsx | 10 - SWS_App/src/components/RadarMap.css | 90 +++++++++ SWS_App/src/components/RadarMap.tsx | 215 ++++++++++++++++++++++ SWS_App/src/hooks/useRadar.ts | 40 ++++ SWS_App/src/pages/HomePage.tsx | 53 ++++-- SWS_App/src/services/noaaRadar.ts | 12 ++ SWS_App/src/services/rainviewer.ts | 55 ++++++ 10 files changed, 505 insertions(+), 33 deletions(-) delete mode 100644 SWS_App/src/components/MapPlaceholder.tsx create mode 100644 SWS_App/src/components/RadarMap.css create mode 100644 SWS_App/src/components/RadarMap.tsx create mode 100644 SWS_App/src/hooks/useRadar.ts create mode 100644 SWS_App/src/services/noaaRadar.ts create mode 100644 SWS_App/src/services/rainviewer.ts diff --git a/CLAUDE.md b/CLAUDE.md index e975059..7566774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,6 @@ When modifying this project: ## Non-Goals -- Radar or satellite maps - Severe weather alerts (unless explicitly added later) - Social or sharing features - Native mobile frameworks (Flutter, React Native, MAUI) diff --git a/SWS_App/package-lock.json b/SWS_App/package-lock.json index 4b3ce16..4b48d9e 100644 --- a/SWS_App/package-lock.json +++ b/SWS_App/package-lock.json @@ -1,24 +1,27 @@ { - "name": "my-app", - "version": "0.0.4", + "name": "sws-app", + "version": "0.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "my-app", - "version": "0.0.4", + "name": "sws-app", + "version": "0.0.5", "dependencies": { "@capacitor/android": "^8.2.0", "@capacitor/core": "^8.2.0", "@capacitor/ios": "^8.2.0", "bootstrap": "^5.3.7", + "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.13.1" }, "devDependencies": { "@capacitor/cli": "^8.2.0", "@eslint/js": "^9.39.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -1494,6 +1497,17 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1931,6 +1945,13 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1938,6 +1959,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", @@ -5074,6 +5105,12 @@ "node": ">=6" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5768,6 +5805,20 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/SWS_App/package.json b/SWS_App/package.json index 5365ca2..2142e7d 100644 --- a/SWS_App/package.json +++ b/SWS_App/package.json @@ -25,13 +25,16 @@ "@capacitor/core": "^8.2.0", "@capacitor/ios": "^8.2.0", "bootstrap": "^5.3.7", + "leaflet": "^1.9.4", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.13.1" }, "devDependencies": { "@capacitor/cli": "^8.2.0", "@eslint/js": "^9.39.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/SWS_App/src/components/MapPlaceholder.tsx b/SWS_App/src/components/MapPlaceholder.tsx deleted file mode 100644 index f75b0f8..0000000 --- a/SWS_App/src/components/MapPlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -export function MapPlaceholder() { - return ( -
- Map view coming soon -
- ) -} diff --git a/SWS_App/src/components/RadarMap.css b/SWS_App/src/components/RadarMap.css new file mode 100644 index 0000000..ba668fd --- /dev/null +++ b/SWS_App/src/components/RadarMap.css @@ -0,0 +1,90 @@ +.radar-map-card { + height: 100%; + display: flex; + flex-direction: column; +} + +.radar-map-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: 0; +} + +.radar-map-inner { + flex: 1; + min-height: 300px; + position: relative; +} + +.radar-map-card .leaflet-container { + height: 100%; + width: 100%; + border-radius: 0 0 0.375rem 0.375rem; + z-index: 0; +} + +.radar-map-fullscreen { + position: fixed; + inset: 0; + z-index: 1050; + background: #fff; + display: flex; + flex-direction: column; +} + +.radar-map-fullscreen .leaflet-container { + height: 100%; + width: 100%; + border-radius: 0; + flex: 1; +} + +.radar-map-close-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + z-index: 1051; +} + +.radar-map-timestamp { + position: absolute; + bottom: 1.5rem; + left: 0.5rem; + z-index: 1000; + background: rgba(255, 255, 255, 0.9); + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + pointer-events: none; +} + +.radar-map-legend { + position: absolute; + bottom: 1.5rem; + right: 0.5rem; + z-index: 1000; + background: rgba(255, 255, 255, 0.9); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.65rem; + pointer-events: none; + display: flex; + flex-direction: column; + gap: 2px; +} + +.radar-map-legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.radar-map-legend-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; +} diff --git a/SWS_App/src/components/RadarMap.tsx b/SWS_App/src/components/RadarMap.tsx new file mode 100644 index 0000000..9420aae --- /dev/null +++ b/SWS_App/src/components/RadarMap.tsx @@ -0,0 +1,215 @@ +import { useState, useEffect } from 'react' +import { MapContainer, TileLayer, WMSTileLayer, useMap } from 'react-leaflet' +import 'leaflet/dist/leaflet.css' +import './RadarMap.css' +import { useRadar } from '../hooks/useRadar' +import { NOAA_WMS_URL, NOAA_WMS_PARAMS } from '../services/noaaRadar' + +type MapSource = 'rainviewer' | 'noaa' + +interface RadarMapProps { + lat: number + lon: number +} + +const LEGEND_ITEMS = [ + { color: '#96d2fa', label: 'Light' }, + { color: '#04e604', label: 'Moderate' }, + { color: '#f0f050', label: 'Heavy' }, + { color: '#e89632', label: 'Very Heavy' }, + { color: '#e83200', label: 'Intense' }, + { color: '#9600b4', label: 'Extreme' }, +] + +function RadarLegend() { + return ( +
+ {LEGEND_ITEMS.map(({ color, label }) => ( +
+ + {label} +
+ ))} +
+ ) +} + +function MapController({ + lat, + lon, + expanded, +}: { + lat: number + lon: number + expanded: boolean +}) { + const map = useMap() + + useEffect(() => { + map.setView([lat, lon], map.getZoom()) + }, [map, lat, lon]) + + useEffect(() => { + // Allow DOM to update before recalculating map size + const id = setTimeout(() => map.invalidateSize(), 50) + return () => clearTimeout(id) + }, [map, expanded]) + + useEffect(() => { + if (expanded) { + map.scrollWheelZoom.enable() + } else { + map.scrollWheelZoom.disable() + } + }, [map, expanded]) + + return null +} + +function formatTimeAgo(unixSeconds: number): string { + const diffMin = Math.round((Date.now() / 1000 - unixSeconds) / 60) + if (diffMin < 1) return 'just now' + if (diffMin === 1) return '1 min ago' + return `${diffMin} min ago` +} + +export function RadarMap({ lat, lon }: RadarMapProps) { + const { frames, error } = useRadar() + const [expanded, setExpanded] = useState(false) + const [mapSource, setMapSource] = useState('noaa') + + const latestFrame = frames.length > 0 ? frames[frames.length - 1] : null + + // Close fullscreen on Escape + useEffect(() => { + if (!expanded) return + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') setExpanded(false) + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [expanded]) + + // Prevent body scroll when fullscreen + useEffect(() => { + if (expanded) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [expanded]) + + const mapContent = ( + + + + {mapSource === 'rainviewer' && latestFrame && ( + + )} + {mapSource === 'noaa' && ( + + )} + + ) + + const sourceToggle = ( +
+ + +
+ ) + + if (expanded) { + return ( +
+ +
+ {mapContent} + {mapSource === 'rainviewer' && latestFrame && ( +
+ Radar: {formatTimeAgo(latestFrame.time)} +
+ )} + {mapSource === 'rainviewer' && error && ( +
Radar unavailable
+ )} + +
+
+ ) + } + + return ( +
+
+

Radar Map

+
+ {sourceToggle} + +
+
+
+
+ {mapContent} + {mapSource === 'rainviewer' && latestFrame && ( +
+ Radar: {formatTimeAgo(latestFrame.time)} +
+ )} + {mapSource === 'rainviewer' && error && ( +
Radar unavailable
+ )} + +
+
+
+ ) +} diff --git a/SWS_App/src/hooks/useRadar.ts b/SWS_App/src/hooks/useRadar.ts new file mode 100644 index 0000000..13c2e3a --- /dev/null +++ b/SWS_App/src/hooks/useRadar.ts @@ -0,0 +1,40 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { fetchRadarFrames, RAINVIEWER_TTL_MS } from '../services/rainviewer' +import type { RadarFrame } from '../services/rainviewer' + +interface RadarState { + frames: RadarFrame[] + loading: boolean + error: string | null +} + +const INITIAL: RadarState = { frames: [], loading: true, error: null } + +export function useRadar() { + const [state, setState] = useState(INITIAL) + const mountedRef = useRef(true) + + const load = useCallback(() => { + fetchRadarFrames() + .then((frames) => { + if (mountedRef.current) setState({ frames, loading: false, error: null }) + }) + .catch((err: unknown) => { + if (!mountedRef.current) return + const message = err instanceof Error ? err.message : 'Failed to load radar data' + setState((s) => ({ ...s, loading: false, error: message })) + }) + }, []) + + useEffect(() => { + mountedRef.current = true + load() + const id = setInterval(load, RAINVIEWER_TTL_MS) + return () => { + mountedRef.current = false + clearInterval(id) + } + }, [load]) + + return { ...state, refetch: load } +} diff --git a/SWS_App/src/pages/HomePage.tsx b/SWS_App/src/pages/HomePage.tsx index 9baa632..027e487 100644 --- a/SWS_App/src/pages/HomePage.tsx +++ b/SWS_App/src/pages/HomePage.tsx @@ -1,11 +1,14 @@ -import { useState } from 'react' +import { lazy, Suspense, useState } from 'react' import { CurrentWeatherCard } from '../components/CurrentWeatherCard' import { Forecast10Day } from '../components/Forecast10Day' import { Hourly24 } from '../components/Hourly24' -import { MapPlaceholder } from '../components/MapPlaceholder' import { SavedLocationsList } from '../components/SavedLocationsList' import { SearchBar } from '../components/SearchBar' import { UnitToggle } from '../components/UnitToggle' + +const RadarMap = lazy(() => + import('../components/RadarMap').then((m) => ({ default: m.RadarMap })) +) import { useSavedLocations } from '../hooks/useSavedLocations' import { useUnits } from '../hooks/useUnits' import { useWeather } from '../hooks/useWeather' @@ -110,24 +113,38 @@ export function HomePage() { {/* Weather data */} {!loading && !error && current && activeLocation && ( -
-
-
- - {hourly.length > 0 && } - {daily.length > 0 && } +
+ +
+ {hourly.length > 0 && ( +
+ +
+ )} +
0 ? ' col-lg-6' : ''}`}> + +
+
+ Loading map… +
+
+
+ } + > + +
-
- -
+ {daily.length > 0 && }
)}
diff --git a/SWS_App/src/services/noaaRadar.ts b/SWS_App/src/services/noaaRadar.ts new file mode 100644 index 0000000..82e1866 --- /dev/null +++ b/SWS_App/src/services/noaaRadar.ts @@ -0,0 +1,12 @@ +export const NOAA_WMS_URL = 'https://opengeo.ncep.noaa.gov/geoserver/conus/conus_bref_qcd/ows' + +export const NOAA_WMS_PARAMS = { + layers: 'conus_bref_qcd', + format: 'image/png', + transparent: true, + version: '1.1.1', +} as const + +export function isInUS(lat: number, lon: number): boolean { + return lat >= 24 && lat <= 50 && lon >= -125 && lon <= -66 +} diff --git a/SWS_App/src/services/rainviewer.ts b/SWS_App/src/services/rainviewer.ts new file mode 100644 index 0000000..fd9b59e --- /dev/null +++ b/SWS_App/src/services/rainviewer.ts @@ -0,0 +1,55 @@ +const MANIFEST_URL = 'https://api.rainviewer.com/public/weather-maps.json' +export const RAINVIEWER_TTL_MS = 10 * 60 * 1000 + +interface RainViewerFrame { + time: number + path: string +} + +interface RainViewerManifest { + version: string + generated: number + host: string + radar: { + past: RainViewerFrame[] + nowcast: RainViewerFrame[] + } +} + +export interface RadarFrame { + time: number + path: string + tileUrl: string +} + +interface CacheEntry { + data: RadarFrame[] + expiresAt: number +} + +let cached: CacheEntry | null = null + +function buildTileUrl(host: string, path: string): string { + return `${host}${path}/256/{z}/{x}/{y}/2/1_1.png` +} + +export async function fetchRadarFrames(): Promise { + if (cached && Date.now() < cached.expiresAt) return cached.data + + const res = await fetch(MANIFEST_URL) + if (!res.ok) throw new Error(`RainViewer fetch failed: ${res.status}`) + + const manifest = (await res.json()) as RainViewerManifest + + const frames: RadarFrame[] = [ + ...manifest.radar.past, + ...manifest.radar.nowcast, + ].map((f) => ({ + time: f.time, + path: f.path, + tileUrl: buildTileUrl(manifest.host, f.path), + })) + + cached = { data: frames, expiresAt: Date.now() + RAINVIEWER_TTL_MS } + return frames +} From 86182a8c69026a2a349b96fca7ac682f8d9fcaa2 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Tue, 17 Mar 2026 16:34:59 -0400 Subject: [PATCH 3/4] Add Radar Switching between NOAA and rainviewer --- SWS_App/src/__tests__/weather.test.ts | 2 + SWS_App/src/components/RadarMap.css | 48 ++++++++++++++++-- SWS_App/src/components/RadarMap.tsx | 71 +++++++++++++++++++++++++-- SWS_App/src/hooks/useAQI.ts | 10 ++-- 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/SWS_App/src/__tests__/weather.test.ts b/SWS_App/src/__tests__/weather.test.ts index 7b32325..60666f1 100644 --- a/SWS_App/src/__tests__/weather.test.ts +++ b/SWS_App/src/__tests__/weather.test.ts @@ -67,6 +67,7 @@ const mockHourlyResponse = { temperature_2m: Array.from({ length: 48 }, (_, i) => 20 + i * 0.1), precipitation_probability: Array.from({ length: 48 }, () => 5), wind_speed_10m: Array.from({ length: 48 }, () => 10), + wind_direction_10m: Array.from({ length: 48 }, () => 180), weather_code: Array.from({ length: 48 }, () => 0), uv_index: Array.from({ length: 48 }, () => 3), }, @@ -180,6 +181,7 @@ describe('getHourlyForecast', () => { temperature_2m: Array.from({ length: 48 }, () => 20), precipitation_probability: Array.from({ length: 48 }, () => 0), wind_speed_10m: Array.from({ length: 48 }, () => 10), + wind_direction_10m: Array.from({ length: 48 }, () => 0), weather_code: Array.from({ length: 48 }, () => 0), uv_index: Array.from({ length: 48 }, () => 0), }, diff --git a/SWS_App/src/components/RadarMap.css b/SWS_App/src/components/RadarMap.css index ba668fd..1bf0829 100644 --- a/SWS_App/src/components/RadarMap.css +++ b/SWS_App/src/components/RadarMap.css @@ -50,7 +50,7 @@ .radar-map-timestamp { position: absolute; - bottom: 1.5rem; + bottom: 3.5rem; left: 0.5rem; z-index: 1000; background: rgba(255, 255, 255, 0.9); @@ -60,19 +60,61 @@ pointer-events: none; } +.radar-map-legend-btn { + position: absolute; + bottom: 3.5rem; + right: 0.5rem; + z-index: 1000; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; + font-size: 0.7rem; + padding: 0.2rem 0.5rem; + cursor: pointer; +} + +.radar-map-legend-btn:hover { + background: rgba(255, 255, 255, 1); +} + .radar-map-legend { position: absolute; - bottom: 1.5rem; + bottom: 3.5rem; right: 0.5rem; z-index: 1000; background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(0, 0, 0, 0.3); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.65rem; - pointer-events: none; + pointer-events: auto; display: flex; flex-direction: column; gap: 2px; + max-height: 60vh; + overflow-y: auto; +} + +.radar-map-legend-header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + margin-bottom: 4px; + gap: 8px; + cursor: pointer; + user-select: none; +} + +.radar-map-legend-section { + font-weight: 600; + margin-top: 4px; + margin-bottom: 2px; +} + +.radar-map-legend-divider { + border-top: 1px solid rgba(0, 0, 0, 0.15); + margin: 4px 0; } .radar-map-legend-item { diff --git a/SWS_App/src/components/RadarMap.tsx b/SWS_App/src/components/RadarMap.tsx index 9420aae..afe0167 100644 --- a/SWS_App/src/components/RadarMap.tsx +++ b/SWS_App/src/components/RadarMap.tsx @@ -12,7 +12,7 @@ interface RadarMapProps { lon: number } -const LEGEND_ITEMS = [ +const RAINVIEWER_LEGEND = [ { color: '#96d2fa', label: 'Light' }, { color: '#04e604', label: 'Moderate' }, { color: '#f0f050', label: 'Heavy' }, @@ -21,15 +21,67 @@ const LEGEND_ITEMS = [ { color: '#9600b4', label: 'Extreme' }, ] -function RadarLegend() { +const NOAA_RAIN_LEGEND = [ + { color: '#00ffff', label: 'Very Light (5+ dBZ)' }, + { color: '#00ff00', label: 'Light (20+ dBZ)' }, + { color: '#ffff00', label: 'Moderate (30+ dBZ)' }, + { color: '#ff9000', label: 'Heavy (40+ dBZ)' }, + { color: '#ff0000', label: 'Intense (50+ dBZ)' }, + { color: '#be0000', label: 'Severe (60+ dBZ)' }, + { color: '#ff00ff', label: 'Extreme (65+ dBZ)' }, +] + +const NOAA_WINTER_LEGEND = [ + { color: '#add8e6', label: 'Flurries' }, + { color: '#00ffff', label: 'Light Snow' }, + { color: '#00ff00', label: 'Moderate Snow' }, + { color: '#ffff00', label: 'Heavy Snow / Squall' }, + { color: '#ff69b4', label: 'Freezing Rain / Sleet' }, + { color: '#ff9000', label: 'Dangerous Accumulation' }, +] + +interface RadarLegendProps { + mapSource: MapSource + expanded: boolean + onToggle: () => void +} + +function RadarLegend({ mapSource, expanded, onToggle }: RadarLegendProps) { + if (!expanded) { + return ( + + ) + } + + const rainItems = mapSource === 'noaa' ? NOAA_RAIN_LEGEND : RAINVIEWER_LEGEND + const sectionTitle = mapSource === 'noaa' ? 'Rain & Storms' : 'Precipitation' + return (
- {LEGEND_ITEMS.map(({ color, label }) => ( +
+ {sectionTitle} + +
+ {rainItems.map(({ color, label }) => (
{label}
))} + {mapSource === 'noaa' && ( + <> +
+
Winter Weather
+ {NOAA_WINTER_LEGEND.map(({ color, label }) => ( +
+ + {label} +
+ ))} + + )}
) } @@ -77,6 +129,7 @@ export function RadarMap({ lat, lon }: RadarMapProps) { const { frames, error } = useRadar() const [expanded, setExpanded] = useState(false) const [mapSource, setMapSource] = useState('noaa') + const [legendExpanded, setLegendExpanded] = useState(false) const latestFrame = frames.length > 0 ? frames[frames.length - 1] : null @@ -174,7 +227,11 @@ export function RadarMap({ lat, lon }: RadarMapProps) { {mapSource === 'rainviewer' && error && (
Radar unavailable
)} - + setLegendExpanded((e) => !e)} + />
) @@ -207,7 +264,11 @@ export function RadarMap({ lat, lon }: RadarMapProps) { {mapSource === 'rainviewer' && error && (
Radar unavailable
)} - + setLegendExpanded((e) => !e)} + />
diff --git a/SWS_App/src/hooks/useAQI.ts b/SWS_App/src/hooks/useAQI.ts index 28cde4c..df0aace 100644 --- a/SWS_App/src/hooks/useAQI.ts +++ b/SWS_App/src/hooks/useAQI.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useRef, useCallback } from 'react' import type { HourlyAQI } from '../types/weather' import { getHourlyAQI } from '../services/aqi' @@ -18,11 +18,13 @@ const INITIAL_STATE: AQIState = { export function useAQI(lat: number | null, lon: number | null): AQIState & { fetch: () => void } { const [state, setState] = useState(INITIAL_STATE) + const prevLocationRef = useRef<{ lat: number | null; lon: number | null }>({ lat, lon }) - // Reset when location changes — never auto-fetch - useEffect(() => { + // Reset synchronously during render when location changes — never auto-fetch + if (prevLocationRef.current.lat !== lat || prevLocationRef.current.lon !== lon) { + prevLocationRef.current = { lat, lon } setState(INITIAL_STATE) - }, [lat, lon]) + } const fetch = useCallback(() => { if (lat == null || lon == null) return From ea93d589cb71a4666a1c440cca10408e12122e10 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Tue, 17 Mar 2026 16:39:09 -0400 Subject: [PATCH 4/4] Fix lint --- SWS_App/src/hooks/useAQI.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/SWS_App/src/hooks/useAQI.ts b/SWS_App/src/hooks/useAQI.ts index df0aace..8b1f292 100644 --- a/SWS_App/src/hooks/useAQI.ts +++ b/SWS_App/src/hooks/useAQI.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from 'react' +import { useState, useCallback } from 'react' import type { HourlyAQI } from '../types/weather' import { getHourlyAQI } from '../services/aqi' @@ -18,11 +18,13 @@ const INITIAL_STATE: AQIState = { export function useAQI(lat: number | null, lon: number | null): AQIState & { fetch: () => void } { const [state, setState] = useState(INITIAL_STATE) - const prevLocationRef = useRef<{ lat: number | null; lon: number | null }>({ lat, lon }) + const [prevLat, setPrevLat] = useState(lat) + const [prevLon, setPrevLon] = useState(lon) // Reset synchronously during render when location changes — never auto-fetch - if (prevLocationRef.current.lat !== lat || prevLocationRef.current.lon !== lon) { - prevLocationRef.current = { lat, lon } + if (prevLat !== lat || prevLon !== lon) { + setPrevLat(lat) + setPrevLon(lon) setState(INITIAL_STATE) }