Use local time
This commit is contained in:
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;
|
||||
padding-bottom: 6px;
|
||||
width: 100%;
|
||||
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.day {
|
||||
|
||||
@@ -1,87 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS } from '../services/units'
|
||||
import MetricChart from './MetricChart.vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
import {computed, ref, watch} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
|
||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const props = defineProps<{metricKey: string | null}>();
|
||||
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>
|
||||
|
||||
<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);
|
||||
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;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.title { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
}
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.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); }
|
||||
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>
|
||||
|
||||
<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>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="title">{{ metricKey }}</span>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -82,8 +82,8 @@ onMounted(async () => {
|
||||
<span class="chevron" :class="{ open }">▼</span>
|
||||
</div>
|
||||
|
||||
<div style="max-height: 50vh; overflow-y: auto">
|
||||
<div v-if="open" class="hour-list">
|
||||
<div v-if="open" style="height: 50vh; overflow-y: auto">
|
||||
<div 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>
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user