Sat updates
This commit is contained in:
@@ -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<typeof ref>
|
||||
posRef: ReturnType<typeof ref>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
export class AirTrafficLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<VectorSource>
|
||||
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 = <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 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<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;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user