Radio sonde on map
This commit is contained in:
267
client/src/services/sondes.ts
Normal file
267
client/src/services/sondes.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {api, BASE} from '@/services/api.ts';
|
||||
import {h, render as vueRender} from 'vue';
|
||||
import Map from 'ol/Map';
|
||||
import {Feature} from 'ol';
|
||||
import {LineString, Point} from 'ol/geom';
|
||||
import {fromLonLat} from 'ol/proj';
|
||||
import {Style, Stroke, Icon} from 'ol/style';
|
||||
import {Vector as VectorLayer} from 'ol/layer';
|
||||
import {Vector as VectorSource} from 'ol/source';
|
||||
import {adjustedInterval} from '@ztimson/utils';
|
||||
import SondePopup from '@/components/WeatherBalloon.vue';
|
||||
import {bringToFront} from './zindex';
|
||||
|
||||
const API = BASE + '/api';
|
||||
|
||||
interface SondeReading {
|
||||
timestamp: number;
|
||||
id: string;
|
||||
type: string;
|
||||
subtype: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude: number;
|
||||
heading: number;
|
||||
speed: number;
|
||||
vertical_speed: number;
|
||||
sats: number;
|
||||
battery: number;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
pressure: number;
|
||||
frequency: number;
|
||||
frequency_hz: number;
|
||||
snr: number;
|
||||
ppm: number;
|
||||
aprsid: string;
|
||||
}
|
||||
|
||||
interface Sonde extends SondeReading {
|
||||
history: SondeReading[];
|
||||
}
|
||||
|
||||
export class SondesLayer {
|
||||
private map: Map;
|
||||
private layer!: VectorLayer<VectorSource>;
|
||||
private popups: any = {};
|
||||
private data: Sonde[] = [];
|
||||
private clickHandler: ((e: any) => void) | null = null;
|
||||
private refreshTimer: any = null;
|
||||
visible = false;
|
||||
|
||||
constructor(map: Map) {
|
||||
this.map = map;
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (this.visible) return;
|
||||
|
||||
this.visible = true;
|
||||
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||
this.map.addLayer(this.layer);
|
||||
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._attachClick();
|
||||
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return;
|
||||
|
||||
this.visible = false;
|
||||
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
if (this.clickHandler) {
|
||||
this.map.un('singleclick', this.clickHandler);
|
||||
this.clickHandler = null;
|
||||
}
|
||||
|
||||
for (const id of Object.keys(this.popups)) this._closePopup(id);
|
||||
if (this.layer) this.map.removeLayer(this.layer);
|
||||
}
|
||||
|
||||
getClosest(lat: number, lng: number): Sonde | null {
|
||||
if (!this.data.length) return null;
|
||||
|
||||
let closest: Sonde | null = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
for (const sonde of this.data) {
|
||||
if (sonde.latitude == null || sonde.longitude == null) continue;
|
||||
|
||||
const distance = this._distance(lat, lng, sonde.latitude, sonde.longitude);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closest = sonde;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
getAutoCandidates(lat: number, lng: number, limit = Infinity): Sonde[] {
|
||||
return this.data.filter(sonde => {
|
||||
return sonde.latitude != null && sonde.longitude != null;
|
||||
}).sort((a, b) => {
|
||||
const ad = this._distance(lat, lng, a.latitude, a.longitude);
|
||||
const bd = this._distance(lat, lng, b.latitude, b.longitude);
|
||||
return ad - bd;
|
||||
}).slice(0, limit);
|
||||
}
|
||||
|
||||
isPriority(sonde: Sonde): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getAutoDistance(lat: number, lng: number, sonde: Sonde): number {
|
||||
return this._distance(lat, lng, sonde.latitude, sonde.longitude);
|
||||
}
|
||||
|
||||
getById(id: string): Sonde | null {
|
||||
return this.data.find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
openAutoPopup(sonde: Sonde) {
|
||||
this._openPopup(sonde);
|
||||
}
|
||||
|
||||
closeAutoPopup(id: string) {
|
||||
this._closePopup(id);
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sondes`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!;
|
||||
source.clear();
|
||||
|
||||
for (const sonde of this.data) {
|
||||
if (sonde.latitude == null || sonde.longitude == null) continue;
|
||||
|
||||
const points: any = [...sonde.history, sonde].map(r => fromLonLat([r.longitude, r.latitude]));
|
||||
|
||||
if (points.length > 1) {
|
||||
const trail = new Feature({geometry: new LineString(points)});
|
||||
trail.setStyle(new Style({
|
||||
stroke: new Stroke({
|
||||
color: 'rgba(255,170,0,0.5)',
|
||||
width: 2,
|
||||
lineDash: [6, 4],
|
||||
}),
|
||||
}));
|
||||
|
||||
source.addFeature(trail);
|
||||
}
|
||||
|
||||
const marker = new Feature({geometry: new Point(points[points.length - 1])});
|
||||
marker.set('sonde', sonde.id);
|
||||
marker.set('sondeData', sonde);
|
||||
marker.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: '/sonde.png',
|
||||
scale: 0.1,
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}));
|
||||
|
||||
source.addFeature(marker);
|
||||
}
|
||||
}
|
||||
|
||||
private _distance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => d * Math.PI / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
private _openPopup(sonde: Sonde) {
|
||||
if (this.popups[sonde.id]) return;
|
||||
|
||||
const history = [...sonde.history, sonde];
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
const makeProps = (h_: SondeReading[]) => ({
|
||||
history: h_,
|
||||
position: mobile
|
||||
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||
: {x: 16, y: 16},
|
||||
onClose: () => this._closePopup(sonde.id),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(SondePopup, makeProps(history)), container);
|
||||
|
||||
this.popups[sonde.id] = {
|
||||
container,
|
||||
update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container),
|
||||
unmount: () => {
|
||||
vueRender(null, container);
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _closePopup(id: string) {
|
||||
const popup = this.popups[id];
|
||||
|
||||
if (popup) {
|
||||
popup.unmount();
|
||||
delete this.popups[id];
|
||||
}
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const id of Object.keys(this.popups)) {
|
||||
const sonde = this.data.find(s => s.id === id);
|
||||
|
||||
if (!sonde) {
|
||||
this._closePopup(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.popups[id].update([...sonde.history, sonde]);
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = evt => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null);
|
||||
if (!f) return;
|
||||
|
||||
const sonde = f.get('sondeData') as Sonde;
|
||||
|
||||
if (this.popups[sonde.id]) {
|
||||
this._closePopup(sonde.id);
|
||||
return;
|
||||
}
|
||||
|
||||
this._openPopup(sonde);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user