From 4c7a7fd1f543c9e34fb928f3bbfef3d0f7feed50 Mon Sep 17 00:00:00 2001 From: ztimson Date: Tue, 15 Sep 2026 11:47:29 -0400 Subject: [PATCH] Better AutoMode --- client/src/components/Aircraft.vue | 2 +- client/src/components/MapView.vue | 506 +++++++++-------------------- client/src/services/adsb.ts | 252 ++++++-------- client/src/services/ais.ts | 218 +++++-------- client/src/services/sats.ts | 250 ++++++-------- client/src/services/sondes.ts | 214 +++++------- client/src/services/units.ts | 20 +- client/tsconfig.app.json | 1 + 8 files changed, 530 insertions(+), 933 deletions(-) diff --git a/client/src/components/Aircraft.vue b/client/src/components/Aircraft.vue index de7c161..563982e 100644 --- a/client/src/components/Aircraft.vue +++ b/client/src/components/Aircraft.vue @@ -50,7 +50,7 @@ const description = computed(() => props.plane.desc || [props.plane.manufacturer const operator = computed(() => props.plane.operator || props.plane.owner || '') const altitudeHistory = computed(() => (props.history || []) - .filter(p => !p.live && p.ts != null && p.altitude != null) + .filter(p => !p.live && p.ts != null) .map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))}))) const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value))) diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue index 7294767..60f8221 100644 --- a/client/src/components/MapView.vue +++ b/client/src/components/MapView.vue @@ -63,19 +63,15 @@ const overlayLayers: {[key: string]: any} = {}; const MAP_STATE_KEY = 'situation-center-map-state'; const MAP_LAYERS_KEY = 'situation-center-map-layers'; const AUTO_MODE_KEY = 'situation-center-auto-mode'; +const AUTO_INTERVAL_MS = 15_000; -const AUTO_CHECK_MS = 2_000; -const AUTO_DISTANCE_MARGIN = 0.75; - -const AUTO_CATEGORIES = ['aircraft', 'marine', 'satellites'] as const; - +const AUTO_CATEGORIES = ['sondes', 'aircraft', 'marine', 'satellites'] as const; type AutoCategory = typeof AUTO_CATEGORIES[number]; interface AutoObject { category: AutoCategory; id: string; - discoveredAt: number; - priority: boolean; + score: number; } let map: Map; @@ -92,10 +88,40 @@ let aurora: Aurora; const autoMode = ref(false); const autoCategory = ref(null); const autoTarget = ref(null); - let autoTargetKey: string | null = null; -let autoKnown: Record = {}; -let autoLastKeys = new Set(); + +// Per-category lookup so auto-mode logic never branches on category directly. +const AUTO_CONFIG: Record any; + id: (target: any) => string; + position: (target: any) => [number, number] | null; + zoom: number; +}> = { + sondes: { + layer: () => sondes, + id: t => String(t.id), + position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null, + zoom: 8, + }, + aircraft: { + layer: () => airTraffic, + id: t => String(t.icao), + position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null, + zoom: 10, + }, + marine: { + layer: () => aisLayer, + id: t => String(t.mmsi), + position: t => t.lat != null && t.lon != null ? [t.lon, t.lat] : null, + zoom: 12, + }, + satellites: { + layer: () => sats, + id: t => String(t.id), + position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null, + zoom: 6, + }, +}; function loadMapState() { try { @@ -128,11 +154,7 @@ function saveMapState() { const zoom = view.getZoom(); if (typeof zoom !== 'number') return; - localStorage.setItem(MAP_STATE_KEY, JSON.stringify({ - latitude, - longitude, - zoom, - })); + localStorage.setItem(MAP_STATE_KEY, JSON.stringify({latitude, longitude, zoom})); } catch { // Ignore localStorage failures. } @@ -233,90 +255,54 @@ function buildRainLayer(url: string): TileLayer { }); } +function removeRainLayer() { + if (!overlayLayers['rain']) return; + map.removeLayer(overlayLayers['rain']); + delete overlayLayers['rain']; +} + async function refreshRain() { const url = await fetchRadarUrl(); - if (!url) return; - - if (overlayLayers['rain']) { - map.removeLayer(overlayLayers['rain']); - delete overlayLayers['rain']; - } - - if (!activeOverlays.value.has('rain')) return; + removeRainLayer(); + if (!url || !activeOverlays.value.has('rain')) return; const layer = buildRainLayer(url); overlayLayers['rain'] = layer; map.addLayer(layer); } -function toggleOverlay(id: string) { - if (id === 'aircraft') { - if (activeOverlays.value.has('aircraft')) { - activeOverlays.value.delete('aircraft'); - airTraffic.hide(); - range.hide(); - } else { - activeOverlays.value.add('aircraft'); - airTraffic.show(); - range.show(); - } - } else if (id === 'marine') { - if (activeOverlays.value.has('marine')) { - activeOverlays.value.delete('marine'); - aisLayer.hide(); - } else { - activeOverlays.value.add('marine'); - aisLayer.show(); - } - } else if (id === 'satellites') { - if (activeOverlays.value.has('satellites')) { - activeOverlays.value.delete('satellites'); - sats.hide(); - } else { - activeOverlays.value.add('satellites'); - 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') { - if (activeOverlays.value.has('aurora')) { - activeOverlays.value.delete('aurora'); - aurora.hide(); - } else { - activeOverlays.value.add('aurora'); - aurora.show(); - } - } else if (id === 'wind') { - if (activeOverlays.value.has('wind')) { - activeOverlays.value.delete('wind'); - hideWindOverlay(); - } else { - activeOverlays.value.add('wind'); - showWindOverlay(); - } - } else if (id === 'rain') { - if (activeOverlays.value.has('rain')) { - activeOverlays.value.delete('rain'); +// One handler per overlay id — toggleOverlay/restoreOverlayState just drive these. +const OVERLAY_HANDLERS: Record void; hide: () => void}> = { + aircraft: {show: () => { airTraffic.show(); range.show(); }, hide: () => { airTraffic.hide(); range.hide(); }}, + marine: {show: () => aisLayer.show(), hide: () => aisLayer.hide()}, + satellites: {show: () => sats.show(), hide: () => sats.hide()}, + sondes: {show: () => sondes.show(), hide: () => sondes.hide()}, + aurora: {show: () => aurora.show(), hide: () => aurora.hide()}, + wind: {show: showWindOverlay, hide: hideWindOverlay}, + rain: {show: refreshRain, hide: removeRainLayer}, +}; - if (overlayLayers['rain']) { - map.removeLayer(overlayLayers['rain']); - delete overlayLayers['rain']; - } - } else { - activeOverlays.value.add('rain'); - refreshRain(); - } +function toggleOverlay(id: string) { + const handler = OVERLAY_HANDLERS[id]; + if (!handler) return; + + if (activeOverlays.value.has(id)) { + activeOverlays.value.delete(id); + handler.hide(); + } else { + activeOverlays.value.add(id); + handler.show(); } saveLayers(); } +function restoreOverlayState() { + for (const id of Object.keys(OVERLAY_HANDLERS)) { + activeOverlays.value.has(id) ? (OVERLAY_HANDLERS)[id].show() : (OVERLAY_HANDLERS)[id].hide(); + } +} + function toggleMenu(id: string) { openMenu.value = openMenu.value === id ? null : id; } @@ -336,11 +322,7 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer const ring = new Feature(new CircleGeom(center, nm * NM)); ring.setStyle(new Style({ - stroke: new Stroke({ - color: ringStroke, - width: 1, - lineDash: [6, 4], - }), + stroke: new Stroke({color: ringStroke, width: 1, lineDash: [6, 4]}), fill: new Fill({color: 'transparent'}), })); @@ -353,10 +335,7 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer image: new CircleStyle({ radius: 7, fill: new Fill({color: dark ? '#ffffff' : '#000000'}), - stroke: new Stroke({ - color: dark ? '#000000' : '#ffffff', - width: 2, - }), + stroke: new Stroke({color: dark ? '#000000' : '#ffffff', width: 2}), }), })); @@ -365,95 +344,91 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer return new VectorLayer({source, zIndex: 20}); } -function restoreOverlayState() { - if (activeOverlays.value.has('aircraft')) { - airTraffic.show(); - range.show(); - } else { - airTraffic.hide(); - range.hide(); +function getAutoObjects(): AutoObject[] { + const objects: AutoObject[] = []; + + for (const category of AUTO_CATEGORIES) { + if (!activeOverlays.value.has(category)) continue; + + const config = AUTO_CONFIG[category]; + const layer = config.layer(); + if (!layer.visible) continue; + + for (const target of layer.getCandidates(current.value.latitude, current.value.longitude)) { + objects.push({category, id: config.id(target), score: target.score}); + } } - if (activeOverlays.value.has('marine')) aisLayer.show(); - else aisLayer.hide(); - - if (activeOverlays.value.has('satellites')) sats.show(); - else sats.hide(); - - if (activeOverlays.value.has('sondes')) sondes.show(); - else sondes.hide(); - - if (activeOverlays.value.has('aurora')) aurora.show(); - else aurora.hide(); - - if (activeOverlays.value.has('wind')) showWindOverlay(); - if (activeOverlays.value.has('rain')) refreshRain(); + return objects; } -function getAutoLayer(category: AutoCategory): any { - if (category === 'aircraft') return airTraffic; - if (category === 'marine') return aisLayer; - return sats; -} - -function getAutoId(category: AutoCategory, target: any): string { - if (category === 'aircraft') return String(target.icao); - if (category === 'marine') return String(target.mmsi); - return String(target.satellite); -} - -function getAutoTarget(category: AutoCategory, id: any) { - if (category === 'aircraft') return airTraffic.getById(id); - if (category === 'marine') return aisLayer.getById(id); - return sats.getById(id); -} - -function getAutoPosition(category: AutoCategory, target: any): [number, number] | null { - if (category === 'aircraft') return target.latitude != null && target.longitude != null - ? [target.longitude, target.latitude] - : null; - - if (category === 'marine') return target.lat != null && target.lon != null - ? [target.lon, target.lat] - : null; - - return target.latitude != null && target.longitude != null - ? [target.longitude, target.latitude] - : null; -} - -function getAutoZoom(category: AutoCategory): number { - if (category === 'aircraft') return 10; - if (category === 'marine') return 12; - return 6; -} - -function focusAutoTarget(category: AutoCategory, target: any, animate = true) { - const position = getAutoPosition(category, target); +function focusAutoTarget(category: AutoCategory, target: any) { + const config = AUTO_CONFIG[category]; + const position = config.position(target); if (!position) return; - const center = fromLonLat(position); - - map.getView().animate({ - center, - zoom: getAutoZoom(category), - duration: animate ? 900 : 0, - }); -} - -function closeAutoPopup() { - if (!autoCategory.value || !autoTarget.value) return; - - getAutoLayer(autoCategory.value).closeAutoPopup(getAutoId(autoCategory.value, autoTarget.value)); + map.getView().animate({center: fromLonLat(position), zoom: config.zoom, duration: 900}); } function clearAutoTarget() { - closeAutoPopup(); + if (autoCategory.value && autoTarget.value) { + const config = AUTO_CONFIG[autoCategory.value]; + config.layer().closePopup(config.id(autoTarget.value)); + } + autoCategory.value = null; autoTarget.value = null; autoTargetKey = null; } +function setAutoTarget(object: AutoObject) { + const config = AUTO_CONFIG[object.category]; + const target = config.layer().getById(object.id); + if (!target) return; + + clearAutoTarget(); + + autoCategory.value = object.category; + autoTarget.value = target; + autoTargetKey = `${object.category}:${object.id}`; + + config.layer().openPopup(target); + focusAutoTarget(object.category, target); +} + +function pickBestAndFocus() { + if (!autoMode.value) return; + + const objects = getAutoObjects(); + if (!objects.length) return clearAutoTarget(); + + const best: any = objects.sort((a, b) => b.score - a.score)[0]; + const bestKey = `${best.category}:${best.id}`; + + if (bestKey === autoTargetKey) { + const fresh = AUTO_CONFIG[best.category].layer().getById(best.id); + if (!fresh) return clearAutoTarget(); + + autoTarget.value = fresh; + focusAutoTarget(best.category, fresh); + return; + } + + setAutoTarget(best); +} + +function startAutoMode() { + if (autoMode.value) return; + + autoMode.value = true; + saveAutoMode(); + + clearAutoTarget(); + pickBestAndFocus(); + + autoInterval = setInterval(pickBestAndFocus, AUTO_INTERVAL_MS); +} + function stopAutoMode() { if (autoInterval) { clearInterval(autoInterval); @@ -461,174 +436,12 @@ function stopAutoMode() { } clearAutoTarget(); - autoMode.value = false; - autoKnown = {}; - autoLastKeys = new Set(); - saveAutoMode(); } -function getAutoObjects(): AutoObject[] { - const objects: AutoObject[] = []; - - for (const category of AUTO_CATEGORIES) { - if (!activeOverlays.value.has(category)) continue; - - const layer = getAutoLayer(category); - if (!layer.visible) continue; - - const targets = layer.getAutoCandidates( - current.value.latitude, - current.value.longitude, - ); - - for (const target of targets) { - const id = getAutoId(category, target); - const key = `${category}:${id}`; - const priority = category !== 'satellites' && layer.isPriority(target); - - if (!autoKnown[key]) { - autoKnown[key] = { - category, - id, - discoveredAt: Date.now(), - priority, - }; - } else { - autoKnown[key].priority = priority; - } - - objects.push(autoKnown[key]); - } - } - - return objects; -} - -function getAutoDistance(object: AutoObject): number { - const target = getAutoTarget(object.category, object.id); - if (!target) return Infinity; - - return getAutoLayer(object.category).getAutoDistance( - current.value.latitude, - current.value.longitude, - target, - ); -} - -function getNewest(objects: AutoObject[]): AutoObject | null { - return objects.slice().sort((a, b) => b.discoveredAt - a.discoveredAt)[0] ?? null; -} - -function getClosest(objects: AutoObject[]): AutoObject | null { - return objects.slice().sort((a, b) => getAutoDistance(a) - getAutoDistance(b))[0] ?? null; -} - -function setAutoTarget(object: AutoObject) { - const target = getAutoTarget(object.category, object.id); - if (!target) return; - - clearAutoTarget(); - - autoCategory.value = object.category; - autoTarget.value = target; - autoTargetKey = `${object.category}:${object.id}`; - - getAutoLayer(object.category).openAutoPopup(target); - focusAutoTarget(object.category, target); -} - -function shouldNewTargetSteal(currentObject: AutoObject, newObject: AutoObject): boolean { - const currentDistance = getAutoDistance(currentObject); - const newDistance = getAutoDistance(newObject); - if (!isFinite(currentDistance)) return true; - if (!isFinite(newDistance)) return false; - return newDistance <= currentDistance * AUTO_DISTANCE_MARGIN; -} - -function reconcileAutoMode() { - if (!autoMode.value) return; - - const objects = getAutoObjects(); - const currentKeys = new Set(objects.map(o => `${o.category}:${o.id}`)); - const discovered = objects.filter(o => !autoLastKeys.has(`${o.category}:${o.id}`)); - - for (const key of Object.keys(autoKnown)) { - if (!currentKeys.has(key)) delete autoKnown[key]; - } - - autoLastKeys = currentKeys; - - const military = objects - .filter(o => o.priority) - .sort((a, b) => getAutoDistance(a) - getAutoDistance(b)); - - if (military.length) { - const best: any = military[0]; - const key = `${best.category}:${best.id}`; - - if (key !== autoTargetKey) setAutoTarget(best); - return; - } - - if (autoTargetKey && currentKeys.has(autoTargetKey)) { - const currentObject = autoKnown[autoTargetKey]; - - if (!currentObject) return; - if (!discovered.length) return; - - const newest = getNewest(discovered); - if (!newest) return; - if (`${newest.category}:${newest.id}` === autoTargetKey) return; - - if (shouldNewTargetSteal(currentObject, newest)) setAutoTarget(newest); - - return; - } - - if (objects.length) { - const newest = getNewest(objects); - if (newest) setAutoTarget(newest); - } -} - -function startAutoMode() { - if (autoMode.value) return; - - autoMode.value = true; - saveAutoMode(); - - autoKnown = {}; - autoLastKeys = new Set(); - clearAutoTarget(); - - reconcileAutoMode(); - - autoInterval = setInterval(() => { - reconcileAutoMode(); - - if (!autoCategory.value || !autoTarget.value) return; - - const fresh = getAutoTarget( - autoCategory.value, - getAutoId(autoCategory.value, autoTarget.value), - ); - - if (!fresh) { - clearAutoTarget(); - reconcileAutoMode(); - return; - } - - autoTarget.value = fresh; - focusAutoTarget(autoCategory.value, fresh); - }, AUTO_CHECK_MS); -} - function toggleAutoMode() { - if (autoMode.value) stopAutoMode(); - else startAutoMode(); + autoMode.value ? stopAutoMode() : startAutoMode(); } onMounted(async () => { @@ -642,11 +455,7 @@ onMounted(async () => { longitude: position?.longitude || 0, }; - stationLayer = buildStationLayer( - current.value.latitude, - current.value.longitude, - props.dark, - ); + stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark); const initialLatitude = savedMapState?.latitude ?? current.value.latitude; const initialLongitude = savedMapState?.longitude ?? current.value.longitude; @@ -654,14 +463,8 @@ onMounted(async () => { map = new Map({ target: mapEl.value!, - layers: [ - buildBaseLayer(props.dark), - stationLayer, - ], - interactions: defaultInteractions({ - altShiftDragRotate: false, - pinchRotate: false, - }), + layers: [buildBaseLayer(props.dark), stationLayer], + interactions: defaultInteractions({altShiftDragRotate: false, pinchRotate: false}), view: new View({ center: fromLonLat([initialLongitude, initialLatitude]), zoom: initialZoom, @@ -683,11 +486,7 @@ onMounted(async () => { radarInterval = setInterval(refreshRain, 5 * 60 * 1000); - if (loadAutoMode()) { - setTimeout(() => { - if (!autoMode.value) startAutoMode(); - }, 1_000); - } + if (loadAutoMode()) setTimeout(() => { if (!autoMode.value) startAutoMode(); }, 1_000); }); onUnmounted(() => { @@ -710,12 +509,7 @@ watch(() => props.dark, dark => { map.getLayers().setAt(0, buildBaseLayer(dark)); map.removeLayer(stationLayer); - stationLayer = buildStationLayer( - current.value.latitude || 0, - current.value.longitude || 0, - dark, - ); - + stationLayer = buildStationLayer(current.value.latitude || 0, current.value.longitude || 0, dark); map.addLayer(stationLayer); }); diff --git a/client/src/services/adsb.ts b/client/src/services/adsb.ts index d446566..ac9df92 100644 --- a/client/src/services/adsb.ts +++ b/client/src/services/adsb.ts @@ -1,4 +1,5 @@ import {BASE} from '@/services/api.ts'; +import {greatCircleDist} from '@/services/units.ts'; import {h, render as vueRender} from 'vue'; import Map from 'ol/Map'; import {Feature} from 'ol'; @@ -8,21 +9,13 @@ import {fromLonLat} from 'ol/proj'; import {Style, Icon, Stroke} from 'ol/style'; import {Vector as VectorLayer} from 'ol/layer'; import {Vector as VectorSource} from 'ol/source'; -import {adjustedInterval} from '@ztimson/utils'; +import {adjustedInterval, sortByProp} from '@ztimson/utils'; import AircraftPopup from '@/components/Aircraft.vue'; import {bringToFront} from './zindex'; const API = BASE + '/api'; -const EARTH_R = 6371; const AUTO_RANGE = 92.6; -function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number { - const toRad = (d: number) => d * Math.PI / 180; - const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - function isPriorityAircraft(plane: any): boolean { if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true; @@ -75,112 +68,22 @@ export class AirTrafficLayer { this.map = map; } - async show() { - if (this.visible) return; + private _attachClick() { + this.clickHandler = evt => { + const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null); + if (!f) return; - this.visible = true; - this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100}); - this.map.addLayer(this.layer); + const plane = f.get('planeData'); - await this._fetch(); - this._draw(); - this._attachClick(); - - this.refreshTimer = adjustedInterval(async () => { - try { - await this._fetch(); - this._draw(); - this._refreshPopups(); - } catch (err) { - console.error(err); + if (this.popups[plane.icao]) { + this.closePopup(plane.icao); + return; } - }, 1_000); - } - hide() { - if (!this.visible) return; + this.openPopup(plane); + }; - 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 icao of Object.keys(this.popups)) this._closePopup(icao); - if (this.layer) this.map.removeLayer(this.layer); - Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l)); - - this.traceLayers = {}; - this.traceSegs = {}; - } - - getClosest(latitude: number, longitude: number): any | null { - let closest: any = null; - let closestDistance = Infinity; - - for (const plane of this.data) { - if (plane.latitude == null || plane.longitude == null) continue; - - const distance = greatCircleDist(latitude, longitude, plane.latitude, plane.longitude); - if (distance < closestDistance) { - closestDistance = distance; - closest = plane; - } - } - - return closest; - } - - getAutoCandidates(latitude: number, longitude: number, limit = Infinity): any[] { - return this.data.filter(plane => { - if (plane.latitude == null || plane.longitude == null) return false; - return greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) <= AUTO_RANGE; - }).sort((a, b) => { - const priority = Number(isPriorityAircraft(b)) - Number(isPriorityAircraft(a)); - if (priority) return priority; - - return greatCircleDist(latitude, longitude, a.latitude, a.longitude) - greatCircleDist(latitude, longitude, b.latitude, b.longitude); - }).slice(0, limit); - } - - isPriority(plane: any): boolean { - return isPriorityAircraft(plane); - } - - getAutoDistance(latitude: number, longitude: number, plane: any): number { - return greatCircleDist(latitude, longitude, plane.latitude, plane.longitude); - } - - getById(icao: string): any | null { - return this.data.find(p => String(p.icao) === String(icao)) ?? null; - } - - openAutoPopup(plane: any) { - this._openPopup(plane); - } - - closeAutoPopup(icao: string) { - this._closePopup(String(icao)); - } - - private async _fetch() { - const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []); - this.data = (j || []).map((a: any) => ({ - ...a, - icao: a.hex, - latitude: a.lat, - longitude: a.lon, - heading: a.track, - speed: a.gs, - climb: a.baro_rate ?? a.geom_rate ?? 0, - name: a.flight?.trim(), - })); + this.map.on('singleclick', this.clickHandler); } private _draw() { @@ -208,6 +111,21 @@ export class AirTrafficLayer { } } + private async _fetch() { + const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []); + + this.data = (j || []).map((a: any) => ({ + ...a, + icao: a.hex, + latitude: a.lat, + longitude: a.lon, + heading: a.track, + speed: a.gs, + climb: a.baro_rate ?? a.geom_rate ?? 0, + name: a.flight?.trim(), + })); + } + private async _fetchTrace(plane: any) { const icao = plane.icao; @@ -229,6 +147,7 @@ export class AirTrafficLayer { if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur); const traceHistory = history; + if (traceHistory.length < 2) { const popup = this.popups[icao]; if (popup) popup.update(plane, history); @@ -275,9 +194,76 @@ export class AirTrafficLayer { if (popup) popup.update(plane, history); } - private _openPopup(plane: any) { + private _refreshPopups() { + for (const icao of Object.keys(this.popups)) { + const plane = this.data.find(p => p.icao === icao); + if (!plane) { + this.closePopup(icao); + continue; + } + this._fetchTrace(plane); + } + } + + 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 icao of Object.keys(this.popups)) this.closePopup(icao); + if(this.layer) this.map.removeLayer(this.layer); + Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l)); + this.traceLayers = {}; + this.traceSegs = {}; + } + + async show() { + if(this.visible) return; + + this.visible = true; + this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100}); + 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); + } + }, 1_000); + } + + getById(icao: string): any | null { + return this.data.find(p => String(p.icao) === String(icao)) ?? null; + } + + getCandidates(latitude: number, longitude: number, limit = Infinity): any[] { + function score(plane: any): number { + const ratio = Math.min(greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) / AUTO_RANGE, 1); + return isPriorityAircraft(plane) ? 2 - ratio : 1 - ratio; + } + + return this.data.map(plane => ({...plane, score: score(plane)})).toSorted(sortByProp('score', true)); + } + + openPopup(plane: any) { const icao = String(plane.icao); - if (this.popups[icao]) return; + if(this.popups[icao]) return; const container = document.createElement('div'); document.body.appendChild(container); @@ -289,7 +275,7 @@ export class AirTrafficLayer { position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: 16, y: 16}, - onClose: () => this._closePopup(icao), + onClose: () => this.closePopup(icao), onBringToFront: () => bringToFront(container), }); @@ -306,52 +292,20 @@ export class AirTrafficLayer { this._fetchTrace(plane); } - private _closePopup(icao: string) { + closePopup(icao: string) { const popup = this.popups[icao]; - - if (popup) { + if(popup) { popup.unmount(); delete this.popups[icao]; } const segs = this.traceSegs[icao], layer = this.traceLayers[icao]; - if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f)); - - if (layer) { + if(segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f)); + if(layer) { this.map.removeLayer(layer); delete this.traceLayers[icao]; } delete this.traceSegs[icao]; } - - private _refreshPopups() { - for (const icao of Object.keys(this.popups)) { - const plane = this.data.find(p => p.icao === icao); - - if (!plane) { - this._closePopup(icao); - continue; - } - - this._fetchTrace(plane); - } - } - - private _attachClick() { - this.clickHandler = evt => { - const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null); - if (!f) return; - - const plane = f.get('planeData'); - if (this.popups[plane.icao]) { - this._closePopup(plane.icao); - return; - } - - this._openPopup(plane); - }; - - this.map.on('singleclick', this.clickHandler); - } } diff --git a/client/src/services/ais.ts b/client/src/services/ais.ts index 1487420..96de794 100644 --- a/client/src/services/ais.ts +++ b/client/src/services/ais.ts @@ -1,4 +1,5 @@ import {BASE} from '@/services/api.ts'; +import {greatCircleDist} from '@/services/units.ts'; import {h, render as vueRender} from 'vue'; import Map from 'ol/Map'; import {Feature} from 'ol'; @@ -7,12 +8,11 @@ import {fromLonLat} from 'ol/proj'; import {Style, Icon} from 'ol/style'; import {Vector as VectorLayer} from 'ol/layer'; import {Vector as VectorSource} from 'ol/source'; -import {adjustedInterval} from '@ztimson/utils'; +import {adjustedInterval, sortByProp} from '@ztimson/utils'; import ShipPopup from '@/components/Ship.vue'; import {bringToFront} from './zindex'; const API = BASE + '/api'; -const EARTH_R = 6371; const AUTO_RANGE = 100; const SHIP_COLORS: Record = { @@ -26,13 +26,6 @@ const SHIP_COLORS: Record = { 'Unknown': '#fad106', }; -function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number { - const toRad = (d: number) => d * Math.PI / 180; - const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - function isPriorityBoat(boat: any): boolean { if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true; @@ -90,98 +83,22 @@ export class AISLayer { this.map = map; } - async show() { - if (this.visible) return; + private _attachClick() { + this.clickHandler = evt => { + const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null); + if (!f) return; - this.visible = true; - this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100}); - this.map.addLayer(this.layer); + const boat = f.get('boatData'); - await this._fetch(); - this._draw(); - this._attachClick(); - - this.refreshTimer = adjustedInterval(async () => { - try { - await this._fetch(); - this._draw(); - this._refreshPopups(); - } catch (err) { - console.error(err); + if (this.popups[boat.mmsi]) { + this.closePopup(boat.mmsi); + return; } - }, 5_000); - } - hide() { - if (!this.visible) return; + this.openPopup(boat); + }; - 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 mmsi of Object.keys(this.popups)) this._closePopup(mmsi); - if (this.layer) this.map.removeLayer(this.layer); - } - - getClosest(latitude: number, longitude: number): any | null { - let closest: any = null; - let closestDistance = Infinity; - - for (const boat of this.data) { - if (boat.lat == null || boat.lon == null) continue; - - const distance = greatCircleDist(latitude, longitude, boat.lat, boat.lon); - if (distance < closestDistance) { - closestDistance = distance; - closest = boat; - } - } - - return closest; - } - - getAutoCandidates(latitude: number, longitude: number, limit = Infinity): any[] { - return this.data.filter(boat => { - if (boat.lat == null || boat.lon == null) return false; - return greatCircleDist(latitude, longitude, boat.lat, boat.lon) <= AUTO_RANGE; - }).sort((a, b) => { - const priority = Number(isPriorityBoat(b)) - Number(isPriorityBoat(a)); - if (priority) return priority; - - return greatCircleDist(latitude, longitude, a.lat, a.lon) - greatCircleDist(latitude, longitude, b.lat, b.lon); - }).slice(0, limit); - } - - isPriority(boat: any): boolean { - return isPriorityBoat(boat); - } - - getAutoDistance(latitude: number, longitude: number, boat: any): number { - return greatCircleDist(latitude, longitude, boat.lat, boat.lon); - } - - getById(mmsi: string): any | null { - return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null; - } - - openAutoPopup(boat: any) { - this._openPopup(boat); - } - - closeAutoPopup(mmsi: string) { - this._closePopup(String(mmsi)); - } - - private async _fetch() { - this.data = await fetch(`${API}/ais`).then(r => r.json()) || []; + this.map.on('singleclick', this.clickHandler); } private _draw() { @@ -209,7 +126,77 @@ export class AISLayer { } } - private _openPopup(boat: any) { + private async _fetch() { + this.data = await fetch(`${API}/ais`).then(r => r.json()) || []; + } + + private _refreshPopups() { + for (const mmsi of Object.keys(this.popups)) { + const boat = this.data.find(b => String(b.mmsi) === mmsi); + if (!boat) { + this.closePopup(mmsi); + continue; + } + this.popups[mmsi].update(boat); + } + } + + 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 mmsi of Object.keys(this.popups)) this.closePopup(mmsi); + if (this.layer) this.map.removeLayer(this.layer); + } + + async show() { + if (this.visible) return; + + this.visible = true; + this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100}); + 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); + } + + getById(mmsi: string): any | null { + return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null; + } + + getCandidates(latitude: number, longitude: number, limit = Infinity): any[] { + function score(boat: any): number { + const ratio = Math.min(greatCircleDist(latitude, longitude, boat.lat, boat.lon) / AUTO_RANGE, 1); + return isPriorityBoat(boat) ? 2 - ratio : 1 - ratio; + } + + return this.data.filter(b => b.lat != null && b.lon != null) + .map(boat => ({...boat, score: score(boat)})) + .toSorted(sortByProp('score', true)); + } + + openPopup(boat: any) { const mmsi = String(boat.mmsi); if (this.popups[mmsi]) return; @@ -222,7 +209,7 @@ export class AISLayer { position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: 16, y: 16}, - onClose: () => this._closePopup(mmsi), + onClose: () => this.closePopup(mmsi), onBringToFront: () => bringToFront(container), }); @@ -237,44 +224,11 @@ export class AISLayer { }; } - private _closePopup(mmsi: string) { + closePopup(mmsi: string) { const popup = this.popups[mmsi]; - if (popup) { popup.unmount(); delete this.popups[mmsi]; } } - - private _refreshPopups() { - for (const mmsi of Object.keys(this.popups)) { - const boat = this.data.find(b => String(b.mmsi) === mmsi); - - if (!boat) { - this._closePopup(mmsi); - continue; - } - - this.popups[mmsi].update(boat); - } - } - - private _attachClick() { - this.clickHandler = evt => { - const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null); - if (!f) return; - - const boat = f.get('boatData'); - const mmsi = String(boat.mmsi); - - if (this.popups[mmsi]) { - this._closePopup(mmsi); - return; - } - - this._openPopup(boat); - }; - - this.map.on('singleclick', this.clickHandler); - } } diff --git a/client/src/services/sats.ts b/client/src/services/sats.ts index 19804fe..e650cda 100644 --- a/client/src/services/sats.ts +++ b/client/src/services/sats.ts @@ -1,4 +1,5 @@ import {api, BASE} from '@/services/api.ts'; +import {greatCircleDist} from '@/services/units.ts'; import {h, render as vueRender} from 'vue'; import Map from 'ol/Map'; import {Feature} from 'ol'; @@ -7,13 +8,12 @@ import {fromLonLat} from 'ol/proj'; import {Style, Stroke, Icon, Fill} from 'ol/style'; import {Vector as VectorLayer} from 'ol/layer'; import {Vector as VectorSource} from 'ol/source'; -import {adjustedInterval} from '@ztimson/utils'; -import TinyGSPopup from '@/components/Satellite.vue'; +import {adjustedInterval, sortByProp} from '@ztimson/utils'; +import Satellite from '@/components/Satellite.vue'; import {bringToFront} from './zindex'; const API = BASE + '/api'; const EARTH_R = 6371; -const AUTO_ELEVATION = 70; interface TinyGSReading { id: number; @@ -41,13 +41,6 @@ interface RangeEstimate { groundDist: number; } -function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number { - const toRad = (d: number) => d * Math.PI / 180; - const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1); - const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null { const d = greatCircleDist(gsLat, gsLon, satLat, satLon); const gamma = d / EARTH_R; @@ -101,119 +94,18 @@ export class SatsLayer { api.position().then(gs => this.gs = gs); } - async show() { - if (this.visible) return; + private _attachClick() { + this.clickHandler = evt => { + const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null); + if (!f) return; - this.visible = true; - this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105}); - this.map.addLayer(this.layer); + const sat = f.get('satData') as TinyGSSatellite; - await this._fetch(); - this._draw(); - this._attachClick(); + if (this.popups[sat.id]) return this.closePopup(sat.id); + this.openPopup(sat); + }; - 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 name of Object.keys(this.popups).map(Number)) this._closePopup(name); - if (this.layer) this.map.removeLayer(this.layer); - } - - getClosest(lat: number, lng: number): TinyGSSatellite | null { - if (!this.data.length) return null; - - let closest: TinyGSSatellite | null = null; - let closestDistance = Infinity; - - for (const sat of this.data) { - if (sat.latitude == null || sat.longitude == null) continue; - - const range = this._calcRange(sat); - - if (range && range.slantRange < closestDistance) { - closestDistance = range.slantRange; - closest = sat; - } - } - - if (closest || !this.gs) return closest; - - closestDistance = Infinity; - - for (const sat of this.data) { - const distance = greatCircleDist(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude); - - if (distance < closestDistance) { - closestDistance = distance; - closest = sat; - } - } - - return closest; - } - - getAutoCandidates(lat: number, lng: number, limit = Infinity): TinyGSSatellite[] { - return this.data.filter(sat => { - if (sat.latitude == null || sat.longitude == null) return false; - return sat.el >= AUTO_ELEVATION; - }).sort((a, b) => { - const ar = this._calcRange(a), br = this._calcRange(b); - const ad = ar?.slantRange ?? greatCircleDist(lat, lng, a.latitude, a.longitude); - const bd = br?.slantRange ?? greatCircleDist(lat, lng, b.latitude, b.longitude); - - return ad - bd; - }).slice(0, limit); - } - - isPriority(sat: TinyGSSatellite): boolean { - return false; - } - - getAutoDistance(lat: number, lng: number, sat: TinyGSSatellite): number { - return this._calcRange(sat)?.slantRange ?? greatCircleDist(lat, lng, sat.latitude, sat.longitude); - } - - getRange(sat: TinyGSSatellite): number | null { - return this._calcRange(sat)?.slantRange ?? null; - } - - getById(id: number): TinyGSSatellite | null { - return this.data.find(s => s.id === id) ?? null; - } - - openAutoPopup(sat: TinyGSSatellite) { - this._openPopup(sat); - } - - closeAutoPopup(id: number) { - this._closePopup(id); - } - - private async _fetch() { - this.data = await fetch(`${API}/sats`).then(r => r.json()) || []; + this.map.on('singleclick', this.clickHandler); } private _draw() { @@ -253,6 +145,10 @@ export class SatsLayer { } } + private async _fetch() { + this.data = await fetch(`${API}/sats`).then(r => r.json()) || []; + } + private _calcRange(sat: TinyGSSatellite): RangeEstimate | null { if (!this.gs) return null; return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el); @@ -285,14 +181,85 @@ export class SatsLayer { private _removeReceptionCircle(id: number) { const layer = this.receptionCircles[id]; - if (layer) { this.map.removeLayer(layer); delete this.receptionCircles[id]; } } - private _openPopup(sat: TinyGSSatellite) { + private _refreshPopups() { + for (const id of Object.keys(this.popups).map(Number)) { + const sat = this.data.find(s => s.id === id); + if (!sat) { + this.closePopup(id); + continue; + } + + const history = [...sat.history, sat]; + const range = this._calcRange(sat); + + this.popups[id].update(history, range, estimateEtaOut(history)); + this._drawReceptionCircle(sat, range?.horizonRadius); + } + } + + 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).map(Number)) this.closePopup(id); + if (this.layer) this.map.removeLayer(this.layer); + } + + 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); + } + + getById(id: number): TinyGSSatellite | null { + return this.data.find(s => s.id === id) ?? null; + } + + getCandidates(latitude: number, longitude: number, limit = Infinity): TinyGSSatellite[] { + const dist = (sat: TinyGSSatellite) => this._calcRange(sat)?.slantRange ?? greatCircleDist(latitude, longitude, sat.latitude, sat.longitude); + + return this.data.filter(s => s.latitude != null && s.longitude != null) + .map(sat => ({...sat, score: -dist(sat)})) + .toSorted(sortByProp('score', true)); + } + + getRange(sat: TinyGSSatellite): number | null { + return this._calcRange(sat)?.slantRange ?? null; + } + + openPopup(sat: TinyGSSatellite) { if (this.popups[sat.id]) return; const history = [...sat.history, sat]; @@ -312,15 +279,14 @@ export class SatsLayer { position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: 16, y: 16}, - onClose: () => this._closePopup(sat.id), + onClose: () => this.closePopup(sat.id), onBringToFront: () => bringToFront(container), }); - vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container); + vueRender(h(Satellite, makeProps(history, range, eta)), container); this.popups[sat.id] = { - container, - update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container), + update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(Satellite, makeProps(h_, r, e)), container), unmount: () => { vueRender(null, container); container.remove(); @@ -328,39 +294,13 @@ export class SatsLayer { }; } - private _closePopup(id: number) { + closePopup(id: number) { const popup = this.popups[id]; - if(popup) { + if (popup) { popup.unmount(); delete this.popups[id]; } + this._removeReceptionCircle(id); } - - private _refreshPopups() { - for (const name of Object.keys(this.popups).map(Number)) { - const sat = this.data.find(s => s.id == name); - if (!sat) { - this._closePopup(name); - continue; - } - - const history = [...sat.history, sat]; - const range = this._calcRange(sat); - this.popups[name].update(history, range, estimateEtaOut(history),); - this._drawReceptionCircle(sat, range?.horizonRadius); - } - } - - private _attachClick() { - this.clickHandler = evt => { - const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null); - if (!f) return; - const sat = f.get('satData') as TinyGSSatellite; - if(this.popups[sat.id]) return this._closePopup(sat.id); - this._openPopup(sat); - }; - - this.map.on('singleclick', this.clickHandler); - } } diff --git a/client/src/services/sondes.ts b/client/src/services/sondes.ts index 1903243..7073570 100644 --- a/client/src/services/sondes.ts +++ b/client/src/services/sondes.ts @@ -1,4 +1,5 @@ -import {api, BASE} from '@/services/api.ts'; +import {BASE} from '@/services/api.ts'; +import {greatCircleDist} from '@/services/units.ts'; import {h, render as vueRender} from 'vue'; import Map from 'ol/Map'; import {Feature} from 'ol'; @@ -7,7 +8,7 @@ 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 {adjustedInterval, sortByProp} from '@ztimson/utils'; import SondePopup from '@/components/WeatherBalloon.vue'; import {bringToFront} from './zindex'; @@ -52,99 +53,22 @@ export class SondesLayer { this.map = map; } - async show() { - if (this.visible) return; + private _attachClick() { + this.clickHandler = evt => { + const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null); + if (!f) return; - this.visible = true; - this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105}); - this.map.addLayer(this.layer); + const sonde = f.get('sondeData') as Sonde; - await this._fetch(); - this._draw(); - this._attachClick(); - - this.refreshTimer = adjustedInterval(async () => { - try { - await this._fetch(); - this._draw(); - this._refreshPopups(); - } catch (err) { - console.error(err); + if (this.popups[sonde.id]) { + this.closePopup(sonde.id); + return; } - }, 5_000); - } - hide() { - if (!this.visible) return; + this.openPopup(sonde); + }; - 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()) || []; + this.map.on('singleclick', this.clickHandler); } private _draw() { @@ -203,17 +127,72 @@ export class SondesLayer { return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; } - 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 async _fetch() { + this.data = await fetch(`${API}/sondes`).then(r => r.json()) || []; } - private _openPopup(sonde: Sonde) { + 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]); + } + } + + 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); + } + + 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); + } + + getById(id: string): Sonde | null { + return this.data.find(s => s.id === id) ?? null; + } + + getCandidates(latitude: number, longitude: number, limit = Infinity): Sonde[] { + return this.data.filter(s => s.latitude != null && s.longitude != null) + .map(sonde => ({...sonde, score: -greatCircleDist(latitude, longitude, sonde.latitude, sonde.longitude)})) + .toSorted(sortByProp('score', true)); + } + + openPopup(sonde: Sonde) { if (this.popups[sonde.id]) return; const history = [...sonde.history, sonde]; @@ -227,14 +206,13 @@ export class SondesLayer { position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: 16, y: 16}, - onClose: () => this._closePopup(sonde.id), + 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); @@ -243,43 +221,11 @@ export class SondesLayer { }; } - private _closePopup(id: string) { + 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); - } } diff --git a/client/src/services/units.ts b/client/src/services/units.ts index db7be31..3089fdf 100644 --- a/client/src/services/units.ts +++ b/client/src/services/units.ts @@ -62,6 +62,14 @@ export const METRICS: Record = { forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' }, } +export function formatValue(key: string, value: number | string | null): string { + if (value === null || value === undefined) return '—' + const meta = METRICS[key] + if (!meta) return String(value) + if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}` + return `${value}${meta.unit}` +} + export function getPressureTrend(delta: number) { if (delta > 0.5) return 'Rising'; if (delta < -0.5) return 'Falling'; @@ -76,12 +84,12 @@ export function getUVLabel(uv: number) { return 'Extreme'; } -export function formatValue(key: string, value: number | string | null): string { - if (value === null || value === undefined) return '—' - const meta = METRICS[key] - if (!meta) return String(value) - if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}` - return `${value}${meta.unit}` +const EARTH_R = 6371; +export function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number { + const toRad = (d: number) => d * Math.PI / 180; + const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1); + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))] diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json index de65fda..e28db47 100644 --- a/client/tsconfig.app.json +++ b/client/tsconfig.app.json @@ -7,6 +7,7 @@ "noUncheckedIndexedAccess": true, "skipLibCheck": true, "noImplicitAny": false, + "lib": ["DOM","ESNext"], // Path mapping for cleaner imports. "paths": {