450 lines
16 KiB
Vue
450 lines
16 KiB
Vue
<template>
|
|
<div class="app-container">
|
|
<!-- Optional Retro CRT Scanlines Overlay -->
|
|
<div class="crt-overlay" v-if="crtEnabled"></div>
|
|
|
|
<main class="container">
|
|
<!-- SNES / Mad TV Style City & Station Control Hero -->
|
|
<HouseHero
|
|
:lastUpdated="currentMetrics?.updated_at"
|
|
@refresh="fetchData"
|
|
@theme-changed="handleThemeChanged"
|
|
@crt-changed="handleCrtChanged"
|
|
/>
|
|
|
|
<!-- Error / System Warning Alert (if any) -->
|
|
<div v-if="error" class="retro-window error-banner-window">
|
|
<div class="retro-window-header error-header">
|
|
<div class="retro-window-title">
|
|
<span>⚠️</span>
|
|
<span>SYSTEM ALERT // COMMUNICATION INTERRUPTED</span>
|
|
</div>
|
|
</div>
|
|
<div class="retro-window-content error-content">
|
|
<span class="error-msg">{{ error }}</span>
|
|
<button @click="fetchData" class="retro-btn btn-accent">RETRY LINK</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ============================================== -->
|
|
<!-- GROUP 1: TEMPERATURE & CLIMATE (Includes Dewpoint) -->
|
|
<!-- ============================================== -->
|
|
<section class="group-section">
|
|
<div class="group-header-bar">
|
|
<div class="group-title">
|
|
<span>🌡️</span>
|
|
<span>TEMPERATURE & THERMAL CLIMATE</span>
|
|
</div>
|
|
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
|
|
</div>
|
|
|
|
<div class="metrics-list-stack">
|
|
<!-- 1. Current Temperature -->
|
|
<WeatherMetricCard
|
|
title="Temperature"
|
|
icon="🌡️"
|
|
:value="currentMetrics?.temperature"
|
|
unit="°C"
|
|
:subText="currentMetrics?.apparent_temperature ? `Feels ${currentMetrics.apparent_temperature.toFixed(1)}°C` : undefined"
|
|
:highlight="true"
|
|
@inspect="openModal('temperature', 'Current Temperature', '🌡️', currentMetrics?.temperature, '°C', 'Ambient Dry-Bulb Reading')"
|
|
/>
|
|
|
|
<!-- 2. Dew Point (Calculated Metric) -->
|
|
<WeatherMetricCard
|
|
title="Dew Point"
|
|
icon="🌫️"
|
|
:value="effectiveDewPoint"
|
|
unit="°C"
|
|
:subBadge="dewPointComfort.text"
|
|
subText="Atmospheric Saturation"
|
|
@inspect="openModal('dew_point', 'Dew Point (Calculated)', '🌫️', effectiveDewPoint, '°C', `Atmospheric Saturation: ${dewPointComfort.text}`)"
|
|
/>
|
|
|
|
<!-- 3. Feels Like / Apparent Temperature -->
|
|
<WeatherMetricCard
|
|
title="Feels Like"
|
|
icon="🌤️"
|
|
:value="currentMetrics?.apparent_temperature"
|
|
unit="°C"
|
|
subText="Thermal Comfort Index"
|
|
@inspect="openModal('apparent_temperature', 'Apparent Temperature (Feels Like)', '🌤️', currentMetrics?.apparent_temperature, '°C', 'Calculated Thermal Comfort Index')"
|
|
/>
|
|
|
|
<!-- 4. Relative Humidity -->
|
|
<WeatherMetricCard
|
|
title="Rel. Humidity"
|
|
icon="💧"
|
|
:value="currentMetrics?.humidity"
|
|
unit="%"
|
|
:subText="currentMetrics?.humidity ? `${currentMetrics.humidity > 65 ? 'High Moisture' : 'Normal Range'}` : undefined"
|
|
@inspect="openModal('humidity', 'Relative Humidity', '💧', currentMetrics?.humidity, '%', 'Air Moisture Saturation Level')"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================== -->
|
|
<!-- GROUP 2: ATMOSPHERE & PRECIPITATION -->
|
|
<!-- ============================================== -->
|
|
<section class="group-section">
|
|
<div class="group-header-bar">
|
|
<div class="group-title">
|
|
<span>🌧️</span>
|
|
<span>ATMOSPHERE & PRECIPITATION</span>
|
|
</div>
|
|
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
|
|
</div>
|
|
|
|
<div class="metrics-list-stack">
|
|
<!-- Pressure -->
|
|
<WeatherMetricCard
|
|
title="Pressure"
|
|
icon="🧭"
|
|
:value="currentMetrics?.pressure"
|
|
unit="hPa"
|
|
:subText="currentMetrics?.pressure ? (currentMetrics.pressure >= 1013 ? 'High Pressure (Fair)' : 'Low Pressure System') : undefined"
|
|
@inspect="openModal('pressure', 'Atmospheric Pressure', '🧭', currentMetrics?.pressure, 'hPa', 'Barometric Surface Pressure')"
|
|
/>
|
|
|
|
<!-- Rain Status -->
|
|
<WeatherMetricCard
|
|
title="Rainfall Condition"
|
|
icon="🌧️"
|
|
:value="currentMetrics?.rain_status_label || (currentMetrics?.rain_status === 1 ? 'Wet (Rain)' : 'Dry')"
|
|
unit=""
|
|
:subBadge="currentMetrics?.rain_status === 1 ? 'PRECIPITATION' : 'NO RAIN'"
|
|
:subBadgeColor="currentMetrics?.rain_status === 1 ? '#38bdf8' : '#10b981'"
|
|
subText="Optical Rain Sensor State"
|
|
@inspect="openModal('pressure', 'Precipitation & Barometric Log', '🌧️', currentMetrics?.rain_status_label, '', 'Live Precipitation & Barometric Log')"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================== -->
|
|
<!-- GROUP 3: WIND & GUSTS -->
|
|
<!-- ============================================== -->
|
|
<section class="group-section">
|
|
<div class="group-header-bar">
|
|
<div class="group-title">
|
|
<span>💨</span>
|
|
<span>WIND VELOCITY & GUST DYNAMICS</span>
|
|
</div>
|
|
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
|
|
</div>
|
|
|
|
<div class="metrics-list-stack">
|
|
<!-- Wind Speed -->
|
|
<WeatherMetricCard
|
|
title="Wind Speed"
|
|
icon="💨"
|
|
:value="currentMetrics?.wind_speed"
|
|
unit="km/h"
|
|
:subText="`Direction: ${windCardinal}`"
|
|
@inspect="openModal('wind_speed', 'Wind Speed & Flow', '💨', currentMetrics?.wind_speed, 'km/h', `Sustained Velocity • Heading ${windCardinal}`)"
|
|
/>
|
|
|
|
<!-- Gust Speed -->
|
|
<WeatherMetricCard
|
|
title="Peak Gusts"
|
|
icon="🌪️"
|
|
:value="currentMetrics?.gust_speed"
|
|
unit="km/h"
|
|
:subText="currentMetrics?.gust_speed ? `Peak: ${currentMetrics.gust_speed.toFixed(1)} km/h` : undefined"
|
|
@inspect="openModal('gust_speed', 'Gust Velocity Dynamics', '🌪️', currentMetrics?.gust_speed, 'km/h', 'Maximum Gust Speed in Sample Window')"
|
|
/>
|
|
|
|
<!-- Wind Direction -->
|
|
<WeatherMetricCard
|
|
title="Wind Bearing"
|
|
icon="🧭"
|
|
:value="windCardinal"
|
|
unit=""
|
|
:subText="currentMetrics?.wind_direction !== undefined && currentMetrics?.wind_direction !== null ? `Angle: ${Math.round(currentMetrics.wind_direction)}°` : undefined"
|
|
:subBadge="`${Math.round(currentMetrics?.wind_direction ?? 0)}°`"
|
|
@inspect="openModal('wind_speed', 'Wind Direction & Dynamics', '🧭', `${windCardinal} (${Math.round(currentMetrics?.wind_direction ?? 0)}°)`, '', 'Compass Rose Bearing & Flow')"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================== -->
|
|
<!-- GROUP 4: SOLAR & RADIATION -->
|
|
<!-- ============================================== -->
|
|
<section class="group-section">
|
|
<div class="group-header-bar">
|
|
<div class="group-title">
|
|
<span>☀️</span>
|
|
<span>SOLAR ILLUMINANCE & UV RADIATION</span>
|
|
</div>
|
|
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
|
|
</div>
|
|
|
|
<div class="metrics-list-stack">
|
|
<!-- Solar Illuminance -->
|
|
<WeatherMetricCard
|
|
title="Solar Illuminance"
|
|
icon="☀️"
|
|
:value="currentMetrics?.illuminance_lux"
|
|
unit="LUX"
|
|
:subText="currentMetrics?.illuminance_lux && currentMetrics.illuminance_lux > 10000 ? 'Direct Sunlight' : 'Ambient Light'"
|
|
@inspect="openModal('illuminance_lux', 'Solar Illuminance', '☀️', currentMetrics?.illuminance_lux, 'LUX', 'Photometric Illuminance from Solar Sensor')"
|
|
/>
|
|
|
|
<!-- UV Index -->
|
|
<WeatherMetricCard
|
|
title="UV Index"
|
|
icon="🔆"
|
|
:value="currentMetrics?.uv_index ?? 0"
|
|
unit="UVI"
|
|
:subBadge="uvLevel.label"
|
|
:subText="`Exposure: ${uvLevel.label}`"
|
|
@inspect="openModal('uv_index', 'Solar UV Index', '🔆', currentMetrics?.uv_index ?? 0, 'UVI', `Ultraviolet Radiation Index (${uvLevel.label})`)"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================== -->
|
|
<!-- GROUP 5: STATION TELEMETRY & SYSTEM STATUS -->
|
|
<!-- ============================================== -->
|
|
<section class="group-section">
|
|
<div class="group-header-bar">
|
|
<div class="group-title">
|
|
<span>📡</span>
|
|
<span>STATION TELEMETRY & LINK QUALITY</span>
|
|
</div>
|
|
<span class="group-hint">[STATION STATUS]</span>
|
|
</div>
|
|
|
|
<div class="metrics-list-stack">
|
|
<!-- Battery -->
|
|
<WeatherMetricCard
|
|
title="Station Battery"
|
|
icon="🔋"
|
|
:value="currentMetrics?.battery_percent !== null && currentMetrics?.battery_percent !== undefined ? currentMetrics.battery_percent : '100'"
|
|
unit="%"
|
|
:subBadge="currentMetrics?.battery_percent && currentMetrics.battery_percent > 30 ? 'HEALTHY' : 'GOOD'"
|
|
subText="Solar Power Buffer"
|
|
@inspect="openModal('battery_percent', 'Station Battery Level', '🔋', currentMetrics?.battery_percent, '%', 'Onboard Power Cell Capacity')"
|
|
/>
|
|
|
|
<!-- Signal Strength / LQI -->
|
|
<WeatherMetricCard
|
|
title="Signal Quality (LQI)"
|
|
icon="📶"
|
|
:value="currentMetrics?.linkquality_lqi !== null && currentMetrics?.linkquality_lqi !== undefined ? currentMetrics.linkquality_lqi : '255'"
|
|
unit="LQI"
|
|
subText="Shelly / Zigbee Link"
|
|
@inspect="openModal('linkquality_lqi', 'Link Quality Indicator', '📶', currentMetrics?.linkquality_lqi, 'LQI', 'RF Signal Quality Indicator')"
|
|
/>
|
|
|
|
<!-- Last Updated -->
|
|
<WeatherMetricCard
|
|
title="Telemetry Status"
|
|
icon="⏱️"
|
|
value="ACTIVE"
|
|
unit=""
|
|
:subText="currentMetrics?.updated_at ? `Sync: ${formatTime(currentMetrics.updated_at)}` : 'Live Stream'"
|
|
@inspect="openModal('temperature', 'Live Telemetry Link', '⏱️', 'ONLINE', '', 'Prometheus Real-Time Metrics Pipeline')"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- 16-Bit No-Scroll Detail Screen Overlay -->
|
|
<MetricDetailModal
|
|
v-if="selectedModalMetric"
|
|
:metricKey="selectedModalMetric.key"
|
|
:title="selectedModalMetric.title"
|
|
:icon="selectedModalMetric.icon"
|
|
:value="selectedModalMetric.value"
|
|
:unit="selectedModalMetric.unit"
|
|
:subText="selectedModalMetric.subText"
|
|
:history="history"
|
|
:theme="currentTheme"
|
|
@close="closeModal"
|
|
@change-timeframe="fetchHistoryForHours"
|
|
/>
|
|
|
|
<!-- Retro Footer -->
|
|
<footer class="app-footer container">
|
|
<p>GRABENGASSE 7 • REGENSBURG // 16-BIT METEOROLOGICAL STATION</p>
|
|
<p class="footer-sub">SHELLY WS90 INTEGRATION • PROMETHEUS TIME-SERIES ENGINE • V1.0</p>
|
|
</footer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import HouseHero from './components/HouseHero.vue'
|
|
import WeatherMetricCard from './components/WeatherMetricCard.vue'
|
|
import MetricDetailModal from './components/MetricDetailModal.vue'
|
|
import type { CurrentWeatherMetrics, WeatherHistoryResponse } from './types/weather'
|
|
import {
|
|
calculateDewPoint,
|
|
getDewPointComfort,
|
|
getWindDirectionCardinal,
|
|
getUVIndexLevel
|
|
} from './utils/weatherCalculations'
|
|
|
|
const currentMetrics = ref<CurrentWeatherMetrics | null>(null)
|
|
const history = ref<WeatherHistoryResponse | undefined>(undefined)
|
|
const error = ref<string | null>(null)
|
|
const currentTheme = ref<'dark' | 'light'>('dark')
|
|
const crtEnabled = ref(true)
|
|
|
|
interface ModalConfig {
|
|
key: string
|
|
title: string
|
|
icon: string
|
|
value?: number | string | null
|
|
unit?: string
|
|
subText?: string
|
|
}
|
|
|
|
const selectedModalMetric = ref<ModalConfig | null>(null)
|
|
|
|
let pollTimer: number | null = null
|
|
|
|
// Calculated Dew Point
|
|
const effectiveDewPoint = computed(() => {
|
|
if (currentMetrics.value?.dew_point !== undefined && currentMetrics.value?.dew_point !== null) {
|
|
return currentMetrics.value.dew_point
|
|
}
|
|
return calculateDewPoint(
|
|
currentMetrics.value?.temperature,
|
|
currentMetrics.value?.humidity
|
|
)
|
|
})
|
|
|
|
// Dew Point Comfort Description
|
|
const dewPointComfort = computed(() => {
|
|
return getDewPointComfort(effectiveDewPoint.value)
|
|
})
|
|
|
|
// Wind Direction Cardinal
|
|
const windCardinal = computed(() => {
|
|
return getWindDirectionCardinal(currentMetrics.value?.wind_direction)
|
|
})
|
|
|
|
// UV Level Info
|
|
const uvLevel = computed(() => {
|
|
return getUVIndexLevel(currentMetrics.value?.uv_index)
|
|
})
|
|
|
|
function formatTime(isoString?: string): string {
|
|
if (!isoString) return ''
|
|
try {
|
|
const date = new Date(isoString)
|
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
} catch {
|
|
return isoString
|
|
}
|
|
}
|
|
|
|
function openModal(
|
|
key: string,
|
|
title: string,
|
|
icon: string,
|
|
value?: number | string | null,
|
|
unit?: string,
|
|
subText?: string
|
|
) {
|
|
selectedModalMetric.value = { key, title, icon, value, unit, subText }
|
|
}
|
|
|
|
function closeModal() {
|
|
selectedModalMetric.value = null
|
|
}
|
|
|
|
function handleThemeChanged(theme: 'dark' | 'light') {
|
|
currentTheme.value = theme
|
|
}
|
|
|
|
function handleCrtChanged(enabled: boolean) {
|
|
crtEnabled.value = enabled
|
|
}
|
|
|
|
async function fetchCurrentMetrics() {
|
|
try {
|
|
const res = await fetch('/api/v1/weather/current')
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const data = await res.json()
|
|
currentMetrics.value = data
|
|
error.value = null
|
|
} catch (err: any) {
|
|
console.error('Error fetching current metrics:', err)
|
|
error.value = 'Station Telemetry Offline. Retrying link to Prometheus...'
|
|
}
|
|
}
|
|
|
|
async function fetchHistoryForHours(hours: number = 24) {
|
|
try {
|
|
const res = await fetch(`/api/v1/weather/history?hours=${hours}`)
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const data = await res.json()
|
|
history.value = data
|
|
} catch (err: any) {
|
|
console.error('Error fetching history:', err)
|
|
}
|
|
}
|
|
|
|
async function fetchData() {
|
|
await Promise.all([fetchCurrentMetrics(), fetchHistoryForHours(24)])
|
|
}
|
|
|
|
onMounted(() => {
|
|
const savedTheme = (localStorage.getItem('grabengasse_theme') as 'dark' | 'light') || 'dark'
|
|
currentTheme.value = savedTheme
|
|
|
|
const savedCrt = localStorage.getItem('grabengasse_crt')
|
|
if (savedCrt !== null) {
|
|
crtEnabled.value = savedCrt === 'true'
|
|
}
|
|
|
|
fetchData()
|
|
pollTimer = window.setInterval(fetchCurrentMetrics, 30000)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (pollTimer) clearInterval(pollTimer)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.app-container {
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-width: 100vw;
|
|
overflow-x: hidden;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.error-banner-window {
|
|
margin-bottom: 1.25rem;
|
|
border-color: #881337 #fb7185 #fb7185 #881337;
|
|
}
|
|
|
|
.error-header {
|
|
background: linear-gradient(90deg, #991b1b 0%, #dc2626 50%, #7f1d1d 100%);
|
|
}
|
|
|
|
.error-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8rem;
|
|
color: var(--retro-accent-red);
|
|
}
|
|
|
|
.error-msg {
|
|
font-weight: 700;
|
|
}
|
|
|
|
.footer-sub {
|
|
font-size: 0.65rem;
|
|
color: var(--retro-text-muted);
|
|
}
|
|
</style>
|