wxbox/client/src/lib/Map.svelte
core 61df1e725c
Some checks are pending
Verify Latest Dependencies / Verify Latest Dependencies (push) Waiting to run
build and test / wxbox - latest (push) Waiting to run
csr rendering v2
2025-05-31 11:15:36 -04:00

364 lines
12 KiB
Svelte

<script lang="ts">
import maplibregl from 'maplibre-gl';
import ToolbarProductSelector from '$lib/ToolbarProductSelector.svelte';
import { Button } from '$lib/components/ui/button';
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';
import { CifContainer } from '$lib/generated_interop/cifContainer';
import fragmentSource from '$lib/map/fragment.glsl?raw';
import vertexSource from '$lib/map/vertex.glsl?raw';
import { degreesToRadians } from '@turf/turf';
import type { DigitalRadarData, Radial } from './generated_interop/digitalRadarData';
import {forwardGeodesic, radToDeg} from "$lib/vincenty";
import {degToRad} from "$lib/vincenty.js";
const BELOW_THRESHOLD = -9999.0;
const RANGE_FOLDED = -9998.0;
interface Props {
categories: LayerList;
stations: Record<string, StationStatus>;
}
let { categories, stations }: Props = $props();
const id = $props.id();
let map: maplibregl.Map | null = $state(null);
let selectedPrimaryLayer: string | null = $state(null);
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,
style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
center: [0, 0],
zoom: 1
});
map.on('load', async () => {
if (!map) return;
function load_image(path: string) {
const img = document.createElement('img');
img.src = '/' + path + '.svg';
img.onload = () => {
map?.addImage(path, img);
};
}
load_image('radar-rect-green');
load_image('radar-rect-red');
// should this be here? am unfamiliar with the structure
const layers = map.getStyle().layers;
let sym_layer;
for (let i = 0; i < layers.length; i++) {
if (layers[i].type === 'symbol') {
sym_layer = layers[i].id;
break;
}
}
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
}
},
sym_layer
);
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
}
},
sym_layer
);
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();
});
let r = await fetch('http://localhost:3000/v2/nexrad/l2/KLWX/1/REF');
const buf = await r.arrayBuffer();
const container = CifContainer.fromBinary(new Uint8Array(buf));
console.log('wxrad http://localhost:3000/v2/nexrad/l2/KLWX/1/REF: ', container);
if (container.messageType.oneofKind == 'digitalRadarData') {
const drd: DigitalRadarData = container.messageType.digitalRadarData;
const dataLayer = {
id: 'dataGl',
type: 'custom',
onAdd(map, gl) {
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexSource);
gl.compileShader(vertexShader);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentSource);
gl.compileShader(fragmentShader);
this.program = gl.createProgram();
gl.attachShader(this.program, vertexShader);
gl.attachShader(this.program, fragmentShader);
gl.linkProgram(this.program);
gl.useProgram(this.program);
this.aPos = gl.getAttribLocation(this.program, 'a_pos');
/*
110.574 km = 1 deg
deg = 1/110.574 km
111.320 * cos(latitude) km = 1 deg
*/
const lat = 38.976111;
const long = -77.4875;
gl.uniform1f(gl.getUniformLocation(this.program, 'radarLat'), lat);
gl.uniform1f(gl.getUniformLocation(this.program, 'radarLng'), long);
const vertexData: number[] = [];
const triangleAzimuthLookup: number[] = [];
const radarCoordinate = maplibregl.MercatorCoordinate.fromLngLat({
lng: long,
lat: lat
});
for (let i =0; i < drd.radials.length; i++) {
const radial = drd.radials[i];
if (radial.product && radial.product.data && radial.product.data.data) {
const angle_a = radial.azimuthAngleDegrees;
const angle_b = angle_a + radial.azimuthSpacingDegrees * 1.2;
const start_range = radial.product.data.startRange; // m
const sample_interval = radial.product.data.sampleInterval; // m
const number_of_samples = radial.product.data.data.length;
const range = sample_interval * number_of_samples + start_range; // m
// add an extra sample for good measure
const padded_range = range + sample_interval; // m
const in_weird_mercator_units = padded_range / radarCoordinate.meterInMercatorCoordinateUnits();
const pointAX = radarCoordinate.x + in_weird_mercator_units * Math.sin(degToRad(-angle_a + 180));
const pointAY = radarCoordinate.y + in_weird_mercator_units * Math.cos(degToRad(-angle_a + 180));
const pointA = new maplibregl.MercatorCoordinate(pointAX, pointAY, 0);
const pointBX = radarCoordinate.x + in_weird_mercator_units * Math.sin(degToRad(-angle_b + 180));
const pointBY = radarCoordinate.y + in_weird_mercator_units * Math.cos(degToRad(-angle_b + 180));
const pointB = new maplibregl.MercatorCoordinate(pointBX, pointBY, 0);
vertexData.push(radarCoordinate.x);
vertexData.push(radarCoordinate.y);
vertexData.push(pointA.x);
vertexData.push(pointA.y);
vertexData.push(pointB.x);
vertexData.push(pointB.y);
triangleAzimuthLookup.push(angle_a);
}
}
const radar_range_maximum = 560; // ish km
const degrees_of_lat = radar_range_maximum / 110.574;
const degrees_of_long =
radar_range_maximum / (111.32 * Math.cos(degreesToRadians(lat)));
this.buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(vertexData),
gl.STATIC_DRAW
);
this.vertexCount = vertexData.length/2;
gl.uniform1fv(gl.getUniformLocation(this.program, 'triangleAzimuthLookup'), new Float32Array(triangleAzimuthLookup));
function scaleMomentData(radial: Radial, u: number): number {
if (!radial.product || !radial.product.data) return BELOW_THRESHOLD;
if (radial.product?.data?.scale == 0) {
return u;
} else {
if (u == 0) {
return BELOW_THRESHOLD;
} else if (u == 1) {
return RANGE_FOLDED;
} else {
return (u - radial.product.data?.offset) / radial.product?.data?.scale;
}
}
}
const raw_data = [];
let validRadials = 0;
for (const radial of drd.radials) {
if (radial.product && radial.product.data && radial.product.data.data) {
for (const datapoint of radial.product.data.data) {
raw_data.push(scaleMomentData(radial, datapoint));
}
validRadials++;
}
}
const data = new Float32Array(raw_data);
gl.activeTexture(gl.TEXTURE0 + 10);
this.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, 1832, validRadials, 0, gl.RED, gl.FLOAT, data);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(this.program, 'data'), 10);
},
render(gl, args) {
gl.activeTexture(gl.TEXTURE0 + 10);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.useProgram(this.program);
gl.uniformMatrix4fv(
gl.getUniformLocation(this.program, 'u_matrix'),
false,
args.defaultProjectionData.mainMatrix
);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.enableVertexAttribArray(this.aPos);
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLES, 0, this.vertexCount);
}
};
map.addLayer(dataLayer, 'alerts');
}
});
map.on('error', (e) => {
console.error(e);
toast.error('Data loading failed!');
});
});
</script>
<div class="flex flex-1 flex-col">
<div class="flex flex-row items-center px-2">
{#if map}
<!-- left -->
<div class="flex flex-1 grow flex-row justify-start">
<Button variant="ghost"><b>1</b></Button>
</div>
<!-- center -->
<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>
{/if}
</div>
<div class="map flex-1" {id}></div>
</div>