diff --git a/client/src/services/adsb.ts b/client/src/services/adsb.ts
index 29b6a93..c97c416 100644
--- a/client/src/services/adsb.ts
+++ b/client/src/services/adsb.ts
@@ -1,4 +1,5 @@
-import {BASE} from '@/services/api.ts';
+import { BASE } from '@/services/api.ts'
+import { createApp, ref } from 'vue'
import Map from 'ol/Map'
import { Feature } from 'ol'
import Point from 'ol/geom/Point'
@@ -7,32 +8,12 @@ import { fromLonLat } from 'ol/proj'
import { Style, Icon, Stroke } from 'ol/style'
import { Vector as VectorLayer } from 'ol/layer'
import { Vector as VectorSource } from 'ol/source'
-import {adjustedInterval} from '@ztimson/utils';
+import { adjustedInterval } from '@ztimson/utils'
+import AircraftPopup from '@/components/Aircraft.vue'
const API = BASE + '/api'
-const SPEED_UNITS = {
- knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
- kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
- mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
-}
-
-const ALTITUDE_UNITS = {
- meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
- feet: { label: 'FT', convert: (v: number) => v },
-}
-
-const VERTICAL_UNITS = {
- mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
- fps: { label: 'FT/S', convert: (v: number) => v },
-}
-
-const UNIT_KEY = 'at_units'
-function getUnits() {
- const s = localStorage.getItem(UNIT_KEY)
- return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
-}
-function saveUnits(p: any) { localStorage.setItem(UNIT_KEY, JSON.stringify(p)) }
+let highestZIndex = 1000
function getAltColor(alt: number): [number, number, number] {
const a = Math.max(0, alt)
@@ -50,127 +31,10 @@ function getAltColor(alt: number): [number, number, number] {
return [Math.round(128 * r), 0, 139]
}
-function buildNavballSvg(data: any): string {
- const heading = data.heading ?? 0
- const airspeedKnots = data.speed || 0
- const verticalRateFps = data.climb || 0
- const groundSpeedFps = airspeedKnots * 1.68781
- const pitchRad = groundSpeedFps > 0 ? Math.atan(verticalRateFps / groundSpeedFps) : 0
- const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
-
- const pitchLines: string[] = []
- for (let p = -30; p <= 30; p += 5) {
- if (p === 0) continue
- const isMajor = p % 10 === 0
- const [start, end] = [85, 115]
- if (isMajor) {
- pitchLines.push(`
-
${Math.abs(p)}
-
-
${Math.abs(p)}
- `)
- } else {
- pitchLines.push(`
`)
- }
- }
-
- const rollTicks: string[] = []
- for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
- const rad = angle * Math.PI / 180
- const a = Math.abs(angle)
- if (a === 45) {
- rollTicks.push(`
`)
- } else {
- const inner = (a === 0 || a % 30 === 0) ? 72 : 77
- const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
- rollTicks.push(`
`)
- }
- }
-
- const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
- const rad = offset * Math.PI / 180
- const x = 100 + 90 * Math.sin(rad)
- const y = 100 - 90 * Math.cos(rad)
- const h = Math.round((offset + 360) % 360)
- const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
- return lbl ? `
${lbl}` : ''
- }).join('')
-
- return `
-
- `
-}
-
-function buildGaugeHtml(label: string, value: any, unit: string, type: string): string {
- return `
-
-
${label}
-
- ${value}
- ${unit}
-
-
- `
-}
-
-function buildPopupHtml(data: any): string {
- const prefs = getUnits()
- const speed = SPEED_UNITS[prefs.speed as keyof typeof SPEED_UNITS]
- const alt = ALTITUDE_UNITS[prefs.altitude as keyof typeof ALTITUDE_UNITS]
- const vert = VERTICAL_UNITS[prefs.vertical as keyof typeof VERTICAL_UNITS]
- const callsign = data.name?.trim()
- const altitude = data.alt_baro ?? data.alt_geom ?? 0
-
- return `
-
-
-
-
- ${data.country || ''} • ${data.class || 'Unknown'} • ${data.type || ''}
- ICAO: ${(data.icao || '').toUpperCase()}
-
-
-
-
- ${buildGaugeHtml('Air Speed', speed.convert(data.speed || 0), speed.label, 'speed')}
- ${buildGaugeHtml('Altitude', data.landed ? 'LANDED' : alt.convert(altitude), data.landed ? '' : alt.label, 'altitude')}
- ${buildGaugeHtml('Climb', vert.convert(data.climb || 0) >= 0 ? `+${vert.convert(data.climb || 0)}` : vert.convert(data.climb || 0), vert.label, 'vertical')}
-
-
${buildNavballSvg(data)}
-
-
- `
+interface PopupInstance {
+ planeRef: ReturnType
+ posRef: ReturnType
+ unmount: () => void
}
export class AirTrafficLayer {
@@ -178,28 +42,25 @@ export class AirTrafficLayer {
private layer!: VectorLayer
private traceLayers: Record> = {}
private traceSegs: Record = {}
- private popups: Record void }> = {}
- private historyCache: Record = {}
- private data: any[] = []
- private clickHandler: ((e: any) => void) | null = null
- private refreshTimer: any = null;
+ private popups: any = {}
+ private historyCache: any = {}
+ private data: any[] = []
+ private clickHandler: ((e: any) => void) | null = null
+ private refreshTimer: any = null
visible = false
constructor(map: Map) { this.map = map }
- // ── Public ────────────────────────────────────────────────────────────────
+ // ── Public ──────────────────────────────────────────────────────────────────
async show() {
if (this.visible) return
this.visible = true
-
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
this.map.addLayer(this.layer)
-
await this._fetch()
this._draw()
this._attachClick()
-
this.refreshTimer = adjustedInterval(async () => {
await this._fetch()
this._draw()
@@ -210,19 +71,16 @@ export class AirTrafficLayer {
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 icao of Object.keys(this.popups)) this._closePopup(icao)
-
this.map.removeLayer(this.layer)
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
this.traceLayers = {}
this.traceSegs = {}
}
- // ── Private ───────────────────────────────────────────────────────────────
+ // ── Private ──────────────────────────────────────────────────────────────────
private async _fetch() {
const j = await fetch(`${API}/adsb`).then(r => r.json())
@@ -241,7 +99,6 @@ export class AirTrafficLayer {
private _draw() {
const source = this.layer.getSource()!
source.clear()
-
for (const plane of this.data) {
if (plane.latitude == null) continue
const coord = fromLonLat([plane.longitude, plane.latitude])
@@ -266,8 +123,7 @@ export class AirTrafficLayer {
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
this.historyCache[icao] = j.history || []
}
-
- const history = [...(this.historyCache[icao])]
+ const history = [...this.historyCache[icao]]
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
const last = history[history.length - 1]
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
@@ -292,7 +148,7 @@ export class AirTrafficLayer {
const s = history[i], e = history[i + 1]
const sc = getAltColor(s.altitude || 0)
const ec = getAltColor(e.altitude || 0)
- const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2))
+ const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2))
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
vs.addFeature(f)
@@ -301,67 +157,46 @@ export class AirTrafficLayer {
this.traceSegs[icao] = segs
}
+ private _calcPopupPos(plane: any): { x: number; y: number } {
+ const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.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(plane: any) {
const icao = plane.icao
if (this.popups[icao]) return
- const el = document.createElement('div')
- el.style.cssText = `
- position:absolute; z-index:9999; pointer-events:auto;
- background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3);
- border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden;
- `
+ const planeRef = ref(plane)
+ const posRef = ref(this._calcPopupPos(plane))
- const close = document.createElement('button')
- close.innerHTML = '✕'
- close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
- close.onclick = () => this._closePopup(icao)
+ const container = document.createElement('div')
+ document.body.appendChild(container)
- el.innerHTML = buildPopupHtml(plane)
- el.appendChild(close)
- document.body.appendChild(el)
- this._positionPopup(el, plane)
-
- el.querySelectorAll('.at-gauge').forEach(g => {
- g.addEventListener('click', (e) => {
- const prefs = getUnits()
- const type = (e.currentTarget as HTMLElement).dataset.gauge!
- if (type === 'speed') {
- const keys = Object.keys(SPEED_UNITS)
- prefs.speed = keys[(keys.indexOf(prefs.speed) + 1) % keys.length]
- } else if (type === 'altitude') {
- const keys = Object.keys(ALTITUDE_UNITS)
- prefs.altitude = keys[(keys.indexOf(prefs.altitude) + 1) % keys.length]
- prefs.vertical = prefs.altitude === 'meters' ? 'mps' : 'fps'
- } else if (type === 'vertical') {
- const keys = Object.keys(VERTICAL_UNITS)
- prefs.vertical = keys[(keys.indexOf(prefs.vertical) + 1) % keys.length]
- prefs.altitude = prefs.vertical === 'mps' ? 'meters' : 'feet'
- }
- saveUnits(prefs)
- el.innerHTML = buildPopupHtml(plane)
- el.appendChild(close)
- })
- g.addEventListener('mouseenter', e => (e.currentTarget as HTMLElement).style.borderColor = '#0ff')
- g.addEventListener('mouseleave', e => (e.currentTarget as HTMLElement).style.borderColor = '#0f0')
+ const app = createApp(AircraftPopup, {
+ plane: planeRef.value,
+ position: posRef.value,
+ onClose: () => this._closePopup(icao),
+ onBringToFront: () => {
+ highestZIndex++
+ container.style.zIndex = String(highestZIndex)
+ },
})
- this.popups[icao] = { el, destroy: () => el.remove() }
- this._fetchTrace(plane)
- }
+ app.mount(container)
- private _positionPopup(el: HTMLDivElement, plane: any) {
- const pixel = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
- if (!pixel) return
- const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
- el.style.left = `${rect.left + pixel[0] + 16}px`
- el.style.top = `${rect.top + pixel[1] - 16}px`
+ this.popups[icao] = {
+ planeRef,
+ posRef,
+ unmount: () => { app.unmount(); container.remove() },
+ }
+ this._fetchTrace(plane)
}
private _closePopup(icao: string) {
const popup = this.popups[icao]
- if (popup) { popup.destroy(); delete this.popups[icao] }
-
+ if (popup) { popup.unmount(); delete this.popups[icao] }
const segs = this.traceSegs[icao]
const layer = this.traceLayers[icao]
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
@@ -373,14 +208,8 @@ 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 }
- const { el } = this.popups[icao]
- el.innerHTML = buildPopupHtml(plane)
- const close = document.createElement('button')
- close.innerHTML = '✕'
- close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
- close.onclick = () => this._closePopup(icao)
- el.appendChild(close)
- this._positionPopup(el, plane)
+ this.popups[icao].planeRef.value = plane
+ // Don't reposition — user may have dragged it 🎯
this._fetchTrace(plane)
}
}
diff --git a/client/src/services/api.ts b/client/src/services/api.ts
index 2de095f..2ddf213 100644
--- a/client/src/services/api.ts
+++ b/client/src/services/api.ts
@@ -1,8 +1,16 @@
-export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
+import {ref, onUnmounted} from 'vue';
export type DataRow = Record
+export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
+
const cache: any = {};
+const current = ref(null);
+const hourly = ref([]);
+const daily = ref([]);
+
+let interval: ReturnType;
+let refCount = 0;
async function get(path: string, params: Record = {}): Promise {
const url = new URL(`${BASE}${path}`, window.location.origin);
@@ -29,3 +37,24 @@ export const api = {
...(fields ? {fields} : {})
}),
};
+
+async function refresh() {
+ const start = new Date(), end = new Date();
+ start.setDate(start.getDate() + 1);
+ end.setDate(end.getDate() + 5);
+ current.value = await api.current();
+ return {current}
+}
+
+export function useWeather(ms = 5 * 60_000) {
+ if(++refCount === 1) {
+ refresh();
+ interval = setInterval(refresh, ms);
+ }
+
+ onUnmounted(() => {
+ if(--refCount === 0) clearInterval(interval);
+ });
+
+ return {current, hourly, daily, refresh};
+}
diff --git a/client/src/views/Dashboard.vue b/client/src/views/Dashboard.vue
index 37b6679..4aad0e2 100644
--- a/client/src/views/Dashboard.vue
+++ b/client/src/views/Dashboard.vue
@@ -6,7 +6,7 @@ import Precipitation from '@/components/Precipitation.vue';
import Seismic from '@/components/Seismic.vue';
import Sun from '@/components/Sun.vue';
import Wind from '@/components/Wind.vue';
-import {BASE} from '@/services/api.ts';
+import {BASE, useWeather} from '@/services/api.ts';
import {formatDate} from '@ztimson/utils';
import {onMounted, onUnmounted, provide, ref} from 'vue';
import MapView from '../components/MapView.vue';
@@ -23,6 +23,7 @@ let clock: ReturnType;
provide('openGraph', (key: string) => selectedMetric.value = key);
onMounted(async () => {
+ useWeather();
clock = setInterval(() => {
day.value = formatDate('dddd');
date.value = formatDate('MMMM D');
diff --git a/server/src/server.mjs b/server/src/server.mjs
index 0fb95cc..f645aaf 100644
--- a/server/src/server.mjs
+++ b/server/src/server.mjs
@@ -56,20 +56,11 @@ app.get('/api/current', async (req, res) => {
const space = getCelestialCurrent(coords.latitude, coords.longitude)
const condition = getWeatherCondition(Object.assign(sensors, space))
- const forecast = await getForecast();
- const merged = {
- ...(forecast?.summary ? {
- precipitation_chance: forecast.summary.precipitation_chance,
- temperature_max: forecast.summary.temperature_max,
- temperature_min: forecast.summary.temperature_min,
- uv_index_max: forecast.summary.uv_index,
- } : {}),
+ res.json(filterFields({
...condition,
...sensors,
...space,
- }
-
- res.json(filterFields(merged, fields))
+ }, fields))
})
// ── Hourly ────────────────────────────────────────────────────────────────────
@@ -148,10 +139,10 @@ app.get('/api/position', async (req, res) => {
// ── 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/space', async (req, res) => {
+// const {fields} = req.query;
+// res.json(filterFields(await getSpaceWeather(), fields));
+// });
// ── ADSB Proxy ────────────────────────────────────────────────────────────────