feat: client work
This commit is contained in:
parent
e090501624
commit
4ee16ada3c
6 changed files with 358 additions and 13 deletions
|
@ -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;
|
||||
}
|
91
client/src/lib/AlertPopup.svelte
Normal file
91
client/src/lib/AlertPopup.svelte
Normal file
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts">
|
||||
import {c, type RGBColor, typeLookupTable} from "$lib/alertLayer";
|
||||
import {Separator} from "$lib/components/ui/separator";
|
||||
import * as Tooltip from "$lib/components/ui/tooltip";
|
||||
import BoxTooltip from "$lib/BoxTooltip.svelte";
|
||||
|
||||
interface Props {
|
||||
event: string;
|
||||
urgency: string;
|
||||
severity: string;
|
||||
certainty: string;
|
||||
description: string;
|
||||
showFull: boolean;
|
||||
}
|
||||
let { event, urgency, severity, certainty, showFull, description }: Props = $props();
|
||||
|
||||
let color: RGBColor = $state(c('#a21caf'));
|
||||
let code: string = $state("UNK");
|
||||
if (Object.keys(typeLookupTable).includes(event)) {
|
||||
color = typeLookupTable[event][0];
|
||||
code = typeLookupTable[event][1];
|
||||
}
|
||||
const colorString = $derived(`rgb(${color[1]},${color[2]},${color[3]})`);
|
||||
|
||||
function inverse() {
|
||||
let lumi = 0.2126*color[1] + 0.7152*color[2] + 0.0722*color[3];
|
||||
if (lumi < 140) {
|
||||
return "text-white";
|
||||
} else {
|
||||
return "text-black";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-sm font-bold leading-loose font-sans">
|
||||
<span class="px-2 py-1 mr-2 {inverse()}" style="background-color: {colorString}">{code}</span>
|
||||
{event}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<h4 class="text-xs font-sans">
|
||||
{#if urgency === "Immediate"}
|
||||
<BoxTooltip colors="bg-red-600 text-red-950" text="Immediate" hover="This event is actively occurring or will happen in the immediate future." />
|
||||
{:else if urgency === "Expected"}
|
||||
<BoxTooltip colors="bg-slate-800" text="Expected" hover="This event is expected to happen in the near future." />
|
||||
{:else if urgency === "Future"}
|
||||
<BoxTooltip colors="bg-slate-800" text="Expected" hover="This event will happen in the future." />
|
||||
{:else if urgency === "Past"}
|
||||
<BoxTooltip colors="bg-slate-800" text="Past" hover="This event has happened." />
|
||||
{:else}
|
||||
<BoxTooltip colors="bg-slate-800" text={urgency} hover="This event's urgency is unknown." />
|
||||
{/if}
|
||||
</h4>
|
||||
<h4 class="text-xs font-sans">
|
||||
{#if severity === "Extreme"}
|
||||
<BoxTooltip colors="bg-red-600 text-red-950" text="Extreme" hover="This event is historically extreme and is likely very dangerous to property and life." />
|
||||
{:else if severity === "Severe"}
|
||||
<BoxTooltip colors="bg-red-400 text-red-950" text="Severe" hover="This event is severe and may be dangerous to property and/or life." />
|
||||
{:else if severity === "Moderate"}
|
||||
<BoxTooltip colors="bg-amber-950 text-amber-600" text="Moderate" hover="This event may cause damage to property or cause injury." />
|
||||
{:else if severity === "Minor"}
|
||||
<BoxTooltip colors="bg-slate-800" text="Minor" hover="This is a minor event." />
|
||||
{:else}
|
||||
<BoxTooltip colors="bg-slate-800" text={severity} hover="This event's severity is unknown." />
|
||||
{/if}
|
||||
</h4>
|
||||
<h4 class="text-xs font-sans">
|
||||
{#if certainty === "Observed"}
|
||||
<BoxTooltip colors="bg-sky-600 text-sky-50" text="Observed" hover="This event has been visually confirmed by spotters or law enforcement." />
|
||||
{:else if certainty === "Likely"}
|
||||
<BoxTooltip colors="bg-amber-500 text-amber-950" text="Likely" hover="This event is expected to occur." />
|
||||
|
||||
{:else if certainty === "Possible"}
|
||||
<BoxTooltip colors="bg-amber-950 text-amber-600" text="Possible" hover="It is possible for this event to occur." />
|
||||
{:else if certainty === "Unlikely"}
|
||||
<BoxTooltip colors="bg-slate-800" text="Unlikely" hover="It is unlikely for this event to occur." />
|
||||
{:else}
|
||||
<BoxTooltip colors="bg-slate-800" text={certainty} hover="This event's certainty is unknown." />
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{#if showFull}
|
||||
<pre>{description}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
17
client/src/lib/BoxTooltip.svelte
Normal file
17
client/src/lib/BoxTooltip.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from "$lib/components/ui/tooltip";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
hover: string;
|
||||
colors: string;
|
||||
}
|
||||
let { text, hover, colors }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="px-2 py-1 {colors} font-semibold">{text}</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{hover}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
|
@ -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(`<div id="${uniqid}"></div>`)
|
||||
|
||||
.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(`<div id="${uniqid}"></div>`).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 @@
|
|||
</div>
|
||||
|
||||
<!-- center -->
|
||||
<div class="flex flex-1 grow flex-row justify-center">
|
||||
<ToolbarProductSelector
|
||||
bind:pickingSiteForCategory
|
||||
bind:selectedSite
|
||||
bind:selectedPrimaryLayer
|
||||
{categories}
|
||||
{map}
|
||||
{stations}
|
||||
/>
|
||||
<div class="flex flex-1 grow flex-row justify-center gap-2">
|
||||
<!-- center-left -->
|
||||
<div class="flex flex-1 grow flex-row justify-end">
|
||||
<Toggle bind:pressed={showAlertLayer} onPressedChange={updateAlertLayer}>
|
||||
<CloudAlertIcon class="size-4" />
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
<!-- center-center -->
|
||||
<div class="flex flex-1 grow flex-row justify-center">
|
||||
<ToolbarProductSelector
|
||||
bind:pickingSiteForCategory
|
||||
bind:selectedSite
|
||||
bind:selectedPrimaryLayer
|
||||
{categories}
|
||||
{map}
|
||||
{stations}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- center-center -->
|
||||
<div class="flex flex-1 grow flex-row justify-start">
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 grow flex-row justify-end gap-1"></div>
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
id: 'data',
|
||||
type: 'raster',
|
||||
source: `${selectedSite}-${data.id}`
|
||||
});
|
||||
}, 'alerts');
|
||||
}}>{data.layer}</DropdownMenu.Item
|
||||
>
|
||||
{/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}
|
||||
<DropdownMenu.Item onclick={() => {selectedPrimaryLayer = null; if (map.getLayer('data')) {map.removeLayer('data')}}}>
|
||||
None
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
|
112
client/src/lib/alertLayer.ts
Normal file
112
client/src/lib/alertLayer.ts
Normal file
|
@ -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<string, [RGBColor, string]> = {
|
||||
'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);
|
Loading…
Add table
Reference in a new issue