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=
|
EMAIL=
|
||||||
DEFAULT_LON=
|
ALTITUDE=
|
||||||
DEFAULT_ALT=
|
LATITUDE=
|
||||||
INFLUX_TOKEN=
|
LONGITUDE=
|
||||||
|
TZ=
|
||||||
|
|||||||
10
.gitignore
vendored
@@ -1,6 +1,14 @@
|
|||||||
.idea
|
.idea
|
||||||
.env.local
|
|
||||||
.vscode
|
.vscode
|
||||||
|
.env*
|
||||||
|
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
server/data/*
|
||||||
|
server/public/*
|
||||||
|
|
||||||
|
sensors/bin
|
||||||
|
sensors/include
|
||||||
|
sensors/lib*
|
||||||
|
sensors/venv
|
||||||
|
sensors/pyvenv.cfg
|
||||||
|
|||||||
@@ -1,13 +1,33 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<title>Weather Station</title>
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<title>Vite App</title>
|
|
||||||
</head>
|
<meta charset="UTF-8">
|
||||||
<body>
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<div id="app"></div>
|
<meta name="theme-color" content="#0f172a">
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<meta name="description" content="Local Weather & Forecasting">
|
||||||
</body>
|
<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>
|
</html>
|
||||||
|
|||||||
150
client/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "weather-station",
|
"name": "weather-station",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ztimson/css-utils": "^1.3.5",
|
||||||
|
"@ztimson/utils": "^0.29.5",
|
||||||
|
"ol-mapbox-style": "^13.4.1",
|
||||||
"vue": "^3.5.38"
|
"vue": "^3.5.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -22,6 +25,7 @@
|
|||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-vue-devtools": "^8.1.2",
|
"vite-plugin-vue-devtools": "^8.1.2",
|
||||||
|
"vue": "^3.5.38",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-tsc": "^3.3.5"
|
"vue-tsc": "^3.3.5"
|
||||||
},
|
},
|
||||||
@@ -269,6 +273,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -278,6 +283,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -311,6 +317,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.7"
|
"@babel/types": "^7.29.7"
|
||||||
@@ -475,6 +482,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.29.7",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
@@ -561,6 +569,7 @@
|
|||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
@@ -581,6 +590,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -942,7 +985,6 @@
|
|||||||
"version": "3.9.3",
|
"version": "3.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
@@ -1266,7 +1308,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
@@ -1372,6 +1413,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
|
||||||
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
|
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.7",
|
"@babel/parser": "^7.29.7",
|
||||||
@@ -1385,6 +1427,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
|
||||||
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
|
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.38",
|
"@vue/compiler-core": "3.5.38",
|
||||||
@@ -1395,6 +1438,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
|
||||||
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
|
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.7",
|
"@babel/parser": "^7.29.7",
|
||||||
@@ -1412,6 +1456,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
|
||||||
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
|
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.38",
|
"@vue/compiler-dom": "3.5.38",
|
||||||
@@ -1472,6 +1517,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
|
||||||
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
|
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.38"
|
"@vue/shared": "3.5.38"
|
||||||
@@ -1481,6 +1527,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
|
||||||
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
|
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.38",
|
"@vue/reactivity": "3.5.38",
|
||||||
@@ -1491,6 +1538,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
|
||||||
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
|
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.38",
|
"@vue/reactivity": "3.5.38",
|
||||||
@@ -1503,6 +1551,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
|
||||||
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
|
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.38",
|
"@vue/compiler-ssr": "3.5.38",
|
||||||
@@ -1516,6 +1565,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
|
||||||
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/tsconfig": {
|
"node_modules/@vue/tsconfig": {
|
||||||
@@ -1541,13 +1591,27 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
|
||||||
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"reference-spec-reader": "^0.2.0",
|
"reference-spec-reader": "^0.2.0",
|
||||||
"unzipit": "2.0.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": {
|
"node_modules/alien-signals": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
|
||||||
@@ -1758,6 +1822,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -1835,7 +1900,6 @@
|
|||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
@@ -1849,6 +1913,7 @@
|
|||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
@@ -1881,6 +1946,7 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
@@ -1905,7 +1971,6 @@
|
|||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -1937,7 +2002,6 @@
|
|||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
|
||||||
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
|
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@petamoriken/float16": "^3.9.3",
|
"@petamoriken/float16": "^3.9.3",
|
||||||
@@ -2106,6 +2170,12 @@
|
|||||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -2130,7 +2200,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
@@ -2420,11 +2489,18 @@
|
|||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/memorystream": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
||||||
@@ -2434,6 +2510,15 @@
|
|||||||
"node": ">= 0.10.0"
|
"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": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@@ -2462,6 +2547,7 @@
|
|||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2535,7 +2621,6 @@
|
|||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fflate": "^0.8.0"
|
"fflate": "^0.8.0"
|
||||||
@@ -2566,7 +2651,6 @@
|
|||||||
"version": "10.9.0",
|
"version": "10.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
|
||||||
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
|
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/rbush": "4.0.0",
|
"@types/rbush": "4.0.0",
|
||||||
@@ -2581,6 +2665,22 @@
|
|||||||
"url": "https://opencollective.com/openlayers"
|
"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": {
|
"node_modules/open": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||||
@@ -2606,14 +2706,12 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
"node_modules/parse-headers": {
|
"node_modules/parse-headers": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
@@ -2644,7 +2742,6 @@
|
|||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-protobuf-schema": "^2.1.0"
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
@@ -2664,6 +2761,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -2696,6 +2794,7 @@
|
|||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2737,14 +2836,12 @@
|
|||||||
"version": "3.6.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/quick-lru": {
|
"node_modules/quick-lru": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -2757,14 +2854,12 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/rbush": {
|
"node_modules/rbush": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"quickselect": "^3.0.0"
|
"quickselect": "^3.0.0"
|
||||||
@@ -2803,14 +2898,12 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/resolve-protobuf-schema": {
|
"node_modules/resolve-protobuf-schema": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"protocol-buffers-schema": "^3.3.1"
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
@@ -3316,6 +3409,7 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -3377,6 +3471,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -3398,7 +3498,7 @@
|
|||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -3436,7 +3536,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
|
||||||
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -3473,6 +3572,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/varint": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
||||||
@@ -3683,6 +3788,7 @@
|
|||||||
"version": "3.5.38",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
|
||||||
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
|
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.38",
|
"@vue/compiler-dom": "3.5.38",
|
||||||
@@ -3732,7 +3838,6 @@
|
|||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
@@ -3772,7 +3877,6 @@
|
|||||||
"version": "1.10.2",
|
"version": "1.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||||
"dev": true,
|
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
@@ -3786,7 +3890,6 @@
|
|||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
|
||||||
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
|
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zarrita/storage": "^0.2.0",
|
"@zarrita/storage": "^0.2.0",
|
||||||
@@ -3797,7 +3900,6 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
||||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT AND BSD-3-Clause"
|
"license": "MIT AND BSD-3-Clause"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"build": "vue-tsc --build && vite build"
|
"build": "vue-tsc --build && vite build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ztimson/css-utils": "^1.3.5",
|
||||||
|
"@ztimson/utils": "^0.29.5",
|
||||||
|
"ol-mapbox-style": "^13.4.1",
|
||||||
"vue": "^3.5.38"
|
"vue": "^3.5.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -22,9 +25,9 @@
|
|||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-vue-devtools": "^8.1.2",
|
"vite-plugin-vue-devtools": "^8.1.2",
|
||||||
|
"vue": "^3.5.38",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-tsc": "^3.3.5",
|
"vue-tsc": "^3.3.5"
|
||||||
"vue": "^3.5.38"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.18.0 || >=24.12.0"
|
"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 }
|
function toggleDark() { dark.value = !dark.value }
|
||||||
</script>
|
</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">
|
<style scoped lang="scss">
|
||||||
.app-wrap {
|
.app-wrap {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -52,7 +17,7 @@ body {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
z-index: 500;
|
z-index: 50;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 99px;
|
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 './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 {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -27,3 +64,25 @@ a,
|
|||||||
place-items: center;
|
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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import Loading from '@/components/Loading.vue';
|
||||||
import type { DataRow } from '../services/api'
|
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` : '—')
|
onMounted(async () => {
|
||||||
const feels = computed(() => props.data.env_heat_index_c != null ? `Feels ${(props.data.env_heat_index_c as number).toFixed(1)}°C` : '')
|
data.value = await api.current();
|
||||||
const icon = computed(() => props.data.forecast_weather_icon as string | null)
|
});
|
||||||
const label = computed(() => props.data.forecast_weather_label as string | null)
|
|
||||||
const humidity = computed(() => props.data.env_humidity != null ? `${(props.data.env_humidity as number).toFixed(0)}%` : '—')
|
|
||||||
const wind = computed(() => props.data.wind_speed_kmh != null ? `${(props.data.wind_speed_kmh as number).toFixed(1)} km/h` : '—')
|
|
||||||
const uvi = computed(() => props.data.light_uvi != null ? `UV ${(props.data.light_uvi as number).toFixed(1)}` : null)
|
|
||||||
const sunrise = computed(() => props.data.sun_sunrise ? new Date(props.data.sun_sunrise as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
|
|
||||||
const sunset = computed(() => props.data.sun_sunset ? new Date(props.data.sun_sunset as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.hero {
|
.today-card {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
border-radius: 12px;
|
||||||
gap: 8px;
|
background: var(--surface);
|
||||||
padding: 16px;
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
padding: 14px;
|
||||||
background: var(--surface);
|
display: flex;
|
||||||
border: 1px solid var(--border);
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.today-icon {
|
||||||
.top {
|
width: 52px;
|
||||||
display: flex;
|
height: 52px;
|
||||||
align-items: center;
|
object-fit: contain;
|
||||||
gap: 12px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.today-label {
|
||||||
.weather-icon {
|
font-size: 12px;
|
||||||
width: 64px;
|
font-weight: 600;
|
||||||
height: 64px;
|
color: var(--text-muted);
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
|
.hi-lo {
|
||||||
.temps {
|
display: flex;
|
||||||
flex: 1;
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
.hi { color: var(--text); }
|
||||||
|
.lo { color: var(--text-muted); font-weight: 400; }
|
||||||
}
|
}
|
||||||
|
.divider {
|
||||||
.temp-main {
|
height: 1px;
|
||||||
font-size: 42px;
|
background: var(--border);
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
.stat-grid {
|
||||||
.temp-feel {
|
display: grid;
|
||||||
font-size: 13px;
|
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||||
color: var(--text-muted);
|
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 {
|
.stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
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>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="hero">
|
<div class="today-card">
|
||||||
<div class="top">
|
<div class="flex-r align-items-center justify-between">
|
||||||
<img v-if="icon" :src="icon" class="weather-icon" alt="weather" />
|
<div v-if="!data" class="pos-rel br-2 overflow-hidden" style="width: 75px; height: 50px"><Loading/></div>
|
||||||
<div class="temps">
|
<div v-else class="flex-c justify-center align-items-center mx-3">
|
||||||
<div class="temp-main">{{ temp }}</div>
|
<img :src="BASE + '/api/icon/' + data.icon" class="today-icon" alt="weather" />
|
||||||
<div class="temp-feel">{{ feels }}</div>
|
<div class="today-label">{{ data.label }}</div>
|
||||||
<div class="weather-label">{{ label }}</div>
|
</div>
|
||||||
</div>
|
<div class="flex-r align-items-center gap-2">
|
||||||
</div>
|
<div class="fs-6 m-0 p-0 flex-r align-items-center">
|
||||||
<div class="stats">
|
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 50px; height: 1.25em"><Loading/></div>
|
||||||
<div class="stat"><span class="stat-label">Humidity</span><span class="stat-value">{{ humidity }}</span></div>
|
<template v-else>{{ Math.round(data.temperature) }}°C</template>
|
||||||
<div class="stat"><span class="stat-label">Wind</span><span class="stat-value">{{ wind }}</span></div>
|
</div>
|
||||||
<div v-if="uvi" class="stat"><span class="stat-label">UV</span><span class="stat-value">{{ uvi }}</span></div>
|
<div class="flex-c fg-muted">
|
||||||
<div class="stat"><span class="stat-label">Sunrise</span><span class="stat-value">{{ sunrise }}</span></div>
|
<div class="hi">
|
||||||
<div class="stat"><span class="stat-label">Sunset</span><span class="stat-value">{{ sunset }}</span></div>
|
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
||||||
</div>
|
<template v-else>↑ {{ ~~(data.temperature_max) }}°</template>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|||||||
@@ -1,62 +1,94 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import Loading from '@/components/Loading.vue';
|
||||||
import type { DataRow } from '../services/api'
|
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 => ({
|
function dir(deg: number) {
|
||||||
date: new Date(d.time as string).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
|
if(deg > 22.5 && deg <= 67.5) return 'NE';
|
||||||
icon: d.forecast_weather_icon as string | null,
|
if(deg > 67.5 && deg <= 112.5) return 'E';
|
||||||
label: d.forecast_weather_label as string,
|
if(deg > 112.5 && deg <= 157.5) return 'SE';
|
||||||
high: d.env_temp_max_c != null ? `${(d.env_temp_max_c as number).toFixed(1)}°` : '—',
|
if(deg > 157.5 && deg <= 202.5) return 'S';
|
||||||
low: d.env_temp_min_c != null ? `${(d.env_temp_min_c as number).toFixed(1)}°` : '—',
|
if(deg > 202.5 && deg <= 247.5) return 'SW';
|
||||||
rain: d.forecast_precipitation_probability != null ? `${d.forecast_precipitation_probability}%` : null,
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.strip {
|
.forecast-wrap {
|
||||||
display: flex;
|
width: 100%;
|
||||||
gap: 8px;
|
}
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar { height: 4px; }
|
.strip {
|
||||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day {
|
.day {
|
||||||
flex: 1;
|
flex: 0 0 100px;
|
||||||
min-width: 80px;
|
display: flex;
|
||||||
display: flex;
|
flex-direction: column;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
align-items: center;
|
gap: 4px;
|
||||||
gap: 4px;
|
padding: 10px 8px;
|
||||||
padding: 10px 8px;
|
border-radius: 10px;
|
||||||
border-radius: 10px;
|
background: var(--surface);
|
||||||
background: var(--surface);
|
border: 1px solid var(--border);
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-name { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; }
|
.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-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-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-temps { display: flex; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
.day-low { color: var(--text-muted); font-weight: 400; }
|
.day-low { color: var(--text-muted); font-weight: 400; }
|
||||||
.day-rain { font-size: 11px; color: #38bdf8; }
|
.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>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="strip">
|
<div class="forecast-wrap">
|
||||||
<div v-for="item in items" :key="item.date" class="day">
|
<div v-if="days?.length" class="strip">
|
||||||
<div class="day-name">{{ item.date }}</div>
|
<div v-for="d in days" class="day">
|
||||||
<img v-if="item.icon" :src="item.icon" class="day-icon" :alt="item.label" />
|
<div class="day-name">{{ formatDate('ddd, MMM D', d.time) }}</div>
|
||||||
<div class="day-label">{{ item.label }}</div>
|
<img :src="BASE + '/api/icon/' + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
|
||||||
<div class="day-temps">
|
<div class="day-label">{{ d.label }}</div>
|
||||||
<span>{{ item.high }}</span>
|
<div class="day-temps">
|
||||||
<span class="day-low">{{ item.low }}</span>
|
<span>↑{{ ~~(d.temperature_max) + '°' || '—' }}</span>
|
||||||
</div>
|
<span class="day-low">↓{{ ~~(d.temperature_min) + '°' || '—' }}</span>
|
||||||
<div v-if="item.rain" class="day-rain">🌧 {{ item.rain }}</div>
|
</div>
|
||||||
</div>
|
<div class="day-humid align-x">🌡️ {{ ~~(d.humidity) || 0 }}%</div>
|
||||||
</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>
|
</template>
|
||||||
|
|||||||
@@ -1,87 +1,245 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import {computed, nextTick, ref, watch} from 'vue';
|
||||||
import { METRICS } from '../services/units'
|
import {api} from '../services/api';
|
||||||
import MetricChart from './MetricChart.vue'
|
|
||||||
import type { DataRow } from '../services/api'
|
|
||||||
|
|
||||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
const props = defineProps<{metricKey: string | null}>();
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed; inset: 0;
|
||||||
inset: 0;
|
background: rgba(0,0,0,0.6);
|
||||||
background: rgba(0,0,0,0.6);
|
z-index: 1000;
|
||||||
z-index: 1000;
|
display: flex; align-items: center; justify-content: center;
|
||||||
display: flex;
|
padding: 16px;
|
||||||
align-items: center;
|
backdrop-filter: blur(4px);
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
width: 100%;
|
width: 100%; max-width: 700px;
|
||||||
max-width: 800px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
align-items: center;
|
margin-bottom: 16px;
|
||||||
justify-content: space-between;
|
.title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; }
|
||||||
margin-bottom: 20px;
|
.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); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.controls {
|
||||||
.modal-title {
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
font-size: 18px;
|
button {
|
||||||
font-weight: 600;
|
font-size: 11px; padding: 4px 12px; border-radius: 6px;
|
||||||
color: var(--text);
|
border: 1px solid var(--border); background: none; color: var(--text-muted); cursor: pointer;
|
||||||
display: flex;
|
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
align-items: center;
|
}
|
||||||
gap: 8px;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.current-val {
|
||||||
.close-btn {
|
font-size: 13px; color: var(--text-muted);
|
||||||
background: none;
|
span { color: var(--accent); font-weight: 700; }
|
||||||
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); }
|
|
||||||
}
|
}
|
||||||
|
.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>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">
|
<span class="title">{{ metricKey?.replace(/_/g, ' ') }}</span>
|
||||||
<span>{{ meta?.icon }}</span>
|
<button class="close-btn" @click="emit('close')">✕</button>
|
||||||
<span>{{ meta?.label ?? metricKey }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<button class="close-btn" @click="emit('close')">✕</button>
|
<div class="controls gap-0 mb-2">
|
||||||
</div>
|
<button :class="{active: mode === 'hourly'}" style="border-radius: 6px 0 0 6px" @click="mode = 'hourly'">Hourly</button>
|
||||||
<MetricChart :metric-key="metricKey" :current-data="currentData" />
|
<button :class="{active: mode === 'daily'}" style="border-radius: 0 6px 6px 0" @click="mode = 'daily'">Daily</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="controls">
|
||||||
</Transition>
|
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customStart"/>
|
||||||
</Teleport>
|
<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>
|
</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">
|
<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 { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map'
|
||||||
import View from 'ol/View'
|
import View from 'ol/View'
|
||||||
@@ -9,108 +14,162 @@ import XYZ from 'ol/source/XYZ'
|
|||||||
import Feature from 'ol/Feature'
|
import Feature from 'ol/Feature'
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point'
|
||||||
import CircleGeom from 'ol/geom/Circle'
|
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 { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
||||||
import { current } from '../services/weather'
|
import { applyStyle } from 'ol-mapbox-style'
|
||||||
import 'ol/ol.css'
|
import 'ol/ol.css'
|
||||||
|
|
||||||
const props = defineProps<{ dark: boolean }>()
|
const current = ref({ latitude: 0, longitude: 0 })
|
||||||
const mapEl = ref<HTMLDivElement>()
|
const props = defineProps<{ dark: boolean }>()
|
||||||
const OWM_KEY = import.meta.env.VITE_OWM_API_KEY || ''
|
const mapEl = ref<HTMLDivElement>()
|
||||||
|
const showOverlays = ref(false)
|
||||||
|
const atActive = ref(true)
|
||||||
|
|
||||||
// Nautical miles to meters
|
|
||||||
const NM = 1852
|
const NM = 1852
|
||||||
|
|
||||||
|
const showWind = ref(false)
|
||||||
|
const windSrc = ref('')
|
||||||
|
|
||||||
const OVERLAYS = [
|
const OVERLAYS = [
|
||||||
{
|
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||||
id: 'rain',
|
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
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 map: Map
|
||||||
let stationLayer: VectorLayer<VectorSource>
|
let stationLayer: VectorLayer<VectorSource>
|
||||||
let radarInterval: ReturnType<typeof setInterval>
|
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({
|
return new TileLayer({
|
||||||
source: new XYZ({
|
source: new XYZ({
|
||||||
url: dark
|
url,
|
||||||
? 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
maxZoom: 7,
|
||||||
: 'https://{a-d}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
tileLoadFunction: (tile: any, src: string) => {
|
||||||
attributions: '© OpenStreetMap © CARTO',
|
const img = tile.getImage()
|
||||||
maxZoom: 19,
|
img.onerror = () => tile.setState(3)
|
||||||
|
img.src = src
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
zIndex: 0,
|
opacity: 0.2,
|
||||||
|
zIndex: 5,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOverlayLayer(id: string): TileLayer<XYZ> {
|
async function refreshRain() {
|
||||||
const def = OVERLAYS.find(o => o.id === id)!
|
const url = await fetchRadarUrl()
|
||||||
return new TileLayer({
|
if (!url) return
|
||||||
source: new XYZ({ url: def.url(radarTs.value), attributions: '' }),
|
|
||||||
opacity: 0.6,
|
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
|
||||||
zIndex: 5,
|
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> {
|
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||||||
const center = fromLonLat([lon, lat])
|
const center = fromLonLat([lon, lat])
|
||||||
const source = new VectorSource()
|
const source = new VectorSource()
|
||||||
const ringColor = dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.18)'
|
|
||||||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
|
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]) {
|
for (const nm of [50, 100, 150, 200]) {
|
||||||
const ring = new Feature(new CircleGeom(center, nm * NM))
|
const ring = new Feature(new CircleGeom(center, nm * NM))
|
||||||
ring.setStyle(new Style({
|
ring.setStyle(new Style({
|
||||||
stroke: new Stroke({ color: ringStroke, width: 1, lineDash: [6, 4] }),
|
stroke: new Stroke({ color: ringStroke, width: 1, lineDash: [6, 4] }),
|
||||||
fill: new Fill({ color: 'transparent' }),
|
fill: new Fill({ color: 'transparent' }),
|
||||||
}))
|
}))
|
||||||
source.addFeature(ring)
|
source.addFeature(ring)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station dot
|
|
||||||
const dot = new Feature(new Point(center))
|
const dot = new Feature(new Point(center))
|
||||||
dot.setStyle(new Style({
|
dot.setStyle(new Style({
|
||||||
image: new CircleStyle({
|
image: new CircleStyle({
|
||||||
radius: 7,
|
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 }),
|
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 })
|
return new VectorLayer({ source, zIndex: 20 })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOverlay(id: string) {
|
onMounted(async () => {
|
||||||
if (activeOverlays.value.has(id)) {
|
const position = await api.position()
|
||||||
activeOverlays.value.delete(id)
|
current.value = {
|
||||||
const layer = overlayLayers[id]
|
latitude: position?.latitude || 0,
|
||||||
if (layer) {
|
longitude: position?.longitude || 0,
|
||||||
map.removeLayer(layer);
|
|
||||||
delete overlayLayers[id];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
activeOverlays.value.add(id)
|
|
||||||
const layer = buildOverlayLayer(id)
|
|
||||||
overlayLayers[id] = layer
|
|
||||||
map.addLayer(layer)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark)
|
||||||
const lat = (current.value.gps_lat as number) || 0
|
|
||||||
const lon = (current.value.gps_lon as number) || 0
|
|
||||||
|
|
||||||
stationLayer = buildStationLayer(lat, lon, props.dark)
|
|
||||||
|
|
||||||
map = new Map({
|
map = new Map({
|
||||||
target: mapEl.value!,
|
target: mapEl.value!,
|
||||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||||
view: new View({ center: fromLonLat([lon, lat]), zoom: 8 }),
|
interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }),
|
||||||
|
view: new View({ center: fromLonLat([current.value.longitude, current.value.latitude]), zoom: 8, maxZoom: 13 }),
|
||||||
controls: [],
|
controls: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const id of activeOverlays.value) {
|
aisLayer = new AISLayer(map)
|
||||||
const layer = buildOverlayLayer(id)
|
aisLayer.show()
|
||||||
overlayLayers[id] = layer
|
airTraffic = new AirTrafficLayer(map)
|
||||||
map.addLayer(layer)
|
airTraffic.show()
|
||||||
}
|
range = new RangeLayer(map)
|
||||||
|
range.show()
|
||||||
|
|
||||||
// Refresh radar every 5 min
|
refreshRain()
|
||||||
radarInterval = setInterval(() => {
|
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => clearInterval(radarInterval))
|
onUnmounted(() => {
|
||||||
|
clearInterval(radarInterval)
|
||||||
|
airTraffic.hide()
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.dark, dark => {
|
watch(() => props.dark, dark => {
|
||||||
map.getLayers().setAt(0, buildBaseLayer(dark))
|
map.getLayers().setAt(0, buildBaseLayer(dark))
|
||||||
// Rebuild station layer with correct colors
|
|
||||||
map.removeLayer(stationLayer)
|
map.removeLayer(stationLayer)
|
||||||
const lat = (current.value.gps_lat as number) || 0
|
const lat = (current.value.latitude as number) || 0
|
||||||
const lon = (current.value.gps_lon as number) || 0
|
const lon = (current.value.longitude as number) || 0
|
||||||
stationLayer = buildStationLayer(lat, lon, dark)
|
stationLayer = buildStationLayer(lat, lon, dark)
|
||||||
map.addLayer(stationLayer)
|
map.addLayer(stationLayer)
|
||||||
})
|
})
|
||||||
@@ -192,19 +235,13 @@ watch(() => props.dark, dark => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-toggles {
|
.wind-iframe {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 16px;
|
inset: 0;
|
||||||
left: 50%;
|
width: 100%;
|
||||||
transform: translateX(-50%);
|
height: 100%;
|
||||||
display: flex;
|
border: none;
|
||||||
gap: 8px;
|
z-index: 10;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-btn {
|
.overlay-btn {
|
||||||
@@ -229,15 +266,85 @@ watch(() => props.dark, dark => {
|
|||||||
|
|
||||||
&:hover:not(.active) { background: var(--hover); }
|
&: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>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="map-wrap">
|
<div class="map-wrap">
|
||||||
<div ref="mapEl" class="map-el" />
|
<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
|
<button
|
||||||
v-for="o in OVERLAYS"
|
v-for="o in OVERLAYS" :key="o.id"
|
||||||
:key="o.id"
|
|
||||||
class="overlay-btn"
|
class="overlay-btn"
|
||||||
:class="{ active: activeOverlays.has(o.id) }"
|
:class="{ active: activeOverlays.has(o.id) }"
|
||||||
@click="toggleOverlay(o.id)"
|
@click="toggleOverlay(o.id)"
|
||||||
@@ -245,5 +352,24 @@ watch(() => props.dark, dark => {
|
|||||||
{{ o.icon }} {{ o.label }}
|
{{ o.icon }} {{ o.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const label = computed(() => props.data[`${props.metricKey.replace(/_[^_]+$/, ''
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, transform 0.1s;
|
transition: background 0.15s, transform 0.1s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&:hover { background: var(--hover); }
|
&:hover { background: var(--hover); }
|
||||||
&:active { transform: scale(0.97); }
|
&:active { transform: scale(0.97); }
|
||||||
|
|||||||
@@ -1,160 +1,155 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import {computed, ref, onMounted} from 'vue';
|
||||||
import { Line } from 'vue-chartjs'
|
import {Line} from 'vue-chartjs';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
||||||
LineElement, Tooltip, Legend, Filler, type ChartOptions
|
LineElement, Tooltip, Legend, Filler, type ChartOptions
|
||||||
} from 'chart.js'
|
} from 'chart.js';
|
||||||
import { METRICS, formatValue } from '../services/units'
|
import {METRICS, formatValue} from '../services/units';
|
||||||
import { fetchHistoricHourly } from '../services/weather'
|
import type {DataRow} from '../services/api';
|
||||||
import type { DataRow } from '../services/api'
|
import {api} 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 historic = ref<DataRow[]>([]);
|
||||||
const forecast = ref<DataRow[]>([])
|
const forecast = ref<DataRow[]>([]);
|
||||||
const loading = ref(true)
|
const loading = ref(true);
|
||||||
const meta = computed(() => METRICS[props.metricKey])
|
const meta = computed(() => METRICS[props.metricKey]);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const now = new Date()
|
const start = new Date(Date.now() - 24 * 3600000);
|
||||||
const start = new Date(now.getTime() - 24 * 3600000).toISOString()
|
const end = new Date(Date.now() + 48 * 3600000);
|
||||||
const end = new Date(now.getTime() + 48 * 3600000).toISOString()
|
const hourly = await api.hourly(start, end);
|
||||||
const [hist, fore] = await Promise.allSettled([
|
historic.value = hourly.filter((h: any) => new Date(h.time).getTime() <= Date.now());
|
||||||
fetchHistoricHourly(start, now.toISOString()),
|
forecast.value = hourly.filter((h: any) => new Date(h.time).getTime() > Date.now());
|
||||||
api.hourly(now.toISOString(), end),
|
loading.value = false;
|
||||||
])
|
});
|
||||||
if (hist.status === 'fulfilled') historic.value = hist.value.filter(r => r[props.metricKey] != null)
|
|
||||||
if (fore.status === 'fulfilled') forecast.value = fore.value.filter(r => r[props.metricKey] != null)
|
|
||||||
loading.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
const nowLabel = computed(() => {
|
const nowLabel = computed(() => {
|
||||||
const d = new Date()
|
const d = new Date();
|
||||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`
|
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||||
})
|
});
|
||||||
|
|
||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
const color = meta.value?.color ?? '#38bdf8'
|
const color = meta.value?.color ?? '#38bdf8';
|
||||||
const histLabels = historic.value.map(r => r.time as string)
|
const histLabels = historic.value.map(r => r.time as string);
|
||||||
const foreLabels = forecast.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 histVals = historic.value.map(r => r[props.metricKey] as number);
|
||||||
const foreVals = forecast.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 currentVal = props.currentData[props.metricKey] as number | null;
|
||||||
|
|
||||||
const allLabels = [...histLabels, nowLabel.value, ...foreLabels]
|
const allLabels = [...histLabels, nowLabel.value, ...foreLabels];
|
||||||
const histData = [...histVals, currentVal, ...foreVals.map(() => null)]
|
const histData = [...histVals, currentVal, ...foreVals.map(() => null)];
|
||||||
const foreData = [...histVals.map(() => null), currentVal, ...foreVals]
|
const foreData = [...histVals.map(() => null), currentVal, ...foreVals];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: allLabels,
|
labels: allLabels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Historic',
|
label: 'Historic',
|
||||||
data: histData,
|
data: histData,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: `${color}22`,
|
backgroundColor: `${color}22`,
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: true,
|
fill: true,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Forecast',
|
label: 'Forecast',
|
||||||
data: foreData,
|
data: foreData,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderDash: [6, 4],
|
borderDash: [6, 4],
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: false,
|
fill: false,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Now',
|
label: 'Now',
|
||||||
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
|
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
|
||||||
borderColor: '#ffffff88',
|
borderColor: '#ffffff88',
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
pointRadius: 6,
|
pointRadius: 6,
|
||||||
pointHoverRadius:8,
|
pointHoverRadius: 8,
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
showLine: false,
|
showLine: false,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: {mode: 'index', intersect: false},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: {display: false},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
|
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-muted)',
|
||||||
maxTicksLimit: 12,
|
maxTicksLimit: 12,
|
||||||
maxRotation: 0,
|
maxRotation: 0,
|
||||||
callback(val, i) {
|
callback(val, i) {
|
||||||
const lbl = this.getLabelForValue(i)
|
const lbl = this.getLabelForValue(i);
|
||||||
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : ''
|
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
grid: { color: 'var(--border)' },
|
grid: {color: 'var(--border)'},
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
ticks: { color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number) },
|
ticks: {color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number)},
|
||||||
grid: { color: 'var(--border)' },
|
grid: {color: 'var(--border)'},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
annotation: {
|
annotation: {
|
||||||
annotations: {
|
annotations: {
|
||||||
nowLine: {
|
nowLine: {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
xMin: nowLabel.value,
|
xMin: nowLabel.value,
|
||||||
xMax: nowLabel.value,
|
xMax: nowLabel.value,
|
||||||
borderColor: '#ffffff44',
|
borderColor: '#ffffff44',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderDash: [4, 4],
|
borderDash: [4, 4],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.chart-wrap {
|
.chart-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 260px;
|
height: 260px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 260px;
|
height: 260px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="loading">Loading chart…</div>
|
<div v-if="loading" class="loading">Loading chart…</div>
|
||||||
<div v-else class="chart-wrap">
|
<div v-else class="chart-wrap">
|
||||||
<Line :data="chartData" :options="chartOptions" />
|
<Line :data="chartData" :options="chartOptions"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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> {
|
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||||
const url = new URL(`${BASE}${path}`, window.location.origin)
|
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
|
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||||
const res = await fetch(url.toString())
|
const key = url.toString();
|
||||||
return res.json()
|
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 = {
|
export const api = {
|
||||||
current: (fields?: string) => get<DataRow>('/api/data', fields ? { fields } : {}),
|
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? {fields} : {}),
|
||||||
hourly: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/hourly', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(fields ? { fields } : {}) }),
|
current: (fields?: string) => get<DataRow>('/api/current', fields ? {fields} : {}),
|
||||||
daily: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/daily', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(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> = {
|
export const METRICS: Record<string, MetricMeta> = {
|
||||||
// Environment
|
// Environment
|
||||||
env_temp_c: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
|
temperature: { 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' },
|
humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' },
|
||||||
env_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' },
|
||||||
env_dew_point_c: { 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' },
|
||||||
env_heat_index_c: { 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' },
|
||||||
env_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' },
|
||||||
env_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' },
|
||||||
env_abs_humidity: { 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' },
|
||||||
env_vpd_kpa: { 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' },
|
||||||
env_aqi_score: { 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' },
|
||||||
env_gas_ohms: { label: 'Gas Resistance', unit: ' Ω', icon: '⚗️', group: 'Environment', precision: 0, color: '#fbbf24' },
|
|
||||||
// Light
|
// Light
|
||||||
light_lux: { label: 'Illuminance', unit: ' lux', icon: '☀️', group: 'Light', precision: 0, color: '#fde68a' },
|
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' },
|
uv_index: { 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' },
|
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' },
|
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_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' },
|
clouds: { 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' },
|
day_light_integral: { 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' },
|
visibility: { label: 'Visibility', unit: ' km', icon: '👁️', group: 'Light', precision: 1, color: '#7dd3fc' },
|
||||||
// Wind
|
// Wind
|
||||||
wind_speed_kmh: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' },
|
wind_speed: { 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_gusts: { 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_direction: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
|
||||||
// Seismic
|
// Seismic
|
||||||
seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
|
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_x: { 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_y: { 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_z: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||||
// Compass
|
// 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
|
||||||
ground_distance_cm: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' },
|
ground_distance: { 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' },
|
accumulation: { 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' },
|
lidar_strength: { label: 'LIDAR Strength', unit: '', icon: '📡', group: 'Ground', precision: 0, color: '#6ee7b7' },
|
||||||
// Lightning
|
// Lightning
|
||||||
lightning_distance_km: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' },
|
lightning_distance: { 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_rate: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' },
|
||||||
// Moon
|
// 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
|
||||||
sun_elevation: { label: 'Solar Elevation', unit: '°', icon: '☀️', group: 'Celestial', precision: 1, color: '#fef08a' },
|
sun_elevation: { label: 'Solar Elevation', unit: '°', icon: '☀️', group: 'Celestial', precision: 1, color: '#fef08a' },
|
||||||
// Space
|
// Space
|
||||||
@@ -58,16 +57,25 @@ export const METRICS: Record<string, MetricMeta> = {
|
|||||||
// Marine
|
// Marine
|
||||||
wave_height: { label: 'Wave Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#38bdf8' },
|
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' },
|
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
|
||||||
forecast_precipitation_mm: { label: 'Precipitation', unit: ' mm', icon: '🌧️', group: 'Forecast', precision: 1, color: '#38bdf8' },
|
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' },
|
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 {
|
export function formatValue(key: string, value: number | string | null): string {
|
||||||
if (value === null || value === undefined) return '—'
|
if (value === null || value === undefined) return '—'
|
||||||
const meta = METRICS[key]
|
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">
|
`<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
import GraphModal from '@/components/GraphModal.vue';
|
||||||
import { current, hourly, daily, fetchAll, loading } from '../services/weather'
|
import HourlyForecast from '@/components/HourlyForecast.vue';
|
||||||
import { METRICS, GROUPS } from '../services/units'
|
import Moon from '@/components/Moon.vue';
|
||||||
import MapView from '../components/MapView.vue'
|
import Precipitation from '@/components/Precipitation.vue';
|
||||||
import CurrentWeather from '../components/CurrentWeather.vue'
|
import Seismic from '@/components/Seismic.vue';
|
||||||
import ForecastStrip from '../components/ForecastStrip.vue'
|
import Sun from '@/components/Sun.vue';
|
||||||
import MetricCard from '../components/MetricCard.vue'
|
import Wind from '@/components/Wind.vue';
|
||||||
import GraphModal from '../components/GraphModal.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)
|
provide('openGraph', (key: string) => selectedMetric.value = key);
|
||||||
let interval: ReturnType<typeof setInterval>
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchAll()
|
useWeather();
|
||||||
interval = setInterval(fetchAll, 60 * 1000)
|
clock = setInterval(() => {
|
||||||
})
|
day.value = formatDate('dddd');
|
||||||
|
date.value = formatDate('MMMM D');
|
||||||
|
time.value = formatDate('h:mm A');
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
onUnmounted(() => clearInterval(interval))
|
onUnmounted(() => {
|
||||||
|
clearInterval(clock);
|
||||||
// Group all available metric keys present in current data
|
});
|
||||||
const groupedMetrics = computed(() => {
|
|
||||||
return GROUPS.map(group => ({
|
|
||||||
group,
|
|
||||||
keys: Object.entries(METRICS)
|
|
||||||
.filter(([key, meta]) => meta.group === group && current.value[key] != null)
|
|
||||||
.map(([key]) => key)
|
|
||||||
})).filter(g => g.keys.length > 0)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -50,16 +55,18 @@ const groupedMetrics = computed(() => {
|
|||||||
|
|
||||||
.panel-col {
|
.panel-col {
|
||||||
width: 380px;
|
width: 380px;
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
|
|
||||||
&::-webkit-scrollbar { width: 4px; }
|
&::-webkit-scrollbar {
|
||||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-label {
|
.group-label {
|
||||||
@@ -74,107 +81,106 @@ const groupedMetrics = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: 1 1 140px; // grow, shrink, min width before wrapping
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-bar {
|
.loading-bar {
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
animation: pulse 1s infinite;
|
animation: pulse 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% {
|
||||||
50% { opacity: 0.4; }
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
overflow-y: auto;
|
||||||
overflow: hidden;
|
overflow-x: hidden; // ← added
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-col {
|
.map-col {
|
||||||
position: absolute;
|
width: 100%;
|
||||||
inset: 0;
|
height: 100vw;
|
||||||
height: 100vh;
|
min-height: 100vw;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
z-index: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-col {
|
.panel-col {
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
border-radius: 16px 16px 0 0;
|
overflow-y: visible;
|
||||||
|
overflow-x: hidden; // ← added
|
||||||
/* 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 */
|
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
box-sizing: border-box; // ← added
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
display: none;
|
||||||
display: block;
|
|
||||||
width: 36px;
|
|
||||||
height: 4px;
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin: 0 auto 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar { display: none; }
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<div v-if="loading" class="loading-bar" />
|
<div class="map-col">
|
||||||
|
<MapView :dark="props.dark"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="map-col">
|
<div class="panel-col">
|
||||||
<MapView :dark="props.dark" />
|
<div class="flex-r align-items-center gap-3 mb-3">
|
||||||
</div>
|
<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">
|
<Seismic class="mb-3" />
|
||||||
<CurrentWeather :data="current" />
|
<Precipitation class="mb-3" />
|
||||||
<ForecastStrip :days="daily" />
|
<Wind class="mb-3" />
|
||||||
|
<Sun class="mb-3" />
|
||||||
|
<Moon class="mb-3" />
|
||||||
|
|
||||||
<template v-for="g in groupedMetrics" :key="g.group">
|
<div class="align-x">
|
||||||
<div class="group-label">{{ g.group }}</div>
|
<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 class="metric-grid">
|
</div>
|
||||||
<MetricCard
|
</div>
|
||||||
v-for="key in g.keys"
|
|
||||||
:key="key"
|
|
||||||
:metric-key="key"
|
|
||||||
:data="current"
|
|
||||||
@click="selectedMetric = key"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GraphModal
|
<GraphModal :metric-key="selectedMetric" @close="selectedMetric = null" />
|
||||||
:metric-key="selectedMetric"
|
</div>
|
||||||
:current-data="current"
|
|
||||||
@close="selectedMetric = null"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
</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",
|
"name": "weather-station",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"dependencies": {
|
"scripts": {
|
||||||
"@influxdata/influxdb-client": "^1.33.2",
|
"start": "node src/server.mjs"
|
||||||
"@scalar/express-api-reference": "^0.10.4",
|
},
|
||||||
"dotenv": "^16.3.1",
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"@scalar/express-api-reference": "^0.10.4",
|
||||||
"yaml": "^2.9.0"
|
"@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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
USER_NAME="$(whoami)"
|
USER_NAME="$(whoami)"
|
||||||
|
VM_DIR="/opt/victoriametrics"
|
||||||
|
|
||||||
echo "🔧 Setting up Weather Station..."
|
echo "🔧 Setting up Weather Station..."
|
||||||
|
|
||||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v influxd &> /dev/null; then
|
if ! systemctl is-enabled --quiet victoriametrics 2>/dev/null; then
|
||||||
echo "📦 Installing InfluxDB..."
|
echo "📦 Installing VictoriaMetrics..."
|
||||||
curl https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null
|
sudo mkdir -p $VM_DIR/data
|
||||||
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
|
cd $VM_DIR
|
||||||
sudo apt update && sudo apt install -y influxdb2
|
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 systemctl enable --now influxdb
|
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
|
else
|
||||||
echo "✅ InfluxDB already installed, skipping..."
|
echo "✅ VictoriaMetrics already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
# ── Server / Node.js ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
echo "📦 Installing Node.js..."
|
echo "📦 Installing Node.js..."
|
||||||
@@ -28,7 +52,47 @@ else
|
|||||||
echo "✅ Node.js already installed, skipping..."
|
echo "✅ Node.js already installed, skipping..."
|
||||||
fi
|
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
|
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
||||||
echo "🐍 Setting up Python environment..."
|
echo "🐍 Setting up Python environment..."
|
||||||
@@ -40,14 +104,12 @@ else
|
|||||||
echo "✅ Python environment already set up, skipping..."
|
echo "✅ Python environment already set up, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Systemd service ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
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
|
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Weather Station Sensors
|
Description=Weather Station Sensors
|
||||||
After=network.target
|
After=network.target victoriametrics.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=$SCRIPT_DIR/sensors
|
WorkingDirectory=$SCRIPT_DIR/sensors
|
||||||
@@ -62,10 +124,9 @@ EOF
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now weather-sensors
|
sudo systemctl enable --now weather-sensors
|
||||||
else
|
else
|
||||||
echo "✅ Systemd service already installed, skipping..."
|
echo "✅ Sensor service already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ All done!"
|
echo "✅ All done!"
|
||||||
echo " InfluxDB UI: http://localhost:8086"
|
echo " VictoriaMetrics UI: http://localhost:8428"
|
||||||
echo " Sensor logs: journalctl -fu weather-sensors"
|
echo " Sensor logs: journalctl -fu weather-sensors"
|
||||||
|
|
||||||
|
|||||||