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) 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, }))) 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, () => {
+ +