add working vaa ash-polygons map w/ volcano selection capabilities
This commit is contained in:
@@ -17,24 +17,24 @@ export default function IvansAlerts({ settings, widgetID }) {
|
|||||||
const url = useBuildUrl('/api/v2/comm_coor_ivans', settings?.startDate, settings?.endDate);
|
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
|
// 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
|
// sort the apiData
|
||||||
const data = useMemo(() => {
|
const some_data = useMemo(() => {
|
||||||
if (apiData?.length === 0) return [];
|
if (data?.length === 0) return [];
|
||||||
const sorts = settings?.activeSorts;
|
const sorts = settings?.activeSorts;
|
||||||
|
|
||||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...apiData].sort(ascTimeVolcanoSort);
|
if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...data].sort(ascTimeVolcanoSort);
|
||||||
else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...apiData].sort(descTimeVolcanoSort);
|
else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...data].sort(descTimeVolcanoSort);
|
||||||
else if (sorts?.timeSort === "asc") return [...apiData].sort(ascTimeSort);
|
else if (sorts?.timeSort === "asc") return [...data].sort(ascTimeSort);
|
||||||
else return [...apiData].sort(descTimeSort);
|
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
|
// copy data to clipboard in formatted way when user clicks on copy btn in widget settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings?.copyToClipboard === true) {
|
if (settings?.copyToClipboard === true) {
|
||||||
const dataText = data.map((item) => {
|
const dataText = some_data.map((item) => {
|
||||||
return `${item.volcano_name}\n` +
|
return `${item.volcano_name}\n` +
|
||||||
`Report Number: ${item.report_num}\n` +
|
`Report Number: ${item.report_num}\n` +
|
||||||
`Send Time: ${item.send_time}\n` +
|
`Send Time: ${item.send_time}\n` +
|
||||||
@@ -50,7 +50,7 @@ export default function IvansAlerts({ settings, widgetID }) {
|
|||||||
}));
|
}));
|
||||||
updateSettings(widgetID, {copyToClipboard: false});
|
updateSettings(widgetID, {copyToClipboard: false});
|
||||||
}
|
}
|
||||||
}, [settings, data, widgetID, updateSettings]);
|
}, [settings, some_data, widgetID, updateSettings]);
|
||||||
|
|
||||||
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">
|
||||||
@@ -64,7 +64,7 @@ export default function IvansAlerts({ settings, widgetID }) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.length === 0) return (
|
if (some_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 for the selected dates.</Text>
|
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -80,7 +80,7 @@ export default function IvansAlerts({ settings, widgetID }) {
|
|||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
bg="gray.800"
|
bg="gray.800"
|
||||||
>
|
>
|
||||||
{data.map((item, index) => {
|
{some_data.map((item, index) => {
|
||||||
if (thisWidget?.size === "large") return (
|
if (thisWidget?.size === "large") return (
|
||||||
<Collapsible.Root key={index} w="100%">
|
<Collapsible.Root key={index} w="100%">
|
||||||
{/* Using context to dynamically change the border radii of the triggers */}
|
{/* Using context to dynamically change the border radii of the triggers */}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import React, { 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": "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 "
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vaa_datetime": "2026-08-25T01:01:00Z",
|
||||||
|
"vnum": 282080,
|
||||||
|
"volc_name": "Aira",
|
||||||
|
"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": "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"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
, []);
|
||||||
|
|
||||||
|
const {data, isLoading, error, selected} = 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;
|
||||||
|
const selected = settings?.selected;
|
||||||
|
return {data, isLoading, error, selected};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
|
||||||
|
// convert Degree & Minutes (DM) to Decimal Degrees (DD)
|
||||||
|
const polygons = useMemo(() => {
|
||||||
|
if (!mock_data) return [];
|
||||||
|
return mock_data.map((vaa) => {
|
||||||
|
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' },
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return (
|
||||||
|
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||||
|
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||||
|
<Text color="red.400">{error}</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data?.length === 0) return (
|
||||||
|
<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>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={settings?.position}
|
||||||
|
zoom={13}
|
||||||
|
ref={mapRef}
|
||||||
|
style={{height: "100%", width: "100%"}}
|
||||||
|
maxBounds={[[-90, -180], [90, 180]]}
|
||||||
|
minZoom={2} // smallest zoom allowed before empty edges start appearing
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
attribution='© OpenStreetMap contributors'
|
||||||
|
noWrap
|
||||||
|
/>
|
||||||
|
{polygons?.map((polygon, index) => {
|
||||||
|
return polygon?.projections?.map((coordinates, index) =>
|
||||||
|
<Polygon key={index} pathOptions={lineColors[index]} positions={coordinates}>
|
||||||
|
<Tooltip sticky>{polygon?.volc_name}</Tooltip>
|
||||||
|
</Polygon>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ZoomToPolygon polygons={polygons} selected={selected} settings={settings} />
|
||||||
|
</MapContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDMtoDD(DMString) {
|
||||||
|
const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/);
|
||||||
|
|
||||||
|
const datetime = cleaned_DMString[0];
|
||||||
|
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];
|
||||||
|
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, selected, settings }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let targetBounds;
|
||||||
|
|
||||||
|
if (!selected && settings?.position) targetBounds = settings.position;
|
||||||
|
else if (selected) {
|
||||||
|
const vaaObject = polygons.find((vaa) => vaa.volc_name === selected);
|
||||||
|
targetBounds = vaaObject?.projections?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
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, selected, polygons, settings]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
const unique_data = useMemo(() => {
|
||||||
|
if (!data || !Array.isArray(data)) return [];
|
||||||
|
const key = 'volc_name';
|
||||||
|
return [...new Map(data.map(item => [item[key], item])).values()];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!inputValue.trim()) return unique_data;
|
||||||
|
return unique_data.filter((item) => item.volc_name.toLowerCase().includes(inputValue.toLowerCase()));
|
||||||
|
}, [unique_data, inputValue]);
|
||||||
|
|
||||||
|
const collection = useMemo(() => {
|
||||||
|
return createListCollection({
|
||||||
|
items: filteredItems.sort((a, b) => a.volc_name.localeCompare(b.volc_name)),
|
||||||
|
itemToString: (item) => item.volc_name,
|
||||||
|
itemToValue: (item) => item.volc_name,
|
||||||
|
});
|
||||||
|
}, [filteredItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSettings(widgetId, {data: unique_data, isLoading: isLoading, error: error, selected: inputValue});
|
||||||
|
}, [updateSettings, widgetId, unique_data, isLoading, error, inputValue]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox.Root openOnClick collection={collection} onInputValueChange={(e) => setInputValue(e.inputValue)}>
|
||||||
|
<Combobox.Label>Select a Volcano</Combobox.Label>
|
||||||
|
<Combobox.Control>
|
||||||
|
<Combobox.Input placeholder="Search recent VAA's by volcano name"/>
|
||||||
|
<Combobox.IndicatorGroup>
|
||||||
|
<Combobox.ClearTrigger />
|
||||||
|
</Combobox.IndicatorGroup>
|
||||||
|
</Combobox.Control>
|
||||||
|
|
||||||
|
<Portal>
|
||||||
|
<Combobox.Positioner>
|
||||||
|
<Combobox.Content>
|
||||||
|
<Combobox.Empty>No VAA's found</Combobox.Empty>
|
||||||
|
{collection.items.map((vaa) =>
|
||||||
|
<Combobox.Item key={vaa?.volc_name} item={vaa}>
|
||||||
|
{vaa?.volc_name}
|
||||||
|
</Combobox.Item>
|
||||||
|
)}
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Positioner>
|
||||||
|
</Portal>
|
||||||
|
</Combobox.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import { lazy } from 'react';
|
|||||||
// w: 4 = one-third screen width
|
// w: 4 = one-third screen width
|
||||||
// w: 3 = one-quarter 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 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
|
const getEndDate = () => (new Date()).toLocaleDateString('en-CA'); // 'en-CA' formats the date as YYYY-MM-DD
|
||||||
|
|
||||||
@@ -178,6 +182,26 @@ export const WIDGET_REGISTRY = {
|
|||||||
allowedSizes: {
|
allowedSizes: {
|
||||||
medium: { w: 3, h: 12 },
|
medium: { w: 3, h: 12 },
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"vaa_map": {
|
||||||
|
name: "VAA Ash Polygon 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],
|
||||||
|
startDate: getToday(),
|
||||||
|
endDate: getToday(),
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: undefined,
|
||||||
|
selected: undefined,
|
||||||
|
},
|
||||||
|
allowedSizes: {
|
||||||
|
medium: { w: 6, h: 12 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
@@ -30,7 +30,7 @@ export default function useWidgetApi(endpoint) {
|
|||||||
setData(responseData || []);
|
setData(responseData || []);
|
||||||
|
|
||||||
} catch (err) {
|
} 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?");
|
setError("Failed to load widget data. Is the Django API running?");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user