Compare commits
81
Commits
73f7908aab
...
issue-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf693e6f08 | ||
|
|
4520447156 | ||
|
|
534dc6d2df | ||
|
|
4c7a7fd1f5 | ||
|
|
28478251cd | ||
|
|
1cf7545c93 | ||
|
|
6e21a8626c | ||
|
|
9f65318aee | ||
|
|
5ac3871da0 | ||
|
|
eaef6e8066 | ||
|
|
a0c15f1631 | ||
|
|
d57faa486c | ||
|
|
3c6cc71346 | ||
|
|
a255638f73 | ||
|
|
746ec877da | ||
|
|
8eb3e49308 | ||
|
|
1d1e7351db | ||
|
|
0e152b4dc7 | ||
|
|
fca0e942e7 | ||
|
|
6c4310753f | ||
|
|
0ebf997924 | ||
|
|
2b299753ca | ||
|
|
83c48bf3e7 | ||
|
|
a99cbf3354 | ||
|
|
a60f605e6b | ||
|
|
6bcc5bec19 | ||
|
|
fc306f277a | ||
|
|
396a1f4cab | ||
|
|
b86e4613bf | ||
|
|
a0cfe64188 | ||
|
|
033e34a971 | ||
|
|
688552d0d3 | ||
|
|
7cc60afbae | ||
|
|
1db910cb4a | ||
|
|
2b31bdde8f | ||
|
|
79acad8c07 | ||
|
|
2c25251864 | ||
|
|
9e1ef69b16 | ||
|
|
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 |
@@ -1,3 +1,4 @@
|
|||||||
|
EMAIL=
|
||||||
ALTITUDE=
|
ALTITUDE=
|
||||||
LATITUDE=
|
LATITUDE=
|
||||||
LONGITUDE=
|
LONGITUDE=
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
+20
-2
@@ -2,11 +2,29 @@
|
|||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<title>Weather Station</title>
|
<title>Weather Station</title>
|
||||||
|
<link rel="icon" href="/favicon.png">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+78
-23
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ztimson/css-utils": "^1.3.5",
|
"@ztimson/css-utils": "^1.3.5",
|
||||||
"@ztimson/utils": "^0.29.5",
|
"@ztimson/utils": "^0.29.5",
|
||||||
|
"ol-mapbox-style": "^13.4.1",
|
||||||
"vue": "^3.5.38"
|
"vue": "^3.5.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -589,6 +590,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "24.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
|
||||||
|
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||||
|
"@mapbox/unitbezier": "^1.0.0",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -950,7 +985,6 @@
|
|||||||
"version": "3.9.3",
|
"version": "3.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
@@ -1274,7 +1308,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
@@ -1558,7 +1591,6 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
|
||||||
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"reference-spec-reader": "^0.2.0",
|
"reference-spec-reader": "^0.2.0",
|
||||||
@@ -1868,7 +1900,6 @@
|
|||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
@@ -1940,7 +1971,6 @@
|
|||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -1972,7 +2002,6 @@
|
|||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
|
||||||
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
|
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@petamoriken/float16": "^3.9.3",
|
"@petamoriken/float16": "^3.9.3",
|
||||||
@@ -2141,6 +2170,12 @@
|
|||||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -2165,7 +2200,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
@@ -2461,6 +2495,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mapbox-to-css-font": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/memorystream": {
|
"node_modules/memorystream": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
||||||
@@ -2470,6 +2510,15 @@
|
|||||||
"node": ">= 0.10.0"
|
"node": ">= 0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@@ -2572,7 +2621,6 @@
|
|||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fflate": "^0.8.0"
|
"fflate": "^0.8.0"
|
||||||
@@ -2603,7 +2651,6 @@
|
|||||||
"version": "10.9.0",
|
"version": "10.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
|
||||||
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
|
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/rbush": "4.0.0",
|
"@types/rbush": "4.0.0",
|
||||||
@@ -2618,6 +2665,22 @@
|
|||||||
"url": "https://opencollective.com/openlayers"
|
"url": "https://opencollective.com/openlayers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ol-mapbox-style": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-da4FKZUXoXMzK5ADeRHAXzd3bTeiKg/Y9LBOTB6qNTP62MQm93y6ISrIXzOS9atfGgQMEHJ2pCgP272IMeZCXA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^24.4.1",
|
||||||
|
"mapbox-to-css-font": "^3.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ahocevar"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ol": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/open": {
|
"node_modules/open": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||||
@@ -2643,14 +2706,12 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
"node_modules/parse-headers": {
|
"node_modules/parse-headers": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
@@ -2681,7 +2742,6 @@
|
|||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-protobuf-schema": "^2.1.0"
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
@@ -2776,14 +2836,12 @@
|
|||||||
"version": "3.6.1",
|
"version": "3.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/quick-lru": {
|
"node_modules/quick-lru": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -2796,14 +2854,12 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/rbush": {
|
"node_modules/rbush": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"quickselect": "^3.0.0"
|
"quickselect": "^3.0.0"
|
||||||
@@ -2842,14 +2898,12 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/resolve-protobuf-schema": {
|
"node_modules/resolve-protobuf-schema": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"protocol-buffers-schema": "^3.3.1"
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
@@ -3417,6 +3471,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -3476,7 +3536,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
|
||||||
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -3779,7 +3838,6 @@
|
|||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
@@ -3819,7 +3877,6 @@
|
|||||||
"version": "1.10.2",
|
"version": "1.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||||
"dev": true,
|
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
@@ -3833,7 +3890,6 @@
|
|||||||
"version": "0.7.3",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
|
||||||
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
|
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zarrita/storage": "^0.2.0",
|
"@zarrita/storage": "^0.2.0",
|
||||||
@@ -3844,7 +3900,6 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
||||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT AND BSD-3-Clause"
|
"license": "MIT AND BSD-3-Clause"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ztimson/css-utils": "^1.3.5",
|
"@ztimson/css-utils": "^1.3.5",
|
||||||
"@ztimson/utils": "^0.29.5",
|
"@ztimson/utils": "^0.29.5",
|
||||||
|
"ol-mapbox-style": "^13.4.1",
|
||||||
"vue": "^3.5.38"
|
"vue": "^3.5.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,9 +25,9 @@
|
|||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-vue-devtools": "^8.1.2",
|
"vite-plugin-vue-devtools": "^8.1.2",
|
||||||
|
"vue": "^3.5.38",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-tsc": "^3.3.5",
|
"vue-tsc": "^3.3.5"
|
||||||
"vue": "^3.5.38"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.18.0 || >=24.12.0"
|
"node": "^22.18.0 || >=24.12.0"
|
||||||
|
|||||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Allow: /
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
+1
-1
@@ -17,7 +17,7 @@ function toggleDark() { dark.value = !dark.value }
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
z-index: 500;
|
z-index: 50;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import AircraftAltitude from '@/components/Altitude.vue'
|
||||||
|
|
||||||
const BASE_SPEED_UNITS = {
|
const BASE_SPEED_UNITS = {
|
||||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||||
@@ -15,16 +17,16 @@ const BASE_VERTICAL_UNITS = {
|
|||||||
fps: { label: 'FT/S', convert: (v: number) => v },
|
fps: { label: 'FT/S', convert: (v: number) => v },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const photoError = ref(false);
|
||||||
const UNIT_KEY = 'at_units'
|
const UNIT_KEY = 'at_units'
|
||||||
function loadUnits() {
|
function loadUnits() {
|
||||||
const s = localStorage.getItem(UNIT_KEY)
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
|
const props = defineProps<{ plane: any; position: { x: number; y: number }; history?: any[] }>()
|
||||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
// ── Units ─────────────────────────────────────────────────────────────────────
|
|
||||||
const prefs = ref(loadUnits())
|
const prefs = ref(loadUnits())
|
||||||
function saveAndSet(p: any) {
|
function saveAndSet(p: any) {
|
||||||
prefs.value = { ...p }
|
prefs.value = { ...p }
|
||||||
@@ -38,17 +40,21 @@ const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyo
|
|||||||
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
|
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
|
||||||
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
|
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
|
||||||
|
|
||||||
// ── Plane data ────────────────────────────────────────────────────────────────
|
|
||||||
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
|
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
|
||||||
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
|
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
|
||||||
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
|
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
|
||||||
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
|
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
|
||||||
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
||||||
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
|
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
|
||||||
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || '')
|
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || props.plane.aircraft || '')
|
||||||
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
||||||
|
|
||||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
const altitudeHistory = computed(() => (props.history || [])
|
||||||
|
.filter(p => !p.live && p.ts != null)
|
||||||
|
.map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))})))
|
||||||
|
|
||||||
|
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
|
||||||
|
|
||||||
const pos = ref({ ...props.position })
|
const pos = ref({ ...props.position })
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
@@ -108,7 +114,6 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('touchcancel', onTouchEnd)
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Navball ───────────────────────────────────────────────────────────────────
|
|
||||||
const navball = computed(() => {
|
const navball = computed(() => {
|
||||||
const data = props.plane
|
const data = props.plane
|
||||||
const heading = data.heading ?? data.track ?? 0
|
const heading = data.heading ?? data.track ?? 0
|
||||||
@@ -189,80 +194,26 @@ const navball = computed(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
|
||||||
|
|
||||||
<!-- Header (drag handle) -->
|
|
||||||
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
|
||||||
<h3 class="ap-callsign">
|
|
||||||
✈️
|
|
||||||
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
|
||||||
<span v-else>N/A</span>
|
|
||||||
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
|
||||||
</h3>
|
|
||||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="ap-meta flex-r justify-between">
|
|
||||||
<div class="flex-c">
|
|
||||||
<span>{{ operator }}</span>
|
|
||||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-c align-x-end">
|
|
||||||
<span>{{ description }}</span>
|
|
||||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ap-body">
|
|
||||||
<div class="ap-gauges">
|
|
||||||
<div class="ap-gauge-wrap">
|
|
||||||
<div class="ap-gauge-label">Air Speed</div>
|
|
||||||
<div class="ap-gauge" @click.stop="cycleSpeed">
|
|
||||||
<span class="ap-gauge-val">{{ speedVal }}</span>
|
|
||||||
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ap-gauge-wrap">
|
|
||||||
<div class="ap-gauge-label">Altitude</div>
|
|
||||||
<div class="ap-gauge" @click.stop="cycleAltitude">
|
|
||||||
<span class="ap-gauge-val">{{ altVal }}</span>
|
|
||||||
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ap-gauge-wrap">
|
|
||||||
<div class="ap-gauge-label">Climb</div>
|
|
||||||
<div class="ap-gauge" @click.stop="cycleVertical">
|
|
||||||
<span class="ap-gauge-val">{{ climbVal }}</span>
|
|
||||||
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ap-navball" v-html="navball" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aircraft-popup {
|
.aircraft-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 9999;
|
z-index: 100;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
background: rgba(0,0,0,0.88);
|
background: rgba(0,0,0,0.88);
|
||||||
border: 1px solid rgba(200,200,220,0.3);
|
border: 1px solid rgba(0, 255, 0, 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ap-header {
|
.ap-header {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
|
||||||
cursor: move;
|
cursor: move;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
@@ -285,10 +236,6 @@ const navball = computed(() => {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
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 {
|
.ap-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -309,6 +256,19 @@ const navball = computed(() => {
|
|||||||
}
|
}
|
||||||
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
.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 {
|
.ap-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -340,5 +300,69 @@ const navball = computed(() => {
|
|||||||
.ap-gauge:hover { border-color: #0ff; }
|
.ap-gauge:hover { border-color: #0ff; }
|
||||||
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
||||||
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
||||||
.ap-navball { flex: 1; }
|
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
|
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
|
<h3 class="ap-callsign">
|
||||||
|
✈️
|
||||||
|
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||||
|
<span v-else>N/A</span>
|
||||||
|
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||||
|
</h3>
|
||||||
|
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ap-meta flex-r justify-between">
|
||||||
|
<div class="flex-c flex-fill">
|
||||||
|
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||||
|
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c flex-fill align-x-end">
|
||||||
|
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||||
|
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!photoError" class="ap-photo-wrap">
|
||||||
|
<img class="ap-photo" :src="BASE + '/api/adsb/' + plane.icao + '/image'" :alt="callsign" @error="photoError = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ap-body">
|
||||||
|
<div class="ap-gauges">
|
||||||
|
<div class="ap-gauge-wrap">
|
||||||
|
<div class="ap-gauge-label">Air Speed</div>
|
||||||
|
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||||
|
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||||
|
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ap-gauge-wrap">
|
||||||
|
<div class="ap-gauge-label">Altitude</div>
|
||||||
|
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||||
|
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||||
|
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ap-gauge-wrap">
|
||||||
|
<div class="ap-gauge-label">Climb</div>
|
||||||
|
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||||
|
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||||
|
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ap-navball" v-html="navball" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AircraftAltitude
|
||||||
|
:history="altitudeHistory"
|
||||||
|
:current-altitude="currentAltitude"
|
||||||
|
:unit="altitude.label"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed} from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
history: any[]
|
||||||
|
currentAltitude: number | null
|
||||||
|
color?: string,
|
||||||
|
unit: string,
|
||||||
|
startTime?: string,
|
||||||
|
endTime?: string,
|
||||||
|
}>(), {
|
||||||
|
color: '#0f0'
|
||||||
|
});
|
||||||
|
|
||||||
|
const WIDTH = 320
|
||||||
|
const HEIGHT = 120
|
||||||
|
const PAD = {top: 10, right: 12, bottom: 24, left: 42}
|
||||||
|
|
||||||
|
const points = computed(() => {
|
||||||
|
const history = [...(props.history || [])]
|
||||||
|
.filter(p => p.altitude != null && p.altitude !== 0 && !p.live)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.ts == null || b.ts == null) return 0
|
||||||
|
return Number(a.ts) - Number(b.ts)
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
const points = history.map((p, i) => ({
|
||||||
|
...p,
|
||||||
|
ts: p.ts != null ? Number(p.ts) : now - (history.length - i - 1),
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (props.currentAltitude != null && points.length) {
|
||||||
|
const last = points[points.length - 1]
|
||||||
|
points.push({
|
||||||
|
ts: Math.max(last.ts + 1, now),
|
||||||
|
altitude: props.currentAltitude,
|
||||||
|
live: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
})
|
||||||
|
|
||||||
|
const graph = computed(() => {
|
||||||
|
const pts = points.value
|
||||||
|
if (pts.length < 2) return null
|
||||||
|
|
||||||
|
const minTs = Number(pts[0].ts)
|
||||||
|
const maxTs = Number(pts[pts.length - 1].ts)
|
||||||
|
const rawAltitudes = pts.map(p => Number(p.altitude))
|
||||||
|
const minAlt = Math.min(...rawAltitudes)
|
||||||
|
const maxAlt = Math.max(...rawAltitudes)
|
||||||
|
const altRange = Math.max(maxAlt - minAlt, 1)
|
||||||
|
const timeRange = Math.max(maxTs - minTs, 1)
|
||||||
|
|
||||||
|
const x = (ts: number) => PAD.left + ((ts - minTs) / timeRange) * (WIDTH - PAD.left - PAD.right)
|
||||||
|
const y = (alt: number) => PAD.top + (1 - (alt - minAlt) / altRange) * (HEIGHT - PAD.top - PAD.bottom)
|
||||||
|
|
||||||
|
const line = pts.map(p => `${x(Number(p.ts)).toFixed(1)},${y(Number(p.altitude)).toFixed(1)}`).join(' ')
|
||||||
|
const latest = pts[pts.length - 1]
|
||||||
|
const latestX = x(Number(latest.ts))
|
||||||
|
const latestY = y(Number(latest.altitude))
|
||||||
|
|
||||||
|
const formatTime = (ts: number) => {
|
||||||
|
const d = new Date(ts * 1000)
|
||||||
|
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
line,
|
||||||
|
latestX,
|
||||||
|
latestY,
|
||||||
|
minAlt,
|
||||||
|
maxAlt,
|
||||||
|
startTime: formatTime(minTs),
|
||||||
|
endTime: formatTime(maxTs),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.altitude-graph {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.altitude-graph-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.altitude-graph svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.altitude-graph text {
|
||||||
|
fill: #777;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="graph" class="altitude-graph">
|
||||||
|
<div class="altitude-graph-title">
|
||||||
|
<span>ALTITUDE</span>
|
||||||
|
<span>{{ unit }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg :viewBox="`0 0 ${WIDTH} ${HEIGHT}`" preserveAspectRatio="none">
|
||||||
|
<line
|
||||||
|
:x1="PAD.left"
|
||||||
|
:y1="PAD.top"
|
||||||
|
:x2="PAD.left"
|
||||||
|
:y2="HEIGHT - PAD.bottom"
|
||||||
|
stroke="rgba(255,255,255,0.2)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<line
|
||||||
|
:x1="PAD.left"
|
||||||
|
:y1="HEIGHT - PAD.bottom"
|
||||||
|
:x2="WIDTH - PAD.right"
|
||||||
|
:y2="HEIGHT - PAD.bottom"
|
||||||
|
stroke="rgba(255,255,255,0.2)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text :x="PAD.left - 5" :y="PAD.top + 4" text-anchor="end">
|
||||||
|
{{ graph.maxAlt }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text :x="PAD.left - 5" :y="HEIGHT - PAD.bottom + 4" text-anchor="end">
|
||||||
|
{{ graph.minAlt }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<polyline
|
||||||
|
:points="graph.line"
|
||||||
|
fill="none"
|
||||||
|
:stroke="color"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<circle
|
||||||
|
:cx="graph.latestX"
|
||||||
|
:cy="graph.latestY"
|
||||||
|
r="3"
|
||||||
|
:fill="color"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<text :x="PAD.left" :y="HEIGHT - 6">
|
||||||
|
{{ startTime || graph.startTime }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<text :x="WIDTH - PAD.right" :y="HEIGHT - 6" text-anchor="end">
|
||||||
|
{{ endTime || graph.endTime }}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
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 {inject, onMounted, ref} from 'vue';
|
||||||
import {api, BASE} from '../services/api';
|
import {api, BASE} from '../services/api';
|
||||||
|
|
||||||
@@ -108,47 +108,75 @@ onMounted(async () => {
|
|||||||
<div class="stat" @click="openGraph?.('heat_index')">
|
<div class="stat" @click="openGraph?.('heat_index')">
|
||||||
<span class="stat-label">Feels Like</span>
|
<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>
|
<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>
|
||||||
<div class="stat" @click="openGraph?.('humidity')">
|
<div class="stat" @click="openGraph?.('humidity')">
|
||||||
<span class="stat-label">Humidity</span>
|
<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>
|
<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>
|
||||||
<div class="stat" @click="openGraph?.('clouds')">
|
<div class="stat" @click="openGraph?.('clouds')">
|
||||||
<span class="stat-label">Cloud Cover</span>
|
<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>
|
<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>
|
<span v-else class="stat-value">
|
||||||
</div>
|
<template v-if="data.clouds == null">—</template>
|
||||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
<template v-else>{{ Math.round(data.clouds) }}<span class="fg-muted">%</span></template>
|
||||||
<span class="stat-label">Pressure</span>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('pressure_slp')">
|
<div class="stat" @click="openGraph?.('pressure_slp')">
|
||||||
<span class="stat-label">Sea Level</span>
|
<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>
|
<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>
|
||||||
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
|
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
|
||||||
<span class="stat-label">VPD KPA</span>
|
<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>
|
<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>
|
||||||
<div class="stat" @click="openGraph?.('air_quality')">
|
<div class="stat" @click="openGraph?.('air_quality')">
|
||||||
<span class="stat-label">Air Quality</span>
|
<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>
|
<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>
|
||||||
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
||||||
<span class="stat-label">Gas Resistance</span>
|
<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>
|
<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>
|
||||||
<div class="stat" @click="openGraph?.('visibility')">
|
<div class="stat" @click="openGraph?.('visibility')">
|
||||||
<span class="stat-label">Visibility</span>
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ onMounted(async () => {
|
|||||||
<span>↑{{ ~~(d.temperature_max) + '°' || '—' }}</span>
|
<span>↑{{ ~~(d.temperature_max) + '°' || '—' }}</span>
|
||||||
<span class="day-low">↓{{ ~~(d.temperature_min) + '°' || '—' }}</span>
|
<span class="day-low">↓{{ ~~(d.temperature_min) + '°' || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="day-humid">🌡️ {{ ~~(d.humidity) || 0 }}%</div>
|
<div class="day-humid align-x">🌡️ {{ ~~(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-wind align-x">🍃 {{ ~~(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-rain align-x">🌧️ {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="strip">
|
<div v-else class="strip">
|
||||||
|
|||||||
+589
-251
@@ -1,234 +1,517 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AirTrafficLayer } from '@/services/adsb.ts'
|
import {AirTrafficLayer} from '@/services/adsb.ts';
|
||||||
import { AISLayer } from '@/services/ais.ts'
|
import {AISLayer} from '@/services/ais.ts';
|
||||||
import {api} from '@/services/api.ts';
|
import {api} from '@/services/api.ts';
|
||||||
import {RangeLayer} from '@/services/range.ts';
|
import {RangeLayer} from '@/services/range.ts';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import {SatsLayer} from '@/services/sats.ts';
|
||||||
import Map from 'ol/Map'
|
import VectorTileLayer from 'ol/layer/VectorTile';
|
||||||
import View from 'ol/View'
|
import {onMounted, onUnmounted, ref, watch} from 'vue';
|
||||||
import TileLayer from 'ol/layer/Tile'
|
import Map from 'ol/Map';
|
||||||
import VectorLayer from 'ol/layer/Vector'
|
import View from 'ol/View';
|
||||||
import VectorSource from 'ol/source/Vector'
|
import TileLayer from 'ol/layer/Tile';
|
||||||
import XYZ from 'ol/source/XYZ'
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
import Feature from 'ol/Feature'
|
import VectorSource from 'ol/source/Vector';
|
||||||
import Point from 'ol/geom/Point'
|
import XYZ from 'ol/source/XYZ';
|
||||||
import CircleGeom from 'ol/geom/Circle'
|
import Feature from 'ol/Feature';
|
||||||
import { fromLonLat, toLonLat } from 'ol/proj'
|
import Point from 'ol/geom/Point';
|
||||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
import CircleGeom from 'ol/geom/Circle';
|
||||||
import 'ol/ol.css'
|
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 {Aurora} from '@/services/aurora.ts';
|
||||||
|
import {SondesLayer} from '@/services/sondes.ts';
|
||||||
|
import 'ol/ol.css';
|
||||||
|
|
||||||
const current = ref({latitude: 0, longitude: 0});
|
const current = ref({latitude: 0, longitude: 0});
|
||||||
const props = defineProps<{ dark: boolean }>()
|
const props = defineProps<{dark: boolean}>();
|
||||||
const mapEl = ref<HTMLDivElement>()
|
const mapEl = ref<HTMLDivElement>();
|
||||||
const showOverlays = ref(false)
|
const showOverlays = ref(false);
|
||||||
const atActive = ref(true)
|
const openMenu = ref<string | null>(null);
|
||||||
|
const NM = 1852;
|
||||||
|
const showWind = ref(false);
|
||||||
|
const windSrc = ref('');
|
||||||
|
|
||||||
const NM = 1852
|
const OVERLAY_GROUPS = [
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
label: 'Traffic',
|
||||||
|
icon: '✈️',
|
||||||
|
items: [
|
||||||
|
{id: 'aircraft', label: 'Aircraft', icon: '✈️'},
|
||||||
|
{id: 'marine', label: 'Marine', icon: '🚢'},
|
||||||
|
{id: 'satellites', label: 'Satellites', icon: '🛰️'},
|
||||||
|
{id: 'sondes', label: 'Weather Balloons', icon: '️🎈'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weather',
|
||||||
|
label: 'Weather',
|
||||||
|
icon: '🌦️',
|
||||||
|
items: [
|
||||||
|
{id: 'aurora', label: 'Aurora', icon: '🌌'},
|
||||||
|
{id: 'rain', label: 'Rain', icon: '🌧️'},
|
||||||
|
{id: 'wind', label: 'Wind', icon: '💨'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const radarFrames = ref<{ path: string; time: number }[]>([])
|
const DEFAULT_OVERLAYS = ['aircraft', 'marine', 'satellites', 'rain'];
|
||||||
const radarFrameIndex = ref(0)
|
const activeOverlays = ref<Set<string>>(new Set(DEFAULT_OVERLAYS));
|
||||||
const radarPath = computed(() => radarFrames.value[radarFrameIndex.value]?.path ?? '')
|
const overlayLayers: {[key: string]: any} = {};
|
||||||
const nowcastStartIndex = ref(0)
|
|
||||||
|
|
||||||
const showWind = ref(false)
|
const MAP_STATE_KEY = 'situation-center-map-state';
|
||||||
const windSrc = ref('')
|
const MAP_LAYERS_KEY = 'situation-center-map-layers';
|
||||||
|
const AUTO_MODE_KEY = 'situation-center-auto-mode';
|
||||||
|
const AUTO_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
const OVERLAYS = [
|
const AUTO_CATEGORIES = ['sondes', 'aircraft', 'marine', 'satellites'] as const;
|
||||||
{ id: 'rain', label: 'Rain', icon: '🌧️', url: () => `https://tilecache.rainviewer.com${radarPath.value}/256/{z}/{x}/{y}/2/1_1.png` },
|
type AutoCategory = typeof AUTO_CATEGORIES[number];
|
||||||
{ id: 'wind', label: 'Wind', icon: '💨', url: () => '' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
interface AutoObject {
|
||||||
const overlayLayers: { [key: string]: any } = {}
|
category: AutoCategory;
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
let map: Map
|
let map: Map;
|
||||||
let stationLayer: VectorLayer<VectorSource>
|
let stationLayer: VectorLayer<VectorSource>;
|
||||||
let radarInterval: ReturnType<typeof setInterval>
|
let radarInterval: ReturnType<typeof setInterval>;
|
||||||
let airTraffic: AirTrafficLayer
|
let autoInterval: ReturnType<typeof setInterval>;
|
||||||
let aisLayer: AISLayer
|
let airTraffic: AirTrafficLayer;
|
||||||
let range: RangeLayer
|
let aisLayer: AISLayer;
|
||||||
|
let range: RangeLayer;
|
||||||
|
let sats: SatsLayer;
|
||||||
|
let sondes: SondesLayer;
|
||||||
|
let aurora: Aurora;
|
||||||
|
|
||||||
|
const autoMode = ref(false);
|
||||||
|
const autoCategory = ref<AutoCategory | null>(null);
|
||||||
|
const autoTarget = ref<any>(null);
|
||||||
|
let autoTargetKey: string | null = null;
|
||||||
|
|
||||||
|
// Per-category lookup so auto-mode logic never branches on category directly.
|
||||||
|
const AUTO_CONFIG: Record<AutoCategory, {
|
||||||
|
layer: () => any;
|
||||||
|
id: (target: any) => string;
|
||||||
|
position: (target: any) => [number, number] | null;
|
||||||
|
zoom: number;
|
||||||
|
}> = {
|
||||||
|
sondes: {
|
||||||
|
layer: () => sondes,
|
||||||
|
id: t => String(t.id),
|
||||||
|
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
|
||||||
|
zoom: 8,
|
||||||
|
},
|
||||||
|
aircraft: {
|
||||||
|
layer: () => airTraffic,
|
||||||
|
id: t => String(t.icao),
|
||||||
|
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
|
||||||
|
zoom: 10,
|
||||||
|
},
|
||||||
|
marine: {
|
||||||
|
layer: () => aisLayer,
|
||||||
|
id: t => String(t.mmsi),
|
||||||
|
position: t => t.lat != null && t.lon != null ? [t.lon, t.lat] : null,
|
||||||
|
zoom: 12,
|
||||||
|
},
|
||||||
|
satellites: {
|
||||||
|
layer: () => sats,
|
||||||
|
id: t => String(t.id),
|
||||||
|
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
|
||||||
|
zoom: 6,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadMapState() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(MAP_STATE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof state.latitude !== 'number' ||
|
||||||
|
typeof state.longitude !== 'number' ||
|
||||||
|
typeof state.zoom !== 'number'
|
||||||
|
) return null;
|
||||||
|
|
||||||
|
return state;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMapState() {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const view = map.getView();
|
||||||
|
const center = view.getCenter();
|
||||||
|
if (!center) return;
|
||||||
|
|
||||||
|
const [longitude, latitude] = toLonLat(center);
|
||||||
|
const zoom = view.getZoom();
|
||||||
|
if (typeof zoom !== 'number') return;
|
||||||
|
|
||||||
|
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({latitude, longitude, zoom}));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLayers() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(MAP_LAYERS_KEY);
|
||||||
|
if (!raw) return new Set(DEFAULT_OVERLAYS);
|
||||||
|
|
||||||
|
const layers = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(layers)) return new Set(DEFAULT_OVERLAYS);
|
||||||
|
|
||||||
|
const validLayers = OVERLAY_GROUPS.flatMap(group => group.items.map(item => item.id));
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
layers.filter((id: unknown): id is string =>
|
||||||
|
typeof id === 'string' && validLayers.includes(id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return new Set(DEFAULT_OVERLAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLayers() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MAP_LAYERS_KEY, JSON.stringify([...activeOverlays.value]));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAutoMode() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(AUTO_MODE_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAutoMode() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AUTO_MODE_KEY, String(autoMode.value));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
||||||
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
|
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncWindSrc() {
|
function syncWindSrc() {
|
||||||
const view = map.getView()
|
const view = map.getView();
|
||||||
const center: any = toLonLat(view.getCenter()!)
|
const center: any = toLonLat(view.getCenter()!);
|
||||||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
|
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRadarFrames() {
|
function showWindOverlay() {
|
||||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
syncWindSrc();
|
||||||
const data = await res.json()
|
showWind.value = true;
|
||||||
const past = data.radar.past ?? []
|
map.getInteractions().forEach(i => i.setActive(false));
|
||||||
const nowcast = data.radar.nowcast ?? []
|
|
||||||
radarFrames.value = [...past, ...nowcast]
|
|
||||||
nowcastStartIndex.value = past.length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRainSource(url: string): XYZ {
|
function hideWindOverlay() {
|
||||||
return new XYZ({
|
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,
|
url,
|
||||||
attributions: '',
|
|
||||||
maxZoom: 7,
|
maxZoom: 7,
|
||||||
tileLoadFunction: (tile: any, src: string) => {
|
tileLoadFunction: (tile: any, src: string) => {
|
||||||
const img = tile.getImage()
|
const img = tile.getImage();
|
||||||
img.onerror = () => tile.setState(3)
|
img.onerror = () => tile.setState(3);
|
||||||
img.src = src
|
img.src = src;
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
opacity: 0.2,
|
||||||
|
zIndex: 5,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekRadar(index: number) {
|
function removeRainLayer() {
|
||||||
radarFrameIndex.value = index
|
if (!overlayLayers['rain']) return;
|
||||||
const layer = overlayLayers['rain']
|
map.removeLayer(overlayLayers['rain']);
|
||||||
if (layer) {
|
delete overlayLayers['rain'];
|
||||||
layer.setSource(buildRainSource(OVERLAYS.find(o => o.id === 'rain')!.url()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAT() {
|
async function refreshRain() {
|
||||||
if(atActive.value) {
|
const url = await fetchRadarUrl();
|
||||||
aisLayer.hide()
|
removeRainLayer();
|
||||||
airTraffic.hide()
|
if (!url || !activeOverlays.value.has('rain')) return;
|
||||||
range.hide()
|
|
||||||
} else {
|
const layer = buildRainLayer(url);
|
||||||
aisLayer.show()
|
overlayLayers['rain'] = layer;
|
||||||
airTraffic.show()
|
map.addLayer(layer);
|
||||||
range.show()
|
|
||||||
}
|
|
||||||
atActive.value = !atActive.value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One handler per overlay id — toggleOverlay/restoreOverlayState just drive these.
|
||||||
|
const OVERLAY_HANDLERS: Record<string, {show: () => void; hide: () => void}> = {
|
||||||
|
aircraft: {show: () => { airTraffic.show(); range.show(); }, hide: () => { airTraffic.hide(); range.hide(); }},
|
||||||
|
marine: {show: () => aisLayer.show(), hide: () => aisLayer.hide()},
|
||||||
|
satellites: {show: () => sats.show(), hide: () => sats.hide()},
|
||||||
|
sondes: {show: () => sondes.show(), hide: () => sondes.hide()},
|
||||||
|
aurora: {show: () => aurora.show(), hide: () => aurora.hide()},
|
||||||
|
wind: {show: showWindOverlay, hide: hideWindOverlay},
|
||||||
|
rain: {show: refreshRain, hide: removeRainLayer},
|
||||||
|
};
|
||||||
|
|
||||||
function toggleOverlay(id: string) {
|
function toggleOverlay(id: string) {
|
||||||
if (id === 'wind') {
|
const handler = OVERLAY_HANDLERS[id];
|
||||||
if (activeOverlays.value.has('wind')) {
|
if (!handler) return;
|
||||||
activeOverlays.value.delete('wind')
|
|
||||||
showWind.value = false
|
|
||||||
} else {
|
|
||||||
activeOverlays.value.add('wind')
|
|
||||||
syncWindSrc()
|
|
||||||
showWind.value = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeOverlays.value.has(id)) {
|
if (activeOverlays.value.has(id)) {
|
||||||
activeOverlays.value.delete(id)
|
activeOverlays.value.delete(id);
|
||||||
const layer = overlayLayers[id]
|
handler.hide();
|
||||||
if (layer) { map.removeLayer(layer); delete overlayLayers[id] }
|
|
||||||
} else {
|
} else {
|
||||||
activeOverlays.value.add(id)
|
activeOverlays.value.add(id);
|
||||||
const layer = buildOverlayLayer(id)
|
handler.show();
|
||||||
overlayLayers[id] = layer
|
|
||||||
map.addLayer(layer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOverlayState() {
|
||||||
|
for (const id of Object.keys(OVERLAY_HANDLERS)) {
|
||||||
|
activeOverlays.value.has(id) ? (<any>OVERLAY_HANDLERS)[id].show() : (<any>OVERLAY_HANDLERS)[id].hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu(id: string) {
|
||||||
|
openMenu.value = openMenu.value === id ? null : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBaseLayer(dark: boolean) {
|
function buildBaseLayer(dark: boolean) {
|
||||||
return new TileLayer({
|
const layer = new VectorTileLayer({declutter: true});
|
||||||
source: new XYZ({
|
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json');
|
||||||
url: dark
|
return layer;
|
||||||
? '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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||||||
const center = fromLonLat([lon, lat])
|
const center = fromLonLat([lon, lat]);
|
||||||
const source = new VectorSource()
|
const source = new VectorSource();
|
||||||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
|
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)';
|
||||||
|
|
||||||
for (const nm of [50, 100, 150, 200]) {
|
for (const nm of [50, 100, 150, 200]) {
|
||||||
const ring = new Feature(new CircleGeom(center, nm * NM))
|
const ring = new Feature(new CircleGeom(center, nm * NM));
|
||||||
|
|
||||||
ring.setStyle(new Style({
|
ring.setStyle(new Style({
|
||||||
stroke: new Stroke({color: ringStroke, width: 1, lineDash: [6, 4]}),
|
stroke: new Stroke({color: ringStroke, width: 1, lineDash: [6, 4]}),
|
||||||
fill: new Fill({color: 'transparent'}),
|
fill: new Fill({color: 'transparent'}),
|
||||||
}))
|
}));
|
||||||
source.addFeature(ring)
|
|
||||||
|
source.addFeature(ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dot = new Feature(new Point(center))
|
const dot = new Feature(new Point(center));
|
||||||
|
|
||||||
dot.setStyle(new Style({
|
dot.setStyle(new Style({
|
||||||
image: new CircleStyle({
|
image: new CircleStyle({
|
||||||
radius: 7,
|
radius: 7,
|
||||||
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
|
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
|
||||||
stroke: new Stroke({color: dark ? '#000000' : '#ffffff', width: 2}),
|
stroke: new Stroke({color: dark ? '#000000' : '#ffffff', width: 2}),
|
||||||
}),
|
}),
|
||||||
}))
|
}));
|
||||||
source.addFeature(dot)
|
|
||||||
|
|
||||||
return new VectorLayer({ source, zIndex: 20 })
|
source.addFeature(dot);
|
||||||
|
|
||||||
|
return new VectorLayer({source, zIndex: 20});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoObjects(): AutoObject[] {
|
||||||
|
const objects: AutoObject[] = [];
|
||||||
|
|
||||||
|
for (const category of AUTO_CATEGORIES) {
|
||||||
|
if (!activeOverlays.value.has(category)) continue;
|
||||||
|
|
||||||
|
const config = AUTO_CONFIG[category];
|
||||||
|
const layer = config.layer();
|
||||||
|
if (!layer.visible) continue;
|
||||||
|
|
||||||
|
for (const target of layer.getCandidates(current.value.latitude, current.value.longitude)) {
|
||||||
|
objects.push({category, id: config.id(target), score: target.score});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAutoTarget(category: AutoCategory, target: any) {
|
||||||
|
const config = AUTO_CONFIG[category];
|
||||||
|
const position = config.position(target);
|
||||||
|
if (!position) return;
|
||||||
|
|
||||||
|
map.getView().animate({center: fromLonLat(position), zoom: config.zoom, duration: 900});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutoTarget() {
|
||||||
|
if (autoCategory.value && autoTarget.value) {
|
||||||
|
const config = AUTO_CONFIG[autoCategory.value];
|
||||||
|
config.layer().closePopup(config.id(autoTarget.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
autoCategory.value = null;
|
||||||
|
autoTarget.value = null;
|
||||||
|
autoTargetKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoTarget(object: AutoObject) {
|
||||||
|
const config = AUTO_CONFIG[object.category];
|
||||||
|
const target = config.layer().getById(object.id);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
clearAutoTarget();
|
||||||
|
|
||||||
|
autoCategory.value = object.category;
|
||||||
|
autoTarget.value = target;
|
||||||
|
autoTargetKey = `${object.category}:${object.id}`;
|
||||||
|
|
||||||
|
config.layer().openPopup(target);
|
||||||
|
focusAutoTarget(object.category, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestAndFocus() {
|
||||||
|
if (!autoMode.value) return;
|
||||||
|
|
||||||
|
const objects = getAutoObjects();
|
||||||
|
if (!objects.length) return clearAutoTarget();
|
||||||
|
|
||||||
|
const best: any = objects.sort((a, b) => b.score - a.score)[0];
|
||||||
|
const bestKey = `${best.category}:${best.id}`;
|
||||||
|
|
||||||
|
if (bestKey === autoTargetKey) {
|
||||||
|
const fresh = AUTO_CONFIG[best.category].layer().getById(best.id);
|
||||||
|
if (!fresh) return clearAutoTarget();
|
||||||
|
|
||||||
|
autoTarget.value = fresh;
|
||||||
|
focusAutoTarget(best.category, fresh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoTarget(best);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoMode() {
|
||||||
|
if (autoMode.value) return;
|
||||||
|
|
||||||
|
autoMode.value = true;
|
||||||
|
saveAutoMode();
|
||||||
|
|
||||||
|
clearAutoTarget();
|
||||||
|
pickBestAndFocus();
|
||||||
|
|
||||||
|
autoInterval = setInterval(pickBestAndFocus, AUTO_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAutoMode() {
|
||||||
|
if (autoInterval) {
|
||||||
|
clearInterval(autoInterval);
|
||||||
|
autoInterval = undefined as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAutoTarget();
|
||||||
|
autoMode.value = false;
|
||||||
|
saveAutoMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoMode() {
|
||||||
|
autoMode.value ? stopAutoMode() : startAutoMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchRadarFrames()
|
activeOverlays.value = loadLayers();
|
||||||
radarFrameIndex.value = nowcastStartIndex.value - 1
|
|
||||||
|
|
||||||
|
const savedMapState = loadMapState();
|
||||||
const position = await api.position();
|
const position = await api.position();
|
||||||
|
|
||||||
const lat = position?.latitude || 0
|
current.value = {
|
||||||
const lon = position?.longitude || 0
|
latitude: position?.latitude || 0,
|
||||||
|
longitude: position?.longitude || 0,
|
||||||
|
};
|
||||||
|
|
||||||
stationLayer = buildStationLayer(lat, lon, props.dark)
|
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark);
|
||||||
|
|
||||||
|
const initialLatitude = savedMapState?.latitude ?? current.value.latitude;
|
||||||
|
const initialLongitude = savedMapState?.longitude ?? current.value.longitude;
|
||||||
|
const initialZoom = savedMapState?.zoom ?? 8;
|
||||||
|
|
||||||
map = new Map({
|
map = new Map({
|
||||||
target: mapEl.value!,
|
target: mapEl.value!,
|
||||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||||
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
|
interactions: defaultInteractions({altShiftDragRotate: false, pinchRotate: false}),
|
||||||
|
view: new View({
|
||||||
|
center: fromLonLat([initialLongitude, initialLatitude]),
|
||||||
|
zoom: initialZoom,
|
||||||
|
maxZoom: 13,
|
||||||
|
}),
|
||||||
controls: [],
|
controls: [],
|
||||||
})
|
});
|
||||||
|
|
||||||
|
map.on('moveend', saveMapState);
|
||||||
|
|
||||||
aisLayer = new AISLayer(map);
|
aisLayer = new AISLayer(map);
|
||||||
aisLayer.show();
|
airTraffic = new AirTrafficLayer(map);
|
||||||
airTraffic = new AirTrafficLayer(map)
|
sats = new SatsLayer(map);
|
||||||
airTraffic.show()
|
sondes = new SondesLayer(map);
|
||||||
|
aurora = new Aurora(map);
|
||||||
range = new RangeLayer(map);
|
range = new RangeLayer(map);
|
||||||
range.show();
|
|
||||||
|
|
||||||
for (const id of activeOverlays.value) {
|
restoreOverlayState();
|
||||||
if (id === 'wind') continue
|
|
||||||
const layer = buildOverlayLayer(id)
|
|
||||||
overlayLayers[id] = layer
|
|
||||||
map.addLayer(layer)
|
|
||||||
}
|
|
||||||
|
|
||||||
map.on('moveend', () => {
|
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
|
||||||
if (showWind.value) syncWindSrc()
|
|
||||||
})
|
|
||||||
|
|
||||||
radarInterval = setInterval(async () => {
|
if (loadAutoMode()) setTimeout(() => { if (!autoMode.value) startAutoMode(); }, 1_000);
|
||||||
await fetchRadarFrames()
|
});
|
||||||
radarFrameIndex.value = nowcastStartIndex.value - 1
|
|
||||||
seekRadar(radarFrameIndex.value)
|
|
||||||
}, 5 * 60 * 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearInterval(radarInterval)
|
stopAutoMode();
|
||||||
airTraffic.hide()
|
clearInterval(radarInterval);
|
||||||
})
|
|
||||||
|
if (map) saveMapState();
|
||||||
|
|
||||||
|
airTraffic?.hide();
|
||||||
|
aisLayer?.hide();
|
||||||
|
sats?.hide();
|
||||||
|
sondes?.hide();
|
||||||
|
aurora?.hide();
|
||||||
|
range?.hide();
|
||||||
|
});
|
||||||
|
|
||||||
watch(() => props.dark, dark => {
|
watch(() => props.dark, dark => {
|
||||||
map.getLayers().setAt(0, buildBaseLayer(dark))
|
if (!map) return;
|
||||||
map.removeLayer(stationLayer)
|
|
||||||
const lat = (current.value.latitude as number) || 0
|
map.getLayers().setAt(0, buildBaseLayer(dark));
|
||||||
const lon = (current.value.longitude as number) || 0
|
map.removeLayer(stationLayer);
|
||||||
stationLayer = buildStationLayer(lat, lon, dark)
|
|
||||||
map.addLayer(stationLayer)
|
stationLayer = buildStationLayer(current.value.latitude || 0, current.value.longitude || 0, dark);
|
||||||
})
|
map.addLayer(stationLayer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -254,9 +537,29 @@ watch(() => props.dark, dark => {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-toggles {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.overlay-btn {
|
.overlay-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
@@ -274,24 +577,91 @@ watch(() => props.dark, dark => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(.active) { background: var(--hover); }
|
&:hover:not(.active) {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-toggles {
|
.group-btn {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.has-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-indicator {
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-submenu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 16px;
|
bottom: calc(100% + 8px);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
flex-direction: column;
|
||||||
z-index: 100;
|
gap: 4px;
|
||||||
|
min-width: 130px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: 99px;
|
border: 1px solid var(--border);
|
||||||
padding: 6px 10px;
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.submenu-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-btn {
|
||||||
|
&.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
animation: auto-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auto-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.layers-menu {
|
.layers-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
@@ -319,6 +689,7 @@ watch(() => props.dark, dark => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
align-items: stretch;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -326,82 +697,43 @@ watch(() => props.dark, dark => {
|
|||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-timeline {
|
.mobile-group {
|
||||||
position: absolute;
|
position: relative;
|
||||||
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 {
|
.mobile-submenu {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-ticks {
|
.mobile-submenu .overlay-btn {
|
||||||
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%;
|
width: 100%;
|
||||||
accent-color: var(--accent);
|
justify-content: flex-start;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-label {
|
.desktop {
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
color: var(--text);
|
|
||||||
min-width: 42px;
|
|
||||||
text-align: right;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop { display: flex; }
|
.mobile {
|
||||||
.mobile { display: none; }
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.desktop { display: none; }
|
.desktop {
|
||||||
.mobile { display: flex; }
|
display: none;
|
||||||
.radar-timeline { bottom: 10px; }
|
}
|
||||||
.overlay-toggles { bottom: 10px; }
|
|
||||||
.layers-menu { bottom: 10px; }
|
.mobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-menu {
|
||||||
|
bottom: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -409,46 +741,52 @@ input[type='range'] {
|
|||||||
<div class="map-wrap">
|
<div class="map-wrap">
|
||||||
<div ref="mapEl" class="map-el" />
|
<div ref="mapEl" class="map-el" />
|
||||||
|
|
||||||
<!-- Windy animated wind iframe -->
|
<iframe v-if="showWind" class="wind-iframe" :src="windSrc" frameborder="0" allowfullscreen/>
|
||||||
<iframe
|
|
||||||
v-if="showWind"
|
|
||||||
class="wind-iframe"
|
|
||||||
:src="windSrc"
|
|
||||||
frameborder="0"
|
|
||||||
allowfullscreen
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Desktop toggles -->
|
|
||||||
<div class="overlay-toggles desktop">
|
<div class="overlay-toggles desktop">
|
||||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="overlay-group">
|
||||||
✈️ Traffic
|
<button class="overlay-btn group-btn" :class="{'has-active': group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
|
||||||
|
{{ group.icon }} {{ group.label }}
|
||||||
|
<span class="group-indicator">
|
||||||
|
{{ openMenu === group.id ? '▲' : '▼' }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-for="o in OVERLAYS" :key="o.id"
|
<div v-if="openMenu === group.id" class="overlay-submenu">
|
||||||
class="overlay-btn"
|
<button v-for="item in group.items" :key="item.id" class="overlay-btn submenu-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
|
||||||
:class="{ active: activeOverlays.has(o.id) }"
|
{{ item.icon }} {{ item.label }}
|
||||||
@click="toggleOverlay(o.id)"
|
</button>
|
||||||
>
|
</div>
|
||||||
{{ o.icon }} {{ o.label }}
|
</div>
|
||||||
|
|
||||||
|
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
|
||||||
|
{{ autoMode ? '⏹' : '▶' }} Auto
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile layers button + dropdown -->
|
|
||||||
<div class="layers-menu mobile">
|
<div class="layers-menu mobile">
|
||||||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||||||
⚙️ Layers
|
⚙️ Layers
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="showOverlays" class="layers-dropdown">
|
<div v-if="showOverlays" class="layers-dropdown">
|
||||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="mobile-group">
|
||||||
✈️ Traffic
|
<button class="overlay-btn" :class="{active: group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
|
||||||
|
{{ group.icon }} {{ group.label }}
|
||||||
|
<span class="group-indicator">
|
||||||
|
{{ openMenu === group.id ? '▲' : '▼' }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-for="o in OVERLAYS" :key="o.id"
|
<div v-if="openMenu === group.id" class="mobile-submenu">
|
||||||
class="overlay-btn"
|
<button v-for="item in group.items" :key="item.id" class="overlay-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
|
||||||
:class="{ active: activeOverlays.has(o.id) }"
|
{{ item.icon }} {{ item.label }}
|
||||||
@click="toggleOverlay(o.id)"
|
</button>
|
||||||
>
|
</div>
|
||||||
{{ o.icon }} {{ o.label }}
|
</div>
|
||||||
|
|
||||||
|
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
|
||||||
|
{{ autoMode ? '⏹ Stop Auto' : '▶ Auto Mode' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ const {current: d} = useWeather();
|
|||||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||||
<span class="stat-label">Rain</span>
|
<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>
|
<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>
|
||||||
<div class="stat" @click="openGraph?.('lightning_rate')">
|
<div class="stat" @click="openGraph?.('lightning_rate')">
|
||||||
<span class="stat-label">Lightning</span>
|
<span class="stat-label">Lightning</span>
|
||||||
@@ -82,6 +86,11 @@ const {current: d} = useWeather();
|
|||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
<div class="stat-grid">
|
<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')">
|
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||||
<span class="stat-label">Pressure (SLP)</span>
|
<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>
|
<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>
|
<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>
|
<span v-else class="stat-value">{{ d.precipitation?.toFixed(1) ?? '—' }} <span v-if="d.precipitation != null" class="fg-muted">mm</span></span>
|
||||||
</div>
|
</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">
|
<div class="stat">
|
||||||
<span class="stat-label">Dew Point</span>
|
<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>
|
<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>
|
<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>
|
<span v-else class="stat-value">{{ d.frost_risk ?? '—' }}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
history: any[]
|
||||||
|
range: { altitude: number; slantRange: number; horizonRadius: number; groundDist: number } | null
|
||||||
|
eta: number | null
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
const UNIT_KEY = 'at_units'
|
||||||
|
|
||||||
|
function loadUnits() {
|
||||||
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
|
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = ref(loadUnits())
|
||||||
|
|
||||||
|
function saveAndSet(p: any) {
|
||||||
|
prefs.value = { ...p }
|
||||||
|
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleAltitude() {
|
||||||
|
const p = loadUnits()
|
||||||
|
p.altitude = p.altitude === 'meters' ? 'feet' : 'meters'
|
||||||
|
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
|
||||||
|
saveAndSet(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUnits(e: StorageEvent) {
|
||||||
|
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = computed(() => prefs.value.altitude === 'feet'
|
||||||
|
? { label: 'mi', convert: (v: number) => v * 0.621371 }
|
||||||
|
: { label: 'km', convert: (v: number) => v })
|
||||||
|
|
||||||
|
const latest = computed(() => props.history[props.history.length - 1])
|
||||||
|
const rangeVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.slantRange)) : null)
|
||||||
|
const altVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.altitude)) : null)
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pos = ref({ ...props.position })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
function onMouseUp() { isDragging.value = false }
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX, sy = touch.clientY
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||||
|
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.addEventListener('storage', syncUnits)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.removeEventListener('storage', syncUnits)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Radar HUD ─────────────────────────────────────────────────────────────────
|
||||||
|
/** Center = zenith (el 90), edge = horizon (el 0). */
|
||||||
|
function polarPoint(az: number, el: number, radius = 100) {
|
||||||
|
const r = radius * (1 - el / 90)
|
||||||
|
const rad = (az - 90) * Math.PI / 180
|
||||||
|
return { x: 120 + r * Math.cos(rad), y: 120 + r * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
const trackPoints = computed(() => props.history.map(r => polarPoint(r.az, r.el)).map(p => `${p.x},${p.y}`).join(' '))
|
||||||
|
const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, latest.value.el) : null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sat-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(212,164,255,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(212,164,255,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.tp-name { margin: 0 24px 0 0; color: #d4a4ff; font-size: 16px; }
|
||||||
|
|
||||||
|
.tp-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.tp-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
.tp-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(212,164,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-body { display: flex; gap: 16px; padding: 12px 16px; }
|
||||||
|
|
||||||
|
.tp-gauges { flex: 0 0 110px; }
|
||||||
|
.tp-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; }
|
||||||
|
.tp-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #d4a4ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #d4a4ff; }
|
||||||
|
.tp-gauge-unit { font-size: 11px; color: #d4a4ff; margin-bottom: 0.75em; }
|
||||||
|
.tp-gauge-click { cursor: pointer; }
|
||||||
|
.tp-gauge-click:hover { border-color: #fff; }
|
||||||
|
|
||||||
|
.tp-radar { flex: 1; }
|
||||||
|
.ring, .axis { fill: none; stroke: rgba(212,164,255,0.3); stroke-width: 1; }
|
||||||
|
.dir { fill: #d4a4ff; font-size: 10px; font-weight: bold; }
|
||||||
|
.track { fill: none; stroke: #d4a4ff; stroke-width: 1.5; }
|
||||||
|
.current { fill: #d4a4ff; }
|
||||||
|
|
||||||
|
.tp-fields { width: 100%; font-size: 11px; padding: 0 16px 12px; border-collapse: collapse; }
|
||||||
|
.tp-fields td { padding: 2px 4px; color: #888; }
|
||||||
|
.tp-fields td:last-child { color: #ccc; text-align: right; }
|
||||||
|
.ok { color: #3fdb6d !important; }
|
||||||
|
.bad { color: #e0475a !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sat-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
|
<div ref="header" class="tp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
|
<h3 class="tp-name">🛰️ {{ latest?.satellite || 'Unknown' }}</h3>
|
||||||
|
<button class="tp-close" @click.stop="emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-meta">
|
||||||
|
<div class="flex-c flex-fill">
|
||||||
|
<span>Range: {{ rangeVal != null ? rangeVal + ' ' + distance.label : 'Range —' }}</span>
|
||||||
|
<span>Freq: {{ latest?.freqMHz }} MHz</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c flex-fill align-x-end">
|
||||||
|
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
|
||||||
|
<span>SNR: {{ latest?.packetSnr }} dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-body">
|
||||||
|
<div class="tp-gauges">
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Altitude (±10%)</div>
|
||||||
|
<div class="tp-gauge tp-gauge-click" @click.stop="cycleAltitude">
|
||||||
|
<span class="tp-gauge-val">{{ altVal != null ? altVal + ' ' + distance.label : '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Azimuth</div>
|
||||||
|
<div class="tp-gauge">
|
||||||
|
<span class="tp-gauge-val">{{ latest?.az?.toFixed(2) ?? '—' }}</span>
|
||||||
|
<span class="tp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Elevation</div>
|
||||||
|
<div class="tp-gauge">
|
||||||
|
<span class="tp-gauge-val">{{ latest?.el?.toFixed(2) ?? '—' }}</span>
|
||||||
|
<span class="tp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 240 240" class="tp-radar">
|
||||||
|
<circle cx="120" cy="120" r="100" class="ring" /><circle cx="120" cy="120" r="66" class="ring" /><circle cx="120" cy="120" r="33" class="ring" />
|
||||||
|
<line x1="120" y1="20" x2="120" y2="220" class="axis" /><line x1="20" y1="120" x2="220" y2="120" class="axis" />
|
||||||
|
<text x="120" y="14" text-anchor="middle" class="dir">N</text>
|
||||||
|
<text x="228" y="124" text-anchor="middle" class="dir">E</text>
|
||||||
|
<text x="120" y="234" text-anchor="middle" class="dir">S</text>
|
||||||
|
<text x="12" y="124" text-anchor="middle" class="dir">W</text>
|
||||||
|
<polyline :points="trackPoints" class="track" />
|
||||||
|
<circle v-if="currentPoint" :cx="currentPoint.x" :cy="currentPoint.y" r="5" class="current" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,47 +5,100 @@ import { api } from '../services/api';
|
|||||||
import MetricRow from './MetricRow.vue';
|
import MetricRow from './MetricRow.vue';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
const d = ref<any>(null);
|
||||||
const history = ref<number[]>([]);
|
|
||||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
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 SEG_W = W / (MAX_PTS - 1);
|
||||||
const SUB = 4;
|
const SUB = 4;
|
||||||
|
const RIGHT_PAD = 2 * SEG_W;
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval>;
|
let interval: ReturnType<typeof setInterval>;
|
||||||
let rafId: number;
|
let rafId: number;
|
||||||
let drawProgress = 0;
|
let drawProgress = 0;
|
||||||
let lastTimestamp = 0;
|
let lastTimestamp = 0;
|
||||||
const ANIM_DURATION = 1000;
|
const ANIM_DURATION = 1200;
|
||||||
|
|
||||||
let pointBuffer: { x: number; y: number }[] = [];
|
type Pt = { x: number; y: number };
|
||||||
let prevMax = 1.0;
|
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;
|
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);
|
const newMax = Math.max(...pts, 1.0);
|
||||||
prevMax = prevMax + (newMax - prevMax) * 0.15;
|
t.prevMax = t.prevMax + (newMax - t.prevMax) * 0.15; // eased amplitude scale
|
||||||
const buf: { x: number; y: number }[] = [];
|
const tipX = W - RIGHT_PAD;
|
||||||
|
const buf: Pt[] = [];
|
||||||
|
|
||||||
pts.forEach((v, i) => {
|
pts.forEach((v, i) => {
|
||||||
const prev = pts[i - 1] ?? v;
|
const prev = pts[i - 1] ?? v;
|
||||||
const max = prevMax;
|
const prevNorm = prev === 0 ? 0 : (prev / t.prevMax) * amp;
|
||||||
const prevNorm = prev === 0 ? 0 : (prev / max) * amp;
|
const norm = v === 0 ? 0 : (v / t.prevMax) * amp;
|
||||||
const norm = v === 0 ? 0 : (v / max) * amp;
|
const baseX = tipX - (pts.length - i) * SEG_W;
|
||||||
const baseX = i * SEG_W;
|
|
||||||
const s = SEG_W / SUB;
|
const s = SEG_W / SUB;
|
||||||
|
|
||||||
|
// one oscillation per value, amplitude fading prevNorm → norm
|
||||||
const a0 = prevNorm * 0.75 + norm * 0.25;
|
const a0 = prevNorm * 0.75 + norm * 0.25;
|
||||||
const a1 = prevNorm * 0.25 + norm * 0.75;
|
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 - a0 }); // up
|
||||||
buf.push({ x: baseX + s, y: mid - a1 }); // up peak
|
buf.push({ x: baseX + s * 2, y: mid - a1 }); // up peak
|
||||||
buf.push({ x: baseX + s * 2, y: mid + a2 }); // down
|
buf.push({ x: baseX + s * 3, y: mid + norm }); // down
|
||||||
buf.push({ x: baseX + s * 3, y: mid }); // back to exact center ✓
|
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 = <any>buf[i];
|
||||||
|
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const animPoints = <any>[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) {
|
function draw(timestamp: number) {
|
||||||
@@ -58,64 +111,38 @@ function draw(timestamp: number) {
|
|||||||
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
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 = (<any>AXES)[i].color;
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
ctx.fillText((<any>AXES)[i].label, W - 20, i * BAND + 14);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
// Center line per band — full width
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.moveTo(0, H / 2);
|
ctx.moveTo(0, mid);
|
||||||
ctx.lineTo(W, H / 2);
|
ctx.lineTo(W, mid);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
if (pointBuffer.length < 2) { rafId = requestAnimationFrame(draw); return; }
|
drawTrace(ctx, <any>traces[i], (<any>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);
|
rafId = requestAnimationFrame(draw);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
d.value = await api.current('seismic_magnitude');
|
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
||||||
history.value.push(d.value.seismic_magnitude ?? 0);
|
AXES.forEach((axis, i) => {
|
||||||
if (history.value.length > MAX_PTS) history.value.shift();
|
const t = <any>traces[i];
|
||||||
buildBuffer(history.value);
|
t.history.push(d.value[axis.key] ?? 0);
|
||||||
|
if (t.history.length > MAX_PTS) t.history.shift();
|
||||||
|
buildBuffer(t, i);
|
||||||
|
});
|
||||||
drawProgress = 0;
|
drawProgress = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,14 +160,17 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
canvas { width: 100%; height: 60px; }
|
canvas { width: 100%; height: 180px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">🫨 Seismic</div>
|
<div class="card-title">🫨 Seismic</div>
|
||||||
<div v-if="!d" class="p-2">
|
<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 />
|
<Loading />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+116
-96
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||||
@@ -9,12 +10,15 @@ const SPEED_UNITS = {
|
|||||||
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||||
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||||
}
|
}
|
||||||
const UNIT_KEY = 'ais_units'
|
|
||||||
|
const UNIT_KEY = 'at_units'
|
||||||
function loadUnits() {
|
function loadUnits() {
|
||||||
const s = localStorage.getItem(UNIT_KEY)
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
return s ? JSON.parse(s) : { speed: 'knots' }
|
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefs = ref(loadUnits())
|
const prefs = ref(loadUnits())
|
||||||
|
|
||||||
function cycleSpeed() {
|
function cycleSpeed() {
|
||||||
const keys = Object.keys(SPEED_UNITS)
|
const keys = Object.keys(SPEED_UNITS)
|
||||||
const p = loadUnits()
|
const p = loadUnits()
|
||||||
@@ -22,12 +26,20 @@ function cycleSpeed() {
|
|||||||
prefs.value = { ...p }
|
prefs.value = { ...p }
|
||||||
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncUnits(e: StorageEvent) {
|
||||||
|
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||||
|
}
|
||||||
|
|
||||||
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
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 heading = computed(() => props.boat.heading ?? props.boat.bearing)
|
||||||
|
const course = computed(() => props.boat.cog ?? props.boat.course)
|
||||||
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
||||||
|
const photoError = ref(false)
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
const pos = ref({ ...props.position })
|
const pos = ref({ ...props.position })
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
@@ -78,6 +90,7 @@ onMounted(() => {
|
|||||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
document.addEventListener('touchend', onTouchEnd)
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
document.addEventListener('touchcancel', onTouchEnd)
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.addEventListener('storage', syncUnits)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
@@ -85,14 +98,13 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('touchmove', onTouchMove)
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
document.removeEventListener('touchend', onTouchEnd)
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
document.removeEventListener('touchcancel', onTouchEnd)
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.removeEventListener('storage', syncUnits)
|
||||||
})
|
})
|
||||||
|
|
||||||
const compass = computed(() => {
|
const compass = computed(() => {
|
||||||
const hdg = heading.value
|
const hdg = heading.value
|
||||||
const course = props.boat.course ?? hdg
|
const course = props.boat.cog ?? hdg
|
||||||
const bearing = props.boat.bearing ?? 0
|
|
||||||
|
|
||||||
// ── Compass tape ──────────────────────────────────────────────────────────
|
|
||||||
const tapeTicks: string[] = []
|
const tapeTicks: string[] = []
|
||||||
for (let offset = -60; offset <= 60; offset += 5) {
|
for (let offset = -60; offset <= 60; offset += 5) {
|
||||||
const deg = ((hdg + offset) % 360 + 360) % 360
|
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||||
@@ -113,9 +125,8 @@ const compass = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rose centre & radii ───────────────────────────────────────────────────
|
const CX = 100, CY = 140
|
||||||
const CX = 100, CY = 158 // moved down from 145
|
const R = 68
|
||||||
const R = 68 // grown from 55
|
|
||||||
|
|
||||||
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
||||||
const rudX = CX + (rudder / 30) * 40
|
const rudX = CX + (rudder / 30) * 40
|
||||||
@@ -125,12 +136,11 @@ const compass = computed(() => {
|
|||||||
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
||||||
const cNeedleY = CY - 52 * Math.cos(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 bearRad = bearRel * Math.PI / 180
|
||||||
const bNeedleX = CX + 52 * Math.sin(bearRad)
|
const bNeedleX = CX + 52 * Math.sin(bearRad)
|
||||||
const bNeedleY = CY - 52 * Math.cos(bearRad)
|
const bNeedleY = CY - 52 * Math.cos(bearRad)
|
||||||
|
|
||||||
// Ring tick marks scaled to new R
|
|
||||||
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
||||||
const a = i * 10
|
const a = i * 10
|
||||||
const rad = (a - 90) * Math.PI / 180
|
const rad = (a - 90) * Math.PI / 180
|
||||||
@@ -151,126 +161,63 @@ 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>`
|
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
|
||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
// Legend & rudder bar shifted to bottom of taller SVG
|
|
||||||
const LEG_Y = 190
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<svg width="200" height="230" viewBox="0 0 200 230">
|
<svg width="200" height="210" viewBox="0 0 200 210">
|
||||||
<!-- Tape -->
|
|
||||||
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
|
<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="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
|
||||||
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
||||||
${tapeTicks.join('')}
|
${tapeTicks.join('')}
|
||||||
<polygon points="100,20 96,12 104,12" fill="#ff0"/>
|
<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}" 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"/>
|
<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}
|
${ringTicks}
|
||||||
${cardinals}
|
${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"/>
|
<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"/>
|
<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"/>
|
<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="80" y="${CY + 23}" width="40" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
||||||
<rect x="60" y="${LEG_Y - 4}" width="80" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
<rect x="${rudX - 4}" y="${CY + 22}" width="8" height="8" fill="#ff0" rx="1"/>
|
||||||
<line x1="${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"/>
|
|
||||||
|
|
||||||
<!-- Legend -->
|
<line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
|
||||||
<line x1="12" y1="${LEG_Y + 20}" x2="24" y2="${LEG_Y + 20}" stroke="#0f0" stroke-width="2"/>
|
<text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
|
||||||
<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"/>
|
<line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||||
<text x="27" y="${LEG_Y + 33}" fill="#888" font-size="9">BRG</text>
|
<text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
|
||||||
|
|
||||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
|
||||||
<h3 class="sp-name">
|
|
||||||
🚢
|
|
||||||
<span>{{ name.toUpperCase() }}</span>
|
|
||||||
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
|
||||||
</h3>
|
|
||||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sp-meta">
|
|
||||||
<div class="flex-c">
|
|
||||||
<span>{{ boat.operator || boat.owner || 'Unknown' }}</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sp-body">
|
|
||||||
<div class="sp-gauges">
|
|
||||||
<div class="sp-gauge-wrap">
|
|
||||||
<div class="sp-gauge-label">Speed</div>
|
|
||||||
<div class="sp-gauge" @click.stop="cycleSpeed">
|
|
||||||
<span class="sp-gauge-val">{{ speedVal }}</span>
|
|
||||||
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sp-gauge-wrap">
|
|
||||||
<div class="sp-gauge-label">Course</div>
|
|
||||||
<div class="sp-gauge">
|
|
||||||
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '—' }}</span>
|
|
||||||
<span v-if="boat.course != null" class="sp-gauge-unit">°</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sp-gauge-wrap">
|
|
||||||
<div class="sp-gauge-label">Heading</div>
|
|
||||||
<div class="sp-gauge">
|
|
||||||
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '—' }}</span>
|
|
||||||
<span v-if="boat.heading != null" class="sp-gauge-unit">°</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sp-compass" v-html="compass"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ship-popup {
|
.ship-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 9999;
|
z-index: 100;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
background: rgba(0,0,0,0.88);
|
background: rgba(0,0,0,0.88);
|
||||||
border: 1px solid rgba(200,200,220,0.3);
|
border: 1px solid rgba(0,255,255,0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sp-header {
|
.sp-header {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
border-bottom: 1px solid rgba(0,255,255,0.2);
|
||||||
cursor: move;
|
cursor: move;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
@@ -312,6 +259,22 @@ const compass = computed(() => {
|
|||||||
}
|
}
|
||||||
.sp-close:hover { background: rgba(255,255,255,0.35); }
|
.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 {
|
.sp-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -342,7 +305,64 @@ const compass = computed(() => {
|
|||||||
}
|
}
|
||||||
.sp-gauge:hover { border-color: #0f0; }
|
.sp-gauge:hover { border-color: #0f0; }
|
||||||
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
|
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
|
||||||
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 2px; }
|
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||||
|
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||||
.sp-compass { flex: 1; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
|
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
|
<h3 class="sp-name">
|
||||||
|
🚢
|
||||||
|
<span>{{ name.toUpperCase() }}</span>
|
||||||
|
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||||
|
</h3>
|
||||||
|
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-meta">
|
||||||
|
<div class="flex-c flex-fill">
|
||||||
|
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||||
|
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c flex-fill align-x-end">
|
||||||
|
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||||
|
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo -->
|
||||||
|
<div v-if="!photoError" class="sp-photo-wrap">
|
||||||
|
<img class="sp-photo" :src="BASE + '/api/ais/' + boat.mmsi + '/image'" :alt="name" @error="photoError = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-body">
|
||||||
|
<div class="sp-gauges">
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Speed</div>
|
||||||
|
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||||
|
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||||
|
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Course</div>
|
||||||
|
<div class="sp-gauge">
|
||||||
|
<span class="sp-gauge-val">{{ course != null ? course.toFixed(0) : '—' }}</span>
|
||||||
|
<span v-if="course != null" class="sp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Heading</div>
|
||||||
|
<div class="sp-gauge">
|
||||||
|
<span class="sp-gauge-val">{{ heading != null ? heading.toFixed(0) : '—' }}</span>
|
||||||
|
<span v-if="heading != null" class="sp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-compass" v-html="compass"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const arc = computed(() => {
|
|||||||
<div class="sun-card">
|
<div class="sun-card">
|
||||||
<div class="card-title">🌅 Sun</div>
|
<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>
|
<template v-else>
|
||||||
<!-- Top labels -->
|
<!-- Top labels -->
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {formatDate} from '@ztimson/utils';
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import Altitude from '@/components/Altitude.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
history: any[]
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||||
|
const BASE_SPEED_UNITS = {
|
||||||
|
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||||
|
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 3.6) },
|
||||||
|
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 2.23694) },
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_ALTITUDE_UNITS = {
|
||||||
|
meters: { label: 'M', convert: (v: number) => Math.round(v) },
|
||||||
|
feet: { label: 'FT', convert: (v: number) => Math.round(v * 3.28084) },
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_VERTICAL_UNITS = {
|
||||||
|
mps: { label: 'M/S', convert: (v: number) => Number(v.toFixed(1)) },
|
||||||
|
fps: { label: 'FT/S', convert: (v: number) => Number((v * 3.28084).toFixed(1)) },
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIT_KEY = 'at_units'
|
||||||
|
|
||||||
|
function loadUnits() {
|
||||||
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
|
return s
|
||||||
|
? JSON.parse(s)
|
||||||
|
: { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = ref(loadUnits())
|
||||||
|
|
||||||
|
function saveAndSet(p: any) {
|
||||||
|
prefs.value = { ...p }
|
||||||
|
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleSpeed() {
|
||||||
|
const keys = Object.keys(BASE_SPEED_UNITS)
|
||||||
|
const p = loadUnits()
|
||||||
|
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||||
|
saveAndSet(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleAltitude() {
|
||||||
|
const keys = Object.keys(BASE_ALTITUDE_UNITS)
|
||||||
|
const p = loadUnits()
|
||||||
|
p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]
|
||||||
|
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
|
||||||
|
saveAndSet(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleVertical() {
|
||||||
|
const keys = Object.keys(BASE_VERTICAL_UNITS)
|
||||||
|
const p = loadUnits()
|
||||||
|
p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]
|
||||||
|
p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'
|
||||||
|
saveAndSet(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUnits(e: StorageEvent) {
|
||||||
|
if (e.key === UNIT_KEY) prefs.value = loadUnits()
|
||||||
|
}
|
||||||
|
|
||||||
|
const speed = computed(() =>
|
||||||
|
BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS]
|
||||||
|
)
|
||||||
|
|
||||||
|
const altitudeUnit = computed(() =>
|
||||||
|
BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS]
|
||||||
|
)
|
||||||
|
|
||||||
|
const vertical = computed(() =>
|
||||||
|
BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS]
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Data ──────────────────────────────────────────────────────────────────────
|
||||||
|
const latest = computed(() => props.history[props.history.length - 1])
|
||||||
|
|
||||||
|
const altitude = computed(() => {
|
||||||
|
if (latest.value?.altitude == null) return null
|
||||||
|
return altitudeUnit.value.convert(Number(latest.value.altitude))
|
||||||
|
})
|
||||||
|
|
||||||
|
const speedVal = computed(() => {
|
||||||
|
if (latest.value?.speed == null) return null
|
||||||
|
return speed.value.convert(Number(latest.value.speed))
|
||||||
|
})
|
||||||
|
|
||||||
|
const climbVal = computed(() => {
|
||||||
|
if (latest.value?.climb == null) return null
|
||||||
|
const v = vertical.value.convert(Number(latest.value.climb))
|
||||||
|
return v >= 0 ? `+${v}` : String(v)
|
||||||
|
})
|
||||||
|
|
||||||
|
const altitudeHistory = computed(() => props.history.map(p => ({
|
||||||
|
...p,
|
||||||
|
altitude: p.altitude != null
|
||||||
|
? altitudeUnit.value.convert(Number(p.altitude))
|
||||||
|
: null,
|
||||||
|
})))
|
||||||
|
|
||||||
|
const currentAltitude = computed(() =>
|
||||||
|
latest.value?.altitude != null
|
||||||
|
? altitudeUnit.value.convert(Number(latest.value.altitude))
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pos = ref({ ...props.position })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = {
|
||||||
|
mx: e.clientX,
|
||||||
|
my: e.clientY,
|
||||||
|
ex: pos.value.x,
|
||||||
|
ey: pos.value.y
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('tp-close')) return
|
||||||
|
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX
|
||||||
|
const sy = touch.clientY
|
||||||
|
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = {
|
||||||
|
mx: sx,
|
||||||
|
my: sy,
|
||||||
|
ex: pos.value.x,
|
||||||
|
ey: pos.value.y
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) {
|
||||||
|
clearTimeout(longPressTimer.value)
|
||||||
|
longPressTimer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging.value && header.value) {
|
||||||
|
header.value.style.opacity = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.addEventListener('storage', syncUnits)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
window.removeEventListener('storage', syncUnits)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sonde-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(255,170,0,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,170,0,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-name {
|
||||||
|
margin: 0 24px 0 0;
|
||||||
|
color: #ffaa00;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-close:hover {
|
||||||
|
background: rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,170,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-c {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-x-end {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauges {
|
||||||
|
flex: 0 0 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge-wrap {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #ffaa00;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge:hover {
|
||||||
|
border-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge-val {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffaa00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-gauge-unit {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #ffaa00;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment-card {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex: 0 0 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255,170,0,0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment-info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment-stat-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-environment-stat-value {
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: #3fdb6d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
color: #e0475a !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="sonde-popup"
|
||||||
|
:style="{ left: pos.x + 'px', top: pos.y + 'px' }"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="header"
|
||||||
|
class="tp-header"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
@touchstart.passive="onTouchStart"
|
||||||
|
>
|
||||||
|
<h3 class="tp-name">🎈 {{ latest?.id || 'Unknown Sonde' }}</h3>
|
||||||
|
<button
|
||||||
|
class="tp-close"
|
||||||
|
@click.stop="emit('close')"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-meta">
|
||||||
|
<div class="flex-c flex-fill">
|
||||||
|
<span>Type: {{ latest?.type || 'Unknown' }}</span>
|
||||||
|
<span>Freq: {{ latest?.freqMHz?.toFixed(2) || '—' }} MHz</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-c flex-fill align-x-end">
|
||||||
|
<span>Bat: {{ latest?.battery ?? '—' }} V</span>
|
||||||
|
<span>SNR: {{ latest?.snr ?? '—' }} dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-body">
|
||||||
|
<div class="tp-gauges">
|
||||||
|
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Air Speed</div>
|
||||||
|
<div
|
||||||
|
class="tp-gauge"
|
||||||
|
@click.stop="cycleSpeed"
|
||||||
|
>
|
||||||
|
<span class="tp-gauge-val">
|
||||||
|
{{ speedVal ?? '—' }}
|
||||||
|
</span>
|
||||||
|
<span class="tp-gauge-unit">
|
||||||
|
{{ speed.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Altitude</div>
|
||||||
|
<div
|
||||||
|
class="tp-gauge"
|
||||||
|
@click.stop="cycleAltitude"
|
||||||
|
>
|
||||||
|
<span class="tp-gauge-val">
|
||||||
|
{{ altitude != null ? altitude : '—' }}
|
||||||
|
</span>
|
||||||
|
<span class="tp-gauge-unit">
|
||||||
|
{{ altitudeUnit.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-gauge-wrap">
|
||||||
|
<div class="tp-gauge-label">Climb</div>
|
||||||
|
<div
|
||||||
|
class="tp-gauge"
|
||||||
|
@click.stop="cycleVertical"
|
||||||
|
>
|
||||||
|
<span class="tp-gauge-val">
|
||||||
|
{{ climbVal ?? '—' }}
|
||||||
|
</span>
|
||||||
|
<span class="tp-gauge-unit">
|
||||||
|
{{ vertical.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-environment">
|
||||||
|
<div class="tp-environment-label">Environment</div>
|
||||||
|
|
||||||
|
<div class="tp-environment-cards gap-1">
|
||||||
|
<div class="tp-environment-card">
|
||||||
|
<span class="tp-environment-icon">🌡️</span>
|
||||||
|
<div class="tp-environment-info">
|
||||||
|
<span class="tp-environment-stat-label">Temperature</span>
|
||||||
|
<span class="tp-environment-stat-value">
|
||||||
|
{{ latest?.temperature ?? '—' }} °C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-environment-card">
|
||||||
|
<span class="tp-environment-icon">💧</span>
|
||||||
|
<div class="tp-environment-info">
|
||||||
|
<span class="tp-environment-stat-label">Humidity</span>
|
||||||
|
<span class="tp-environment-stat-value">
|
||||||
|
{{ latest?.humidity ?? '—' }} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-environment-card">
|
||||||
|
<span class="tp-environment-icon">⬇️</span>
|
||||||
|
<div class="tp-environment-info">
|
||||||
|
<span class="tp-environment-stat-label">Pressure</span>
|
||||||
|
<span class="tp-environment-stat-value">
|
||||||
|
{{ latest?.pressure > 0 ? latest.pressure : '—' }} hpa
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tp-environment-card">
|
||||||
|
<span class="tp-environment-icon">🫧</span>
|
||||||
|
<div class="tp-environment-info">
|
||||||
|
<span class="tp-environment-stat-label">PPM</span>
|
||||||
|
<span class="tp-environment-stat-value">
|
||||||
|
{{ latest?.ppm?.toFixed(2) ?? '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Altitude
|
||||||
|
:history="altitudeHistory"
|
||||||
|
:currentAltitude="currentAltitude"
|
||||||
|
:unit="altitudeUnit.label"
|
||||||
|
start-time=" "
|
||||||
|
:end-time="formatDate('HH:mm:ss', latest.timestamp)"
|
||||||
|
color="#ffaa00"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+264
-186
@@ -1,93 +1,119 @@
|
|||||||
import { BASE } from '@/services/api.ts'
|
import {BASE} from '@/services/api.ts';
|
||||||
import { createApp, ref } from 'vue'
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
import Map from 'ol/Map'
|
import {h, render as vueRender} from 'vue';
|
||||||
import { Feature } from 'ol'
|
import Map from 'ol/Map';
|
||||||
import Point from 'ol/geom/Point'
|
import {Feature} from 'ol';
|
||||||
import LineString from 'ol/geom/LineString'
|
import Point from 'ol/geom/Point';
|
||||||
import { fromLonLat } from 'ol/proj'
|
import LineString from 'ol/geom/LineString';
|
||||||
import { Style, Icon, Stroke } from 'ol/style'
|
import {fromLonLat} from 'ol/proj';
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import {Style, Icon, Stroke} from 'ol/style';
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
import AircraftPopup from '@/components/Aircraft.vue'
|
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||||
|
import AircraftPopup from '@/components/Aircraft.vue';
|
||||||
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api';
|
||||||
|
const AUTO_RANGE = 92.6;
|
||||||
|
|
||||||
let highestZIndex = 1000
|
function isPriorityAircraft(plane: any): boolean {
|
||||||
|
if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true;
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
plane.type,
|
||||||
|
plane.category,
|
||||||
|
plane.aircraft_type,
|
||||||
|
plane.operator,
|
||||||
|
plane.owner,
|
||||||
|
plane.flight,
|
||||||
|
plane.callsign,
|
||||||
|
plane.description,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
|
||||||
|
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|usaf|rcaf)\b/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
function getAltColor(alt: number): [number, number, number] {
|
function getAltColor(alt: number): [number, number, number] {
|
||||||
const a = Math.max(0, alt)
|
const a = Math.max(0, alt);
|
||||||
if (a < 10) return [0, 0, 0]
|
if (a < 10) return [0, 0, 0];
|
||||||
if (a < 10000) return [135, 206, 250]
|
if (a < 10000) return [135, 206, 250];
|
||||||
|
|
||||||
if (a < 25000) {
|
if (a < 25000) {
|
||||||
const r = (a - 10000) / 15000
|
const r = (a - 10000) / 15000;
|
||||||
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
|
return [Math.round(135 * (1 - r)), Math.round(206 * (1 - r)), Math.round(250 * (1 - r) + 255 * r)];
|
||||||
}
|
|
||||||
if (a < 40000) {
|
|
||||||
const r = (a - 25000) / 15000
|
|
||||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
|
||||||
}
|
|
||||||
const r = Math.min((a - 40000) / 10000, 1)
|
|
||||||
return [Math.round(128 * r), 0, 139]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopupInstance {
|
if (a < 40000) {
|
||||||
planeRef: ReturnType<typeof ref>
|
const r = (a - 25000) / 15000;
|
||||||
posRef: ReturnType<typeof ref>
|
return [0, 0, Math.round(255 * (1 - r) + 139 * r)];
|
||||||
unmount: () => void
|
}
|
||||||
|
|
||||||
|
const r = Math.min((a - 40000) / 10000, 1);
|
||||||
|
return [Math.round(128 * r), 0, 139];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AirTrafficLayer {
|
export class AirTrafficLayer {
|
||||||
private map: Map
|
private map: Map;
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>;
|
||||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
private traceLayers: Record<string, VectorLayer<VectorSource>> = {};
|
||||||
private traceSegs: Record<string, Feature[]> = {}
|
private traceSegs: Record<string, Feature[]> = {};
|
||||||
private popups: any = {}
|
private popups: any = {};
|
||||||
private historyCache: any = {}
|
private historyCache: any = {};
|
||||||
private data: any[] = []
|
private data: any[] = [];
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
private clickHandler: ((e: any) => void) | null = null;
|
||||||
private refreshTimer: any = null
|
private refreshTimer: any = null;
|
||||||
visible = false
|
visible = false;
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) {
|
||||||
|
this.map = map;
|
||||||
// ── Public ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async show() {
|
|
||||||
if (this.visible) return
|
|
||||||
this.visible = true
|
|
||||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
|
||||||
this.map.addLayer(this.layer)
|
|
||||||
await this._fetch()
|
|
||||||
this._draw()
|
|
||||||
this._attachClick()
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
|
||||||
try {
|
|
||||||
await this._fetch()
|
|
||||||
this._draw()
|
|
||||||
this._refreshPopups()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}, 1_000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
private _attachClick() {
|
||||||
if (!this.visible) return
|
this.clickHandler = evt => {
|
||||||
this.visible = false
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
if (!f) return;
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
|
||||||
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
const plane = f.get('planeData');
|
||||||
this.map.removeLayer(this.layer)
|
|
||||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
if (this.popups[plane.icao]) {
|
||||||
this.traceLayers = {}
|
this.closePopup(plane.icao);
|
||||||
this.traceSegs = {}
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ──────────────────────────────────────────────────────────────────
|
this.openPopup(plane);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.map.on('singleclick', this.clickHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _draw() {
|
||||||
|
const source = this.layer.getSource()!;
|
||||||
|
source.clear();
|
||||||
|
|
||||||
|
for (const plane of this.data) {
|
||||||
|
if (plane.latitude == null || plane.longitude == null) continue;
|
||||||
|
|
||||||
|
const coord = fromLonLat([plane.longitude, plane.latitude]);
|
||||||
|
const f = new Feature({geometry: new Point(coord)});
|
||||||
|
|
||||||
|
f.set('icao', plane.icao);
|
||||||
|
f.set('planeData', plane);
|
||||||
|
f.setStyle(new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||||
|
scale: 1.25,
|
||||||
|
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||||
|
anchor: [0.5, 0.5],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.addFeature(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _fetch() {
|
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) => ({
|
this.data = (j || []).map((a: any) => ({
|
||||||
...a,
|
...a,
|
||||||
icao: a.hex,
|
icao: a.hex,
|
||||||
@@ -97,137 +123,189 @@ export class AirTrafficLayer {
|
|||||||
speed: a.gs,
|
speed: a.gs,
|
||||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||||
name: a.flight?.trim(),
|
name: a.flight?.trim(),
|
||||||
}))
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
private _draw() {
|
|
||||||
const source = this.layer.getSource()!
|
|
||||||
source.clear()
|
|
||||||
for (const plane of this.data) {
|
|
||||||
if (plane.latitude == null) continue
|
|
||||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
|
||||||
const f = new Feature({ geometry: new Point(coord) })
|
|
||||||
f.set('icao', plane.icao)
|
|
||||||
f.set('planeData', plane)
|
|
||||||
f.setStyle(new Style({
|
|
||||||
image: new Icon({
|
|
||||||
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
|
||||||
scale: 1,
|
|
||||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
|
||||||
anchor: [0.5, 0.5],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
source.addFeature(f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchTrace(plane: any) {
|
private async _fetchTrace(plane: any) {
|
||||||
const icao = plane.icao
|
const icao = plane.icao;
|
||||||
if (!this.historyCache[icao]) {
|
|
||||||
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
|
|
||||||
this.historyCache[icao] = j.history || []
|
|
||||||
}
|
|
||||||
const history = [...this.historyCache[icao]]
|
|
||||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
|
||||||
const last = history[history.length - 1]
|
|
||||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
|
||||||
if (history.length < 2) return
|
|
||||||
|
|
||||||
let traceLayer = this.traceLayers[icao]
|
if (!this.historyCache[icao]) {
|
||||||
let vs: VectorSource
|
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json());
|
||||||
|
this.historyCache[icao] = j.history || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = this.historyCache[icao];
|
||||||
|
const cur = {
|
||||||
|
ts: Math.floor(Date.now() / 1000),
|
||||||
|
latitude: plane.latitude,
|
||||||
|
longitude: plane.longitude,
|
||||||
|
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
|
||||||
|
live: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const last = history[history.length - 1];
|
||||||
|
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
|
||||||
|
|
||||||
|
const traceHistory = history;
|
||||||
|
|
||||||
|
if (traceHistory.length < 2) {
|
||||||
|
const popup = this.popups[icao];
|
||||||
|
if (popup) popup.update(plane, history);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let traceLayer = this.traceLayers[icao];
|
||||||
|
let vs: VectorSource;
|
||||||
|
|
||||||
if (!traceLayer) {
|
if (!traceLayer) {
|
||||||
vs = new VectorSource()
|
vs = new VectorSource();
|
||||||
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
|
traceLayer = new VectorLayer({source: vs, zIndex: 99});
|
||||||
this.map.addLayer(traceLayer)
|
this.map.addLayer(traceLayer);
|
||||||
this.traceLayers[icao] = traceLayer
|
this.traceLayers[icao] = traceLayer;
|
||||||
this.traceSegs[icao] = []
|
this.traceSegs[icao] = [];
|
||||||
} else {
|
} else {
|
||||||
vs = traceLayer.getSource()!
|
vs = traceLayer.getSource()!;
|
||||||
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const segs: Feature[] = []
|
const segs: any = this.traceSegs[icao];
|
||||||
for (let i = 0; i < history.length - 1; i++) {
|
|
||||||
const s = history[i], e = history[i + 1]
|
for (let i = segs.length; i < traceHistory.length - 1; i++) {
|
||||||
const sc = getAltColor(s.altitude || 0)
|
const s = traceHistory[i], e = traceHistory[i + 1];
|
||||||
const ec = getAltColor(e.altitude || 0)
|
const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
|
||||||
const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2))
|
const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
|
||||||
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
|
||||||
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
const f = new Feature(new LineString([
|
||||||
vs.addFeature(f)
|
fromLonLat([s.longitude, s.latitude]),
|
||||||
segs.push(f)
|
fromLonLat([e.longitude, e.latitude]),
|
||||||
}
|
]));
|
||||||
this.traceSegs[icao] = segs
|
|
||||||
|
f.setStyle(new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: `rgba(${avg.join(',')},0.8)`,
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vs.addFeature(f);
|
||||||
|
segs.push(f);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _calcPopupPos(plane: any): { x: number; y: number } {
|
const popup = this.popups[icao];
|
||||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
if (popup) popup.update(plane, history);
|
||||||
if (!pixel) return { x: 10, y: 60 }
|
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
|
||||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
|
||||||
}
|
|
||||||
|
|
||||||
private _openPopup(plane: any) {
|
|
||||||
const icao = plane.icao
|
|
||||||
if (this.popups[icao]) return
|
|
||||||
|
|
||||||
const planeRef = ref(plane)
|
|
||||||
const posRef = ref(this._calcPopupPos(plane))
|
|
||||||
|
|
||||||
const container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
|
|
||||||
const mobile = window.innerWidth <= 768;
|
|
||||||
|
|
||||||
const app = createApp(AircraftPopup, {
|
|
||||||
plane: planeRef.value,
|
|
||||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
|
||||||
onClose: () => this._closePopup(icao),
|
|
||||||
onBringToFront: () => {
|
|
||||||
highestZIndex++
|
|
||||||
container.style.zIndex = String(highestZIndex)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
app.mount(container)
|
|
||||||
|
|
||||||
this.popups[icao] = {
|
|
||||||
planeRef,
|
|
||||||
posRef,
|
|
||||||
unmount: () => { app.unmount(); container.remove() },
|
|
||||||
}
|
|
||||||
this._fetchTrace(plane)
|
|
||||||
}
|
|
||||||
|
|
||||||
private _closePopup(icao: string) {
|
|
||||||
const popup = this.popups[icao]
|
|
||||||
if (popup) { popup.unmount(); delete this.popups[icao] }
|
|
||||||
const segs = this.traceSegs[icao]
|
|
||||||
const layer = this.traceLayers[icao]
|
|
||||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
|
||||||
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
|
|
||||||
delete this.traceSegs[icao]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshPopups() {
|
private _refreshPopups() {
|
||||||
for (const icao of Object.keys(this.popups)) {
|
for (const icao of Object.keys(this.popups)) {
|
||||||
const plane = this.data.find(p => p.icao === icao)
|
const plane = this.data.find(p => p.icao === icao);
|
||||||
if (!plane) { this._closePopup(icao); continue }
|
if (!plane) {
|
||||||
this.popups[icao].planeRef.value = plane
|
this.closePopup(icao);
|
||||||
// Don't reposition — user may have dragged it 🎯
|
continue;
|
||||||
this._fetchTrace(plane)
|
}
|
||||||
|
this._fetchTrace(plane);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attachClick() {
|
hide() {
|
||||||
this.clickHandler = (evt) => {
|
if(!this.visible) return;
|
||||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
|
this.visible = false;
|
||||||
if (!f) return
|
|
||||||
const plane = f.get('planeData')
|
if(this.refreshTimer) {
|
||||||
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
|
clearInterval(this.refreshTimer);
|
||||||
this._openPopup(plane)
|
this.refreshTimer = null;
|
||||||
}
|
}
|
||||||
this.map.on('singleclick', this.clickHandler)
|
|
||||||
|
if(this.clickHandler) {
|
||||||
|
this.map.un('singleclick', this.clickHandler);
|
||||||
|
this.clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const icao of Object.keys(this.popups)) this.closePopup(icao);
|
||||||
|
if(this.layer) this.map.removeLayer(this.layer);
|
||||||
|
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l));
|
||||||
|
this.traceLayers = {};
|
||||||
|
this.traceSegs = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
if(this.visible) return;
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
|
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||||
|
this.map.addLayer(this.layer);
|
||||||
|
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._attachClick();
|
||||||
|
|
||||||
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._refreshPopups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(icao: string): any | null {
|
||||||
|
return this.data.find(p => String(p.icao) === String(icao)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
|
||||||
|
function score(plane: any): number {
|
||||||
|
const ratio = Math.min(greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) / AUTO_RANGE, 1);
|
||||||
|
return isPriorityAircraft(plane) ? 2 - ratio : 1 - ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.data.map(plane => ({...plane, score: score(plane)})).toSorted(sortByProp('score', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
openPopup(plane: any) {
|
||||||
|
const icao = String(plane.icao);
|
||||||
|
if(this.popups[icao]) return;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768;
|
||||||
|
const makeProps = (p: any, history: any[] = []) => ({
|
||||||
|
plane: p,
|
||||||
|
history,
|
||||||
|
position: mobile
|
||||||
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
|
: {x: 16, y: 16},
|
||||||
|
onClose: () => this.closePopup(icao),
|
||||||
|
onBringToFront: () => bringToFront(container),
|
||||||
|
});
|
||||||
|
|
||||||
|
vueRender(h(AircraftPopup, makeProps(plane, this.historyCache[icao] || [])), container);
|
||||||
|
|
||||||
|
this.popups[icao] = {
|
||||||
|
update: (p: any, history: any[] = []) => vueRender(h(AircraftPopup, makeProps(p, history)), container),
|
||||||
|
unmount: () => {
|
||||||
|
vueRender(null, container);
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this._fetchTrace(plane);
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopup(icao: string) {
|
||||||
|
const popup = this.popups[icao];
|
||||||
|
if(popup) {
|
||||||
|
popup.unmount();
|
||||||
|
delete this.popups[icao];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
|
||||||
|
if(segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
|
||||||
|
if(layer) {
|
||||||
|
this.map.removeLayer(layer);
|
||||||
|
delete this.traceLayers[icao];
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.traceSegs[icao];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-116
@@ -1,32 +1,52 @@
|
|||||||
import { BASE } from '@/services/api.ts'
|
import {BASE} from '@/services/api.ts';
|
||||||
import { createApp, ref } from 'vue'
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
import Map from 'ol/Map'
|
import {h, render as vueRender} from 'vue';
|
||||||
import { Feature } from 'ol'
|
import Map from 'ol/Map';
|
||||||
import Point from 'ol/geom/Point'
|
import {Feature} from 'ol';
|
||||||
import { fromLonLat } from 'ol/proj'
|
import Point from 'ol/geom/Point';
|
||||||
import { Style, Icon } from 'ol/style'
|
import {fromLonLat} from 'ol/proj';
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import {Style, Icon} from 'ol/style';
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
import ShipPopup from '@/components/Ship.vue'
|
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||||
|
import ShipPopup from '@/components/Ship.vue';
|
||||||
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api';
|
||||||
|
const AUTO_RANGE = 100;
|
||||||
let highestZIndex = 1000
|
|
||||||
|
|
||||||
const SHIP_COLORS: Record<string, string> = {
|
const SHIP_COLORS: Record<string, string> = {
|
||||||
'Base Station': '#00aaff',
|
'Base Station': '#ffffff',
|
||||||
'Class A': '#00ff00',
|
'Class A': '#00ff00',
|
||||||
'Class B': '#8450ea',
|
'Class B': '#8450ea',
|
||||||
'SAR Aircraft': '#ff0000',
|
'SAR Aircraft': '#ff0000',
|
||||||
'AtoN': '#00aaff',
|
'AtoN': '#ffffff',
|
||||||
'Class B/CS': '#8450ea',
|
'Class B/CS': '#8450ea',
|
||||||
'Sart/Epirb/MOB': '#00aaff',
|
'Sart/Epirb/MOB': '#00aaff',
|
||||||
'Unknown': '#fad106',
|
'Unknown': '#fad106',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPriorityBoat(boat: any): boolean {
|
||||||
|
if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
boat.type,
|
||||||
|
boat.vesselType,
|
||||||
|
boat.category,
|
||||||
|
boat.name,
|
||||||
|
boat.shipname,
|
||||||
|
boat.operator,
|
||||||
|
boat.owner,
|
||||||
|
boat.callsign,
|
||||||
|
boat.description,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
|
||||||
|
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|rcaf)\b/.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBoatIcon(boat: any): string {
|
function buildBoatIcon(boat: any): string {
|
||||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'];
|
||||||
|
|
||||||
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
||||||
return `data:image/svg+xml,${encodeURIComponent(`
|
return `data:image/svg+xml,${encodeURIComponent(`
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
@@ -36,8 +56,9 @@ function buildBoatIcon(boat: any): string {
|
|||||||
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
|
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
|
||||||
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
|
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
`)}`
|
`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `data:image/svg+xml,${encodeURIComponent(`
|
return `data:image/svg+xml,${encodeURIComponent(`
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
||||||
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||||
@@ -46,132 +67,168 @@ function buildBoatIcon(boat: any): string {
|
|||||||
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
||||||
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
|
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
|
||||||
</svg>
|
</svg>
|
||||||
`)}`
|
`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AISLayer {
|
export class AISLayer {
|
||||||
private map: Map
|
private map: Map;
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>;
|
||||||
private popups: any = {}
|
private popups: any = {};
|
||||||
private data: any[] = []
|
private data: any[] = [];
|
||||||
private clickHandler: ((e: any) => void) | null = null
|
private clickHandler: ((e: any) => void) | null = null;
|
||||||
private refreshTimer: any = null
|
private refreshTimer: any = null;
|
||||||
visible = false
|
visible = false;
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) {
|
||||||
|
this.map = map;
|
||||||
async show() {
|
|
||||||
if (this.visible) return
|
|
||||||
this.visible = true
|
|
||||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
|
||||||
this.map.addLayer(this.layer)
|
|
||||||
await this._fetch()
|
|
||||||
this._draw()
|
|
||||||
this._attachClick()
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
|
||||||
try {
|
|
||||||
await this._fetch()
|
|
||||||
this._draw()
|
|
||||||
this._refreshPopups()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}, 5_000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
private _attachClick() {
|
||||||
if (!this.visible) return
|
this.clickHandler = evt => {
|
||||||
this.visible = false
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
if (!f) return;
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
|
||||||
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
const boat = f.get('boatData');
|
||||||
this.map.removeLayer(this.layer)
|
|
||||||
|
if (this.popups[boat.mmsi]) {
|
||||||
|
this.closePopup(boat.mmsi);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetch() {
|
this.openPopup(boat);
|
||||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
};
|
||||||
|
|
||||||
|
this.map.on('singleclick', this.clickHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
private _draw() {
|
||||||
const source = this.layer.getSource()!
|
const source = this.layer.getSource()!;
|
||||||
source.clear()
|
source.clear();
|
||||||
|
|
||||||
for (const boat of this.data) {
|
for (const boat of this.data) {
|
||||||
if (boat.lat == null || boat.lon == null) continue
|
if (boat.lat == null || boat.lon == null) continue;
|
||||||
const coord = fromLonLat([boat.lon, boat.lat])
|
|
||||||
const f = new Feature({ geometry: new Point(coord) })
|
const coord = fromLonLat([boat.lon, boat.lat]);
|
||||||
f.set('mmsi', boat.mmsi)
|
const f = new Feature({geometry: new Point(coord)});
|
||||||
f.set('boatData', boat)
|
|
||||||
|
f.set('mmsi', boat.mmsi);
|
||||||
|
f.set('boatData', boat);
|
||||||
f.setStyle(new Style({
|
f.setStyle(new Style({
|
||||||
image: new Icon({
|
image: new Icon({
|
||||||
src: buildBoatIcon(boat),
|
src: buildBoatIcon(boat),
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
|
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
|
||||||
anchor: [0.5, 0.5],
|
anchor: [0.5, 0.5],
|
||||||
}),
|
}),
|
||||||
}))
|
}));
|
||||||
source.addFeature(f)
|
|
||||||
|
source.addFeature(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _calcPopupPos(boat: any): { x: number; y: number } {
|
private async _fetch() {
|
||||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
|
||||||
if (!pixel) return { x: 10, y: 60 }
|
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
|
||||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
|
||||||
}
|
|
||||||
|
|
||||||
private _openPopup(boat: any) {
|
|
||||||
const mmsi = String(boat.mmsi)
|
|
||||||
if (this.popups[mmsi]) return
|
|
||||||
|
|
||||||
const boatRef = ref(boat)
|
|
||||||
const posRef = ref(this._calcPopupPos(boat))
|
|
||||||
|
|
||||||
const container = document.createElement('div')
|
|
||||||
document.body.appendChild(container)
|
|
||||||
|
|
||||||
const mobile = window.innerWidth <= 768;
|
|
||||||
|
|
||||||
const app = createApp(ShipPopup, {
|
|
||||||
boat: boatRef.value,
|
|
||||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
|
||||||
onClose: () => this._closePopup(mmsi),
|
|
||||||
onBringToFront: () => {
|
|
||||||
highestZIndex++
|
|
||||||
container.style.zIndex = String(highestZIndex)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
app.mount(container)
|
|
||||||
this.popups[mmsi] = {
|
|
||||||
boatRef,
|
|
||||||
posRef,
|
|
||||||
unmount: () => { app.unmount(); container.remove() },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _closePopup(mmsi: string) {
|
|
||||||
const popup = this.popups[mmsi]
|
|
||||||
if (popup) { popup.unmount(); delete this.popups[mmsi] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshPopups() {
|
private _refreshPopups() {
|
||||||
for (const mmsi of Object.keys(this.popups)) {
|
for (const mmsi of Object.keys(this.popups)) {
|
||||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
const boat = this.data.find(b => String(b.mmsi) === mmsi);
|
||||||
if (!boat) { this._closePopup(mmsi); continue }
|
if (!boat) {
|
||||||
this.popups[mmsi].boatRef.value = boat
|
this.closePopup(mmsi);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.popups[mmsi].update(boat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _attachClick() {
|
hide() {
|
||||||
this.clickHandler = (evt) => {
|
if (!this.visible) return;
|
||||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null)
|
this.visible = false;
|
||||||
if (!f) return
|
|
||||||
const boat = f.get('boatData')
|
if (this.refreshTimer) {
|
||||||
const mmsi = String(boat.mmsi)
|
clearInterval(this.refreshTimer);
|
||||||
if (this.popups[mmsi]) { this._closePopup(mmsi); return }
|
this.refreshTimer = null;
|
||||||
this._openPopup(boat)
|
}
|
||||||
|
|
||||||
|
if (this.clickHandler) {
|
||||||
|
this.map.un('singleclick', this.clickHandler);
|
||||||
|
this.clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mmsi of Object.keys(this.popups)) this.closePopup(mmsi);
|
||||||
|
if (this.layer) this.map.removeLayer(this.layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
if (this.visible) return;
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
|
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
|
||||||
|
this.map.addLayer(this.layer);
|
||||||
|
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._attachClick();
|
||||||
|
|
||||||
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._refreshPopups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(mmsi: string): any | null {
|
||||||
|
return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
|
||||||
|
function score(boat: any): number {
|
||||||
|
const ratio = Math.min(greatCircleDist(latitude, longitude, boat.lat, boat.lon) / AUTO_RANGE, 1);
|
||||||
|
return isPriorityBoat(boat) ? 2 - ratio : 1 - ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.data.filter(b => b.lat != null && b.lon != null)
|
||||||
|
.map(boat => ({...boat, score: score(boat)}))
|
||||||
|
.toSorted(sortByProp('score', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
openPopup(boat: any) {
|
||||||
|
const mmsi = String(boat.mmsi);
|
||||||
|
if (this.popups[mmsi]) return;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768;
|
||||||
|
const makeProps = (b: any) => ({
|
||||||
|
boat: b,
|
||||||
|
position: mobile
|
||||||
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
|
: {x: 16, y: 16},
|
||||||
|
onClose: () => this.closePopup(mmsi),
|
||||||
|
onBringToFront: () => bringToFront(container),
|
||||||
|
});
|
||||||
|
|
||||||
|
vueRender(h(ShipPopup, makeProps(boat)), container);
|
||||||
|
|
||||||
|
this.popups[mmsi] = {
|
||||||
|
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
|
||||||
|
unmount: () => {
|
||||||
|
vueRender(null, container);
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopup(mmsi: string) {
|
||||||
|
const popup = this.popups[mmsi];
|
||||||
|
if (popup) {
|
||||||
|
popup.unmount();
|
||||||
|
delete this.popups[mmsi];
|
||||||
}
|
}
|
||||||
this.map.on('singleclick', this.clickHandler)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import 'ol/ol.css';
|
||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
|
import {Feature} from 'ol';
|
||||||
|
import Point from 'ol/geom/Point';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import {Style} from 'ol/style';
|
||||||
|
|
||||||
|
export function normalize(coordinates: number | {lon: number} | {long: number} | {longitude: number}) {
|
||||||
|
const convert = (n) => {
|
||||||
|
if (n > 180) return n - 360;
|
||||||
|
if (n < -180) return n + 360;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof coordinates == 'number') return convert(coordinates);
|
||||||
|
if(typeof coordinates['lon'] == 'number') return convert(coordinates['lon']);
|
||||||
|
if(typeof coordinates['long'] == 'number') return convert(coordinates['long']);
|
||||||
|
if(typeof coordinates['longitude'] == 'number') return convert(coordinates['longitude']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Aurora {
|
||||||
|
data: any = null;
|
||||||
|
layer;
|
||||||
|
map;
|
||||||
|
name = 'aurora';
|
||||||
|
refresh;
|
||||||
|
refreshRate = 60_000 * 60;
|
||||||
|
timestamp;
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
constructor(map) {
|
||||||
|
this.map = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private style(intensity: number) {
|
||||||
|
return new Style({renderer: (coordinates, state) => {
|
||||||
|
const ctx = state.context as CanvasRenderingContext2D;
|
||||||
|
const [x, y] = <[number, number]>coordinates;
|
||||||
|
const previousComposite = ctx.globalCompositeOperation;
|
||||||
|
ctx.globalCompositeOperation = 'screen';
|
||||||
|
const zoom = this.map.getView().getZoom() || 1;
|
||||||
|
const baseRadius = 50;
|
||||||
|
const radius = baseRadius * Math.pow(2, zoom - 3);
|
||||||
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||||
|
const alpha = intensity / 50;
|
||||||
|
gradient.addColorStop(0, `rgba(0, 255, 0, ${alpha})`);
|
||||||
|
gradient.addColorStop(1, 'rgba(0, 255, 0, 0)');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalCompositeOperation = previousComposite;
|
||||||
|
}});
|
||||||
|
};
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
const d = await fetch(BASE + '/api/aurora').then(resp => resp.json());
|
||||||
|
this.timestamp = new Date(d.timestamp).getTime();
|
||||||
|
this.data = d.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async redraw() {
|
||||||
|
const threshold = 5;
|
||||||
|
const features = (this.data?.coordinates || [])
|
||||||
|
.filter(([lon, lat, intensity]: [number, number, number], i) => i % 4 == 0 && lat != 0 && intensity >= threshold)
|
||||||
|
.map(([lon, lat, intensity]: [number, number, number]) => {
|
||||||
|
const feature = new Feature({geometry: new Point([normalize(lon), lat])});
|
||||||
|
const geom = feature.getGeometry() as Point;
|
||||||
|
geom.transform('EPSG:4326', this.map.getView().getProjection());
|
||||||
|
feature.setStyle(this.style(intensity));
|
||||||
|
return feature;
|
||||||
|
});
|
||||||
|
if(this.layer) this.map.removeLayer(this.layer);
|
||||||
|
if(!features.length) return;
|
||||||
|
this.layer = new VectorLayer({source: new VectorSource({features, wrapX: true}), zIndex: 5030, opacity: 0.4});
|
||||||
|
this.layer.set('name', this.name);
|
||||||
|
this.map.addLayer(this.layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
if(this.visible) return;
|
||||||
|
this.visible = true;
|
||||||
|
if(!this.data || Date.now() - this.timestamp > this.refreshRate) await this.update();
|
||||||
|
if(!this.visible) return;
|
||||||
|
await this.redraw();
|
||||||
|
if(!this.refresh) {
|
||||||
|
this.refresh = setInterval(async () => {
|
||||||
|
await this.update();
|
||||||
|
if(!this.visible) return;
|
||||||
|
await this.redraw();
|
||||||
|
}, this.refreshRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if(!this.visible) return;
|
||||||
|
this.visible = false;
|
||||||
|
if(this.refresh) this.refresh = clearInterval(this.refresh);
|
||||||
|
this.map.getLayers().forEach(layer => {
|
||||||
|
if(layer?.get('name') == this.name) this.map.removeLayer(layer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import {api, BASE} from '@/services/api.ts';
|
||||||
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
|
import {h, render as vueRender} from 'vue';
|
||||||
|
import Map from 'ol/Map';
|
||||||
|
import {Feature} from 'ol';
|
||||||
|
import {LineString, Point, Circle as CircleGeom} from 'ol/geom';
|
||||||
|
import {fromLonLat} from 'ol/proj';
|
||||||
|
import {Style, Stroke, Icon, Fill} from 'ol/style';
|
||||||
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
|
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||||
|
import Satellite from '@/components/Satellite.vue';
|
||||||
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
|
const API = BASE + '/api';
|
||||||
|
const EARTH_R = 6371;
|
||||||
|
|
||||||
|
interface TinyGSReading {
|
||||||
|
id: number;
|
||||||
|
timestamp: number;
|
||||||
|
freqMHz: number;
|
||||||
|
satellite: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
az: number;
|
||||||
|
el: number;
|
||||||
|
packetRssi: number;
|
||||||
|
packetSnr: number;
|
||||||
|
freqError: number;
|
||||||
|
crcOk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TinyGSSatellite extends TinyGSReading {
|
||||||
|
history: TinyGSReading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RangeEstimate {
|
||||||
|
altitude: number;
|
||||||
|
slantRange: number;
|
||||||
|
horizonRadius: number;
|
||||||
|
groundDist: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
|
||||||
|
const d = greatCircleDist(gsLat, gsLon, satLat, satLon);
|
||||||
|
const gamma = d / EARTH_R;
|
||||||
|
const el = elDeg * Math.PI / 180;
|
||||||
|
const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma);
|
||||||
|
|
||||||
|
if (k <= 0) return null;
|
||||||
|
|
||||||
|
const altitude = EARTH_R * (1 - k) / k;
|
||||||
|
const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma));
|
||||||
|
const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2);
|
||||||
|
|
||||||
|
return {altitude, slantRange, horizonRadius, groundDist: d};
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateEtaOut(history: TinyGSReading[]): number | null {
|
||||||
|
const pts: any = history.slice(-6);
|
||||||
|
if (pts.length < 2) return null;
|
||||||
|
|
||||||
|
const t0 = pts[0].timestamp;
|
||||||
|
const xs = pts.map(p => (p.timestamp - t0) / 1000);
|
||||||
|
const ys = pts.map(p => p.el);
|
||||||
|
const n = xs.length;
|
||||||
|
const sumX = xs.reduce((a, b) => a + b, 0);
|
||||||
|
const sumY = ys.reduce((a, b) => a + b, 0);
|
||||||
|
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
|
||||||
|
const sumXX = xs.reduce((a, x) => a + x * x, 0);
|
||||||
|
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
||||||
|
|
||||||
|
if (!isFinite(slope) || slope >= 0) return null;
|
||||||
|
|
||||||
|
const lastEl: any = ys[ys.length - 1], lastX: any = xs[xs.length - 1];
|
||||||
|
const secsFromNow = (lastX - lastEl / slope) - lastX;
|
||||||
|
|
||||||
|
return secsFromNow > 0 ? secsFromNow * 1000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SatsLayer {
|
||||||
|
private map: Map;
|
||||||
|
private layer!: VectorLayer<VectorSource>;
|
||||||
|
private popups: any = {};
|
||||||
|
private receptionCircles: Record<string, VectorLayer<VectorSource>> = {};
|
||||||
|
private data: TinyGSSatellite[] = [];
|
||||||
|
private clickHandler: ((e: any) => void) | null = null;
|
||||||
|
private refreshTimer: any = null;
|
||||||
|
visible = false;
|
||||||
|
gs: any;
|
||||||
|
|
||||||
|
constructor(map: Map) {
|
||||||
|
this.map = map;
|
||||||
|
api.position().then(gs => this.gs = gs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _attachClick() {
|
||||||
|
this.clickHandler = evt => {
|
||||||
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
const sat = f.get('satData') as TinyGSSatellite;
|
||||||
|
|
||||||
|
if (this.popups[sat.id]) return this.closePopup(sat.id);
|
||||||
|
this.openPopup(sat);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.map.on('singleclick', this.clickHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _draw() {
|
||||||
|
const source = this.layer.getSource()!;
|
||||||
|
source.clear();
|
||||||
|
|
||||||
|
for (const sat of this.data) {
|
||||||
|
if (sat.latitude == null || sat.longitude == null) continue;
|
||||||
|
|
||||||
|
const points: any = [...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]));
|
||||||
|
|
||||||
|
if (points.length > 1) {
|
||||||
|
const trail = new Feature({geometry: new LineString(points)});
|
||||||
|
trail.setStyle(new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(212,164,255,0.5)',
|
||||||
|
width: 2,
|
||||||
|
lineDash: [6, 4],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.addFeature(trail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = new Feature({geometry: new Point(points[points.length - 1])});
|
||||||
|
marker.set('satellite', sat.id);
|
||||||
|
marker.set('satData', sat);
|
||||||
|
marker.setStyle(new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: '/satellite.png',
|
||||||
|
scale: 0.1,
|
||||||
|
anchor: [0.5, 0.5],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.addFeature(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _fetch() {
|
||||||
|
this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
|
||||||
|
if (!this.gs) return null;
|
||||||
|
return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) {
|
||||||
|
this._removeReceptionCircle(sat.id);
|
||||||
|
if (!radiusKm) return;
|
||||||
|
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
feature.setStyle(new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(212,164,255,0.6)',
|
||||||
|
width: 2,
|
||||||
|
}),
|
||||||
|
fill: new Fill({color: 'rgba(212,164,255,0.12)'}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const layer = new VectorLayer({
|
||||||
|
source: new VectorSource({features: [feature]}),
|
||||||
|
zIndex: 104,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.addLayer(layer);
|
||||||
|
this.receptionCircles[sat.id] = layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removeReceptionCircle(id: number) {
|
||||||
|
const layer = this.receptionCircles[id];
|
||||||
|
if (layer) {
|
||||||
|
this.map.removeLayer(layer);
|
||||||
|
delete this.receptionCircles[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _refreshPopups() {
|
||||||
|
for (const id of Object.keys(this.popups).map(Number)) {
|
||||||
|
const sat = this.data.find(s => s.id === id);
|
||||||
|
if (!sat) {
|
||||||
|
this.closePopup(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = [...sat.history, sat];
|
||||||
|
const range = this._calcRange(sat);
|
||||||
|
|
||||||
|
this.popups[id].update(history, range, estimateEtaOut(history));
|
||||||
|
this._drawReceptionCircle(sat, range?.horizonRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if (!this.visible) return;
|
||||||
|
this.visible = false;
|
||||||
|
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clickHandler) {
|
||||||
|
this.map.un('singleclick', this.clickHandler);
|
||||||
|
this.clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(this.popups).map(Number)) this.closePopup(id);
|
||||||
|
if (this.layer) this.map.removeLayer(this.layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
if (this.visible) return;
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
|
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||||
|
this.map.addLayer(this.layer);
|
||||||
|
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._attachClick();
|
||||||
|
|
||||||
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._refreshPopups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: number): TinyGSSatellite | null {
|
||||||
|
return this.data.find(s => s.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCandidates(latitude: number, longitude: number, limit = Infinity): TinyGSSatellite[] {
|
||||||
|
const dist = (sat: TinyGSSatellite) => this._calcRange(sat)?.slantRange ?? greatCircleDist(latitude, longitude, sat.latitude, sat.longitude);
|
||||||
|
|
||||||
|
return this.data.filter(s => s.latitude != null && s.longitude != null)
|
||||||
|
.map(sat => ({...sat, score: -dist(sat)}))
|
||||||
|
.toSorted(sortByProp('score', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
getRange(sat: TinyGSSatellite): number | null {
|
||||||
|
return this._calcRange(sat)?.slantRange ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openPopup(sat: TinyGSSatellite) {
|
||||||
|
if (this.popups[sat.id]) return;
|
||||||
|
|
||||||
|
const history = [...sat.history, sat];
|
||||||
|
const range = this._calcRange(sat);
|
||||||
|
const eta = estimateEtaOut(history);
|
||||||
|
|
||||||
|
this._drawReceptionCircle(sat, range?.horizonRadius);
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768;
|
||||||
|
const makeProps = (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => ({
|
||||||
|
history: h_,
|
||||||
|
range: r,
|
||||||
|
eta: e,
|
||||||
|
position: mobile
|
||||||
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
|
: {x: 16, y: 16},
|
||||||
|
onClose: () => this.closePopup(sat.id),
|
||||||
|
onBringToFront: () => bringToFront(container),
|
||||||
|
});
|
||||||
|
|
||||||
|
vueRender(h(Satellite, makeProps(history, range, eta)), container);
|
||||||
|
|
||||||
|
this.popups[sat.id] = {
|
||||||
|
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(Satellite, makeProps(h_, r, e)), container),
|
||||||
|
unmount: () => {
|
||||||
|
vueRender(null, container);
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopup(id: number) {
|
||||||
|
const popup = this.popups[id];
|
||||||
|
if (popup) {
|
||||||
|
popup.unmount();
|
||||||
|
delete this.popups[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeReceptionCircle(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
|
import {h, render as vueRender} from 'vue';
|
||||||
|
import Map from 'ol/Map';
|
||||||
|
import {Feature} from 'ol';
|
||||||
|
import {LineString, Point} from 'ol/geom';
|
||||||
|
import {fromLonLat} from 'ol/proj';
|
||||||
|
import {Style, Stroke, Icon} from 'ol/style';
|
||||||
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
|
import {adjustedInterval, sortByProp} from '@ztimson/utils';
|
||||||
|
import SondePopup from '@/components/WeatherBalloon.vue';
|
||||||
|
import {bringToFront} from './zindex';
|
||||||
|
|
||||||
|
const API = BASE + '/api';
|
||||||
|
const SONDE_STALE_TTL = 60_000 * 5;
|
||||||
|
|
||||||
|
interface SondeReading {
|
||||||
|
timestamp: number;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
subtype: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
altitude: number;
|
||||||
|
heading: number;
|
||||||
|
speed: number;
|
||||||
|
climb: number;
|
||||||
|
sats: number;
|
||||||
|
battery: number;
|
||||||
|
temperature: number;
|
||||||
|
humidity: number;
|
||||||
|
pressure: number;
|
||||||
|
freqMHz: number;
|
||||||
|
snr: number;
|
||||||
|
ppm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sonde extends SondeReading {
|
||||||
|
history: SondeReading[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SondesLayer {
|
||||||
|
private map: Map;
|
||||||
|
private layer!: VectorLayer<VectorSource>;
|
||||||
|
private popups: any = {};
|
||||||
|
private data: Sonde[] = [];
|
||||||
|
private clickHandler: ((e: any) => void) | null = null;
|
||||||
|
private refreshTimer: any = null;
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
constructor(map: Map) {
|
||||||
|
this.map = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _attachClick() {
|
||||||
|
this.clickHandler = evt => {
|
||||||
|
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
const sonde = f.get('sondeData') as Sonde;
|
||||||
|
|
||||||
|
if (this.popups[sonde.id]) {
|
||||||
|
this.closePopup(sonde.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openPopup(sonde);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.map.on('singleclick', this.clickHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _draw() {
|
||||||
|
const source = this.layer.getSource()!;
|
||||||
|
source.clear();
|
||||||
|
|
||||||
|
for (const sonde of this.data) {
|
||||||
|
if (sonde.latitude == null || sonde.longitude == null) continue;
|
||||||
|
|
||||||
|
const points: any = [...sonde.history, sonde].map(r => fromLonLat([r.longitude, r.latitude]));
|
||||||
|
|
||||||
|
if (points.length > 1) {
|
||||||
|
const trail = new Feature({geometry: new LineString(points)});
|
||||||
|
trail.setStyle(new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: 'rgba(255,170,0,0.5)',
|
||||||
|
width: 2,
|
||||||
|
lineDash: [6, 4],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.addFeature(trail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = new Feature({geometry: new Point(points[points.length - 1])});
|
||||||
|
marker.set('sonde', sonde.id);
|
||||||
|
marker.set('sondeData', sonde);
|
||||||
|
marker.setStyle(new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: this._getIcon(sonde),
|
||||||
|
scale: 0.75,
|
||||||
|
anchor: [0.5, 0.5],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.addFeature(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getIcon(sonde: Sonde): string {
|
||||||
|
const opacity = (Date.now() - sonde.timestamp > SONDE_STALE_TTL) ? 0.5 : 1;
|
||||||
|
const descending = sonde.climb < 0;
|
||||||
|
|
||||||
|
const svg = descending
|
||||||
|
? `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="${opacity}">
|
||||||
|
<path d="M8 20a16 16 0 0 1 32 0Z" fill="#FFAA00" stroke-width="2" stroke="black"/>
|
||||||
|
<path d="M8 20l10 12M40 20L30 32" fill="#FFAA00" stroke-width="2" stroke="black"/>
|
||||||
|
<rect x="16" y="32" width="16" height="11" rx="2" fill="#FFAA00"/>
|
||||||
|
</svg>`
|
||||||
|
: `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="${opacity}">
|
||||||
|
<circle cx="24" cy="13" r="12" fill="#FFAA00"/>
|
||||||
|
<path d="M24 27v5" fill="#FFAA00" stroke-width="2" stroke="black" />
|
||||||
|
<rect x="16" y="32" width="16" height="11" rx="2" fill="#FFAA00"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _fetch() {
|
||||||
|
this.data = await fetch(`${API}/sondes`).then(r => r.json()) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _refreshPopups() {
|
||||||
|
for (const id of Object.keys(this.popups)) {
|
||||||
|
const sonde = this.data.find(s => s.id === id);
|
||||||
|
if (!sonde) {
|
||||||
|
this.closePopup(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.popups[id].update([...sonde.history, sonde]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if (!this.visible) return;
|
||||||
|
this.visible = false;
|
||||||
|
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearInterval(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clickHandler) {
|
||||||
|
this.map.un('singleclick', this.clickHandler);
|
||||||
|
this.clickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(this.popups)) this.closePopup(id);
|
||||||
|
if (this.layer) this.map.removeLayer(this.layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
if (this.visible) return;
|
||||||
|
|
||||||
|
this.visible = true;
|
||||||
|
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
|
||||||
|
this.map.addLayer(this.layer);
|
||||||
|
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._attachClick();
|
||||||
|
|
||||||
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
|
try {
|
||||||
|
await this._fetch();
|
||||||
|
this._draw();
|
||||||
|
this._refreshPopups();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Sonde | null {
|
||||||
|
return this.data.find(s => s.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCandidates(latitude: number, longitude: number, limit = Infinity): Sonde[] {
|
||||||
|
return this.data.filter(s => s.latitude != null && s.longitude != null)
|
||||||
|
.map(sonde => ({...sonde, score: -greatCircleDist(latitude, longitude, sonde.latitude, sonde.longitude)}))
|
||||||
|
.toSorted(sortByProp('score', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
openPopup(sonde: Sonde) {
|
||||||
|
if (this.popups[sonde.id]) return;
|
||||||
|
|
||||||
|
const history = [...sonde.history, sonde];
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const mobile = window.innerWidth <= 768;
|
||||||
|
const makeProps = (h_: SondeReading[]) => ({
|
||||||
|
history: h_,
|
||||||
|
position: mobile
|
||||||
|
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
|
||||||
|
: {x: 16, y: 16},
|
||||||
|
onClose: () => this.closePopup(sonde.id),
|
||||||
|
onBringToFront: () => bringToFront(container),
|
||||||
|
});
|
||||||
|
|
||||||
|
vueRender(h(SondePopup, makeProps(history)), container);
|
||||||
|
|
||||||
|
this.popups[sonde.id] = {
|
||||||
|
update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container),
|
||||||
|
unmount: () => {
|
||||||
|
vueRender(null, container);
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopup(id: string) {
|
||||||
|
const popup = this.popups[id];
|
||||||
|
if (popup) {
|
||||||
|
popup.unmount();
|
||||||
|
delete this.popups[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,14 @@ export const METRICS: Record<string, MetricMeta> = {
|
|||||||
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatValue(key: string, value: number | string | null): string {
|
||||||
|
if (value === null || value === undefined) return '—'
|
||||||
|
const meta = METRICS[key]
|
||||||
|
if (!meta) return String(value)
|
||||||
|
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
||||||
|
return `${value}${meta.unit}`
|
||||||
|
}
|
||||||
|
|
||||||
export function getPressureTrend(delta: number) {
|
export function getPressureTrend(delta: number) {
|
||||||
if (delta > 0.5) return 'Rising';
|
if (delta > 0.5) return 'Rising';
|
||||||
if (delta < -0.5) return 'Falling';
|
if (delta < -0.5) return 'Falling';
|
||||||
@@ -76,12 +84,12 @@ export function getUVLabel(uv: number) {
|
|||||||
return 'Extreme';
|
return 'Extreme';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatValue(key: string, value: number | string | null): string {
|
const EARTH_R = 6371;
|
||||||
if (value === null || value === undefined) return '—'
|
export function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const meta = METRICS[key]
|
const toRad = (d: number) => d * Math.PI / 180;
|
||||||
if (!meta) return String(value)
|
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
|
||||||
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
return `${value}${meta.unit}`
|
return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
|
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// src/services/zIndex.ts
|
||||||
|
export let highestZIndex = 1000
|
||||||
|
export function bringToFront(el: HTMLElement) {
|
||||||
|
el.style.zIndex = String(++highestZIndex)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
// Extra safety for array and object lookups, but may have false positives.
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"lib": ["DOM","ESNext"],
|
||||||
|
|
||||||
// Path mapping for cleaner imports.
|
// Path mapping for cleaner imports.
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
+72
-23
@@ -151,42 +151,89 @@ def read_bme(c, sensor, alt_m=None):
|
|||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||||
|
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
MPU_ADDR = 0x68
|
MPU_ADDR = 0x68
|
||||||
_mpu_peak = None
|
_mpu_peak = None
|
||||||
_mpu_lock = threading.Lock()
|
_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):
|
def calibrate_mpu(samples=200):
|
||||||
if not bus: return None
|
if not bus: return None
|
||||||
print(" Calibrating MPU6050, keep still...")
|
print(" Calibrating MPU6050, keep still...")
|
||||||
sums = [0.0] * 6
|
sums, n = [0.0, 0.0, 0.0], 0
|
||||||
n = 0
|
|
||||||
for _ in range(samples):
|
for _ in range(samples):
|
||||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
|
a = read_accel()
|
||||||
if d:
|
if a:
|
||||||
sums[0] += s16((d[0] << 8) | d[1]) / 16384.0
|
sums = [s + v for s, v in zip(sums, a)]
|
||||||
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
|
|
||||||
n += 1
|
n += 1
|
||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
if n == 0: return None
|
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():
|
def mpu_loop():
|
||||||
global _mpu_peak
|
global _mpu_peak, _mpu_grav
|
||||||
while True:
|
while True:
|
||||||
if bus and _mpu_offsets:
|
if bus and _mpu_grav:
|
||||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
|
a = read_accel()
|
||||||
if d:
|
if a:
|
||||||
o = _mpu_offsets
|
_still_buf.append(a)
|
||||||
ax = s16((d[0] << 8) | d[1]) / 16384.0 - o[0]
|
|
||||||
ay = s16((d[2] << 8) | d[3]) / 16384.0 - o[1]
|
R, g = rotation_to_z(_mpu_grav)
|
||||||
az = s16((d[4] << 8) | d[5]) / 16384.0 - o[2]
|
ax = R[0][0] * a[0] + R[0][1] * a[1] + R[0][2] * a[2]
|
||||||
mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**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:
|
with _mpu_lock:
|
||||||
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
|
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
|
||||||
_mpu_peak = {
|
_mpu_peak = {
|
||||||
@@ -203,6 +250,8 @@ def flush_mpu(c):
|
|||||||
data, _mpu_peak = _mpu_peak, None
|
data, _mpu_peak = _mpu_peak, None
|
||||||
if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']:
|
if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']:
|
||||||
write(c, data)
|
write(c, data)
|
||||||
|
else:
|
||||||
|
write(c, {'seismic_x': 0.0, 'seismic_y': 0.0, 'seismic_z': 0.0, 'seismic_magnitude': 0.0})
|
||||||
|
|
||||||
# ── QMC5883L ──────────────────────────────────────────────────────────────────
|
# ── QMC5883L ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -482,9 +531,9 @@ if __name__ == '__main__':
|
|||||||
if bus:
|
if bus:
|
||||||
try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0))
|
try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0))
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
_mpu_offsets = try_init(calibrate_mpu, 'MPU6050')
|
_mpu_grav = try_init(calibrate_mpu, 'MPU6050')
|
||||||
else:
|
else:
|
||||||
_mpu_offsets = None
|
_mpu_grav = None
|
||||||
print(" ❌ MPU6050: no I2C bus")
|
print(" ❌ MPU6050: no I2C bus")
|
||||||
|
|
||||||
threading.Thread(target=mpu_loop, daemon=True).start()
|
threading.Thread(target=mpu_loop, daemon=True).start()
|
||||||
|
|||||||
Generated
+615
@@ -15,9 +15,568 @@
|
|||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.11.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@scalar/client-side-rendering": {
|
"node_modules/@scalar/client-side-rendering": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz",
|
||||||
@@ -1392,6 +1951,55 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.35.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||||
|
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.1.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.8.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.35.4",
|
||||||
|
"@img/sharp-darwin-x64": "0.35.4",
|
||||||
|
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||||
|
"@img/sharp-linux-arm": "0.35.4",
|
||||||
|
"@img/sharp-linux-arm64": "0.35.4",
|
||||||
|
"@img/sharp-linux-ppc64": "0.35.4",
|
||||||
|
"@img/sharp-linux-riscv64": "0.35.4",
|
||||||
|
"@img/sharp-linux-s390x": "0.35.4",
|
||||||
|
"@img/sharp-linux-x64": "0.35.4",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||||
|
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||||
|
"@img/sharp-win32-arm64": "0.35.4",
|
||||||
|
"@img/sharp-win32-ia32": "0.35.4",
|
||||||
|
"@img/sharp-win32-x64": "0.35.4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
@@ -1585,6 +2193,13 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1253,11 +1253,13 @@ function toSvg(shape, fillColor, strokeColor, strokeWidth, scale) {
|
|||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let svg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="' + shape.viewBox + '" '
|
let svg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="' + shape.viewBox + '" '
|
||||||
+ (shape.noAspect ? 'preserveAspectRatio="none" ' : '')
|
+ (shape.noAspect ? 'preserveAspectRatio="none" ' : '')
|
||||||
+ 'width="' + wi + '" height="' + he + '">'
|
+ '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) {
|
if (shape.path) {
|
||||||
let path = shape.path;
|
let path = shape.path;
|
||||||
|
|||||||
+326
-144
@@ -6,34 +6,96 @@ import Database from 'better-sqlite3';
|
|||||||
import { fromCsv } from '@ztimson/utils';
|
import { fromCsv } from '@ztimson/utils';
|
||||||
import { getIcon } from './adsb-shapes.mjs';
|
import { getIcon } from './adsb-shapes.mjs';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
const DATA = resolve(DIR, '../data');
|
const DATA = resolve(DIR, '../data');
|
||||||
|
const IMAGES_DIR = resolve(DATA, 'aircraft');
|
||||||
const DB_PATH = resolve(DATA, 'aircraft.db');
|
const DB_PATH = resolve(DATA, 'aircraft.db');
|
||||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||||
|
const AIRLINES_CACHE = resolve(DATA, 'airlines.csv');
|
||||||
const MAX_HISTORY = 500;
|
const MAX_HISTORY = 500;
|
||||||
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
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 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 AIRLINES_URL = 'https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat';
|
||||||
|
|
||||||
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 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', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
||||||
|
const MILITARY_KEYWORDS = [
|
||||||
|
'air force', 'airforce', 'navy', 'army', 'marine corps', 'coast guard',
|
||||||
|
'national guard', 'military', 'defence', 'defense', 'police', 'luftwaffe',
|
||||||
|
'aeronautica militare', 'armee de l air', 'raf', 'usaf'
|
||||||
|
];
|
||||||
|
|
||||||
|
let db;
|
||||||
|
let passengerOperators = PASSENGER_OPERATORS;
|
||||||
|
let adsbCache = null;
|
||||||
|
let adsbCacheTs = 0;
|
||||||
const noRecord = [];
|
const noRecord = [];
|
||||||
const history = new Map();
|
const history = new Map();
|
||||||
const milRanges = [];
|
const milRanges = [];
|
||||||
let db;
|
|
||||||
|
|
||||||
setInterval(() => {
|
function backfillModel(aircraft) {
|
||||||
const cutoff = Date.now() - HISTORY_TTL;
|
if (!aircraft) return {};
|
||||||
for (const [key, trail] of history) {
|
const similar = db.prepare(`
|
||||||
const last = trail.at(-1);
|
SELECT
|
||||||
if (!last || last.ts < cutoff) history.delete(key);
|
manufacturer,
|
||||||
|
model,
|
||||||
|
engines,
|
||||||
|
categoryDescription,
|
||||||
|
class
|
||||||
|
FROM aircraft
|
||||||
|
WHERE aircraft = ?
|
||||||
|
AND (
|
||||||
|
manufacturer IS NOT NULL
|
||||||
|
OR model IS NOT NULL
|
||||||
|
OR engines IS NOT NULL
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`).get(aircraft);
|
||||||
|
if (!similar) return {};
|
||||||
|
return {
|
||||||
|
manufacturer: similar.manufacturer || null,
|
||||||
|
model: similar.model || null,
|
||||||
|
engines: similar.engines || null,
|
||||||
|
categoryDescription: similar.categoryDescription || null,
|
||||||
|
class: similar.class || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRegistration1(icao) {
|
||||||
|
const url = `https://hexdatabase.com/h/${icao}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const html = await resp.text();
|
||||||
|
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
||||||
|
if (!tableMatch) return null;
|
||||||
|
const $ = cheerio.load(tableMatch[0]);
|
||||||
|
const row = $('tr').filter((_, el) => $(el).text()?.toLowerCase()?.includes(icao.toLowerCase())).first();
|
||||||
|
if (!row.length) return null;
|
||||||
|
const cells = row.find('td');
|
||||||
|
return {
|
||||||
|
registration: $(cells[1]).text().trim() || null,
|
||||||
|
aircraft: $(cells[2]).text().trim() || null,
|
||||||
|
operator: $(cells[3]).text().trim() || null,
|
||||||
|
serialNumber: $(cells[5]).text().trim() || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRegistration2(icao) {
|
||||||
|
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const found = await resp.json();
|
||||||
|
return {
|
||||||
|
registration: found.Registration || null,
|
||||||
|
manufacturer: found.Manufacturer || null,
|
||||||
|
aircraft: found.ICAOTypeCode || null,
|
||||||
|
model: found.Type || null,
|
||||||
|
operator: found.RegisteredOwners || null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, 1000 * 60 * 5);
|
|
||||||
|
|
||||||
function fetchWithTimeout(url, ms = 5000) {
|
function fetchWithTimeout(url, ms = 5000) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -41,35 +103,11 @@ function fetchWithTimeout(url, ms = 5000) {
|
|||||||
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
|
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncMilitaryRanges() {
|
|
||||||
const { ADSB_URL } = cfg();
|
|
||||||
try {
|
|
||||||
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const { military } = await res.json();
|
|
||||||
|
|
||||||
db.exec('DELETE FROM military_ranges');
|
|
||||||
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
|
||||||
const insertMany = db.transaction(ranges => {
|
|
||||||
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
insertMany(military);
|
|
||||||
milRanges.length = 0;
|
|
||||||
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
|
||||||
} catch (e) {
|
|
||||||
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
|
|
||||||
milRanges.length = 0;
|
|
||||||
milRanges.push(...rows.map(r => [r.start, r.end]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initAircraftDb() {
|
export async function initAircraftDb() {
|
||||||
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
|
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
|
||||||
|
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||||
const missing = !fs.existsSync(DB_PATH);
|
const missing = !fs.existsSync(DB_PATH);
|
||||||
if (missing) console.log(`✈️ Building database`);
|
if (missing) console.log(`✈️ Building database`);
|
||||||
|
|
||||||
db = new Database(DB_PATH);
|
db = new Database(DB_PATH);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS aircraft (
|
CREATE TABLE IF NOT EXISTS aircraft (
|
||||||
@@ -89,26 +127,36 @@ export async function initAircraftDb() {
|
|||||||
serialNumber TEXT,
|
serialNumber TEXT,
|
||||||
aircraft TEXT
|
aircraft TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS military_ranges (
|
|
||||||
start TEXT NOT NULL,
|
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
|
||||||
end TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await syncMilitaryRanges();
|
await syncAirlines();
|
||||||
|
await syncMilitaryIcao();
|
||||||
|
|
||||||
if(missing) {
|
if(missing) {
|
||||||
const res = await fetch(CSV_URL);
|
const res = await fetch(CSV_URL);
|
||||||
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
fs.writeFileSync(CSV_CACHE, text);
|
fs.writeFileSync(CSV_CACHE, text);
|
||||||
|
|
||||||
const csv = fromCsv(text, true);
|
const csv = fromCsv(text, true);
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
INSERT OR REPLACE INTO aircraft VALUES (
|
INSERT OR REPLACE INTO aircraft VALUES (
|
||||||
@icao, @built, @categoryDescription, @country, @engines,
|
@icao,
|
||||||
@class, @manufacturer, @model, @modes, @operator,
|
@built,
|
||||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
@categoryDescription,
|
||||||
|
@country,
|
||||||
|
@engines,
|
||||||
|
@class,
|
||||||
|
@manufacturer,
|
||||||
|
@model,
|
||||||
|
@modes,
|
||||||
|
@operator,
|
||||||
|
@operatorCallsign,
|
||||||
|
@owner,
|
||||||
|
@registration,
|
||||||
|
@serialNumber,
|
||||||
|
@aircraft
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -131,132 +179,116 @@ export async function initAircraftDb() {
|
|||||||
owner: row.owner || null,
|
owner: row.owner || null,
|
||||||
registration: row.registration || null,
|
registration: row.registration || null,
|
||||||
serialNumber: row.serialNumber || null,
|
serialNumber: row.serialNumber || null,
|
||||||
aircraft: row.typecode || null,
|
aircraft: row.typecode || null
|
||||||
})));
|
})));
|
||||||
|
|
||||||
fs.unlinkSync(CSV_CACHE);
|
fs.unlinkSync(CSV_CACHE);
|
||||||
console.log(`✈️ Aircraft imported: ${csv.length}`);
|
console.log(`✈️ Registrations imported: ${csv.length}`);
|
||||||
} else {
|
} else {
|
||||||
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
|
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
|
||||||
console.log(`✈️ Aircraft loaded: ${count.c}`);
|
console.log(`✈️ Registrations loaded: ${count.c}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIcaoInMilitaryRange(icao) {
|
function isMilitaryIcao(icao) {
|
||||||
if (!icao) return false;
|
if (!icao) return false;
|
||||||
const hex = icao.toUpperCase();
|
const hex = icao.toUpperCase();
|
||||||
return milRanges.some(([s, e]) => hex >= s && hex <= e);
|
return milRanges.some(([s, e]) => hex >= s && hex <= e);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wordMatch(text, keyword) {
|
async function syncAirlines() {
|
||||||
if (!text) return false;
|
try {
|
||||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
if (!fs.existsSync(AIRLINES_CACHE)) {
|
||||||
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
console.log('✈️ Downloading airline database');
|
||||||
|
const res = await fetchWithTimeout(AIRLINES_URL, 10000);
|
||||||
|
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
fs.writeFileSync(AIRLINES_CACHE, await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = fs.readFileSync(AIRLINES_CACHE, 'utf8');
|
||||||
|
const airlines = text.split('\n').slice(1).map(row =>
|
||||||
|
row.split(',')[1]?.slice(1, -1).toLowerCase()).filter(Boolean).flat();
|
||||||
|
passengerOperators = airlines.sort((a, b) => b.length - a.length);
|
||||||
|
console.log(`✈️ Airlines loaded: ${airlines.length}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`⚠️ Could not load airline database: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncMilitaryIcao() {
|
||||||
|
const { ADSB_URL } = cfg();
|
||||||
|
try {
|
||||||
|
const page = await fetch(ADSB_URL + ':8080').then(resp => resp.text());
|
||||||
|
const db = /databaseFolder = "(db-.+?)"/.exec(page);
|
||||||
|
const res = await fetch(ADSB_URL + `:8080/${db[1]}/ranges.js`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const { military } = await res.json();
|
||||||
|
db.exec('DELETE FROM military_ranges');
|
||||||
|
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
||||||
|
const insertMany = db.transaction(ranges => {
|
||||||
|
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
||||||
|
});
|
||||||
|
insertMany(military);
|
||||||
|
milRanges.length = 0;
|
||||||
|
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
||||||
|
} catch (e) {
|
||||||
|
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
|
||||||
|
milRanges.length = 0;
|
||||||
|
milRanges.push(...rows.map(r => [r.start, r.end]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classifyAircraft(row) {
|
export function classifyAircraft(row) {
|
||||||
if (!row) return 'unknown';
|
if (!row) return 'unknown';
|
||||||
|
|
||||||
const matchesAny = (fields, list) =>
|
const matchesAny = (fields, list) => fields.some(f => {
|
||||||
fields.some(f => list.some(k => wordMatch(f, k)));
|
const value = normalizeName(f);
|
||||||
|
return value && list.some(k => {
|
||||||
|
const kw = normalizeName(k);
|
||||||
|
return kw && new RegExp('\\b' + kw.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + '\\b', 'i').test(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const operatorFields = [row.operator, row.operatorCallsign];
|
const operators = [row.operator, row.operatorCallsign, row.owner];
|
||||||
const ownerFields = [row.owner];
|
if (isMilitaryIcao(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(operators, MILITARY_KEYWORDS))
|
||||||
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
return 'military';
|
||||||
|
|
||||||
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
|
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operators, CARGO_OPERATORS)) return 'cargo';
|
||||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
|
if (matchesAny(operators, passengerOperators)) return 'passenger';
|
||||||
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
|
||||||
if (row.owner && !row.operator) return 'private';
|
if (row.owner && !row.operator) return 'private';
|
||||||
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHexDb(icao) {
|
function normalizeName(value) {
|
||||||
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
|
return String(value || '')
|
||||||
if (!resp.ok) return null;
|
.normalize('NFKD')
|
||||||
const found = await resp.json();
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
return {
|
.toLowerCase()
|
||||||
registration: found.Registration || null,
|
.replace(/&/g, ' and ')
|
||||||
manufacturer: found.Manufacturer || null,
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
aircraft: found.ICAOTypeCode || null,
|
.trim()
|
||||||
model: found.Type || null,
|
.replace(/\s+/g, ' ');
|
||||||
operator: found.RegisteredOwners || null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrapeHexDatabase(icao) {
|
export async function enrich(a) {
|
||||||
const url = `https://hexdatabase.com/h/${icao}`;
|
|
||||||
const resp = await fetch(url);
|
|
||||||
if (!resp.ok) return null;
|
|
||||||
|
|
||||||
const $ = cheerio.load(await resp.text());
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function backfillFromSimilar(aircraft) {
|
|
||||||
if (!aircraft) return {};
|
|
||||||
const similar = db.prepare(`
|
|
||||||
SELECT manufacturer, model, engines, categoryDescription, class
|
|
||||||
FROM aircraft
|
|
||||||
WHERE aircraft = ?
|
|
||||||
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
|
|
||||||
LIMIT 1
|
|
||||||
`).get(aircraft);
|
|
||||||
if (!similar) return {};
|
|
||||||
return {
|
|
||||||
manufacturer: similar.manufacturer || null,
|
|
||||||
model: similar.model || null,
|
|
||||||
engines: similar.engines || null,
|
|
||||||
categoryDescription: similar.categoryDescription || null,
|
|
||||||
class: similar.class || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enrichAircraft(a) {
|
|
||||||
if (!a.hex) return a;
|
if (!a.hex) return a;
|
||||||
const icao = a.hex.toUpperCase();
|
const icao = a.hex.toUpperCase();
|
||||||
|
|
||||||
// 1. Check own DB first
|
|
||||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
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' };
|
if (noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||||
let found = await Promise.any([
|
const hexDbPromise = fetchRegistration2(icao);
|
||||||
fetchHexDb(icao).then(resp => {
|
const scrapePromise = fetchRegistration1(icao);
|
||||||
if(resp) console.log(`Source1: ${icao}`)
|
let found = await hexDbPromise.catch(() => {});
|
||||||
return resp;
|
if (!found) found = await scrapePromise.catch(() => {});
|
||||||
}),
|
|
||||||
scrapeHexDatabase(icao).then(resp => {
|
|
||||||
if(resp) console.log(`Source2: ${icao}`)
|
|
||||||
return resp;
|
|
||||||
}),
|
|
||||||
]).catch(() => null);
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
console.log(`Not Found: ${icao}`)
|
|
||||||
noRecord.push(icao);
|
noRecord.push(icao);
|
||||||
return { icao, ...a, type: 'unknown' };
|
return { icao, ...a, type: 'unknown' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
|
|
||||||
const merged = { ...row, ...found };
|
const merged = { ...row, ...found };
|
||||||
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
||||||
const similar = backfillFromSimilar(merged.aircraft);
|
const similar = backfillModel(merged.aircraft);
|
||||||
Object.assign(merged, similar);
|
Object.assign(merged, similar);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save back to DB
|
|
||||||
if(found) {
|
if(found) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
||||||
@@ -283,16 +315,19 @@ export async function enrichAircraft(a) {
|
|||||||
serialNumber: merged.serialNumber || null,
|
serialNumber: merged.serialNumber || null,
|
||||||
engines: merged.engines || null,
|
engines: merged.engines || null,
|
||||||
categoryDescription: merged.categoryDescription || null,
|
categoryDescription: merged.categoryDescription || null,
|
||||||
class: merged.class || null,
|
class: merged.class || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
|
return { icao, ...a, ...merged, type: classifyAircraft(merged) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getADSB() {
|
export async function get() {
|
||||||
|
if(adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||||
|
|
||||||
const { ADSB_URL } = cfg();
|
const { ADSB_URL } = cfg();
|
||||||
if(!ADSB_URL) return [];
|
if(!ADSB_URL) return [];
|
||||||
|
|
||||||
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
|
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
const aircraft = j.aircraft || [];
|
const aircraft = j.aircraft || [];
|
||||||
@@ -303,25 +338,172 @@ export async function getADSB() {
|
|||||||
const key = a.hex.toLowerCase();
|
const key = a.hex.toLowerCase();
|
||||||
if (!history.has(key)) history.set(key, []);
|
if (!history.has(key)) history.set(key, []);
|
||||||
const trail = history.get(key);
|
const trail = history.get(key);
|
||||||
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() });
|
trail.push({
|
||||||
|
latitude: a.lat,
|
||||||
|
longitude: a.lon,
|
||||||
|
altitude: a.alt_baro || 0,
|
||||||
|
ts: Date.now()
|
||||||
|
});
|
||||||
if (trail.length > MAX_HISTORY) trail.shift();
|
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 = await enrich(a);
|
||||||
a.icon = getIcon(a);
|
a.icon = getIcon(a);
|
||||||
return a;
|
return a;
|
||||||
}));
|
}));
|
||||||
|
adsbCacheTs = Date.now();
|
||||||
|
return adsbCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getADSBHistory(icao) {
|
export async function getAttenuation() {
|
||||||
return { history: history.get(icao?.toLowerCase()) || [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getADSBRange() {
|
|
||||||
const { ADSB_URL } = cfg();
|
const { ADSB_URL } = cfg();
|
||||||
if (!ADSB_URL) return [];
|
if (!ADSB_URL) return [];
|
||||||
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
|
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
return j?.actualRange?.last24h?.points || [];
|
return j?.actualRange?.last24h?.points || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getHistory(icao) {
|
||||||
|
return { history: history.get(icao?.toLowerCase()) || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIcaoImage(icao) {
|
||||||
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
async function duckduckgo(query) {
|
||||||
|
async function getVqd(query) {
|
||||||
|
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': UA,
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if(!res.ok) return console.warn(`DuckDuckGo Search failed: ${res.status}`);
|
||||||
|
const html = await res.text();
|
||||||
|
const match = html.match(/vqd=["']([^"']+)["']/);
|
||||||
|
return match?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vqd = await getVqd(query);
|
||||||
|
if (!vqd) throw new Error('Could not get vqd token');
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 500));
|
||||||
|
const searchUrl = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${encodeURIComponent(vqd)}&f=,,,,,&p=1`;
|
||||||
|
const data = await fetch(searchUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': UA,
|
||||||
|
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
'Referer': `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`
|
||||||
|
}
|
||||||
|
}).then(async resp => {
|
||||||
|
if (!resp.ok) return console.warn(`DuckDuckGo image failed: ${resp.status}`);
|
||||||
|
const text = await resp.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
console.warn(`DuckDuckGo returned invalid JSON: ${text.slice(0, 200)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data?.results?.length) {
|
||||||
|
for (const result of data.results) {
|
||||||
|
if (!result.image) continue;
|
||||||
|
try {
|
||||||
|
const imgRes = await fetch(result.image, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
'User-Agent': UA,
|
||||||
|
'Referer': result.url || 'https://duckduckgo.com/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (imgRes.ok) return imgRes.blob();
|
||||||
|
console.warn(`Image fetch failed: ${imgRes.status} ${result.image}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Image fetch failed: ${result.image} - ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function genericImage(modelType) {
|
||||||
|
if (!modelType) return null;
|
||||||
|
|
||||||
|
const filePath = resolve(IMAGES_DIR, 'generic', `${modelType}.jpg`);
|
||||||
|
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
const blob = await duckduckgo(`${modelType} Aircraft`);
|
||||||
|
if (!blob) return null;
|
||||||
|
|
||||||
|
fs.mkdirSync(dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||||
|
const metadata = await sharp(buffer).metadata();
|
||||||
|
const width = metadata.width || 1200;
|
||||||
|
const height = metadata.height || 800;
|
||||||
|
const fontSize = Math.max(48, Math.round(Math.min(width, height) * 0.16));
|
||||||
|
const watermark = Buffer.from(`
|
||||||
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
|
||||||
|
font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}px"
|
||||||
|
font-weight="700" fill="white" fill-opacity="0.65"
|
||||||
|
stroke="black" stroke-opacity="0.35" stroke-width="${Math.max(2, Math.round(fontSize * 0.04))}"
|
||||||
|
transform="rotate(-20 ${width / 2} ${height / 2})">STOCK IMAGE</text>
|
||||||
|
</svg>`);
|
||||||
|
|
||||||
|
const watermarked = await sharp(buffer).composite([{ input: watermark }]).jpeg().toBuffer();
|
||||||
|
fs.writeFileSync(filePath, watermarked);
|
||||||
|
return watermarked;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function icaoImage(icao) {
|
||||||
|
const filePath = resolve(IMAGES_DIR, `${icao}.jpg`);
|
||||||
|
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
const planeSpotters = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao}`, {
|
||||||
|
headers: { 'User-Agent': 'open-sight.net (zaktimson@gmail.com)' }
|
||||||
|
}).then(resp => resp.ok ? resp.json() : null).catch(() => null);
|
||||||
|
|
||||||
|
const src = planeSpotters?.photos?.[0]?.thumbnail_large?.src || planeSpotters?.photos?.[0]?.thumbnail?.src;
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
const resp = await fetch(src, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
'User-Agent': UA,
|
||||||
|
'Referer': 'https://www.planespotters.net/'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
|
||||||
|
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
icao = icao.toLowerCase();
|
||||||
|
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||||
|
const aircraft = await enrich({ hex: icao });
|
||||||
|
const modelType = aircraft.aircraft || aircraft.model || aircraft.type;
|
||||||
|
const specific = await icaoImage(icao);
|
||||||
|
if (specific) return specific;
|
||||||
|
return genericImage(modelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge old history
|
||||||
|
setInterval(() => {
|
||||||
|
const cutoff = Date.now() - HISTORY_TTL;
|
||||||
|
for (const [key, trail] of history) {
|
||||||
|
const last = trail.at(-1);
|
||||||
|
if (!last || last.ts < cutoff) history.delete(key);
|
||||||
|
}
|
||||||
|
}, 1000 * 60 * 5);
|
||||||
+61
-1
@@ -1,4 +1,7 @@
|
|||||||
import {cfg} from './config.mjs';
|
import {cfg} from './config.mjs';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
|
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
|
||||||
const SENDER_TYPE = {
|
const SENDER_TYPE = {
|
||||||
@@ -8,6 +11,14 @@ const SENDER_TYPE = {
|
|||||||
12: 'AtoN', 13: 'SART/EPIRB'
|
12: 'AtoN', 13: 'SART/EPIRB'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DATA = resolve(DIR, '../data');
|
||||||
|
const IMAGES_DIR = resolve(DATA, 'ships');
|
||||||
|
|
||||||
|
let aisCache = null;
|
||||||
|
let aisCacheTs = 0;
|
||||||
|
const AIS_TTL = 1000;
|
||||||
|
|
||||||
function decodeMsgTypes(raw) {
|
function decodeMsgTypes(raw) {
|
||||||
const types = [];
|
const types = [];
|
||||||
for (let i = 0; i < 32; i++) {
|
for (let i = 0; i < 32; i++) {
|
||||||
@@ -38,6 +49,7 @@ function decodeFlags(flags) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAIS() {
|
export async function getAIS() {
|
||||||
|
if (aisCache && Date.now() - aisCacheTs < AIS_TTL) return aisCache;
|
||||||
const {ADSB_URL} = cfg();
|
const {ADSB_URL} = cfg();
|
||||||
if (!ADSB_URL) return {data: []};
|
if (!ADSB_URL) return {data: []};
|
||||||
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
|
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
|
||||||
@@ -45,7 +57,7 @@ export async function getAIS() {
|
|||||||
|
|
||||||
const staticMap = new Map(staticRows.map(row => [row[0], row]));
|
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,
|
const [mmsi, lat, lon, distance, bearing, heading,
|
||||||
cog, speed, status, rrsi, ppm, count, msg_type,
|
cog, speed, status, rrsi, ppm, count, msg_type,
|
||||||
last_signal, last_group, group_mask, flags, altitude,
|
last_signal, last_group, group_mask, flags, altitude,
|
||||||
@@ -79,4 +91,52 @@ export async function getAIS() {
|
|||||||
status
|
status
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
aisCacheTs = Date.now();
|
||||||
|
return aisCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAISImage(mmsi) {
|
||||||
|
if(!mmsi) return null;
|
||||||
|
mmsi = String(mmsi);
|
||||||
|
|
||||||
|
const filePath = resolve(IMAGES_DIR, `${mmsi}.jpg`);
|
||||||
|
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
function randomUA() {
|
||||||
|
const v = 36 + Math.floor(Math.random() * 40);
|
||||||
|
const builds = [
|
||||||
|
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||||
|
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||||
|
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||||
|
];
|
||||||
|
return builds[~~(Math.random() * builds.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
|
||||||
|
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
|
||||||
|
}).then(resp => {
|
||||||
|
if(resp.ok) return resp.text();
|
||||||
|
throw new Error('Failed to fetch vessel page');
|
||||||
|
});
|
||||||
|
|
||||||
|
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:${imgUrl}`;
|
||||||
|
if(imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`;
|
||||||
|
|
||||||
|
const resp = await fetch(imgUrl, {
|
||||||
|
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!resp.ok) throw new Error('Failed to fetch image');
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {adjustedInterval, Logger} from '@ztimson/utils';
|
import {adjustedInterval} from '@ztimson/utils';
|
||||||
|
|
||||||
const cacheOptions = {
|
const cacheOptions = {
|
||||||
ttl: null,
|
ttl: null,
|
||||||
@@ -12,7 +12,6 @@ export class CacheService {
|
|||||||
|
|
||||||
cached = null;
|
cached = null;
|
||||||
lastUpdate = null;
|
lastUpdate = null;
|
||||||
logger;
|
|
||||||
name;
|
name;
|
||||||
options;
|
options;
|
||||||
pending;
|
pending;
|
||||||
@@ -24,7 +23,6 @@ export class CacheService {
|
|||||||
ttl: options.reload,
|
ttl: options.reload,
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
this.logger = new Logger(name);
|
|
||||||
if(this.options.reload) setTimeout(() => this.startLoop(), 1000);
|
if(this.options.reload) setTimeout(() => this.startLoop(), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,16 +60,15 @@ export class CacheService {
|
|||||||
|
|
||||||
update(catchErr = true) {
|
update(catchErr = true) {
|
||||||
if(this.pending) return this.pending;
|
if(this.pending) return this.pending;
|
||||||
this.logger.info('Fetching latest');
|
console.log('🌌 Fetching Aurora');
|
||||||
this.pending = this.#fetchWithRetry().then(data => {
|
this.pending = this.#fetchWithRetry().then(data => {
|
||||||
if(data?.err) throw new Error(data.err?.stack || data.err?.message || data.err);
|
if(data?.err) throw new Error(data.err?.stack || data.err?.message || data.err);
|
||||||
if(data?.error) throw new Error(data.error?.stack || data.error?.message || data.error);
|
if(data?.error) throw new Error(data.error?.stack || data.error?.message || data.error);
|
||||||
this.lastUpdate = new Date();
|
this.lastUpdate = new Date();
|
||||||
this.cached = data || null;
|
this.cached = data || null;
|
||||||
this.logger.debug('Finished updating');
|
|
||||||
return {timestamp: this.lastUpdate, data: this.cached};
|
return {timestamp: this.lastUpdate, data: this.cached};
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
if(catchErr) this.logger.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
|
if(catchErr) console.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
|
||||||
else throw err;
|
else throw err;
|
||||||
}).finally(() => this.pending = null);
|
}).finally(() => this.pending = null);
|
||||||
return this.pending;
|
return this.pending;
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export function cfg() {
|
|||||||
return {
|
return {
|
||||||
PORT: process.env.PORT || 3000,
|
PORT: process.env.PORT || 3000,
|
||||||
ADSB_URL: process.env.ADSB_URL || '',
|
ADSB_URL: process.env.ADSB_URL || '',
|
||||||
|
TINYGS_URL: process.env.TINYGS_URL || '',
|
||||||
|
TINYGS_AUTH: process.env.TINYGS_AUTH || '',
|
||||||
|
TINYGS_MQTT_PORT: process.env.TINYGS_MQTT_PORT || 1883,
|
||||||
|
TINYGS_MQTT_USER: process.env.TINYGS_MQTT_USER || '',
|
||||||
|
TINYGS_MQTT_PASS: process.env.TINYGS_MQTT_PASS || '',
|
||||||
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
||||||
|
EMAIL: process.env.EMAIL,
|
||||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||||
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
|
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
|
||||||
import {getCelestialCurrent} from './celestial.mjs';
|
import {getCelestialCurrent} from './celestial.mjs';
|
||||||
import {localDateStr} from './config.mjs';
|
import {localDateStr} from './config.mjs';
|
||||||
|
import {frostRisk} from './openweather.mjs';
|
||||||
|
|
||||||
export let lastForecast = { ts: 0, slots: [], summary: null }
|
export let lastForecast = { ts: 0, slots: [], summary: null }
|
||||||
export const forecastTTL = 5 * 60 * 60_000
|
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.heat_index = Math.round(heatIndex(slot.temperature, slot.humidity) * 100) / 100;
|
||||||
slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity);
|
slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity);
|
||||||
slot.humidity_abs = absoluteHumidity(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
|
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
|
? 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: avg(fin('precipitation')),
|
||||||
precipitation_chance: Math.round(peak(fin('precipitation_chance'))),
|
precipitation_chance: Math.round(peak(fin('precipitation_chance'))),
|
||||||
accumulation: finAll('precipitation').reduce((a, b) => a + b, 0),
|
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_index: peak(fin('uv_index')),
|
||||||
uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0),
|
uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0),
|
||||||
solar_wm2: avg(fin('solar_wm2')),
|
solar_wm2: avg(fin('solar_wm2')),
|
||||||
|
|||||||
+112
-39
@@ -2,6 +2,9 @@ import { existsSync, mkdirSync } from 'fs'
|
|||||||
import { resolve, dirname } from 'path'
|
import { resolve, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { tzOffsetMinutes } from './config.mjs'
|
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 DIR = dirname(fileURLToPath(import.meta.url))
|
||||||
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
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)
|
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||||
if (!data?.daily?.time) return []
|
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 code = data.daily.weathercode[i]
|
||||||
const icon = OWM_ICONS[code] || '01d'
|
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 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 utcMidnight = new Date(`${time}T00:00:00Z`)
|
||||||
const offset = tzOffsetMinutes(utcMidnight)
|
const offset = tzOffsetMinutes(utcMidnight)
|
||||||
const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000)
|
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',
|
label: WMO[code] || 'Unknown',
|
||||||
icon,
|
icon,
|
||||||
code,
|
|
||||||
time: localMidnight,
|
|
||||||
temperature: temp,
|
temperature: temp,
|
||||||
temperature_max: temp,
|
temperature_max: tempMax,
|
||||||
temperature_min: data.daily.temperature_2m_min[i],
|
temperature_min: tempMin,
|
||||||
humidity,
|
humidity,
|
||||||
humidity_abs: absoluteHumidity(temp, humidity),
|
humidity_abs: absoluteHumidity(temp, humidity),
|
||||||
dew_point: dewPoint(temp, humidity),
|
dew_point: dewPoint(temp, humidity),
|
||||||
heat_index: heatIndex(temp, humidity),
|
heat_index: heatIndex(temp, humidity),
|
||||||
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
|
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
|
||||||
precipitation: data.daily.precipitation_sum[i],
|
pressure_hpa: null,
|
||||||
precipitation_chance: data.daily.precipitation_probability_max[i],
|
pressure_slp: null,
|
||||||
wind_speed: data.daily.windspeed_10m_max[i],
|
pressure_rate: null,
|
||||||
wind_gusts: data.daily.windgusts_10m_max[i],
|
storm_trend: 0,
|
||||||
wind_direction: data.daily.winddirection_10m_dominant[i],
|
wind_speed: windSpeed,
|
||||||
uv_index: uv,
|
wind_gusts: windGusts,
|
||||||
solar_wm2: data.daily.shortwave_radiation_sum[i],
|
wind_direction: data.daily.winddirection_10m_dominant[i] ?? 0,
|
||||||
clouds: null,
|
clouds,
|
||||||
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
raining: precipChance > 60 ? 'True' : 'False',
|
||||||
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||||
@@ -146,8 +185,6 @@ export async function hourlyWeather(lat, lon, start, end) {
|
|||||||
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||||
if (!data?.hourly?.time) return []
|
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 = {}
|
const sunMap = {}
|
||||||
if (data.daily?.time) {
|
if (data.daily?.time) {
|
||||||
data.daily.time.forEach((date, i) => {
|
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 dateKey = time.slice(0, 10)
|
||||||
const sun = sunMap[dateKey] ?? {}
|
const sun = sunMap[dateKey] ?? {}
|
||||||
const t = new Date(time + 'Z').getTime()
|
const t = new Date(time + 'Z').getTime()
|
||||||
const daytime = sun.sunrise && sun.sunset
|
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
|
: false
|
||||||
|
|
||||||
const code = data.hourly.weathercode[i]
|
const code = data.hourly.weathercode[i]
|
||||||
const icon = await ensureIcon(code).catch(() => null)
|
|
||||||
const temp = data.hourly.temperature_2m[i]
|
const temp = data.hourly.temperature_2m[i]
|
||||||
const humidity = data.hourly.relative_humidity_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 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'),
|
time: new Date(time + 'Z'),
|
||||||
daytime,
|
daytime,
|
||||||
label: WMO[code] || 'Unknown',
|
label: WMO[code] || 'Unknown',
|
||||||
icon,
|
icon: OWM_ICONS[code] || '01d',
|
||||||
code,
|
|
||||||
temperature: temp,
|
temperature: temp,
|
||||||
|
temperature_max: temp,
|
||||||
|
temperature_min: temp,
|
||||||
humidity,
|
humidity,
|
||||||
humidity_abs: absoluteHumidity(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),
|
heat_index: heatIndex(temp, humidity),
|
||||||
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
|
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
|
||||||
pressure_hpa: data.hourly.surface_pressure[i],
|
pressure_hpa: data.hourly.surface_pressure[i] ?? null,
|
||||||
precipitation: data.hourly.precipitation[i],
|
pressure_slp: null,
|
||||||
precipitation_chance: data.hourly.precipitation_probability[i],
|
pressure_rate: null,
|
||||||
wind_speed: data.hourly.windspeed_10m[i],
|
storm_trend: 0,
|
||||||
wind_gusts: data.hourly.windgusts_10m[i],
|
wind_speed: windSpeed,
|
||||||
wind_direction: data.hourly.winddirection_10m[i],
|
wind_gusts: data.hourly.windgusts_10m[i] ?? 0,
|
||||||
|
wind_direction: data.hourly.winddirection_10m[i] ?? 0,
|
||||||
|
clouds,
|
||||||
|
raining: precipChance > 60 ? 'True' : 'False',
|
||||||
|
precipitation: precip,
|
||||||
|
precipitation_chance: precipChance,
|
||||||
|
accumulation: precip,
|
||||||
|
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
|
||||||
uv_index: uv,
|
uv_index: 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
|
visibility: data.hourly.visibility[i] != null
|
||||||
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
|
? 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);
|
await downloadIcon(icon, iconPath);
|
||||||
return 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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import {cfg} from './config.mjs';
|
||||||
|
import {isEqual} from '@ztimson/utils';
|
||||||
|
import {randomUUID} from 'node:crypto';
|
||||||
|
|
||||||
|
const HISTORY_LIMIT = 500;
|
||||||
|
const POLL_MS = 5_000;
|
||||||
|
const TTL_MS = 3 * 60_000;
|
||||||
|
const MAX_PREDICTION_DT = 30;
|
||||||
|
const MAX_AZ_ERROR = 35;
|
||||||
|
const MAX_EL_ERROR = 20;
|
||||||
|
const MAX_GROUND_DISTANCE_KM = 1000;
|
||||||
|
const MAX_FREQ_ERROR_KHZ = 15;
|
||||||
|
|
||||||
|
const satellites = new Map();
|
||||||
|
|
||||||
|
function normalizeAzimuth(az) {
|
||||||
|
return ((az % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circularDistance(a, b) {
|
||||||
|
const diff = Math.abs(normalizeAzimuth(a) - normalizeAzimuth(b));
|
||||||
|
return Math.min(diff, 360 - diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function circularDelta(from, to) {
|
||||||
|
let delta = normalizeAzimuth(to) - normalizeAzimuth(from);
|
||||||
|
if (delta > 180) delta -= 360;
|
||||||
|
if (delta < -180) delta += 360;
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) *
|
||||||
|
Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateBearing(lat1, lon1, lat2, lon2) {
|
||||||
|
const φ1 = lat1 * Math.PI / 180;
|
||||||
|
const φ2 = lat2 * Math.PI / 180;
|
||||||
|
const λ = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
const y = Math.sin(λ) * Math.cos(φ2);
|
||||||
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ);
|
||||||
|
return normalizeAzimuth(Math.atan2(y, x) * 180 / Math.PI);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWm(raw) {
|
||||||
|
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
|
||||||
|
const [latitude, longitude] = (f[15] || '').split('/').map(s => parseFloat(s));
|
||||||
|
const [az, el] = (f[16] || '').split('/').map(s => parseFloat(s));
|
||||||
|
return {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
freqMHz: parseFloat(f[3]),
|
||||||
|
satellite: f[14],
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
az: normalizeAzimuth(az),
|
||||||
|
el,
|
||||||
|
packetRssi: parseFloat(f[21]),
|
||||||
|
packetSnr: parseFloat(f[22]),
|
||||||
|
freqError: parseFloat(f[23]),
|
||||||
|
crcOk: !/CRC ERROR/i.test(f[24]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrack(reading) {
|
||||||
|
const id = randomUUID();
|
||||||
|
const track = {
|
||||||
|
id,
|
||||||
|
name: reading.satellite,
|
||||||
|
history: [],
|
||||||
|
lastSeen: reading.timestamp,
|
||||||
|
state: {
|
||||||
|
az: reading.az,
|
||||||
|
el: reading.el,
|
||||||
|
latitude: reading.latitude,
|
||||||
|
longitude: reading.longitude,
|
||||||
|
freqError: reading.freqError,
|
||||||
|
azVelocity: 0,
|
||||||
|
elVelocity: 0,
|
||||||
|
latitudeVelocity: 0,
|
||||||
|
longitudeVelocity: 0,
|
||||||
|
groundSpeedKmh: 0,
|
||||||
|
heading: null,
|
||||||
|
freqErrorVelocity: 0,
|
||||||
|
confidence: 0.25,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
track.history.push(reading);
|
||||||
|
satellites.set(id, track);
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
function predictTrack(track, timestamp) {
|
||||||
|
const dtRaw = (timestamp - track.lastSeen) / 1000;
|
||||||
|
const dt = Math.max(0, Math.min(dtRaw, MAX_PREDICTION_DT));
|
||||||
|
const state = track.state;
|
||||||
|
return {
|
||||||
|
az: normalizeAzimuth(state.az + state.azVelocity * dt),
|
||||||
|
el: state.el + state.elVelocity * dt,
|
||||||
|
latitude: state.latitude + state.latitudeVelocity * dt,
|
||||||
|
longitude: state.longitude + state.longitudeVelocity * dt,
|
||||||
|
freqError: state.freqError + state.freqErrorVelocity * dt,
|
||||||
|
dt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTrack(track, reading) {
|
||||||
|
if(!track || !reading) return Infinity;
|
||||||
|
const dt = (reading.timestamp - track.lastSeen) / 1000;
|
||||||
|
if(dt <= 0 || dt > MAX_PREDICTION_DT) return Infinity;
|
||||||
|
if(track.name !== reading.satellite) return Infinity;
|
||||||
|
|
||||||
|
const predicted = predictTrack(track, reading.timestamp);
|
||||||
|
const azError = circularDistance(predicted.az, reading.az);
|
||||||
|
const elError = Math.abs(predicted.el - reading.el);
|
||||||
|
if(azError > MAX_AZ_ERROR || elError > MAX_EL_ERROR) return Infinity;
|
||||||
|
|
||||||
|
let groundDistance = 0;
|
||||||
|
if(Number.isFinite(reading.latitude) && Number.isFinite(reading.longitude) && Number.isFinite(predicted.latitude) && Number.isFinite(predicted.longitude)) {
|
||||||
|
groundDistance = haversineDistance(predicted.latitude, predicted.longitude, reading.latitude, reading.longitude);
|
||||||
|
if(groundDistance > MAX_GROUND_DISTANCE_KM) return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
let freqErrorDifference = 0;
|
||||||
|
if(Number.isFinite(reading.freqError) && Number.isFinite(predicted.freqError)) {
|
||||||
|
freqErrorDifference = Math.abs(reading.freqError - predicted.freqError);
|
||||||
|
if(freqErrorDifference > MAX_FREQ_ERROR_KHZ) return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (azError * 4 + elError * 5 + groundDistance * 0.03 + freqErrorDifference * 0.15); // Lower = better
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTrack(track, reading) {
|
||||||
|
const previous = track.history.at(-1);
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
track.history.push(reading);
|
||||||
|
track.lastSeen = reading.timestamp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = (reading.timestamp - previous.timestamp) / 1000;
|
||||||
|
if(dt <= 0) return;
|
||||||
|
|
||||||
|
const state = track.state;
|
||||||
|
const azVelocity = circularDelta(previous.az, reading.az) / dt;
|
||||||
|
const elVelocity = (reading.el - previous.el) / dt;
|
||||||
|
const latitudeVelocity = (reading.latitude - previous.latitude) / dt;
|
||||||
|
const longitudeVelocity = (reading.longitude - previous.longitude) / dt;
|
||||||
|
const distanceKm = haversineDistance(previous.latitude, previous.longitude, reading.latitude, reading.longitude);
|
||||||
|
const groundSpeedKmh = distanceKm / dt * 3600;
|
||||||
|
const heading = distanceKm > 0.01 ? calculateBearing(previous.latitude, previous.longitude, reading.latitude, reading.longitude) : state.heading;
|
||||||
|
const freqErrorVelocity = Number.isFinite(reading.freqError) && Number.isFinite(previous.freqError) ? (reading.freqError - previous.freqError) / dt : state.freqErrorVelocity;
|
||||||
|
const SMOOTHING = 0.35;
|
||||||
|
|
||||||
|
state.azVelocity = state.azVelocity * (1 - SMOOTHING) + azVelocity * SMOOTHING;
|
||||||
|
state.elVelocity = state.elVelocity * (1 - SMOOTHING) + elVelocity * SMOOTHING;
|
||||||
|
state.latitudeVelocity = state.latitudeVelocity * (1 - SMOOTHING) + latitudeVelocity * SMOOTHING;
|
||||||
|
state.longitudeVelocity = state.longitudeVelocity * (1 - SMOOTHING) + longitudeVelocity * SMOOTHING;
|
||||||
|
state.freqErrorVelocity = state.freqErrorVelocity * (1 - SMOOTHING) + freqErrorVelocity * SMOOTHING;
|
||||||
|
state.az = reading.az;
|
||||||
|
state.el = reading.el;
|
||||||
|
state.latitude = reading.latitude;
|
||||||
|
state.longitude = reading.longitude;
|
||||||
|
state.freqError = reading.freqError;
|
||||||
|
state.groundSpeedKmh = groundSpeedKmh;
|
||||||
|
state.heading = heading;
|
||||||
|
|
||||||
|
const prediction = predictTrack(track, reading.timestamp);
|
||||||
|
const predictionError = circularDistance(prediction.az, reading.az) + Math.abs(prediction.el - reading.el);
|
||||||
|
|
||||||
|
if(predictionError < 5) {
|
||||||
|
state.confidence = Math.min(1, state.confidence + 0.08);
|
||||||
|
} else if(predictionError < 15) {
|
||||||
|
state.confidence = Math.min(1, state.confidence + 0.03);
|
||||||
|
} else {
|
||||||
|
state.confidence = Math.max(0, state.confidence - 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
track.lastSeen = reading.timestamp;
|
||||||
|
if(!isEqual(track.history.at(-1), reading)) {
|
||||||
|
track.history.push(reading);
|
||||||
|
if(track.history.length > HISTORY_LIMIT) track.history.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneStale() {
|
||||||
|
const now = Date.now();
|
||||||
|
for(const [id, track] of satellites) {
|
||||||
|
if(now - track.lastSeen > TTL_MS) satellites.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestTrack(reading) {
|
||||||
|
let bestTrack = null;
|
||||||
|
let bestScore = Infinity;
|
||||||
|
for(const track of satellites.values()) {
|
||||||
|
const score = scoreTrack(track, reading);
|
||||||
|
if(score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestTrack = track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {track: bestTrack, score: bestScore,};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollTinyGS() {
|
||||||
|
const {TINYGS_URL, TINYGS_AUTH} = cfg();
|
||||||
|
try {
|
||||||
|
const raw = await fetch(TINYGS_URL + '/wm', {
|
||||||
|
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {},
|
||||||
|
}).then(r => r.text());
|
||||||
|
const reading = parseWm(raw);
|
||||||
|
if(!reading.satellite || reading.satellite === '-') return;
|
||||||
|
|
||||||
|
pruneStale();
|
||||||
|
const {track, score,} = findBestTrack(reading);
|
||||||
|
if(!track || !Number.isFinite(score)) {
|
||||||
|
createTrack(reading);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTrack(track, reading);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[tinygs] poll failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(pollTinyGS, POLL_MS);
|
||||||
|
|
||||||
|
export const getTinyGSData = () => {
|
||||||
|
pruneStale();
|
||||||
|
return Array.from(satellites.values()).map(track => {
|
||||||
|
const latest = track.history.at(-1);
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
satellite: track.name,
|
||||||
|
...latest,
|
||||||
|
velocity: {
|
||||||
|
v: track.state.groundSpeedKmh,
|
||||||
|
az: track.state.azVelocity,
|
||||||
|
el: track.state.elVelocity,
|
||||||
|
},
|
||||||
|
heading: track.state.heading,
|
||||||
|
history: track.history.slice(0, -1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
+14
-4
@@ -1,4 +1,5 @@
|
|||||||
import {cfg} from './config.mjs';
|
import {cfg} from './config.mjs';
|
||||||
|
import {frostRisk} from './openweather.mjs';
|
||||||
|
|
||||||
async function query(c, q, start, end) {
|
async function query(c, q, start, end) {
|
||||||
const url = new URL('/api/v1/query_range', c.DB_HOST);
|
const url = new URL('/api/v1/query_range', c.DB_HOST);
|
||||||
@@ -30,9 +31,15 @@ function metricToFields(results) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function queryCurrent() {
|
export async function queryCurrent() {
|
||||||
const c = cfg();
|
const c = cfg()
|
||||||
const results = await queryInstant(c, `{__name__!=""}`);
|
const results = await queryInstant(c, `{__name__!=""}`)
|
||||||
return {time: new Date(), ...metricToFields(results)};
|
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) {
|
export async function queryHourly(start, end) {
|
||||||
@@ -101,11 +108,14 @@ export async function queryDaily(start, end) {
|
|||||||
|
|
||||||
export async function getCoords() {
|
export async function getCoords() {
|
||||||
const c = cfg();
|
const c = cfg();
|
||||||
|
if(c.LATITUDE !== 0 && c.LONGITUDE !== 0 && c.ALTITUDE !== 0)
|
||||||
|
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||||
|
|
||||||
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
||||||
const fields = metricToFields(results);
|
const fields = metricToFields(results);
|
||||||
|
|
||||||
if(fields.latitude && fields.longitude) {
|
if(fields.latitude && fields.longitude) {
|
||||||
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||||
}
|
}
|
||||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
return {latitude: 0, longitude: 0, altitude: 0};
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-34
@@ -7,11 +7,15 @@ import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
|||||||
import {apiReference} from '@scalar/express-api-reference';
|
import {apiReference} from '@scalar/express-api-reference';
|
||||||
import {spec} from './spec.mjs';
|
import {spec} from './spec.mjs';
|
||||||
import {existsSync} from 'fs';
|
import {existsSync} from 'fs';
|
||||||
import {getAIS} from './ais.mjs';
|
import {getAIS, getAISImage} from './ais.mjs';
|
||||||
import {getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs';
|
import {getIcaoImage, get, getHistory, getAttenuation, initAircraftDb} from './adsb.mjs';
|
||||||
import {fetchIcon} from './openweather.mjs';
|
import {fetchIcon} from './openweather.mjs';
|
||||||
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||||
|
import {getTinyGSData} from './sats.mjs';
|
||||||
|
import {getSpaceWeather} from './space.mjs';
|
||||||
|
import {Aurora} from './aurora.mjs';
|
||||||
|
import {getSondes} from './sonde.mjs';
|
||||||
|
|
||||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ function filterArr(arr, fields) {
|
|||||||
return arr.map(row => filterFields(row, fields));
|
return arr.map(row => filterFields(row, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Current ───────────────────────────────────────────────────────────────────
|
// ── Weather ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/current', asyncHandler(async (req, res) => {
|
app.get('/api/current', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
@@ -71,51 +75,61 @@ app.get('/api/current', asyncHandler(async (req, res) => {
|
|||||||
res.json(filterFields(merged, fields))
|
res.json(filterFields(merged, fields))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const coords = await getCoords()
|
const coords = await getCoords()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
now.setMinutes(0, 0, 0)
|
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 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([
|
const [history, forecast24, meteo] = await Promise.all([
|
||||||
start.getTime() < now.getTime() ? queryHourly(start, now) : Promise.resolve([]),
|
start < now
|
||||||
end.getTime() > now.getTime() ? getForecast().then(f => f.slots.filter(h => new Date(h.time) <= end)) : Promise.resolve([]),
|
? queryHourly(start, now)
|
||||||
end.getTime() > forecastLimit.getTime() ? hourlyWeather(coords.latitude, coords.longitude, forecastLimit, end) : Promise.resolve([]),
|
: 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])
|
const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo])
|
||||||
res.json(filterArr(hourly, fields))
|
res.json(filterArr(combined, fields))
|
||||||
}));
|
}))
|
||||||
|
|
||||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get('/api/daily', asyncHandler(async (req, res) => {
|
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const coords = await getCoords();
|
const coords = await getCoords()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayStart = new Date(now)
|
const todayStart = new Date(now)
|
||||||
todayStart.setHours(0, 0, 0, 0)
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
const todayEnd = new Date(todayStart)
|
const todayEnd = new Date(todayStart)
|
||||||
todayEnd.setDate(todayStart.getDate() + 1)
|
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 end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
|
||||||
|
|
||||||
const [history, current, future] = Promise.all([
|
const [history, current, future] = await Promise.all([
|
||||||
start.getTime() < todayStart.getTime() ? queryDaily(start, todayStart) : Promise.resolve([]), // Historic
|
start < todayStart
|
||||||
start.getTime() <= todayStart.getTime() ? queryCurrent().then(resp => [resp]) : Promise.resolve([]), // Current
|
? queryDaily(start, todayStart)
|
||||||
start.getTime() >= todayEnd.getTime() ? dailyWeather(coords.latitude, coords.longitude, todayStart, end) : Promise.resolve([]), // Future
|
: Promise.resolve([]),
|
||||||
]);
|
start <= todayStart && end >= todayStart
|
||||||
const daily = [...history, ...current, ...getCelestialForecast(coords.latitude, coords.longitude, future)];
|
? 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))
|
res.json(filterArr(daily, fields))
|
||||||
}));
|
}))
|
||||||
|
|
||||||
// ── Position ──────────────────────────────────────────────────────────────────
|
// ── Position ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getAttenuation())));
|
||||||
app.get('/api/position', asyncHandler(async (req, res) => {
|
app.get('/api/position', asyncHandler(async (req, res) => {
|
||||||
const {fields} = req.query;
|
const {fields} = req.query;
|
||||||
const data = await getCoords();
|
const data = await getCoords();
|
||||||
@@ -133,18 +147,30 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
|||||||
|
|
||||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
||||||
// app.get('/api/space', async (req, res) => {
|
app.get('/api/space', async (req, res) => {
|
||||||
// const {fields} = req.query;
|
const {fields} = req.query;
|
||||||
// res.json(filterFields(await getSpaceWeather(), fields));
|
res.json(filterFields(await getSpaceWeather(), fields));
|
||||||
// });
|
});
|
||||||
|
|
||||||
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
// ── ADSB/AIS/SAT/RadioSonde Proxy ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await get())));
|
||||||
|
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
|
||||||
|
const buffer = await getIcaoImage(req.params.icao);
|
||||||
|
res.contentType('image/jpeg').send(buffer)
|
||||||
|
}));
|
||||||
|
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getHistory(req.params.icao))));
|
||||||
|
|
||||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
|
||||||
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', asyncHandler(async (req, res) => res.json(await getAIS())));
|
||||||
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
app.get('/api/ais/:mmsi/image', asyncHandler(async (req, res) => {
|
||||||
|
const buffer = await getAISImage(req.params.mmsi);
|
||||||
|
res.contentType('image/jpeg').send(buffer)
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/sondes', asyncHandler(async (req, res) => res.json(await getSondes())));
|
||||||
|
|
||||||
|
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
|
||||||
|
|
||||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+49
-124
@@ -10,17 +10,16 @@ export const spec = {
|
|||||||
'/api/position': {
|
'/api/position': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'Station position',
|
summary: 'Station position',
|
||||||
description: 'Latest GPS position and info',
|
description: 'Latest GPS position and altitude',
|
||||||
parameters: [ ],
|
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: 'Last known position',
|
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: {
|
get: {
|
||||||
summary: 'Current conditions',
|
summary: 'Current conditions',
|
||||||
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
|
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: {
|
Position: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
lat: { type: 'number', description: 'Latitude' },
|
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||||
lon: { type: 'number', description: 'Longitude' },
|
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||||
alt: { type: 'number', description: 'Altitude' },
|
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
DataRow: {
|
DataRow: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
time: { type: 'string', description: 'ISO8601 timestamp or date' },
|
time: { type: 'string', format: 'date-time', description: 'ISO8601 timestamp' },
|
||||||
// Environment
|
// Environment
|
||||||
temperature: { type: 'number', description: 'Temperature (°C)' },
|
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_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
|
||||||
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
|
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
|
||||||
humidity: { type: 'number', description: 'Relative humidity (%)' },
|
humidity: { type: 'number', description: 'Relative humidity (%)' },
|
||||||
|
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
||||||
dew_point: { type: 'number', description: 'Dew point (°C)' },
|
dew_point: { type: 'number', description: 'Dew point (°C)' },
|
||||||
heat_index: { type: 'number', description: 'Feels like / heat index (°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_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||||
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||||
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||||
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' },
|
||||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
|
||||||
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
|
|
||||||
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
||||||
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
||||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
|
// Light & Solar
|
||||||
// Light
|
|
||||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||||
uv_index: { type: 'number', description: 'UV index' },
|
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' },
|
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||||
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
|
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||||
// Wind (Open-Meteo)
|
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||||
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
|
daily_light_integral: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||||
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
|
clouds: { type: 'number', description: 'Cloud cover fraction (0–1)' },
|
||||||
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' },
|
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||||
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' },
|
daylight: { type: 'number', description: 'Hours of daylight' },
|
||||||
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' },
|
// Weather
|
||||||
// Forecast
|
label: { type: 'string', description: 'Human readable weather label' },
|
||||||
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
|
icon: { type: 'string', description: 'OWM icon code e.g. 01d' },
|
||||||
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
|
raining: { type: 'string', enum: ['True','False'] },
|
||||||
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. 01d' },
|
precipitation: { type: 'number', description: 'Precipitation (mm)' },
|
||||||
forecast_precipitation_mm: { type: 'number', description: 'Precipitation (mm)' },
|
precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' },
|
||||||
forecast_precipitation_probability: { type: 'number', description: 'Precipitation probability (%)' },
|
accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' },
|
||||||
forecast_snowfall_mm: { type: 'number', description: 'Snowfall (mm)' },
|
storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' },
|
||||||
forecast_snow_depth_m: { type: 'number', description: 'Snow depth (m)' },
|
// Wind
|
||||||
forecast_cape: { type: 'number', description: 'Convective available potential energy (J/kg)' },
|
wind_speed: { type: 'number', description: 'Wind speed (km/h)' },
|
||||||
forecast_freezing_level_m: { type: 'number', description: 'Freezing level altitude (m)' },
|
wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||||
forecast_evapotranspiration_mm:{ type: 'number', description: 'Evapotranspiration (mm)' },
|
wind_direction: { type: 'number', description: 'Wind direction (°)' },
|
||||||
// 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' },
|
|
||||||
// Lightning
|
// Lightning
|
||||||
|
lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||||
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||||
lightning_energy: { type: 'number', description: 'Lightning energy' },
|
// Sun
|
||||||
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||||
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
|
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||||
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
|
sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' },
|
||||||
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (0–7)' },
|
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
|
// GPS
|
||||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
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