From b37ea92a6d7f065c7cd02a7d5f5366e78b4c8685 Mon Sep 17 00:00:00 2001 From: robertmcabee Date: Tue, 31 Mar 2026 15:41:28 -0600 Subject: [PATCH 1/5] improve error handling when a request is made #42 --- frontend/src/App.jsx | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e06c83e..3679879 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,7 +22,7 @@ export default function App() { const [timezone, setTimezone] = useState(dayjs.tz.guess()); // API State - const [jobStatus, setjobStatus] = useState("NONE"); + const [jobStatus, setJobStatus] = useState("NONE"); const [jobId, setjobId] = useState(""); const [numFrames, setNumFrames] = useState(0); const [frames, setFrames] = useState([]); @@ -40,8 +40,10 @@ export default function App() { try { // ---- validate request ---- if (!selectedStation?.properties?.station_id) { - throw new Error("No station selected"); + setErrorMessage("Please select a radar station first."); + return; } + setErrorMessage(""); const requestBody = { stationId: selectedStation.properties.station_id }; @@ -56,12 +58,14 @@ export default function App() { requestBody.startUtc = dayjs().subtract(45, 'minute').utc().format("YYYY-MM-DDTHH:mm:ss[Z]") requestBody.endUtc = dayjs().subtract(25, 'minute').utc().format("YYYY-MM-DDTHH:mm:ss[Z]") } - // ---- make request ---- - setjobStatus("REQUESTED"); - setErrorMessage(""); + + // ---- reset state ---- + setJobStatus("REQUESTED"); setjobId(""); setNumFrames(0); setFrames([]); + + // ---- make request ---- const response = await fetch("/apis/run", { method: 'POST', headers: { @@ -70,14 +74,24 @@ export default function App() { }, body: JSON.stringify(requestBody) }); + + // ---- Handle Errors ---- if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`); + const errorData = await response.json().catch(() => ({})); + const errorMsg = errorData.error || errorData.message || `Error ${response.status}: ${response.statusText}`; + setErrorMessage(errorMsg); + setJobStatus("FAILED"); + throw new Error(errorMsg); // This sends the message to the catch block } + const data = await response.json(); - console.log(data) setjobId(data.job_id); } catch (err) { - console.error(err) + console.error("Fetch Error:", err); + setJobStatus("FAILED"); + if (!errorMessage) { + setErrorMessage("A network error occurred. Please try again."); + } } }; @@ -129,9 +143,11 @@ useEffect(() => { const response = await fetch(`/apis/status?job_id=${jobId}`); const data = await response.json(); console.log(data); - setjobStatus(data.status); - if (data.error_message){ + setJobStatus(data.status); + if (data.error){ setErrorMessage(data.error_message); + console.log("here"); + } else { setErrorMessage(""); } @@ -238,8 +254,7 @@ useEffect(() => { {jobStatus === "PROCESSING" &&

The radar data is being processed. This usually takes a couple minutes.

} {jobStatus === "REQUESTED" &&

The radar data has been requested. Please wait.

} - -

{errorMessage}

+ {errorMessage&&

{errorMessage}

}
- {jobStatus === "PROCESSING" &&

The radar data is being processed. This usually takes a couple minutes.

} - {jobStatus === "REQUESTED" &&

The radar data has been requested. Please wait.

} - {errorMessage&&

{errorMessage}

} + {/* Fetch Button */} + + + {jobStatus === "PROCESSING" && ( +

+ The radar data is being processed. This usually takes a couple + minutes. +

+ )} + {jobStatus === "REQUESTED" && ( +

The radar data has been requested. Please wait.

+ )} + {errorMessage &&

{errorMessage}

} -
- - ({ value: i }))} - /> -
- - - - +
+ + ({ value: i }))} + /> +
+ + + ); } diff --git a/frontend/src/components/GeoTiffAnimation.jsx b/frontend/src/components/GeoTiffAnimation.jsx index 7450803..f0e071f 100644 --- a/frontend/src/components/GeoTiffAnimation.jsx +++ b/frontend/src/components/GeoTiffAnimation.jsx @@ -1,7 +1,7 @@ +import parseGeoraster from "georaster"; +import L from "leaflet"; import { useEffect, useState } from "react"; import { ImageOverlay } from "react-leaflet"; -import L from "leaflet"; -import parseGeoraster from "georaster"; export default function GeotiffAnimation({ frames, currentIndex }) { const [frameData, setFrameData] = useState([]); @@ -9,51 +9,59 @@ export default function GeotiffAnimation({ frames, currentIndex }) { useEffect(() => { async function processFrames() { - const processed = await Promise.all(frames.map(async (url) => { - const res = await fetch(url); - const buf = await res.arrayBuffer(); - const geo = await parseGeoraster(buf); + const processed = await Promise.all( + frames.map(async (url) => { + const res = await fetch(url); + const buf = await res.arrayBuffer(); + const geo = await parseGeoraster(buf); - const canvas = document.createElement('canvas'); - const w = canvas.width = geo.width; - const h = canvas.height = geo.height; - const ctx = canvas.getContext('2d'); - const imgData = ctx.createImageData(w, h); - const data = imgData.data; + const canvas = document.createElement("canvas"); + canvas.width = geo.width; + const w = canvas.width; + canvas.height = geo.height; + const h = canvas.height; + const ctx = canvas.getContext("2d"); + const imgData = ctx.createImageData(w, h); + const data = imgData.data; - // Cache band references - const bands = geo.values; - const numBands = bands.length; + // Cache band references + const bands = geo.values; + const numBands = bands.length; - for (let i = 0; i < w * h; i++) { - const x = i % w; - const y = (i / w) | 0; - const i4 = i << 2; + for (let i = 0; i < w * h; i++) { + const x = i % w; + const y = (i / w) | 0; + const i4 = i << 2; - if (numBands >= 3) { - data[i4] = bands[0][y][x]; // R - data[i4 + 1] = bands[1][y][x]; // G - data[i4 + 2] = bands[2][y][x]; // B - data[i4 + 3] = numBands === 4 ? bands[3][y][x] : 255; - } else { - const v = bands[0][y][x]; - data[i4] = data[i4 + 1] = data[i4 + 2] = v; - data[i4 + 3] = 255; + if (numBands >= 3) { + data[i4] = bands[0][y][x]; // R + data[i4 + 1] = bands[1][y][x]; // G + data[i4 + 2] = bands[2][y][x]; // B + data[i4 + 3] = numBands === 4 ? bands[3][y][x] : 255; + } else { + const v = bands[0][y][x]; + data[i4] = data[i4 + 1] = data[i4 + 2] = v; + data[i4 + 3] = 255; + } } - } - ctx.putImageData(imgData, 0, 0); + ctx.putImageData(imgData, 0, 0); - const southWest = L.Projection.SphericalMercator.unproject(L.point(geo.xmin, geo.ymin)); - const northEast = L.Projection.SphericalMercator.unproject(L.point(geo.xmax, geo.ymax)); + const southWest = L.Projection.SphericalMercator.unproject( + L.point(geo.xmin, geo.ymin), + ); + const northEast = L.Projection.SphericalMercator.unproject( + L.point(geo.xmax, geo.ymax), + ); - return { - url: canvas.toDataURL(), - bounds: [ - [southWest.lat, southWest.lng], - [northEast.lat, northEast.lng] - ] - }; - })); + return { + url: canvas.toDataURL(), + bounds: [ + [southWest.lat, southWest.lng], + [northEast.lat, northEast.lng], + ], + }; + }), + ); setFrameData(processed); setLoading(false); @@ -71,4 +79,4 @@ export default function GeotiffAnimation({ frames, currentIndex }) { opacity={0.7} /> ); -} \ No newline at end of file +} diff --git a/frontend/src/components/LeafletMap.jsx b/frontend/src/components/LeafletMap.jsx index 82341b6..9540b30 100644 --- a/frontend/src/components/LeafletMap.jsx +++ b/frontend/src/components/LeafletMap.jsx @@ -1,44 +1,44 @@ -{/* Note that the leaflet map requires a defined height. */ } -{/* Vite hot reload has inconsistent behavior when making changes to the map, be sure to *fully* reload the page */ } - -import { useState, useEffect, useCallback } from 'react' -import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet' -import 'leaflet/dist/leaflet.css' -import GeoTiffAnimation from './GeoTiffAnimation' - +{ + /* Note that the leaflet map requires a defined height. */ +} +{ + /* Vite hot reload has inconsistent behavior when making changes to the map, be sure to *fully* reload the page */ +} -function DisplayPosition({ map, mapLatLng, setMapLatLng }) { +import { useCallback, useEffect, useState } from "react"; +import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import GeoTiffAnimation from "./GeoTiffAnimation"; +function DisplayPosition({ map, setMapLatLng }) { const onMove = useCallback(() => { - setMapLatLng(map.getCenter()) - }, [map, setMapLatLng]) + setMapLatLng(map.getCenter()); + }, [map, setMapLatLng]); useEffect(() => { - map.on('move', onMove) + map.on("move", onMove); return () => { - map.off('move', onMove) - } - }, [map, onMove]) + map.off("move", onMove); + }; + }, [map, onMove]); } - export default function LeafletMap({ stations = [], selectedStation, setSelectedStation, frames = [], - currentFrameIndex + currentFrameIndex, }) { - const [map, setMap] = useState(null) - const [mapLatLng, setMapLatLng] = useState({ "lat": 40.0, "lng": -98.0 }); + const [map, setMap] = useState(null); + const [mapLatLng, setMapLatLng] = useState({ lat: 40.0, lng: -98.0 }); useEffect(() => { - if (!map || !selectedStation) return - const [lng, lat] = selectedStation.geometry.coordinates - const latLng = [lat, lng] - map.setView(latLng, 9) - setMapLatLng(map.getCenter()) - }, [selectedStation, map, setMapLatLng]) + if (!map || !selectedStation) return; + const [lng, lat] = selectedStation.geometry.coordinates; + const latLng = [lat, lng]; + map.setView(latLng, 9); + }, [selectedStation, map]); return (
@@ -54,52 +54,44 @@ export default function LeafletMap({ center={mapLatLng} zoom={4} scrollWheelZoom={false} - style={{ height: '600px', width: '100%' }} + style={{ height: "600px", width: "100%" }} ref={setMap} > - {stations.map((station) => { - const id = station?.properties?.station_id - const name = station?.properties?.name - const coords = station?.geometry?.coordinates - - if (!id || !coords || coords.length < 2) return null + {stations.map((station) => { + const id = station?.properties?.station_id; + const name = station?.properties?.name; + const coords = station?.geometry?.coordinates; - const [lng, lat] = coords + if (!id || !coords || coords.length < 2) return null; - const isSelected = - selectedStation?.properties?.station_id === id + const [lng, lat] = coords; - return ( - - - {name} ({id})
+ const isSelected = selectedStation?.properties?.station_id === id; - -
-
- ) - })} - + : "bg-[#1976d2] hover:bg-[#1565c0] text-white cursor-pointer" + }`} + onClick={() => setSelectedStation(station)} + > + {isSelected ? "Selected" : "Select"} + + + + ); + })} +
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/components/RadarStationDropdown.jsx b/frontend/src/components/RadarStationDropdown.jsx index 8b1211a..ed849a3 100644 --- a/frontend/src/components/RadarStationDropdown.jsx +++ b/frontend/src/components/RadarStationDropdown.jsx @@ -1,23 +1,23 @@ import FormControl from "@mui/material/FormControl"; import InputLabel from "@mui/material/InputLabel"; -import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; export default function RadarStationDropdown({ stations = [], selectedStation, - setSelectedStation + setSelectedStation, }) { - const stationsArr = Array.isArray(stations) ? stations : [] + const stationsArr = Array.isArray(stations) ? stations : []; function handleChange(event) { - const stationID = event.target.value + const stationID = event.target.value; const stationObj = stationsArr.find( - s => s?.properties?.station_id === stationID - ) + (s) => s?.properties?.station_id === stationID, + ); - setSelectedStation(stationObj || null) + setSelectedStation(stationObj || null); } const stationID = selectedStation?.properties?.station_id || ""; @@ -31,7 +31,6 @@ export default function RadarStationDropdown({ label="Select a station" onChange={handleChange} > - {stationsArr.map((feature) => { const props = feature?.properties; const stationId = props?.station_id ?? ""; @@ -48,4 +47,4 @@ export default function RadarStationDropdown({ ); -} \ No newline at end of file +} From 38b859467fb65d4b486a8ba5906b591b55f500d6 Mon Sep 17 00:00:00 2001 From: robertmcabee Date: Fri, 3 Apr 2026 13:05:47 -0600 Subject: [PATCH 3/5] add duration selector for #47; CSS tweaks for large screens --- frontend/src/App.jsx | 225 +++++++++++++++++++++++-------------------- 1 file changed, 123 insertions(+), 102 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0278944..ea04ad8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,6 +28,7 @@ export default function App() { dayjs().tz(dayjs.tz.guess()), ); const [timezone, setTimezone] = useState(dayjs.tz.guess()); + const [selectedDuration, setSelectedDuration] = useState("60"); // API State const [jobStatus, setJobStatus] = useState("NONE"); @@ -52,28 +53,27 @@ export default function App() { return; } setErrorMessage(""); + const durationMinutes = Number(selectedDuration); const requestBody = { stationId: selectedStation.properties.station_id, }; if (!currentMode) { console.log("using historical data"); - // if not fetching current data the end time requested is t+30 minutes requestBody.startUtc = selectedDateTime .utc() .format("YYYY-MM-DDTHH:mm:ss[Z]"); requestBody.endUtc = selectedDateTime - .add(30, "minute") + .add(durationMinutes, "minute") .utc() .format("YYYY-MM-DDTHH:mm:ss[Z]"); } else { - // currently using the same timebox as the default in run_request.py when no timebox is provided console.log("using current data"); requestBody.startUtc = dayjs() - .subtract(45, "minute") + .subtract(durationMinutes + 15, "minute") .utc() .format("YYYY-MM-DDTHH:mm:ss[Z]"); requestBody.endUtc = dayjs() - .subtract(25, "minute") + .subtract(15, "minute") .utc() .format("YYYY-MM-DDTHH:mm:ss[Z]"); } @@ -198,7 +198,7 @@ export default function App() { if (isPlaying) { playbackRef.current = setInterval(() => { setCurrentFrameIndex((prev) => (prev + 1) % frames.length); - }, 750); + }, 300); } else { clearInterval(playbackRef.current); } @@ -207,7 +207,7 @@ export default function App() { }, [isPlaying, frames.length]); // Handle Slider Change - const handleSliderChange = (newValue) => { + const handleSliderChange = (event, newValue) => { setIsPlaying(false); setCurrentFrameIndex(newValue); }; @@ -215,110 +215,131 @@ export default function App() { // ---------------------------------------- JSX ---------------------------------------- return ( - -
- {/* Station Selector */} -
+
+
+
+ {/* Station Selector */} -
-
-
- { - setCurrentMode(!currentMode); - }} - > -

Use Current Data

-
-
- {/* Timezone Selector */} - - Timezone - - - {/* Date Time Selector */} - - - setSelectedDateTime(dayjs(newValue).tz(timezone)) - } - defaultValue={dayjs("2026-03-11T15:00")} - className="flex-1" - /> - + Get latest radar data + +
+
+ + Duration + + + {/* Timezone Selector */} + + Timezone + + + {/* Date Time Selector */} + +
+ + setSelectedDateTime(dayjs(newValue).tz(timezone)) + } + defaultValue={dayjs("2026-03-11T15:00")} + className="w-full" + /> +
+
+
+ {/* Fetch Button */} + + {jobStatus === "PROCESSING" && ( +

+ The radar data is being processed. This usually takes a couple + minutes. +

+ )} + {jobStatus === "REQUESTED" && ( +

The radar data has been requested. Please wait.

+ )} + {errorMessage &&

{errorMessage}

}
- {/* Fetch Button */} - -
- {jobStatus === "PROCESSING" && ( -

- The radar data is being processed. This usually takes a couple - minutes. -

- )} - {jobStatus === "REQUESTED" && ( -

The radar data has been requested. Please wait.

- )} - {errorMessage &&

{errorMessage}

} -
- - ({ value: i }))} - /> +
+ +
+ + 0 ? frames.length - 1 : 0} + step={1} + onChange={handleSliderChange} + valueLabelDisplay="auto" + marks={frames.map((_, i) => ({ value: i }))} + /> +
+
- - - - - +
); } From e6f8b2bf9fe4a9395ee8d8d2d6ff69516eefbbe0 Mon Sep 17 00:00:00 2001 From: robertmcabee Date: Fri, 3 Apr 2026 13:06:25 -0600 Subject: [PATCH 4/5] remove last 2 hour check --- backend/apis/run_request.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/apis/run_request.py b/backend/apis/run_request.py index 4b3218d..de189fb 100644 --- a/backend/apis/run_request.py +++ b/backend/apis/run_request.py @@ -86,10 +86,6 @@ def validate_time_parameters(request_fields: dict): except ValueError: return jsonify({"error": "Invalid datetime format. Expected ISO 8601: YYYY-MM-DDTHH:MM:SSZ"}) - # startUtc must be within the last 2 hours - if start_utc < now - timedelta(minutes=120): - return jsonify({"error": "startUtc must be within the last 2 hours"}) - # endUtc must be after startUtc if end_utc <= start_utc: return jsonify({"error": "endUtc must be after startUtc"}) From 22cde9a161d4c3da1127ae2ba90736f66e8bd92d Mon Sep 17 00:00:00 2001 From: robertmcabee Date: Fri, 3 Apr 2026 13:14:55 -0600 Subject: [PATCH 5/5] enable scrollwheel zoom on map --- frontend/src/components/LeafletMap.jsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/LeafletMap.jsx b/frontend/src/components/LeafletMap.jsx index 9540b30..41df610 100644 --- a/frontend/src/components/LeafletMap.jsx +++ b/frontend/src/components/LeafletMap.jsx @@ -53,7 +53,7 @@ export default function LeafletMap({ @@ -78,10 +78,11 @@ export default function LeafletMap({ {name} ({id})