AIS fixes + daily
This commit is contained in:
4
README.md
Normal file
4
README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## TODO
|
||||
- Make sure openmeteo properties match + we backfill any missing
|
||||
- Fix daily (missing future dates + timestamp on 24 hour forecast is wrong)
|
||||
- Update openapi doc
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #d0dae1;
|
||||
--surface: #e5e9ec;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
|
||||
@@ -149,8 +149,8 @@ const navball = computed(() => {
|
||||
|
||||
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 x = 100 + 70 * Math.sin(rad)
|
||||
const y = 100 - 70 * 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 ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getAQILabel, getPressureTrend} from '@/services/units.ts';
|
||||
import {inject, onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
@@ -107,22 +108,37 @@ onMounted(async () => {
|
||||
<div class="stat" @click="openGraph?.('heat_index')">
|
||||
<span class="stat-label">Feels Like</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ ~~(data.heat_index) }}°C</span>
|
||||
<span v-else class="stat-value">{{ ~~(data.heat_index) }}<span class="fg-muted">°C</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('humidity')">
|
||||
<span class="stat-label">Humidity</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ Math.round(data.humidity) }}% <span class="fg-muted">({{ data.humidity_abs.toFixed(2) }})</span></span>
|
||||
<span v-else class="stat-value">{{ Math.round(data.humidity) }}%</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('clouds')">
|
||||
<span class="stat-label">Cloud Cover</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ Math.round(data.clouds) }}%</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.pressure_hpa?.toFixed(1) }} <span class="fg-muted">hPa</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_slp')">
|
||||
<span class="stat-label">Sea Level</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ getPressureTrend(data.pressure_trend) }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
|
||||
<span class="stat-label">VPD KPA</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.vapor_pressure_deficit?.toFixed(2) }} <span class="fg-muted">kPa</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('air_quality')">
|
||||
<span class="stat-label">Air Quality</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.air_quality }} <span class="fg-muted">({{ data.air_quality_label }})</span></span>
|
||||
<span v-else class="stat-value">{{ data.air_quality }} <span class="fg-muted">/ 500</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
||||
<span class="stat-label">Gas Resistance</span>
|
||||
@@ -132,27 +148,7 @@ onMounted(async () => {
|
||||
<div class="stat" @click="openGraph?.('visibility')">
|
||||
<span class="stat-label">Visibility</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.visibility?.toFixed(1) }} km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.pressure_hpa?.toFixed(1) }} <span class="fg-muted">({{ (data.pressure_rate > 0 ? '+' : '') + data.pressure_rate }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_slp')">
|
||||
<span class="stat-label">Sea Level</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ data.pressure_trend }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
|
||||
<span class="stat-label">VPD KPA</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.vapor_pressure_deficit?.toFixed(2) }}</span>
|
||||
<span v-else class="stat-value">{{ data.visibility?.toFixed(1) }} <span class="fg-muted">km</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getPressureTrend} from '@/services/units.ts';
|
||||
import {inject} from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
@@ -61,55 +62,50 @@ const {current: d} = useWeather();
|
||||
<div class="card-title">🌧️ Precipitation</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Raining</span>
|
||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||
<span class="stat-label">Rain</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.raining == 'True' ? d.storm_direction : 'No' }}</span>
|
||||
<span v-else class="stat-value">{{ d.raining == null ? '—' : d.raining ? 'Raining' : d.precipitation_chance + '%' }}</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_rate')">
|
||||
<span class="stat-label">Lightning</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_rate }}<span class="fg-muted">/hr</span></span>
|
||||
<span v-else class="stat-value">{{ d.lightning_rate ?? '—' }}<span v-if="d.lightning_rate" class="fg-muted">/hr</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_distance')">
|
||||
<span class="stat-label">Distance</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_distance }} <span class="fg-muted">km</span></span>
|
||||
<span v-else class="stat-value">{{ d.lightning_distance ?? '—' }} <span v-if="d.lightning_distance" class="fg-muted">km</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||
<span class="stat-label">Chance</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.precipitation_chance }}<span class="fg-muted">%</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</span>
|
||||
<span class="stat-label">Pressure (SLP)</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.pressure_hpa.toFixed(1) }} <span class="fg-muted">({{d.pressure_trend}})</span></span>
|
||||
<span v-else class="stat-value">{{ d.pressure_slp?.toFixed(1) ?? '—' }} <span v-if="d.pressure_slp" class="fg-muted">({{getPressureTrend(d.pressure_rate)}})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('precipitation')">
|
||||
<span class="stat-label">Precipitation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.precipitation.toFixed(1) }} <span class="fg-muted">mm</span></span>
|
||||
<span v-else class="stat-value">{{ d.precipitation?.toFixed(1) ?? '—' }} <span v-if="d.precipitation != null" class="fg-muted">mm</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('accumulation')">
|
||||
<span class="stat-label">Accumulation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.accumulation.toFixed(1) }} <span class="fg-muted">mm {{ d.accumulation_type != 'none' ? `(${d.accumulation_type})` : '' }}</span></span>
|
||||
<span v-else class="stat-value">{{ d.accumulation?.toFixed(1) ?? '—' }} <span v-if="d.accumulation != null" class="fg-muted">cm</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Dew Point</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.dew_point.toFixed(1) }}<span class="fg-muted">°C</span></span>
|
||||
<span v-else class="stat-value">{{ d.dew_point?.toFixed(1) ?? '—' }}<span v-if="d.dew_point" class="fg-muted">°C</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Frost Risk</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.frost_risk }}</span>
|
||||
<span v-else class="stat-value">{{ d.frost_risk ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,8 +36,8 @@ function toPath(pts: number[]) {
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
d.value = await api.current('magnitude');
|
||||
history.value.push(d.value.magnitude ?? 0);
|
||||
d.value = await api.current('seismic_magnitude');
|
||||
history.value.push(d.value.seismic_magnitude ?? 0);
|
||||
if(history.value.length > MAX_PTS) history.value.shift();
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ svg { width: 100%; height: 60px; overflow: visible; }
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<MetricRow label="Magnitude" :value="d.magnitude?.toFixed(1)" metric-key="magnitude" :data="d" />
|
||||
<MetricRow label="Magnitude" :value="d.seismic_magnitude?.toFixed(1)" metric-key="seismic_magnitude" :data="d" />
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||
<line :x1="0" :y1="H/2" :x2="W" :y2="H/2" stroke="var(--border)" stroke-width="1" />
|
||||
<path :d="toPath(history)" fill="none" stroke="var(--accent)" stroke-width="1.5" />
|
||||
|
||||
@@ -24,42 +24,81 @@ function cycleSpeed() {
|
||||
}
|
||||
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||
|
||||
// ── Derived data ──────────────────────────────────────────────────────────────
|
||||
const name = computed(() => props.boat.name?.trim() || props.boat.callsign?.trim() || `MMSI: ${props.boat.mmsi}`)
|
||||
const name = computed(() => props.boat.name?.trim() || props.boat.callsign?.trim() || '-')
|
||||
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0)
|
||||
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
||||
const rows = computed(() => [
|
||||
['MMSI', props.boat.mmsi],
|
||||
['Country', props.boat.country],
|
||||
['Callsign', props.boat.callsign || '-'],
|
||||
['IMO', props.boat.imo || '-'],
|
||||
['Ship Type', props.boat.ship_type ?? '-'],
|
||||
['Lat / Lon', `${props.boat.lat?.toFixed(5)}, ${props.boat.lon?.toFixed(5)}`],
|
||||
['Distance', props.boat.distance != null ? `${props.boat.distance.toFixed(2)} nmi` : '-'],
|
||||
['Bearing', props.boat.bearing != null ? `${props.boat.bearing}°` : '-'],
|
||||
['Destination', props.boat.destination || '-'],
|
||||
['ETA', props.boat.eta || '-'],
|
||||
['Draught', props.boat.draught != null ? `${props.boat.draught}m` : '-'],
|
||||
['RSSI', props.boat.rssi != null ? `${props.boat.rssi.toFixed(1)} dB` : '-'],
|
||||
['Channels', props.boat.channels || '-'],
|
||||
['Last Signal', props.boat.last_signal != null ? `${props.boat.last_signal}s ago` : '-'],
|
||||
])
|
||||
|
||||
// ── Compass HUD ───────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const header = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||
e.preventDefault()
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||
}
|
||||
function onMouseUp() { isDragging.value = false }
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||
const touch: any = e.touches[0]
|
||||
const sx = touch.clientX, sy = touch.clientY
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||
if (header.value) header.value.style.opacity = '0.8'
|
||||
}, 500)
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
const touch: any = e.touches[0]
|
||||
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onTouchEnd() {
|
||||
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
|
||||
const compass = computed(() => {
|
||||
const hdg = heading.value
|
||||
const spd = props.boat.speed ?? 0
|
||||
const course = props.boat.course ?? hdg
|
||||
const bearing = props.boat.bearing ?? 0
|
||||
|
||||
// Compass tape: show ±60° window
|
||||
// ── Compass tape ──────────────────────────────────────────────────────────
|
||||
const tapeTicks: string[] = []
|
||||
for (let offset = -60; offset <= 60; offset += 5) {
|
||||
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||
const x = 100 + (offset / 60) * 90
|
||||
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||
const x = 100 + (offset / 60) * 90
|
||||
const major = offset % 30 === 0
|
||||
const mid = offset % 15 === 0
|
||||
|
||||
if (major) {
|
||||
const lbl = ({ 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' } as any)[deg]
|
||||
?? String(deg).padStart(3, '0')
|
||||
@@ -74,105 +113,87 @@ const compass = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Depth/rudder gauge (visual only, based on course vs heading difference)
|
||||
// ── Rose centre & radii ───────────────────────────────────────────────────
|
||||
const CX = 100, CY = 158 // moved down from 145
|
||||
const R = 68 // grown from 55
|
||||
|
||||
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
||||
const rudX = 100 + (rudder / 30) * 40
|
||||
const rudX = CX + (rudder / 30) * 40
|
||||
|
||||
// Speed arc (0–30 kts range)
|
||||
const spdPct = Math.min(spd / 30, 1)
|
||||
const arcEnd = 160 + spdPct * 140 // degrees on a 160–300° arc
|
||||
function polarToXY(cx: number, cy: number, r: number, deg: number) {
|
||||
const rad = (deg - 90) * Math.PI / 180
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||
}
|
||||
function describeArc(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
|
||||
const s = polarToXY(cx, cy, r, startDeg)
|
||||
const e = polarToXY(cx, cy, r, endDeg)
|
||||
const lg = endDeg - startDeg > 180 ? 1 : 0
|
||||
return `M ${s.x} ${s.y} A ${r} ${r} 0 ${lg} 1 ${e.x} ${e.y}`
|
||||
}
|
||||
const spdArcPath = describeArc(100, 145, 52, 160, Math.max(161, arcEnd))
|
||||
|
||||
// Course indicator needle angle (relative to up = heading)
|
||||
const courseRel = ((course - hdg + 540) % 360) - 180
|
||||
const courseRad = courseRel * Math.PI / 180
|
||||
const cNeedleX = 100 + 44 * Math.sin(courseRad)
|
||||
const cNeedleY = 145 - 44 * Math.cos(courseRad)
|
||||
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
||||
const cNeedleY = CY - 52 * Math.cos(courseRad)
|
||||
|
||||
// Bearing indicator
|
||||
const bearRel = ((bearing - hdg + 540) % 360) - 180
|
||||
const bearRad = bearRel * Math.PI / 180
|
||||
const bNeedleX = 100 + 52 * Math.sin(bearRad)
|
||||
const bNeedleY = 145 - 52 * Math.cos(bearRad)
|
||||
const bearRel = ((bearing - hdg + 540) % 360) - 180
|
||||
const bearRad = bearRel * Math.PI / 180
|
||||
const bNeedleX = CX + 52 * Math.sin(bearRad)
|
||||
const bNeedleY = CY - 52 * Math.cos(bearRad)
|
||||
|
||||
// Ring tick marks scaled to new R
|
||||
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
||||
const a = i * 10
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const r1 = a % 30 === 0 ? R - 11 : R - 5
|
||||
const sw = a % 30 === 0 ? 2 : 1
|
||||
const x1 = CX + r1 * Math.cos(rad)
|
||||
const y1 = CY + r1 * Math.sin(rad)
|
||||
const x2 = CX + R * Math.cos(rad)
|
||||
const y2 = CY + R * Math.sin(rad)
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
|
||||
}).join('')
|
||||
|
||||
const cardinals = (['N','E','S','W'] as const).map((lbl, i) => {
|
||||
const a = i * 90
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const x = CX + (R - 18) * Math.cos(rad)
|
||||
const y = CY + (R - 18) * Math.sin(rad) + 4
|
||||
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
|
||||
}).join('')
|
||||
|
||||
// Legend & rudder bar shifted to bottom of taller SVG
|
||||
const LEG_Y = 190
|
||||
|
||||
return `
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" style="background:#000;border-radius:4px">
|
||||
<!-- Compass tape -->
|
||||
<svg width="200" height="230" viewBox="0 0 200 230">
|
||||
<!-- Tape -->
|
||||
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
|
||||
<line x1="10" y1="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
|
||||
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
||||
${tapeTicks.join('')}
|
||||
<!-- Centre lubber line -->
|
||||
<polygon points="100,12 96,20 104,20" fill="#ff0"/>
|
||||
<polygon points="100,20 96,12 104,12" fill="#ff0"/>
|
||||
|
||||
<!-- Heading readout -->
|
||||
<rect x="72" y="60" width="56" height="20" fill="rgba(0,0,0,0.9)" stroke="#0ff" stroke-width="1" rx="2"/>
|
||||
<text x="100" y="75" text-anchor="middle" fill="#0ff" font-size="13" font-weight="bold">${String(Math.round(hdg)).padStart(3, '0')}°</text>
|
||||
<rect x="72" y="64" width="56" height="20" fill="rgba(0,0,0,0.9)" stroke="#0ff" stroke-width="1" rx="2"/>
|
||||
<text x="100" y="78" text-anchor="middle" fill="#0ff" font-size="13" font-weight="bold">${String(Math.round(hdg)).padStart(3, '0')}°</text>
|
||||
|
||||
<!-- Rose ring -->
|
||||
<circle cx="100" cy="145" r="55" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
|
||||
<circle cx="100" cy="145" r="40" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
|
||||
<!-- Rose -->
|
||||
<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
|
||||
<circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
|
||||
${ringTicks}
|
||||
${cardinals}
|
||||
|
||||
<!-- Ring tick marks -->
|
||||
${Array.from({ length: 36 }, (_, i) => {
|
||||
const a = i * 10
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const r1 = a % 30 === 0 ? 44 : 50
|
||||
const sw = a % 30 === 0 ? 2 : 1
|
||||
const x1 = 100 + r1 * Math.cos(rad)
|
||||
const y1 = 145 + r1 * Math.sin(rad)
|
||||
const x2 = 100 + 55 * Math.cos(rad)
|
||||
const y2 = 145 + 55 * Math.sin(rad)
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
|
||||
}).join('')}
|
||||
|
||||
<!-- Cardinal labels -->
|
||||
${(['N','E','S','W'] as const).map((lbl, i) => {
|
||||
const a = i * 90
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const x = 100 + 47 * Math.cos(rad)
|
||||
const y = 145 + 47 * Math.sin(rad) + 4
|
||||
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="9" font-weight="bold" opacity="0.7">${lbl}</text>`
|
||||
}).join('')}
|
||||
|
||||
<!-- Course needle (green) -->
|
||||
<line x1="100" y1="145" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||
<!-- Course needle -->
|
||||
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
|
||||
|
||||
<!-- Bearing needle (yellow dashed) -->
|
||||
<line x1="100" y1="145" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<!-- Bearing needle -->
|
||||
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
|
||||
|
||||
<!-- Ship symbol -->
|
||||
<polygon points="100,138 96,152 100,149 104,152" fill="#fff" stroke="#0ff" stroke-width="1"/>
|
||||
|
||||
<!-- Speed arc -->
|
||||
<path d="${spdArcPath}" fill="none" stroke="#0f0" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="${describeArc(100, 145, 52, 160, 300)}" fill="none" stroke="rgba(0,255,0,0.15)" stroke-width="4"/>
|
||||
|
||||
<!-- Speed readout -->
|
||||
<text x="100" y="190" text-anchor="middle" fill="#0f0" font-size="11" font-weight="bold">${speedVal.value} ${speed.value.label}</text>
|
||||
<polygon points="${CX},${CY - 9} ${CX - 4},${CY + 5} ${CX},${CY + 2} ${CX + 4},${CY + 5}" fill="#fff" stroke="#0ff" stroke-width="1"/>
|
||||
|
||||
<!-- Rudder bar -->
|
||||
<rect x="60" y="175" width="80" height="6" fill="rgba(0,40,60,0.9)" rx="3"/>
|
||||
<line x1="100" y1="173" x2="100" y2="183" stroke="#0ff" stroke-width="1" opacity="0.5"/>
|
||||
<rect x="${rudX - 4}" y="174" width="8" height="8" fill="#ff0" rx="1"/>
|
||||
<rect x="60" y="${LEG_Y - 4}" width="80" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
||||
<line x1="${CX}" y1="${LEG_Y - 4}" x2="${CX}" y2="${LEG_Y + 2}" stroke="#0ff" stroke-width="1" opacity="0.5"/>
|
||||
<rect x="${rudX - 4}" y="${LEG_Y - 3}" width="8" height="8" fill="#ff0" rx="1"/>
|
||||
|
||||
<!-- Legend -->
|
||||
<line x1="12" y1="163" x2="24" y2="163" stroke="#0f0" stroke-width="2"/>
|
||||
<text x="27" y="167" fill="#888" font-size="9">COG</text>
|
||||
<line x1="12" y1="173" x2="24" y2="173" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<text x="27" y="177" fill="#888" font-size="9">BRG</text>
|
||||
<line x1="12" y1="${LEG_Y + 20}" x2="24" y2="${LEG_Y + 20}" stroke="#0f0" stroke-width="2"/>
|
||||
<text x="27" y="${LEG_Y + 22}" fill="#888" font-size="9">COG</text>
|
||||
<line x1="12" y1="${LEG_Y + 30}" x2="24" y2="${LEG_Y + 30}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<text x="27" y="${LEG_Y + 33}" fill="#888" font-size="9">BRG</text>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
@@ -181,7 +202,6 @@ const compass = computed(() => {
|
||||
<template>
|
||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<!-- Header -->
|
||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="sp-name">
|
||||
🚢
|
||||
@@ -191,21 +211,18 @@ const compass = computed(() => {
|
||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ boat.operator || boat.owner || '' }}</span>
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown' }}</span>
|
||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>{{ boat.ship_type || '' }}</span>
|
||||
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||
<span>{{ boat.ship_type || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="sp-body">
|
||||
<!-- Gauges -->
|
||||
<div class="sp-gauges">
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Speed</div>
|
||||
@@ -217,38 +234,21 @@ const compass = computed(() => {
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Course</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '-' }}</span>
|
||||
<span class="sp-gauge-unit">°</span>
|
||||
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.course != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Heading</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '-' }}</span>
|
||||
<span class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Distance</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.distance?.toFixed(1) ?? '-' }}</span>
|
||||
<span class="sp-gauge-unit">NMI</span>
|
||||
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.heading != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compass HUD -->
|
||||
<div class="sp-compass" v-html="compass"/>
|
||||
</div>
|
||||
|
||||
<!-- Details table -->
|
||||
<div class="sp-details">
|
||||
<div v-for="[label, val] in rows" :key="label" class="sp-detail-row">
|
||||
<span class="sp-detail-label">{{ label }}</span>
|
||||
<span class="sp-detail-val">{{ val ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -257,24 +257,23 @@ const compass = computed(() => {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,10,20,0.92);
|
||||
border: 1px solid rgba(0,200,255,0.3);
|
||||
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.6);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 360px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.sp-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(0,200,255,0.2);
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: rgba(0,20,40,0.6);
|
||||
}
|
||||
|
||||
.sp-name {
|
||||
@@ -290,15 +289,15 @@ const compass = computed(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid rgba(0,200,255,0.15);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
}
|
||||
|
||||
.sp-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 24px;
|
||||
@@ -311,27 +310,26 @@ const compass = computed(() => {
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.sp-close:hover { background: rgba(255,255,255,0.25); }
|
||||
.sp-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
.sp-body {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(0,200,255,0.15);
|
||||
}
|
||||
|
||||
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.sp-gauge-wrap {}
|
||||
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 0; }
|
||||
.sp-gauge-wrap { margin-bottom: 12px; }
|
||||
|
||||
.sp-gauge-label {
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.sp-gauge {
|
||||
padding: 5px 6px;
|
||||
padding: 6px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border: 2px solid #0ff;
|
||||
border-radius: 3px;
|
||||
@@ -343,20 +341,8 @@ const compass = computed(() => {
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sp-gauge:hover { border-color: #0f0; }
|
||||
.sp-gauge-val { font-size: 18px; font-weight: bold; color: #0ff; }
|
||||
.sp-gauge-unit { font-size: 10px; color: #0ff; margin-bottom: 2px; }
|
||||
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
|
||||
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 2px; }
|
||||
|
||||
.sp-compass { flex: 1; }
|
||||
|
||||
.sp-details {
|
||||
padding: 8px 16px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px 16px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sp-detail-row { display: flex; flex-direction: column; }
|
||||
.sp-detail-label { color: #888; font-size: 9px; font-weight: bold; }
|
||||
.sp-detail-val { color: #0ff; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getUVLabel} from '@/services/units.ts';
|
||||
import {inject, computed } from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
@@ -96,17 +97,17 @@ const arc = computed(() => {
|
||||
<div class="stat">
|
||||
<span class="stat-label">Daylight</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) }} <span class="fg-muted">hrs</span></span>
|
||||
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) ?? '—' }} <span v-if="data.daylight" class="fg-muted">hrs</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lux')">
|
||||
<span class="stat-label">Lux</span>
|
||||
<span class="stat-label">Brightness</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.lux }}</span>
|
||||
<span v-else class="stat-value">{{ data.lux ?? '—' }} <span v-if="data.lux != null" class="fg-muted">lm/m²</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('solar_wm2')">
|
||||
<span class="stat-label">Solar W/m²</span>
|
||||
<span class="stat-label">Irradiance</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.solar_wm2 }}</span>
|
||||
<span v-else class="stat-value">{{ data.solar_wm2 ?? '—' }} <span v-if="data.lux != null" class="fg-muted">W/m²</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,17 +117,17 @@ const arc = computed(() => {
|
||||
<div class="stat" @click="openGraph?.('uv_index')">
|
||||
<span class="stat-label">UV Index</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index.toFixed(1) }} <span class="fg-muted">({{ data.uv_index_label }})</span></span>
|
||||
<span v-else class="stat-value">{{ data.uv_index?.toFixed(1) ?? '—' }} <span v-if="data.uv_index != null" class="fg-muted">({{ getUVLabel(data.uv_index) }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_dose')">
|
||||
<span class="stat-label">UV Dose</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) }} <span class="fg-muted">J/m²</span></span>
|
||||
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) ?? '—' }} <span v-if="data.uv_dose != null" class="fg-muted">J/m²</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_index_max')">
|
||||
<span class="stat-label">UV Max</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index_max ?? '—' }}</span>
|
||||
<span v-else class="stat-value">{{ data.uv_index_max ?? '—' }} <span v-if="data.uv_index_max != null" class="fg-muted">({{getUVLabel(data.uv_index_max)}})</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,17 +75,17 @@ function dir(deg: number) {
|
||||
<div class="stat" @click="openGraph?.('wind_speed')">
|
||||
<span class="stat-label">Speed</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ ~~(d.wind_speed) }} <span class="fg-muted">Kph</span></span>
|
||||
<span v-else class="stat-value">{{ d.wind_speed != null ? ~~(d.wind_speed) : '—' }} <span v-if="d.wind_speed != null" class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('wind_gusts')">
|
||||
<span class="stat-label">Gusts</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ ~~(d.wind_gusts) }} <span class="fg-muted">Kph</span></span>
|
||||
<span v-else class="stat-value">{{ d.wind_gusts != null ? ~~(d.wind_gusts) : '—' }} <span v-if="d.wind_gusts != null" class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Direction</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ dir(d.wind_direction) }}</span>
|
||||
<span v-else class="stat-value">{{ (d.wind_speed || d.wind_gusts) ? dir(d.wind_direction) : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,9 +174,11 @@ export class AirTrafficLayer {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(AircraftPopup, {
|
||||
plane: planeRef.value,
|
||||
position: posRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(icao),
|
||||
onBringToFront: () => {
|
||||
highestZIndex++
|
||||
|
||||
@@ -126,9 +126,11 @@ export class AISLayer {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(ShipPopup, {
|
||||
boat: boatRef.value,
|
||||
position: posRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(mmsi),
|
||||
onBringToFront: () => {
|
||||
highestZIndex++
|
||||
|
||||
@@ -33,7 +33,7 @@ export const METRICS: Record<string, MetricMeta> = {
|
||||
wind_gusts: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
|
||||
wind_direction: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
|
||||
// Seismic
|
||||
magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
|
||||
seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
|
||||
seismic_x: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_y: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_z: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
@@ -62,6 +62,20 @@ export const METRICS: Record<string, MetricMeta> = {
|
||||
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
||||
}
|
||||
|
||||
export function getPressureTrend(delta: number) {
|
||||
if (delta > 0.5) return 'Rising';
|
||||
if (delta < -0.5) return 'Falling';
|
||||
return 'Stable';
|
||||
}
|
||||
|
||||
export function getUVLabel(uv: number) {
|
||||
if (uv < 3) return 'Low';
|
||||
if (uv < 6) return 'Moderate';
|
||||
if (uv < 8) return 'High';
|
||||
if (uv < 11) return 'Very High';
|
||||
return 'Extreme';
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: number | string | null): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {cfg, localDateStr} from './config.mjs';
|
||||
import {cfg} from './config.mjs';
|
||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './sensors.mjs';
|
||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||
import {apiReference} from '@scalar/express-api-reference';
|
||||
@@ -10,11 +10,13 @@ import {existsSync} from 'fs';
|
||||
import {getAIS} from './ais.mjs';
|
||||
import {getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs';
|
||||
import {fetchIcon} from './openweather.mjs';
|
||||
import {lastForecast, getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||
|
||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||
|
||||
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('[unhandledRejection]', promise, '\nReason:', reason)
|
||||
})
|
||||
@@ -47,7 +49,7 @@ function filterArr(arr, fields) {
|
||||
|
||||
// ── Current ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/current', async (req, res) => {
|
||||
app.get('/api/current', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
|
||||
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||
@@ -67,21 +69,19 @@ app.get('/api/current', async (req, res) => {
|
||||
}
|
||||
|
||||
res.json(filterFields(merged, fields))
|
||||
})
|
||||
}));
|
||||
|
||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/hourly', async (req, res) => {
|
||||
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
const forecastLimit = new Date(now.getTime() + 24 * 60 * 60_000)
|
||||
|
||||
const start = req.query.start ? new Date(req.query.start) : now
|
||||
const end = req.query.end ? new Date(req.query.end) : forecastLimit
|
||||
|
||||
const coords = await getCoords()
|
||||
|
||||
const [history, forecast24, forecast3rdParty] = await Promise.all([
|
||||
start.getTime() < now.getTime() ? queryHourly(start, now) : Promise.resolve([]),
|
||||
end.getTime() > now.getTime() ? getForecast().then(f => f.slots.filter(h => new Date(h.time) <= end)) : Promise.resolve([]),
|
||||
@@ -90,55 +90,46 @@ app.get('/api/hourly', async (req, res) => {
|
||||
|
||||
const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...forecast3rdParty])
|
||||
res.json(filterArr(hourly, fields))
|
||||
})
|
||||
}));
|
||||
|
||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/daily', async (req, res) => {
|
||||
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords();
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now)
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const next = new Date(todayStart)
|
||||
next.setDate(todayStart.getDate() + 1)
|
||||
const todayEnd = new Date(todayStart)
|
||||
todayEnd.setDate(todayStart.getDate() + 1)
|
||||
const start = req.query.start ? new Date(req.query.start) : todayEnd
|
||||
const end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
|
||||
|
||||
const start = req.query.start ? new Date(req.query.start) : todayStart
|
||||
const end = req.query.end ? new Date(req.query.end) : next
|
||||
|
||||
const coords = await getCoords()
|
||||
const todayStr = localDateStr(now)
|
||||
const usePhysics = lastForecast.summary && start <= now && end > now
|
||||
|
||||
const [sensorResult] = await Promise.allSettled([start < now ? queryDaily(start, now) : Promise.resolve([])])
|
||||
const history = sensorResult.status === 'fulfilled' ? sensorResult.value : []
|
||||
|
||||
const meteoFrom = new Date(Math.max(next.getTime(), start.getTime()))
|
||||
const [meteoResult] = await Promise.allSettled([end > next ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
|
||||
const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
|
||||
|
||||
const todaySlot = usePhysics ? [lastForecast.summary] : []
|
||||
const meteoClean = meteo.filter(r => localDateStr(new Date(r.time)) !== todayStr)
|
||||
|
||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean])
|
||||
const [history, current, future] = Promise.all([
|
||||
start.getTime() < todayStart.getTime() ? queryDaily(start, todayStart) : Promise.resolve([]), // Historic
|
||||
start.getTime() <= todayStart.getTime() ? queryCurrent().then(resp => [resp]) : Promise.resolve([]), // Current
|
||||
start.getTime() >= todayEnd.getTime() ? dailyWeather(coords.latitude, coords.longitude, todayStart, end) : Promise.resolve([]), // Future
|
||||
]);
|
||||
const daily = [...history, ...current, ...getCelestialForecast(coords.latitude, coords.longitude, future)];
|
||||
res.json(filterArr(daily, fields))
|
||||
})
|
||||
}));
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/position', async (req, res) => {
|
||||
app.get('/api/position', asyncHandler(async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const data = await getCoords();
|
||||
res.json(filterFields(data, fields));
|
||||
});
|
||||
}));
|
||||
|
||||
// ── Icon ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/icon/:icon', async (req, resp, next) => {
|
||||
app.get('/api/icon/:icon', asyncHandler(async (req, res) => {
|
||||
const { icon } = req.params;
|
||||
if(!icon) return next(); // 404 fallback can handle missing icons
|
||||
resp.contentType('image/png');
|
||||
resp.sendFile(await fetchIcon(icon));
|
||||
});
|
||||
}));
|
||||
|
||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -150,20 +141,20 @@ app.get('/api/icon/:icon', async (req, resp, next) => {
|
||||
|
||||
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/adsb', async (req, res) => res.json(await getADSB()));
|
||||
app.get('/api/adsb/:icao', async (req, res) => res.json(await getADSBHistory(req.params.icao)));
|
||||
app.get('/api/ais', async (req, res) => res.json(await getAIS()));
|
||||
app.get('/api/range', async (req, res) => res.json(await getADSBRange()));
|
||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
||||
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/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
||||
|
||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
|
||||
app.get('/openapi.json', (req, res) => res.json(spec));
|
||||
app.get('/openapi.yaml', async (req, res) => {
|
||||
app.get('/openapi.json',asyncHandler(async (req, res) => res.json(spec)));
|
||||
app.get('/openapi.yaml',asyncHandler(async (req, res) => {
|
||||
const {stringify} = await import('yaml');
|
||||
res.setHeader('Content-Type', 'text/yaml');
|
||||
res.send(stringify(spec));
|
||||
});
|
||||
}));
|
||||
|
||||
// ── Website ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user