364 lines
12 KiB
Svelte
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>
|