Hooked up auroras
This commit is contained in:
@@ -219,7 +219,7 @@ const navball = computed(() => {
|
|||||||
|
|
||||||
<!-- Photo -->
|
<!-- Photo -->
|
||||||
<div v-if="!photoError" class="ap-photo-wrap">
|
<div v-if="!photoError" class="ap-photo-wrap">
|
||||||
<img class="ap-photo" :src="BASE + '/api/adsb-image/' + plane.icao" :alt="callsign" @error="photoError = true" />
|
<img class="ap-photo" :src="BASE + '/api/adsb/' + plane.icao + '/image'" :alt="callsign" @error="photoError = true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { defaults as defaultInteractions } from 'ol/interaction'
|
|||||||
import { fromLonLat, toLonLat } from 'ol/proj'
|
import { fromLonLat, toLonLat } from 'ol/proj'
|
||||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
||||||
import { applyStyle } from 'ol-mapbox-style'
|
import { applyStyle } from 'ol-mapbox-style'
|
||||||
|
import { Aurora } from '@/services/aurora.ts'
|
||||||
import 'ol/ol.css'
|
import 'ol/ol.css'
|
||||||
|
|
||||||
const current = ref({ latitude: 0, longitude: 0 })
|
const current = ref({ latitude: 0, longitude: 0 })
|
||||||
@@ -35,6 +36,7 @@ const OVERLAYS = [
|
|||||||
{ id: 'traffic', label: 'Traffic', icon: '✈️' },
|
{ id: 'traffic', label: 'Traffic', icon: '✈️' },
|
||||||
{ id: 'sats', label: 'Sats', icon: '🛰️' },
|
{ id: 'sats', label: 'Sats', icon: '🛰️' },
|
||||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||||
|
{ id: 'aurora', label: 'Aurora', icon: '🌌' },
|
||||||
{ id: 'wind', label: 'Wind', icon: '💨' },
|
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ let airTraffic: AirTrafficLayer
|
|||||||
let aisLayer: AISLayer
|
let aisLayer: AISLayer
|
||||||
let range: RangeLayer
|
let range: RangeLayer
|
||||||
let sats: SatsLayer
|
let sats: SatsLayer
|
||||||
|
let aurora: Aurora
|
||||||
|
|
||||||
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
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`
|
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()
|
airTraffic.show()
|
||||||
range.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') {
|
} else if (id === 'sats') {
|
||||||
if (activeOverlays.value.has('sats')) {
|
if (activeOverlays.value.has('sats')) {
|
||||||
activeOverlays.value.delete('sats')
|
activeOverlays.value.delete('sats')
|
||||||
@@ -207,6 +218,7 @@ onMounted(async () => {
|
|||||||
airTraffic.show()
|
airTraffic.show()
|
||||||
sats = new SatsLayer(map)
|
sats = new SatsLayer(map)
|
||||||
sats.show();
|
sats.show();
|
||||||
|
aurora = new Aurora(map)
|
||||||
range = new RangeLayer(map)
|
range = new RangeLayer(map)
|
||||||
range.show()
|
range.show()
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ const compass = computed(() => {
|
|||||||
|
|
||||||
<!-- Photo -->
|
<!-- Photo -->
|
||||||
<div v-if="!photoError" class="sp-photo-wrap">
|
<div v-if="!photoError" class="sp-photo-wrap">
|
||||||
<img class="sp-photo" :src="BASE + '/api/ais-iamge/' + boat.mmsi" :alt="name" @error="photoError = true" />
|
<img class="sp-photo" :src="BASE + '/api/ais/' + boat.mmsi + '/image'" :alt="name" @error="photoError = true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sp-body">
|
<div class="sp-body">
|
||||||
|
|||||||
104
client/src/services/aurora.ts
Normal file
104
client/src/services/aurora.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {isEqual} from '@ztimson/utils';
|
|||||||
|
|
||||||
const HISTORY_LIMIT = 500;
|
const HISTORY_LIMIT = 500;
|
||||||
const POLL_MS = 5_000;
|
const POLL_MS = 5_000;
|
||||||
const TTL_MS = 60_000;
|
const TTL_MS = 3 * 60_000;
|
||||||
|
|
||||||
function parseWm(raw) {
|
function parseWm(raw) {
|
||||||
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ 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';
|
import {getTinyGSData} from './sats.mjs';
|
||||||
|
import {getSpaceWeather} from './space.mjs';
|
||||||
|
import {Aurora} from './aurora.mjs';
|
||||||
|
|
||||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ function filterArr(arr, fields) {
|
|||||||
return arr.map(row => filterFields(row, fields));
|
return arr.map(row => filterFields(row, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Current ───────────────────────────────────────────────────────────────────
|
// ── Weather ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/current', asyncHandler(async (req, res) => {
|
app.get('/api/current', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
@@ -72,8 +74,6 @@ app.get('/api/current', asyncHandler(async (req, res) => {
|
|||||||
res.json(filterFields(merged, fields))
|
res.json(filterFields(merged, fields))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const coords = await getCoords()
|
const coords = await getCoords()
|
||||||
@@ -99,8 +99,6 @@ app.get('/api/hourly', asyncHandler(async (req, res) => {
|
|||||||
res.json(filterArr(combined, fields))
|
res.json(filterArr(combined, fields))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get('/api/daily', asyncHandler(async (req, res) => {
|
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const coords = await getCoords()
|
const coords = await getCoords()
|
||||||
@@ -130,6 +128,7 @@ app.get('/api/daily', asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
// ── Position ──────────────────────────────────────────────────────────────────
|
// ── Position ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
||||||
app.get('/api/position', asyncHandler(async (req, res) => {
|
app.get('/api/position', asyncHandler(async (req, res) => {
|
||||||
const {fields} = req.query;
|
const {fields} = req.query;
|
||||||
const data = await getCoords();
|
const data = await getCoords();
|
||||||
@@ -147,30 +146,31 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
|||||||
|
|
||||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
||||||
// app.get('/api/space', async (req, res) => {
|
app.get('/api/space', async (req, res) => {
|
||||||
// const {fields} = req.query;
|
const {fields} = req.query;
|
||||||
// res.json(filterFields(await getSpaceWeather(), fields));
|
res.json(filterFields(await getSpaceWeather(), fields));
|
||||||
// });
|
});
|
||||||
|
|
||||||
// ── ADSB/AIS/SAT 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/:icao/image', asyncHandler(async (req, res) => {
|
||||||
const blob = await getADSBImage(req.params.icao);
|
const blob = await getADSBImage(req.params.icao);
|
||||||
if (!blob) return res.status(404).send('No image found');
|
if (!blob) return res.status(404).send('No image found');
|
||||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||||
res.contentType(blob.type).send(buffer);
|
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/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', 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);
|
const blob = await getAISImage(req.params.mmsi);
|
||||||
if (!blob) return res.status(404).send('No image found');
|
if (!blob) return res.status(404).send('No image found');
|
||||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||||
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/sats', (_req, res) => res.json(getTinyGSData()));
|
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
|
||||||
|
|
||||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user