Files
weather-station/client/src/services/sats.ts
2026-09-13 14:04:03 -04:00

367 lines
9.8 KiB
TypeScript

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<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;
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);
}
}