add - descriptive tooltips and default to most recent vaa on mount

This commit is contained in:
2026-08-28 13:11:56 -07:00
parent 6304db66c2
commit 7e0723a776
3 changed files with 115 additions and 54 deletions
+74 -37
View File
@@ -1,4 +1,4 @@
import React, { useRef, useMemo, useEffect } from "react"; import { useRef, useMemo, useEffect } from "react";
import {MapContainer, useMap, TileLayer, Polygon, Tooltip} from "react-leaflet"; import {MapContainer, useMap, TileLayer, Polygon, Tooltip} from "react-leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import {Flex, Spinner, Text} from "@chakra-ui/react"; import {Flex, Spinner, Text} from "@chakra-ui/react";
@@ -25,15 +25,15 @@ export default function VaaMap({ settings, widgetID}) {
"obs_h_avs_m": 504.0, "obs_h_avs_m": 504.0,
"projections": "projections":
{ {
"six": "14/0200Z SFC/FL250 S1532 W07036 - S1552 W07029 - S1613 W07035 - S1546 W07150 - S1532 W07036 ", "six": "25/2030Z SFC/FL160 N1446 W09202 - N1428 W09052 - N1423 W09200 - N1446 W09202",
"twelve": "14/0800Z SFC/FL250 S1513 W07045 - S1527 W07033 - S1551 W07029 - S1544 W07150 - S1513 W07045 ", "twelve": "26/0230Z SFC/FL160 N1444 W09203 - N1428 W09052 - N1422 W09201 - N1444 W09203",
"eighteen": "14/1400Z SFC/FL250 S1511 W07044 - S1531 W07028 - S1550 W07027 - S1546 W07151 - S1511 W07044 " "eighteen": "26/0830Z SFC/FL160 N1441 W09204 - N1428 W09052 - N1419 W09202 - N1441 W09204"
} }
}, },
{ {
"vaa_datetime": "2026-08-25T01:01:00Z", "vaa_datetime": "2026-08-25T01:01:00Z",
"vnum": 282080, "vnum": 282080,
"volc_name": "Aira", "volc_name": "Sabancaya",
"volc_elev_m": 1117, "volc_elev_m": 1117,
"vaac_location": "Tokyo", "vaac_location": "Tokyo",
"obs_va_datetime": "2026-08-25T00:50:00Z", "obs_va_datetime": "2026-08-25T00:50:00Z",
@@ -45,27 +45,25 @@ export default function VaaMap({ settings, widgetID}) {
"obs_h_avs_m": 4369, "obs_h_avs_m": 4369,
"projections": "projections":
{ {
"six": "25/2030Z SFC/FL160 N1446 W09202 - N1428 W09052 - N1423 W09200 - N1446 W09202", "six": "14/0200Z SFC/FL250 S1532 W07036 - S1552 W07029 - S1613 W07035 - S1546 W07150 - S1532 W07036 ",
"twelve": "26/0230Z SFC/FL160 N1444 W09203 - N1428 W09052 - N1422 W09201 - N1444 W09203", "twelve": "14/0800Z SFC/FL250 S1513 W07045 - S1527 W07033 - S1551 W07029 - S1544 W07150 - S1513 W07045 ",
"eighteen": "26/0830Z SFC/FL160 N1441 W09204 - N1428 W09052 - N1419 W09202 - N1441 W09204" "eighteen": "14/1400Z SFC/FL250 S1511 W07044 - S1531 W07028 - S1550 W07027 - S1546 W07151 - S1511 W07044 "
}, },
}, },
] ]
, []); , []);
const {data, isLoading, error, selected} = useMemo(() => { const {data, isLoading, error} = useMemo(() => {
const data = settings?.data?.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime)); const data = settings?.data?.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime));
const isLoading = settings?.isLoading; const isLoading = settings?.isLoading;
const error = settings?.error; const error = settings?.error;
const selected = settings?.selected; return {data, isLoading, error};
return {data, isLoading, error, selected}; }, [settings.data, settings.isLoading, settings.error]);
}, [settings]);
// convert Degree & Minutes (DM) to Decimal Degrees (DD) // convert Degree & Minutes (DM) to Decimal Degrees (DD)
const polygons = useMemo(() => { const polygons = useMemo(() => {
if (!mock_data) return []; if (!mock_data) return []; // TODO: replace with real data
return mock_data.map((vaa) => { return mock_data.map((vaa) => { // TODO: replace with real data
const six_hrs = convertDMtoDD(vaa.projections.six); const six_hrs = convertDMtoDD(vaa.projections.six);
const twelve_hrs = convertDMtoDD(vaa.projections.twelve); const twelve_hrs = convertDMtoDD(vaa.projections.twelve);
const eighteen_hrs = convertDMtoDD(vaa.projections.eighteen); const eighteen_hrs = convertDMtoDD(vaa.projections.eighteen);
@@ -79,6 +77,12 @@ export default function VaaMap({ settings, widgetID}) {
2: { fillColor: 'blue' }, 2: { fillColor: 'blue' },
} }
const hourCode = {
0: "+6 hours",
1: "+12 hours",
2: "+18 hours",
}
if (isLoading) return ( if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Spinner size="xl" color="blue.400" thickness="4px"/> <Spinner size="xl" color="blue.400" thickness="4px"/>
@@ -93,14 +97,12 @@ export default function VaaMap({ settings, widgetID}) {
if (data?.length === 0) return ( if (data?.length === 0) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Text textAlign="center" color="gray.400">No flight level data available.</Text> <Text textAlign="center" color="gray.400">No VAA data available.</Text>
</Flex> </Flex>
); );
return ( return (
<MapContainer <MapContainer
center={settings?.position}
zoom={13}
ref={mapRef} ref={mapRef}
style={{height: "100%", width: "100%"}} style={{height: "100%", width: "100%"}}
maxBounds={[[-90, -180], [90, 180]]} maxBounds={[[-90, -180], [90, 180]]}
@@ -111,14 +113,33 @@ export default function VaaMap({ settings, widgetID}) {
attribution='&copy; OpenStreetMap contributors' attribution='&copy; OpenStreetMap contributors'
noWrap noWrap
/> />
{polygons?.map((polygon, index) => { {polygons?.map((polygon) => {
return polygon?.projections?.map((coordinates, index) => return polygon?.projections?.map((coordinates, index) =>
<Polygon key={index} pathOptions={lineColors[index]} positions={coordinates}> <Polygon key={index} pathOptions={lineColors[index]} positions={coordinates}>
<Tooltip sticky>{polygon?.volc_name}</Tooltip> <Tooltip sticky>
<Flex direction="column">
<Flex direction="row" justify="space-between" gap={3} bg="gray.300" borderRadius="sm">
<Text pl={1} fontWeight="bold">{polygon?.volc_name} </Text>
<Text pr={1} fontWeight="semibold">{hourCode[index]}</Text>
</Flex>
<Flex direction="row" justify="space-between" gap={3}>
<Text pl={1} fontWeight="semibold">VAA Datetime:</Text>
<Text pr={1}>{polygon?.vaa_datetime}</Text>
</Flex>
<Flex direction="row" justify="space-between" gap={3}>
<Text pl={1} fontWeight="semibold">VAAC Location:</Text>
<Text pr={1}>{polygon?.vaac_location}</Text>
</Flex>
<Flex direction="row" justify="space-between" gap={3}>
<Text pl={1} fontWeight="semibold">Height From Vent:</Text>
<Text pr={1}>{polygon?.obs_h_avs_m} (m)</Text>
</Flex>
</Flex>
</Tooltip>
</Polygon> </Polygon>
) )
})} })}
<ZoomToPolygon polygons={polygons} selected={selected} settings={settings} /> <ZoomToPolygon polygons={polygons} settings={settings} />
</MapContainer> </MapContainer>
) )
} }
@@ -126,10 +147,7 @@ export default function VaaMap({ settings, widgetID}) {
function convertDMtoDD(DMString) { function convertDMtoDD(DMString) {
const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/); const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/);
const datetime = cleaned_DMString[0]; const coordinates = cleaned_DMString.slice(2).map((coordinate) => {
const flight_level = Number(String(cleaned_DMString[1]).slice(6)) * 100; // in feet
const coordinates = cleaned_DMString.slice(2).map((coordinate, index) => {
const direction = coordinate[0]; const direction = coordinate[0];
if (direction === "N" || direction === "S") { // 4 digits long if (direction === "N" || direction === "S") { // 4 digits long
const lat = Number(coordinate.slice(1, 3)) + Number((Number(coordinate.slice(3, 5)) / 60).toFixed(5)); const lat = Number(coordinate.slice(1, 3)) + Number((Number(coordinate.slice(3, 5)) / 60).toFixed(5));
@@ -146,30 +164,49 @@ function convertDMtoDD(DMString) {
return polygon; return polygon;
} }
function ZoomToPolygon({ polygons, selected, settings }) { function ZoomToPolygon({ polygons, settings }) {
const map = useMap(); const map = useMap();
useEffect(() => { useEffect(() => {
let targetBounds; let targetBounds;
let default_vaa_object;
const selectedVAA = settings?.selected;
const defaultVAA = settings?.defaultVAA;
if (!selected && settings?.position) targetBounds = settings.position; // selectedVAA and defualtVAA are filled in dynamically so they may not always be defined, defualt to hardcoded CVO coordinates
else if (selected) { if (!selectedVAA && !defaultVAA) {
const vaaObject = polygons.find((vaa) => vaa.volc_name === selected); targetBounds = [settings.position];
targetBounds = vaaObject?.projections?.[0]; // 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) { if (targetBounds) {
try { try {
const bounds = L.latLngBounds(targetBounds); const bounds = L.latLngBounds(targetBounds);
if (bounds.isValid()) if (bounds.isValid()) map.fitBounds(bounds, {padding: [50, 50], animate: true, maxZoom: 8});
map.fitBounds(bounds, {
padding: [50, 50],
animate: true,
maxZoom: 8,
});
} catch (err) { } catch (err) {
console.warn('Invalid bounds structure passed to fitBounds:', targetBounds, err); console.warn('Invalid bounds structure passed to fitBounds:', targetBounds, err);
} }
} }
}, [map, selected, polygons, settings]); }, [map, polygons, settings.position, settings.selected, settings.defaultVAA]);
} }
@@ -11,32 +11,55 @@ export default function VaaMapSettings({widgetId, currentSettings}) {
const url = useBuildUrl("api/v2/comm_coor_vaa", currentSettings?.startDate, currentSettings?.endDate); const url = useBuildUrl("api/v2/comm_coor_vaa", currentSettings?.startDate, currentSettings?.endDate);
const {data, isLoading, error} = VdapApi(url); const {data, isLoading, error} = VdapApi(url);
const unique_data = useMemo(() => { // retrieving only the latest VAA for each volcano (resultant has no order)
if (!data || !Array.isArray(data)) return []; const latest_vaas = useMemo(() => {
const key = 'volc_name'; if (!data || !Array.isArray(data)) return undefined;
return [...new Map(data.map(item => [item[key], item])).values()]; // 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]); }, [data]);
const filteredItems = useMemo(() => { useEffect(() => {
if (!inputValue.trim()) return unique_data; if (!latest_vaas) updateSettings(widgetId, {isLoading: isLoading, error: error});
return unique_data.filter((item) => item.volc_name.toLowerCase().includes(inputValue.toLowerCase())); else updateSettings(widgetId, {data: latest_vaas, isLoading: isLoading, error: error});
}, [unique_data, inputValue]); }, [updateSettings, widgetId, latest_vaas, isLoading, error]);
const collection = useMemo(() => { // 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({ return createListCollection({
items: filteredItems.sort((a, b) => a.volc_name.localeCompare(b.volc_name)), items: filtered_vaas?.sort((a, b) => a.volc_name.localeCompare(b.volc_name)),
itemToString: (item) => item.volc_name, itemToString: (item) => item.volc_name,
itemToValue: (item) => item.volc_name, itemToValue: (item) => item.volc_name,
}); });
}, [filteredItems]); }, [filtered_vaas]);
// update state w/ selected vaa so map zooms to polygon
useEffect(() => { useEffect(() => {
updateSettings(widgetId, {data: unique_data, isLoading: isLoading, error: error, selected: inputValue}); updateSettings(widgetId, {selected: inputValue});
}, [updateSettings, widgetId, unique_data, isLoading, error, inputValue]); }, [updateSettings, widgetId, inputValue]);
return ( return (
<Combobox.Root openOnClick collection={collection} onInputValueChange={(e) => setInputValue(e.inputValue)}> <Combobox.Root openOnClick collection={vaa_collection} onInputValueChange={(e) => setInputValue(e.inputValue)}>
<Combobox.Label>Select a Volcano</Combobox.Label> <Combobox.Label>Select a Volcano</Combobox.Label>
<Combobox.Control> <Combobox.Control>
<Combobox.Input placeholder="Search recent VAA's by volcano name"/> <Combobox.Input placeholder="Search recent VAA's by volcano name"/>
@@ -49,7 +72,7 @@ export default function VaaMapSettings({widgetId, currentSettings}) {
<Combobox.Positioner> <Combobox.Positioner>
<Combobox.Content> <Combobox.Content>
<Combobox.Empty>No VAA's found</Combobox.Empty> <Combobox.Empty>No VAA's found</Combobox.Empty>
{collection.items.map((vaa) => {vaa_collection.items.map((vaa) =>
<Combobox.Item key={vaa?.volc_name} item={vaa}> <Combobox.Item key={vaa?.volc_name} item={vaa}>
{vaa?.volc_name} {vaa?.volc_name}
</Combobox.Item> </Combobox.Item>
+3 -2
View File
@@ -185,19 +185,20 @@ export const WIDGET_REGISTRY = {
}, },
"vaa_map": { "vaa_map": {
name: "VAA Ash Polygon Map", name: "VAA Polygon Projections Map",
category: "Maps", 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", 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')), component: lazy(() => import('../components/Widgets/VaaMap.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')), settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')),
defaultSettings: { defaultSettings: {
position: [45.61142, -122.49630], position: [45.61142, -122.49630], // CVO's coordinates
startDate: getToday(), startDate: getToday(),
endDate: getToday(), endDate: getToday(),
data: [], data: [],
isLoading: false, isLoading: false,
isError: undefined, isError: undefined,
selected: undefined, selected: undefined,
defaultVAA: undefined,
}, },
allowedSizes: { allowedSizes: {
medium: { w: 6, h: 12 } medium: { w: 6, h: 12 }