feat(openclaw): add shopping, supermarket deals, geizhals, and ebay MCP tools
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
<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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 270 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 930 KiB |
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<header class="retro-window hero-header">
|
||||
<!-- Top System Window Titlebar -->
|
||||
<div class="retro-window-header">
|
||||
<div class="retro-window-title">
|
||||
<span class="system-icon">🕹️</span>
|
||||
<span>CITY TELEMETRY // GRABENGASSE 7</span>
|
||||
</div>
|
||||
<div class="retro-window-controls">
|
||||
<span class="retro-btn-box">_</span>
|
||||
<span class="retro-btn-box">□</span>
|
||||
<span class="retro-btn-box">×</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero Content Board -->
|
||||
<div class="retro-window-content hero-content">
|
||||
<div class="hero-top-row">
|
||||
<!-- Brand & Crest -->
|
||||
<div class="hero-brand">
|
||||
<div class="city-crest">
|
||||
<span class="crest-icon">🏛️</span>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<h1 class="brand-title">GRABENGASSE 7</h1>
|
||||
<div class="brand-meta">
|
||||
<span class="meta-tag">REGENSBURG</span>
|
||||
<span class="meta-separator">•</span>
|
||||
<span class="meta-tag">ELEV 342M</span>
|
||||
<span class="meta-separator">•</span>
|
||||
<span class="meta-tag">WS-90</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action / Controls Panel -->
|
||||
<div class="hero-actions">
|
||||
<!-- Live Station Badge -->
|
||||
<div class="retro-badge badge-green action-badge">
|
||||
<span class="retro-blinker"></span>
|
||||
<span>WS-90 LIVE</span>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle (Dark / Light) -->
|
||||
<button
|
||||
class="retro-btn theme-toggle"
|
||||
@click="toggleTheme"
|
||||
:title="isDark ? 'Switch to 16-Bit Bright Theme (Amiga/SNES Day)' : 'Switch to 16-Bit Dark Theme (CRT Night)'"
|
||||
>
|
||||
<span>{{ isDark ? '☀️' : '🌙' }}</span>
|
||||
<span>{{ isDark ? 'DAY' : 'NIGHT' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- CRT Scanline Toggle -->
|
||||
<button
|
||||
class="retro-btn crt-toggle"
|
||||
:class="{ active: crtEnabled }"
|
||||
@click="toggleCrt"
|
||||
title="Toggle Retro CRT Scanline Filter"
|
||||
>
|
||||
<span>📺</span>
|
||||
<span>{{ crtEnabled ? 'CRT ON' : 'CRT OFF' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- PWA Install App Button (When installable or on mobile) -->
|
||||
<button
|
||||
class="retro-btn install-btn"
|
||||
:class="{ 'btn-accent': installable }"
|
||||
@click="handleInstallClick"
|
||||
title="Install Grabengasse 7 as a native Android/Apple Home Screen App"
|
||||
>
|
||||
<span>📲</span>
|
||||
<span>INSTALL</span>
|
||||
</button>
|
||||
|
||||
<!-- Manual Refresh -->
|
||||
<button class="retro-btn sync-btn" @click="$emit('refresh')" title="Poll latest metrics">
|
||||
<span>🔄</span>
|
||||
<span>SYNC</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iOS / Safari Installation Guide Banner (if clicked on iOS) -->
|
||||
<div v-if="showIosGuide" class="retro-well ios-install-well">
|
||||
<div class="ios-guide-header">
|
||||
<span>🍎 INSTALL ON APPLE iOS / SAFARI:</span>
|
||||
<button class="retro-btn-box" @click="showIosGuide = false">×</button>
|
||||
</div>
|
||||
<div class="ios-guide-body">
|
||||
<span>1. Tap the <strong>Share button (⎋)</strong> in Safari</span>
|
||||
<span>2. Scroll and select <strong>'Add to Home Screen (⊞)'</strong></span>
|
||||
<span>3. Tap <strong>'Add'</strong> to launch as a standalone app!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Animated SimCity Weather Advisory Ticker -->
|
||||
<div class="retro-well ticker-well">
|
||||
<div class="ticker-label">NEWS:</div>
|
||||
<div class="ticker-container">
|
||||
<div class="ticker-text">
|
||||
<span>*** GRABENGASSE 7 WEATHER REPORT: METEOROLOGICAL TELEMETRY ACTIVE ***</span>
|
||||
<span class="ticker-item">STATION: SHELLY WS90 (PROMETHEUS)</span>
|
||||
<span class="ticker-item">LOCATION: REGENSBURG HISTORIC DISTRICT</span>
|
||||
<span class="ticker-item">PWA READY: INSTALLABLE AS STANDALONE ANDROID & APPLE APP</span>
|
||||
<span class="ticker-item">LAST SYNC: {{ lastUpdated ? formatTime(lastUpdated) : 'CONNECTING...' }}</span>
|
||||
<span class="ticker-item">ADVISORY: CLICK ANY METRIC FOR 16-BIT HISTORICAL GRAPH INSPECTOR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
lastUpdated?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'theme-changed', theme: 'dark' | 'light'): void
|
||||
(e: 'crt-changed', enabled: boolean): void
|
||||
}>()
|
||||
|
||||
const isDark = ref(true)
|
||||
const crtEnabled = ref(true)
|
||||
const installable = ref(false)
|
||||
const showIosGuide = ref(false)
|
||||
let deferredPrompt: any = null
|
||||
|
||||
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 toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
const theme = isDark.value ? 'dark' : 'light'
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('grabengasse_theme', theme)
|
||||
emit('theme-changed', theme)
|
||||
}
|
||||
|
||||
function toggleCrt() {
|
||||
crtEnabled.value = !crtEnabled.value
|
||||
localStorage.setItem('grabengasse_crt', crtEnabled.value ? 'true' : 'false')
|
||||
emit('crt-changed', crtEnabled.value)
|
||||
}
|
||||
|
||||
async function handleInstallClick() {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
if (outcome === 'accepted') {
|
||||
installable.value = false
|
||||
deferredPrompt = null
|
||||
}
|
||||
} else {
|
||||
// Check if on iOS or desktop where native prompt isn't supported directly
|
||||
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
||||
if (isIos) {
|
||||
showIosGuide.value = !showIosGuide.value
|
||||
} else {
|
||||
alert("To install this app on your device:\n\n• Android: Tap the browser menu (⋮) -> 'Install App' or 'Add to Home screen'\n• iPhone/iPad: Tap Share (⎋) -> 'Add to Home Screen (⊞)'\n• Chrome/Edge: Click the install icon in the URL bar (⊕)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('grabengasse_theme') || 'dark'
|
||||
isDark.value = savedTheme === 'dark'
|
||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||
|
||||
const savedCrt = localStorage.getItem('grabengasse_crt')
|
||||
if (savedCrt !== null) {
|
||||
crtEnabled.value = savedCrt === 'true'
|
||||
}
|
||||
|
||||
// Listen for PWA beforeinstallprompt on Android / Chrome
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt = e
|
||||
installable.value = true
|
||||
})
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installable.value = false
|
||||
deferredPrompt = null
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-header {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.city-crest {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
background: var(--retro-window-inner);
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: var(--font-pixel);
|
||||
font-size: clamp(0.95rem, 3.8vw, 1.35rem);
|
||||
color: var(--retro-accent-amber);
|
||||
letter-spacing: 0.03em;
|
||||
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.5);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: clamp(0.55rem, 1.8vw, 0.65rem);
|
||||
color: var(--retro-text-dim);
|
||||
text-transform: uppercase;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
color: var(--retro-accent-cyan);
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
color: var(--retro-text-muted);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
|
||||
/* iOS Guide */
|
||||
.ios-install-well {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-color: #0c4a6e #38bdf8 #38bdf8 #0c4a6e;
|
||||
background: var(--retro-window-inner);
|
||||
}
|
||||
|
||||
.ios-guide-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 0.65rem;
|
||||
color: var(--retro-accent-cyan);
|
||||
}
|
||||
|
||||
.ios-guide-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--retro-text-main);
|
||||
}
|
||||
|
||||
/* Ticker */
|
||||
.ticker-well {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticker-label {
|
||||
font-family: var(--font-pixel);
|
||||
font-size: clamp(0.55rem, 2vw, 0.65rem);
|
||||
color: var(--retro-accent-red);
|
||||
background: var(--retro-window-bg);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-top: 1px solid var(--retro-bevel-light);
|
||||
border-left: 1px solid var(--retro-bevel-light);
|
||||
border-right: 1px solid var(--retro-bevel-dark);
|
||||
border-bottom: 1px solid var(--retro-bevel-dark);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ticker-container {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ticker-text {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.68rem, 2.2vw, 0.75rem);
|
||||
color: var(--retro-text-main);
|
||||
animation: tickerSlide 25s linear infinite;
|
||||
}
|
||||
|
||||
.ticker-item {
|
||||
margin-left: 1.5rem;
|
||||
color: var(--retro-accent-amber);
|
||||
}
|
||||
|
||||
@keyframes tickerSlide {
|
||||
0% { transform: translateX(100%); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-top-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.city-crest {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Clean 2x2 Grid of Actions on Mobile for balanced layout */
|
||||
.hero-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-actions .action-badge {
|
||||
grid-column: span 2;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.hero-actions .retro-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 0.3rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,773 @@
|
||||
<template>
|
||||
<div class="retro-screen-backdrop" @click.self="closeModal">
|
||||
<div class="retro-screen-container" role="dialog" aria-modal="true">
|
||||
<!-- Screen Titlebar / Navigation Header -->
|
||||
<div class="retro-window-header screen-header">
|
||||
<button class="retro-btn back-btn" @click="closeModal" title="Return to metrics list (Esc)">
|
||||
<span class="back-arrow">◀</span>
|
||||
<span>BACK</span>
|
||||
</button>
|
||||
|
||||
<div class="screen-title">
|
||||
<span class="screen-icon">{{ icon }}</span>
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="screen-controls">
|
||||
<button class="retro-btn-box close-box" @click="closeModal" title="Close (Esc)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Screen Content (Strictly fits inside 100dvh, zero scrolling required) -->
|
||||
<div class="screen-body">
|
||||
<!-- Top Stats Ledger Strip -->
|
||||
<div class="stats-ledger-strip">
|
||||
<div class="ledger-item primary-ledger">
|
||||
<span class="ledger-label">CURRENT</span>
|
||||
<div class="ledger-val-row">
|
||||
<span class="ledger-val highlight">{{ formattedValue }}</span>
|
||||
<span class="ledger-unit" v-if="unit">{{ unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ledger-item">
|
||||
<span class="ledger-label">24H MIN</span>
|
||||
<div class="ledger-val-row">
|
||||
<span class="ledger-arrow arrow-down">▼</span>
|
||||
<span class="ledger-val">{{ stats.min !== null ? stats.min : '--' }}</span>
|
||||
<span class="ledger-unit-sm" v-if="unit">{{ unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ledger-item">
|
||||
<span class="ledger-label">24H MAX</span>
|
||||
<div class="ledger-val-row">
|
||||
<span class="ledger-arrow arrow-up">▲</span>
|
||||
<span class="ledger-val">{{ stats.max !== null ? stats.max : '--' }}</span>
|
||||
<span class="ledger-unit-sm" v-if="unit">{{ unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ledger-item">
|
||||
<span class="ledger-label">24H AVG</span>
|
||||
<div class="ledger-val-row">
|
||||
<span class="ledger-arrow arrow-avg">~</span>
|
||||
<span class="ledger-val">{{ stats.avg !== null ? stats.avg : '--' }}</span>
|
||||
<span class="ledger-unit-sm" v-if="unit">{{ unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Range Selector Toolbar -->
|
||||
<div class="timeframe-toolbar">
|
||||
<div class="timeframe-group">
|
||||
<button
|
||||
v-for="tf in timeframes"
|
||||
:key="tf.hours"
|
||||
class="retro-btn tf-btn"
|
||||
:class="{ active: currentHours === tf.hours }"
|
||||
@click="selectTimeframe(tf.hours)"
|
||||
>
|
||||
{{ tf.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="telemetry-badge">
|
||||
<span class="retro-blinker"></span>
|
||||
<span>PROMETHEUS ENGINE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flexible Interactive Chart Canvas (Fills available screen height) -->
|
||||
<div class="retro-well chart-canvas-well">
|
||||
<div class="chart-canvas-wrapper" v-if="chartData.labels.length > 0">
|
||||
<Line :data="chartData" :options="computedChartOptions" />
|
||||
</div>
|
||||
<div class="loading-state" v-else>
|
||||
<span class="retro-blinker"></span>
|
||||
<span>QUERYING PROMETHEUS GRAPH SENSORS...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1-Line Compact Advisory Banner -->
|
||||
<div class="advisory-strip" v-if="advisoryNote">
|
||||
<span class="advisory-icon">💡</span>
|
||||
<span class="advisory-text">{{ advisoryNote }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import type { WeatherHistoryResponse, TimeSeriesPoint } from '../types/weather'
|
||||
import { calculateHistoryStats } from '../utils/weatherCalculations'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
TimeScale,
|
||||
Filler
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
metricKey: string
|
||||
title: string
|
||||
icon: string
|
||||
value?: number | string | null
|
||||
unit?: string
|
||||
subText?: string
|
||||
history?: WeatherHistoryResponse
|
||||
theme?: 'dark' | 'light'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'change-timeframe', hours: number): void
|
||||
}>()
|
||||
|
||||
const currentHours = ref(24)
|
||||
|
||||
const timeframes = [
|
||||
{ label: '24H', hours: 24 },
|
||||
{ label: '7D', hours: 168 },
|
||||
{ label: '30D', hours: 720 },
|
||||
{ label: '1Y', hours: 8760 }
|
||||
]
|
||||
|
||||
function selectTimeframe(hours: number) {
|
||||
currentHours.value = hours
|
||||
emit('change-timeframe', hours)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (props.value === null || props.value === undefined) return '--'
|
||||
if (typeof props.value === 'number') {
|
||||
return Number.isInteger(props.value) ? props.value.toString() : props.value.toFixed(1)
|
||||
}
|
||||
return props.value
|
||||
})
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts * 1000)
|
||||
if (currentHours.value <= 24) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
} else if (currentHours.value <= 168) {
|
||||
return `${date.toLocaleDateString([], { weekday: 'short' })} ${date.getHours()}h`
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
|
||||
}
|
||||
}
|
||||
|
||||
// Get the main time series for this metric
|
||||
const primarySeries = computed<TimeSeriesPoint[]>(() => {
|
||||
if (!props.history || !props.history.metrics) return []
|
||||
const metric = props.history.metrics[props.metricKey]
|
||||
return metric?.data || []
|
||||
})
|
||||
|
||||
// Calculate Min, Max, Avg, Current stats
|
||||
const stats = computed(() => {
|
||||
return calculateHistoryStats(primarySeries.value)
|
||||
})
|
||||
|
||||
// Build Chart Data depending on the metric group
|
||||
const chartData = computed(() => {
|
||||
if (!props.history || !props.history.metrics) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
const metrics = props.history.metrics
|
||||
|
||||
// TEMPERATURE & DEW POINT GROUP
|
||||
if (
|
||||
props.metricKey === 'temperature' ||
|
||||
props.metricKey === 'apparent_temperature' ||
|
||||
props.metricKey === 'dew_point'
|
||||
) {
|
||||
const tempPoints = metrics.temperature?.data || []
|
||||
const apparentPoints = metrics.apparent_temperature?.data || []
|
||||
const dewPoints = metrics.dew_point?.data || []
|
||||
const labels = (tempPoints.length > 0 ? tempPoints : apparentPoints).map(p =>
|
||||
formatTimestamp(p.timestamp)
|
||||
)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Temp (°C)',
|
||||
data: tempPoints.map(p => p.value),
|
||||
borderColor: '#fbbf24',
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.12)',
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
borderWidth: 2,
|
||||
pointRadius: currentHours.value > 168 ? 0 : 2
|
||||
},
|
||||
{
|
||||
label: 'Feels Like',
|
||||
data: apparentPoints.map(p => p.value),
|
||||
borderColor: '#f43f5e',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [4, 4],
|
||||
tension: 0.25,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: 'Dew Point',
|
||||
data: dewPoints.map(p => p.value),
|
||||
borderColor: '#00f0ff',
|
||||
backgroundColor: 'transparent',
|
||||
tension: 0.25,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// HUMIDITY
|
||||
if (props.metricKey === 'humidity') {
|
||||
const humPoints = metrics.humidity?.data || []
|
||||
return {
|
||||
labels: humPoints.map(p => formatTimestamp(p.timestamp)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Humidity (%)',
|
||||
data: humPoints.map(p => p.value),
|
||||
borderColor: '#00f0ff',
|
||||
backgroundColor: 'rgba(0, 240, 255, 0.15)',
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
borderWidth: 2,
|
||||
pointRadius: currentHours.value > 168 ? 0 : 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// WIND & GUSTS
|
||||
if (props.metricKey === 'wind_speed' || props.metricKey === 'gust_speed') {
|
||||
const windPoints = metrics.wind_speed?.data || []
|
||||
const gustPoints = metrics.gust_speed?.data || []
|
||||
const labels = (windPoints.length > 0 ? windPoints : gustPoints).map(p =>
|
||||
formatTimestamp(p.timestamp)
|
||||
)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Wind (km/h)',
|
||||
data: windPoints.map(p => p.value),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.15)',
|
||||
fill: true,
|
||||
tension: 0.2,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: 'Gust (km/h)',
|
||||
data: gustPoints.map(p => p.value),
|
||||
borderColor: '#fbbf24',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [3, 3],
|
||||
tension: 0.2,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// SOLAR & UV
|
||||
if (props.metricKey === 'illuminance_lux' || props.metricKey === 'uv_index') {
|
||||
const luxPoints = metrics.illuminance_lux?.data || []
|
||||
const uvPoints = metrics.uv_index?.data || []
|
||||
const labels = (luxPoints.length > 0 ? luxPoints : uvPoints).map(p =>
|
||||
formatTimestamp(p.timestamp)
|
||||
)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Solar (LUX)',
|
||||
data: luxPoints.map(p => p.value),
|
||||
borderColor: '#fbbf24',
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.15)',
|
||||
fill: true,
|
||||
tension: 0.2,
|
||||
borderWidth: 2,
|
||||
yAxisID: 'y',
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: 'UV Index',
|
||||
data: uvPoints.map(p => p.value),
|
||||
borderColor: '#c084fc',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
yAxisID: 'y1',
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// PRESSURE
|
||||
if (props.metricKey === 'pressure') {
|
||||
const pressPoints = metrics.pressure?.data || []
|
||||
return {
|
||||
labels: pressPoints.map(p => formatTimestamp(p.timestamp)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Pressure (hPa)',
|
||||
data: pressPoints.map(p => p.value),
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: 'rgba(56, 189, 248, 0.12)',
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
borderWidth: 2,
|
||||
pointRadius: currentHours.value > 168 ? 0 : 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback single series
|
||||
const fallbackPoints = primarySeries.value
|
||||
return {
|
||||
labels: fallbackPoints.map(p => formatTimestamp(p.timestamp)),
|
||||
datasets: [
|
||||
{
|
||||
label: `${props.title} (${props.unit || ''})`,
|
||||
data: fallbackPoints.map(p => p.value),
|
||||
borderColor: '#fbbf24',
|
||||
backgroundColor: 'rgba(251, 191, 36, 0.15)',
|
||||
fill: true,
|
||||
tension: 0.25,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const computedChartOptions = computed(() => {
|
||||
const isLight = props.theme === 'light'
|
||||
const textColor = isLight ? '#1e293b' : '#f1f5f9'
|
||||
const gridColor = isLight ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.08)'
|
||||
|
||||
const isDualAxis =
|
||||
props.metricKey === 'illuminance_lux' || props.metricKey === 'uv_index'
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: textColor,
|
||||
font: { family: "'Press Start 2P', monospace", size: 8 },
|
||||
boxWidth: 10,
|
||||
padding: 6
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
backgroundColor: isLight ? '#e2e8f0' : '#111827',
|
||||
titleColor: textColor,
|
||||
bodyColor: textColor,
|
||||
borderColor: isLight ? '#94a3b8' : '#374151',
|
||||
borderWidth: 2,
|
||||
titleFont: { family: "'Press Start 2P', monospace", size: 8 },
|
||||
bodyFont: { family: "'Space Mono', monospace", size: 10 }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { family: "'Space Mono', monospace", size: 9 },
|
||||
maxTicksLimit: 6
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { family: "'Space Mono', monospace", size: 9 }
|
||||
}
|
||||
},
|
||||
...(isDualAxis
|
||||
? {
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
min: 0,
|
||||
max: 12,
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: {
|
||||
color: '#c084fc',
|
||||
font: { family: "'Space Mono', monospace", size: 9 },
|
||||
stepSize: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Advisory Note (SimCity style commentary)
|
||||
const advisoryNote = computed(() => {
|
||||
if (
|
||||
props.metricKey === 'temperature' ||
|
||||
props.metricKey === 'apparent_temperature' ||
|
||||
props.metricKey === 'dew_point'
|
||||
) {
|
||||
return 'Dew point represents saturation temperature. Condensation forms when temp reaches dew point.'
|
||||
}
|
||||
if (props.metricKey === 'humidity') {
|
||||
return 'Ideal relative humidity is 40%–60%. Above 70% promotes condensation and muggy conditions.'
|
||||
}
|
||||
if (props.metricKey === 'wind_speed' || props.metricKey === 'gust_speed') {
|
||||
return 'Wind velocity measured by Shelly WS90 ultrasonic anemometer at 10m elevation.'
|
||||
}
|
||||
if (props.metricKey === 'illuminance_lux' || props.metricKey === 'uv_index') {
|
||||
return 'Solar illuminance measures daylight LUX. UV Index above 6 requires protection.'
|
||||
}
|
||||
if (props.metricKey === 'pressure') {
|
||||
return 'Standard sea level pressure is 1013.25 hPa. Rising pressure indicates fair weather.'
|
||||
}
|
||||
return null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.retro-screen-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--retro-bg);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: 100vw;
|
||||
max-height: 100dvh;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.retro-screen-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
height: 100%;
|
||||
max-height: 100dvh;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--retro-window-bg);
|
||||
border-top: 3px solid var(--retro-bevel-light);
|
||||
border-left: 3px solid var(--retro-bevel-light);
|
||||
border-right: 3px solid var(--retro-bevel-dark);
|
||||
border-bottom: 3px solid var(--retro-bevel-dark);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-header {
|
||||
padding: 0.35rem 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
gap: 0.25rem;
|
||||
background: var(--retro-window-inner);
|
||||
}
|
||||
|
||||
.back-arrow {
|
||||
color: var(--retro-accent-amber);
|
||||
}
|
||||
|
||||
.screen-title {
|
||||
font-family: var(--font-pixel);
|
||||
font-size: clamp(0.58rem, 2.2vw, 0.72rem);
|
||||
font-weight: 700;
|
||||
color: var(--retro-titlebar-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.screen-icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.screen-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Screen Body: strictly fills available viewport space */
|
||||
.screen-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Stats Ledger Strip */
|
||||
.stats-ledger-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ledger-item {
|
||||
background: var(--retro-window-inner);
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
padding: 0.25rem 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.primary-ledger {
|
||||
border-color: var(--retro-accent-amber);
|
||||
}
|
||||
|
||||
.ledger-label {
|
||||
font-family: var(--font-pixel);
|
||||
font-size: clamp(0.48rem, 1.6vw, 0.58rem);
|
||||
color: var(--retro-text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ledger-val-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ledger-val {
|
||||
font-family: var(--font-digital);
|
||||
font-size: clamp(1.3rem, 4.2vw, 1.7rem);
|
||||
font-weight: 700;
|
||||
color: var(--retro-text-main);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ledger-val.highlight {
|
||||
color: var(--retro-accent-amber);
|
||||
}
|
||||
|
||||
.ledger-unit,
|
||||
.ledger-unit-sm {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.65rem;
|
||||
color: var(--retro-accent-cyan);
|
||||
}
|
||||
|
||||
.ledger-arrow {
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.arrow-down { color: var(--retro-accent-cyan); }
|
||||
.arrow-up { color: var(--retro-accent-red); }
|
||||
.arrow-avg { color: var(--retro-accent-amber); }
|
||||
|
||||
/* Timeframe Toolbar */
|
||||
.timeframe-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeframe-group {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tf-btn {
|
||||
padding: 0.25rem 0.45rem;
|
||||
font-size: clamp(0.58rem, 1.8vw, 0.68rem);
|
||||
}
|
||||
|
||||
.telemetry-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: clamp(0.52rem, 1.6vw, 0.62rem);
|
||||
color: var(--retro-accent-green);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Chart Canvas Well: Fills all remaining space */
|
||||
.chart-canvas-well {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chart-canvas-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 0.65rem;
|
||||
color: var(--retro-text-dim);
|
||||
}
|
||||
|
||||
/* Advisory Strip */
|
||||
.advisory-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--retro-window-inner);
|
||||
border-top: 1px solid var(--retro-bevel-dark);
|
||||
border-left: 1px solid var(--retro-bevel-dark);
|
||||
border-right: 1px solid var(--retro-bevel-light);
|
||||
border-bottom: 1px solid var(--retro-bevel-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.advisory-icon {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.advisory-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.58rem, 1.8vw, 0.68rem);
|
||||
color: var(--retro-text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.retro-screen-container {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.screen-body {
|
||||
padding: 0.4rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.timeframe-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tf-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0.3rem 0.2rem;
|
||||
}
|
||||
|
||||
.telemetry-badge {
|
||||
display: none; /* Hide on tight phones to maximize chart width */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div
|
||||
class="retro-metric-row"
|
||||
:class="{ 'row-highlight': highlight }"
|
||||
@click="$emit('inspect')"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown.enter="$emit('inspect')"
|
||||
@keydown.space.prevent="$emit('inspect')"
|
||||
title="Click or tap to view 16-bit history graph & telemetry"
|
||||
>
|
||||
<!-- Left: Icon & Metric Info -->
|
||||
<div class="row-left">
|
||||
<div class="row-icon-box">
|
||||
<span class="row-icon">{{ icon }}</span>
|
||||
</div>
|
||||
<div class="row-text-group">
|
||||
<div class="row-title">{{ title }}</div>
|
||||
<div class="row-sub-group">
|
||||
<span class="retro-badge sub-badge" v-if="subBadge" :style="badgeCustomStyle">
|
||||
{{ subBadge }}
|
||||
</span>
|
||||
<span class="row-subtext" v-if="subText">{{ subText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Digital Value & Inspect Button -->
|
||||
<div class="row-right">
|
||||
<div class="row-value-well">
|
||||
<span class="row-primary-val">{{ formattedValue }}</span>
|
||||
<span class="row-primary-unit" v-if="unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div class="row-inspect-btn">
|
||||
<span class="inspect-arrow">▶</span>
|
||||
<span class="inspect-text">GRAPH</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
icon: string
|
||||
value?: number | string | null
|
||||
unit?: string
|
||||
subText?: string
|
||||
subBadge?: string
|
||||
subBadgeColor?: string
|
||||
highlight?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'inspect'): void
|
||||
}>()
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (props.value === null || props.value === undefined) return '--'
|
||||
if (typeof props.value === 'number') {
|
||||
return Number.isInteger(props.value) ? props.value.toString() : props.value.toFixed(1)
|
||||
}
|
||||
return props.value
|
||||
})
|
||||
|
||||
const badgeCustomStyle = computed(() => {
|
||||
if (!props.subBadgeColor) return {}
|
||||
return {
|
||||
borderColor: props.subBadgeColor,
|
||||
color: props.subBadgeColor
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.retro-metric-row {
|
||||
background-color: var(--retro-card-bg);
|
||||
border-top: 2px solid var(--retro-bevel-light);
|
||||
border-left: 2px solid var(--retro-bevel-light);
|
||||
border-right: 2px solid var(--retro-bevel-dark);
|
||||
border-bottom: 2px solid var(--retro-bevel-dark);
|
||||
box-shadow: var(--retro-shadow);
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease, transform 0.05s ease;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.retro-metric-row:hover {
|
||||
background-color: var(--retro-card-hover);
|
||||
}
|
||||
|
||||
.retro-metric-row:active {
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
box-shadow: inset 1px 1px 0px rgba(0, 0, 0, 0.6);
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.row-highlight {
|
||||
border-color: var(--retro-accent-amber);
|
||||
}
|
||||
|
||||
/* Left Group */
|
||||
.row-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row-icon-box {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
background: var(--retro-window-inner);
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
font-family: var(--font-pixel);
|
||||
font-size: clamp(0.58rem, 2.2vw, 0.72rem);
|
||||
font-weight: 700;
|
||||
color: var(--retro-text-main);
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-sub-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-subtext {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(0.58rem, 2vw, 0.68rem);
|
||||
color: var(--retro-text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-badge {
|
||||
font-size: 0.55rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Right Group */
|
||||
.row-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-value-well {
|
||||
background: var(--retro-window-inner);
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
padding: 0.25rem 0.55rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
min-width: 85px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.row-primary-val {
|
||||
font-family: var(--font-digital);
|
||||
font-size: clamp(1.6rem, 5.5vw, 2.1rem);
|
||||
font-weight: 700;
|
||||
color: var(--retro-accent-amber);
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.row-primary-unit {
|
||||
font-family: var(--font-ui);
|
||||
font-size: clamp(0.7rem, 2.2vw, 0.85rem);
|
||||
font-weight: 700;
|
||||
color: var(--retro-accent-cyan);
|
||||
}
|
||||
|
||||
.row-inspect-btn {
|
||||
background: var(--retro-window-bg);
|
||||
border-top: 2px solid var(--retro-bevel-light);
|
||||
border-left: 2px solid var(--retro-bevel-light);
|
||||
border-right: 2px solid var(--retro-bevel-dark);
|
||||
border-bottom: 2px solid var(--retro-bevel-dark);
|
||||
padding: 0.35rem 0.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.6rem;
|
||||
color: var(--retro-accent-cyan);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inspect-arrow {
|
||||
color: var(--retro-accent-amber);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.retro-metric-row:hover .row-inspect-btn {
|
||||
background-color: var(--retro-accent-amber);
|
||||
color: #000000;
|
||||
border-color: #fde68a #92400e #92400e #fde68a;
|
||||
}
|
||||
|
||||
.retro-metric-row:hover .inspect-arrow {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.retro-metric-row {
|
||||
padding: 0.45rem 0.5rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.row-icon-box {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.row-value-well {
|
||||
padding: 0.2rem 0.4rem;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.inspect-text {
|
||||
display: none; /* Icon arrow only on very narrow screens */
|
||||
}
|
||||
|
||||
.row-inspect-btn {
|
||||
padding: 0.35rem 0.4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const value: string
|
||||
export default value
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const value: string
|
||||
export default value
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const value: string
|
||||
export default value
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
|
||||
// Register Service Worker for PWA (Progressive Web App)
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((reg) => {
|
||||
console.log('[PWA] ServiceWorker registered with scope:', reg.scope)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[PWA] ServiceWorker registration failed:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import zlib
|
||||
import struct
|
||||
|
||||
def make_png(width, height, get_pixel_func):
|
||||
# PNG signature
|
||||
png = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
# IHDR
|
||||
ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)
|
||||
ihdr_crc = zlib.crc32(b'IHDR' + ihdr_data)
|
||||
png += struct.pack('>I', len(ihdr_data)) + b'IHDR' + ihdr_data + struct.pack('>I', ihdr_crc)
|
||||
|
||||
# Raw image data: for each row, 0 (filter type none) followed by RGBA bytes
|
||||
raw_data = bytearray()
|
||||
for y in range(height):
|
||||
raw_data.append(0) # Filter type 0
|
||||
for x in range(width):
|
||||
r, g, b, a = get_pixel_func(x, y, width, height)
|
||||
raw_data.extend([r, g, b, a])
|
||||
|
||||
# IDAT
|
||||
compressed = zlib.compress(raw_data, 9)
|
||||
idat_crc = zlib.crc32(b'IDAT' + compressed)
|
||||
png += struct.pack('>I', len(compressed)) + b'IDAT' + compressed + struct.pack('>I', idat_crc)
|
||||
|
||||
# IEND
|
||||
iend_crc = zlib.crc32(b'IEND')
|
||||
png += struct.pack('>I', 0) + b'IEND' + struct.pack('>I', iend_crc)
|
||||
|
||||
return png
|
||||
|
||||
def retro_icon_pixel(x, y, w, h):
|
||||
# Normalize coordinates to 32x32 grid
|
||||
gx = int(x * 32 / w)
|
||||
gy = int(y * 32 / h)
|
||||
|
||||
# Colors
|
||||
bg_dark = (12, 16, 26, 255)
|
||||
bevel_light = (68, 86, 126, 255)
|
||||
bevel_dark = (7, 9, 15, 255)
|
||||
amber_gold = (251, 191, 36, 255)
|
||||
amber_bright = (254, 240, 138, 255)
|
||||
cyan_bright = (0, 240, 255, 255)
|
||||
cyan_dark = (12, 74, 110, 255)
|
||||
emerald = (16, 185, 129, 255)
|
||||
white = (255, 255, 255, 255)
|
||||
roof_red = (244, 63, 94, 255)
|
||||
roof_dark = (159, 18, 57, 255)
|
||||
|
||||
# Outer border
|
||||
if gx == 0 or gy == 0:
|
||||
return bevel_light
|
||||
if gx == 31 or gy == 31:
|
||||
return bevel_dark
|
||||
if gx == 1 or gy == 1:
|
||||
return bevel_light
|
||||
if gx == 30 or gy == 30:
|
||||
return bevel_dark
|
||||
|
||||
# Inner background with subtle checker pattern
|
||||
is_pattern = ((gx // 2) + (gy // 2)) % 2 == 0
|
||||
bg = (18, 24, 38, 255) if is_pattern else (14, 19, 31, 255)
|
||||
|
||||
# Weather Antenna / Satellite mast (top center)
|
||||
if gx == 16 and 4 <= gy <= 10:
|
||||
return cyan_bright
|
||||
if (gx in (15, 17) and gy == 5) or (gx in (14, 18) and gy == 4):
|
||||
return cyan_bright
|
||||
if gx == 16 and gy == 3:
|
||||
return (255, 59, 48, 255) # Red blinking LED
|
||||
|
||||
# Sun / Cloud in top left
|
||||
if 4 <= gx <= 9 and 4 <= gy <= 9:
|
||||
if (gx - 6)**2 + (gy - 6)**2 <= 6:
|
||||
return amber_gold
|
||||
|
||||
# House Roof (gy between 11 and 17)
|
||||
# Peak at gx=16, gy=11. Slopes down to gx=7, gx=25 at gy=17
|
||||
roof_width = (gy - 11) * 3
|
||||
if 11 <= gy <= 17 and (16 - roof_width // 2) <= gx <= (16 + roof_width // 2):
|
||||
if gx == (16 - roof_width // 2) or gy == 11:
|
||||
return amber_bright
|
||||
if gx == (16 + roof_width // 2):
|
||||
return roof_dark
|
||||
return roof_red
|
||||
|
||||
# House Body (gy between 18 and 27, gx between 9 and 23)
|
||||
if 18 <= gy <= 27 and 9 <= gx <= 23:
|
||||
# House borders
|
||||
if gx == 9 or gy == 18:
|
||||
return amber_bright
|
||||
if gx == 23 or gy == 27:
|
||||
return bevel_dark
|
||||
|
||||
# Door (gx 14..18, gy 21..27)
|
||||
if 14 <= gx <= 18 and 21 <= gy <= 27:
|
||||
if gx in (14, 18) or gy == 21:
|
||||
return bevel_dark
|
||||
if gx == 17 and gy == 24:
|
||||
return amber_gold # Doorknob
|
||||
return (40, 50, 70, 255)
|
||||
|
||||
# Windows (gx 11..12, gy 20..22 and gx 20..21, gy 20..22)
|
||||
if (11 <= gx <= 12 and 20 <= gy <= 22) or (20 <= gx <= 21 and 20 <= gy <= 22):
|
||||
return cyan_bright
|
||||
|
||||
# Wall texture
|
||||
return (220, 225, 235, 255)
|
||||
|
||||
# Ground baseline (gy between 28 and 29)
|
||||
if 28 <= gy <= 29 and 2 <= gx <= 29:
|
||||
return emerald
|
||||
|
||||
return bg
|
||||
|
||||
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/icon-192.png', 'wb') as f:
|
||||
f.write(make_png(192, 192, retro_icon_pixel))
|
||||
|
||||
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/icon-512.png', 'wb') as f:
|
||||
f.write(make_png(512, 512, retro_icon_pixel))
|
||||
|
||||
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/apple-touch-icon.png', 'wb') as f:
|
||||
f.write(make_png(180, 180, retro_icon_pixel))
|
||||
|
||||
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/favicon.png', 'wb') as f:
|
||||
f.write(make_png(64, 64, retro_icon_pixel))
|
||||
|
||||
print("Icons generated successfully!")
|
||||
@@ -0,0 +1,35 @@
|
||||
import subprocess
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Start Chromium in background with remote debugging
|
||||
chrome_cmd = [
|
||||
'/usr/local/bin/chromium',
|
||||
'--headless',
|
||||
'--remote-debugging-port=9222',
|
||||
'--disable-gpu',
|
||||
'--window-size=390,844',
|
||||
'https://grabengasse7.de'
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(chrome_cmd)
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
# Get WebSocket debugger URL from /json
|
||||
tabs = json.loads(urllib.request.urlopen('http://127.0.0.1:9222/json').read())
|
||||
print("Found tabs:", len(tabs))
|
||||
target = tabs[0]
|
||||
ws_url = target['webSocketDebuggerUrl']
|
||||
print("WS URL:", ws_url)
|
||||
|
||||
# We can use simple websocket client
|
||||
import socket
|
||||
# Or use curl / CDP commands
|
||||
print("Chromium started successfully on port 9222")
|
||||
finally:
|
||||
proc.terminate()
|
||||
@@ -0,0 +1,127 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
async function runTest() {
|
||||
console.log("Starting Chromium...");
|
||||
const chrome = spawn('/usr/local/bin/chromium', [
|
||||
'--headless',
|
||||
'--remote-debugging-port=9222',
|
||||
'--disable-gpu',
|
||||
'--window-size=390,844'
|
||||
]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
try {
|
||||
const listRes = await fetch('http://127.0.0.1:9222/json');
|
||||
const tabs = await listRes.json();
|
||||
const wsUrl = tabs[0].webSocketDebuggerUrl;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
let id = 1;
|
||||
const callbacks = new Map();
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.id && callbacks.has(msg.id)) {
|
||||
callbacks.get(msg.id)(msg);
|
||||
callbacks.delete(msg.id);
|
||||
}
|
||||
};
|
||||
|
||||
await new Promise((r) => ws.onopen = r);
|
||||
|
||||
function sendCommand(method, params = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const cmdId = id++;
|
||||
callbacks.set(cmdId, resolve);
|
||||
ws.send(JSON.stringify({ id: cmdId, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Navigating to https://grabengasse7.de ...");
|
||||
await sendCommand('Page.enable');
|
||||
await sendCommand('Runtime.enable');
|
||||
await sendCommand('Page.navigate', { url: 'https://grabengasse7.de' });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
// Evaluate scroll dimensions
|
||||
const scrollEval = await sendCommand('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
docScrollWidth: document.documentElement.scrollWidth,
|
||||
docClientWidth: document.documentElement.clientWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
bodyClientWidth: document.body.clientWidth,
|
||||
hasHorizontalScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
overflowElements: Array.from(document.querySelectorAll('*'))
|
||||
.filter(el => el.scrollWidth > window.innerWidth || el.getBoundingClientRect().right > window.innerWidth)
|
||||
.map(el => ({ tag: el.tagName, class: el.className, right: el.getBoundingClientRect().right, scrollW: el.scrollWidth }))
|
||||
.slice(0, 5)
|
||||
})`,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
const scrollData = JSON.parse(scrollEval.result?.result?.value || '{}');
|
||||
console.log("Viewport Dimensions Check:", scrollData);
|
||||
|
||||
// Capture Home Screenshot
|
||||
const homeScreenshot = await sendCommand('Page.captureScreenshot', { format: 'png' });
|
||||
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_home.png', Buffer.from(homeScreenshot.result.data, 'base64'));
|
||||
console.log("Saved node_home.png");
|
||||
|
||||
// Click on the first metric row (.retro-metric-row)
|
||||
console.log("Clicking on Temperature metric row...");
|
||||
const clickEval = await sendCommand('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const rows = document.querySelectorAll('.retro-metric-row');
|
||||
if (rows.length > 0) {
|
||||
rows[0].click();
|
||||
return JSON.stringify({ clicked: true, count: rows.length });
|
||||
}
|
||||
return JSON.stringify({ clicked: false, count: 0 });
|
||||
})()`,
|
||||
returnByValue: true
|
||||
});
|
||||
console.log("Click result:", JSON.parse(clickEval.result?.result?.value || '{}'));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
// Evaluate Detail Screen dimensions
|
||||
const detailEval = await sendCommand('Runtime.evaluate', {
|
||||
expression: `(() => {
|
||||
const modal = document.querySelector('.retro-screen-container') || document.querySelector('.retro-modal-window');
|
||||
const backdrop = document.querySelector('.retro-screen-backdrop') || document.querySelector('.retro-modal-backdrop');
|
||||
const canvas = document.querySelector('canvas');
|
||||
return JSON.stringify({
|
||||
modalExists: !!modal,
|
||||
backdropExists: !!backdrop,
|
||||
canvasExists: !!canvas,
|
||||
modalHeight: modal ? modal.offsetHeight : 0,
|
||||
modalScrollHeight: modal ? modal.scrollHeight : 0,
|
||||
windowHeight: window.innerHeight,
|
||||
requiresVerticalScroll: modal ? modal.scrollHeight > modal.clientHeight : false,
|
||||
docScrollWidth: document.documentElement.scrollWidth,
|
||||
docClientWidth: document.documentElement.clientWidth,
|
||||
requiresHorizontalScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth
|
||||
});
|
||||
})()`,
|
||||
returnByValue: true
|
||||
});
|
||||
console.log("Detail Screen Check:", JSON.parse(detailEval.result?.result?.value || '{}'));
|
||||
|
||||
// Capture Detail Screen Screenshot
|
||||
const detailScreenshot = await sendCommand('Page.captureScreenshot', { format: 'png' });
|
||||
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_detail.png', Buffer.from(detailScreenshot.result.data, 'base64'));
|
||||
console.log("Saved node_detail.png");
|
||||
|
||||
ws.close();
|
||||
} finally {
|
||||
chrome.kill();
|
||||
}
|
||||
}
|
||||
|
||||
runTest().catch(console.error);
|
||||
@@ -0,0 +1,75 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
async function runThemeTest() {
|
||||
const chrome = spawn('/usr/local/bin/chromium', [
|
||||
'--headless',
|
||||
'--remote-debugging-port=9222',
|
||||
'--disable-gpu',
|
||||
'--window-size=390,844'
|
||||
]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
try {
|
||||
const listRes = await fetch('http://127.0.0.1:9222/json');
|
||||
const tabs = await listRes.json();
|
||||
const ws = new WebSocket(tabs[0].webSocketDebuggerUrl);
|
||||
|
||||
let id = 1;
|
||||
const callbacks = new Map();
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.id && callbacks.has(msg.id)) {
|
||||
callbacks.get(msg.id)(msg);
|
||||
callbacks.delete(msg.id);
|
||||
}
|
||||
};
|
||||
|
||||
await new Promise((r) => ws.onopen = r);
|
||||
|
||||
function sendCommand(method, params = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const cmdId = id++;
|
||||
callbacks.set(cmdId, resolve);
|
||||
ws.send(JSON.stringify({ id: cmdId, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
await sendCommand('Page.enable');
|
||||
await sendCommand('Runtime.enable');
|
||||
await sendCommand('Page.navigate', { url: 'https://grabengasse7.de' });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
|
||||
// Click Day mode toggle
|
||||
await sendCommand('Runtime.evaluate', {
|
||||
expression: `document.querySelector('.theme-toggle').click();`
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Capture Light Home Screenshot
|
||||
const lightHome = await sendCommand('Page.captureScreenshot', { format: 'png' });
|
||||
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_light_home.png', Buffer.from(lightHome.result.data, 'base64'));
|
||||
|
||||
// Click Temperature row
|
||||
await sendCommand('Runtime.evaluate', {
|
||||
expression: `document.querySelector('.retro-metric-row').click();`
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Capture Light Detail Screenshot
|
||||
const lightDetail = await sendCommand('Page.captureScreenshot', { format: 'png' });
|
||||
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_light_detail.png', Buffer.from(lightDetail.result.data, 'base64'));
|
||||
|
||||
console.log("Light theme screenshots captured successfully!");
|
||||
ws.close();
|
||||
} finally {
|
||||
chrome.kill();
|
||||
}
|
||||
}
|
||||
|
||||
runThemeTest().catch(console.error);
|
||||
@@ -0,0 +1,407 @@
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--font-pixel: 'Press Start 2P', monospace;
|
||||
--font-ui: 'Silkscreen', 'Press Start 2P', monospace;
|
||||
--font-mono: 'Space Mono', monospace;
|
||||
--font-digital: 'VT323', monospace;
|
||||
|
||||
/* 16-Bit Dark Palette (SNES / Mad TV Night Studio) */
|
||||
--retro-bg: #0c101a;
|
||||
--retro-bg-pattern: #131926;
|
||||
--retro-window-bg: #1a2236;
|
||||
--retro-window-inner: #101624;
|
||||
--retro-card-bg: #182033;
|
||||
--retro-card-hover: #222c44;
|
||||
|
||||
--retro-bevel-light: #44567e;
|
||||
--retro-bevel-dark: #07090f;
|
||||
--retro-border: #28354f;
|
||||
|
||||
--retro-titlebar-bg: linear-gradient(90deg, #1c396c 0%, #2f599e 50%, #172d54 100%);
|
||||
--retro-titlebar-text: #f8fafc;
|
||||
--retro-titlebar-stripes: repeating-linear-gradient(90deg, #244882, #244882 4px, #162f59 4px, #162f59 8px);
|
||||
|
||||
--retro-text-main: #f1f5f9;
|
||||
--retro-text-dim: #94a3b8;
|
||||
--retro-text-muted: #64748b;
|
||||
|
||||
--retro-accent-amber: #fbbf24;
|
||||
--retro-accent-cyan: #00f0ff;
|
||||
--retro-accent-green: #10b981;
|
||||
--retro-accent-red: #f43f5e;
|
||||
--retro-accent-purple: #c084fc;
|
||||
|
||||
--retro-shadow: 3px 3px 0px #05070c;
|
||||
--retro-shadow-lg: 6px 6px 0px #04060a;
|
||||
--retro-grid-line: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--chart-grid-color: rgba(255, 255, 255, 0.08);
|
||||
--chart-text-color: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--font-pixel: 'Press Start 2P', monospace;
|
||||
--font-ui: 'Silkscreen', 'Press Start 2P', monospace;
|
||||
--font-mono: 'Space Mono', monospace;
|
||||
--font-digital: 'VT323', monospace;
|
||||
|
||||
/* 16-Bit Bright Palette (Amiga Workbench / Windows 95 / SNES Day Mode) */
|
||||
--retro-bg: #b8bec9;
|
||||
--retro-bg-pattern: #c8ceda;
|
||||
--retro-window-bg: #d4d8e2;
|
||||
--retro-window-inner: #f1f4f9;
|
||||
--retro-card-bg: #dde2ed;
|
||||
--retro-card-hover: #eaf0fa;
|
||||
|
||||
--retro-bevel-light: #ffffff;
|
||||
--retro-bevel-dark: #586173;
|
||||
--retro-border: #4d576a;
|
||||
|
||||
--retro-titlebar-bg: linear-gradient(90deg, #0a246a 0%, #1d50b5 60%, #081e54 100%);
|
||||
--retro-titlebar-text: #ffffff;
|
||||
--retro-titlebar-stripes: repeating-linear-gradient(90deg, #103387, #103387 4px, #071c4f 4px, #071c4f 8px);
|
||||
|
||||
--retro-text-main: #0f172a;
|
||||
--retro-text-dim: #334155;
|
||||
--retro-text-muted: #64748b;
|
||||
|
||||
--retro-accent-amber: #b45309;
|
||||
--retro-accent-cyan: #0284c7;
|
||||
--retro-accent-green: #047857;
|
||||
--retro-accent-red: #be123c;
|
||||
--retro-accent-purple: #7e22ce;
|
||||
|
||||
--retro-shadow: 3px 3px 0px #50596b;
|
||||
--retro-shadow-lg: 6px 6px 0px #404857;
|
||||
--retro-grid-line: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--chart-grid-color: rgba(0, 0, 0, 0.1);
|
||||
--chart-text-color: #334155;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-mono);
|
||||
background-color: var(--retro-bg);
|
||||
color: var(--retro-text-main);
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
position: relative;
|
||||
background-image:
|
||||
radial-gradient(var(--retro-bg-pattern) 15%, transparent 16%),
|
||||
radial-gradient(var(--retro-bg-pattern) 15%, transparent 16%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Optional CRT Scanlines Layer */
|
||||
.crt-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
background: linear-gradient(
|
||||
rgba(18, 16, 16, 0) 50%,
|
||||
rgba(0, 0, 0, 0.18) 50%
|
||||
), linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 0, 0, 0.015),
|
||||
rgba(0, 255, 0, 0.01),
|
||||
rgba(0, 0, 255, 0.015)
|
||||
);
|
||||
background-size: 100% 3px, 6px 100%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Base Container with Safe Area Support */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0.6rem 0.6rem 3rem 0.6rem;
|
||||
padding-left: max(0.6rem, env(safe-area-inset-left));
|
||||
padding-right: max(0.6rem, env(safe-area-inset-right));
|
||||
padding-bottom: max(3rem, env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 16-Bit Retro Window Component */
|
||||
.retro-window {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--retro-window-bg);
|
||||
border-top: 3px solid var(--retro-bevel-light);
|
||||
border-left: 3px solid var(--retro-bevel-light);
|
||||
border-right: 3px solid var(--retro-bevel-dark);
|
||||
border-bottom: 3px solid var(--retro-bevel-dark);
|
||||
box-shadow: var(--retro-shadow);
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.retro-window-header {
|
||||
background: var(--retro-titlebar-bg);
|
||||
color: var(--retro-titlebar-text);
|
||||
padding: 0.35rem 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid var(--retro-bevel-dark);
|
||||
font-family: var(--font-pixel);
|
||||
font-size: clamp(0.58rem, 2.2vw, 0.7rem);
|
||||
letter-spacing: 0.04em;
|
||||
user-select: none;
|
||||
gap: 0.4rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.retro-window-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.retro-window-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.retro-btn-box {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
background: var(--retro-window-bg);
|
||||
border-top: 2px solid var(--retro-bevel-light);
|
||||
border-left: 2px solid var(--retro-bevel-light);
|
||||
border-right: 2px solid var(--retro-bevel-dark);
|
||||
border-bottom: 2px solid var(--retro-bevel-dark);
|
||||
color: var(--retro-text-main);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-pixel);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.retro-btn-box:active {
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.retro-window-content {
|
||||
padding: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Inset Retro Wells */
|
||||
.retro-well {
|
||||
background: var(--retro-window-inner);
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
padding: 0.45rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 16-Bit Interactive Buttons */
|
||||
.retro-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: clamp(0.62rem, 2.2vw, 0.72rem);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.4rem 0.65rem;
|
||||
background-color: var(--retro-window-bg);
|
||||
color: var(--retro-text-main);
|
||||
border-top: 2px solid var(--retro-bevel-light);
|
||||
border-left: 2px solid var(--retro-bevel-light);
|
||||
border-right: 2px solid var(--retro-bevel-dark);
|
||||
border-bottom: 2px solid var(--retro-bevel-dark);
|
||||
box-shadow: 2px 2px 0px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
transition: background-color 0.1s ease;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.retro-btn:hover {
|
||||
background-color: var(--retro-card-hover);
|
||||
}
|
||||
|
||||
.retro-btn:active,
|
||||
.retro-btn.active {
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
box-shadow: inset 1px 1px 0px rgba(0,0,0,0.5);
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.retro-btn.btn-accent {
|
||||
background-color: var(--retro-accent-amber);
|
||||
color: #000000;
|
||||
border-color: #fde68a #92400e #92400e #fde68a;
|
||||
}
|
||||
|
||||
/* Group Headers */
|
||||
.group-section {
|
||||
margin-bottom: 1.15rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.group-header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.45rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--retro-window-bg);
|
||||
border-top: 2px solid var(--retro-bevel-light);
|
||||
border-left: 2px solid var(--retro-bevel-light);
|
||||
border-right: 2px solid var(--retro-bevel-dark);
|
||||
border-bottom: 2px solid var(--retro-bevel-dark);
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-family: var(--font-pixel);
|
||||
font-size: clamp(0.58rem, 2.2vw, 0.72rem);
|
||||
font-weight: 700;
|
||||
color: var(--retro-text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.group-hint {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.58rem;
|
||||
color: var(--retro-text-dim);
|
||||
}
|
||||
|
||||
/* Top-Down Metric List Stack */
|
||||
.metrics-list-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.retro-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: clamp(0.52rem, 1.8vw, 0.62rem);
|
||||
font-weight: 700;
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
border-left: 2px solid var(--retro-bevel-dark);
|
||||
border-right: 2px solid var(--retro-bevel-light);
|
||||
border-bottom: 2px solid var(--retro-bevel-light);
|
||||
background: var(--retro-window-inner);
|
||||
color: var(--retro-text-main);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.retro-badge.badge-green {
|
||||
color: var(--retro-accent-green);
|
||||
border-color: #064e3b #34d399 #34d399 #064e3b;
|
||||
}
|
||||
|
||||
.retro-badge.badge-amber {
|
||||
color: var(--retro-accent-amber);
|
||||
border-color: #78350f #fbbf24 #fbbf24 #78350f;
|
||||
}
|
||||
|
||||
.retro-badge.badge-cyan {
|
||||
color: var(--retro-accent-cyan);
|
||||
border-color: #0c4a6e #38bdf8 #38bdf8 #0c4a6e;
|
||||
}
|
||||
|
||||
.retro-badge.badge-red {
|
||||
color: var(--retro-accent-red);
|
||||
border-color: #881337 #fb7185 #fb7185 #881337;
|
||||
}
|
||||
|
||||
/* Flashing Blinker for Live Status */
|
||||
.retro-blinker {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
min-width: 7px;
|
||||
background-color: var(--retro-accent-green);
|
||||
animation: retroBlink 1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes retroBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* App Footer */
|
||||
.app-footer {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 2px solid var(--retro-bevel-dark);
|
||||
font-family: var(--font-ui);
|
||||
font-size: clamp(0.52rem, 1.8vw, 0.65rem);
|
||||
color: var(--retro-text-dim);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: 0.4rem 0.4rem 2rem 0.4rem;
|
||||
}
|
||||
.group-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface CurrentWeatherMetrics {
|
||||
temperature?: float;
|
||||
apparent_temperature?: float;
|
||||
wind_chill?: float;
|
||||
dew_point?: float;
|
||||
humidex?: float;
|
||||
humidity?: float;
|
||||
pressure?: float;
|
||||
rain_status?: number;
|
||||
rain_status_label?: string;
|
||||
wind_speed?: float;
|
||||
gust_speed?: float;
|
||||
wind_direction?: float;
|
||||
illuminance_lux?: float;
|
||||
uv_index?: float;
|
||||
battery_percent?: float;
|
||||
linkquality_lqi?: float;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type float = number;
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesMetric {
|
||||
name: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
data: TimeSeriesPoint[];
|
||||
}
|
||||
|
||||
export interface WeatherHistoryResponse {
|
||||
timeframe: string;
|
||||
metrics: Record<string, TimeSeriesMetric>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { TimeSeriesPoint } from '../types/weather'
|
||||
|
||||
/**
|
||||
* Calculate dew point (°C) from temperature (°C) and relative humidity (%)
|
||||
* using the Magnus-Tetens formula.
|
||||
*/
|
||||
export function calculateDewPoint(
|
||||
temperature?: number | null,
|
||||
humidity?: number | null
|
||||
): number | null {
|
||||
if (
|
||||
temperature === undefined ||
|
||||
temperature === null ||
|
||||
humidity === undefined ||
|
||||
humidity === null ||
|
||||
humidity <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const a = 17.625
|
||||
const b = 243.04
|
||||
|
||||
try {
|
||||
const alpha = (a * temperature) / (b + temperature) + Math.log(humidity / 100)
|
||||
const dewPoint = (b * alpha) / (a - alpha)
|
||||
return Math.round(dewPoint * 10) / 10
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return thermal sensation / comfort level based on dew point (°C)
|
||||
*/
|
||||
export function getDewPointComfort(dewPoint?: number | null): {
|
||||
text: string
|
||||
color: string
|
||||
} {
|
||||
if (dewPoint === undefined || dewPoint === null) {
|
||||
return { text: 'Unknown', color: 'var(--retro-text-dim)' }
|
||||
}
|
||||
if (dewPoint < 10) {
|
||||
return { text: 'Dry / Crisp', color: 'var(--retro-text-cyan)' }
|
||||
} else if (dewPoint <= 15) {
|
||||
return { text: 'Pleasant / Fresh', color: 'var(--retro-text-green)' }
|
||||
} else if (dewPoint <= 18) {
|
||||
return { text: 'Comfortable', color: 'var(--retro-text-amber)' }
|
||||
} else if (dewPoint <= 21) {
|
||||
return { text: 'Humid / Warm', color: 'var(--retro-text-amber)' }
|
||||
} else {
|
||||
return { text: 'Muggy / Oppressive', color: 'var(--retro-text-red)' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees (0-360°) to 16-wind compass rose direction
|
||||
*/
|
||||
export function getWindDirectionCardinal(degrees?: number | null): string {
|
||||
if (degrees === undefined || degrees === null) return 'N/A'
|
||||
const compassSectors = [
|
||||
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
|
||||
]
|
||||
const val = Math.floor((degrees / 22.5) + 0.5)
|
||||
return compassSectors[val % 16]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UV Index category & retro color
|
||||
*/
|
||||
export function getUVIndexLevel(uv?: number | null): {
|
||||
label: string
|
||||
color: string
|
||||
} {
|
||||
if (uv === undefined || uv === null) {
|
||||
return { label: 'Low', color: 'var(--retro-text-green)' }
|
||||
}
|
||||
if (uv < 3) return { label: 'Low', color: 'var(--retro-text-green)' }
|
||||
if (uv < 6) return { label: 'Moderate', color: 'var(--retro-text-amber)' }
|
||||
if (uv < 8) return { label: 'High', color: 'var(--retro-text-amber)' }
|
||||
if (uv < 11) return { label: 'Very High', color: 'var(--retro-text-red)' }
|
||||
return { label: 'Extreme', color: 'var(--retro-accent-purple)' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate basic statistical properties (min, max, average, current) for a time series
|
||||
*/
|
||||
export function calculateHistoryStats(points?: TimeSeriesPoint[]): {
|
||||
min: number | null
|
||||
max: number | null
|
||||
avg: number | null
|
||||
current: number | null
|
||||
} {
|
||||
if (!points || points.length === 0) {
|
||||
return { min: null, max: null, avg: null, current: null }
|
||||
}
|
||||
|
||||
let min = points[0].value
|
||||
let max = points[0].value
|
||||
let sum = 0
|
||||
|
||||
for (const p of points) {
|
||||
if (p.value < min) min = p.value
|
||||
if (p.value > max) max = p.value
|
||||
sum += p.value
|
||||
}
|
||||
|
||||
const avg = Math.round((sum / points.length) * 10) / 10
|
||||
const current = points[points.length - 1].value
|
||||
|
||||
return {
|
||||
min: Math.round(min * 10) / 10,
|
||||
max: Math.round(max * 10) / 10,
|
||||
avg,
|
||||
current: Math.round(current * 10) / 10
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user