Compare commits
225 Commits
146336d1ce
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed3a43a53 | |||
| f3ca75decb | |||
| 88e6cc26f5 | |||
| f5cfd492f3 | |||
| 1494b43ed5 | |||
| 8f5e8edd2e | |||
| 54e86e550c | |||
| 09a08d94aa | |||
| 11012afbe9 | |||
| d4cdb15737 | |||
| b62f7da629 | |||
| 43848c4b35 | |||
| a1c714847d | |||
| 1038b06a46 | |||
| d5697c4e20 | |||
| 7e8ea62f64 | |||
| be40d845bd | |||
| 54c53a5822 | |||
| 0a722edcc6 | |||
| 3e44dbb8eb | |||
| fec6ee8a48 | |||
| 1451145408 | |||
| 4114a0adad | |||
| 7edd0ab8c4 | |||
| 5bd2e786c2 | |||
| bbcc62af7d | |||
| 91205d8050 | |||
| ce86fdfcc8 | |||
| 3838a73e75 | |||
| 76bd8b30c7 | |||
| 30c866b599 | |||
| 9471e904ce | |||
| 8740a1cd69 | |||
| 773d89c378 | |||
| d398cd3a04 | |||
| 3d9a8f82ce | |||
| 96a86c4d12 | |||
| 835856e74c | |||
| 34c52ffb9f | |||
| 946ccd67e1 | |||
| 2ccacf563c | |||
| 3750ece535 | |||
| 93dce28cca | |||
| 73f7908aab | |||
| effdd81114 | |||
| 6bc4d920f8 | |||
| d9fbe0568d | |||
| 404f3971c4 | |||
| 570ed1e749 | |||
| 8364918a0c | |||
| 85909e550b | |||
| 221a200e79 | |||
| bb11b92ec9 | |||
| 579eed746e | |||
| a4f13d87ab | |||
| 85b1d66c02 | |||
| d34f3cfa0e | |||
| f3356ff05c | |||
| c18a4189af | |||
| c5477d05ad | |||
| bea55ac448 | |||
| 765335369c | |||
| 3944daa3ed | |||
| e767a08bb5 | |||
| e4c3da05be | |||
| 44e648ecef | |||
| e4042f06d6 | |||
| 943a6ee085 | |||
| 225002046b | |||
| 9e7afbd98c | |||
| 62d2c28039 | |||
| 74c5e48d8a | |||
| ce332be419 | |||
| 36eda62e7f | |||
| 6eefd1b86b | |||
| 0c0ebfd20b | |||
| 74ddb37446 | |||
| 73f1453daf | |||
| aeffa30417 | |||
| 74a8018f01 | |||
| efd08cbabe | |||
| 738f13e745 | |||
| 216420ad80 | |||
| 5947b61b8f | |||
| 06be7c0b4f | |||
| 70701483f5 | |||
| 1ee386949c | |||
| 9b7b7f49a7 | |||
| 80b9b1bfcc | |||
| b87bcbabb7 | |||
| f6f8a252dc | |||
| a73c5ff95b | |||
| 14d9a43c32 | |||
| 5f462700ad | |||
| 0c2857306f | |||
| 159bf62876 | |||
| a38098e3b7 | |||
| 29ccc7aba9 | |||
| a2441fdb37 | |||
| 40b6a5488f | |||
| 1f5ae434b3 | |||
| 5c51dd52de | |||
| 1b65a8619c | |||
| f2afc09fac | |||
| 86cdaab197 | |||
| 3887654211 | |||
| 7c08d2ab60 | |||
| 5860d02434 | |||
| d3ad4e15c6 | |||
| bd073a4613 | |||
| 257232f6f1 | |||
| afb530a999 | |||
| 19d2dd2d9d | |||
| e91876b7b7 | |||
| 1d3a3be225 | |||
| 84707b2777 | |||
| b8ffc20360 | |||
| 08319ce277 | |||
| 8bece21ddb | |||
| 60f76b06d4 | |||
| d6e799e956 | |||
| ff44b7fed5 | |||
| 82c10d9278 | |||
| fdb752073f | |||
| 704424530b | |||
| 968cd1a669 | |||
| 1d018db749 | |||
| db29f0fdec | |||
| 83c2e655f6 | |||
| d20ef97cf1 | |||
| f377d0b1c8 | |||
| c783a79414 | |||
| cab3cec0e4 | |||
| fedd7ef5e2 | |||
| caf721db6e | |||
| 64bc62b511 | |||
| 5e044dfd68 | |||
| fabf536db4 | |||
| e97dffd874 | |||
| fa66a63d63 | |||
| 50ba52a6d3 | |||
| 4c36062b73 | |||
| fd534ede69 | |||
| 3620c446d0 | |||
| 1c0416c668 | |||
| 122bbab555 | |||
| a162ecf692 | |||
| 1f50ba606b | |||
| 3b355d89cf | |||
| 9e901f8266 | |||
| a5b4f7da45 | |||
| debf68d795 | |||
| d4266baf25 | |||
| 28f69ec1e4 | |||
| 694b9bad24 | |||
| 367c35461b | |||
| 56cbfb3967 | |||
| a69867406b | |||
| 5794eca9f1 | |||
| 7dfd5e6e1f | |||
| f5664fe542 | |||
| 22c391a41c | |||
| 04d5b44f87 | |||
| 5f71bd182a | |||
| 9a9fde7caa | |||
| 88f86646af | |||
| 7dca2a200b | |||
| 7418616a5b | |||
| 2c137348d8 | |||
| 8c6e7cdbcd | |||
| 1f9cdda59f | |||
| de3be07eea | |||
| 89f3f156ce | |||
| d540ab5efb | |||
| 25c6bcce7b | |||
| 9e8f1a7c12 | |||
| 9ac343d9aa | |||
| f3f1c516e0 | |||
| ab9521dd91 | |||
| 6f35c647c2 | |||
| 2476675493 | |||
| 3cf62b8e11 | |||
| 8019dabe2a | |||
| 811780125e | |||
| fa79835572 | |||
| 97cf840847 | |||
| 67c46a935e | |||
| 87d4870235 | |||
| a9f0bd6c9a | |||
| 99467ed321 | |||
| 7c27f74614 | |||
| 7e36ad5ce0 | |||
| 928ff620d2 | |||
| 3fd94626b6 | |||
| b525dd27c8 | |||
| 70372a23cf | |||
| aac3486dd3 | |||
| ec79a49736 | |||
| 6f24e74b72 | |||
| 2985a62d7d | |||
| 0c2c957dd1 | |||
| eb51cfda00 | |||
| a174864ddb | |||
| 40ec87d356 | |||
| f49d4011ac | |||
| edcb5cb262 | |||
| 36f2ea086a | |||
| d5bb7ff24f | |||
| 214cf50908 | |||
| 8fe35b820a | |||
| 4300ebc532 | |||
| 4edd856eaf | |||
| 7606564a5e | |||
| 875a654eb3 | |||
| a3beb47429 | |||
| daabb1665e | |||
| 26ad37e82b | |||
| 0e49efdb34 | |||
| a80516d864 | |||
| a2d20a3329 | |||
| 3888339089 | |||
| dabbaf7775 | |||
| 2e5227f679 | |||
| 2c8666db56 | |||
| 451809b7cc |
9
.env
@@ -1,4 +1,5 @@
|
||||
DEFAULT_LAT=
|
||||
DEFAULT_LON=
|
||||
DEFAULT_ALT=
|
||||
INFLUX_TOKEN=
|
||||
EMAIL=
|
||||
ALTITUDE=
|
||||
LATITUDE=
|
||||
LONGITUDE=
|
||||
TZ=
|
||||
|
||||
10
.gitignore
vendored
@@ -1,6 +1,14 @@
|
||||
.idea
|
||||
.env.local
|
||||
.vscode
|
||||
.env*
|
||||
|
||||
dist
|
||||
node_modules
|
||||
server/data/*
|
||||
server/public/*
|
||||
|
||||
sensors/bin
|
||||
sensors/include
|
||||
sensors/lib*
|
||||
sensors/venv
|
||||
sensors/pyvenv.cfg
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
<!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>
|
||||
<head>
|
||||
<title>Weather Station</title>
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="description" content="Local Weather & Forecasting">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Weather Station">
|
||||
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Weather Station">
|
||||
<meta property="og:description" content="Local Weather & Forecasting">
|
||||
<meta property="og:image" content="/favicon.png">
|
||||
<meta property="og:url" content="./">
|
||||
|
||||
<!-- Twitter/X card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Weather Station">
|
||||
<meta name="twitter:description" content="Local Weather & Forecasting">
|
||||
<meta name="twitter:image" content="/favicon.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
150
client/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "weather-station",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@ztimson/css-utils": "^1.3.5",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"ol-mapbox-style": "^13.4.1",
|
||||
"vue": "^3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -22,6 +25,7 @@
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-vue-devtools": "^8.1.2",
|
||||
"vue": "^3.5.38",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
@@ -269,6 +273,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -278,6 +283,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -311,6 +317,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
@@ -475,6 +482,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
@@ -561,6 +569,7 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -581,6 +590,40 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
|
||||
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -942,7 +985,6 @@
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
@@ -1266,7 +1308,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
@@ -1372,6 +1413,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
|
||||
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
@@ -1385,6 +1427,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
|
||||
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.38",
|
||||
@@ -1395,6 +1438,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
|
||||
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
@@ -1412,6 +1456,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
|
||||
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.38",
|
||||
@@ -1472,6 +1517,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
|
||||
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.38"
|
||||
@@ -1481,6 +1527,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
|
||||
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.38",
|
||||
@@ -1491,6 +1538,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
|
||||
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.38",
|
||||
@@ -1503,6 +1551,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
|
||||
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.38",
|
||||
@@ -1516,6 +1565,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
|
||||
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/tsconfig": {
|
||||
@@ -1541,13 +1591,27 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
|
||||
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reference-spec-reader": "^0.2.0",
|
||||
"unzipit": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ztimson/css-utils": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@ztimson/css-utils/-/css-utils-1.3.5.tgz",
|
||||
"integrity": "sha512-evQgelNCkol8CaD1ppWYQOWmGoku5pVz1LTx9WH0p/JtKSDjd1lEubKDsczh4qVlRJsKvyRD8f9TJcD98DuMYA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ztimson/utils": {
|
||||
"version": "0.29.5",
|
||||
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.29.5.tgz",
|
||||
"integrity": "sha512-8mUuhi//3agwrueR006emOvJu1JXxVEryJmD3nkEmK4yQ9qS24oZijdoaiLRid/6xc75j3Fk6YjmnYmjq61iPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"var-persist": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
|
||||
@@ -1758,6 +1822,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
@@ -1835,7 +1900,6 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
@@ -1849,6 +1913,7 @@
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -1881,6 +1946,7 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
@@ -1905,7 +1971,6 @@
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -1937,7 +2002,6 @@
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
|
||||
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@petamoriken/float16": "^3.9.3",
|
||||
@@ -2106,6 +2170,12 @@
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -2130,7 +2200,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
@@ -2420,11 +2489,18 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-to-css-font": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-3.2.0.tgz",
|
||||
"integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/memorystream": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
||||
@@ -2434,6 +2510,15 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -2462,6 +2547,7 @@
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2535,7 +2621,6 @@
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.0"
|
||||
@@ -2566,7 +2651,6 @@
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
|
||||
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
@@ -2581,6 +2665,22 @@
|
||||
"url": "https://opencollective.com/openlayers"
|
||||
}
|
||||
},
|
||||
"node_modules/ol-mapbox-style": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.4.1.tgz",
|
||||
"integrity": "sha512-da4FKZUXoXMzK5ADeRHAXzd3bTeiKg/Y9LBOTB6qNTP62MQm93y6ISrIXzOS9atfGgQMEHJ2pCgP272IMeZCXA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.4.1",
|
||||
"mapbox-to-css-font": "^3.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ahocevar"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||
@@ -2606,14 +2706,12 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"dev": true,
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-headers": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
@@ -2644,7 +2742,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
@@ -2664,6 +2761,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@@ -2696,6 +2794,7 @@
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2737,14 +2836,12 @@
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2757,14 +2854,12 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^3.0.0"
|
||||
@@ -2803,14 +2898,12 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
@@ -3316,6 +3409,7 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3377,6 +3471,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3398,7 +3498,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -3436,7 +3536,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
|
||||
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -3473,6 +3572,12 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/var-persist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/var-persist/-/var-persist-1.0.1.tgz",
|
||||
"integrity": "sha512-Zon+pwvEpb0dEQCVShoMQQWV1JbWi4P2knW3h2sfSZS3pLecgbFig76tMSHEECQuEQ3KfYhMXgRDDIybtHTyZw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/varint": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
||||
@@ -3683,6 +3788,7 @@
|
||||
"version": "3.5.38",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
|
||||
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.38",
|
||||
@@ -3732,7 +3838,6 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/which": {
|
||||
@@ -3772,7 +3877,6 @@
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
@@ -3786,7 +3890,6 @@
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
|
||||
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zarrita/storage": "^0.2.0",
|
||||
@@ -3797,7 +3900,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||
"dev": true,
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"build": "vue-tsc --build && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ztimson/css-utils": "^1.3.5",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"ol-mapbox-style": "^13.4.1",
|
||||
"vue": "^3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -22,9 +25,9 @@
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-vue-devtools": "^8.1.2",
|
||||
"vue": "^3.5.38",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-tsc": "^3.3.5",
|
||||
"vue": "^3.5.38"
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
|
||||
370
client/public/dark-theme.json
Normal file
@@ -0,0 +1,370 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Smooth Dark",
|
||||
"sources": {
|
||||
"osm": {
|
||||
"type": "vector",
|
||||
"tiles": ["https://tiles.zakscode.com/data/osm/{z}/{x}/{y}.pbf"],
|
||||
"maxzoom": 14
|
||||
}
|
||||
},
|
||||
"glyphs": "http://localhost:8080/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": { "background-color": "#2b2b2e" }
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "water",
|
||||
"paint": { "fill-color": "#1e2832" }
|
||||
},
|
||||
{
|
||||
"id": "landcover-grass",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "grass"],
|
||||
"paint": { "fill-color": "#2a3028", "fill-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "landcover-wood",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wood"],
|
||||
"paint": { "fill-color": "#262e24", "fill-opacity": 0.5 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-park",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "park"],
|
||||
"paint": { "fill-color": "#283028", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-cemetery",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "cemetery"],
|
||||
"paint": { "fill-color": "#2c2e2a", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-hospital",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "hospital"],
|
||||
"paint": { "fill-color": "#322828", "fill-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-school",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "school"],
|
||||
"paint": { "fill-color": "#302c28", "fill-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "building",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "building",
|
||||
"minzoom": 13,
|
||||
"paint": {
|
||||
"fill-color": "#333336",
|
||||
"fill-opacity": { "stops": [[13, 0.3], [16, 0.6]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-country",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["==", "admin_level", 2],
|
||||
"paint": {
|
||||
"line-color": "#4a4448",
|
||||
"line-width": { "stops": [[2, 0.5], [6, 1.5], [10, 2.5]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-state",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["==", "admin_level", 4],
|
||||
"minzoom": 4,
|
||||
"paint": {
|
||||
"line-color": "#3e3a3c",
|
||||
"line-width": { "stops": [[4, 0.3], [8, 1]] },
|
||||
"line-dasharray": [3, 2]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "waterway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "waterway",
|
||||
"paint": {
|
||||
"line-color": "#1e2832",
|
||||
"line-width": { "stops": [[8, 0.5], [14, 2]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tunnel",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||
"paint": {
|
||||
"line-color": "#2e2e30",
|
||||
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] },
|
||||
"line-dasharray": [3, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-motorway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "motorway"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#4a4240",
|
||||
"line-width": { "stops": [[5, 0.5], [10, 2], [14, 5], [18, 12]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-trunk",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "trunk"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#443e3c",
|
||||
"line-width": { "stops": [[6, 0.4], [10, 1.5], [14, 4], [18, 10]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-primary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "primary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#3e3a38",
|
||||
"line-width": { "stops": [[7, 0.3], [10, 1], [14, 3.5], [18, 9]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-secondary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "secondary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#383636",
|
||||
"line-width": { "stops": [[8, 0.3], [12, 1], [14, 3], [18, 7]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-tertiary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "tertiary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#353334",
|
||||
"line-width": { "stops": [[9, 0.2], [14, 2], [18, 5]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-minor",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["in", "class", "minor", "service"], ["!=", "brunnel", "tunnel"]],
|
||||
"minzoom": 12,
|
||||
"paint": {
|
||||
"line-color": "#333234",
|
||||
"line-width": { "stops": [[12, 0.3], [16, 2], [18, 4]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-path",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "path"],
|
||||
"minzoom": 14,
|
||||
"paint": {
|
||||
"line-color": "#3a3638",
|
||||
"line-width": 1,
|
||||
"line-dasharray": [4, 2]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rail",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "rail"],
|
||||
"minzoom": 10,
|
||||
"paint": {
|
||||
"line-color": "#3c3a3e",
|
||||
"line-width": { "stops": [[10, 0.5], [14, 1.5]] },
|
||||
"line-dasharray": [5, 5]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bridge",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||
"paint": {
|
||||
"line-color": "#403c3a",
|
||||
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aero-runway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "aeroway",
|
||||
"filter": ["==", "class", "runway"],
|
||||
"minzoom": 11,
|
||||
"paint": {
|
||||
"line-color": "#3a383c",
|
||||
"line-width": { "stops": [[11, 2], [16, 12]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aero-taxiway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "aeroway",
|
||||
"filter": ["==", "class", "taxiway"],
|
||||
"minzoom": 12,
|
||||
"paint": {
|
||||
"line-color": "#3a383c",
|
||||
"line-width": { "stops": [[12, 0.5], [16, 4]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-water",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "water_name",
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[3, 10], [8, 14]] },
|
||||
"text-letter-spacing": 0.1
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#4a6068",
|
||||
"text-halo-color": "#2b2b2e",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-road",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation_name",
|
||||
"minzoom": 13,
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[13, 10], [16, 13]] },
|
||||
"symbol-placement": "line",
|
||||
"text-rotation-alignment": "map"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#6a6268",
|
||||
"text-halo-color": "#2b2b2e",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-place-city",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "city"],
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[4, 11], [8, 16], [12, 20]] },
|
||||
"text-anchor": "center"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#a0969a",
|
||||
"text-halo-color": "#2b2b2e",
|
||||
"text-halo-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-place-town",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "town"],
|
||||
"minzoom": 7,
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[7, 10], [12, 15]] },
|
||||
"text-anchor": "center"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#8a8088",
|
||||
"text-halo-color": "#2b2b2e",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-place-village",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "village"],
|
||||
"minzoom": 10,
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[10, 9], [14, 13]] },
|
||||
"text-anchor": "center"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#787078",
|
||||
"text-halo-color": "#2b2b2e",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-country",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "country"],
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[2, 10], [5, 16]] },
|
||||
"text-transform": "uppercase",
|
||||
"text-letter-spacing": 0.15
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#908690",
|
||||
"text-halo-color": "#2b2b2e",
|
||||
"text-halo-width": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
BIN
client/public/favicon.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
370
client/public/light-theme.json
Normal file
@@ -0,0 +1,370 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Smooth Light",
|
||||
"sources": {
|
||||
"osm": {
|
||||
"type": "vector",
|
||||
"tiles": ["https://tiles.zakscode.com/data/osm/{z}/{x}/{y}.pbf"],
|
||||
"maxzoom": 14
|
||||
}
|
||||
},
|
||||
"glyphs": "http://localhost:8080/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": { "background-color": "#f2efe9" }
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "water",
|
||||
"paint": { "fill-color": "#c8d7d5" }
|
||||
},
|
||||
{
|
||||
"id": "landcover-grass",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "grass"],
|
||||
"paint": { "fill-color": "#e0e8d8", "fill-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "landcover-wood",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landcover",
|
||||
"filter": ["==", "class", "wood"],
|
||||
"paint": { "fill-color": "#d4dfc4", "fill-opacity": 0.5 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-park",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "park"],
|
||||
"paint": { "fill-color": "#dce8d0", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-cemetery",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "cemetery"],
|
||||
"paint": { "fill-color": "#e0e4dd", "fill-opacity": 0.7 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-hospital",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "hospital"],
|
||||
"paint": { "fill-color": "#f0e4e4", "fill-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "landuse-school",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "school"],
|
||||
"paint": { "fill-color": "#eae0d8", "fill-opacity": 0.6 }
|
||||
},
|
||||
{
|
||||
"id": "building",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "building",
|
||||
"minzoom": 13,
|
||||
"paint": {
|
||||
"fill-color": "#dfdbd7",
|
||||
"fill-opacity": { "stops": [[13, 0.3], [16, 0.6]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-country",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["==", "admin_level", 2],
|
||||
"paint": {
|
||||
"line-color": "#c4b8aa",
|
||||
"line-width": { "stops": [[2, 0.5], [6, 1.5], [10, 2.5]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "boundary-state",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "boundary",
|
||||
"filter": ["==", "admin_level", 4],
|
||||
"minzoom": 4,
|
||||
"paint": {
|
||||
"line-color": "#d0c8bc",
|
||||
"line-width": { "stops": [[4, 0.3], [8, 1]] },
|
||||
"line-dasharray": [3, 2]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "waterway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "waterway",
|
||||
"paint": {
|
||||
"line-color": "#c8d7d5",
|
||||
"line-width": { "stops": [[8, 0.5], [14, 2]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tunnel",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||
"paint": {
|
||||
"line-color": "#e8e4e0",
|
||||
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] },
|
||||
"line-dasharray": [3, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-motorway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "motorway"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#e0d0b8",
|
||||
"line-width": { "stops": [[5, 0.5], [10, 2], [14, 5], [18, 12]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-trunk",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "trunk"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#e4d8c4",
|
||||
"line-width": { "stops": [[6, 0.4], [10, 1.5], [14, 4], [18, 10]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-primary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "primary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#e8dfd4",
|
||||
"line-width": { "stops": [[7, 0.3], [10, 1], [14, 3.5], [18, 9]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-secondary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "secondary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#ece6dc",
|
||||
"line-width": { "stops": [[8, 0.3], [12, 1], [14, 3], [18, 7]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-tertiary",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "class", "tertiary"], ["!=", "brunnel", "tunnel"]],
|
||||
"paint": {
|
||||
"line-color": "#f0ebe4",
|
||||
"line-width": { "stops": [[9, 0.2], [14, 2], [18, 5]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-minor",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["in", "class", "minor", "service"], ["!=", "brunnel", "tunnel"]],
|
||||
"minzoom": 12,
|
||||
"paint": {
|
||||
"line-color": "#f0ebe4",
|
||||
"line-width": { "stops": [[12, 0.3], [16, 2], [18, 4]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "road-path",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "path"],
|
||||
"minzoom": 14,
|
||||
"paint": {
|
||||
"line-color": "#d8d0c4",
|
||||
"line-width": 1,
|
||||
"line-dasharray": [4, 2]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rail",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["==", "class", "rail"],
|
||||
"minzoom": 10,
|
||||
"paint": {
|
||||
"line-color": "#d0ccc4",
|
||||
"line-width": { "stops": [[10, 0.5], [14, 1.5]] },
|
||||
"line-dasharray": [5, 5]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bridge",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||
"paint": {
|
||||
"line-color": "#e0d8cc",
|
||||
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aero-runway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "aeroway",
|
||||
"filter": ["==", "class", "runway"],
|
||||
"minzoom": 11,
|
||||
"paint": {
|
||||
"line-color": "#d8d4cc",
|
||||
"line-width": { "stops": [[11, 2], [16, 12]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aero-taxiway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "aeroway",
|
||||
"filter": ["==", "class", "taxiway"],
|
||||
"minzoom": 12,
|
||||
"paint": {
|
||||
"line-color": "#d8d4cc",
|
||||
"line-width": { "stops": [[12, 0.5], [16, 4]] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-water",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "water_name",
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[3, 10], [8, 14]] },
|
||||
"text-letter-spacing": 0.1
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#8aacaa",
|
||||
"text-halo-color": "#f2efe9",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-road",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation_name",
|
||||
"minzoom": 13,
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[13, 10], [16, 13]] },
|
||||
"symbol-placement": "line",
|
||||
"text-rotation-alignment": "map"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#9c9284",
|
||||
"text-halo-color": "#f2efe9",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-place-city",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "city"],
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[4, 11], [8, 16], [12, 20]] },
|
||||
"text-anchor": "center"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#6b6156",
|
||||
"text-halo-color": "#f2efe9",
|
||||
"text-halo-width": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-place-town",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "town"],
|
||||
"minzoom": 7,
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[7, 10], [12, 15]] },
|
||||
"text-anchor": "center"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#7a7068",
|
||||
"text-halo-color": "#f2efe9",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-place-village",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "village"],
|
||||
"minzoom": 10,
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[10, 9], [14, 13]] },
|
||||
"text-anchor": "center"
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#8a8078",
|
||||
"text-halo-color": "#f2efe9",
|
||||
"text-halo-width": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "label-country",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"filter": ["==", "class", "country"],
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": { "stops": [[2, 10], [5, 16]] },
|
||||
"text-transform": "uppercase",
|
||||
"text-letter-spacing": 0.15
|
||||
},
|
||||
"paint": {
|
||||
"text-color": "#8a7e72",
|
||||
"text-halo-color": "#f2efe9",
|
||||
"text-halo-width": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
14
client/public/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Weather Station",
|
||||
"short_name": "Weather",
|
||||
"description": "Local Weather & Forecasting",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{ "src": "/favicon.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"categories": ["weather", "utilities"]
|
||||
}
|
||||
@@ -7,41 +7,6 @@ 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;
|
||||
@@ -52,7 +17,7 @@ body {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 500;
|
||||
z-index: 50;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,4 +1,41 @@
|
||||
@import './base.css';
|
||||
@import '@ztimson/css-utils/dist/css-utils.min.css';
|
||||
|
||||
::-webkit-scrollbar { height: 4px; width: 4px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #e5e9ec;
|
||||
--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; }
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
@@ -27,3 +64,25 @@ a,
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
16
client/src/components/AirQuality.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import {useWeather} from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const {current: d} = useWeather();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card" v-if="d">
|
||||
<div class="card-title">💨 Air Quality</div>
|
||||
|
||||
<MetricRow label="AQI" :value="d.air_quality" metric-key="air_quality" :data="d" />
|
||||
<MetricRow label="Label" :value="d.air_quality_label" />
|
||||
<MetricRow label="Resistance (Ohms)" :value="d.air_quality_ohms?.toLocaleString()" metric-key="air_quality_ohms" :data="d" />
|
||||
</div>
|
||||
</template>
|
||||
363
client/src/components/Aircraft.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const BASE_SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
||||
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
||||
}
|
||||
const BASE_ALTITUDE_UNITS = {
|
||||
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
||||
feet: { label: 'FT', convert: (v: number) => v },
|
||||
}
|
||||
const BASE_VERTICAL_UNITS = {
|
||||
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
||||
fps: { label: 'FT/S', convert: (v: number) => v },
|
||||
}
|
||||
|
||||
const photoError = ref(false);
|
||||
const UNIT_KEY = 'at_units'
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||
const prefs = ref(loadUnits())
|
||||
function saveAndSet(p: any) {
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||
}
|
||||
function cycleSpeed() { const keys = Object.keys(BASE_SPEED_UNITS); const p = loadUnits(); p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]; saveAndSet(p) }
|
||||
function cycleAltitude() { const keys = Object.keys(BASE_ALTITUDE_UNITS); const p = loadUnits(); p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]; p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'; saveAndSet(p) }
|
||||
function cycleVertical() { const keys = Object.keys(BASE_VERTICAL_UNITS); const p = loadUnits(); p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]; p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'; saveAndSet(p) }
|
||||
|
||||
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
|
||||
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
|
||||
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
|
||||
|
||||
// ── Plane data ────────────────────────────────────────────────────────────────
|
||||
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
|
||||
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
|
||||
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
|
||||
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
|
||||
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
||||
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
|
||||
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || '')
|
||||
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const header = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||
e.preventDefault()
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||
}
|
||||
function onMouseUp() { isDragging.value = false }
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||
const touch: any = e.touches[0]
|
||||
const sx = touch.clientX, sy = touch.clientY
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||
if (header.value) header.value.style.opacity = '0.8'
|
||||
}, 500)
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
const touch: any = e.touches[0]
|
||||
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onTouchEnd() {
|
||||
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
|
||||
// ── Navball ───────────────────────────────────────────────────────────────────
|
||||
const navball = computed(() => {
|
||||
const data = props.plane
|
||||
const heading = data.heading ?? data.track ?? 0
|
||||
const airspeedKnots = data.speed || data.gs || 0
|
||||
const vertRateFps = data.climb || data.baro_rate || 0
|
||||
const groundSpeedFps = airspeedKnots * 1.68781
|
||||
const pitchRad = groundSpeedFps > 0 ? Math.atan(vertRateFps / groundSpeedFps) : 0
|
||||
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
||||
|
||||
const pitchLines: string[] = []
|
||||
for (let p = -30; p <= 30; p += 5) {
|
||||
if (p === 0) continue
|
||||
const isMajor = p % 10 === 0
|
||||
const [start, end] = [85, 115]
|
||||
if (isMajor) {
|
||||
pitchLines.push(`
|
||||
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||
`)
|
||||
} else {
|
||||
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const rollTicks: string[] = []
|
||||
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
|
||||
const rad = angle * Math.PI / 180
|
||||
const a = Math.abs(angle)
|
||||
if (a === 45) {
|
||||
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
|
||||
} else {
|
||||
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
|
||||
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
|
||||
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
|
||||
const rad = offset * Math.PI / 180
|
||||
const x = 100 + 70 * Math.sin(rad)
|
||||
const y = 100 - 70 * Math.cos(rad)
|
||||
const h = Math.round((offset + 360) % 360)
|
||||
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
|
||||
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<svg width="200" height="220" viewBox="0 0 200 220">
|
||||
<defs>
|
||||
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
|
||||
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1e90ff"/>
|
||||
<stop offset="100%" style="stop-color:#4169e1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#8b4513"/>
|
||||
<stop offset="100%" style="stop-color:#654321"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
||||
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
||||
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
||||
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
||||
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
||||
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
||||
${pitchLines.join('')}
|
||||
</g>
|
||||
<g transform="translate(0,25)">${rollTicks.join('')}</g>
|
||||
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
|
||||
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
|
||||
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
|
||||
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<!-- Header (drag handle) -->
|
||||
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="ap-callsign">
|
||||
✈️
|
||||
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||
<span v-else>N/A</span>
|
||||
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||
</h3>
|
||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="ap-meta flex-r justify-between">
|
||||
<div class="flex-c">
|
||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="ap-photo-wrap">
|
||||
<img class="ap-photo" :src="BASE + '/api/adsb-image/' + plane.icao" :alt="callsign" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="ap-body">
|
||||
<div class="ap-gauges">
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Air Speed</div>
|
||||
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Altitude</div>
|
||||
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Climb</div>
|
||||
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-navball" v-html="navball" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aircraft-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.ap-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.ap-callsign {
|
||||
margin: 0 24px 0 0;
|
||||
color: #7eeee1;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
|
||||
|
||||
.ap-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
}
|
||||
|
||||
.ap-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
/* ── Photo ── */
|
||||
.ap-photo-wrap {
|
||||
max-width: 360px;
|
||||
max-height: 160px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
|
||||
.ap-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ap-gauges { flex: 0 0 110px; }
|
||||
.ap-gauge-wrap { margin-bottom: 12px; }
|
||||
|
||||
.ap-gauge-label {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ap-gauge {
|
||||
padding: 6px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border: 2px solid #0f0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.ap-gauge:hover { border-color: #0ff; }
|
||||
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
||||
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
||||
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||
</style>
|
||||
@@ -1,99 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getPressureTrend} from '@/services/units.ts';
|
||||
import {inject, onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
const props = defineProps<{ data: DataRow }>()
|
||||
const data = ref<any>();
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
|
||||
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' }) : '—')
|
||||
onMounted(async () => {
|
||||
data.value = await api.current();
|
||||
});
|
||||
</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);
|
||||
.today-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.today-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
.today-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.temps {
|
||||
flex: 1;
|
||||
.hi-lo {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
.hi { color: var(--text); }
|
||||
.lo { color: var(--text-muted); font-weight: 400; }
|
||||
}
|
||||
|
||||
.temp-main {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.temp-feel {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-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>
|
||||
<div class="today-card">
|
||||
<div class="flex-r align-items-center justify-between">
|
||||
<div v-if="!data" class="pos-rel br-2 overflow-hidden" style="width: 75px; height: 50px"><Loading/></div>
|
||||
<div v-else class="flex-c justify-center align-items-center mx-3">
|
||||
<img :src="BASE + '/api/icon/' + data.icon" class="today-icon" alt="weather" />
|
||||
<div class="today-label">{{ data.label }}</div>
|
||||
</div>
|
||||
<div class="flex-r align-items-center gap-2">
|
||||
<div class="fs-6 m-0 p-0 flex-r align-items-center">
|
||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 50px; height: 1.25em"><Loading/></div>
|
||||
<template v-else>{{ Math.round(data.temperature) }}°C</template>
|
||||
</div>
|
||||
<div class="flex-c fg-muted">
|
||||
<div class="hi">
|
||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
||||
<template v-else>↑ {{ ~~(data.temperature_max) }}°</template>
|
||||
</div>
|
||||
<div class="lo">
|
||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
||||
<template v-else>↓ {{ ~~(data.temperature_min) }}°</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('heat_index')">
|
||||
<span class="stat-label">Feels Like</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.heat_index == null">—</template>
|
||||
<template v-else>{{ ~~(data.heat_index) }}<span class="fg-muted">°C</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('humidity')">
|
||||
<span class="stat-label">Humidity</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.humidity == null">—</template>
|
||||
<template v-else>{{ Math.round(data.humidity) }}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||
<span class="stat-label">Rain</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.raining == null">—</template>
|
||||
<template v-else-if="data.raining">Raining</template>
|
||||
<template v-else>{{data.precipitation_chance}}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('clouds')">
|
||||
<span class="stat-label">Cloud Cover</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.clouds == null">—</template>
|
||||
<template v-else>{{ Math.round(data.clouds) }}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_slp')">
|
||||
<span class="stat-label">Sea Level</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.pressure_slp == null">—</template>
|
||||
<template v-else>{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ getPressureTrend(data.pressure_trend) }})</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
|
||||
<span class="stat-label">VPD KPA</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.vapor_pressure_deficit == null">—</template>
|
||||
<template v-else>{{ data.vapor_pressure_deficit?.toFixed(2) }} <span class="fg-muted">kPa</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('air_quality')">
|
||||
<span class="stat-label">Air Quality</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.air_quality == null">—</template>
|
||||
<template v-else>{{ data.air_quality }} <span class="fg-muted">/ 500</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
||||
<span class="stat-label">Gas Resistance</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.air_quality_ohms == null">—</template>
|
||||
<template v-else>{{ data.air_quality_ohms }} <span class="fg-muted">Ω</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('visibility')">
|
||||
<span class="stat-label">Visibility</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.visibility == null">—</template>
|
||||
<template v-else>{{ data.visibility?.toFixed(1) }} <span class="fg-muted">km</span></template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,62 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
const props = defineProps<{ days: DataRow[] }>()
|
||||
const days = ref<any[]>()
|
||||
const filler = [1, 2, 3, 4, 5];
|
||||
|
||||
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,
|
||||
})))
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'NE';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'E';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'SE';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'S';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'SW';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'W';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'NW';
|
||||
return 'N';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const start = new Date(), end = new Date();
|
||||
start.setDate(start.getDate() + 1);
|
||||
end.setDate(end.getDate() + 5);
|
||||
days.value = await api.daily(start, end);
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
.forecast-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
.strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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);
|
||||
flex: 0 0 100px;
|
||||
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; }
|
||||
.day-name { font-size: 10px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; text-align: center; }
|
||||
.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-humid { font-size: 11px; color: var(--text-muted); }
|
||||
.day-wind { font-size: 11px; color: var(--text-muted); }
|
||||
.day-rain { font-size: 11px; color: var(--text-muted); }
|
||||
.day-precip { font-size: 9px; color: var(--text-muted); }
|
||||
</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>
|
||||
<div class="forecast-wrap">
|
||||
<div v-if="days?.length" class="strip">
|
||||
<div v-for="d in days" class="day">
|
||||
<div class="day-name">{{ formatDate('ddd, MMM D', d.time) }}</div>
|
||||
<img :src="BASE + '/api/icon/' + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
|
||||
<div class="day-label">{{ d.label }}</div>
|
||||
<div class="day-temps">
|
||||
<span>↑{{ ~~(d.temperature_max) + '°' || '—' }}</span>
|
||||
<span class="day-low">↓{{ ~~(d.temperature_min) + '°' || '—' }}</span>
|
||||
</div>
|
||||
<div class="day-humid align-x">🌡️ {{ ~~(d.humidity) || 0 }}%</div>
|
||||
<div class="day-wind align-x">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div>
|
||||
<div class="day-rain align-x">🌧️ {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="strip">
|
||||
<div v-for="d in filler" class="day">
|
||||
<div class="br-2 overflow-hidden pos-rel my-2" style="height: 50px; width: 50px;"><Loading /></div>
|
||||
<div class="day-temps mb-2">
|
||||
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
||||
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
||||
</div>
|
||||
<div class="w-100 br-2 overflow-hidden pos-rel mb-2" style="height: 1em"><Loading/></div>
|
||||
<div class="w-100 br-2 overflow-hidden pos-rel mb-2" style="height: 1em"><Loading/></div>
|
||||
<div class="w-100 br-2 overflow-hidden pos-rel mb-2" style="height: 1em"><Loading/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,87 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS } from '../services/units'
|
||||
import MetricChart from './MetricChart.vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
import {computed, nextTick, ref, watch} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
|
||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const props = defineProps<{metricKey: string | null}>();
|
||||
const emit = defineEmits<{(e: 'close'): void}>();
|
||||
|
||||
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
||||
const mode = ref<'hourly' | 'daily'>('hourly');
|
||||
const rows = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedPoint = ref<{t: number, v: number} | null>(null);
|
||||
const W = 600, H = 200, PAD = 30;
|
||||
|
||||
const customStart = ref('');
|
||||
const customEnd = ref('');
|
||||
|
||||
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||
function toDateTimeLocal(d: Date) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
function toDateLocal(d: Date) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function setDefaults() {
|
||||
const past = new Date(), future = new Date();
|
||||
if(mode.value === 'hourly') {
|
||||
past.setHours(past.getHours() - 24, 0, 0, 0);
|
||||
future.setHours(future.getHours() + 24, 0, 0, 0);
|
||||
customStart.value = toDateTimeLocal(past);
|
||||
customEnd.value = toDateTimeLocal(future);
|
||||
} else {
|
||||
past.setDate(past.getDate() - 3);
|
||||
future.setDate(future.getDate() + 3);
|
||||
customStart.value = toDateLocal(past);
|
||||
customEnd.value = toDateLocal(future);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if(!props.metricKey) return;
|
||||
selectedPoint.value = null;
|
||||
loading.value = true;
|
||||
const past = new Date(customStart.value);
|
||||
const future = new Date(customEnd.value);
|
||||
rows.value = mode.value === 'hourly'
|
||||
? await api.hourly(past, future, `time,${props.metricKey}`)
|
||||
: await api.daily(past, future, `time,${props.metricKey}`);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch([() => props.metricKey, mode], async () => {
|
||||
setDefaults();
|
||||
await nextTick();
|
||||
fetchData();
|
||||
}, {immediate: true});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const points = computed(() => {
|
||||
if(!rows.value.length || !props.metricKey) return [];
|
||||
return rows.value.map(r => ({
|
||||
t: new Date(r.time as string).getTime(),
|
||||
v: r[props.metricKey!] as number | null,
|
||||
})).filter(p => p.v != null) as {t: number, v: number}[];
|
||||
});
|
||||
|
||||
const chart = computed(() => {
|
||||
const pts = points.value;
|
||||
if(!pts.length) return {path: {hist:'', fut:''}, currentX: 0, dot: null as any, yLabels: [] as any[], xLabels: [] as any[], dotPoints: [] as any[]};
|
||||
|
||||
const ts = pts.map(p => p.t), vs = pts.map(p => p.v);
|
||||
const minT = Math.min(...ts), maxT = Math.max(...ts);
|
||||
const rawMin = Math.min(...vs), rawMax = Math.max(...vs);
|
||||
const vpad = (rawMax - rawMin) * 0.1 || 1;
|
||||
const minV = rawMin - vpad, maxV = rawMax + vpad;
|
||||
|
||||
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
||||
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
||||
const nowT = now.getTime();
|
||||
const currentX = Math.max(PAD, Math.min(W - PAD, xOf(nowT)));
|
||||
|
||||
// hist/fut always split at nowT — never affected by selected point
|
||||
const hist = pts.filter(p => p.t <= nowT);
|
||||
const fut = pts.filter(p => p.t >= nowT);
|
||||
// stitch the join so lines meet cleanly at the now-line
|
||||
const joinPt = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||
if(!hist.length || hist.at(-1)!.t !== joinPt.t) hist.push(joinPt);
|
||||
if(!fut.length || fut[0]!.t !== joinPt.t) fut.unshift(joinPt);
|
||||
|
||||
const toPath = (arr: typeof pts) =>
|
||||
arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v).toFixed(1)}`).join(' ');
|
||||
|
||||
// selected dot — default to nearest point to now
|
||||
const activePt = selectedPoint.value
|
||||
? pts.reduce((a, b) => Math.abs(a.t - selectedPoint.value!.t) < Math.abs(b.t - selectedPoint.value!.t) ? a : b)
|
||||
: pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||
const dot = {x: xOf(activePt.t), y: yOf(activePt.v), v: activePt.v, t: activePt.t};
|
||||
|
||||
const yLabels = Array.from({length: 5}, (_, i) => {
|
||||
const v = minV + (i / 4) * (maxV - minV);
|
||||
return {y: yOf(v), label: v.toFixed(1)};
|
||||
});
|
||||
|
||||
const xLabels: {x: number, label: string}[] = [];
|
||||
const tickCount = mode.value === 'hourly' ? 9 : 7;
|
||||
for(let i = 0; i <= tickCount; i++) {
|
||||
const t = minT + (i / tickCount) * (maxT - minT);
|
||||
const d = new Date(t);
|
||||
xLabels.push({x: xOf(t), label: mode.value === 'hourly'
|
||||
? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
||||
: d.toLocaleDateString([], {month: 'short', day: 'numeric'})});
|
||||
}
|
||||
|
||||
const dotPoints = pts.map(p => ({x: xOf(p.t), y: yOf(p.v), t: p.t, v: p.v}));
|
||||
|
||||
return {path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels, dotPoints};
|
||||
});
|
||||
|
||||
function onSvgClick(e: MouseEvent) {
|
||||
const svg = e.currentTarget as SVGSVGElement;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const pts = chart.value.dotPoints;
|
||||
if(!pts.length) return;
|
||||
const nearest = pts.reduce((a, b) => Math.abs(a.x - svgX) < Math.abs(b.x - svgX) ? a : b);
|
||||
selectedPoint.value = {t: nearest.t, v: nearest.v};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%; max-width: 700px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
.title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; }
|
||||
.close-btn {
|
||||
background: none; border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; width: 32px; height: 32px; cursor: pointer; font-size: 16px;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.controls {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
button {
|
||||
font-size: 11px; padding: 4px 12px; border-radius: 6px;
|
||||
border: 1px solid var(--border); background: none; color: var(--text-muted); cursor: pointer;
|
||||
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
}
|
||||
input {
|
||||
font-size: 11px; padding: 3px 6px; border-radius: 6px;
|
||||
border: 1px solid var(--border); background: var(--bg); color: var(--text);
|
||||
}
|
||||
.apply-btn {
|
||||
font-size: 11px; padding: 4px 10px; border-radius: 6px;
|
||||
border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.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); }
|
||||
.current-val {
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
span { color: var(--accent); font-weight: 700; }
|
||||
}
|
||||
.chart-wrap { position: relative; }
|
||||
.loading-overlay {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.2); border-radius: 8px;
|
||||
.spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid var(--border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||
}
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
svg { width: 100%; overflow: visible; cursor: crosshair; }
|
||||
.tick-label { font-size: 9px; fill: var(--text-muted); }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span>{{ meta?.icon }}</span>
|
||||
<span>{{ meta?.label ?? metricKey }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<MetricChart :metric-key="metricKey" :current-data="currentData" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="title">{{ metricKey?.replace(/_/g, ' ') }}</span>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="controls gap-0 mb-2">
|
||||
<button :class="{active: mode === 'hourly'}" style="border-radius: 6px 0 0 6px" @click="mode = 'hourly'">Hourly</button>
|
||||
<button :class="{active: mode === 'daily'}" style="border-radius: 0 6px 6px 0" @click="mode = 'daily'">Daily</button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customStart"/>
|
||||
<span style="font-size:11px;color:var(--text-muted)">to</span>
|
||||
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customEnd"/>
|
||||
<button class="apply-btn" @click="fetchData">Apply</button>
|
||||
<div v-if="chart.dot" class="flex-fill flex-r justify-end align-items-center current-val">
|
||||
<span>{{ (chart.dot.v as number).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-wrap">
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none" @click="onSvgClick">
|
||||
<template v-for="tick in chart.yLabels" :key="tick.y">
|
||||
<line :x1="PAD" :y1="tick.y" :x2="W-PAD" :y2="tick.y" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>
|
||||
<text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text>
|
||||
</template>
|
||||
<template v-for="tick in chart.xLabels" :key="tick.x">
|
||||
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
|
||||
</template>
|
||||
|
||||
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||
|
||||
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
|
||||
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
|
||||
|
||||
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
|
||||
|
||||
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="5" fill="var(--accent)"/>
|
||||
</svg>
|
||||
|
||||
<div v-if="loading" class="loading-overlay"><div class="spinner"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
109
client/src/components/HourlyForecast.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
const hours = ref<any[]>();
|
||||
const open = ref(false);
|
||||
const filler = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'NE';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'E';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'SE';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'S';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'SW';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'W';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'NW';
|
||||
return 'N';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
hours.value = await api.hourly();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); }
|
||||
.chevron { font-size: 10px; color: var(--text-muted); transition: transform 0.2s; &.open { transform: rotate(180deg); } }
|
||||
.preview { display: flex; gap: 6px; align-items: center; }
|
||||
.prev-icon { width: 20px; height: 20px; object-fit: contain; }
|
||||
.prev-temp { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
}
|
||||
|
||||
.hour-list {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hour-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.hour-time { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; width: 52px; flex-shrink: 0; }
|
||||
.hour-icon { width: 26px; height: 26px; object-fit: contain; flex-shrink: 0; }
|
||||
.hour-label { font-size: 11px; color: var(--text-muted); flex: 1; }
|
||||
.hour-temp { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.hour-meta { display: flex; gap: 8px; font-size: 10px; color: var(--text-muted); }
|
||||
}
|
||||
|
||||
.filler-row {
|
||||
height: 42px;
|
||||
pos-rel: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-header" @click="open = !open">
|
||||
<span class="title">24hr Forecast</span>
|
||||
<span class="chevron" :class="{ open }">▼</span>
|
||||
</div>
|
||||
|
||||
<div v-if="open" style="max-height: 50vh; overflow-y: auto">
|
||||
<div class="hour-list">
|
||||
<template v-if="hours?.length">
|
||||
<div v-for="h in hours" class="hour-row">
|
||||
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
||||
<img :src="BASE + '/api/icon/' + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
|
||||
<span class="hour-label">{{ h.label }}</span>
|
||||
<div class="hour-meta">
|
||||
<span>🌧️ {{ (h.precipitation as any || 0).toFixed(1) }}mm</span>
|
||||
<span>🍃 {{ ~~h.wind_gusts }}kph {{ dir(h.wind_dir) }}</span>
|
||||
</div>
|
||||
<span class="hour-temp">{{ ~~h.temperature }}°</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="f in filler" class="filler-row p-2">
|
||||
<div class="br-1 pos-rel w-100 overflow-hidden" style="height: 100%">
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
43
client/src/components/Loading.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<style scoped lang="scss">
|
||||
.shimmer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.stroke {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #777;
|
||||
}
|
||||
|
||||
.dark .animate {
|
||||
animation: shimmer 3s infinite;
|
||||
background: linear-gradient(90deg, #ffffff10 25%, #ffffff30 50%, #ffffff10 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.animate {
|
||||
animation: shimmer 3s infinite;
|
||||
background: linear-gradient(to right, #e6e6e6 0%, #cccccc 50%, #e6e6e6 100%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="shimmer">
|
||||
<div class="stroke animate"></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { AirTrafficLayer } from '@/services/adsb.ts'
|
||||
import { AISLayer } from '@/services/ais.ts'
|
||||
import { api } from '@/services/api.ts'
|
||||
import { RangeLayer } from '@/services/range.ts'
|
||||
import VectorTileLayer from 'ol/layer/VectorTile'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
@@ -9,108 +14,162 @@ import XYZ from 'ol/source/XYZ'
|
||||
import Feature from 'ol/Feature'
|
||||
import Point from 'ol/geom/Point'
|
||||
import CircleGeom from 'ol/geom/Circle'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { defaults as defaultInteractions } from 'ol/interaction'
|
||||
import { fromLonLat, toLonLat } from 'ol/proj'
|
||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
||||
import { current } from '../services/weather'
|
||||
import { applyStyle } from 'ol-mapbox-style'
|
||||
import 'ol/ol.css'
|
||||
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
const OWM_KEY = import.meta.env.VITE_OWM_API_KEY || ''
|
||||
const current = ref({ latitude: 0, longitude: 0 })
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
const showOverlays = ref(false)
|
||||
const atActive = ref(true)
|
||||
|
||||
// Nautical miles to meters
|
||||
const NM = 1852
|
||||
|
||||
const showWind = ref(false)
|
||||
const windSrc = ref('')
|
||||
|
||||
const OVERLAYS = [
|
||||
{
|
||||
id: 'rain',
|
||||
label: 'Rain',
|
||||
icon: '🌧️',
|
||||
url: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png`,
|
||||
needsTs: true,
|
||||
},
|
||||
{
|
||||
id: 'clouds',
|
||||
label: 'Clouds',
|
||||
icon: '☁️',
|
||||
url: () => `https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{
|
||||
id: 'wind',
|
||||
label: 'Wind',
|
||||
icon: '💨',
|
||||
url: () => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{
|
||||
id: 'temp',
|
||||
label: 'Temp',
|
||||
icon: '🌡️',
|
||||
url: () => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{
|
||||
id: 'pressure',
|
||||
label: 'Pressure',
|
||||
icon: '📊',
|
||||
url: () => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`,
|
||||
needsTs: false,
|
||||
},
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||
]
|
||||
|
||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||
const radarTs = ref(Math.floor(Date.now() / 1000))
|
||||
const overlayLayers: {[key: string]: any} = {}
|
||||
const overlayLayers: { [key: string]: any } = {}
|
||||
|
||||
let map: Map
|
||||
let stationLayer: VectorLayer<VectorSource>
|
||||
let radarInterval: ReturnType<typeof setInterval>
|
||||
let airTraffic: AirTrafficLayer
|
||||
let aisLayer: AISLayer
|
||||
let range: RangeLayer
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
||||
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
|
||||
}
|
||||
|
||||
function syncWindSrc() {
|
||||
const view = map.getView()
|
||||
const center: any = toLonLat(view.getCenter()!)
|
||||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
|
||||
}
|
||||
|
||||
function showWindOverlay() {
|
||||
syncWindSrc()
|
||||
showWind.value = true
|
||||
map.getInteractions().forEach(i => i.setActive(false))
|
||||
}
|
||||
|
||||
function hideWindOverlay() {
|
||||
showWind.value = false
|
||||
map.getInteractions().forEach(i => i.setActive(true))
|
||||
}
|
||||
|
||||
async function fetchRadarUrl(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
||||
const data = await res.json()
|
||||
const past = data.radar?.past ?? []
|
||||
const latest = past[past.length - 1]
|
||||
if (!latest) return null
|
||||
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildRainLayer(url: string): TileLayer<XYZ> {
|
||||
return new TileLayer({
|
||||
source: new XYZ({
|
||||
url: dark
|
||||
? 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{a-d}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attributions: '© OpenStreetMap © CARTO',
|
||||
maxZoom: 19,
|
||||
url,
|
||||
maxZoom: 7,
|
||||
tileLoadFunction: (tile: any, src: string) => {
|
||||
const img = tile.getImage()
|
||||
img.onerror = () => tile.setState(3)
|
||||
img.src = src
|
||||
},
|
||||
}),
|
||||
zIndex: 0,
|
||||
opacity: 0.2,
|
||||
zIndex: 5,
|
||||
})
|
||||
}
|
||||
|
||||
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: 5,
|
||||
})
|
||||
async function refreshRain() {
|
||||
const url = await fetchRadarUrl()
|
||||
if (!url) return
|
||||
|
||||
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
|
||||
if (!activeOverlays.value.has('rain')) return
|
||||
|
||||
const layer = buildRainLayer(url)
|
||||
overlayLayers['rain'] = layer
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
function toggleAT() {
|
||||
if (atActive.value) {
|
||||
aisLayer.hide()
|
||||
airTraffic.hide()
|
||||
range.hide()
|
||||
} else {
|
||||
aisLayer.show()
|
||||
airTraffic.show()
|
||||
range.show()
|
||||
}
|
||||
atActive.value = !atActive.value
|
||||
}
|
||||
|
||||
function toggleOverlay(id: string) {
|
||||
if (id === 'wind') {
|
||||
if (activeOverlays.value.has('wind')) {
|
||||
activeOverlays.value.delete('wind')
|
||||
hideWindOverlay()
|
||||
} else {
|
||||
activeOverlays.value.add('wind')
|
||||
showWindOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (id === 'rain') {
|
||||
if (activeOverlays.value.has('rain')) {
|
||||
activeOverlays.value.delete('rain')
|
||||
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
|
||||
} else {
|
||||
activeOverlays.value.add('rain')
|
||||
refreshRain()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
const layer = new VectorTileLayer({ declutter: true })
|
||||
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json')
|
||||
return layer
|
||||
}
|
||||
|
||||
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||||
const center = fromLonLat([lon, lat])
|
||||
const source = new VectorSource()
|
||||
const ringColor = dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.18)'
|
||||
const center = fromLonLat([lon, lat])
|
||||
const source = new VectorSource()
|
||||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
|
||||
|
||||
// Rings at 50, 100, 150, 200 nm
|
||||
for (const nm of [50, 100, 150, 200]) {
|
||||
const ring = new Feature(new CircleGeom(center, nm * NM))
|
||||
ring.setStyle(new Style({
|
||||
stroke: new Stroke({ color: ringStroke, width: 1, lineDash: [6, 4] }),
|
||||
fill: new Fill({ color: 'transparent' }),
|
||||
fill: new Fill({ color: 'transparent' }),
|
||||
}))
|
||||
source.addFeature(ring)
|
||||
}
|
||||
|
||||
// Station dot
|
||||
const dot = new Feature(new Point(center))
|
||||
dot.setStyle(new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 7,
|
||||
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
|
||||
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
|
||||
stroke: new Stroke({ color: dark ? '#000000' : '#ffffff', width: 2 }),
|
||||
}),
|
||||
}))
|
||||
@@ -119,60 +178,44 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
|
||||
return new VectorLayer({ source, zIndex: 20 })
|
||||
}
|
||||
|
||||
function toggleOverlay(id: string) {
|
||||
if (activeOverlays.value.has(id)) {
|
||||
activeOverlays.value.delete(id)
|
||||
const layer = overlayLayers[id]
|
||||
if (layer) {
|
||||
map.removeLayer(layer);
|
||||
delete overlayLayers[id];
|
||||
}
|
||||
} else {
|
||||
activeOverlays.value.add(id)
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers[id] = layer
|
||||
map.addLayer(layer)
|
||||
onMounted(async () => {
|
||||
const position = await api.position()
|
||||
current.value = {
|
||||
latitude: position?.latitude || 0,
|
||||
longitude: position?.longitude || 0,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const lat = (current.value.gps_lat as number) || 0
|
||||
const lon = (current.value.gps_lon as number) || 0
|
||||
|
||||
stationLayer = buildStationLayer(lat, lon, props.dark)
|
||||
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark)
|
||||
|
||||
map = new Map({
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||
view: new View({ center: fromLonLat([lon, lat]), zoom: 8 }),
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||
interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }),
|
||||
view: new View({ center: fromLonLat([current.value.longitude, current.value.latitude]), zoom: 8, maxZoom: 13 }),
|
||||
controls: [],
|
||||
})
|
||||
|
||||
for (const id of activeOverlays.value) {
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers[id] = layer
|
||||
map.addLayer(layer)
|
||||
}
|
||||
aisLayer = new AISLayer(map)
|
||||
aisLayer.show()
|
||||
airTraffic = new AirTrafficLayer(map)
|
||||
airTraffic.show()
|
||||
range = new RangeLayer(map)
|
||||
range.show()
|
||||
|
||||
// Refresh radar every 5 min
|
||||
radarInterval = setInterval(() => {
|
||||
radarTs.value = Math.floor(Date.now() / 1000)
|
||||
const layer = overlayLayers['rain']
|
||||
if (layer) {
|
||||
const def = OVERLAYS.find(o => o.id === 'rain')!
|
||||
layer.setSource(new XYZ({ url: def.url(radarTs.value) }))
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
refreshRain()
|
||||
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(radarInterval))
|
||||
onUnmounted(() => {
|
||||
clearInterval(radarInterval)
|
||||
airTraffic.hide()
|
||||
})
|
||||
|
||||
watch(() => props.dark, dark => {
|
||||
map.getLayers().setAt(0, buildBaseLayer(dark))
|
||||
// Rebuild station layer with correct colors
|
||||
map.removeLayer(stationLayer)
|
||||
const lat = (current.value.gps_lat as number) || 0
|
||||
const lon = (current.value.gps_lon as number) || 0
|
||||
const lat = (current.value.latitude as number) || 0
|
||||
const lon = (current.value.longitude as number) || 0
|
||||
stationLayer = buildStationLayer(lat, lon, dark)
|
||||
map.addLayer(stationLayer)
|
||||
})
|
||||
@@ -192,19 +235,13 @@ watch(() => props.dark, dark => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay-toggles {
|
||||
.wind-iframe {
|
||||
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);
|
||||
backdrop-filter: blur(8px);
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.overlay-btn {
|
||||
@@ -229,15 +266,85 @@ watch(() => props.dark, dark => {
|
||||
|
||||
&:hover:not(.active) { background: var(--hover); }
|
||||
}
|
||||
|
||||
.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);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.layers-menu {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 10px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
padding: 8px 14px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.layers-dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.desktop { display: flex; }
|
||||
.mobile { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop { display: none; }
|
||||
.mobile { display: flex; }
|
||||
.overlay-toggles { bottom: 10px; }
|
||||
.layers-menu { bottom: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-el" />
|
||||
<div class="overlay-toggles">
|
||||
|
||||
<iframe
|
||||
v-if="showWind"
|
||||
class="wind-iframe"
|
||||
:src="windSrc"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
/>
|
||||
|
||||
<div class="overlay-toggles desktop">
|
||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||
✈️ Traffic
|
||||
</button>
|
||||
<button
|
||||
v-for="o in OVERLAYS"
|
||||
:key="o.id"
|
||||
v-for="o in OVERLAYS" :key="o.id"
|
||||
class="overlay-btn"
|
||||
:class="{ active: activeOverlays.has(o.id) }"
|
||||
@click="toggleOverlay(o.id)"
|
||||
@@ -245,5 +352,24 @@ watch(() => props.dark, dark => {
|
||||
{{ o.icon }} {{ o.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="layers-menu mobile">
|
||||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||||
⚙️ Layers
|
||||
</button>
|
||||
<div v-if="showOverlays" class="layers-dropdown">
|
||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||
✈️ Traffic
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,6 +27,7 @@ const label = computed(() => props.data[`${props.metricKey.replace(/_[^_]+$/, ''
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
&:active { transform: scale(0.97); }
|
||||
|
||||
@@ -1,160 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
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'
|
||||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
||||
LineElement, Tooltip, Legend, Filler, type ChartOptions
|
||||
} from 'chart.js';
|
||||
import {METRICS, formatValue} from '../services/units';
|
||||
import type {DataRow} from '../services/api';
|
||||
import {api} from '../services/api';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler);
|
||||
|
||||
const props = defineProps<{ metricKey: string; currentData: DataRow }>()
|
||||
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])
|
||||
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 start = new Date(Date.now() - 24 * 3600000);
|
||||
const end = new Date(Date.now() + 48 * 3600000);
|
||||
const hourly = await api.hourly(start, end);
|
||||
historic.value = hourly.filter((h: any) => new Date(h.time).getTime() <= Date.now());
|
||||
forecast.value = hourly.filter((h: any) => new Date(h.time).getTime() > Date.now());
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const nowLabel = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`
|
||||
})
|
||||
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 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]
|
||||
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,
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
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],
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
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%;
|
||||
position: relative;
|
||||
height: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 260px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
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>
|
||||
<div v-if="loading" class="loading">Loading chart…</div>
|
||||
<div v-else class="chart-wrap">
|
||||
<Line :data="chartData" :options="chartOptions"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
33
client/src/components/MetricRow.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import {inject} from 'vue';
|
||||
|
||||
const props = defineProps<{label: string; value: any; metricKey?: string; data?: any}>();
|
||||
const openGraph = inject<(key: string, data: any) => void>('openGraph');
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
|
||||
.label { color: var(--text-muted); }
|
||||
.value { font-weight: 600; color: var(--text); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="row" :class="{clickable: !!metricKey}" @click="metricKey && openGraph?.(metricKey, data)">
|
||||
<span class="label">{{ label }}</span>
|
||||
<span class="value">{{ value ?? '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
98
client/src/components/Moon.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {ref, watchEffect} from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const {current: d} = useWeather();
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
watchEffect(() => {
|
||||
if(!d.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const size = 64;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size / 2 - 3;
|
||||
|
||||
// Background circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#1c1c1c';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
const illumination = (d.value.moon_illumination ?? 0) / 100;
|
||||
const phase = d.value.moon_phase as string;
|
||||
const phaseMap: Record<string, number> = {
|
||||
'New Moon': 0, 'Waxing Crescent': 0.125, 'First Quarter': 0.25,
|
||||
'Waxing Gibbous': 0.375, 'Full Moon': 0.5, 'Waning Gibbous': 0.625,
|
||||
'Last Quarter': 0.75, 'Waning Crescent': 0.875,
|
||||
};
|
||||
const phaseVal = phaseMap[phase] ?? 0;
|
||||
|
||||
if(illumination > 0.01) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = '#e8e8c0';
|
||||
ctx.beginPath();
|
||||
|
||||
if(illumination >= 0.99) {
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
} else if(phaseVal < 0.5) {
|
||||
// Waxing: right side lit, growing
|
||||
ctx.arc(cx, cy, r, -Math.PI / 2, Math.PI / 2, false);
|
||||
const ellipseWidth = (phaseVal * 4 - 1) * r;
|
||||
ctx.ellipse(cx, cy, Math.abs(ellipseWidth), r, 0, Math.PI / 2, -Math.PI / 2, ellipseWidth < 0);
|
||||
} else {
|
||||
// Waning: left side lit, shrinking
|
||||
ctx.arc(cx, cy, r, Math.PI / 2, -Math.PI / 2, false);
|
||||
const ellipseWidth = (3 - phaseVal * 4) * r;
|
||||
ctx.ellipse(cx, cy, Math.abs(ellipseWidth), r, 0, -Math.PI / 2, Math.PI / 2, ellipseWidth < 0);
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">🌙 Moon</div>
|
||||
|
||||
<div class="flex-r align-items-center gap-3 p-2">
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 40px">
|
||||
<Loading />
|
||||
</div>
|
||||
<template v-else>
|
||||
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
||||
<div class="flex-c">
|
||||
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div v-if="!d" class="flex-c p-2 gap-2">
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
||||
<MetricRow label="Next New Moon" :value="formatDate('MMM D', d.moon_new)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
121
client/src/components/Precipitation.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getPressureTrend} from '@/services/units.ts';
|
||||
import {inject} from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
const {current: d} = useWeather();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.precip-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="precip-card">
|
||||
<div class="card-title">🌧️ Precipitation</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||
<span class="stat-label">Rain</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="d.raining == null">—</template>
|
||||
<template v-else-if="d.raining">Raining</template>
|
||||
<template v-else>{{d.precipitation_chance}}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_rate')">
|
||||
<span class="stat-label">Lightning</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_rate ?? '—' }}<span v-if="d.lightning_rate" class="fg-muted">/hr</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_distance')">
|
||||
<span class="stat-label">Distance</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_distance ?? '—' }} <span v-if="d.lightning_distance" class="fg-muted">km</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.pressure_hpa?.toFixed(1) ?? '—' }} <span class="fg-muted">hpa</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure (SLP)</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.pressure_slp?.toFixed(1) ?? '—' }} <span v-if="d.pressure_slp" class="fg-muted">({{getPressureTrend(d.pressure_rate)}})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('precipitation')">
|
||||
<span class="stat-label">Precipitation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.precipitation?.toFixed(1) ?? '—' }} <span v-if="d.precipitation != null" class="fg-muted">mm</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Dew Point</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.dew_point?.toFixed(1) ?? '—' }}<span v-if="d.dew_point" class="fg-muted">°C</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Frost Risk</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.frost_risk ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('accumulation')">
|
||||
<span class="stat-label">Accumulation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.accumulation?.toFixed(1) ?? '—' }} <span v-if="d.accumulation != null" class="fg-muted">cm</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
182
client/src/components/Seismic.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { api } from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const d = ref<any>(null);
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const AXES = [
|
||||
{ key: 'seismic_x', label: 'X', color: '#ccc' },
|
||||
{ key: 'seismic_y', label: 'Y', color: '#ccc' },
|
||||
{ key: 'seismic_z', label: 'Z', color: '#ccc' },
|
||||
];
|
||||
|
||||
const W = 640, BAND = 80, H = BAND * AXES.length, MAX_PTS = 120;
|
||||
const SEG_W = W / (MAX_PTS - 1);
|
||||
const SUB = 4;
|
||||
const RIGHT_PAD = 2 * SEG_W;
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
let rafId: number;
|
||||
let drawProgress = 0;
|
||||
let lastTimestamp = 0;
|
||||
const ANIM_DURATION = 1200;
|
||||
|
||||
type Pt = { x: number; y: number };
|
||||
const traces = AXES.map(() => ({
|
||||
history: [] as number[],
|
||||
prevMax: 1.0,
|
||||
buffer: [] as Pt[],
|
||||
}));
|
||||
|
||||
function buildBuffer(t: (typeof traces)[number], band: number) {
|
||||
const pts = t.history;
|
||||
if (pts.length < 2) return;
|
||||
const mid = band * BAND + BAND / 2, amp = BAND / 2 - 6;
|
||||
const newMax = Math.max(...pts, 1.0);
|
||||
t.prevMax = t.prevMax + (newMax - t.prevMax) * 0.15; // eased amplitude scale
|
||||
const tipX = W - RIGHT_PAD;
|
||||
const buf: Pt[] = [];
|
||||
|
||||
pts.forEach((v, i) => {
|
||||
const prev = pts[i - 1] ?? v;
|
||||
const prevNorm = prev === 0 ? 0 : (prev / t.prevMax) * amp;
|
||||
const norm = v === 0 ? 0 : (v / t.prevMax) * amp;
|
||||
const baseX = tipX - (pts.length - i) * SEG_W;
|
||||
const s = SEG_W / SUB;
|
||||
|
||||
// one oscillation per value, amplitude fading prevNorm → norm
|
||||
const a0 = prevNorm * 0.75 + norm * 0.25;
|
||||
const a1 = prevNorm * 0.25 + norm * 0.75;
|
||||
|
||||
buf.push({ x: baseX + s, y: mid - a0 }); // up
|
||||
buf.push({ x: baseX + s * 2, y: mid - a1 }); // up peak
|
||||
buf.push({ x: baseX + s * 3, y: mid + norm }); // down
|
||||
buf.push({ x: baseX + s * 4, y: mid }); // back to exact center ✓
|
||||
});
|
||||
|
||||
t.buffer = buf;
|
||||
}
|
||||
|
||||
function drawTrace(ctx: CanvasRenderingContext2D, t: (typeof traces)[number], color: string) {
|
||||
const buf = t.buffer;
|
||||
if (buf.length <= SUB) return;
|
||||
|
||||
// slide everything left as the new segment draws in
|
||||
const slide = (1 - drawProgress) * SEG_W;
|
||||
|
||||
const settledCount = buf.length - SUB;
|
||||
const anchor = buf[settledCount - 1];
|
||||
const animFrac = drawProgress * (SUB + 1);
|
||||
const animFloor = Math.floor(animFrac);
|
||||
const animRemainder = animFrac - animFloor;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
for (let i = 0; i < settledCount; i++) {
|
||||
const p = buf[i];
|
||||
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
||||
}
|
||||
|
||||
const animPoints = [anchor, ...buf.slice(settledCount)];
|
||||
|
||||
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
||||
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
|
||||
}
|
||||
|
||||
if (animFloor < animPoints.length - 1 && animRemainder > 0) {
|
||||
const curr = animPoints[animFloor];
|
||||
const next = animPoints[animFloor + 1];
|
||||
ctx.lineTo(
|
||||
curr.x + (next.x - curr.x) * animRemainder + slide,
|
||||
curr.y + (next.y - curr.y) * animRemainder,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function draw(timestamp: number) {
|
||||
const ctx = canvas.value?.getContext('2d');
|
||||
if (!ctx) { rafId = requestAnimationFrame(draw); return; }
|
||||
|
||||
const dt = Math.min(timestamp - lastTimestamp, 100);
|
||||
lastTimestamp = timestamp;
|
||||
drawProgress = Math.min(1, drawProgress + dt / ANIM_DURATION);
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
AXES.forEach((_, i) => {
|
||||
const mid = i * BAND + BAND / 2;
|
||||
|
||||
// Axis label — top-left of each band
|
||||
ctx.font = '16px monospace';
|
||||
ctx.fillStyle = AXES[i].color;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillText(AXES[i].label, W - 20, i * BAND + 14);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Center line per band — full width
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.moveTo(0, mid);
|
||||
ctx.lineTo(W, mid);
|
||||
ctx.stroke();
|
||||
|
||||
drawTrace(ctx, traces[i], AXES[i].color);
|
||||
});
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
||||
AXES.forEach((axis, i) => {
|
||||
const t = traces[i];
|
||||
t.history.push(d.value[axis.key] ?? 0);
|
||||
if (t.history.length > MAX_PTS) t.history.shift();
|
||||
buildBuffer(t, i);
|
||||
});
|
||||
drawProgress = 0;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await poll();
|
||||
lastTimestamp = performance.now();
|
||||
rafId = requestAnimationFrame(draw);
|
||||
interval = setInterval(poll, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval);
|
||||
cancelAnimationFrame(rafId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
canvas { width: 100%; height: 180px; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">🫨 Seismic</div>
|
||||
<div v-if="!d" class="p-2">
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden mb-2" style="height: 30px">
|
||||
<Loading />
|
||||
</div>
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 150px">
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<MetricRow label="Magnitude" :value="d.seismic_magnitude?.toFixed(1)" metric-key="seismic_magnitude" :data="d" />
|
||||
<canvas ref="canvas" :width="W" :height="H" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
356
client/src/components/Ship.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
const SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => v.toFixed(1) },
|
||||
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||
}
|
||||
const UNIT_KEY = 'ais_units'
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots' }
|
||||
}
|
||||
const prefs = ref(loadUnits())
|
||||
function cycleSpeed() {
|
||||
const keys = Object.keys(SPEED_UNITS)
|
||||
const p = loadUnits()
|
||||
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||
}
|
||||
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||
|
||||
const name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
|
||||
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0)
|
||||
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
||||
const photoError = ref(false)
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const header = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||
e.preventDefault()
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||
}
|
||||
function onMouseUp() { isDragging.value = false }
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||
const touch: any = e.touches[0]
|
||||
const sx = touch.clientX, sy = touch.clientY
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||
if (header.value) header.value.style.opacity = '0.8'
|
||||
}, 500)
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
const touch: any = e.touches[0]
|
||||
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onTouchEnd() {
|
||||
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
|
||||
const compass = computed(() => {
|
||||
const hdg = heading.value
|
||||
const course = props.boat.cog ?? hdg
|
||||
|
||||
const tapeTicks: string[] = []
|
||||
for (let offset = -60; offset <= 60; offset += 5) {
|
||||
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||
const x = 100 + (offset / 60) * 90
|
||||
const major = offset % 30 === 0
|
||||
const mid = offset % 15 === 0
|
||||
if (major) {
|
||||
const lbl = ({ 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' } as any)[deg]
|
||||
?? String(deg).padStart(3, '0')
|
||||
tapeTicks.push(`
|
||||
<line x1="${x}" y1="22" x2="${x}" y2="38" stroke="#0ff" stroke-width="2"/>
|
||||
<text x="${x}" y="50" text-anchor="middle" fill="#0ff" font-size="11" font-weight="bold">${lbl}</text>
|
||||
`)
|
||||
} else if (mid) {
|
||||
tapeTicks.push(`<line x1="${x}" y1="26" x2="${x}" y2="38" stroke="#0ff" stroke-width="1.5"/>`)
|
||||
} else {
|
||||
tapeTicks.push(`<line x1="${x}" y1="30" x2="${x}" y2="38" stroke="#0ff" stroke-width="1"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const CX = 100, CY = 140
|
||||
const R = 68
|
||||
|
||||
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
||||
const rudX = CX + (rudder / 30) * 40
|
||||
|
||||
const courseRel = ((course - hdg + 540) % 360) - 180
|
||||
const courseRad = courseRel * Math.PI / 180
|
||||
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
||||
const cNeedleY = CY - 52 * Math.cos(courseRad)
|
||||
|
||||
const bearRel = ((course - hdg + 540) % 360) - 180
|
||||
const bearRad = bearRel * Math.PI / 180
|
||||
const bNeedleX = CX + 52 * Math.sin(bearRad)
|
||||
const bNeedleY = CY - 52 * Math.cos(bearRad)
|
||||
|
||||
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
||||
const a = i * 10
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const r1 = a % 30 === 0 ? R - 11 : R - 5
|
||||
const sw = a % 30 === 0 ? 2 : 1
|
||||
const x1 = CX + r1 * Math.cos(rad)
|
||||
const y1 = CY + r1 * Math.sin(rad)
|
||||
const x2 = CX + R * Math.cos(rad)
|
||||
const y2 = CY + R * Math.sin(rad)
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
|
||||
}).join('')
|
||||
|
||||
const cardinals = (['N','E','S','W'] as const).map((lbl, i) => {
|
||||
const a = i * 90
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const x = CX + (R - 18) * Math.cos(rad)
|
||||
const y = CY + (R - 18) * Math.sin(rad) + 4
|
||||
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<svg width="200" height="210" viewBox="0 0 200 210">
|
||||
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
|
||||
<line x1="10" y1="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
|
||||
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
||||
${tapeTicks.join('')}
|
||||
<polygon points="100,20 96,12 104,12" fill="#ff0"/>
|
||||
|
||||
<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
|
||||
<circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
|
||||
|
||||
<g transform="rotate(${-hdg}, ${CX}, ${CY})">
|
||||
${ringTicks}
|
||||
${cardinals}
|
||||
</g>
|
||||
|
||||
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
|
||||
|
||||
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
|
||||
|
||||
<polygon points="${CX},${CY - 9} ${CX - 4},${CY + 5} ${CX},${CY + 2} ${CX + 4},${CY + 5}" fill="#fff" stroke="#0ff" stroke-width="1"/>
|
||||
|
||||
<rect x="80" y="${CY + 23}" width="40" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
||||
<rect x="${rudX - 4}" y="${CY + 22}" width="8" height="8" fill="#ff0" rx="1"/>
|
||||
|
||||
<line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
|
||||
<text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
|
||||
|
||||
<line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="sp-name">
|
||||
🚢
|
||||
<span>{{ name.toUpperCase() }}</span>
|
||||
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||
</h3>
|
||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="sp-photo-wrap">
|
||||
<img class="sp-photo" :src="BASE + '/api/ais-iamge/' + boat.mmsi" :alt="name" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="sp-body">
|
||||
<div class="sp-gauges">
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Speed</div>
|
||||
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Course</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.course != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Heading</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.heading != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-compass" v-html="compass"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ship-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sp-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.sp-name {
|
||||
margin: 0 24px 0 0;
|
||||
color: #7eeee1;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sp-mmsi { color: white; }
|
||||
|
||||
.sp-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
}
|
||||
|
||||
.sp-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.sp-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
/* ── Photo ── */
|
||||
.sp-photo-wrap {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sp-photo {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sp-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 0; }
|
||||
.sp-gauge-wrap { margin-bottom: 12px; }
|
||||
|
||||
.sp-gauge-label {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.sp-gauge {
|
||||
padding: 6px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border: 2px solid #0ff;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sp-gauge:hover { border-color: #0f0; }
|
||||
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
|
||||
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||
</style>
|
||||
196
client/src/components/Sun.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getUVLabel} from '@/services/units.ts';
|
||||
import {inject, computed } from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
const {current: data} = useWeather();
|
||||
|
||||
const W = 320, H = 120, HORIZON = 45;
|
||||
|
||||
const arc = computed(() => {
|
||||
if (!data.value) return { filledPath: '', remainPath: '', cx: 0, cy: 0, pct: 0 };
|
||||
|
||||
const rise = new Date(data.value.sunrise).getTime();
|
||||
const set = new Date(data.value.sunset).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const dayLen = set - rise;
|
||||
const noon = rise + dayLen / 2;
|
||||
const start = noon - 12 * 3600 * 1000;
|
||||
const end = noon + 12 * 3600 * 1000;
|
||||
|
||||
const riseT = (rise - start) / (end - start);
|
||||
const setT = (set - start) / (end - start);
|
||||
const dayPct = Math.max(0, Math.min(1, (now - rise) / dayLen));
|
||||
const sunT = riseT + dayPct * (setT - riseT);
|
||||
|
||||
// After sunset, continue tracking sun below horizon
|
||||
const totalPct = Math.max(0, Math.min(1, (now - start) / (end - start)));
|
||||
|
||||
const x1 = 0, x2 = W;
|
||||
const totalW = x2 - x1;
|
||||
const amplitude = H * 0.45;
|
||||
const midY = H / 2;
|
||||
|
||||
const sineY = (t: number) => midY - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
|
||||
const horizonY = sineY(riseT);
|
||||
|
||||
const pts = Array.from({ length: 201 }, (_, i) => {
|
||||
const t = i / 200;
|
||||
return { x: x1 + totalW * t, y: sineY(t) };
|
||||
});
|
||||
|
||||
const splitIdx = Math.round(totalPct * 200);
|
||||
const { x: cx, y: cy } = <any>pts[Math.min(splitIdx, 200)];
|
||||
|
||||
const traveledPts: any = pts.slice(0, splitIdx + 1);
|
||||
const filledPath = traveledPts.map((p: any, i: any) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
||||
+ ` L${traveledPts[traveledPts.length - 1].x.toFixed(1)},${horizonY}`
|
||||
+ ` L${x1},${horizonY} Z`;
|
||||
|
||||
const remainPts = pts.slice(splitIdx);
|
||||
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||||
|
||||
const riseX = x1 + totalW * riseT;
|
||||
const setX = x1 + totalW * setT;
|
||||
|
||||
return { filledPath, remainPath, cx, cy, pct: dayPct, riseX, setX, horizonY };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sun-card">
|
||||
<div class="card-title">🌅 Sun</div>
|
||||
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 3"><Loading /></div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Top labels -->
|
||||
<div class="sun-top-labels" v-if="data">
|
||||
<div>
|
||||
<div class="stat-label">Sunrise</div>
|
||||
<div class="stat-value">{{ new Date(data.sunrise).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="stat-label">Sunset</div>
|
||||
<div class="stat-value">{{ new Date(data.sunset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||
<!-- Filled traveled area -->
|
||||
<path v-if="arc.filledPath" :d="arc.filledPath" fill="#3b82f6" opacity="0.35" />
|
||||
<!-- Remaining curve stroke -->
|
||||
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
||||
<!-- Horizon line -->
|
||||
<line :x1="0" :y1="arc.horizonY" :x2="W" :y2="arc.horizonY" stroke="var(--border)" stroke-width="1" />
|
||||
<!-- Sun dot -->
|
||||
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Daylight</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) ?? '—' }} <span v-if="data.daylight" class="fg-muted">hrs</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lux')">
|
||||
<span class="stat-label">Brightness</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.lux ?? '—' }} <span v-if="data.lux != null" class="fg-muted">lm/m²</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('solar_wm2')">
|
||||
<span class="stat-label">Irradiance</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.solar_wm2 ?? '—' }} <span v-if="data.lux != null" class="fg-muted">W/m²</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('uv_index')">
|
||||
<span class="stat-label">UV Index</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index?.toFixed(1) ?? '—' }} <span v-if="data.uv_index != null" class="fg-muted">({{ getUVLabel(data.uv_index) }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_dose')">
|
||||
<span class="stat-label">UV Dose</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) ?? '—' }} <span v-if="data.uv_dose != null" class="fg-muted">J/m²</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_index_max')">
|
||||
<span class="stat-label">UV Max</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index_max ?? '—' }} <span v-if="data.uv_index_max != null" class="fg-muted">({{getUVLabel(data.uv_index_max)}})</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sun-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sun-top-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: visible;
|
||||
}
|
||||
.sun-dot {
|
||||
filter: drop-shadow(0 0 5px #f59e0b88);
|
||||
}
|
||||
</style>
|
||||
92
client/src/components/Wind.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {inject} from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
const {current: d} = useWeather();
|
||||
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'North-East';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'East';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'South-East';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'South';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'South-West';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'West';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'North-West';
|
||||
return 'North';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wind-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="wind-card">
|
||||
<div class="card-title">🍃 Wind</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('wind_speed')">
|
||||
<span class="stat-label">Speed</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ d.wind_speed != null ? ~~(d.wind_speed) : '—' }} <span v-if="d.wind_speed != null" class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('wind_gusts')">
|
||||
<span class="stat-label">Gusts</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ d.wind_gusts != null ? ~~(d.wind_gusts) : '—' }} <span v-if="d.wind_gusts != null" class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Direction</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ (d.wind_speed || d.wind_gusts) ? dir(d.wind_direction) : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
224
client/src/services/adsb.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { BASE } from '@/services/api.ts'
|
||||
import { createApp, ref } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import Point from 'ol/geom/Point'
|
||||
import LineString from 'ol/geom/LineString'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Icon, Stroke } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
import { adjustedInterval } from '@ztimson/utils'
|
||||
import AircraftPopup from '@/components/Aircraft.vue'
|
||||
import { bringToFront } from './zindex'
|
||||
|
||||
const API = BASE + '/api'
|
||||
|
||||
function getAltColor(alt: number): [number, number, number] {
|
||||
const a = Math.max(0, alt)
|
||||
if (a < 10) return [0, 0, 0]
|
||||
if (a < 10000) return [135, 206, 250]
|
||||
if (a < 25000) {
|
||||
const r = (a - 10000) / 15000
|
||||
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
|
||||
}
|
||||
if (a < 40000) {
|
||||
const r = (a - 25000) / 15000
|
||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
||||
}
|
||||
const r = Math.min((a - 40000) / 10000, 1)
|
||||
return [Math.round(128 * r), 0, 139]
|
||||
}
|
||||
|
||||
interface PopupInstance {
|
||||
planeRef: ReturnType<typeof ref>
|
||||
posRef: ReturnType<typeof ref>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
export class AirTrafficLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
||||
private traceSegs: Record<string, Feature[]> = {}
|
||||
private popups: any = {}
|
||||
private historyCache: any = {}
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = null
|
||||
visible = false
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 1_000)
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
||||
this.map.removeLayer(this.layer)
|
||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
||||
this.traceLayers = {}
|
||||
this.traceSegs = {}
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
||||
this.data = (j || []).map((a: any) => ({
|
||||
...a,
|
||||
icao: a.hex,
|
||||
latitude: a.lat,
|
||||
longitude: a.lon,
|
||||
heading: a.track,
|
||||
speed: a.gs,
|
||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||
name: a.flight?.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
source.clear()
|
||||
for (const plane of this.data) {
|
||||
if (plane.latitude == null) continue
|
||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('icao', plane.icao)
|
||||
f.set('planeData', plane)
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||
scale: 1.25,
|
||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}))
|
||||
source.addFeature(f)
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchTrace(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (!this.historyCache[icao]) {
|
||||
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
|
||||
this.historyCache[icao] = j.history || []
|
||||
}
|
||||
const history = [...this.historyCache[icao]]
|
||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
||||
const last = history[history.length - 1]
|
||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
||||
if (history.length < 2) return
|
||||
|
||||
let traceLayer = this.traceLayers[icao]
|
||||
let vs: VectorSource
|
||||
|
||||
if (!traceLayer) {
|
||||
vs = new VectorSource()
|
||||
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
|
||||
this.map.addLayer(traceLayer)
|
||||
this.traceLayers[icao] = traceLayer
|
||||
this.traceSegs[icao] = []
|
||||
} else {
|
||||
vs = traceLayer.getSource()!
|
||||
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
|
||||
}
|
||||
|
||||
const segs: Feature[] = []
|
||||
for (let i = 0; i < history.length - 1; i++) {
|
||||
const s = history[i], e = history[i + 1]
|
||||
const sc = getAltColor(s.altitude || 0)
|
||||
const ec = getAltColor(e.altitude || 0)
|
||||
const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2))
|
||||
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
||||
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
||||
vs.addFeature(f)
|
||||
segs.push(f)
|
||||
}
|
||||
this.traceSegs[icao] = segs
|
||||
}
|
||||
|
||||
private _calcPopupPos(plane: any): { x: number; y: number } {
|
||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
||||
if (!pixel) return { x: 10, y: 60 }
|
||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
||||
}
|
||||
|
||||
private _openPopup(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (this.popups[icao]) return
|
||||
|
||||
const planeRef = ref(plane)
|
||||
const posRef = ref(this._calcPopupPos(plane))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(AircraftPopup, {
|
||||
plane: planeRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(icao),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
|
||||
this.popups[icao] = {
|
||||
planeRef,
|
||||
posRef,
|
||||
unmount: () => { app.unmount(); container.remove() },
|
||||
}
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
|
||||
private _closePopup(icao: string) {
|
||||
const popup = this.popups[icao]
|
||||
if (popup) { popup.unmount(); delete this.popups[icao] }
|
||||
const segs = this.traceSegs[icao]
|
||||
const layer = this.traceLayers[icao]
|
||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
||||
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
|
||||
delete this.traceSegs[icao]
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const icao of Object.keys(this.popups)) {
|
||||
const plane = this.data.find(p => p.icao === icao)
|
||||
if (!plane) { this._closePopup(icao); continue }
|
||||
this.popups[icao].planeRef.value = plane
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = (evt) => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
|
||||
if (!f) return
|
||||
const plane = f.get('planeData')
|
||||
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
|
||||
this._openPopup(plane)
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
}
|
||||
}
|
||||
173
client/src/services/ais.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { BASE } from '@/services/api.ts'
|
||||
import { createApp, ref } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import Point from 'ol/geom/Point'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Icon } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
import { adjustedInterval } from '@ztimson/utils'
|
||||
import ShipPopup from '@/components/Ship.vue'
|
||||
import { bringToFront } from './zindex'
|
||||
|
||||
const API = BASE + '/api'
|
||||
|
||||
const SHIP_COLORS: Record<string, string> = {
|
||||
'Base Station': '#ffffff',
|
||||
'Class A': '#00ff00',
|
||||
'Class B': '#8450ea',
|
||||
'SAR Aircraft': '#ff0000',
|
||||
'AtoN': '#ffffff',
|
||||
'Class B/CS': '#8450ea',
|
||||
'Sart/Epirb/MOB': '#00aaff',
|
||||
'Unknown': '#fad106',
|
||||
}
|
||||
|
||||
function buildBoatIcon(boat: any): string {
|
||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
||||
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<polygon points="16,2 30,16 16,30 2,16" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||
<circle cx="16" cy="9" r="1.8" fill="#000"/>
|
||||
<line x1="16" y1="9" x2="16" y2="23" stroke="#000" stroke-width="1.8"/>
|
||||
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
|
||||
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
|
||||
</svg>
|
||||
`)}`
|
||||
}
|
||||
return `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
||||
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#000"/>
|
||||
<line x1="12" y1="12" x2="12" y2="22" stroke="#000" stroke-width="1.5"/>
|
||||
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
||||
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
|
||||
</svg>
|
||||
`)}`
|
||||
}
|
||||
|
||||
export class AISLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private popups: any = {}
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = null
|
||||
visible = false
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
||||
this.map.removeLayer(this.layer)
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
source.clear()
|
||||
for (const boat of this.data) {
|
||||
if (boat.lat == null || boat.lon == null) continue
|
||||
const coord = fromLonLat([boat.lon, boat.lat])
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('mmsi', boat.mmsi)
|
||||
f.set('boatData', boat)
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: buildBoatIcon(boat),
|
||||
scale: 0.8,
|
||||
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}))
|
||||
source.addFeature(f)
|
||||
}
|
||||
}
|
||||
|
||||
private _calcPopupPos(boat: any): { x: number; y: number } {
|
||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
||||
if (!pixel) return { x: 10, y: 60 }
|
||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
||||
}
|
||||
|
||||
private _openPopup(boat: any) {
|
||||
const mmsi = String(boat.mmsi)
|
||||
if (this.popups[mmsi]) return
|
||||
|
||||
const boatRef = ref(boat)
|
||||
const posRef = ref(this._calcPopupPos(boat))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(ShipPopup, {
|
||||
boat: boatRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(mmsi),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
this.popups[mmsi] = {
|
||||
boatRef,
|
||||
posRef,
|
||||
unmount: () => { app.unmount(); container.remove() },
|
||||
}
|
||||
}
|
||||
|
||||
private _closePopup(mmsi: string) {
|
||||
const popup = this.popups[mmsi]
|
||||
if (popup) { popup.unmount(); delete this.popups[mmsi] }
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const mmsi of Object.keys(this.popups)) {
|
||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
||||
if (!boat) { this._closePopup(mmsi); continue }
|
||||
this.popups[mmsi].boatRef.value = boat
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = (evt) => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null)
|
||||
if (!f) return
|
||||
const boat = f.get('boatData')
|
||||
const mmsi = String(boat.mmsi)
|
||||
if (this.popups[mmsi]) { this._closePopup(mmsi); return }
|
||||
this._openPopup(boat)
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,60 @@
|
||||
const BASE = import.meta.env.DEV ? 'http://localhost:3000' : ''
|
||||
import {ref, onUnmounted} from 'vue';
|
||||
|
||||
export type DataRow = Record<string, number | string | null>
|
||||
|
||||
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
||||
|
||||
const cache: any = {};
|
||||
const current = ref<any>(null);
|
||||
const hourly = ref<any[]>([]);
|
||||
const daily = ref<any[]>([]);
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
let refCount = 0;
|
||||
|
||||
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()
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
const key = url.toString();
|
||||
if(cache[key]) return cache[key];
|
||||
cache[key] = fetch(url.toString())
|
||||
.then(resp => resp.json())
|
||||
.finally(() => delete cache[key]);
|
||||
return cache[key];
|
||||
}
|
||||
|
||||
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 } : {}) }),
|
||||
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? {fields} : {}),
|
||||
current: (fields?: string) => get<DataRow>('/api/current', fields ? {fields} : {}),
|
||||
hourly: (start?: Date | string, end?: Date | string, fields?: string) => get<DataRow[]>('/api/hourly', {
|
||||
...(start ? {start: typeof start == 'string' ? start : start.toISOString()} : {}),
|
||||
...(end ? {end: typeof end == 'string' ? end : end.toISOString()} : {}),
|
||||
...(fields ? {fields} : {})
|
||||
}),
|
||||
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
||||
...(start ? {start: typeof start == 'string' ? start : start.toISOString()} : {}),
|
||||
...(end ? {end: typeof end == 'string' ? end : end.toISOString()} : {}),
|
||||
...(fields ? {fields} : {})
|
||||
}),
|
||||
};
|
||||
|
||||
async function refresh() {
|
||||
const start = new Date(), end = new Date();
|
||||
start.setDate(start.getDate() + 1);
|
||||
end.setDate(end.getDate() + 5);
|
||||
current.value = await api.current();
|
||||
return {current}
|
||||
}
|
||||
|
||||
export function useWeather(ms = 5 * 60_000) {
|
||||
if(++refCount === 1) {
|
||||
refresh();
|
||||
interval = setInterval(refresh, ms);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if(--refCount === 0) clearInterval(interval);
|
||||
});
|
||||
|
||||
return {current, hourly, daily, refresh};
|
||||
}
|
||||
|
||||
46
client/src/services/range.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import { Polygon } from 'ol/geom'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Stroke } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
|
||||
export class RangeLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
visible = false
|
||||
interval: any;
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if(this.visible) this.hide(false);
|
||||
this.visible = true
|
||||
|
||||
const points: [number, number][] = await fetch(`${BASE}/api/range`).then(r => r.json())
|
||||
const coords = points.map(([lat, lon]: number[]) => fromLonLat([<number>lon, <number>lat]))
|
||||
coords.push(<any>coords[0]) // close the polygon
|
||||
|
||||
const feature = new Feature({ geometry: new Polygon([coords]) })
|
||||
feature.setStyle(new Style({
|
||||
stroke: new Stroke({ color: 'rgb(0 171 138 / 0.53)', width: 1.5 })
|
||||
}))
|
||||
|
||||
const source = new VectorSource({ features: [feature] })
|
||||
this.layer = new VectorLayer({ source, zIndex: 50 })
|
||||
this.map.addLayer(this.layer)
|
||||
if(!this.interval) this.interval = setInterval(() => this.show, 60_000);
|
||||
}
|
||||
|
||||
hide(stop = true) {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
this.map.removeLayer(this.layer)
|
||||
if(stop && this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,46 +9,45 @@ export interface MetricMeta {
|
||||
|
||||
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' },
|
||||
temperature: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
|
||||
humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' },
|
||||
dew_point: { label: 'Dew Point', unit: '°C', icon: '🌫️', group: 'Environment', precision: 1, color: '#818cf8' },
|
||||
heat_index: { label: 'Feels Like', unit: '°C', icon: '🤔', group: 'Environment', precision: 1, color: '#fb923c' },
|
||||
pressure_hpa: { label: 'Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
|
||||
pressure_slp: { label: 'Sea Level Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
|
||||
humidity_abs: { label: 'Absolute Humidity', unit: ' g/m³', icon: '💦', group: 'Environment', precision: 2, color: '#67e8f9' },
|
||||
vapor_pressure_deficit: { label: 'Vapour Pressure Deficit', unit: ' kPa', icon: '🌿', group: 'Environment', precision: 2, color: '#4ade80' },
|
||||
air_quality: { label: 'Air Quality', unit: '/100', icon: '🏭', group: 'Environment', precision: 0, color: '#34d399' },
|
||||
air_quality_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' },
|
||||
lux: { label: 'Illuminance', unit: ' lux', icon: '☀️', group: 'Light', precision: 0, color: '#fde68a' },
|
||||
uv_index: { label: 'UV Index', unit: '', icon: '🕶️', group: 'Light', precision: 1, color: '#f472b6' },
|
||||
solar_wm2: { label: 'Solar Irradiance', unit: ' W/m²', icon: '⚡', group: 'Light', precision: 1, color: '#fcd34d' },
|
||||
uv_dose: { 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' },
|
||||
clouds: { label: 'Cloud Cover', unit: '%', icon: '☁️', group: 'Light', precision: 0, color: '#94a3b8' },
|
||||
day_light_integral: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' },
|
||||
visibility: { 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' },
|
||||
wind_speed: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' },
|
||||
wind_gusts: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
|
||||
wind_direction: { 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' },
|
||||
seismic_x: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_y: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_z: { 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' },
|
||||
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' },
|
||||
ground_distance: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' },
|
||||
accumulation: { label: 'Accumulation Depth', unit: ' cm', icon: '❄️', group: 'Ground', precision: 1, color: '#bfdbfe' },
|
||||
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' },
|
||||
lightning_distance: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' },
|
||||
lightning_rate: { 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' },
|
||||
moon_illumination: { 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
|
||||
@@ -58,16 +57,25 @@ export const METRICS: Record<string, MetricMeta> = {
|
||||
// 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 getPressureTrend(delta: number) {
|
||||
if (delta > 0.5) return 'Rising';
|
||||
if (delta < -0.5) return 'Falling';
|
||||
return 'Stable';
|
||||
}
|
||||
|
||||
export function getUVLabel(uv: number) {
|
||||
if (uv < 3) return 'Low';
|
||||
if (uv < 6) return 'Moderate';
|
||||
if (uv < 8) return 'High';
|
||||
if (uv < 11) return 'Very High';
|
||||
return 'Extreme';
|
||||
}
|
||||
|
||||
export function formatValue(key: string, value: number | string | null): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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)
|
||||
5
client/src/services/zindex.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/services/zIndex.ts
|
||||
export let highestZIndex = 1000
|
||||
export function bringToFront(el: HTMLElement) {
|
||||
el.style.zIndex = String(++highestZIndex)
|
||||
}
|
||||
@@ -1,34 +1,39 @@
|
||||
<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'
|
||||
`<script setup lang="ts">
|
||||
import GraphModal from '@/components/GraphModal.vue';
|
||||
import HourlyForecast from '@/components/HourlyForecast.vue';
|
||||
import Moon from '@/components/Moon.vue';
|
||||
import Precipitation from '@/components/Precipitation.vue';
|
||||
import Seismic from '@/components/Seismic.vue';
|
||||
import Sun from '@/components/Sun.vue';
|
||||
import Wind from '@/components/Wind.vue';
|
||||
import {BASE, useWeather} from '@/services/api.ts';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
||||
import MapView from '../components/MapView.vue';
|
||||
import CurrentWeather from '../components/CurrentWeather.vue';
|
||||
import ForecastStrip from '../components/ForecastStrip.vue';
|
||||
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const day = ref(formatDate('dddd'));
|
||||
const date = ref(formatDate('MMMM D'));
|
||||
const time = ref(formatDate('h:mm A'));
|
||||
const props = defineProps<{dark: boolean}>();
|
||||
const selectedMetric = ref<string | null>(null);
|
||||
let clock: ReturnType<typeof setInterval>;
|
||||
|
||||
const selectedMetric = ref<string | null>(null)
|
||||
let interval: ReturnType<typeof setInterval>
|
||||
provide('openGraph', (key: string) => selectedMetric.value = key);
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
interval = setInterval(fetchAll, 60 * 1000)
|
||||
})
|
||||
useWeather();
|
||||
clock = setInterval(() => {
|
||||
day.value = formatDate('dddd');
|
||||
date.value = formatDate('MMMM D');
|
||||
time.value = formatDate('h:mm A');
|
||||
}, 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)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(clock);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -50,16 +55,18 @@ const groupedMetrics = computed(() => {
|
||||
|
||||
.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; }
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.group-label {
|
||||
@@ -74,107 +81,106 @@ const groupedMetrics = computed(() => {
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
> * {
|
||||
flex: 1 1 140px; // grow, shrink, min width before wrapping
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; // ← added
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.map-col {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100vw;
|
||||
min-height: 100vw;
|
||||
padding: 8px;
|
||||
z-index: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-col {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
border-radius: 16px 16px 0 0;
|
||||
|
||||
/* Show a peek of the panel, scrolls up over the map */
|
||||
max-height: calc(100vh - 120px);
|
||||
min-height: 40vh;
|
||||
overflow-y: auto;
|
||||
|
||||
background: var(--bg);
|
||||
padding: 12px 16px 24px;
|
||||
|
||||
/* Smooth momentum scroll on iOS */
|
||||
overflow-y: visible;
|
||||
overflow-x: hidden; // ← added
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-sizing: border-box; // ← added
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div v-if="loading" class="loading-bar" />
|
||||
<div class="dashboard">
|
||||
<div class="map-col">
|
||||
<MapView :dark="props.dark"/>
|
||||
</div>
|
||||
|
||||
<div class="map-col">
|
||||
<MapView :dark="props.dark" />
|
||||
</div>
|
||||
<div class="panel-col">
|
||||
<div class="flex-r align-items-center gap-3 mb-3">
|
||||
<div class="pe-3" style="border-right: 2px solid var(--border)">
|
||||
<h2 class="fs-6 m-0" style="line-height: 1em">{{time}}</h2>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<h1 class="m-0 fs-2" style="line-height: 1em">{{day}}</h1>
|
||||
<h2 class="m-0 fs-1 fg-muted" style="line-height: 1em">{{date}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<CurrentWeather class="mb-3" />
|
||||
<HourlyForecast class="mb-3" />
|
||||
<ForecastStrip class="mb-3" />
|
||||
|
||||
<div class="panel-col">
|
||||
<CurrentWeather :data="current" />
|
||||
<ForecastStrip :days="daily" />
|
||||
<Seismic class="mb-3" />
|
||||
<Precipitation class="mb-3" />
|
||||
<Wind class="mb-3" />
|
||||
<Sun class="mb-3" />
|
||||
<Moon class="mb-3" />
|
||||
|
||||
<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>
|
||||
<div class="align-x">
|
||||
<a :href="BASE + '/docs'" style="font-size: 0.75em;" target="_blank">OpenAPI Docs</a> | <a href="https://zakscode.com" style="font-size: 0.75em;" target="_blank">ZaksCode</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphModal
|
||||
:metric-key="selectedMetric"
|
||||
:current-data="current"
|
||||
@close="selectedMetric = null"
|
||||
/>
|
||||
</div>
|
||||
<GraphModal :metric-key="selectedMetric" @close="selectedMetric = null" />
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
|
||||
322
sensors/debug.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import time
|
||||
import math
|
||||
import board
|
||||
import smbus2
|
||||
import serial
|
||||
import pynmea2
|
||||
import bme680
|
||||
import adafruit_ltr390
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
bus = smbus2.SMBus(1)
|
||||
i2c = board.I2C()
|
||||
|
||||
def try_read(fn):
|
||||
try:
|
||||
return fn()
|
||||
except:
|
||||
return None
|
||||
|
||||
# ── BME680 (0x77) ────────────────────────────────────────────────────────────
|
||||
|
||||
GAS_REFERENCE = 250000
|
||||
|
||||
def gas_to_aqi(gas_ohms, humidity):
|
||||
if gas_ohms is None:
|
||||
return None
|
||||
gas_score = min(gas_ohms / GAS_REFERENCE, 1.0) * 75
|
||||
hum_score = (25 - abs(humidity - 40) * 0.5)
|
||||
aqi = min(max(round(gas_score + hum_score), 0), 100)
|
||||
if aqi >= 80: label = "Excellent"
|
||||
elif aqi >= 60: label = "Good"
|
||||
elif aqi >= 40: label = "Fair"
|
||||
elif aqi >= 20: label = "Poor"
|
||||
else: label = "Very Poor"
|
||||
return {'score': aqi, 'label': label}
|
||||
|
||||
class BME680:
|
||||
def __init__(self):
|
||||
self._cache = None
|
||||
self.sensor = bme680.BME680(bme680.I2C_ADDR_SECONDARY)
|
||||
self.sensor.set_humidity_oversample(bme680.OS_2X)
|
||||
self.sensor.set_pressure_oversample(bme680.OS_4X)
|
||||
self.sensor.set_temperature_oversample(bme680.OS_8X)
|
||||
self.sensor.set_filter(bme680.FILTER_SIZE_3)
|
||||
self.sensor.set_gas_status(bme680.ENABLE_GAS_MEAS)
|
||||
self.sensor.set_gas_heater_temperature(320)
|
||||
self.sensor.set_gas_heater_duration(150)
|
||||
self.sensor.select_gas_heater_profile(0)
|
||||
|
||||
def read(self):
|
||||
if self.sensor.get_sensor_data():
|
||||
dew_point = self.sensor.data.temperature - ((100 - self.sensor.data.humidity) / 5.0)
|
||||
gas_ohms = round(self.sensor.data.gas_resistance, 0) if self.sensor.data.heat_stable else None
|
||||
self._cache = {
|
||||
'temp_c': round(self.sensor.data.temperature, 2),
|
||||
'temp_f': round(self.sensor.data.temperature * 9/5 + 32, 2),
|
||||
'humidity': round(self.sensor.data.humidity, 2),
|
||||
'dew_point': round(dew_point, 2),
|
||||
'pressure': round(self.sensor.data.pressure, 2),
|
||||
'gas_ohms': gas_ohms,
|
||||
'aqi': gas_to_aqi(gas_ohms, self.sensor.data.humidity),
|
||||
}
|
||||
return self._cache
|
||||
|
||||
# ── MPU6050 (0x68) ───────────────────────────────────────────────────────────
|
||||
|
||||
class MPU6050:
|
||||
ADDR = 0x68
|
||||
CALIBRATION_SAMPLES = 200
|
||||
|
||||
def __init__(self):
|
||||
try_read(lambda: bus.write_byte_data(self.ADDR, 0x6B, 0))
|
||||
time.sleep(0.1)
|
||||
self.offsets = self._calibrate()
|
||||
self._peak = None
|
||||
self._lock = threading.Lock()
|
||||
threading.Thread(target=self._sample_loop, daemon=True).start()
|
||||
|
||||
def _calibrate(self):
|
||||
print("Calibrating MPU6050, keep still...")
|
||||
sums = [0] * 6
|
||||
for _ in range(self.CALIBRATION_SAMPLES):
|
||||
d = try_read(lambda: bus.read_i2c_block_data(self.ADDR, 0x3B, 14))
|
||||
if d:
|
||||
def s(v): return v - 65536 if v > 32767 else v
|
||||
sums[0] += s((d[0] << 8) | d[1]) / 16384.0
|
||||
sums[1] += s((d[2] << 8) | d[3]) / 16384.0
|
||||
sums[2] += s((d[4] << 8) | d[5]) / 16384.0
|
||||
sums[3] += s((d[8] << 8) | d[9]) / 131.0
|
||||
sums[4] += s((d[10] << 8) | d[11]) / 131.0
|
||||
sums[5] += s((d[12] << 8) | d[13]) / 131.0
|
||||
time.sleep(0.005)
|
||||
n = self.CALIBRATION_SAMPLES
|
||||
return [sums[0]/n, sums[1]/n, sums[2]/n - 1.0,
|
||||
sums[3]/n, sums[4]/n, sums[5]/n]
|
||||
|
||||
def _sample_loop(self):
|
||||
while True:
|
||||
d = try_read(lambda: bus.read_i2c_block_data(self.ADDR, 0x3B, 14))
|
||||
if d:
|
||||
def s(v): return v - 65536 if v > 32767 else v
|
||||
o = self.offsets
|
||||
ax = s((d[0] << 8) | d[1]) / 16384.0 - o[0]
|
||||
ay = s((d[2] << 8) | d[3]) / 16384.0 - o[1]
|
||||
az = s((d[4] << 8) | d[5]) / 16384.0 - o[2]
|
||||
gx = s((d[8] << 8) | d[9]) / 131.0 - o[3]
|
||||
gy = s((d[10] << 8) | d[11]) / 131.0 - o[4]
|
||||
gz = s((d[12] << 8) | d[13]) / 131.0 - o[5]
|
||||
# subtract gravity so idle magnitude ≈ 0
|
||||
mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**2)
|
||||
with self._lock:
|
||||
if self._peak is None or mag > self._peak['magnitude']:
|
||||
self._peak = {
|
||||
'ax': round(ax, 3), 'ay': round(ay, 3), 'az': round(az, 3),
|
||||
'gx': round(gx, 3), 'gy': round(gy, 3), 'gz': round(gz, 3),
|
||||
'magnitude': round(mag, 4),
|
||||
}
|
||||
time.sleep(0.01)
|
||||
|
||||
def read(self):
|
||||
with self._lock:
|
||||
data = self._peak
|
||||
self._peak = None # reset after each display read
|
||||
return data
|
||||
|
||||
# ── QMC5883L Compass (0x1E) ──────────────────────────────────────────────────
|
||||
|
||||
def read_compass():
|
||||
addr = 0x1E
|
||||
try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D))
|
||||
data = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6))
|
||||
if not data:
|
||||
return None
|
||||
def s(v): return v - 65536 if v > 32767 else v
|
||||
x = s((data[1] << 8) | data[0])
|
||||
y = s((data[3] << 8) | data[2])
|
||||
z = s((data[5] << 8) | data[4])
|
||||
heading = math.degrees(math.atan2(y, x))
|
||||
if heading < 0:
|
||||
heading += 360
|
||||
return {'x': x, 'y': y, 'z': z, 'heading': round(heading, 1)}
|
||||
|
||||
# ── LTR390 (0x53) ────────────────────────────────────────────────────────────
|
||||
|
||||
UV_LABELS = [(0,'Low'),(3,'Moderate'),(6,'High'),(8,'Very High'),(11,'Extreme')]
|
||||
|
||||
def uv_label(uvi):
|
||||
label = 'Low'
|
||||
for threshold, name in UV_LABELS:
|
||||
if uvi >= threshold:
|
||||
label = name
|
||||
return label
|
||||
|
||||
class LTR390:
|
||||
def __init__(self):
|
||||
self.sensor = try_read(lambda: adafruit_ltr390.LTR390(i2c))
|
||||
|
||||
def read(self):
|
||||
if not self.sensor:
|
||||
return None
|
||||
return try_read(lambda: {
|
||||
'lux': round(self.sensor.lux, 2),
|
||||
'uvi': round(self.sensor.uvi, 2),
|
||||
'uv_label': uv_label(self.sensor.uvi),
|
||||
'solar_wm2': round(self.sensor.lux / 120, 2),
|
||||
})
|
||||
|
||||
# ── TF-Luna (0x10) ───────────────────────────────────────────────────────────
|
||||
|
||||
def read_tfluna():
|
||||
data = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
||||
if not data:
|
||||
return None
|
||||
dist = data[0] | (data[1] << 8)
|
||||
strength = data[2] | (data[3] << 8)
|
||||
if strength < 100 or dist <= 0 or dist > 800:
|
||||
return None
|
||||
return {'distance_cm': dist, 'strength': strength}
|
||||
|
||||
# ── AS3935 (0x03) ────────────────────────────────────────────────────────────
|
||||
|
||||
AS3935_ADDR = 0x03
|
||||
AS3935_NOISE_FLOOR = 7 # 0-7, raise if false positives persist
|
||||
AS3935_WATCHDOG = 3 # 0-15
|
||||
AS3935_SPIKE_REJ = 15 # 0-15, filters EMI from TF-Luna
|
||||
|
||||
def init_as3935():
|
||||
val = (AS3935_NOISE_FLOOR << 4) | AS3935_WATCHDOG
|
||||
try_read(lambda: bus.write_byte_data(AS3935_ADDR, 0x01, val))
|
||||
try_read(lambda: bus.write_byte_data(AS3935_ADDR, 0x02, AS3935_SPIKE_REJ))
|
||||
try_read(lambda: bus.write_byte_data(AS3935_ADDR, 0x00, 0x24)) # indoor
|
||||
|
||||
def read_as3935():
|
||||
data = try_read(lambda: bus.read_i2c_block_data(AS3935_ADDR, 0x00, 9))
|
||||
if not data:
|
||||
return None
|
||||
interrupt = data[3] & 0x0F
|
||||
energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3]
|
||||
try_read(lambda: bus.read_byte_data(AS3935_ADDR, 0x03)) # clear interrupt
|
||||
if interrupt not in (0x04, 0x08) or energy < 1000:
|
||||
return {'noise_floor': AS3935_NOISE_FLOOR, 'interrupt': 0, 'distance_km': 0, 'energy': 0}
|
||||
return {
|
||||
'noise_floor': AS3935_NOISE_FLOOR,
|
||||
'interrupt': interrupt,
|
||||
'distance_km': data[6] & 0x3F,
|
||||
'energy': energy,
|
||||
}
|
||||
|
||||
# ── GPS (ttyS0) ──────────────────────────────────────────────────────────────
|
||||
|
||||
gps_cache = {}
|
||||
|
||||
def read_gps():
|
||||
try:
|
||||
ser = serial.Serial('/dev/ttyS0', 9600, timeout=1)
|
||||
line = ser.readline().decode('ascii', errors='replace').strip()
|
||||
ser.close()
|
||||
if 'GGA' in line:
|
||||
msg = pynmea2.parse(line)
|
||||
gps_cache.update({
|
||||
'lat': msg.latitude,
|
||||
'lon': msg.longitude,
|
||||
'alt': msg.altitude,
|
||||
'satellites': msg.num_sats,
|
||||
})
|
||||
elif 'VTG' in line:
|
||||
msg = pynmea2.parse(line)
|
||||
gps_cache['speed_kmh'] = msg.spd_over_grnd_kmph
|
||||
elif 'RMC' in line:
|
||||
msg = pynmea2.parse(line)
|
||||
gps_cache['heading'] = msg.true_course
|
||||
except:
|
||||
pass
|
||||
return gps_cache or None
|
||||
|
||||
# ── Display ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def fmt(val, unit='', unavailable='—'):
|
||||
return f"{val}{unit}" if val is not None else unavailable
|
||||
|
||||
def section(title):
|
||||
print(f"\n {title}")
|
||||
print(f" {'─' * 40}")
|
||||
|
||||
def print_dashboard(bme_data, mpu_data):
|
||||
compass_data = read_compass()
|
||||
ltr_data = ltr.read()
|
||||
luna_data = read_tfluna()
|
||||
as_data = read_as3935()
|
||||
gps_data = read_gps()
|
||||
|
||||
print('\033[2J\033[H', end='')
|
||||
print(f"╔{'═'*50}╗")
|
||||
print(f"║ 🌦 Weather Station — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ║")
|
||||
print(f"╚{'═'*50}╝")
|
||||
|
||||
section("🧭 BN-880 — GPS / Compass")
|
||||
if gps_data:
|
||||
print(f" Heading: {compass_data['heading']}°")
|
||||
print(f" Lat/Lon: {fmt(gps_data.get('lat'))}°, {fmt(gps_data.get('lon'))}°")
|
||||
print(f" Altitude: {fmt(gps_data.get('alt'), 'm')}")
|
||||
print(f" Speed: {fmt(gps_data.get('speed_kmh'), ' km/h')}")
|
||||
print(f" Satellites: {fmt(gps_data.get('satellites'))}")
|
||||
else:
|
||||
print(" ❌ Unavailable")
|
||||
|
||||
section("🌡 BME680 — Temp / Humidity / Pressure / AQ")
|
||||
if bme_data:
|
||||
print(f" Temperature: {bme_data['temp_c']}°C ({bme_data['temp_f']}°F)")
|
||||
print(f" Humidity: {bme_data['humidity']}%")
|
||||
print(f" Dew Point: {bme_data['dew_point']}°C")
|
||||
print(f" Pressure: {bme_data['pressure']} hPa")
|
||||
print(f" Gas Res: {fmt(bme_data['gas_ohms'], ' Ω')}")
|
||||
else:
|
||||
print(" ❌ Unavailable")
|
||||
|
||||
section("☀️ LTR390 — Light / UV")
|
||||
if ltr_data:
|
||||
print(f" Lux: {ltr_data['lux']}")
|
||||
print(f" UV Index: {ltr_data['uvi']} — {ltr_data['uv_label']}")
|
||||
print(f" Solar W/m²: {ltr_data['solar_wm2']}")
|
||||
else:
|
||||
print(" ❌ Unavailable")
|
||||
|
||||
section("⚡ AS3935 — Lightning")
|
||||
if as_data:
|
||||
if as_data['interrupt'] == 0:
|
||||
print(f" No event (noise floor: {as_data['noise_floor']}/7)")
|
||||
else:
|
||||
print(f" Distance: {as_data['distance_km']} km")
|
||||
print(f" Energy: {as_data['energy']}")
|
||||
else:
|
||||
print(" ❌ Unavailable")
|
||||
|
||||
section("📐 MPU6050 — Seismic / IMU")
|
||||
if mpu_data:
|
||||
print(f" Magnitude: {mpu_data['magnitude']}g")
|
||||
else:
|
||||
print(" ❌ Unavailable")
|
||||
|
||||
section("📏 TF-Luna — Distance")
|
||||
if luna_data:
|
||||
print(f" Distance: {luna_data['distance_cm']} cm")
|
||||
print(f" Strength: {luna_data['strength']}")
|
||||
else:
|
||||
print(" ❌ Unavailable")
|
||||
|
||||
print(f"\n{'═'*52}\n")
|
||||
|
||||
if __name__ == '__main__':
|
||||
bme = BME680()
|
||||
mpu = MPU6050()
|
||||
ltr = LTR390()
|
||||
init_as3935()
|
||||
bme_data = None
|
||||
mpu_data = None
|
||||
while True:
|
||||
bme_data = bme.read() or bme_data
|
||||
mpu_data = mpu.read()
|
||||
print_dashboard(bme_data, mpu_data)
|
||||
time.sleep(1)
|
||||
577
sensors/main.py
Normal file
@@ -0,0 +1,577 @@
|
||||
import time, math, board, smbus2, serial, psutil, pynmea2, bme680
|
||||
import adafruit_ltr390, threading, os, statistics, requests
|
||||
from datetime import datetime, timezone
|
||||
from collections import deque
|
||||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
def cfg():
|
||||
load_dotenv(BASE_DIR / '.env', override=True)
|
||||
load_dotenv(BASE_DIR / '.env.local', override=True)
|
||||
return {
|
||||
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
|
||||
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
|
||||
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
|
||||
'DB_HOST': os.getenv('DB_HOST', 'http://localhost:8428'),
|
||||
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
|
||||
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
|
||||
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
|
||||
'PRESSURE_TREND_WINDOW': int( os.getenv('PRESSURE_TREND_WINDOW', '60')),
|
||||
'LUNA_CAL_SAMPLES': int( os.getenv('LUNA_CAL_SAMPLES', '20')),
|
||||
'LUNA_MAX_DIST_CM': int( os.getenv('LUNA_MAX_DIST_CM', '800')),
|
||||
'LUNA_MIN_DEPTH_CM': int( os.getenv('LUNA_MIN_DEPTH_CM', '2')),
|
||||
'AS3935_ADDR': int( os.getenv('AS3935_ADDR', '0x03'), 16),
|
||||
'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')),
|
||||
'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')),
|
||||
'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')),
|
||||
'LOOP_INTERVAL': float( os.getenv('LOOP_INTERVAL', '1.0')),
|
||||
'FLOOD_TEMP_THRESHOLD': float( os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')),
|
||||
'SLUSH_TEMP_THRESHOLD': float( os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')),
|
||||
'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')),
|
||||
'SEISMIC_NOISE_FLOOR': float( os.getenv('SEISMIC_NOISE_FLOOR', '0.02')),
|
||||
'TEMP_CALIBRATION': float( os.getenv('TEMP_CALIBRATION', '0.0')),
|
||||
'VISIBILITY_MAX': float( os.getenv('VISIBILITY_MAX', '50.0')),
|
||||
'CLOUD_SUN_MIN_ELEV': float( os.getenv('CLOUD_SUN_MIN_ELEV', '5.0')),
|
||||
'CLOUD_LUX_CLEAR_SKY': float( os.getenv('CLOUD_LUX_CLEAR_SKY', '100000.0')),
|
||||
'CLOUD_HUMIDITY_WEIGHT': float( os.getenv('CLOUD_HUMIDITY_WEIGHT', '0.3')),
|
||||
}
|
||||
|
||||
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||
|
||||
def write(c, fields, collection='weather'):
|
||||
field_str = ','.join(
|
||||
f"{k}={'true' if v is True else 'false' if v is False else v}"
|
||||
for k, v in fields.items() if v is not None
|
||||
)
|
||||
if not field_str: return
|
||||
requests.post(f"{c['DB_HOST']}/write", data=f"{collection} {field_str}", timeout=2)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def try_init(fn, name):
|
||||
try:
|
||||
r = fn()
|
||||
print(f" ✅ {name}")
|
||||
return r
|
||||
except Exception as e:
|
||||
print(f" ❌ {name}: {e}")
|
||||
return None
|
||||
|
||||
def try_read(fn):
|
||||
try: return fn()
|
||||
except: return None
|
||||
|
||||
def s16(v): return v - 65536 if v > 32767 else v
|
||||
|
||||
try: bus = smbus2.SMBus(1)
|
||||
except: bus = None; print("[I2C] SMBus init failed")
|
||||
|
||||
try: i2c = board.I2C()
|
||||
except: i2c = None; print("[I2C] board.I2C init failed")
|
||||
|
||||
# ── BME680 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pressure_buffer = deque(maxlen=60)
|
||||
|
||||
def init_bme():
|
||||
sensor = bme680.BME680(bme680.I2C_ADDR_SECONDARY)
|
||||
sensor.set_humidity_oversample(bme680.OS_2X)
|
||||
sensor.set_pressure_oversample(bme680.OS_4X)
|
||||
sensor.set_temperature_oversample(bme680.OS_8X)
|
||||
sensor.set_filter(bme680.FILTER_SIZE_3)
|
||||
sensor.set_gas_status(bme680.ENABLE_GAS_MEAS)
|
||||
sensor.set_gas_heater_temperature(320)
|
||||
sensor.set_gas_heater_duration(150)
|
||||
sensor.select_gas_heater_profile(0)
|
||||
return sensor
|
||||
|
||||
def heat_index(tc, rh):
|
||||
tf = tc * 9/5 + 32
|
||||
if tf < 80: return round(tc, 2)
|
||||
hi = (-42.379 + 2.04901523*tf + 10.14333127*rh
|
||||
- 0.22475541*tf*rh - 0.00683783*tf**2
|
||||
- 0.05481717*rh**2 + 0.00122874*tf**2*rh
|
||||
+ 0.00085282*tf*rh**2 - 0.00000199*tf**2*rh**2)
|
||||
return round((hi - 32) * 5/9, 2)
|
||||
|
||||
def absolute_humidity(tc, rh):
|
||||
return round((6.112 * math.exp((17.67*tc)/(tc+243.5)) * rh * 2.1674) / (273.15+tc), 3)
|
||||
|
||||
def vpd(tc, rh):
|
||||
es = 0.6108 * math.exp((17.27*tc)/(tc+237.3))
|
||||
return round(es - (rh/100)*es, 3)
|
||||
|
||||
def gas_to_aqi(gas_ohms, humidity, gas_reference):
|
||||
if gas_ohms is None: return None
|
||||
gas_score = min(gas_ohms / gas_reference, 1.0) * 75
|
||||
hum_score = 25 - abs(humidity - 40) * 0.5
|
||||
quality = min(max(gas_score + hum_score, 0), 100)
|
||||
aqi = round((1 - quality / 100) * 500)
|
||||
return aqi
|
||||
|
||||
def sea_level_pressure(pressure_hpa, alt_m):
|
||||
if alt_m is None: return None
|
||||
return round(pressure_hpa / math.pow(1 - (alt_m / 44330.0), 5.255), 2)
|
||||
|
||||
def get_pressure_trend(window):
|
||||
if pressure_buffer.maxlen != window:
|
||||
pressure_buffer.__init__(window)
|
||||
if len(pressure_buffer) < 2: return None
|
||||
delta = pressure_buffer[-1] - pressure_buffer[0]
|
||||
return round(delta, 2)
|
||||
|
||||
def read_bme(c, sensor, alt_m=None):
|
||||
if not sensor: return None, None, None
|
||||
try:
|
||||
if not sensor.get_sensor_data(): return None, None, None
|
||||
t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure
|
||||
gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None
|
||||
aqi_score = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
|
||||
pressure_buffer.append(p)
|
||||
p_rate = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
|
||||
dp = round(t - (100 - rh) / 5.0, 2)
|
||||
write(c, {
|
||||
'temperature': round(t + c['TEMP_CALIBRATION'], 2),
|
||||
'humidity': round(rh, 2),
|
||||
'dew_point': dp,
|
||||
'pressure_hpa': round(p, 2),
|
||||
'pressure_slp': sea_level_pressure(p, alt_m),
|
||||
'pressure_rate': p_rate,
|
||||
'humidity_abs': absolute_humidity(t, rh),
|
||||
'vapor_pressure_deficit': vpd(t, rh),
|
||||
'heat_index': heat_index(t, rh),
|
||||
'air_quality_ohms': gas,
|
||||
'air_quality': aqi_score,
|
||||
})
|
||||
return t, rh, aqi_score
|
||||
except Exception as e:
|
||||
print(f"[BME680] {e}")
|
||||
return None, None, None
|
||||
|
||||
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
MPU_ADDR = 0x68
|
||||
_mpu_peak = None
|
||||
_mpu_lock = threading.Lock()
|
||||
_mpu_grav = None # slow-tracked gravity vector [x, y, z]
|
||||
GRAV_ALPHA = 0.05 # EMA rate while still
|
||||
STILL_SPREAD = 0.03 # g, max peak-to-peak per axis to count as "still"
|
||||
_still_buf = deque(maxlen=50) # ~0.5s @ 100Hz
|
||||
|
||||
def read_accel():
|
||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 6))
|
||||
if not d: return None
|
||||
return [
|
||||
s16((d[0] << 8) | d[1]) / 16384.0,
|
||||
s16((d[2] << 8) | d[3]) / 16384.0,
|
||||
s16((d[4] << 8) | d[5]) / 16384.0,
|
||||
]
|
||||
|
||||
def rotation_to_z(g):
|
||||
"""Rodrigues rotation mapping gravity vector g -> +Z. Returns (R, |g|)."""
|
||||
mag = math.sqrt(sum(v * v for v in g))
|
||||
u = [v / mag for v in g]
|
||||
kx, ky = u[1], -u[0] # axis = u × z
|
||||
s = math.sqrt(kx * kx + ky * ky) # sin(theta)
|
||||
c = u[2] # cos(theta)
|
||||
if s < 1e-8:
|
||||
return [[1, 0, 0], [0, c, 0], [0, 0, c]], mag
|
||||
kx, ky = kx / s, ky / s
|
||||
v = 1 - c
|
||||
return [
|
||||
[c + kx * kx * v, kx * ky * v, ky * s],
|
||||
[ky * kx * v, c + ky * ky * v, -kx * s],
|
||||
[-ky * s, kx * s, c ],
|
||||
], mag
|
||||
|
||||
def is_still(buf):
|
||||
if len(buf) < buf.maxlen: return False
|
||||
return all(
|
||||
max(s[i] for s in buf) - min(s[i] for s in buf) < STILL_SPREAD
|
||||
for i in range(3)
|
||||
)
|
||||
|
||||
def calibrate_mpu(samples=200):
|
||||
if not bus: return None
|
||||
print(" Calibrating MPU6050, keep still...")
|
||||
sums, n = [0.0, 0.0, 0.0], 0
|
||||
for _ in range(samples):
|
||||
a = read_accel()
|
||||
if a:
|
||||
sums = [s + v for s, v in zip(sums, a)]
|
||||
n += 1
|
||||
time.sleep(0.005)
|
||||
if n == 0: return None
|
||||
|
||||
g = [s / n for s in sums]
|
||||
mag = math.sqrt(sum(v * v for v in g))
|
||||
if not 0.5 < mag < 1.5:
|
||||
print(f" ❌ MPU6050: bad gravity magnitude {mag:.2f}g")
|
||||
return None
|
||||
|
||||
print(f" ✅ MPU6050: gravity {mag:.3f}g on ({g[0]:.2f}, {g[1]:.2f}, {g[2]:.2f}) → mapped to Z")
|
||||
return g
|
||||
|
||||
def mpu_loop():
|
||||
global _mpu_peak, _mpu_grav
|
||||
while True:
|
||||
if bus and _mpu_grav:
|
||||
a = read_accel()
|
||||
if a:
|
||||
_still_buf.append(a)
|
||||
|
||||
R, g = rotation_to_z(_mpu_grav)
|
||||
ax = R[0][0] * a[0] + R[0][1] * a[1] + R[0][2] * a[2]
|
||||
ay = R[1][0] * a[0] + R[1][1] * a[1] + R[1][2] * a[2]
|
||||
az = R[2][0] * a[0] + R[2][1] * a[1] + R[2][2] * a[2] - g
|
||||
mag = math.sqrt(ax**2 + ay**2 + az**2)
|
||||
|
||||
# Re-track gravity whenever the sensor is *still* —
|
||||
# absorbs bias shifts of any size, blocks shakes
|
||||
if is_still(_still_buf):
|
||||
_mpu_grav = [p + (v - p) * GRAV_ALPHA for p, v in zip(_mpu_grav, a)]
|
||||
|
||||
with _mpu_lock:
|
||||
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
|
||||
_mpu_peak = {
|
||||
'seismic_x': round(ax, 3),
|
||||
'seismic_y': round(ay, 3),
|
||||
'seismic_z': round(az, 3),
|
||||
'seismic_magnitude': round(mag, 4),
|
||||
}
|
||||
time.sleep(0.01)
|
||||
|
||||
def flush_mpu(c):
|
||||
global _mpu_peak
|
||||
with _mpu_lock:
|
||||
data, _mpu_peak = _mpu_peak, None
|
||||
if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']:
|
||||
write(c, data)
|
||||
else:
|
||||
write(c, {'seismic_x': 0.0, 'seismic_y': 0.0, 'seismic_z': 0.0, 'seismic_magnitude': 0.0})
|
||||
|
||||
# ── QMC5883L ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_compass(c):
|
||||
if not bus: return
|
||||
try:
|
||||
addr = 0x1E
|
||||
try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D))
|
||||
d = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6))
|
||||
if not d: return
|
||||
x = s16((d[1] << 8) | d[0])
|
||||
y = s16((d[3] << 8) | d[2])
|
||||
z = s16((d[5] << 8) | d[4])
|
||||
heading = math.degrees(math.atan2(y, x))
|
||||
if heading < 0: heading += 360
|
||||
write(c, {'compass_x': x, 'compass_y': y, 'compass_z': z, 'heading': round(heading, 1)})
|
||||
except Exception as e:
|
||||
print(f"[QMC5883L] {e}")
|
||||
|
||||
# ── LTR390 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
uv_dose_mj = 0.0
|
||||
uv_dose_date = datetime.now().date()
|
||||
last_uv_time = time.time()
|
||||
dli_lux_acc = 0.0
|
||||
dli_date = datetime.now().date()
|
||||
last_lux_time = time.time()
|
||||
daylight_start = None
|
||||
|
||||
DAYLIGHT_LUX_THRESHOLD = 50
|
||||
|
||||
def sun_elevation(lat, lon):
|
||||
now = datetime.now(timezone.utc)
|
||||
doy = now.timetuple().tm_yday
|
||||
hour_ut = now.hour + now.minute / 60 + now.second / 3600
|
||||
decl = math.radians(23.45 * math.sin(math.radians((360 / 365) * (doy - 81))))
|
||||
lstm = 15 * round(lon / 15)
|
||||
eot = (9.87 * math.sin(math.radians(2 * (360/365) * (doy - 81)))
|
||||
- 7.53 * math.cos(math.radians((360/365) * (doy - 81)))
|
||||
- 1.5 * math.sin(math.radians((360/365) * (doy - 81))))
|
||||
solar_time = hour_ut + (lon - lstm) / 15 + eot / 60
|
||||
ha = math.radians(15 * (solar_time - 12))
|
||||
lat_r = math.radians(lat)
|
||||
elev = math.degrees(math.asin(
|
||||
math.sin(lat_r) * math.sin(decl) +
|
||||
math.cos(lat_r) * math.cos(decl) * math.cos(ha)
|
||||
))
|
||||
return elev
|
||||
|
||||
def estimate_cloud_cover(c, lux, temp, dew_point, lat, lon):
|
||||
elev = sun_elevation(lat, lon)
|
||||
temp_dp_spread = temp - dew_point
|
||||
hum_cloud = max(0.0, min(100.0, (1 - temp_dp_spread / 20.0) * 100))
|
||||
|
||||
if elev < c['CLOUD_SUN_MIN_ELEV']:
|
||||
return round(hum_cloud, 1), round(elev, 2)
|
||||
|
||||
theoretical = max(c['CLOUD_LUX_CLEAR_SKY'] * math.sin(math.radians(elev)), 1.0)
|
||||
solar_ratio = min(lux / theoretical, 1.0)
|
||||
solar_cloud = (1.0 - solar_ratio) * 100
|
||||
|
||||
w = c['CLOUD_HUMIDITY_WEIGHT']
|
||||
blended = (1 - w) * solar_cloud + w * hum_cloud
|
||||
return round(min(max(blended, 0.0), 100.0), 1), round(elev, 2)
|
||||
|
||||
def estimate_visibility(c, rh, aqi):
|
||||
rh_clamped = min(rh, 99.5)
|
||||
beta_rh = 0.000146 * math.exp(0.06 * rh_clamped)
|
||||
aqi_factor = 1.0 + ((aqi or 0) / 500.0) * 0.5
|
||||
extinction = beta_rh * aqi_factor
|
||||
return round(min(3.912 / extinction, c['VISIBILITY_MAX']), 2)
|
||||
|
||||
def read_ltr(c, sensor, temp=None, dew_point=None, rh=None, aqi=None, lat=0.0, lon=0.0):
|
||||
global uv_dose_mj, uv_dose_date, last_uv_time
|
||||
global dli_lux_acc, dli_date, last_lux_time, daylight_start
|
||||
|
||||
if not sensor: return
|
||||
try:
|
||||
lux = round(sensor.lux, 2)
|
||||
uvi = round(sensor.uvi, 2)
|
||||
except Exception as e:
|
||||
print(f"[LTR390] {e}")
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
today = datetime.now().date()
|
||||
|
||||
if today != uv_dose_date: uv_dose_mj, uv_dose_date = 0.0, today
|
||||
if today != dli_date: dli_lux_acc, dli_date, daylight_start = 0.0, today, None
|
||||
|
||||
uv_dose_mj += uvi * 25 * (now - last_uv_time)
|
||||
dli_lux_acc += lux * (now - last_lux_time)
|
||||
last_uv_time = now
|
||||
last_lux_time = now
|
||||
|
||||
if lux >= DAYLIGHT_LUX_THRESHOLD and daylight_start is None:
|
||||
daylight_start = now
|
||||
|
||||
fields = {
|
||||
'lux': lux,
|
||||
'uv_index': uvi,
|
||||
'solar_wm2': round(lux / 120, 2),
|
||||
'uv_dose': round(uv_dose_mj, 2),
|
||||
'daily_light_integral': round(dli_lux_acc / 54 / 1_000_000, 4),
|
||||
}
|
||||
|
||||
if temp is not None and dew_point is not None:
|
||||
cloud, sun_elev = estimate_cloud_cover(c, lux, temp, dew_point, lat, lon)
|
||||
fields['clouds'] = cloud
|
||||
fields['sun_elevation'] = sun_elev
|
||||
|
||||
if rh is not None:
|
||||
fields['visibility'] = estimate_visibility(c, rh, aqi)
|
||||
|
||||
write(c, fields)
|
||||
|
||||
# ── TF-Luna ───────────────────────────────────────────────────────────────────
|
||||
|
||||
luna_baseline_cm = None
|
||||
|
||||
def calibrate_luna(c):
|
||||
global luna_baseline_cm
|
||||
if not bus: print(" ❌ TF-Luna: no I2C bus"); return
|
||||
print(" Calibrating TF-Luna...")
|
||||
readings = []
|
||||
for _ in range(c['LUNA_CAL_SAMPLES']):
|
||||
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
||||
if d:
|
||||
dist, strength = d[0] | (d[1] << 8), d[2] | (d[3] << 8)
|
||||
if strength >= 100 and 0 < dist <= c['LUNA_MAX_DIST_CM']:
|
||||
readings.append(dist)
|
||||
time.sleep(0.1)
|
||||
if readings:
|
||||
luna_baseline_cm = round(sum(readings) / len(readings), 1)
|
||||
print(f" ✅ TF-Luna baseline: {luna_baseline_cm} cm")
|
||||
else:
|
||||
print(" ❌ TF-Luna: calibration failed")
|
||||
|
||||
def read_luna(c):
|
||||
if not bus: return
|
||||
try:
|
||||
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
||||
if not d: return
|
||||
dist = d[0] | (d[1] << 8)
|
||||
strength = d[2] | (d[3] << 8)
|
||||
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']: return
|
||||
fields = {'ground_distance': dist, 'ground_calibration': luna_baseline_cm, 'lidar_strength': strength}
|
||||
if luna_baseline_cm and (luna_baseline_cm - dist) >= c['LUNA_MIN_DEPTH_CM']:
|
||||
depth = round(luna_baseline_cm - dist, 1)
|
||||
fields['accumulation'] = depth
|
||||
else:
|
||||
fields['accumulation'] = 0
|
||||
write(c, fields)
|
||||
except Exception as e:
|
||||
print(f"[TF-Luna] {e}")
|
||||
|
||||
# ── AS3935 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
lightning_distances = deque(maxlen=10)
|
||||
lightning_count = 0
|
||||
lightning_window_start = time.time()
|
||||
|
||||
def init_as3935(c):
|
||||
if not bus: return
|
||||
try:
|
||||
addr = c['AS3935_ADDR']
|
||||
bus.write_byte_data(addr, 0x01, (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG'])
|
||||
bus.write_byte_data(addr, 0x02, c['AS3935_SPIKE_REJ'])
|
||||
bus.write_byte_data(addr, 0x00, 0x24)
|
||||
print(" ✅ AS3935")
|
||||
except Exception as e:
|
||||
print(f" ❌ AS3935: {e}")
|
||||
|
||||
def read_as3935(c):
|
||||
global lightning_count, lightning_window_start
|
||||
if not bus: return
|
||||
try:
|
||||
addr = c['AS3935_ADDR']
|
||||
data = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 9))
|
||||
if not data: return
|
||||
interrupt = data[3] & 0x0F
|
||||
energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3]
|
||||
try_read(lambda: bus.read_byte_data(addr, 0x03))
|
||||
if interrupt not in (0x04, 0x08) or energy < 1000: return
|
||||
|
||||
dist = data[6] & 0x3F
|
||||
lightning_distances.append(dist)
|
||||
lightning_count += 1
|
||||
|
||||
elapsed = time.time() - lightning_window_start
|
||||
if elapsed >= 3600:
|
||||
lightning_count, lightning_window_start, elapsed = 1, time.time(), 1
|
||||
|
||||
trend = 'Stationary'
|
||||
if len(lightning_distances) >= 3:
|
||||
avg_early = sum(list(lightning_distances)[:3]) / 3
|
||||
avg_late = sum(list(lightning_distances)[-3:]) / 3
|
||||
if avg_late < avg_early - 2: trend = 'Approaching'
|
||||
elif avg_late > avg_early + 2: trend = 'Retreating'
|
||||
|
||||
write(c, {
|
||||
'lightning_distance': dist,
|
||||
'lightning_rate': round(lightning_count / (elapsed / 3600), 1),
|
||||
'storm_trend': trend,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[AS3935] {e}")
|
||||
|
||||
# ── GPS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
gps_cache = {}
|
||||
|
||||
def read_gps(c):
|
||||
try:
|
||||
ser = serial.Serial(c['GPS_PORT'], c['GPS_BAUD'], timeout=1)
|
||||
line = ser.readline().decode('ascii', errors='replace').strip()
|
||||
ser.close()
|
||||
if 'GGA' in line:
|
||||
msg = pynmea2.parse(line)
|
||||
gps_cache.update({
|
||||
'latitude': msg.latitude,
|
||||
'longitude': msg.longitude,
|
||||
'altitude': msg.altitude,
|
||||
'gps_satellites': int(msg.num_sats),
|
||||
})
|
||||
if gps_cache:
|
||||
write(c, {k: v for k, v in gps_cache.items() if v is not None})
|
||||
except Exception as e:
|
||||
print(f"[GPS] {e}")
|
||||
return gps_cache
|
||||
|
||||
# ── Rain ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_rain(c):
|
||||
write(c, {'precipitation': 0, 'raining': False})
|
||||
|
||||
# ── Wind ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_wind(c):
|
||||
write(c, {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
|
||||
|
||||
# ── System ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_system(c):
|
||||
try:
|
||||
cpu_temp = None
|
||||
for key in ('cpu_thermal', 'coretemp', 'k10temp', 'acpitz'):
|
||||
if key in (temps := psutil.sensors_temperatures()):
|
||||
cpu_temp = round(temps[key][0].current, 1)
|
||||
break
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
write(c, {
|
||||
'cpu_temp': cpu_temp,
|
||||
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
|
||||
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
|
||||
'mem_used_mb': round(mem.used / 1024**2, 1),
|
||||
'mem_total_mb': round(mem.total / 1024**2, 1),
|
||||
'mem_percent': mem.percent,
|
||||
'disk_used_gb': round(disk.used / 1024**3, 2),
|
||||
'disk_total_gb': round(disk.total / 1024**3, 2),
|
||||
'disk_percent': disk.percent,
|
||||
}, collection='system')
|
||||
except Exception as e:
|
||||
print(f"[System] {e}")
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("\n🌦 Weather Station — Initialising...\n")
|
||||
c = cfg()
|
||||
|
||||
bme_sensor = try_init(init_bme, 'BME680')
|
||||
ltr_sensor = try_init(lambda: adafruit_ltr390.LTR390(i2c) if i2c else (_ for _ in ()).throw(Exception('no i2c')), 'LTR390')
|
||||
|
||||
if bus:
|
||||
try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0))
|
||||
time.sleep(0.1)
|
||||
_mpu_grav = try_init(calibrate_mpu, 'MPU6050')
|
||||
else:
|
||||
_mpu_grav = None
|
||||
print(" ❌ MPU6050: no I2C bus")
|
||||
|
||||
threading.Thread(target=mpu_loop, daemon=True).start()
|
||||
init_as3935(c)
|
||||
calibrate_luna(c)
|
||||
|
||||
print("\n🚀 Starting sensor loop...\n")
|
||||
|
||||
last_temp = None
|
||||
last_rh = None
|
||||
last_aqi = None
|
||||
|
||||
while True:
|
||||
c = cfg()
|
||||
gps = read_gps(c)
|
||||
|
||||
lat = gps.get('latitude') or c['LATITUDE']
|
||||
lon = gps.get('longitude') or c['LONGITUDE']
|
||||
alt = gps.get('altitude') or c['ALTITUDE']
|
||||
|
||||
result = read_bme(c, bme_sensor, alt_m=alt)
|
||||
if result and result[0] is not None:
|
||||
last_temp, last_rh, last_aqi = result
|
||||
|
||||
dp = None
|
||||
if last_temp is not None and last_rh is not None:
|
||||
dp = round(last_temp - (100 - last_rh) / 5.0, 2)
|
||||
|
||||
flush_mpu(c)
|
||||
read_compass(c)
|
||||
read_ltr(c, ltr_sensor,
|
||||
temp=last_temp, dew_point=dp,
|
||||
rh=last_rh, aqi=last_aqi,
|
||||
lat=lat, lon=lon)
|
||||
read_luna(c)
|
||||
read_as3935(c)
|
||||
read_rain(c)
|
||||
read_wind(c)
|
||||
read_system(c)
|
||||
|
||||
time.sleep(c['LOOP_INTERVAL'])
|
||||
9
sensors/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
requests
|
||||
python-dotenv
|
||||
smbus2
|
||||
adafruit-circuitpython-ltr390
|
||||
bme680
|
||||
psutil
|
||||
pyserial
|
||||
pynmea2
|
||||
RPi.GPIO
|
||||
@@ -1,213 +0,0 @@
|
||||
const RAD = Math.PI / 180
|
||||
const DEG = 180 / Math.PI
|
||||
|
||||
function jdn(date) {
|
||||
return (date instanceof Date ? date : new Date(date)) / 86400000 + 2440587.5
|
||||
}
|
||||
|
||||
function jdnToDate(jd) {
|
||||
return new Date((jd - 2440587.5) * 86400000)
|
||||
}
|
||||
|
||||
function sunPosition(lat, lon, date = new Date()) {
|
||||
const d = jdn(date) - 2451545.0
|
||||
const L = (280.46 + 0.9856474 * d) % 360
|
||||
const g = (357.528 + 0.9856003 * d) % 360
|
||||
const lam = L + 1.915 * Math.sin(g * RAD) + 0.02 * Math.sin(2 * g * RAD)
|
||||
const eps = 23.439 - 0.0000004 * d
|
||||
const sinL = Math.sin(lam * RAD)
|
||||
const ra = Math.atan2(Math.cos(eps * RAD) * sinL, Math.cos(lam * RAD)) * DEG
|
||||
const dec = Math.asin(Math.sin(eps * RAD) * sinL) * DEG
|
||||
const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600
|
||||
const GMST = (6.697375 + 0.0657098242 * d + UT) % 24
|
||||
const LMST = (GMST + lon / 15) % 24
|
||||
const ha = LMST * 15 - ra
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG
|
||||
const az = Math.atan2(
|
||||
-Math.sin(ha * RAD),
|
||||
Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG
|
||||
return {
|
||||
sun_elevation: Math.round(elev * 10) / 10,
|
||||
sun_azimuth: Math.round(((az + 360) % 360) * 10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
function sunriseSunset(lat, lon, date = new Date()) {
|
||||
const d = Math.floor(jdn(date)) - 2451545
|
||||
const noon = 2451545 + 0.0009 + ((-lon) / 360) + Math.round(d - (-lon) / 360)
|
||||
const M = (357.5291 + 0.98560028 * (noon - 2451545)) % 360
|
||||
const C = 1.9148 * Math.sin(M * RAD) + 0.02 * Math.sin(2 * M * RAD) + 0.0003 * Math.sin(3 * M * RAD)
|
||||
const lam = (M + C + 180 + 102.9372) % 360
|
||||
const jnoon = noon + 0.0053 * Math.sin(M * RAD) - 0.0069 * Math.sin(2 * lam * RAD)
|
||||
const dec = Math.asin(Math.sin(23.4397 * RAD) * Math.sin(lam * RAD)) * DEG
|
||||
const cosH = (Math.sin(-0.8333 * RAD) - Math.sin(lat * RAD) * Math.sin(dec * RAD)) / (Math.cos(lat * RAD) * Math.cos(dec * RAD))
|
||||
|
||||
if (Math.abs(cosH) > 1) {
|
||||
return { sun_sunrise: null, sun_sunset: null, sun_solar_noon: jdnToDate(jnoon).toISOString(), sun_polar: cosH < -1 ? 'day' : 'night' }
|
||||
}
|
||||
|
||||
const H = Math.acos(cosH) * DEG
|
||||
const rise = jdnToDate(jnoon - H / 360)
|
||||
const set = jdnToDate(jnoon + H / 360)
|
||||
const noon_d = jdnToDate(jnoon)
|
||||
|
||||
// Round to nearest second, drop milliseconds
|
||||
const fmt = d => new Date(Math.round(d.getTime() / 1000) * 1000).toISOString()
|
||||
|
||||
return {
|
||||
sun_sunrise: fmt(rise),
|
||||
sun_sunset: fmt(set),
|
||||
sun_solar_noon: fmt(noon_d),
|
||||
sun_day_length_hours: Math.round((set - rise) / 36000) / 100,
|
||||
sun_golden_hour_morning_start: fmt(new Date(rise - 30 * 60000)),
|
||||
sun_golden_hour_morning_end: fmt(new Date(rise + 30 * 60000)),
|
||||
sun_golden_hour_evening_start: fmt(new Date(set - 30 * 60000)),
|
||||
sun_golden_hour_evening_end: fmt(new Date(set + 30 * 60000)),
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth sunspot number approximation from solar flux F10.7
|
||||
// SSN ≈ 1.61 * F10.7 - 63.7 (linear regression, valid for F10.7 > 70)
|
||||
export function ssnFromFlux(f107) {
|
||||
if (!f107) return null
|
||||
return Math.max(0, Math.round(1.61 * f107 - 63.7))
|
||||
}
|
||||
|
||||
export function sunActivityLabel(f107) {
|
||||
if (!f107) return null
|
||||
if (f107 < 80) return 'Very Low'
|
||||
if (f107 < 100) return 'Low'
|
||||
if (f107 < 150) return 'Moderate'
|
||||
if (f107 < 200) return 'High'
|
||||
return 'Very High'
|
||||
}
|
||||
|
||||
function moonPhase(date = new Date()) {
|
||||
const jd = jdn(date)
|
||||
const cycle = 29.53058867
|
||||
const known = 2451550.1
|
||||
const phase = ((jd - known) % cycle + cycle) % cycle
|
||||
const illum = Math.round((1 - Math.cos(phase / cycle * 2 * Math.PI)) / 2 * 100)
|
||||
let name
|
||||
if (phase < 1.85) name = 'New Moon'
|
||||
else if (phase < 7.38) name = 'Waxing Crescent'
|
||||
else if (phase < 9.22) name = 'First Quarter'
|
||||
else if (phase < 14.76) name = 'Waxing Gibbous'
|
||||
else if (phase < 16.61) name = 'Full Moon'
|
||||
else if (phase < 22.15) name = 'Waning Gibbous'
|
||||
else if (phase < 23.99) name = 'Last Quarter'
|
||||
else name = 'Waning Crescent'
|
||||
return { moon_phase: name, moon_illumination_pct: illum }
|
||||
}
|
||||
|
||||
function nextMoonEvents(date = new Date()) {
|
||||
const cycle = 29.53058867
|
||||
const jd = jdn(date)
|
||||
const known = 2451550.1
|
||||
const phase = ((jd - known) % cycle + cycle) % cycle
|
||||
const toNew = (cycle - phase) % cycle
|
||||
const toFull = phase < 14.76 ? 14.76 - phase : cycle - phase + 14.76
|
||||
return {
|
||||
moon_next_new: jdnToDate(jd + toNew).toISOString(),
|
||||
moon_next_full: jdnToDate(jd + toFull).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function moonriseMoonset(lat, lon, date = new Date()) {
|
||||
const base = new Date(date)
|
||||
base.setUTCHours(0, 0, 0, 0)
|
||||
let prev = null
|
||||
let moonrise = null
|
||||
let moonset = null
|
||||
|
||||
// Scan 48h in 10min steps — covers edge cases where moon rises/sets next UTC day
|
||||
for (let m = 0; m <= 2880; m += 10) {
|
||||
const t = new Date(base.getTime() + m * 60000)
|
||||
const d = jdn(t) - 2451545
|
||||
const L = (218.316 + 13.176396 * d) % 360
|
||||
const M = (134.963 + 13.064993 * d) % 360
|
||||
const F = (93.272 + 13.229350 * d) % 360
|
||||
const lon_ = L + 6.289 * Math.sin(M * RAD)
|
||||
const b = 5.128 * Math.sin(F * RAD)
|
||||
const dec = Math.asin(
|
||||
Math.sin(b * RAD) * Math.cos(23.4397 * RAD) +
|
||||
Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD)
|
||||
) * DEG
|
||||
const GMST = (6.697375 + 0.0657098242 * d + (t.getUTCHours() + t.getUTCMinutes() / 60)) % 24
|
||||
const LMST = (GMST + lon / 15) % 24
|
||||
const ra = Math.atan2(
|
||||
Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD),
|
||||
Math.cos(lon_ * RAD)
|
||||
) * DEG
|
||||
const ha = LMST * 15 - ra
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG
|
||||
|
||||
if (prev !== null) {
|
||||
if (prev < 0 && elev >= 0 && !moonrise) moonrise = new Date(t.getTime() - 5 * 60000).toISOString()
|
||||
if (prev >= 0 && elev < 0 && !moonset) moonset = new Date(t.getTime() - 5 * 60000).toISOString()
|
||||
}
|
||||
prev = elev
|
||||
if (moonrise && moonset) break
|
||||
}
|
||||
|
||||
return { moon_moonrise: moonrise, moon_moonset: moonset }
|
||||
}
|
||||
|
||||
function nextSolsticeEquinox(date = new Date()) {
|
||||
const y = date.getFullYear()
|
||||
const events = [
|
||||
{ name: 'March Equinox', date: new Date(Date.UTC(y, 2, 20)) },
|
||||
{ name: 'June Solstice', date: new Date(Date.UTC(y, 5, 21)) },
|
||||
{ name: 'September Equinox', date: new Date(Date.UTC(y, 8, 22)) },
|
||||
{ name: 'December Solstice', date: new Date(Date.UTC(y, 11, 21)) },
|
||||
{ name: 'March Equinox', date: new Date(Date.UTC(y+1, 2, 20)) },
|
||||
]
|
||||
const next = events.find(e => e.date > date)
|
||||
const prev = [...events].reverse().find(e => e.date <= date)
|
||||
return {
|
||||
season_next_event: next.name,
|
||||
season_next_event_date: next.date.toISOString(),
|
||||
season_last_event: prev?.name || null,
|
||||
season_last_event_date: prev?.date.toISOString() || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function getCelestialCurrent(lat, lon, date = new Date()) {
|
||||
return {
|
||||
...sunPosition(lat, lon, date),
|
||||
...sunriseSunset(lat, lon, date),
|
||||
...moonPhase(date),
|
||||
...nextMoonEvents(date),
|
||||
...moonriseMoonset(lat, lon, date),
|
||||
...nextSolsticeEquinox(date),
|
||||
}
|
||||
}
|
||||
|
||||
export function getCelestialHourly(lat, lon, hours) {
|
||||
return hours.map(({ time }) => {
|
||||
const d = new Date(time)
|
||||
const pos = sunPosition(lat, lon, d)
|
||||
const phase = moonPhase(d)
|
||||
return { time, ...pos, moon_illumination_pct: phase.moon_illumination_pct, moon_phase: phase.moon_phase }
|
||||
})
|
||||
}
|
||||
|
||||
export function getCelestialDaily(lat, lon, days) {
|
||||
return days.map(({ time }) => {
|
||||
const d = new Date(time)
|
||||
return {
|
||||
time,
|
||||
...sunriseSunset(lat, lon, d),
|
||||
...moonPhase(d),
|
||||
...nextMoonEvents(d),
|
||||
...moonriseMoonset(lat, lon, d),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { config } from 'dotenv'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
export function cfg() {
|
||||
config({ path: resolve(ROOT, '.env'), override: true })
|
||||
config({ path: resolve(ROOT, '.env.local'), override: true })
|
||||
return {
|
||||
PORT: process.env.PORT || 3000,
|
||||
INFLUX_URL: process.env.INFLUX_URL || 'http://localhost:8086',
|
||||
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
|
||||
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
|
||||
INFLUX_BUCKET: process.env.INFLUX_BUCKET || 'station',
|
||||
DEFAULT_LAT: parseFloat(process.env.DEFAULT_LAT || '0'),
|
||||
DEFAULT_LON: parseFloat(process.env.DEFAULT_LON || '0'),
|
||||
DEFAULT_ALT: parseFloat(process.env.DEFAULT_ALT || '0'),
|
||||
}
|
||||
}
|
||||
BIN
server/data/aircraft.db
Normal file
@@ -1,52 +0,0 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'public', 'icons')
|
||||
|
||||
export const WMO = {
|
||||
0: { label: 'Clear Sky', icon: '01d' },
|
||||
1: { label: 'Mainly Clear', icon: '01d' },
|
||||
2: { label: 'Partly Cloudy', icon: '02d' },
|
||||
3: { label: 'Overcast', icon: '04d' },
|
||||
45: { label: 'Fog', icon: '50d' },
|
||||
48: { label: 'Icy Fog', icon: '50d' },
|
||||
51: { label: 'Light Drizzle', icon: '09d' },
|
||||
53: { label: 'Drizzle', icon: '09d' },
|
||||
55: { label: 'Heavy Drizzle', icon: '09d' },
|
||||
61: { label: 'Light Rain', icon: '10d' },
|
||||
63: { label: 'Rain', icon: '10d' },
|
||||
65: { label: 'Heavy Rain', icon: '10d' },
|
||||
71: { label: 'Light Snow', icon: '13d' },
|
||||
73: { label: 'Snow', icon: '13d' },
|
||||
75: { label: 'Heavy Snow', icon: '13d' },
|
||||
77: { label: 'Snow Grains', icon: '13d' },
|
||||
80: { label: 'Light Showers', icon: '09d' },
|
||||
81: { label: 'Showers', icon: '09d' },
|
||||
82: { label: 'Heavy Showers', icon: '09d' },
|
||||
85: { label: 'Snow Showers', icon: '13d' },
|
||||
86: { label: 'Heavy Snow Showers', icon: '13d' },
|
||||
95: { label: 'Thunderstorm', icon: '11d' },
|
||||
96: { label: 'Thunderstorm w/ Hail', icon: '11d' },
|
||||
99: { label: 'Thunderstorm w/ Hail', icon: '11d' },
|
||||
}
|
||||
|
||||
export function weatherFromCode(code, isDay = true) {
|
||||
const w = WMO[code] ?? { label: 'Unknown', icon: '01d' }
|
||||
const icon = isDay ? w.icon : w.icon.replace('d', 'n')
|
||||
return { weather_code: code, weather_label: w.label, weather_icon: `/icons/${icon}.png` }
|
||||
}
|
||||
|
||||
export async function downloadIcons() {
|
||||
if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true })
|
||||
const icons = new Set(Object.values(WMO).map(w => [w.icon, w.icon.replace('d','n')]).flat())
|
||||
await Promise.all([...icons].map(async icon => {
|
||||
const path = resolve(DIR, `${icon}.png`)
|
||||
if (existsSync(path)) return
|
||||
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`)
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
writeFileSync(path, buf)
|
||||
console.log(` 📥 Downloaded icon: ${icon}.png`)
|
||||
}))
|
||||
console.log('✅ Weather icons ready')
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { InfluxDB } from '@influxdata/influxdb-client'
|
||||
import { cfg } from './config.mjs'
|
||||
|
||||
let client, currentUrl, currentToken
|
||||
|
||||
function getClient() {
|
||||
const c = cfg()
|
||||
if (c.INFLUX_URL !== currentUrl || c.INFLUX_TOKEN !== currentToken) {
|
||||
client = new InfluxDB({ url: c.INFLUX_URL, token: c.INFLUX_TOKEN })
|
||||
currentUrl = c.INFLUX_URL
|
||||
currentToken = c.INFLUX_TOKEN
|
||||
}
|
||||
return { client, c }
|
||||
}
|
||||
|
||||
async function query(flux) {
|
||||
const { client, c } = getClient()
|
||||
const api = client.getQueryApi(c.INFLUX_ORG)
|
||||
const rows = []
|
||||
return new Promise((resolve, reject) => {
|
||||
api.queryRows(flux, {
|
||||
next(row, meta) { rows.push(meta.toObject(row)) },
|
||||
error(err) { reject(err) },
|
||||
complete() { resolve(rows) },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function truncateToHour(iso) {
|
||||
const d = new Date(iso)
|
||||
d.setMinutes(0, 0, 0)
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`
|
||||
}
|
||||
|
||||
function truncateToDay(iso) {
|
||||
return new Date(iso).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function extractRow(row) {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
if (!k.startsWith('_') && k !== 'result' && k !== 'table') out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function queryCurrent() {
|
||||
const { c } = getClient()
|
||||
const flux = `
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: -1h)
|
||||
|> filter(fn: (r) => r._measurement =~ /.*/)
|
||||
|> last()
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`
|
||||
const rows = await query(flux)
|
||||
const result = {}
|
||||
for (const row of rows) {
|
||||
Object.assign(result, extractRow(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function queryHourly(start, end) {
|
||||
const { c } = getClient()
|
||||
const flux = `
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => r._measurement =~ /.*/)
|
||||
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`
|
||||
const rows = await query(flux)
|
||||
return rows.map(row => ({
|
||||
time: truncateToHour(row._time),
|
||||
...extractRow(row),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function queryDaily(start, end) {
|
||||
const { c } = getClient()
|
||||
const measurements = ['environment', 'light', 'seismic', 'compass', 'ground', 'accumulation', 'lightning', 'gps']
|
||||
const results = {}
|
||||
|
||||
for (const m of measurements) {
|
||||
const [means, mins, maxs] = await Promise.all([
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: min, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
])
|
||||
|
||||
// Mean values are the base — flat field names, no suffix
|
||||
for (const row of means) {
|
||||
const time = truncateToDay(row._time)
|
||||
if (!results[time]) results[time] = { time }
|
||||
Object.assign(results[time], extractRow(row))
|
||||
}
|
||||
|
||||
// Only promote temp min/max to named fields, ignore the rest
|
||||
for (const row of mins) {
|
||||
const time = truncateToDay(row._time)
|
||||
if (!results[time]) results[time] = { time }
|
||||
if (row.env_temp_c != null) results[time].env_temp_min_c = row.env_temp_c
|
||||
}
|
||||
for (const row of maxs) {
|
||||
const time = truncateToDay(row._time)
|
||||
if (!results[time]) results[time] = { time }
|
||||
if (row.env_temp_c != null) results[time].env_temp_max_c = row.env_temp_c
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(results).sort((a, b) => a.time.localeCompare(b.time))
|
||||
}
|
||||
|
||||
export async function getCoords() {
|
||||
const { c } = getClient()
|
||||
const flux = `
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: -24h)
|
||||
|> filter(fn: (r) => r._measurement == "gps")
|
||||
|> last()
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`
|
||||
const rows = await query(flux)
|
||||
if (rows.length && rows[0].gps_lat && rows[0].gps_lon) {
|
||||
return { lat: rows[0].gps_lat, lon: rows[0].gps_lon, alt: rows[0].gps_alt_m || c.DEFAULT_ALT }
|
||||
}
|
||||
return { lat: c.DEFAULT_LAT, lon: c.DEFAULT_LON, alt: c.DEFAULT_ALT }
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { pipeline } from 'stream/promises'
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
||||
const CACHE = {}
|
||||
const CACHE_MS = 15 * 60 * 1000
|
||||
|
||||
if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true })
|
||||
|
||||
// WMO weather code map
|
||||
const WMO = {
|
||||
0: { label: 'Clear Sky' },
|
||||
1: { label: 'Mainly Clear' },
|
||||
2: { label: 'Partly Cloudy' },
|
||||
3: { label: 'Overcast' },
|
||||
45: { label: 'Fog' },
|
||||
48: { label: 'Icy Fog' },
|
||||
51: { label: 'Light Drizzle' },
|
||||
53: { label: 'Drizzle' },
|
||||
55: { label: 'Heavy Drizzle' },
|
||||
61: { label: 'Light Rain' },
|
||||
63: { label: 'Rain' },
|
||||
65: { label: 'Heavy Rain' },
|
||||
71: { label: 'Light Snow' },
|
||||
73: { label: 'Snow' },
|
||||
75: { label: 'Heavy Snow' },
|
||||
77: { label: 'Snow Grains' },
|
||||
80: { label: 'Light Showers' },
|
||||
81: { label: 'Showers' },
|
||||
82: { label: 'Heavy Showers' },
|
||||
85: { label: 'Snow Showers' },
|
||||
86: { label: 'Heavy Snow Showers' },
|
||||
95: { label: 'Thunderstorm' },
|
||||
96: { label: 'Thunderstorm w/ Hail' },
|
||||
99: { label: 'Thunderstorm w/ Heavy Hail' },
|
||||
}
|
||||
|
||||
// OWM icon mapping by WMO code + is_day
|
||||
const OWM_ICONS = {
|
||||
0: { day: '01d', night: '01n' },
|
||||
1: { day: '01d', night: '01n' },
|
||||
2: { day: '02d', night: '02n' },
|
||||
3: { day: '04d', night: '04n' },
|
||||
45: { day: '50d', night: '50n' },
|
||||
48: { day: '50d', night: '50n' },
|
||||
51: { day: '09d', night: '09n' },
|
||||
53: { day: '09d', night: '09n' },
|
||||
55: { day: '09d', night: '09n' },
|
||||
61: { day: '10d', night: '10n' },
|
||||
63: { day: '10d', night: '10n' },
|
||||
65: { day: '10d', night: '10n' },
|
||||
71: { day: '13d', night: '13n' },
|
||||
73: { day: '13d', night: '13n' },
|
||||
75: { day: '13d', night: '13n' },
|
||||
77: { day: '13d', night: '13n' },
|
||||
80: { day: '09d', night: '09n' },
|
||||
81: { day: '09d', night: '09n' },
|
||||
82: { day: '09d', night: '09n' },
|
||||
85: { day: '13d', night: '13n' },
|
||||
86: { day: '13d', night: '13n' },
|
||||
95: { day: '11d', night: '11n' },
|
||||
96: { day: '11d', night: '11n' },
|
||||
99: { day: '11d', night: '11n' },
|
||||
}
|
||||
|
||||
async function ensureIcon(code, isDay) {
|
||||
const variant = isDay ? 'day' : 'night'
|
||||
const owmCode = OWM_ICONS[code]?.[variant] || '01d'
|
||||
const filename = `${owmCode}.png`
|
||||
const filepath = resolve(ICON_DIR, filename)
|
||||
if (!existsSync(filepath)) {
|
||||
const url = `https://openweathermap.org/img/wn/${owmCode}@2x.png`
|
||||
const res = await fetch(url)
|
||||
await pipeline(res.body, createWriteStream(filepath))
|
||||
}
|
||||
return `/icons/${filename}`
|
||||
}
|
||||
|
||||
export async function resolveWeather(code, isDay = 1) {
|
||||
const label = WMO[code]?.label || 'Unknown'
|
||||
const icon = await ensureIcon(code, isDay).catch(() => null)
|
||||
return { forecast_weather_label: label, forecast_weather_icon: icon, forecast_weathercode: code }
|
||||
}
|
||||
|
||||
async function cachedFetch(key, url) {
|
||||
const now = Date.now()
|
||||
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
CACHE[key] = { ts: now, data }
|
||||
return data
|
||||
}
|
||||
|
||||
const HOURLY_WEATHER = [
|
||||
'temperature_2m', 'relative_humidity_2m', 'precipitation', 'precipitation_probability',
|
||||
'weathercode', 'windspeed_10m', 'winddirection_10m', 'windgusts_10m',
|
||||
'pressure_msl', 'cloudcover', 'visibility', 'cape', 'uv_index', 'is_day',
|
||||
'et0_fao_evapotranspiration', 'dewpoint_2m', 'apparent_temperature',
|
||||
'snowfall', 'snow_depth', 'freezinglevel_height',
|
||||
].join(',')
|
||||
|
||||
const DAILY_WEATHER = [
|
||||
'temperature_2m_max', 'temperature_2m_min', 'precipitation_sum',
|
||||
'precipitation_probability_max', 'windspeed_10m_max', 'windgusts_10m_max',
|
||||
'winddirection_10m_dominant', 'weathercode', 'sunrise', 'sunset',
|
||||
'uv_index_max', 'snowfall_sum', 'precipitation_hours', 'shortwave_radiation_sum',
|
||||
].join(',')
|
||||
|
||||
const HOURLY_AQ = [
|
||||
'pm10', 'pm2_5', 'carbon_monoxide', 'nitrogen_dioxide', 'sulphur_dioxide',
|
||||
'ozone', 'aerosol_optical_depth', 'dust', 'uv_index',
|
||||
'alder_pollen', 'birch_pollen', 'grass_pollen',
|
||||
'mugwort_pollen', 'olive_pollen', 'ragweed_pollen',
|
||||
].join(',')
|
||||
|
||||
const HOURLY_MARINE = [
|
||||
'wave_height', 'wave_direction', 'wave_period',
|
||||
'wind_wave_height', 'swell_wave_height', 'swell_wave_direction', 'swell_wave_period',
|
||||
].join(',')
|
||||
|
||||
function zipHourly(data, prefix = '') {
|
||||
if (!data?.hourly) return []
|
||||
return data.hourly.time.map((time, i) => {
|
||||
const row = { time }
|
||||
for (const [k, v] of Object.entries(data.hourly)) {
|
||||
if (k !== 'time') row[prefix + k] = v[i]
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
function zipDaily(data, prefix = '') {
|
||||
if (!data?.daily) return []
|
||||
return data.daily.time.map((time, i) => {
|
||||
const row = { time }
|
||||
for (const [k, v] of Object.entries(data.daily)) {
|
||||
if (k !== 'time') row[prefix + k] = v[i]
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
// Remap Open-Meteo field names to our prefixed convention
|
||||
function remapWeatherFields(row) {
|
||||
const map = {
|
||||
temperature_2m: 'env_temp_c',
|
||||
apparent_temperature: 'env_heat_index_c',
|
||||
dewpoint_2m: 'env_dew_point_c',
|
||||
relative_humidity_2m: 'env_humidity',
|
||||
pressure_msl: 'env_pressure_slp',
|
||||
cloudcover: 'light_cloud_pct',
|
||||
uv_index: 'light_uvi',
|
||||
visibility: 'light_visibility_km',
|
||||
windspeed_10m: 'wind_speed_kmh',
|
||||
winddirection_10m: 'wind_direction_deg',
|
||||
windgusts_10m: 'wind_gusts_kmh',
|
||||
weathercode: 'forecast_weathercode',
|
||||
precipitation: 'forecast_precipitation_mm',
|
||||
precipitation_probability: 'forecast_precipitation_probability',
|
||||
snowfall: 'forecast_snowfall_mm',
|
||||
snow_depth: 'forecast_snow_depth_m',
|
||||
cape: 'forecast_cape',
|
||||
is_day: 'sun_is_day',
|
||||
freezinglevel_height: 'forecast_freezing_level_m',
|
||||
et0_fao_evapotranspiration: 'forecast_evapotranspiration_mm',
|
||||
// daily
|
||||
temperature_2m_max: 'env_temp_max_c',
|
||||
temperature_2m_min: 'env_temp_min_c',
|
||||
precipitation_sum: 'forecast_precipitation_mm',
|
||||
precipitation_probability_max: 'forecast_precipitation_probability',
|
||||
windspeed_10m_max: 'wind_speed_max_kmh',
|
||||
windgusts_10m_max: 'wind_gusts_max_kmh',
|
||||
winddirection_10m_dominant: 'wind_direction_deg',
|
||||
uv_index_max: 'light_uvi_max',
|
||||
snowfall_sum: 'forecast_snowfall_mm',
|
||||
shortwave_radiation_sum: 'light_solar_wm2_sum',
|
||||
sunrise: 'sun_sunrise',
|
||||
sunset: 'sun_sunset',
|
||||
}
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
out[map[k] || k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function getOpenMeteo(lat, lon, start, end) {
|
||||
const base = start && end
|
||||
? `&start_date=${start.slice(0,10)}&end_date=${end.slice(0,10)}&past_days=0`
|
||||
: `&forecast_days=7`
|
||||
|
||||
const [weather, aq, marine] = await Promise.allSettled([
|
||||
cachedFetch(`weather_${lat}_${lon}_${start}_${end}`,
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_WEATHER}&daily=${DAILY_WEATHER}¤t_weather=true&timezone=auto${base}`),
|
||||
cachedFetch(`aq_${lat}_${lon}_${start}_${end}`,
|
||||
`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_AQ}&timezone=auto${base}`),
|
||||
cachedFetch(`marine_${lat}_${lon}_${start}_${end}`,
|
||||
`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_MARINE}&daily=wave_height_max,wave_direction_dominant,wave_period_max&timezone=auto${base}`),
|
||||
])
|
||||
|
||||
const w = weather.status === 'fulfilled' ? weather.value : {}
|
||||
const a = aq.status === 'fulfilled' ? aq.value : {}
|
||||
const m = marine.status === 'fulfilled' ? marine.value : {}
|
||||
|
||||
// Build hourly merged map
|
||||
const hourlyMap = {}
|
||||
for (const row of zipHourly(w)) {
|
||||
const remapped = remapWeatherFields(row)
|
||||
hourlyMap[row.time] = remapped
|
||||
}
|
||||
for (const row of zipHourly(a)) {
|
||||
const { time, ...rest } = row
|
||||
hourlyMap[time] = { ...hourlyMap[time], ...rest }
|
||||
}
|
||||
for (const row of zipHourly(m)) {
|
||||
const { time, ...rest } = row
|
||||
hourlyMap[time] = { ...hourlyMap[time], ...rest }
|
||||
}
|
||||
|
||||
// Build daily merged map
|
||||
const dailyMap = {}
|
||||
for (const row of zipDaily(w)) {
|
||||
const remapped = remapWeatherFields(row)
|
||||
dailyMap[row.time] = remapped
|
||||
}
|
||||
for (const row of zipDaily(m)) {
|
||||
const { time, ...rest } = row
|
||||
dailyMap[time] = { ...dailyMap[time], ...rest }
|
||||
}
|
||||
|
||||
// Resolve current weather
|
||||
let current = null
|
||||
if (w.current_weather) {
|
||||
const cw = w.current_weather
|
||||
const wInfo = await resolveWeather(cw.weathercode, cw.is_day).catch(() => ({}))
|
||||
current = {
|
||||
wind_speed_kmh: cw.windspeed,
|
||||
wind_direction_deg: cw.winddirection,
|
||||
sun_is_day: cw.is_day,
|
||||
...wInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve weather labels + icons for all hourly/daily rows
|
||||
const hourlyArr = await Promise.all(
|
||||
Object.values(hourlyMap)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(async row => {
|
||||
if (row.forecast_weathercode != null) {
|
||||
const wInfo = await resolveWeather(row.forecast_weathercode, row.sun_is_day ?? 1).catch(() => ({}))
|
||||
return { ...row, ...wInfo }
|
||||
}
|
||||
return row
|
||||
})
|
||||
)
|
||||
|
||||
const dailyArr = await Promise.all(
|
||||
Object.values(dailyMap)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(async row => {
|
||||
if (row.forecast_weathercode != null) {
|
||||
const wInfo = await resolveWeather(row.forecast_weathercode, 1).catch(() => ({}))
|
||||
return { ...row, ...wInfo }
|
||||
}
|
||||
return row
|
||||
})
|
||||
)
|
||||
|
||||
return { current, hourly: hourlyArr, daily: dailyArr }
|
||||
}
|
||||
2727
server/package-lock.json
generated
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"name": "weather-station",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"@scalar/express-api-reference": "^0.10.4",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
"name": "weather-station",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scalar/express-api-reference": "^0.10.4",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"adm-zip": "^0.5.17",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 948 B |
|
Before Width: | Height: | Size: 945 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,142 +0,0 @@
|
||||
import express from 'express'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { cfg } from './config.mjs'
|
||||
import { queryCurrent, queryHourly, queryDaily, getCoords } from './influx.mjs'
|
||||
import { getCelestialCurrent, getCelestialHourly, getCelestialDaily } from './celestial.mjs'
|
||||
import { getSpaceWeather } from './space.mjs'
|
||||
import { getOpenMeteo } from './openmeteo.mjs'
|
||||
import { apiReference } from '@scalar/express-api-reference'
|
||||
import { spec } from './spec.mjs'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
const app = express()
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const CLIENT_DIST = resolve(DIR, 'public')
|
||||
|
||||
app.use(express.json())
|
||||
app.use('/icons', express.static(resolve(DIR, 'public', 'icons')))
|
||||
app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next() })
|
||||
app.use(express.static(CLIENT_DIST))
|
||||
|
||||
function filterFields(obj, fields) {
|
||||
if (!fields) return obj
|
||||
const keys = new Set(fields.split(',').map(f => f.trim()))
|
||||
return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.has(k)))
|
||||
}
|
||||
|
||||
function filterArr(arr, fields) {
|
||||
if (!fields) return arr
|
||||
return arr.map(row => filterFields(row, fields))
|
||||
}
|
||||
|
||||
function mergeRows(arrays) {
|
||||
const map = {}
|
||||
for (const arr of arrays) {
|
||||
for (const row of arr) {
|
||||
map[row.time] = { ...map[row.time], ...row }
|
||||
}
|
||||
}
|
||||
return Object.values(map).sort((a, b) => a.time.localeCompare(b.time))
|
||||
}
|
||||
|
||||
// ── GET /api/data ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/data', async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
|
||||
const [sensor, space, meteo] = await Promise.allSettled([
|
||||
queryCurrent(),
|
||||
getSpaceWeather(),
|
||||
getOpenMeteo(coords.lat, coords.lon),
|
||||
])
|
||||
|
||||
const celestial = getCelestialCurrent(coords.lat, coords.lon)
|
||||
|
||||
const data = {
|
||||
...(sensor.status === 'fulfilled' ? sensor.value : {}),
|
||||
...celestial,
|
||||
...(space.status === 'fulfilled' ? space.value : {}),
|
||||
...(meteo.status === 'fulfilled' && meteo.value.current ? meteo.value.current : {}),
|
||||
}
|
||||
|
||||
res.json(filterFields(data, fields))
|
||||
})
|
||||
|
||||
// ── GET /api/hourly ───────────────────────────────────────────────────────────
|
||||
app.get('/api/hourly', async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const start = req.query.start || new Date(new Date().setHours(0,0,0,0)).toISOString()
|
||||
const end = req.query.end || new Date().toISOString()
|
||||
const coords = await getCoords()
|
||||
|
||||
const [sensor, meteo, space] = await Promise.allSettled([
|
||||
queryHourly(start, end),
|
||||
getOpenMeteo(coords.lat, coords.lon, start, end),
|
||||
getSpaceWeather(),
|
||||
])
|
||||
|
||||
const sensorRows = sensor.status === 'fulfilled' ? sensor.value : []
|
||||
const meteoHourly = meteo.status === 'fulfilled' ? meteo.value.hourly : []
|
||||
const spaceData = space.status === 'fulfilled' ? space.value : {}
|
||||
const seedRows = sensorRows.length ? sensorRows : meteoHourly
|
||||
const celestial = getCelestialHourly(coords.lat, coords.lon, seedRows)
|
||||
|
||||
// Spread space weather into every hourly row
|
||||
const result = mergeRows([sensorRows, meteoHourly, celestial])
|
||||
.map(row => ({ ...spaceData, ...row }))
|
||||
|
||||
res.json(filterArr(result, fields))
|
||||
})
|
||||
|
||||
// ── GET /api/daily ------------------------------------------------------------
|
||||
|
||||
app.get('/api/daily', async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const start = req.query.start || new Date(new Date().setHours(0,0,0,0)).toISOString()
|
||||
const end = req.query.end || new Date().toISOString()
|
||||
const coords = await getCoords()
|
||||
|
||||
const [sensor, meteo, space] = await Promise.allSettled([
|
||||
queryDaily(start, end),
|
||||
getOpenMeteo(coords.lat, coords.lon, start, end),
|
||||
getSpaceWeather(),
|
||||
])
|
||||
|
||||
const sensorRows = sensor.status === 'fulfilled' ? sensor.value : []
|
||||
const meteoDaily = meteo.status === 'fulfilled' ? meteo.value.daily : []
|
||||
const spaceData = space.status === 'fulfilled' ? space.value : {}
|
||||
const seedRows = sensorRows.length ? sensorRows : meteoDaily
|
||||
const celestial = getCelestialDaily(coords.lat, coords.lon, seedRows)
|
||||
|
||||
const result = mergeRows([sensorRows, meteoDaily, celestial])
|
||||
.map(row => ({ ...spaceData, ...row }))
|
||||
|
||||
res.json(filterArr(result, fields))
|
||||
})
|
||||
|
||||
// ── DOCS ----------------------------------------------------------------------
|
||||
|
||||
app.get('/openapi.json', (req, res) => res.json(spec))
|
||||
app.get('/openapi.yaml', async (req, res) => {
|
||||
const { stringify } = await import('yaml')
|
||||
res.setHeader('Content-Type', 'text/yaml')
|
||||
res.send(stringify(spec))
|
||||
})
|
||||
|
||||
// Scalar UI
|
||||
app.use('/docs', apiReference({ spec: { url: '/openapi.json' }, theme: 'default' }))
|
||||
|
||||
|
||||
// ── SPA Redirect --------------------------------------------------------------
|
||||
app.get('*', (req, res) => {
|
||||
const index = resolve(CLIENT_DIST, 'index.html')
|
||||
if (existsSync(index)) res.sendFile(index)
|
||||
else res.status(404).send('Client not built yet — run npm run build in /client')
|
||||
})
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const c = cfg()
|
||||
app.listen(c.PORT, () => console.log(`🌦 Weather API — http://localhost:${c.PORT}`))
|
||||
215
server/spec.mjs
@@ -1,215 +0,0 @@
|
||||
export const spec = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Weather Station',
|
||||
version: '1.0.0',
|
||||
description: 'Hyperlocal weather station — sensor data, forecasts, celestial & space weather',
|
||||
},
|
||||
servers: [{ url: 'http://localhost:3000' }],
|
||||
paths: {
|
||||
'/api/data': {
|
||||
get: {
|
||||
summary: 'Current conditions',
|
||||
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
|
||||
parameters: [
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current conditions',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/hourly': {
|
||||
get: {
|
||||
summary: 'Hourly data',
|
||||
description: 'Hourly aggregated sensor data merged with Open-Meteo hourly forecast, celestial positions and space weather. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of hourly rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/daily': {
|
||||
get: {
|
||||
summary: 'Daily data',
|
||||
description: 'Daily aggregated sensor data (mean/min/max) merged with Open-Meteo daily forecast and celestial events. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of daily rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
DataRow: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
time: { type: 'string', description: 'ISO8601 timestamp or date' },
|
||||
// Environment
|
||||
env_temp_c: { type: 'number', description: 'Temperature (°C)' },
|
||||
env_temp_f: { type: 'number', description: 'Temperature (°F)' },
|
||||
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
|
||||
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
|
||||
env_humidity: { type: 'number', description: 'Relative humidity (%)' },
|
||||
env_dew_point_c: { type: 'number', description: 'Dew point (°C)' },
|
||||
env_heat_index_c: { type: 'number', description: 'Feels like / heat index (°C)' },
|
||||
env_pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||
env_pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||
env_pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||
env_pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||
env_abs_humidity: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
||||
env_vpd_kpa: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||
env_gas_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
|
||||
env_aqi_score: { type: 'number', description: 'Air quality index score (0–100)' },
|
||||
env_aqi_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
||||
env_frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
|
||||
// Light
|
||||
light_lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||
light_uvi: { type: 'number', description: 'UV index' },
|
||||
light_solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||
light_uv_dose_mj: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
|
||||
light_cloud_pct: { type: 'number', description: 'Estimated cloud cover (%)' },
|
||||
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] },
|
||||
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||
light_daylight_hours: { type: 'number', description: 'Hours above daylight threshold today' },
|
||||
light_visibility_km: { type: 'number', description: 'Estimated visibility (km)' },
|
||||
light_uvi_max: { type: 'number', description: 'Daily max UV index' },
|
||||
light_solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
|
||||
// Wind (Open-Meteo)
|
||||
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
|
||||
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
|
||||
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' },
|
||||
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' },
|
||||
// Forecast
|
||||
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
|
||||
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
|
||||
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. /icons/01d.png' },
|
||||
forecast_precipitation_mm: { type: 'number', description: 'Precipitation (mm)' },
|
||||
forecast_precipitation_probability: { type: 'number', description: 'Precipitation probability (%)' },
|
||||
forecast_snowfall_mm: { type: 'number', description: 'Snowfall (mm)' },
|
||||
forecast_snow_depth_m: { type: 'number', description: 'Snow depth (m)' },
|
||||
forecast_cape: { type: 'number', description: 'Convective available potential energy (J/kg)' },
|
||||
forecast_freezing_level_m: { type: 'number', description: 'Freezing level altitude (m)' },
|
||||
forecast_evapotranspiration_mm:{ type: 'number', description: 'Evapotranspiration (mm)' },
|
||||
// Seismic
|
||||
seismic_ax: { type: 'number', description: 'Peak acceleration X axis (g)' },
|
||||
seismic_ay: { type: 'number', description: 'Peak acceleration Y axis (g)' },
|
||||
seismic_az: { type: 'number', description: 'Peak acceleration Z axis (g, includes gravity)' },
|
||||
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g, gravity removed)' },
|
||||
// Compass
|
||||
compass_heading: { type: 'number', description: 'Magnetic heading (°)' },
|
||||
compass_x: { type: 'number' },
|
||||
compass_y: { type: 'number' },
|
||||
compass_z: { type: 'number' },
|
||||
// Ground / Accumulation
|
||||
ground_distance_cm: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||
ground_lidar_strength: { type: 'number', description: 'LIDAR signal strength' },
|
||||
ground_calibrated_baseline_cm: { type: 'number', description: 'Calibrated baseline distance (cm)' },
|
||||
ground_accumulation_depth_cm: { type: 'number', description: 'Snow/flood depth (cm)' },
|
||||
ground_accumulation_type: { type: 'string', enum: ['snow','slush','ice','flood'], description: 'Accumulation type' },
|
||||
// Lightning
|
||||
lightning_distance_km: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||
lightning_energy: { type: 'number', description: 'Lightning energy' },
|
||||
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||
lightning_storm_trend: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
|
||||
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
|
||||
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (0–7)' },
|
||||
// GPS
|
||||
gps_lat: { type: 'number', description: 'Latitude (°)' },
|
||||
gps_lon: { type: 'number', description: 'Longitude (°)' },
|
||||
gps_alt_m: { type: 'number', description: 'Altitude (m)' },
|
||||
gps_satellites: { type: 'number', description: 'Satellites in view' },
|
||||
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
|
||||
gps_heading: { type: 'number', description: 'GPS heading (°)' },
|
||||
// Sun
|
||||
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||
sun_sunrise: { type: 'string', description: 'Sunrise time (ISO8601)' },
|
||||
sun_sunset: { type: 'string', description: 'Sunset time (ISO8601)' },
|
||||
sun_solar_noon: { type: 'string', description: 'Solar noon (ISO8601)' },
|
||||
sun_day_length_hours: { type: 'number', description: 'Day length (hours)' },
|
||||
sun_golden_hour_morning_start: { type: 'string' },
|
||||
sun_golden_hour_morning_end: { type: 'string' },
|
||||
sun_golden_hour_evening_start: { type: 'string' },
|
||||
sun_golden_hour_evening_end: { type: 'string' },
|
||||
sun_is_day: { type: 'number', description: '1 if daytime' },
|
||||
sun_ssn: { type: 'number', description: 'Estimated sunspot number' },
|
||||
sun_activity_label: { type: 'string', enum: ['Very Low','Low','Moderate','High','Very High'] },
|
||||
// Moon
|
||||
moon_phase: { type: 'string', description: 'Moon phase name' },
|
||||
moon_illumination_pct: { type: 'number', description: 'Moon illumination (%)' },
|
||||
moon_moonrise: { type: 'string', description: 'Moonrise time (ISO8601)' },
|
||||
moon_moonset: { type: 'string', description: 'Moonset time (ISO8601)' },
|
||||
moon_next_full: { type: 'string', description: 'Next full moon (ISO8601)' },
|
||||
moon_next_new: { type: 'string', description: 'Next new moon (ISO8601)' },
|
||||
// Season
|
||||
season_next_event: { type: 'string', description: 'Next solstice/equinox name' },
|
||||
season_next_event_date: { type: 'string' },
|
||||
season_last_event: { type: 'string' },
|
||||
season_last_event_date: { type: 'string' },
|
||||
// Space weather
|
||||
space_kp_index: { type: 'number', description: 'Planetary K index (0–9)' },
|
||||
space_ap_index: { type: 'number', description: 'Ap geomagnetic index' },
|
||||
space_geomagnetic_storm: { type: 'string', enum: ['None','Minor','Moderate','Strong','Severe'] },
|
||||
space_solar_wind_speed_kms: { type: 'number', description: 'Solar wind speed (km/s)' },
|
||||
space_solar_wind_density: { type: 'number', description: 'Solar wind proton density (p/cm³)' },
|
||||
space_solar_wind_temp: { type: 'number', description: 'Solar wind temperature (K)' },
|
||||
space_imf_bz: { type: 'number', description: 'IMF Bz component (nT)' },
|
||||
space_imf_bt: { type: 'number', description: 'IMF total field Bt (nT)' },
|
||||
space_solar_flux_f107: { type: 'number', description: 'Solar flux F10.7 index' },
|
||||
// Marine
|
||||
wave_height: { type: 'number', description: 'Significant wave height (m)' },
|
||||
wave_direction: { type: 'number', description: 'Wave direction (°)' },
|
||||
wave_period: { type: 'number', description: 'Wave period (s)' },
|
||||
swell_wave_height: { type: 'number', description: 'Swell wave height (m)' },
|
||||
swell_wave_direction: { type: 'number', description: 'Swell direction (°)' },
|
||||
swell_wave_period: { type: 'number', description: 'Swell period (s)' },
|
||||
wind_wave_height: { type: 'number', description: 'Wind wave height (m)' },
|
||||
wave_height_max: { type: 'number', description: 'Daily max wave height (m)' },
|
||||
// AQ
|
||||
pm10: { type: 'number', description: 'PM10 (μg/m³)' },
|
||||
pm2_5: { type: 'number', description: 'PM2.5 (μg/m³)' },
|
||||
carbon_monoxide: { type: 'number', description: 'CO (μg/m³)' },
|
||||
nitrogen_dioxide: { type: 'number', description: 'NO₂ (μg/m³)' },
|
||||
sulphur_dioxide: { type: 'number', description: 'SO₂ (μg/m³)' },
|
||||
ozone: { type: 'number', description: 'O₃ (μg/m³)' },
|
||||
aerosol_optical_depth: { type: 'number' },
|
||||
dust: { type: 'number', description: 'Dust (μg/m³)' },
|
||||
alder_pollen: { type: 'number', description: 'Alder pollen (grains/m³)' },
|
||||
birch_pollen: { type: 'number', description: 'Birch pollen (grains/m³)' },
|
||||
grass_pollen: { type: 'number', description: 'Grass pollen (grains/m³)' },
|
||||
mugwort_pollen: { type: 'number', description: 'Mugwort pollen (grains/m³)' },
|
||||
olive_pollen: { type: 'number', description: 'Olive pollen (grains/m³)' },
|
||||
ragweed_pollen: { type: 'number', description: 'Ragweed pollen (grains/m³)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1301
server/src/adsb-shapes.mjs
Normal file
369
server/src/adsb.mjs
Normal file
@@ -0,0 +1,369 @@
|
||||
import { cfg } from './config.mjs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as fs from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { fromCsv } from '@ztimson/utils';
|
||||
import {getIcon} from './adsb-shapes.mjs';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = resolve(DIR, '../data');
|
||||
const DB_PATH = resolve(DATA, 'aircraft.db');
|
||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||
const MAX_HISTORY = 500;
|
||||
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
const ADSB_TTL = 1000;
|
||||
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
||||
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
|
||||
|
||||
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
||||
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
||||
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
||||
|
||||
let adsbCache = null;
|
||||
let adsbCacheTs = 0;
|
||||
const noRecord = [];
|
||||
const history = new Map();
|
||||
const milRanges = [];
|
||||
let db;
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - HISTORY_TTL;
|
||||
for (const [key, trail] of history) {
|
||||
const last = trail.at(-1);
|
||||
if (!last || last.ts < cutoff) history.delete(key);
|
||||
}
|
||||
}, 1000 * 60 * 5);
|
||||
|
||||
function fetchWithTimeout(url, ms = 5000) {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), ms);
|
||||
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
|
||||
}
|
||||
|
||||
async function syncMilitaryRanges() {
|
||||
const { ADSB_URL } = cfg();
|
||||
try {
|
||||
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { military } = await res.json();
|
||||
|
||||
db.exec('DELETE FROM military_ranges');
|
||||
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
||||
const insertMany = db.transaction(ranges => {
|
||||
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
||||
});
|
||||
|
||||
insertMany(military);
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
||||
} catch (e) {
|
||||
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...rows.map(r => [r.start, r.end]));
|
||||
}
|
||||
}
|
||||
|
||||
export async function initAircraftDb() {
|
||||
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
|
||||
|
||||
const missing = !fs.existsSync(DB_PATH);
|
||||
if (missing) console.log(`✈️ Building database`);
|
||||
|
||||
db = new Database(DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS aircraft (
|
||||
icao TEXT PRIMARY KEY,
|
||||
built TEXT,
|
||||
categoryDescription TEXT,
|
||||
country TEXT,
|
||||
engines TEXT,
|
||||
class TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
modes TEXT,
|
||||
operator TEXT,
|
||||
operatorCallsign TEXT,
|
||||
owner TEXT,
|
||||
registration TEXT,
|
||||
serialNumber TEXT,
|
||||
aircraft TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS military_ranges (
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await syncMilitaryRanges();
|
||||
|
||||
if (missing) {
|
||||
const res = await fetch(CSV_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
fs.writeFileSync(CSV_CACHE, text);
|
||||
|
||||
const csv = fromCsv(text, true);
|
||||
const insert = db.prepare(`
|
||||
INSERT OR REPLACE INTO aircraft VALUES (
|
||||
@icao, @built, @categoryDescription, @country, @engines,
|
||||
@class, @manufacturer, @model, @modes, @operator,
|
||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
||||
)
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction(rows => {
|
||||
for (const row of rows) insert.run(row);
|
||||
});
|
||||
|
||||
insertMany(csv.map(row => ({
|
||||
icao: (row.icao24 || '').toUpperCase(),
|
||||
built: row.built || null,
|
||||
categoryDescription: row.categoryDescription || null,
|
||||
country: row.country || null,
|
||||
engines: row.engines || null,
|
||||
class: row.icaoAircraftClass || null,
|
||||
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
|
||||
model: row.model || null,
|
||||
modes: row.modes || null,
|
||||
operator: row.operator || null,
|
||||
operatorCallsign: row.operatorCallsign || null,
|
||||
owner: row.owner || null,
|
||||
registration: row.registration || null,
|
||||
serialNumber: row.serialNumber || null,
|
||||
aircraft: row.typecode || null,
|
||||
})));
|
||||
|
||||
fs.unlinkSync(CSV_CACHE);
|
||||
console.log(`✈️ Aircraft imported: ${csv.length}`);
|
||||
} else {
|
||||
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
|
||||
console.log(`✈️ Aircraft loaded: ${count.c}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isIcaoInMilitaryRange(icao) {
|
||||
if (!icao) return false;
|
||||
const hex = icao.toUpperCase();
|
||||
return milRanges.some(([s, e]) => hex >= s && hex <= e);
|
||||
}
|
||||
|
||||
function wordMatch(text, keyword) {
|
||||
if (!text) return false;
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/gi, '\\$&');
|
||||
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
||||
}
|
||||
|
||||
export function classifyAircraft(row) {
|
||||
if (!row) return 'unknown';
|
||||
|
||||
const matchesAny = (fields, list) =>
|
||||
fields.some(f => list.some(k => wordMatch(f, k)));
|
||||
|
||||
const operatorFields = [row.operator, row.operatorCallsign];
|
||||
const ownerFields = [row.owner];
|
||||
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
||||
|
||||
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
|
||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
|
||||
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
||||
if (row.owner && !row.operator) return 'private';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function fetchHexDb(icao) {
|
||||
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
|
||||
if (!resp.ok) return null;
|
||||
const found = await resp.json();
|
||||
return {
|
||||
registration: found.Registration || null,
|
||||
manufacturer: found.Manufacturer || null,
|
||||
aircraft: found.ICAOTypeCode || null,
|
||||
model: found.Type || null,
|
||||
operator: found.RegisteredOwners || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function scrapeHexDatabase(icao) {
|
||||
const url = `https://hexdatabase.com/h/${icao}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const html = await resp.text();
|
||||
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
||||
if (!tableMatch) return null;
|
||||
|
||||
const $ = cheerio.load(tableMatch[0]);
|
||||
const row = $('tr').filter((_, el) => $(el).text().toLowerCase().includes(icao.toLowerCase())).first();
|
||||
if (!row.length) return null;
|
||||
|
||||
const cells = row.find('td');
|
||||
return {
|
||||
registration: $(cells[1]).text().trim() || null,
|
||||
aircraft: $(cells[2]).text().trim() || null,
|
||||
operator: $(cells[3]).text().trim() || null,
|
||||
serialNumber: $(cells[5]).text().trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function backfillFromSimilar(aircraft) {
|
||||
if (!aircraft) return {};
|
||||
const similar = db.prepare(`
|
||||
SELECT manufacturer, model, engines, categoryDescription, class
|
||||
FROM aircraft
|
||||
WHERE aircraft = ?
|
||||
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
|
||||
LIMIT 1
|
||||
`).get(aircraft);
|
||||
if (!similar) return {};
|
||||
return {
|
||||
manufacturer: similar.manufacturer || null,
|
||||
model: similar.model || null,
|
||||
engines: similar.engines || null,
|
||||
categoryDescription: similar.categoryDescription || null,
|
||||
class: similar.class || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrichAircraft(a) {
|
||||
if (!a.hex) return a;
|
||||
const icao = a.hex.toUpperCase();
|
||||
|
||||
// 1. Check own DB first
|
||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
||||
if(row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
|
||||
// 2. Race the two external sources
|
||||
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||
const hexDbPromise = fetchHexDb(icao);
|
||||
const scrapePromise = scrapeHexDatabase(icao);
|
||||
let found = await hexDbPromise.catch(() => {});
|
||||
if(!found) found = await scrapePromise.catch(() => {});
|
||||
if(!found) {
|
||||
noRecord.push(icao);
|
||||
return { icao, ...a, type: 'unknown' };
|
||||
}
|
||||
|
||||
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
|
||||
const merged = { ...row, ...found };
|
||||
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
||||
const similar = backfillFromSimilar(merged.aircraft);
|
||||
Object.assign(merged, similar);
|
||||
}
|
||||
|
||||
// 4. Save back to DB
|
||||
if (found) {
|
||||
db.prepare(`
|
||||
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
||||
VALUES (@icao, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
|
||||
ON CONFLICT(icao) DO UPDATE SET
|
||||
registration = COALESCE(excluded.registration, registration),
|
||||
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
|
||||
aircraft = COALESCE(excluded.aircraft, aircraft),
|
||||
model = COALESCE(excluded.model, model),
|
||||
operator = COALESCE(excluded.operator, operator),
|
||||
country = COALESCE(excluded.country, country),
|
||||
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
|
||||
engines = COALESCE(excluded.engines, engines),
|
||||
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
|
||||
class = COALESCE(excluded.class, class)
|
||||
`).run({
|
||||
icao,
|
||||
registration: merged.registration || null,
|
||||
manufacturer: merged.manufacturer || null,
|
||||
aircraft: merged.aircraft || null,
|
||||
model: merged.model || null,
|
||||
operator: merged.operator || null,
|
||||
country: merged.country || null,
|
||||
serialNumber: merged.serialNumber || null,
|
||||
engines: merged.engines || null,
|
||||
categoryDescription: merged.categoryDescription || null,
|
||||
class: merged.class || null,
|
||||
});
|
||||
}
|
||||
|
||||
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
|
||||
}
|
||||
|
||||
export async function getADSB() {
|
||||
if (adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||
const { ADSB_URL } = cfg();
|
||||
if (!ADSB_URL) return [];
|
||||
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
|
||||
const j = await r.json();
|
||||
const aircraft = j.aircraft || [];
|
||||
|
||||
for (const a of aircraft) {
|
||||
a.type = 'unknown';
|
||||
if (!a.hex || !a.lat || !a.lon) continue;
|
||||
const key = a.hex.toLowerCase();
|
||||
if (!history.has(key)) history.set(key, []);
|
||||
const trail = history.get(key);
|
||||
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() });
|
||||
if (trail.length > MAX_HISTORY) trail.shift();
|
||||
}
|
||||
|
||||
adsbCache = await Promise.all(aircraft.map(async a => {
|
||||
a = await enrichAircraft(a);
|
||||
a.icon = getIcon(a);
|
||||
return a;
|
||||
}));
|
||||
adsbCacheTs = Date.now();
|
||||
return adsbCache;
|
||||
}
|
||||
|
||||
export async function getADSBHistory(icao) {
|
||||
return { history: history.get(icao?.toLowerCase()) || [] };
|
||||
}
|
||||
|
||||
export async function getADSBRange() {
|
||||
const { ADSB_URL } = cfg();
|
||||
if (!ADSB_URL) return [];
|
||||
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
|
||||
const j = await r.json();
|
||||
return j?.actualRange?.last24h?.points || [];
|
||||
}
|
||||
|
||||
export async function getADSBImage(icao) {
|
||||
async function duckduckgo(query) {
|
||||
async function getVqd(query) {
|
||||
const res = await fetch(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
})
|
||||
const html = await res.text()
|
||||
const match = html.match(/vqd=['"]?([\d-]+)['"]?/)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
const vqd = await getVqd(query)
|
||||
if (!vqd) throw new Error('Could not get vqd token')
|
||||
|
||||
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${vqd}&f=,,,,,&p=1`
|
||||
const data = await fetch(url,
|
||||
{headers: {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://duckduckgo.com/'}}
|
||||
).then(resp => resp.json());
|
||||
return data?.results?.[0]?.image || null;
|
||||
}
|
||||
|
||||
|
||||
const c = cfg();
|
||||
let aircraft = (await adsbCache).find(a => a.icao?.toLowerCase() === icao?.toLowerCase());
|
||||
if(!aircraft) aircraft = enrichAircraft({hex: icao});
|
||||
if(!aircraft) return null;
|
||||
|
||||
const generic = duckduckgo(aircraft.model ? `${aircraft.manufacturer} ${aircraft.model}` : `${aircraft.aircraft} Aircraft`);
|
||||
const specific = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao.toLowerCase()}`,
|
||||
{headers: {'User-Agent': `Personal ADSB Feeder + Dashboard (${c.EMAIL})`}}
|
||||
).then(resp => resp.ok ? resp.json() : null);
|
||||
|
||||
let src;
|
||||
if(specific?.photos?.[0]) src = specific.photos[0].thumbnail_large?.src || specific.photos[0].thumbnail?.src;
|
||||
else src = await generic;
|
||||
|
||||
if(!src) return null;
|
||||
return fetch(src).then(resp => resp.blob());
|
||||
}
|
||||
107
server/src/ais.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import {cfg} from './config.mjs';
|
||||
|
||||
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
|
||||
const SENDER_TYPE = {
|
||||
0: 'Other', 1: 'Unknown', 2: 'Cargo', 3: 'Class B',
|
||||
4: 'Passenger', 5: 'Special', 6: 'Tanker', 7: 'High Speed',
|
||||
8: 'Fishing', 9: 'Aircraft', 10: 'Helicopter', 11: 'Base Station',
|
||||
12: 'AtoN', 13: 'SART/EPIRB'
|
||||
};
|
||||
|
||||
let aisCache = null;
|
||||
let aisCacheTs = 0;
|
||||
const AIS_TTL = 1000;
|
||||
|
||||
function decodeMsgTypes(raw) {
|
||||
const types = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (raw & (1 << i)) types.push(i);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
function decodeFlags(flags) {
|
||||
return {
|
||||
validated: flags & 3,
|
||||
validated2: (flags & 3) == 2 ? -1 : flags & 3,
|
||||
repeat: (flags >> 2) & 3,
|
||||
virtual_aid: (flags >> 4) & 1,
|
||||
approx: (flags >> 5) & 1,
|
||||
channels: (flags >> 6) & 15,
|
||||
cs_unit: (flags >> 10) & 3,
|
||||
raim: (flags >> 12) & 3,
|
||||
dte: (flags >> 14) & 3,
|
||||
assigned: (flags >> 16) & 3,
|
||||
display: (flags >> 18) & 3,
|
||||
dsc: (flags >> 20) & 3,
|
||||
band: (flags >> 22) & 3,
|
||||
msg22: (flags >> 24) & 3,
|
||||
off_position: (flags >> 26) & 3,
|
||||
maneuver: (flags >> 28) & 3,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAIS() {
|
||||
if (aisCache && Date.now() - aisCacheTs < AIS_TTL) return aisCache;
|
||||
const {ADSB_URL} = cfg();
|
||||
if (!ADSB_URL) return {data: []};
|
||||
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
|
||||
const {dynamic, static: staticRows, time} = await r.json();
|
||||
|
||||
const staticMap = new Map(staticRows.map(row => [row[0], row]));
|
||||
|
||||
aisCache = dynamic.map(row => {
|
||||
const [mmsi, lat, lon, distance, bearing, heading,
|
||||
cog, speed, status, rrsi, ppm, count, msg_type,
|
||||
last_signal, last_group, group_mask, flags, altitude,
|
||||
received_stations, mmsi_type, shipclass, country] = row;
|
||||
const s = staticMap.get(mmsi) ?? [];
|
||||
const [_mmsi, shipname, callsign, destination, shiptype,
|
||||
imo, to_bow, to_stern, to_port, to_starboard, draught,
|
||||
eta_month, eta_day, eta_hour, eta_minute] = s;
|
||||
|
||||
const f = decodeFlags(flags);
|
||||
|
||||
return {
|
||||
mmsi,
|
||||
type: SENDER_TYPE[shipclass],
|
||||
lat, lon, altitude,
|
||||
heading, cog, speed,
|
||||
distance, bearing,
|
||||
destination,
|
||||
rrsi, ppm, count,
|
||||
msg_type: decodeMsgTypes(msg_type),
|
||||
channels: CHANNEL_MAP[f.channels] ?? f.channels,
|
||||
...f,
|
||||
shiptype, imo, draught,
|
||||
country, callsign,
|
||||
shipname,
|
||||
to_bow, to_stern, to_port, to_starboard,
|
||||
eta_month, eta_day, eta_hour, eta_minute,
|
||||
last_signal: last_signal ? time - last_signal : null,
|
||||
last_group, group_mask,
|
||||
received_stations, shipclass,
|
||||
status
|
||||
};
|
||||
});
|
||||
aisCacheTs = Date.now();
|
||||
return aisCache;
|
||||
}
|
||||
|
||||
export async function getAISImage(mmsi) {
|
||||
console.log(`https://www.vesselfinder.com/vessels/details/${mmsi}`)
|
||||
const res = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||
})
|
||||
const html = await res.text()
|
||||
const match = html.match(/<img.+main-photo.+>/)
|
||||
if(!match) return null;
|
||||
const srcMatch = match[0].match(/src="(.+?)"/)
|
||||
if (!srcMatch) return null;
|
||||
|
||||
let imgUrl = srcMatch[1]
|
||||
if (imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`
|
||||
return fetch(imgUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||
}).then(resp => resp.blob())
|
||||
}
|
||||
98
server/src/aurora.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import {adjustedInterval, Logger} from '@ztimson/utils';
|
||||
|
||||
const cacheOptions = {
|
||||
ttl: null,
|
||||
reload: null,
|
||||
retry: 3,
|
||||
url: null,
|
||||
}
|
||||
|
||||
export class CacheService {
|
||||
#fetchLoop;
|
||||
|
||||
cached = null;
|
||||
lastUpdate = null;
|
||||
logger;
|
||||
name;
|
||||
options;
|
||||
pending;
|
||||
|
||||
constructor(name, options) {
|
||||
this.name = name;
|
||||
this.options = {
|
||||
...cacheOptions,
|
||||
ttl: options.reload,
|
||||
...options
|
||||
};
|
||||
this.logger = new Logger(name);
|
||||
if(this.options.reload) setTimeout(() => this.startLoop(), 1000);
|
||||
}
|
||||
|
||||
async #fetchWithRetry() {
|
||||
let lastError;
|
||||
for(let attempt = 1; attempt <= this.options.retry; attempt++) {
|
||||
try {
|
||||
return await this.fetch();
|
||||
} catch(err) {
|
||||
lastError = err;
|
||||
if(attempt < this.options.retry)
|
||||
this.logger.warn(`Retrying (${attempt + 1}/${this.options.retry}): ${err.message || err.toString()}`);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
if(!this.options.url) return Promise.reject('Fetch is not configured');
|
||||
const resp = await fetch(this.options.url);
|
||||
if(!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
startLoop(speed = this.options.reload) {
|
||||
this.stopLoop();
|
||||
this.#fetchLoop = adjustedInterval(() => this.get(), speed);
|
||||
}
|
||||
|
||||
stopLoop() {
|
||||
if(!this.#fetchLoop) return;
|
||||
clearInterval(this.#fetchLoop);
|
||||
this.#fetchLoop = null;
|
||||
}
|
||||
|
||||
update(catchErr = true) {
|
||||
if(this.pending) return this.pending;
|
||||
this.logger.info('Fetching latest');
|
||||
this.pending = this.#fetchWithRetry().then(data => {
|
||||
if(data?.err) throw new Error(data.err?.stack || data.err?.message || data.err);
|
||||
if(data?.error) throw new Error(data.error?.stack || data.error?.message || data.error);
|
||||
this.lastUpdate = new Date();
|
||||
this.cached = data || null;
|
||||
this.logger.debug('Finished updating');
|
||||
return {timestamp: this.lastUpdate, data: this.cached};
|
||||
}).catch(err => {
|
||||
if(catchErr) this.logger.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
|
||||
else throw err;
|
||||
}).finally(() => this.pending = null);
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
async get(wait = false) {
|
||||
if(!this.cached || !this.lastUpdate || this.lastUpdate?.getTime() + this.options.ttl <= Date.now()) {
|
||||
if(wait) await this.update();
|
||||
else this.update();
|
||||
}
|
||||
return Promise.resolve({timestamp: this.lastUpdate || null, data: this.cached || null});
|
||||
}
|
||||
}
|
||||
|
||||
export class AuroraService extends CacheService {
|
||||
constructor() {
|
||||
super('Aurora', {
|
||||
reload: 60_000 * 15,
|
||||
url: 'https://services.swpc.noaa.gov/json/ovation_aurora_latest.json',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const Aurora = new AuroraService();
|
||||
204
server/src/celestial.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
const RAD = Math.PI / 180;
|
||||
const DEG = 180 / Math.PI;
|
||||
|
||||
function jdn(date) {
|
||||
return (date instanceof Date ? date : new Date(date)) / 86400000 + 2440587.5;
|
||||
}
|
||||
|
||||
function jdnToDate(jd) {
|
||||
return new Date((jd - 2440587.5) * 86400000);
|
||||
}
|
||||
|
||||
function sunPosition(lat, lon, date = new Date()) {
|
||||
const d = jdn(date) - 2451545.0;
|
||||
const L = (280.46 + 0.9856474 * d) % 360;
|
||||
const g = (357.528 + 0.9856003 * d) % 360;
|
||||
const lam = L + 1.915 * Math.sin(g * RAD) + 0.02 * Math.sin(2 * g * RAD);
|
||||
const eps = 23.439 - 0.0000004 * d;
|
||||
const sinL = Math.sin(lam * RAD);
|
||||
const ra = Math.atan2(Math.cos(eps * RAD) * sinL, Math.cos(lam * RAD)) * DEG;
|
||||
const dec = Math.asin(Math.sin(eps * RAD) * sinL) * DEG;
|
||||
const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||
const GMST = (6.697375 + 0.0657098242 * d + UT) % 24;
|
||||
const LMST = (GMST + lon / 15) % 24;
|
||||
const ha = LMST * 15 - ra;
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
const az = Math.atan2(
|
||||
-Math.sin(ha * RAD),
|
||||
Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
return {
|
||||
sun_elevation: Math.round(elev * 10) / 10,
|
||||
sun_azimuth: Math.round(((az + 360) % 360) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
function sunriseSunset(lat, lon, date = new Date()) {
|
||||
const d = Math.floor(jdn(date)) - 2451545;
|
||||
const noon = 2451545 + 0.0009 + ((-lon) / 360) + Math.round(d - (-lon) / 360);
|
||||
const M = (357.5291 + 0.98560028 * (noon - 2451545)) % 360;
|
||||
const C = 1.9148 * Math.sin(M * RAD) + 0.02 * Math.sin(2 * M * RAD) + 0.0003 * Math.sin(3 * M * RAD);
|
||||
const lam = (M + C + 180 + 102.9372) % 360;
|
||||
const jnoon = noon + 0.0053 * Math.sin(M * RAD) - 0.0069 * Math.sin(2 * lam * RAD);
|
||||
const dec = Math.asin(Math.sin(23.4397 * RAD) * Math.sin(lam * RAD)) * DEG;
|
||||
const cosH = (Math.sin(-0.8333 * RAD) - Math.sin(lat * RAD) * Math.sin(dec * RAD)) / (Math.cos(lat * RAD) * Math.cos(dec * RAD));
|
||||
|
||||
if(Math.abs(cosH) > 1) {
|
||||
return {sunrise: null, sunset: null, daytime: cosH < -1};
|
||||
}
|
||||
|
||||
const H = Math.acos(cosH) * DEG;
|
||||
const rise = jdnToDate(jnoon - H / 360);
|
||||
const set = jdnToDate(jnoon + H / 360);
|
||||
|
||||
return {
|
||||
sunrise: rise,
|
||||
sunset: set,
|
||||
daylight: Math.round((set - rise) / 36000) / 100,
|
||||
daytime: cosH < -1
|
||||
};
|
||||
}
|
||||
|
||||
// Smooth sunspot number approximation from solar flux F10.7
|
||||
// SSN ≈ 1.61 * F10.7 - 63.7 (linear regression, valid for F10.7 > 70)
|
||||
export function ssnFromFlux(f107) {
|
||||
if(!f107) return null;
|
||||
return Math.max(0, Math.round(1.61 * f107 - 63.7));
|
||||
}
|
||||
|
||||
export function sunActivityLabel(f107) {
|
||||
if(!f107) return null;
|
||||
if(f107 < 80) return 'Very Low';
|
||||
if(f107 < 100) return 'Low';
|
||||
if(f107 < 150) return 'Moderate';
|
||||
if(f107 < 200) return 'High';
|
||||
return 'Very High';
|
||||
}
|
||||
|
||||
function moonPosition(lat, lon, date = new Date()) {
|
||||
const d = jdn(date) - 2451545;
|
||||
const L = (218.316 + 13.176396 * d) % 360;
|
||||
const M = (134.963 + 13.064993 * d) % 360;
|
||||
const F = (93.272 + 13.229350 * d) % 360;
|
||||
const lon_ = L + 6.289 * Math.sin(M * RAD);
|
||||
const b = 5.128 * Math.sin(F * RAD);
|
||||
const dec = Math.asin(
|
||||
Math.sin(b * RAD) * Math.cos(23.4397 * RAD) +
|
||||
Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD)
|
||||
) * DEG;
|
||||
const ra = Math.atan2(
|
||||
Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD),
|
||||
Math.cos(lon_ * RAD)
|
||||
) * DEG;
|
||||
const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||
const GMST = (6.697375 + 0.0657098242 * d + UT) % 24;
|
||||
const LMST = (GMST + lon / 15) % 24;
|
||||
const ha = LMST * 15 - ra;
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
const az = Math.atan2(
|
||||
-Math.sin(ha * RAD),
|
||||
Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
return {
|
||||
moon_elevation: Math.round(elev * 10) / 10,
|
||||
moon_azimuth: Math.round(((az + 360) % 360) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
function moonPhase(date = new Date()) {
|
||||
const jd = jdn(date);
|
||||
const cycle = 29.53058867;
|
||||
const known = 2451550.1;
|
||||
const phase = ((jd - known) % cycle + cycle) % cycle;
|
||||
// More accurate illumination using proper phase angle
|
||||
const illum = Math.round((1 - Math.cos(phase / cycle * 2 * Math.PI)) / 2 * 1000) / 10;
|
||||
let name;
|
||||
if(phase < 1.85) name = 'New Moon';
|
||||
else if(phase < 7.38) name = 'Waxing Crescent';
|
||||
else if(phase < 9.22) name = 'First Quarter';
|
||||
else if(phase < 14.76) name = 'Waxing Gibbous';
|
||||
else if(phase < 16.61) name = 'Full Moon';
|
||||
else if(phase < 22.15) name = 'Waning Gibbous';
|
||||
else if(phase < 23.99) name = 'Last Quarter';
|
||||
else name = 'Waning Crescent';
|
||||
return { moon_phase: name, moon_illumination: illum };
|
||||
}
|
||||
|
||||
function nextMoonEvents(date = new Date()) {
|
||||
const cycle = 29.53058867;
|
||||
const jd = jdn(date);
|
||||
const known = 2451550.1;
|
||||
const phase = ((jd - known) % cycle + cycle) % cycle;
|
||||
const toNew = (cycle - phase) % cycle;
|
||||
const toFull = phase < 14.76 ? 14.76 - phase : cycle - phase + 14.76;
|
||||
return {
|
||||
moon_new: jdnToDate(jd + toNew),
|
||||
moon_full: jdnToDate(jd + toFull),
|
||||
};
|
||||
}
|
||||
|
||||
function moonriseMoonset(lat, lon, date = new Date()) {
|
||||
const base = new Date(date);
|
||||
base.setUTCHours(0, 0, 0, 0);
|
||||
let prev = null;
|
||||
let moonrise = null;
|
||||
let moonset = null;
|
||||
|
||||
// Scan 48h in 10min steps — covers edge cases where moon rises/sets next UTC day
|
||||
for(let m = 0; m <= 2880; m += 10) {
|
||||
const t = new Date(base.getTime() + m * 60000);
|
||||
const d = jdn(t) - 2451545;
|
||||
const L = (218.316 + 13.176396 * d) % 360;
|
||||
const M = (134.963 + 13.064993 * d) % 360;
|
||||
const F = (93.272 + 13.229350 * d) % 360;
|
||||
const lon_ = L + 6.289 * Math.sin(M * RAD);
|
||||
const b = 5.128 * Math.sin(F * RAD);
|
||||
const dec = Math.asin(
|
||||
Math.sin(b * RAD) * Math.cos(23.4397 * RAD) +
|
||||
Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD)
|
||||
) * DEG;
|
||||
const GMST = (6.697375 + 0.0657098242 * d + (t.getUTCHours() + t.getUTCMinutes() / 60)) % 24;
|
||||
const LMST = (GMST + lon / 15) % 24;
|
||||
const ra = Math.atan2(
|
||||
Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD),
|
||||
Math.cos(lon_ * RAD)
|
||||
) * DEG;
|
||||
const ha = LMST * 15 - ra;
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
|
||||
if(prev !== null) {
|
||||
if(prev < 0 && elev >= 0 && !moonrise) moonrise = new Date(t.getTime() - 5 * 60000);
|
||||
if(prev >= 0 && elev < 0 && !moonset) moonset = new Date(t.getTime() - 5 * 60000);
|
||||
}
|
||||
prev = elev;
|
||||
if(moonrise && moonset) break;
|
||||
}
|
||||
|
||||
return {moonrise: moonrise, moonset: moonset};
|
||||
}
|
||||
|
||||
export function getCelestialCurrent(lat, lon, date = new Date()) {
|
||||
const data = {
|
||||
...sunPosition(lat, lon, date),
|
||||
...sunriseSunset(lat, lon, date),
|
||||
...moonPhase(date),
|
||||
...moonPosition(lat, lon, date),
|
||||
...nextMoonEvents(date),
|
||||
...moonriseMoonset(lat, lon, date),
|
||||
};
|
||||
data.daytime = date.getTime() >= data.sunrise && date.getTime() <= data.sunset;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getCelestialForecast(lat, lon, hours) {
|
||||
return hours.map((data) => Object.assign(data, getCelestialCurrent(lat, lon, new Date(data.time))))
|
||||
}
|
||||
44
server/src/config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import { config } from 'dotenv'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
|
||||
export function cfg() {
|
||||
config({ path: resolve(ROOT, '.env'), override: true })
|
||||
config({ path: resolve(ROOT, '.env.local'), override: true })
|
||||
return {
|
||||
PORT: process.env.PORT || 3000,
|
||||
ADSB_URL: process.env.ADSB_URL || '',
|
||||
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
||||
EMAIL: process.env.EMAIL,
|
||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
||||
TZ: process.env.TZ || 'UTC',
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the UTC offset in minutes for the configured TZ at a given date
|
||||
export function tzOffsetMinutes(date = new Date()) {
|
||||
const tz = cfg().TZ
|
||||
// Format the date in the target timezone and compare to UTC
|
||||
const local = new Date(date.toLocaleString('en-US', { timeZone: tz }))
|
||||
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
|
||||
return Math.round((local - utc) / 60_000)
|
||||
}
|
||||
|
||||
// Shift a UTC date to local-time-as-if-UTC (for day bucketing)
|
||||
export function toLocalMidnight(date = new Date()) {
|
||||
const offset = tzOffsetMinutes(date)
|
||||
const local = new Date(date.getTime() + offset * 60_000)
|
||||
local.setUTCHours(0, 0, 0, 0)
|
||||
return local
|
||||
}
|
||||
|
||||
// Get YYYY-MM-DD string in local timezone
|
||||
export function localDateStr(date = new Date()) {
|
||||
const offset = tzOffsetMinutes(date)
|
||||
const shifted = new Date(date.getTime() + offset * 60_000)
|
||||
return shifted.toISOString().slice(0, 10)
|
||||
}
|
||||
310
server/src/forecast.mjs
Normal file
@@ -0,0 +1,310 @@
|
||||
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
|
||||
import {getCelestialCurrent} from './celestial.mjs';
|
||||
import {localDateStr} from './config.mjs';
|
||||
import {frostRisk} from './openweather.mjs';
|
||||
|
||||
export let lastForecast = { ts: 0, slots: [], summary: null }
|
||||
export const forecastTTL = 5 * 60 * 60_000
|
||||
|
||||
function estimatePrecipitation(precipChance, clouds) {
|
||||
if (precipChance < 30) return 0;
|
||||
const intensity = (precipChance / 100) * clouds;
|
||||
if (intensity > 0.7) return Math.round(intensity * 15 * 10) / 10;
|
||||
if (intensity > 0.4) return Math.round(intensity * 6 * 10) / 10;
|
||||
return Math.round(intensity * 2 * 10) / 10;
|
||||
}
|
||||
|
||||
function estimateUV(sunElevation, cloudFraction) {
|
||||
if (sunElevation <= 0) return 0;
|
||||
const clearSkyUV = Math.sin((sunElevation * Math.PI) / 180) * 12;
|
||||
return Math.round(clearSkyUV * (1 - cloudFraction * 0.75) * 10) / 10;
|
||||
}
|
||||
|
||||
function linearTrend(values) {
|
||||
const n = values.length;
|
||||
if (n < 2) return 0;
|
||||
const xMean = (n - 1) / 2;
|
||||
const yMean = values.reduce((a, b) => a + b, 0) / n;
|
||||
const num = values.reduce((s, v, i) => s + (i - xMean) * (v - yMean), 0);
|
||||
const den = values.reduce((s, _, i) => s + (i - xMean) ** 2, 0);
|
||||
return den === 0 ? 0 : num / den;
|
||||
}
|
||||
|
||||
function dewPoint(tempC, humidity) {
|
||||
const a = 17.27, b = 237.7;
|
||||
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100);
|
||||
return (b * gamma) / (a - gamma);
|
||||
}
|
||||
|
||||
function heatIndex(tempC, humidity) {
|
||||
const T = tempC * 9 / 5 + 32;
|
||||
if (T < 80) return tempC;
|
||||
const HI =
|
||||
-42.379 + 2.04901523 * T + 10.14333127 * humidity
|
||||
- 0.22475541 * T * humidity - 0.00683783 * T * T
|
||||
- 0.05481717 * humidity * humidity + 0.00122874 * T * T * humidity
|
||||
+ 0.00085282 * T * humidity * humidity - 0.00000199 * T * T * humidity * humidity;
|
||||
return (HI - 32) * 5 / 9;
|
||||
}
|
||||
|
||||
function vaporPressureDeficit(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3));
|
||||
return Math.round(svp * (1 - humidity / 100) * 1000) / 1000;
|
||||
}
|
||||
|
||||
function absoluteHumidity(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3));
|
||||
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000;
|
||||
}
|
||||
|
||||
function pressureRule(trend3h, currentHpa) {
|
||||
if (trend3h < -2.0) return { label: 'Storm likely', precipitation_chance: 0.85 };
|
||||
if (trend3h < -0.8) return { label: 'Rain likely', precipitation_chance: 0.65 };
|
||||
if (trend3h < -0.3) return { label: 'Cloudy', precipitation_chance: 0.35 };
|
||||
if (trend3h > 1.5) return { label: 'Clearing', precipitation_chance: 0.05 };
|
||||
if (trend3h > 0.3) return { label: 'Improving', precipitation_chance: 0.10 };
|
||||
if (currentHpa < 1000) return { label: 'Unsettled', precipitation_chance: 0.30 };
|
||||
if (currentHpa < 1013) return { label: 'Partly cloudy', precipitation_chance: 0.15 };
|
||||
return { label: 'Fair', precipitation_chance: 0.05 };
|
||||
}
|
||||
|
||||
function solarTempDelta(sunElevation, cloudFraction) {
|
||||
if (sunElevation <= 0) return 0;
|
||||
return Math.sin((sunElevation * Math.PI) / 180) * (1 - cloudFraction) * 6;
|
||||
}
|
||||
|
||||
function nocturnalCooling(cloudFraction) {
|
||||
return (1 - cloudFraction) * 1.5;
|
||||
}
|
||||
|
||||
function forecastHumidity(absHumidity, forecastTempC) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * forecastTempC) / (forecastTempC + 237.3));
|
||||
const rh = (absHumidity * (forecastTempC + 273.15)) / (svp * 2165) * 100;
|
||||
return Math.min(100, Math.max(0, Math.round(rh * 10) / 10));
|
||||
}
|
||||
|
||||
function estimateClouds(humidity, pressureTrend) {
|
||||
let base = humidity / 100 * 0.8;
|
||||
if (pressureTrend < -0.3) base = Math.min(1, base + 0.2);
|
||||
if (pressureTrend > 0.3) base = Math.max(0, base - 0.15);
|
||||
return Math.round(base * 100) / 100;
|
||||
}
|
||||
|
||||
function forecastWind(currentWind, pressureTrend) {
|
||||
const boost = pressureTrend < -1 ? 1.3 : pressureTrend < -0.5 ? 1.1 : 1.0;
|
||||
return Math.round(currentWind * boost * 10) / 10;
|
||||
}
|
||||
|
||||
export async function get24HourForecast(currentSensors) {
|
||||
const now = new Date();
|
||||
const sixAgo = new Date(now - 6 * 3600 * 1000);
|
||||
|
||||
const coords = await getCoords();
|
||||
const history = await queryHourly(sixAgo, now);
|
||||
|
||||
const pressures = history.map(r => r.pressure_hpa).filter(Number.isFinite);
|
||||
const temps = history.map(r => r.temperature).filter(Number.isFinite);
|
||||
const humidities = history.map(r => r.humidity).filter(Number.isFinite);
|
||||
const winds = history.map(r => r.wind_speed).filter(Number.isFinite);
|
||||
|
||||
const pressureTrend = linearTrend(pressures);
|
||||
const tempTrend = linearTrend(temps);
|
||||
const humidityTrend = linearTrend(humidities);
|
||||
const pressure3h = pressureTrend * 3;
|
||||
|
||||
const seedTemp = currentSensors.temperature ?? temps.at(-1) ?? 20;
|
||||
const seedHumidity = currentSensors.humidity ?? humidities.at(-1) ?? 60;
|
||||
const seedPressure = currentSensors.pressure_hpa ?? pressures.at(-1) ?? 1013;
|
||||
const seedWind = currentSensors.wind_speed ?? winds.at(-1) ?? 0;
|
||||
const seedAbsHum = currentSensors.humidity_abs ?? absoluteHumidity(seedTemp, seedHumidity);
|
||||
|
||||
const weather = pressureRule(pressure3h, seedPressure);
|
||||
|
||||
let runningAccumulation = 0;
|
||||
const slots = Array.from({ length: 24 }, (_, i) => {
|
||||
const time = new Date(now.getTime() + (i + 1) * 3600 * 1000);
|
||||
time.setMinutes(0, 0, 0);
|
||||
|
||||
const pressureDamping = Math.exp(-i * 0.08);
|
||||
const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping;
|
||||
const pressure_slp = Math.round((pressure + (coords.altitude ?? 0) / 8.5) * 100) / 100;
|
||||
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
||||
const bgTemp = seedTemp + tempTrend * (i + 1);
|
||||
const temperature = Math.round(bgTemp * 10) / 10;
|
||||
const humidity = forecastHumidity(seedAbsHum, temperature);
|
||||
const wind_speed = forecastWind(seedWind, pressureTrend);
|
||||
const precipitation_chance = Math.round(weather.precipitation_chance * (1 - i * 0.02) * 100);
|
||||
const raining = precipitation_chance > 60 ? 'True' : 'False';
|
||||
const precipitation = estimatePrecipitation(precipitation_chance, clouds);
|
||||
runningAccumulation = Math.round((runningAccumulation + precipitation) * 100) / 100;
|
||||
|
||||
const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time);
|
||||
const uv_index = estimateUV(celestial.sun_elevation ?? 0, clouds);
|
||||
|
||||
return {
|
||||
time,
|
||||
temperature,
|
||||
humidity,
|
||||
humidity_abs: absoluteHumidity(temperature, humidity),
|
||||
dew_point: Math.round(dewPoint(temperature, humidity) * 100) / 100,
|
||||
heat_index: Math.round(heatIndex(temperature, humidity) * 100) / 100,
|
||||
vapor_pressure_deficit: vaporPressureDeficit(temperature, humidity),
|
||||
pressure_hpa: Math.round(pressure * 100) / 100,
|
||||
pressure_slp,
|
||||
pressure_rate: Math.round(pressureTrend * 100) / 100,
|
||||
storm_trend: pressure3h < -2 ? 1 : pressure3h > 1.5 ? -1 : 0,
|
||||
wind_speed,
|
||||
wind_gusts: Math.round(wind_speed * 1.4 * 10) / 10,
|
||||
wind_direction: currentSensors.wind_direction ?? 0,
|
||||
clouds,
|
||||
raining,
|
||||
precipitation,
|
||||
precipitation_chance,
|
||||
accumulation: runningAccumulation,
|
||||
uv_index,
|
||||
uv_dose: 0,
|
||||
solar_wm2: 0,
|
||||
daily_light_integral: 0,
|
||||
lux: 0,
|
||||
visibility: 50,
|
||||
...celestial,
|
||||
};
|
||||
});
|
||||
|
||||
for (const slot of slots) {
|
||||
if (slot.sunrise && slot.sunset) {
|
||||
const t = slot.time instanceof Date ? slot.time.getTime() : new Date(slot.time).getTime();
|
||||
const rise = slot.sunrise instanceof Date ? slot.sunrise.getTime() : new Date(slot.sunrise).getTime();
|
||||
const set = slot.sunset instanceof Date ? slot.sunset.getTime() : new Date(slot.sunset).getTime();
|
||||
slot.daytime = t >= rise && t < set;
|
||||
} else {
|
||||
slot.daytime = (slot.sun_elevation ?? 0) > 0;
|
||||
}
|
||||
|
||||
const solar = solarTempDelta(slot.sun_elevation ?? 0, slot.clouds);
|
||||
const cool = slot.daytime ? 0 : nocturnalCooling(slot.clouds);
|
||||
slot.temperature = Math.round((slot.temperature + solar - cool) * 10) / 10;
|
||||
slot.humidity = forecastHumidity(seedAbsHum, slot.temperature);
|
||||
slot.dew_point = Math.round(dewPoint(slot.temperature, slot.humidity) * 100) / 100;
|
||||
slot.heat_index = Math.round(heatIndex(slot.temperature, slot.humidity) * 100) / 100;
|
||||
slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity);
|
||||
slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity);
|
||||
slot.frost_risk = frostRisk(slot.temperature, slot.dew_point, slot.humidity)
|
||||
|
||||
slot.solar_wm2 = slot.sun_elevation > 0
|
||||
? Math.round(Math.sin((slot.sun_elevation * Math.PI) / 180) * 1000 * (1 - slot.clouds * 0.75) * 100) / 100
|
||||
: 0;
|
||||
slot.daily_light_integral = Math.round(slot.solar_wm2 * 3600 / 1_000_000 * 4.57 * 100) / 100;
|
||||
slot.lux = slot.solar_wm2 > 0 ? Math.round(slot.solar_wm2 * 120) : 0;
|
||||
slot.visibility = slot.clouds > 0.85 ? 10 : 50;
|
||||
|
||||
Object.assign(slot, getWeatherCondition(slot));
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function groupByLocalDay(slots) {
|
||||
const days = {}
|
||||
for (const slot of slots) {
|
||||
const key = localDateStr(new Date(slot.time))
|
||||
if (!days[key]) days[key] = []
|
||||
days[key].push(slot)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
function summarise(slots) {
|
||||
if (!slots.length) return null
|
||||
|
||||
const daytime = slots.filter(s => s.daytime)
|
||||
const source = daytime.length ? daytime : slots
|
||||
|
||||
const avg = (arr) => arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length * 100) / 100 : null
|
||||
const peak = (arr) => arr.length ? Math.max(...arr) : null
|
||||
const fin = (key) => source.map(s => s[key]).filter(Number.isFinite)
|
||||
const finAll = (key) => slots.map(s => s[key]).filter(Number.isFinite)
|
||||
|
||||
const labelCount = {}
|
||||
for (const s of source) labelCount[s.label] = (labelCount[s.label] || 0) + 1
|
||||
const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0]
|
||||
const dominant = source.find(s => s.label === label)
|
||||
|
||||
return {
|
||||
time: slots[0].time,
|
||||
label: dominant.label,
|
||||
icon: dominant.icon,
|
||||
temperature: peak(fin('temperature')),
|
||||
temperature_max: peak(finAll('temperature')),
|
||||
temperature_min: Math.min(...finAll('temperature')),
|
||||
humidity: avg(fin('humidity')),
|
||||
humidity_abs: avg(fin('humidity_abs')),
|
||||
dew_point: avg(fin('dew_point')),
|
||||
heat_index: peak(fin('heat_index')),
|
||||
vapor_pressure_deficit: avg(fin('vapor_pressure_deficit')),
|
||||
pressure_hpa: avg(fin('pressure_hpa')),
|
||||
pressure_slp: avg(fin('pressure_slp')),
|
||||
pressure_rate: avg(fin('pressure_rate')),
|
||||
storm_trend: slots.at(-1)?.storm_trend ?? 0,
|
||||
wind_speed: peak(fin('wind_speed')),
|
||||
wind_gusts: peak(fin('wind_gusts')),
|
||||
wind_direction: avg(fin('wind_direction')),
|
||||
clouds: avg(fin('clouds')),
|
||||
raining: peak(fin('precipitation_chance')) > 60 ? 'True' : 'False',
|
||||
precipitation: avg(fin('precipitation')),
|
||||
precipitation_chance: Math.round(peak(fin('precipitation_chance'))),
|
||||
accumulation: finAll('precipitation').reduce((a, b) => a + b, 0),
|
||||
frost_risk: slots.find(s => s.frost_risk !== 'None')?.frost_risk ?? slots.at(-1)?.frost_risk ?? 'None',
|
||||
uv_index: peak(fin('uv_index')),
|
||||
uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0),
|
||||
solar_wm2: avg(fin('solar_wm2')),
|
||||
daily_light_integral: Math.round(finAll('daily_light_integral').reduce((a, b) => a + b, 0) * 100) / 100,
|
||||
lux: peak(fin('lux')),
|
||||
visibility: avg(fin('visibility')),
|
||||
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
||||
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
||||
daylight: slots.find(s => s.daylight)?.daylight ?? null,
|
||||
moon_phase: dominant.moon_phase ?? null,
|
||||
moon_illumination: dominant.moon_illumination ?? null,
|
||||
moon_elevation: dominant.moon_elevation ?? null,
|
||||
moon_azimuth: dominant.moon_azimuth ?? null,
|
||||
moon_new: dominant.moon_new ?? null,
|
||||
moon_full: dominant.moon_full ?? null,
|
||||
moonrise: slots.find(s => s.moonrise)?.moonrise ?? null,
|
||||
moonset: slots.find(s => s.moonset)?.moonset ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getForecast() {
|
||||
if (Date.now() - lastForecast.ts < forecastTTL) return lastForecast;
|
||||
const sensors = await queryCurrent().catch(() => null)
|
||||
if (!sensors) return lastForecast
|
||||
const slots = await get24HourForecast(sensors).catch(() => null)
|
||||
if (!slots) return lastForecast
|
||||
|
||||
const days = groupByLocalDay(slots)
|
||||
const todayKey = localDateStr(new Date())
|
||||
const todaySlots = days[todayKey] ?? slots
|
||||
lastForecast = { ts: Date.now(), slots, summary: summarise(todaySlots) }
|
||||
return lastForecast
|
||||
}
|
||||
|
||||
export function getWeatherCondition(data) {
|
||||
if(data.lightning_rate > 0 && data.raining === 'True') {
|
||||
return {label: 'Thunderstorm', icon: '11d'};
|
||||
}
|
||||
if(data.raining === 'True') {
|
||||
if(data.precipitation > 7.6) return {label: 'Heavy Rain', icon: '10d'};
|
||||
if(data.precipitation > 2.5) return {label: 'Moderate Rain', icon: '10d'};
|
||||
return {label: 'Light Rain', icon: '09d'};
|
||||
}
|
||||
if(data.visibility < 10) return {label: 'Mist', icon: '50d'};
|
||||
|
||||
const dayNight = data.daytime ? 'd' : 'n';
|
||||
const c = data.clouds > 1 ? data.clouds / 100 : data.clouds;
|
||||
if(c > 0.85) return {label: 'Overcast Clouds', icon: `04${dayNight}`};
|
||||
if(c > 0.50) return {label: 'Broken Clouds', icon: `04${dayNight}`};
|
||||
if(c > 0.25) return {label: 'Scattered Clouds', icon: `03${dayNight}`};
|
||||
if(c > 0.10) return {label: 'Few Clouds', icon: `02${dayNight}`};
|
||||
return {label: 'Clear Sky', icon: `01${dayNight}`};
|
||||
}
|
||||
274
server/src/openmeteo.mjs
Normal file
@@ -0,0 +1,274 @@
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { tzOffsetMinutes } from './config.mjs'
|
||||
import { getWeatherCondition } from './forecast.mjs'
|
||||
import { getCelestialForecast } from './celestial.mjs'
|
||||
import {frostRisk} from './openweather.mjs';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
||||
const CACHE = {}
|
||||
const CACHE_MS = 15 * 60 * 1000
|
||||
|
||||
if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true })
|
||||
|
||||
const WMO = {
|
||||
0: 'Clear Sky', 1: 'Mainly Clear', 2: 'Partly Cloudy',
|
||||
3: 'Overcast', 45: 'Fog', 48: 'Icy Fog',
|
||||
51: 'Light Drizzle', 53: 'Drizzle', 55: 'Heavy Drizzle',
|
||||
61: 'Light Rain', 63: 'Rain', 65: 'Heavy Rain',
|
||||
71: 'Light Snow', 73: 'Snow', 75: 'Heavy Snow',
|
||||
77: 'Snow Grains', 80: 'Light Showers', 81: 'Showers',
|
||||
82: 'Heavy Showers', 85: 'Snow Showers', 86: 'Heavy Snow Showers',
|
||||
95: 'Thunderstorm', 96: 'Thunderstorm w/ Hail', 99: 'Thunderstorm w/ Heavy Hail',
|
||||
}
|
||||
|
||||
const OWM_ICONS = {
|
||||
0: '01d', 1: '01d', 2: '02d', 3: '04d',
|
||||
45: '50d', 48: '50d',
|
||||
51: '09d', 53: '09d', 55: '09d',
|
||||
61: '10d', 63: '10d', 65: '10d',
|
||||
71: '13d', 73: '13d', 75: '13d', 77: '13d',
|
||||
80: '09d', 81: '09d', 82: '09d',
|
||||
85: '13d', 86: '13d',
|
||||
95: '11d', 96: '11d', 99: '11d',
|
||||
}
|
||||
|
||||
async function cachedFetch(key, url) {
|
||||
const now = Date.now()
|
||||
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
CACHE[key] = { ts: now, data }
|
||||
return data
|
||||
}
|
||||
|
||||
function dewPoint(tempC, humidity) {
|
||||
const a = 17.27, b = 237.7
|
||||
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100)
|
||||
return Math.round((b * gamma) / (a - gamma) * 100) / 100
|
||||
}
|
||||
|
||||
function heatIndex(tempC, humidity) {
|
||||
const T = tempC * 9 / 5 + 32
|
||||
if (T < 80) return Math.round(tempC * 100) / 100
|
||||
const HI =
|
||||
-42.379 + 2.04901523 * T + 10.14333127 * humidity
|
||||
- 0.22475541 * T * humidity - 0.00683783 * T * T
|
||||
- 0.05481717 * humidity * humidity + 0.00122874 * T * T * humidity
|
||||
+ 0.00085282 * T * humidity * humidity - 0.00000199 * T * T * humidity * humidity
|
||||
return Math.round((HI - 32) * 5 / 9 * 100) / 100
|
||||
}
|
||||
|
||||
function vaporPressureDeficit(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3))
|
||||
return Math.round(svp * (1 - humidity / 100) * 1000) / 1000
|
||||
}
|
||||
|
||||
function absoluteHumidity(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3))
|
||||
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000
|
||||
}
|
||||
|
||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DAILY_FIELDS = [
|
||||
'temperature_2m_max', 'temperature_2m_min',
|
||||
'precipitation_sum', 'precipitation_probability_max',
|
||||
'windspeed_10m_max', 'windgusts_10m_max', 'winddirection_10m_dominant',
|
||||
'weathercode', 'sunrise', 'sunset', 'relative_humidity_2m_mean',
|
||||
'uv_index_max', 'shortwave_radiation_sum',
|
||||
].join(',')
|
||||
|
||||
export async function dailyWeather(lat, lon, start, end) {
|
||||
const startStr = start.toISOString().slice(0, 10)
|
||||
const endStr = end.toISOString().slice(0, 10)
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
||||
`&daily=${DAILY_FIELDS}&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
||||
|
||||
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||
if (!data?.daily?.time) return []
|
||||
|
||||
const rows = data.daily.time.map((time, i) => {
|
||||
const code = data.daily.weathercode[i]
|
||||
const icon = OWM_ICONS[code] || '01d'
|
||||
const tempMax = data.daily.temperature_2m_max[i]
|
||||
const tempMin = data.daily.temperature_2m_min[i]
|
||||
const temp = tempMax
|
||||
const humidity = data.daily.relative_humidity_2m_mean[i]
|
||||
const precip = data.daily.precipitation_sum[i] ?? 0
|
||||
const precipChance = data.daily.precipitation_probability_max[i] ?? 0
|
||||
const windSpeed = data.daily.windspeed_10m_max[i] ?? 0
|
||||
const windGusts = data.daily.windgusts_10m_max[i] ?? 0
|
||||
|
||||
const utcMidnight = new Date(`${time}T00:00:00Z`)
|
||||
const offset = tzOffsetMinutes(utcMidnight)
|
||||
const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000)
|
||||
|
||||
const sunrise = data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null
|
||||
const sunset = data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null
|
||||
const daylight = sunrise && sunset
|
||||
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
|
||||
: null
|
||||
|
||||
const clouds = humidity / 100 * 0.8 // rough estimate — not provided by daily endpoint
|
||||
|
||||
const row = {
|
||||
time: localMidnight,
|
||||
daytime: true,
|
||||
label: WMO[code] || 'Unknown',
|
||||
icon,
|
||||
temperature: temp,
|
||||
temperature_max: tempMax,
|
||||
temperature_min: tempMin,
|
||||
humidity,
|
||||
humidity_abs: absoluteHumidity(temp, humidity),
|
||||
dew_point: dewPoint(temp, humidity),
|
||||
heat_index: heatIndex(temp, humidity),
|
||||
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
|
||||
pressure_hpa: null,
|
||||
pressure_slp: null,
|
||||
pressure_rate: null,
|
||||
storm_trend: 0,
|
||||
wind_speed: windSpeed,
|
||||
wind_gusts: windGusts,
|
||||
wind_direction: data.daily.winddirection_10m_dominant[i] ?? 0,
|
||||
clouds,
|
||||
raining: precipChance > 60 ? 'True' : 'False',
|
||||
precipitation: precip,
|
||||
precipitation_chance: precipChance,
|
||||
accumulation: precip,
|
||||
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
|
||||
uv_index: data.daily.uv_index_max[i] ?? 0,
|
||||
uv_index_max: data.daily.uv_index_max[i] ?? 0,
|
||||
uv_dose: 0,
|
||||
solar_wm2: data.daily.shortwave_radiation_sum[i] ?? 0,
|
||||
daily_light_integral: data.daily.shortwave_radiation_sum[i]
|
||||
? Math.round(data.daily.shortwave_radiation_sum[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
|
||||
: 0,
|
||||
lux: 0,
|
||||
visibility: 50,
|
||||
sunrise,
|
||||
sunset,
|
||||
daylight,
|
||||
lightning_rate: 0,
|
||||
lightning_distance: null,
|
||||
seismic_magnitude: null,
|
||||
air_quality: null,
|
||||
ground_distance: null,
|
||||
}
|
||||
|
||||
return { ...row, ...getWeatherCondition(row) }
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HOURLY_FIELDS = [
|
||||
'temperature_2m', 'relative_humidity_2m', 'dewpoint_2m',
|
||||
'apparent_temperature', 'precipitation_probability', 'precipitation',
|
||||
'weathercode', 'surface_pressure', 'cloudcover',
|
||||
'windspeed_10m', 'windgusts_10m', 'winddirection_10m',
|
||||
'uv_index', 'shortwave_radiation', 'vapour_pressure_deficit',
|
||||
'visibility',
|
||||
].join(',')
|
||||
|
||||
export async function hourlyWeather(lat, lon, start, end) {
|
||||
const startStr = start instanceof Date ? start.toISOString().slice(0, 10) : start.slice(0, 10)
|
||||
const endStr = end instanceof Date ? end.toISOString().slice(0, 10) : end.slice(0, 10)
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
||||
`&hourly=${HOURLY_FIELDS}&daily=sunrise,sunset&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
||||
|
||||
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||
if (!data?.hourly?.time) return []
|
||||
|
||||
const sunMap = {}
|
||||
if (data.daily?.time) {
|
||||
data.daily.time.forEach((date, i) => {
|
||||
sunMap[date] = {
|
||||
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
||||
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const rows = data.hourly.time.map((time, i) => {
|
||||
const dateKey = time.slice(0, 10)
|
||||
const sun = sunMap[dateKey] ?? {}
|
||||
const t = new Date(time + 'Z').getTime()
|
||||
const daytime = sun.sunrise && sun.sunset
|
||||
? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
|
||||
: false
|
||||
|
||||
const code = data.hourly.weathercode[i]
|
||||
const temp = data.hourly.temperature_2m[i]
|
||||
const humidity = data.hourly.relative_humidity_2m[i]
|
||||
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
|
||||
const precip = data.hourly.precipitation[i] ?? 0
|
||||
const precipChance = data.hourly.precipitation_probability[i] ?? 0
|
||||
const windSpeed = data.hourly.windspeed_10m[i] ?? 0
|
||||
const uv = data.hourly.uv_index[i] ?? 0
|
||||
|
||||
const sunrise = sun.sunrise ?? null
|
||||
const sunset = sun.sunset ?? null
|
||||
const daylight = sunrise && sunset
|
||||
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
|
||||
: null
|
||||
|
||||
const row = {
|
||||
time: new Date(time + 'Z'),
|
||||
daytime,
|
||||
label: WMO[code] || 'Unknown',
|
||||
icon: OWM_ICONS[code] || '01d',
|
||||
temperature: temp,
|
||||
temperature_max: temp,
|
||||
temperature_min: temp,
|
||||
humidity,
|
||||
humidity_abs: absoluteHumidity(temp, humidity),
|
||||
dew_point: data.hourly.dewpoint_2m[i] ?? dewPoint(temp, humidity),
|
||||
heat_index: heatIndex(temp, humidity),
|
||||
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
|
||||
pressure_hpa: data.hourly.surface_pressure[i] ?? null,
|
||||
pressure_slp: null,
|
||||
pressure_rate: null,
|
||||
storm_trend: 0,
|
||||
wind_speed: windSpeed,
|
||||
wind_gusts: data.hourly.windgusts_10m[i] ?? 0,
|
||||
wind_direction: data.hourly.winddirection_10m[i] ?? 0,
|
||||
clouds,
|
||||
raining: precipChance > 60 ? 'True' : 'False',
|
||||
precipitation: precip,
|
||||
precipitation_chance: precipChance,
|
||||
accumulation: precip,
|
||||
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
|
||||
uv_index: uv,
|
||||
uv_index_max: uv,
|
||||
uv_dose: 0,
|
||||
solar_wm2: data.hourly.shortwave_radiation[i] ?? 0,
|
||||
daily_light_integral: data.hourly.shortwave_radiation[i]
|
||||
? Math.round(data.hourly.shortwave_radiation[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
|
||||
: 0,
|
||||
lux: data.hourly.shortwave_radiation[i]
|
||||
? Math.round(data.hourly.shortwave_radiation[i] * 120)
|
||||
: 0,
|
||||
visibility: data.hourly.visibility[i] != null
|
||||
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
|
||||
: 50,
|
||||
sunrise,
|
||||
sunset,
|
||||
daylight,
|
||||
lightning_rate: 0,
|
||||
lightning_distance: null,
|
||||
seismic_magnitude: null,
|
||||
air_quality: null,
|
||||
ground_distance: null,
|
||||
}
|
||||
|
||||
return { ...row, ...getWeatherCondition(row) }
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
28
server/src/openweather.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import {existsSync, mkdirSync, writeFileSync} from 'fs';
|
||||
import * as path from 'node:path';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../data/icons');
|
||||
|
||||
export async function downloadIcon(icon, path) {
|
||||
if(!existsSync(DIR)) mkdirSync(dirname(path), {recursive: true});
|
||||
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
writeFileSync(path, buf);
|
||||
}
|
||||
|
||||
export async function fetchIcon(icon) {
|
||||
const iconPath = path.join(DIR, icon + '.png');
|
||||
if(!existsSync(DIR) || !existsSync(iconPath))
|
||||
await downloadIcon(icon, iconPath);
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
export function frostRisk(tempC, dewPointC, humidity) {
|
||||
if (tempC > 4) return 'None'
|
||||
if (tempC <= 0 && dewPointC <= 0) return 'High'
|
||||
if (tempC <= 2 && humidity > 85) return 'Moderate'
|
||||
if (tempC <= 4) return 'Low'
|
||||
return 'None'
|
||||
}
|
||||
118
server/src/sensors.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import {cfg} from './config.mjs';
|
||||
import {frostRisk} from './openweather.mjs';
|
||||
|
||||
async function query(c, q, start, end) {
|
||||
const url = new URL('/api/v1/query_range', c.DB_HOST);
|
||||
url.searchParams.set('query', q);
|
||||
url.searchParams.set('step', '60s');
|
||||
if (start) url.searchParams.set('start', start);
|
||||
if (end) url.searchParams.set('end', end);
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
return json.data?.result || [];
|
||||
}
|
||||
|
||||
async function queryInstant(c, q) {
|
||||
const url = new URL('/api/v1/query', c.DB_HOST);
|
||||
url.searchParams.set('query', q);
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
return json.data?.result || [];
|
||||
}
|
||||
|
||||
function metricToFields(results) {
|
||||
const out = {};
|
||||
for (const r of results) {
|
||||
if(r.metric.__name__.startsWith('system_')) continue;
|
||||
const val = r.value?.[1] ?? r.values?.at(-1)?.[1];
|
||||
if (val !== undefined) out[r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function queryCurrent() {
|
||||
const c = cfg()
|
||||
const results = await queryInstant(c, `{__name__!=""}`)
|
||||
const fields = metricToFields(results)
|
||||
const dp = fields.dew_point ?? null
|
||||
return {
|
||||
time: new Date(),
|
||||
...fields,
|
||||
frost_risk: frostRisk(fields.temperature ?? 99, dp ?? 99, fields.humidity ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryHourly(start, end) {
|
||||
const c = cfg();
|
||||
const s = Math.floor(start.getTime() / 1000);
|
||||
const e = Math.floor(end.getTime() / 1000);
|
||||
const byTime = {};
|
||||
|
||||
const results = await query(c, `avg_over_time({__name__!=""}[1h])`, s, e);
|
||||
results.forEach(r => {
|
||||
r.values.forEach(([ts, val]) => {
|
||||
if(r.metric.__name__.startsWith('system_')) return;
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00';
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||
});
|
||||
});
|
||||
return Object.values(byTime)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||
}
|
||||
|
||||
export async function queryDaily(start, end) {
|
||||
const c = cfg();
|
||||
const s = Math.floor(start.getTime() / 1000);
|
||||
const e = Math.floor(end.getTime() / 1000);
|
||||
const byTime = {};
|
||||
|
||||
const [means, mins, maxs] = await Promise.all([
|
||||
query(c, `avg_over_time({__name__!=""}[1d])`, s, e),
|
||||
query(c, `min_over_time({__name__!=""}[1d])`, s, e),
|
||||
query(c, `max_over_time({__name__!=""}[1d])`, s, e),
|
||||
]);
|
||||
|
||||
means.forEach(r => {
|
||||
r.values.forEach(([ts, val]) => {
|
||||
if(r.metric.__name__.startsWith('system_')) return;
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||
});
|
||||
});
|
||||
|
||||
mins.forEach(r => {
|
||||
if (r.metric.__name__ !== 'temperature') return;
|
||||
r.values.forEach(([ts, val]) => {
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time].env_temp_min_c = parseFloat(val);
|
||||
});
|
||||
});
|
||||
|
||||
maxs.forEach(r => {
|
||||
if (r.metric.__name__ !== 'temperature') return;
|
||||
r.values.forEach(([ts, val]) => {
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time].env_temp_max_c = parseFloat(val);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(byTime)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||
}
|
||||
|
||||
export async function getCoords() {
|
||||
const c = cfg();
|
||||
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
||||
const fields = metricToFields(results);
|
||||
|
||||
if (fields.latitude && fields.longitude) {
|
||||
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||
}
|
||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||
}
|
||||
213
server/src/server.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
import express from 'express';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {cfg} from './config.mjs';
|
||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './sensors.mjs';
|
||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||
import {apiReference} from '@scalar/express-api-reference';
|
||||
import {spec} from './spec.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
import {getAIS, getAISImage} from './ais.mjs';
|
||||
import {getADSBImage, getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs';
|
||||
import {fetchIcon} from './openweather.mjs';
|
||||
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||
|
||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||
|
||||
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('[unhandledRejection]', promise, '\nReason:', reason)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err)
|
||||
})
|
||||
|
||||
const app = express();
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const CLIENT_DIST = resolve(DIR, '../public');
|
||||
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
});
|
||||
app.use(express.static(CLIENT_DIST));
|
||||
|
||||
function filterFields(obj, fields) {
|
||||
if(!fields) return obj;
|
||||
const keys = new Set(fields.split(',').map(f => f.trim()));
|
||||
return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.has(k)));
|
||||
}
|
||||
|
||||
function filterArr(arr, fields) {
|
||||
if(!fields) return arr;
|
||||
return arr.map(row => filterFields(row, fields));
|
||||
}
|
||||
|
||||
// ── Current ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/current', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
|
||||
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||
const condition = getWeatherCondition(Object.assign(sensors, space))
|
||||
|
||||
const forecast = await getForecast();
|
||||
const merged = {
|
||||
...condition,
|
||||
...sensors,
|
||||
...(forecast?.summary ? {
|
||||
precipitation_chance: forecast.summary.precipitation_chance,
|
||||
temperature_max: forecast.summary.temperature_max,
|
||||
temperature_min: forecast.summary.temperature_min,
|
||||
uv_index_max: forecast.summary.uv_index,
|
||||
} : {}),
|
||||
...space,
|
||||
}
|
||||
|
||||
res.json(filterFields(merged, fields))
|
||||
}));
|
||||
|
||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
const forecast24Limit = new Date(now.getTime() + 24 * 60 * 60_000)
|
||||
const start = req.query.start ? new Date(req.query.start) : now
|
||||
const end = req.query.end ? new Date(req.query.end) : forecast24Limit
|
||||
|
||||
const [history, forecast24, meteo] = await Promise.all([
|
||||
start < now
|
||||
? queryHourly(start, now)
|
||||
: Promise.resolve([]),
|
||||
now < forecast24Limit && end > now
|
||||
? getForecast().then(f => f.slots.filter(h => new Date(h.time) >= now && new Date(h.time) <= Math.min(end, forecast24Limit)))
|
||||
: Promise.resolve([]),
|
||||
end > forecast24Limit
|
||||
? hourlyWeather(coords.latitude, coords.longitude, forecast24Limit, end)
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo])
|
||||
res.json(filterArr(combined, fields))
|
||||
}))
|
||||
|
||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now)
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const todayEnd = new Date(todayStart)
|
||||
todayEnd.setDate(todayStart.getDate() + 1)
|
||||
const start = req.query.start ? new Date(req.query.start) : todayStart
|
||||
const end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
|
||||
|
||||
const [history, current, future] = await Promise.all([
|
||||
start < todayStart
|
||||
? queryDaily(start, todayStart)
|
||||
: Promise.resolve([]),
|
||||
start <= todayStart && end >= todayStart
|
||||
? queryCurrent().then(resp => [resp])
|
||||
: Promise.resolve([]),
|
||||
end > todayEnd
|
||||
? dailyWeather(coords.latitude, coords.longitude, todayEnd, end)
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...current, ...future])
|
||||
res.json(filterArr(daily, fields))
|
||||
}))
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/position', asyncHandler(async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const data = await getCoords();
|
||||
res.json(filterFields(data, fields));
|
||||
}));
|
||||
|
||||
// ── Icon ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
||||
const { icon } = req.params;
|
||||
if(!icon) return next(); // 404 fallback can handle missing icons
|
||||
res.contentType('image/png');
|
||||
res.sendFile(await fetchIcon(icon));
|
||||
}));
|
||||
|
||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
||||
// app.get('/api/space', async (req, res) => {
|
||||
// const {fields} = req.query;
|
||||
// res.json(filterFields(await getSpaceWeather(), fields));
|
||||
// });
|
||||
|
||||
// ── ADSB/AIS Proxy ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
||||
app.get('/api/adsb-image/:icao', asyncHandler(async (req, res) => {
|
||||
const blob = await getADSBImage(req.params.icao);
|
||||
if (!blob) return res.status(404).send('No image found');
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
res.contentType(blob.type).send(buffer);
|
||||
}));
|
||||
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getADSBHistory(req.params.icao))));
|
||||
app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS())));
|
||||
app.get('/api/ais-image/:mmsi', asyncHandler(async (req, res) => {
|
||||
const blob = await getAISImage(req.params.mmsi);
|
||||
if (!blob) return res.status(404).send('No image found');
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
res.contentType(blob.type).send(buffer);
|
||||
}));
|
||||
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
||||
|
||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
|
||||
app.get('/openapi.json',asyncHandler(async (req, res) => res.json(spec)));
|
||||
app.get('/openapi.yaml',asyncHandler(async (req, res) => {
|
||||
const {stringify} = await import('yaml');
|
||||
res.setHeader('Content-Type', 'text/yaml');
|
||||
res.send(stringify(spec));
|
||||
}));
|
||||
|
||||
// ── Website ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
const index = resolve(CLIENT_DIST, 'index.html');
|
||||
if(existsSync(index)) res.sendFile(index);
|
||||
else res.status(404).send('Client not built yet — run npm run build in /client');
|
||||
});
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.status(404).json({ error: `Not found: ${req.method} ${req.path}` })
|
||||
})
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(`[ERROR] ${req.method} ${req.path}\n${err.stack}`)
|
||||
res.status(500).json({ error: err.message, stack: err.stack })
|
||||
})
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const c = cfg();
|
||||
|
||||
setTimeout(getForecast, 1)
|
||||
setInterval(getForecast, forecastTTL)
|
||||
await initAircraftDb();
|
||||
|
||||
app.listen(c.PORT, () => {
|
||||
console.log(`⛅ Weather API — http://localhost:${c.PORT}`)
|
||||
});
|
||||
161
server/src/spec.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
export const spec = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Weather Station',
|
||||
version: '1.0.0',
|
||||
description: 'Hyperlocal weather station — sensor data, forecasts, celestial & space weather',
|
||||
},
|
||||
servers: [{ url: 'http://localhost:3000' }],
|
||||
paths: {
|
||||
'/api/position': {
|
||||
get: {
|
||||
summary: 'Station position',
|
||||
description: 'Latest GPS position and altitude',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Last known position',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Position' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/current': {
|
||||
get: {
|
||||
summary: 'Current conditions',
|
||||
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
|
||||
parameters: [
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current conditions',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/hourly': {
|
||||
get: {
|
||||
summary: 'Hourly data',
|
||||
description: 'Hourly aggregated sensor data merged with Open-Meteo hourly forecast, celestial positions and space weather. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of hourly rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/daily': {
|
||||
get: {
|
||||
summary: 'Daily data',
|
||||
description: 'Daily aggregated sensor data (mean/min/max) merged with Open-Meteo daily forecast and celestial events. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of daily rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Position: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||
}
|
||||
},
|
||||
DataRow: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
time: { type: 'string', format: 'date-time', description: 'ISO8601 timestamp' },
|
||||
// Environment
|
||||
temperature: { type: 'number', description: 'Temperature (°C)' },
|
||||
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
|
||||
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
|
||||
humidity: { type: 'number', description: 'Relative humidity (%)' },
|
||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
||||
dew_point: { type: 'number', description: 'Dew point (°C)' },
|
||||
heat_index: { type: 'number', description: 'Feels like / heat index (°C)' },
|
||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' },
|
||||
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
||||
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
||||
// Light & Solar
|
||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||
uv_index: { type: 'number', description: 'UV index' },
|
||||
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||
daily_light_integral: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||
clouds: { type: 'number', description: 'Cloud cover fraction (0–1)' },
|
||||
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||
daylight: { type: 'number', description: 'Hours of daylight' },
|
||||
// Weather
|
||||
label: { type: 'string', description: 'Human readable weather label' },
|
||||
icon: { type: 'string', description: 'OWM icon code e.g. 01d' },
|
||||
raining: { type: 'string', enum: ['True','False'] },
|
||||
precipitation: { type: 'number', description: 'Precipitation (mm)' },
|
||||
precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' },
|
||||
accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' },
|
||||
storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' },
|
||||
// Wind
|
||||
wind_speed: { type: 'number', description: 'Wind speed (km/h)' },
|
||||
wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||
wind_direction: { type: 'number', description: 'Wind direction (°)' },
|
||||
// Lightning
|
||||
lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||
// Sun
|
||||
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||
sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' },
|
||||
sunset: { type: 'string', format: 'date-time', description: 'Sunset time' },
|
||||
daytime: { type: 'boolean', description: 'True if between sunrise and sunset' },
|
||||
// Moon
|
||||
moon_phase: { type: 'string', description: 'Moon phase name' },
|
||||
moon_illumination: { type: 'number', description: 'Moon illumination (%)' },
|
||||
moon_elevation: { type: 'number', description: 'Moon elevation (°)' },
|
||||
moon_azimuth: { type: 'number', description: 'Moon azimuth (°)' },
|
||||
moonrise: { type: 'string', format: 'date-time', description: 'Moonrise time' },
|
||||
moonset: { type: 'string', format: 'date-time', description: 'Moonset time' },
|
||||
moon_new: { type: 'string', format: 'date-time', description: 'Next new moon' },
|
||||
moon_full: { type: 'string', format: 'date-time', description: 'Next full moon' },
|
||||
// Seismic
|
||||
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g)' },
|
||||
// Ground
|
||||
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||
// GPS
|
||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
setup.sh
@@ -3,22 +3,46 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
USER_NAME="$(whoami)"
|
||||
VM_DIR="/opt/victoriametrics"
|
||||
|
||||
echo "🔧 Setting up Weather Station..."
|
||||
|
||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
||||
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||
|
||||
if ! command -v influxd &> /dev/null; then
|
||||
echo "📦 Installing InfluxDB..."
|
||||
curl https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null
|
||||
echo "deb [signed-by=/usr/share/keyrings/influxdata-archive-keyring.gpg] https://repos.influxdata.com/debian stable main" | sudo tee /etc/apt/sources.list.d/influxdata.list
|
||||
sudo apt update && sudo apt install -y influxdb2
|
||||
sudo systemctl enable --now influxdb
|
||||
if ! systemctl is-enabled --quiet victoriametrics 2>/dev/null; then
|
||||
echo "📦 Installing VictoriaMetrics..."
|
||||
sudo mkdir -p $VM_DIR/data
|
||||
cd $VM_DIR
|
||||
wget -qO vm.tar.gz "https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/v1.146.0/victoria-metrics-linux-arm64-v1.146.0.tar.gz"
|
||||
sudo tar -xzf vm.tar.gz
|
||||
sudo chmod +x $VM_DIR/victoria-metrics-prod
|
||||
rm /tmp/vm.tar.gz
|
||||
|
||||
sudo tee /etc/systemd/system/victoriametrics.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=VictoriaMetrics
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=$VM_DIR/victoria-metrics-prod \
|
||||
-storageDataPath=$VM_DIR/data \
|
||||
-httpListenAddr=:8428 \
|
||||
-retentionPeriod=12
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now victoriametrics
|
||||
else
|
||||
echo "✅ InfluxDB already installed, skipping..."
|
||||
echo "✅ VictoriaMetrics already installed, skipping..."
|
||||
fi
|
||||
|
||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
||||
# ── Server / Node.js ──────────────────────────────────────────────────────────
|
||||
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "📦 Installing Node.js..."
|
||||
@@ -28,7 +52,47 @@ else
|
||||
echo "✅ Node.js already installed, skipping..."
|
||||
fi
|
||||
|
||||
# ── Python venv ───────────────────────────────────────────────────────────────
|
||||
if [ ! -d "$SCRIPT_DIR/server/public/index.html" ]; then
|
||||
echo "🌐 Building Client..."
|
||||
cd "$SCRIPT_DIR/client"
|
||||
npm ci
|
||||
npx vite build
|
||||
else
|
||||
echo "✅ Client installed, skipping..."
|
||||
fi
|
||||
|
||||
if [ ! -d "$SCRIPT_DIR/server/node_modules" ]; then
|
||||
echo "🌐 Installing Server..."
|
||||
cd "$SCRIPT_DIR/server"
|
||||
npm ci
|
||||
else
|
||||
echo "✅ Server installed, skipping..."
|
||||
fi
|
||||
|
||||
if ! systemctl is-enabled --quiet weather-station 2>/dev/null; then
|
||||
echo "⚙️ Installing server service..."
|
||||
sudo tee /etc/systemd/system/weather-station.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Weather Station API
|
||||
After=network.target victoriametrics.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=$SCRIPT_DIR/server
|
||||
ExecStart=npm run start
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=$USER_NAME
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now weather-sensors
|
||||
else
|
||||
echo "✅ Server service already installed, skipping..."
|
||||
fi
|
||||
|
||||
# ── Sensors / Python venv ─────────────────────────────────────────────────────
|
||||
|
||||
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
||||
echo "🐍 Setting up Python environment..."
|
||||
@@ -40,14 +104,12 @@ else
|
||||
echo "✅ Python environment already set up, skipping..."
|
||||
fi
|
||||
|
||||
# ── Systemd service ───────────────────────────────────────────────────────────
|
||||
|
||||
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
||||
echo "⚙️ Installing systemd service..."
|
||||
echo "⚙️ Installing sensor service..."
|
||||
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Weather Station Sensors
|
||||
After=network.target
|
||||
After=network.target victoriametrics.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=$SCRIPT_DIR/sensors
|
||||
@@ -62,10 +124,9 @@ EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now weather-sensors
|
||||
else
|
||||
echo "✅ Systemd service already installed, skipping..."
|
||||
echo "✅ Sensor service already installed, skipping..."
|
||||
fi
|
||||
|
||||
echo "✅ All done!"
|
||||
echo " InfluxDB UI: http://localhost:8086"
|
||||
echo " VictoriaMetrics UI: http://localhost:8428"
|
||||
echo " Sensor logs: journalctl -fu weather-sensors"
|
||||
|
||||
|
||||