Radio sonde on map
This commit is contained in:
BIN
client/public/sonde.png
Normal file
BIN
client/public/sonde.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
@@ -20,6 +20,7 @@ import {fromLonLat, toLonLat} from 'ol/proj';
|
|||||||
import {Style, Fill, Stroke, Circle as CircleStyle} from 'ol/style';
|
import {Style, Fill, Stroke, Circle as CircleStyle} from 'ol/style';
|
||||||
import {applyStyle} from 'ol-mapbox-style';
|
import {applyStyle} from 'ol-mapbox-style';
|
||||||
import {Aurora} from '@/services/aurora.ts';
|
import {Aurora} from '@/services/aurora.ts';
|
||||||
|
import {SondesLayer} from '@/services/sondes.ts';
|
||||||
import 'ol/ol.css';
|
import 'ol/ol.css';
|
||||||
|
|
||||||
const current = ref({latitude: 0, longitude: 0});
|
const current = ref({latitude: 0, longitude: 0});
|
||||||
@@ -40,6 +41,7 @@ const OVERLAY_GROUPS = [
|
|||||||
{id: 'aircraft', label: 'Aircraft', icon: '✈️'},
|
{id: 'aircraft', label: 'Aircraft', icon: '✈️'},
|
||||||
{id: 'marine', label: 'Marine', icon: '🚢'},
|
{id: 'marine', label: 'Marine', icon: '🚢'},
|
||||||
{id: 'satellites', label: 'Satellites', icon: '🛰️'},
|
{id: 'satellites', label: 'Satellites', icon: '🛰️'},
|
||||||
|
{id: 'sondes', label: 'Weather Balloons', icon: '️🎈'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,6 +86,7 @@ let airTraffic: AirTrafficLayer;
|
|||||||
let aisLayer: AISLayer;
|
let aisLayer: AISLayer;
|
||||||
let range: RangeLayer;
|
let range: RangeLayer;
|
||||||
let sats: SatsLayer;
|
let sats: SatsLayer;
|
||||||
|
let sondes: SondesLayer;
|
||||||
let aurora: Aurora;
|
let aurora: Aurora;
|
||||||
|
|
||||||
const autoMode = ref(false);
|
const autoMode = ref(false);
|
||||||
@@ -273,6 +276,14 @@ function toggleOverlay(id: string) {
|
|||||||
activeOverlays.value.add('satellites');
|
activeOverlays.value.add('satellites');
|
||||||
sats.show();
|
sats.show();
|
||||||
}
|
}
|
||||||
|
} else if (id === 'sondes') {
|
||||||
|
if(activeOverlays.value.has('sondes')) {
|
||||||
|
activeOverlays.value.delete('sondes');
|
||||||
|
sondes.hide();
|
||||||
|
} else {
|
||||||
|
activeOverlays.value.add('sondes');
|
||||||
|
sondes.show();
|
||||||
|
}
|
||||||
} else if (id === 'aurora') {
|
} else if (id === 'aurora') {
|
||||||
if (activeOverlays.value.has('aurora')) {
|
if (activeOverlays.value.has('aurora')) {
|
||||||
activeOverlays.value.delete('aurora');
|
activeOverlays.value.delete('aurora');
|
||||||
@@ -669,6 +680,7 @@ onMounted(async () => {
|
|||||||
aisLayer = new AISLayer(map);
|
aisLayer = new AISLayer(map);
|
||||||
airTraffic = new AirTrafficLayer(map);
|
airTraffic = new AirTrafficLayer(map);
|
||||||
sats = new SatsLayer(map);
|
sats = new SatsLayer(map);
|
||||||
|
sondes = new SondesLayer(map);
|
||||||
aurora = new Aurora(map);
|
aurora = new Aurora(map);
|
||||||
range = new RangeLayer(map);
|
range = new RangeLayer(map);
|
||||||
|
|
||||||
@@ -692,6 +704,7 @@ onUnmounted(() => {
|
|||||||
airTraffic?.hide();
|
airTraffic?.hide();
|
||||||
aisLayer?.hide();
|
aisLayer?.hide();
|
||||||
sats?.hide();
|
sats?.hide();
|
||||||
|
sondes?.hide();
|
||||||
aurora?.hide();
|
aurora?.hide();
|
||||||
range?.hide();
|
range?.hide();
|
||||||
});
|
});
|
||||||
|
|||||||
256
client/src/components/WeatherBalloon.vue
Normal file
256
client/src/components/WeatherBalloon.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
history: any[]
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
const latest = computed(() => props.history[props.history.length - 1])
|
||||||
|
|
||||||
|
const altitude = computed(() => latest.value?.altitude != null ? Math.round(latest.value.altitude / 1000) : null)
|
||||||
|
const speed = computed(() => latest.value?.speed != null ? latest.value.speed.toFixed(1) : null)
|
||||||
|
const climb = computed(() => latest.value?.vertical_speed != null ? latest.value.vertical_speed.toFixed(1) : null)
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pos = ref({ ...props.position })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
function onMouseUp() { isDragging.value = false }
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX, sy = touch.clientY
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||||
|
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sonde-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(255,170,0,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,170,0,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.tp-name { margin: 0 24px 0 0; color: #ffaa00; font-size: 16px; }
|
||||||
|
|
||||||
|
.tp-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.tp-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
.tp-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,170,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-c { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.align-x-end { align-items: flex-end; }
|
||||||
|
|
||||||
|
.tp-body { display: flex; gap: 16px; padding: 12px 16px; }
|
||||||
|
|
||||||
|
.tp-gauges { flex: 1; }
|
||||||
|
|
||||||
|
.tp-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; }
|
||||||
|
|
||||||
|
.tp-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #ffaa00;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #ffaa00; }
|
||||||
|
.tp-gauge-unit { font-size: 11px; color: #ffaa00; margin-bottom: 0.75em; }
|
||||||
|
|
||||||
|
.tp-fields {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.tp-fields td { padding: 2px 4px; color: #888; }
|
||||||
|
.tp-fields td:last-child { color: #ccc; text-align: right; }
|
||||||
|
|
||||||
|
.ok { color: #3fdb6d !important; }
|
||||||
|
.bad { color: #e0475a !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sonde-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
|
<div ref="header" class="tp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
|
<h3 class="tp-name">🎈 {{ latest?.id || 'Unknown Sonde' }}</h3>
|
||||||
|
<button class="tp-close" @click.stop="emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-meta">
|
||||||
|
<div class="flex-c">
|
||||||
|
<span>{{ latest?.type || 'Unknown' }}</span>
|
||||||
|
<span>{{ latest?.frequency?.toFixed(3) || '—' }} MHz</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c align-x-end">
|
||||||
|
<span>SNR: {{ latest?.snr ?? '—' }} dB</span>
|
||||||
|
<span>{{ latest?.sats ?? '—' }} sats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-body">
|
||||||
|
<div class="tp-gauges">
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Altitude</div>
|
||||||
|
<div class="tp-gauge">
|
||||||
|
<span class="tp-gauge-val">{{ altitude != null ? altitude + ' km' : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Speed</div>
|
||||||
|
<div class="tp-gauge">
|
||||||
|
<span class="tp-gauge-val">{{ speed ?? '—' }}</span>
|
||||||
|
<span class="tp-gauge-unit">m/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Vertical Speed</div>
|
||||||
|
<div class="tp-gauge">
|
||||||
|
<span class="tp-gauge-val">{{ climb ?? '—' }}</span>
|
||||||
|
<span class="tp-gauge-unit">m/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="tp-fields">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Heading</td>
|
||||||
|
<td>{{ latest?.heading?.toFixed(1) ?? '—' }}°</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Temperature</td>
|
||||||
|
<td>{{ latest?.temperature ?? '—' }} °C</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Humidity</td>
|
||||||
|
<td>{{ latest?.humidity ?? '—' }} %</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Battery</td>
|
||||||
|
<td>{{ latest?.battery ?? '—' }} V</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Frame</td>
|
||||||
|
<td>{{ latest?.frame ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Frequency</td>
|
||||||
|
<td>{{ latest?.frequency_hz ? (latest.frequency_hz / 1000).toFixed(3) : '—' }} MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>PPM</td>
|
||||||
|
<td>{{ latest?.ppm?.toFixed(2) ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Latitude</td>
|
||||||
|
<td>{{ latest?.latitude?.toFixed(5) ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Longitude</td>
|
||||||
|
<td>{{ latest?.longitude?.toFixed(5) ?? '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ export async function getSondes() {
|
|||||||
id,
|
id,
|
||||||
type: t.type,
|
type: t.type,
|
||||||
subtype: t.subtype,
|
subtype: t.subtype,
|
||||||
lat: t.lat,
|
latitude: t.lat,
|
||||||
lon: t.lon,
|
longitude: t.lon,
|
||||||
altitude: t.alt,
|
altitude: t.alt,
|
||||||
heading: t.heading,
|
heading: t.heading,
|
||||||
speed: t.vel_h,
|
speed: t.vel_h,
|
||||||
|
|||||||
Reference in New Issue
Block a user