diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue index 473cec1..1fd4022 100644 --- a/client/src/components/MapView.vue +++ b/client/src/components/MapView.vue @@ -3,6 +3,7 @@ import { AirTrafficLayer } from '@/services/adsb.ts' import { AISLayer } from '@/services/ais.ts' import { api } from '@/services/api.ts' import { RangeLayer } from '@/services/range.ts' +import {SatsLayer} from '@/services/sats.ts'; import VectorTileLayer from 'ol/layer/VectorTile' import { onMounted, onUnmounted, ref, watch } from 'vue' import Map from 'ol/Map' @@ -24,7 +25,6 @@ const current = ref({ latitude: 0, longitude: 0 }) const props = defineProps<{ dark: boolean }>() const mapEl = ref() const showOverlays = ref(false) -const atActive = ref(true) const NM = 1852 @@ -38,7 +38,7 @@ const OVERLAYS = [ { id: 'wind', label: 'Wind', icon: '💨' }, ] -const activeOverlays = ref>(new Set(['traffic', 'rain'])) +const activeOverlays = ref>(new Set(['traffic', 'sats', 'rain'])) const overlayLayers: { [key: string]: any } = {} let map: Map @@ -47,6 +47,7 @@ let radarInterval: ReturnType let airTraffic: AirTrafficLayer let aisLayer: AISLayer let range: RangeLayer +let sats: SatsLayer function buildWindSrc(lat: number, lon: number, zoom: number) { return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1` @@ -123,9 +124,15 @@ function toggleOverlay(id: string) { airTraffic.show() range.show() } - } - - if (id === 'wind') { + } else if (id === 'sats') { + if (activeOverlays.value.has('sats')) { + activeOverlays.value.delete('sats') + sats.hide() + } else { + activeOverlays.value.add('sats') + sats.show() + } + } else if (id === 'wind') { if (activeOverlays.value.has('wind')) { activeOverlays.value.delete('wind') hideWindOverlay() @@ -133,10 +140,7 @@ function toggleOverlay(id: string) { activeOverlays.value.add('wind') showWindOverlay() } - return - } - - if (id === 'rain') { + } else if (id === 'rain') { if (activeOverlays.value.has('rain')) { activeOverlays.value.delete('rain') if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] } @@ -144,7 +148,6 @@ function toggleOverlay(id: string) { activeOverlays.value.add('rain') refreshRain() } - return } } @@ -202,6 +205,8 @@ onMounted(async () => { aisLayer.show() airTraffic = new AirTrafficLayer(map) airTraffic.show() + sats = new SatsLayer(map) + sats.show(); range = new RangeLayer(map) range.show() diff --git a/client/src/components/Satellite.vue b/client/src/components/Satellite.vue index bea91ca..098cea4 100644 --- a/client/src/components/Satellite.vue +++ b/client/src/components/Satellite.vue @@ -1,48 +1,228 @@ - diff --git a/client/src/services/adsb.ts b/client/src/services/adsb.ts index 8849625..d67c1af 100644 --- a/client/src/services/adsb.ts +++ b/client/src/services/adsb.ts @@ -1,5 +1,5 @@ import { BASE } from '@/services/api.ts' -import { createApp, ref } from 'vue' +import { h, render as vueRender } from 'vue' import Map from 'ol/Map' import { Feature } from 'ol' import Point from 'ol/geom/Point' @@ -30,12 +30,6 @@ function getAltColor(alt: number): [number, number, number] { return [Math.round(128 * r), 0, 139] } -interface PopupInstance { - planeRef: ReturnType - posRef: ReturnType - unmount: () => void -} - export class AirTrafficLayer { private map: Map private layer!: VectorLayer @@ -167,27 +161,22 @@ export class AirTrafficLayer { const icao = plane.icao if (this.popups[icao]) return - const planeRef = ref(plane) - const posRef = ref(this._calcPopupPos(plane)) - const container = document.createElement('div') document.body.appendChild(container) + const mobile = window.innerWidth <= 768 - const mobile = window.innerWidth <= 768; - - const app = createApp(AircraftPopup, { - plane: planeRef.value, - position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16}, + const makeProps = (p: any) => ({ + plane: p, + position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: window.innerWidth - 360 - 16, y: 16 }, onClose: () => this._closePopup(icao), onBringToFront: () => bringToFront(container), }) - app.mount(container) + vueRender(h(AircraftPopup, makeProps(plane)), container) this.popups[icao] = { - planeRef, - posRef, - unmount: () => { app.unmount(); container.remove() }, + update: (p: any) => vueRender(h(AircraftPopup, makeProps(p)), container), + unmount: () => { vueRender(null, container); container.remove() }, } this._fetchTrace(plane) } @@ -206,7 +195,7 @@ export class AirTrafficLayer { for (const icao of Object.keys(this.popups)) { const plane = this.data.find(p => p.icao === icao) if (!plane) { this._closePopup(icao); continue } - this.popups[icao].planeRef.value = plane + this.popups[icao].update(plane) this._fetchTrace(plane) } } diff --git a/client/src/services/ais.ts b/client/src/services/ais.ts index f26203a..69db28f 100644 --- a/client/src/services/ais.ts +++ b/client/src/services/ais.ts @@ -1,5 +1,5 @@ import { BASE } from '@/services/api.ts' -import { createApp, ref } from 'vue' +import { h, render as vueRender } from 'vue' import Map from 'ol/Map' import { Feature } from 'ol' import Point from 'ol/geom/Point' @@ -123,26 +123,22 @@ export class AISLayer { const mmsi = String(boat.mmsi) if (this.popups[mmsi]) return - const boatRef = ref(boat) - const posRef = ref(this._calcPopupPos(boat)) - const container = document.createElement('div') document.body.appendChild(container) + const mobile = window.innerWidth <= 768 - const mobile = window.innerWidth <= 768; - - const app = createApp(ShipPopup, { - boat: boatRef.value, - position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16}, + const makeProps = (b: any) => ({ + boat: b, + position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: window.innerWidth - 360 - 16, y: 16 }, onClose: () => this._closePopup(mmsi), onBringToFront: () => bringToFront(container), }) - app.mount(container) + vueRender(h(ShipPopup, makeProps(boat)), container) + this.popups[mmsi] = { - boatRef, - posRef, - unmount: () => { app.unmount(); container.remove() }, + update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container), + unmount: () => { vueRender(null, container); container.remove() }, } } @@ -155,7 +151,7 @@ export class AISLayer { 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].boatRef.value = boat + this.popups[mmsi].update(boat) } } diff --git a/client/src/services/sats.ts b/client/src/services/sats.ts index ed4f74d..430728e 100644 --- a/client/src/services/sats.ts +++ b/client/src/services/sats.ts @@ -1,10 +1,10 @@ -import { BASE } from '@/services/api.ts' -import { createApp, ref } from 'vue' +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 { LineString, Point, Circle as CircleGeom } from 'ol/geom' import { fromLonLat } from 'ol/proj' -import { Style, Stroke, Fill, Circle as CircleStyle, Text } from 'ol/style' +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' @@ -12,6 +12,7 @@ import TinyGSPopup from '@/components/Satellite.vue' import { bringToFront } from './zindex' const API = BASE + '/api' +const EARTH_R = 6371 // km interface TinyGSReading { timestamp: number @@ -31,16 +32,68 @@ interface TinyGSSatellite extends TinyGSReading { history: TinyGSReading[] } -export class TinyGSLayer { - private map: Map - private layer!: VectorLayer - private popups: any = {} - private data: TinyGSSatellite[] = [] - private clickHandler: ((e: any) => void) | null = null - private refreshTimer: any = null - visible = false +interface RangeEstimate { + altitude: number // km, derived from az/el geometry + slantRange: number // km, straight-line distance to satellite + horizonRadius: number // km, max ground radius satellite is visible from at this altitude + groundDist: number // km, great-circle distance to subpoint +} - constructor(map: Map) { this.map = map } +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)) +} + +/** Derives altitude/slant range from ground station + satellite subpoint + observed elevation (no TLE required). */ +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 + const el = elDeg * Math.PI / 180 + const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma) + if (k <= 0) return null // inconsistent geometry (noisy reading) + + const altitude = EARTH_R * (1 - k) / k + const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma)) + const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2) + return { altitude, slantRange, horizonRadius, groundDist: d } +} + +/** Linear regression on recent elevation history to project time until satellite sets below horizon (el = 0). */ +function estimateEtaOut(history: TinyGSReading[]): number | null { + const pts = history.slice(-6) + if (pts.length < 2) return null + + const t0 = pts[0].timestamp + const xs = pts.map(p => (p.timestamp - t0) / 1000) + const ys = pts.map(p => p.el) + const n = xs.length + const sumX = xs.reduce((a, b) => a + b, 0), sumY = ys.reduce((a, b) => a + b, 0) + const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0), sumXX = xs.reduce((a, x) => a + x * x, 0) + const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) + if (!isFinite(slope) || slope >= 0) return null // rising or flat, can't predict setting + + const lastEl = ys[ys.length - 1], lastX = xs[xs.length - 1] + const secsFromNow = (lastX - lastEl / slope) - lastX + return secsFromNow > 0 ? secsFromNow * 1000 : null +} + +export class SatsLayer { + private map: Map + private layer!: VectorLayer + private popups: any = {} + private receptionCircles: Record> = {} + private data: TinyGSSatellite[] = [] + private clickHandler: ((e: any) => void) | null = null + private refreshTimer: any = null + visible = false + gs; + + constructor(map: Map) { + this.map = map + api.position().then(gs => this.gs = gs); + } async show() { if (this.visible) return @@ -71,7 +124,7 @@ export class TinyGSLayer { } private async _fetch() { - this.data = await fetch(`${API}/tinygs`).then(r => r.json()) || [] + this.data = await fetch(`${API}/sats`).then(r => r.json()) || [] } private _draw() { @@ -92,60 +145,83 @@ export class TinyGSLayer { marker.set('satellite', sat.satellite) marker.set('satData', sat) marker.setStyle(new Style({ - image: new CircleStyle({ - radius: 6, - fill: new Fill({ color: sat.crcOk ? '#3fdb6d' : '#e0475a' }), - stroke: new Stroke({ color: '#000', width: 1.5 }), - }), - text: new Text({ - text: sat.satellite, - font: '12px sans-serif', - fill: new Fill({ color: '#d4a4ff' }), - stroke: new Stroke({ color: '#000000', width: 2 }), - offsetY: -14, + image: new Icon({ + src: '/satellite.png', + scale: 0.1, + anchor: [0.5, 0.5], }), })) source.addFeature(marker) } } - private _calcPopupPos(sat: TinyGSSatellite): { x: number; y: number } { - const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([sat.longitude, sat.latitude])) - if (!pixel) return { x: 10, y: 60 } - const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect() - return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 } + 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) + } + + private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) { + this._removeReceptionCircle(sat.satellite) + if (!radiusKm) return + const feature = new Feature({ geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000) }) + feature.setStyle(new Style({ + stroke: new Stroke({ color: 'rgba(212,164,255,0.6)', width: 2 }), + fill: new Fill({ color: 'rgba(212,164,255,0.12)' }), + })) + const layer = new VectorLayer({ source: new VectorSource({ features: [feature] }), zIndex: 104 }) + this.map.addLayer(layer) + this.receptionCircles[sat.satellite] = layer + } + + private _removeReceptionCircle(name: string) { + const l = this.receptionCircles[name] + if (l) { this.map.removeLayer(l); delete this.receptionCircles[name] } } private _openPopup(sat: TinyGSSatellite) { if (this.popups[sat.satellite]) return - const satRef = ref(sat) + const history = [...sat.history, sat] + const range = this._calcRange(sat) + const eta = estimateEtaOut(history) + this._drawReceptionCircle(sat, range?.horizonRadius) + const container = document.createElement('div') document.body.appendChild(container) - const mobile = window.innerWidth <= 768 - const app = createApp(TinyGSPopup, { - satellite: satRef.value, + const makeProps = (h_: any, r: any, e: any) => ({ + history: h_, + range: r, + eta: e, position: mobile ? { x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16 } : { x: window.innerWidth - 360 - 16, y: 16 }, onClose: () => this._closePopup(sat.satellite), onBringToFront: () => bringToFront(container), }) - app.mount(container) - this.popups[sat.satellite] = { satRef, unmount: () => { app.unmount(); container.remove() } } + vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container) + + this.popups[sat.satellite] = { + container, + update: (h_: any, r: any, e: any) => vueRender(h(TinyGSPopup, makeProps(h_, r, e)), container), + unmount: () => { vueRender(null, container); container.remove() }, + } } private _closePopup(name: string) { const popup = this.popups[name] if (popup) { popup.unmount(); delete this.popups[name] } + this._removeReceptionCircle(name) } private _refreshPopups() { for (const name of Object.keys(this.popups)) { const sat = this.data.find(s => s.satellite === name) if (!sat) { this._closePopup(name); continue } - this.popups[name].satRef.value = sat + const history = [...sat.history, sat] + const range = this._calcRange(sat) + this.popups[name].update(history, range, estimateEtaOut(history)) + this._drawReceptionCircle(sat, range?.horizonRadius) } } diff --git a/server/src/sats.mjs b/server/src/sats.mjs index 099c155..8c8ec3b 100644 --- a/server/src/sats.mjs +++ b/server/src/sats.mjs @@ -1,9 +1,9 @@ import {cfg} from './config.mjs'; import {isEqual} from '@ztimson/utils'; -const HISTORY_LIMIT = 500 -const POLL_MS = 5_000 -const TTL_MS = 15 * 60_000 // drop a satellite's bucket if nothing new for 15 min +const HISTORY_LIMIT = 500; +const POLL_MS = 5_000; +const TTL_MS = 60_000; function parseWm(raw) { const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());