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, Circle as CircleGeom} from 'ol/geom'; 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 {bringToFront} from './zindex'; const API = BASE + '/api'; const EARTH_R = 6371; const AUTO_ELEVATION = 70; interface TinyGSReading { id: number; timestamp: number; freqMHz: number; satellite: string; latitude: number; longitude: number; az: number; el: number; packetRssi: number; packetSnr: number; freqError: number; crcOk: boolean; } interface TinyGSSatellite extends TinyGSReading { history: TinyGSReading[]; } interface RangeEstimate { altitude: number; slantRange: number; horizonRadius: number; 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; const el = elDeg * Math.PI / 180; const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma); if (k <= 0) return null; 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}; } function estimateEtaOut(history: TinyGSReading[]): number | null { const pts: any = 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); const sumY = ys.reduce((a, b) => a + b, 0); const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0); const 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; const lastEl: any = ys[ys.length - 1], lastX: any = 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: any; constructor(map: Map) { this.map = map; api.position().then(gs => this.gs = gs); } 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 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()) || []; } private _draw() { const source = this.layer.getSource()!; source.clear(); for (const sat of this.data) { if (sat.latitude == null || sat.longitude == null) continue; const points: any = [...sat.history, sat].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(212,164,255,0.5)', width: 2, lineDash: [6, 4], }), })); source.addFeature(trail); } const marker = new Feature({geometry: new Point(points[points.length - 1])}); marker.set('satellite', sat.id); marker.set('satData', sat); marker.setStyle(new Style({ image: new Icon({ src: '/satellite.png', scale: 0.1, anchor: [0.5, 0.5], }), })); source.addFeature(marker); } } 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.id); 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.id] = layer; } private _removeReceptionCircle(id: number) { const layer = this.receptionCircles[id]; if (layer) { this.map.removeLayer(layer); delete this.receptionCircles[id]; } } private _openPopup(sat: TinyGSSatellite) { if (this.popups[sat.id]) return; 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 makeProps = (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => ({ history: h_, range: r, eta: e, position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: 16, y: 16}, onClose: () => this._closePopup(sat.id), onBringToFront: () => bringToFront(container), }); vueRender(h(TinyGSPopup, 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), unmount: () => { vueRender(null, container); container.remove(); }, }; } private _closePopup(id: number) { const popup = this.popups[id]; 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); } }