AIS fixes + daily

This commit is contained in:
2026-06-27 13:38:52 -04:00
parent bea55ac448
commit c5477d05ad
13 changed files with 251 additions and 259 deletions

4
README.md Normal file
View 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

View File

@@ -6,7 +6,7 @@
:root {
--bg: #ffffff;
--surface: #d0dae1;
--surface: #e5e9ec;
--border: #e2e8f0;
--text: #0f172a;
--text-muted: #64748b;

View File

@@ -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>` : ''

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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 (030 kts range)
const spdPct = Math.min(spd / 30, 1)
const arcEnd = 160 + spdPct * 140 // degrees on a 160300° 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>

View File

@@ -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/</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>

View File

@@ -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>

View File

@@ -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++

View File

@@ -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++

View File

@@ -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]

View File

@@ -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 ───────────────────────────────────────────────────────────────────