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; +}