Use local time
This commit is contained in:
@@ -1,64 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import Dashboard from './views/Dashboard.vue'
|
import Dashboard from './views/Dashboard.vue'
|
||||||
import '@ztimson/css-utils/dist/css-utils.min.css';
|
|
||||||
|
|
||||||
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
|
||||||
function toggleDark() { dark.value = !dark.value }
|
function toggleDark() { dark.value = !dark.value }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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 {
|
|
||||||
--bg: #ffffff;
|
|
||||||
--surface: #d0dae1;
|
|
||||||
--border: #e2e8f0;
|
|
||||||
--text: #0f172a;
|
|
||||||
--text-muted: #64748b;
|
|
||||||
--hover: #f1f5f9;
|
|
||||||
--accent: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--bg: #0a0a0a;
|
|
||||||
--surface: #111111;
|
|
||||||
--border: #222222;
|
|
||||||
--text: #f1f5f9;
|
|
||||||
--text-muted: #64748b;
|
|
||||||
--hover: #1a1a1a;
|
|
||||||
--accent: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
|
||||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.app-wrap {
|
.app-wrap {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|||||||
@@ -1,4 +1,41 @@
|
|||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
@import '@ztimson/css-utils/dist/css-utils.min.css';
|
||||||
|
|
||||||
|
::-webkit-scrollbar { height: 4px; width: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--surface: #d0dae1;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--text: #0f172a;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--hover: #f1f5f9;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #111111;
|
||||||
|
--border: #222222;
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--hover: #1a1a1a;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -27,3 +64,25 @@ a,
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 10px 12px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
28
client/src/components/EnvironmentCard.vue
Normal file
28
client/src/components/EnvironmentCard.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, onMounted} from 'vue';
|
||||||
|
import {api} from '../services/api';
|
||||||
|
import MetricRow from './MetricRow.vue';
|
||||||
|
|
||||||
|
const d = ref<any>(null);
|
||||||
|
onMounted(async () => d.value = await api.current());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card" v-if="d">
|
||||||
|
<div class="card-title">🌡️ Environment</div>
|
||||||
|
|
||||||
|
<MetricRow label="Temperature" :value="`${d.temperature?.toFixed(1)}°C`" metric-key="temperature" :data="d" />
|
||||||
|
<MetricRow label="Feels Like" :value="`${d.heat_index?.toFixed(1)}°C`" metric-key="heat_index" :data="d" />
|
||||||
|
<MetricRow label="Dew Point" :value="`${d.dew_point?.toFixed(1)}°C`" metric-key="dew_point" :data="d" />
|
||||||
|
<div class="divider" />
|
||||||
|
<MetricRow label="Humidity" :value="`${d.humidity?.toFixed(1)}%`" metric-key="humidity" :data="d" />
|
||||||
|
<MetricRow label="Abs Humidity" :value="`${d.humidity_abs?.toFixed(2)} g/m³`" metric-key="humidity_abs" :data="d" />
|
||||||
|
<MetricRow label="Vapor Pressure Deficit" :value="`${d.vapor_pressure_deficit?.toFixed(2)} kPa`" metric-key="vapor_pressure_deficit" :data="d" />
|
||||||
|
<div class="divider" />
|
||||||
|
<MetricRow label="Pressure" :value="`${d.pressure_hpa?.toFixed(1)} hPa`" metric-key="pressure_hpa" :data="d" />
|
||||||
|
<MetricRow label="Sea Level Pressure" :value="`${d.pressure_slp?.toFixed(1)} hPa`" metric-key="pressure_slp" :data="d" />
|
||||||
|
<MetricRow label="Trend" :value="d.pressure_trend" />
|
||||||
|
<div class="divider" />
|
||||||
|
<MetricRow label="Visibility" :value="`${d.visibility?.toFixed(1)} km`" metric-key="visibility" :data="d" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -37,9 +37,6 @@ onMounted(async () => {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&::-webkit-scrollbar { height: 4px; }
|
|
||||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.day {
|
.day {
|
||||||
|
|||||||
@@ -1,13 +1,63 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import {computed, ref, watch} from 'vue';
|
||||||
import { METRICS } from '../services/units'
|
import {api} from '../services/api';
|
||||||
import MetricChart from './MetricChart.vue'
|
|
||||||
import type { DataRow } from '../services/api'
|
|
||||||
|
|
||||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
const props = defineProps<{metricKey: string | null}>();
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
const emit = defineEmits<{(e: 'close'): void}>();
|
||||||
|
|
||||||
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
const mode = ref<'hourly' | 'daily'>('hourly');
|
||||||
|
const rows = ref<any[]>([]);
|
||||||
|
const W = 600, H = 200, PAD = 30;
|
||||||
|
|
||||||
|
watch([() => props.metricKey, mode], async ([key]) => {
|
||||||
|
if(!key) return;
|
||||||
|
const now = new Date(), past = new Date(), future = new Date();
|
||||||
|
past.setDate(past.getDate() - 3);
|
||||||
|
future.setDate(future.getDate() + 3);
|
||||||
|
rows.value = mode.value === 'hourly'
|
||||||
|
? await api.hourly(past, future)
|
||||||
|
: await api.daily(past, future);
|
||||||
|
}, {immediate: true});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const points = computed(() => {
|
||||||
|
if(!rows.value.length || !props.metricKey) return [];
|
||||||
|
return rows.value.map(r => ({
|
||||||
|
t: new Date(r.time as string).getTime(),
|
||||||
|
v: r[props.metricKey!] as number | null,
|
||||||
|
})).filter(p => p.v != null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const {minT, maxT, minV, maxV, path, currentX} = computed(() => {
|
||||||
|
const pts = points.value;
|
||||||
|
if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:'', currentX: 0};
|
||||||
|
const ts = pts.map(p => p.t), vs = pts.map(p => p.v as number);
|
||||||
|
const minT = Math.min(...ts), maxT = Math.max(...ts);
|
||||||
|
const minV = Math.min(...vs), maxV = Math.max(...vs);
|
||||||
|
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
||||||
|
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
||||||
|
const nowT = now.getTime();
|
||||||
|
const currentX = xOf(nowT);
|
||||||
|
|
||||||
|
// split historic vs future
|
||||||
|
const hist = pts.filter(p => p.t <= nowT);
|
||||||
|
const fut = pts.filter(p => p.t >= nowT);
|
||||||
|
const histPath = hist.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
|
||||||
|
const futPath = fut.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
|
||||||
|
const path = {hist: histPath, fut: futPath};
|
||||||
|
return {minT, maxT, minV, maxV, path, currentX};
|
||||||
|
}).value;
|
||||||
|
|
||||||
|
const currentDot = computed(() => {
|
||||||
|
const pts = points.value;
|
||||||
|
if(!pts.length || !props.metricKey) return null;
|
||||||
|
const nowT = now.getTime();
|
||||||
|
const closest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||||
|
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
||||||
|
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
||||||
|
return {x: xOf(closest.t), y: yOf(closest.v as number), v: closest.v};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -22,35 +72,22 @@ const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 700px;
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
.title { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||||
font-size: 18px;
|
.close-btn {
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -59,12 +96,26 @@ const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:hover { background: var(--hover); }
|
&:hover { background: var(--hover); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svg { width: 100%; overflow: visible; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -73,13 +124,28 @@ const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
|||||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">
|
<span class="title">{{ metricKey }}</span>
|
||||||
<span>{{ meta?.icon }}</span>
|
|
||||||
<span>{{ meta?.label ?? metricKey }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="close-btn" @click="emit('close')">✕</button>
|
<button class="close-btn" @click="emit('close')">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<MetricChart :metric-key="metricKey" :current-data="currentData" />
|
|
||||||
|
<div class="mode-toggle">
|
||||||
|
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly</button>
|
||||||
|
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||||
|
<!-- grid -->
|
||||||
|
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||||
|
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||||
|
<!-- now line -->
|
||||||
|
<line :x1="currentX" :y1="PAD" :x2="currentX" :y2="H-PAD" stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.5"/>
|
||||||
|
<!-- historic solid -->
|
||||||
|
<path :d="path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||||
|
<!-- future dashed -->
|
||||||
|
<path :d="path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.6"/>
|
||||||
|
<!-- current dot -->
|
||||||
|
<circle v-if="currentDot" :cx="currentDot.x" :cy="currentDot.y" r="4" fill="var(--accent)"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ onMounted(async () => {
|
|||||||
<span class="chevron" :class="{ open }">▼</span>
|
<span class="chevron" :class="{ open }">▼</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="max-height: 50vh; overflow-y: auto">
|
<div v-if="open" style="height: 50vh; overflow-y: auto">
|
||||||
<div v-if="open" class="hour-list">
|
<div class="hour-list">
|
||||||
<template v-if="hours?.length">
|
<template v-if="hours?.length">
|
||||||
<div v-for="h in hours" class="hour-row">
|
<div v-for="h in hours" class="hour-row">
|
||||||
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
||||||
|
|||||||
33
client/src/components/MetricRow.vue
Normal file
33
client/src/components/MetricRow.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {inject} from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{label: string; value: any; metricKey?: string; data?: any}>();
|
||||||
|
const openGraph = inject<(key: string, data: any) => void>('openGraph');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { background: var(--hover); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.label { color: var(--text-muted); }
|
||||||
|
.value { font-weight: 600; color: var(--text); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="row" :class="{clickable: !!metricKey}" @click="metricKey && openGraph?.(metricKey, data)">
|
||||||
|
<span class="label">{{ label }}</span>
|
||||||
|
<span class="value">{{ value ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,28 +1,31 @@
|
|||||||
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : ''
|
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
||||||
|
|
||||||
export type DataRow = Record<string, number | string | null>
|
export type DataRow = Record<string, number | string | null>
|
||||||
|
|
||||||
const cache: any = {};
|
const cache: any = {};
|
||||||
|
|
||||||
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||||
const url = new URL(`${BASE}${path}`, window.location.origin)
|
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
|
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||||
if(cache[url.toString()]) return cache[url.toString()];
|
const key = url.toString();
|
||||||
cache[url.toString()] = await fetch(url.toString()).then(resp => resp.json()).finally(() => { delete cache[url.toString()]; });
|
if(cache[key]) return cache[key];
|
||||||
return cache[url.toString()]
|
cache[key] = fetch(url.toString())
|
||||||
|
.then(resp => resp.json())
|
||||||
|
.finally(() => delete cache[key]);
|
||||||
|
return cache[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
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?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
|
hourly: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
|
||||||
...(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)} : {}),
|
||||||
...(fields ? { fields } : {})
|
...(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)} : {}),
|
||||||
...(fields ? { fields } : {})
|
...(fields ? {fields} : {})
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
`<script setup lang="ts">
|
`<script setup lang="ts">
|
||||||
|
import EnvironmentCard from '@/components/EnvironmentCard.vue';
|
||||||
|
import GraphModal from '@/components/GraphModal.vue';
|
||||||
import HourlyForecast from '@/components/HourlyForecast.vue';
|
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, provide, ref} from 'vue';
|
||||||
import MapView from '../components/MapView.vue';
|
import MapView from '../components/MapView.vue';
|
||||||
import CurrentWeather from '../components/CurrentWeather.vue';
|
import CurrentWeather from '../components/CurrentWeather.vue';
|
||||||
import ForecastStrip from '../components/ForecastStrip.vue';
|
import ForecastStrip from '../components/ForecastStrip.vue';
|
||||||
@@ -10,8 +12,11 @@ const day = ref(formatDate('dddd'));
|
|||||||
const date = ref(formatDate('MMMM D'));
|
const date = ref(formatDate('MMMM D'));
|
||||||
const time = ref(formatDate('h:mm A'));
|
const time = ref(formatDate('h:mm A'));
|
||||||
const props = defineProps<{dark: boolean}>();
|
const props = defineProps<{dark: boolean}>();
|
||||||
|
const selectedMetric = ref<string | null>(null);
|
||||||
let clock: ReturnType<typeof setInterval>;
|
let clock: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
|
provide('openGraph', (key: string) => selectedMetric.value = key);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
clock = setInterval(() => {
|
clock = setInterval(() => {
|
||||||
day.value = formatDate('dddd');
|
day.value = formatDate('dddd');
|
||||||
@@ -161,13 +166,11 @@ onUnmounted(() => {
|
|||||||
<CurrentWeather />
|
<CurrentWeather />
|
||||||
<HourlyForecast />
|
<HourlyForecast />
|
||||||
<ForecastStrip />
|
<ForecastStrip />
|
||||||
|
|
||||||
|
<EnvironmentCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <GraphModal-->
|
<GraphModal :metric-key="selectedMetric" @close="selectedMetric = null" />
|
||||||
<!-- :metric-key="selectedMetric"-->
|
|
||||||
<!-- :current-data="current"-->
|
|
||||||
<!-- @close="selectedMetric = null"-->
|
|
||||||
<!-- />-->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -17,5 +17,30 @@ export function cfg() {
|
|||||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||||
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
||||||
|
TZ: process.env.TZ || 'UTC',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the UTC offset in minutes for the configured TZ at a given date
|
||||||
|
export function tzOffsetMinutes(date = new Date()) {
|
||||||
|
const tz = cfg().TZ
|
||||||
|
// Format the date in the target timezone and compare to UTC
|
||||||
|
const local = new Date(date.toLocaleString('en-US', { timeZone: tz }))
|
||||||
|
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
|
||||||
|
return Math.round((local - utc) / 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift a UTC date to local-time-as-if-UTC (for day bucketing)
|
||||||
|
export function toLocalMidnight(date = new Date()) {
|
||||||
|
const offset = tzOffsetMinutes(date)
|
||||||
|
const local = new Date(date.getTime() + offset * 60_000)
|
||||||
|
local.setUTCHours(0, 0, 0, 0)
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get YYYY-MM-DD string in local timezone
|
||||||
|
export function localDateStr(date = new Date()) {
|
||||||
|
const offset = tzOffsetMinutes(date)
|
||||||
|
const shifted = new Date(date.getTime() + offset * 60_000)
|
||||||
|
return shifted.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// forecast.mjs
|
|
||||||
import {queryHourly, getCoords, queryCurrent} from './influx.mjs';
|
import {queryHourly, getCoords, queryCurrent} from './influx.mjs';
|
||||||
import {getCelestialCurrent} from './celestial.mjs';
|
import {getCelestialCurrent} from './celestial.mjs';
|
||||||
import {getWeatherCondition} from './openweather.mjs';
|
import {getWeatherCondition} from './openweather.mjs';
|
||||||
|
import {localDateStr} from './config.mjs';
|
||||||
|
|
||||||
export let lastForecast = { ts: 0, slots: [], summary: null }
|
export let lastForecast = { ts: 0, slots: [], summary: null }
|
||||||
export const forecastTTL = 5 * 60 * 60_000
|
export const forecastTTL = 5 * 60 * 60_000
|
||||||
|
|
||||||
// ── Physics Helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function linearTrend(values) {
|
function linearTrend(values) {
|
||||||
const n = values.length;
|
const n = values.length;
|
||||||
if (n < 2) return 0;
|
if (n < 2) return 0;
|
||||||
@@ -45,8 +43,6 @@ function absoluteHumidity(tempC, humidity) {
|
|||||||
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000;
|
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pressure Tendency → WMO Weather Rule ─────────────────────────────────────
|
|
||||||
|
|
||||||
function pressureRule(trend3h, currentHpa) {
|
function pressureRule(trend3h, currentHpa) {
|
||||||
if (trend3h < -2.0) return { label: 'Storm likely', code: 211, precipChance: 0.85 };
|
if (trend3h < -2.0) return { label: 'Storm likely', code: 211, precipChance: 0.85 };
|
||||||
if (trend3h < -0.8) return { label: 'Rain likely', code: 501, precipChance: 0.65 };
|
if (trend3h < -0.8) return { label: 'Rain likely', code: 501, precipChance: 0.65 };
|
||||||
@@ -58,33 +54,21 @@ function pressureRule(trend3h, currentHpa) {
|
|||||||
return { label: 'Fair', code: 800, precipChance: 0.05 };
|
return { label: 'Fair', code: 800, precipChance: 0.05 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Solar Heating Curve ───────────────────────────────────────────────────────
|
|
||||||
// Uses sun_elevation from celestial.mjs — max ~6°C gain at solar noon, clear sky
|
|
||||||
|
|
||||||
function solarTempDelta(sunElevation, cloudFraction) {
|
function solarTempDelta(sunElevation, cloudFraction) {
|
||||||
if (sunElevation <= 0) return 0;
|
if (sunElevation <= 0) return 0;
|
||||||
return Math.sin((sunElevation * Math.PI) / 180) * (1 - cloudFraction) * 6;
|
return Math.sin((sunElevation * Math.PI) / 180) * (1 - cloudFraction) * 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Nocturnal Cooling (Stefan–Boltzmann approximation) ────────────────────────
|
|
||||||
// Clear nights lose more heat via longwave radiation
|
|
||||||
|
|
||||||
function nocturnalCooling(cloudFraction) {
|
function nocturnalCooling(cloudFraction) {
|
||||||
return (1 - cloudFraction) * 1.5; // °C/hr max clear-sky cooling
|
return (1 - cloudFraction) * 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Humidity Forecast ─────────────────────────────────────────────────────────
|
|
||||||
// As temp rises, relative humidity drops (conserving absolute humidity)
|
|
||||||
|
|
||||||
function forecastHumidity(absHumidity, forecastTempC) {
|
function forecastHumidity(absHumidity, forecastTempC) {
|
||||||
const svp = 0.6108 * Math.exp((17.27 * forecastTempC) / (forecastTempC + 237.3));
|
const svp = 0.6108 * Math.exp((17.27 * forecastTempC) / (forecastTempC + 237.3));
|
||||||
const rh = (absHumidity * (forecastTempC + 273.15)) / (svp * 2165) * 100;
|
const rh = (absHumidity * (forecastTempC + 273.15)) / (svp * 2165) * 100;
|
||||||
return Math.min(100, Math.max(0, Math.round(rh * 10) / 10));
|
return Math.min(100, Math.max(0, Math.round(rh * 10) / 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cloud Cover Estimate ──────────────────────────────────────────────────────
|
|
||||||
// Derived from humidity + pressure tendency (Oktas-style heuristic)
|
|
||||||
|
|
||||||
function estimateClouds(humidity, pressureTrend) {
|
function estimateClouds(humidity, pressureTrend) {
|
||||||
let base = humidity / 100 * 0.8;
|
let base = humidity / 100 * 0.8;
|
||||||
if (pressureTrend < -0.3) base = Math.min(1, base + 0.2);
|
if (pressureTrend < -0.3) base = Math.min(1, base + 0.2);
|
||||||
@@ -92,16 +76,11 @@ function estimateClouds(humidity, pressureTrend) {
|
|||||||
return Math.round(base * 100) / 100;
|
return Math.round(base * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Wind Forecast ─────────────────────────────────────────────────────────────
|
|
||||||
// Pressure gradient approximation — steeper falls = stronger winds
|
|
||||||
|
|
||||||
function forecastWind(currentWind, pressureTrend) {
|
function forecastWind(currentWind, pressureTrend) {
|
||||||
const boost = pressureTrend < -1 ? 1.3 : pressureTrend < -0.5 ? 1.1 : 1.0;
|
const boost = pressureTrend < -1 ? 1.3 : pressureTrend < -0.5 ? 1.1 : 1.0;
|
||||||
return Math.round(currentWind * boost * 10) / 10;
|
return Math.round(currentWind * boost * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main 24hr Forecast ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function get24HourForecast(currentSensors) {
|
export async function get24HourForecast(currentSensors) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const sixAgo = new Date(now - 6 * 3600 * 1000);
|
const sixAgo = new Date(now - 6 * 3600 * 1000);
|
||||||
@@ -109,50 +88,38 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
const coords = await getCoords();
|
const coords = await getCoords();
|
||||||
const history = await queryHourly(sixAgo, now);
|
const history = await queryHourly(sixAgo, now);
|
||||||
|
|
||||||
// Extract trend series from history
|
|
||||||
const pressures = history.map(r => r.pressure_hpa).filter(Number.isFinite);
|
const pressures = history.map(r => r.pressure_hpa).filter(Number.isFinite);
|
||||||
const temps = history.map(r => r.temperature).filter(Number.isFinite);
|
const temps = history.map(r => r.temperature).filter(Number.isFinite);
|
||||||
const humidities = history.map(r => r.humidity).filter(Number.isFinite);
|
const humidities = history.map(r => r.humidity).filter(Number.isFinite);
|
||||||
const winds = history.map(r => r.wind_speed).filter(Number.isFinite);
|
const winds = history.map(r => r.wind_speed).filter(Number.isFinite);
|
||||||
|
|
||||||
const pressureTrend = linearTrend(pressures); // hPa/hr
|
const pressureTrend = linearTrend(pressures);
|
||||||
const tempTrend = linearTrend(temps); // °C/hr
|
const tempTrend = linearTrend(temps);
|
||||||
const humidityTrend = linearTrend(humidities); // %/hr
|
const humidityTrend = linearTrend(humidities);
|
||||||
const pressure3h = pressureTrend * 3; // 3hr tendency for WMO rules
|
const pressure3h = pressureTrend * 3;
|
||||||
|
|
||||||
// Seed from current sensors
|
|
||||||
const seedTemp = currentSensors.temperature ?? temps.at(-1) ?? 20;
|
const seedTemp = currentSensors.temperature ?? temps.at(-1) ?? 20;
|
||||||
const seedHumidity = currentSensors.humidity ?? humidities.at(-1) ?? 60;
|
const seedHumidity = currentSensors.humidity ?? humidities.at(-1) ?? 60;
|
||||||
const seedPressure = currentSensors.pressure_hpa ?? pressures.at(-1) ?? 1013;
|
const seedPressure = currentSensors.pressure_hpa ?? pressures.at(-1) ?? 1013;
|
||||||
const seedWind = currentSensors.wind_speed ?? winds.at(-1) ?? 0;
|
const seedWind = currentSensors.wind_speed ?? winds.at(-1) ?? 0;
|
||||||
const seedAbsHum = currentSensors.humidity_abs
|
const seedAbsHum = currentSensors.humidity_abs ?? absoluteHumidity(seedTemp, seedHumidity);
|
||||||
?? absoluteHumidity(seedTemp, seedHumidity);
|
|
||||||
|
|
||||||
const weather = pressureRule(pressure3h, seedPressure);
|
const weather = pressureRule(pressure3h, seedPressure);
|
||||||
|
|
||||||
// Build 24 hourly slots
|
|
||||||
const slots = Array.from({ length: 24 }, (_, i) => {
|
const slots = Array.from({ length: 24 }, (_, i) => {
|
||||||
const time = new Date(now.getTime() + (i + 1) * 3600 * 1000);
|
const time = new Date(now.getTime() + (i + 1) * 3600 * 1000);
|
||||||
time.setMinutes(0, 0, 0);
|
time.setMinutes(0, 0, 0);
|
||||||
|
|
||||||
// Pressure decays toward mean over time (damped trend)
|
|
||||||
const pressureDamping = Math.exp(-i * 0.08);
|
const pressureDamping = Math.exp(-i * 0.08);
|
||||||
const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping;
|
const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping;
|
||||||
|
|
||||||
// Cloud cover from humidity + pressure tendency
|
|
||||||
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
||||||
|
|
||||||
// Temperature: solar heating + nocturnal cooling on top of background trend
|
|
||||||
// We use a placeholder sun_elevation here — getCelestialForecast will enrich it
|
|
||||||
// so we do a two-pass: first build slots, then apply celestial, then fix temp
|
|
||||||
const bgTemp = seedTemp + tempTrend * (i + 1);
|
const bgTemp = seedTemp + tempTrend * (i + 1);
|
||||||
const temperature = Math.round(bgTemp * 10) / 10;
|
const temperature = Math.round(bgTemp * 10) / 10;
|
||||||
|
|
||||||
const humidity = forecastHumidity(seedAbsHum, temperature);
|
const humidity = forecastHumidity(seedAbsHum, temperature);
|
||||||
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);
|
||||||
let snapshot = {
|
const snapshot = {
|
||||||
time,
|
time,
|
||||||
temperature,
|
temperature,
|
||||||
humidity,
|
humidity,
|
||||||
@@ -172,7 +139,6 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Second pass — apply solar heating now that we have sun_elevation per slot
|
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
if (slot.sunrise && slot.sunset) {
|
if (slot.sunrise && slot.sunset) {
|
||||||
const t = slot.time instanceof Date ? slot.time.getTime() : new Date(slot.time).getTime();
|
const t = slot.time instanceof Date ? slot.time.getTime() : new Date(slot.time).getTime();
|
||||||
@@ -196,20 +162,38 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group slots into local-time day buckets, summarise each as daytime-only
|
||||||
|
function groupByLocalDay(slots) {
|
||||||
|
const days = {}
|
||||||
|
for (const slot of slots) {
|
||||||
|
const key = localDateStr(new Date(slot.time))
|
||||||
|
if (!days[key]) days[key] = []
|
||||||
|
days[key].push(slot)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
function summarise(slots) {
|
function summarise(slots) {
|
||||||
if (!slots.length) return null
|
if (!slots.length) return null
|
||||||
const temps = slots.map(s => s.temperature).filter(Number.isFinite)
|
|
||||||
const precips = slots.map(s => s.precipitation_chance).filter(Number.isFinite)
|
|
||||||
const winds = slots.map(s => s.wind_speed).filter(Number.isFinite)
|
|
||||||
const gusts = slots.map(s => s.wind_gusts).filter(Number.isFinite)
|
|
||||||
const uvs = slots.map(s => s.uv_index).filter(Number.isFinite)
|
|
||||||
const solar = slots.map(s => s.solar_wm2).filter(Number.isFinite)
|
|
||||||
|
|
||||||
// Most frequent label/code by occurrence
|
// Use only daytime slots for representative label/stats, fallback to all if none
|
||||||
|
const daytime = slots.filter(s => s.daytime)
|
||||||
|
const source = daytime.length ? daytime : slots
|
||||||
|
|
||||||
|
const temps = source.map(s => s.temperature).filter(Number.isFinite)
|
||||||
|
const precips = source.map(s => s.precipitation_chance).filter(Number.isFinite)
|
||||||
|
const winds = source.map(s => s.wind_speed).filter(Number.isFinite)
|
||||||
|
const gusts = source.map(s => s.wind_gusts).filter(Number.isFinite)
|
||||||
|
const uvs = source.map(s => s.uv_index).filter(Number.isFinite)
|
||||||
|
const solar = source.map(s => s.solar_wm2).filter(Number.isFinite)
|
||||||
|
|
||||||
|
// Min/max temp uses ALL slots for the full day range
|
||||||
|
const allTemps = slots.map(s => s.temperature).filter(Number.isFinite)
|
||||||
|
|
||||||
const labelCount = {}
|
const labelCount = {}
|
||||||
for (const s of slots) labelCount[s.label] = (labelCount[s.label] || 0) + 1
|
for (const s of source) labelCount[s.label] = (labelCount[s.label] || 0) + 1
|
||||||
const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0]
|
const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0]
|
||||||
const dominant = slots.find(s => s.label === label)
|
const dominant = source.find(s => s.label === label)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
time: slots[0].time,
|
time: slots[0].time,
|
||||||
@@ -217,8 +201,8 @@ function summarise(slots) {
|
|||||||
code: dominant.code,
|
code: dominant.code,
|
||||||
icon: dominant.icon,
|
icon: dominant.icon,
|
||||||
temperature: Math.max(...temps),
|
temperature: Math.max(...temps),
|
||||||
temperature_max: Math.max(...temps),
|
temperature_max: Math.max(...allTemps),
|
||||||
temperature_min: Math.min(...temps),
|
temperature_min: Math.min(...allTemps),
|
||||||
precipitation_chance: Math.round(Math.max(...precips)),
|
precipitation_chance: Math.round(Math.max(...precips)),
|
||||||
wind_speed: Math.round(Math.max(...winds) * 10) / 10,
|
wind_speed: Math.round(Math.max(...winds) * 10) / 10,
|
||||||
wind_gusts: gusts.length ? Math.round(Math.max(...gusts) * 10) / 10 : null,
|
wind_gusts: gusts.length ? Math.round(Math.max(...gusts) * 10) / 10 : null,
|
||||||
@@ -227,18 +211,22 @@ function summarise(slots) {
|
|||||||
solar_wm2: solar.length ? Math.round(solar.reduce((a, b) => a + b, 0) / solar.length * 10) / 10 : null,
|
solar_wm2: solar.length ? Math.round(solar.reduce((a, b) => a + b, 0) / solar.length * 10) / 10 : null,
|
||||||
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
||||||
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
||||||
// useful extras for /api/current
|
humidity: Math.round(source.map(s => s.humidity).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
||||||
humidity: Math.round(slots.map(s => s.humidity).filter(Number.isFinite).reduce((a, b) => a + b, 0) / slots.length * 10) / 10,
|
pressure_hpa: Math.round(source.map(s => s.pressure_hpa).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
||||||
pressure_hpa: Math.round(slots.map(s => s.pressure_hpa).filter(Number.isFinite).reduce((a, b) => a + b, 0) / slots.length * 10) / 10,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getForecast() {
|
export async function getForecast() {
|
||||||
if(Date.now() - lastForecast.ts < forecastTTL) return lastForecast;
|
if (Date.now() - lastForecast.ts < forecastTTL) return lastForecast;
|
||||||
const sensors = await queryCurrent().catch(() => null)
|
const sensors = await queryCurrent().catch(() => null)
|
||||||
if(!sensors) return lastForecast
|
if (!sensors) return lastForecast
|
||||||
const slots = await get24HourForecast(sensors).catch(() => null)
|
const slots = await get24HourForecast(sensors).catch(() => null)
|
||||||
if(!slots) return lastForecast
|
if (!slots) return lastForecast
|
||||||
lastForecast = { ts: Date.now(), slots, summary: summarise(slots) }
|
|
||||||
|
// Summarise today's local-day slots only
|
||||||
|
const days = groupByLocalDay(slots)
|
||||||
|
const todayKey = localDateStr(new Date())
|
||||||
|
const todaySlots = days[todayKey] ?? slots
|
||||||
|
lastForecast = { ts: Date.now(), slots, summary: summarise(todaySlots) }
|
||||||
return lastForecast
|
return lastForecast
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// openmeteo.mjs
|
|
||||||
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||||
import { resolve, dirname } from 'path'
|
import { resolve, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { pipeline } from 'stream/promises'
|
import { pipeline } from 'stream/promises'
|
||||||
|
import { localDateStr } from './config.mjs'
|
||||||
|
|
||||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||||
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
||||||
@@ -53,8 +53,6 @@ async function cachedFetch(key, url) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Physics helpers (mirror forecast.mjs — no import to keep this self-contained) ──
|
|
||||||
|
|
||||||
function dewPoint(tempC, humidity) {
|
function dewPoint(tempC, humidity) {
|
||||||
const a = 17.27, b = 237.7
|
const a = 17.27, b = 237.7
|
||||||
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100)
|
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100)
|
||||||
@@ -98,16 +96,6 @@ function cloudsLabel(fraction) {
|
|||||||
return 'Overcast'
|
return 'Overcast'
|
||||||
}
|
}
|
||||||
|
|
||||||
function airQualityLabel(aqi) {
|
|
||||||
if (!aqi) return null
|
|
||||||
if (aqi <= 50) return 'Good'
|
|
||||||
if (aqi <= 100) return 'Moderate'
|
|
||||||
if (aqi <= 150) return 'Unhealthy for Sensitive'
|
|
||||||
if (aqi <= 200) return 'Unhealthy'
|
|
||||||
if (aqi <= 300) return 'Very Unhealthy'
|
|
||||||
return 'Hazardous'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DAILY_FIELDS = [
|
const DAILY_FIELDS = [
|
||||||
@@ -133,13 +121,17 @@ export async function dailyWeather(lat, lon, start, end) {
|
|||||||
const temp = data.daily.temperature_2m_max[i]
|
const temp = data.daily.temperature_2m_max[i]
|
||||||
const humidity = data.daily.relative_humidity_2m_mean[i]
|
const humidity = data.daily.relative_humidity_2m_mean[i]
|
||||||
const uv = data.daily.uv_index_max[i]
|
const uv = data.daily.uv_index_max[i]
|
||||||
const clouds = null // not available in daily
|
|
||||||
|
// Open-Meteo returns local date strings — parse as local midnight using TZ
|
||||||
|
// e.g. "2026-03-01" → local midnight, not UTC midnight
|
||||||
|
const tzOffset = (new Date(new Date().toLocaleString('en-US', { timeZone: require('./config.mjs').cfg().TZ })) - new Date(new Date().toLocaleString('en-US', { timeZone: 'UTC' }))) / 60_000
|
||||||
|
const localMidnight = new Date(new Date(`${time}T00:00:00Z`).getTime() - tzOffset * 60_000)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: WMO[code] || 'Unknown',
|
label: WMO[code] || 'Unknown',
|
||||||
icon,
|
icon,
|
||||||
code,
|
code,
|
||||||
time: new Date(time + 'T00:00:00Z'),
|
time: localMidnight,
|
||||||
temperature: temp,
|
temperature: temp,
|
||||||
temperature_max: temp,
|
temperature_max: temp,
|
||||||
temperature_min: data.daily.temperature_2m_min[i],
|
temperature_min: data.daily.temperature_2m_min[i],
|
||||||
@@ -156,9 +148,9 @@ export async function dailyWeather(lat, lon, start, end) {
|
|||||||
uv_index: uv,
|
uv_index: uv,
|
||||||
uv_index_label: uvLabel(uv),
|
uv_index_label: uvLabel(uv),
|
||||||
solar_wm2: data.daily.shortwave_radiation_sum[i],
|
solar_wm2: data.daily.shortwave_radiation_sum[i],
|
||||||
clouds,
|
clouds: null,
|
||||||
sunrise: data.daily.sunrise[i],
|
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
||||||
sunset: data.daily.sunset[i],
|
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -178,25 +170,34 @@ export async function hourlyWeather(lat, lon, start, end) {
|
|||||||
const startStr = start instanceof Date ? start.toISOString().slice(0, 10) : start.slice(0, 10)
|
const startStr = start instanceof Date ? start.toISOString().slice(0, 10) : start.slice(0, 10)
|
||||||
const endStr = end instanceof Date ? end.toISOString().slice(0, 10) : end.slice(0, 10)
|
const endStr = end instanceof Date ? end.toISOString().slice(0, 10) : end.slice(0, 10)
|
||||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
||||||
`&hourly=${HOURLY_FIELDS}&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
`&hourly=${HOURLY_FIELDS}&daily=sunrise,sunset&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
||||||
|
|
||||||
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||||
if (!data?.hourly?.time) return []
|
if (!data?.hourly?.time) return []
|
||||||
|
|
||||||
|
// Build sunrise/sunset map keyed by local date string from Open-Meteo
|
||||||
|
// Open-Meteo returns these as local time ISO strings e.g. "2026-03-01T06:45"
|
||||||
const sunMap = {}
|
const sunMap = {}
|
||||||
if (data.daily?.time) {
|
if (data.daily?.time) {
|
||||||
data.daily.time.forEach((date, i) => {
|
data.daily.time.forEach((date, i) => {
|
||||||
sunMap[date] = { sunrise: data.daily.sunrise[i], sunset: data.daily.sunset[i] }
|
sunMap[date] = {
|
||||||
|
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
||||||
|
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all(data.hourly.time.map(async (time, i) => {
|
return Promise.all(data.hourly.time.map(async (time, i) => {
|
||||||
const date = time.slice(0, 10)
|
// Open-Meteo returns local time strings — treat as UTC-equivalent for Date parsing
|
||||||
const sun = sunMap[date] ?? {}
|
// then key into sunMap using the local date portion
|
||||||
const t = new Date(time).getTime()
|
const slotDate = new Date(time) // parsed as local ISO (no Z suffix) — browser/node both treat naive as local
|
||||||
|
const dateKey = localDateStr(slotDate)
|
||||||
|
const sun = sunMap[dateKey] ?? {}
|
||||||
|
const t = slotDate.getTime()
|
||||||
const daytime = sun.sunrise && sun.sunset
|
const daytime = sun.sunrise && sun.sunset
|
||||||
? t >= new Date(sun.sunrise).getTime() && t < new Date(sun.sunset).getTime()
|
? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
|
||||||
: false
|
: false
|
||||||
|
|
||||||
const code = data.hourly.weathercode[i]
|
const code = data.hourly.weathercode[i]
|
||||||
const icon = await ensureIcon(code).catch(() => null)
|
const icon = await ensureIcon(code).catch(() => null)
|
||||||
const temp = data.hourly.temperature_2m[i]
|
const temp = data.hourly.temperature_2m[i]
|
||||||
@@ -205,7 +206,7 @@ export async function hourlyWeather(lat, lon, start, end) {
|
|||||||
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
|
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
|
||||||
|
|
||||||
return {
|
return {
|
||||||
time: time,
|
time: slotDate,
|
||||||
daytime,
|
daytime,
|
||||||
label: WMO[code] || 'Unknown',
|
label: WMO[code] || 'Unknown',
|
||||||
icon,
|
icon,
|
||||||
@@ -215,8 +216,7 @@ export async function hourlyWeather(lat, lon, start, end) {
|
|||||||
humidity_abs: absoluteHumidity(temp, humidity),
|
humidity_abs: absoluteHumidity(temp, humidity),
|
||||||
dew_point: dewPoint(temp, humidity),
|
dew_point: dewPoint(temp, humidity),
|
||||||
heat_index: heatIndex(temp, humidity),
|
heat_index: heatIndex(temp, humidity),
|
||||||
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i]
|
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
|
||||||
?? vaporPressureDeficit(temp, humidity),
|
|
||||||
pressure_hpa: data.hourly.surface_pressure[i],
|
pressure_hpa: data.hourly.surface_pressure[i],
|
||||||
precipitation: data.hourly.precipitation[i],
|
precipitation: data.hourly.precipitation[i],
|
||||||
precipitation_chance: data.hourly.precipitation_probability[i],
|
precipitation_chance: data.hourly.precipitation_probability[i],
|
||||||
@@ -228,7 +228,7 @@ export async function hourlyWeather(lat, lon, start, end) {
|
|||||||
solar_wm2: data.hourly.shortwave_radiation[i],
|
solar_wm2: data.hourly.shortwave_radiation[i],
|
||||||
clouds_label: cloudsLabel(clouds),
|
clouds_label: cloudsLabel(clouds),
|
||||||
visibility: data.hourly.visibility[i] != null
|
visibility: data.hourly.visibility[i] != null
|
||||||
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10 // m → km
|
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
|
||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {resolve, dirname} from 'path';
|
import {resolve, dirname} from 'path';
|
||||||
import {fileURLToPath} from 'url';
|
import {fileURLToPath} from 'url';
|
||||||
import {cfg} from './config.mjs';
|
import {cfg, localDateStr, tzOffsetMinutes} from './config.mjs';
|
||||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
||||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||||
import {getSpaceWeather} from './space.mjs';
|
import {getSpaceWeather} from './space.mjs';
|
||||||
@@ -13,6 +13,16 @@ import {getWeatherCondition} from './openweather.mjs';
|
|||||||
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
|
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
|
||||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||||
|
|
||||||
|
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('[unhandledRejection]', promise, '\nReason:', reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('[uncaughtException]', err)
|
||||||
|
})
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
const CLIENT_DIST = resolve(DIR, '../public');
|
const CLIENT_DIST = resolve(DIR, '../public');
|
||||||
@@ -44,7 +54,6 @@ app.get('/api/current', async (req, res) => {
|
|||||||
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||||
const condition = getWeatherCondition(Object.assign(sensors, space))
|
const condition = getWeatherCondition(Object.assign(sensors, space))
|
||||||
|
|
||||||
// Merge forecast summary fields — sensor readings take priority
|
|
||||||
const forecast = await getForecast();
|
const forecast = await getForecast();
|
||||||
const merged = {
|
const merged = {
|
||||||
...(forecast?.summary ? {
|
...(forecast?.summary ? {
|
||||||
@@ -73,7 +82,6 @@ app.get('/api/hourly', async (req, res) => {
|
|||||||
|
|
||||||
const coords = await getCoords()
|
const coords = await getCoords()
|
||||||
|
|
||||||
// Past → now: InfluxDB
|
|
||||||
const historyEnd = new Date(Math.min(now, end))
|
const historyEnd = new Date(Math.min(now, end))
|
||||||
historyEnd.setMinutes(0, 0, 0)
|
historyEnd.setMinutes(0, 0, 0)
|
||||||
|
|
||||||
@@ -82,12 +90,10 @@ app.get('/api/hourly', async (req, res) => {
|
|||||||
])
|
])
|
||||||
const history = historyResult.status === 'fulfilled' ? historyResult.value : []
|
const history = historyResult.status === 'fulfilled' ? historyResult.value : []
|
||||||
|
|
||||||
// Now → +24h: cached physics forecast
|
|
||||||
const physics = end > now
|
const physics = end > now
|
||||||
? lastForecast.slots.filter(s => { const t = new Date(s.time); return t >= now && t <= new Date(Math.min(end, plus24)) })
|
? lastForecast.slots.filter(s => { const t = new Date(s.time); return t >= now && t <= new Date(Math.min(end, plus24)) })
|
||||||
: []
|
: []
|
||||||
|
|
||||||
// +24h → beyond: Open-Meteo
|
|
||||||
let meteo = []
|
let meteo = []
|
||||||
if (end > plus24) {
|
if (end > plus24) {
|
||||||
const [meteoResult] = await Promise.allSettled([hourlyWeather(coords.latitude, coords.longitude, plus24, end)])
|
const [meteoResult] = await Promise.allSettled([hourlyWeather(coords.latitude, coords.longitude, plus24, end)])
|
||||||
@@ -103,22 +109,32 @@ app.get('/api/hourly', async (req, res) => {
|
|||||||
app.get('/api/daily', async (req, res) => {
|
app.get('/api/daily', async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
now.setHours(0, 0, 0, 0)
|
|
||||||
const next = new Date(now); next.setDate(now.getDate() + 1)
|
// Use local midnight as "today" boundary
|
||||||
const start = req.query.start ? new Date(req.query.start) : now
|
const offset = tzOffsetMinutes(now)
|
||||||
const end = req.query.end ? new Date(req.query.end) : next
|
const localNow = new Date(now.getTime() + offset * 60_000)
|
||||||
|
localNow.setUTCHours(0, 0, 0, 0)
|
||||||
|
const todayLocal = new Date(localNow.getTime() - offset * 60_000) // back to UTC
|
||||||
|
const nextLocal = new Date(todayLocal.getTime() + 24 * 3600_000)
|
||||||
|
|
||||||
|
const start = req.query.start ? new Date(req.query.start) : todayLocal
|
||||||
|
const end = req.query.end ? new Date(req.query.end) : nextLocal
|
||||||
|
|
||||||
const coords = await getCoords()
|
const coords = await getCoords()
|
||||||
const todayStr = now.toISOString().slice(0, 10) // ✅ string for comparison
|
|
||||||
const usePhysics = lastForecast.summary && start <= now && end > now // ✅ only when today is in window
|
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 [sensorResult] = await Promise.allSettled([start < now ? queryDaily(start, now) : Promise.resolve([])])
|
||||||
const history = sensorResult.status === 'fulfilled' ? sensorResult.value : []
|
const history = sensorResult.status === 'fulfilled' ? sensorResult.value : []
|
||||||
|
|
||||||
// ✅ meteo starts from whichever is later: next or start
|
const meteoFrom = new Date(Math.max(nextLocal.getTime(), start.getTime()))
|
||||||
const meteoFrom = new Date(Math.max(next.getTime(), start.getTime()))
|
const [meteoResult] = await Promise.allSettled([end > nextLocal ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
|
||||||
const [meteoResult] = await Promise.allSettled([end > next ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
|
|
||||||
const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
|
const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
|
||||||
|
|
||||||
const todaySlot = usePhysics ? [lastForecast.summary] : []
|
const todaySlot = usePhysics ? [lastForecast.summary] : []
|
||||||
const meteoClean = meteo.filter(r => String(r.time).slice(0, 10) !== todayStr) // ✅ safe string compare
|
// Filter meteo using local date string so we never double-count today
|
||||||
|
const meteoClean = meteo.filter(r => localDateStr(new Date(r.time)) !== todayStr)
|
||||||
|
|
||||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean])
|
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean])
|
||||||
res.json(filterArr(daily, fields))
|
res.json(filterArr(daily, fields))
|
||||||
@@ -175,6 +191,7 @@ app.use((err, req, res, next) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const c = cfg();
|
const c = cfg();
|
||||||
|
|
||||||
setTimeout(getForecast, 1)
|
setTimeout(getForecast, 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user