diff --git a/client/src/components/Widgets/IvansAlerts.jsx b/client/src/components/Widgets/IvansAlerts.jsx
index 47d2a65..48451a7 100644
--- a/client/src/components/Widgets/IvansAlerts.jsx
+++ b/client/src/components/Widgets/IvansAlerts.jsx
@@ -17,24 +17,24 @@ export default function IvansAlerts({ settings, widgetID }) {
const url = useBuildUrl('/api/v2/comm_coor_ivans', settings?.startDate, settings?.endDate);
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
- const {data: apiData, isLoading, error} = VdapApi(url);
+ const {data, isLoading, error} = VdapApi(url);
// sort the apiData
- const data = useMemo(() => {
- if (apiData?.length === 0) return [];
+ const some_data = useMemo(() => {
+ if (data?.length === 0) return [];
const sorts = settings?.activeSorts;
- if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...apiData].sort(ascTimeVolcanoSort);
- else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...apiData].sort(descTimeVolcanoSort);
- else if (sorts?.timeSort === "asc") return [...apiData].sort(ascTimeSort);
- else return [...apiData].sort(descTimeSort);
+ if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...data].sort(ascTimeVolcanoSort);
+ else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...data].sort(descTimeVolcanoSort);
+ else if (sorts?.timeSort === "asc") return [...data].sort(ascTimeSort);
+ else return [...data].sort(descTimeSort);
- }, [apiData, settings]);
+ }, [data, settings]);
// copy data to clipboard in formatted way when user clicks on copy btn in widget settings
useEffect(() => {
if (settings?.copyToClipboard === true) {
- const dataText = data.map((item) => {
+ const dataText = some_data.map((item) => {
return `${item.volcano_name}\n` +
`Report Number: ${item.report_num}\n` +
`Send Time: ${item.send_time}\n` +
@@ -50,7 +50,7 @@ export default function IvansAlerts({ settings, widgetID }) {
}));
updateSettings(widgetID, {copyToClipboard: false});
}
- }, [settings, data, widgetID, updateSettings]);
+ }, [settings, some_data, widgetID, updateSettings]);
if (isLoading) return (
@@ -64,7 +64,7 @@ export default function IvansAlerts({ settings, widgetID }) {
);
- if (data.length === 0) return (
+ if (some_data.length === 0) return (
No flight level data available for the selected dates.
@@ -80,7 +80,7 @@ export default function IvansAlerts({ settings, widgetID }) {
borderRadius="xl"
bg="gray.800"
>
- {data.map((item, index) => {
+ {some_data.map((item, index) => {
if (thisWidget?.size === "large") return (
{/* Using context to dynamically change the border radii of the triggers */}
diff --git a/client/src/components/Widgets/VaaMap.jsx b/client/src/components/Widgets/VaaMap.jsx
new file mode 100644
index 0000000..1ea059e
--- /dev/null
+++ b/client/src/components/Widgets/VaaMap.jsx
@@ -0,0 +1,212 @@
+import { useRef, useMemo, useEffect } from "react";
+import {MapContainer, useMap, TileLayer, Polygon, Tooltip} from "react-leaflet";
+import "leaflet/dist/leaflet.css";
+import {Flex, Spinner, Text} from "@chakra-ui/react";
+import L from 'leaflet';
+
+export default function VaaMap({ settings, widgetID}) {
+ const mapRef = useRef(null);
+
+ // TEST DATA
+ const mock_data = useMemo( () =>
+ [
+ {
+ "vaa_datetime": "2026-08-18T18:01:00Z",
+ "vnum": 342090,
+ "volc_name": "Fuego",
+ "volc_elev_m": 3763,
+ "vaac_location": "Washington",
+ "obs_va_datetime": "2026-08-18T17:30:00Z",
+ "obs_va_epoch": 1787074200000,
+ "aviation_color_code": null,
+ "obs_fl": "140",
+ "obs_fl_dir": "SW",
+ "obs_h_asl_m": 4267.0,
+ "obs_h_avs_m": 504.0,
+ "projections":
+ {
+ "six": "25/2030Z SFC/FL160 N1446 W09202 - N1428 W09052 - N1423 W09200 - N1446 W09202",
+ "twelve": "26/0230Z SFC/FL160 N1444 W09203 - N1428 W09052 - N1422 W09201 - N1444 W09203",
+ "eighteen": "26/0830Z SFC/FL160 N1441 W09204 - N1428 W09052 - N1419 W09202 - N1441 W09204"
+ }
+ },
+ {
+ "vaa_datetime": "2026-08-25T01:01:00Z",
+ "vnum": 282080,
+ "volc_name": "Sabancaya",
+ "volc_elev_m": 1117,
+ "vaac_location": "Tokyo",
+ "obs_va_datetime": "2026-08-25T00:50:00Z",
+ "obs_va_epoch": 1787619000000,
+ "aviation_color_code": null,
+ "obs_fl": "180",
+ "obs_fl_dir": null,
+ "obs_h_asl_m": 5486,
+ "obs_h_avs_m": 4369,
+ "projections":
+ {
+ "six": "14/0200Z SFC/FL250 S1532 W07036 - S1552 W07029 - S1613 W07035 - S1546 W07150 - S1532 W07036 ",
+ "twelve": "14/0800Z SFC/FL250 S1513 W07045 - S1527 W07033 - S1551 W07029 - S1544 W07150 - S1513 W07045 ",
+ "eighteen": "14/1400Z SFC/FL250 S1511 W07044 - S1531 W07028 - S1550 W07027 - S1546 W07151 - S1511 W07044 "
+ },
+ },
+ ]
+ , []);
+
+ const {data, isLoading, error} = useMemo(() => {
+ const data = settings?.data?.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime));
+ const isLoading = settings?.isLoading;
+ const error = settings?.error;
+ return {data, isLoading, error};
+ }, [settings.data, settings.isLoading, settings.error]);
+
+ // convert Degree & Minutes (DM) to Decimal Degrees (DD)
+ const polygons = useMemo(() => {
+ if (!mock_data) return []; // TODO: replace with real data
+ return mock_data.map((vaa) => { // TODO: replace with real data
+ const six_hrs = convertDMtoDD(vaa.projections.six);
+ const twelve_hrs = convertDMtoDD(vaa.projections.twelve);
+ const eighteen_hrs = convertDMtoDD(vaa.projections.eighteen);
+ return {...vaa, projections: [six_hrs, twelve_hrs, eighteen_hrs]};
+ })
+ }, [mock_data]);
+
+ const lineColors= {
+ 0: { color: 'red' },
+ 1: { color: 'purple' },
+ 2: { fillColor: 'blue' },
+ }
+
+ const hourCode = {
+ 0: "+6 hours",
+ 1: "+12 hours",
+ 2: "+18 hours",
+ }
+
+ if (isLoading) return (
+
+
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (data?.length === 0) return (
+
+ No VAA data available.
+
+ );
+
+ return (
+
+
+ {polygons?.map((polygon) => {
+ return polygon?.projections?.map((coordinates, index) =>
+
+
+
+
+ {polygon?.volc_name}
+ {hourCode[index]}
+
+
+ VAA Datetime:
+ {polygon?.vaa_datetime}
+
+
+ VAAC Location:
+ {polygon?.vaac_location}
+
+
+ Height From Vent:
+ {polygon?.obs_h_avs_m} (m)
+
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+function convertDMtoDD(DMString) {
+ const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/);
+
+ const coordinates = cleaned_DMString.slice(2).map((coordinate) => {
+ const direction = coordinate[0];
+ if (direction === "N" || direction === "S") { // 4 digits long
+ const lat = Number(coordinate.slice(1, 3)) + Number((Number(coordinate.slice(3, 5)) / 60).toFixed(5));
+ return direction === "S" ? -lat : lat; // south has negative coordinates
+ } else if (direction === "E" || direction === "W") { // 5 digits long
+ const long = Number(coordinate.slice(1, 4)) + Number((Number(coordinate.slice(4, 6)) / 60).toFixed(5));
+ return direction === "W" ? -long : long; // west has negative coordinates
+ }
+ });
+
+ // pair up lat and long into their own array pairs
+ let polygon = [];
+ for (let i = 0; i < coordinates.length; i+=2) polygon.push([coordinates[i], coordinates[i+1]]);
+ return polygon;
+}
+
+function ZoomToPolygon({ polygons, settings }) {
+ const map = useMap();
+
+ useEffect(() => {
+ let targetBounds;
+ let default_vaa_object;
+ const selectedVAA = settings?.selected;
+ const defaultVAA = settings?.defaultVAA;
+
+ // selectedVAA and defualtVAA are filled in dynamically so they may not always be defined, defualt to hardcoded CVO coordinates
+ if (!selectedVAA && !defaultVAA) {
+ targetBounds = [settings.position];
+ // console.log("zooming to cvo");
+ }
+
+ // if defaultVAA is defined and user hasn't selected a vaa to pan to, then use defaultVAA coordinates
+ else if (!selectedVAA) {
+ // console.log("zooming to", defaultVAA);
+ default_vaa_object = polygons.find((vaa) => vaa.volc_name === defaultVAA);
+ // if for whatever reason defaultVAA isn't found in list of vaa data, default to hardcoded CVO coordinates
+ default_vaa_object ?
+ targetBounds = default_vaa_object?.projections?.[0] :
+ targetBounds = [settings?.position];
+ }
+
+ else if (selectedVAA) {
+ // console.log("zooming to", selectedVAA);
+ const vaa_object = polygons.find((vaa) => vaa.volc_name === selectedVAA);
+ // if selected VAA is not found (say user just started typing in search) will default to defaultVAA, unless defualtVAA is also undefined
+ vaa_object ?
+ targetBounds = vaa_object?.projections?.[0] :
+ (default_vaa_object ?
+ targetBounds = default_vaa_object?.projections?.[0] :
+ targetBounds = [settings?.position]);
+ }
+
+ if (targetBounds) {
+ try {
+ const bounds = L.latLngBounds(targetBounds);
+ if (bounds.isValid()) map.fitBounds(bounds, {padding: [50, 50], animate: true, maxZoom: 8});
+ } catch (err) {
+ console.warn('Invalid bounds structure passed to fitBounds:', targetBounds, err);
+ }
+ }
+ }, [map, polygons, settings.position, settings.selected, settings.defaultVAA]);
+}
\ No newline at end of file
diff --git a/client/src/components/Widgets/settings/VaaMapSettings.jsx b/client/src/components/Widgets/settings/VaaMapSettings.jsx
new file mode 100644
index 0000000..15fe27f
--- /dev/null
+++ b/client/src/components/Widgets/settings/VaaMapSettings.jsx
@@ -0,0 +1,85 @@
+import {useMemo, useEffect, useState} from "react";
+import VdapApi from "@/hooks/useVdapApi.js";
+import useBuildUrl from "@/hooks/useBuildUrl.js";
+import {useDashboardStore} from "@/store/useDashboardStore.jsx";
+import {Combobox, createListCollection, Portal} from "@chakra-ui/react";
+
+export default function VaaMapSettings({widgetId, currentSettings}) {
+ const [inputValue, setInputValue] = useState("");
+ const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
+
+ const url = useBuildUrl("api/v2/comm_coor_vaa", currentSettings?.startDate, currentSettings?.endDate);
+ const {data, isLoading, error} = VdapApi(url);
+
+ // retrieving only the latest VAA for each volcano (resultant has no order)
+ const latest_vaas = useMemo(() => {
+ if (!data || !Array.isArray(data)) return undefined;
+ // retrieves last occurance of vaa, so the vaa's need to stay in rev-chrono order here to work
+ return [...new Map(data.map(item => [item['volc_name'], item])).values()];
+ }, [data]);
+
+ useEffect(() => {
+ if (!latest_vaas) updateSettings(widgetId, {isLoading: isLoading, error: error});
+ else updateSettings(widgetId, {data: latest_vaas, isLoading: isLoading, error: error});
+ }, [updateSettings, widgetId, latest_vaas, isLoading, error]);
+
+ // find volc_name of last issued vaa, map will default to this ash polygon
+ const default_vaa = useMemo(() => {
+ if (!latest_vaas || latest_vaas.length === 0) return undefined;
+ const chrono_order = latest_vaas.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime));
+ // console.log(chrono_order);
+ // console.log(chrono_order[0].volc_name);
+ return chrono_order[0].volc_name;
+ }, [latest_vaas]);
+
+ // set default vaa in settings
+ useEffect(() => {
+ if (default_vaa) updateSettings(widgetId, {defaultVAA: default_vaa});
+ }, [default_vaa, updateSettings, widgetId]);
+
+
+ // filtering based on what user types in search bar
+ const filtered_vaas = useMemo(() => {
+ if (!inputValue.trim()) return latest_vaas;
+ return latest_vaas?.filter((item) => item.volc_name.toLowerCase().includes(inputValue.toLowerCase()));
+ }, [latest_vaas, inputValue]);
+
+ const vaa_collection = useMemo(() => {
+ return createListCollection({
+ items: filtered_vaas?.sort((a, b) => a.volc_name.localeCompare(b.volc_name)),
+ itemToString: (item) => item.volc_name,
+ itemToValue: (item) => item.volc_name,
+ });
+ }, [filtered_vaas]);
+
+ // update state w/ selected vaa so map zooms to polygon
+ useEffect(() => {
+ updateSettings(widgetId, {selected: inputValue});
+ }, [updateSettings, widgetId, inputValue]);
+
+
+ return (
+ setInputValue(e.inputValue)}>
+ Select a Volcano
+
+
+
+
+
+
+
+
+
+
+ No VAA's found
+ {vaa_collection.items.map((vaa) =>
+
+ {vaa?.volc_name}
+
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx
index 2118963..da9466a 100644
--- a/client/src/constants/widgetRegistry.jsx
+++ b/client/src/constants/widgetRegistry.jsx
@@ -7,6 +7,10 @@ import { lazy } from 'react';
// w: 4 = one-third screen width
// w: 3 = one-quarter screen width
+// ONE DAY DATE RANGE
+const getToday = () => (new Date()).toLocaleDateString('en-CA')
+
+// ONE WEEK DATE RANGE
const getStartDate = () => (new Date(Date.now() - 604800000)).toLocaleDateString('en-CA'); // 7 days = 604800000 = 7*24*60*60*1000
const getEndDate = () => (new Date()).toLocaleDateString('en-CA'); // 'en-CA' formats the date as YYYY-MM-DD
@@ -178,6 +182,27 @@ export const WIDGET_REGISTRY = {
allowedSizes: {
medium: { w: 3, h: 12 },
}
+ },
+
+ "vaa_map": {
+ name: "VAA Polygon Projections Map",
+ category: "Maps",
+ description: "By selecting a volcano, the map will take you to it and show a polygon of the ash in 8, 12, and 18 hours",
+ component: lazy(() => import('../components/Widgets/VaaMap.jsx')),
+ settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')),
+ defaultSettings: {
+ position: [45.61142, -122.49630], // CVO's coordinates
+ startDate: getToday(),
+ endDate: getToday(),
+ data: [],
+ isLoading: false,
+ isError: undefined,
+ selected: undefined,
+ defaultVAA: undefined,
+ },
+ allowedSizes: {
+ medium: { w: 6, h: 12 }
+ }
}
};
\ No newline at end of file
diff --git a/client/src/hooks/useVdapApi.js b/client/src/hooks/useVdapApi.js
index 0f780b1..850cb09 100644
--- a/client/src/hooks/useVdapApi.js
+++ b/client/src/hooks/useVdapApi.js
@@ -30,7 +30,7 @@ export default function useWidgetApi(endpoint) {
setData(responseData || []);
} catch (err) {
- console.error(`Failed to fetch from ${endpoint}:`, err);
+ console.error(`Failed to parse data from ${endpoint}:`, err);
setError("Failed to load widget data. Is the Django API running?");
} finally {
setIsLoading(false);