diff --git a/package-lock.json b/package-lock.json
index bd443cb4..ac61a850 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,12 @@
"@mui/material": "^5.15.15",
"@mui/x-date-pickers": "^7.4.0",
"@tanstack/react-query": "^5.36.0",
+ "@turf/bearing": "^6.5.0",
+ "@turf/circle": "^6.5.0",
+ "@turf/destination": "^6.5.0",
+ "@turf/dissolve": "^6.5.0",
+ "@turf/distance": "^6.5.0",
+ "@turf/flatten": "^6.5.0",
"axios": "^1.6.8",
"core-js": "^3.36.0",
"d3": "^7.8.5",
@@ -5091,6 +5097,110 @@
"react": "^18.0.0"
}
},
+ "node_modules/@turf/bearing": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/bearing/-/bearing-6.5.0.tgz",
+ "integrity": "sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/circle": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/circle/-/circle-6.5.0.tgz",
+ "integrity": "sha512-oU1+Kq9DgRnoSbWFHKnnUdTmtcRUMmHoV9DjTXu9vOLNV5OWtAAh1VZ+mzsioGGzoDNT/V5igbFOkMfBQc0B6A==",
+ "dependencies": {
+ "@turf/destination": "^6.5.0",
+ "@turf/helpers": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/destination": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/destination/-/destination-6.5.0.tgz",
+ "integrity": "sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/dissolve": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/dissolve/-/dissolve-6.5.0.tgz",
+ "integrity": "sha512-WBVbpm9zLTp0Bl9CE35NomTaOL1c4TQCtEoO43YaAhNEWJOOIhZMFJyr8mbvYruKl817KinT3x7aYjjCMjTAsQ==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0",
+ "@turf/meta": "^6.5.0",
+ "polygon-clipping": "^0.15.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/distance": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz",
+ "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/invariant": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/flatten": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/flatten/-/flatten-6.5.0.tgz",
+ "integrity": "sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0",
+ "@turf/meta": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/helpers": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz",
+ "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==",
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/invariant": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz",
+ "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/meta": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-6.5.0.tgz",
+ "integrity": "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==",
+ "dependencies": {
+ "@turf/helpers": "^6.5.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -15018,6 +15128,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/polygon-clipping": {
+ "version": "0.15.7",
+ "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz",
+ "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==",
+ "dependencies": {
+ "robust-predicates": "^3.0.2",
+ "splaytree": "^3.1.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -17428,6 +17547,11 @@
"wbuf": "^1.7.3"
}
},
+ "node_modules/splaytree": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.1.2.tgz",
+ "integrity": "sha512-4OM2BJgC5UzrhVnnJA4BkHKGtjXNzzUfpQjCO8I05xYPsfS/VuQDwjCGGMi8rYQilHEV4j8NBqTFbls/PZEE7A=="
+ },
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
diff --git a/package.json b/package.json
index b2554d2d..edf6d89e 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,12 @@
"@mui/material": "^5.15.15",
"@mui/x-date-pickers": "^7.4.0",
"@tanstack/react-query": "^5.36.0",
+ "@turf/bearing": "^6.5.0",
+ "@turf/circle": "^6.5.0",
+ "@turf/destination": "^6.5.0",
+ "@turf/dissolve": "^6.5.0",
+ "@turf/distance": "^6.5.0",
+ "@turf/flatten": "^6.5.0",
"axios": "^1.6.8",
"core-js": "^3.36.0",
"d3": "^7.8.5",
diff --git a/src/components/control-panel/control-panel.js b/src/components/control-panel/control-panel.js
index 45cc01bb..3e15b7ae 100644
--- a/src/components/control-panel/control-panel.js
+++ b/src/components/control-panel/control-panel.js
@@ -32,20 +32,23 @@ const layerIcons = {
export const ControlPanel = () => {
//const { defaultModelLayers, setDefaultModelLayers, toggleLayerVisibility, makeAllLayersInvisible } = useLayers();
- const { defaultModelLayers, setDefaultModelLayers, toggleLayerVisibility } = useLayers();
+ const { defaultModelLayers,
+ setDefaultModelLayers,
+ toggleLayerVisibility,
+ toggleHurricaneLayerVisibility } = useLayers();
const data_url = `${process.env.REACT_APP_UI_DATA_URL}get_ui_data?limit=1&use_v3_sp=true`;
- //const gs_wms_url = `${process.env.REACT_APP_GS_DATA_URL}wms`;
-
const layers = [...defaultModelLayers];
const maxele_layer = layers.find((layer) => layer.properties.product_type === "maxele63");
const obs_layer = layers.find((layer) => layer.properties.product_type === "obs");
const [currentLayerSelection, setCurrentLayerSelection] = React.useState('maxele63');
- const [checked, setChecked] = React.useState(true);
+ const [checkedObs, setCheckedObs] = React.useState(true);
+ const [checkedHurr, setCheckedHurr] = React.useState(true);
// keep track of which model run to retrieve
const [ runCycle, setRunCycle] = React.useState(0);
+ const [ runAdvisory, setRunAdvisory] = React.useState(0);
const [ runDate, setRunDate] = React.useState("");
const [ instanceName, setInstanceName] = React.useState("");
const [ metClass, setMetClass] = React.useState("");
@@ -133,7 +136,7 @@ export const ControlPanel = () => {
setInstanceName(layers[0].properties.instance_name);
setMetClass(layers[0].properties.met_class);
setEventType(layers[0].properties.event_type);
-
+ setRunAdvisory(layers[0].properties.advisory_number);
setRunCycle(parseInt(layers[0].properties.cycle));
setRunDate(layers[0].properties.run_date);
setInitialDataFetched(true);
@@ -164,10 +167,17 @@ export const ControlPanel = () => {
// switch on/off the observation layer if it exists
const toggleObsLayer = (event) => {
- setChecked(event.target.checked);
+ setCheckedObs(event.target.checked);
toggleLayerVisibility(obs_layer.id);
};
+ // switch on/off the hurricane track layer, if it exists
+ const toggleHurricaneLayer = (event) => {
+ setCheckedHurr(event.target.checked);
+ const layerID = obs_layer.id.substr(0, obs_layer.id.lastIndexOf("-")) + '-hurr';
+ toggleHurricaneLayerVisibility(layerID);
+ };
+
// cycle to the next model run cycle and retrieve the
// layers associated with that cycle/date
const changeModelRunCycle = (e) => {
@@ -242,7 +252,7 @@ export const ControlPanel = () => {
{
layers.length && (
-
+
Model run date: {runDate}
)
@@ -255,7 +265,7 @@ export const ControlPanel = () => {
button-key='previous'
onClick={changeModelRunCycle}
>
- Cycle {runCycle}
+ {metClass === 'synoptic'? `Cycle ${runCycle}` : `Advisory ${runAdvisory}`}
{
-
- {/* TODO: NOTE: If this is a tropical storm run, we need to change cycle to advisoy
- Also probabaly want to add a switch for hurricane layers - which
- involves making a request to the MetGet API
- Third need to implement actual code to load different model runs each time
- up/down arrows are clicked. This has to time managed in some way so that
- Geoserver is not inundated with requests */}
{ // grid name
layers.length && (
@@ -283,11 +286,20 @@ export const ControlPanel = () => {
layers.some(layer => layer.properties.product_type === "obs") && (
}
+ endDecorator={ }
>Observations
)
}
+ { // hurricane track toggle
+ layers.some(layer => layer.properties.met_class === "tropical") && (
+ }
+ >Hurricane Track
+ )
+ }
+
{/* layer selection */}
{
state: newLayerDefaultState(layer)
});
- // TODO: do we really need to do this here??!
// if this is an obs layer, need to retrieve
// the json data for it from GeoServer
const pieces = layer.id.split('-');
@@ -136,7 +135,6 @@ export const DefaultLayers = () => {
if (obs_url) {
const obs_response = await fetch(obs_url);
const obs_data = await obs_response.json();
- //console.log("obs_data 1: " + JSON.stringify(obs_data, null, 2))
setObsData(obs_data);
}
@@ -159,7 +157,7 @@ export const DefaultLayers = () => {
getDefaultLayers().then();
}, []);
- // memoizing this params object prevents
+ // memorizing this params object prevents
// that map flicker on state changes.
const wmsLayerParams = useMemo(() => ({
format:"image/png",
diff --git a/src/components/map/map.js b/src/components/map/map.js
index 941a979f..dfa87469 100644
--- a/src/components/map/map.js
+++ b/src/components/map/map.js
@@ -1,6 +1,7 @@
import React from 'react';
import { MapContainer } from 'react-leaflet';
import { DefaultLayers } from './default-layers';
+import { StormLayers } from './storm-layers';
import { BaseMap } from './base-map';
import {
useLayers,
@@ -25,6 +26,7 @@ export const Map = () => {
style={{ height: '100vh', width:'100wh' }}>
+
);
};
\ No newline at end of file
diff --git a/src/components/map/storm-layers.js b/src/components/map/storm-layers.js
new file mode 100644
index 00000000..18cec8bc
--- /dev/null
+++ b/src/components/map/storm-layers.js
@@ -0,0 +1,219 @@
+import React, { useState, useEffect, Fragment} from 'react';
+import { GeoJSON } from 'react-leaflet';
+import { Marker } from 'leaflet';
+import {
+ getTrackData,
+ getTrackGeojson
+} from "@utils/hurricane/track";
+import { useLayers } from '@context';
+
+const newLayerDefaultState = () => {
+ return ({
+ visible: true,
+ opacity: 1.0,
+ });
+ };
+
+export const StormLayers = () => {
+
+ const {
+ defaultModelLayers,
+ hurricaneTrackLayers,
+ setHurricaneTrackLayers,
+ } = useLayers();
+ const [hurricaneData, setHurricaneData] = useState();
+
+ const layer_list = defaultModelLayers;
+ const topLayer = layer_list[0];
+
+ function coneStyle() {
+ return {
+ fillColor: '#858585',
+ weight: 2,
+ opacity: 1,
+ color: '#858585',
+ fillOpacity: 0.2,
+ dashArray: '5',
+ };
+ }
+
+ function lineStyle() {
+ return {
+ weight: 2,
+ opacity: 1,
+ color: 'red',
+ };
+ }
+
+ const hurrPointToLayer = ((feature, latlng) => {
+ const icon_url = `${process.env.REACT_APP_HURRICANE_ICON_URL}`;
+ let iconName = null;
+ const L = window.L;
+ const iconSize = [20, 40];
+ const iconAnchor = 15;
+
+ switch (feature.properties.storm_type) {
+ default:
+ case 'TD':
+ iconName="dep.png";
+ break;
+ case 'TS':
+ iconName="storm.png";
+ break;
+ case 'CAT1':
+ iconName="cat_1.png";
+ break;
+ case 'CAT2':
+ iconName="cat_2.png";
+ break;
+ case 'CAT3':
+ iconName="cat_3.png";
+ break;
+ case 'CAT4':
+ iconName="cat_4.png";
+ break;
+ case 'CAT5':
+ iconName="cat5.png";
+ break;
+ }
+
+ const url = icon_url + iconName;
+ const icon = L.icon({
+ iconUrl: url,
+ iconSize: [iconSize, iconSize],
+ iconAnchor: [iconAnchor, iconAnchor],
+ popupAnchor: [0, 0],
+ });
+
+ return new Marker(latlng, {
+ icon:icon
+ });
+ });
+
+ const onEachHurrFeature = (feature, layer) => {
+ if (feature.properties && feature.properties.time_utc) {
+ const popupContent = feature.properties.storm_name + ": " +
+ feature.properties.time_utc + ", " +
+ feature.properties.max_wind_speed_mph + "mph";
+
+ layer.on("mouseover", function (e) {
+ this.bindPopup(popupContent).openPopup(e.latlng);
+ });
+
+ layer.on("mousemove", function (e) {
+ this.getPopup().setLatLng(e.latlng);
+ });
+
+ layer.on("mouseout", function () {
+ this.closePopup();
+ });
+ }
+ };
+
+ // compare the hurricane layers list to the layers list and
+ // remove any hurricane layers that are related to model run layers
+ // that have been removed
+ const removeAnyOrphanHurricaneLayers = () => {
+
+ hurricaneTrackLayers.map((hurrLayer) => {
+ if (! layer_list.some(layer => layer.id.substr(0, layer.id.lastIndexOf("-")) + '-hurr' === hurrLayer.id)) {
+ const newHurricaneTrackLayers = hurricaneTrackLayers.filter((layerToRemove) => layerToRemove.id !== hurrLayer.id);
+ setHurricaneTrackLayers(newHurricaneTrackLayers);
+ }
+ });
+ // also check to see if indeed there is anything left in layer_list
+ // if not remove all hurricane layers
+ if (layer_list.length === 0) {
+ setHurricaneTrackLayers([]);
+ }
+ };
+
+
+ useEffect(() => {
+
+ async function getStormLayers() {
+ // create id fro new hurricane layer
+ if (topLayer && topLayer.properties.met_class === 'tropical') {
+ const id = topLayer.id.substr(0, topLayer.id.lastIndexOf("-")) + '-hurr';
+
+ // first check to make sure this layer doesn't already exist
+ if (!hurricaneTrackLayers.some(layer => layer.id === id)) {
+ // get year, storm number, and advisory for this storm
+ const year = topLayer.properties.run_date.substring(0, 4);
+ let stormNumber = topLayer.properties.storm_number;
+ // storm number can sometimes start with "al" so must remove if so
+ if (stormNumber && stormNumber.length > 3)
+ stormNumber = stormNumber.slice(2);
+ const advisory = topLayer.properties.advisory_number;
+ const stormName = topLayer.properties.storm_name;
+
+ getTrackData(year, stormNumber, advisory).then((track) => {
+ //for testing ...
+ //getTrackData("2023", "10", "17").then((track) => {
+ if (track != null) {
+ const trackGeojson = getTrackGeojson(
+ track,
+ "utc",
+ stormName
+ );
+
+ // now create some metadata for this layer
+ // and save in hurricaneTrackLayers
+ const trackLayer = [{
+ id: id,
+ stormName: stormName,
+ stormNumber: stormNumber,
+ runDate: topLayer.properties.run_date,
+ advisory: advisory,
+ instanceName: topLayer.properties.instance_name,
+ eventType: topLayer.properties.event_type,
+ state: newLayerDefaultState()
+ }];
+
+ // check to make sure we actually got geojson data
+ // before creating layer
+ if (trackGeojson) {
+ setHurricaneData(trackGeojson);
+ setHurricaneTrackLayers([...trackLayer, ...hurricaneTrackLayers]);
+ }
+ }
+ });
+ }
+ }
+ removeAnyOrphanHurricaneLayers();
+ }
+
+ getStormLayers().then();
+ }, [layer_list]);
+
+ return(
+ <>
+ {hurricaneTrackLayers
+ .filter(({state}) => state.visible)
+ .map((layer, index) => {
+ return (
+
+
+
+
+
+ );
+ })
+ };
+ >
+ );
+
+};
\ No newline at end of file
diff --git a/src/components/trays/hurricane/hurricane-card.js b/src/components/trays/hurricane/hurricane-card.js
new file mode 100644
index 00000000..796ea8d4
--- /dev/null
+++ b/src/components/trays/hurricane/hurricane-card.js
@@ -0,0 +1,97 @@
+import React from 'react';
+import {
+ Accordion,
+ AccordionDetails,
+ Stack,
+ Switch,
+ Typography,
+} from '@mui/joy';
+import {
+ Schedule as ClockIcon,
+} from '@mui/icons-material';
+import { useLayers } from '@context';
+import { useToggleState } from '@hooks';
+
+export const HurricaneCard = ( layer ) => {
+ const {
+ toggleHurricaneLayerVisibility,
+ } = useLayers();
+ const expanded = useToggleState(false);
+ const hlayer = layer.layer;
+ const isVisible = hlayer.state.visible;
+
+ return (
+
+ {/*
+ the usual AccordionSummary component results in a button,
+ but we want some buttons _inside_ the accordion summary,
+ so we'll build a custom component here.
+ */}
+
+ {hlayer &&
+
+
+
+ {hlayer.stormName}
+
+ toggleHurricaneLayerVisibility(hlayer.id) }
+ className="action-button"
+ />
+
+
+
+
+ { new Date(hlayer.runDate).toUTCString() }
+
+
+ Advisory { hlayer.advisory }
+
+
+ }
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/trays/hurricane/index.js b/src/components/trays/hurricane/index.js
new file mode 100644
index 00000000..0598e1d4
--- /dev/null
+++ b/src/components/trays/hurricane/index.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Storm as HurricaneIcon } from '@mui/icons-material';
+import { Stack } from '@mui/joy';
+import { HurricaneList } from './list';
+
+export const icon = ;
+export const title = 'Hurricanes';
+export const trayContents = () => (
+
+
+
+ );
\ No newline at end of file
diff --git a/src/components/trays/hurricane/list.js b/src/components/trays/hurricane/list.js
new file mode 100644
index 00000000..d48a0235
--- /dev/null
+++ b/src/components/trays/hurricane/list.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import {
+ AccordionGroup,
+ Divider,
+} from '@mui/joy';
+import { useLayers } from '@context';
+import { HurricaneCard } from './hurricane-card';
+
+export const HurricaneList = () => {
+ const { hurricaneTrackLayers } = useLayers();
+ const layers = [...hurricaneTrackLayers];
+
+ return (
+
+ {
+ layers
+ //.sort((a, b) => a.state.order - b.state.order)
+ .map((layer, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/trays/index.js b/src/components/trays/index.js
index 38180878..6f72e87d 100644
--- a/src/components/trays/index.js
+++ b/src/components/trays/index.js
@@ -1,4 +1,4 @@
-import * as hurricanes from './hurricanes';
+import * as hurricanes from './hurricane';
import * as layers from './layers';
import * as model_selection from './model-selection';
import * as remove_items from './remove';
diff --git a/src/context/map-context.js b/src/context/map-context.js
index ddd2b354..ca5b11fc 100644
--- a/src/context/map-context.js
+++ b/src/context/map-context.js
@@ -42,13 +42,29 @@ const layerTypes = {
export const LayersProvider = ({ children }) => {
const [defaultModelLayers, setDefaultModelLayers] = useState([]);
- const [filteredModelLayers, setFilteredModelLayers] = useState([]);
+ const [hurricaneTrackLayers, setHurricaneTrackLayers] = useState([]);
// this object contains data for graph rendering
const [selectedObservations, setSelectedObservations] = useState([]);
const [map, setMap] = useState(null);
+ const toggleHurricaneLayerVisibility = id => {
+ const newLayers = [...hurricaneTrackLayers];
+ const index = newLayers.findIndex(l => l.id === id);
+ if (index === -1) {
+ console.error('Could not locate layer', id);
+ return;
+ }
+ const alteredLayer = newLayers[index];
+ alteredLayer.state.visible = !alteredLayer.state.visible;
+ setHurricaneTrackLayers([
+ ...newLayers.slice(0, index),
+ { ...alteredLayer },
+ ...newLayers.slice(index + 1),
+ ]);
+ };
+
const toggleLayerVisibility = id => {
const newLayers = [...defaultModelLayers];
const index = newLayers.findIndex(l => l.id === id);
@@ -122,8 +138,9 @@ export const LayersProvider = ({ children }) => {
setMap,
defaultModelLayers,
setDefaultModelLayers,
- filteredModelLayers,
- setFilteredModelLayers,
+ hurricaneTrackLayers,
+ setHurricaneTrackLayers,
+ toggleHurricaneLayerVisibility,
toggleLayerVisibility,
selectedObservations,
setSelectedObservations,
diff --git a/src/utils/hurricane/config.js b/src/utils/hurricane/config.js
new file mode 100644
index 00000000..fdb4a56b
--- /dev/null
+++ b/src/utils/hurricane/config.js
@@ -0,0 +1,203 @@
+// Catalog level definition objects.
+const catalogSchema = [
+ {
+ prop: "model",
+ name: "Model",
+ format: (value) =>
+ ({
+ gfs: "GFS",
+ nhc: "NHC"
+ }[value]),
+ icon: "diagram-2-fill",
+ advanced: true,
+ info: true
+ },
+ { prop: "storm", name: "Storm", icon: "hurricane", advanced: true },
+ { prop: "mesh", name: "Mesh", icon: "grid-fill", advanced: true },
+ {
+ prop: "advisory",
+ name: "Advisory",
+ format: (value) => `Advisory ${value}`,
+ icon: "exclamation-triangle-fill",
+ advanced: true
+ },
+ {
+ prop: "datetime",
+ control: "datetime",
+ name: "Date and Time",
+ icon: "clock-fill",
+ advanced: true,
+ encode: (value) => value.getTime() / 1000,
+ decode: (value) => new Date(value * 1000)
+ },
+ {
+ prop: "ensembleMember",
+ name: "Ensemble Member",
+ filter: (value) => !["namforecast", "nowcast", "forecast"].includes(value),
+ format: (value) =>
+ ({
+ nhcOfcl: "NHC Official",
+ devRight50: "Veer Right 50%",
+ devLeft50: "Veer Left 50%"
+ }[value]),
+ icon: "arrow-left-right",
+ advanced: true
+ },
+ {
+ prop: "metric",
+ name: "Metric",
+ filter: (value) => value !== "obs",
+ format: (value) =>
+ ({
+ maxele63: "Peak Water Surface Elevation",
+ maxwvel63: "Maximum Wind Speed",
+ swan: "Maximum Wave Height"
+ }[value]),
+ icon: "bar-chart-line-fill"
+ }
+];
+
+// Track point marker shapes.
+const markerShapes = {
+ h: "M 24.65373,20.997978 C 21.728818,26.7657 14.06931,31.641531 4.8786829,31.633223 4.6050068,31.632246 14.675795,26.079066 11.358451,24.1648 6.8667437,21.571231 5.1733733,15.725316 7.4527036,11.066959 10.182133,5.4878779 17.751718,0.09157311 27.108017,0.37746681 c 0.31766,0.009774 -9.527835,5.69490489 -6.360035,7.52364719 4.49073,2.592592 6.251542,8.472228 3.905748,13.096864 z",
+ td: "M 16,5.9999998 C 10.47684,5.9999998 6.0000005,10.47735 6.0000005,16 6.0000005,21.52265 10.47735,26 16,26 21.522649,26 26,21.52265 26,16 26,10.47735 21.522649,5.9999998 16,5.9999998 Z M 16,21.18524 c -2.86361,0 -5.18473,-2.32163 -5.18473,-5.18473 0,-2.86311 2.32112,-5.18524 5.18473,-5.18524 2.86361,0 5.18473,2.32163 5.18473,5.18524 0,2.86361 -2.32112,5.18473 -5.18473,5.18473 z",
+ ts: "M 24.658829,21.000934 C 27.005948,16.373198 25.244631,10.49073 20.750876,7.8961857 17.581288,6.0669002 27.432341,0.37855954 27.114502,0.36926885 17.75292,0.08272474 10.179061,5.4825654 7.4480904,11.064308 5.1674731,15.725294 6.8617997,21.574511 11.356044,24.169545 14.675261,26.085869 4.5982967,31.641207 4.8726163,31.641696 14.068433,31.650497 21.732265,26.771913 24.658829,21.000934 Z M 13.506591,20.432246 c -2.428779,-1.402403 -3.261028,-4.507446 -1.858625,-6.936714 1.402404,-2.428779 4.507446,-3.261028 6.936714,-1.858625 2.429269,1.402404 3.261029,4.507447 1.858626,6.936226 -1.402404,2.429268 -4.506958,3.261028 -6.936715,1.858624 z"
+};
+const markers = {
+ td: {
+ shape: markerShapes.td,
+ fill: "#3565ff"
+ },
+ ts: {
+ shape: markerShapes.ts,
+ fill: "#5ebbff"
+ },
+ h1: {
+ shape: markerShapes.h,
+ fill: "#ffffba"
+ },
+ h2: {
+ shape: markerShapes.h,
+ fill: "#ffcc00"
+ },
+ h3: {
+ shape: markerShapes.h,
+ fill: "#ff8800"
+ },
+ h4: {
+ shape: markerShapes.h,
+ fill: "#ff5000"
+ },
+ h5: {
+ shape: markerShapes.h,
+ fill: "#cc0000"
+ },
+ "td-current": {
+ shape: markerShapes.td,
+ fill: "#3565ff",
+ duration: 3000
+ },
+ "ts-current": {
+ shape: markerShapes.ts,
+ fill: "#5ebbff",
+ duration: 3000
+ },
+ "h1-current": {
+ shape: markerShapes.h,
+ fill: "#ffffba",
+ duration: 3000
+ },
+ "h2-current": {
+ shape: markerShapes.h,
+ fill: "#ffcc00",
+ duration: 3000
+ },
+ "h3-current": {
+ shape: markerShapes.h,
+ fill: "#ff8800",
+ duration: 3000
+ },
+ "h4-current": {
+ shape: markerShapes.h,
+ fill: "#ff5000",
+ duration: 3000
+ },
+ "h5-current": {
+ shape: markerShapes.h,
+ fill: "#cc0000",
+ duration: 3000
+ }
+};
+
+// Legend item definitions.
+const legends = {
+ track: {
+ colors: [
+ "#3565ff",
+ "#5ebbff",
+ "#ffffba",
+ "#ffcc00",
+ "#ff8800",
+ "#ff5000",
+ "#cc0000"
+ ],
+ labels: ["TD", "TS", "H1", "H2", "H3", "H4", "H5"]
+ }
+};
+
+// Temporary map of storm name to number.
+const stormNumberMap = {
+ DANIELLE: 5,
+ IAN: 9,
+ SEVEN: 7
+};
+
+// Basin code for MetGet.
+const trackBasin = "al";
+
+// Cone of uncertainty point radii from NOAA.
+const trackConeSpecs = {
+ 2020: {
+ hour: [0, 3, 12, 24, 36, 48, 60, 72, 96, 120],
+ cone: [9.5, 16, 26, 41, 55, 69, 86, 103, 151, 196]
+ },
+ 2021: {
+ hour: [0, 3, 12, 24, 36, 48, 60, 72, 96, 120],
+ cone: [9.5, 16, 27, 40, 55, 69, 86, 102, 148, 200]
+ },
+ 2022: {
+ hour: [0, 3, 12, 24, 36, 48, 60, 72, 96, 120],
+ cone: [9.5, 16, 26, 39, 52, 67, 84, 100, 142, 200]
+ },
+ 2023: {
+ hour: [0, 3, 12, 24, 36, 48, 60, 72, 96, 120],
+ cone: [9.5, 16, 26, 39, 53, 67, 81, 99, 145, 205]
+ },
+ 2024: {
+ hour: [0, 3, 12, 24, 36, 48, 60, 72, 96, 120],
+ cone: [9.5, 16, 26, 39, 53, 67, 81, 99, 145, 205]
+ }
+};
+
+// Map layers that implement the track point popup behavior.
+const trackPointLayers = [
+ "track-point",
+ "track-point-storm",
+ "track-point-storm-background"
+];
+
+// API URL for retrieving storm track data.
+//const trackUrl = "https://api.metget.zachcobell.com/stormtrack";
+const trackUrl = "https://api.metget.org/stormtrack";
+
+export default {
+ catalogSchema,
+ legends,
+ markerShapes,
+ markers,
+ stormNumberMap,
+ trackBasin,
+ trackConeSpecs,
+ trackPointLayers,
+ trackUrl
+};
diff --git a/src/utils/hurricane/time.js b/src/utils/hurricane/time.js
new file mode 100644
index 00000000..a365a563
--- /dev/null
+++ b/src/utils/hurricane/time.js
@@ -0,0 +1,83 @@
+/**
+ * Format a date for display.
+ * @param {Date} date - Date object.
+ * @param {string} timezone - Selected timezone ("local" or "utc").
+ * @param {string} timeFormat - Desired time format ("12" or "24").
+ * @returns {string} - Date for display (e.g., Wed Sep 28 2022 2:00 PM EDT).
+ */
+export function formatDateTime(date, timezone, timeFormat) {
+ let dow, month, day, year, time;
+ if (timezone == "local") {
+ [dow, month, day, year, time] = date.toString().split(" ");
+ } else {
+ [dow, day, month, year, time] = date.toUTCString().split(" ");
+ dow = dow.replace(/,/g, "");
+ }
+ return [
+ dow,
+ month,
+ day,
+ year,
+ formatTime(time, timeFormat),
+ getDisplayTimezone(timezone)
+ ].join(" ");
+}
+
+/**
+ * Format a time for display.
+ * @param {string} timeStr - Time string in the format HH:MM:SS.
+ * @param {string} timeFormat - Desired time format ("12" or "24").
+ * @returns {string} - Time string in the desired format.
+ */
+export function formatTime(timeStr, timeFormat = "12") {
+ const [hour, minute] = timeStr.split(":").slice(0, 2);
+ if (timeFormat === "24") return `${hour}:${minute}`;
+ hour = parseInt(hour, 10);
+ let ampm = "AM";
+ if (hour > 11) {
+ ampm = "PM";
+ if (hour > 12) hour = hour - 12;
+ }
+ if (hour === 0) hour = "12";
+ return `${hour}:${minute} ${ampm}`;
+}
+
+/**
+ * Format the selected timezone for display.
+ * @param {string} timezone - Selected timezone ("local" or "utc").
+ * @returns {string} - Display timezone abbreviation.
+ */
+export function getDisplayTimezone(timezone) {
+ return timezone === "local" ? getLocalTimezoneAbbreviation() : "UTC";
+}
+
+/**
+ * Get the abbreviation for the user's timezone.
+ * @returns {string} - Timezone abbreviation.
+ */
+export function getLocalTimezoneAbbreviation() {
+ return new Date()
+ .toLocaleTimeString("en-us", { timeZoneName: "short" })
+ .split(" ")[2];
+}
+
+/**
+ * Get the date from a storm track point feature.
+ * @param {Object} feature - Track point feature GeoJSON.
+ * @returns {Date}
+ */
+export function getTrackPointDate(feature) {
+ return new Date(`${feature.properties.time_utc}Z`);
+}
+
+/**
+ * Offset a date based on the selected timezone.
+ * @param {Date} date - Date in UTC.
+ * @param {string} timezone - Selected timezone ("local" or "utc").
+ * @returns {Date}
+ */
+export function offsetDate(date, timezone) {
+ return timezone === "utc"
+ ? new Date(date.getTime() + date.getTimezoneOffset() * 60000)
+ : date;
+}
diff --git a/src/utils/hurricane/track.js b/src/utils/hurricane/track.js
new file mode 100644
index 00000000..04430dc1
--- /dev/null
+++ b/src/utils/hurricane/track.js
@@ -0,0 +1,270 @@
+import bearing from "@turf/bearing";
+import circle from "@turf/circle";
+import destination from "@turf/destination";
+import dissolve from "@turf/dissolve";
+import distance from "@turf/distance";
+import config from "./config";
+import { getTrackPointDate } from "./time";
+
+const { trackBasin, trackConeSpecs, trackUrl } = config;
+
+/**
+ * Fetch and parse JSON.
+ * @param {string} url - JSON URL.
+ * @returns {Object}
+ */
+export async function getJSON(url) {
+ const response = await fetch(url);
+ if (response.status != 200) return null;
+ return (await response.json()).body;
+}
+
+/**
+ * Retrieve storm track data from MetGet.
+ * @param {number} stormYear - Current storm year.
+ * @param {number} stormNumber - Current storm number.
+ * @param {string} advisoryNumber - Current advisory number.
+ * @returns {Object} - Object containing best track and advisory responses.
+ */
+export async function getTrackData(stormYear, stormNumber, advisoryNumber) {
+ if (!stormYear || !stormNumber || !advisoryNumber) return null;
+
+ const url = new URL(trackUrl);
+ url.searchParams.set("basin", trackBasin);
+ url.searchParams.set("storm", stormNumber.toString().padStart(2, "0"));
+ url.searchParams.set("year", stormYear);
+ url.searchParams.set("type", "best");
+ const bestUrl = url.toString();
+ url.searchParams.set("advisory", advisoryNumber.padStart(3, "0"));
+ url.searchParams.set("type", "forecast");
+ const advisoryUrl = url.toString();
+ const [best, advisory] = await Promise.all(
+ [bestUrl, advisoryUrl].map(getJSON)
+ );
+ if (best === null || advisory === null) return null;
+ return { best, advisory };
+}
+
+/**
+ * Calculate the average bearing based on two or three consecutive points from a line.
+ * @param {Object} prev - Previous point feature.
+ * @param {Object} curr - Current point feature.
+ * @param {Object} next - Next point feature.
+ * @returns {number} - Bearing in degrees from north.
+ */
+function avgBearing(prev, curr, next) {
+ const bearings = [];
+ if (prev) bearings.push(bearing(prev, curr));
+ if (next) bearings.push(bearing(curr, next));
+ return bearings.length
+ ? bearings.reduce((a, b) => a + b) / bearings.length
+ : null;
+}
+
+/**
+ * Build storm track GeoJSON for display on the map.
+ * @param {Object} trackData - Raw storm track data from MetGet.
+ * @param {string} timezone - Selected timezone setting ("local" or "utc").
+ * @returns {Object} - Object with track-related GeoJSON feature collections.
+ */
+export function getTrackGeojson(trackData, timezone, storm_name = "") {
+ if (!trackData) return null;
+
+ // TODO: We're assuming that the second point in the advisory track represents
+ // the forecast time. Is this always the case?
+ const forecastDate = new Date(
+ `${trackData.advisory.geojson.features[1].properties.time_utc}Z`
+ );
+ const forecastDateTime = forecastDate.toISOString().split(".")[0];
+ const forecastTime = forecastDate.getTime();
+
+ // Combine the best and advisory tracks.
+ const pointFeatures = trackData.best.geojson.features.filter(
+ (feature) => feature.properties.time_utc < forecastDateTime
+ );
+ pointFeatures.push(...trackData.advisory.geojson.features.slice(1));
+
+ // Assign a property indicating whether this is the storm point.
+ // Updated to indicate what type of storm it is
+ for (const pointFeature of pointFeatures) {
+ // find proper icon for windspeed
+ const storm_type = getStormType(pointFeature.properties.max_wind_speed_mph);
+ if (storm_type) pointFeature.properties["storm_type"] = storm_type;
+
+ pointFeature.properties.is_storm =
+ pointFeature.properties.time_utc == forecastDateTime;
+
+ // Add storm name if it is defined
+ if (storm_name) pointFeature.properties["storm_name"] = storm_name;
+ }
+
+ const labelFeatures = [];
+ const bufferFeatures = [];
+
+ // Find the correct cone radii for the storm year, or use the closest year if
+ // the storm year is not available.
+ const trackYear = parseInt(trackData.best.query.year, 10);
+ let coneSpec = trackConeSpecs[trackYear];
+ if (!coneSpec) {
+ const specYears = Object.keys(trackConeSpecs);
+ const maxYear = Math.max(specYears);
+ const minYear = Math.min(specYears);
+ coneSpec = trackConeSpecs[trackYear > maxYear ? maxYear : minYear];
+ }
+
+ let prevDayOfMonth = null;
+ let prevIdx = null;
+ let prevRadius = null;
+ pointFeatures.slice().forEach((feature, i) => {
+ const date = getTrackPointDate(feature, timezone);
+ const deltaHours = (date.getTime() - forecastTime) / 3600000; // Convert ms to hrs
+
+ // If this point is on or after the forecast time, build the cone of uncertainty.
+ if (deltaHours >= 0) {
+ let hourIdx = 0;
+ while (
+ coneSpec.hour[hourIdx] < deltaHours &&
+ coneSpec.hour.length - 1 > hourIdx
+ ) {
+ hourIdx++;
+ }
+ // Look up the radius, interpolating if necessary.
+ const radiusNmi =
+ coneSpec.hour[hourIdx] === deltaHours
+ ? coneSpec.cone[hourIdx]
+ : ((deltaHours - coneSpec.hour[hourIdx - 1]) /
+ (coneSpec.hour[hourIdx] - coneSpec.hour[hourIdx - 1])) *
+ (coneSpec.cone[hourIdx] - coneSpec.cone[hourIdx - 1]) +
+ coneSpec.cone[hourIdx - 1];
+ // Convert radius in nmi to km.
+ const radius = radiusNmi * 1.852;
+ // Add a circle around the point to the cone.
+ bufferFeatures.push(circle(feature, radius));
+ // If this point is after the forecast time, draw a polygon whose sides are lines
+ // tangent to this circle and the previous circle.
+ if (prevIdx !== null) {
+ const featureDistance = distance(pointFeatures[prevIdx], feature);
+ const featureBearing = avgBearing(pointFeatures[prevIdx], feature);
+ const bearingOffset =
+ (Math.acos((radius - prevRadius) / featureDistance) * 180) / Math.PI;
+ if (!isNaN(bearingOffset)) {
+ const currCoords = [-1, 1].map(
+ (direction) =>
+ destination(
+ feature,
+ radius,
+ featureBearing + (180 - bearingOffset * direction)
+ ).geometry.coordinates
+ );
+ const prevCoords = [1, -1].map(
+ (direction) =>
+ destination(
+ pointFeatures[prevIdx],
+ prevRadius,
+ featureBearing + (180 - bearingOffset * direction)
+ ).geometry.coordinates
+ );
+ // Add the polygon to the cone.
+ bufferFeatures.push({
+ type: "Feature",
+ geometry: {
+ type: "Polygon",
+ coordinates: [[...currCoords, ...prevCoords, currCoords[0]]]
+ },
+ properties: {}
+ });
+ }
+ }
+ prevIdx = i;
+ prevRadius = radius;
+ }
+
+ const dayOfMonth = date.getDate();
+ // If this point has a different day of the month than the previous point,
+ // Generate the offsets for a date label.
+ if (
+ (!prevDayOfMonth && pointFeatures.length > 1) ||
+ prevDayOfMonth !== dayOfMonth
+ ) {
+ const avgDegrees = avgBearing(
+ pointFeatures[i - 1],
+ feature,
+ pointFeatures[i + 1]
+ );
+ const avgRadians = (avgDegrees * Math.PI) / 180;
+ labelFeatures.push({
+ ...feature,
+ properties: {
+ label: date.toString().split(" ").slice(1, 3).join(" "),
+ textOffset: [Math.cos(avgRadians) * 2.5, Math.sin(avgRadians) * 2.5],
+ rotate: avgDegrees + 90
+ }
+ });
+ }
+ prevDayOfMonth = dayOfMonth;
+ });
+
+ // Dissolve the cone features.
+ let coneFeatureCollection = {
+ type: "FeatureCollection",
+ features: bufferFeatures
+ };
+ if (bufferFeatures.length)
+ coneFeatureCollection = dissolve({
+ type: "FeatureCollection",
+ features: bufferFeatures
+ });
+ // add some styling to the cone
+ /* coneFeatureCollection.features[0].properties = {
+ "fill-opacity": 0,
+ stroke: "#858585",
+ "stroke-opacity": 0.7
+ }; */
+
+ return {
+ cone: coneFeatureCollection,
+ labels: {
+ type: "FeatureCollection",
+ features: labelFeatures
+ },
+ line: {
+ type: "Feature",
+ // properties: {},
+ geometry: {
+ type: "LineString",
+ coordinates: pointFeatures.map((point) => point.geometry.coordinates)
+ }
+ },
+ points: { type: "FeatureCollection", features: pointFeatures }
+ };
+}
+// get the type of storm it is, according to saffir-simpson scale
+function getStormType(windsp) {
+ let storm_type = "";
+ switch (true) {
+ case windsp <= 38.0:
+ storm_type = "TD";
+ break;
+ case windsp > 38.0 && windsp <= 73.0:
+ storm_type = "TS";
+ break;
+ case windsp > 73.0 && windsp <= 95.0:
+ storm_type = "CAT1";
+ break;
+ case windsp > 95.0 && windsp <= 110.0:
+ storm_type = "CAT2";
+ break;
+ case windsp > 110.0 && windsp <= 129.0:
+ storm_type = "CAT3";
+ break;
+ case windsp > 129.0 && windsp <= 156.0:
+ storm_type = "CAT4";
+ break;
+ case windsp > 156.0:
+ storm_type = "CAT5";
+ break;
+ default:
+ storm_type = null;
+ }
+ return storm_type;
+}