diff --git a/client/index.html b/client/index.html index 9e5fc8f..8f71eb0 100644 --- a/client/index.html +++ b/client/index.html @@ -1,13 +1,15 @@ - - - - - Vite App - - -
- - + + Weather Station + + + + + + + +
+ + diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue index c61d0ce..fba76b7 100644 --- a/client/src/components/MapView.vue +++ b/client/src/components/MapView.vue @@ -1,4 +1,5 @@ diff --git a/client/src/services/airtraffic.ts b/client/src/services/airtraffic.ts new file mode 100644 index 0000000..4a83325 --- /dev/null +++ b/client/src/services/airtraffic.ts @@ -0,0 +1,426 @@ +import Map from 'ol/Map' +import { Feature } from 'ol' +import Point from 'ol/geom/Point' +import LineString from 'ol/geom/LineString' +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' + +const API = '/api' + +const COLORS: Record = { + 'Cargo': '#00ff00', + 'Military': '#ff0000', + 'Private': '#8450ea', + 'Passenger': '#009dff', + 'Unknown': '#fad106', +} + +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)) } + +function getAltColor(alt: number): [number, number, number] { + const a = Math.max(0, alt) + if (a < 10) return [0, 0, 0] + if (a < 10000) return [135, 206, 250] + if (a < 25000) { + const r = (a - 10000) / 15000 + return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)] + } + if (a < 40000) { + const r = (a - 25000) / 15000 + return [0, 0, Math.round(255 * (1-r) + 139 * r)] + } + const r = Math.min((a - 40000) / 10000, 1) + return [Math.round(128 * r), 0, 139] +} + +function buildPlaneIcon(plane: any, shapes: any): string { + const color = COLORS[plane.class] || COLORS['Unknown'] + const shape = shapes[plane.shape] || shapes['unknown'] || shapes[Object.keys(shapes)[0]] + if (!shape) { + return `data:image/svg+xml,${encodeURIComponent(``)}` + } + const paths = (Array.isArray(shape.path) ? shape.path : [shape.path]) + .map((p: string) => ``) + .join('') + return `data:image/svg+xml,${encodeURIComponent(`${paths}`)}` +} + +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 ` + + + + + + + + + + + + + + ${heading.toFixed(0).padStart(3, '0')}° + + + + + ${pitchLines.join('')} + + ${rollTicks.join('')} + + + + + ${compassLabels} + + + ` +} + +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 ` +
+
+

+ ✈️ ${callsign + ? `${callsign.toUpperCase()}` + : 'N/A'} +

+
+ ${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)}
+
+
+ ` +} + +export class AirTrafficLayer { + private map: Map + private layer!: VectorLayer + private traceLayers: Record> = {} + private traceSegs: Record = {} + private popups: Record void }> = {} + private historyCache: Record = {} + private shapes: any = null + private data: any[] = [] + private clickHandler: ((e: any) => void) | null = null + private refreshTimer: ReturnType | null = null + visible = false + + constructor(map: Map) { this.map = map } + + // ── Public ──────────────────────────────────────────────────────────────── + + async show() { + if (this.visible) return + this.visible = true + + if (!this.shapes) this.shapes = await fetch(`${API}/air-traffic-shapes`).then(r => r.json()) + + this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 }) + this.map.addLayer(this.layer) + + await this._fetch() + this._draw() + this._attachClick() + + this.refreshTimer = setInterval(async () => { + await this._fetch() + this._draw() + this._refreshPopups() + }, 15_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 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 async _fetch() { + const j = await fetch(`${API}/air-traffic`).then(r => r.json()) + this.data = j.data || [] + } + + private _draw() { + const source = this.layer.getSource()! + const existing: Record = {} + source.getFeatures().forEach(f => { existing[f.get('icao')] = f }) + const incoming = new Set(this.data.filter(p => p.latitude != null).map(p => p.icao)) + + // Remove stale + Object.keys(existing).forEach(icao => { + if (!incoming.has(icao)) { source.removeFeature(existing[icao]); this._closePopup(icao) } + }) + + // Update / add + for (const plane of this.data) { + if (plane.latitude == null) continue + const coord = fromLonLat([plane.longitude, plane.latitude]) + const style = new Style({ + image: new Icon({ + src: buildPlaneIcon(plane, this.shapes), + scale: 1, + rotation: (plane.heading ?? 0) * (Math.PI / 180), + anchor: [0.5, 0.5], + }), + }) + + if (existing[plane.icao]) { + ;(((existing[plane.icao]).getGeometry() as Point)).setCoordinates(coord) + (existing[plane.icao]).setStyle(style) + (existing[plane.icao]).set('planeData', plane) + } else { + const f = new Feature({ geometry: new Point(coord) }) + f.set('icao', plane.icao) + f.set('planeData', plane) + f.setStyle(style) + source.addFeature(f) + } + } + } + + private async _fetchTrace(plane: any) { + const icao = plane.icao + if (!this.historyCache[icao]) { + const j = await fetch(`${API}/air-traffic/${icao}`).then(r => r.json()) + this.historyCache[icao] = j.history || [] + } + + 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) + if (history.length < 2) return + + let traceLayer = this.traceLayers[icao] + let vs: VectorSource + + if (!traceLayer) { + vs = new VectorSource() + traceLayer = new VectorLayer({ source: vs, zIndex: 99 }) + this.map.addLayer(traceLayer) + this.traceLayers[icao] = traceLayer + this.traceSegs[icao] = [] + } else { + vs = traceLayer.getSource()! + ;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f)) + } + + const segs: Feature[] = [] + for (let i = 0; i < history.length - 1; i++) { + 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 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) + segs.push(f) + } + this.traceSegs[icao] = segs + } + + 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 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.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') + }) + + this.popups[icao] = { el, destroy: () => el.remove() } + this._fetchTrace(plane) + } + + 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` + } + + private _closePopup(icao: string) { + const popup = this.popups[icao] + if (popup) { popup.destroy(); delete this.popups[icao] } + + const segs = this.traceSegs[icao] + const layer = this.traceLayers[icao] + if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f)) + if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] } + delete this.traceSegs[icao] + } + + private _refreshPopups() { + 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._fetchTrace(plane) + } + } + + private _attachClick() { + this.clickHandler = (evt) => { + const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null) + if (!f) return + const plane = f.get('planeData') + if (this.popups[plane.icao]) { this._closePopup(plane.icao); return } + this._openPopup(plane) + } + this.map.on('singleclick', this.clickHandler) + } +} diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 4abbcf8..2f1d0ea 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -1,4 +1,4 @@ -const BASE = import.meta.env.DEV ? 'http://localhost:3000' : '' +const BASE = import.meta.env.DEV ? 'http://10.69.5.23:3000' : '' export type DataRow = Record diff --git a/client/src/views/Dashboard.vue b/client/src/views/Dashboard.vue index c029116..18e1adb 100644 --- a/client/src/views/Dashboard.vue +++ b/client/src/views/Dashboard.vue @@ -14,20 +14,19 @@ const selectedMetric = ref(null) let interval: ReturnType onMounted(async () => { - await fetchAll() - interval = setInterval(fetchAll, 60 * 1000) + await fetchAll() + interval = setInterval(fetchAll, 60 * 1000) }) onUnmounted(() => clearInterval(interval)) -// Group all available metric keys present in current data const groupedMetrics = computed(() => { - return GROUPS.map(group => ({ - group, - keys: Object.entries(METRICS) - .filter(([key, meta]) => meta.group === group && current.value[key] != null) - .map(([key]) => key) - })).filter(g => g.keys.length > 0) + return GROUPS.map(group => ({ + group, + keys: Object.entries(METRICS) + .filter(([key, meta]) => meta.group === group && current.value[key] != null) + .map(([key]) => key) + })).filter(g => g.keys.length > 0) }) @@ -96,50 +95,27 @@ const groupedMetrics = computed(() => { @media (max-width: 768px) { .dashboard { flex-direction: column; - position: relative; - overflow: hidden; + overflow-y: auto; + height: 100dvh; } .map-col { - position: absolute; - inset: 0; - height: 100vh; + width: 100%; + height: 100vw; + min-height: 100vw; padding: 8px; - z-index: 0; + flex-shrink: 0; } .panel-col { - position: absolute; - bottom: 0; - left: 0; - right: 0; - z-index: 10; width: 100%; + flex-shrink: 0; border-left: none; border-top: 1px solid var(--border); - border-radius: 16px 16px 0 0; - - /* Show a peek of the panel, scrolls up over the map */ - max-height: calc(100vh - 120px); - min-height: 40vh; - overflow-y: auto; - - background: var(--bg); - padding: 12px 16px 24px; - - /* Smooth momentum scroll on iOS */ + overflow-y: visible; -webkit-overflow-scrolling: touch; - &::before { - content: ''; - display: block; - width: 36px; - height: 4px; - background: var(--border); - border-radius: 2px; - margin: 0 auto 12px; - } - + &::before { display: none; } &::-webkit-scrollbar { display: none; } } } diff --git a/server/airtraffic.mjs b/server/airtraffic.mjs new file mode 100644 index 0000000..38e62ff --- /dev/null +++ b/server/airtraffic.mjs @@ -0,0 +1,49 @@ +import { cfg } from './config.mjs' +import { writeFile, readFile, existsSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const DIR = dirname(fileURLToPath(import.meta.url)) +const SHAPES_PATH = resolve(DIR, 'shapes.json') + +export async function getShapes() { + if (existsSync(SHAPES_PATH)) { + return JSON.parse(await readFile(SHAPES_PATH, 'utf8')) + } + + const r = await fetch('https://raw.githubusercontent.com/wiedehopf/tar1090/master/html/markers.min.js') + const text = await r.text() + + // tar1090 exposes a var with all the shape definitions + let shapes = {} + try { + const match = text.match(/var\s+_aircraft_markers\s*=\s*(\{[\s\S]*?\});/) + if (match) shapes = JSON.parse(match[1]) + } catch (e) { + console.error('Failed to parse tar1090 shapes:', e) + } + + writeFile(SHAPES_PATH, JSON.stringify(shapes), () => {}) + return shapes +} + +export async function getAirTraffic() { + const { ADSB_URL } = cfg() + if (!ADSB_URL) return { data: [] } + const r = await fetch(`${ADSB_URL}/re-api/?all`) + const j = await r.json() + return { data: j.aircraft || [] } +} + +export async function getAirTrafficHistory(icao) { + const { ADSB_URL } = cfg() + if (!ADSB_URL) return { history: [] } + const r = await fetch(`${ADSB_URL}/re-api/?trace&icao=${icao}`) + const j = await r.json() + const history = (j.trace || []).map(t => ({ + latitude: t[1], + longitude: t[2], + altitude: t[3] || 0, + })) + return { history } +} diff --git a/server/config.mjs b/server/config.mjs index f1fcca8..422b656 100644 --- a/server/config.mjs +++ b/server/config.mjs @@ -9,6 +9,7 @@ export function cfg() { config({ path: resolve(ROOT, '.env.local'), override: true }) return { PORT: process.env.PORT || 3000, + ADSB_URL: process.env.ADSB_URL || '', INFLUX_URL: process.env.INFLUX_URL || 'http://localhost:8086', INFLUX_TOKEN: process.env.INFLUX_TOKEN || '', INFLUX_ORG: process.env.INFLUX_ORG || 'weather', diff --git a/server/public/favicon.png b/server/public/favicon.png new file mode 100644 index 0000000..27b92f0 Binary files /dev/null and b/server/public/favicon.png differ diff --git a/server/server.mjs b/server/server.mjs index f99a790..b380977 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -9,6 +9,7 @@ import { getOpenMeteo } from './openmeteo.mjs' import { apiReference } from '@scalar/express-api-reference' import { spec } from './spec.mjs' import { existsSync } from 'fs' +import {getAirTraffic, getAirTrafficHistory, getAirTrafficShapes} from './airtraffic.mjs'; const app = express() const DIR = dirname(fileURLToPath(import.meta.url)) @@ -119,6 +120,11 @@ app.get('/api/daily', async (req, res) => { res.json(filterArr(result, fields)) }) +// -- ADSB ---------------------------------------------------------------------- +app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic())) +app.get('/api/air-traffic/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao))) +app.get('/api/air-traffic-shapes', async (req, res) => res.json(await getShapes())) + // ── DOCS ---------------------------------------------------------------------- app.get('/openapi.json', (req, res) => res.json(spec))