diff --git a/client/src/app.css b/client/src/app.css index d8b13b5..7f8ac39 100644 --- a/client/src/app.css +++ b/client/src/app.css @@ -72,4 +72,19 @@ body { @apply bg-background text-foreground; } +} + +.maplibregl-popup-content { + background-color: hsl(var(--popover)); + color: hsl(var(--popover-foreground)) +} +.maplibregl-popup-tip { + border-bottom-color: hsl(var(--popover)) !important; + border-top-color: hsl(var(--popover)) !important; + color: hsl(var(--popover)); +} +.maplibregl-popup-anchor-bottom { +} +.maplibregl-popup { + max-width: unset !important; } \ No newline at end of file diff --git a/client/src/lib/AlertPopup.svelte b/client/src/lib/AlertPopup.svelte new file mode 100644 index 0000000..4d4e316 --- /dev/null +++ b/client/src/lib/AlertPopup.svelte @@ -0,0 +1,91 @@ + + + +
+

+ {code} + {event} +

+ +
+

+ {#if urgency === "Immediate"} + + {:else if urgency === "Expected"} + + {:else if urgency === "Future"} + + {:else if urgency === "Past"} + + {:else} + + {/if} +

+

+ {#if severity === "Extreme"} + + {:else if severity === "Severe"} + + {:else if severity === "Moderate"} + + {:else if severity === "Minor"} + + {:else} + + {/if} +

+

+ {#if certainty === "Observed"} + + {:else if certainty === "Likely"} + + + {:else if certainty === "Possible"} + + {:else if certainty === "Unlikely"} + + {:else} + + {/if} +

+
+ + {#if showFull} +
{description}
+ {/if} +
+
\ No newline at end of file diff --git a/client/src/lib/BoxTooltip.svelte b/client/src/lib/BoxTooltip.svelte new file mode 100644 index 0000000..53c6ada --- /dev/null +++ b/client/src/lib/BoxTooltip.svelte @@ -0,0 +1,17 @@ + + + + {text} + +

{hover}

+
+
\ No newline at end of file diff --git a/client/src/lib/Map.svelte b/client/src/lib/Map.svelte index edd2364..b510ba7 100644 --- a/client/src/lib/Map.svelte +++ b/client/src/lib/Map.svelte @@ -2,10 +2,14 @@ import maplibregl from 'maplibre-gl'; import ToolbarProductSelector from '$lib/ToolbarProductSelector.svelte'; import { Button } from '$lib/components/ui/button'; - import { onMount } from 'svelte'; + import {mount, onMount} from 'svelte'; import type { LayerList } from './layerList'; import type { StationStatus } from './stationData'; import {toast} from "svelte-sonner"; + import {Toggle} from "$lib/components/ui/toggle"; + import CloudAlertIcon from "@lucide/svelte/icons/cloud-alert"; + import {borderLUT, fillLUT} from "$lib/alertLayer"; + import AlertPopup from "$lib/AlertPopup.svelte"; interface Props { categories: LayerList; @@ -20,6 +24,17 @@ let pickingSiteForCategory: boolean = $state(false); let selectedSite: string | null = $state(null); + let showAlertLayer: boolean = $state(true); + function updateAlertLayer() { + if (!map) { + showAlertLayer = !showAlertLayer; + return; + } + + map.setLayoutProperty('alerts', 'visibility', showAlertLayer ? "visible" : "none"); + map.setLayoutProperty('alerts-outline', 'visibility', showAlertLayer ? "visible" : "none"); + } + onMount(() => { map = new maplibregl.Map({ container: id, @@ -39,6 +54,82 @@ } load_image('radar-rect-green'); load_image('radar-rect-red'); + + map.addSource('alerts', { + type: 'geojson', + data: 'https://api.weather.gov/alerts/active?status=actual' + }); + map.addLayer({ + id: 'alerts', + type: 'fill', + source: 'alerts', + paint: { + // @ts-expect-error this type is too complicated + 'fill-color': fillLUT, + } + }); + map.addLayer({ + id: 'alerts-outline', + type: 'line', + source: 'alerts', + layout: { + 'line-join': 'round' + }, + paint: { + // @ts-expect-error this type is too complicated + 'line-color': borderLUT, + 'line-width': 3 + } + }); + let createPopup = (e, full: boolean) => { + const randLetter = String.fromCharCode(65 + Math.floor(Math.random() * 26)); + const uniqid = randLetter + Date.now(); + + new maplibregl.Popup({ className: 'popup' }) + .setLngLat(e.lngLat) + .setHTML(`
`) + + .addTo(map); + mount(AlertPopup, { + target: document.getElementById(uniqid)!, + props: { + showFull: full, + ...e.features[0].properties + } + }); + }; + map.on('click', 'alerts', (e) => {createPopup(e,true)}); + let currentFeatureCoordinates = undefined; + const popup = new maplibregl.Popup({ + closeButton: false, + closeOnClick: false + }); + map.on('mousemove', 'alerts', (e) => { + // Change the cursor style as a UI indicator. + map.getCanvas().style.cursor = 'pointer'; + + // Populate the popup and set its coordinates + // based on the feature found. + const randLetter = String.fromCharCode(65 + Math.floor(Math.random() * 26)); + const uniqid = randLetter + Date.now(); + + popup.setLngLat(e.lngLat).setHTML(`
`).addTo(map); + mount(AlertPopup, { + target: document.getElementById(uniqid)!, + props: { + showFull: false, + ...e.features[0].properties + } + }); + + }); + map.on('mouseenter', 'alerts', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'alerts', () => { + map.getCanvas().style.cursor = ''; + popup.remove(); + }); }); map.on('error', (e) => { console.error(e); @@ -56,15 +147,31 @@ -
- +
+ +
+ + + +
+ + +
+ +
+ + +
+ +
+
diff --git a/client/src/lib/ToolbarProductSelector.svelte b/client/src/lib/ToolbarProductSelector.svelte index 0584c45..53aad2b 100644 --- a/client/src/lib/ToolbarProductSelector.svelte +++ b/client/src/lib/ToolbarProductSelector.svelte @@ -83,7 +83,7 @@ id: 'data', type: 'raster', source: `${selectedSite}-${data.id}` - }); + }, 'alerts'); }}>{data.layer} {/each} @@ -129,7 +129,7 @@ id: 'data', type: 'raster', source: `${layer.id}` - }); + }, 'alerts'); } }} @@ -204,7 +204,7 @@ id: 'data', type: 'raster', source: `${selectedSite}-${selectedPrimaryLayer}` - }); + }, 'alerts'); } // preserve the layer the user had if they click while already selected. } }); @@ -219,6 +219,9 @@ {/if} {/if} {/each} + {selectedPrimaryLayer = null; if (map.getLayer('data')) {map.removeLayer('data')}}}> + None + diff --git a/client/src/lib/alertLayer.ts b/client/src/lib/alertLayer.ts new file mode 100644 index 0000000..edaf026 --- /dev/null +++ b/client/src/lib/alertLayer.ts @@ -0,0 +1,112 @@ +export type RGBColor = ['rgb', number, number, number]; +export type RGBAColor = ['rgba', number, number, number, number]; + +export function c(input: string): RGBColor { + // @ts-expect-error aaa + const m = input.match(/^#([0-9a-f]{6})$/i)[1]; + if( m) { + return [ + 'rgb', + parseInt(m.substring(0,2),16), + parseInt(m.substring(2,4),16), + parseInt(m.substring(4,6),16) + ]; + } +} + +export const typeLookupTable: Record = { + 'Hazardous Weather Outlook': [c('#eee8aa'),'HWO'], + + // Winter Weather/Cold Weather + 'Winter Storm Watch': [c('#4682b4'),'WSA'], + 'Blizzard Warning': [c('#ff4500'),'BZW'], + 'Winter Storm Warning': [c('#ff69b4'),'WSW'], + 'Ice Storm Warning': [c('#8b008b'),'WSW'], + 'Winter Weather Advisory': [c('#7b68ee'),'WSW'], + 'Freeze Watch': [c('#00ffff'),'NPW'], + 'Freeze Warning': [c('#483d8b'),'FZW'], + 'Frost Advisory': [c('#6495ed'),'NPW'], + 'Cold Weather Advisory': [c('#afeeee'),'NPW'], + 'Extreme Cold Warning': [c('#0000ff'),'NPW'], + + // Fire Weather + 'Fire Weather Watch': [c('#ffdead'),'FWA'], + 'Red Flag Warning': [c('#ff1493'),'FWA'], + + // Fog / Wind / Severe Weather + 'Dense Fog Advisory': [c('#708090'),'NPW'], + 'High Wind Watch': [c('#b8860b'),'HWA'], + 'High Wind Warning': [c('#daa520'),'HWW'], + 'Wind Advisory': [c('#d2b48c'),'NPW'], + 'Severe Thunderstorm Watch': [c('#db7093'),'SVA'], + 'Severe Thunderstorm Warning': [c('#ffa500'),'SVR'], + 'Tornado Watch': [c('#ffff00'),'TOA'], + 'Tornado Warning': [c('#ff0000'),'TOR'], + 'Extreme Wind Warning': [c('#ff8c00'),'EWW'], + + // Marine + 'Small Craft Advisory': [c('#d8bfd8'),'SCA'], + 'Gale Warning': [c('#dda0dd'),'MWS'], + 'Storm Warning': [c('#9400d3'),'MWS'], + 'Hurricane Force Wind Warning': [c('#cd5c5c'),'NPW'], + 'Special Marine Warning': [c('#ffa500'),'SMW'], + + // Flooding + 'Coastal Flood Watch': [c('#66cdaa'),'CFA'], + 'Coastal Flood Warning': [c('#228b22'),'CFW'], + 'Coastal Flood Advisory': [c('#7cfc00'),'CFW'], + 'Flood Advisory': [c('#2e8b57'),'FFA'], + 'Flash Flood Warning': [c('#8b0000'),'FFW'], + 'Flood Warning': [c('#8b0000'),'FFW'], + 'River Flood Watch': [c('#00ff7f'),'FLA'], + 'River Flood Warning': [c('#00ff00'),'FLW'], + + // Excessive Heat + 'Excessive Heat Watch': [c('#800000'),'NPW'], + 'Excessive Heat Warning': [c('#c71585'),'NPW'], + 'Heat Advisory': [c('#ff7f50'),'NPW'], + + // Tropical + 'Tropical Storm Watch': [c('#f08080'),'TRA'], + 'Tropical Storm Warning': [c('#b22222'),'TRW'], + 'Hurricane Watch': [c('#ff00ff'),'HUA'], + 'Hurricane Warning': [c('#dc143c'),'HUW'], + 'Storm Surge Warning': [c('#b524f7'),'SSW'], + 'Storm Surge Watch': [c('#db7ff7'),'SSA'], + 'Hurricane Local Statement': [c('#ffe4b5'),'HLS'], + + 'Special Weather Statement': [c('#ffe4b5'), 'SPS'] + +}; +const fallback: RGBColor = c('#a21caf'); + +const areaOpacity = 0.1; +const borderOpacity = 1.0; + +function generateLUT(opacity: number): any[] { + const arr: any[] = [ + 'match', + ['get', 'event'], + + ]; + + for (const [matcher, [value]] of Object.entries(typeLookupTable)) { + arr.push(matcher); + + const rgba_value = structuredClone(value as unknown as RGBAColor); + rgba_value.push(opacity); + rgba_value[0] = 'rgba'; + + arr.push(rgba_value); + } + + const rgba_value = structuredClone(fallback as unknown as RGBAColor); + rgba_value.push(opacity); + rgba_value[0] = 'rgba'; + + arr.push(rgba_value); + + return arr; +} +export const borderLUT = generateLUT(borderOpacity); +export const fillLUT = generateLUT(areaOpacity);