Fix icons
This commit is contained in:
@@ -9,6 +9,22 @@ function toggleDark() { dark.value = !dark.value }
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: .45rem;
|
||||||
|
height: .45rem;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||||
|
background-color: #d7d7d799;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: #0000;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #ffffff;
|
--bg: #ffffff;
|
||||||
--surface: #d0dae1;
|
--surface: #d0dae1;
|
||||||
|
|||||||
109
client/src/components/HourlyForecast.vue
Normal file
109
client/src/components/HourlyForecast.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Loading from '@/components/Loading.vue';
|
||||||
|
import {formatDate} from '@ztimson/utils';
|
||||||
|
import {onMounted, ref} from 'vue';
|
||||||
|
import {api, BASE} from '../services/api';
|
||||||
|
|
||||||
|
const hours = ref<any[]>();
|
||||||
|
const open = ref(false);
|
||||||
|
const filler = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
|
function dir(deg: number) {
|
||||||
|
if(deg > 22.5 && deg <= 67.5) return 'NE';
|
||||||
|
if(deg > 67.5 && deg <= 112.5) return 'E';
|
||||||
|
if(deg > 112.5 && deg <= 157.5) return 'SE';
|
||||||
|
if(deg > 157.5 && deg <= 202.5) return 'S';
|
||||||
|
if(deg > 202.5 && deg <= 247.5) return 'SW';
|
||||||
|
if(deg > 247.5 && deg <= 292.5) return 'W';
|
||||||
|
if(deg > 292.5 && deg <= 337.5) return 'NW';
|
||||||
|
return 'N';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
hours.value = await api.hourly();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); }
|
||||||
|
.chevron { font-size: 10px; color: var(--text-muted); transition: transform 0.2s; &.open { transform: rotate(180deg); } }
|
||||||
|
.preview { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.prev-icon { width: 20px; height: 20px; object-fit: contain; }
|
||||||
|
.prev-temp { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-list {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.hour-time { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; width: 52px; flex-shrink: 0; }
|
||||||
|
.hour-icon { width: 26px; height: 26px; object-fit: contain; flex-shrink: 0; }
|
||||||
|
.hour-label { font-size: 11px; color: var(--text-muted); flex: 1; }
|
||||||
|
.hour-temp { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||||
|
.hour-meta { display: flex; gap: 8px; font-size: 10px; color: var(--text-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.filler-row {
|
||||||
|
height: 42px;
|
||||||
|
pos-rel: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" @click="open = !open">
|
||||||
|
<span class="title">24hr Forecast</span>
|
||||||
|
<span class="chevron" :class="{ open }">▼</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="max-height: 50vh; overflow-y: auto">
|
||||||
|
<div v-if="open" class="hour-list">
|
||||||
|
<template v-if="hours?.length">
|
||||||
|
<div v-for="h in hours" class="hour-row">
|
||||||
|
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
||||||
|
<img :src="BASE + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
|
||||||
|
<span class="hour-label">{{ h.label }}</span>
|
||||||
|
<div class="hour-meta">
|
||||||
|
<span>🌧️ {{ (h.precipitation as any || 0).toFixed(1) }}mm</span>
|
||||||
|
<span>🍃 {{ ~~h.wind_gusts }}kph {{ dir(h.wind_dir) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="hour-temp">{{ ~~h.temperature }}°</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="f in filler" class="filler-row p-2">
|
||||||
|
<div class="br-1 pos-rel w-100 overflow-hidden" style="height: 100%">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -399,8 +399,8 @@ input[type='range'] {
|
|||||||
.desktop { display: none; }
|
.desktop { display: none; }
|
||||||
.mobile { display: flex; }
|
.mobile { display: flex; }
|
||||||
.radar-timeline { bottom: 10px; }
|
.radar-timeline { bottom: 10px; }
|
||||||
.overlay-toggles { bottom: 70px; }
|
.overlay-toggles { bottom: 10px; }
|
||||||
.layers-menu { bottom: 70px; }
|
.layers-menu { bottom: 10px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ async function get<T>(path: string, params: Record<string, string> = {}): Promis
|
|||||||
export const api = {
|
export const api = {
|
||||||
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? { fields } : {}),
|
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? { fields } : {}),
|
||||||
current: (fields?: string) => get<DataRow>('/api/current', fields ? { fields } : {}),
|
current: (fields?: string) => get<DataRow>('/api/current', fields ? { fields } : {}),
|
||||||
hourly: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/hourly', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(fields ? { fields } : {}) }),
|
hourly: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
|
||||||
|
...(start ? { start: start.toISOString().slice(0, 10) } : {}),
|
||||||
|
...(end ? { end: end.toISOString().slice(0, 10) } : {}),
|
||||||
|
...(fields ? { fields } : {})
|
||||||
|
}),
|
||||||
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
||||||
...(start ? { start: start.toISOString().slice(0, 10) } : {}),
|
...(start ? { start: start.toISOString().slice(0, 10) } : {}),
|
||||||
...(end ? { end: end.toISOString().slice(0, 10) } : {}),
|
...(end ? { end: end.toISOString().slice(0, 10) } : {}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
`<script setup lang="ts">
|
`<script setup lang="ts">
|
||||||
|
import HourlyForecast from '@/components/HourlyForecast.vue';
|
||||||
import {formatDate} from '@ztimson/utils';
|
import {formatDate} from '@ztimson/utils';
|
||||||
import {onMounted, onUnmounted, ref} from 'vue';
|
import {onMounted, onUnmounted, ref} from 'vue';
|
||||||
import MapView from '../components/MapView.vue';
|
import MapView from '../components/MapView.vue';
|
||||||
@@ -158,6 +159,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CurrentWeather />
|
<CurrentWeather />
|
||||||
|
<HourlyForecast />
|
||||||
<ForecastStrip />
|
<ForecastStrip />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
const wind_speed = forecastWind(seedWind, pressureTrend);
|
const wind_speed = forecastWind(seedWind, pressureTrend);
|
||||||
|
|
||||||
const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time);
|
const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time);
|
||||||
const snapshot = {
|
let snapshot = {
|
||||||
time,
|
time,
|
||||||
temperature,
|
temperature,
|
||||||
humidity,
|
humidity,
|
||||||
@@ -167,7 +167,9 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
...celestial
|
...celestial
|
||||||
};
|
};
|
||||||
|
|
||||||
return Object.assign(snapshot, getWeatherCondition(snapshot));
|
Object.assign(snapshot, getWeatherCondition(snapshot));
|
||||||
|
snapshot.icon = `/icons/${snapshot.icon}.png`;
|
||||||
|
return snapshot;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second pass — apply solar heating now that we have sun_elevation per slot
|
// Second pass — apply solar heating now that we have sun_elevation per slot
|
||||||
@@ -213,7 +215,7 @@ function summarise(slots) {
|
|||||||
time: slots[0].time,
|
time: slots[0].time,
|
||||||
label: dominant.label,
|
label: dominant.label,
|
||||||
code: dominant.code,
|
code: dominant.code,
|
||||||
icon: `/icons/${dominant.icon}.png`,
|
icon: dominant.icon,
|
||||||
temperature: Math.max(...temps),
|
temperature: Math.max(...temps),
|
||||||
temperature_max: Math.max(...temps),
|
temperature_max: Math.max(...temps),
|
||||||
temperature_min: Math.min(...temps),
|
temperature_min: Math.min(...temps),
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ function filterArr(arr, fields) {
|
|||||||
return arr.map(row => filterFields(row, fields));
|
return arr.map(row => filterFields(row, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.status(404).json({ error: `Not found: ${req.method} ${req.path}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(`[ERROR] ${req.method} ${req.path}\n${err.stack}`)
|
||||||
|
res.status(500).json({ error: err.message, stack: err.stack })
|
||||||
|
})
|
||||||
|
|
||||||
// ── Current ───────────────────────────────────────────────────────────────────
|
// ── Current ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/current', async (req, res) => {
|
app.get('/api/current', async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user