Fixed up sensor cards
This commit is contained in:
18
client/src/components/AirQuality.vue
Normal file
18
client/src/components/AirQuality.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<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">💨 Air Quality</div>
|
||||
|
||||
<MetricRow label="AQI" :value="d.air_quality" metric-key="air_quality" :data="d" />
|
||||
<MetricRow label="Label" :value="d.air_quality_label" />
|
||||
<MetricRow label="Resistance (Ohms)" :value="d.air_quality_ohms?.toLocaleString()" metric-key="air_quality_ohms" :data="d" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {inject, onMounted, ref} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
|
||||
const data = ref<any>();
|
||||
|
||||
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';
|
||||
}
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
|
||||
onMounted(async () => {
|
||||
data.value = await api.current();
|
||||
@@ -32,93 +22,57 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.today-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.today-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.today-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-date {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.today-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hi-lo {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
|
||||
.hi { color: var(--text); }
|
||||
.lo { color: var(--text-muted); font-weight: 400; }
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
@@ -150,34 +104,55 @@ onMounted(async () => {
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Precipitation</span>
|
||||
<div class="stat" @click="openGraph?.('heat_index')">
|
||||
<span class="stat-label">Feels Like</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.raining == 'False' ? (data.precipitation_chance + '%') : (data.percipitation > 0 ? data.precipitation.toFixed(1) + 'mm' : 'Spitting')}}</span>
|
||||
<span v-else class="stat-value">{{ ~~(data.heat_index) }}°C</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat" @click="openGraph?.('humidity')">
|
||||
<span class="stat-label">Humidity</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ Math.round(data.humidity) }}%</span>
|
||||
<span v-else class="stat-value">{{ Math.round(data.humidity) }}% <span class="fg-muted">({{ data.humidity_abs.toFixed(2) }})</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Wind</span>
|
||||
<div class="stat" @click="openGraph?.('clouds')">
|
||||
<span class="stat-label">Cloud Cover</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.wind_gusts }} Kph <span class="fg-muted">({{ dir(data.wind_direction) }})</span></span>
|
||||
<span v-else class="stat-value">{{ Math.round(data.clouds) }}%</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Clouds</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ Math.round(data.clouds) }}%</span></div>
|
||||
<div class="stat">
|
||||
<div class="stat" @click="openGraph?.('air_quality')">
|
||||
<span class="stat-label">Air Quality</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.air_quality }} <span class="fg-muted">({{data.air_quality_label}})</span></span>
|
||||
<span v-else class="stat-value">{{ data.air_quality }} <span class="fg-muted">({{ data.air_quality_label }})</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">UV index</span>
|
||||
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
||||
<span class="stat-label">Gas Resistance</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index.toFixed(1) }} <span class="fg-muted">({{data.uv_index_label}})</span></span>
|
||||
<span v-else class="stat-value">{{ data.air_quality_ohms }} <span class="fg-muted">Ω</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('visibility')">
|
||||
<span class="stat-label">Visibility</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.visibility?.toFixed(1) }} km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.pressure_hpa?.toFixed(1) }} <span class="fg-muted">({{ (data.pressure_rate > 0 ? '+' : '') + data.pressure_rate }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_slp')">
|
||||
<span class="stat-label">Sea Level</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ data.pressure_trend }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
|
||||
<span class="stat-label">VPD KPA</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ data.vapor_pressure_deficit?.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<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>
|
||||
@@ -11,12 +11,16 @@ 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);
|
||||
const past = new Date(), future = new Date();
|
||||
if(mode.value === 'hourly') {
|
||||
past.setHours(past.getHours() - 24);
|
||||
future.setHours(future.getHours() + 24);
|
||||
rows.value = await api.hourly(past, future);
|
||||
} else {
|
||||
past.setDate(past.getDate() - 3);
|
||||
future.setDate(future.getDate() + 3);
|
||||
rows.value = await api.daily(past, future);
|
||||
}
|
||||
}, {immediate: true});
|
||||
|
||||
const now = new Date();
|
||||
@@ -29,34 +33,52 @@ const points = computed(() => {
|
||||
})).filter(p => p.v != null);
|
||||
});
|
||||
|
||||
const {minT, maxT, minV, maxV, path, currentX} = computed(() => {
|
||||
const chart = computed(() => {
|
||||
const pts = points.value;
|
||||
if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:'', currentX: 0};
|
||||
if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:{hist:'',fut:''}, currentX:0, dot:null as any, yLabels:[] as any[]};
|
||||
|
||||
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 rawMin = Math.min(...vs), rawMax = Math.max(...vs);
|
||||
const pad = (rawMax - rawMin) * 0.1 || 1;
|
||||
const minV = rawMin - pad, maxV = rawMax + pad;
|
||||
|
||||
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);
|
||||
const currentX = Math.max(PAD, Math.min(W - PAD, 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};
|
||||
// stitch current point into both paths so lines meet
|
||||
const nearest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||
const dot = {x: xOf(nearest.t), y: yOf(nearest.v as number), v: nearest.v};
|
||||
|
||||
const toPath = (arr: typeof pts) =>
|
||||
arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
|
||||
|
||||
// y-axis labels (5 ticks)
|
||||
const yLabels = Array.from({length: 5}, (_, i) => {
|
||||
const frac = i / 4;
|
||||
const v = minV + frac * (maxV - minV);
|
||||
return {y: yOf(v), label: v.toFixed(1)};
|
||||
});
|
||||
|
||||
// x-axis labels
|
||||
const xLabels: {x: number, label: string}[] = [];
|
||||
const span = maxT - minT;
|
||||
const tickCount = mode.value === 'hourly' ? 9 : 7;
|
||||
for(let i = 0; i <= tickCount; i++) {
|
||||
const t = minT + (i / tickCount) * span;
|
||||
const d = new Date(t);
|
||||
const label = mode.value === 'hourly'
|
||||
? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
||||
: d.toLocaleDateString([], {month: 'short', day: 'numeric'});
|
||||
xLabels.push({x: xOf(t), label});
|
||||
}
|
||||
|
||||
return {minV, maxV, path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -85,8 +107,7 @@ const currentDot = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.title { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||
.title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; }
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
@@ -103,7 +124,6 @@ const currentDot = computed(() => {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
button {
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
@@ -115,7 +135,17 @@ const currentDot = computed(() => {
|
||||
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
}
|
||||
}
|
||||
.current-val {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
span { color: var(--accent); font-weight: 700; }
|
||||
}
|
||||
svg { width: 100%; overflow: visible; }
|
||||
.tick-label {
|
||||
font-size: 9px;
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
@@ -124,27 +154,44 @@ svg { width: 100%; overflow: visible; }
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="title">{{ metricKey }}</span>
|
||||
<span class="title">{{ metricKey?.replace(/_/g, ' ') }}</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>
|
||||
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly ±24h</button>
|
||||
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily ±3d</button>
|
||||
</div>
|
||||
|
||||
<div v-if="chart.dot" class="current-val">Now: <span>{{ (chart.dot.v as number).toFixed(2) }}</span></div>
|
||||
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||
<!-- grid -->
|
||||
<!-- y-axis grid lines & labels -->
|
||||
<template v-for="tick in chart.yLabels" :key="tick.y">
|
||||
<line :x1="PAD" :y1="tick.y" :x2="W-PAD" :y2="tick.y" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>
|
||||
<text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text>
|
||||
</template>
|
||||
|
||||
<!-- x-axis labels -->
|
||||
<template v-for="tick in chart.xLabels" :key="tick.x">
|
||||
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
|
||||
</template>
|
||||
|
||||
<!-- axes -->
|
||||
<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"/>
|
||||
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
|
||||
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
|
||||
|
||||
<!-- historic solid -->
|
||||
<path :d="path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||
<path :d="chart.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"/>
|
||||
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
|
||||
|
||||
<!-- current dot -->
|
||||
<circle v-if="currentDot" :cx="currentDot.x" :cy="currentDot.y" r="4" fill="var(--accent)"/>
|
||||
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="4" fill="var(--accent)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ onMounted(async () => {
|
||||
<span class="chevron" :class="{ open }">▼</span>
|
||||
</div>
|
||||
|
||||
<div v-if="open" style="height: 50vh; overflow-y: auto">
|
||||
<div v-if="open" style="max-height: 50vh; overflow-y: auto">
|
||||
<div class="hour-list">
|
||||
<template v-if="hours?.length">
|
||||
<div v-for="h in hours" class="hour-row">
|
||||
|
||||
98
client/src/components/Moon.vue
Normal file
98
client/src/components/Moon.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, computed, watchEffect} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const d = ref<any>(null);
|
||||
onMounted(async () => d.value = await api.current());
|
||||
|
||||
const moonIcon = computed(() => {
|
||||
if(!d.value) return '🌑';
|
||||
const phase = d.value.moon_phase as string;
|
||||
const map: Record<string, string> = {
|
||||
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓',
|
||||
'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖',
|
||||
'Last Quarter': '🌗', 'Waning Crescent': '🌘',
|
||||
};
|
||||
return map[phase] ?? '🌕';
|
||||
});
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
watchEffect(() => {
|
||||
if(!d.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const size = 64;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size / 2 - 3;
|
||||
|
||||
// Background circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#1c1c1c';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
const illumination = (d.value.moon_illumination ?? 0) / 100;
|
||||
const phase = d.value.moon_phase as string;
|
||||
const phaseMap: Record<string, number> = {
|
||||
'New Moon': 0, 'Waxing Crescent': 0.125, 'First Quarter': 0.25,
|
||||
'Waxing Gibbous': 0.375, 'Full Moon': 0.5, 'Waning Gibbous': 0.625,
|
||||
'Last Quarter': 0.75, 'Waning Crescent': 0.875,
|
||||
};
|
||||
const phaseVal = phaseMap[phase] ?? 0;
|
||||
|
||||
if(illumination > 0.01) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = '#e8e8c0';
|
||||
ctx.beginPath();
|
||||
|
||||
if(illumination >= 0.99) {
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
} else if(phaseVal < 0.5) {
|
||||
// Waxing: right side lit, growing
|
||||
ctx.arc(cx, cy, r, -Math.PI / 2, Math.PI / 2, false);
|
||||
const ellipseWidth = (phaseVal * 4 - 1) * r;
|
||||
ctx.ellipse(cx, cy, Math.abs(ellipseWidth), r, 0, Math.PI / 2, -Math.PI / 2, ellipseWidth < 0);
|
||||
} else {
|
||||
// Waning: left side lit, shrinking
|
||||
ctx.arc(cx, cy, r, Math.PI / 2, -Math.PI / 2, false);
|
||||
const ellipseWidth = (3 - phaseVal * 4) * r;
|
||||
ctx.ellipse(cx, cy, Math.abs(ellipseWidth), r, 0, -Math.PI / 2, Math.PI / 2, ellipseWidth < 0);
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card" v-if="d">
|
||||
<div class="card-title">🌙 Moon</div>
|
||||
|
||||
<div class="flex-r align-items-center gap-3 p-2">
|
||||
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
||||
<div class="flex-c">
|
||||
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<MetricRow label="Next Full Moon" :value="new Date(d.moon_full).toLocaleDateString()" />
|
||||
<MetricRow label="Next New Moon" :value="new Date(d.moon_new).toLocaleDateString()" />
|
||||
</div>
|
||||
</template>
|
||||
118
client/src/components/Precipitation.vue
Normal file
118
client/src/components/Precipitation.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
import { api } from '../services/api';
|
||||
|
||||
const d = ref<any>(null);
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
|
||||
onMounted(async () => d.value = await api.current());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.precip-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="precip-card" v-if="d">
|
||||
<div class="card-title">🌧️ Precipitation</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Raining</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.raining == 'True' ? d.storm_direction : 'No' }}</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_rate')">
|
||||
<span class="stat-label">Lightning</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_rate }}<span class="fg-muted">/hr</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_distance')">
|
||||
<span class="stat-label">Distance</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_distance }} <span class="fg-muted">km</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||
<span class="stat-label">Chance</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.precipitation_chance }}<span class="fg-muted">%</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.pressure_hpa.toFixed(1) }} <span class="fg-muted">({{d.pressure_trend}})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('precipitation')">
|
||||
<span class="stat-label">Precipitation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.precipitation.toFixed(1) }} <span class="fg-muted">mm</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('accumulation')">
|
||||
<span class="stat-label">Accumulation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.accumulation.toFixed(1) }} <span class="fg-muted">mm {{ d.accumulation_type != 'none' ? `(${d.accumulation_type})` : '' }}</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Dew Point</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.dew_point.toFixed(1) }}<span class="fg-muted">°C</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Frost Risk</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.frost_risk }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
50
client/src/components/Seismic.vue
Normal file
50
client/src/components/Seismic.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, onUnmounted} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const d = ref<any>(null);
|
||||
const history = ref<number[]>([]);
|
||||
const W = 320, H = 60, MAX_PTS = 120;
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
function toPath(pts: number[]) {
|
||||
if(!pts.length) return '';
|
||||
const mid = H / 2, amp = H / 2 - 4;
|
||||
const max = Math.max(...pts) || 1;
|
||||
return pts.map((v, i) => {
|
||||
const x = (i / (MAX_PTS - 1)) * W;
|
||||
const normalized = v / max;
|
||||
const flipped = i % 2 === 0 ? normalized : -normalized;
|
||||
const y = mid - flipped * amp;
|
||||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
d.value = await api.current('magnitude');
|
||||
history.value.push(d.value.magnitude ?? 0);
|
||||
if(history.value.length > MAX_PTS) history.value.shift();
|
||||
}
|
||||
|
||||
onMounted(async () => { await poll(); interval = setInterval(poll, 5000); });
|
||||
onUnmounted(() => clearInterval(interval));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
svg { width: 100%; height: 60px; overflow: visible; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="card" v-if="d">
|
||||
<div class="card-title">🫨 Seismic</div>
|
||||
|
||||
<MetricRow label="Magnitude" :value="d.magnitude?.toFixed(4)" metric-key="magnitude" :data="d" />
|
||||
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||
<line :x1="0" :y1="H/2" :x2="W" :y2="H/2" stroke="var(--border)" stroke-width="1" />
|
||||
<path :d="toPath(history)" fill="none" stroke="var(--accent)" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
198
client/src/components/Sun.vue
Normal file
198
client/src/components/Sun.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import { inject, onMounted, ref, computed } from 'vue';
|
||||
import { api } from '../services/api';
|
||||
|
||||
const data = ref<any>(null);
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
|
||||
onMounted(async () => data.value = await api.current());
|
||||
|
||||
const W = 320, H = 120, HORIZON = 45;
|
||||
|
||||
const arc = computed(() => {
|
||||
if (!data.value) return { filledPath: '', remainPath: '', cx: 0, cy: 0, pct: 0 };
|
||||
|
||||
const rise = new Date(data.value.sunrise).getTime();
|
||||
const set = new Date(data.value.sunset).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const dayLen = set - rise;
|
||||
const noon = rise + dayLen / 2;
|
||||
const start = noon - 12 * 3600 * 1000;
|
||||
const end = noon + 12 * 3600 * 1000;
|
||||
|
||||
const pct = Math.max(0, Math.min(1, (now - start) / (end - start)));
|
||||
|
||||
const x1 = 0, x2 = W;
|
||||
const totalW = x2 - x1;
|
||||
|
||||
// Sine curve: below horizon at edges, peaks above at noon
|
||||
const getPoint = (t: number) => ({
|
||||
x: x1 + totalW * t,
|
||||
y: HORIZON - Math.sin(t * Math.PI * 2 - Math.PI / 2) * (HORIZON) + (HORIZON)
|
||||
// Simpler: just a single-hump arc above horizon + dip below
|
||||
});
|
||||
|
||||
// Generate points along full sine path
|
||||
const pts = Array.from({ length: 201 }, (_, i) => {
|
||||
const t = i / 200;
|
||||
const x = x1 + totalW * t;
|
||||
// Single smooth S: -sin gives us below->above->below
|
||||
const amplitude = (HORIZON);
|
||||
const y = HORIZON - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const splitIdx = Math.round(pct * 200);
|
||||
const { x: cx, y: cy } = pts[splitIdx];
|
||||
|
||||
// Filled traveled path: close down to horizon baseline for fill
|
||||
const traveledPts = pts.slice(0, splitIdx + 1);
|
||||
const filledPath = traveledPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
||||
+ ` L${traveledPts[traveledPts.length - 1].x.toFixed(1)},${HORIZON}`
|
||||
+ ` L${x1},${HORIZON} Z`;
|
||||
|
||||
// Remaining path stroke only
|
||||
const remainPts = pts.slice(splitIdx);
|
||||
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||||
|
||||
// Sunrise / sunset x positions
|
||||
const riseX = x1 + totalW * ((rise - start) / (end - start));
|
||||
const setX = x1 + totalW * ((set - start) / (end - start));
|
||||
const noonX = x1 + totalW * 0.5;
|
||||
|
||||
return { filledPath, remainPath, cx, cy, pct, riseX, setX, noonX };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sun-card">
|
||||
<div class="card-title">🌅 Sun</div>
|
||||
|
||||
<!-- Top labels -->
|
||||
<div class="sun-top-labels" v-if="data">
|
||||
<div>
|
||||
<div class="stat-label">Sunrise</div>
|
||||
<div class="stat-value">{{ new Date(data.sunrise).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="stat-label">Sunset</div>
|
||||
<div class="stat-value">{{ new Date(data.sunset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||
<!-- Filled traveled area -->
|
||||
<path v-if="arc.filledPath" :d="arc.filledPath" fill="#3b82f6" opacity="0.35" />
|
||||
<!-- Remaining curve stroke -->
|
||||
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
||||
<!-- Horizon line -->
|
||||
<line :x1="0" :y1="HORIZON" :x2="W" :y2="HORIZON" stroke="var(--border)" stroke-width="1" />
|
||||
<!-- Sun dot -->
|
||||
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
||||
</svg>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Daylight</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) }} <span class="fg-muted">hrs</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lux')">
|
||||
<span class="stat-label">Lux</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.lux }}</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('solar_wm2')">
|
||||
<span class="stat-label">Solar W/m²</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.solar_wm2 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('uv_index')">
|
||||
<span class="stat-label">UV Index</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index.toFixed(1) }} <span class="fg-muted">({{ data.uv_index_label }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_dose')">
|
||||
<span class="stat-label">UV Dose</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) }} <span class="fg-muted">J/m²</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_index_max')">
|
||||
<span class="stat-label">UV Max</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index_max ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sun-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sun-top-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: visible;
|
||||
}
|
||||
.sun-dot {
|
||||
filter: drop-shadow(0 0 5px #f59e0b88);
|
||||
}
|
||||
</style>
|
||||
94
client/src/components/Wind.vue
Normal file
94
client/src/components/Wind.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
import { api } from '../services/api';
|
||||
|
||||
const d = ref<any>(null);
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'North-East';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'East';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'South-East';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'South';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'South-West';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'West';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'North-West';
|
||||
return 'North';
|
||||
}
|
||||
|
||||
onMounted(async () => d.value = await api.current());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wind-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="wind-card" v-if="d">
|
||||
<div class="card-title">🍃 Wind</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('wind_speed')">
|
||||
<span class="stat-label">Speed</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ ~~(d.wind_speed) }} <span class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('wind_gusts')">
|
||||
<span class="stat-label">Gusts</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ ~~(d.wind_gusts) }} <span class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Direction</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ dir(d.wind_direction) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,11 @@
|
||||
`<script setup lang="ts">
|
||||
import EnvironmentCard from '@/components/EnvironmentCard.vue';
|
||||
import GraphModal from '@/components/GraphModal.vue';
|
||||
import HourlyForecast from '@/components/HourlyForecast.vue';
|
||||
import Moon from '@/components/Moon.vue';
|
||||
import Precipitation from '@/components/Precipitation.vue';
|
||||
import Seismic from '@/components/Seismic.vue';
|
||||
import Sun from '@/components/Sun.vue';
|
||||
import Wind from '@/components/Wind.vue';
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
||||
@@ -164,9 +168,14 @@ onUnmounted(() => {
|
||||
<HourlyForecast class="mb-3" />
|
||||
<ForecastStrip class="mb-3" />
|
||||
|
||||
<EnvironmentCard class="mb-3" />
|
||||
<Precipitation class="mb-3" />
|
||||
<Wind class="mb-3" />
|
||||
<Seismic class="mb-3" />
|
||||
<Sun class="mb-3" />
|
||||
<Moon class="mb-2" />
|
||||
|
||||
<div class="align-x">
|
||||
<a :href="BASE + '/docs'" style="font-size: 0.75em;" target="_blank">OpenAPI Docs</a>
|
||||
<a :href="BASE + '/docs'" style="font-size: 0.75em;" target="_blank">OpenAPI Docs</a> | <a href="https://zakscode.com" style="font-size: 0.75em;" target="_blank">ZaksCode</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -528,7 +528,6 @@ def read_as3935(c):
|
||||
elif avg_late > avg_early + 2: trend = 'Retreating'
|
||||
write(c, 'lightning', {
|
||||
'lightning_distance': dist,
|
||||
'lightning_energy': energy,
|
||||
'lightning_rate': strike_rate,
|
||||
}, tags={'storm_direction': trend})
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user