This commit is contained in:
2026-06-21 22:14:04 -04:00
commit 533aec8ba2
46 changed files with 3530 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DataRow } from '../services/api'
const props = defineProps<{ data: DataRow }>()
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' }) : '—')
</script>
<style scoped lang="scss">
.hero {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.weather-icon {
width: 64px;
height: 64px;
object-fit: contain;
}
.temps {
flex: 1;
}
.temp-main {
font-size: 42px;
font-weight: 700;
line-height: 1;
color: var(--text);
}
.temp-feel {
font-size: 13px;
color: var(--text-muted);
}
.weather-label {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.stat-value { font-size: 14px; font-weight: 600; color: var(--text); }
</style>
<template>
<div class="hero">
<div class="top">
<img v-if="icon" :src="icon" class="weather-icon" alt="weather" />
<div class="temps">
<div class="temp-main">{{ temp }}</div>
<div class="temp-feel">{{ feels }}</div>
<div class="weather-label">{{ label }}</div>
</div>
</div>
<div class="stats">
<div class="stat"><span class="stat-label">Humidity</span><span class="stat-value">{{ humidity }}</span></div>
<div class="stat"><span class="stat-label">Wind</span><span class="stat-value">{{ wind }}</span></div>
<div v-if="uvi" class="stat"><span class="stat-label">UV</span><span class="stat-value">{{ uvi }}</span></div>
<div class="stat"><span class="stat-label">Sunrise</span><span class="stat-value">{{ sunrise }}</span></div>
<div class="stat"><span class="stat-label">Sunset</span><span class="stat-value">{{ sunset }}</span></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
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,
})))
</script>
<style scoped lang="scss">
.strip {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
&::-webkit-scrollbar { height: 4px; }
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
}
.day {
flex: 1;
min-width: 80px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 8px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.day-name { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; }
.day-icon { width: 36px; height: 36px; object-fit: contain; }
.day-label { font-size: 10px; color: var(--text-muted); text-align: center; line-height: 1.2; }
.day-temps { display: flex; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
.day-low { color: var(--text-muted); font-weight: 400; }
.day-rain { font-size: 11px; color: #38bdf8; }
</style>
<template>
<div class="strip">
<div v-for="item in items" :key="item.date" class="day">
<div class="day-name">{{ item.date }}</div>
<img v-if="item.icon" :src="item.icon" class="day-icon" :alt="item.label" />
<div class="day-label">{{ item.label }}</div>
<div class="day-temps">
<span>{{ item.high }}</span>
<span class="day-low">{{ item.low }}</span>
</div>
<div v-if="item.rain" class="day-rain">🌧 {{ item.rain }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { METRICS } from '../services/units'
import MetricChart from './MetricChart.vue'
import type { DataRow } from '../services/api'
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
</script>
<style scoped lang="scss">
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
backdrop-filter: blur(4px);
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.close-btn {
background: none;
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
&:hover { background: var(--hover); }
}
</style>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
<div class="modal">
<div class="modal-header">
<div class="modal-title">
<span>{{ meta?.icon }}</span>
<span>{{ meta?.label ?? metricKey }}</span>
</div>
<button class="close-btn" @click="emit('close')"></button>
</div>
<MetricChart :metric-key="metricKey" :current-data="currentData" />
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import XYZ from 'ol/source/XYZ'
import { fromLonLat } from 'ol/proj'
import { current } from '../services/weather'
import 'ol/ol.css'
const props = defineProps<{ dark: boolean }>()
const mapEl = ref<HTMLDivElement>()
let map: Map
const OVERLAYS = [
{ id: 'clouds', label: 'Clouds', icon: '☁️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/coverage/0/256/{z}/{x}/{y}/1/1_1.png` },
{ id: 'rain', label: 'Rain', icon: '🌧️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png` },
{ id: 'wind', label: 'Wind', icon: '💨', url: (_: number) => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=demo` },
{ id: 'temp', label: 'Temperature', icon: '🌡️', url: (_: number) => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=demo` },
{ id: 'pressure', label: 'Pressure', icon: '📊', url: (_: number) => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=demo` },
]
const activeOverlays = ref<Set<string>>(new Set(['rain']))
const radarTs = ref(Math.floor(Date.now() / 1000))
const overlayLayers = new Map<string, TileLayer<XYZ>>()
let radarInterval: ReturnType<typeof setInterval>
function buildBaseLayer(dark: boolean) {
return new TileLayer({
source: new XYZ({
url: dark
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
}),
})
}
function buildOverlayLayer(id: string): TileLayer<XYZ> {
const def = OVERLAYS.find(o => o.id === id)!
return new TileLayer({
source: new XYZ({ url: def.url(radarTs.value), attributions: '' }),
opacity: 0.6,
zIndex: 10,
})
}
function toggleOverlay(id: string) {
if (activeOverlays.value.has(id)) {
activeOverlays.value.delete(id)
const layer = overlayLayers.get(id)
if (layer) { map.removeLayer(layer); overlayLayers.delete(id) }
} else {
activeOverlays.value.add(id)
const layer = buildOverlayLayer(id)
overlayLayers.set(id, layer)
map.addLayer(layer)
}
}
onMounted(() => {
const lat = (current.value.gps_lat as number) || 0
const lon = (current.value.gps_lon as number) || 0
map = new Map({
target: mapEl.value!,
layers: [buildBaseLayer(props.dark)],
view: new View({
center: fromLonLat([lon, lat]),
zoom: 9,
}),
controls: [],
})
// Add default active overlays
for (const id of activeOverlays.value) {
const layer = buildOverlayLayer(id)
overlayLayers.set(id, layer)
map.addLayer(layer)
}
// Refresh radar every 5 min
radarInterval = setInterval(() => {
radarTs.value = Math.floor(Date.now() / 1000)
const layer = overlayLayers.get('rain')
if (layer) {
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url(radarTs.value) }))
}
}, 5 * 60 * 1000)
})
onUnmounted(() => clearInterval(radarInterval))
watch(() => props.dark, (dark) => {
const layers = map.getLayers()
layers.setAt(0, buildBaseLayer(dark))
})
</script>
<style scoped lang="scss">
.map-wrap {
position: relative;
width: 100%;
height: 100%;
border-radius: 12px;
overflow: hidden;
}
.map-el {
width: 100%;
height: 100%;
}
.overlay-toggles {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
background: var(--surface);
border-radius: 99px;
padding: 6px 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
}
.overlay-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 99px;
border: 1.5px solid var(--border);
background: transparent;
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
&:hover:not(.active) {
background: var(--hover);
}
}
</style>
<template>
<div class="map-wrap">
<div ref="mapEl" class="map-el" />
<div class="overlay-toggles">
<button
v-for="o in OVERLAYS"
:key="o.id"
class="overlay-btn"
:class="{ active: activeOverlays.has(o.id) }"
@click="toggleOverlay(o.id)"
>
{{ o.icon }} {{ o.label }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
import { METRICS, formatValue } from '../services/units'
import type { DataRow } from '../services/api'
const props = defineProps<{
metricKey: string
data: DataRow
}>()
const emit = defineEmits<{ (e: 'click', key: string): void }>()
const meta = computed(() => METRICS[props.metricKey])
const value = computed(() => formatValue(props.metricKey, props.data[props.metricKey] as number | null))
const label = computed(() => props.data[`${props.metricKey.replace(/_[^_]+$/, '')}_label`] as string | undefined)
</script>
<style scoped lang="scss">
.card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
user-select: none;
&:hover { background: var(--hover); }
&:active { transform: scale(0.97); }
}
.icon {
font-size: 22px;
line-height: 1;
flex-shrink: 0;
}
.info {
flex: 1;
min-width: 0;
}
.metric-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.metric-value {
font-size: 18px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.metric-sub {
font-size: 11px;
color: var(--text-muted);
}
</style>
<template>
<div class="card" @click="emit('click', metricKey)">
<span class="icon">{{ meta?.icon ?? '📊' }}</span>
<div class="info">
<div class="metric-label">{{ meta?.label ?? metricKey }}</div>
<div class="metric-value">{{ value }}</div>
<div v-if="label" class="metric-sub">{{ label }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
LineElement, Tooltip, Legend, Filler, type ChartOptions
} from 'chart.js'
import { METRICS, formatValue } from '../services/units'
import { fetchHistoricHourly } from '../services/weather'
import type { DataRow } from '../services/api'
import { api } from '../services/api'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
const props = defineProps<{ metricKey: string; currentData: DataRow }>()
const historic = ref<DataRow[]>([])
const forecast = ref<DataRow[]>([])
const loading = ref(true)
const meta = computed(() => METRICS[props.metricKey])
onMounted(async () => {
const now = new Date()
const start = new Date(now.getTime() - 24 * 3600000).toISOString()
const end = new Date(now.getTime() + 48 * 3600000).toISOString()
const [hist, fore] = await Promise.allSettled([
fetchHistoricHourly(start, now.toISOString()),
api.hourly(now.toISOString(), end),
])
if (hist.status === 'fulfilled') historic.value = hist.value.filter(r => r[props.metricKey] != null)
if (fore.status === 'fulfilled') forecast.value = fore.value.filter(r => r[props.metricKey] != null)
loading.value = false
})
const nowLabel = computed(() => {
const d = new Date()
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`
})
const chartData = computed(() => {
const color = meta.value?.color ?? '#38bdf8'
const histLabels = historic.value.map(r => r.time as string)
const foreLabels = forecast.value.map(r => r.time as string)
const histVals = historic.value.map(r => r[props.metricKey] as number)
const foreVals = forecast.value.map(r => r[props.metricKey] as number)
const currentVal = props.currentData[props.metricKey] as number | null
const allLabels = [...histLabels, nowLabel.value, ...foreLabels]
const histData = [...histVals, currentVal, ...foreVals.map(() => null)]
const foreData = [...histVals.map(() => null), currentVal, ...foreVals]
return {
labels: allLabels,
datasets: [
{
label: 'Historic',
data: histData,
borderColor: color,
backgroundColor: `${color}22`,
borderWidth: 2,
pointRadius: 0,
tension: 0.3,
fill: true,
spanGaps: false,
},
{
label: 'Forecast',
data: foreData,
borderColor: color,
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [6, 4],
pointRadius: 0,
tension: 0.3,
fill: false,
spanGaps: false,
},
{
label: 'Now',
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
borderColor: '#ffffff88',
backgroundColor: color,
pointRadius: 6,
pointHoverRadius:8,
borderWidth: 0,
showLine: false,
spanGaps: false,
}
]
}
})
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
}
}
},
scales: {
x: {
ticks: {
color: 'var(--text-muted)',
maxTicksLimit: 12,
maxRotation: 0,
callback(val, i) {
const lbl = this.getLabelForValue(i)
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : ''
}
},
grid: { color: 'var(--border)' },
},
y: {
ticks: { color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number) },
grid: { color: 'var(--border)' },
}
},
annotation: {
annotations: {
nowLine: {
type: 'line',
xMin: nowLabel.value,
xMax: nowLabel.value,
borderColor: '#ffffff44',
borderWidth: 1,
borderDash: [4, 4],
}
}
}
}))
</script>
<style scoped lang="scss">
.chart-wrap {
position: relative;
height: 260px;
width: 100%;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 260px;
color: var(--text-muted);
font-size: 14px;
}
</style>
<template>
<div v-if="loading" class="loading">Loading chart</div>
<div v-else class="chart-wrap">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>