Radio sonde + auto mode
This commit is contained in:
@@ -1,238 +1,380 @@
|
||||
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'
|
||||
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 // km
|
||||
const API = BASE + '/api';
|
||||
const EARTH_R = 6371;
|
||||
const AUTO_ELEVATION = 70;
|
||||
|
||||
interface TinyGSReading {
|
||||
timestamp: number
|
||||
freqMHz: number
|
||||
satellite: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
az: number
|
||||
el: number
|
||||
packetRssi: number
|
||||
packetSnr: number
|
||||
freqError: number
|
||||
crcOk: boolean
|
||||
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[]
|
||||
history: TinyGSReading[];
|
||||
}
|
||||
|
||||
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
|
||||
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))
|
||||
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 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);
|
||||
|
||||
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 }
|
||||
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};
|
||||
}
|
||||
|
||||
/** Linear regression on recent elevation history to project time until satellite sets below horizon (el = 0). */
|
||||
function estimateEtaOut(history: TinyGSReading[]): number | null {
|
||||
const pts = <any>history.slice(-6)
|
||||
if (pts.length < 2) return 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), 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 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);
|
||||
|
||||
const lastEl = ys[ys.length - 1], lastX = xs[xs.length - 1]
|
||||
const secsFromNow = (lastX - lastEl / slope) - lastX
|
||||
return secsFromNow > 0 ? secsFromNow * 1000 : null
|
||||
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<VectorSource>
|
||||
private popups: any = {}
|
||||
private receptionCircles: Record<string, VectorLayer<VectorSource>> = {}
|
||||
private data: TinyGSSatellite[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = null
|
||||
visible = false
|
||||
gs;
|
||||
private map: Map;
|
||||
private layer!: VectorLayer<VectorSource>;
|
||||
private popups: any = {};
|
||||
private receptionCircles: Record<string, VectorLayer<VectorSource>> = {};
|
||||
private data: TinyGSSatellite[] = [];
|
||||
private clickHandler: ((e: any) => void) | null = null;
|
||||
private refreshTimer: any = null;
|
||||
visible = false;
|
||||
gs: any;
|
||||
|
||||
constructor(map: Map) {
|
||||
this.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()
|
||||
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()
|
||||
await this._fetch();
|
||||
this._draw();
|
||||
this._refreshPopups();
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000)
|
||||
}, 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)) this._closePopup(name)
|
||||
this.map.removeLayer(this.layer)
|
||||
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)) 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(name: string): TinyGSSatellite | null {
|
||||
return this.data.find(s => s.satellite === name) ?? null;
|
||||
}
|
||||
|
||||
openAutoPopup(sat: TinyGSSatellite) {
|
||||
this._openPopup(sat);
|
||||
}
|
||||
|
||||
closeAutoPopup(name: string) {
|
||||
this._closePopup(name);
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/sats`).then(r => r.json()) || []
|
||||
this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
source.clear()
|
||||
const source = this.layer.getSource()!;
|
||||
source.clear();
|
||||
|
||||
for (const sat of this.data) {
|
||||
if (sat.latitude == null || sat.longitude == null) continue
|
||||
if (sat.latitude == null || sat.longitude == null) continue;
|
||||
|
||||
const points: any = [...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]));
|
||||
|
||||
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 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.satellite)
|
||||
marker.set('satData', sat)
|
||||
const marker = new Feature({geometry: new Point(points[points.length - 1])});
|
||||
marker.set('satellite', sat.satellite);
|
||||
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)
|
||||
}));
|
||||
|
||||
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)
|
||||
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) })
|
||||
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
|
||||
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] }
|
||||
}
|
||||
const layer = this.receptionCircles[name];
|
||||
|
||||
private _openPopup(sat: TinyGSSatellite) {
|
||||
if (this.popups[sat.satellite]) 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_: any, r: any, e: any) => ({
|
||||
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.satellite),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
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() },
|
||||
if (layer) {
|
||||
this.map.removeLayer(layer);
|
||||
delete this.receptionCircles[name];
|
||||
}
|
||||
}
|
||||
|
||||
private _openPopup(sat: TinyGSSatellite) {
|
||||
if (this.popups[sat.satellite]) 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.satellite),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
});
|
||||
|
||||
vueRender(h(TinyGSPopup, makeProps(history, range, eta)), container);
|
||||
|
||||
this.popups[sat.satellite] = {
|
||||
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(name: string) {
|
||||
const popup = this.popups[name]
|
||||
if (popup) { popup.unmount(); delete this.popups[name] }
|
||||
this._removeReceptionCircle(name)
|
||||
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 }
|
||||
const history = [...sat.history, sat]
|
||||
const range = this._calcRange(sat)
|
||||
this.popups[name].update(history, range, estimateEtaOut(history))
|
||||
this._drawReceptionCircle(sat, range?.horizonRadius)
|
||||
const sat = this.data.find(s => s.satellite === 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.satellite]) { this._closePopup(sat.satellite); return }
|
||||
this._openPopup(sat)
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
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.satellite]) {
|
||||
this._closePopup(sat.satellite);
|
||||
return;
|
||||
}
|
||||
|
||||
this._openPopup(sat);
|
||||
};
|
||||
|
||||
this.map.on('singleclick', this.clickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user