Compare commits
45 Commits
6bc4d920f8
...
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 |
@@ -1,5 +0,0 @@
|
||||
## TODO
|
||||
- Proxy ICAO lookups on failure
|
||||
- Make sure openmeteo properties match + we backfill any missing
|
||||
- Fix daily (missing future dates + timestamp on 24 hour forecast is wrong)
|
||||
- Update openapi doc
|
||||
|
||||
@@ -2,11 +2,29 @@
|
||||
<html lang="">
|
||||
<head>
|
||||
<title>Weather Station</title>
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="description" content="Local Weather & Forecasting">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Weather Station">
|
||||
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<!-- 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>
|
||||
|
||||
101
client/package-lock.json
generated
101
client/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@ztimson/css-utils": "^1.3.5",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"ol-mapbox-style": "^13.4.1",
|
||||
"vue": "^3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -589,6 +590,40 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
|
||||
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -950,7 +985,6 @@
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
@@ -1274,7 +1308,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
@@ -1558,7 +1591,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
|
||||
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reference-spec-reader": "^0.2.0",
|
||||
@@ -1868,7 +1900,6 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
@@ -1940,7 +1971,6 @@
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -1972,7 +2002,6 @@
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
|
||||
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@petamoriken/float16": "^3.9.3",
|
||||
@@ -2141,6 +2170,12 @@
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -2165,7 +2200,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
@@ -2461,6 +2495,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-to-css-font": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-3.2.0.tgz",
|
||||
"integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/memorystream": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
||||
@@ -2470,6 +2510,15 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -2572,7 +2621,6 @@
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.0"
|
||||
@@ -2603,7 +2651,6 @@
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
|
||||
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
@@ -2618,6 +2665,22 @@
|
||||
"url": "https://opencollective.com/openlayers"
|
||||
}
|
||||
},
|
||||
"node_modules/ol-mapbox-style": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.4.1.tgz",
|
||||
"integrity": "sha512-da4FKZUXoXMzK5ADeRHAXzd3bTeiKg/Y9LBOTB6qNTP62MQm93y6ISrIXzOS9atfGgQMEHJ2pCgP272IMeZCXA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.4.1",
|
||||
"mapbox-to-css-font": "^3.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ahocevar"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||
@@ -2643,14 +2706,12 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"dev": true,
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-headers": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
@@ -2681,7 +2742,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
@@ -2776,14 +2836,12 @@
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2796,14 +2854,12 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^3.0.0"
|
||||
@@ -2842,14 +2898,12 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
@@ -3417,6 +3471,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3476,7 +3536,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
|
||||
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -3779,7 +3838,6 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/which": {
|
||||
@@ -3819,7 +3877,6 @@
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
@@ -3833,7 +3890,6 @@
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
|
||||
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zarrita/storage": "^0.2.0",
|
||||
@@ -3844,7 +3900,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||
"dev": true,
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@ztimson/css-utils": "^1.3.5",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"ol-mapbox-style": "^13.4.1",
|
||||
"vue": "^3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,9 +25,9 @@
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-vue-devtools": "^8.1.2",
|
||||
"vue": "^3.5.38",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-tsc": "^3.3.5",
|
||||
"vue": "^3.5.38"
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
|
||||
370
client/public/dark-theme.json
Normal file
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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
BIN
client/public/favicon.png
Normal file
BIN
client/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
370
client/public/light-theme.json
Normal file
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
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"]
|
||||
}
|
||||
@@ -17,7 +17,7 @@ function toggleDark() { dark.value = !dark.value }
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 500;
|
||||
z-index: 50;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
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) },
|
||||
@@ -15,6 +16,7 @@ const BASE_VERTICAL_UNITS = {
|
||||
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)
|
||||
@@ -203,17 +205,24 @@ const navball = computed(() => {
|
||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<!-- Meta -->
|
||||
<div class="ap-meta flex-r justify-between">
|
||||
<div class="flex-c">
|
||||
<span>{{ operator }}</span>
|
||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>{{ description }}</span>
|
||||
<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">
|
||||
@@ -247,7 +256,7 @@ const navball = computed(() => {
|
||||
<style scoped>
|
||||
.aircraft-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
@@ -257,6 +266,7 @@ const navball = computed(() => {
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.ap-header {
|
||||
@@ -285,10 +295,6 @@ const navball = computed(() => {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
}
|
||||
.ap-meta--detail { margin-top: 3px; }
|
||||
|
||||
.ap-icao { text-align: right; }
|
||||
.ap-operator { text-align: right; color: #aaa; }
|
||||
|
||||
.ap-close {
|
||||
position: absolute;
|
||||
@@ -309,6 +315,19 @@ const navball = computed(() => {
|
||||
}
|
||||
.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;
|
||||
@@ -340,5 +359,5 @@ const navball = computed(() => {
|
||||
.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; }
|
||||
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getAQILabel, getPressureTrend} from '@/services/units.ts';
|
||||
import {getPressureTrend} from '@/services/units.ts';
|
||||
import {inject, onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
@@ -108,47 +108,75 @@ onMounted(async () => {
|
||||
<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">{{ ~~(data.heat_index) }}<span class="fg-muted">°C</span></span>
|
||||
<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">{{ Math.round(data.humidity) }}%</span>
|
||||
<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">{{ Math.round(data.clouds) }}%</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</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.pressure_hpa?.toFixed(1) }} <span class="fg-muted">hPa</span></span>
|
||||
<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">{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ getPressureTrend(data.pressure_trend) }})</span></span>
|
||||
<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">{{ data.vapor_pressure_deficit?.toFixed(2) }} <span class="fg-muted">kPa</span></span>
|
||||
<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">{{ data.air_quality }} <span class="fg-muted">/ 500</span></span>
|
||||
<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">{{ data.air_quality_ohms }} <span class="fg-muted">Ω</span></span>
|
||||
<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">{{ data.visibility?.toFixed(1) }} <span class="fg-muted">km</span></span>
|
||||
<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>
|
||||
|
||||
@@ -73,9 +73,9 @@ onMounted(async () => {
|
||||
<span>↑{{ ~~(d.temperature_max) + '°' || '—' }}</span>
|
||||
<span class="day-low">↓{{ ~~(d.temperature_min) + '°' || '—' }}</span>
|
||||
</div>
|
||||
<div class="day-humid">🌡️ {{ ~~(d.humidity) || 0 }}%</div>
|
||||
<div class="day-wind">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div>
|
||||
<div class="day-rain">🌧️ {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
|
||||
<div class="day-humid align-x">🌡️ {{ ~~(d.humidity) || 0 }}%</div>
|
||||
<div class="day-wind align-x">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div>
|
||||
<div class="day-rain align-x">🌧️ {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="strip">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<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 { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '@/services/api.ts'
|
||||
import { RangeLayer } from '@/services/range.ts'
|
||||
import VectorTileLayer from 'ol/layer/VectorTile'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
import TileLayer from 'ol/layer/Tile'
|
||||
@@ -13,11 +14,13 @@ import XYZ from 'ol/source/XYZ'
|
||||
import Feature from 'ol/Feature'
|
||||
import Point from 'ol/geom/Point'
|
||||
import CircleGeom from 'ol/geom/Circle'
|
||||
import { defaults as defaultInteractions } from 'ol/interaction'
|
||||
import { fromLonLat, toLonLat } from 'ol/proj'
|
||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
||||
import { applyStyle } from 'ol-mapbox-style'
|
||||
import 'ol/ol.css'
|
||||
|
||||
const current = ref({latitude: 0, longitude: 0});
|
||||
const current = ref({ latitude: 0, longitude: 0 })
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
const showOverlays = ref(false)
|
||||
@@ -25,17 +28,12 @@ const atActive = ref(true)
|
||||
|
||||
const NM = 1852
|
||||
|
||||
const radarFrames = ref<{ path: string; time: number }[]>([])
|
||||
const radarFrameIndex = ref(0)
|
||||
const radarPath = computed(() => radarFrames.value[radarFrameIndex.value]?.path ?? '')
|
||||
const nowcastStartIndex = ref(0)
|
||||
|
||||
const showWind = ref(false)
|
||||
const windSrc = ref('')
|
||||
|
||||
const OVERLAYS = [
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️', url: () => `https://tilecache.rainviewer.com${radarPath.value}/256/{z}/{x}/{y}/2/1_1.png` },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨', url: () => '' },
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||
]
|
||||
|
||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||
@@ -58,38 +56,60 @@ function syncWindSrc() {
|
||||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
|
||||
}
|
||||
|
||||
async function fetchRadarFrames() {
|
||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
||||
const data = await res.json()
|
||||
const past = data.radar.past ?? []
|
||||
const nowcast = data.radar.nowcast ?? []
|
||||
radarFrames.value = [...past, ...nowcast]
|
||||
nowcastStartIndex.value = past.length
|
||||
function showWindOverlay() {
|
||||
syncWindSrc()
|
||||
showWind.value = true
|
||||
map.getInteractions().forEach(i => i.setActive(false))
|
||||
}
|
||||
|
||||
function buildRainSource(url: string): XYZ {
|
||||
return new XYZ({
|
||||
function hideWindOverlay() {
|
||||
showWind.value = false
|
||||
map.getInteractions().forEach(i => i.setActive(true))
|
||||
}
|
||||
|
||||
async function fetchRadarUrl(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
||||
const data = await res.json()
|
||||
const past = data.radar?.past ?? []
|
||||
const latest = past[past.length - 1]
|
||||
if (!latest) return null
|
||||
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildRainLayer(url: string): TileLayer<XYZ> {
|
||||
return new TileLayer({
|
||||
source: new XYZ({
|
||||
url,
|
||||
attributions: '',
|
||||
maxZoom: 7,
|
||||
tileLoadFunction: (tile: any, src: string) => {
|
||||
const img = tile.getImage()
|
||||
img.onerror = () => tile.setState(3)
|
||||
img.src = src
|
||||
},
|
||||
}),
|
||||
opacity: 0.2,
|
||||
zIndex: 5,
|
||||
})
|
||||
}
|
||||
|
||||
function seekRadar(index: number) {
|
||||
radarFrameIndex.value = index
|
||||
const layer = overlayLayers['rain']
|
||||
if (layer) {
|
||||
layer.setSource(buildRainSource(OVERLAYS.find(o => o.id === 'rain')!.url()))
|
||||
}
|
||||
async function refreshRain() {
|
||||
const url = await fetchRadarUrl()
|
||||
if (!url) return
|
||||
|
||||
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
|
||||
if (!activeOverlays.value.has('rain')) return
|
||||
|
||||
const layer = buildRainLayer(url)
|
||||
overlayLayers['rain'] = layer
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
function toggleAT() {
|
||||
if(atActive.value) {
|
||||
if (atActive.value) {
|
||||
aisLayer.hide()
|
||||
airTraffic.hide()
|
||||
range.hide()
|
||||
@@ -105,45 +125,30 @@ function toggleOverlay(id: string) {
|
||||
if (id === 'wind') {
|
||||
if (activeOverlays.value.has('wind')) {
|
||||
activeOverlays.value.delete('wind')
|
||||
showWind.value = false
|
||||
hideWindOverlay()
|
||||
} else {
|
||||
activeOverlays.value.add('wind')
|
||||
syncWindSrc()
|
||||
showWind.value = true
|
||||
showWindOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeOverlays.value.has(id)) {
|
||||
activeOverlays.value.delete(id)
|
||||
const layer = overlayLayers[id]
|
||||
if (layer) { map.removeLayer(layer); delete overlayLayers[id] }
|
||||
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(id)
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers[id] = layer
|
||||
map.addLayer(layer)
|
||||
activeOverlays.value.add('rain')
|
||||
refreshRain()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
return new TileLayer({
|
||||
source: new XYZ({
|
||||
url: dark
|
||||
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
|
||||
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
|
||||
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function buildOverlayLayer(id: string): TileLayer<XYZ> {
|
||||
const def = OVERLAYS.find(o => o.id === id)!
|
||||
return new TileLayer({
|
||||
source: buildRainSource(def.url()),
|
||||
opacity: 0.2,
|
||||
zIndex: 5,
|
||||
})
|
||||
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> {
|
||||
@@ -174,46 +179,31 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRadarFrames()
|
||||
radarFrameIndex.value = nowcastStartIndex.value - 1
|
||||
const position = await api.position()
|
||||
current.value = {
|
||||
latitude: position?.latitude || 0,
|
||||
longitude: position?.longitude || 0,
|
||||
}
|
||||
|
||||
const position = await api.position();
|
||||
|
||||
const lat = position?.latitude || 0
|
||||
const lon = position?.longitude || 0
|
||||
|
||||
stationLayer = buildStationLayer(lat, lon, props.dark)
|
||||
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark)
|
||||
|
||||
map = new Map({
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
|
||||
interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }),
|
||||
view: new View({ center: fromLonLat([current.value.longitude, current.value.latitude]), zoom: 8, maxZoom: 13 }),
|
||||
controls: [],
|
||||
})
|
||||
|
||||
aisLayer = new AISLayer(map);
|
||||
aisLayer.show();
|
||||
aisLayer = new AISLayer(map)
|
||||
aisLayer.show()
|
||||
airTraffic = new AirTrafficLayer(map)
|
||||
airTraffic.show()
|
||||
range = new RangeLayer(map);
|
||||
range.show();
|
||||
range = new RangeLayer(map)
|
||||
range.show()
|
||||
|
||||
for (const id of activeOverlays.value) {
|
||||
if (id === 'wind') continue
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers[id] = layer
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
map.on('moveend', () => {
|
||||
if (showWind.value) syncWindSrc()
|
||||
})
|
||||
|
||||
radarInterval = setInterval(async () => {
|
||||
await fetchRadarFrames()
|
||||
radarFrameIndex.value = nowcastStartIndex.value - 1
|
||||
seekRadar(radarFrameIndex.value)
|
||||
}, 5 * 60 * 1000)
|
||||
refreshRain()
|
||||
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -326,80 +316,12 @@ watch(() => props.dark, dark => {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.radar-timeline {
|
||||
position: absolute;
|
||||
bottom: 72px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border-radius: 16px;
|
||||
padding: 8px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
width: min(600px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.slider-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slider-ticks {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
&__line {
|
||||
width: 1px;
|
||||
height: 5px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #888);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.nowcast &__line { background: #6ab4ff; }
|
||||
&.nowcast &__label { color: #6ab4ff; }
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop { display: flex; }
|
||||
.mobile { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop { display: none; }
|
||||
.mobile { display: flex; }
|
||||
.radar-timeline { bottom: 10px; }
|
||||
.overlay-toggles { bottom: 10px; }
|
||||
.layers-menu { bottom: 10px; }
|
||||
}
|
||||
@@ -409,7 +331,6 @@ input[type='range'] {
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-el" />
|
||||
|
||||
<!-- Windy animated wind iframe -->
|
||||
<iframe
|
||||
v-if="showWind"
|
||||
class="wind-iframe"
|
||||
@@ -418,7 +339,6 @@ input[type='range'] {
|
||||
allowfullscreen
|
||||
/>
|
||||
|
||||
<!-- Desktop toggles -->
|
||||
<div class="overlay-toggles desktop">
|
||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||
✈️ Traffic
|
||||
@@ -433,7 +353,6 @@ input[type='range'] {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layers button + dropdown -->
|
||||
<div class="layers-menu mobile">
|
||||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||||
⚙️ Layers
|
||||
|
||||
@@ -65,7 +65,11 @@ const {current: d} = useWeather();
|
||||
<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">{{ d.raining == null ? '—' : d.raining ? 'Raining' : d.precipitation_chance + '%' }}</span>
|
||||
<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>
|
||||
@@ -82,6 +86,11 @@ const {current: d} = useWeather();
|
||||
<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>
|
||||
@@ -92,11 +101,6 @@ const {current: d} = useWeather();
|
||||
<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" @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 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>
|
||||
@@ -107,6 +111,11 @@ const {current: d} = useWeather();
|
||||
<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>
|
||||
|
||||
@@ -5,47 +5,100 @@ import { api } from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const d = ref<any>(null);
|
||||
const history = ref<number[]>([]);
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
const W = 640, H = 120, MAX_PTS = 120;
|
||||
|
||||
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 = 1000;
|
||||
const ANIM_DURATION = 1200;
|
||||
|
||||
let pointBuffer: { x: number; y: number }[] = [];
|
||||
let prevMax = 1.0;
|
||||
type Pt = { x: number; y: number };
|
||||
const traces = AXES.map(() => ({
|
||||
history: [] as number[],
|
||||
prevMax: 1.0,
|
||||
buffer: [] as Pt[],
|
||||
}));
|
||||
|
||||
function buildBuffer(pts: number[]) {
|
||||
function buildBuffer(t: (typeof traces)[number], band: number) {
|
||||
const pts = t.history;
|
||||
if (pts.length < 2) return;
|
||||
const mid = H / 2, amp = H / 2 - 6;
|
||||
const mid = band * BAND + BAND / 2, amp = BAND / 2 - 6;
|
||||
const newMax = Math.max(...pts, 1.0);
|
||||
prevMax = prevMax + (newMax - prevMax) * 0.15;
|
||||
const buf: { x: number; y: number }[] = [];
|
||||
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 max = prevMax;
|
||||
const prevNorm = prev === 0 ? 0 : (prev / max) * amp;
|
||||
const norm = v === 0 ? 0 : (v / max) * amp;
|
||||
const baseX = i * SEG_W;
|
||||
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;
|
||||
const a2 = norm;
|
||||
|
||||
buf.push({ x: baseX, y: mid - a0 }); // up
|
||||
buf.push({ x: baseX + s, y: mid - a1 }); // up peak
|
||||
buf.push({ x: baseX + s * 2, y: mid + a2 }); // down
|
||||
buf.push({ x: baseX + s * 3, y: mid }); // back to exact center ✓
|
||||
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 ✓
|
||||
});
|
||||
|
||||
pointBuffer = buf;
|
||||
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) {
|
||||
@@ -58,64 +111,38 @@ function draw(timestamp: number) {
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Center line — full width
|
||||
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, H / 2);
|
||||
ctx.lineTo(W, H / 2);
|
||||
ctx.moveTo(0, mid);
|
||||
ctx.lineTo(W, mid);
|
||||
ctx.stroke();
|
||||
|
||||
if (pointBuffer.length < 2) { rafId = requestAnimationFrame(draw); return; }
|
||||
drawTrace(ctx, traces[i], AXES[i].color);
|
||||
});
|
||||
|
||||
// Shift the whole path left by PAD_SEGS segments so tip never hits the edge
|
||||
const xOffset = 3 * SEG_W;
|
||||
|
||||
const settledCount = pointBuffer.length - SUB;
|
||||
const anchor = pointBuffer[settledCount - 1];
|
||||
const totalSteps = SUB + 1;
|
||||
const animFrac = drawProgress * totalSteps;
|
||||
const animFloor = Math.floor(animFrac);
|
||||
const animRemainder = animFrac - animFloor;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#4d9fff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
for (let i = 0; i < settledCount; i++) {
|
||||
const p = pointBuffer[i];
|
||||
const x = p.x - xOffset;
|
||||
i === 0 ? ctx.moveTo(x, p.y) : ctx.lineTo(x, p.y);
|
||||
}
|
||||
|
||||
const animPoints = [
|
||||
anchor,
|
||||
...Array.from({ length: SUB }, (_, i) => pointBuffer[settledCount + i]),
|
||||
];
|
||||
|
||||
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
||||
ctx.lineTo(animPoints[i].x - xOffset, 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 - xOffset,
|
||||
curr.y + (next.y - curr.y) * animRemainder,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
d.value = await api.current('seismic_magnitude');
|
||||
history.value.push(d.value.seismic_magnitude ?? 0);
|
||||
if (history.value.length > MAX_PTS) history.value.shift();
|
||||
buildBuffer(history.value);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -133,14 +160,17 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
canvas { width: 100%; height: 60px; }
|
||||
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" style="height: 80px">
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
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 }>()
|
||||
@@ -24,10 +25,12 @@ function cycleSpeed() {
|
||||
}
|
||||
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||
|
||||
const name = computed(() => props.boat.name?.trim() || props.boat.callsign?.trim() || '-')
|
||||
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 })
|
||||
@@ -89,10 +92,8 @@ onUnmounted(() => {
|
||||
|
||||
const compass = computed(() => {
|
||||
const hdg = heading.value
|
||||
const course = props.boat.course ?? hdg
|
||||
const bearing = props.boat.bearing ?? 0
|
||||
const course = props.boat.cog ?? hdg
|
||||
|
||||
// ── Compass tape ──────────────────────────────────────────────────────────
|
||||
const tapeTicks: string[] = []
|
||||
for (let offset = -60; offset <= 60; offset += 5) {
|
||||
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||
@@ -113,9 +114,8 @@ const compass = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rose centre & radii ───────────────────────────────────────────────────
|
||||
const CX = 100, CY = 158 // moved down from 145
|
||||
const R = 68 // grown from 55
|
||||
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
|
||||
@@ -125,12 +125,11 @@ const compass = computed(() => {
|
||||
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
||||
const cNeedleY = CY - 52 * Math.cos(courseRad)
|
||||
|
||||
const bearRel = ((bearing - hdg + 540) % 360) - 180
|
||||
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)
|
||||
|
||||
// Ring tick marks scaled to new R
|
||||
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
||||
const a = i * 10
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
@@ -151,49 +150,38 @@ const compass = computed(() => {
|
||||
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
|
||||
}).join('')
|
||||
|
||||
// Legend & rudder bar shifted to bottom of taller SVG
|
||||
const LEG_Y = 190
|
||||
|
||||
return `
|
||||
<svg width="200" height="230" viewBox="0 0 200 230">
|
||||
<!-- Tape -->
|
||||
<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"/>
|
||||
|
||||
<!-- Heading readout -->
|
||||
<rect x="72" y="64" width="56" height="20" fill="rgba(0,0,0,0.9)" stroke="#0ff" stroke-width="1" rx="2"/>
|
||||
<text x="100" y="78" text-anchor="middle" fill="#0ff" font-size="13" font-weight="bold">${String(Math.round(hdg)).padStart(3, '0')}°</text>
|
||||
|
||||
<!-- Rose -->
|
||||
<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>
|
||||
|
||||
<!-- Course needle -->
|
||||
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
|
||||
|
||||
<!-- Bearing needle -->
|
||||
<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"/>
|
||||
|
||||
<!-- Ship symbol -->
|
||||
<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"/>
|
||||
|
||||
<!-- Rudder bar -->
|
||||
<rect x="60" y="${LEG_Y - 4}" width="80" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
||||
<line x1="${CX}" y1="${LEG_Y - 4}" x2="${CX}" y2="${LEG_Y + 2}" stroke="#0ff" stroke-width="1" opacity="0.5"/>
|
||||
<rect x="${rudX - 4}" y="${LEG_Y - 3}" width="8" height="8" fill="#ff0" rx="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"/>
|
||||
|
||||
<!-- Legend -->
|
||||
<line x1="12" y1="${LEG_Y + 20}" x2="24" y2="${LEG_Y + 20}" stroke="#0f0" stroke-width="2"/>
|
||||
<text x="27" y="${LEG_Y + 22}" fill="#888" font-size="9">COG</text>
|
||||
<line x1="12" y1="${LEG_Y + 30}" x2="24" y2="${LEG_Y + 30}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<text x="27" y="${LEG_Y + 33}" fill="#888" font-size="9">BRG</text>
|
||||
<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>
|
||||
`
|
||||
})
|
||||
@@ -213,15 +201,20 @@ const compass = computed(() => {
|
||||
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown' }}</span>
|
||||
<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' }}</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">
|
||||
@@ -255,7 +248,7 @@ const compass = computed(() => {
|
||||
<style scoped>
|
||||
.ship-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
@@ -265,6 +258,7 @@ const compass = computed(() => {
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sp-header {
|
||||
@@ -312,6 +306,21 @@ const compass = computed(() => {
|
||||
}
|
||||
.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;
|
||||
@@ -342,7 +351,6 @@ const compass = computed(() => {
|
||||
}
|
||||
.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: 2px; }
|
||||
|
||||
.sp-compass { flex: 1; }
|
||||
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||
</style>
|
||||
|
||||
@@ -64,7 +64,7 @@ const arc = computed(() => {
|
||||
<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: 1.5"><Loading /></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 -->
|
||||
|
||||
@@ -10,11 +10,10 @@ 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'
|
||||
|
||||
let highestZIndex = 1000
|
||||
|
||||
function getAltColor(alt: number): [number, number, number] {
|
||||
const a = Math.max(0, alt)
|
||||
if (a < 10) return [0, 0, 0]
|
||||
@@ -51,8 +50,6 @@ export class AirTrafficLayer {
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
// ── Public ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
@@ -84,10 +81,8 @@ export class AirTrafficLayer {
|
||||
this.traceSegs = {}
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private async _fetch() {
|
||||
const j = await fetch(`${API}/adsb`).then(r => r.json())
|
||||
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
||||
this.data = (j || []).map((a: any) => ({
|
||||
...a,
|
||||
icao: a.hex,
|
||||
@@ -112,7 +107,7 @@ export class AirTrafficLayer {
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||
scale: 1,
|
||||
scale: 1.25,
|
||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
@@ -182,12 +177,9 @@ export class AirTrafficLayer {
|
||||
|
||||
const app = createApp(AircraftPopup, {
|
||||
plane: planeRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(icao),
|
||||
onBringToFront: () => {
|
||||
highestZIndex++
|
||||
container.style.zIndex = String(highestZIndex)
|
||||
},
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
@@ -215,7 +207,6 @@ export class AirTrafficLayer {
|
||||
const plane = this.data.find(p => p.icao === icao)
|
||||
if (!plane) { this._closePopup(icao); continue }
|
||||
this.popups[icao].planeRef.value = plane
|
||||
// Don't reposition — user may have dragged it 🎯
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,16 @@ 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'
|
||||
|
||||
let highestZIndex = 1000
|
||||
|
||||
const SHIP_COLORS: Record<string, string> = {
|
||||
'Base Station': '#00aaff',
|
||||
'Base Station': '#ffffff',
|
||||
'Class A': '#00ff00',
|
||||
'Class B': '#8450ea',
|
||||
'SAR Aircraft': '#ff0000',
|
||||
'AtoN': '#00aaff',
|
||||
'AtoN': '#ffffff',
|
||||
'Class B/CS': '#8450ea',
|
||||
'Sart/Epirb/MOB': '#00aaff',
|
||||
'Unknown': '#fad106',
|
||||
@@ -134,12 +133,9 @@ export class AISLayer {
|
||||
|
||||
const app = createApp(ShipPopup, {
|
||||
boat: boatRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(mmsi),
|
||||
onBringToFront: () => {
|
||||
highestZIndex++
|
||||
container.style.zIndex = String(highestZIndex)
|
||||
},
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
|
||||
5
client/src/services/zindex.ts
Normal file
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)
|
||||
}
|
||||
@@ -151,42 +151,89 @@ def read_bme(c, sensor, alt_m=None):
|
||||
return None, None, None
|
||||
|
||||
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
MPU_ADDR = 0x68
|
||||
_mpu_peak = None
|
||||
_mpu_lock = threading.Lock()
|
||||
_mpu_offsets = None
|
||||
_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 = [0.0] * 6
|
||||
n = 0
|
||||
sums, n = [0.0, 0.0, 0.0], 0
|
||||
for _ in range(samples):
|
||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
|
||||
if d:
|
||||
sums[0] += s16((d[0] << 8) | d[1]) / 16384.0
|
||||
sums[1] += s16((d[2] << 8) | d[3]) / 16384.0
|
||||
sums[2] += s16((d[4] << 8) | d[5]) / 16384.0
|
||||
sums[3] += s16((d[8] << 8) | d[9]) / 131.0
|
||||
sums[4] += s16((d[10] << 8) | d[11]) / 131.0
|
||||
sums[5] += s16((d[12] << 8) | d[13]) / 131.0
|
||||
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
|
||||
return [sums[0]/n, sums[1]/n, sums[2]/n - 1.0, sums[3]/n, sums[4]/n, sums[5]/n]
|
||||
|
||||
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
|
||||
global _mpu_peak, _mpu_grav
|
||||
while True:
|
||||
if bus and _mpu_offsets:
|
||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
|
||||
if d:
|
||||
o = _mpu_offsets
|
||||
ax = s16((d[0] << 8) | d[1]) / 16384.0 - o[0]
|
||||
ay = s16((d[2] << 8) | d[3]) / 16384.0 - o[1]
|
||||
az = s16((d[4] << 8) | d[5]) / 16384.0 - o[2]
|
||||
mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**2)
|
||||
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 = {
|
||||
@@ -203,6 +250,8 @@ def flush_mpu(c):
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -482,9 +531,9 @@ if __name__ == '__main__':
|
||||
if bus:
|
||||
try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0))
|
||||
time.sleep(0.1)
|
||||
_mpu_offsets = try_init(calibrate_mpu, 'MPU6050')
|
||||
_mpu_grav = try_init(calibrate_mpu, 'MPU6050')
|
||||
else:
|
||||
_mpu_offsets = None
|
||||
_mpu_grav = None
|
||||
print(" ❌ MPU6050: no I2C bus")
|
||||
|
||||
threading.Thread(target=mpu_loop, daemon=True).start()
|
||||
|
||||
@@ -1253,11 +1253,13 @@ function toSvg(shape, fillColor, strokeColor, strokeWidth, scale) {
|
||||
return svg;
|
||||
}
|
||||
|
||||
|
||||
let svg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="' + shape.viewBox + '" '
|
||||
+ (shape.noAspect ? 'preserveAspectRatio="none" ' : '')
|
||||
+ 'width="' + wi + '" height="' + he + '">'
|
||||
+ '<g' + (shape.transform ? (' transform="' + shape.transform + '"') : '') + '>';
|
||||
+ '<g' + (shape.transform ? (' transform="' + shape.transform + '"') : '') + '>'
|
||||
+ '<rect x="' + shape.viewBox.split(' ')[0] + '" y="' + shape.viewBox.split(' ')[1]
|
||||
+ '" width="' + shape.viewBox.split(' ')[2] + '" height="' + shape.viewBox.split(' ')[3]
|
||||
+ '" fill="#ffffff01" stroke="none"/>';
|
||||
|
||||
if (shape.path) {
|
||||
let path = shape.path;
|
||||
|
||||
@@ -14,14 +14,17 @@ 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', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||
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 = [];
|
||||
@@ -150,7 +153,7 @@ function isIcaoInMilitaryRange(icao) {
|
||||
|
||||
function wordMatch(text, keyword) {
|
||||
if (!text) return false;
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/gi, '\\$&');
|
||||
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
||||
}
|
||||
|
||||
@@ -190,17 +193,20 @@ async function scrapeHexDatabase(icao) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const $ = cheerio.load(await resp.text());
|
||||
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 {
|
||||
country: $(cells[1]).text().trim() || null,
|
||||
registration: $(cells[2]).text().trim() || null,
|
||||
aircraft: $(cells[3]).text().trim() || null,
|
||||
operator: $(cells[4]).text().trim() || null,
|
||||
serialNumber: $(cells[6]).text().trim() || null,
|
||||
registration: $(cells[1]).text().trim() || null,
|
||||
aircraft: $(cells[2]).text().trim() || null,
|
||||
operator: $(cells[3]).text().trim() || null,
|
||||
serialNumber: $(cells[5]).text().trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,14 +235,14 @@ export async function enrichAircraft(a) {
|
||||
|
||||
// 1. Check own DB first
|
||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
||||
if(row?.manufacturer && row?.model) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
if(row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
|
||||
// 2. Race the two external sources
|
||||
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||
let found = await Promise.any([
|
||||
fetchHexDb(icao),
|
||||
scrapeHexDatabase(icao),
|
||||
]).catch(() => null);
|
||||
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' };
|
||||
@@ -284,6 +290,7 @@ export async function enrichAircraft(a) {
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -300,11 +307,13 @@ export async function getADSB() {
|
||||
if (trail.length > MAX_HISTORY) trail.shift();
|
||||
}
|
||||
|
||||
return Promise.all(aircraft.map(async a => {
|
||||
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) {
|
||||
@@ -318,3 +327,43 @@ export async function getADSBRange() {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ const SENDER_TYPE = {
|
||||
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++) {
|
||||
@@ -38,6 +42,7 @@ function decodeFlags(flags) {
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -45,7 +50,7 @@ export async function getAIS() {
|
||||
|
||||
const staticMap = new Map(staticRows.map(row => [row[0], row]));
|
||||
|
||||
return dynamic.map(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,
|
||||
@@ -79,4 +84,24 @@ export async function getAIS() {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function cfg() {
|
||||
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'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -188,6 +189,7 @@ export async function get24HourForecast(currentSensors) {
|
||||
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
|
||||
@@ -252,6 +254,7 @@ function summarise(slots) {
|
||||
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')),
|
||||
|
||||
@@ -2,6 +2,9 @@ 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')
|
||||
@@ -87,43 +90,79 @@ export async function dailyWeather(lat, lon, start, end) {
|
||||
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||
if (!data?.daily?.time) return []
|
||||
|
||||
return Promise.all(data.daily.time.map(async (time, i) => {
|
||||
const rows = data.daily.time.map((time, i) => {
|
||||
const code = data.daily.weathercode[i]
|
||||
const icon = OWM_ICONS[code] || '01d'
|
||||
const temp = data.daily.temperature_2m_max[i]
|
||||
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 uv = data.daily.uv_index_max[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
|
||||
|
||||
// "2026-03-01" → local midnight by shifting from UTC midnight by tz offset
|
||||
const utcMidnight = new Date(`${time}T00:00:00Z`)
|
||||
const offset = tzOffsetMinutes(utcMidnight)
|
||||
const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000)
|
||||
|
||||
return {
|
||||
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,
|
||||
code,
|
||||
time: localMidnight,
|
||||
temperature: temp,
|
||||
temperature_max: temp,
|
||||
temperature_min: data.daily.temperature_2m_min[i],
|
||||
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),
|
||||
precipitation: data.daily.precipitation_sum[i],
|
||||
precipitation_chance: data.daily.precipitation_probability_max[i],
|
||||
wind_speed: data.daily.windspeed_10m_max[i],
|
||||
wind_gusts: data.daily.windgusts_10m_max[i],
|
||||
wind_direction: data.daily.winddirection_10m_dominant[i],
|
||||
uv_index: uv,
|
||||
solar_wm2: data.daily.shortwave_radiation_sum[i],
|
||||
clouds: null,
|
||||
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
||||
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
@@ -146,8 +185,6 @@ export async function hourlyWeather(lat, lon, start, end) {
|
||||
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||
if (!data?.hourly?.time) return []
|
||||
|
||||
// Open-Meteo returns local time strings e.g. "2026-03-01T06:00" (no Z)
|
||||
// sunMap keyed by date portion of those local strings
|
||||
const sunMap = {}
|
||||
if (data.daily?.time) {
|
||||
data.daily.time.forEach((date, i) => {
|
||||
@@ -158,44 +195,80 @@ export async function hourlyWeather(lat, lon, start, end) {
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.all(data.hourly.time.map(async (time, i) => {
|
||||
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 >= new Date(sun.sunrise.toISOString().replace('Z', '') + 'Z').getTime() && t < new Date(sun.sunset.toISOString().replace('Z', '') + 'Z').getTime()
|
||||
? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
|
||||
: false
|
||||
|
||||
const code = data.hourly.weathercode[i]
|
||||
const icon = await ensureIcon(code).catch(() => null)
|
||||
const temp = data.hourly.temperature_2m[i]
|
||||
const humidity = data.hourly.relative_humidity_2m[i]
|
||||
const uv = data.hourly.uv_index[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
|
||||
|
||||
return {
|
||||
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,
|
||||
code,
|
||||
icon: OWM_ICONS[code] || '01d',
|
||||
temperature: temp,
|
||||
temperature_max: temp,
|
||||
temperature_min: temp,
|
||||
humidity,
|
||||
humidity_abs: absoluteHumidity(temp, humidity),
|
||||
dew_point: dewPoint(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],
|
||||
precipitation: data.hourly.precipitation[i],
|
||||
precipitation_chance: data.hourly.precipitation_probability[i],
|
||||
wind_speed: data.hourly.windspeed_10m[i],
|
||||
wind_gusts: data.hourly.windgusts_10m[i],
|
||||
wind_direction: data.hourly.winddirection_10m[i],
|
||||
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,
|
||||
solar_wm2: data.hourly.shortwave_radiation[i],
|
||||
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
|
||||
: null,
|
||||
: 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
|
||||
}
|
||||
|
||||
@@ -18,3 +18,11 @@ export async function fetchIcon(icon) {
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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);
|
||||
@@ -30,9 +31,15 @@ function metricToFields(results) {
|
||||
}
|
||||
|
||||
export async function queryCurrent() {
|
||||
const c = cfg();
|
||||
const results = await queryInstant(c, `{__name__!=""}`);
|
||||
return {time: new Date(), ...metricToFields(results)};
|
||||
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) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||
import {apiReference} from '@scalar/express-api-reference';
|
||||
import {spec} from './spec.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
import {getAIS} from './ais.mjs';
|
||||
import {getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs';
|
||||
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';
|
||||
@@ -78,41 +78,54 @@ app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||
const coords = await getCoords()
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
const forecastLimit = new Date(now.getTime() + 24 * 60 * 60_000)
|
||||
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) : forecastLimit
|
||||
const end = req.query.end ? new Date(req.query.end) : forecast24Limit
|
||||
|
||||
const [history, forecast24, forecast3rdParty] = await Promise.all([
|
||||
start.getTime() < now.getTime() ? queryHourly(start, now) : Promise.resolve([]),
|
||||
end.getTime() > now.getTime() ? getForecast().then(f => f.slots.filter(h => new Date(h.time) <= end)) : Promise.resolve([]),
|
||||
end.getTime() > forecastLimit.getTime() ? hourlyWeather(coords.latitude, coords.longitude, forecastLimit, end) : Promise.resolve([]),
|
||||
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 hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...forecast3rdParty])
|
||||
res.json(filterArr(hourly, fields))
|
||||
}));
|
||||
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 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) : todayEnd
|
||||
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] = Promise.all([
|
||||
start.getTime() < todayStart.getTime() ? queryDaily(start, todayStart) : Promise.resolve([]), // Historic
|
||||
start.getTime() <= todayStart.getTime() ? queryCurrent().then(resp => [resp]) : Promise.resolve([]), // Current
|
||||
start.getTime() >= todayEnd.getTime() ? dailyWeather(coords.latitude, coords.longitude, todayStart, end) : Promise.resolve([]), // Future
|
||||
]);
|
||||
const daily = [...history, ...current, ...getCelestialForecast(coords.latitude, coords.longitude, future)];
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -139,11 +152,23 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
||||
// res.json(filterFields(await getSpaceWeather(), fields));
|
||||
// });
|
||||
|
||||
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
||||
// ── 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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -10,17 +10,16 @@ export const spec = {
|
||||
'/api/position': {
|
||||
get: {
|
||||
summary: 'Station position',
|
||||
description: 'Latest GPS position and info',
|
||||
parameters: [ ],
|
||||
description: 'Latest GPS position and altitude',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Last known position',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Position' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/data': {
|
||||
'/api/current': {
|
||||
get: {
|
||||
summary: 'Current conditions',
|
||||
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
|
||||
@@ -82,153 +81,79 @@ export const spec = {
|
||||
Position: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lat: { type: 'number', description: 'Latitude' },
|
||||
lon: { type: 'number', description: 'Longitude' },
|
||||
alt: { type: 'number', description: 'Altitude' },
|
||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||
}
|
||||
},
|
||||
DataRow: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
time: { type: 'string', description: 'ISO8601 timestamp or date' },
|
||||
time: { type: 'string', format: 'date-time', description: 'ISO8601 timestamp' },
|
||||
// Environment
|
||||
temperature: { 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)' },
|
||||
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' },
|
||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
|
||||
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'] },
|
||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
|
||||
// Light
|
||||
// Light & Solar
|
||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||
uv_index: { type: 'number', description: 'UV index' },
|
||||
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
|
||||
clouds: { 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)' },
|
||||
daylight: { type: 'number', description: 'Hours above daylight threshold today' },
|
||||
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||
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. 01d' },
|
||||
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: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||
lidar_strength: { type: 'number', description: 'LIDAR signal strength' },
|
||||
ground_calibration: { type: 'number', description: 'Calibrated baseline distance (cm)' },
|
||||
accumulation: { type: 'number', description: 'Snow/flood depth (cm)' },
|
||||
ground_accumulation_type: { type: 'string', enum: ['snow','slush','ice','flood'], description: 'Accumulation type' },
|
||||
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)' },
|
||||
lightning_energy: { type: 'number', description: 'Lightning energy' },
|
||||
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||
storm_direction: { 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)' },
|
||||
// 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)' },
|
||||
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³)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user