Sats
This commit is contained in:
@@ -32,11 +32,13 @@ const showWind = ref(false)
|
|||||||
const windSrc = ref('')
|
const windSrc = ref('')
|
||||||
|
|
||||||
const OVERLAYS = [
|
const OVERLAYS = [
|
||||||
|
{ id: 'traffic', label: 'Traffic', icon: '✈️' },
|
||||||
|
{ id: 'sats', label: 'Sats', icon: '🛰️' },
|
||||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||||
{ id: 'wind', label: 'Wind', icon: '💨' },
|
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
const activeOverlays = ref<Set<string>>(new Set(['traffic', 'rain']))
|
||||||
const overlayLayers: { [key: string]: any } = {}
|
const overlayLayers: { [key: string]: any } = {}
|
||||||
|
|
||||||
let map: Map
|
let map: Map
|
||||||
@@ -108,20 +110,21 @@ async function refreshRain() {
|
|||||||
map.addLayer(layer)
|
map.addLayer(layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAT() {
|
|
||||||
if (atActive.value) {
|
|
||||||
aisLayer.hide()
|
|
||||||
airTraffic.hide()
|
|
||||||
range.hide()
|
|
||||||
} else {
|
|
||||||
aisLayer.show()
|
|
||||||
airTraffic.show()
|
|
||||||
range.show()
|
|
||||||
}
|
|
||||||
atActive.value = !atActive.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOverlay(id: string) {
|
function toggleOverlay(id: string) {
|
||||||
|
if (id === 'traffic') {
|
||||||
|
if (activeOverlays.value.has('traffic')) {
|
||||||
|
activeOverlays.value.delete('traffic');
|
||||||
|
aisLayer.hide()
|
||||||
|
airTraffic.hide()
|
||||||
|
range.hide()
|
||||||
|
} else {
|
||||||
|
activeOverlays.value.add('traffic');
|
||||||
|
aisLayer.show()
|
||||||
|
airTraffic.show()
|
||||||
|
range.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (id === 'wind') {
|
if (id === 'wind') {
|
||||||
if (activeOverlays.value.has('wind')) {
|
if (activeOverlays.value.has('wind')) {
|
||||||
activeOverlays.value.delete('wind')
|
activeOverlays.value.delete('wind')
|
||||||
@@ -340,9 +343,6 @@ watch(() => props.dark, dark => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="overlay-toggles desktop">
|
<div class="overlay-toggles desktop">
|
||||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
|
||||||
✈️ Traffic
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-for="o in OVERLAYS" :key="o.id"
|
v-for="o in OVERLAYS" :key="o.id"
|
||||||
class="overlay-btn"
|
class="overlay-btn"
|
||||||
@@ -358,9 +358,6 @@ watch(() => props.dark, dark => {
|
|||||||
⚙️ Layers
|
⚙️ Layers
|
||||||
</button>
|
</button>
|
||||||
<div v-if="showOverlays" class="layers-dropdown">
|
<div v-if="showOverlays" class="layers-dropdown">
|
||||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
|
||||||
✈️ Traffic
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-for="o in OVERLAYS" :key="o.id"
|
v-for="o in OVERLAYS" :key="o.id"
|
||||||
class="overlay-btn"
|
class="overlay-btn"
|
||||||
|
|||||||
48
client/src/components/Satellite.vue
Normal file
48
client/src/components/Satellite.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['bringToFront']);
|
||||||
|
const props = defineProps<{ history: any[]; position: { x: number, y: number }; onClose: () => void }>()
|
||||||
|
const latest = computed(() => props.history[props.history.length - 1])
|
||||||
|
|
||||||
|
/** Center = zenith (el 90), edge = horizon (el 0). */
|
||||||
|
function polarPoint(az: number, el: number, radius = 100) {
|
||||||
|
const r = radius * (1 - el / 90)
|
||||||
|
const rad = (az - 90) * Math.PI / 180
|
||||||
|
return { x: 120 + r * Math.cos(rad), y: 120 + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackPoints = computed(() => props.history.map(r => polarPoint(r.az, r.el)).map(p => `${p.x},${p.y}`).join(' '))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tinygs-popup { position: fixed; width: 340px; background: var(--surface); border-radius: 12px; padding: 12px; box-shadow: 0 4px 20px rgba(0,0,0,.3); z-index: 1000; }
|
||||||
|
.close { position: absolute; top: 8px; right: 8px; background: none; border: none; cursor: pointer; }
|
||||||
|
.fields { width: 100%; font-size: 13px; margin-bottom: 8px; td { padding: 2px 4px; } }
|
||||||
|
.ok { color: #3fdb6d; }
|
||||||
|
.bad { color: #e0475a; }
|
||||||
|
.radar { width: 100%; }
|
||||||
|
.ring, .axis { fill: none; stroke: var(--border); stroke-width: 1; }
|
||||||
|
.track { fill: none; stroke: #d4a4ff; stroke-width: 1.5; }
|
||||||
|
.current { fill: #d4a4ff; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tinygs-popup" :style="{ left: position.x + 'px', top: position.y + 'px' }" @mousedown="emit('bringToFront')">
|
||||||
|
<button class="close" @click="onClose">✕</button>
|
||||||
|
<h3>{{ latest?.satellite || 'Unknown' }}</h3>
|
||||||
|
<table class="fields">
|
||||||
|
<tr><td>Frequency</td><td>{{ latest?.freqMHz }} MHz</td></tr>
|
||||||
|
<tr><td>Packet RSSI</td><td>{{ latest?.packetRssi }} dBm</td></tr>
|
||||||
|
<tr><td>SNR</td><td>{{ latest?.packetSnr }} dB</td></tr>
|
||||||
|
<tr><td>Freq error</td><td>{{ latest?.freqError }} Hz</td></tr>
|
||||||
|
<tr><td>CRC</td><td :class="latest?.crcOk ? 'ok' : 'bad'">{{ latest?.crcOk ? 'OK' : 'ERROR' }}</td></tr>
|
||||||
|
</table>
|
||||||
|
<svg viewBox="0 0 240 240" class="radar">
|
||||||
|
<circle cx="120" cy="120" r="100" class="ring" /><circle cx="120" cy="120" r="66" class="ring" /><circle cx="120" cy="120" r="33" class="ring" />
|
||||||
|
<line x1="120" y1="20" x2="120" y2="220" class="axis" /><line x1="20" y1="120" x2="220" y2="120" class="axis" />
|
||||||
|
<polyline :points="trackPoints" class="track" />
|
||||||
|
<circle v-if="latest" v-bind="polarPoint(latest.az, latest.el)" r="4" class="current" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -79,11 +79,11 @@ function drawTrace(ctx: CanvasRenderingContext2D, t: (typeof traces)[number], co
|
|||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
for (let i = 0; i < settledCount; i++) {
|
for (let i = 0; i < settledCount; i++) {
|
||||||
const p = buf[i];
|
const p = <any>buf[i];
|
||||||
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
const animPoints = [anchor, ...buf.slice(settledCount)];
|
const animPoints = <any>[anchor, ...buf.slice(settledCount)];
|
||||||
|
|
||||||
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
||||||
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
|
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
|
||||||
@@ -116,9 +116,9 @@ function draw(timestamp: number) {
|
|||||||
|
|
||||||
// Axis label — top-left of each band
|
// Axis label — top-left of each band
|
||||||
ctx.font = '16px monospace';
|
ctx.font = '16px monospace';
|
||||||
ctx.fillStyle = AXES[i].color;
|
ctx.fillStyle = (<any>AXES)[i].color;
|
||||||
ctx.globalAlpha = 0.6;
|
ctx.globalAlpha = 0.6;
|
||||||
ctx.fillText(AXES[i].label, W - 20, i * BAND + 14);
|
ctx.fillText((<any>AXES)[i].label, W - 20, i * BAND + 14);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
// Center line per band — full width
|
// Center line per band — full width
|
||||||
@@ -129,7 +129,7 @@ function draw(timestamp: number) {
|
|||||||
ctx.lineTo(W, mid);
|
ctx.lineTo(W, mid);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
drawTrace(ctx, traces[i], AXES[i].color);
|
drawTrace(ctx, <any>traces[i], (<any>AXES)[i].color);
|
||||||
});
|
});
|
||||||
|
|
||||||
rafId = requestAnimationFrame(draw);
|
rafId = requestAnimationFrame(draw);
|
||||||
@@ -138,7 +138,7 @@ function draw(timestamp: number) {
|
|||||||
async function poll() {
|
async function poll() {
|
||||||
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
||||||
AXES.forEach((axis, i) => {
|
AXES.forEach((axis, i) => {
|
||||||
const t = traces[i];
|
const t = <any>traces[i];
|
||||||
t.history.push(d.value[axis.key] ?? 0);
|
t.history.push(d.value[axis.key] ?? 0);
|
||||||
if (t.history.length > MAX_PTS) t.history.shift();
|
if (t.history.length > MAX_PTS) t.history.shift();
|
||||||
buildBuffer(t, i);
|
buildBuffer(t, i);
|
||||||
|
|||||||
162
client/src/services/sats.ts
Normal file
162
client/src/services/sats.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { BASE } from '@/services/api.ts'
|
||||||
|
import { createApp, ref } from 'vue'
|
||||||
|
import Map from 'ol/Map'
|
||||||
|
import { Feature } from 'ol'
|
||||||
|
import { LineString, Point } from 'ol/geom'
|
||||||
|
import { fromLonLat } from 'ol/proj'
|
||||||
|
import { Style, Stroke, Fill, Circle as CircleStyle, Text } 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'
|
||||||
|
|
||||||
|
interface TinyGSReading {
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
constructor(map: Map) { this.map = map }
|
||||||
|
|
||||||
|
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)) this._closePopup(name)
|
||||||
|
this.map.removeLayer(this.layer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _fetch() {
|
||||||
|
this.data = await fetch(`${API}/tinygs`).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.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,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
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 _openPopup(sat: TinyGSSatellite) {
|
||||||
|
if (this.popups[sat.satellite]) return
|
||||||
|
|
||||||
|
const satRef = ref(sat)
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768
|
||||||
|
|
||||||
|
const app = createApp(TinyGSPopup, {
|
||||||
|
satellite: satRef.value,
|
||||||
|
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() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private _closePopup(name: string) {
|
||||||
|
const popup = this.popups[name]
|
||||||
|
if (popup) { popup.unmount(); delete this.popups[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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// Extra safety for array and object lookups, but may have false positives.
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
|
||||||
// Path mapping for cleaner imports.
|
// Path mapping for cleaner imports.
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export function cfg() {
|
|||||||
return {
|
return {
|
||||||
PORT: process.env.PORT || 3000,
|
PORT: process.env.PORT || 3000,
|
||||||
ADSB_URL: process.env.ADSB_URL || '',
|
ADSB_URL: process.env.ADSB_URL || '',
|
||||||
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
TINYGS_URL: process.env.TINYGS_URL || '',
|
||||||
|
TINYGS_AUTH: process.env.TINYGS_AUTH || '',
|
||||||
|
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
||||||
EMAIL: process.env.EMAIL,
|
EMAIL: process.env.EMAIL,
|
||||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||||
|
|||||||
69
server/src/sats.mjs
Normal file
69
server/src/sats.mjs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
function parseWm(raw) {
|
||||||
|
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
||||||
|
const [latitude, longitude] = f[15].split('/').map(s => parseFloat(s));
|
||||||
|
const [az, el] = f[16].split('/').map(s => parseFloat(s));
|
||||||
|
return {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
freqMHz: parseFloat(f[3]),
|
||||||
|
satellite: f[14],
|
||||||
|
latitude, longitude,
|
||||||
|
az, el,
|
||||||
|
packetRssi: parseFloat(f[21]),
|
||||||
|
packetSnr: parseFloat(f[22]),
|
||||||
|
freqError: parseFloat(f[23]),
|
||||||
|
crcOk: !/CRC ERROR/i.test(f[24]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const satellites = new Map();
|
||||||
|
|
||||||
|
function pruneStale() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [name, bucket] of satellites) {
|
||||||
|
if (now - bucket.lastSeen > TTL_MS) satellites.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollTinyGS() {
|
||||||
|
const { TINYGS_URL, TINYGS_AUTH } = cfg();
|
||||||
|
try {
|
||||||
|
const raw = await fetch(TINYGS_URL + '/wm', {
|
||||||
|
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {}
|
||||||
|
}).then(r => r.text());
|
||||||
|
const reading = parseWm(raw);
|
||||||
|
if (!reading.satellite || reading.satellite === '-') return;
|
||||||
|
|
||||||
|
let bucket = satellites.get(reading.satellite);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { history: [], lastSeen: 0 };
|
||||||
|
satellites.set(reading.satellite, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.lastSeen = reading.timestamp;
|
||||||
|
if (!isEqual(bucket.history.at(-1), reading)) {
|
||||||
|
bucket.history.push(reading);
|
||||||
|
if (bucket.history.length > HISTORY_LIMIT) bucket.history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneStale();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[tinygs] poll failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(pollTinyGS, POLL_MS);
|
||||||
|
|
||||||
|
export const getTinyGSData = () => {
|
||||||
|
pruneStale();
|
||||||
|
return Array.from(satellites.entries()).map(([name, bucket]) => ({
|
||||||
|
...bucket.history.at(-1),
|
||||||
|
history: bucket.history.slice(0, -1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -108,11 +108,14 @@ export async function queryDaily(start, end) {
|
|||||||
|
|
||||||
export async function getCoords() {
|
export async function getCoords() {
|
||||||
const c = cfg();
|
const c = cfg();
|
||||||
|
if(c.LATITUDE !== 0 && c.LONGITUDE !== 0 && c.ALTITUDE !== 0)
|
||||||
|
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||||
|
|
||||||
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
||||||
const fields = metricToFields(results);
|
const fields = metricToFields(results);
|
||||||
|
|
||||||
if (fields.latitude && fields.longitude) {
|
if(fields.latitude && fields.longitude) {
|
||||||
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||||
}
|
}
|
||||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
return {latitude: 0, longitude: 0, altitude: 0};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {getADSBImage, getADSB, getADSBHistory, getADSBRange, initAircraftDb} fro
|
|||||||
import {fetchIcon} from './openweather.mjs';
|
import {fetchIcon} from './openweather.mjs';
|
||||||
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||||
|
import {getTinyGSData} from './sats.mjs';
|
||||||
|
|
||||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
|||||||
// res.json(filterFields(await getSpaceWeather(), fields));
|
// res.json(filterFields(await getSpaceWeather(), fields));
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// ── ADSB/AIS Proxy ────────────────────────────────────────────────────────────
|
// ── ADSB/AIS/SAT Proxy ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
||||||
app.get('/api/adsb-image/:icao', asyncHandler(async (req, res) => {
|
app.get('/api/adsb-image/:icao', asyncHandler(async (req, res) => {
|
||||||
@@ -170,6 +171,7 @@ app.get('/api/ais-image/:mmsi', asyncHandler(async (req, res) => {
|
|||||||
res.contentType(blob.type).send(buffer);
|
res.contentType(blob.type).send(buffer);
|
||||||
}));
|
}));
|
||||||
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
||||||
|
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
|
||||||
|
|
||||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user