From 6bcc5bec190014af8568c0507f220dab69b8630d Mon Sep 17 00:00:00 2001 From: ztimson Date: Fri, 11 Sep 2026 22:08:14 -0400 Subject: [PATCH] Hooked up auroras --- client/src/components/Aircraft.vue | 2 +- client/src/components/MapView.vue | 12 ++++ client/src/components/Ship.vue | 2 +- client/src/services/aurora.ts | 104 +++++++++++++++++++++++++++++ server/src/sats.mjs | 2 +- server/src/server.mjs | 26 ++++---- 6 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 client/src/services/aurora.ts diff --git a/client/src/components/Aircraft.vue b/client/src/components/Aircraft.vue index 212f9b8..bebfda0 100644 --- a/client/src/components/Aircraft.vue +++ b/client/src/components/Aircraft.vue @@ -219,7 +219,7 @@ const navball = computed(() => {
- +
diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue index 1fd4022..479daf1 100644 --- a/client/src/components/MapView.vue +++ b/client/src/components/MapView.vue @@ -19,6 +19,7 @@ import { defaults as defaultInteractions } from 'ol/interaction' import { fromLonLat, toLonLat } from 'ol/proj' import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style' import { applyStyle } from 'ol-mapbox-style' +import { Aurora } from '@/services/aurora.ts' import 'ol/ol.css' const current = ref({ latitude: 0, longitude: 0 }) @@ -35,6 +36,7 @@ const OVERLAYS = [ { id: 'traffic', label: 'Traffic', icon: '✈️' }, { id: 'sats', label: 'Sats', icon: '🛰️' }, { id: 'rain', label: 'Rain', icon: '🌧️' }, + { id: 'aurora', label: 'Aurora', icon: '🌌' }, { id: 'wind', label: 'Wind', icon: '💨' }, ] @@ -48,6 +50,7 @@ let airTraffic: AirTrafficLayer let aisLayer: AISLayer let range: RangeLayer let sats: SatsLayer +let aurora: Aurora function buildWindSrc(lat: number, lon: number, zoom: number) { return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1` @@ -124,6 +127,14 @@ function toggleOverlay(id: string) { airTraffic.show() range.show() } + } else if (id === 'aurora') { + if (activeOverlays.value.has('aurora')) { + activeOverlays.value.delete('aurora') + aurora.hide() + } else { + activeOverlays.value.add('aurora') + aurora.show() + } } else if (id === 'sats') { if (activeOverlays.value.has('sats')) { activeOverlays.value.delete('sats') @@ -207,6 +218,7 @@ onMounted(async () => { airTraffic.show() sats = new SatsLayer(map) sats.show(); + aurora = new Aurora(map) range = new RangeLayer(map) range.show() diff --git a/client/src/components/Ship.vue b/client/src/components/Ship.vue index 07da9f2..28f8ebd 100644 --- a/client/src/components/Ship.vue +++ b/client/src/components/Ship.vue @@ -213,7 +213,7 @@ const compass = computed(() => {
- +
diff --git a/client/src/services/aurora.ts b/client/src/services/aurora.ts new file mode 100644 index 0000000..242ef01 --- /dev/null +++ b/client/src/services/aurora.ts @@ -0,0 +1,104 @@ +import 'ol/ol.css'; +import {BASE} from '@/services/api.ts'; +import {Feature} from 'ol'; +import Point from 'ol/geom/Point'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import {Style} from 'ol/style'; + +export function normalize(coordinates: number | {lon: number} | {long: number} | {longitude: number}) { + const convert = (n) => { + if (n > 180) return n - 360; + if (n < -180) return n + 360; + return n; + } + + if(typeof coordinates == 'number') return convert(coordinates); + if(typeof coordinates['lon'] == 'number') return convert(coordinates['lon']); + if(typeof coordinates['long'] == 'number') return convert(coordinates['long']); + if(typeof coordinates['longitude'] == 'number') return convert(coordinates['longitude']); +} + +export class Aurora { + data: any = null; + layer; + map; + name = 'aurora'; + refresh; + refreshRate = 60_000 * 60; + timestamp; + visible = false; + + constructor(map) { + this.map = map; + } + + private style(intensity: number) { + return new Style({renderer: (coordinates, state) => { + const ctx = state.context as CanvasRenderingContext2D; + const [x, y] = <[number, number]>coordinates; + const previousComposite = ctx.globalCompositeOperation; + ctx.globalCompositeOperation = 'screen'; + const zoom = this.map.getView().getZoom() || 1; + const baseRadius = 50; + const radius = baseRadius * Math.pow(2, zoom - 3); + const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius); + const alpha = intensity / 50; + gradient.addColorStop(0, `rgba(0, 255, 0, ${alpha})`); + gradient.addColorStop(1, 'rgba(0, 255, 0, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.fill(); + ctx.globalCompositeOperation = previousComposite; + }}); + }; + + async update() { + const d = await fetch(BASE + '/api/aurora').then(resp => resp.json()); + this.timestamp = new Date(d.timestamp).getTime(); + this.data = d.data; + } + + async redraw() { + const threshold = 5; + const features = (this.data?.coordinates || []) + .filter(([lon, lat, intensity]: [number, number, number], i) => i % 4 == 0 && lat != 0 && intensity >= threshold) + .map(([lon, lat, intensity]: [number, number, number]) => { + const feature = new Feature({geometry: new Point([normalize(lon), lat])}); + const geom = feature.getGeometry() as Point; + geom.transform('EPSG:4326', this.map.getView().getProjection()); + feature.setStyle(this.style(intensity)); + return feature; + }); + if(this.layer) this.map.removeLayer(this.layer); + if(!features.length) return; + this.layer = new VectorLayer({source: new VectorSource({features, wrapX: true}), zIndex: 5030, opacity: 0.4}); + this.layer.set('name', this.name); + this.map.addLayer(this.layer); + } + + async show() { + if(this.visible) return; + this.visible = true; + if(!this.data || Date.now() - this.timestamp > this.refreshRate) await this.update(); + if(!this.visible) return; + await this.redraw(); + if(!this.refresh) { + this.refresh = setInterval(async () => { + await this.update(); + if(!this.visible) return; + await this.redraw(); + }, this.refreshRate); + } + } + + hide() { + if(!this.visible) return; + this.visible = false; + if(this.refresh) this.refresh = clearInterval(this.refresh); + this.map.getLayers().forEach(layer => { + if(layer?.get('name') == this.name) this.map.removeLayer(layer); + }); + } +} diff --git a/server/src/sats.mjs b/server/src/sats.mjs index 609f7bc..8881fd2 100644 --- a/server/src/sats.mjs +++ b/server/src/sats.mjs @@ -3,7 +3,7 @@ import {isEqual} from '@ztimson/utils'; const HISTORY_LIMIT = 500; const POLL_MS = 5_000; -const TTL_MS = 60_000; +const TTL_MS = 3 * 60_000; function parseWm(raw) { const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim()); diff --git a/server/src/server.mjs b/server/src/server.mjs index bac05ff..30eb7ab 100644 --- a/server/src/server.mjs +++ b/server/src/server.mjs @@ -13,6 +13,8 @@ import {fetchIcon} from './openweather.mjs'; import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs'; import {dailyWeather, hourlyWeather} from './openmeteo.mjs'; import {getTinyGSData} from './sats.mjs'; +import {getSpaceWeather} from './space.mjs'; +import {Aurora} from './aurora.mjs'; // ── Uncaught error handlers ─────────────────────────────────────────────────── @@ -48,7 +50,7 @@ function filterArr(arr, fields) { return arr.map(row => filterFields(row, fields)); } -// ── Current ─────────────────────────────────────────────────────────────────── +// ── Weather ─────────────────────────────────────────────────────────────────── app.get('/api/current', asyncHandler(async (req, res) => { const { fields } = req.query @@ -72,8 +74,6 @@ app.get('/api/current', asyncHandler(async (req, res) => { res.json(filterFields(merged, fields)) })); -// ── Hourly ──────────────────────────────────────────────────────────────────── - app.get('/api/hourly', asyncHandler(async (req, res) => { const { fields } = req.query const coords = await getCoords() @@ -99,8 +99,6 @@ app.get('/api/hourly', asyncHandler(async (req, res) => { res.json(filterArr(combined, fields)) })) -// ── Daily ───────────────────────────────────────────────────────────────────── - app.get('/api/daily', asyncHandler(async (req, res) => { const { fields } = req.query const coords = await getCoords() @@ -130,6 +128,7 @@ app.get('/api/daily', asyncHandler(async (req, res) => { // ── Position ────────────────────────────────────────────────────────────────── +app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange()))); app.get('/api/position', asyncHandler(async (req, res) => { const {fields} = req.query; const data = await getCoords(); @@ -147,30 +146,31 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => { // ── Space ───────────────────────────────────────────────────────────────────── -// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get())); -// app.get('/api/space', async (req, res) => { -// const {fields} = req.query; -// res.json(filterFields(await getSpaceWeather(), fields)); -// }); +app.get('/api/aurora', async (req, res) => res.json(await Aurora.get())); +app.get('/api/space', async (req, res) => { + const {fields} = req.query; + res.json(filterFields(await getSpaceWeather(), fields)); +}); // ── ADSB/AIS/SAT Proxy ──────────────────────────────────────────────────────────── 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/:icao/image', asyncHandler(async (req, res) => { const blob = await getADSBImage(req.params.icao); if (!blob) return res.status(404).send('No image found'); const buffer = Buffer.from(await blob.arrayBuffer()); res.contentType(blob.type).send(buffer); })); app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getADSBHistory(req.params.icao)))); + app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS()))); -app.get('/api/ais-image/:mmsi', asyncHandler(async (req, res) => { +app.get('/api/ais/:mmsi/image', asyncHandler(async (req, res) => { const blob = await getAISImage(req.params.mmsi); if (!blob) return res.status(404).send('No image found'); const buffer = Buffer.from(await blob.arrayBuffer()); res.contentType(blob.type).send(buffer); })); -app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange()))); + app.get('/api/sats', (_req, res) => res.json(getTinyGSData())); // ── DOCS ──────────────────────────────────────────────────────────────────────