init
This commit is contained in:
39
client/.gitignore
vendored
Normal file
39
client/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
3
client/.vscode/extensions.json
vendored
Normal file
3
client/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
42
client/README.md
Normal file
42
client/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# client
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
1
client/env.d.ts
vendored
Normal file
1
client/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
client/package.json
Normal file
27
client/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "weather-station",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vue-tsc --build && vue build"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-vue-devtools": "^8.1.2",
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
77
client/src/App.vue
Normal file
77
client/src/App.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
|
||||
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
function toggleDark() { dark.value = !dark.value }
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #f8fafc;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--hover: #f1f5f9;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #111111;
|
||||
--border: #222222;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #64748b;
|
||||
--hover: #1a1a1a;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-wrap {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 500;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
transition: background 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="app-wrap" :class="{ dark }">
|
||||
<button class="theme-toggle" @click="toggleDark">
|
||||
{{ dark ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
<Dashboard :dark="dark" />
|
||||
</div>
|
||||
</template>
|
||||
86
client/src/assets/base.css
Normal file
86
client/src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
1
client/src/assets/logo.svg
Normal file
1
client/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
35
client/src/assets/main.css
Normal file
35
client/src/assets/main.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
99
client/src/components/CurrentWeather.vue
Normal file
99
client/src/components/CurrentWeather.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{ data: DataRow }>()
|
||||
|
||||
const temp = computed(() => props.data.env_temp_c != null ? `${(props.data.env_temp_c as number).toFixed(1)}°C` : '—')
|
||||
const feels = computed(() => props.data.env_heat_index_c != null ? `Feels ${(props.data.env_heat_index_c as number).toFixed(1)}°C` : '')
|
||||
const icon = computed(() => props.data.forecast_weather_icon as string | null)
|
||||
const label = computed(() => props.data.forecast_weather_label as string | null)
|
||||
const humidity = computed(() => props.data.env_humidity != null ? `${(props.data.env_humidity as number).toFixed(0)}%` : '—')
|
||||
const wind = computed(() => props.data.wind_speed_kmh != null ? `${(props.data.wind_speed_kmh as number).toFixed(1)} km/h` : '—')
|
||||
const uvi = computed(() => props.data.light_uvi != null ? `UV ${(props.data.light_uvi as number).toFixed(1)}` : null)
|
||||
const sunrise = computed(() => props.data.sun_sunrise ? new Date(props.data.sun_sunrise as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
|
||||
const sunset = computed(() => props.data.sun_sunset ? new Date(props.data.sun_sunset as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.temps {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.temp-main {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.temp-feel {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.weather-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
|
||||
.stat-value { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="hero">
|
||||
<div class="top">
|
||||
<img v-if="icon" :src="icon" class="weather-icon" alt="weather" />
|
||||
<div class="temps">
|
||||
<div class="temp-main">{{ temp }}</div>
|
||||
<div class="temp-feel">{{ feels }}</div>
|
||||
<div class="weather-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="stat-label">Humidity</span><span class="stat-value">{{ humidity }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Wind</span><span class="stat-value">{{ wind }}</span></div>
|
||||
<div v-if="uvi" class="stat"><span class="stat-label">UV</span><span class="stat-value">{{ uvi }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Sunrise</span><span class="stat-value">{{ sunrise }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Sunset</span><span class="stat-value">{{ sunset }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
62
client/src/components/ForecastStrip.vue
Normal file
62
client/src/components/ForecastStrip.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{ days: DataRow[] }>()
|
||||
|
||||
const items = computed(() => props.days.slice(0, 5).map(d => ({
|
||||
date: new Date(d.time as string).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
|
||||
icon: d.forecast_weather_icon as string | null,
|
||||
label: d.forecast_weather_label as string,
|
||||
high: d.env_temp_max_c != null ? `${(d.env_temp_max_c as number).toFixed(1)}°` : '—',
|
||||
low: d.env_temp_min_c != null ? `${(d.env_temp_min_c as number).toFixed(1)}°` : '—',
|
||||
rain: d.forecast_precipitation_probability != null ? `${d.forecast_precipitation_probability}%` : null,
|
||||
})))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.day {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.day-name { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; }
|
||||
.day-icon { width: 36px; height: 36px; object-fit: contain; }
|
||||
.day-label { font-size: 10px; color: var(--text-muted); text-align: center; line-height: 1.2; }
|
||||
.day-temps { display: flex; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.day-low { color: var(--text-muted); font-weight: 400; }
|
||||
.day-rain { font-size: 11px; color: #38bdf8; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="strip">
|
||||
<div v-for="item in items" :key="item.date" class="day">
|
||||
<div class="day-name">{{ item.date }}</div>
|
||||
<img v-if="item.icon" :src="item.icon" class="day-icon" :alt="item.label" />
|
||||
<div class="day-label">{{ item.label }}</div>
|
||||
<div class="day-temps">
|
||||
<span>{{ item.high }}</span>
|
||||
<span class="day-low">{{ item.low }}</span>
|
||||
</div>
|
||||
<div v-if="item.rain" class="day-rain">🌧 {{ item.rain }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
87
client/src/components/GraphModal.vue
Normal file
87
client/src/components/GraphModal.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS } from '../services/units'
|
||||
import MetricChart from './MetricChart.vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span>{{ meta?.icon }}</span>
|
||||
<span>{{ meta?.label ?? metricKey }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<MetricChart :metric-key="metricKey" :current-data="currentData" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
169
client/src/components/MapView.vue
Normal file
169
client/src/components/MapView.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
import TileLayer from 'ol/layer/Tile'
|
||||
import XYZ from 'ol/source/XYZ'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { current } from '../services/weather'
|
||||
import 'ol/ol.css'
|
||||
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
let map: Map
|
||||
|
||||
const OVERLAYS = [
|
||||
{ id: 'clouds', label: 'Clouds', icon: '☁️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/coverage/0/256/{z}/{x}/{y}/1/1_1.png` },
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png` },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨', url: (_: number) => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=demo` },
|
||||
{ id: 'temp', label: 'Temperature', icon: '🌡️', url: (_: number) => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=demo` },
|
||||
{ id: 'pressure', label: 'Pressure', icon: '📊', url: (_: number) => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=demo` },
|
||||
]
|
||||
|
||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||
const radarTs = ref(Math.floor(Date.now() / 1000))
|
||||
const overlayLayers = new Map<string, TileLayer<XYZ>>()
|
||||
let radarInterval: ReturnType<typeof setInterval>
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
return new TileLayer({
|
||||
source: new XYZ({
|
||||
url: dark
|
||||
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
|
||||
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
|
||||
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function buildOverlayLayer(id: string): TileLayer<XYZ> {
|
||||
const def = OVERLAYS.find(o => o.id === id)!
|
||||
return new TileLayer({
|
||||
source: new XYZ({ url: def.url(radarTs.value), attributions: '' }),
|
||||
opacity: 0.6,
|
||||
zIndex: 10,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleOverlay(id: string) {
|
||||
if (activeOverlays.value.has(id)) {
|
||||
activeOverlays.value.delete(id)
|
||||
const layer = overlayLayers.get(id)
|
||||
if (layer) { map.removeLayer(layer); overlayLayers.delete(id) }
|
||||
} else {
|
||||
activeOverlays.value.add(id)
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers.set(id, layer)
|
||||
map.addLayer(layer)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const lat = (current.value.gps_lat as number) || 0
|
||||
const lon = (current.value.gps_lon as number) || 0
|
||||
|
||||
map = new Map({
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark)],
|
||||
view: new View({
|
||||
center: fromLonLat([lon, lat]),
|
||||
zoom: 9,
|
||||
}),
|
||||
controls: [],
|
||||
})
|
||||
|
||||
// Add default active overlays
|
||||
for (const id of activeOverlays.value) {
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers.set(id, layer)
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
// Refresh radar every 5 min
|
||||
radarInterval = setInterval(() => {
|
||||
radarTs.value = Math.floor(Date.now() / 1000)
|
||||
const layer = overlayLayers.get('rain')
|
||||
if (layer) {
|
||||
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url(radarTs.value) }))
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(radarInterval))
|
||||
|
||||
watch(() => props.dark, (dark) => {
|
||||
const layers = map.getLayers()
|
||||
layers.setAt(0, buildBaseLayer(dark))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.map-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-el {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay-toggles {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
background: var(--surface);
|
||||
border-radius: 99px;
|
||||
padding: 6px 10px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.overlay-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background: var(--hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-el" />
|
||||
<div class="overlay-toggles">
|
||||
<button
|
||||
v-for="o in OVERLAYS"
|
||||
:key="o.id"
|
||||
class="overlay-btn"
|
||||
:class="{ active: activeOverlays.has(o.id) }"
|
||||
@click="toggleOverlay(o.id)"
|
||||
>
|
||||
{{ o.icon }} {{ o.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
76
client/src/components/MetricCard.vue
Normal file
76
client/src/components/MetricCard.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS, formatValue } from '../services/units'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{
|
||||
metricKey: string
|
||||
data: DataRow
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'click', key: string): void }>()
|
||||
|
||||
const meta = computed(() => METRICS[props.metricKey])
|
||||
const value = computed(() => formatValue(props.metricKey, props.data[props.metricKey] as number | null))
|
||||
const label = computed(() => props.data[`${props.metricKey.replace(/_[^_]+$/, '')}_label`] as string | undefined)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
user-select: none;
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
&:active { transform: scale(0.97); }
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="card" @click="emit('click', metricKey)">
|
||||
<span class="icon">{{ meta?.icon ?? '📊' }}</span>
|
||||
<div class="info">
|
||||
<div class="metric-label">{{ meta?.label ?? metricKey }}</div>
|
||||
<div class="metric-value">{{ value }}</div>
|
||||
<div v-if="label" class="metric-sub">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
160
client/src/components/MetricChart.vue
Normal file
160
client/src/components/MetricChart.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
||||
LineElement, Tooltip, Legend, Filler, type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { METRICS, formatValue } from '../services/units'
|
||||
import { fetchHistoricHourly } from '../services/weather'
|
||||
import type { DataRow } from '../services/api'
|
||||
import { api } from '../services/api'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
|
||||
|
||||
const props = defineProps<{ metricKey: string; currentData: DataRow }>()
|
||||
|
||||
const historic = ref<DataRow[]>([])
|
||||
const forecast = ref<DataRow[]>([])
|
||||
const loading = ref(true)
|
||||
const meta = computed(() => METRICS[props.metricKey])
|
||||
|
||||
onMounted(async () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getTime() - 24 * 3600000).toISOString()
|
||||
const end = new Date(now.getTime() + 48 * 3600000).toISOString()
|
||||
const [hist, fore] = await Promise.allSettled([
|
||||
fetchHistoricHourly(start, now.toISOString()),
|
||||
api.hourly(now.toISOString(), end),
|
||||
])
|
||||
if (hist.status === 'fulfilled') historic.value = hist.value.filter(r => r[props.metricKey] != null)
|
||||
if (fore.status === 'fulfilled') forecast.value = fore.value.filter(r => r[props.metricKey] != null)
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
const nowLabel = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
const color = meta.value?.color ?? '#38bdf8'
|
||||
const histLabels = historic.value.map(r => r.time as string)
|
||||
const foreLabels = forecast.value.map(r => r.time as string)
|
||||
const histVals = historic.value.map(r => r[props.metricKey] as number)
|
||||
const foreVals = forecast.value.map(r => r[props.metricKey] as number)
|
||||
const currentVal = props.currentData[props.metricKey] as number | null
|
||||
|
||||
const allLabels = [...histLabels, nowLabel.value, ...foreLabels]
|
||||
const histData = [...histVals, currentVal, ...foreVals.map(() => null)]
|
||||
const foreData = [...histVals.map(() => null), currentVal, ...foreVals]
|
||||
|
||||
return {
|
||||
labels: allLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Historic',
|
||||
data: histData,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}22`,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: 'Forecast',
|
||||
data: foreData,
|
||||
borderColor: color,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 4],
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: 'Now',
|
||||
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
|
||||
borderColor: '#ffffff88',
|
||||
backgroundColor: color,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius:8,
|
||||
borderWidth: 0,
|
||||
showLine: false,
|
||||
spanGaps: false,
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: 'var(--text-muted)',
|
||||
maxTicksLimit: 12,
|
||||
maxRotation: 0,
|
||||
callback(val, i) {
|
||||
const lbl = this.getLabelForValue(i)
|
||||
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : ''
|
||||
}
|
||||
},
|
||||
grid: { color: 'var(--border)' },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number) },
|
||||
grid: { color: 'var(--border)' },
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: {
|
||||
nowLine: {
|
||||
type: 'line',
|
||||
xMin: nowLabel.value,
|
||||
xMax: nowLabel.value,
|
||||
borderColor: '#ffffff44',
|
||||
borderWidth: 1,
|
||||
borderDash: [4, 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 260px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="loading">Loading chart…</div>
|
||||
<div v-else class="chart-wrap">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
6
client/src/main.ts
Normal file
6
client/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
16
client/src/services/api.ts
Normal file
16
client/src/services/api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const BASE = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||
|
||||
export type DataRow = Record<string, number | string | null>
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin)
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
|
||||
const res = await fetch(url.toString())
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
current: (fields?: string) => get<DataRow>('/api/data', fields ? { fields } : {}),
|
||||
hourly: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/hourly', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(fields ? { fields } : {}) }),
|
||||
daily: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/daily', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(fields ? { fields } : {}) }),
|
||||
}
|
||||
79
client/src/services/units.ts
Normal file
79
client/src/services/units.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export interface MetricMeta {
|
||||
label: string
|
||||
unit: string
|
||||
icon: string
|
||||
group: string
|
||||
precision: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export const METRICS: Record<string, MetricMeta> = {
|
||||
// Environment
|
||||
env_temp_c: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
|
||||
env_temp_f: { label: 'Temperature', unit: '°F', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
|
||||
env_humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' },
|
||||
env_dew_point_c: { label: 'Dew Point', unit: '°C', icon: '🌫️', group: 'Environment', precision: 1, color: '#818cf8' },
|
||||
env_heat_index_c: { label: 'Feels Like', unit: '°C', icon: '🤔', group: 'Environment', precision: 1, color: '#fb923c' },
|
||||
env_pressure_hpa: { label: 'Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
|
||||
env_pressure_slp: { label: 'Sea Level Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
|
||||
env_abs_humidity: { label: 'Absolute Humidity', unit: ' g/m³', icon: '💦', group: 'Environment', precision: 2, color: '#67e8f9' },
|
||||
env_vpd_kpa: { label: 'Vapour Pressure Deficit', unit: ' kPa', icon: '🌿', group: 'Environment', precision: 2, color: '#4ade80' },
|
||||
env_aqi_score: { label: 'Air Quality', unit: '/100', icon: '🏭', group: 'Environment', precision: 0, color: '#34d399' },
|
||||
env_gas_ohms: { label: 'Gas Resistance', unit: ' Ω', icon: '⚗️', group: 'Environment', precision: 0, color: '#fbbf24' },
|
||||
// Light
|
||||
light_lux: { label: 'Illuminance', unit: ' lux', icon: '☀️', group: 'Light', precision: 0, color: '#fde68a' },
|
||||
light_uvi: { label: 'UV Index', unit: '', icon: '🕶️', group: 'Light', precision: 1, color: '#f472b6' },
|
||||
light_solar_wm2: { label: 'Solar Irradiance', unit: ' W/m²', icon: '⚡', group: 'Light', precision: 1, color: '#fcd34d' },
|
||||
light_uv_dose_mj: { label: 'UV Dose Today', unit: ' mJ/cm²', icon: '📡', group: 'Light', precision: 2, color: '#f9a8d4' },
|
||||
light_burn_time_min: { label: 'Burn Time', unit: ' min', icon: '🔥', group: 'Light', precision: 0, color: '#fb7185' },
|
||||
light_cloud_pct: { label: 'Cloud Cover', unit: '%', icon: '☁️', group: 'Light', precision: 0, color: '#94a3b8' },
|
||||
light_dli: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' },
|
||||
light_visibility_km: { label: 'Visibility', unit: ' km', icon: '👁️', group: 'Light', precision: 1, color: '#7dd3fc' },
|
||||
// Wind
|
||||
wind_speed_kmh: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' },
|
||||
wind_gusts_kmh: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
|
||||
wind_direction_deg: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
|
||||
// Seismic
|
||||
seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
|
||||
seismic_ax: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_ay: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_az: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
// Compass
|
||||
compass_heading: { label: 'Compass Heading', unit: '°', icon: '🧭', group: 'Compass', precision: 1, color: '#c084fc' },
|
||||
// Ground
|
||||
ground_distance_cm: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' },
|
||||
ground_accumulation_depth_cm: { label: 'Accumulation Depth', unit: ' cm', icon: '❄️', group: 'Ground', precision: 1, color: '#bfdbfe' },
|
||||
ground_lidar_strength: { label: 'LIDAR Strength', unit: '', icon: '📡', group: 'Ground', precision: 0, color: '#6ee7b7' },
|
||||
// Lightning
|
||||
lightning_distance_km: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' },
|
||||
lightning_strikes_per_hour: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' },
|
||||
// Moon
|
||||
moon_illumination_pct: { label: 'Moon Illumination', unit: '%', icon: '🌙', group: 'Celestial', precision: 0, color: '#e2e8f0' },
|
||||
// Sun
|
||||
sun_elevation: { label: 'Solar Elevation', unit: '°', icon: '☀️', group: 'Celestial', precision: 1, color: '#fef08a' },
|
||||
// Space
|
||||
space_kp_index: { label: 'Kp Index', unit: '', icon: '🌌', group: 'Space', precision: 1, color: '#818cf8' },
|
||||
space_solar_wind_speed_kms: { label: 'Solar Wind', unit: ' km/s', icon: '☄️', group: 'Space', precision: 0, color: '#c4b5fd' },
|
||||
space_imf_bz: { label: 'IMF Bz', unit: ' nT', icon: '🧲', group: 'Space', precision: 1, color: '#a5b4fc' },
|
||||
// Marine
|
||||
wave_height: { label: 'Wave Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#38bdf8' },
|
||||
swell_wave_height: { label: 'Swell Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#7dd3fc' },
|
||||
wave_period: { label: 'Wave Period', unit: ' s', icon: '⏱️', group: 'Marine', precision: 1, color: '#bae6fd' },
|
||||
// AQ
|
||||
pm2_5: { label: 'PM2.5', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fca5a5' },
|
||||
pm10: { label: 'PM10', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fdba74' },
|
||||
ozone: { label: 'Ozone', unit: ' μg/m³', icon: '🌐', group: 'Air Quality', precision: 1, color: '#6ee7b7' },
|
||||
// Forecast
|
||||
forecast_precipitation_mm: { label: 'Precipitation', unit: ' mm', icon: '🌧️', group: 'Forecast', precision: 1, color: '#38bdf8' },
|
||||
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: number | string | null): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
if (!meta) return String(value)
|
||||
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
||||
return `${value}${meta.unit}`
|
||||
}
|
||||
|
||||
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
|
||||
28
client/src/services/weather.ts
Normal file
28
client/src/services/weather.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { api, type DataRow } from './api'
|
||||
|
||||
export const current = ref<DataRow>({})
|
||||
export const hourly = ref<DataRow[]>([])
|
||||
export const daily = ref<DataRow[]>([])
|
||||
export const loading = ref(false)
|
||||
export const lastFetch = ref<Date | null>(null)
|
||||
|
||||
export async function fetchAll() {
|
||||
loading.value = true
|
||||
const [c, h, d] = await Promise.allSettled([api.current(), api.hourly(), api.daily()])
|
||||
if (c.status === 'fulfilled') current.value = c.value
|
||||
if (h.status === 'fulfilled') hourly.value = h.value
|
||||
if (d.status === 'fulfilled') daily.value = d.value
|
||||
lastFetch.value = new Date()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
export async function fetchHistoricHourly(start: string, end: string): Promise<DataRow[]> {
|
||||
return api.hourly(start, end)
|
||||
}
|
||||
|
||||
export async function fetchHistoricDaily(start: string, end: string): Promise<DataRow[]> {
|
||||
return api.daily(start, end)
|
||||
}
|
||||
|
||||
export const isDaytime = computed(() => current.value.sun_is_day === 1)
|
||||
138
client/src/views/Dashboard.vue
Normal file
138
client/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { current, hourly, daily, fetchAll, loading } from '../services/weather'
|
||||
import { METRICS, GROUPS } from '../services/units'
|
||||
import MapView from '../components/MapView.vue'
|
||||
import CurrentWeather from '../components/CurrentWeather.vue'
|
||||
import ForecastStrip from '../components/ForecastStrip.vue'
|
||||
import MetricCard from '../components/MetricCard.vue'
|
||||
import GraphModal from '../components/GraphModal.vue'
|
||||
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
|
||||
const selectedMetric = ref<string | null>(null)
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
interval = setInterval(fetchAll, 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
|
||||
// Group all available metric keys present in current data
|
||||
const groupedMetrics = computed(() => {
|
||||
return GROUPS.map(group => ({
|
||||
group,
|
||||
keys: Object.entries(METRICS)
|
||||
.filter(([key, meta]) => meta.group === group && current.value[key] != null)
|
||||
.map(([key]) => key)
|
||||
})).filter(g => g.keys.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.map-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-col {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard { flex-direction: column; }
|
||||
.map-col { height: 45vh; padding: 8px; }
|
||||
.panel-col { width: 100%; border-left: none; border-top: 1px solid var(--border); }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div v-if="loading" class="loading-bar" />
|
||||
|
||||
<div class="map-col">
|
||||
<MapView :dark="props.dark" />
|
||||
</div>
|
||||
|
||||
<div class="panel-col">
|
||||
<CurrentWeather :data="current" />
|
||||
<ForecastStrip :days="daily" />
|
||||
|
||||
<template v-for="g in groupedMetrics" :key="g.group">
|
||||
<div class="group-label">{{ g.group }}</div>
|
||||
<div class="metric-grid">
|
||||
<MetricCard
|
||||
v-for="key in g.keys"
|
||||
:key="key"
|
||||
:metric-key="key"
|
||||
:data="current"
|
||||
@click="selectedMetric = key"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<GraphModal
|
||||
:metric-key="selectedMetric"
|
||||
:current-data="current"
|
||||
@close="selectedMetric = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
18
client/tsconfig.app.json
Normal file
18
client/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
11
client/tsconfig.json
Normal file
11
client/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
client/tsconfig.node.json
Normal file
27
client/tsconfig.node.json
Normal file
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
20
client/vite.config.ts
Normal file
20
client/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
serve: { port: 5173 },
|
||||
build: {
|
||||
outDir: '../server/public',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user