212 lines
8.2 KiB
React
212 lines
8.2 KiB
React
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 (
|
|
<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 VAA data available.</Text>
|
|
</Flex>
|
|
);
|
|
|
|
return (
|
|
<MapContainer
|
|
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) => {
|
|
return polygon?.projections?.map((coordinates, index) =>
|
|
<Polygon key={index} pathOptions={lineColors[index]} positions={coordinates}>
|
|
<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>
|
|
)
|
|
})}
|
|
<ZoomToPolygon polygons={polygons} settings={settings} />
|
|
</MapContainer>
|
|
)
|
|
}
|
|
|
|
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]);
|
|
} |