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 {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 maplibregl from 'maplibre-gl';
|
||||||
import ToolbarProductSelector from '$lib/ToolbarProductSelector.svelte';
|
import ToolbarProductSelector from '$lib/ToolbarProductSelector.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { onMount } from 'svelte';
|
import {mount, onMount} from 'svelte';
|
||||||
import type { LayerList } from './layerList';
|
import type { LayerList } from './layerList';
|
||||||
import type { StationStatus } from './stationData';
|
import type { StationStatus } from './stationData';
|
||||||
import {toast} from "svelte-sonner";
|
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 {
|
interface Props {
|
||||||
categories: LayerList;
|
categories: LayerList;
|
||||||
|
@ -20,6 +24,17 @@
|
||||||
let pickingSiteForCategory: boolean = $state(false);
|
let pickingSiteForCategory: boolean = $state(false);
|
||||||
let selectedSite: string | null = $state(null);
|
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(() => {
|
onMount(() => {
|
||||||
map = new maplibregl.Map({
|
map = new maplibregl.Map({
|
||||||
container: id,
|
container: id,
|
||||||
|
@ -39,6 +54,82 @@
|
||||||
}
|
}
|
||||||
load_image('radar-rect-green');
|
load_image('radar-rect-green');
|
||||||
load_image('radar-rect-red');
|
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) => {
|
map.on('error', (e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
@ -56,15 +147,31 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- center -->
|
<!-- center -->
|
||||||
<div class="flex flex-1 grow flex-row justify-center">
|
<div class="flex flex-1 grow flex-row justify-center gap-2">
|
||||||
<ToolbarProductSelector
|
<!-- center-left -->
|
||||||
bind:pickingSiteForCategory
|
<div class="flex flex-1 grow flex-row justify-end">
|
||||||
bind:selectedSite
|
<Toggle bind:pressed={showAlertLayer} onPressedChange={updateAlertLayer}>
|
||||||
bind:selectedPrimaryLayer
|
<CloudAlertIcon class="size-4" />
|
||||||
{categories}
|
</Toggle>
|
||||||
{map}
|
</div>
|
||||||
{stations}
|
|
||||||
/>
|
<!-- 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>
|
||||||
|
|
||||||
<div class="flex flex-1 grow flex-row justify-end gap-1"></div>
|
<div class="flex flex-1 grow flex-row justify-end gap-1"></div>
|
||||||
|
|
|
@ -83,7 +83,7 @@
|
||||||
id: 'data',
|
id: 'data',
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
source: `${selectedSite}-${data.id}`
|
source: `${selectedSite}-${data.id}`
|
||||||
});
|
}, 'alerts');
|
||||||
}}>{data.layer}</DropdownMenu.Item
|
}}>{data.layer}</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
|
@ -129,7 +129,7 @@
|
||||||
id: 'data',
|
id: 'data',
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
source: `${layer.id}`
|
source: `${layer.id}`
|
||||||
});
|
}, 'alerts');
|
||||||
}
|
}
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
@ -204,7 +204,7 @@
|
||||||
id: 'data',
|
id: 'data',
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
source: `${selectedSite}-${selectedPrimaryLayer}`
|
source: `${selectedSite}-${selectedPrimaryLayer}`
|
||||||
});
|
}, 'alerts');
|
||||||
} // preserve the layer the user had if they click while already selected.
|
} // preserve the layer the user had if they click while already selected.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -219,6 +219,9 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
<DropdownMenu.Item onclick={() => {selectedPrimaryLayer = null; if (map.getLayer('data')) {map.removeLayer('data')}}}>
|
||||||
|
None
|
||||||
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</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