diff --git a/client/src/components/CurrentWeather.vue b/client/src/components/CurrentWeather.vue
index 165b868..37be6dc 100644
--- a/client/src/components/CurrentWeather.vue
+++ b/client/src/components/CurrentWeather.vue
@@ -3,97 +3,167 @@ import { computed } from 'vue'
import type { DataRow } from '../services/api'
const props = defineProps<{ data: DataRow }>()
+const d = computed(() => props.data)
-const temp = computed(() => props.data.env_temp_c != null ? `${(props.data.env_temp_c as number).toFixed(1)}°C` : '—')
-const feels = computed(() => props.data.env_heat_index_c != null ? `Feels ${(props.data.env_heat_index_c as number).toFixed(1)}°C` : '')
-const icon = computed(() => props.data.forecast_weather_icon as string | null)
-const label = computed(() => props.data.forecast_weather_label as string | null)
-const humidity = computed(() => props.data.env_humidity != null ? `${(props.data.env_humidity as number).toFixed(0)}%` : '—')
-const wind = computed(() => props.data.wind_speed_kmh != null ? `${(props.data.wind_speed_kmh as number).toFixed(1)} km/h` : '—')
-const uvi = computed(() => props.data.light_uvi != null ? `UV ${(props.data.light_uvi as number).toFixed(1)}` : null)
-const sunrise = computed(() => props.data.sun_sunrise ? new Date(props.data.sun_sunrise as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
-const sunset = computed(() => props.data.sun_sunset ? new Date(props.data.sun_sunset as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
+const fmt = (v: unknown, dec = 1, unit = '') => v != null ? `${(v as number).toFixed(dec)}${unit}` : '—'
+const time = (v: unknown) => v ? new Date(v as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—'
+
+const high = computed(() => fmt(d.value.env_temp_max_c, 1, '°C'))
+const low = computed(() => fmt(d.value.env_temp_min_c, 1, '°C'))
+const sunrise = computed(() => time(d.value.sun_sunrise))
+const sunset = computed(() => time(d.value.sun_sunset))
+const ghMornEnd = computed(() => time(d.value.sun_golden_hour_morning_end))
+const ghEveStart = computed(() => time(d.value.sun_golden_hour_evening_start))
+const dayLen = computed(() => d.value.sun_day_length_hours != null ? `${(d.value.sun_day_length_hours as number).toFixed(2)}h` : '—')
+const uviMax = computed(() => fmt(d.value.light_uvi_max, 1))
+const precipProb = computed(() => d.value.forecast_precipitation_probability != null ? `${d.value.forecast_precipitation_probability}%` : '—')
+const precipMm = computed(() => fmt(d.value.forecast_precipitation_mm, 1, 'mm'))
+const precipHrs = computed(() => d.value.precipitation_hours != null ? `${d.value.precipitation_hours}h` : null)
+const windMax = computed(() => fmt(d.value.wind_speed_max_kmh, 1, ' km/h'))
+const gustMax = computed(() => fmt(d.value.wind_gusts_max_kmh, 1, ' km/h'))
+const icon = computed(() => d.value.forecast_weather_icon as string | null)
+const label = computed(() => d.value.forecast_weather_label as string | null)
+const solarSum = computed(() => d.value.light_solar_wm2_sum != null ? `${(d.value.light_solar_wm2_sum as number).toFixed(1)} W/m²` : null)
-
-
-
![weather]()
-
-
{{ temp }}
-
{{ feels }}
-
{{ label }}
-
-
-
-
Humidity{{ humidity }}
-
Wind{{ wind }}
-
UV{{ uvi }}
-
Sunrise{{ sunrise }}
-
Sunset{{ sunset }}
-
-
+
+
+
+
+
+
+
+
☀️ Sun
+
+
Sunrise{{ sunrise }}
+
Sunset{{ sunset }}
+
Day Length{{ dayLen }}
+
UV Max{{ uviMax }}
+
Golden AM{{ ghMornEnd }}
+
Golden PM{{ ghEveStart }}
+
Solar{{ solarSum }}
+
+
+
+
+
+
🌧️ Precipitation & Wind
+
+
Chance{{ precipProb }}
+
Amount{{ precipMm }}over {{ precipHrs }}
+
Wind Max{{ windMax }}
+
Gust Max{{ gustMax }}
+
+
+
diff --git a/client/src/components/ForecastStrip.vue b/client/src/components/ForecastStrip.vue
index 67d9fcf..6b0d410 100644
--- a/client/src/components/ForecastStrip.vue
+++ b/client/src/components/ForecastStrip.vue
@@ -4,59 +4,68 @@ import type { DataRow } from '../services/api'
const props = defineProps<{ days: DataRow[] }>()
-const items = computed(() => props.days.slice(0, 5).map(d => ({
- date: new Date(d.time as string).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
- icon: d.forecast_weather_icon as string | null,
- label: d.forecast_weather_label as string,
- high: d.env_temp_max_c != null ? `${(d.env_temp_max_c as number).toFixed(1)}°` : '—',
- low: d.env_temp_min_c != null ? `${(d.env_temp_min_c as number).toFixed(1)}°` : '—',
- rain: d.forecast_precipitation_probability != null ? `${d.forecast_precipitation_probability}%` : null,
+const items = computed(() => props.days.slice(0, 7).map(d => ({
+ date: new Date(d.time as string).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
+ icon: d.forecast_weather_icon as string | null,
+ label: d.forecast_weather_label as string,
+ high: d.env_temp_max_c != null ? `${(d.env_temp_max_c as number).toFixed(1)}°` : '—',
+ low: d.env_temp_min_c != null ? `${(d.env_temp_min_c as number).toFixed(1)}°` : '—',
+ rain: d.forecast_precipitation_probability != null ? `${d.forecast_precipitation_probability}%` : null,
+ precip: d.forecast_precipitation_mm != null ? `${(d.forecast_precipitation_mm as number).toFixed(1)}mm` : null,
})))
-
-
-
{{ item.date }}
-
![]()
-
{{ item.label }}
-
- {{ item.high }}
- {{ item.low }}
-
-
🌧 {{ item.rain }}
-
-
+
+
+
+
{{ item.date }}
+
![]()
+
{{ item.label }}
+
+ {{ item.high }}
+ {{ item.low }}
+
+
🌧 {{ item.rain }}
+
{{ item.precip }}
+
+
+
diff --git a/client/src/components/MapView.vue b/client/src/components/MapView.vue
index 40fbf32..3f7f817 100644
--- a/client/src/components/MapView.vue
+++ b/client/src/components/MapView.vue
@@ -1,6 +1,6 @@
@@ -180,7 +255,15 @@ watch(() => current.value, () => {
height: 100%;
}
-// Shared button style
+.wind-iframe {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: none;
+ z-index: 10;
+}
+
.overlay-btn {
display: flex;
align-items: center;
@@ -204,7 +287,6 @@ watch(() => current.value, () => {
&:hover:not(.active) { background: var(--hover); }
}
-// Desktop bar
.overlay-toggles {
position: absolute;
bottom: 16px;
@@ -220,11 +302,10 @@ watch(() => current.value, () => {
backdrop-filter: blur(8px);
}
-// Mobile layers menu
.layers-menu {
position: absolute;
bottom: 16px;
- right: 16px;
+ right: 10px;
z-index: 100;
display: flex;
flex-direction: column;
@@ -255,13 +336,82 @@ watch(() => current.value, () => {
backdrop-filter: blur(8px);
}
-// Visibility switching
+.radar-timeline {
+ position: absolute;
+ bottom: 72px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: var(--surface);
+ border-radius: 16px;
+ padding: 8px 16px;
+ box-shadow: 0 2px 12px rgba(0,0,0,0.2);
+ backdrop-filter: blur(8px);
+ width: min(600px, calc(100vw - 32px));
+}
+
+.slider-wrap {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.slider-ticks {
+ position: relative;
+ height: 16px;
+}
+
+.tick {
+ position: absolute;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+
+ &__line {
+ width: 1px;
+ height: 5px;
+ background: var(--border);
+ }
+
+ &__label {
+ font-size: 9px;
+ color: var(--text-muted, #888);
+ white-space: nowrap;
+ }
+
+ &.nowcast &__line { background: #6ab4ff; }
+ &.nowcast &__label { color: #6ab4ff; }
+}
+
+input[type='range'] {
+ width: 100%;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+.timeline-label {
+ font-size: 12px;
+ color: var(--text);
+ min-width: 42px;
+ text-align: right;
+ flex-shrink: 0;
+}
+
.desktop { display: flex; }
.mobile { display: none; }
@media (max-width: 768px) {
.desktop { display: none; }
.mobile { display: flex; }
+ .radar-timeline { bottom: 10px; }
+ .overlay-toggles { bottom: 70px; }
+ .layers-menu { bottom: 70px; }
}
@@ -269,8 +419,45 @@ watch(() => current.value, () => {
+
+
+
+
+
+
+
+
+
+
{{ hourLabel(radarFrames[i]) }}
+
+
+
+
+
{{ frameLabel(radarFrames[radarFrameIndex]) }}
+
+
+
+
-
diff --git a/client/src/services/airtraffic.ts b/client/src/services/airtraffic.ts
index 6dab139..6a389a4 100644
--- a/client/src/services/airtraffic.ts
+++ b/client/src/services/airtraffic.ts
@@ -247,8 +247,17 @@ export class AirTrafficLayer {
// ── Private ───────────────────────────────────────────────────────────────
private async _fetch() {
- const j = await fetch(`${API}/air-traffic`).then(r => r.json())
- this.data = j.data || []
+ const j = await fetch(`${API}/air-traffic`).then(r => r.json())
+ this.data = (j.data || []).map((a: any) => ({
+ ...a,
+ icao: a.hex,
+ latitude: a.lat,
+ longitude: a.lon,
+ heading: a.track,
+ speed: a.gs,
+ climb: a.baro_rate ?? a.geom_rate ?? 0,
+ name: a.flight?.trim(),
+ }))
}
private _draw() {
diff --git a/client/src/services/api.ts b/client/src/services/api.ts
index ac1263e..bbb6c03 100644
--- a/client/src/services/api.ts
+++ b/client/src/services/api.ts
@@ -2,6 +2,14 @@ export const BASE = import.meta.env.DEV ? 'http://10.69.5.23:3000' : ''
export type DataRow = Record
+export async function fetchDaily(start?: string, end?: string): Promise {
+ const params = new URLSearchParams()
+ if (start) params.set('start', start)
+ if (end) params.set('end', end)
+ const res = await fetch(`/api/daily?${params}`)
+ return res.json()
+}
+
async function get(path: string, params: Record = {}): Promise {
const url = new URL(`${BASE}${path}`, window.location.origin)
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
diff --git a/client/src/services/weather.ts b/client/src/services/weather.ts
index 942f8cc..0e670aa 100644
--- a/client/src/services/weather.ts
+++ b/client/src/services/weather.ts
@@ -8,8 +8,13 @@ export const loading = ref(false)
export const lastFetch = ref(null)
export async function fetchAll() {
+ const date = (d: Date = new Date()) => `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
+
loading.value = true
- const [c, h, d] = await Promise.allSettled([api.current(), api.hourly(), api.daily()])
+ const start = new Date(), end = new Date();
+ start.setDate(start.getDate() + 1);
+ end.setDate(end.getDate() + 5);
+ const [c, h, d] = await Promise.allSettled([api.current(), api.hourly(), api.daily(date(start), date(end))])
if (c.status === 'fulfilled') current.value = c.value
if (h.status === 'fulfilled') hourly.value = h.value
if (d.status === 'fulfilled') daily.value = d.value
diff --git a/client/src/views/Dashboard.vue b/client/src/views/Dashboard.vue
index 18e1adb..2b882fd 100644
--- a/client/src/views/Dashboard.vue
+++ b/client/src/views/Dashboard.vue
@@ -9,7 +9,6 @@ import MetricCard from '../components/MetricCard.vue'
import GraphModal from '../components/GraphModal.vue'
const props = defineProps<{ dark: boolean }>()
-
const selectedMetric = ref(null)
let interval: ReturnType
@@ -73,9 +72,15 @@ const groupedMetrics = computed(() => {
}
.metric-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
+ display: flex;
+ flex-wrap: wrap;
gap: 8px;
+ min-width: 0;
+
+ > * {
+ flex: 1 1 140px; // grow, shrink, min width before wrapping
+ min-width: 0;
+ }
}
.loading-bar {
@@ -96,6 +101,7 @@ const groupedMetrics = computed(() => {
.dashboard {
flex-direction: column;
overflow-y: auto;
+ overflow-x: hidden; // ← added
height: 100dvh;
}
@@ -113,7 +119,9 @@ const groupedMetrics = computed(() => {
border-left: none;
border-top: 1px solid var(--border);
overflow-y: visible;
+ overflow-x: hidden; // ← added
-webkit-overflow-scrolling: touch;
+ box-sizing: border-box; // ← added
&::before { display: none; }
&::-webkit-scrollbar { display: none; }