Compare commits
| 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 | ||
|
|
73f7908aab | ||
|
|
effdd81114 | ||
|
|
6bc4d920f8 | ||
|
|
d9fbe0568d | ||
|
|
404f3971c4 | ||
|
|
570ed1e749 | ||
|
|
8364918a0c | ||
|
|
85909e550b | ||
|
|
221a200e79 | ||
|
|
bb11b92ec9 | ||
|
|
579eed746e | ||
|
|
a4f13d87ab | ||
|
|
85b1d66c02 | ||
|
|
d34f3cfa0e | ||
|
|
f3356ff05c | ||
|
|
c18a4189af | ||
|
|
c5477d05ad | ||
|
|
bea55ac448 | ||
|
|
765335369c | ||
|
|
3944daa3ed | ||
|
|
e767a08bb5 | ||
|
|
e4c3da05be | ||
|
|
44e648ecef | ||
|
|
e4042f06d6 | ||
|
|
943a6ee085 | ||
|
|
225002046b | ||
|
|
9e7afbd98c | ||
|
|
62d2c28039 | ||
|
|
74c5e48d8a | ||
|
|
ce332be419 | ||
|
|
36eda62e7f | ||
|
|
6eefd1b86b | ||
|
|
0c0ebfd20b | ||
|
|
74ddb37446 | ||
|
|
73f1453daf | ||
|
|
aeffa30417 | ||
|
|
74a8018f01 | ||
|
|
efd08cbabe | ||
|
|
738f13e745 | ||
|
|
216420ad80 | ||
|
|
5947b61b8f | ||
|
|
06be7c0b4f | ||
|
|
70701483f5 | ||
|
|
1ee386949c | ||
|
|
9b7b7f49a7 | ||
|
|
80b9b1bfcc | ||
|
|
b87bcbabb7 | ||
|
|
f6f8a252dc | ||
|
|
a73c5ff95b |
@@ -1,4 +1,4 @@
|
|||||||
INFLUX_TOKEN=
|
EMAIL=
|
||||||
ALTITUDE=
|
ALTITUDE=
|
||||||
LATITUDE=
|
LATITUDE=
|
||||||
LONGITUDE=
|
LONGITUDE=
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
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: /
|
||||||
|
After Width: | Height: | Size: 30 KiB |
@@ -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 |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #ffffff;
|
--bg: #ffffff;
|
||||||
--surface: #d0dae1;
|
--surface: #e5e9ec;
|
||||||
--border: #e2e8f0;
|
--border: #e2e8f0;
|
||||||
--text: #0f172a;
|
--text: #0f172a;
|
||||||
--text-muted: #64748b;
|
--text-muted: #64748b;
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, onMounted} from 'vue';
|
import {useWeather} from '../services/api';
|
||||||
import {api} from '../services/api';
|
|
||||||
import MetricRow from './MetricRow.vue';
|
import MetricRow from './MetricRow.vue';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
const {current: d} = useWeather();
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import AircraftAltitude from '@/components/Altitude.vue'
|
||||||
|
|
||||||
|
const BASE_SPEED_UNITS = {
|
||||||
|
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||||
|
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
||||||
|
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
||||||
|
}
|
||||||
|
const BASE_ALTITUDE_UNITS = {
|
||||||
|
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
||||||
|
feet: { label: 'FT', convert: (v: number) => v },
|
||||||
|
}
|
||||||
|
const BASE_VERTICAL_UNITS = {
|
||||||
|
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
||||||
|
fps: { label: 'FT/S', convert: (v: number) => v },
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoError = ref(false);
|
||||||
|
const UNIT_KEY = 'at_units'
|
||||||
|
function loadUnits() {
|
||||||
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
|
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ plane: any; position: { x: number; y: number }; history?: any[] }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
const prefs = ref(loadUnits())
|
||||||
|
function saveAndSet(p: any) {
|
||||||
|
prefs.value = { ...p }
|
||||||
|
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||||
|
}
|
||||||
|
function cycleSpeed() { const keys = Object.keys(BASE_SPEED_UNITS); const p = loadUnits(); p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]; saveAndSet(p) }
|
||||||
|
function cycleAltitude() { const keys = Object.keys(BASE_ALTITUDE_UNITS); const p = loadUnits(); p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]; p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'; saveAndSet(p) }
|
||||||
|
function cycleVertical() { const keys = Object.keys(BASE_VERTICAL_UNITS); const p = loadUnits(); p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]; p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'; saveAndSet(p) }
|
||||||
|
|
||||||
|
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
|
||||||
|
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
|
||||||
|
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
|
||||||
|
|
||||||
|
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
|
||||||
|
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
|
||||||
|
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
|
||||||
|
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
|
||||||
|
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
||||||
|
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
|
||||||
|
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || props.plane.aircraft || '')
|
||||||
|
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
||||||
|
|
||||||
|
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 isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
function onMouseUp() { isDragging.value = false }
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX, sy = touch.clientY
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||||
|
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
|
||||||
|
const navball = computed(() => {
|
||||||
|
const data = props.plane
|
||||||
|
const heading = data.heading ?? data.track ?? 0
|
||||||
|
const airspeedKnots = data.speed || data.gs || 0
|
||||||
|
const vertRateFps = data.climb || data.baro_rate || 0
|
||||||
|
const groundSpeedFps = airspeedKnots * 1.68781
|
||||||
|
const pitchRad = groundSpeedFps > 0 ? Math.atan(vertRateFps / groundSpeedFps) : 0
|
||||||
|
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
||||||
|
|
||||||
|
const pitchLines: string[] = []
|
||||||
|
for (let p = -30; p <= 30; p += 5) {
|
||||||
|
if (p === 0) continue
|
||||||
|
const isMajor = p % 10 === 0
|
||||||
|
const [start, end] = [85, 115]
|
||||||
|
if (isMajor) {
|
||||||
|
pitchLines.push(`
|
||||||
|
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||||
|
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||||
|
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||||
|
`)
|
||||||
|
} else {
|
||||||
|
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollTicks: string[] = []
|
||||||
|
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
|
||||||
|
const rad = angle * Math.PI / 180
|
||||||
|
const a = Math.abs(angle)
|
||||||
|
if (a === 45) {
|
||||||
|
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
|
||||||
|
} else {
|
||||||
|
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
|
||||||
|
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
|
||||||
|
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
|
||||||
|
const rad = offset * Math.PI / 180
|
||||||
|
const x = 100 + 70 * Math.sin(rad)
|
||||||
|
const y = 100 - 70 * Math.cos(rad)
|
||||||
|
const h = Math.round((offset + 360) % 360)
|
||||||
|
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
|
||||||
|
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="200" height="220" viewBox="0 0 200 220">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
|
||||||
|
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1e90ff"/>
|
||||||
|
<stop offset="100%" style="stop-color:#4169e1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#8b4513"/>
|
||||||
|
<stop offset="100%" style="stop-color:#654321"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
||||||
|
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
||||||
|
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
||||||
|
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
||||||
|
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
||||||
|
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
||||||
|
${pitchLines.join('')}
|
||||||
|
</g>
|
||||||
|
<g transform="translate(0,25)">${rollTicks.join('')}</g>
|
||||||
|
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
|
||||||
|
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||||
|
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||||
|
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
|
||||||
|
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
|
||||||
|
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aircraft-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(0, 255, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-callsign {
|
||||||
|
margin: 0 24px 0 0;
|
||||||
|
color: #7eeee1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
|
||||||
|
|
||||||
|
.ap-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
.ap-photo-wrap {
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 160px;
|
||||||
|
background: #111;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
|
||||||
|
|
||||||
|
.ap-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-gauges { flex: 0 0 110px; }
|
||||||
|
.ap-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.ap-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #0f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.ap-gauge:hover { border-color: #0ff; }
|
||||||
|
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
||||||
|
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
||||||
|
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<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,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
|
import {getPressureTrend} from '@/services/units.ts';
|
||||||
import {inject, onMounted, ref} from 'vue';
|
import {inject, onMounted, ref} from 'vue';
|
||||||
import {api} from '../services/api';
|
import {api, BASE} from '../services/api';
|
||||||
|
|
||||||
const data = ref<any>();
|
const data = ref<any>();
|
||||||
const openGraph = inject<(key: string) => void>('openGraph');
|
const openGraph = inject<(key: string) => void>('openGraph');
|
||||||
@@ -80,7 +81,7 @@ onMounted(async () => {
|
|||||||
<div class="flex-r align-items-center justify-between">
|
<div class="flex-r align-items-center justify-between">
|
||||||
<div v-if="!data" class="pos-rel br-2 overflow-hidden" style="width: 75px; height: 50px"><Loading/></div>
|
<div v-if="!data" class="pos-rel br-2 overflow-hidden" style="width: 75px; height: 50px"><Loading/></div>
|
||||||
<div v-else class="flex-c justify-center align-items-center mx-3">
|
<div v-else class="flex-c justify-center align-items-center mx-3">
|
||||||
<img :src="'https://openweathermap.org/img/wn/' + data.icon + '@2x.png'" class="today-icon" alt="weather" />
|
<img :src="BASE + '/api/icon/' + data.icon" class="today-icon" alt="weather" />
|
||||||
<div class="today-label">{{ data.label }}</div>
|
<div class="today-label">{{ data.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-r align-items-center gap-2">
|
<div class="flex-r align-items-center gap-2">
|
||||||
@@ -107,52 +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) }}°C</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 class="fg-muted">({{ data.humidity_abs.toFixed(2) }})</span></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?.('air_quality')">
|
<template v-else>{{ Math.round(data.clouds) }}<span class="fg-muted">%</span></template>
|
||||||
<span class="stat-label">Air Quality</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.air_quality }} <span class="fg-muted">({{ data.air_quality_label }})</span></span>
|
|
||||||
</div>
|
|
||||||
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
|
||||||
<span class="stat-label">Gas Resistance</span>
|
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
|
||||||
<span v-else class="stat-value">{{ data.air_quality_ohms }} <span class="fg-muted">Ω</span></span>
|
|
||||||
</div>
|
|
||||||
<div class="stat" @click="openGraph?.('visibility')">
|
|
||||||
<span class="stat-label">Visibility</span>
|
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
|
||||||
<span v-else class="stat-value">{{ data.visibility?.toFixed(1) }} km</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider" />
|
|
||||||
|
|
||||||
<div class="stat-grid">
|
|
||||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
|
||||||
<span class="stat-label">Pressure</span>
|
|
||||||
<div v-if="!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">({{ (data.pressure_rate > 0 ? '+' : '') + data.pressure_rate }})</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">({{ 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>
|
<span v-else class="stat-value">
|
||||||
|
<template v-if="data.vapor_pressure_deficit == null">—</template>
|
||||||
|
<template v-else>{{ data.vapor_pressure_deficit?.toFixed(2) }} <span class="fg-muted">kPa</span></template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat" @click="openGraph?.('air_quality')">
|
||||||
|
<span class="stat-label">Air Quality</span>
|
||||||
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||||
|
<span v-else class="stat-value">
|
||||||
|
<template v-if="data.air_quality == null">—</template>
|
||||||
|
<template v-else>{{ data.air_quality }} <span class="fg-muted">/ 500</span></template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
||||||
|
<span class="stat-label">Gas Resistance</span>
|
||||||
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||||
|
<span v-else class="stat-value">
|
||||||
|
<template v-if="data.air_quality_ohms == null">—</template>
|
||||||
|
<template v-else>{{ data.air_quality_ohms }} <span class="fg-muted">Ω</span></template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat" @click="openGraph?.('visibility')">
|
||||||
|
<span class="stat-label">Visibility</span>
|
||||||
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||||
|
<span v-else class="stat-value">
|
||||||
|
<template v-if="data.visibility == null">—</template>
|
||||||
|
<template v-else>{{ data.visibility?.toFixed(1) }} <span class="fg-muted">km</span></template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,21 +67,20 @@ onMounted(async () => {
|
|||||||
<div v-if="days?.length" class="strip">
|
<div v-if="days?.length" class="strip">
|
||||||
<div v-for="d in days" class="day">
|
<div v-for="d in days" class="day">
|
||||||
<div class="day-name">{{ formatDate('ddd, MMM D', d.time) }}</div>
|
<div class="day-name">{{ formatDate('ddd, MMM D', d.time) }}</div>
|
||||||
<img :src="BASE + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
|
<img :src="BASE + '/api/icon/' + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
|
||||||
<div class="day-label">{{ d.label }}</div>
|
<div class="day-label">{{ d.label }}</div>
|
||||||
<div class="day-temps">
|
<div class="day-temps">
|
||||||
<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">
|
||||||
<div v-for="d in filler" class="day">
|
<div v-for="d in filler" class="day">
|
||||||
<div class="day-name w-100 br-2 overflow-hidden pos-rel"><Loading /></div>
|
<div class="br-2 overflow-hidden pos-rel my-2" style="height: 50px; width: 50px;"><Loading /></div>
|
||||||
<div class="br-2 overflow-hidden pos-rel mb-2" style="height: 80px; width: 80px;"><Loading /></div>
|
|
||||||
<div class="day-temps mb-2">
|
<div class="day-temps mb-2">
|
||||||
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
||||||
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watch} from 'vue';
|
import {computed, nextTick, ref, watch} from 'vue';
|
||||||
import {api} from '../services/api';
|
import {api} from '../services/api';
|
||||||
|
|
||||||
const props = defineProps<{metricKey: string | null}>();
|
const props = defineProps<{metricKey: string | null}>();
|
||||||
@@ -7,20 +7,52 @@ const emit = defineEmits<{(e: 'close'): void}>();
|
|||||||
|
|
||||||
const mode = ref<'hourly' | 'daily'>('hourly');
|
const mode = ref<'hourly' | 'daily'>('hourly');
|
||||||
const rows = ref<any[]>([]);
|
const rows = ref<any[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedPoint = ref<{t: number, v: number} | null>(null);
|
||||||
const W = 600, H = 200, PAD = 30;
|
const W = 600, H = 200, PAD = 30;
|
||||||
|
|
||||||
watch([() => props.metricKey, mode], async ([key]) => {
|
const customStart = ref('');
|
||||||
if(!key) return;
|
const customEnd = ref('');
|
||||||
|
|
||||||
|
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||||
|
function toDateTimeLocal(d: Date) {
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
function toDateLocal(d: Date) {
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaults() {
|
||||||
const past = new Date(), future = new Date();
|
const past = new Date(), future = new Date();
|
||||||
if(mode.value === 'hourly') {
|
if(mode.value === 'hourly') {
|
||||||
past.setHours(past.getHours() - 24);
|
past.setHours(past.getHours() - 24, 0, 0, 0);
|
||||||
future.setHours(future.getHours() + 24);
|
future.setHours(future.getHours() + 24, 0, 0, 0);
|
||||||
rows.value = await api.hourly(past, future);
|
customStart.value = toDateTimeLocal(past);
|
||||||
|
customEnd.value = toDateTimeLocal(future);
|
||||||
} else {
|
} else {
|
||||||
past.setDate(past.getDate() - 3);
|
past.setDate(past.getDate() - 3);
|
||||||
future.setDate(future.getDate() + 3);
|
future.setDate(future.getDate() + 3);
|
||||||
rows.value = await api.daily(past, future);
|
customStart.value = toDateLocal(past);
|
||||||
|
customEnd.value = toDateLocal(future);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
if(!props.metricKey) return;
|
||||||
|
selectedPoint.value = null;
|
||||||
|
loading.value = true;
|
||||||
|
const past = new Date(customStart.value);
|
||||||
|
const future = new Date(customEnd.value);
|
||||||
|
rows.value = mode.value === 'hourly'
|
||||||
|
? await api.hourly(past, future, `time,${props.metricKey}`)
|
||||||
|
: await api.daily(past, future, `time,${props.metricKey}`);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([() => props.metricKey, mode], async () => {
|
||||||
|
setDefaults();
|
||||||
|
await nextTick();
|
||||||
|
fetchData();
|
||||||
}, {immediate: true});
|
}, {immediate: true});
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -30,67 +62,78 @@ const points = computed(() => {
|
|||||||
return rows.value.map(r => ({
|
return rows.value.map(r => ({
|
||||||
t: new Date(r.time as string).getTime(),
|
t: new Date(r.time as string).getTime(),
|
||||||
v: r[props.metricKey!] as number | null,
|
v: r[props.metricKey!] as number | null,
|
||||||
})).filter(p => p.v != null);
|
})).filter(p => p.v != null) as {t: number, v: number}[];
|
||||||
});
|
});
|
||||||
|
|
||||||
const chart = computed(() => {
|
const chart = computed(() => {
|
||||||
const pts = points.value;
|
const pts = points.value;
|
||||||
if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:{hist:'',fut:''}, currentX:0, dot:null as any, yLabels:[] as any[]};
|
if(!pts.length) return {path: {hist:'', fut:''}, currentX: 0, dot: null as any, yLabels: [] as any[], xLabels: [] as any[], dotPoints: [] as any[]};
|
||||||
|
|
||||||
const ts = pts.map(p => p.t), vs = pts.map(p => p.v as number);
|
const ts = pts.map(p => p.t), vs = pts.map(p => p.v);
|
||||||
const minT = Math.min(...ts), maxT = Math.max(...ts);
|
const minT = Math.min(...ts), maxT = Math.max(...ts);
|
||||||
const rawMin = Math.min(...vs), rawMax = Math.max(...vs);
|
const rawMin = Math.min(...vs), rawMax = Math.max(...vs);
|
||||||
const pad = (rawMax - rawMin) * 0.1 || 1;
|
const vpad = (rawMax - rawMin) * 0.1 || 1;
|
||||||
const minV = rawMin - pad, maxV = rawMax + pad;
|
const minV = rawMin - vpad, maxV = rawMax + vpad;
|
||||||
|
|
||||||
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
||||||
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
||||||
const nowT = now.getTime();
|
const nowT = now.getTime();
|
||||||
const currentX = Math.max(PAD, Math.min(W - PAD, xOf(nowT)));
|
const currentX = Math.max(PAD, Math.min(W - PAD, xOf(nowT)));
|
||||||
|
|
||||||
|
// hist/fut always split at nowT — never affected by selected point
|
||||||
const hist = pts.filter(p => p.t <= nowT);
|
const hist = pts.filter(p => p.t <= nowT);
|
||||||
const fut = pts.filter(p => p.t >= nowT);
|
const fut = pts.filter(p => p.t >= nowT);
|
||||||
|
// stitch the join so lines meet cleanly at the now-line
|
||||||
// stitch current point into both paths so lines meet
|
const joinPt = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||||
const nearest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
if(!hist.length || hist.at(-1)!.t !== joinPt.t) hist.push(joinPt);
|
||||||
const dot = {x: xOf(nearest.t), y: yOf(nearest.v as number), v: nearest.v};
|
if(!fut.length || fut[0]!.t !== joinPt.t) fut.unshift(joinPt);
|
||||||
|
|
||||||
const toPath = (arr: typeof pts) =>
|
const toPath = (arr: typeof pts) =>
|
||||||
arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
|
arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v).toFixed(1)}`).join(' ');
|
||||||
|
|
||||||
|
// selected dot — default to nearest point to now
|
||||||
|
const activePt = selectedPoint.value
|
||||||
|
? pts.reduce((a, b) => Math.abs(a.t - selectedPoint.value!.t) < Math.abs(b.t - selectedPoint.value!.t) ? a : b)
|
||||||
|
: pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||||
|
const dot = {x: xOf(activePt.t), y: yOf(activePt.v), v: activePt.v, t: activePt.t};
|
||||||
|
|
||||||
// y-axis labels (5 ticks)
|
|
||||||
const yLabels = Array.from({length: 5}, (_, i) => {
|
const yLabels = Array.from({length: 5}, (_, i) => {
|
||||||
const frac = i / 4;
|
const v = minV + (i / 4) * (maxV - minV);
|
||||||
const v = minV + frac * (maxV - minV);
|
|
||||||
return {y: yOf(v), label: v.toFixed(1)};
|
return {y: yOf(v), label: v.toFixed(1)};
|
||||||
});
|
});
|
||||||
|
|
||||||
// x-axis labels
|
|
||||||
const xLabels: {x: number, label: string}[] = [];
|
const xLabels: {x: number, label: string}[] = [];
|
||||||
const span = maxT - minT;
|
|
||||||
const tickCount = mode.value === 'hourly' ? 9 : 7;
|
const tickCount = mode.value === 'hourly' ? 9 : 7;
|
||||||
for(let i = 0; i <= tickCount; i++) {
|
for(let i = 0; i <= tickCount; i++) {
|
||||||
const t = minT + (i / tickCount) * span;
|
const t = minT + (i / tickCount) * (maxT - minT);
|
||||||
const d = new Date(t);
|
const d = new Date(t);
|
||||||
const label = mode.value === 'hourly'
|
xLabels.push({x: xOf(t), label: mode.value === 'hourly'
|
||||||
? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
||||||
: d.toLocaleDateString([], {month: 'short', day: 'numeric'});
|
: d.toLocaleDateString([], {month: 'short', day: 'numeric'})});
|
||||||
xLabels.push({x: xOf(t), label});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {minV, maxV, path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels};
|
const dotPoints = pts.map(p => ({x: xOf(p.t), y: yOf(p.v), t: p.t, v: p.v}));
|
||||||
|
|
||||||
|
return {path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels, dotPoints};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onSvgClick(e: MouseEvent) {
|
||||||
|
const svg = e.currentTarget as SVGSVGElement;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||||
|
const pts = chart.value.dotPoints;
|
||||||
|
if(!pts.length) return;
|
||||||
|
const nearest = pts.reduce((a, b) => Math.abs(a.x - svgX) < Math.abs(b.x - svgX) ? a : b);
|
||||||
|
selectedPoint.value = {t: nearest.t, v: nearest.v};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed; inset: 0;
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.6);
|
background: rgba(0,0,0,0.6);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
@@ -99,53 +142,52 @@ const chart = computed(() => {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
width: 100%;
|
width: 100%; max-width: 700px;
|
||||||
max-width: 700px;
|
|
||||||
}
|
}
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
.title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; }
|
.title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; }
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none; border: 1px solid var(--border); color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border-radius: 8px; width: 32px; height: 32px; cursor: pointer; font-size: 16px;
|
||||||
color: var(--text);
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
&:hover { background: var(--hover); }
|
&:hover { background: var(--hover); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mode-toggle {
|
.controls {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
button {
|
button {
|
||||||
font-size: 11px;
|
font-size: 11px; padding: 4px 12px; border-radius: 6px;
|
||||||
padding: 4px 12px;
|
border: 1px solid var(--border); background: none; color: var(--text-muted); cursor: pointer;
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
}
|
}
|
||||||
|
input {
|
||||||
|
font-size: 11px; padding: 3px 6px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg); color: var(--text);
|
||||||
|
}
|
||||||
|
.apply-btn {
|
||||||
|
font-size: 11px; padding: 4px 10px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.current-val {
|
.current-val {
|
||||||
font-size: 13px;
|
font-size: 13px; color: var(--text-muted);
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
span { color: var(--accent); font-weight: 700; }
|
span { color: var(--accent); font-weight: 700; }
|
||||||
}
|
}
|
||||||
svg { width: 100%; overflow: visible; }
|
.chart-wrap { position: relative; }
|
||||||
.tick-label {
|
.loading-overlay {
|
||||||
font-size: 9px;
|
position: absolute; inset: 0;
|
||||||
fill: var(--text-muted);
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.2); border-radius: 8px;
|
||||||
|
.spinner {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border: 3px solid var(--border); border-top-color: var(--accent);
|
||||||
|
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
svg { width: 100%; overflow: visible; cursor: crosshair; }
|
||||||
|
.tick-label { font-size: 9px; fill: var(--text-muted); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -158,41 +200,44 @@ svg { width: 100%; overflow: visible; }
|
|||||||
<button class="close-btn" @click="emit('close')">✕</button>
|
<button class="close-btn" @click="emit('close')">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mode-toggle">
|
<div class="controls gap-0 mb-2">
|
||||||
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly ±24h</button>
|
<button :class="{active: mode === 'hourly'}" style="border-radius: 6px 0 0 6px" @click="mode = 'hourly'">Hourly</button>
|
||||||
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily ±3d</button>
|
<button :class="{active: mode === 'daily'}" style="border-radius: 0 6px 6px 0" @click="mode = 'daily'">Daily</button>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customStart"/>
|
||||||
|
<span style="font-size:11px;color:var(--text-muted)">to</span>
|
||||||
|
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customEnd"/>
|
||||||
|
<button class="apply-btn" @click="fetchData">Apply</button>
|
||||||
|
<div v-if="chart.dot" class="flex-fill flex-r justify-end align-items-center current-val">
|
||||||
|
<span>{{ (chart.dot.v as number).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="chart.dot" class="current-val">Now: <span>{{ (chart.dot.v as number).toFixed(2) }}</span></div>
|
<div class="chart-wrap">
|
||||||
|
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none" @click="onSvgClick">
|
||||||
|
<template v-for="tick in chart.yLabels" :key="tick.y">
|
||||||
|
<line :x1="PAD" :y1="tick.y" :x2="W-PAD" :y2="tick.y" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>
|
||||||
|
<text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text>
|
||||||
|
</template>
|
||||||
|
<template v-for="tick in chart.xLabels" :key="tick.x">
|
||||||
|
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
|
||||||
|
</template>
|
||||||
|
|
||||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||||
<!-- y-axis grid lines & labels -->
|
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||||
<template v-for="tick in chart.yLabels" :key="tick.y">
|
|
||||||
<line :x1="PAD" :y1="tick.y" :x2="W-PAD" :y2="tick.y" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>
|
|
||||||
<text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- x-axis labels -->
|
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
|
||||||
<template v-for="tick in chart.xLabels" :key="tick.x">
|
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
|
||||||
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- axes -->
|
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||||
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
|
||||||
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
|
||||||
|
|
||||||
<!-- now line -->
|
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="5" fill="var(--accent)"/>
|
||||||
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
|
</svg>
|
||||||
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
|
|
||||||
|
|
||||||
<!-- historic solid -->
|
<div v-if="loading" class="loading-overlay"><div class="spinner"/></div>
|
||||||
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
</div>
|
||||||
<!-- future dashed -->
|
|
||||||
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
|
|
||||||
|
|
||||||
<!-- current dot -->
|
|
||||||
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="4" fill="var(--accent)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ onMounted(async () => {
|
|||||||
<template v-if="hours?.length">
|
<template v-if="hours?.length">
|
||||||
<div v-for="h in hours" class="hour-row">
|
<div v-for="h in hours" class="hour-row">
|
||||||
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
||||||
<img :src="BASE + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
|
<img :src="BASE + '/api/icon/' + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
|
||||||
<span class="hour-label">{{ h.label }}</span>
|
<span class="hour-label">{{ h.label }}</span>
|
||||||
<div class="hour-meta">
|
<div class="hour-meta">
|
||||||
<span>🌧️ {{ (h.precipitation as any || 0).toFixed(1) }}mm</span>
|
<span>🌧️ {{ (h.precipitation as any || 0).toFixed(1) }}mm</span>
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import Loading from '@/components/Loading.vue';
|
||||||
import {formatDate} from '@ztimson/utils';
|
import {formatDate} from '@ztimson/utils';
|
||||||
import {ref, onMounted, computed, watchEffect} from 'vue';
|
import {ref, watchEffect} from 'vue';
|
||||||
import {api} from '../services/api';
|
import {useWeather} from '../services/api';
|
||||||
import MetricRow from './MetricRow.vue';
|
import MetricRow from './MetricRow.vue';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
const {current: d} = useWeather();
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
|
|
||||||
const moonIcon = computed(() => {
|
|
||||||
if(!d.value) return '🌑';
|
|
||||||
const phase = d.value.moon_phase as string;
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓',
|
|
||||||
'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖',
|
|
||||||
'Last Quarter': '🌗', 'Waning Crescent': '🌘',
|
|
||||||
};
|
|
||||||
return map[phase] ?? '🌕';
|
|
||||||
});
|
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
@@ -80,20 +68,31 @@ watchEffect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card" v-if="d">
|
<div class="card">
|
||||||
<div class="card-title">🌙 Moon</div>
|
<div class="card-title">🌙 Moon</div>
|
||||||
|
|
||||||
<div class="flex-r align-items-center gap-3 p-2">
|
<div class="flex-r align-items-center gap-3 p-2">
|
||||||
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 40px">
|
||||||
<div class="flex-c">
|
<Loading />
|
||||||
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
|
||||||
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
||||||
|
<div class="flex-c">
|
||||||
|
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
||||||
|
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
<div v-if="!d" class="flex-c p-2 gap-2">
|
||||||
<MetricRow label="Next New Moon" :value="formatDate('MMM D', d.moon_new)" />
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
||||||
|
<MetricRow label="Next New Moon" :value="formatDate('MMM D', d.moon_new)" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import {getPressureTrend} from '@/services/units.ts';
|
||||||
import { api } from '../services/api';
|
import {inject} from 'vue';
|
||||||
|
import {useWeather} from '../services/api';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
|
||||||
const openGraph = inject<(key: string) => void>('openGraph');
|
const openGraph = inject<(key: string) => void>('openGraph');
|
||||||
|
const {current: d} = useWeather();
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -59,59 +58,63 @@ onMounted(async () => d.value = await api.current());
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="precip-card" v-if="d">
|
<div class="precip-card">
|
||||||
<div class="card-title">🌧️ Precipitation</div>
|
<div class="card-title">🌧️ Precipitation</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat">
|
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||||
<span class="stat-label">Raining</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 == 'True' ? d.storm_direction : 'No' }}</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>
|
||||||
<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.lightning_rate }}<span class="fg-muted">/hr</span></span>
|
<span v-else class="stat-value">{{ d.lightning_rate ?? '—' }}<span v-if="d.lightning_rate" class="fg-muted">/hr</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('lightning_distance')">
|
<div class="stat" @click="openGraph?.('lightning_distance')">
|
||||||
<span class="stat-label">Distance</span>
|
<span class="stat-label">Distance</span>
|
||||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
<span v-else class="stat-value">{{ d.lightning_distance }} <span class="fg-muted">km</span></span>
|
<span v-else class="stat-value">{{ d.lightning_distance ?? '—' }} <span v-if="d.lightning_distance" class="fg-muted">km</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
|
||||||
<span class="stat-label">Chance</span>
|
|
||||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
|
||||||
<span v-else class="stat-value">{{ d.precipitation_chance }}<span class="fg-muted">%</span></span>
|
|
||||||
</div>
|
|
||||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||||
<span class="stat-label">Pressure</span>
|
<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>
|
<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">({{d.pressure_trend}})</span></span>
|
<span v-else class="stat-value">{{ d.pressure_hpa?.toFixed(1) ?? '—' }} <span class="fg-muted">hpa</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||||
|
<span class="stat-label">Pressure (SLP)</span>
|
||||||
|
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
|
<span v-else class="stat-value">{{ d.pressure_slp?.toFixed(1) ?? '—' }} <span v-if="d.pressure_slp" class="fg-muted">({{getPressureTrend(d.pressure_rate)}})</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('precipitation')">
|
<div class="stat" @click="openGraph?.('precipitation')">
|
||||||
<span class="stat-label">Precipitation</span>
|
<span class="stat-label">Precipitation</span>
|
||||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<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 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 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 class="fg-muted">mm {{ d.accumulation_type != 'none' ? `(${d.accumulation_type})` : '' }}</span></span>
|
|
||||||
</div>
|
</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>
|
||||||
<span v-else class="stat-value">{{ d.dew_point.toFixed(1) }}<span class="fg-muted">°C</span></span>
|
<span v-else class="stat-value">{{ d.dew_point?.toFixed(1) ?? '—' }}<span v-if="d.dew_point" class="fg-muted">°C</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-label">Frost Risk</span>
|
<span class="stat-label">Frost Risk</span>
|
||||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<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 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>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,63 +1,182 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, onMounted, onUnmounted} from 'vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
import {api} from '../services/api';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
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 W = 320, H = 60, MAX_PTS = 120;
|
|
||||||
|
const AXES = [
|
||||||
|
{ key: 'seismic_x', label: 'X', color: '#ccc' },
|
||||||
|
{ key: 'seismic_y', label: 'Y', color: '#ccc' },
|
||||||
|
{ key: 'seismic_z', label: 'Z', color: '#ccc' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const W = 640, BAND = 80, H = BAND * AXES.length, MAX_PTS = 120;
|
||||||
|
const SEG_W = W / (MAX_PTS - 1);
|
||||||
|
const SUB = 4;
|
||||||
|
const RIGHT_PAD = 2 * SEG_W;
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval>;
|
let interval: ReturnType<typeof setInterval>;
|
||||||
|
let rafId: number;
|
||||||
|
let drawProgress = 0;
|
||||||
|
let lastTimestamp = 0;
|
||||||
|
const ANIM_DURATION = 1200;
|
||||||
|
|
||||||
function toPath(pts: number[]) {
|
type Pt = { x: number; y: number };
|
||||||
if (!pts.length) return '';
|
const traces = AXES.map(() => ({
|
||||||
const mid = H / 2, amp = H / 2 - 4;
|
history: [] as number[],
|
||||||
const max = Math.max(Math.max(...pts), 1.0); // 1.0 = min scale floor
|
prevMax: 1.0,
|
||||||
|
buffer: [] as Pt[],
|
||||||
|
}));
|
||||||
|
|
||||||
// Fixed px per point — path grows left→right, never stretches
|
function buildBuffer(t: (typeof traces)[number], band: number) {
|
||||||
const segW = W / (MAX_PTS - 1);
|
const pts = t.history;
|
||||||
|
if (pts.length < 2) return;
|
||||||
const subPts: { x: number; y: number }[] = [];
|
const mid = band * BAND + BAND / 2, amp = BAND / 2 - 6;
|
||||||
|
const newMax = Math.max(...pts, 1.0);
|
||||||
|
t.prevMax = t.prevMax + (newMax - t.prevMax) * 0.15; // eased amplitude scale
|
||||||
|
const tipX = W - RIGHT_PAD;
|
||||||
|
const buf: Pt[] = [];
|
||||||
|
|
||||||
pts.forEach((v, i) => {
|
pts.forEach((v, i) => {
|
||||||
const norm = (v / max) * amp;
|
const prev = pts[i - 1] ?? v;
|
||||||
const baseX = i * segW;
|
const prevNorm = prev === 0 ? 0 : (prev / t.prevMax) * amp;
|
||||||
const s = segW / 4;
|
const norm = v === 0 ? 0 : (v / t.prevMax) * amp;
|
||||||
|
const baseX = tipX - (pts.length - i) * SEG_W;
|
||||||
|
const s = SEG_W / SUB;
|
||||||
|
|
||||||
subPts.push({ x: baseX, y: mid - norm * 0.5 });
|
// one oscillation per value, amplitude fading prevNorm → norm
|
||||||
subPts.push({ x: baseX + s, y: mid - norm });
|
const a0 = prevNorm * 0.75 + norm * 0.25;
|
||||||
subPts.push({ x: baseX + s * 2, y: mid + norm });
|
const a1 = prevNorm * 0.25 + norm * 0.75;
|
||||||
subPts.push({ x: baseX + s * 3, y: mid });
|
|
||||||
|
buf.push({ x: baseX + s, y: mid - a0 }); // up
|
||||||
|
buf.push({ x: baseX + s * 2, y: mid - a1 }); // up peak
|
||||||
|
buf.push({ x: baseX + s * 3, y: mid + norm }); // down
|
||||||
|
buf.push({ x: baseX + s * 4, y: mid }); // back to exact center ✓
|
||||||
});
|
});
|
||||||
|
|
||||||
return subPts.map((p, i) =>
|
t.buffer = buf;
|
||||||
`${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`
|
}
|
||||||
).join(' ');
|
|
||||||
|
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) {
|
||||||
|
const ctx = canvas.value?.getContext('2d');
|
||||||
|
if (!ctx) { rafId = requestAnimationFrame(draw); return; }
|
||||||
|
|
||||||
|
const dt = Math.min(timestamp - lastTimestamp, 100);
|
||||||
|
lastTimestamp = timestamp;
|
||||||
|
drawProgress = Math.min(1, drawProgress + dt / ANIM_DURATION);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
AXES.forEach((_, i) => {
|
||||||
|
const mid = i * BAND + BAND / 2;
|
||||||
|
|
||||||
|
// Axis label — top-left of each band
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.fillStyle = (<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.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.moveTo(0, mid);
|
||||||
|
ctx.lineTo(W, mid);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
drawTrace(ctx, <any>traces[i], (<any>AXES)[i].color);
|
||||||
|
});
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(draw);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
d.value = await api.current('magnitude');
|
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
||||||
history.value.push(d.value.magnitude ?? 0);
|
AXES.forEach((axis, i) => {
|
||||||
if(history.value.length > MAX_PTS) history.value.shift();
|
const t = <any>traces[i];
|
||||||
|
t.history.push(d.value[axis.key] ?? 0);
|
||||||
|
if (t.history.length > MAX_PTS) t.history.shift();
|
||||||
|
buildBuffer(t, i);
|
||||||
|
});
|
||||||
|
drawProgress = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => { await poll(); interval = setInterval(poll, 5000); });
|
onMounted(async () => {
|
||||||
onUnmounted(() => clearInterval(interval));
|
await poll();
|
||||||
|
lastTimestamp = performance.now();
|
||||||
|
rafId = requestAnimationFrame(draw);
|
||||||
|
interval = setInterval(poll, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(interval);
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
svg { width: 100%; height: 60px; overflow: visible; }
|
canvas { width: 100%; height: 180px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card" v-if="d">
|
<div class="card">
|
||||||
<div class="card-title">🫨 Seismic</div>
|
<div class="card-title">🫨 Seismic</div>
|
||||||
|
<div v-if="!d" class="p-2">
|
||||||
<MetricRow label="Magnitude" :value="d.magnitude?.toFixed(1)" metric-key="magnitude" :data="d" />
|
<div class="w-100 pos-rel br-2 overflow-hidden mb-2" style="height: 30px">
|
||||||
|
<Loading />
|
||||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
</div>
|
||||||
<line :x1="0" :y1="H/2" :x2="W" :y2="H/2" stroke="var(--border)" stroke-width="1" />
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 150px">
|
||||||
<path :d="toPath(history)" fill="none" stroke="var(--accent)" stroke-width="1.5" />
|
<Loading />
|
||||||
</svg>
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<MetricRow label="Magnitude" :value="d.seismic_magnitude?.toFixed(1)" metric-key="seismic_magnitude" :data="d" />
|
||||||
|
<canvas ref="canvas" :width="W" :height="H" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
const SPEED_UNITS = {
|
||||||
|
knots: { label: 'KTS', convert: (v: number) => v.toFixed(1) },
|
||||||
|
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||||
|
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIT_KEY = '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 cycleSpeed() {
|
||||||
|
const keys = Object.keys(SPEED_UNITS)
|
||||||
|
const p = loadUnits()
|
||||||
|
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||||
|
prefs.value = { ...p }
|
||||||
|
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
|
||||||
|
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 photoError = ref(false)
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pos = ref({ ...props.position })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
function onMouseUp() { isDragging.value = false }
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX, sy = touch.clientY
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||||
|
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
const compass = computed(() => {
|
||||||
|
const hdg = heading.value
|
||||||
|
const course = props.boat.cog ?? hdg
|
||||||
|
|
||||||
|
const tapeTicks: string[] = []
|
||||||
|
for (let offset = -60; offset <= 60; offset += 5) {
|
||||||
|
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||||
|
const x = 100 + (offset / 60) * 90
|
||||||
|
const major = offset % 30 === 0
|
||||||
|
const mid = offset % 15 === 0
|
||||||
|
if (major) {
|
||||||
|
const lbl = ({ 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' } as any)[deg]
|
||||||
|
?? String(deg).padStart(3, '0')
|
||||||
|
tapeTicks.push(`
|
||||||
|
<line x1="${x}" y1="22" x2="${x}" y2="38" stroke="#0ff" stroke-width="2"/>
|
||||||
|
<text x="${x}" y="50" text-anchor="middle" fill="#0ff" font-size="11" font-weight="bold">${lbl}</text>
|
||||||
|
`)
|
||||||
|
} else if (mid) {
|
||||||
|
tapeTicks.push(`<line x1="${x}" y1="26" x2="${x}" y2="38" stroke="#0ff" stroke-width="1.5"/>`)
|
||||||
|
} else {
|
||||||
|
tapeTicks.push(`<line x1="${x}" y1="30" x2="${x}" y2="38" stroke="#0ff" stroke-width="1"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CX = 100, CY = 140
|
||||||
|
const R = 68
|
||||||
|
|
||||||
|
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
||||||
|
const rudX = CX + (rudder / 30) * 40
|
||||||
|
|
||||||
|
const courseRel = ((course - hdg + 540) % 360) - 180
|
||||||
|
const courseRad = courseRel * Math.PI / 180
|
||||||
|
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
||||||
|
const cNeedleY = CY - 52 * Math.cos(courseRad)
|
||||||
|
|
||||||
|
const bearRel = ((course - hdg + 540) % 360) - 180
|
||||||
|
const bearRad = bearRel * Math.PI / 180
|
||||||
|
const bNeedleX = CX + 52 * Math.sin(bearRad)
|
||||||
|
const bNeedleY = CY - 52 * Math.cos(bearRad)
|
||||||
|
|
||||||
|
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
||||||
|
const a = i * 10
|
||||||
|
const rad = (a - 90) * Math.PI / 180
|
||||||
|
const r1 = a % 30 === 0 ? R - 11 : R - 5
|
||||||
|
const sw = a % 30 === 0 ? 2 : 1
|
||||||
|
const x1 = CX + r1 * Math.cos(rad)
|
||||||
|
const y1 = CY + r1 * Math.sin(rad)
|
||||||
|
const x2 = CX + R * Math.cos(rad)
|
||||||
|
const y2 = CY + R * Math.sin(rad)
|
||||||
|
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const cardinals = (['N','E','S','W'] as const).map((lbl, i) => {
|
||||||
|
const a = i * 90
|
||||||
|
const rad = (a - 90) * Math.PI / 180
|
||||||
|
const x = CX + (R - 18) * Math.cos(rad)
|
||||||
|
const y = CY + (R - 18) * Math.sin(rad) + 4
|
||||||
|
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="200" height="210" viewBox="0 0 200 210">
|
||||||
|
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
|
||||||
|
<line x1="10" y1="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
|
||||||
|
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
||||||
|
${tapeTicks.join('')}
|
||||||
|
<polygon points="100,20 96,12 104,12" fill="#ff0"/>
|
||||||
|
|
||||||
|
<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
|
||||||
|
<circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
|
||||||
|
|
||||||
|
<g transform="rotate(${-hdg}, ${CX}, ${CY})">
|
||||||
|
${ringTicks}
|
||||||
|
${cardinals}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||||
|
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
|
||||||
|
|
||||||
|
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||||
|
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
|
||||||
|
|
||||||
|
<polygon points="${CX},${CY - 9} ${CX - 4},${CY + 5} ${CX},${CY + 2} ${CX + 4},${CY + 5}" fill="#fff" stroke="#0ff" stroke-width="1"/>
|
||||||
|
|
||||||
|
<rect x="80" y="${CY + 23}" width="40" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
||||||
|
<rect x="${rudX - 4}" y="${CY + 22}" width="8" height="8" fill="#ff0" rx="1"/>
|
||||||
|
|
||||||
|
<line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
|
||||||
|
<text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
|
||||||
|
|
||||||
|
<line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||||
|
<text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ship-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(0,255,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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(0,255,255,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-name {
|
||||||
|
margin: 0 24px 0 0;
|
||||||
|
color: #7eeee1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.sp-mmsi { color: white; }
|
||||||
|
|
||||||
|
.sp-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.sp-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
/* ── Photo ── */
|
||||||
|
.sp-photo-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
background: #111;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.sp-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.sp-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #0ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.sp-gauge:hover { border-color: #0f0; }
|
||||||
|
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
|
||||||
|
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||||
|
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
import { inject, onMounted, ref, computed } from 'vue';
|
import {getUVLabel} from '@/services/units.ts';
|
||||||
import { api } from '../services/api';
|
import {inject, computed } from 'vue';
|
||||||
|
import {useWeather} from '../services/api';
|
||||||
|
|
||||||
const data = ref<any>(null);
|
|
||||||
const openGraph = inject<(key: string) => void>('openGraph');
|
const openGraph = inject<(key: string) => void>('openGraph');
|
||||||
|
const {current: data} = useWeather();
|
||||||
onMounted(async () => data.value = await api.current());
|
|
||||||
|
|
||||||
const W = 320, H = 120, HORIZON = 45;
|
const W = 320, H = 120, HORIZON = 45;
|
||||||
|
|
||||||
@@ -22,47 +21,42 @@ const arc = computed(() => {
|
|||||||
const start = noon - 12 * 3600 * 1000;
|
const start = noon - 12 * 3600 * 1000;
|
||||||
const end = noon + 12 * 3600 * 1000;
|
const end = noon + 12 * 3600 * 1000;
|
||||||
|
|
||||||
const pct = Math.max(0, Math.min(1, (now - start) / (end - start)));
|
const riseT = (rise - start) / (end - start);
|
||||||
|
const setT = (set - start) / (end - start);
|
||||||
|
const dayPct = Math.max(0, Math.min(1, (now - rise) / dayLen));
|
||||||
|
const sunT = riseT + dayPct * (setT - riseT);
|
||||||
|
|
||||||
|
// After sunset, continue tracking sun below horizon
|
||||||
|
const totalPct = Math.max(0, Math.min(1, (now - start) / (end - start)));
|
||||||
|
|
||||||
const x1 = 0, x2 = W;
|
const x1 = 0, x2 = W;
|
||||||
const totalW = x2 - x1;
|
const totalW = x2 - x1;
|
||||||
|
const amplitude = H * 0.45;
|
||||||
|
const midY = H / 2;
|
||||||
|
|
||||||
// Sine curve: below horizon at edges, peaks above at noon
|
const sineY = (t: number) => midY - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
|
||||||
const getPoint = (t: number) => ({
|
const horizonY = sineY(riseT);
|
||||||
x: x1 + totalW * t,
|
|
||||||
y: HORIZON - Math.sin(t * Math.PI * 2 - Math.PI / 2) * (HORIZON) + (HORIZON)
|
|
||||||
// Simpler: just a single-hump arc above horizon + dip below
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate points along full sine path
|
|
||||||
const pts = Array.from({ length: 201 }, (_, i) => {
|
const pts = Array.from({ length: 201 }, (_, i) => {
|
||||||
const t = i / 200;
|
const t = i / 200;
|
||||||
const x = x1 + totalW * t;
|
return { x: x1 + totalW * t, y: sineY(t) };
|
||||||
// Single smooth S: -sin gives us below->above->below
|
|
||||||
const amplitude = (HORIZON);
|
|
||||||
const y = HORIZON - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
|
|
||||||
return { x, y };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const splitIdx = Math.round(pct * 200);
|
const splitIdx = Math.round(totalPct * 200);
|
||||||
const { x: cx, y: cy } = <any>pts[splitIdx];
|
const { x: cx, y: cy } = <any>pts[Math.min(splitIdx, 200)];
|
||||||
|
|
||||||
// Filled traveled path: close down to horizon baseline for fill
|
const traveledPts: any = pts.slice(0, splitIdx + 1);
|
||||||
const traveledPts = pts.slice(0, splitIdx + 1);
|
const filledPath = traveledPts.map((p: any, i: any) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
||||||
const filledPath = traveledPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
+ ` L${traveledPts[traveledPts.length - 1].x.toFixed(1)},${horizonY}`
|
||||||
+ ` L${(<any>traveledPts[traveledPts.length - 1]).x.toFixed(1)},${HORIZON}`
|
+ ` L${x1},${horizonY} Z`;
|
||||||
+ ` L${x1},${HORIZON} Z`;
|
|
||||||
|
|
||||||
// Remaining path stroke only
|
|
||||||
const remainPts = pts.slice(splitIdx);
|
const remainPts = pts.slice(splitIdx);
|
||||||
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||||||
|
|
||||||
// Sunrise / sunset x positions
|
const riseX = x1 + totalW * riseT;
|
||||||
const riseX = x1 + totalW * ((rise - start) / (end - start));
|
const setX = x1 + totalW * setT;
|
||||||
const setX = x1 + totalW * ((set - start) / (end - start));
|
|
||||||
const noonX = x1 + totalW * 0.5;
|
|
||||||
|
|
||||||
return { filledPath, remainPath, cx, cy, pct, riseX, setX, noonX };
|
return { filledPath, remainPath, cx, cy, pct: dayPct, riseX, setX, horizonY };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,28 +64,32 @@ const arc = computed(() => {
|
|||||||
<div class="sun-card">
|
<div class="sun-card">
|
||||||
<div class="card-title">🌅 Sun</div>
|
<div class="card-title">🌅 Sun</div>
|
||||||
|
|
||||||
<!-- Top labels -->
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 3"><Loading /></div>
|
||||||
<div class="sun-top-labels" v-if="data">
|
|
||||||
<div>
|
|
||||||
<div class="stat-label">Sunrise</div>
|
|
||||||
<div class="stat-value">{{ new Date(data.sunrise).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
|
||||||
</div>
|
|
||||||
<div style="text-align:right">
|
|
||||||
<div class="stat-label">Sunset</div>
|
|
||||||
<div class="stat-value">{{ new Date(data.sunset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
<template v-else>
|
||||||
<!-- Filled traveled area -->
|
<!-- Top labels -->
|
||||||
<path v-if="arc.filledPath" :d="arc.filledPath" fill="#3b82f6" opacity="0.35" />
|
<div class="sun-top-labels" v-if="data">
|
||||||
<!-- Remaining curve stroke -->
|
<div>
|
||||||
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
<div class="stat-label">Sunrise</div>
|
||||||
<!-- Horizon line -->
|
<div class="stat-value">{{ new Date(data.sunrise).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||||
<line :x1="0" :y1="HORIZON" :x2="W" :y2="HORIZON" stroke="var(--border)" stroke-width="1" />
|
</div>
|
||||||
<!-- Sun dot -->
|
<div style="text-align:right">
|
||||||
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
<div class="stat-label">Sunset</div>
|
||||||
</svg>
|
<div class="stat-value">{{ new Date(data.sunset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||||
|
<!-- Filled traveled area -->
|
||||||
|
<path v-if="arc.filledPath" :d="arc.filledPath" fill="#3b82f6" opacity="0.35" />
|
||||||
|
<!-- Remaining curve stroke -->
|
||||||
|
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
||||||
|
<!-- Horizon line -->
|
||||||
|
<line :x1="0" :y1="arc.horizonY" :x2="W" :y2="arc.horizonY" stroke="var(--border)" stroke-width="1" />
|
||||||
|
<!-- Sun dot -->
|
||||||
|
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
@@ -99,17 +97,17 @@ const arc = computed(() => {
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-label">Daylight</span>
|
<span class="stat-label">Daylight</span>
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) }} <span class="fg-muted">hrs</span></span>
|
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) ?? '—' }} <span v-if="data.daylight" class="fg-muted">hrs</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('lux')">
|
<div class="stat" @click="openGraph?.('lux')">
|
||||||
<span class="stat-label">Lux</span>
|
<span class="stat-label">Brightness</span>
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
<span v-else class="stat-value">{{ data.lux }}</span>
|
<span v-else class="stat-value">{{ data.lux ?? '—' }} <span v-if="data.lux != null" class="fg-muted">lm/m²</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('solar_wm2')">
|
<div class="stat" @click="openGraph?.('solar_wm2')">
|
||||||
<span class="stat-label">Solar W/m²</span>
|
<span class="stat-label">Irradiance</span>
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
<span v-else class="stat-value">{{ data.solar_wm2 }}</span>
|
<span v-else class="stat-value">{{ data.solar_wm2 ?? '—' }} <span v-if="data.lux != null" class="fg-muted">W/m²</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,17 +117,17 @@ const arc = computed(() => {
|
|||||||
<div class="stat" @click="openGraph?.('uv_index')">
|
<div class="stat" @click="openGraph?.('uv_index')">
|
||||||
<span class="stat-label">UV Index</span>
|
<span class="stat-label">UV Index</span>
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
<span v-else class="stat-value">{{ data.uv_index.toFixed(1) }} <span class="fg-muted">({{ data.uv_index_label }})</span></span>
|
<span v-else class="stat-value">{{ data.uv_index?.toFixed(1) ?? '—' }} <span v-if="data.uv_index != null" class="fg-muted">({{ getUVLabel(data.uv_index) }})</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('uv_dose')">
|
<div class="stat" @click="openGraph?.('uv_dose')">
|
||||||
<span class="stat-label">UV Dose</span>
|
<span class="stat-label">UV Dose</span>
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) }} <span class="fg-muted">J/m²</span></span>
|
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) ?? '—' }} <span v-if="data.uv_dose != null" class="fg-muted">J/m²</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('uv_index_max')">
|
<div class="stat" @click="openGraph?.('uv_index_max')">
|
||||||
<span class="stat-label">UV Max</span>
|
<span class="stat-label">UV Max</span>
|
||||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
<span v-else class="stat-value">{{ data.uv_index_max ?? '—' }}</span>
|
<span v-else class="stat-value">{{ data.uv_index_max ?? '—' }} <span v-if="data.uv_index_max != null" class="fg-muted">({{getUVLabel(data.uv_index_max)}})</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Loading from '@/components/Loading.vue';
|
import Loading from '@/components/Loading.vue';
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import {inject} from 'vue';
|
||||||
import { api } from '../services/api';
|
import {useWeather} from '../services/api';
|
||||||
|
|
||||||
const d = ref<any>(null);
|
|
||||||
const openGraph = inject<(key: string) => void>('openGraph');
|
const openGraph = inject<(key: string) => void>('openGraph');
|
||||||
|
const {current: d} = useWeather();
|
||||||
|
|
||||||
function dir(deg: number) {
|
function dir(deg: number) {
|
||||||
if(deg > 22.5 && deg <= 67.5) return 'North-East';
|
if(deg > 22.5 && deg <= 67.5) return 'North-East';
|
||||||
@@ -16,8 +16,6 @@ function dir(deg: number) {
|
|||||||
if(deg > 292.5 && deg <= 337.5) return 'North-West';
|
if(deg > 292.5 && deg <= 337.5) return 'North-West';
|
||||||
return 'North';
|
return 'North';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => d.value = await api.current());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -70,24 +68,24 @@ onMounted(async () => d.value = await api.current());
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="wind-card" v-if="d">
|
<div class="wind-card">
|
||||||
<div class="card-title">🍃 Wind</div>
|
<div class="card-title">🍃 Wind</div>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div class="stat" @click="openGraph?.('wind_speed')">
|
<div class="stat" @click="openGraph?.('wind_speed')">
|
||||||
<span class="stat-label">Speed</span>
|
<span class="stat-label">Speed</span>
|
||||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||||
<span v-else class="stat-value">{{ ~~(d.wind_speed) }} <span class="fg-muted">Kph</span></span>
|
<span v-else class="stat-value">{{ d.wind_speed != null ? ~~(d.wind_speed) : '—' }} <span v-if="d.wind_speed != null" class="fg-muted">Kph</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat" @click="openGraph?.('wind_gusts')">
|
<div class="stat" @click="openGraph?.('wind_gusts')">
|
||||||
<span class="stat-label">Gusts</span>
|
<span class="stat-label">Gusts</span>
|
||||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||||
<span v-else class="stat-value">{{ ~~(d.wind_gusts) }} <span class="fg-muted">Kph</span></span>
|
<span v-else class="stat-value">{{ d.wind_gusts != null ? ~~(d.wind_gusts) : '—' }} <span v-if="d.wind_gusts != null" class="fg-muted">Kph</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-label">Direction</span>
|
<span class="stat-label">Direction</span>
|
||||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
<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">{{ dir(d.wind_direction) }}</span>
|
<span v-else class="stat-value">{{ (d.wind_speed || d.wind_gusts) ? dir(d.wind_direction) : '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,416 +1,311 @@
|
|||||||
import {BASE} from '@/services/api.ts';
|
import {BASE} from '@/services/api.ts';
|
||||||
import Map from 'ol/Map'
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
import { Feature } from 'ol'
|
import {h, render as vueRender} from 'vue';
|
||||||
import Point from 'ol/geom/Point'
|
import Map from 'ol/Map';
|
||||||
import LineString from 'ol/geom/LineString'
|
import {Feature} from 'ol';
|
||||||
import { fromLonLat } from 'ol/proj'
|
import Point from 'ol/geom/Point';
|
||||||
import { Style, Icon, Stroke } from 'ol/style'
|
import LineString from 'ol/geom/LineString';
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import {fromLonLat} from 'ol/proj';
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import {Style, Icon, Stroke} from 'ol/style';
|
||||||
import {adjustedInterval} from '@ztimson/utils';
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
|
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;
|
||||||
|
|
||||||
const COLORS: Record<string, string> = {
|
function isPriorityAircraft(plane: any): boolean {
|
||||||
'cargo': '#00ff00',
|
if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true;
|
||||||
'military': '#ff0000',
|
|
||||||
'private': '#8450ea',
|
const text = [
|
||||||
'passenger': '#009dff',
|
plane.type,
|
||||||
'unknown': '#fad106',
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPEED_UNITS = {
|
|
||||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
|
||||||
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
|
||||||
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALTITUDE_UNITS = {
|
|
||||||
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
|
||||||
feet: { label: 'FT', convert: (v: number) => v },
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERTICAL_UNITS = {
|
|
||||||
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
|
||||||
fps: { label: 'FT/S', convert: (v: number) => v },
|
|
||||||
}
|
|
||||||
|
|
||||||
const UNIT_KEY = 'at_units'
|
|
||||||
function getUnits() {
|
|
||||||
const s = localStorage.getItem(UNIT_KEY)
|
|
||||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
|
||||||
}
|
|
||||||
function saveUnits(p: any) { localStorage.setItem(UNIT_KEY, JSON.stringify(p)) }
|
|
||||||
|
|
||||||
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) {
|
if (a < 40000) {
|
||||||
const r = (a - 25000) / 15000
|
const r = (a - 25000) / 15000;
|
||||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPlaneIcon(plane: any, shapes: any): string {
|
|
||||||
const color = COLORS[plane.type] || COLORS['unknown']
|
|
||||||
const shape = shapes[plane.aircraft?.toLowerCase()] || shapes['unknown'];
|
|
||||||
const paths = (Array.isArray(shape.path) ? shape.path : [shape.path]).map((p: string) => `<path d="${p}" fill="${color}" stroke="#000" stroke-width="${shape.stroke || 1}"/>`).join('')
|
|
||||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${shape.w * 2}" height="${shape.h * 2}" viewBox="${shape.viewBox}">${paths}</svg>`)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNavballSvg(data: any): string {
|
|
||||||
const heading = data.heading ?? 0
|
|
||||||
const airspeedKnots = data.speed || 0
|
|
||||||
const verticalRateFps = data.climb || 0
|
|
||||||
const groundSpeedFps = airspeedKnots * 1.68781
|
|
||||||
const pitchRad = groundSpeedFps > 0 ? Math.atan(verticalRateFps / groundSpeedFps) : 0
|
|
||||||
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
|
||||||
|
|
||||||
const pitchLines: string[] = []
|
|
||||||
for (let p = -30; p <= 30; p += 5) {
|
|
||||||
if (p === 0) continue
|
|
||||||
const isMajor = p % 10 === 0
|
|
||||||
const [start, end] = [85, 115]
|
|
||||||
if (isMajor) {
|
|
||||||
pitchLines.push(`
|
|
||||||
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
|
||||||
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
|
||||||
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
|
||||||
`)
|
|
||||||
} else {
|
|
||||||
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rollTicks: string[] = []
|
const r = Math.min((a - 40000) / 10000, 1);
|
||||||
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
|
return [Math.round(128 * r), 0, 139];
|
||||||
const rad = angle * Math.PI / 180
|
|
||||||
const a = Math.abs(angle)
|
|
||||||
if (a === 45) {
|
|
||||||
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
|
|
||||||
} else {
|
|
||||||
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
|
|
||||||
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
|
|
||||||
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
|
|
||||||
const rad = offset * Math.PI / 180
|
|
||||||
const x = 100 + 90 * Math.sin(rad)
|
|
||||||
const y = 100 - 90 * Math.cos(rad)
|
|
||||||
const h = Math.round((offset + 360) % 360)
|
|
||||||
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
|
|
||||||
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
|
|
||||||
}).join('')
|
|
||||||
|
|
||||||
return `
|
|
||||||
<svg width="200" height="220" viewBox="0 0 200 220">
|
|
||||||
<defs>
|
|
||||||
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
|
|
||||||
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#1e90ff"/>
|
|
||||||
<stop offset="100%" style="stop-color:#4169e1"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#8b4513"/>
|
|
||||||
<stop offset="100%" style="stop-color:#654321"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
|
||||||
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
|
||||||
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
|
||||||
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
|
||||||
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
|
||||||
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
|
||||||
${pitchLines.join('')}
|
|
||||||
</g>
|
|
||||||
<g transform="translate(0,25)">${rollTicks.join('')}</g>
|
|
||||||
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
|
|
||||||
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
|
||||||
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
|
||||||
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
|
|
||||||
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
|
|
||||||
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGaugeHtml(label: string, value: any, unit: string, type: string): string {
|
|
||||||
return `
|
|
||||||
<div style="margin-bottom:12px">
|
|
||||||
<div style="color:#888;font-size:11px;font-weight:bold;margin-bottom:3px">${label}</div>
|
|
||||||
<div class="at-gauge" data-gauge="${type}" style="padding:6px;background:rgba(0,0,0,0.95);border:2px solid #0f0;border-radius:3px;cursor:pointer;display:flex;align-items:end;justify-content:center;gap:4px">
|
|
||||||
<span style="font-size:20px;font-weight:bold;color:#0f0">${value}</span>
|
|
||||||
<span style="font-size:11px;color:#0f0;margin-bottom:2px">${unit}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPopupHtml(data: any): string {
|
|
||||||
const prefs = getUnits()
|
|
||||||
const speed = SPEED_UNITS[prefs.speed as keyof typeof SPEED_UNITS]
|
|
||||||
const alt = ALTITUDE_UNITS[prefs.altitude as keyof typeof ALTITUDE_UNITS]
|
|
||||||
const vert = VERTICAL_UNITS[prefs.vertical as keyof typeof VERTICAL_UNITS]
|
|
||||||
const callsign = data.name?.trim()
|
|
||||||
const altitude = data.alt_baro ?? data.alt_geom ?? 0
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="font-family:monospace;color:#ccc;min-width:320px">
|
|
||||||
<div style="padding:12px 16px;border-bottom:1px solid rgba(200,200,220,0.2)">
|
|
||||||
<h3 style="margin:0;color:#7eeee1;font-size:16px">
|
|
||||||
✈️ ${callsign
|
|
||||||
? `<a href="https://www.flightaware.com/live/flight/${callsign}" target="_blank" style="color:#6bb6ff;text-decoration:none">${callsign.toUpperCase()}</a>`
|
|
||||||
: 'N/A'}
|
|
||||||
</h3>
|
|
||||||
<div style="font-size:11px;color:#888;margin-top:4px">
|
|
||||||
${data.country || ''} • ${data.class || 'Unknown'} • ${data.type || ''}
|
|
||||||
<span style="float:right">ICAO: ${(data.icao || '').toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:16px;padding:12px 16px">
|
|
||||||
<div style="flex:0 0 110px">
|
|
||||||
${buildGaugeHtml('Air Speed', speed.convert(data.speed || 0), speed.label, 'speed')}
|
|
||||||
${buildGaugeHtml('Altitude', data.landed ? 'LANDED' : alt.convert(altitude), data.landed ? '' : alt.label, 'altitude')}
|
|
||||||
${buildGaugeHtml('Climb', vert.convert(data.climb || 0) >= 0 ? `+${vert.convert(data.climb || 0)}` : vert.convert(data.climb || 0), vert.label, 'vertical')}
|
|
||||||
</div>
|
|
||||||
<div style="flex:1">${buildNavballSvg(data)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
|
private popups: any = {};
|
||||||
private historyCache: Record<string, any[]> = {}
|
private historyCache: any = {};
|
||||||
private shapes: any = null
|
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
|
|
||||||
|
|
||||||
if (!this.shapes) this.shapes = await fetch(`${API}/vehicles`).then(r => r.json())
|
|
||||||
|
|
||||||
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 () => {
|
|
||||||
await this._fetch()
|
|
||||||
this._draw()
|
|
||||||
this._refreshPopups()
|
|
||||||
}, 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 (!f) return;
|
||||||
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
const plane = f.get('planeData');
|
||||||
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.popups[plane.icao]) {
|
||||||
|
this.closePopup(plane.icao);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.map.removeLayer(this.layer)
|
this.openPopup(plane);
|
||||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
};
|
||||||
this.traceLayers = {}
|
|
||||||
this.traceSegs = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private ───────────────────────────────────────────────────────────────
|
this.map.on('singleclick', this.clickHandler);
|
||||||
|
|
||||||
private async _fetch() {
|
|
||||||
const j = await fetch(`${API}/adsb`).then(r => r.json())
|
|
||||||
this.data = (j || []).map((a: any) => ({
|
|
||||||
...a,
|
|
||||||
icao: a.hex,
|
|
||||||
latitude: a.lat,
|
|
||||||
longitude: a.lon,
|
|
||||||
heading: a.track,
|
|
||||||
speed: a.gs,
|
|
||||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
|
||||||
name: a.flight?.trim(),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _draw() {
|
private _draw() {
|
||||||
const source = this.layer.getSource()!
|
const source = this.layer.getSource()!;
|
||||||
source.clear()
|
source.clear();
|
||||||
|
|
||||||
for (const plane of this.data) {
|
for (const plane of this.data) {
|
||||||
if (plane.latitude == null) continue
|
if (plane.latitude == null || plane.longitude == null) continue;
|
||||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
|
||||||
const f = new Feature({ geometry: new Point(coord) })
|
const coord = fromLonLat([plane.longitude, plane.latitude]);
|
||||||
f.set('icao', plane.icao)
|
const f = new Feature({geometry: new Point(coord)});
|
||||||
f.set('planeData', plane)
|
|
||||||
|
f.set('icao', plane.icao);
|
||||||
|
f.set('planeData', plane);
|
||||||
f.setStyle(new Style({
|
f.setStyle(new Style({
|
||||||
image: new Icon({
|
image: new Icon({
|
||||||
src: buildPlaneIcon(plane, this.shapes),
|
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||||
scale: 1,
|
scale: 1.25,
|
||||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||||
anchor: [0.5, 0.5],
|
anchor: [0.5, 0.5],
|
||||||
}),
|
}),
|
||||||
}))
|
}));
|
||||||
source.addFeature(f)
|
|
||||||
|
source.addFeature(f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _fetch() {
|
||||||
|
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
||||||
|
|
||||||
|
this.data = (j || []).map((a: any) => ({
|
||||||
|
...a,
|
||||||
|
icao: a.hex,
|
||||||
|
latitude: a.lat,
|
||||||
|
longitude: a.lon,
|
||||||
|
heading: a.track,
|
||||||
|
speed: a.gs,
|
||||||
|
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||||
|
name: a.flight?.trim(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private async _fetchTrace(plane: any) {
|
private async _fetchTrace(plane: any) {
|
||||||
const icao = plane.icao
|
const icao = plane.icao;
|
||||||
|
|
||||||
if (!this.historyCache[icao]) {
|
if (!this.historyCache[icao]) {
|
||||||
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
|
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json());
|
||||||
this.historyCache[icao] = j.history || []
|
this.historyCache[icao] = j.history || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = [...(<any>this.historyCache[icao])]
|
const history = this.historyCache[icao];
|
||||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
const cur = {
|
||||||
const last = history[history.length - 1]
|
ts: Math.floor(Date.now() / 1000),
|
||||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
latitude: plane.latitude,
|
||||||
if (history.length < 2) return
|
longitude: plane.longitude,
|
||||||
|
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
|
||||||
|
live: true,
|
||||||
|
};
|
||||||
|
|
||||||
let traceLayer = this.traceLayers[icao]
|
const last = history[history.length - 1];
|
||||||
let vs: VectorSource
|
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 + <any>ec[idx]) / 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]),
|
||||||
|
]));
|
||||||
|
|
||||||
|
f.setStyle(new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: `rgba(${avg.join(',')},0.8)`,
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vs.addFeature(f);
|
||||||
|
segs.push(f);
|
||||||
}
|
}
|
||||||
this.traceSegs[icao] = segs
|
|
||||||
}
|
|
||||||
|
|
||||||
private _openPopup(plane: any) {
|
const popup = this.popups[icao];
|
||||||
const icao = plane.icao
|
if (popup) popup.update(plane, history);
|
||||||
if (this.popups[icao]) return
|
|
||||||
|
|
||||||
const el = document.createElement('div')
|
|
||||||
el.style.cssText = `
|
|
||||||
position:absolute; z-index:9999; pointer-events:auto;
|
|
||||||
background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3);
|
|
||||||
border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden;
|
|
||||||
`
|
|
||||||
|
|
||||||
const close = document.createElement('button')
|
|
||||||
close.innerHTML = '✕'
|
|
||||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
|
||||||
close.onclick = () => this._closePopup(icao)
|
|
||||||
|
|
||||||
el.innerHTML = buildPopupHtml(plane)
|
|
||||||
el.appendChild(close)
|
|
||||||
document.body.appendChild(el)
|
|
||||||
this._positionPopup(el, plane)
|
|
||||||
|
|
||||||
el.querySelectorAll('.at-gauge').forEach(g => {
|
|
||||||
g.addEventListener('click', (e) => {
|
|
||||||
const prefs = getUnits()
|
|
||||||
const type = (e.currentTarget as HTMLElement).dataset.gauge!
|
|
||||||
if (type === 'speed') {
|
|
||||||
const keys = Object.keys(SPEED_UNITS)
|
|
||||||
prefs.speed = keys[(keys.indexOf(prefs.speed) + 1) % keys.length]
|
|
||||||
} else if (type === 'altitude') {
|
|
||||||
const keys = Object.keys(ALTITUDE_UNITS)
|
|
||||||
prefs.altitude = keys[(keys.indexOf(prefs.altitude) + 1) % keys.length]
|
|
||||||
prefs.vertical = prefs.altitude === 'meters' ? 'mps' : 'fps'
|
|
||||||
} else if (type === 'vertical') {
|
|
||||||
const keys = Object.keys(VERTICAL_UNITS)
|
|
||||||
prefs.vertical = keys[(keys.indexOf(prefs.vertical) + 1) % keys.length]
|
|
||||||
prefs.altitude = prefs.vertical === 'mps' ? 'meters' : 'feet'
|
|
||||||
}
|
|
||||||
saveUnits(prefs)
|
|
||||||
el.innerHTML = buildPopupHtml(plane)
|
|
||||||
el.appendChild(close)
|
|
||||||
})
|
|
||||||
g.addEventListener('mouseenter', e => (e.currentTarget as HTMLElement).style.borderColor = '#0ff')
|
|
||||||
g.addEventListener('mouseleave', e => (e.currentTarget as HTMLElement).style.borderColor = '#0f0')
|
|
||||||
})
|
|
||||||
|
|
||||||
this.popups[icao] = { el, destroy: () => el.remove() }
|
|
||||||
this._fetchTrace(plane)
|
|
||||||
}
|
|
||||||
|
|
||||||
private _positionPopup(el: HTMLDivElement, plane: any) {
|
|
||||||
const pixel = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
|
||||||
if (!pixel) return
|
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
|
||||||
el.style.left = `${rect.left + <any>pixel[0] + 16}px`
|
|
||||||
el.style.top = `${rect.top + <any>pixel[1] - 16}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
private _closePopup(icao: string) {
|
|
||||||
const popup = this.popups[icao]
|
|
||||||
if (popup) { popup.destroy(); 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) {
|
||||||
const { el } = <any>this.popups[icao]
|
this.closePopup(icao);
|
||||||
el.innerHTML = buildPopupHtml(plane)
|
continue;
|
||||||
const close = document.createElement('button')
|
}
|
||||||
close.innerHTML = '✕'
|
this._fetchTrace(plane);
|
||||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
|
||||||
close.onclick = () => this._closePopup(icao)
|
|
||||||
el.appendChild(close)
|
|
||||||
this._positionPopup(el, 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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,53 @@
|
|||||||
import {BASE} from '@/services/api.ts';
|
import {BASE} from '@/services/api.ts';
|
||||||
import Map from 'ol/Map'
|
import {greatCircleDist} from '@/services/units.ts';
|
||||||
import { Feature } from 'ol'
|
import {h, render as vueRender} from 'vue';
|
||||||
import Point from 'ol/geom/Point'
|
import Map from 'ol/Map';
|
||||||
import { fromLonLat } from 'ol/proj'
|
import {Feature} from 'ol';
|
||||||
import { Style, Icon, Text, Fill, Stroke } from 'ol/style'
|
import Point from 'ol/geom/Point';
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import {fromLonLat} from 'ol/proj';
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import {Style, Icon} from 'ol/style';
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import {Vector as VectorLayer} from 'ol/layer';
|
||||||
|
import {Vector as VectorSource} from 'ol/source';
|
||||||
|
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;
|
||||||
|
|
||||||
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': '#ff6600',
|
'SAR Aircraft': '#ff0000',
|
||||||
'Aid to Navigation': '#ffff00',
|
'AtoN': '#ffffff',
|
||||||
'Class B CS': '#8450ea',
|
'Class B/CS': '#8450ea',
|
||||||
'Sart/Epirb/MOB': '#ff0000',
|
'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 (boat.type === 'Base Station') {
|
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">
|
||||||
<polygon points="16,2 30,16 16,30 2,16" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
<polygon points="16,2 30,16 16,30 2,16" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||||
@@ -33,198 +56,179 @@ 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,28 12,22 0,28" 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"/>
|
||||||
<circle cx="12" cy="12" r="1.5" fill="#000"/>
|
<circle cx="12" cy="12" r="1.5" fill="#000"/>
|
||||||
<line x1="12" y1="12" x2="12" y2="22" stroke="#000" stroke-width="1.5"/>
|
<line x1="12" y1="12" x2="12" y2="22" stroke="#000" stroke-width="1.5"/>
|
||||||
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
<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>
|
||||||
`)}`
|
`)}`;
|
||||||
}
|
|
||||||
|
|
||||||
function buildPopupHtml(boat: any): string {
|
|
||||||
const name = boat.name?.trim() || boat.callsign?.trim() || `MMSI: ${boat.mmsi}`
|
|
||||||
const rows: [string, any][] = [
|
|
||||||
['Type', boat.type],
|
|
||||||
['MMSI', boat.mmsi],
|
|
||||||
['Country', boat.country],
|
|
||||||
['Callsign', boat.callsign || '-'],
|
|
||||||
['IMO', boat.imo || '-'],
|
|
||||||
['Ship Type', boat.ship_type ?? '-'],
|
|
||||||
['Speed', boat.speed != null ? `${boat.speed} kts` : '-'],
|
|
||||||
['Heading', boat.heading != null ? `${boat.heading}°` : '-'],
|
|
||||||
['Course', boat.course != null ? `${boat.course}°` : '-'],
|
|
||||||
['Lat / Lon', `${boat.lat?.toFixed(5)}, ${boat.lon?.toFixed(5)}`],
|
|
||||||
['Distance', `${boat.distance?.toFixed(2)} nmi`],
|
|
||||||
['Bearing', `${boat.bearing}°`],
|
|
||||||
['Destination', boat.destination || '-'],
|
|
||||||
['ETA', boat.eta || '-'],
|
|
||||||
['Draught', boat.draught != null ? `${boat.draught}m` : '-'],
|
|
||||||
['RSSI', `${boat.rssi?.toFixed(1)} dB`],
|
|
||||||
['Drift', `${boat.drift?.toFixed(2)} ppm`],
|
|
||||||
['Msg Types', boat.msg_type?.join(', ') || '-'],
|
|
||||||
['Channels', boat.channels || '-'],
|
|
||||||
['Sources', boat.sources],
|
|
||||||
['Receiver', boat.receiver],
|
|
||||||
['Count', boat.count],
|
|
||||||
['Last Signal', boat.last_signal != null ? `${boat.last_signal}s ago` : '-'],
|
|
||||||
['In Range', boat.in_range],
|
|
||||||
]
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="font-family:monospace;color:#ccc;min-width:280px">
|
|
||||||
<div style="padding:12px 16px;border-bottom:1px solid rgba(200,200,220,0.2)">
|
|
||||||
<h3 style="margin:0;color:#7eeee1;font-size:16px">🚢 ${name}</h3>
|
|
||||||
<div style="font-size:11px;color:#888;margin-top:4px">${boat.type || 'Unknown'}</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:12px 16px;display:grid;grid-template-columns:1fr 1fr;gap:6px 16px">
|
|
||||||
${rows.map(([label, val]) => `
|
|
||||||
<div>
|
|
||||||
<div style="color:#888;font-size:10px;font-weight:bold">${label}</div>
|
|
||||||
<div style="color:#0f0;font-size:13px">${val ?? '-'}</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AISLayer {
|
export class AISLayer {
|
||||||
private map: Map
|
private map: Map;
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>;
|
||||||
private popups: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
|
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;
|
||||||
// ── 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 () => {
|
|
||||||
await this._fetch()
|
|
||||||
this._draw()
|
|
||||||
this._refreshPopups()
|
|
||||||
}, 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 (!f) return;
|
||||||
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
const boat = f.get('boatData');
|
||||||
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.popups[boat.mmsi]) {
|
||||||
this.map.removeLayer(this.layer)
|
this.closePopup(boat.mmsi);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private ───────────────────────────────────────────────────────────────
|
this.openPopup(boat);
|
||||||
|
};
|
||||||
|
|
||||||
private async _fetch() {
|
this.map.on('singleclick', this.clickHandler);
|
||||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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: 1,
|
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 _openPopup(boat: any) {
|
private async _fetch() {
|
||||||
const mmsi = String(boat.mmsi)
|
this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
|
||||||
if (this.popups[mmsi]) return
|
|
||||||
|
|
||||||
const el = document.createElement('div')
|
|
||||||
el.style.cssText = `
|
|
||||||
position:absolute; z-index:9999; pointer-events:auto;
|
|
||||||
background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3);
|
|
||||||
border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden;
|
|
||||||
`
|
|
||||||
|
|
||||||
const close = document.createElement('button')
|
|
||||||
close.innerHTML = '✕'
|
|
||||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
|
||||||
close.onclick = () => this._closePopup(mmsi)
|
|
||||||
|
|
||||||
el.innerHTML = buildPopupHtml(boat)
|
|
||||||
el.appendChild(close)
|
|
||||||
document.body.appendChild(el)
|
|
||||||
this._positionPopup(el, boat)
|
|
||||||
|
|
||||||
this.popups[mmsi] = { el, destroy: () => el.remove() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private _positionPopup(el: HTMLDivElement, boat: any) {
|
|
||||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
|
||||||
if (!pixel) return
|
|
||||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
|
||||||
el.style.left = `${rect.left + pixel[0] + 16}px`
|
|
||||||
el.style.top = `${rect.top + pixel[1] - 16}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
private _closePopup(mmsi: string) {
|
|
||||||
const popup = this.popups[mmsi]
|
|
||||||
if (popup) { popup.destroy(); 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) {
|
||||||
const { el } = <any>this.popups[mmsi]
|
this.closePopup(mmsi);
|
||||||
el.innerHTML = buildPopupHtml(boat)
|
continue;
|
||||||
const close = document.createElement('button')
|
}
|
||||||
close.innerHTML = '✕'
|
this.popups[mmsi].update(boat);
|
||||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
|
||||||
close.onclick = () => this._closePopup(mmsi)
|
|
||||||
el.appendChild(close)
|
|
||||||
this._positionPopup(el, 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
import {ref, onUnmounted} from 'vue';
|
||||||
|
|
||||||
export type DataRow = Record<string, number | string | null>
|
export type DataRow = Record<string, number | string | null>
|
||||||
|
|
||||||
|
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
||||||
|
|
||||||
const cache: any = {};
|
const cache: any = {};
|
||||||
|
const current = ref<any>(null);
|
||||||
|
const hourly = ref<any[]>([]);
|
||||||
|
const daily = ref<any[]>([]);
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval>;
|
||||||
|
let refCount = 0;
|
||||||
|
|
||||||
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||||
@@ -18,14 +26,35 @@ async function get<T>(path: string, params: Record<string, string> = {}): Promis
|
|||||||
export const api = {
|
export const api = {
|
||||||
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? {fields} : {}),
|
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? {fields} : {}),
|
||||||
current: (fields?: string) => get<DataRow>('/api/current', fields ? {fields} : {}),
|
current: (fields?: string) => get<DataRow>('/api/current', fields ? {fields} : {}),
|
||||||
hourly: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
|
hourly: (start?: Date | string, end?: Date | string, fields?: string) => get<DataRow[]>('/api/hourly', {
|
||||||
...(start ? {start: start.toISOString().slice(0, 10)} : {}),
|
...(start ? {start: typeof start == 'string' ? start : start.toISOString()} : {}),
|
||||||
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
|
...(end ? {end: typeof end == 'string' ? end : end.toISOString()} : {}),
|
||||||
...(fields ? {fields} : {})
|
...(fields ? {fields} : {})
|
||||||
}),
|
}),
|
||||||
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
||||||
...(start ? {start: start.toISOString().slice(0, 10)} : {}),
|
...(start ? {start: typeof start == 'string' ? start : start.toISOString()} : {}),
|
||||||
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
|
...(end ? {end: typeof end == 'string' ? end : end.toISOString()} : {}),
|
||||||
...(fields ? {fields} : {})
|
...(fields ? {fields} : {})
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const start = new Date(), end = new Date();
|
||||||
|
start.setDate(start.getDate() + 1);
|
||||||
|
end.setDate(end.getDate() + 5);
|
||||||
|
current.value = await api.current();
|
||||||
|
return {current}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWeather(ms = 5 * 60_000) {
|
||||||
|
if(++refCount === 1) {
|
||||||
|
refresh();
|
||||||
|
interval = setInterval(refresh, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if(--refCount === 0) clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {current, hourly, daily, refresh};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,12 @@ export class RangeLayer {
|
|||||||
private map: Map
|
private map: Map
|
||||||
private layer!: VectorLayer<VectorSource>
|
private layer!: VectorLayer<VectorSource>
|
||||||
visible = false
|
visible = false
|
||||||
|
interval: any;
|
||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) { this.map = map }
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if(this.visible) this.hide(false);
|
||||||
this.visible = true
|
this.visible = true
|
||||||
|
|
||||||
const points: [number, number][] = await fetch(`${BASE}/api/range`).then(r => r.json())
|
const points: [number, number][] = await fetch(`${BASE}/api/range`).then(r => r.json())
|
||||||
@@ -30,11 +31,16 @@ export class RangeLayer {
|
|||||||
const source = new VectorSource({ features: [feature] })
|
const source = new VectorSource({ features: [feature] })
|
||||||
this.layer = new VectorLayer({ source, zIndex: 50 })
|
this.layer = new VectorLayer({ source, zIndex: 50 })
|
||||||
this.map.addLayer(this.layer)
|
this.map.addLayer(this.layer)
|
||||||
|
if(!this.interval) this.interval = setInterval(() => this.show, 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide(stop = true) {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
this.visible = false
|
this.visible = false
|
||||||
this.map.removeLayer(this.layer)
|
this.map.removeLayer(this.layer)
|
||||||
|
if(stop && this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
this.interval = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export const METRICS: Record<string, MetricMeta> = {
|
|||||||
wind_gusts: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
|
wind_gusts: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
|
||||||
wind_direction: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
|
wind_direction: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
|
||||||
// Seismic
|
// Seismic
|
||||||
magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
|
seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
|
||||||
seismic_x: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
seismic_x: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||||
seismic_y: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
seismic_y: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||||
seismic_z: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
seismic_z: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||||
@@ -70,4 +70,26 @@ export function formatValue(key: string, value: number | string | null): string
|
|||||||
return `${value}${meta.unit}`
|
return `${value}${meta.unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPressureTrend(delta: number) {
|
||||||
|
if (delta > 0.5) return 'Rising';
|
||||||
|
if (delta < -0.5) return 'Falling';
|
||||||
|
return 'Stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUVLabel(uv: number) {
|
||||||
|
if (uv < 3) return 'Low';
|
||||||
|
if (uv < 6) return 'Moderate';
|
||||||
|
if (uv < 8) return 'High';
|
||||||
|
if (uv < 11) return 'Very High';
|
||||||
|
return 'Extreme';
|
||||||
|
}
|
||||||
|
|
||||||
|
const EARTH_R = 6371;
|
||||||
|
export function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (d: number) => d * Math.PI / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
|
||||||
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
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,7 +6,7 @@ import Precipitation from '@/components/Precipitation.vue';
|
|||||||
import Seismic from '@/components/Seismic.vue';
|
import Seismic from '@/components/Seismic.vue';
|
||||||
import Sun from '@/components/Sun.vue';
|
import Sun from '@/components/Sun.vue';
|
||||||
import Wind from '@/components/Wind.vue';
|
import Wind from '@/components/Wind.vue';
|
||||||
import {BASE} from '@/services/api.ts';
|
import {BASE, useWeather} from '@/services/api.ts';
|
||||||
import {formatDate} from '@ztimson/utils';
|
import {formatDate} from '@ztimson/utils';
|
||||||
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
||||||
import MapView from '../components/MapView.vue';
|
import MapView from '../components/MapView.vue';
|
||||||
@@ -23,6 +23,7 @@ let clock: ReturnType<typeof setInterval>;
|
|||||||
provide('openGraph', (key: string) => selectedMetric.value = key);
|
provide('openGraph', (key: string) => selectedMetric.value = key);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
useWeather();
|
||||||
clock = setInterval(() => {
|
clock = setInterval(() => {
|
||||||
day.value = formatDate('dddd');
|
day.value = formatDate('dddd');
|
||||||
date.value = formatDate('MMMM D');
|
date.value = formatDate('MMMM D');
|
||||||
@@ -168,11 +169,11 @@ onUnmounted(() => {
|
|||||||
<HourlyForecast class="mb-3" />
|
<HourlyForecast class="mb-3" />
|
||||||
<ForecastStrip class="mb-3" />
|
<ForecastStrip class="mb-3" />
|
||||||
|
|
||||||
|
<Seismic class="mb-3" />
|
||||||
<Precipitation class="mb-3" />
|
<Precipitation class="mb-3" />
|
||||||
<Wind class="mb-3" />
|
<Wind class="mb-3" />
|
||||||
<Seismic class="mb-3" />
|
|
||||||
<Sun class="mb-3" />
|
<Sun class="mb-3" />
|
||||||
<Moon class="mb-2" />
|
<Moon class="mb-3" />
|
||||||
|
|
||||||
<div class="align-x">
|
<div class="align-x">
|
||||||
<a :href="BASE + '/docs'" style="font-size: 0.75em;" target="_blank">OpenAPI Docs</a> | <a href="https://zakscode.com" style="font-size: 0.75em;" target="_blank">ZaksCode</a>
|
<a :href="BASE + '/docs'" style="font-size: 0.75em;" target="_blank">OpenAPI Docs</a> | <a href="https://zakscode.com" style="font-size: 0.75em;" target="_blank">ZaksCode</a>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import time, math, board, smbus2, serial, psutil, pynmea2, bme680
|
import time, math, board, smbus2, serial, psutil, pynmea2, bme680
|
||||||
import adafruit_ltr390, threading, os, statistics
|
import adafruit_ltr390, threading, os, statistics, requests
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from influxdb_client import InfluxDBClient, Point
|
|
||||||
from influxdb_client.client.write_api import SYNCHRONOUS
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -16,10 +14,7 @@ def cfg():
|
|||||||
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
|
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
|
||||||
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
|
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
|
||||||
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
|
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
|
||||||
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8086'),
|
'DB_HOST': os.getenv('DB_HOST', 'http://localhost:8428'),
|
||||||
'INFLUX_TOKEN': os.getenv('INFLUX_TOKEN', ''),
|
|
||||||
'INFLUX_ORG': os.getenv('INFLUX_ORG', 'weather'),
|
|
||||||
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
|
|
||||||
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
|
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
|
||||||
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
|
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
|
||||||
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
|
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
|
||||||
@@ -31,49 +26,27 @@ def cfg():
|
|||||||
'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')),
|
'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')),
|
||||||
'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')),
|
'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')),
|
||||||
'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')),
|
'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')),
|
||||||
'SKIN_TYPE_2_MED': int( os.getenv('SKIN_TYPE_2_MED', '200')),
|
|
||||||
'LOOP_INTERVAL': float( os.getenv('LOOP_INTERVAL', '1.0')),
|
'LOOP_INTERVAL': float( os.getenv('LOOP_INTERVAL', '1.0')),
|
||||||
'FLOOD_TEMP_THRESHOLD': float( os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')),
|
'FLOOD_TEMP_THRESHOLD': float( os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')),
|
||||||
'SLUSH_TEMP_THRESHOLD': float( os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')),
|
'SLUSH_TEMP_THRESHOLD': float( os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')),
|
||||||
'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')),
|
'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')),
|
||||||
'SEISMIC_NOISE_FLOOR': float( os.getenv('SEISMIC_NOISE_FLOOR', '0.02')),
|
'SEISMIC_NOISE_FLOOR': float( os.getenv('SEISMIC_NOISE_FLOOR', '0.02')),
|
||||||
'TEMP_CALIBRATION': float( os.getenv('TEMP_CALIBRATION', '0.0')),
|
'TEMP_CALIBRATION': float( os.getenv('TEMP_CALIBRATION', '0.0')),
|
||||||
'CLOUD_BUCKET_SIZE': int( os.getenv('CLOUD_BUCKET_SIZE', '5')),
|
'VISIBILITY_MAX': float( os.getenv('VISIBILITY_MAX', '50.0')),
|
||||||
'CLOUD_HISTORY': int( os.getenv('CLOUD_HISTORY', '20')),
|
'CLOUD_SUN_MIN_ELEV': float( os.getenv('CLOUD_SUN_MIN_ELEV', '5.0')),
|
||||||
'CLOUD_BASELINE_PCT': float( os.getenv('CLOUD_BASELINE_PCT', '0.9')),
|
'CLOUD_LUX_CLEAR_SKY': float( os.getenv('CLOUD_LUX_CLEAR_SKY', '100000.0')),
|
||||||
'CLOUD_VOLATILITY_THRESH':float( os.getenv('CLOUD_VOLATILITY_THRESH', '0.3')),
|
'CLOUD_HUMIDITY_WEIGHT': float( os.getenv('CLOUD_HUMIDITY_WEIGHT', '0.3')),
|
||||||
'CLOUD_CLEAR_MAX': float( os.getenv('CLOUD_CLEAR_MAX', '15.0')),
|
|
||||||
'CLOUD_MOSTLY_CLEAR_MAX': float( os.getenv('CLOUD_MOSTLY_CLEAR_MAX', '40.0')),
|
|
||||||
'CLOUD_CLOUDY_MAX': float( os.getenv('CLOUD_CLOUDY_MAX', '80.0')),
|
|
||||||
'CLOUD_MIN_SAMPLES': int( os.getenv('CLOUD_MIN_SAMPLES', '5')),
|
|
||||||
'CLOUD_LUX_WINDOW': int( os.getenv('CLOUD_LUX_WINDOW', '10')),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_influx_client = None
|
def write(c, fields, collection='weather'):
|
||||||
_influx_writer = None
|
field_str = ','.join(
|
||||||
_influx_url = None
|
f"{k}={'true' if v is True else 'false' if v is False else v}"
|
||||||
_influx_token = None
|
for k, v in fields.items() if v is not None
|
||||||
|
)
|
||||||
def get_writer(c):
|
if not field_str: return
|
||||||
global _influx_client, _influx_writer, _influx_url, _influx_token
|
requests.post(f"{c['DB_HOST']}/write", data=f"{collection} {field_str}", timeout=2)
|
||||||
if c['INFLUX_URL'] != _influx_url or c['INFLUX_TOKEN'] != _influx_token:
|
|
||||||
if _influx_client: _influx_client.close()
|
|
||||||
_influx_client = InfluxDBClient(url=c['INFLUX_URL'], token=c['INFLUX_TOKEN'], org=c['INFLUX_ORG'])
|
|
||||||
_influx_writer = _influx_client.write_api(write_options=SYNCHRONOUS)
|
|
||||||
_influx_url, _influx_token = c['INFLUX_URL'], c['INFLUX_TOKEN']
|
|
||||||
return _influx_writer
|
|
||||||
|
|
||||||
def write(c, measurement, fields, tags={}):
|
|
||||||
try:
|
|
||||||
p = Point(measurement).time(datetime.now(timezone.utc))
|
|
||||||
for k, v in tags.items(): p = p.tag(k, v)
|
|
||||||
for k, v in fields.items():
|
|
||||||
if v is not None: p = p.field(k, float(v) if isinstance(v, (int, float)) else v)
|
|
||||||
get_writer(c).write(bucket=c['INFLUX_BUCKET'], org=c['INFLUX_ORG'], record=p)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[InfluxDB] {measurement}: {e}")
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -131,57 +104,35 @@ def vpd(tc, rh):
|
|||||||
return round(es - (rh/100)*es, 3)
|
return round(es - (rh/100)*es, 3)
|
||||||
|
|
||||||
def gas_to_aqi(gas_ohms, humidity, gas_reference):
|
def gas_to_aqi(gas_ohms, humidity, gas_reference):
|
||||||
if gas_ohms is None: return None, None
|
if gas_ohms is None: return None
|
||||||
gas_score = min(gas_ohms / gas_reference, 1.0) * 75
|
gas_score = min(gas_ohms / gas_reference, 1.0) * 75
|
||||||
hum_score = 25 - abs(humidity - 40) * 0.5
|
hum_score = 25 - abs(humidity - 40) * 0.5
|
||||||
quality = min(max(gas_score + hum_score, 0), 100)
|
quality = min(max(gas_score + hum_score, 0), 100)
|
||||||
aqi = round((1 - quality / 100) * 500)
|
aqi = round((1 - quality / 100) * 500)
|
||||||
if aqi <= 50: label = 'Minimal'
|
return aqi
|
||||||
elif aqi <= 100: label = 'Low'
|
|
||||||
elif aqi <= 150: label = 'Moderate'
|
|
||||||
elif aqi <= 200: label = 'Medium'
|
|
||||||
elif aqi <= 300: label = 'High'
|
|
||||||
else: label = 'Hazard'
|
|
||||||
return aqi, label
|
|
||||||
|
|
||||||
def sea_level_pressure(pressure_hpa, alt_m):
|
def sea_level_pressure(pressure_hpa, alt_m):
|
||||||
if alt_m is None: return None
|
if alt_m is None: return None
|
||||||
return round(pressure_hpa / math.pow(1 - (alt_m / 44330.0), 5.255), 2)
|
return round(pressure_hpa / math.pow(1 - (alt_m / 44330.0), 5.255), 2)
|
||||||
|
|
||||||
def frost_risk(temp_c, dew_point_c):
|
|
||||||
margin = temp_c - dew_point_c
|
|
||||||
if temp_c <= 0: return 'High'
|
|
||||||
if temp_c <= 3 and margin < 2: return 'Moderate'
|
|
||||||
if temp_c <= 5: return 'Low'
|
|
||||||
return 'None'
|
|
||||||
|
|
||||||
def get_pressure_trend(window):
|
def get_pressure_trend(window):
|
||||||
if pressure_buffer.maxlen != window:
|
if pressure_buffer.maxlen != window:
|
||||||
pressure_buffer.__init__(window)
|
pressure_buffer.__init__(window)
|
||||||
if len(pressure_buffer) < 2: return None, None
|
if len(pressure_buffer) < 2: return None
|
||||||
delta = pressure_buffer[-1] - pressure_buffer[0]
|
delta = pressure_buffer[-1] - pressure_buffer[0]
|
||||||
label = 'Rising' if delta > 1.0 else 'Falling' if delta < -1.0 else 'Stable'
|
return round(delta, 2)
|
||||||
return round(delta, 2), label
|
|
||||||
|
|
||||||
def read_bme(c, sensor, alt_m=None):
|
def read_bme(c, sensor, alt_m=None):
|
||||||
null = {
|
if not sensor: return None, None, None
|
||||||
'temperature': None, 'humidity': None, 'humidity_abs': None,
|
|
||||||
'dew_point': None, 'pressure_hpa': None, 'pressure_slp': None,
|
|
||||||
'pressure_rate': None, 'vapor_pressure_deficit': None,
|
|
||||||
'heat_index': None, 'air_quality_ohms': None, 'air_quality': None,
|
|
||||||
}
|
|
||||||
if not sensor:
|
|
||||||
write(c, 'environment', null)
|
|
||||||
return None, None
|
|
||||||
try:
|
try:
|
||||||
if not sensor.get_sensor_data(): return None, None
|
if not sensor.get_sensor_data(): return None, None, None
|
||||||
t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure
|
t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure
|
||||||
gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None
|
gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None
|
||||||
aqi_score, aqi_label = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
|
aqi_score = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
|
||||||
pressure_buffer.append(p)
|
pressure_buffer.append(p)
|
||||||
p_rate, p_label = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
|
p_rate = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
|
||||||
dp = round(t - (100-rh)/5.0, 2)
|
dp = round(t - (100 - rh) / 5.0, 2)
|
||||||
write(c, 'environment', {
|
write(c, {
|
||||||
'temperature': round(t + c['TEMP_CALIBRATION'], 2),
|
'temperature': round(t + c['TEMP_CALIBRATION'], 2),
|
||||||
'humidity': round(rh, 2),
|
'humidity': round(rh, 2),
|
||||||
'dew_point': dp,
|
'dew_point': dp,
|
||||||
@@ -193,60 +144,103 @@ def read_bme(c, sensor, alt_m=None):
|
|||||||
'heat_index': heat_index(t, rh),
|
'heat_index': heat_index(t, rh),
|
||||||
'air_quality_ohms': gas,
|
'air_quality_ohms': gas,
|
||||||
'air_quality': aqi_score,
|
'air_quality': aqi_score,
|
||||||
}, tags={
|
|
||||||
'air_quality_label': aqi_label or '',
|
|
||||||
'pressure_trend': p_label or '',
|
|
||||||
'frost_risk': frost_risk(t, dp),
|
|
||||||
})
|
})
|
||||||
return t, rh
|
return t, rh, aqi_score
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[BME680] {e}")
|
print(f"[BME680] {e}")
|
||||||
return 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['magnitude']:
|
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
|
||||||
_mpu_peak = {
|
_mpu_peak = {
|
||||||
'seismic_x': round(ax, 3),
|
'seismic_x': round(ax, 3),
|
||||||
'seismic_y': round(ay, 3),
|
'seismic_y': round(ay, 3),
|
||||||
'seismic_z': round(az, 3),
|
'seismic_z': round(az, 3),
|
||||||
'magnitude': round(mag, 4),
|
'seismic_magnitude': round(mag, 4),
|
||||||
}
|
}
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
@@ -254,181 +248,125 @@ def flush_mpu(c):
|
|||||||
global _mpu_peak
|
global _mpu_peak
|
||||||
with _mpu_lock:
|
with _mpu_lock:
|
||||||
data, _mpu_peak = _mpu_peak, None
|
data, _mpu_peak = _mpu_peak, None
|
||||||
if data and data['magnitude'] > c['SEISMIC_NOISE_FLOOR']:
|
if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']:
|
||||||
write(c, 'seismic', 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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_compass(c):
|
def read_compass(c):
|
||||||
null = {'x': None, 'y': None, 'z': None, 'heading': None}
|
if not bus: return
|
||||||
if not bus: write(c, 'compass', null); return
|
|
||||||
try:
|
try:
|
||||||
addr = 0x1E
|
addr = 0x1E
|
||||||
try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D))
|
try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D))
|
||||||
d = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6))
|
d = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6))
|
||||||
if not d: write(c, 'compass', null); return
|
if not d: return
|
||||||
x = s16((d[1] << 8)|d[0])
|
x = s16((d[1] << 8) | d[0])
|
||||||
y = s16((d[3] << 8)|d[2])
|
y = s16((d[3] << 8) | d[2])
|
||||||
z = s16((d[5] << 8)|d[4])
|
z = s16((d[5] << 8) | d[4])
|
||||||
heading = math.degrees(math.atan2(y, x))
|
heading = math.degrees(math.atan2(y, x))
|
||||||
if heading < 0: heading += 360
|
if heading < 0: heading += 360
|
||||||
write(c, 'compass', {
|
write(c, {'compass_x': x, 'compass_y': y, 'compass_z': z, 'heading': round(heading, 1)})
|
||||||
'compass_x': x, 'compass_y': y, 'compass_z': z,
|
|
||||||
'heading': round(heading, 1),
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[QMC5883L] {e}")
|
print(f"[QMC5883L] {e}")
|
||||||
write(c, 'compass', null)
|
|
||||||
|
|
||||||
# ── LTR390 ───────────────────────────────────────────────────────────────────
|
# ── LTR390 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
uv_dose_mj = 0.0
|
uv_dose_mj = 0.0
|
||||||
uv_dose_date = datetime.now().date()
|
uv_dose_date = datetime.now().date()
|
||||||
last_uv_time = time.time()
|
last_uv_time = time.time()
|
||||||
dli_lux_acc = 0.0
|
dli_lux_acc = 0.0
|
||||||
dli_date = datetime.now().date()
|
dli_date = datetime.now().date()
|
||||||
last_lux_time = time.time()
|
last_lux_time = time.time()
|
||||||
daylight_start = None
|
daylight_start = None
|
||||||
|
|
||||||
DAYLIGHT_LUX_THRESHOLD = 50
|
DAYLIGHT_LUX_THRESHOLD = 50
|
||||||
|
|
||||||
_cloud_baseline = {}
|
def sun_elevation(lat, lon):
|
||||||
_cloud_lux_window = deque(maxlen=10)
|
now = datetime.now(timezone.utc)
|
||||||
|
doy = now.timetuple().tm_yday
|
||||||
|
hour_ut = now.hour + now.minute / 60 + now.second / 3600
|
||||||
|
decl = math.radians(23.45 * math.sin(math.radians((360 / 365) * (doy - 81))))
|
||||||
|
lstm = 15 * round(lon / 15)
|
||||||
|
eot = (9.87 * math.sin(math.radians(2 * (360/365) * (doy - 81)))
|
||||||
|
- 7.53 * math.cos(math.radians((360/365) * (doy - 81)))
|
||||||
|
- 1.5 * math.sin(math.radians((360/365) * (doy - 81))))
|
||||||
|
solar_time = hour_ut + (lon - lstm) / 15 + eot / 60
|
||||||
|
ha = math.radians(15 * (solar_time - 12))
|
||||||
|
lat_r = math.radians(lat)
|
||||||
|
elev = math.degrees(math.asin(
|
||||||
|
math.sin(lat_r) * math.sin(decl) +
|
||||||
|
math.cos(lat_r) * math.cos(decl) * math.cos(ha)
|
||||||
|
))
|
||||||
|
return elev
|
||||||
|
|
||||||
def _el_bucket(solar_el, bucket_size):
|
def estimate_cloud_cover(c, lux, temp, dew_point, lat, lon):
|
||||||
if solar_el is None: return None
|
elev = sun_elevation(lat, lon)
|
||||||
return round(solar_el / bucket_size) * bucket_size
|
temp_dp_spread = temp - dew_point
|
||||||
|
hum_cloud = max(0.0, min(100.0, (1 - temp_dp_spread / 20.0) * 100))
|
||||||
|
|
||||||
def moon_phase_factor():
|
if elev < c['CLOUD_SUN_MIN_ELEV']:
|
||||||
known_new = datetime(2024, 1, 11, tzinfo=timezone.utc)
|
return round(hum_cloud, 1), round(elev, 2)
|
||||||
lunar_cycle = 29.53058867
|
|
||||||
days = (datetime.now(timezone.utc) - known_new).total_seconds() / 86400
|
|
||||||
phase = (days % lunar_cycle) / lunar_cycle
|
|
||||||
return round(math.cos(math.pi * (phase - 0.5)) * 0.5 + 0.5, 3)
|
|
||||||
|
|
||||||
def update_cloud_baseline(c, solar_el, lux):
|
theoretical = max(c['CLOUD_LUX_CLEAR_SKY'] * math.sin(math.radians(elev)), 1.0)
|
||||||
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
|
solar_ratio = min(lux / theoretical, 1.0)
|
||||||
if bucket is None: return
|
solar_cloud = (1.0 - solar_ratio) * 100
|
||||||
if bucket not in _cloud_baseline:
|
|
||||||
_cloud_baseline[bucket] = deque(maxlen=c['CLOUD_HISTORY'])
|
|
||||||
_cloud_baseline[bucket].append(lux)
|
|
||||||
|
|
||||||
def get_expected_lux(c, solar_el):
|
w = c['CLOUD_HUMIDITY_WEIGHT']
|
||||||
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
|
blended = (1 - w) * solar_cloud + w * hum_cloud
|
||||||
if bucket is None: return None
|
return round(min(max(blended, 0.0), 100.0), 1), round(elev, 2)
|
||||||
history = _cloud_baseline.get(bucket)
|
|
||||||
if not history or len(history) < c['CLOUD_MIN_SAMPLES']: return None
|
|
||||||
sorted_vals = sorted(history)
|
|
||||||
idx = max(0, int(len(sorted_vals) * c['CLOUD_BASELINE_PCT']) - 1)
|
|
||||||
baseline = sorted_vals[idx]
|
|
||||||
if solar_el is not None and solar_el < 0:
|
|
||||||
baseline = baseline * moon_phase_factor()
|
|
||||||
return round(baseline, 2)
|
|
||||||
|
|
||||||
def classify_clouds(c, lux, solar_el):
|
def estimate_visibility(c, rh, aqi):
|
||||||
# Resize window if config changed
|
rh_clamped = min(rh, 99.5)
|
||||||
if _cloud_lux_window.maxlen != c['CLOUD_LUX_WINDOW']:
|
beta_rh = 0.000146 * math.exp(0.06 * rh_clamped)
|
||||||
_cloud_lux_window.__init__(maxlen=c['CLOUD_LUX_WINDOW'])
|
aqi_factor = 1.0 + ((aqi or 0) / 500.0) * 0.5
|
||||||
_cloud_lux_window.append(lux)
|
extinction = beta_rh * aqi_factor
|
||||||
|
return round(min(3.912 / extinction, c['VISIBILITY_MAX']), 2)
|
||||||
|
|
||||||
expected = get_expected_lux(c, solar_el)
|
def read_ltr(c, sensor, temp=None, dew_point=None, rh=None, aqi=None, lat=0.0, lon=0.0):
|
||||||
if expected is None or expected < 0.1:
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
ratio = min(lux / expected, 1.0)
|
|
||||||
cloud_pct = round((1 - ratio) * 100, 1)
|
|
||||||
|
|
||||||
volatility = None
|
|
||||||
if len(_cloud_lux_window) >= 3:
|
|
||||||
mean = statistics.mean(_cloud_lux_window)
|
|
||||||
if mean > 0:
|
|
||||||
volatility = round(statistics.stdev(_cloud_lux_window) / mean, 3)
|
|
||||||
|
|
||||||
vt = c['CLOUD_VOLATILITY_THRESH']
|
|
||||||
if volatility is not None and volatility > vt and c['CLOUD_CLEAR_MAX'] < cloud_pct < c['CLOUD_CLOUDY_MAX']:
|
|
||||||
label = 'Partly Cloudy'
|
|
||||||
elif cloud_pct < c['CLOUD_CLEAR_MAX']:
|
|
||||||
label = 'Clear'
|
|
||||||
elif cloud_pct < c['CLOUD_MOSTLY_CLEAR_MAX']:
|
|
||||||
label = 'Mostly Clear'
|
|
||||||
elif cloud_pct < c['CLOUD_CLOUDY_MAX']:
|
|
||||||
label = 'Cloudy'
|
|
||||||
else:
|
|
||||||
label = 'Overcast'
|
|
||||||
|
|
||||||
return cloud_pct, label, volatility
|
|
||||||
|
|
||||||
def visibility_estimate(lux, humidity, solar_el=None):
|
|
||||||
base_km = 50.0
|
|
||||||
hum_factor = max(0, 1 - ((humidity - 40) / 60)) if humidity > 40 else 1.0
|
|
||||||
if solar_el is not None and solar_el > 5:
|
|
||||||
lux_factor = min(lux / 10000, 1.0)
|
|
||||||
else:
|
|
||||||
lux_factor = 1.0
|
|
||||||
return round(base_km * hum_factor * lux_factor, 1)
|
|
||||||
|
|
||||||
def uv_index_label(uvi):
|
|
||||||
if uvi < 3: return 'Low'
|
|
||||||
elif uvi < 6: return 'Moderate'
|
|
||||||
elif uvi < 8: return 'High'
|
|
||||||
elif uvi < 11: return 'Very High'
|
|
||||||
else: return 'Extreme'
|
|
||||||
|
|
||||||
def read_ltr(c, sensor, solar_el, humidity=None):
|
|
||||||
global uv_dose_mj, uv_dose_date, last_uv_time
|
global uv_dose_mj, uv_dose_date, last_uv_time
|
||||||
global dli_lux_acc, dli_date, last_lux_time, daylight_start
|
global dli_lux_acc, dli_date, last_lux_time, daylight_start
|
||||||
|
|
||||||
null = {
|
if not sensor: return
|
||||||
'lux': None, 'uv_index': None, 'solar_wm2': None,
|
|
||||||
'uv_dose': None, 'clouds': None, 'daily_light_integral': None,
|
|
||||||
'daylight': None, 'visibility': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if not sensor: write(c, 'light', null); return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lux = round(sensor.lux, 2)
|
lux = round(sensor.lux, 2)
|
||||||
uvi = round(sensor.uvi, 2)
|
uvi = round(sensor.uvi, 2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[LTR390] {e}")
|
print(f"[LTR390] {e}")
|
||||||
write(c, 'light', null)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
today = datetime.now().date()
|
today = datetime.now().date()
|
||||||
|
|
||||||
if today != uv_dose_date:
|
if today != uv_dose_date: uv_dose_mj, uv_dose_date = 0.0, today
|
||||||
uv_dose_mj, uv_dose_date = 0.0, today
|
if today != dli_date: dli_lux_acc, dli_date, daylight_start = 0.0, today, None
|
||||||
if today != dli_date:
|
|
||||||
dli_lux_acc, dli_date, daylight_start = 0.0, today, None
|
|
||||||
|
|
||||||
dt = now - last_uv_time
|
uv_dose_mj += uvi * 25 * (now - last_uv_time)
|
||||||
uv_dose_mj += uvi * 25 * dt
|
|
||||||
dli_lux_acc += lux * (now - last_lux_time)
|
dli_lux_acc += lux * (now - last_lux_time)
|
||||||
last_uv_time = now
|
last_uv_time = now
|
||||||
last_lux_time = now
|
last_lux_time = now
|
||||||
|
|
||||||
dli = round(dli_lux_acc / 54 / 1_000_000, 4)
|
|
||||||
|
|
||||||
if lux >= DAYLIGHT_LUX_THRESHOLD and daylight_start is None:
|
if lux >= DAYLIGHT_LUX_THRESHOLD and daylight_start is None:
|
||||||
daylight_start = now
|
daylight_start = now
|
||||||
daylight_hours = round((now - daylight_start) / 3600, 2) if daylight_start else 0.0
|
|
||||||
|
|
||||||
update_cloud_baseline(c, solar_el, lux)
|
fields = {
|
||||||
cloud_pct, cloud_label, volatility = classify_clouds(c, lux, solar_el)
|
|
||||||
|
|
||||||
write(c, 'light', {
|
|
||||||
'lux': lux,
|
'lux': lux,
|
||||||
'uv_index': uvi,
|
'uv_index': uvi,
|
||||||
'solar_wm2': round(lux / 120, 2),
|
'solar_wm2': round(lux / 120, 2),
|
||||||
'uv_dose': round(uv_dose_mj, 2),
|
'uv_dose': round(uv_dose_mj, 2),
|
||||||
'clouds': cloud_pct,
|
'daily_light_integral': round(dli_lux_acc / 54 / 1_000_000, 4),
|
||||||
'daily_light_integral': dli,
|
}
|
||||||
'daylight': daylight_hours,
|
|
||||||
'visibility': visibility_estimate(lux, humidity, solar_el) if humidity else None,
|
if temp is not None and dew_point is not None:
|
||||||
}, tags={
|
cloud, sun_elev = estimate_cloud_cover(c, lux, temp, dew_point, lat, lon)
|
||||||
'clouds_label': cloud_label or '',
|
fields['clouds'] = cloud
|
||||||
'uv_index_label': uv_index_label(uvi),
|
fields['sun_elevation'] = sun_elev
|
||||||
})
|
|
||||||
|
if rh is not None:
|
||||||
|
fields['visibility'] = estimate_visibility(c, rh, aqi)
|
||||||
|
|
||||||
|
write(c, fields)
|
||||||
|
|
||||||
# ── TF-Luna ───────────────────────────────────────────────────────────────────
|
# ── TF-Luna ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -442,47 +380,35 @@ def calibrate_luna(c):
|
|||||||
for _ in range(c['LUNA_CAL_SAMPLES']):
|
for _ in range(c['LUNA_CAL_SAMPLES']):
|
||||||
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
||||||
if d:
|
if d:
|
||||||
dist, strength = d[0]|(d[1]<<8), d[2]|(d[3]<<8)
|
dist, strength = d[0] | (d[1] << 8), d[2] | (d[3] << 8)
|
||||||
if strength >= 100 and 0 < dist <= c['LUNA_MAX_DIST_CM']:
|
if strength >= 100 and 0 < dist <= c['LUNA_MAX_DIST_CM']:
|
||||||
readings.append(dist)
|
readings.append(dist)
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
if readings:
|
if readings:
|
||||||
luna_baseline_cm = round(sum(readings)/len(readings), 1)
|
luna_baseline_cm = round(sum(readings) / len(readings), 1)
|
||||||
print(f" ✅ TF-Luna baseline: {luna_baseline_cm} cm")
|
print(f" ✅ TF-Luna baseline: {luna_baseline_cm} cm")
|
||||||
else:
|
else:
|
||||||
print(" ❌ TF-Luna: calibration failed")
|
print(" ❌ TF-Luna: calibration failed")
|
||||||
|
|
||||||
def read_luna(c, temp_c):
|
def read_luna(c):
|
||||||
null = {'ground_distance': None, 'lidar_strength': None}
|
if not bus: return
|
||||||
if not bus: write(c, 'accumulation', null); return
|
|
||||||
try:
|
try:
|
||||||
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
||||||
if not d: write(c, 'ground', null); return
|
if not d: return
|
||||||
dist = d[0]|(d[1]<<8)
|
dist = d[0] | (d[1] << 8)
|
||||||
strength = d[2]|(d[3]<<8)
|
strength = d[2] | (d[3] << 8)
|
||||||
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']:
|
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']: return
|
||||||
write(c, 'accumulation', null); return
|
|
||||||
|
|
||||||
fields = {'ground_distance': dist, 'ground_calibration': luna_baseline_cm, 'lidar_strength': strength}
|
fields = {'ground_distance': dist, 'ground_calibration': luna_baseline_cm, 'lidar_strength': strength}
|
||||||
|
|
||||||
kind = 'none'
|
|
||||||
if luna_baseline_cm and (luna_baseline_cm - dist) >= c['LUNA_MIN_DEPTH_CM']:
|
if luna_baseline_cm and (luna_baseline_cm - dist) >= c['LUNA_MIN_DEPTH_CM']:
|
||||||
t = temp_c
|
depth = round(luna_baseline_cm - dist, 1)
|
||||||
depth = round(luna_baseline_cm - dist, 1)
|
|
||||||
kind = (
|
|
||||||
'flood' if t is not None and t > c['FLOOD_TEMP_THRESHOLD'] else
|
|
||||||
'slush' if t is not None and t > c['SLUSH_TEMP_THRESHOLD'] else
|
|
||||||
'snow' if strength > c['SNOW_STRENGTH_MIN'] else 'ice'
|
|
||||||
)
|
|
||||||
fields['accumulation'] = depth
|
fields['accumulation'] = depth
|
||||||
else:
|
else:
|
||||||
fields['accumulation'] = 0
|
fields['accumulation'] = 0
|
||||||
write(c, 'accumulation', fields, tags={'accumulation_type': kind})
|
write(c, fields)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[TF-Luna] {e}")
|
print(f"[TF-Luna] {e}")
|
||||||
write(c, 'accumulation', null)
|
|
||||||
|
|
||||||
# ── AS3935 ───────────────────────────────────────────────────────────────────
|
# ── AS3935 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
lightning_distances = deque(maxlen=10)
|
lightning_distances = deque(maxlen=10)
|
||||||
lightning_count = 0
|
lightning_count = 0
|
||||||
@@ -492,8 +418,7 @@ def init_as3935(c):
|
|||||||
if not bus: return
|
if not bus: return
|
||||||
try:
|
try:
|
||||||
addr = c['AS3935_ADDR']
|
addr = c['AS3935_ADDR']
|
||||||
val = (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG']
|
bus.write_byte_data(addr, 0x01, (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG'])
|
||||||
bus.write_byte_data(addr, 0x01, val)
|
|
||||||
bus.write_byte_data(addr, 0x02, c['AS3935_SPIKE_REJ'])
|
bus.write_byte_data(addr, 0x02, c['AS3935_SPIKE_REJ'])
|
||||||
bus.write_byte_data(addr, 0x00, 0x24)
|
bus.write_byte_data(addr, 0x00, 0x24)
|
||||||
print(" ✅ AS3935")
|
print(" ✅ AS3935")
|
||||||
@@ -511,25 +436,27 @@ def read_as3935(c):
|
|||||||
energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3]
|
energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3]
|
||||||
try_read(lambda: bus.read_byte_data(addr, 0x03))
|
try_read(lambda: bus.read_byte_data(addr, 0x03))
|
||||||
if interrupt not in (0x04, 0x08) or energy < 1000: return
|
if interrupt not in (0x04, 0x08) or energy < 1000: return
|
||||||
|
|
||||||
dist = data[6] & 0x3F
|
dist = data[6] & 0x3F
|
||||||
lightning_distances.append(dist)
|
lightning_distances.append(dist)
|
||||||
lightning_count += 1
|
lightning_count += 1
|
||||||
|
|
||||||
elapsed = time.time() - lightning_window_start
|
elapsed = time.time() - lightning_window_start
|
||||||
if elapsed >= 3600:
|
if elapsed >= 3600:
|
||||||
lightning_count = 1
|
lightning_count, lightning_window_start, elapsed = 1, time.time(), 1
|
||||||
lightning_window_start = time.time()
|
|
||||||
elapsed = 1
|
|
||||||
strike_rate = round(lightning_count / (elapsed / 3600), 1)
|
|
||||||
trend = 'Stationary'
|
trend = 'Stationary'
|
||||||
if len(lightning_distances) >= 3:
|
if len(lightning_distances) >= 3:
|
||||||
avg_early = sum(list(lightning_distances)[:3]) / 3
|
avg_early = sum(list(lightning_distances)[:3]) / 3
|
||||||
avg_late = sum(list(lightning_distances)[-3:]) / 3
|
avg_late = sum(list(lightning_distances)[-3:]) / 3
|
||||||
if avg_late < avg_early - 2: trend = 'Approaching'
|
if avg_late < avg_early - 2: trend = 'Approaching'
|
||||||
elif avg_late > avg_early + 2: trend = 'Retreating'
|
elif avg_late > avg_early + 2: trend = 'Retreating'
|
||||||
write(c, 'lightning', {
|
|
||||||
|
write(c, {
|
||||||
'lightning_distance': dist,
|
'lightning_distance': dist,
|
||||||
'lightning_rate': strike_rate,
|
'lightning_rate': round(lightning_count / (elapsed / 3600), 1),
|
||||||
}, tags={'storm_direction': trend})
|
'storm_trend': trend,
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[AS3935] {e}")
|
print(f"[AS3935] {e}")
|
||||||
|
|
||||||
@@ -551,66 +478,44 @@ def read_gps(c):
|
|||||||
'gps_satellites': int(msg.num_sats),
|
'gps_satellites': int(msg.num_sats),
|
||||||
})
|
})
|
||||||
if gps_cache:
|
if gps_cache:
|
||||||
write(c, 'gps', {k: v for k, v in gps_cache.items() if v is not None})
|
write(c, {k: v for k, v in gps_cache.items() if v is not None})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[GPS] {e}")
|
print(f"[GPS] {e}")
|
||||||
write(c, 'gps', {'latitude': None, 'longitude': None, 'altitude': None, 'gps_satellites': None})
|
|
||||||
return gps_cache
|
return gps_cache
|
||||||
|
|
||||||
def solar_elevation(lat, lon):
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
day = now.timetuple().tm_yday
|
|
||||||
decl = math.radians(23.45 * math.sin(math.radians((360/365)*(day-81))))
|
|
||||||
hour_angle = math.radians(((now.hour + now.minute/60) - 12) * 15 + lon)
|
|
||||||
lat_r = math.radians(lat)
|
|
||||||
el = math.degrees(math.asin(
|
|
||||||
math.sin(lat_r)*math.sin(decl) +
|
|
||||||
math.cos(lat_r)*math.cos(decl)*math.cos(hour_angle)
|
|
||||||
))
|
|
||||||
return round(el, 2)
|
|
||||||
|
|
||||||
# ── Rain ──────────────────────────────────────────────────────────────────────
|
# ── Rain ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_rain(c):
|
def read_rain(c):
|
||||||
try:
|
write(c, {'precipitation': 0, 'raining': False})
|
||||||
write(c, 'rain', {'precipitation': 0}, tags={'raining': False})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Rain] {e}")
|
|
||||||
|
|
||||||
# ── Wind ──────────────────────────────────────────────────────────────────────
|
# ── Wind ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_wind(c):
|
def read_wind(c):
|
||||||
try:
|
write(c, {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
|
||||||
write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Wind] {e}")
|
|
||||||
|
|
||||||
# ── System ────────────────────────────────────────────────────────────────────
|
# ── System ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def read_system(c):
|
def read_system(c):
|
||||||
try:
|
try:
|
||||||
cpu_temp = None
|
cpu_temp = None
|
||||||
temps = psutil.sensors_temperatures()
|
|
||||||
for key in ('cpu_thermal', 'coretemp', 'k10temp', 'acpitz'):
|
for key in ('cpu_thermal', 'coretemp', 'k10temp', 'acpitz'):
|
||||||
if key in temps:
|
if key in (temps := psutil.sensors_temperatures()):
|
||||||
cpu_temp = round(temps[key][0].current, 1)
|
cpu_temp = round(temps[key][0].current, 1)
|
||||||
break
|
break
|
||||||
|
|
||||||
cpu_freq = psutil.cpu_freq()
|
cpu_freq = psutil.cpu_freq()
|
||||||
vm = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
disk = psutil.disk_usage('/')
|
disk = psutil.disk_usage('/')
|
||||||
|
write(c, {
|
||||||
write(c, 'system', {
|
|
||||||
'cpu_temp': cpu_temp,
|
'cpu_temp': cpu_temp,
|
||||||
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
|
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
|
||||||
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
|
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
|
||||||
'mem_used_mb': round(vm.used / 1024**2, 1),
|
'mem_used_mb': round(mem.used / 1024**2, 1),
|
||||||
'mem_total_mb': round(vm.total / 1024**2, 1),
|
'mem_total_mb': round(mem.total / 1024**2, 1),
|
||||||
'mem_percent': vm.percent,
|
'mem_percent': mem.percent,
|
||||||
'disk_used_gb': round(disk.used / 1024**3, 2),
|
'disk_used_gb': round(disk.used / 1024**3, 2),
|
||||||
'disk_total_gb': round(disk.total / 1024**3, 2),
|
'disk_total_gb': round(disk.total / 1024**3, 2),
|
||||||
'disk_percent': disk.percent,
|
'disk_percent': disk.percent,
|
||||||
})
|
}, collection='system')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[System] {e}")
|
print(f"[System] {e}")
|
||||||
|
|
||||||
@@ -626,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()
|
||||||
@@ -639,24 +544,31 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
last_temp = None
|
last_temp = None
|
||||||
last_rh = None
|
last_rh = None
|
||||||
|
last_aqi = None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
c = cfg()
|
c = cfg()
|
||||||
gps = read_gps(c)
|
gps = read_gps(c)
|
||||||
|
|
||||||
lat = gps.get('latitude') or c['LATITUDE']
|
lat = gps.get('latitude') or c['LATITUDE']
|
||||||
lon = gps.get('longitude') or c['LONGITUDE']
|
lon = gps.get('longitude') or c['LONGITUDE']
|
||||||
alt = gps.get('altitude') or c['ALTITUDE']
|
alt = gps.get('altitude') or c['ALTITUDE']
|
||||||
sol = solar_elevation(lat, lon) if lat and lon else None
|
|
||||||
|
|
||||||
result = read_bme(c, bme_sensor, alt_m=alt)
|
result = read_bme(c, bme_sensor, alt_m=alt)
|
||||||
if result and result[0] is not None:
|
if result and result[0] is not None:
|
||||||
last_temp, last_rh = result
|
last_temp, last_rh, last_aqi = result
|
||||||
|
|
||||||
|
dp = None
|
||||||
|
if last_temp is not None and last_rh is not None:
|
||||||
|
dp = round(last_temp - (100 - last_rh) / 5.0, 2)
|
||||||
|
|
||||||
flush_mpu(c)
|
flush_mpu(c)
|
||||||
read_compass(c)
|
read_compass(c)
|
||||||
read_ltr(c, ltr_sensor, sol, humidity=last_rh)
|
read_ltr(c, ltr_sensor,
|
||||||
read_luna(c, last_temp)
|
temp=last_temp, dew_point=dp,
|
||||||
|
rh=last_rh, aqi=last_aqi,
|
||||||
|
lat=lat, lon=lon)
|
||||||
|
read_luna(c)
|
||||||
read_as3935(c)
|
read_as3935(c)
|
||||||
read_rain(c)
|
read_rain(c)
|
||||||
read_wind(c)
|
read_wind(c)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
influxdb-client
|
requests
|
||||||
python-dotenv
|
python-dotenv
|
||||||
smbus2
|
smbus2
|
||||||
adafruit-circuitpython-ltr390
|
adafruit-circuitpython-ltr390
|
||||||
|
|||||||
@@ -1,683 +0,0 @@
|
|||||||
{
|
|
||||||
"a319": {
|
|
||||||
"w": 23,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-10 -10 380 373",
|
|
||||||
"strokeScale": 16,
|
|
||||||
"path": "M160.574 263.98c2.88 19.23 5.92 26.17 5.61 29.66.04 1.3-.3 2.17-.93 2.67-7.24 5.72-50.22 32.65-50.66 34.44-.76 2.3-.53 12.26.12 12.92l56.5-13.24c2.01 7.89 4.2 14.9 6.43 22.07h4.91c2.23-7.16 4.42-14.18 6.43-22.07l56.5 13.24c.65-.65.88-10.62.12-12.92-.44-1.78-43.42-28.72-50.67-34.44-.62-.5-.96-1.38-.93-2.67-.3-3.5 2.73-10.43 5.61-29.66 2.43-20.77 1-53.35 1.54-78.49h27.68c-.35 2.48 1.18 7.97 2.1 7.79 1.6-.84 1.85-5.49 1.83-8.28h5.81c.22 1.21.39 3.83 1.3 4.01 1.13 0 1.24-3.07 1.44-4.01 9.06-.15 9.09.66 22.46 4.28.6.15 1.11.51 1.14 1.11-.1 3.12.49 8.1 2 8.25 1.49-.62 1.9-5.29 1.99-7.76l33.56 10.1c-.11 4.05.39 8.45 1.75 9.14 1.3.16 1.96-5.45 2.43-7.8l50.94 15.49c.63 2.2-.41 4.93 1.17 4.83.34.02.69-.15.77-.53.26-3.24.3-6.89.28-9.93l-.12-6.34c-.1-1.34-.06-2.72-1.95-4.33-7.72-5.4-73.22-37.45-107.72-56.62-2.87-1.59-2.62-1.71-2.81-2.26-.14-1.19 1.33-1.04 1.52-2.13.8-1.09 4.16-26.76.37-35.02l-18.37.3c-.3 0-.16.37-.3.6-2.85 11.04-1.55 17.15-.25 28.62a610 610 0 0 1-25.56-13.47c-1.03-.84-2.79-3.24-2.86-5.2l-1.03-74.98c-.02-10.65-10.22-33.63-16.25-38.93a6.64 6.64 0 0 0-4.14-1.92c-.15 0-2.32.2-4.22 1.92-6.03 5.3-16.22 28.28-16.24 38.93l-1.32 74.99c-.07 1.94-1.83 4.35-2.86 5.19-7.05 4.05-17.39 9.35-25.56 13.47 1.3-11.47 2.6-17.58-.24-28.62-.14-.23 0-.6-.3-.61l-18.38-.3c-3.78 8.27-.42 33.94.38 35.03.19 1.09 1.66.94 1.52 2.13-.2.55.05.67-2.81 2.26-34.5 19.17-100 51.2-107.72 56.62-1.9 1.61-1.86 2.99-1.96 4.33 0 1.68-.37 9.79.17 16.27.08.38.42.55.77.53 1.58.1.54-2.62 1.16-4.83 2.36-1.05 35.27-10.8 50.94-15.49.48 2.35 1.13 7.96 2.43 7.8 1.37-.7 1.87-5.09 1.75-9.14l33.56-10.1c.09 2.47.5 7.14 2 7.76 1.5-.15 2.09-5.14 2-8.25.02-.6.54-.96 1.14-1.1 13.37-3.63 13.4-4.45 22.45-4.3.2.95.32 4.03 1.44 4.02.92-.18 1.08-2.8 1.3-4.01h5.81c-.02 2.8.23 7.44 1.83 8.28.93.18 2.45-5.31 2.1-7.79h27.69c.47 26.24-1.3 62.82 1.41 78.49z"
|
|
||||||
},
|
|
||||||
"a320": {
|
|
||||||
"w": 23,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-10 -10 380 415",
|
|
||||||
"strokeScale": 16,
|
|
||||||
"path": "m359.814 226.26-.13-6.33c-.1-1.34-.06-2.72-1.95-4.34-7.72-5.4-73.22-37.44-107.72-56.6-2.87-1.6-2.62-1.73-2.81-2.27-.14-1.19 1.33-1.04 1.52-2.13.8-1.1 4.16-26.77.37-35.03l-18.37.3c-.3 0-.16.38-.3.6-2.85 11.05-1.55 17.16-.25 28.62a588 588 0 0 1-25.56-13.47c-1.03-.83-2.79-3.24-2.86-5.18-.84-5.06-.6-83.1-1.17-89.08-.02-10.65-10.22-33.63-16.25-38.93a6.64 6.64 0 0 0-4.14-1.92c-.15 0-2.32.2-4.21 1.92-6.04 5.3-16.23 28.28-16.25 38.92-.58 5.99-.33 84.03-1.18 89.08-.07 1.95-1.83 4.36-2.86 5.2-7.05 4.05-17.39 9.35-25.56 13.46 1.3-11.46 2.6-17.57-.24-28.62-.14-.22 0-.6-.3-.6l-18.38-.3c-3.78 8.26-.42 33.93.38 35.03.19 1.09 1.66.94 1.52 2.13-.2.54.05.67-2.81 2.26-34.5 19.17-100 51.2-107.73 56.61-1.89 1.62-1.85 3-1.95 4.33 0 1.69-.37 9.8.17 16.28.08.38.42.54.76.52 1.59.1.55-2.62 1.17-4.83 2.36-1.04 35.27-10.8 50.94-15.48.47 2.34 1.13 7.95 2.43 7.8 1.36-.7 1.87-5.1 1.75-9.15l33.56-10.1c.09 2.47.5 7.14 2 7.76 1.5-.15 2.09-5.13 2-8.24.02-.6.53-.96 1.14-1.11 13.37-3.62 13.4-4.44 22.45-4.29.2.94.32 4.02 1.44 4.01.92-.17 1.08-2.8 1.3-4h5.81c-.02 2.79.23 7.44 1.83 8.27.92.18 2.45-5.3 2.1-7.78h27.69c.3 40.33-.65 95.32 1.47 108.16 2.88 19.23 5.91 26.17 5.61 29.65.04 1.3-.3 2.18-.93 2.68-7.25 5.72-50.23 32.65-50.67 34.44-.76 2.3-.53 12.26.13 12.92l56.5-13.24c2 7.89 4.2 14.9 6.42 22.07h4.92a424 424 0 0 0 6.42-22.07l56.5 13.24c.65-.65.89-10.62.13-12.92-.44-1.78-43.42-28.72-50.67-34.44-.63-.5-.97-1.38-.93-2.67-.3-3.5 2.73-10.43 5.61-29.66 2.12-12.83 1.17-67.83 1.47-108.16h27.69c-.35 2.48 1.17 7.97 2.1 7.78 1.6-.83 1.85-5.48 1.83-8.27h5.8c.23 1.2.4 3.83 1.31 4 1.12.02 1.23-3.07 1.44-4 9.05-.16 9.08.66 22.45 4.28.6.15 1.12.5 1.15 1.1-.1 3.12.49 8.1 2 8.25 1.49-.62 1.9-5.28 1.99-7.76l33.56 10.1c-.12 4.06.39 8.45 1.75 9.15 1.3.15 1.96-5.46 2.43-7.8l50.94 15.49c.62 2.2-.42 4.92 1.17 4.83.34.02.68-.15.76-.53.27-3.24.31-6.9.29-9.94z"
|
|
||||||
},
|
|
||||||
"a321": {
|
|
||||||
"w": 23,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-10 -10 380 485",
|
|
||||||
"strokeScale": 16,
|
|
||||||
"path": "M160.674 375.1c2.88 19.24 5.91 26.17 5.61 29.66.04 1.3-.3 2.18-.93 2.67-7.24 5.72-50.22 32.66-50.66 34.45-.76 2.3-.53 12.26.12 12.92l56.5-13.25c2 7.9 4.2 14.91 6.42 22.08h4.92c2.23-7.16 4.42-14.18 6.43-22.08l56.5 13.25c.65-.66.88-10.62.12-12.92-.44-1.79-43.42-28.72-50.67-34.44-.62-.5-.96-1.38-.93-2.68-.3-3.49 2.73-10.42 5.61-29.65 3.14-16.96 1.06-90.04 1.44-134.91h27.68c-.35 2.47 1.18 7.96 2.1 7.78 1.6-.84 1.85-5.48 1.83-8.28h5.81c.22 1.21.39 3.84 1.3 4.01 1.13.01 1.24-3.07 1.44-4 9.06-.16 9.09.65 22.46 4.28.6.14 1.11.5 1.14 1.1-.1 3.12.49 8.1 2 8.25 1.5-.62 1.9-5.29 2-7.76l33.55 10.1c-.11 4.05.4 8.45 1.76 9.14 1.3.16 1.95-5.45 2.43-7.8l50.93 15.49c.63 2.2-.41 4.93 1.17 4.83.34.02.69-.14.77-.53.27-3.24.3-6.89.28-9.93l-.12-6.34c-.1-1.34-.06-2.72-1.95-4.33-7.72-5.4-73.22-37.45-107.72-56.61-2.87-1.6-2.62-1.72-2.81-2.26-.14-1.2 1.33-1.05 1.52-2.13.8-1.1 4.16-26.77.37-35.03l-18.37.3c-.3 0-.16.38-.3.6-2.85 11.05-1.55 17.15-.24 28.62-8.18-4.12-18.52-9.42-25.57-13.47-1.03-.84-2.79-3.24-2.86-5.19l-1.52-129.7c-.02-10.64-10.21-33.62-16.25-38.92a6.64 6.64 0 0 0-4.13-1.92c-.16 0-2.32.2-4.22 1.92-6.03 5.3-16.23 28.28-16.25 38.93l-.83 129.69c-.06 1.95-1.83 4.35-2.85 5.19-7.06 4.05-17.4 9.35-25.57 13.47 1.3-11.47 2.61-17.58-.24-28.62-.14-.23 0-.6-.3-.6l-18.38-.3c-3.78 8.26-.42 33.93.38 35.02.19 1.1 1.66.94 1.52 2.13-.19.55.05.67-2.81 2.26-34.5 19.17-100 51.21-107.72 56.62-1.9 1.61-1.86 2.99-1.95 4.33-.01 1.68-.38 9.79.16 16.27.08.39.42.55.77.53 1.58.1.54-2.62 1.16-4.83 2.37-1.05 35.27-10.8 50.94-15.48.48 2.34 1.14 7.95 2.43 7.8 1.37-.7 1.87-5.1 1.75-9.15l33.57-10.1c.08 2.47.5 7.14 1.99 7.76 1.5-.15 2.1-5.13 2-8.25.02-.6.54-.96 1.14-1.1 13.37-3.63 13.4-4.44 22.46-4.29.2.94.3 4.02 1.43 4.01.92-.18 1.08-2.8 1.3-4h5.81c-.01 2.78.23 7.43 1.83 8.27.93.18 2.45-5.3 2.1-7.78h27.69c.5 44.97-.9 117.74 1.5 134.9z"
|
|
||||||
},
|
|
||||||
"p8": {
|
|
||||||
"w": 24.8,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-2 -2 83.5 87.3",
|
|
||||||
"strokeScale": 3,
|
|
||||||
"path": "M39.156 80.404c-1.458.366-14.151 2.859-14.151 2.859l-.069-2.434 12.769-7.019c-1.535-6.779-1.876-8.758-2.061-14.716l-.022-15.412-4.679.026a4.9 4.9 0 0 1-.429 2.125c-.418-.667-.401-1.322-.397-2.101-.794 0-1.561.198-2.352.329.024.836-.095 1.488-.437 2.156-.347-.635-.352-1.136-.402-1.971l-5.735 1.323c-.008.857.04 1.345-.392 2.119-.373-.631-.41-1.257-.41-1.962L6.055 48.877c-1.786.992-4.702 3.064-6.013 4.015-.312-1.197 3.79-5.921 4.842-6.697 7.89-4.004 16.208-7.251 23.891-11.472-.101-.701-.568-.221-.737-.493-.311-2.068-.344-4.905-.118-6.453.04-.224.102-.456.299-.455 1.586.008 1.912-.042 3.26-.013.198.004.294.208.331.427a28.3 28.3 0 0 1 .083 4.839l3.735-3.088.069-13.676C35.968 9.73 37.55.467 39.554.024c2.004.443 3.586 9.706 3.858 15.785.103 1.762.095 11.43.082 13.678l3.735 3.086a28 28 0 0 1 .075-4.847c.037-.218.124-.415.321-.419 1.349-.029 1.673.021 3.26.013.198 0 .259.233.299.456.225 1.548.194 4.384-.118 6.452-.169.274-.635-.208-.736.495 7.681 4.22 16.001 7.468 23.892 11.471 1.226.61 5.36 5.79 5.331 6.812-1.818-1.045-4.568-3.299-6.503-4.129l-14.335-3.153c.001.705-.037 1.332-.41 1.963-.431-.775-.384-1.263-.392-2.119l-5.735-1.323c-.049.833-.054 1.336-.401 1.971-.341-.669-.46-1.322-.437-2.156-.791-.132-1.561-.331-2.353-.331.004.781.021 1.434-.397 2.101a4.9 4.9 0 0 1-.429-2.123l-4.679-.026-.011 15.412c-.185 5.958-.527 7.938-2.061 14.715l12.769 7.018-.069 2.437-14.151-2.86c-.098.415-.09 1.303-.402 1.292-.312.012-.304-.876-.402-1.291z",
|
|
||||||
"accent": [
|
|
||||||
"M39.554 17.81c.246 0 .23.093.24.575s.022.597-.24.595-.23-.083-.233-.592-.012-.579.233-.578z",
|
|
||||||
"M39.554 23.831c.246 0 .23.093.24.575s.022.597-.24.596-.23-.084-.233-.593-.012-.578.233-.578z",
|
|
||||||
"M39.554 29.75c.248 0 .232.078.241.479s.023.497-.242.496-.232-.07-.234-.494-.012-.481.235-.481z",
|
|
||||||
"M39.554 39.749c.25 0 .233.07.242.43s.023.448-.243.447-.233-.062-.235-.444-.012-.433.236-.433zm0 5.4c.25 0 .233.07.242.43s.023.448-.243.447-.233-.062-.235-.444-.012-.433.236-.433zm0 9c.25 0 .233.07.242.43s.023.448-.243.447-.233-.062-.235-.444-.012-.433.236-.433z",
|
|
||||||
"M39.548 57.735c.2 0 .188.063.195.386s.019.4-.196.399-.188-.056-.19-.397-.01-.388.191-.387z"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"e737": {
|
|
||||||
"w": 23.5,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-2 -2 74.5 74.5",
|
|
||||||
"strokeScale": 3,
|
|
||||||
"path": "M34.89 67.926c-1.458.367-14.152 2.859-14.152 2.859l-.069-2.436 12.769-7.019c-1.535-6.778-1.875-8.756-2.061-14.716l-.016-9.017-4.68.026c.014.786-.128 1.422-.429 2.124-.418-.666-.401-1.321-.397-2.1-.793-.001-1.561.197-2.352.329.023.836-.096 1.488-.437 2.157-.347-.636-.352-1.137-.402-1.972l-5.735 1.324c-.008.857.04 1.345-.392 2.119-.372-.631-.411-1.258-.41-1.963L1.794 42.794c-1.953.524-1.762.769-1.765-1.705 0-.521.117-.752.594-.977 7.89-4.004 16.21-7.251 23.891-11.472-.1-.702-.567-.221-.736-.494-.312-2.068-.343-4.905-.118-6.453.04-.223.102-.457.299-.456 1.586.008 1.912-.042 3.259-.013.197.004.294.209.331.428.162 1.382.191 3.489.074 4.848l3.736-3.087c-.013-2.248-.028-5.838.075-7.6.271-6.08 1.853-15.343 3.858-15.786 2.004.443 3.586 9.706 3.858 15.785.103 1.762.088 5.352.075 7.6l3.736 3.087c-.116-1.358-.087-3.465.074-4.848.037-.219.133-.424.331-.428 1.348-.029 1.674.021 3.259.013.197-.001.259.232.299.456.226 1.548.194 4.385-.118 6.453-.169.273-.636-.207-.736.494C53.75 32.86 62.07 36.108 69.96 40.111c.478.225.595.456.594.977.044 2.197.194 2.108-1.765 1.705l-14.335-3.152c.001.705-.037 1.332-.41 1.963-.431-.774-.384-1.263-.392-2.119l-5.735-1.324c-.049.836-.055 1.336-.402 1.972-.341-.669-.46-1.322-.437-2.157-.791-.132-1.559-.33-2.352-.329.004.779.021 1.434-.397 2.1-.301-.702-.443-1.338-.429-2.124l-4.68-.026-.016 9.017c-.186 5.959-.526 7.938-2.061 14.716l12.769 7.019-.069 2.436-14.152-2.859c-.097.415-.089 1.303-.402 1.291-.312.012-.304-.876-.402-1.291z",
|
|
||||||
"accent": "M35.296 32.772c1.952 0 1.853 1.262 1.977 2.96l.007 16.629c-.038 1.8-.07 2.897-1.988 2.888-1.914 0-1.879-1.018-1.977-2.877l.003-16.631c.073-1.701.025-2.969 1.977-2.969z"
|
|
||||||
},
|
|
||||||
"b737": {
|
|
||||||
"w": 23.5,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-2 -2 74.5 74.7",
|
|
||||||
"strokeScale": 3,
|
|
||||||
"path": "M34.889 67.926c-1.458.366-14.151 2.859-14.151 2.859l-.069-2.435 12.768-7.019c-1.535-6.777-1.875-8.756-2.06-14.715l-.017-9.018-4.679.026c.018.731-.129 1.457-.429 2.125-.418-.667-.401-1.322-.397-2.101-.794 0-1.561.198-2.352.329.024.836-.095 1.488-.437 2.156-.347-.635-.352-1.136-.402-1.971l-5.735 1.323c-.008.857.04 1.345-.392 2.119-.373-.631-.41-1.257-.41-1.962L1.793 42.793c-1.957.397-1.572.561-1.74 1.544.013-.86-.036-2.334-.026-3.249 0-.52.116-.751.595-.976 7.89-4.004 16.208-7.251 23.891-11.472-.101-.701-.568-.221-.737-.493-.311-2.068-.344-4.905-.118-6.453.04-.224.102-.456.299-.455 1.586.008 1.912-.042 3.26-.013.198.004.294.208.331.427a28.31 28.31 0 0 1 .074 4.847l3.736-3.088c-.013-2.249-.028-5.838.075-7.6C31.705 9.732 33.285.469 35.291.024c2.004.443 3.585 9.706 3.858 15.786.103 1.762.087 5.351.074 7.6l3.736 3.088c-.116-1.359-.087-3.466.074-4.849.037-.218.134-.423.331-.427 1.349-.029 1.673.021 3.26.013.198 0 .259.233.299.456.225 1.548.194 4.384-.118 6.452-.169.274-.635-.208-.736.495 7.681 4.22 16.001 7.468 23.892 11.471.476.225.594.456.593.978.011.914-.04 2.388-.026 3.248-.168-.983.217-1.147-1.738-1.543L54.453 39.64c.001.705-.037 1.332-.41 1.963-.431-.775-.384-1.263-.392-2.119l-5.735-1.323c-.049.833-.054 1.336-.401 1.971-.341-.669-.46-1.322-.437-2.156-.791-.132-1.561-.331-2.353-.331.004.781.021 1.434-.397 2.101-.3-.667-.446-1.392-.429-2.123l-4.679-.026-.016 9.017c-.185 5.96-.527 7.938-2.061 14.716l12.769 7.018-.069 2.435-14.153-2.858c-.097.414-.089 1.302-.401 1.29-.312.013-.304-.876-.402-1.29z"
|
|
||||||
},
|
|
||||||
"b738": {
|
|
||||||
"w": 23.5,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-2 -2 74.5 87.3",
|
|
||||||
"strokeScale": 3,
|
|
||||||
"path": "M34.894 80.404c-1.458.366-14.151 2.859-14.151 2.859l-.069-2.434 12.769-7.019c-1.535-6.779-1.876-8.758-2.061-14.716l-.022-15.412-4.679.026c.018.731-.129 1.457-.429 2.125-.418-.667-.401-1.322-.397-2.101-.794 0-1.561.198-2.352.329.024.836-.095 1.488-.437 2.156-.347-.635-.352-1.136-.402-1.971l-5.735 1.323c-.008.857.04 1.345-.392 2.119-.373-.631-.41-1.257-.41-1.962L1.793 48.877c-1.957.397-1.572.561-1.74 1.544.013-.86-.036-2.334-.026-3.249 0-.52.116-.751.595-.976 7.89-4.004 16.208-7.251 23.891-11.472-.101-.701-.568-.221-.737-.493-.311-2.068-.344-4.905-.118-6.453.04-.224.102-.456.299-.455 1.586.008 1.912-.042 3.26-.013.198.004.294.208.331.427a28.34 28.34 0 0 1 .083 4.839l3.735-3.088.069-13.676C31.706 9.73 33.288.467 35.292.024c2.004.443 3.586 9.706 3.858 15.785.103 1.762.095 11.43.082 13.678l3.735 3.086a28.18 28.18 0 0 1 .075-4.847c.037-.218.124-.415.321-.419 1.349-.029 1.673.021 3.26.013.198 0 .259.233.299.456.225 1.548.194 4.384-.118 6.452-.169.274-.635-.208-.736.495 7.681 4.22 16.001 7.468 23.892 11.471.476.225.594.456.593.978.011.914-.04 2.388-.026 3.248-.168-.983.217-1.148-1.738-1.543l-14.335-3.153c.001.705-.037 1.332-.41 1.963-.431-.775-.384-1.263-.392-2.119l-5.735-1.323c-.049.833-.054 1.336-.401 1.971-.341-.669-.46-1.322-.437-2.156-.791-.132-1.561-.331-2.353-.331.004.781.021 1.434-.397 2.101-.3-.667-.446-1.392-.429-2.123l-4.679-.026-.011 15.412c-.185 5.958-.527 7.938-2.061 14.715l12.769 7.018-.069 2.437-14.151-2.86c-.098.415-.09 1.303-.402 1.292-.312.012-.304-.876-.402-1.291z"
|
|
||||||
},
|
|
||||||
"b739": {
|
|
||||||
"w": 23.5,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-2 -2 74.5 92.7",
|
|
||||||
"strokeScale": 3,
|
|
||||||
"path": "M34.889 85.862c-1.458.366-14.151 2.859-14.151 2.859l-.069-2.435 12.769-7.018c-1.535-6.779-1.876-8.758-2.061-14.716l-.017-18.041-4.679.026c.018.731-.129 1.457-.429 2.125-.418-.667-.401-1.322-.397-2.101-.794 0-1.561.198-2.352.329.024.836-.095 1.488-.437 2.156-.347-.635-.352-1.136-.402-1.971l-5.735 1.323c-.008.857.04 1.345-.392 2.119-.373-.631-.41-1.257-.41-1.962L1.793 51.707c-1.957.397-1.572.561-1.74 1.544.013-.86-.036-2.334-.026-3.249 0-.52.116-.751.595-.976 7.89-4.004 16.208-7.251 23.891-11.472-.101-.701-.568-.221-.737-.493-.311-2.068-.344-4.905-.118-6.453.04-.224.102-.456.299-.455 1.586.008 1.912-.042 3.26-.013.198.004.298.21.335.429.161 1.382.198 3.481.082 4.839l3.736-3.086.065-16.507C31.706 9.732 33.287.469 35.292.026c2.004.443 3.585 9.706 3.858 15.785.103 1.762.098 14.258.085 16.507l3.736 3.088a28.19 28.19 0 0 1 .071-4.85c.037-.218.124-.415.321-.419 1.349-.029 1.673.021 3.26.013.198 0 .259.233.299.456.225 1.548.194 4.384-.118 6.452-.169.274-.635-.208-.736.495 7.681 4.22 16.001 7.468 23.892 11.471.476.225.594.456.593.978.011.914-.04 2.388-.026 3.248-.168-.983.217-1.148-1.738-1.543l-14.335-3.153c.001.705-.037 1.332-.41 1.963-.431-.775-.384-1.263-.392-2.119l-5.735-1.323c-.049.833-.054 1.336-.401 1.971-.341-.669-.46-1.322-.437-2.156-.791-.132-1.561-.331-2.353-.331.004.781.021 1.434-.397 2.101-.3-.667-.446-1.392-.429-2.123l-4.679-.026-.016 18.039c-.185 5.96-.527 7.938-2.061 14.716l12.769 7.018-.069 2.437-14.151-2.86c-.098.415-.09 1.303-.402 1.292-.312.012-.304-.877-.401-1.291z"
|
|
||||||
},
|
|
||||||
"airliner": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-1 -2 34 34",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M16 1c-.17 0-.67.58-.9 1.03-.6 1.21-.6 1.15-.65 5.2-.04 2.97-.08 3.77-.18 3.9-.15.17-1.82 1.1-1.98 1.1-.08 0-.1-.25-.05-.83.03-.5.01-.92-.05-1.08-.1-.25-.13-.26-.71-.26-.82 0-.86.07-.78 1.5.03.6.08 1.17.11 1.25.05.12-.02.2-.25.33l-8 4.2c-.2.2-.18.1-.19 1.29 3.9-1.2 3.71-1.21 3.93-1.21.06 0 .1 0 .13.14.08.3.28.3.28-.04 0-.25.03-.27 1.16-.6.65-.2 1.22-.35 1.28-.35.05 0 .12.04.15.17.07.3.27.27.27-.08 0-.25.01-.27.7-.47.68-.1.98-.09 1.47-.1.18 0 .22 0 .26.18.06.34.22.35.27-.01.04-.2.1-.17 1.06-.14l1.07.02.05 4.2c.05 3.84.07 4.28.26 5.09.11.49.2.99.2 1.11 0 .19-.31.43-1.93 1.5l-1.93 1.26v1.02l4.13-.95.63 1.54c.05.07.12.09.19.09s.14-.02.19-.09l.63-1.54 4.13.95V29.3l-1.93-1.27c-1.62-1.06-1.93-1.3-1.93-1.49 0-.12.09-.62.2-1.11.19-.81.2-1.25.26-5.09l.05-4.2 1.07-.02c.96-.03 1.02-.05 1.06.14.05.36.21.35.27 0 .04-.17.08-.16.26-.16.49 0 .8-.02 1.48.1.68.2.69.21.69.46 0 .35.2.38.27.08.03-.13.1-.17.15-.17.06 0 .63.15 1.28.34 1.13.34 1.16.36 1.16.61 0 .35.2.34.28.04.03-.13.07-.14.13-.14.22 0 .03 0 3.93 1.2-.01-1.18.02-1.07-.19-1.27l-8-4.21c-.23-.12-.3-.21-.25-.33.03-.08.08-.65.11-1.25.08-1.43.04-1.5-.78-1.5-.58 0-.61.01-.71.26-.06.16-.08.58-.05 1.08.04.58.03.83-.05.83-.16 0-1.83-.93-1.98-1.1-.1-.13-.14-.93-.18-3.9-.05-4.05-.05-3.99-.65-5.2C16.67 1.58 16.17 1 16 1z"
|
|
||||||
},
|
|
||||||
"blimp": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-3.7 -3 32 32",
|
|
||||||
"path": "M12.4 26.77v-.81s.71-.24 1.42-1.7c0 0 .1.14.15.17.07.02 2.02.08 2.02.08s.18 0 .17-.3v-1.45s.14-.47-1.3-1.22c0 0 5.25-19.33-2.56-21.8-7.81 2.47-2.57 21.8-2.57 21.8-1.43.75-1.3 1.22-1.3 1.22s.02 1.16 0 1.46c0 .3.18.29.18.29s1.95-.06 2.01-.08c.06-.03.15-.17.15-.17.71 1.46 1.42 1.7 1.42 1.7v.81c0 .17.11.17.11.17s.1 0 .1-.17z",
|
|
||||||
"accent": "m12.48-.52-.18.25c-4.72 7.7-.1 26.23-.1 26.23l-.18.13m.03-26.57.25.21c4.72 7.7.1 26.23.1 26.23l.18.12",
|
|
||||||
"accentMult": 0.8
|
|
||||||
},
|
|
||||||
"balloon": {
|
|
||||||
"w": 13,
|
|
||||||
"h": 17,
|
|
||||||
"noRotate": true,
|
|
||||||
"viewBox": "-2 -2 13 17",
|
|
||||||
"path": "M3.56,12.75a.49.49,0,0,1-.46-.34L2.63,11a.51.51,0,0,1,.07-.44l.1-.1-2-3.68a.48.48,0,0,1-.05-.17,4.39,4.39,0,0,1-.48-2A4.29,4.29,0,0,1,4.5.25,4.29,4.29,0,0,1,8.75,4.58a4.39,4.39,0,0,1-.48,2,.45.45,0,0,1-.05.17l-2,3.68a.44.44,0,0,1,.1.1.51.51,0,0,1,.07.45L5.9,12.41a.49.49,0,0,1-.46.34Zm1.6-2.43L6.1,8.59A4.22,4.22,0,0,1,5,8.88v1.44ZM4,10.32V8.88A4.22,4.22,0,0,1,2.9,8.59l.94,1.73Z"
|
|
||||||
},
|
|
||||||
"cessna": {
|
|
||||||
"w": 26,
|
|
||||||
"h": 26,
|
|
||||||
"viewBox": "0 -1 32 31",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M16.36 20.96l2.57.27s.44.05.4.54l-.02.63s-.03.47-.45.54l-2.31.34-.44-.74-.22 1.63-.25-1.62-.38.73-2.35-.35s-.44-.1-.43-.6l-.02-.6s0-.5.48-.5l2.5-.27-.56-5.4-3.64-.1-5.83-1.02h-.45v-2.06s-.07-.37.46-.34l5.8-.17 3.55.12s-.1-2.52.52-2.82l-1.68-.04s-.1-.06 0-.14l1.94-.03s.35-1.18.7 0l1.91.04s.11.05 0 .14l-1.7.02s.62-.09.56 2.82l3.54-.1 5.81.17s.51-.04.48.35l-.01 2.06h-.47l-5.8 1-3.67.11z"
|
|
||||||
},
|
|
||||||
"a359": {
|
|
||||||
"w": 31.5,
|
|
||||||
"h": 31.5,
|
|
||||||
"viewBox": "-20 -20 562 578",
|
|
||||||
"strokeScale": 17,
|
|
||||||
"path": "M237.77 72.911c-1.33 31.55-.18 76.46-.65 103.26-.41 5.93-12.66 14.72-20.17 20.6-8.23 6-17.4 12.8-25.3 19.22 1.78-11.25 2.67-24.53-.85-36.54-.36-.8-.84-1.46-1.81-1.62a139.6 139.6 0 0 0-23.52 0c-.42-.03-1.51.6-1.72 1.18-3.45 9.7-3.78 24.81.65 45.74.2.95 2.63.48 3.75.71l.66 2.6 2.49.07v2.92c-47.16 34.9-116.96 79.7-159.74 112.83-3.1 2.71-7.24 8.15-8.85 12.58-1.93 3.35-3.02 18.22-1.48 17.74 1.11-.04-.15-7.84 5.11-11.96 5-6.14 88.92-38.18 115.75-49.92.32 1.77 1.28 5.92 1.84 6.05 1.22-.63 1.86-5.25 2.55-7.85l31.23-12.26c.34 1.96 1.3 6.51 1.96 6.44.89-.39 2.03-5.01 2.53-8.04 10.54-4 21.91-6.29 34-6.73.37 2.36 1.41 7.66 2.14 7.59 1.2-.76 1.83-4.94 2.52-7.87a735 735 0 0 1 33.22-.76c.07 9.1.74 24.91 3.2 30.65.19 20.22-.58 67.09.83 86.7 1.21 17.82 5.75 39.31 10.65 58.48-.1 2.33-.95 4.6-2.48 6.37-16.17 13.36-37.07 27.7-53.92 42.29-5.92 6.27-6.03 12.24-8.74 24.76l71.52-25.56a127 127 0 0 0 3.77 12.72h.96c.16 3.44.24 10.32 1.2 10.99.97-.67 1.07-7.53 1.23-10.97h.95a127 127 0 0 0 3.78-12.73l71.51 25.57c-2.7-12.52-2.8-18.49-8.73-24.76-16.85-14.6-37.75-28.93-53.93-42.3a10.43 10.43 0 0 1-2.47-6.36c4.9-19.17 9.43-40.66 10.65-58.5 1.41-19.6.64-66.46.83-86.69 2.45-5.73 3.12-21.55 3.2-30.64 10.38.04 22.88.22 33.22.76.68 2.93 1.32 7.1 2.52 7.86.73.08 1.77-5.22 2.14-7.58a106.2 106.2 0 0 1 34 6.73c.5 3.03 1.64 7.65 2.53 8.04.65.07 1.61-4.49 1.95-6.44 8.77 3.19 22.49 8.72 31.23 12.26.7 2.6 1.33 7.22 2.56 7.85.56-.13 1.52-4.28 1.84-6.05 26.83 11.74 110.74 43.78 115.75 49.92 5.26 4.12 4 11.92 5.1 11.96 1.55.48.46-14.39-1.47-17.74-1.61-4.43-5.75-9.87-8.85-12.59-42.78-33.12-112.58-77.92-159.74-112.83v-2.91l2.48-.08.67-2.6c1.12-.22 3.54.24 3.75-.7 4.43-20.93 4.1-36.04.64-45.74-.2-.58-1.29-1.2-1.71-1.19a139.6 139.6 0 0 0-23.52.01c-.97.16-1.45.82-1.8 1.62-3.53 12.01-2.64 25.28-.86 36.54a624 624 0 0 0-25.3-19.22c-7.51-5.88-19.76-14.67-20.17-20.6-.47-26.8.67-71.72-.66-103.26-.33-14.07-11.85-71.55-23.3-72.43-11.46.86-22.98 58.34-23.31 72.41z"
|
|
||||||
},
|
|
||||||
"a332": {
|
|
||||||
"w": 31,
|
|
||||||
"h": 31,
|
|
||||||
"viewBox": "-50 -50 1370 1353",
|
|
||||||
"strokeScale": 42,
|
|
||||||
"path": "M640.424 1251.25c1.83-1.53 3.8-32.27 6.55-53.53l195.5 54.86c1.46 0-2.95-42.47-4.87-46.95-.82-1.92-2.89-5-4.74-6.73-61.32-38.85-113.44-66.94-170.42-102.2 14.04-101.98 28.3-176.49 31.36-236.63 2.42-64.73 1.9-89.45 1.9-158a238.4 238.4 0 0 0 10.29-45.93l79.36.94c.64 10.63 4.56 30.22 8.15 30.42 1.8.01 7.3-16.95 7.33-23.64.03-5.85 1.2-7.27 4.47-7.27 17 4.17 37.8 12.02 55.51 18.1-.57 8.16 4.26 30.31 8.26 31.41 3.21-.48 6.95-20.08 8.12-25.27l52.15 17.53c1.88 12.2 4.92 29.7 8.72 31.1 3.44.79 7.1-21.05 8.56-24.84 1.15-.01 50.81 17.18 51.5 17.83 2.09 10.83 5.7 29.92 8.9 30.99 3.44 1 7.17-19.9 8.21-25.3 32.2 8.64 234.77 99.3 244.66 106.02 3.17 1.94 6.28 4.17 9.4 6.34.36-6.3.43-12.19.66-18.49-7.3-17.2-16.23-36.38-16.1-41.66 0-3.58-.42-5.35-2.61-7.49l-413.4-257.15c0-.74 1.07-1.44 2.43-2.1 3.88-5.52 7.02-32.88 11.3-45.13.1-5.05 8.47-1.33 8-4.5 5.58-18.8 6.18-81.92 1.68-103.47-1.14-.93-61.73-1.84-61.49.32-6.39 32.18-3.2 77.43 1.42 104.14 2.63.47 5.72.27 8.51.31 2.93 14.19 5.05 24.57 7.06 37.92l-120.5-73.55c-1.24-92.1.3-231-2.17-260.1C691.574 110.06 655.244.52 635.224.5c-20.01.03-56.34 109.57-58.84 169.04-2.48 29.11-.93 168-2.18 260.1l-120.49 73.56c2.01-13.35 4.13-23.74 7.05-37.92 2.8-.05 5.89.16 8.52-.32 4.61-26.7 7.8-71.95 1.42-104.13.24-2.16-60.35-1.26-61.5-.33-4.5 21.56-3.89 84.67 1.7 103.49-.48 3.16 7.9-.56 7.98 4.5 4.28 12.24 7.43 39.6 11.31 45.12 1.35.66 2.44 1.36 2.42 2.1-144.21 93.67-277.18 170.05-413.4 257.15-2.19 2.13-2.61 3.9-2.61 7.49.14 5.28-8.8 24.46-16.1 41.66l.65 18.5c3.12-2.18 6.24-4.42 9.4-6.35 9.9-6.71 212.46-97.38 244.66-106.01 1.04 5.39 4.77 26.3 8.21 25.29 3.21-1.07 6.82-20.16 8.9-31 .7-.64 50.35-17.83 51.5-17.82 1.48 3.8 5.13 25.63 8.57 24.85 3.8-1.4 6.84-18.9 8.71-31.11l52.16-17.53c1.17 5.2 4.9 24.79 8.11 25.27 4-1.1 8.83-23.26 8.27-31.42 17.7-6.07 38.5-13.93 55.51-18.1 3.26 0 4.44 1.43 4.46 7.28.03 6.7 5.54 23.64 7.34 23.64 3.59-.2 7.5-19.8 8.15-30.42l79.36-.94a238.5 238.5 0 0 0 10.29 45.93c0 68.55-.52 93.26 1.9 158 3.06 60.14 17.32 134.65 31.35 236.62l-170.41 102.21a23.9 23.9 0 0 0-4.75 6.73c-1.91 4.47-6.32 46.95-4.87 46.95l195.49-54.89c3.34 22.79 4.74 52.12 6.75 53.58.84 1.47 9.27 1.38 10.2-.02z"
|
|
||||||
},
|
|
||||||
"heavy_2e": {
|
|
||||||
"w": 34,
|
|
||||||
"h": 34,
|
|
||||||
"viewBox": "0 -3.2 64.2 64.2",
|
|
||||||
"strokeScale": 1.8,
|
|
||||||
"path": "m 31.414,2.728 c -0.314,0.712 -1.296,2.377 -1.534,6.133 l -0.086,13.379 c 0.006,0.400 -0.380,0.888 -0.945,1.252 l -2.631,1.729 c 0.157,-0.904 0.237,-3.403 -0.162,-3.850 l -2.686,0.006 c -0.336,1.065 -0.358,2.518 -0.109,4.088 h 0.434 L 24.057,26.689 8.611,36.852 7.418,38.432 7.381,39.027 8.875,38.166 l 8.295,-2.771 0.072,0.730 0.156,-0.004 0.150,-0.859 3.799,-1.234 0.074,0.727 0.119,0.004 0.117,-0.832 2.182,-0.730 h 1.670 l 0.061,0.822 h 0.176 l 0.062,-0.822 4.018,-0.002 v 13.602 c 0.051,1.559 0.465,3.272 0.826,4.963 l -6.836,5.426 c -0.097,0.802 -0.003,1.372 0.049,1.885 l 7.734,-2.795 0.477,1.973 h 0.232 l 0.477,-1.973 7.736,2.795 c 0.052,-0.513 0.146,-1.083 0.049,-1.885 l -6.836,-5.426 c 0.361,-1.691 0.775,-3.404 0.826,-4.963 V 33.193 l 4.016,0.002 0.062,0.822 h 0.178 L 38.875,33.195 h 1.672 l 2.182,0.730 0.117,0.832 0.119,-0.004 0.072,-0.727 3.799,1.234 0.152,0.859 0.154,0.004 0.072,-0.730 8.297,2.771 1.492,0.861 -0.037,-0.596 -1.191,-1.580 -15.447,-10.162 0.363,-1.225 H 41.125 c 0.248,-1.569 0.225,-3.023 -0.111,-4.088 l -2.686,-0.006 c -0.399,0.447 -0.317,2.945 -0.160,3.850 L 35.535,23.492 C 34.970,23.128 34.584,22.640 34.590,22.240 L 34.504,8.910 C 34.193,4.926 33.369,3.602 32.934,2.722 32.442,1.732 31.894,1.828 31.414,2.728 Z"
|
|
||||||
},
|
|
||||||
"md11": {
|
|
||||||
"w": 38,
|
|
||||||
"h": 32,
|
|
||||||
"noAspect": true,
|
|
||||||
"strokeScale": 1.8,
|
|
||||||
"viewBox": "-4 -4 72 72",
|
|
||||||
"path": "m 32,0.7 0.3,0.1 0.4,0.4 0.5,0.8 0.6,1.4 0.4,1.7 0.7,4.2 0.2,4.1 0,11.8 4.1,3.2 -0.2,-1.1 0,-1.2 0.1,-1.1 0.2,-0.4 2.5,0 0.2,0.4 0.1,1.1 0,1.2 -0.3,1.3 -0.3,0 -0.3,1.5 16.6,13.4 0,0.6 0.4,2.2 0,1.2 -0.4,-0.9 0,0.3 -9.6,-4.6 -0.1,0.6 -0.1,0.3 -0.1,0 -0.1,-0.3 -0.1,-0.8 -4.5,-2.3 -0.1,0.7 -0.1,0.4 -0.1,0 -0.1,-0.4 -0.1,-0.9 -1.1,-0.6 -6.5,-1.4 0,4.4 -0.1,4.2 -0.5,3.7 -0.6,3.3 6.6,5.5 0,3.2 -7,-2.6 -0.1,0.8 -0.2,0.7 -0.3,0 -0.5,2.1 -0.2,0 -0.2,0.6 -0.2,-0.6 -0.2,0 -0.5,-2.1 -0.3,0 -0.2,-0.7 -0.1,-0.8 -7,2.5 0,-3.1 6.6,-5.5 -0.6,-3.3 -0.5,-3.7 -0.1,-4.2 0,-4.4 -6.5,1.4 -1.1,0.6 -0.1,0.9 -0.1,0.3 -0.1,0 -0.1,-0.3 -0.1,-0.7 -4.5,2.3 -0.1,0.8 -0.1,0.3 -0.1,0 -0.1,-0.3 -0.1,-0.6 -9.6,4.6 0,-0.3 -0.4,0.9 0,-1.2 0.4,-2.2 0,-0.6 16.6,-13.4 -0.3,-1.5 -0.3,0 -0.3,-1.3 0,-1.2 0.1,-1.1 0.2,-0.4 2.5,0 0.2,0.4 0.1,1.1 0,1.2 -0.2,1.1 4.1,-3.3 0,-11.7 0.2,-4.1 0.7,-4.2 0.4,-1.7 0.6,-1.4 0.5,-0.8 0.4,-0.4 z"
|
|
||||||
},
|
|
||||||
"c130": {
|
|
||||||
"w": 33,
|
|
||||||
"h": 35,
|
|
||||||
"noAspect": true,
|
|
||||||
"strokeScale": 2,
|
|
||||||
"viewBox": "-1 -16 64 64",
|
|
||||||
"path": "m 31,1 1,0 1,1 1,2 0,8 3,0 0,-3 1,-1 1,1 0,3 6,0 0,-3 1,-1 1,1 0,3 10,1 0,2 -1,1 -17,3 -5,0 0,10 -1,1 8,2 0,1 -1,1 -8,0 -1,1 -1,-1 -8,0 -1,-1 0,-1 8,-2 -1,-1 0,-10 -5,0 -17,-3 -1,-1 0,-2 10,-1 0,-3 1,-1 1,1 0,3 6,0 0,-3 1,-1 1,1 0,3 3,0 0,-8 1,-2 1,-1 z"
|
|
||||||
},
|
|
||||||
"hi_perf": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"strokeScale": 2.5,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"viewBox": "-7.8 0 80 80",
|
|
||||||
"path": "M 30.82,61.32 29.19,54.84 29.06,60.19 27.70,60.70 22.27,60.63 21.68,59.60 l -0.01,-2.71 6.26,-5.52 -0.03,-3.99 -13.35,-0.01 -3e-6,1.15 -1.94,0.00 -0.01,-1.31 0.68,-0.65 L 13.30,37.20 c -0.01,-0.71 0.57,-0.77 0.60,0 l 0.05,1.57 0.28,0.23 0.26,4.09 L 19.90,38.48 c 0,0 -0.04,-1.26 0.20,-1.28 0.16,-0.02 0.20,0.98 0.20,0.98 l 4.40,-3.70 c 0,0 0.02,-1.28 0.20,-1.28 0.14,-0.00 0.20,0.98 0.20,0.98 l 1.80,-1.54 C 27.02,28.77 28.82,25.58 29,21.20 c 0.06,-1.41 0.23,-3.34 0.86,-3.85 0.21,-4.40 1.32,-11.03 2.39,-11.03 1.07,0 2.17,6.64 2.39,11.03 0.63,0.51 0.80,2.45 0.86,3.85 0.18,4.38 1.98,7.57 2.10,11.44 l 1.80,1.54 c 0,0 0.06,-0.99 0.20,-0.98 0.18,0.01 0.20,1.28 0.20,1.28 l 4.40,3.70 c 0,0 0.04,-1.00 0.20,-0.98 0.24,0.03 0.20,1.28 0.20,1.28 l 5.41,4.60 0.26,-4.09 0.28,-0.23 L 50.59,37.20 c 0.03,-0.77 0.61,-0.71 0.60,0 l 0.02,9.37 0.68,0.65 -0.01,1.31 -1.94,-0.00 -3e-6,-1.15 -13.35,0.01 -0.03,3.99 6.26,5.52 L 42.81,59.60 42.22,60.63 36.79,60.70 35.43,60.19 35.30,54.84 33.67,61.32 Z",
|
|
||||||
"accent": "m 32.30,49.38 -0.02,13.99"
|
|
||||||
},
|
|
||||||
"f18": {
|
|
||||||
"viewBox": "-4 -3 32 32",
|
|
||||||
"w": 25,
|
|
||||||
"h": 25,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M22.2 19.36h.3v-.23h.1v-.12h.15l-.1-.34h-.17v-.16h.12v-2.28l-.09-.1v-1.79c0-.37-.16-2.3-.3 0v.71s-.27.26-.26.6v.51s-.16.03-.17.2v.41l-7.4-3.5s0-1.36-.3-3c0 0-.3-1.14-.56-2.9 0 0-.21-2.73-.69-2.83l-.08-2.84s-.18-1.95-.8-2.92c-.62.97-.7 2.92-.7 2.92l-.09 2.84c-.48.1-.69 2.83-.69 2.83-.26 1.76-.55 2.9-.55 2.9-.31 1.64-.3 3-.3 3l-7.41 3.5v-.41c0-.17-.16-.2-.16-.2v-.52c0-.33-.27-.59-.27-.59v-.71c-.14-2.3-.29-.37-.3 0v1.8l-.09.09v2.28h.13v.16h-.18l-.1.34h.15v.12h.1v.23h.3m20.4 0l-.35.41-7.44.29.67 1.86v1.06l2.63 2.83v1.14c-.03.6-.83.39-.83.39l-3.35-1.65-.39.81h-.78c-.17 0-.35-1.08-.35-1.08s-.19 1.08-.36 1.08h-.78l-.38-.8-3.36 1.64s-.8.22-.82-.4v-1.13l2.61-2.83v-1.06l.68-1.86-7.43-.29-.36-.41",
|
|
||||||
"accent": "M15.04 23.81l-1.42-.66s-.22-3.7.2-4.85m-4.84 5.5l1.4-.66s.23-3.69-.19-4.85"
|
|
||||||
},
|
|
||||||
"e3awacs": {
|
|
||||||
"w": 36,
|
|
||||||
"h": 36,
|
|
||||||
"viewBox": "48 -2 36 36",
|
|
||||||
"path": "M 65.811,0.918 C 65.338,1.311 64.907,3.105 64.761,5.285 c -0.042,0.650 -0.070,2.099 -0.056,3.209 l 0.021,2.031 -2.192,1.652 -2.192,1.652 -0.042,-0.508 c -0.077,-0.900 -0.132,-1.131 -0.257,-1.137 -0.223,-0.014 -0.745,0.020 -0.772,0.047 -0.056,0.054 -0.007,1.835 0.063,2.051 0.035,0.129 0.063,0.244 0.049,0.250 -0.007,0.007 -0.654,0.487 -1.441,1.076 -0.786,0.589 -1.461,1.097 -1.503,1.124 -0.056,0.041 -0.070,-0.176 -0.042,-0.772 0.014,-0.508 0,-0.900 -0.049,-1.015 -0.077,-0.183 -0.090,-0.190 -0.543,-0.169 l -0.473,0.020 -0.056,0.372 c -0.063,0.372 -0.021,1.841 0.063,2.173 0.035,0.162 -0.077,0.257 -1.552,1.367 -1.635,1.225 -2.171,1.726 -2.366,2.227 -0.139,0.366 -0.223,2.762 -0.097,2.762 0.049,0 0.077,-0.210 0.077,-0.609 V 22.480 l 0.278,-0.169 c 0.160,-0.095 0.355,-0.203 0.438,-0.230 0.084,-0.034 1.935,-0.907 4.120,-1.936 l 3.967,-1.868 1.649,-0.332 c 1.468,-0.298 1.705,-0.332 2.150,-0.284 l 0.508,0.047 -0.285,0.183 c -0.738,0.474 -1.274,1.516 -1.274,2.478 0,0.393 0.160,1.002 0.355,1.381 0.202,0.379 0.745,0.927 1.169,1.171 0.306,0.183 0.355,0.237 0.383,0.474 0.021,0.149 0.118,0.819 0.209,1.489 0.118,0.812 0.153,1.239 0.104,1.286 -0.035,0.034 -0.870,0.691 -1.858,1.456 -2.074,1.611 -2.074,1.611 -2.053,2.491 0.021,0.677 0.063,0.867 0.188,0.765 0.049,-0.041 0.884,-0.318 1.858,-0.616 0.974,-0.291 1.949,-0.596 2.164,-0.670 0.480,-0.156 0.445,-0.210 0.515,0.826 0.021,0.399 0.070,0.724 0.097,0.724 0.028,0 0.070,-0.359 0.097,-0.792 0.021,-0.440 0.070,-0.819 0.097,-0.839 0.028,-0.027 0.459,0.081 0.953,0.237 0.501,0.162 1.461,0.460 2.136,0.677 0.682,0.210 1.281,0.399 1.343,0.420 0.090,0.034 0.104,-0.061 0.104,-0.691 0,-0.460 -0.035,-0.792 -0.090,-0.887 -0.042,-0.081 -0.912,-0.792 -1.928,-1.577 -1.016,-0.792 -1.865,-1.456 -1.893,-1.483 -0.028,-0.027 0.021,-0.454 0.111,-0.955 0.090,-0.501 0.188,-1.185 0.216,-1.516 0.049,-0.575 0.056,-0.603 0.257,-0.711 0.731,-0.386 1.350,-1.103 1.566,-1.835 0.348,-1.171 -0.132,-2.620 -1.079,-3.249 -0.195,-0.129 -0.355,-0.257 -0.362,-0.291 0,-0.027 0.230,-0.047 0.515,-0.047 0.348,0 1.058,0.108 2.178,0.332 l 1.656,0.338 4.036,1.896 c 2.220,1.043 4.176,1.970 4.350,2.051 l 0.313,0.156 0.021,0.657 c 0.007,0.366 0.042,0.663 0.077,0.663 0.090,0 0.146,-1.956 0.063,-2.336 -0.188,-0.860 -0.494,-1.198 -2.436,-2.647 l -1.601,-1.198 0.042,-0.278 c 0.084,-0.562 0.125,-1.909 0.063,-2.173 l -0.063,-0.271 -0.466,-0.020 c -0.452,-0.020 -0.466,-0.014 -0.515,0.169 -0.028,0.102 -0.056,0.548 -0.056,0.982 0,0.440 -0.014,0.799 -0.035,0.799 -0.035,0 -2.199,-1.604 -2.798,-2.072 l -0.181,-0.135 0.097,-0.426 c 0.063,-0.264 0.097,-0.691 0.084,-1.144 l -0.021,-0.724 -0.445,-0.020 c -0.257,-0.014 -0.480,0.014 -0.522,0.054 -0.042,0.041 -0.090,0.413 -0.111,0.819 l -0.035,0.745 -2.206,-1.645 -2.206,-1.645 -0.007,-2.166 C 67.391,4.730 67.363,4.168 67.085,2.814 66.911,1.981 66.730,1.500 66.417,1.067 66.194,0.756 66.055,0.722 65.811,0.918 Z"
|
|
||||||
},
|
|
||||||
"heavy_4e": {
|
|
||||||
"w": 34,
|
|
||||||
"h": 34,
|
|
||||||
"viewBox": "0 0 64 64",
|
|
||||||
"strokeScale": 1.8,
|
|
||||||
"path": "m 30.764,3.957 c -1.030,1.995 -1.438,5.650 -1.600,7.687 -0.248,3.120 -0.114,5.478 -0.156,7.568 -0.016,0.798 -0.737,1.483 -1.435,2.163 l -4.630,4.207 c 0.136,-0.609 0.313,-2.735 0.011,-3.413 l -2.147,-0.067 c -0.337,0.636 -0.227,2.516 -0.102,3.486 l 0.414,0.033 0.179,1.447 -5.794,5.342 c 0.077,-0.914 0.114,-2.161 -0.105,-2.633 l -2.172,-0.078 c -0.367,0.716 -0.185,2.323 -0.053,3.475 h 0.394 l 0.138,0.949 -7.991,6.563 C 5.411,40.937 5.586,41.437 5.564,41.830 l -0.694,2.353 0.005,0.991 0.715,-1.236 10.464,-6.218 c 0.012,0.663 0.110,1.051 0.231,1.010 0.135,-0.045 0.328,-0.852 0.361,-1.290 l 2.274,-1.389 c -0.003,0.493 0.054,1.174 0.196,1.088 0.126,-0.076 0.384,-0.807 0.362,-1.370 l 1.528,-0.943 2.988,-1.018 c 0.073,0.381 0.122,0.929 0.292,0.896 0.159,-0.031 0.257,-0.491 0.355,-1.065 l 1.704,-0.597 c 0.025,0.437 0.163,0.976 0.297,0.914 0.149,-0.070 0.339,-0.647 0.356,-1.118 l 1.935,-0.666 0.054,10.106 c 0.183,3.800 0.173,5.797 0.919,9.127 -0.072,0.573 -0.374,0.766 -0.640,1.020 l -6.724,6.317 -0.007,2.046 8.553,-2.312 c 0.019,0.586 0.061,1.045 0.432,1.368 l 0.146,1.817 0.146,-1.817 c 0.371,-0.323 0.413,-0.782 0.432,-1.368 l 8.553,2.312 -0.007,-2.046 -6.724,-6.317 c -0.266,-0.253 -0.569,-0.446 -0.640,-1.020 0.747,-3.331 0.736,-5.327 0.919,-9.127 l 0.054,-10.106 1.935,0.666 c 0.017,0.470 0.207,1.048 0.356,1.118 0.134,0.062 0.272,-0.477 0.297,-0.914 l 1.704,0.597 c 0.098,0.574 0.196,1.034 0.355,1.065 0.170,0.033 0.219,-0.515 0.292,-0.896 l 2.988,1.018 1.528,0.943 c -0.021,0.563 0.237,1.294 0.362,1.370 0.141,0.086 0.198,-0.595 0.196,-1.088 l 2.274,1.389 c 0.033,0.439 0.227,1.245 0.361,1.290 0.121,0.041 0.219,-0.347 0.231,-1.010 l 10.464,6.218 0.715,1.236 0.005,-0.991 -0.694,-2.353 c -0.021,-0.393 0.153,-0.893 -0.151,-1.143 l -7.991,-6.563 0.138,-0.949 h 0.394 c 0.132,-1.152 0.314,-2.760 -0.053,-3.475 l -2.172,0.078 c -0.218,0.472 -0.182,1.719 -0.105,2.633 l -5.794,-5.342 0.179,-1.447 0.414,-0.033 c 0.125,-0.970 0.236,-2.850 -0.102,-3.486 l -2.147,0.067 c -0.302,0.678 -0.125,2.804 0.011,3.413 l -4.630,-4.207 c -0.698,-0.680 -1.419,-1.365 -1.435,-2.163 -0.042,-2.090 0.092,-4.448 -0.156,-7.568 -0.162,-2.037 -0.600,-5.677 -1.600,-7.687 -0.592,-1.190 -1.211,-1.157 -1.809,0 z"
|
|
||||||
},
|
|
||||||
"single_turbo": {
|
|
||||||
"w": 24,
|
|
||||||
"h": 22,
|
|
||||||
"noAspect": true,
|
|
||||||
"viewBox": "-2 -2 23 23",
|
|
||||||
"path": "M 9.53,0.50 C 9.51,0.54 9.42,0.76 9.38,0.82 9.05,0.53 6.02,0.49 6.02,0.99 c 0.50,0 2.50,0.13 3.33,-0.08 C 8.52,1.63 8.53,3.51 8.23,6.47 L 1.33,7.00 C 0.50,7.07 0.50,7.57 0.50,8.44 L 5.97,9.39 C 6.71,9.58 8.12,10.04 8.12,10.04 c 0,0 0.29,4.81 0.82,6.26 l -2.91,0.67 c 0,0 -0.19,0.63 -0.19,1.20 l 3.54,0.23 0.08,0.11 0.08,-0.11 3.55,-0.20 c -0.00,-0.56 -0.18,-1.20 -0.18,-1.20 L 9.99,16.30 c 0.55,-1.44 0.88,-6.25 0.88,-6.25 0,0 1.41,-0.45 2.15,-0.63 l 5.48,-0.90 c 0.01,-0.87 0.01,-1.38 -0.81,-1.45 L 10.79,6.49 C 10.51,3.52 10.54,1.64 9.71,0.91 10.54,1.13 12.54,1.02 13.04,1.03 13.05,0.52 10.02,0.53 9.68,0.82 9.65,0.76 9.55,0.54 9.53,0.50 Z"
|
|
||||||
},
|
|
||||||
"jet_nonswept": {
|
|
||||||
"w": 22,
|
|
||||||
"h": 22,
|
|
||||||
"viewBox": "-2 -2.4 22 22",
|
|
||||||
"path": "M9,17.09l-3.51.61v-.3c0-.65.11-1,.33-1.09L8.5,15a5.61,5.61,0,0,1-.28-1.32l-.53-.41-.1-.69H7.12l0-.21a7.19,7.19,0,0,1-.15-2.19L.24,9.05V8.84c0-1.1.51-1.15.61-1.15L7.8,7.18V2.88C7.8.64,8.89.3,8.93.28L9,.26l.07,0s1.13.36,1.13,2.6v4.3l7,.51c.09,0,.59.06.59,1.15v.21l-6.69,1.16a7.17,7.17,0,0,1-.15,2.19l0,.21h-.47l-.1.69-.53.41A5.61,5.61,0,0,1,9.5,15l2.74,1.28c.2.07.31.43.31,1.08v.3Z"
|
|
||||||
},
|
|
||||||
"jet_swept": {
|
|
||||||
"w": 19,
|
|
||||||
"h": 25,
|
|
||||||
"viewBox": "-1 -1 20 26",
|
|
||||||
"path": "M9.44,23c-.1.6-.35.6-.44.6s-.34,0-.44-.6l-3,.67V22.6A.54.54,0,0,1,6,22.05l2.38-1.12L8,19.33H6.69l0-.2a8.23,8.23,0,0,1-.14-3.85l.06-.18H7.73V13.19h-2L.26,14.29v-.93c0-.28.07-.46.22-.53l7.25-3.6V3.85A4.47,4.47,0,0,1,8.83.49L9,.34l.17.15a4.47,4.47,0,0,1,1.1,3.36V9.23l7.25,3.6c.14.07.22.25.22.53v.93l-5.51-1.1h-2V15.1h1.17l.06.18a8.24,8.24,0,0,1-.15,3.84l0,.2H10l-.36,1.6,2.43,1.14a.52.52,0,0,1,.35.53v1.08Z"
|
|
||||||
},
|
|
||||||
"twin_large": {
|
|
||||||
"w": 25,
|
|
||||||
"h": 25,
|
|
||||||
"strokeScale": 0.9,
|
|
||||||
"viewBox": "-2 -3 25 25",
|
|
||||||
"path": "M10.1,18.34H7l0-.21c-.08-.54,0-.87.11-1L7.19,17l.2,0,2.35-.33c-.16-.82-.42-2.9-.42-3.14s0-2.71,0-3.51H8c-.12,1.34-.41,1.36-.55,1.37h0c-.19,0-.46,0-.6-1.55L.27,9.52l0-.25c.06-.73.31-.9.45-.93l6-.48a3.65,3.65,0,0,1,.3-2,.45.45,0,0,1,.32-.16h0a.39.39,0,0,1,.3.12A3.67,3.67,0,0,1,8,7.77l1.26-.07c0-.71,0-2.92,0-4.48A3.84,3.84,0,0,1,10.1.4a.4.4,0,0,1,.28-.16h.23A.4.4,0,0,1,10.9.4a3.84,3.84,0,0,1,.87,2.81c0,1.55,0,3.77,0,4.48L13,7.77a3.67,3.67,0,0,1,.29-1.94.38.38,0,0,1,.28-.12.46.46,0,0,1,.34.16,3.66,3.66,0,0,1,.3,2l6,.48c.18,0,.43.21.49.94l0,.25-6.53.3c-.14,1.55-.42,1.55-.59,1.55s-.45,0-.57-1.37H11.74c0,.8,0,3.27,0,3.51s-.26,2.32-.42,3.14l2.38.34h.11l.13.13c.15.18.19.51.11,1l0,.21H10.9l-.4,1Z"
|
|
||||||
},
|
|
||||||
"twin_small": {
|
|
||||||
"w": 25,
|
|
||||||
"h": 22,
|
|
||||||
"strokeScale": 1.1,
|
|
||||||
"viewBox": "-3 -4 25 22",
|
|
||||||
"path": "M9.5,15.75c-.21,0-.34-.17-.41-.51l-2.88.23v-.27c0-.78,0-1.11.28-1.13L9,13.1c-.31-1.86-.55-5-.59-5.55l-.08-.09H6.08L.25,6.54v-1A.43.43,0,0,1,.67,5l3.75-.27L5,4.45V3.53H4.73V2.7a.35.35,0,0,1,.34-.35h.07c.12-.52.26-.83.54-.83s.42.31.53.83h.07a.35.35,0,0,1,.34.35v.83H6.36v1l2-.08C8.42.81,9.09.25,9.49.25s1.09.55,1.12,4.21l2,.08v-1h-.25V2.7a.35.35,0,0,1,.34-.35h.07c.12-.52.26-.83.53-.83s.42.31.54.83h.07a.35.35,0,0,1,.34.35v.83H14v.92l.57.32L18.32,5a.42.42,0,0,1,.43.46v1L13,7.46H10.71l-.08.09c0,.56-.27,3.68-.59,5.55l2.46,1c.28,0,.28.35.28,1.13v.27l-2.88-.23C9.84,15.58,9.71,15.75,9.5,15.75Z"
|
|
||||||
},
|
|
||||||
"alpha_jet": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "33.2 9 32 32",
|
|
||||||
"path": "M49.14 36.015c-.07-.194-.09-.223-.176-.238-.13-.024-.14-.068-.14-.664 0-.277-.012-.504-.027-.504-1.378.334-2.455.604-3.915.961-.014 0-.08-.166-.15-.37-.154-.445-.17-.703-.055-.852 1.33-.883 2.31-1.45 3.795-2.349-.111-.93-.214-1.833-.313-2.697l-.525.021c-.236-.807-.356-1.716-.469-2.41-2.494.435-5.205 1.014-7.794 1.527-.013 0-.024-.115-.024-.256 0-.486.29-1.554.513-1.893.909-.79 2.132-1.51 3.348-2.26.066.04.052.226.08.286l1.15-.515c1.223-.529 1.674-.858 2.698-1.528.127-.555.072-.835.106-1.538l.211-.241.568.036.025-.323c.02-.278.01-.326.01-.326l.184-.027c-.14-9.213 2.22-8.012 1.898.024l.19.02.01.318.01.317h.61l.16.214c.02.515.026.708.068 1.458 1 .74 2.136 1.29 2.77 1.62l1.213.58.036-.308 1.527.962c1.79 1.128 1.827 1.158 1.988 1.636.16.475.298 1.111.313 1.441.008.167.006.303-.006.303-2.779-.55-4.895-.975-7.884-1.572l-.018.127c-.021.777-.206 1.64-.314 2.274-.16.028-.354.03-.548.048-.118.88-.24 1.79-.324 2.673 1.294.783 2.32 1.411 3.686 2.261.062.053.089.126.117.314.033.225.029.27-.06.586a2.575 2.575 0 01-.12.366c-1.355-.264-2.437-.531-3.87-.862l-.186-.042.017.571c-.009.648-.11.572-.11.572-.165.06-.101.15-.162.24l-.032.24-.079-.22z"
|
|
||||||
},
|
|
||||||
"a225": {
|
|
||||||
"w": 36,
|
|
||||||
"h": 36,
|
|
||||||
"strokeScale": 2,
|
|
||||||
"viewBox": "75 -4 72 72",
|
|
||||||
"path": " M123.984 55.377l.768 2.092.09 3.559-.655 1.058-.27 1.78-.43-1.828-10.175-3.944-.655-.072-.36 1.876-2.009.024-.451-1.852h-.564l-10.154 4.233-.316 1.803-.428-1.78-.61-.769-.045-3.92.745-2.044.722 1.275L108.506 51l-.767-17.051-6.882.072-21.12 8.682v-2.309l.79-1.66 11.327-8.176-1.016-1.37.023-3.368.564-.529h1.399l.519.626.113 3.727 3.497-2.573-.587-.986-.09-3.223.339-.697h1.76l.45.577v3.006l3.43-2.405-.654-.77-.022-3.823.586-.457h1.287l.518.601v3.247l3.362-2.886v-8.032l.316-3.512.226-2.212.79-2.67.857-1.515.654-.841.677.048.542.457.654 1.25.587 1.058.541 1.804.316 1.515.271 1.732.113 2.91.225 7.767 3.51 2.97-.091-3.198.429-.59 1.635-.023.395.553.08 3.547-.587.83 3.452 2.429-.09-2.958.406-.746h1.602l.541.577.023 3.391-.61.938 3.633 2.477-.045-3.872.564-.577h1.264l.564.722.045 3.367-.88 1.274 11.192 7.6.79.841.248.842.18.721v1.66l-21.277-7.984-7.04.024-.37 17.152 9.381 5.611z"
|
|
||||||
},
|
|
||||||
"a400": {
|
|
||||||
"w": 31,
|
|
||||||
"h": 34,
|
|
||||||
"strokeScale": 3.5,
|
|
||||||
"viewBox": "-9.5 0 140 140",
|
|
||||||
"path": "m 60.2353,6.87783 L62.5882,9.40724 L63.1312,5.07692 L63.7059,11.3484 L67.1765,19.0543 L67.4706,39.5837 L70.4118,45.2896 L77.2941,47.8778 L77,43.9367 L78.2941,39.6425 L72.7059,38.7149 L77.4118,38.2896 L79.1176,35.8778 L80.7647,38.2308 L87,38.8914 L80.8235,39.9367 L81.2941,43.4661 L80.9412,48.6199 L93.5294,52.9367 L93.5294,48.3484 L94.2941,44.3484 L89.1765,43.819 L94.1176,42.7602 L95.5882,40.4661 L97.1176,42.7602 L102.471,43.5747 L97.1176,44.5249 L98,48.2896 L97.3756,54.0905 L118.683,62.0181 L118.706,68.6425 L100.941,67.2443 L99.4118,70.0543 L98.9412,67.1131 L91.5294,66.267 L90.7647,68.8778 L89.7059,66.2896 L83.8235,65.7738 L82.9412,69.1719 L82.3529,65.6516 L73.4118,64.819 L72.4706,68.9955 L71.5294,64.5837 L70.7059,68.7602 L67.7647,71.2308 L67.5294,91.5131 L63.9882,110.925 L85.3529,127.348 L86.2941,132.878 L61.5882,123.348 L60.3529,127.466 L59.4118,123.231 L33.5882,132.29 L34.8824,126.878 L56.2353,110.819 L53,91.3484 L52.5882,72.1131 L49.7059,68.7602 L49.1765,64.6425 L48.0588,68.5249 L46.8824,64.8869 L38.4706,65.5747 L37.2941,69.3484 L36.3529,65.9955 L30.6471,66.1719 L29.7059,69.2896 L28.5294,66.3394 L21.4118,67.0679 L20.7059,70.1719 L19.5294,67.1719 L1.39819,68.5611 L1.65611,61.7828 L22.8824,54.2896 L22.5294,48.7014 L23.5882,43.819 L17.7059,43.4661 L23.1765,42.4661 L24.5882,40.1719 L25.8235,42.4661 L31.7059,43.4796 L26.1176,44.3484 L26.7647,48.7602 L26.0588,53.0679 L38.9412,48.7964 L39.1765,43.8778 L40.1176,39.4072 L33.2353,39.0543 L39.7647,37.7014 L41.2941,35.1719 L42.1765,38.0543 L48,38.8326 L42.6471,40.0543 L43.2805,44.0543 L42.8824,47.5837 L50,44.9955 L52.5294,40.1131 L53,18.5249 L56.1765,11.2308 z"
|
|
||||||
},
|
|
||||||
"v22_slow": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 0.9,
|
|
||||||
"viewBox": "30.8 -0.5 26 26",
|
|
||||||
"path": "M46 21.4h-4.4s-.5-2.2-.1-2.4l.2.3h1v-2.5s-1.4-2.8-1-3.8l-3.7-.4 1.5 3.7-.4.2-2-4.2.2-.2s-.3 0-.4-.4c0 0-.6.1-1-.1l-3.8-.5v-.5l4.8.4s0-1.3 1-.5l2.5-3.3.4.3-2 2.9 3.2.4.6-1v-2s.1-1.2.6-1.7v-.3s.1-.5.3 0l.3-.2s1 .2 1.2 2.3v2l.6.9 3.2-.4-2-2.7.5-.3 2.5 3.3s1-.9 1 .5l4.7-.5v.5l-4.7.6s0 .4-.4.4l.1.2-1.9 4.3-.4-.2 1.6-3.9-4 .5s.4 1-.9 3.8v2.4h1s0-.3.2-.3c0 0 .3 0 0 2.4z"
|
|
||||||
},
|
|
||||||
"v22_fast": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 1,
|
|
||||||
"viewBox": "26.7 -3.3 26 26",
|
|
||||||
"path": "M33.76 6.93l.01.4.2.06.05.07 3.84.45s.18-.36.6-.88c0 0-.26-2.86.6-3.83v-.28l.05-.05s.1-.51.28 0c0 0 .24-.18.3-.15.05.03 1.31.19 1.23 4.3 0 0 .62.84.58.94l3.83-.44.06-.08.23-.08-.01-.4s-.7-.21-4.04-.5l-.12-.13 4.32.01s.43-1.46.88 0l5.11-.07v.06s-4.12.36-4.79.71l-.2-.24.03.52.2.15.06 1.09-.07.14s-.09 1.63-.41 2.37l-.63-.03-.23-1.2-3.91.43s.36.97-.97 3.79l-.1 2.47h1.06s.06-.28.16-.3m0 0s.43-.02-.04 2.41h-4.5s-.46-2.46-.04-2.4c0 0 .12.02.16.29l1.04.01-.08-2.5s-1.2-2.38-.96-3.82l-3.89-.44-.24 1.2-.62.03s-.4-1.1-.43-2.38l-.05-.1.04-1.1.24-.16.01-.5-.2.23s-1.02-.4-4.8-.71v-.07l5.12.05s.42-1.39.86 0h4.34l-.13.14s-3.99.39-4.03.52"
|
|
||||||
},
|
|
||||||
"t38": {
|
|
||||||
"viewBox": "22.2 -6 36 36",
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M41.3 27h-2l-.2-1.8-3.3-.2v-1l3-2s-.2-1.5-.1-3.2l-6.5-.6v-1.5l6.3-3.8s-.6-2.8.4-2.7l.3-.4V4.6s.2-4.3 1-6v-1.4h.1v1.5s1 2.3 1 6l.1 5.1.3.4s1-.3.4 2.7l6.3 3.9v1.4l-6.5.6s.1 1.6 0 3.2l2.9 2v1l-3.3.2z"
|
|
||||||
},
|
|
||||||
"f35": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-4 -1 40 40",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "m 16.85,2.96 c 0.38,3.4 0.78,5.93 0.78,5.93 l 0.29,0.72 0.77,-0.84 0.38,0.73 0.19,4.5 0.71,1.41 5.85,3.95 v 2.84 l -6.47,1.61 0.5,1.17 2.84,1.92 v 1.6 l -4.85,1.2 -0.63,-4.05 -0.45,0.78 H 15.24 L 14.79,25.65 14.16,29.7 9.31,28.5 v -1.6 l 2.85,-1.92 0.49,-1.17 -6.47,-1.61 v -2.84 l 5.85,-3.95 0.71,-1.4 0.2,-4.51 0.37,-0.73 0.77,0.84 0.3,-0.72 c 0,0 0.4,-2.53 0.77,-5.93 C 15.19,2.27 15.77,1.09 16,1 c 0.209,-0.018 0.78,1.25 0.85,1.96 z"
|
|
||||||
},
|
|
||||||
"l159": {
|
|
||||||
"viewBox": "-3.7 0 32 32",
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 1,
|
|
||||||
"accentMult": 0.6,
|
|
||||||
"path": "M13.1 3.13l.11 3c.37 4.18.14 6.3.14 6.3.1.7.59.76.59.76l6.32.7.41-.28v-1.02c.37-2.88.69 0 .69 0v3.55c.03.68-.3 1.6-.3 1.6h-.17a4.2 4.2 0 01-.21-1.14l-.1-.1-6.84 1.44a53.86 53.86 0 01-.46 4.51l3.34.97c.2.48.14 1.3.14 1.3l-4.47.52-4.47-.51s-.06-.83.13-1.3l3.35-.98s-.38-2.55-.46-4.5L4 16.5l-.1.1s-.01.52-.21 1.13H3.5s-.33-.92-.3-1.6v-3.55s.32-2.88.7 0v1.02l.4.27 6.33-.69s.49-.05.58-.76c0 0-.23-2.12.14-6.3l.12-3s.08-1.72.8-1.74c.73.02.81 1.74.81 1.74z",
|
|
||||||
"accent": "M10.81 13.65l.75-.07c.45.92.7 2.13.7 2.13l-.01 10.4v-10.4s.24-1.21.68-2.13l.75.07"
|
|
||||||
},
|
|
||||||
"mirage": {
|
|
||||||
"viewBox": "-5.8 -3.8 36 36",
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "m 13.09,26.20 c 0,0 0.30,-1.70 0.30,-2.80 L 13.94,22.81 13.92,22.07 21.19,21.60 c 0,0 0.20,-1 -0.30,-1.60 l -4,-6.50 c 0,0 0.40,-5.30 -0.70,-5.80 0,0 -0.70,-0.20 -0.60,4.10 l -1.10,-2.10 V 9 c 0,0 0,-0.40 -0.30,-0.60 l -0.10,-0.70 h -0.70 l -0.30,-1.20 V 3.10 c 0,0 -0.25,-2.58 -0.85,-4.48 C 11.65,0.52 11.40,3.10 11.40,3.10 V 6.50 L 11.10,7.70 H 10.40 L 10.30,8.40 C 10,8.60 10,9 10,9 V 9.70 L 8.90,11.80 C 9,7.50 8.30,7.70 8.30,7.70 7.20,8.20 7.60,13.50 7.60,13.50 l -4,6.50 c -0.50,0.60 -0.30,1.60 -0.30,1.60 l 7.27,0.47 -0.02,0.74 L 11.10,23.40 c 0,1.10 0.30,2.80 0.30,2.80 z",
|
|
||||||
"accent": "M 9.90,9.57 C 10.50,12.57 10.80,15.90 10.80,15.90 l 0.20,4.50 0.01,3.21 M 14.59,9.57 C 13.99,12.57 13.69,15.90 13.69,15.90 l -0.20,4.50 -0.01,3.21"
|
|
||||||
},
|
|
||||||
"sb39": {
|
|
||||||
"viewBox": "-4.1 0 32 32",
|
|
||||||
"w": 29,
|
|
||||||
"h": 29,
|
|
||||||
"strokeScale": 1,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M12.77 4.94c.15 1.84.06 3.88.06 3.88l.46.17c.22.04.23.03.25.26.03.23.17.4.17.4l2.5 3.86v.85l-2-1.08h-.4l.26.85 1.7 2.28 1.76 1.94v-.38l1.84 2.53.19-.76h.05v-1.48c.14-.66.26-.25.26-.25v-.28c.2-1.26.3 0 .3 0v1.9l.1.1v2.4h-.1v.19h.16l.1.37h-.15v.14h-.08v.23h-.33v.88l-.12.12h-.2v-.82h.08l-.02-.4-5.3.3-.67.41v.46c-.23.1-.3.3-.3.3l-.32 1.42c-.06.17-.18.15-.18.15l-.25 1.64h-1.31l-.25-1.64s-.12.02-.19-.15l-.31-1.41s-.08-.22-.3-.31v-.46l-.67-.4-5.3-.3-.03.4h.1v.81h-.21L4 23.94v-.88h-.33v-.23H3.6v-.14h-.15l.1-.37h.16v-.19H3.6v-2.4l.1-.1v-1.9s.12-1.26.3 0v.28s.13-.41.27.25v1.48h.05l.19.76 1.84-2.53v.38l1.75-1.94 1.7-2.28.26-.85h-.4l-1.99 1.08v-.85l2.5-3.86s.14-.17.17-.4c.02-.23.02-.22.25-.26l.45-.17s-.08-2.04.07-3.88c0 0 .18-1.92.82-3.32.65 1.4.85 3.32.85 3.32z",
|
|
||||||
"accent": "M13.52 24.7s-.16-.4-.14-.5c.01-.08.24-1 .28-2.33 0 0 0-1.33-.08-2.5-.07-1.18 0-2.77.14-4.16.14-1.4.12-4.17-.05-5.26-.16-1.09-.2-1.64-.2-1.64m-3.1 16.4s.16-.41.14-.5c-.02-.1-.24-1.02-.28-2.34 0 0 0-1.33.08-2.5.07-1.18 0-2.77-.14-4.16a29.67 29.67 0 01.24-6.9"
|
|
||||||
},
|
|
||||||
"md_a4": {
|
|
||||||
"viewBox": "-4.2 -1 32 32",
|
|
||||||
"w": 26,
|
|
||||||
"h": 26,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"strokeScale": 1.1,
|
|
||||||
"path": "M11.7 26.5H8v-.7s0-.3.3-.6l2.6-2.4s-.2-1-.3-4H3v-2.4l.3-.6 6.3-5.5.2-1.3s.3-.2.6-.1V7.8s0-.2.2 0c0 0 0-3.5.5-6.2 0 0 .3-1.2.7-1.2.4 0 .7 1.2.7 1.2.5 2.7.5 6.2.5 6.2 0-.2.2 0 .2 0v1l.6.2.2 1.3 6.3 5.5c.3.4.2.6.2.6v2.3h-7.4c-.1 3-.3 4-.3 4l2.6 2.5c.3.3.3.6.3.6v.7H12l-.1.5-.2-.5z",
|
|
||||||
"accent": "M9.7 20.4l.9-1.7-1-8.4-.9-1.4M14 20.4l-.8-1.7.9-8.4 1-1.4"
|
|
||||||
},
|
|
||||||
"tornado": {
|
|
||||||
"viewBox": "-4 -3 32 32",
|
|
||||||
"w": 25,
|
|
||||||
"h": 25,
|
|
||||||
"strokeScale": 1.1,
|
|
||||||
"accentMult": 0.6,
|
|
||||||
"path": "M11.54 1.27c-.3.76-.4 1.32-.4 1.32-.15.85-.21 1.62-.21 1.62-.19 1.45-.23 3.91-.23 3.91H9.48l-.09.46a28.8 28.8 0 00.04 2.45l-1.18 1.9-7.05 3.18c-.6.75-.72 2.58-.72 2.58l9.37-2.37.03 1.97-2.61 3.56-1.07 1.74c-.05.84.01 1.6.01 1.6l4.02-1.3.13-.22c.26.17 1.37.06 1.37.06l.36 3.2c.03.11.36-3.2.36-3.2s1.1.1 1.36-.06l.13.23 4.02 1.3s.06-.77.01-1.61l-1.06-1.74-2.61-3.56.02-1.97 9.37 2.37s-.12-1.83-.72-2.58l-7.04-3.18-1.18-1.9s.07-1.09.03-2.45l-.09-.46h-1.22s-.04-2.46-.22-3.9c0 0-.07-.78-.22-1.63 0 0-.1-.56-.4-1.32L12.1.04z",
|
|
||||||
"accent": "M15.47 10.17l-.72.86s-.35 1.3-.4 2.43c0 0-.1.88-.04 2.09l-.06 4s.01 1.78-.4 4.2l-.52 1.24M8.71 10.17l.72.86s.34 1.3.39 2.43c0 0 .1.88.05 2.09l.06 4s-.02 1.78.39 4.2l.52 1.24"
|
|
||||||
},
|
|
||||||
"uav": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "0 1 32 32",
|
|
||||||
"strokeScale": 0.9,
|
|
||||||
"path": "M16.8 15.57v-1.08s1.8-5.07-.74-5.58c-2.44.59-.67 5.58-.67 5.58h0v1.08l-2.6.15-10.23.54v1.08l10.26.46 2.55.07v3.86h-3.05v1.38h3.25v.34l-1.81.25v.14h1.98c.15.49.35.47.35.47s.2.02.35-.47h1.99v-.14l-1.81-.25v-.34h3.24v-1.38h-3.05v-3.86l2.55-.07 10.26-.46v-1.08l-10.22-.54z"
|
|
||||||
},
|
|
||||||
"typhoon": {
|
|
||||||
"viewBox": "-4.8 -3.5 34 34",
|
|
||||||
"w": 25,
|
|
||||||
"h": 25,
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M 13.86,26.47 12.48,26.44 12.18,26.22 11.88,26.43 H 10.56 L 10.26,24.26 9.76,23.84 9.74,23.06 2.20,22.66 1.94,23.63 c 0,0 -0.42,-0.05 -0.32,-3.58 l 0.30,-1.07 0.21,1.03 1.20,-1.46 -0.02,-1.80 -0.15,-0.08 0.14,-0.18 c 0,0 0.02,-0.57 0.20,-0.66 0,0 0.20,0.02 0.20,0.64 l 0.14,0.16 -0.12,0.12 v 1.30 l 2.60,-3.36 c 0,0 -0.04,-3.68 0.56,-3.97 0,0 0.37,-0.33 0.52,2.57 L 10.01,9.96 10.02,9.61 10.36,9.01 10.50,7.07 10.78,6.86 10.84,5.89 8.50,6.57 V 5.60 l 2.65,-2.70 c 0,0 0.22,-3.13 1.05,-4.58 0,0 0.80,1.18 1.05,4.58 l 2.65,2.70 v 0.96 l -2.32,-0.67 0.05,0.95 0.28,0.22 0.17,2.06 0.28,0.44 0.05,0.40 2.62,3.38 c 0,0 0.10,-2.63 0.50,-2.60 0,0 0.55,-0.17 0.55,3.96 l 2.63,3.35 0.01,-1.29 -0.15,-0.10 0.14,-0.12 c 0,0 0.02,-0.70 0.17,-0.70 0,0 0.21,-0.12 0.20,0.66 l 0.15,0.12 -0.14,0.14 v 1.80 l 1.21,1.49 0.15,-1.10 0.30,1.10 c 0,0 0.20,3.19 -0.25,3.53 l -0.26,-0.90 -7.60,0.36 0.02,0.83 -0.50,0.36 z",
|
|
||||||
"accent": "M 13.69,6.55 13.86,12.79 l 0.32,5.97 c 0,0 0.12,4.31 -0.01,6.05 m -1.99,-1.64 v 7.07 M 10.21,24.81 C 10.02,23.09 10.18,18.68 10.18,18.68 l 0.34,-5.94 0.18,-6.15"
|
|
||||||
},
|
|
||||||
"rafale": {
|
|
||||||
"w": 25,
|
|
||||||
"h": 25,
|
|
||||||
"viewBox": "-0.3 -1 32 32",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M17.1 9.2s.5 1.3.6 2h.3v.3l1.8 1.6s.2.2.2.5v.6l-1.7-.5.5 1.9 5.4 5.5V20s0-.2.1-.1c0 0 0-.3.3-.3v-1.4s.2-1.3.3 0v2h.1V23h.1l.1.4h-.1v.1H25v.3h-.4v.4s-.5.2-.5-.5l-6 .4v1.3h-.5l-.1.8H16s0 .5-.2.4v1.3h-.3v-1.3l-.1-.4h-1.5l-.2-.9h-.5V24l-6-.4s.2.6-.4.5v-.4h-.3v-.3h-.2v-.1h-.1v-.4h.2v-4.7s.2-1.3.4 0v1.4s.3 0 .3.3v1.2l5.5-5.5.4-1.9-1.6.5v-.6s0-.3.2-.5l1.8-1.6v-.3h.2s.1-.8.6-2c0 0 .4-.9.6-2 0 0 .2-2.3.9-4.3 0 0 .7 2.2.9 4.2 0 0 .2 1.4.5 2.1z"
|
|
||||||
},
|
|
||||||
"hunter": {
|
|
||||||
"viewBox": "-5.2 -4 34 34",
|
|
||||||
"w": 26,
|
|
||||||
"h": 26,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M12.42 27.46l.17-.64 2.35 1.3h.32s.17-1.51-.23-2.22L13 23.52s.22-3.83.17-6.81l.7-.96 8.27 3.92s.45-3.2-.47-4.48l-7.12-6.96-.09.07-1.25-.93s-.01-1.97-.16-3.8c0 0-.34-4-1.2-4.37-.87.37-1.21 4.38-1.21 4.38-.15 1.82-.16 3.79-.16 3.79l-1.25.93-.1-.07-7.11 6.96c-.92 1.29-.47 4.48-.47 4.48l8.28-3.92.69.96c-.05 2.98.17 6.81.17 6.81L8.64 25.9c-.4.7-.23 2.22-.23 2.22h.32l2.35-1.3.17.64h.58z",
|
|
||||||
"accent": "M14.36 6.6l-1.16.77s-.37.02-.44 2.33c0 0 .06 2.16.3 3.47 0 0 .14 1.68.1 3.54l1.32 1.36M9.3 6.6l1.16.78s.37.02.43 2.33c0 0-.05 2.16-.3 3.47 0 0-.13 1.68-.09 3.54l-1.32 1.36"
|
|
||||||
},
|
|
||||||
"lancaster": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "0.2 0 32 32",
|
|
||||||
"strokeScale": 0.8,
|
|
||||||
"path": "M15.74 9.17c-.34.55-.34 1.44-.34 1.44v3.34h-2.06c.3-1.34-.1-1.73-.1-1.73v-.3c.63.11 1.2-.1 1.2-.1-.74-.13-1.22-.05-1.22-.05-.37-.84-.7 0-.7 0-.52-.09-1.23.11-1.23.11.84.16 1.19.04 1.19.04v.3c-.38.56-.09 1.72-.09 1.72h-.41l-2.62.35c.26-.93-.1-1.52-.1-1.52v-.3c1 .07 1.19-.1 1.19-.1-.42-.14-1.2-.06-1.2-.06-.36-.82-.7 0-.7 0-.12-.07-1.48.02-1.24.12.24.1 1.2.06 1.2.06v.32c-.43.9-.05 1.56-.05 1.56l-5.92.77c-.13.03-.99.21-.97 1.08.02.87.99 1.12.99 1.12l9.3.98 3.5.01.14 6.62-4 .15c-.24.8 0 1.58 0 1.58l.06-.33c.03.42.37.41.37.41l3.56.4c0 .7.29.87.29.87h.79s.28-.18.28-.86l3.57-.4s.33 0 .37-.42l.06.33s.24-.79 0-1.58l-4-.15.13-6.62 3.5-.01 9.3-.98s.98-.25 1-1.12c.01-.87-.85-1.05-.97-1.08l-5.93-.77s.38-.66-.04-1.56v-.32s.95.04 1.2-.06c.24-.1-1.13-.2-1.24-.12 0 0-.35-.82-.7 0 0 0-.78-.08-1.2.07 0 0 .18.16 1.18.09v.3s-.36.6-.1 1.52l-2.62-.35h-.41s.3-1.16-.08-1.72v-.3s.34.12 1.18-.04c0 0-.7-.2-1.23-.1 0 0-.33-.85-.7 0 0 0-.48-.09-1.2.04 0 0 .55.21 1.19.1v.3s-.4.39-.1 1.73h-2.06V10.6s0-.9-.35-1.44c0 0 .01-.36-.43-.36s-.43.36-.43.36z"
|
|
||||||
},
|
|
||||||
"beluga": {
|
|
||||||
"w": 29,
|
|
||||||
"h": 29,
|
|
||||||
"viewBox": "-1 -1 34 34",
|
|
||||||
"strokeScale": 0.8,
|
|
||||||
"path": "M17.45 3.79c.58 1.14.5 2.68.5 2.68v5.74l1.91 1.08v-.34h-.26c-.22-1.21 0-2.62 0-2.62H21c.23 1.43 0 2.61 0 2.61h-.25l-.07.84 7.25 4.26c.28.14.25.5.25.5v1.27l-2.25-.83c-.14.95-.2-.08-.2-.08l-1.2-.44c-.13.97-.18-.06-.18-.06l-.97-.36c-.13.89-.18-.06-.18-.06l-1.43-.54c-.15.94-.22-.07-.22-.07l-.62-.24h-1.75c-.12 1.06-.2 0-.2 0h-1.03v4.13l-1.12 6.15 3.37 2.5.05-.61h.1v2.06l-3.98-1.47-.27 1.44H15.81l-.27-1.44-3.98 1.47V29.3h.1l.05.61 3.37-2.5-1.12-6.15v-4.13h-1.03s-.08 1.06-.2 0h-1.75l-.62.24s-.07 1.01-.21.07l-1.43.54s-.06.95-.18.06l-.98.36s-.05 1.03-.17.06l-1.2.44s-.07 1.03-.2.08l-2.25.83-.01-1.26s-.03-.37.25-.5l7.25-4.27-.07-.84h-.25s-.23-1.18 0-2.61h1.4s.22 1.4 0 2.62h-.26v.34l1.9-1.08V6.47s-.07-1.54.51-2.68c0 0 .91-3.34 1.5-3.23.58-.1 1.49 3.23 1.49 3.23z"
|
|
||||||
},
|
|
||||||
"c17": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"strokeScale": 0.8,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"path": "M16.21 5.17l.19.19.26.56.34.83.19.48.22.83.11.82v1.16l.19.3.11.3.08.45v.45l1.16.67-.08-.52h-.11l-.04-.04-.07-.38v-.78l.04-.38h-.15v-.1l.07-.2.11-.1.04-.08h.9l.02.07.13.11.07.2v.1h-.15l.04.38v.78l-.07.38-.04.04h-.11l-.12.74 2.36 1.39-.07-.67h-.12l-.03-.04-.08-.38v-.78l.04-.38h-.15v-.1l.08-.2.1-.1.04-.08h.9l.04.07.11.11.08.2v.1h-.15l.04.38v.78l-.08.38-.04.04h-.1l-.16.93 4.57 2.62.11.08.07.15v.22l.34.79v.33l-.52-.4-3.86-.83-.07.19-.11.03V17l-1.35-.3-.11.23-.12.03v-.33l-.9-.19-.03.22-.11.2-.12.03v-.52l-1.42-.3-.04.22-.1.19-.12.03V16l-.56-.15-.08.49-.11.4-.19.38v2.92l-.07 1.46-.3 1.43-.34 1.16-.22.56L20.22 27l.15.15.03.15v.74l-4.26-1-.11.82-.12-.83-4.26 1.01v-.74l.03-.15.15-.15 3.63-2.36-.22-.56-.34-1.16-.3-1.43-.07-1.46v-2.92l-.19-.37-.11-.41-.08-.49-.56.15v.52l-.11-.03-.11-.19-.04-.22-1.42.3v.52l-.11-.04-.12-.19-.03-.22-.9.19v.33l-.11-.03-.12-.23-1.34.3v.26l-.12-.03-.07-.2-3.86.83-.52.41v-.33l.34-.79v-.22l.07-.15.11-.08 4.57-2.62-.15-.93h-.11l-.04-.04-.07-.38v-.78l.03-.38h-.15v-.1l.08-.2.11-.1.04-.08h.9l.03.07.12.11.07.2v.1h-.15l.04.38v.78l-.08.38-.03.04h-.12l-.07.67 2.36-1.39-.12-.74h-.1l-.05-.04-.07-.38v-.78l.04-.38h-.15v-.1l.07-.2.11-.1.04-.08h.9l.04.07.11.11.08.2v.1h-.15l.03.38v.78l-.07.38-.04.04h-.11l-.08.52 1.16-.67v-.45l.08-.45.11-.3.19-.3V8.88l.11-.82.22-.83.2-.48.33-.83.26-.56.19-.19.19-.07z"
|
|
||||||
},
|
|
||||||
"c5": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"strokeScale": 0.7,
|
|
||||||
"path": "M14.61 4.6v5.82l-2.92 1.56v-.4h.14c.16-.47.09-.96.09-.96 0-.23-.23-.23-.23-.23h-.62c-.21 0-.2.24-.2.24v.93h.15v.76l-2.03 1.07v-.41h.14c.18-.42.09-.98.09-.98 0-.26-.24-.23-.24-.23h-.6c-.2-.02-.2.17-.2.17v1h.16v.78L3.6 16.17c-1.07.54-.97 2.25-.97 2.25l3.62-1.1c.14 1.75.17-.01.17-.06l.94-.26c.16 1.67.17-.07.17-.07l.92-.3c.12 1.97.18-.05.18-.05l1.14-.36c.07 1.95.14.1.14-.04l1.23-.27c.09 2.04.14.01.13-.02l1.43-.28c.14 2.23.18.02.17-.03l1.45-.26a6.8 6.8 0 00.29 2.38v1.94c-.06 1.93.05 3.43.05 3.43.19 2.82.7 4.74.7 4.74l-3.1 1.73c-.53.32-.4 1.13-.4 1.13l3.75-.63c-.07 1.24.4 1.19.4 1.19s.47.05.4-1.2l3.75.64s.13-.8-.4-1.13l-3.09-1.73s.5-1.92.7-4.74c0 0 .1-1.5.05-3.43V17.7s.3-.7.28-2.38l1.45.26c0 .2.04 2.16.18.03l1.42.28c0 .06.01 1.95.13.02l1.23.27c0 .26 0 1.84.15.04l1.14.36c0 .11.02 2.02.18.04l.92.3c0 .07.01 1.98.16.08l.95.26c0 .1.06 1.84.17.06l3.61 1.1s.1-1.7-.96-2.25l-4.74-2.45v-.78h.16v-1s0-.2-.2-.17h-.6s-.25-.03-.24.23c0 0-.1.56.08.98h.14v.41l-2.02-1.07v-.76h.14v-.93s.02-.24-.19-.24h-.62s-.24 0-.23.23c0 0-.07.49.09.96h.14v.4l-2.92-1.56V4.6s-.38-2.66-1.4-3c-1.04.34-1.41 3-1.41 3z"
|
|
||||||
},
|
|
||||||
"super_guppy": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "-1 -1 34 34",
|
|
||||||
"strokeScale": 1,
|
|
||||||
"path": "M15.16 3.9c-2 2.63-1.98 3.52-1.98 3.52l-.01.71-.06 3.61-.9.06c.02-.97-.15-2.08-.15-2.08h.89V9.6h-.92c-.2-.66-.39 0-.39 0h-1.23v.1h1.22c-.2 1.13-.2 2.17-.2 2.17L9.48 12c.03-1.13-.15-2.13-.15-2.13h1.21v-.1H9.3c-.22-.74-.4 0-.4 0H7.65v.1h1.22c-.18.73-.18 2.19-.18 2.19l-6.82.5c-.65.22-.44 1.1-.44 1.1.25.56.84.53.84.53l4.65.02c.16.2.49.2.49.2l5.67-.02c-.12.9.24 2.77.24 2.77l1.96 10.43-4 .43c-.2.01-.26.2-.26.2s-.04.26.01.48c.05.22.27.22.27.22l4.33.6c.12.85.3.78.3.78s.2.07.3-.78l4.34-.6s.22 0 .27-.22c.05-.22 0-.49 0-.49s-.06-.18-.25-.19l-4-.43 1.96-10.43s.35-1.87.24-2.77l5.67.02s.33 0 .5-.2l4.64-.02s.6.03.84-.53c0 0 .21-.88-.44-1.1l-6.82-.5s0-1.46-.18-2.19h1.22v-.1H23s-.2-.74-.42 0h-1.22v.1h1.21s-.18 1-.15 2.13l-1.97-.14s.02-1.04-.19-2.18h1.22V9.6h-1.23s-.19-.66-.4 0h-.9v.12h.88s-.17 1.11-.15 2.08l-.9-.06-.06-3.61v-.7s.01-.9-1.99-3.53c0 0 .16-.84-.79-.97-.94.13-.78.97-.78.97z"
|
|
||||||
},
|
|
||||||
"wb57": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"strokeScale": 0.8,
|
|
||||||
"path": "M16.2 25.02h.98v-.08l1.78-.01s.24-.02.22-.43c0 0 .02-.53-.36-.74l-2.37-1.07.28-3.22h1.48v.09h.07l.12.92h.19l.28-1.02 1.21-.13 10.16-1.34s.06-1.86-1.38-2.25l-9.74-1.54v-1.53s.06-.41-.33-.42h-.43s-.34-.04-.35.35v1.55h-1.32v-1.6L16.26 9s-.1-.33-.33-.33-.34.33-.34.33l-.42 3.54v1.61h-1.32V12.6c-.01-.39-.36-.35-.36-.35h-.42c-.4 0-.34.42-.34.42v1.53L3 15.74c-1.44.39-1.38 2.25-1.38 2.25l10.16 1.34 1.21.13.28 1.06.2-.01.11-.95h.07v-.1h1.48l.29 3.23-2.38 1.07c-.38.2-.36.74-.36.74-.02.4.22.43.22.43h1.78v.1h.99c.01.37.27.35.27.35s.25.02.26-.36z"
|
|
||||||
},
|
|
||||||
"a380": {
|
|
||||||
"w": 42,
|
|
||||||
"h": 42,
|
|
||||||
"viewBox": "-7 -6 40 40",
|
|
||||||
"strokeScale": 1.1,
|
|
||||||
"path": "m 11.60,21.60 c 0,0 0.50,-0.30 0.30,-1 0,0 -0.30,-0.70 -0.30,-3.60 v -2 c 0,0 -0.20,-0.90 -0.20,-2.10 l -1.70,0.50 c 0,0 -0.07,0.39 -0.15,0.41 C 9.47,13.83 9.40,13.40 9.40,13.40 l -1.34,0.60 c 0,0 -0.04,0.46 -0.15,0.45 -0.10,-0.01 -0.15,-0.35 -0.15,-0.35 L 6.50,14.60 c 0,0 0.03,0.66 -0.12,0.66 -0.13,0.00 -0.13,-0.59 -0.13,-0.59 l -1.42,0.65 c 0,0 -0.02,0.65 -0.14,0.67 C 4.59,16.00 4.60,15.40 4.60,15.40 l -1.20,0.50 c 0,0 -0.02,0.66 -0.14,0.67 C 3.14,16.58 3.15,15.99 3.15,15.99 L -2.20,18.30 c 0,0 -0.09,0.62 -0.20,0.60 -0.13,-0.02 0,-2 0.60,-2.40 L 3,12.90 C 2.78,12.08 2.69,11.33 2.80,10.70 c 0,0 0,-0.30 0.20,-0.30 H 4.19 C 4.48,10.52 4.30,11.27 4.30,12 L 7,9.90 c 0,0 -0.30,-0.60 -0.20,-2 0,0 0,-0.30 0.20,-0.50 H 8.09 C 8.29,7.56 8.27,8.39 8.20,9 8.20,9 11.60,6.50 11.60,6 V 3.20 c 0,0 0,-4 1.30,-4.20 0,0 1,-0.30 1.30,4 v 3 c 0,0 -0.20,0.30 3.40,3 V 8 c 0,0 0,-0.40 0.30,-0.50 h 0.80 c 0,0 0.30,0 0.30,0.40 0,0 0,1.70 -0.20,2.20 l 2.90,2 c -0.12,-0.50 -0.17,-1.59 0.17,-1.60 h 1.09 c 0.37,-0.01 0.29,1.77 -0.06,2.50 l 4.60,3.40 c 0.53,0.36 0.88,2.51 0.80,2.50 C 28.25,18.90 28,18.30 28,18.30 l -5.27,-2.34 c 0,0 0.01,0.62 -0.13,0.60 C 22.47,16.54 22.50,15.90 22.50,15.90 l -1.35,-0.53 c 0,0 0.02,0.70 -0.12,0.66 C 20.91,15.99 20.90,15.30 20.90,15.30 l -1.30,-0.60 c 0,0 -0.02,0.73 -0.13,0.70 C 19.33,15.37 19.30,14.60 19.30,14.60 l -1.20,-0.50 c 0,0 -0.08,0.31 -0.20,0.30 C 17.75,14.39 17.70,14 17.70,14 l -1.20,-0.50 c 0,0 -0.05,0.40 -0.20,0.40 -0.15,0 -0.20,-0.40 -0.20,-0.40 L 14.40,13 c 0,0 0,1.40 -0.20,2 v 2 c 0,0 0,2.70 -0.30,3.80 0,0 -0.20,0.40 0.10,0.60 l 4.30,3.50 c 0,0 0.50,0.20 0.70,2 l -5.70,-2.30 v 0.50 c -0.24,2.90 -0.53,2.90 -0.70,0 v -0.40 l -5.80,2.20 c 0,0 0.30,-1.70 0.50,-2 z"
|
|
||||||
},
|
|
||||||
"il_62": {
|
|
||||||
"w": 30,
|
|
||||||
"h": 30,
|
|
||||||
"viewBox": "-1 -2 30 31",
|
|
||||||
"path": "M16.92 26.41s.25.63.01 1.22l-2.8-.87-.16.92-.16-.91-2.83.85s-.19-.67.01-1.18l2.58-2.18s-.09-1.38-.64-1.8l-.15.08-.18-.34h-.32s-.3.06-.3.52h-.18s.04-.35-.16-.53h-.33s-.47-3.31-.12-3.67h.59l.06.17h.24l.07-.17h.58l.09.2h.21l-.01-1.77-.25-.2-1.75-.02L3.57 20l-.18.3s-.46-1.4.02-2.01l.16.13 6.69-5.51.11.13L13.01 11l.03-8.03s.1-2.5.91-2.74c0 0 .8 0 .96 2.77l.01 8 2.6 2.06.14-.15 6.73 5.53.12-.14s.53.56.03 1.98l-.16-.25-7.47-3.28h-1.75l-.22.17-.02 1.8h.23l.03-.19h.6l.05.18h.27l.05-.17h.59s.43.96-.15 3.68h-.27s-.24.04-.2.53h-.17s.02-.42-.27-.55h-.31l-.2.32-.16-.06s-.4.21-.67 1.85z"
|
|
||||||
},
|
|
||||||
"u2": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-3.8 -4 34 34",
|
|
||||||
"path": "M16.9 21s.2 0 .2.3v.7s0 .3-.2.2l-3.7.5s0 1-.2.8c0 0-.2.2-.2-.8l-3.6-.5s-.2 0-.2-.4v-.6s0-.2.3-.3l3-.5V16l-2.4-.2s-.1.8-.5.8c0 0-.5 0-.5-1l-4.3-.3s-.1.5-.2 0l-7-.6v-1.2L9 12.1s-.2-2.5.4-2.5c0 0 .6-.2.5 2.3l2.3-.3s-.4-1.6-.1-2h.5V6.4s0-2.4.6-2.5c0 0 .5 0 .6 2.5v3.2h.5s.3.3 0 2l2.2.4v-1.5s0-.8.4-1c0 0 .6 0 .5 1v1.6l11.5 1.5v1.2l-7 .6s0 .6-.2 0l-4.4.5s0 1-.4.9c0 0-.3 0-.4-.9l-2.6.3v4.2z"
|
|
||||||
},
|
|
||||||
"glider": {
|
|
||||||
"w": 22,
|
|
||||||
"h": 33,
|
|
||||||
"viewBox": "-5.8 -10 76 76",
|
|
||||||
"noAspect": true,
|
|
||||||
"strokeScale": 2.4,
|
|
||||||
"path": "m 32.000,45.932 -0.215,0.314 c -0.118,0.173 -0.196,0.239 -0.378,0.401 -0.226,0.145 -0.850,-0.045 -1.196,-0.137 -0.658,-0.204 -1.909,-0.478 -2.984,-0.718 -0.065,-0.021 -0.186,-0.136 -0.406,-0.344 -0.342,-0.323 -0.409,-0.463 -0.459,-0.961 -0.074,-0.730 0.183,-1.127 0.795,-1.228 0.218,-0.036 0.732,-0.130 1.143,-0.210 0.411,-0.080 1.132,-0.201 1.602,-0.271 1.252,-0.185 1.635,-0.299 1.701,-0.507 0.059,-0.186 -0.006,-2.549 -0.101,-3.654 -0.110,-2.092 -0.181,-3.601 -0.281,-5.738 0.039,-0.214 -0.274,-0.732 -0.553,-0.915 l -5.180,-0.560 c -0.611,-0.069 -3.989,-0.350 -5.732,-0.476 -1.476,-0.108 -2.940,-0.246 -4.432,-0.362 l -3.097,-0.439 C 7.935,29.593 4.497,29.014 2.499e-5,28.410 l 0.019,-2.401 5.562,-0.286 c 2.699,-0.023 6.207,-0.092 9.264,-0.183 0.646,-0.019 4.548,-0.040 8.671,-0.047 l 7.496,-0.012 -0.017,-2.376 c -0.007,-1.423 -0.104,-3.049 0.253,-4.827 0.028,-0.088 0.121,-0.396 0.344,-0.722 0.071,-0.090 0.213,-0.175 0.408,-0.255 0.195,0.080 0.337,0.165 0.408,0.255 0.223,0.325 0.316,0.633 0.343,0.722 0.357,1.778 0.261,3.405 0.253,4.827 l -0.016,2.376 7.496,0.012 c 4.123,0.007 8.025,0.028 8.671,0.047 3.057,0.091 6.564,0.160 9.264,0.183 l 5.562,0.286 0.019,2.401 c -4.497,0.605 -7.935,1.183 -12.228,1.717 l -3.097,0.439 c -1.492,0.116 -2.956,0.254 -4.432,0.362 -1.743,0.127 -5.121,0.408 -5.732,0.476 l -5.180,0.560 c -0.278,0.182 -0.592,0.701 -0.553,0.915 -0.100,2.136 -0.171,3.646 -0.281,5.738 -0.095,1.105 -0.160,3.468 -0.101,3.654 0.066,0.208 0.449,0.322 1.701,0.507 0.470,0.069 1.191,0.191 1.602,0.271 0.411,0.080 0.926,0.174 1.143,0.210 0.612,0.101 0.870,0.498 0.795,1.228 -0.051,0.499 -0.118,0.638 -0.460,0.961 -0.220,0.208 -0.341,0.323 -0.406,0.344 -1.075,0.240 -2.326,0.513 -2.984,0.718 -0.346,0.091 -0.970,0.282 -1.196,0.137 -0.182,-0.162 -0.260,-0.228 -0.378,-0.401 z"
|
|
||||||
},
|
|
||||||
"p3_orion": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 40,
|
|
||||||
"viewBox": "-35 -35 1250 1460",
|
|
||||||
"path": "M580 1349 c0 -22 -5 -70 -11 -107 -11 -67 -12 -68 -50 -80 -21 -7 -56 -13 -76 -13 -74 -3 -98 -12 -98 -39 0 -22 9 -28 90 -56 l90 -31 -4 -189 c-1 -104 -4 -190 -5 -191 -3 -3 -175 -39 -331 -69 -163 -31 -185 -41 -185 -87 0 -51 13 -57 121 -57 l97 0 4 -35 c4 -34 3 -35 -32 -37 l-35 -1 34 -4 c19 -3 40 -12 47 -20 10 -15 12 -15 20 -1 5 9 23 18 39 21 l30 4 -30 2 c-29 1 -31 3 -27 36 l4 35 53 0 c51 0 53 -1 59 -31 9 -47 8 -49 -26 -50 l-33 -2 30 -4 c17 -2 36 -12 43 -21 11 -15 13 -15 25 0 6 9 23 19 37 21 24 4 23 4 -4 6 -21 1 -27 5 -22 17 3 9 6 24 6 35 0 15 7 19 39 19 l39 0 4 -157 c3 -145 5 -162 28 -208 14 -27 32 -50 40 -50 8 0 26 23 40 50 23 46 25 63 28 208 l4 157 39 0 c32 0 39 -4 39 -19 0 -11 3 -26 6 -35 5 -12 -1 -16 -22 -17 -27 -2 -28 -2 -4 -6 14 -2 31 -12 38 -21 11 -15 13 -15 25 0 6 9 26 19 42 21 30 4 30 4 -4 6 -32 1 -33 2 -27 32 3 17 6 35 6 40 0 5 24 9 54 9 l54 0 4 -35 c4 -33 2 -35 -27 -36 l-30 -2 30 -4 c16 -3 34 -12 41 -21 10 -14 12 -14 20 1 5 8 25 17 44 20 l35 4 -35 1 c-35 2 -36 3 -32 37 l4 35 97 0 c108 0 121 6 121 57 0 46 -22 56 -185 87 -156 30 -328 66 -331 69 -1 1 -4 87 -5 191 l-4 189 90 31 c81 28 90 34 90 56 0 27 -24 36 -98 39 -20 0 -55 6 -76 13 -38 12 -39 13 -50 80 -6 37 -11 85 -11 107 0 23 -4 41 -10 41 -5 0 -10 -18 -10 -41z"
|
|
||||||
},
|
|
||||||
"cirrus_sr22": {
|
|
||||||
"w": 23,
|
|
||||||
"h": 25,
|
|
||||||
"strokeScale": 2.5,
|
|
||||||
"noAspect": true,
|
|
||||||
"viewBox": "0 0 64 64",
|
|
||||||
"path": "m 32.529,48.297 9.466,-0.384 C 41.957,46.705 41.877,45.583 41.357,45.379 L 32.729,44.318 c -0.171,0.039 0.467,-4.770 1.102,-7.068 0.302,-1.092 0.624,-2.364 0.914,-3.624 0.194,-0.845 0.447,-2.245 1.227,-2.293 l 21.325,-2.424 1.775,0.206 c -0.165,-2.018 -0.034,-4.383 -1.794,-4.523 L 35.400,23.942 35.189,18.157 c -0.172,-1.993 -0.488,-3.164 -0.953,-3.403 l -1.219,-0.439 -0.055,-0.257 c 0,0 5.143,0.347 6.198,-0.023 0.088,-0.031 0.079,-0.231 0,-0.281 -0.363,-0.227 -6.243,0.030 -6.243,0.030 0,0 -0.356,-1.317 -0.700,-1.707 -0.263,0.374 -0.700,1.707 -0.700,1.707 0,0 -5.889,-0.280 -6.440,-0.030 -0.135,0.061 -0.084,0.239 0,0.281 0.787,0.400 6.390,-0.024 6.395,0.023 l -0.055,0.257 -1.219,0.439 c -0.464,0.240 -0.781,1.410 -0.953,3.403 L 29.036,23.942 7.158,24.591 c -1.761,0.140 -1.630,2.505 -1.794,4.523 l 1.775,-0.206 21.325,2.424 c 0.780,0.048 1.033,1.448 1.227,2.293 0.290,1.261 0.612,2.533 0.914,3.624 0.636,2.298 1.273,7.107 1.102,7.068 l -8.628,1.061 c -0.520,0.204 -0.600,1.325 -0.638,2.533 l 9.466,0.384 0.307,3.297 z"
|
|
||||||
},
|
|
||||||
"verhees": {
|
|
||||||
"viewBox": "-3.7 0 32 32",
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M7.99 22.96l-3.37-.58-5.02-2.5v-4.46L9.74 6.04l.67-1.34.93-.17s.56-.77.84-.74c.28-.03.84.74.84.74l.94.17.66 1.34 10.14 9.38v4.45l-5.02 2.5-3.36.59-4.2-1.06z",
|
|
||||||
"accent": "M-.63 19.22h9.1l3.84 2.68 3.85-2.68h8.82M15.35 5.44l-1.07 1.22m-5-1.22l1.06 1.22.79 8.33 1.18 6.9.36 1.35m-.72 0l.36-1.34 1.19-6.91.78-8.33h-3.94"
|
|
||||||
},
|
|
||||||
"gyrocopter": {
|
|
||||||
"viewBox": "-3.5 -3 32 32",
|
|
||||||
"w": 27,
|
|
||||||
"h": 27,
|
|
||||||
"strokeScale": 1,
|
|
||||||
"path": [
|
|
||||||
"M12.13 18.58l-.27-.43-.66.81-2.51.23s-.07 1.23-.24.03c0 0-.13-1-.1-1.97 0 0 .23-.33.33-.11v.16l3.3-1.9s-.15-.29-.24-.64c0 0-1.56.02-2.53-.14v-.13s1.35-.02 2.47-.1l.41-.04v-.12h-1.24s-.1.05-.1-.11v-.2s-.28.07-.27-.19V12.7l-1.73-.26s.05.49-.28.48c0 0-.25 0-.26-.24v-.92s.02-.25.25-.26c0 0 .25 0 .26.24v.36h.1l1.93.1v-.11c.05-.5.03-.97.03-.97s-.14-1.68.02-3.11c0 0 .39-3.42 1.44-4.1 1.04.68 1.43 4.1 1.43 4.1.16 1.43.02 3.11.02 3.11s-.02.47.03.97v.11l1.93-.1h.1v-.36c.01-.25.26-.24.26-.24.23.01.25.26.25.26v.92c-.01.24-.25.24-.25.24-.34 0-.28-.48-.28-.48l-1.74.26v1.03c.01.26-.26.18-.26.18v.2c0 .17-.1.12-.1.12h-1.25v.12l.42.04c1.11.08 2.47.1 2.47.1v.13c-.98.16-2.54.14-2.54.14-.08.35-.24.63-.24.63l3.3 1.9v-.15c.1-.22.33.11.33.11.04.98-.1 1.97-.1 1.97-.17 1.2-.24-.03-.24-.03l-2.51-.23-.65-.8-.28.42-.1 1.57z",
|
|
||||||
"M25.68 21.87l-12.47-7.8-.37-.76-.42-.26s-.54.3-.6-.35l-.3-.23s-.13.04-.5-.21l-12.7-7.94s.06-.52.45-.7l12.41 7.77.42.76.38.24s.6-.26.64.4l.33.21s.32.09.56.27l12.61 7.89s.02.43-.44.7z"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"helicopter": {
|
|
||||||
"w": 29,
|
|
||||||
"h": 29,
|
|
||||||
"viewBox": "-13 -13 90 90",
|
|
||||||
"strokeScale": 3,
|
|
||||||
"path": "m 24.698,60.712 c 0,0 -0.450,2.134 -0.861,2.142 -0.561,0.011 -0.480,-3.836 -0.593,-5.761 -0.064,-1.098 1.381,-1.192 1.481,-0.042 l 5.464,0.007 -0.068,-9.482 -0.104,-1.108 c -2.410,-2.131 -3.028,-3.449 -3.152,-7.083 l -12.460,13.179 c -0.773,0.813 -2.977,0.599 -3.483,-0.428 L 26.920,35.416 26.866,29.159 11.471,14.513 c -0.813,-0.773 -0.599,-2.977 0.428,-3.483 l 14.971,14.428 0.150,-5.614 c -0.042,-1.324 1.075,-4.784 3.391,-5.633 0.686,-0.251 2.131,-0.293 3.033,0.008 2.349,0.783 3.433,4.309 3.391,5.633 l 0.073,4.400 12.573,-12.763 c 0.779,-0.807 2.977,-0.599 3.483,0.428 L 37.054,28.325 37.027,35.027 52.411,49.365 c 0.813,0.773 0.599,2.977 -0.428,3.483 L 36.992,38.359 c -0.124,3.634 -0.742,5.987 -3.152,8.118 l -0.104,1.108 -0.068,9.482 5.321,-0.068 c 0.101,-1.150 1.546,-1.057 1.481,0.042 -0.113,1.925 -0.032,5.772 -0.593,5.761 -0.412,-0.008 -0.861,-2.142 -0.861,-2.142 l -5.387,-0.011 0.085,9.377 -1.094,2.059 -1.386,-0.018 -1.093,-2.049 0.085,-9.377 z"
|
|
||||||
},
|
|
||||||
"pa24": {
|
|
||||||
"viewBox": "-3 -3 30 30",
|
|
||||||
"w": 30,
|
|
||||||
"h": 30,
|
|
||||||
"noAspect": true,
|
|
||||||
"path": "M12.17 17.81l2.77.46.01.98-3.03.14-3.14-.18.02-.97 2.77-.43-.61-4.52-7.18-1.23s-.4-1.27.02-1.88l5.72-.25 1.39-.9.43-2.7-1.41-.01s-.15-.1 0-.16l1.73-.03s.34-1.08.67 0l1.72.04s.15.07 0 .15h-1.42l.36 2.72 1.37.95 5.82.27s.4.16.02 1.9l-7.33 1.14z"
|
|
||||||
},
|
|
||||||
"bae_hawk": {
|
|
||||||
"viewBox": "-2 0 29 29",
|
|
||||||
"w": 33,
|
|
||||||
"h": 33,
|
|
||||||
"path": "M15.9 21.7l-3-.7h-.7l-2.9.7s-.2-.5.2-1l2.4-1.6-.1-3.4-.1-.2-6.1.7s-.3-1.1.6-1.6l4.3-2.2.8-.5s-.3-1 .2-1.3h.4V7.2s.1-2.1.8-2.4c0 0 .6 0 .7 2.1v3.7h.5s.4.1.1 1.3l.8.6 4.3 2.1s.8.3.5 1.7l-6-.8-.1.2s0 1.8-.2 3.4l2.3 1.7s.4.2.3 1z"
|
|
||||||
},
|
|
||||||
"a10": {
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"w": 24,
|
|
||||||
"h": 24,
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": [
|
|
||||||
"M 16.001,1.860 C 15.777,1.878 15.396,2.202 15.194,2.828 14.910,3.706 14.879,4.322 14.879,9.139 l -4.250e-4,4.609 H 13.460 12.041 L 11.998,13.133 c -0.024,-0.338 -0.092,-0.707 -0.150,-0.820 -0.218,-0.421 -0.633,-0.407 -0.811,0.028 -0.049,0.119 -0.110,0.483 -0.136,0.809 l -0.048,0.593 -0.216,0.035 c -0.118,0.019 -1.118,0.112 -2.222,0.206 -3.270,0.280 -2.267,0.212 -4.830,0.428 -1.988,0.174 -2.134,0.220 -2.390,0.744 -0.146,0.301 -0.161,0.429 -0.185,1.703 -0.015,0.758 -0.014,1.378 0.001,1.378 0.015,0 2.147,0.168 4.738,0.374 2.591,0.206 4.798,0.374 4.904,0.374 h 1.639 l 0.003,1.412 c 0.006,2.535 0.069,3.271 0.329,3.859 0.208,0.539 0.568,0.639 0.987,0.648 0.411,-0.021 0.645,-0.166 0.872,-0.646 0.136,-0.279 0.170,-0.306 0.395,-0.306 h 0.246 l 0.084,0.833 c 0.046,0.458 0.102,0.948 0.123,1.088 l 0.039,0.255 h -2.073 -2.073 c -0.306,0.004 -0.339,-0.003 -0.369,0.046 -0.133,0.249 0.002,3.492 0.145,3.492 h 0.417 2.036 c 1.371,0 1.963,8.440e-4 2.036,0.071 0.028,0.027 0.075,0.146 0.167,0.238 0.096,0.096 0.219,0.153 0.340,0.163 0.122,-0.009 0.247,-0.067 0.344,-0.163 0.092,-0.092 0.139,-0.211 0.167,-0.238 0.073,-0.071 0.665,-0.071 2.036,-0.071 h 2.036 0.417 c 0.143,0 0.278,-3.243 0.145,-3.492 -0.031,-0.048 -0.063,-0.041 -0.369,-0.046 H 18.701 16.628 l 0.039,-0.255 C 16.688,25.734 16.743,25.244 16.790,24.786 l 0.084,-0.833 h 0.246 c 0.225,0 0.259,0.027 0.395,0.306 0.227,0.480 0.461,0.625 0.872,0.647 0.419,-0.009 0.779,-0.110 0.987,-0.649 0.260,-0.587 0.323,-1.324 0.329,-3.859 l 0.003,-1.412 h 1.639 c 0.107,0 2.314,-0.168 4.904,-0.374 2.591,-0.206 4.723,-0.374 4.738,-0.374 0.015,0 0.015,-0.620 9.370e-4,-1.378 -0.025,-1.274 -0.039,-1.402 -0.185,-1.703 -0.255,-0.524 -0.402,-0.570 -2.390,-0.744 -2.563,-0.216 -1.560,-0.148 -4.830,-0.428 -1.104,-0.094 -2.104,-0.187 -2.222,-0.206 l -0.216,-0.035 -0.048,-0.593 c -0.026,-0.326 -0.088,-0.690 -0.136,-0.809 -0.178,-0.435 -0.593,-0.449 -0.811,-0.027 -0.058,0.113 -0.125,0.481 -0.149,0.819 l -0.044,0.615 h -1.419 -1.418 l -4.260e-4,-4.609 C 17.121,4.321 17.090,3.706 16.806,2.828 16.605,2.204 16.226,1.880 16.001,1.860 Z",
|
|
||||||
"m 12.293031,18.986197 0.0039,1.41211 c 0.0062,2.53506 0.06826,3.269951 0.328125,3.857421 0.207911,0.538692 0.566942,0.639573 0.986328,0.648438 0.419386,-0.0089 0.78037,-0.109746 0.988281,-0.648438 0.25987,-0.58747 0.321925,-1.322361 0.328125,-3.857421 l 0.0039,-1.41211 c -2.638672,0 0,0 -2.638672,0 z",
|
|
||||||
"m 17.06817,18.98577 0.0039,1.41211 c 0.0062,2.53506 0.06826,3.269951 0.328125,3.857421 0.207911,0.538692 0.566942,0.639573 0.986328,0.648438 0.419386,-0.0089 0.78037,-0.109746 0.988281,-0.648438 0.25987,-0.58747 0.321925,-1.322361 0.328125,-3.857421 l 0.0039,-1.41211 c -2.638672,0 0,0 -2.638672,0 z"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"chinook": {
|
|
||||||
"viewBox": "-4.5 -3 32 32",
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"path": [
|
|
||||||
"M9.9 8.23s-.08-3.57 1.17-3.46c0 0 1-.12 1.2.5 0 0 .4.83.47 2.9m0 0s.56.08.53.85l-.01 8.41s-.02.34-.23.44c0 0 .06.26-.16.48l.03.22h-.19s.06 2.07-1.12 2.06c0 0-.73.12-1.06-.24 0 0-.65-.65-.58-1.81h-.18l.07-.26s-.19-.12-.14-.4c0 0-.23-.02-.28-.45v-8.7s.07-.52.49-.54",
|
|
||||||
"M17.76 13.38l-.58.56-4.63-5.05-.12-.84-.48-.62h-.16l-.2-.24s-.54.27-.76-.35l-.34.04-1.07.38-6.98 1.53-.15-.74L8.9 6.6l.8.24.74-.07.12-.14.23-.03s.1-.65.75-.51l.3-1.33L13.99-2l.7.21-2.01 6.48-.66.53-.26.64.09.15-.13.25s.4.36.06.76l.3.35.92.86z",
|
|
||||||
"M20.33 16.18l.2.78-6.69 1.52-.79-.31-.77.12-.08.13-.3.06s-.04.6-.69.48l-.13.32-.2 1.12-2.12 6.82-.72-.24 2-6.46.61-.56.3-.68-.06-.18.09-.2s-.51-.41-.07-.91l-1.02-.92-4.8-5.22.53-.5 4.63 4.94.14.84.43.54H11l.15.24s.51-.17.7.32l.45-.09 1.2-.38z"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"apache": {
|
|
||||||
"w": 31,
|
|
||||||
"h": 31,
|
|
||||||
"viewBox": "-3 -3 32 32",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M13.96 12.12l5.06 5.08.16.57-.52.47-.35-.76-4.25-4.22-.1 1.26-.1.12-.98.22-.3 7.76 1.96.07.18.21-.04.39-.1.5-1.97.25-.21.01v.2h-.06l-.03.02-.02.04-.03.07h-.05l-.03-.03-.03-.06v-.27l-.78-.09v.74h-.06l-.04-.13v-.62l-.1-.02v.57l-.04.04-.07-.1v-.5l-.98-.12-.15-.4-.02-.48.16-.22.98-.04.02-1.3.1-.09v1.4h.1l-.02-1.5.1-.07v1.56l.26-.02.19-.02.14-.11-.33-7.7-1.02-.24-.13-1.79-4.65 4.6-.62.2-.45-.5.73-.33 4.55-4.54.26-.13-1.53-.23.01-2.26h.57v1l.5-.02-.01-.45h-.1l.01-.2.1-.08v-.24l.06-.07h.1l.06.07v.2l.08-.08.1-.01v-.1l.06-.08h.1l.05.06.02.27.1.08v.2h-.1v.41h.62V9.7L5.55 4.15l-.17-.6.5-.46.34.67.11.14 4.46 4.47.31.66v-.59l-.04-.06v-.74l-.05-.09-.01-.8.1-.26.03-.35.1-.3.1-.2.17-.13.09-.46.2-.4-.06-.04v-.42l.13-.1.78-.02.11.1.06.07.01.18-.03.3.14.22.11.57.18.17.17.26.06.34.01.26.07.25.01.75-.06.12.01.76-.04.04v.85L18.79 4l.62-.18.46.52-.75.36-4.53 4.5-1.12.62-.01.72h.63v-.43h-.1v-.2l.1-.1v-.22l.06-.07h.08l.05.06v.12h.07l.08.04.07.06v-.21l.05-.06h.07l.06.06.01.22.1.06v.25h-.1v.45l.5.01v-1h.56v2.3l-1.24.16h-.53z"
|
|
||||||
},
|
|
||||||
"blackhawk": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M19.7 27.47s.12.61-.38.56l-2.38.18.34.6-.2.65-.38-.66-.05 1.8-.13.22.04-2.28h-.3s.02 1.3-.25 1.6c0 0-.24-.03-.35-1.22 0 0-.32.12-.32-.69l-2.79-.2s-.4.07-.34-.48l.01-.46s.03-.42.36-.4l3.05-.31-.31-8.77s-.08-.4-.51-.52l-.01-.32-.77-.03v-.85l-.18-.1.2-.88s-.16-.2-.14-1.7l-7.6 5.18-.82.27-.43-.63.83-.26 7.4-5.1h.63l.03-2.26-.1-.11s-.27.42-.5-.02l.02-.9s.22-.43.45 0v.44l.21-.24.02-.44-4.83-7.09-.25-.8.62-.42.27.82 4.19 6.1s-.27-3.85 1.77-3.9c0 0 2.19-.52 2.03 3.65v2.07l.2.19-.01-.44s.18-.34.4 0l.02.48 7.08-4.83.82-.26.42.62-.81.26-7.47 5.13h-.48l-.05 3.8 4.62 6.8.25.83-.62.42-.27-.83-4.06-5.93.01.93-.74.01v.3s-.58.17-.58.86l-.31 8.5h.27v-.38l.15-.3v.71l2.45.24s.52-.05.53.33z"
|
|
||||||
},
|
|
||||||
"s61": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"strokeScale": 1.1,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"path": "M11.16.91l-.9.54 4.9 10.19v.24h-.72v-.15c-.02-.53-.82-.74-1 0-.22.8-.16 1.42-.04 2L2.9 14.96 3.14 16l10.48-1.3.02.07c.06.3.15.45.24.62.11.11.24.16.38-.05.04-.14.07-.3.1-.49h.8l-.03 4.42c.04.33.09.6.14.84l-1.5 6.95.92.14 1.05-5.62.18.4.46 8.84c-.01.2.18.18.24.05v-.13l2.75-.24c.2-.01.2-.12.26-.18l-.03-.99-2.84-.2.48-7.09c.33-.73.67-1.64.92-2.69V14.9l10.38 5.95L29 20l-9.34-5.28.01-.04c.18-.9.54-1.76.2-3.05a.8.8 0 00-.1-.24l7.36-6.96-.75-.64-8.24 7.77V8.82l-.05-.36-.19-.63c-.1-.27-.44-.6-.82-.77h-.92c-.4.17-.7.44-.8.71-.18.31-.13.5-.2.74v.95L11.16.91z"
|
|
||||||
},
|
|
||||||
"f5_tiger": {
|
|
||||||
"viewBox": "-3.5 -3 32 32",
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"path": "M9.77 14.6s-.03-.78.16-1.74l.59-.98.05-.65.8-.72V7.17s-.05-1.28.15-3c0 0 .03-1.33.7-3.36v-1.2h.1V.83s.48 1.15.7 3.37c0 0 .17 1.15.14 3.02l.01 3.32.77.7.08.67.6 1s.14.69.14 1.7l2.47 1.35 2.24 1.22.03-1.82h-.1v-.14h.1l.01-.3s.01-.13.17-.14v-.4s.1-.28.23 0v.68l.1.16h.16v.14h-.26v3.18l.2.23v.68h-.63v-.75l-5.77.57s.06 1.08-.08 2.6l2.5 1.57-.02.94-2.75.17-.2 1.68h-1.8l-.2-1.68-2.77-.17v-.94l2.5-1.58s-.12-1.1-.09-2.62l-5.74-.55v.76H4.4v-.67l.23-.25v-3.15h-.27v-.17h.13l.13-.14v-.73s.1-.17.22 0v.46h.1s.07.02.07.12v.3h.13v.15h-.1v1.82l2.32-1.27z"
|
|
||||||
},
|
|
||||||
"c2": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-31.5 -7 34 34",
|
|
||||||
"transform": "scale(-0.01,0.01)",
|
|
||||||
"strokeScale": 100,
|
|
||||||
"path": "M1169 1932 l-207 -2 -11 -45 c-12 -49 -13 -108 -2 -175 l7 -45 14 28 c13 27 15 27 74 22 34 -4 68 -9 76 -12 8 -4 39 -8 69 -9 29 -2 56 -5 58 -8 3 -3 0 -42 -6 -87 -6 -45 -11 -173 -11 -284 l0 -202 -77 -6 c-43 -4 -166 -14 -273 -22 -107 -8 -278 -21 -380 -30 -346 -28 -440 -35 -455 -35 -23 0 -48 -58 -41 -95 12 -62 19 -64 379 -116 182 -27 373 -53 422 -59 165 -19 155 -15 155 -74 1 -28 5 -70 9 -93 l8 -43 -46 0 c-25 0 -53 -4 -60 -9 -16 -10 -8 -13 71 -26 24 -4 44 -15 57 -33 l20 -27 23 28 c16 19 36 29 63 33 22 3 51 8 64 10 39 8 5 24 -53 24 l-48 0 6 33 c4 17 10 54 13 81 l6 48 64 -5 c35 -2 66 -7 68 -9 3 -2 11 -101 19 -219 20 -304 49 -412 121 -446 33 -16 37 -16 70 0 72 34 101 142 121 446 8 118 16 217 19 219 2 2 33 7 68 9 l64 5 6 -48 c3 -27 9 -64 13 -81 l7 -33 -49 0 c-58 0 -92 -16 -53 -24 13 -2 42 -7 64 -10 27 -4 47 -14 63 -33 l23 -28 20 27 c13 18 33 29 57 33 79 13 87 16 71 26 -7 5 -35 9 -60 9 l-46 0 8 43 c4 23 8 65 9 93 0 59 -10 55 155 74 50 6 240 32 422 59 360 52 367 54 379 116 7 37 -18 95 -41 95 -15 0 -109 7 -455 35 -102 9 -273 22 -380 30 -107 8 -230 18 -272 22 l-78 6 0 128 0 127 34 12 c31 10 46 26 46 51 0 14 -20 11 -36 -6 -31 -30 -44 -18 -44 38 0 30 -5 91 -11 136 -6 45 -9 84 -6 87 2 3 29 6 58 8 30 1 61 5 69 9 8 3 42 8 76 12 59 5 61 5 74 -22 14 -27 14 -26 24 47 6 52 6 91 -2 129 -6 29 -13 55 -14 56 -5 5 -464 10 -669 7z"
|
|
||||||
},
|
|
||||||
"b52": {
|
|
||||||
"viewBox": "-7.5 -7 40 40",
|
|
||||||
"w": 40,
|
|
||||||
"h": 40,
|
|
||||||
"path": "M12.48 25.75l-.16-.67c-.1-.46-.11-.46-2.2-.1l-1.72.28c-.18 0-.12-1.2.07-1.43.1-.12.95-.86 1.9-1.65a18.3 18.3 0 001.72-1.54l-.15-1.43a79.1 79.1 0 01-.25-4.68l-.09-3.34-4.22 2.25c-1.45.8-2.92 1.55-4.42 2.25-.11 0-1.47.67-3.01 1.49-2.16 1.14-2.84 1.44-2.93 1.3-.18-.29-.15-1.59.05-1.83.1-.12.6-.52 1.13-.9l.95-.68v-.8c0-.9.14-1.38.31-1.42.14-.03.27.03.33.69.04.4 0 1.01.04 1.01.03 0 .69-.51 1.31-.99.95-.73 1.09-.88.9-1.02-.26-.19-.44-1.4-.3-2.1.09-.48.11-.5.7-.5.7 0 .78.13.79 1.28 0 .37.03.67.08.67.12 0 3.16-2.27 3.16-2.35 0-.05-.1-.15-.22-.24-.25-.18-.42-1.4-.3-2.1.1-.48.12-.5.73-.5.6 0 .62.02.71.5.05.26.06.76.02 1.1-.04.45-.02.6.1.53L9.6 7.35l1.93-1.39V4.71c0-2.41.5-4.64 1.06-4.64.55 0 1.05 2.23 1.05 4.64v1.25l1.94 1.39c1.06.76 2 1.43 2.1 1.48.1.07.13-.08.08-.52-.04-.35-.03-.85.02-1.12.09-.47.12-.48.72-.48s.63.01.71.49c.14.7-.03 1.92-.29 2.1-.12.09-.21.2-.21.24 0 .08 3.04 2.35 3.16 2.35.04 0 .08-.3.08-.67 0-1.15.08-1.28.78-1.28.6 0 .62.02.7.5.14.7-.03 1.91-.3 2.1-.18.14-.05.3.9 1.02.63.48 1.3 1.03 1.33 1.03.03 0 0-.64.04-1.03.06-.63.15-.76.28-.76.18.01.35.55.35 1.46v.81l.95.68c.52.38 1.02.78 1.12.9.2.24.24 1.54.06 1.83-.1.14-.78-.16-2.93-1.3a34.2 34.2 0 00-3.01-1.5c-.11 0-2.1-1-4.43-2.24l-4.22-2.25-.1 3.34a79.1 79.1 0 01-.39 6.11c0 .06.78.75 1.72 1.54.95.79 1.8 1.53 1.9 1.65.2.23.25 1.43.07 1.43l-1.72-.28c-2.09-.36-2.09-.36-2.19.1-.04.21-.13.52-.2.67-.1.28-.1.28-.18 0z"
|
|
||||||
},
|
|
||||||
"b707": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-2 -2 36 36",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"path": "M21 28.3V30l-5-1.5-5 1.5v-1.5l4.4-3.3s-.8-4.6-.4-8.4c0 0 0-2-2.6-.5L1.3 21v-2l4.3-3-.3-.1v-2.4s0-.4.3-.4h.8s.4 0 .4.4v1.8l3.3-2.4-.3-.2v-2s0-.5.4-.5h.6s.4 0 .4.3v1.7L15 9.4s-.2-7.2 1.4-8.3c0 0 1.4 0 1.3 8.2l4.1 3v-2s0-.3.4-.3h.6s.4 0 .4.4v2.3l-.3.3 3.2 2.2v-1.9s0-.4.2-.4h.7s.4 0 .4.4v2.3l-.2.4 3.8 2.6v2.1L20 15.9s-1.8-.7-2.3 1c0 0 .3 4.9-.7 8.3z"
|
|
||||||
},
|
|
||||||
"rutan_veze": {
|
|
||||||
"viewBox": "-2.8 -1 30 30",
|
|
||||||
"w": 27,
|
|
||||||
"h": 27,
|
|
||||||
"path": "M14.4 15.66l-1.16 1.13-.63.07-.01.18 1.87.1-1.87.08s-.09.53-.34.62c0 0-.25-.1-.35-.6l-1.85-.03 1.83-.17v-.2l-.58-.07-1.26-1.06-6.55 1.9L3 19s-.1-.61.21-1.9l-.32-.12v-.18l7.12-3.62 1.42-2.88s0-1.9.08-2.5l-4.02.03s-.18-.97.3-1l3.9-.03s.25-.9.5-1c0 0 .12-.13.58.98l3.79-.05s.4.05.26.98l-3.8.04s.1 1.18.03 2.59l1.4 2.74 7.32 3.43v.22l-.22.07s.35 1.69.14 1.8l-.4-1.25z"
|
|
||||||
},
|
|
||||||
"pumpkin": {
|
|
||||||
"viewBox": "-4 -3 32 32",
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"accentMult": 0.6,
|
|
||||||
"path": "M12.33 20.61s.43.02.65-.28c0 0 .68.5 1.72-.31 0 0 .94.37 2-1.32 1.04-1.7.58-3.44.58-3.44s.99-.8 2.1.33c0 0 .81-1.95 3.02-.29 0 0 .36-2.2 2.93-.62 0 0-.89-2.47-4.12-2.7-3.23-.23-4.3 2.32-4.3 2.32s-.12-.47-.64-1.12c0 0 1.54-.16.84-.64 0 0-1.04-.97-2.24-1.37 0 0 .06-1.01-.73-2.52 0 0 .5-.38.93-1.64 0 0 .83-.05 1.67-.47 0 0-1.67-1.06-3.27-.58-1.6.48-2.27 1.18-2.76 2.68 0 0-1.33.83-1.8 2.76 0 0-2.8 1.45-1.67 1.63 0 0 1.09.17.94.35 0 0-.42.46-.6.85 0 0-1.36-1.94-3.06-2.1 0 0-3.88-.71-5.4 2.64 0 0 2.64-1.6 2.95.6 0 0 1.97-1.85 3 .34 0 0 1.37-1.4 2.16-.36 0 0-.57 1.47.21 3.02.78 1.55 1.95 2.1 2.36 1.83 0 0 1.16.67 1.7.17 0 0 .3.25.83.24z",
|
|
||||||
"accent": "M10.7 17.63c-.04 0-.53 1.11-.53 1.11l-.76-1.39m2.1 2.35l-.37-.98-.45.76m3.07-.14l-.49-.7-.38.97m-.1-1.93l-.56 1.26-.73-1.23m3.4-.5l-.5 1.37-.64-1.05m-5.72-.74s3.81 2.29 8.36-.27c0 0-.38 2.54-4.03 3.2 0 0-2.94.38-4.33-2.93zm3.11-.28s1-2.46 1.98 0c0 0 .52.75-.23.82-.76.07-1.26.06-1.26.06s-1.08.14-.49-.88zm2.4-1.12s-1.4-1.13-.33-1.2c1.08-.07 1.69 0 1.69 0s.91.05.43.77c-.49.72-.52.8-.52.8s-.27.48-1.28-.37zm-2.6-.12s-1.18 1.04-1.58.34c-.4-.7-.43-.86-.43-.86s-.32-.62.67-.62h.97-.18s1.67.17 1.08.69l-.52.45zm6.26-2.29s-4.12-1.34-10.3 0"
|
|
||||||
},
|
|
||||||
"witchr": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"transform": "rotate(95,16,16)",
|
|
||||||
"path": "M8.55 15.85s.54-.33.86-.37a5.06 5.06 0 011.53.07c.1.1.52.1.27.5-.24.4-1.35 1.12-1.34 1.81l.11.43-.12.3s-.03.16-.3-.04c-.28-.2-.6 0-.6 0s-.02.13.14.38c.16.24.14.2-.1.72-.25.53-.42.2-.77-.02-.35-.21-.23.9.34 1.21 0 0 1.02.08-.01.36 0 0-.9.16-2.1.2 0 0-.8 0-.83.36l3.3-.08s.3-.04.3.13c0 0-.08.45.45 1.42 0 0 .13.13.61 1.08 0 0 .3.6 1.36.14l1.33-.59s.65-.39.9-1.16c0 0-2.3 1.21-2.24.04l.1-1s-.03-.52 1.23-.6c0 0 .43-.12 2.37-.3 0 0-1.55-.14-3.49 0 0 0 2.56-.3 3.28-.24.72.05 1.61-.02 2.08-.37 0 0 1.29-1.15 2.13-1.23 0 0-.13-.4-.72-.23-.6.18-1.6.99-1.6.99s-.54.4-2.3.38c0 0 1.27-.04 1.76-.37.48-.33.7-.72 1.48-1.16.8-.44 1.47-.13 1.67.16.21.3.87 1.51 1.2 1.45 0 0-.73-1.6-1.2-2.03-.46-.44-.99-.68-1.65-.28-.65.4-1.15.97-2.42 1.2-1.27.24-1.45.17-1.45.17s.44-.13.87-.46c.43-.32 1-.74 1.18-.93.18-.18 1.4-1.05 2.14-1 .75.04.99-.05 1.84 1.3.85 1.36.96 1.7 2.1 1.6 1.14-.1 2.77-.68 3.58-.73.81-.05 1.59-.26 2.57 1.27 0 0 .33-1-.8-1.63-1.14-.62-3.6-.06-4.6-.2-.98-.13-1.34-.34-2.17-1.5-.82-1.16-1.79-1.42-3.33-1.5 0 0 .9-.52 1-1.02.1-.5-.16-1.05-.16-1.05s-.26-.4.29-.44c.55-.05.63-.07.63-.07l.5.26.73-.04s1.44.95 3.24.3c1.8-.63 1-1.25 4-.98 0 0 .02-.37-1.79-.39 0 0 .63-.36 2.75.3 0 0 .03-1.27-3.54-1.44 0 0 1.3-.7 3.08.52 0 0-.06-.98-1.2-1.28-1.15-.3-2.34-.86-4.9-.04 0 0 .9-.86 3.39-.51 0 0-1.1-.92-3.4-.48-2.3.43-2.7 2.32-2.7 2.32s-.2.19-.42.23c-.21.05-.21.5-.21.5s.18.24-.44.31c-.62.08-.4-.37-.5-.42 0 0-.65-2.14 4.42-3.67 0 0-3.31-.72-6.31 2.92 0 0-.37-.77.43-1.18.8-.41 1.44-.72 1.37-3.12 0 0-.86 1.75-2.41 2.14 0 0-.68.14-1.63 1.17 0 0-.74-1.04 1.24-2.96 0 0-2.73.65-3.8 3 0 0-.53 1.11-.78 2.85 0 0-.03.92-.37.97 0 0-.56.12-.64-.71 0 0-.4-1.07.61-3.3 0 0-.74.48-1.57 1.92 0 0-.58 1.05-.45 1.86 0 0 .27.5.1.6 0 0-.26-.19-.66.1l-.48.27-5.23 1.18s-.63.42.15.47l4.7-1.12.46-.04.28.27.47.04-.03.22s-.13.5.84-.11z"
|
|
||||||
},
|
|
||||||
"witchl": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"strokeScale": 1.2,
|
|
||||||
"transform": "rotate(85,16,16)",
|
|
||||||
"path": "M9.06 15.51s.55.33.86.37c.32.04 1.43.03 1.53-.07.1-.1.53-.1.28-.5s-1.35-1.12-1.34-1.81l.1-.43-.11-.3s-.04-.16-.31.04c-.28.2-.59 0-.59 0s-.03-.13.14-.38c.16-.24.14-.2-.1-.73-.25-.52-.43-.19-.78.02-.34.22-.22-.9.34-1.2 0 0 1.03-.08 0-.36 0 0-.91-.16-2.11-.21 0 0-.79.01-.82-.35l3.3.08s.3.03.3-.13c0 0-.08-.45.44-1.43 0 0 .13-.12.62-1.07 0 0 .3-.6 1.36-.14l1.32.59s.66.39.9 1.16c0 0-2.3-1.21-2.23-.04l.1 1s-.04.52 1.22.6c0 0 .43.12 2.37.3 0 0-1.55.14-3.48 0 0 0 2.56.3 3.28.24.72-.05 1.6.02 2.08.37 0 0 1.28 1.15 2.12 1.23 0 0-.12.4-.71.23-.6-.18-1.6-.99-1.6-.99s-.55-.4-2.3-.38c0 0 1.27.04 1.75.37s.7.72 1.49 1.16c.79.44 1.47.13 1.67-.16.2-.3.86-1.51 1.2-1.45 0 0-.74 1.59-1.2 2.03-.46.44-1 .68-1.65.28-.66-.4-1.15-.97-2.42-1.2-1.27-.24-1.46-.17-1.46-.17s.44.13.87.46c.43.32 1 .74 1.18.93.19.18 1.4 1.04 2.15 1 .75-.04.98.04 1.83-1.3.85-1.36.97-1.7 2.1-1.6 1.14.1 2.78.68 3.59.73.8.05 1.59.26 2.57-1.27 0 0 .33 1-.81 1.63-1.14.62-3.6.06-4.58.2-.99.13-1.36.34-2.18 1.5-.82 1.16-1.8 1.42-3.33 1.5 0 0 .89.52 1 1.02.1.5-.16 1.05-.16 1.05s-.27.39.29.44c.55.05.62.07.62.07l.51-.26.72.04s1.45-.95 3.24-.31c1.8.64 1 1.26 4 .98 0 0 .02.38-1.78.4 0 0 .62.36 2.75-.3 0 0 .03 1.27-3.54 1.44 0 0 1.3.69 3.08-.52 0 0-.06.98-1.2 1.28-1.16.3-2.34.86-4.91.04 0 0 .9.86 3.4.51 0 0-1.1.92-3.4.48-2.3-.43-2.7-2.32-2.7-2.32s-.2-.19-.42-.23c-.22-.05-.22-.5-.22-.5s.19-.24-.43-.31c-.63-.08-.4.37-.5.42 0 0-.65 2.14 4.41 3.67 0 0-3.3.72-6.3-2.92 0 0-.37.77.43 1.18.8.41 1.44.72 1.36 3.12 0 0-.86-1.75-2.4-2.14 0 0-.68-.14-1.64-1.17 0 0-.74 1.04 1.24 2.96 0 0-2.73-.65-3.8-3 0 0-.53-1.11-.77-2.85 0 0-.03-.92-.37-.97 0 0-.56-.12-.65.7 0 0-.39 1.08.62 3.31 0 0-.74-.48-1.57-1.92 0 0-.59-1.05-.45-1.86 0 0 .27-.5.09-.6 0 0-.26.19-.66-.1l-.48-.27-5.22-1.18s-.64-.42.14-.47l4.71 1.12.45.04.3-.27.45-.04-.03-.22s-.12-.5.84.11z"
|
|
||||||
},
|
|
||||||
"m326": {
|
|
||||||
"viewBox": "-4 -3 32 32",
|
|
||||||
"w": 25,
|
|
||||||
"h": 25,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M12.39 26.43s.42-.35.6-.88h4s.26.07.28-.4l.04-1.26s.05-.57-.38-.66l-3.71-.7s.24-3.58.3-6.71c0 0-.02-.55.26-.56l10.9.02s.5.02.51.42c0 0 .25 4.06.54 0 0 0 .33-3.39.17-4.98 0 0-.4-4-.84 0l-.04.54-.01.2s.02.31-.35.22l-9.63-1.96s-.33.03-.37-.38c-.04-.4-.03-.65-.47-.65-.44 0-.5-.01-.62.12 0 0 .02-4.06-.47-6.9 0 0-.46-2.46-.96-2.38-.49-.08-.95 2.39-.95 2.39-.49 2.83-.47 6.89-.47 6.89-.11-.13-.18-.12-.62-.12s-.43.25-.47.65c-.04.4-.37.38-.37.38l-9.63 1.96c-.37.1-.35-.22-.35-.22l-.01-.2-.04-.54c-.44-4-.84 0-.84 0-.16 1.6.17 4.98.17 4.98.3 4.06.54 0 .54 0 .02-.4.5-.42.5-.42l10.91-.02c.28 0 .27.56.27.56.05 3.13.3 6.71.3 6.71l-3.72.7c-.43.09-.38.66-.38.66l.04 1.25c.02.48.27.41.27.41h4c.2.53.61.88.61.88h.24z",
|
|
||||||
"accent": "M14.45 6.98l-.88 1.83v.57s-1.06 4.84.7 8.03m-2.07 1.55V26M9.95 7.28l.89 1.83v.57s1.05 4.84-.7 8.03"
|
|
||||||
},
|
|
||||||
"miragef1": {
|
|
||||||
"viewBox": "-4 -2 32 32",
|
|
||||||
"w": 35,
|
|
||||||
"h": 35,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M11.98.32c-.93 3.65-.5 8.62-.5 8.62h0l-.32.74c-.2-.03-.5.02-.5.02-.33 1.62-.21 3.21-.21 3.21l-6 6.47v2.56l6.35-1.84c-.04 1.33.01 2.38.01 2.38l-3.08 3.56c-.33.35-.26.6-.26.6l.37 1.2 3.5-.78v.28h1.74v-.28l3.49.79.37-1.22s.07-.24-.27-.59l-3.07-3.56s.05-1.05 0-2.38l6.35 1.84v-2.56l-5.99-6.47s.11-1.6-.2-3.2c0 0-.32-.06-.51-.03l-.33-.74s.4-4.98-.53-8.63c-.22-.97-.4.01-.4.01z",
|
|
||||||
"accent": "M13.38 8.55l.37 1.15s.37 1.64.21 3.21c-.15 1.58-.56 6.47-.36 7.19 0 0 .2 2.83-.52 6.96l-.1 1.2M11.01 8.55l-.36 1.15s-.37 1.64-.21 3.21c.15 1.58.55 6.47.35 7.19 0 0-.18 2.83.53 6.96l.1 1.2"
|
|
||||||
},
|
|
||||||
"unknown": {
|
|
||||||
"w": 22,
|
|
||||||
"h": 22,
|
|
||||||
"viewBox": "-2.5 -2.5 22 22",
|
|
||||||
"path": "M 4.256,15.496 C 3.979,14.340 7.280,13.606 7.280,13.606 V 8.650 l -6,2 c -0.680,0 -1,-0.350 -1,-0.660 C 0.242,9.595 0.496,9.231 0.880,9.130 1.140,9 4.800,7 7.280,5.630 V 3 C 7.280,1.890 7.720,0.290 8.510,0.290 9.300,0.290 9.770,1.840 9.770,3 v 2.630 c 2.450,1.370 6.100,3.370 6.370,3.500 0.390,0.093 0.651,0.461 0.610,0.860 -0.050,0.310 -0.360,0.670 -1.050,0.670 l -5.930,-2 v 4.946 c 0,0 3.300,0.734 3.024,1.890 -0.331,1.384 -2.830,0.378 -4.254,0.378 -1.434,4.520e-4 -3.950,1.016 -4.284,-0.378 z",
|
|
||||||
"size": [
|
|
||||||
22,
|
|
||||||
22
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ground_square": {
|
|
||||||
"w": 11,
|
|
||||||
"h": 11,
|
|
||||||
"noRotate": true,
|
|
||||||
"strokeScale": 1.4,
|
|
||||||
"viewBox": "0 0 16 16",
|
|
||||||
"path": "M 4,4 H 12 V 12 H 4 Z"
|
|
||||||
},
|
|
||||||
"ground_emergency": {
|
|
||||||
"w": 7.2,
|
|
||||||
"h": 18,
|
|
||||||
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-0.6 -1.5 7.2 18\" SIZE><defs><style>.cls-1{fill:#5a5a5a;stroke:strokeColor;stroke-width:strokeWidth;}.cls-2{fill:#fff;stroke:none;}</style></defs><title>emergency_dark</title><g id=\"Layer_2\" data-name=\"Layer 2\"><g id=\"Emergency\"><path class=\"cls-1\" d=\"M1.77,0a3.32,3.32,0,0,0-.63.07L1,.11.86.16h0L.7.23.59.3l0,0L.49.38l0,.06a.15.15,0,0,0,0,0h0v0h0V.9l0,0,0,0h0l0,.07V4c0,.13.06.16.1.23v.3H.09c0,1.32,0,3.38,0,4.8l0,.44C.07,9.82,0,9.88,0,10v2.82c0,.09-.05.14.07.18v1.9a50.76,50.76,0,0,0,5.78,0V13.06C6,13,6,12.93,6,12.84V10c0-.15-.05-.2-.11-.22V9.37c0-1.41,0-3.51,0-4.8H5.55v-.3c0-.08.11-.1.09-.23v-3L5.6,1h0l0,0,0,0V.51h0v0h0a.15.15,0,0,0,0,0,.16.16,0,0,0,0-.06L5.41.32l0,0L5.26.23,5.12.17h0L5,.11l-.16,0A3.45,3.45,0,0,0,4,0H1.77Z\"/><path class=\"cls-2\" d=\"M.77,2.77A4.31,4.31,0,0,1,3,2.33a4.31,4.31,0,0,1,2.26.44l-.38.92A7.51,7.51,0,0,0,3,3.41a7.9,7.9,0,0,0-1.91.27Z\"/><path class=\"cls-2\" d=\"M4.35,8.27a1.05,1.05,0,0,1-.59.93A3.5,3.5,0,0,0,2.62,6.63a1.71,1.71,0,0,1-.56,1.26l-.41.38a1.71,1.71,0,0,0-.56,1.26A1.79,1.79,0,0,0,2.4,11.21H3.6A1.79,1.79,0,0,0,4.92,9.52,1.71,1.71,0,0,0,4.35,8.27Z\"/></g></g></svg>"
|
|
||||||
},
|
|
||||||
"ground_service": {
|
|
||||||
"w": 7.2,
|
|
||||||
"h": 18,
|
|
||||||
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-0.6 -1.5 7.2 18\" SIZE><defs><style>.cls-1{fill:#5a5a5a;stroke:strokeColor;stroke-width:strokeWidth;}.cls-2{fill:#fff;stroke:none;}</style></defs><title>service_dark</title><g id=\"Layer_2\" data-name=\"Layer 2\"><g id=\"Service\"><path class=\"cls-1\" d=\"M1.77,0a3.32,3.32,0,0,0-.63.07L1,.11.86.16h0L.7.23.59.3l0,0L.49.38l0,.06a.15.15,0,0,0,0,0h0v0h0V.9l0,0,0,0h0l0,.07V4c0,.13.06.16.1.23v.3H.09c0,1.32,0,3.38,0,4.8l0,.44C.07,9.82,0,9.88,0,10v2.82c0,.09-.05.14.07.18v1.9a50.76,50.76,0,0,0,5.78,0V13.06C6,13,6,12.93,6,12.84V10c0-.15-.05-.2-.11-.22V9.37c0-1.41,0-3.51,0-4.8H5.55v-.3c0-.08.11-.1.09-.23v-3L5.6,1h0l0,0,0,0V.51h0v0h0a.15.15,0,0,0,0,0,.16.16,0,0,0,0-.06L5.41.32l0,0L5.26.23,5.12.17h0L5,.11l-.16,0A3.45,3.45,0,0,0,4,0H1.77Z\"/><path class=\"cls-2\" d=\"M.77,2.77A4.31,4.31,0,0,1,3,2.33a4.31,4.31,0,0,1,2.26.44l-.38.92A7.51,7.51,0,0,0,3,3.41a7.9,7.9,0,0,0-1.91.27Z\"/><path class=\"cls-2\" d=\"M4.85,8a1.67,1.67,0,0,0-.65-1.3A.12.12,0,0,0,4,6.74v.95a.12.12,0,0,1,0,.09l-.9.65a.12.12,0,0,1-.13,0L2,7.77a.12.12,0,0,1,0-.09V6.73a.11.11,0,0,0-.18-.08A1.72,1.72,0,0,0,1.15,8,1.66,1.66,0,0,0,2,9.4a.19.19,0,0,1,.1.17l0,1.54a.1.1,0,0,0,.11.1H3.78c.07,0,.12,0,.11-.1V9.57A.22.22,0,0,1,4,9.4,1.7,1.7,0,0,0,4.85,8Z\"/></g></g></svg>"
|
|
||||||
},
|
|
||||||
"ground_unknown": {
|
|
||||||
"w": 7.2,
|
|
||||||
"h": 18,
|
|
||||||
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-0.6 -1.5 7.2 18\" SIZE><defs><style>.cls-1{fill:#5a5a5a;stroke:strokeColor;stroke-width:strokeWidth;}.cls-2{fill:#fff;stroke:none;}</style></defs><title>unknown_dark</title><g id=\"Layer_2\" data-name=\"Layer 2\"><g id=\"Unknown\"><path class=\"cls-1\" d=\"M1.77,0a3.32,3.32,0,0,0-.63.07L1,.11.86.16h0L.7.23.59.3l0,0L.49.38l0,.06a.15.15,0,0,0,0,0h0v0h0V.9l0,0,0,0h0l0,.07V4c0,.13.06.16.1.23v.3H.09c0,1.32,0,3.38,0,4.8l0,.44C.07,9.82,0,9.88,0,10v2.82c0,.09-.05.14.07.18v1.9a50.76,50.76,0,0,0,5.78,0V13.06C6,13,6,12.93,6,12.84V10c0-.15-.05-.2-.11-.22V9.37c0-1.41,0-3.51,0-4.8H5.55v-.3c0-.08.11-.1.09-.23v-3L5.6,1h0l0,0,0,0V.51h0v0h0a.15.15,0,0,0,0,0,.16.16,0,0,0,0-.06L5.41.32l0,0L5.26.23,5.12.17h0L5,.11l-.16,0A3.45,3.45,0,0,0,4,0H1.77Z\"/><path class=\"cls-2\" d=\"M.77,2.77A4.31,4.31,0,0,1,3,2.33a4.31,4.31,0,0,1,2.26.44l-.38.92A7.51,7.51,0,0,0,3,3.41a7.9,7.9,0,0,0-1.91.27Z\"/><path class=\"cls-2\" d=\"M4.31,7.51A1.1,1.1,0,0,0,4,7.12a1.55,1.55,0,0,0-.5-.3,2.18,2.18,0,0,0-.77-.12,1.81,1.81,0,0,0-.65.11,1.48,1.48,0,0,0-.5.32,1.43,1.43,0,0,0-.32.48,1.62,1.62,0,0,0-.12.6v.12H2.31V8.2a1,1,0,0,1,0-.25.54.54,0,0,1,.09-.19.42.42,0,0,1,.15-.12.49.49,0,0,1,.22,0,.41.41,0,0,1,.29.09.39.39,0,0,1,.1.3.33.33,0,0,1,0,.19A.65.65,0,0,1,3,8.33l-.22.16a1.61,1.61,0,0,0-.25.22A1.28,1.28,0,0,0,2.33,9a1.2,1.2,0,0,0-.11.45v.37H3.29V9.52a.44.44,0,0,1,.09-.22,1,1,0,0,1,.19-.18l.25-.17a1.35,1.35,0,0,0,.27-.23,1.19,1.19,0,0,0,.21-.33,1.23,1.23,0,0,0,.09-.49A1.06,1.06,0,0,0,4.31,7.51Z\"/><rect class=\"cls-2\" x=\"2.18\" y=\"10.26\" width=\"1.12\" height=\"1.02\"/></g></g></svg>"
|
|
||||||
},
|
|
||||||
"ground_fixed": {
|
|
||||||
"w": 12,
|
|
||||||
"h": 12,
|
|
||||||
"noRotate": true,
|
|
||||||
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-1 -1 14 14\" SIZE><defs><style>.cls-1{fill:#5a5a5a;stroke:strokeColor;stroke-width:strokeWidth;}.cls-2{fill:#fff;stroke:none;}</style></defs><title>fixed_dark</title><g id=\"Layer_2\" data-name=\"Layer 2\"><g id=\"Fixed\"><polygon class=\"cls-1\" points=\"0 0 0 0.43 0 12 12 12 12 0 0 0\"/><path class=\"cls-2\" d=\"M3.24,2.73,1.36.86h9.29L8.77,2.73A4.29,4.29,0,0,0,3.24,2.73Z\"/><circle class=\"cls-2\" cx=\"6\" cy=\"6\" r=\"3.58\" transform=\"translate(-2.49 6) rotate(-45)\"/><path class=\"cls-2\" d=\"M2.73,3.23a4.29,4.29,0,0,0,0,5.54L.86,10.62V1.37Z\"/><path class=\"cls-2\" d=\"M3.24,9.28a4.29,4.29,0,0,0,5.54,0l1.87,1.87H1.35Z\"/><path class=\"cls-2\" d=\"M9.28,8.77a4.29,4.29,0,0,0,0-5.54l1.86-1.86v9.26Z\"/><circle class=\"cls-1\" cx=\"6\" cy=\"6\" r=\"2\" transform=\"translate(-2.49 6) rotate(-45)\"/></g></g></svg>"
|
|
||||||
},
|
|
||||||
"ground_tower": {
|
|
||||||
"w": 22,
|
|
||||||
"h": 18,
|
|
||||||
"strokeScale": 20,
|
|
||||||
"noRotate": true,
|
|
||||||
"viewBox": "-45 -50 730 587",
|
|
||||||
"path": "M168.67 192c11 0 18.61-10.83 14.85-21.18-4.93-13.58-7.55-27.98-7.55-42.82s2.62-29.24 7.55-42.82C187.29 74.83 179.68 64 168.67 64h-17.73c-7.01 0-13.46 4.49-15.41 11.23C130.64 92.21 128 109.88 128 128c0 18.12 2.64 35.79 7.54 52.76 1.94 6.74 8.39 11.24 15.4 11.24h17.73zm-120.8-64c0-37.81 9.46-73.41 26.05-104.66C79.56 12.72 71.97 0 59.97 0H40.61c-6.27 0-12.13 3.59-14.73 9.31C8.22 48.13-1.31 91.41.15 137.12c1.24 38.89 10.78 75.94 26.53 109.73 2.62 5.63 8.41 9.14 14.61 9.14h18.87c12.02 0 19.6-12.74 13.94-23.37C57.43 201.39 47.87 165.84 47.87 128zM614.07 9.29C611.46 3.58 605.61 0 599.34 0h-19.43c-11.98 0-19.66 12.66-14.02 23.25 23.26 43.67 32.56 95.83 21.53 150.66-4.16 20.72-11.49 40.35-21.26 58.57-5.72 10.68 1.8 23.52 13.91 23.52h19.24c6.27 0 12.13-3.58 14.73-9.29C630.57 210.48 640 170.36 640 128s-9.42-82.48-25.93-118.71zM489.06 64h-17.73c-11.01 0-18.61 10.83-14.86 21.18 4.93 13.58 7.55 27.98 7.55 42.82s-2.62 29.24-7.55 42.82c-3.76 10.35 3.85 21.18 14.86 21.18h17.73c7.01 0 13.46-4.49 15.41-11.24 4.9-16.97 7.53-34.64 7.53-52.76 0-18.12-2.64-35.79-7.54-52.76-1.94-6.75-8.39-11.24-15.4-11.24zM372.7 187.76C389.31 173.1 400 151.89 400 128c0-44.18-35.82-80-80.01-80-5.52 0-10.92.56-16.12 1.62a79.525 79.525 0 0 0-28.61 12.04c-21.28 14.38-35.27 38.72-35.27 66.34 0 23.86 10.83 44.86 27.4 59.52L143.98 483.68c-3.4 8.16.46 17.52 8.62 20.92l14.78 6.16c8.16 3.4 17.53-.46 20.93-8.62L245.26 368h149.47l56.96 134.15c3.4 8.16 12.77 12.02 20.93 8.62l14.78-6.16c8.16-3.4 12.01-12.77 8.62-20.92L372.7 187.76zM320 96c17.65 0 32 14.36 32 32s-14.36 32-32 32-32-14.36-32-32 14.35-32 32-32zm-54.35 224l47.84-112.66c2.19.18 4.28.66 6.51.66 2.23 0 4.33-.48 6.52-.66L374.35 320h-108.7z"
|
|
||||||
},
|
|
||||||
"puma": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"strokeScale": 1,
|
|
||||||
"path": "M15.9 28.57l-.17.02-.16-.35-3.9-.49v-1.27l3.79.06-.48-7.98-.58-2.42s-.06-.14-.42-.15c-.36 0-1 .05-1.05-.64v-.56L7.46 20.2s-.88-.2-.5-.6l5.96-5.78-.02-.65s0-.24.25-.2l.65-.01.28-.28v-2.41L6.85 2.89s.22-.87.6-.5l6.62 6.9.02-1.95s.25-1.72.85-2.65c0 0 .95-1.4 2.05 0 0 0 .65 1 .63 2.64V8.9l6.56-6.5s.93.18.48.6l-7.08 6.88-.04 2.65.1.1.9.02s.23 0 .24.28l-.01.85 5.87 6.04s-.26.81-.58.48l-5.32-5.57v.6s-.06.65-.7.64h-.63l-.26.18-.56 2.44-.4 8.12s.16.25.14.55l.16-.01.14-.4v-2.03h.33v2.21h.3v.64h-.3v2.32h-.33v-2.23l-.15-.36h-.17s-.01.37-.4 1.17z"
|
|
||||||
},
|
|
||||||
"tiger": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "-1 -2 34 34",
|
|
||||||
"strokeScale": 0.9,
|
|
||||||
"path": "M13.09 29.05l-.34.02-.7-3.13.3-.01.09.34 3.13-.03-.46-7.45-.09-.16s-.27.1-.3-.66c-.05-.77.01-.84.01-.84s-.24-1.91-.18-2.26c0 0-.03-.1.16-.16 0 0-.4-.3-.44-1.4 0 0-.08 0-.1-.26l-.72.31s-.12.23-.12.45L4.8 17.6s-.56.2-.93.61c0 0-.7-.97.51-1.62l8.46-3.77s.38-.14.83-.18l.48-.26H13.1s-.13.53-.24 0h-.92s-.2.67-.39 0h-.17l.03-1.85h.13s.17-.75.41-.06l2.64-.37-.4-.87-.34-.12v.23s-.3.22-.56 0v-1.3L9.75.3s-.18-.4-.49-.65c0 0 .95-.64 1.68.58L14 7s.04-.3.3-.3h.42V5.62s-.6-.2 0-.44c0 0-.11-1.94.4-3.02v-.63l.42-.44s.37-.38.81 0l.4.44v.63s.56.65.44 4.56c0 0 .82-.23.78.75v.36h-.39l.41.72h.11v-.37s.24-.25.53 0v1.13s-.25.28-.53 0v-.3l-.13-.13h-.1l-.52-1.05h-.17v.63s.6.22 0 .46v1.18l.69.1s.11-.01.35-.2h.14s.12-.3.12-.43l8.92-4.04s.25-.04.64-.48c0 0 .7 1.03-.67 1.72L19.3 10.4l.68.1s.24-.7.4.07h.13v1.8h-.17s-.1.75-.39 0h-.87s-.1.61-.28 0h-1.05v.72s.03.14-.11.24c0 0 .01.43-.08.6l.1.21s.13.1.37.1L22.02 23s.22.5.52.66c0 0-1.03.78-1.77-.7l-3.42-7.55s.05 1.16-.17 1.69c0 0 .09 1.74-.3 1.56l-.1.18-.43 7.4h.68l.05-.9.15.02-.06.87h2.36l-.09-.29h.36l.83 3.11-.36.02-.43-1.7h-2.76s.12.39-.1.94l-.19 1.86-.14-.03.17-1.85-.03-.42h-.13l-.04.84h-.54v.88h-.14s-.23-.54-.3-1.18l-.68-.25v-.8l-2.26-.02z"
|
|
||||||
},
|
|
||||||
"mil24": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-1 -2 34 34",
|
|
||||||
"strokeScale": 0.8,
|
|
||||||
"path": "M16.41 30.41s-.11-.27-.2-.7l-.72-.2V29h-.15s.02.16-.09.18v1.28h-.1v-1.32s-.75-.34 0-.8V27.2h.08l.02 1.13s.11.14.12.32h.1l.56-.17-.24-1.43-1.94-.23s-.35-.54-.02-.84l1.85-.15-.28-7.1-.05-.04-1.46 6.71s-.92.21-.75-.16l1.8-8.3-.16-.96s-.2-.14-.14-.53l-1.02-.1s-.13 1.15-.28-.04l-.6-.05s-.16 1.3-.33-.03l-.76-.12v.38l.07.15v.15l-.07.11h-.17l-.08-.1v-.17l.04-.14v-.39l-.4-.03-.07-.14-.3-.08v.92s-.13.25-.28 0l.03-1.97-7 .68s-.46-.76-.07-.77l10.6-1.03v-.27l.07-.07v-.12l-.04-.03s.04-.99.29-1.07l.38.02v-.54L10.5 1.2s.68-.65.74-.3l3.45 7.8s-.03-4.6 1.12-4.67V2.44h.2v1.59s1.1-.35 1.14 4.96c0 0 .13.41.04 2.13l8.14-7.4s.81.35.52.56l-7.87 7.18s.14.38.1.72l-.02.06v.11l.04.04s.1.49-.04.88l.64.38.48.05s.08-.7.26.04l.52.07-.04-.83h.22l.03.86.52.05h.23l-.04-.96h.24l.06 1.08.07.06.03.2.05.03.01.27-.06.01.05.64 6.15 3.62s-.13.8-.4.68l-6.72-3.98v.33h-.3v-.48l-.38-.22-.22.03s-.1 1.4-.3.03l-.56.06s-.18 1.16-.31.05l-1.02.12s.07.27-.15.57c0 0-.12 1.17-.54 2.79l-.1 6.98 1.85.1s.45.33-.01.85l-1.85.3-.01 1.6s.33.17 0 .41z"
|
|
||||||
},
|
|
||||||
"dauphin": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "-1 -2 34 34",
|
|
||||||
"strokeScale": 1.25,
|
|
||||||
"path": "M15.71 30.45v-.37s-.34 0-.37-.51l-.08-4.33-.05-.39h-2.77l.12 1.07-.28-.2-.32-2.98.3.18.02.3h2.83l-1.18-8.96-7.2 7.16-.45.07-.36-.39.11-.5 7.78-7.56s-.26-1.6-.29-3.71L5.9 1.6l-.04-.4.36-.34.49.08 6.88 7.04s-.11-2.01 1.05-4.8c0 0 .19-.47.38-1.14 0 0 .08-1 .99-1.05 0 0 .91-.12 1.07 1.03 0 0 .09.44.38 1.18 0 0 .82 1.33 1.12 4.96l7.25-7.2.4-.02.37.38-.1.48-7.94 7.72s0 2.02-.26 3.2c0 0 .2.1.39.04l7.96 8.03.02.42-.35.32-.47-.07-7.7-7.9-1.53 9.66h2.62l-.01-.46.25.17.33 2.96-.29-.2-.08-.87-3.03.02s-.06.23-.06.56l-.2 4.03s.04.46-.31.64v.36z"
|
|
||||||
},
|
|
||||||
"gazelle": {
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"viewBox": "-1 -2 34 34",
|
|
||||||
"strokeScale": 1,
|
|
||||||
"path": "M15.87 29s-.66-.15-.43-2.73l-.02-.43h-1.86v.68h-.19v-1.78h2l-.41-8.6-.57-2.79h-.9v1.09s-.15.4-.25 0v-2.08s-.34.09-.58.2l-10.34-.41-.01-.7 10.93.4v-4.6s.11-.28.24 0v2.38h.55V8.31s.27-2.84 1.95-2.99c0 0 1.62-.27 2.04 2.71L23.04.48l.6.36-4.81 7.33v6.24s-.14.32-.26 0v-1.04h-.88l-.18 1.02s.29.38.56.52l4.67 9.28-.6.24-4.77-9.3-.38 1.1-.5 8.53h1.98v1.73h-.19v-.65h-1.8l-.09.5s.05 2.78-.52 2.66z"
|
|
||||||
},
|
|
||||||
"md_f15": {
|
|
||||||
"viewBox": "-4 -3 32 32",
|
|
||||||
"w": 28,
|
|
||||||
"h": 28,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M12.37 22.55s-.04.25.35 1.02v.32h.94v-.31s.28-.44.35-.95v1.08l.65 1.66 3.05.7s.95-1.14.49-1.48l-2.13-2.5-.01.59-1.02-1.2s.1-.64.09-1.6h2.72l2.6.53.92-1.54s.21-.68-.3-1L15.1 12s-.24-1.97-.78-3.5c0 0 .04-.76-.11-1.91h-1.12s.17-5.5-.82-7.72c-1 2.22-.83 7.72-.83 7.72h-1.11c-.16 1.15-.12 1.9-.12 1.9-.54 1.54-.77 3.5-.77 3.5l-6 5.88c-.5.32-.29 1-.29 1l.93 1.54 2.59-.54h2.73c-.03.97.09 1.61.09 1.61l-1.02 1.2-.01-.59-2.14 2.5c-.46.34.49 1.47.49 1.47l3.05-.69.65-1.66v-1.08c.07.5.35.95.35.95v.3h.95v-.31c.38-.77.35-1.02.35-1.02h.1z",
|
|
||||||
"accent": "m 9.43,12 c 0,0 -0.27,1.44 0.64,3.56 M 15.09,12 c 0,0 0.26,1.44 -0.65,3.56 M 13.08,6.59 c 0,0 0.05,2.46 -0.42,5.98 h -0.8 C 11.39,9.05 11.43,6.59 11.43,6.59 m 0.72,15.96 c -0.42775,-12.368057 -1.851788,-4.842979 -1.64,0.08 m 11.43693,6.59 c 0,0 -0.05,2.46 0.42,5.98 h 0.8 c 0.47,-3.52 0.43,-5.98 0.43,-5.98 M 12.37,22.55 c 0.42775,-12.368057 1.851788,-4.842979 1.64,0.08"
|
|
||||||
},
|
|
||||||
"strato": {
|
|
||||||
"w": 40,
|
|
||||||
"h": 40,
|
|
||||||
"viewBox": "0 0 32 32",
|
|
||||||
"strokeScale": 0.8,
|
|
||||||
"path": "M19.5 16.71l.21 6.3-1.83.28s-.2.01-.2.2l-.01.8 2.1.15.02.62s.11.35.25 0l.02-.62 2.1-.15-.02-.81s.03-.18-.17-.2c-.2 0-1.85-.27-1.85-.27l.22-6.3h4.49l5.4-.61-.04-.74s-.02-.3-.34-.3-4.66-.55-4.66-.55h-1.1s.12-.66-.01-1.02h-.6s-.12.44-.02 1.02h-.72s.1-.68 0-1.02h-.6s-.12.52 0 1.04h-.73s.13-.62 0-1.04h-.6s-.15.47 0 1.02l-.47-.23V9.95s-.4-2.32-.85 0v4.33l-.49.24h-5.93l-.5-.24V9.95c-.44-2.32-.84 0-.84 0v4.33l-.47.23c.14-.55 0-1.02 0-1.02h-.6c-.13.42 0 1.04 0 1.04h-.73c.12-.52 0-1.04 0-1.04h-.6c-.1.34 0 1.02 0 1.02H8.6c.1-.58-.02-1.02-.02-1.02H8c-.14.36-.02 1.03-.02 1.03l-1.1-.01s-4.34.56-4.65.56c-.32 0-.35.3-.35.3l-.04.73 5.41.6h4.49l.22 6.31s-1.65.26-1.85.28c-.2.01-.18.19-.18.19l-.02.8 2.1.16.02.62c.14.35.26 0 .26 0l.01-.62 2.1-.16v-.79c0-.19-.2-.2-.2-.2L12.35 23l.2-6.29h3.47z"
|
|
||||||
},
|
|
||||||
"asterisk": {
|
|
||||||
"w": 11,
|
|
||||||
"h": 11,
|
|
||||||
"noRotate": true,
|
|
||||||
"strokeScale": 1.4,
|
|
||||||
"viewBox": "0 0 16 16",
|
|
||||||
"path": "M 7 1 L 7 6 L 5 4 L 4 5 L 6 7 L 2 7 L 2 9 L 6 9 L 4 11 L 5 12 L 7 10 L 7 14 L 9 14 L 9 10 L 11 12 L 12 11 L 10 9 L 14 9 L 14 7 L 10 7 L 12 5 L 11 4 L 9 6 L 9 2 L 7 2"
|
|
||||||
},
|
|
||||||
"b1b_lancer": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "0 0 64 64",
|
|
||||||
"strokeScale": 2.2,
|
|
||||||
"path": "m 31.62,56.29 c -0.23,-0.11 -0.55,-0.86 -0.54,-1.56 l -7.58,2.71 c -0.17,-0.82 -0.12,-1.31 -0.01,-2.03 l 6.90,-6.31 c -0.23,-2.30 -0.51,-3.72 -1.19,-6.08 l -0.28,0.96 -1.13,0.01 -0.42,-1.31 -0.41,1.30 -1.23,-0.00 -0.50,-1.32 -0.21,-6.56 -18.70,1.18 c -0.24,-0.19 -0.38,-2.09 0.48,-3.21 L 26.26,28.95 C 28.75,24.52 29.87,18.91 30.59,9.77 L 29.53,10.42 30.71,8.40 c 0.30,-2.49 0.56,-4.45 1.29,-5.84 0.72,1.39 0.99,3.35 1.29,5.84 l 1.17,2.01 -1.05,-0.65 c 0.72,9.14 1.84,14.76 4.33,19.18 l 19.46,5.12 c 0.86,1.12 0.72,3.02 0.48,3.21 l -18.70,-1.18 -0.21,6.56 -0.50,1.32 -1.23,0.00 -0.41,-1.30 -0.42,1.31 -1.13,-0.01 -0.28,-0.96 c -0.68,2.36 -0.96,3.78 -1.19,6.08 l 6.90,6.31 c 0.10,0.72 0.15,1.22 -0.01,2.03 l -7.58,-2.71 c 0.01,0.70 -0.31,1.45 -0.54,1.56 l -0.37,0.63 z"
|
|
||||||
},
|
|
||||||
"para": {
|
|
||||||
"w": 25,
|
|
||||||
"h": 25,
|
|
||||||
"viewBox": "-4 -4 72 72",
|
|
||||||
"strokeScale": 4,
|
|
||||||
"accentMult": 0.8,
|
|
||||||
"path": "M 2.7004841,35.4 C 2.3957197,34.805237 2.3346108,34.071291 2.3487212,33.492639 2.3628316,32.913987 2.4133442,32.511354 2.7,32 9.5741879,27.222774 18.4208,24.315376 32,24.357064 45.579201,24.281104 54.425812,27.222774 61.3,32 c 0.276334,0.514345 0.356292,0.854368 0.375811,1.434709 C 61.69533,34.01505 61.609571,34.773946 61.3,35.4 51.576472,38.439657 40.678687,39.277517 32,39.189844 21.874148,39.356757 12.026431,38.677104 2.7004841,35.4 Z",
|
|
||||||
"accent": "m 25.330646,24.634523 c -0.449188,4.692891 -0.341936,9.773843 0.03756,14.466734 M 17.376491,25.852595 c -0.815196,4.431676 -0.563898,8.706157 -0.01788,12.689083 M 10.149939,28.044695 c -0.6932861,3.230546 -0.7067571,6.748219 0.03192,9.585637 M 39.107311,24.631007 c 0.449188,4.692891 0.396257,9.757568 0.01676,14.450459 M 47.08978,25.710067 c 0.815196,4.431676 0.618698,8.91815 0.07268,12.901076 M 54.371132,28.11416 c 0.693286,3.230546 0.706757,6.559392 -0.03192,9.39681"
|
|
||||||
},
|
|
||||||
"e390": {
|
|
||||||
"w": 32,
|
|
||||||
"h": 32,
|
|
||||||
"viewBox": "-5 -5 175 167",
|
|
||||||
"strokeScale": 4,
|
|
||||||
"path": "M82.245.552C76.577.258 70.617 29.796 71.583 37.077c-1.005 2.09-1.497 4.03-2.178 6.313l-9.7 5.428.174-.677c1.94-.001 3.26-12.26 2.558-14.59-.124-.3-.118-1.086-.995-1.171H53.79c-.822.087-.942.82-1.08 1.193-1.047 2.612-.37 13.628 1.473 14.575.205 1.793 1.242-.228 1.206 3.08l-48.252 27c-1.22.519-5.173 6.398-4.699 9.612l65.35-18.075c.678 6.023 2.061 10.756 3.762 16.529.011 0 .04-.021.04-.005V104.1c-.356 10.104 1.165 18.468 6.18 30.667l-19.562 13.294c-1.52 1-1.89 4.028-1.49 6.287l24.391-5.857c.201 2.723.464 5.673 1.122 8.162.675-3.067.806-5.317 1.083-8.19l24.204 5.888c.13-2.126-.08-4.896-1.618-6.23L86.876 134.87c4.615-12.33 5.975-20.668 6.068-30.826L93 86.17c1.848-6.117 2.981-10.824 3.724-16.408l65.348 18.075c-.053-3.455-2.224-7.301-4.71-9.617l-48.244-26.994c-.19-3.07 1.082-.493 1.161-3.084 1.673-.836 2.55-11.042 1.44-14.57-.11-.634-.474-1.016-.994-1.193h-7.653c-.474.037-.803.253-1.039 1.196-.654 2.808.135 13.31 2.631 14.565l.193.694-9.7-5.428c-.632-2.071-1.28-4.32-2.132-6.237-.018-8.233-4.47-36.65-10.772-36.611z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,13 +7,14 @@
|
|||||||
"start": "node src/server.mjs"
|
"start": "node src/server.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.33.2",
|
|
||||||
"@scalar/express-api-reference": "^0.10.4",
|
"@scalar/express-api-reference": "^0.10.4",
|
||||||
"@ztimson/utils": "^0.29.5",
|
"@ztimson/utils": "^0.29.5",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 948 B |
|
Before Width: | Height: | Size: 945 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -4,39 +4,230 @@ import { fileURLToPath } from 'url';
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { fromCsv } from '@ztimson/utils';
|
import { fromCsv } from '@ztimson/utils';
|
||||||
|
import { getIcon } from './adsb-shapes.mjs';
|
||||||
|
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 DB_PATH = resolve(DATA, 'aircraft.db');
|
const IMAGES_DIR = resolve(DATA, 'aircraft');
|
||||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
const DB_PATH = resolve(DATA, 'aircraft.db');
|
||||||
const SHAPES_PATH = resolve(DATA, 'shapes.json');
|
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||||
const history = new Map();
|
const AIRLINES_CACHE = resolve(DATA, 'airlines.csv');
|
||||||
const MAX_HISTORY = 500;
|
const MAX_HISTORY = 500;
|
||||||
|
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
||||||
|
|
||||||
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
const ADSB_TTL = 1000;
|
||||||
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
|
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
||||||
|
const AIRLINES_URL = 'https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat';
|
||||||
|
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
||||||
|
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_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'
|
||||||
|
];
|
||||||
|
|
||||||
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
|
||||||
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
|
||||||
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'southwest', 'lufthansa', 'emirates', 'british'];
|
|
||||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
|
||||||
|
|
||||||
const milRanges = [];
|
|
||||||
let db;
|
let db;
|
||||||
|
let passengerOperators = PASSENGER_OPERATORS;
|
||||||
|
let adsbCache = null;
|
||||||
|
let adsbCacheTs = 0;
|
||||||
|
const noRecord = [];
|
||||||
|
const history = new Map();
|
||||||
|
const milRanges = [];
|
||||||
|
|
||||||
async function syncMilitaryRanges() {
|
function backfillModel(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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchWithTimeout(url, ms = 5000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id = setTimeout(() => controller.abort(), ms);
|
||||||
|
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initAircraftDb() {
|
||||||
|
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);
|
||||||
|
if (missing) console.log(`✈️ Building database`);
|
||||||
|
db = new Database(DB_PATH);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS aircraft (
|
||||||
|
icao TEXT PRIMARY KEY,
|
||||||
|
built TEXT,
|
||||||
|
categoryDescription TEXT,
|
||||||
|
country TEXT,
|
||||||
|
engines TEXT,
|
||||||
|
class TEXT,
|
||||||
|
manufacturer TEXT,
|
||||||
|
model TEXT,
|
||||||
|
modes TEXT,
|
||||||
|
operator TEXT,
|
||||||
|
operatorCallsign TEXT,
|
||||||
|
owner TEXT,
|
||||||
|
registration TEXT,
|
||||||
|
serialNumber TEXT,
|
||||||
|
aircraft TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await syncAirlines();
|
||||||
|
await syncMilitaryIcao();
|
||||||
|
|
||||||
|
if(missing) {
|
||||||
|
const res = await fetch(CSV_URL);
|
||||||
|
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const text = await res.text();
|
||||||
|
fs.writeFileSync(CSV_CACHE, text);
|
||||||
|
const csv = fromCsv(text, true);
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO aircraft VALUES (
|
||||||
|
@icao,
|
||||||
|
@built,
|
||||||
|
@categoryDescription,
|
||||||
|
@country,
|
||||||
|
@engines,
|
||||||
|
@class,
|
||||||
|
@manufacturer,
|
||||||
|
@model,
|
||||||
|
@modes,
|
||||||
|
@operator,
|
||||||
|
@operatorCallsign,
|
||||||
|
@owner,
|
||||||
|
@registration,
|
||||||
|
@serialNumber,
|
||||||
|
@aircraft
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertMany = db.transaction(rows => {
|
||||||
|
for (const row of rows) insert.run(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
insertMany(csv.map(row => ({
|
||||||
|
icao: (row.icao24 || '').toUpperCase(),
|
||||||
|
built: row.built || null,
|
||||||
|
categoryDescription: row.categoryDescription || null,
|
||||||
|
country: row.country || null,
|
||||||
|
engines: row.engines || null,
|
||||||
|
class: row.icaoAircraftClass || null,
|
||||||
|
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
|
||||||
|
model: row.model || null,
|
||||||
|
modes: row.modes || null,
|
||||||
|
operator: row.operator || null,
|
||||||
|
operatorCallsign: row.operatorCallsign || null,
|
||||||
|
owner: row.owner || null,
|
||||||
|
registration: row.registration || null,
|
||||||
|
serialNumber: row.serialNumber || null,
|
||||||
|
aircraft: row.typecode || null
|
||||||
|
})));
|
||||||
|
|
||||||
|
fs.unlinkSync(CSV_CACHE);
|
||||||
|
console.log(`✈️ Registrations imported: ${csv.length}`);
|
||||||
|
} else {
|
||||||
|
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
|
||||||
|
console.log(`✈️ Registrations loaded: ${count.c}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMilitaryIcao(icao) {
|
||||||
|
if (!icao) return false;
|
||||||
|
const hex = icao.toUpperCase();
|
||||||
|
return milRanges.some(([s, e]) => hex >= s && hex <= e);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAirlines() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(AIRLINES_CACHE)) {
|
||||||
|
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();
|
const { ADSB_URL } = cfg();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
|
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const { military } = await res.json();
|
const { military } = await res.json();
|
||||||
|
|
||||||
db.exec('DELETE FROM military_ranges');
|
db.exec('DELETE FROM military_ranges');
|
||||||
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
||||||
const insertMany = db.transaction(ranges => {
|
const insertMany = db.transaction(ranges => {
|
||||||
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
insertMany(military);
|
insertMany(military);
|
||||||
milRanges.length = 0;
|
milRanges.length = 0;
|
||||||
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
||||||
@@ -47,173 +238,272 @@ async function syncMilitaryRanges() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initAircraftDb() {
|
|
||||||
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
|
|
||||||
|
|
||||||
const missing = !fs.existsSync(DB_PATH);
|
|
||||||
if (missing) console.log(`✈️ Building database`);
|
|
||||||
|
|
||||||
db = new Database(DB_PATH);
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS aircraft (
|
|
||||||
icao24 TEXT PRIMARY KEY,
|
|
||||||
built TEXT,
|
|
||||||
categoryDescription TEXT,
|
|
||||||
country TEXT,
|
|
||||||
engines TEXT,
|
|
||||||
class TEXT,
|
|
||||||
manufacturer TEXT,
|
|
||||||
model TEXT,
|
|
||||||
modes TEXT,
|
|
||||||
operator TEXT,
|
|
||||||
operatorCallsign TEXT,
|
|
||||||
owner TEXT,
|
|
||||||
registration TEXT,
|
|
||||||
serialNumber TEXT,
|
|
||||||
aircraft TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS military_ranges (
|
|
||||||
start TEXT NOT NULL,
|
|
||||||
end TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await syncMilitaryRanges();
|
|
||||||
|
|
||||||
if (missing) {
|
|
||||||
const res = await fetch(CSV_URL);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const text = await res.text();
|
|
||||||
fs.writeFileSync(CSV_CACHE, text);
|
|
||||||
|
|
||||||
const csv = fromCsv(text, true);
|
|
||||||
const insert = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO aircraft VALUES (
|
|
||||||
@icao24, @built, @categoryDescription, @country, @engines,
|
|
||||||
@class, @manufacturer, @model, @modes, @operator,
|
|
||||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const insertMany = db.transaction(rows => {
|
|
||||||
for (const row of rows) insert.run(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
insertMany(csv.map(row => ({
|
|
||||||
icao24: (row.icao24 || '').toUpperCase(),
|
|
||||||
built: row.built || null,
|
|
||||||
categoryDescription: row.categoryDescription || null,
|
|
||||||
country: row.country || null,
|
|
||||||
engines: row.engines || null,
|
|
||||||
class: row.icaoAircraftClass || null,
|
|
||||||
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
|
|
||||||
model: row.model || null,
|
|
||||||
modes: row.modes || null,
|
|
||||||
operator: row.operator || null,
|
|
||||||
operatorCallsign: row.operatorCallsign || null,
|
|
||||||
owner: row.owner || null,
|
|
||||||
registration: row.registration || null,
|
|
||||||
serialNumber: row.serialNumber || null,
|
|
||||||
aircraft: row.typecode || null,
|
|
||||||
})));
|
|
||||||
|
|
||||||
fs.unlinkSync(CSV_CACHE);
|
|
||||||
console.log(`✈️ Aircraft imported: ${csv.length}`);
|
|
||||||
} else {
|
|
||||||
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
|
|
||||||
console.log(`✈️ Aircraft loaded: ${count.c}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIcaoInMilitaryRange(icao) {
|
|
||||||
if (!icao) return false;
|
|
||||||
const hex = icao.toUpperCase();
|
|
||||||
return milRanges.some(([s, e]) => hex >= s && hex <= e);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wordMatch(text, keyword) {
|
|
||||||
if (!text) return false;
|
|
||||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.icao24) || 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';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrichAircraft(a) {
|
function normalizeName(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrich(a) {
|
||||||
if (!a.hex) return a;
|
if (!a.hex) return a;
|
||||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(a.hex.toUpperCase());
|
const icao = a.hex.toUpperCase();
|
||||||
if (!row) {
|
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
||||||
const resp = await fetch(`https://hexdb.io/api/v1/aircraft/${a.hex.toUpperCase()}`);
|
if (row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||||
if (resp.ok) {
|
if (noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||||
const found = await resp.json();
|
const hexDbPromise = fetchRegistration2(icao);
|
||||||
const insert = db.prepare(`
|
const scrapePromise = fetchRegistration1(icao);
|
||||||
INSERT OR IGNORE INTO aircraft (icao24, registration, manufacturer, aircraft, model, operator)
|
let found = await hexDbPromise.catch(() => {});
|
||||||
VALUES (@icao24, @registration, @manufacturer, @aircraft, @model, @operator)
|
if (!found) found = await scrapePromise.catch(() => {});
|
||||||
`);
|
if (!found) {
|
||||||
const mapped = {
|
noRecord.push(icao);
|
||||||
icao24: a.hex.toUpperCase(),
|
return { icao, ...a, type: 'unknown' };
|
||||||
registration: found.Registration || null,
|
|
||||||
manufacturer: found.Manufacturer || null,
|
|
||||||
aircraft: found.ICAOTypeCode || null,
|
|
||||||
model: found.Type || null,
|
|
||||||
operator: found.RegisteredOwners || null,
|
|
||||||
};
|
|
||||||
insert.run(mapped);
|
|
||||||
return { ...a, ...mapped, type: classifyAircraft(mapped) };
|
|
||||||
}
|
|
||||||
return { ...a, type: isIcaoInMilitaryRange(a.hex) ? 'military' : 'unknown' };
|
|
||||||
}
|
}
|
||||||
return { ...a, ...row, type: classifyAircraft(row) };
|
const merged = { ...row, ...found };
|
||||||
|
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
||||||
|
const similar = backfillModel(merged.aircraft);
|
||||||
|
Object.assign(merged, similar);
|
||||||
|
}
|
||||||
|
if(found) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
||||||
|
VALUES (@icao, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
|
||||||
|
ON CONFLICT(icao) DO UPDATE SET
|
||||||
|
registration = COALESCE(excluded.registration, registration),
|
||||||
|
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
|
||||||
|
aircraft = COALESCE(excluded.aircraft, aircraft),
|
||||||
|
model = COALESCE(excluded.model, model),
|
||||||
|
operator = COALESCE(excluded.operator, operator),
|
||||||
|
country = COALESCE(excluded.country, country),
|
||||||
|
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
|
||||||
|
engines = COALESCE(excluded.engines, engines),
|
||||||
|
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
|
||||||
|
class = COALESCE(excluded.class, class)
|
||||||
|
`).run({
|
||||||
|
icao,
|
||||||
|
registration: merged.registration || null,
|
||||||
|
manufacturer: merged.manufacturer || null,
|
||||||
|
aircraft: merged.aircraft || null,
|
||||||
|
model: merged.model || null,
|
||||||
|
operator: merged.operator || null,
|
||||||
|
country: merged.country || null,
|
||||||
|
serialNumber: merged.serialNumber || null,
|
||||||
|
engines: merged.engines || null,
|
||||||
|
categoryDescription: merged.categoryDescription || null,
|
||||||
|
class: merged.class || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { icao, ...a, ...merged, type: classifyAircraft(merged) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getShapes() {
|
export async function get() {
|
||||||
return JSON.parse(await fs.promises.readFile(SHAPES_PATH, 'utf8'));
|
if(adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||||
}
|
|
||||||
|
|
||||||
export async function getADSB() {
|
|
||||||
const { ADSB_URL } = cfg();
|
const { ADSB_URL } = cfg();
|
||||||
if (!ADSB_URL) return [];
|
if(!ADSB_URL) return [];
|
||||||
const r = await fetch(`${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 || [];
|
||||||
|
|
||||||
for (const a of aircraft) {
|
for (const a of aircraft) {
|
||||||
a.type = 'unknown';
|
a.type = 'unknown';
|
||||||
if (!a.hex || !a.lat || !a.lon) continue;
|
if(!a.hex || !a.lat || !a.lon) continue;
|
||||||
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(enrichAircraft));
|
adsbCache = await Promise.all(aircraft.map(async a => {
|
||||||
|
a = await enrich(a);
|
||||||
|
a.icon = getIcon(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);
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
|
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 = {
|
||||||
0: 'Unknown', 1: 'Base Station', 2: 'Class A',
|
0: 'Other', 1: 'Unknown', 2: 'Cargo', 3: 'Class B',
|
||||||
3: 'Class B', 4: 'SAR Aircraft', 5: 'Aid to Navigation',
|
4: 'Passenger', 5: 'Special', 6: 'Tanker', 7: 'High Speed',
|
||||||
6: 'Class B CS', 7: 'Sart/Epirb/MOB'
|
8: 'Fishing', 9: 'Aircraft', 10: 'Helicopter', 11: 'Base Station',
|
||||||
|
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++) {
|
||||||
@@ -35,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`);
|
||||||
@@ -42,9 +57,9 @@ 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, level, 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,
|
||||||
received_stations, mmsi_type, shipclass, country] = row;
|
received_stations, mmsi_type, shipclass, country] = row;
|
||||||
const s = staticMap.get(mmsi) ?? [];
|
const s = staticMap.get(mmsi) ?? [];
|
||||||
@@ -56,12 +71,12 @@ export async function getAIS() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
mmsi,
|
mmsi,
|
||||||
type: SENDER_TYPE[mmsi_type],
|
type: SENDER_TYPE[shipclass],
|
||||||
lat, lon, altitude,
|
lat, lon, altitude,
|
||||||
heading, cog, speed,
|
heading, cog, speed,
|
||||||
distance, bearing,
|
distance, bearing,
|
||||||
destination,
|
destination,
|
||||||
level, ppm, count,
|
rrsi, ppm, count,
|
||||||
msg_type: decodeMsgTypes(msg_type),
|
msg_type: decodeMsgTypes(msg_type),
|
||||||
channels: CHANNEL_MAP[f.channels] ?? f.channels,
|
channels: CHANNEL_MAP[f.channels] ?? f.channels,
|
||||||
...f,
|
...f,
|
||||||
@@ -76,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,10 +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 || '',
|
||||||
INFLUX_URL: process.env.INFLUX_URL || 'http://localhost:8086',
|
TINYGS_URL: process.env.TINYGS_URL || '',
|
||||||
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
|
TINYGS_AUTH: process.env.TINYGS_AUTH || '',
|
||||||
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
|
TINYGS_MQTT_PORT: process.env.TINYGS_MQTT_PORT || 1883,
|
||||||
INFLUX_BUCKET: process.env.INFLUX_BUCKET || 'station',
|
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',
|
||||||
|
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,11 +1,25 @@
|
|||||||
import {queryHourly, getCoords, queryCurrent} from './influx.mjs';
|
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
|
||||||
import {getCelestialCurrent} from './celestial.mjs';
|
import {getCelestialCurrent} from './celestial.mjs';
|
||||||
import {getWeatherCondition} from './openweather.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
|
||||||
|
|
||||||
|
function estimatePrecipitation(precipChance, clouds) {
|
||||||
|
if (precipChance < 30) return 0;
|
||||||
|
const intensity = (precipChance / 100) * clouds;
|
||||||
|
if (intensity > 0.7) return Math.round(intensity * 15 * 10) / 10;
|
||||||
|
if (intensity > 0.4) return Math.round(intensity * 6 * 10) / 10;
|
||||||
|
return Math.round(intensity * 2 * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateUV(sunElevation, cloudFraction) {
|
||||||
|
if (sunElevation <= 0) return 0;
|
||||||
|
const clearSkyUV = Math.sin((sunElevation * Math.PI) / 180) * 12;
|
||||||
|
return Math.round(clearSkyUV * (1 - cloudFraction * 0.75) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
function linearTrend(values) {
|
function linearTrend(values) {
|
||||||
const n = values.length;
|
const n = values.length;
|
||||||
if (n < 2) return 0;
|
if (n < 2) return 0;
|
||||||
@@ -44,14 +58,14 @@ function absoluteHumidity(tempC, humidity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pressureRule(trend3h, currentHpa) {
|
function pressureRule(trend3h, currentHpa) {
|
||||||
if (trend3h < -2.0) return { label: 'Storm likely', code: 211, precipChance: 0.85 };
|
if (trend3h < -2.0) return { label: 'Storm likely', precipitation_chance: 0.85 };
|
||||||
if (trend3h < -0.8) return { label: 'Rain likely', code: 501, precipChance: 0.65 };
|
if (trend3h < -0.8) return { label: 'Rain likely', precipitation_chance: 0.65 };
|
||||||
if (trend3h < -0.3) return { label: 'Cloudy', code: 803, precipChance: 0.35 };
|
if (trend3h < -0.3) return { label: 'Cloudy', precipitation_chance: 0.35 };
|
||||||
if (trend3h > 1.5) return { label: 'Clearing', code: 801, precipChance: 0.05 };
|
if (trend3h > 1.5) return { label: 'Clearing', precipitation_chance: 0.05 };
|
||||||
if (trend3h > 0.3) return { label: 'Improving', code: 800, precipChance: 0.10 };
|
if (trend3h > 0.3) return { label: 'Improving', precipitation_chance: 0.10 };
|
||||||
if (currentHpa < 1000) return { label: 'Unsettled', code: 802, precipChance: 0.30 };
|
if (currentHpa < 1000) return { label: 'Unsettled', precipitation_chance: 0.30 };
|
||||||
if (currentHpa < 1013) return { label: 'Partly cloudy', code: 802, precipChance: 0.15 };
|
if (currentHpa < 1013) return { label: 'Partly cloudy', precipitation_chance: 0.15 };
|
||||||
return { label: 'Fair', code: 800, precipChance: 0.05 };
|
return { label: 'Fair', precipitation_chance: 0.05 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function solarTempDelta(sunElevation, cloudFraction) {
|
function solarTempDelta(sunElevation, cloudFraction) {
|
||||||
@@ -106,20 +120,28 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
|
|
||||||
const weather = pressureRule(pressure3h, seedPressure);
|
const weather = pressureRule(pressure3h, seedPressure);
|
||||||
|
|
||||||
|
let runningAccumulation = 0;
|
||||||
const slots = Array.from({ length: 24 }, (_, i) => {
|
const slots = Array.from({ length: 24 }, (_, i) => {
|
||||||
const time = new Date(now.getTime() + (i + 1) * 3600 * 1000);
|
const time = new Date(now.getTime() + (i + 1) * 3600 * 1000);
|
||||||
time.setMinutes(0, 0, 0);
|
time.setMinutes(0, 0, 0);
|
||||||
|
|
||||||
const pressureDamping = Math.exp(-i * 0.08);
|
const pressureDamping = Math.exp(-i * 0.08);
|
||||||
const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping;
|
const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping;
|
||||||
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
const pressure_slp = Math.round((pressure + (coords.altitude ?? 0) / 8.5) * 100) / 100;
|
||||||
const bgTemp = seedTemp + tempTrend * (i + 1);
|
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
||||||
const temperature = Math.round(bgTemp * 10) / 10;
|
const bgTemp = seedTemp + tempTrend * (i + 1);
|
||||||
const humidity = forecastHumidity(seedAbsHum, temperature);
|
const temperature = Math.round(bgTemp * 10) / 10;
|
||||||
const wind_speed = forecastWind(seedWind, pressureTrend);
|
const humidity = forecastHumidity(seedAbsHum, temperature);
|
||||||
|
const wind_speed = forecastWind(seedWind, pressureTrend);
|
||||||
|
const precipitation_chance = Math.round(weather.precipitation_chance * (1 - i * 0.02) * 100);
|
||||||
|
const raining = precipitation_chance > 60 ? 'True' : 'False';
|
||||||
|
const precipitation = estimatePrecipitation(precipitation_chance, clouds);
|
||||||
|
runningAccumulation = Math.round((runningAccumulation + precipitation) * 100) / 100;
|
||||||
|
|
||||||
const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time);
|
const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time);
|
||||||
const snapshot = {
|
const uv_index = estimateUV(celestial.sun_elevation ?? 0, clouds);
|
||||||
|
|
||||||
|
return {
|
||||||
time,
|
time,
|
||||||
temperature,
|
temperature,
|
||||||
humidity,
|
humidity,
|
||||||
@@ -128,15 +150,25 @@ export async function get24HourForecast(currentSensors) {
|
|||||||
heat_index: Math.round(heatIndex(temperature, humidity) * 100) / 100,
|
heat_index: Math.round(heatIndex(temperature, humidity) * 100) / 100,
|
||||||
vapor_pressure_deficit: vaporPressureDeficit(temperature, humidity),
|
vapor_pressure_deficit: vaporPressureDeficit(temperature, humidity),
|
||||||
pressure_hpa: Math.round(pressure * 100) / 100,
|
pressure_hpa: Math.round(pressure * 100) / 100,
|
||||||
|
pressure_slp,
|
||||||
|
pressure_rate: Math.round(pressureTrend * 100) / 100,
|
||||||
|
storm_trend: pressure3h < -2 ? 1 : pressure3h > 1.5 ? -1 : 0,
|
||||||
wind_speed,
|
wind_speed,
|
||||||
|
wind_gusts: Math.round(wind_speed * 1.4 * 10) / 10,
|
||||||
|
wind_direction: currentSensors.wind_direction ?? 0,
|
||||||
clouds,
|
clouds,
|
||||||
precipitation_chance: Math.round(weather.precipChance * (1 - i * 0.02) * 100),
|
raining,
|
||||||
...celestial
|
precipitation,
|
||||||
|
precipitation_chance,
|
||||||
|
accumulation: runningAccumulation,
|
||||||
|
uv_index,
|
||||||
|
uv_dose: 0,
|
||||||
|
solar_wm2: 0,
|
||||||
|
daily_light_integral: 0,
|
||||||
|
lux: 0,
|
||||||
|
visibility: 50,
|
||||||
|
...celestial,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(snapshot, getWeatherCondition(snapshot));
|
|
||||||
snapshot.icon = `/icons/${snapshot.icon}.png`;
|
|
||||||
return snapshot;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
@@ -157,12 +189,21 @@ 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
|
||||||
|
? Math.round(Math.sin((slot.sun_elevation * Math.PI) / 180) * 1000 * (1 - slot.clouds * 0.75) * 100) / 100
|
||||||
|
: 0;
|
||||||
|
slot.daily_light_integral = Math.round(slot.solar_wm2 * 3600 / 1_000_000 * 4.57 * 100) / 100;
|
||||||
|
slot.lux = slot.solar_wm2 > 0 ? Math.round(slot.solar_wm2 * 120) : 0;
|
||||||
|
slot.visibility = slot.clouds > 0.85 ? 10 : 50;
|
||||||
|
|
||||||
|
Object.assign(slot, getWeatherCondition(slot));
|
||||||
}
|
}
|
||||||
|
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group slots into local-time day buckets, summarise each as daytime-only
|
|
||||||
function groupByLocalDay(slots) {
|
function groupByLocalDay(slots) {
|
||||||
const days = {}
|
const days = {}
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
@@ -176,19 +217,13 @@ function groupByLocalDay(slots) {
|
|||||||
function summarise(slots) {
|
function summarise(slots) {
|
||||||
if (!slots.length) return null
|
if (!slots.length) return null
|
||||||
|
|
||||||
// Use only daytime slots for representative label/stats, fallback to all if none
|
|
||||||
const daytime = slots.filter(s => s.daytime)
|
const daytime = slots.filter(s => s.daytime)
|
||||||
const source = daytime.length ? daytime : slots
|
const source = daytime.length ? daytime : slots
|
||||||
|
|
||||||
const temps = source.map(s => s.temperature).filter(Number.isFinite)
|
const avg = (arr) => arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length * 100) / 100 : null
|
||||||
const precips = source.map(s => s.precipitation_chance).filter(Number.isFinite)
|
const peak = (arr) => arr.length ? Math.max(...arr) : null
|
||||||
const winds = source.map(s => s.wind_speed).filter(Number.isFinite)
|
const fin = (key) => source.map(s => s[key]).filter(Number.isFinite)
|
||||||
const gusts = source.map(s => s.wind_gusts).filter(Number.isFinite)
|
const finAll = (key) => slots.map(s => s[key]).filter(Number.isFinite)
|
||||||
const uvs = source.map(s => s.uv_index).filter(Number.isFinite)
|
|
||||||
const solar = source.map(s => s.solar_wm2).filter(Number.isFinite)
|
|
||||||
|
|
||||||
// Min/max temp uses ALL slots for the full day range
|
|
||||||
const allTemps = slots.map(s => s.temperature).filter(Number.isFinite)
|
|
||||||
|
|
||||||
const labelCount = {}
|
const labelCount = {}
|
||||||
for (const s of source) labelCount[s.label] = (labelCount[s.label] || 0) + 1
|
for (const s of source) labelCount[s.label] = (labelCount[s.label] || 0) + 1
|
||||||
@@ -196,23 +231,47 @@ function summarise(slots) {
|
|||||||
const dominant = source.find(s => s.label === label)
|
const dominant = source.find(s => s.label === label)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
time: slots[0].time,
|
time: slots[0].time,
|
||||||
label: dominant.label,
|
label: dominant.label,
|
||||||
code: dominant.code,
|
icon: dominant.icon,
|
||||||
icon: dominant.icon,
|
temperature: peak(fin('temperature')),
|
||||||
temperature: Math.max(...temps),
|
temperature_max: peak(finAll('temperature')),
|
||||||
temperature_max: Math.max(...allTemps),
|
temperature_min: Math.min(...finAll('temperature')),
|
||||||
temperature_min: Math.min(...allTemps),
|
humidity: avg(fin('humidity')),
|
||||||
precipitation_chance: Math.round(Math.max(...precips)),
|
humidity_abs: avg(fin('humidity_abs')),
|
||||||
wind_speed: Math.round(Math.max(...winds) * 10) / 10,
|
dew_point: avg(fin('dew_point')),
|
||||||
wind_gusts: gusts.length ? Math.round(Math.max(...gusts) * 10) / 10 : null,
|
heat_index: peak(fin('heat_index')),
|
||||||
uv_index: uvs.length ? Math.max(...uvs) : null,
|
vapor_pressure_deficit: avg(fin('vapor_pressure_deficit')),
|
||||||
uv_index_label: dominant.uv_index_label ?? null,
|
pressure_hpa: avg(fin('pressure_hpa')),
|
||||||
solar_wm2: solar.length ? Math.round(solar.reduce((a, b) => a + b, 0) / solar.length * 10) / 10 : null,
|
pressure_slp: avg(fin('pressure_slp')),
|
||||||
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
pressure_rate: avg(fin('pressure_rate')),
|
||||||
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
storm_trend: slots.at(-1)?.storm_trend ?? 0,
|
||||||
humidity: Math.round(source.map(s => s.humidity).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
wind_speed: peak(fin('wind_speed')),
|
||||||
pressure_hpa: Math.round(source.map(s => s.pressure_hpa).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
wind_gusts: peak(fin('wind_gusts')),
|
||||||
|
wind_direction: avg(fin('wind_direction')),
|
||||||
|
clouds: avg(fin('clouds')),
|
||||||
|
raining: peak(fin('precipitation_chance')) > 60 ? 'True' : 'False',
|
||||||
|
precipitation: avg(fin('precipitation')),
|
||||||
|
precipitation_chance: Math.round(peak(fin('precipitation_chance'))),
|
||||||
|
accumulation: finAll('precipitation').reduce((a, b) => a + b, 0),
|
||||||
|
frost_risk: slots.find(s => s.frost_risk !== 'None')?.frost_risk ?? slots.at(-1)?.frost_risk ?? 'None',
|
||||||
|
uv_index: peak(fin('uv_index')),
|
||||||
|
uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0),
|
||||||
|
solar_wm2: avg(fin('solar_wm2')),
|
||||||
|
daily_light_integral: Math.round(finAll('daily_light_integral').reduce((a, b) => a + b, 0) * 100) / 100,
|
||||||
|
lux: peak(fin('lux')),
|
||||||
|
visibility: avg(fin('visibility')),
|
||||||
|
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
||||||
|
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
||||||
|
daylight: slots.find(s => s.daylight)?.daylight ?? null,
|
||||||
|
moon_phase: dominant.moon_phase ?? null,
|
||||||
|
moon_illumination: dominant.moon_illumination ?? null,
|
||||||
|
moon_elevation: dominant.moon_elevation ?? null,
|
||||||
|
moon_azimuth: dominant.moon_azimuth ?? null,
|
||||||
|
moon_new: dominant.moon_new ?? null,
|
||||||
|
moon_full: dominant.moon_full ?? null,
|
||||||
|
moonrise: slots.find(s => s.moonrise)?.moonrise ?? null,
|
||||||
|
moonset: slots.find(s => s.moonset)?.moonset ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +282,29 @@ export async function getForecast() {
|
|||||||
const slots = await get24HourForecast(sensors).catch(() => null)
|
const slots = await get24HourForecast(sensors).catch(() => null)
|
||||||
if (!slots) return lastForecast
|
if (!slots) return lastForecast
|
||||||
|
|
||||||
// Summarise today's local-day slots only
|
const days = groupByLocalDay(slots)
|
||||||
const days = groupByLocalDay(slots)
|
const todayKey = localDateStr(new Date())
|
||||||
const todayKey = localDateStr(new Date())
|
|
||||||
const todaySlots = days[todayKey] ?? slots
|
const todaySlots = days[todayKey] ?? slots
|
||||||
lastForecast = { ts: Date.now(), slots, summary: summarise(todaySlots) }
|
lastForecast = { ts: Date.now(), slots, summary: summarise(todaySlots) }
|
||||||
return lastForecast
|
return lastForecast
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWeatherCondition(data) {
|
||||||
|
if(data.lightning_rate > 0 && data.raining === 'True') {
|
||||||
|
return {label: 'Thunderstorm', icon: '11d'};
|
||||||
|
}
|
||||||
|
if(data.raining === 'True') {
|
||||||
|
if(data.precipitation > 7.6) return {label: 'Heavy Rain', icon: '10d'};
|
||||||
|
if(data.precipitation > 2.5) return {label: 'Moderate Rain', icon: '10d'};
|
||||||
|
return {label: 'Light Rain', icon: '09d'};
|
||||||
|
}
|
||||||
|
if(data.visibility < 10) return {label: 'Mist', icon: '50d'};
|
||||||
|
|
||||||
|
const dayNight = data.daytime ? 'd' : 'n';
|
||||||
|
const c = data.clouds > 1 ? data.clouds / 100 : data.clouds;
|
||||||
|
if(c > 0.85) return {label: 'Overcast Clouds', icon: `04${dayNight}`};
|
||||||
|
if(c > 0.50) return {label: 'Broken Clouds', icon: `04${dayNight}`};
|
||||||
|
if(c > 0.25) return {label: 'Scattered Clouds', icon: `03${dayNight}`};
|
||||||
|
if(c > 0.10) return {label: 'Few Clouds', icon: `02${dayNight}`};
|
||||||
|
return {label: 'Clear Sky', icon: `01${dayNight}`};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import {InfluxDB} from '@influxdata/influxdb-client';
|
|
||||||
import {cfg} from './config.mjs';
|
|
||||||
|
|
||||||
const MEASUREMENTS = ['accumulation', 'environment', 'light', 'lightning', 'rain', 'seismic', 'wind'];
|
|
||||||
|
|
||||||
let client, currentUrl, currentToken;
|
|
||||||
|
|
||||||
function getClient() {
|
|
||||||
const c = cfg();
|
|
||||||
if(c.INFLUX_URL !== currentUrl || c.INFLUX_TOKEN !== currentToken) {
|
|
||||||
client = new InfluxDB({url: c.INFLUX_URL, token: c.INFLUX_TOKEN});
|
|
||||||
currentUrl = c.INFLUX_URL;
|
|
||||||
currentToken = c.INFLUX_TOKEN;
|
|
||||||
}
|
|
||||||
return {client, c};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function query(flux) {
|
|
||||||
const {client, c} = getClient();
|
|
||||||
const api = client.getQueryApi(c.INFLUX_ORG);
|
|
||||||
const rows = [];
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
api.queryRows(flux, {
|
|
||||||
next(row, meta) { rows.push(meta.toObject(row)); },
|
|
||||||
error(err) { reject(err); },
|
|
||||||
complete() { resolve(rows); },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateToHour(iso) {
|
|
||||||
const d = new Date(iso);
|
|
||||||
d.setMinutes(0, 0, 0);
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateToDay(iso) {
|
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractRow(row) {
|
|
||||||
const out = {};
|
|
||||||
for(const [k, v] of Object.entries(row)) {
|
|
||||||
if(!k.startsWith('_') && k !== 'result' && k !== 'table') out[k] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryCurrent() {
|
|
||||||
const {c} = getClient();
|
|
||||||
const flux = `
|
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
|
||||||
|> range(start: -1h)
|
|
||||||
|> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')})
|
|
||||||
|> last()
|
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
|
||||||
`;
|
|
||||||
const rows = await query(flux);
|
|
||||||
const result = {time: new Date()};
|
|
||||||
for(const row of rows) {
|
|
||||||
Object.assign(result, extractRow(row));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryHourly(start, end) {
|
|
||||||
const {c} = getClient();
|
|
||||||
const flux = `
|
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
|
||||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
|
||||||
|> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')})
|
|
||||||
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
|
||||||
`;
|
|
||||||
const rows = await query(flux);
|
|
||||||
return rows.map(row => ({
|
|
||||||
time: truncateToHour(row._time),
|
|
||||||
...extractRow(row),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryDaily(start, end) {
|
|
||||||
const {c} = getClient();
|
|
||||||
const measurements = ['accumulation', 'environment', 'light', 'lightning', 'seismic'];
|
|
||||||
const results = {};
|
|
||||||
|
|
||||||
for(const m of measurements) {
|
|
||||||
const [means, mins, maxs] = await Promise.all([
|
|
||||||
query(`
|
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
|
||||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
|
||||||
|> filter(fn: (r) => r._measurement == "${m}")
|
|
||||||
|> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
|
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
|
||||||
`),
|
|
||||||
query(`
|
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
|
||||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
|
||||||
|> filter(fn: (r) => r._measurement == "${m}")
|
|
||||||
|> aggregateWindow(every: 1d, fn: min, createEmpty: false)
|
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
|
||||||
`),
|
|
||||||
query(`
|
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
|
||||||
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|
|
||||||
|> filter(fn: (r) => r._measurement == "${m}")
|
|
||||||
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
|
||||||
`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Mean values are the base — flat field names, no suffix
|
|
||||||
for(const row of means) {
|
|
||||||
const time = truncateToDay(row._time);
|
|
||||||
if(!results[time]) results[time] = {time};
|
|
||||||
Object.assign(results[time], extractRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only promote temp min/max to named fields, ignore the rest
|
|
||||||
for(const row of mins) {
|
|
||||||
const time = truncateToDay(row._time);
|
|
||||||
if(!results[time]) results[time] = {time};
|
|
||||||
if(row.temperature != null) results[time].env_temp_min_c = row.temperature;
|
|
||||||
}
|
|
||||||
for(const row of maxs) {
|
|
||||||
const time = truncateToDay(row._time);
|
|
||||||
if(!results[time]) results[time] = {time};
|
|
||||||
if(row.temperature != null) results[time].env_temp_max_c = row.temperature;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.values(results).sort((a, b) => a.time.localeCompare(b.time));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCoords() {
|
|
||||||
const {c} = getClient();
|
|
||||||
const flux = `
|
|
||||||
from(bucket: "${c.INFLUX_BUCKET}")
|
|
||||||
|> range(start: -24h)
|
|
||||||
|> filter(fn: (r) => r._measurement == "gps")
|
|
||||||
|> last()
|
|
||||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
|
||||||
`;
|
|
||||||
const rows = await query(flux);
|
|
||||||
if(rows.length && rows[0].latitude && rows[0].longitude) {
|
|
||||||
return {latitude: rows[0].latitude, longitude: rows[0].longitude, altitude: rows[0].altitude || c.ALTITUDE};
|
|
||||||
}
|
|
||||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
import { resolve, dirname } from 'path'
|
import { resolve, dirname } from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { pipeline } from 'stream/promises'
|
|
||||||
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')
|
||||||
@@ -33,17 +35,6 @@ const OWM_ICONS = {
|
|||||||
95: '11d', 96: '11d', 99: '11d',
|
95: '11d', 96: '11d', 99: '11d',
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureIcon(code) {
|
|
||||||
const owmCode = OWM_ICONS[code] || '01d'
|
|
||||||
const filename = `${owmCode}.png`
|
|
||||||
const filepath = resolve(ICON_DIR, filename)
|
|
||||||
if (!existsSync(filepath)) {
|
|
||||||
const res = await fetch(`https://openweathermap.org/img/wn/${owmCode}@2x.png`)
|
|
||||||
await pipeline(res.body, createWriteStream(filepath))
|
|
||||||
}
|
|
||||||
return `/icons/${filename}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cachedFetch(key, url) {
|
async function cachedFetch(key, url) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
|
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
|
||||||
@@ -80,22 +71,6 @@ function absoluteHumidity(tempC, humidity) {
|
|||||||
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000
|
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
function uvLabel(uv) {
|
|
||||||
if (uv < 3) return 'Low'
|
|
||||||
if (uv < 6) return 'Moderate'
|
|
||||||
if (uv < 8) return 'High'
|
|
||||||
if (uv < 11) return 'Very High'
|
|
||||||
return 'Extreme'
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloudsLabel(fraction) {
|
|
||||||
if (fraction < 0.1) return 'Clear'
|
|
||||||
if (fraction < 0.3) return 'Mostly Clear'
|
|
||||||
if (fraction < 0.6) return 'Partly Cloudy'
|
|
||||||
if (fraction < 0.9) return 'Mostly Cloudy'
|
|
||||||
return 'Overcast'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DAILY_FIELDS = [
|
const DAILY_FIELDS = [
|
||||||
@@ -115,44 +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 = await ensureIcon(code).catch(() => null)
|
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,
|
||||||
uv_index_label: uvLabel(uv),
|
wind_direction: data.daily.winddirection_10m_dominant[i] ?? 0,
|
||||||
solar_wm2: data.daily.shortwave_radiation_sum[i],
|
clouds,
|
||||||
clouds: null,
|
raining: precipChance > 60 ? 'True' : 'False',
|
||||||
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
precipitation: precip,
|
||||||
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
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 ────────────────────────────────────────────────────────────────────
|
||||||
@@ -175,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) => {
|
||||||
@@ -187,49 +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) // "2026-03-01" from local string
|
const dateKey = time.slice(0, 10)
|
||||||
const sun = sunMap[dateKey] ?? {}
|
const sun = sunMap[dateKey] ?? {}
|
||||||
// Open-Meteo local strings have no Z — append Z to treat as UTC for ms comparison
|
|
||||||
// This works because Open-Meteo already returns times in the location's local tz
|
|
||||||
// and sunrise/sunset are parsed the same way, so the comparison is consistent
|
|
||||||
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,
|
||||||
uv_index_label: uvLabel(uv),
|
uv_index_max: uv,
|
||||||
solar_wm2: data.hourly.shortwave_radiation[i],
|
uv_dose: 0,
|
||||||
clouds_label: cloudsLabel(clouds),
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,28 @@
|
|||||||
import {existsSync, mkdirSync, writeFileSync} from 'fs';
|
import {existsSync, mkdirSync, writeFileSync} from 'fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import {resolve, dirname} from 'path';
|
import {resolve, dirname} from 'path';
|
||||||
import {fileURLToPath} from 'url';
|
import {fileURLToPath} from 'url';
|
||||||
|
|
||||||
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'public', 'icons');
|
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../data/icons');
|
||||||
|
|
||||||
export const WMO = {
|
export async function downloadIcon(icon, path) {
|
||||||
0: {label: 'Clear Sky', icon: '01d'},
|
if(!existsSync(DIR)) mkdirSync(dirname(path), {recursive: true});
|
||||||
1: {label: 'Mainly Clear', icon: '01d'},
|
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`);
|
||||||
2: {label: 'Partly Cloudy', icon: '02d'},
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
3: {label: 'Overcast', icon: '04d'},
|
writeFileSync(path, buf);
|
||||||
45: {label: 'Fog', icon: '50d'},
|
|
||||||
48: {label: 'Icy Fog', icon: '50d'},
|
|
||||||
51: {label: 'Light Drizzle', icon: '09d'},
|
|
||||||
53: {label: 'Drizzle', icon: '09d'},
|
|
||||||
55: {label: 'Heavy Drizzle', icon: '09d'},
|
|
||||||
61: {label: 'Light Rain', icon: '10d'},
|
|
||||||
63: {label: 'Rain', icon: '10d'},
|
|
||||||
65: {label: 'Heavy Rain', icon: '10d'},
|
|
||||||
71: {label: 'Light Snow', icon: '13d'},
|
|
||||||
73: {label: 'Snow', icon: '13d'},
|
|
||||||
75: {label: 'Heavy Snow', icon: '13d'},
|
|
||||||
77: {label: 'Snow Grains', icon: '13d'},
|
|
||||||
80: {label: 'Light Showers', icon: '09d'},
|
|
||||||
81: {label: 'Showers', icon: '09d'},
|
|
||||||
82: {label: 'Heavy Showers', icon: '09d'},
|
|
||||||
85: {label: 'Snow Showers', icon: '13d'},
|
|
||||||
86: {label: 'Heavy Snow Showers', icon: '13d'},
|
|
||||||
95: {label: 'Thunderstorm', icon: '11d'},
|
|
||||||
96: {label: 'Thunderstorm w/ Hail', icon: '11d'},
|
|
||||||
99: {label: 'Thunderstorm w/ Hail', icon: '11d'},
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function downloadIcons() {
|
|
||||||
if(!existsSync(DIR)) mkdirSync(DIR, {recursive: true});
|
|
||||||
const icons = new Set(Object.values(WMO).map(w => [w.icon, w.icon.replace('d', 'n')]).flat());
|
|
||||||
await Promise.all([...icons].map(async icon => {
|
|
||||||
const path = resolve(DIR, `${icon}.png`);
|
|
||||||
if(existsSync(path)) return;
|
|
||||||
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`);
|
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
|
||||||
writeFileSync(path, buf);
|
|
||||||
console.log(` 📥 Downloaded icon: ${icon}.png`);
|
|
||||||
}));
|
|
||||||
console.log('✅ Weather icons ready');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWeatherCondition(data) {
|
export async function fetchIcon(icon) {
|
||||||
// Determine primary condition
|
const iconPath = path.join(DIR, icon + '.png');
|
||||||
if(data.lightning_rate > 0 && data.raining === 'True') {
|
if(!existsSync(DIR) || !existsSync(iconPath))
|
||||||
return {label: 'Thunderstorm', code: 211, icon: '11d'};
|
await downloadIcon(icon, iconPath);
|
||||||
}
|
return iconPath;
|
||||||
|
}
|
||||||
if(data.raining === 'True') {
|
|
||||||
if(data.precipitation > 7.6) return {label: 'Heavy Rain', code: 502, icon: '10d'};
|
export function frostRisk(tempC, dewPointC, humidity) {
|
||||||
if(data.precipitation > 2.5) return {label: 'Moderate Rain', code: 501, icon: '10d'};
|
if (tempC > 4) return 'None'
|
||||||
return {label: 'Light Rain', code: 500, icon: '09d'};
|
if (tempC <= 0 && dewPointC <= 0) return 'High'
|
||||||
}
|
if (tempC <= 2 && humidity > 85) return 'Moderate'
|
||||||
|
if (tempC <= 4) return 'Low'
|
||||||
if(data.visibility < 10) {
|
return 'None'
|
||||||
return {label: 'Mist', code: 701, icon: '50d'};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayNight = data.daytime ? 'd' : 'n';
|
|
||||||
if(data.clouds > 85) return {label: 'Overcast Clouds', code: 804, icon: `04${dayNight}`};
|
|
||||||
if(data.clouds > 50) return {label: 'Broken Clouds', code: 803, icon: `04${dayNight}`};
|
|
||||||
if(data.clouds > 25) return {label: 'Scattered Clouds', code: 802, icon: `03${dayNight}`};
|
|
||||||
if(data.clouds > 10) return {label: 'Few Clouds', code: 801, icon: `02${dayNight}`};
|
|
||||||
|
|
||||||
return {label: 'Clear Sky', code: 800, icon: `01${dayNight}`};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {cfg} from './config.mjs';
|
||||||
|
import {frostRisk} from './openweather.mjs';
|
||||||
|
|
||||||
|
async function query(c, q, start, end) {
|
||||||
|
const url = new URL('/api/v1/query_range', c.DB_HOST);
|
||||||
|
url.searchParams.set('query', q);
|
||||||
|
url.searchParams.set('step', '60s');
|
||||||
|
if (start) url.searchParams.set('start', start);
|
||||||
|
if (end) url.searchParams.set('end', end);
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.result || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryInstant(c, q) {
|
||||||
|
const url = new URL('/api/v1/query', c.DB_HOST);
|
||||||
|
url.searchParams.set('query', q);
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.result || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricToFields(results) {
|
||||||
|
const out = {};
|
||||||
|
for (const r of results) {
|
||||||
|
if(r.metric.__name__.startsWith('system_')) continue;
|
||||||
|
const val = r.value?.[1] ?? r.values?.at(-1)?.[1];
|
||||||
|
if (val !== undefined) out[r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryCurrent() {
|
||||||
|
const c = cfg()
|
||||||
|
const results = await queryInstant(c, `{__name__!=""}`)
|
||||||
|
const fields = metricToFields(results)
|
||||||
|
const dp = fields.dew_point ?? null
|
||||||
|
return {
|
||||||
|
time: new Date(),
|
||||||
|
...fields,
|
||||||
|
frost_risk: frostRisk(fields.temperature ?? 99, dp ?? 99, fields.humidity ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryHourly(start, end) {
|
||||||
|
const c = cfg();
|
||||||
|
const s = Math.floor(start.getTime() / 1000);
|
||||||
|
const e = Math.floor(end.getTime() / 1000);
|
||||||
|
const byTime = {};
|
||||||
|
|
||||||
|
const results = await query(c, `avg_over_time({__name__!=""}[1h])`, s, e);
|
||||||
|
results.forEach(r => {
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
if(r.metric.__name__.startsWith('system_')) return;
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00';
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Object.values(byTime)
|
||||||
|
.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryDaily(start, end) {
|
||||||
|
const c = cfg();
|
||||||
|
const s = Math.floor(start.getTime() / 1000);
|
||||||
|
const e = Math.floor(end.getTime() / 1000);
|
||||||
|
const byTime = {};
|
||||||
|
|
||||||
|
const [means, mins, maxs] = await Promise.all([
|
||||||
|
query(c, `avg_over_time({__name__!=""}[1d])`, s, e),
|
||||||
|
query(c, `min_over_time({__name__!=""}[1d])`, s, e),
|
||||||
|
query(c, `max_over_time({__name__!=""}[1d])`, s, e),
|
||||||
|
]);
|
||||||
|
|
||||||
|
means.forEach(r => {
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
if(r.metric.__name__.startsWith('system_')) return;
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mins.forEach(r => {
|
||||||
|
if (r.metric.__name__ !== 'temperature') return;
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time].env_temp_min_c = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
maxs.forEach(r => {
|
||||||
|
if (r.metric.__name__ !== 'temperature') return;
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time].env_temp_max_c = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(byTime)
|
||||||
|
.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoords() {
|
||||||
|
const c = cfg();
|
||||||
|
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 fields = metricToFields(results);
|
||||||
|
|
||||||
|
if(fields.latitude && fields.longitude) {
|
||||||
|
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||||
|
}
|
||||||
|
return {latitude: 0, longitude: 0, altitude: 0};
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import {resolve, dirname} from 'path';
|
import {resolve, dirname} from 'path';
|
||||||
import {fileURLToPath} from 'url';
|
import {fileURLToPath} from 'url';
|
||||||
import {cfg, localDateStr} from './config.mjs';
|
import {cfg} from './config.mjs';
|
||||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
import {queryCurrent, queryHourly, queryDaily, getCoords} from './sensors.mjs';
|
||||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||||
import {getSpaceWeather} from './space.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, getShapes, initAircraftDb} from './adsb.mjs';
|
import {getIcaoImage, get, getHistory, getAttenuation, initAircraftDb} from './adsb.mjs';
|
||||||
import {getWeatherCondition} from './openweather.mjs';
|
import {fetchIcon} from './openweather.mjs';
|
||||||
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
|
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||||
// import {Aurora} from './aurora.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 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
console.error('[unhandledRejection]', promise, '\nReason:', reason)
|
console.error('[unhandledRejection]', promise, '\nReason:', reason)
|
||||||
})
|
})
|
||||||
@@ -30,7 +34,6 @@ const DIR = dirname(fileURLToPath(import.meta.url));
|
|||||||
const CLIENT_DIST = resolve(DIR, '../public');
|
const CLIENT_DIST = resolve(DIR, '../public');
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/icons', express.static(resolve(DIR, '../public', 'icons')));
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
next();
|
next();
|
||||||
@@ -48,9 +51,9 @@ function filterArr(arr, fields) {
|
|||||||
return arr.map(row => filterFields(row, fields));
|
return arr.map(row => filterFields(row, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Current ───────────────────────────────────────────────────────────────────
|
// ── Weather ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/current', async (req, res) => {
|
app.get('/api/current', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
|
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
|
||||||
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||||
@@ -58,118 +61,126 @@ app.get('/api/current', async (req, res) => {
|
|||||||
|
|
||||||
const forecast = await getForecast();
|
const forecast = await getForecast();
|
||||||
const merged = {
|
const merged = {
|
||||||
|
...condition,
|
||||||
|
...sensors,
|
||||||
...(forecast?.summary ? {
|
...(forecast?.summary ? {
|
||||||
precipitation_chance: forecast.summary.precipitation_chance,
|
precipitation_chance: forecast.summary.precipitation_chance,
|
||||||
temperature_max: forecast.summary.temperature_max,
|
temperature_max: forecast.summary.temperature_max,
|
||||||
temperature_min: forecast.summary.temperature_min,
|
temperature_min: forecast.summary.temperature_min,
|
||||||
uv_index_max: forecast.summary.uv_index,
|
uv_index_max: forecast.summary.uv_index,
|
||||||
} : {}),
|
} : {}),
|
||||||
...condition,
|
|
||||||
...sensors,
|
|
||||||
...space,
|
...space,
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(filterFields(merged, fields))
|
res.json(filterFields(merged, fields))
|
||||||
})
|
}));
|
||||||
|
|
||||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||||
|
|
||||||
app.get('/api/hourly', async (req, res) => {
|
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const now = new Date()
|
|
||||||
const plus24 = new Date(now.getTime() + 24 * 3600 * 1000)
|
|
||||||
|
|
||||||
const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0, 0, 0, 0))
|
|
||||||
const end = req.query.end ? new Date(req.query.end) : plus24
|
|
||||||
|
|
||||||
const coords = await getCoords()
|
const coords = await getCoords()
|
||||||
|
const now = new Date()
|
||||||
|
now.setMinutes(0, 0, 0)
|
||||||
|
const forecast24Limit = new Date(now.getTime() + 24 * 60 * 60_000)
|
||||||
|
const start = req.query.start ? new Date(req.query.start) : now
|
||||||
|
const end = req.query.end ? new Date(req.query.end) : forecast24Limit
|
||||||
|
|
||||||
const historyEnd = new Date(Math.min(now, end))
|
const [history, forecast24, meteo] = await Promise.all([
|
||||||
historyEnd.setMinutes(0, 0, 0)
|
start < now
|
||||||
|
? queryHourly(start, now)
|
||||||
const [historyResult] = await Promise.allSettled([
|
: Promise.resolve([]),
|
||||||
start < now ? queryHourly(start, historyEnd) : 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 history = historyResult.status === 'fulfilled' ? historyResult.value : []
|
|
||||||
|
|
||||||
const physics = end > now
|
const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo])
|
||||||
? lastForecast.slots.filter(s => { const t = new Date(s.time); return t >= now && t <= new Date(Math.min(end, plus24)) })
|
res.json(filterArr(combined, fields))
|
||||||
: []
|
}))
|
||||||
|
|
||||||
let meteo = []
|
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||||
if (end > plus24) {
|
|
||||||
const [meteoResult] = await Promise.allSettled([hourlyWeather(coords.latitude, coords.longitude, plus24, end)])
|
|
||||||
meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...physics, ...meteo])
|
|
||||||
res.json(filterArr(hourly, fields))
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.get('/api/daily', async (req, res) => {
|
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
const now = new Date()
|
const coords = await getCoords()
|
||||||
|
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 next = new Date(todayStart)
|
const todayEnd = new Date(todayStart)
|
||||||
next.setDate(todayStart.getDate() + 1)
|
todayEnd.setDate(todayStart.getDate() + 1)
|
||||||
|
|
||||||
const start = req.query.start ? new Date(req.query.start) : todayStart
|
const start = req.query.start ? new Date(req.query.start) : todayStart
|
||||||
const end = req.query.end ? new Date(req.query.end) : next
|
const end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
|
||||||
|
|
||||||
const coords = await getCoords()
|
const [history, current, future] = await Promise.all([
|
||||||
const todayStr = localDateStr(now)
|
start < todayStart
|
||||||
const usePhysics = lastForecast.summary && start <= now && end > now
|
? queryDaily(start, todayStart)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
start <= todayStart && end >= todayStart
|
||||||
|
? queryCurrent().then(resp => [resp])
|
||||||
|
: Promise.resolve([]),
|
||||||
|
end > todayEnd
|
||||||
|
? dailyWeather(coords.latitude, coords.longitude, todayEnd, end)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
const [sensorResult] = await Promise.allSettled([start < now ? queryDaily(start, now) : Promise.resolve([])])
|
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...current, ...future])
|
||||||
const history = sensorResult.status === 'fulfilled' ? sensorResult.value : []
|
|
||||||
|
|
||||||
const meteoFrom = new Date(Math.max(next.getTime(), start.getTime()))
|
|
||||||
const [meteoResult] = await Promise.allSettled([end > next ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
|
|
||||||
const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
|
|
||||||
|
|
||||||
const todaySlot = usePhysics ? [lastForecast.summary] : []
|
|
||||||
const meteoClean = meteo.filter(r => localDateStr(new Date(r.time)) !== todayStr)
|
|
||||||
|
|
||||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean])
|
|
||||||
res.json(filterArr(daily, fields))
|
res.json(filterArr(daily, fields))
|
||||||
})
|
}))
|
||||||
|
|
||||||
// ── Position ──────────────────────────────────────────────────────────────────
|
// ── Position ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/position', async (req, res) => {
|
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getAttenuation())));
|
||||||
|
app.get('/api/position', asyncHandler(async (req, res) => {
|
||||||
const {fields} = req.query;
|
const {fields} = req.query;
|
||||||
const data = await getCoords();
|
const data = await getCoords();
|
||||||
res.json(filterFields(data, fields));
|
res.json(filterFields(data, fields));
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
// ── Icon ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
||||||
|
const { icon } = req.params;
|
||||||
|
if(!icon) return next(); // 404 fallback can handle missing icons
|
||||||
|
res.contentType('image/png');
|
||||||
|
res.sendFile(await fetchIcon(icon));
|
||||||
|
}));
|
||||||
|
|
||||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
// ── 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', async (req, res) => res.json(await getADSB()));
|
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await get())));
|
||||||
app.get('/api/adsb/:icao', async (req, res) => res.json(await getADSBHistory(req.params.icao)));
|
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
|
||||||
app.get('/api/ais', async (req, res) => res.json(await getAIS()));
|
const buffer = await getIcaoImage(req.params.icao);
|
||||||
app.get('/api/range', async (req, res) => res.json(await getADSBRange()));
|
res.contentType('image/jpeg').send(buffer)
|
||||||
app.get('/api/vehicles', async (req, res) => res.json(await getShapes()));
|
}));
|
||||||
|
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getHistory(req.params.icao))));
|
||||||
|
|
||||||
|
app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS())));
|
||||||
|
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 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
|
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
|
||||||
app.get('/openapi.json', (req, res) => res.json(spec));
|
app.get('/openapi.json',asyncHandler(async (req, res) => res.json(spec)));
|
||||||
app.get('/openapi.yaml', async (req, res) => {
|
app.get('/openapi.yaml',asyncHandler(async (req, res) => {
|
||||||
const {stringify} = await import('yaml');
|
const {stringify} = await import('yaml');
|
||||||
res.setHeader('Content-Type', 'text/yaml');
|
res.setHeader('Content-Type', 'text/yaml');
|
||||||
res.send(stringify(spec));
|
res.send(stringify(spec));
|
||||||
});
|
}));
|
||||||
|
|
||||||
// ── Website ───────────────────────────────────────────────────────────────────
|
// ── Website ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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)' },
|
||||||
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||||
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||||
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||||
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' },
|
||||||
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'] },
|
// Light & Solar
|
||||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
|
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||||
// Light
|
uv_index: { type: 'number', description: 'UV index' },
|
||||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||||
uv_index: { type: 'number', description: 'UV index' },
|
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||||
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||||
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
daily_light_integral: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||||
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
|
clouds: { type: 'number', description: 'Cloud cover fraction (0–1)' },
|
||||||
clouds: { type: 'number', description: 'Estimated cloud cover (%)' },
|
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||||
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] },
|
daylight: { type: 'number', description: 'Hours of daylight' },
|
||||||
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
// Weather
|
||||||
daylight: { type: 'number', description: 'Hours above daylight threshold today' },
|
label: { type: 'string', description: 'Human readable weather label' },
|
||||||
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
icon: { type: 'string', description: 'OWM icon code e.g. 01d' },
|
||||||
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
raining: { type: 'string', enum: ['True','False'] },
|
||||||
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
|
precipitation: { type: 'number', description: 'Precipitation (mm)' },
|
||||||
// Wind (Open-Meteo)
|
precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' },
|
||||||
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
|
accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' },
|
||||||
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
|
storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' },
|
||||||
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' },
|
// Wind
|
||||||
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' },
|
wind_speed: { type: 'number', description: 'Wind speed (km/h)' },
|
||||||
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' },
|
wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||||
// Forecast
|
wind_direction: { type: 'number', description: 'Wind direction (°)' },
|
||||||
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
|
|
||||||
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
|
|
||||||
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. /icons/01d.png' },
|
|
||||||
forecast_precipitation_mm: { type: 'number', description: 'Precipitation (mm)' },
|
|
||||||
forecast_precipitation_probability: { type: 'number', description: 'Precipitation probability (%)' },
|
|
||||||
forecast_snowfall_mm: { type: 'number', description: 'Snowfall (mm)' },
|
|
||||||
forecast_snow_depth_m: { type: 'number', description: 'Snow depth (m)' },
|
|
||||||
forecast_cape: { type: 'number', description: 'Convective available potential energy (J/kg)' },
|
|
||||||
forecast_freezing_level_m: { type: 'number', description: 'Freezing level altitude (m)' },
|
|
||||||
forecast_evapotranspiration_mm:{ type: 'number', description: 'Evapotranspiration (mm)' },
|
|
||||||
// Seismic
|
|
||||||
seismic_ax: { type: 'number', description: 'Peak acceleration X axis (g)' },
|
|
||||||
seismic_ay: { type: 'number', description: 'Peak acceleration Y axis (g)' },
|
|
||||||
seismic_az: { type: 'number', description: 'Peak acceleration Z axis (g, includes gravity)' },
|
|
||||||
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g, gravity removed)' },
|
|
||||||
// Compass
|
|
||||||
compass_heading: { type: 'number', description: 'Magnetic heading (°)' },
|
|
||||||
compass_x: { type: 'number' },
|
|
||||||
compass_y: { type: 'number' },
|
|
||||||
compass_z: { type: 'number' },
|
|
||||||
// Ground / Accumulation
|
|
||||||
ground_distance: { 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_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||||
lightning_energy: { type: 'number', description: 'Lightning energy' },
|
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||||
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
|
||||||
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
|
|
||||||
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
|
|
||||||
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (0–7)' },
|
|
||||||
// GPS
|
|
||||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
|
||||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
|
||||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
|
||||||
gps_satellites: { type: 'number', description: 'Satellites in view' },
|
|
||||||
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
|
|
||||||
gps_heading: { type: 'number', description: 'GPS heading (°)' },
|
|
||||||
// Sun
|
// Sun
|
||||||
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||||
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||||
sun_sunrise: { type: 'string', description: 'Sunrise time (ISO8601)' },
|
sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' },
|
||||||
sun_sunset: { type: 'string', description: 'Sunset time (ISO8601)' },
|
sunset: { type: 'string', format: 'date-time', description: 'Sunset time' },
|
||||||
sun_solar_noon: { type: 'string', description: 'Solar noon (ISO8601)' },
|
daytime: { type: 'boolean', description: 'True if between sunrise and sunset' },
|
||||||
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
|
||||||
moon_phase: { type: 'string', description: 'Moon phase name' },
|
moon_phase: { type: 'string', description: 'Moon phase name' },
|
||||||
moon_illumination_pct: { type: 'number', description: 'Moon illumination (%)' },
|
moon_illumination: { type: 'number', description: 'Moon illumination (%)' },
|
||||||
moon_moonrise: { type: 'string', description: 'Moonrise time (ISO8601)' },
|
moon_elevation: { type: 'number', description: 'Moon elevation (°)' },
|
||||||
moon_moonset: { type: 'string', description: 'Moonset time (ISO8601)' },
|
moon_azimuth: { type: 'number', description: 'Moon azimuth (°)' },
|
||||||
moon_next_full: { type: 'string', description: 'Next full moon (ISO8601)' },
|
moonrise: { type: 'string', format: 'date-time', description: 'Moonrise time' },
|
||||||
moon_next_new: { type: 'string', description: 'Next new moon (ISO8601)' },
|
moonset: { type: 'string', format: 'date-time', description: 'Moonset time' },
|
||||||
// Season
|
moon_new: { type: 'string', format: 'date-time', description: 'Next new moon' },
|
||||||
season_next_event: { type: 'string', description: 'Next solstice/equinox name' },
|
moon_full: { type: 'string', format: 'date-time', description: 'Next full moon' },
|
||||||
season_next_event_date: { type: 'string' },
|
// Seismic
|
||||||
season_last_event: { type: 'string' },
|
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g)' },
|
||||||
season_last_event_date: { type: 'string' },
|
// Ground
|
||||||
// Space weather
|
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||||
space_kp_index: { type: 'number', description: 'Planetary K index (0–9)' },
|
// GPS
|
||||||
space_ap_index: { type: 'number', description: 'Ap geomagnetic index' },
|
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||||
space_geomagnetic_storm: { type: 'string', enum: ['None','Minor','Moderate','Strong','Severe'] },
|
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||||
space_solar_wind_speed_kms: { type: 'number', description: 'Solar wind speed (km/s)' },
|
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||||
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³)' },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,46 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
USER_NAME="$(whoami)"
|
USER_NAME="$(whoami)"
|
||||||
|
VM_DIR="/opt/victoriametrics"
|
||||||
|
|
||||||
echo "🔧 Setting up Weather Station..."
|
echo "🔧 Setting up Weather Station..."
|
||||||
|
|
||||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v influxd &> /dev/null; then
|
if ! systemctl is-enabled --quiet victoriametrics 2>/dev/null; then
|
||||||
echo "📦 Installing InfluxDB..."
|
echo "📦 Installing VictoriaMetrics..."
|
||||||
curl https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null
|
sudo mkdir -p $VM_DIR/data
|
||||||
echo "deb [signed-by=/usr/share/keyrings/influxdata-archive-keyring.gpg] https://repos.influxdata.com/debian stable main" | sudo tee /etc/apt/sources.list.d/influxdata.list
|
cd $VM_DIR
|
||||||
sudo apt update && sudo apt install -y influxdb2
|
wget -qO vm.tar.gz "https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/v1.146.0/victoria-metrics-linux-arm64-v1.146.0.tar.gz"
|
||||||
sudo systemctl enable --now influxdb
|
sudo tar -xzf vm.tar.gz
|
||||||
|
sudo chmod +x $VM_DIR/victoria-metrics-prod
|
||||||
|
rm /tmp/vm.tar.gz
|
||||||
|
|
||||||
|
sudo tee /etc/systemd/system/victoriametrics.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=VictoriaMetrics
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=$VM_DIR/victoria-metrics-prod \
|
||||||
|
-storageDataPath=$VM_DIR/data \
|
||||||
|
-httpListenAddr=:8428 \
|
||||||
|
-retentionPeriod=12
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now victoriametrics
|
||||||
else
|
else
|
||||||
echo "✅ InfluxDB already installed, skipping..."
|
echo "✅ VictoriaMetrics already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
# ── Server / Node.js ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
echo "📦 Installing Node.js..."
|
echo "📦 Installing Node.js..."
|
||||||
@@ -28,7 +52,47 @@ else
|
|||||||
echo "✅ Node.js already installed, skipping..."
|
echo "✅ Node.js already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Python venv ───────────────────────────────────────────────────────────────
|
if [ ! -d "$SCRIPT_DIR/server/public/index.html" ]; then
|
||||||
|
echo "🌐 Building Client..."
|
||||||
|
cd "$SCRIPT_DIR/client"
|
||||||
|
npm ci
|
||||||
|
npx vite build
|
||||||
|
else
|
||||||
|
echo "✅ Client installed, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$SCRIPT_DIR/server/node_modules" ]; then
|
||||||
|
echo "🌐 Installing Server..."
|
||||||
|
cd "$SCRIPT_DIR/server"
|
||||||
|
npm ci
|
||||||
|
else
|
||||||
|
echo "✅ Server installed, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! systemctl is-enabled --quiet weather-station 2>/dev/null; then
|
||||||
|
echo "⚙️ Installing server service..."
|
||||||
|
sudo tee /etc/systemd/system/weather-station.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Weather Station API
|
||||||
|
After=network.target victoriametrics.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=$SCRIPT_DIR/server
|
||||||
|
ExecStart=npm run start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=$USER_NAME
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now weather-sensors
|
||||||
|
else
|
||||||
|
echo "✅ Server service already installed, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Sensors / Python venv ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
||||||
echo "🐍 Setting up Python environment..."
|
echo "🐍 Setting up Python environment..."
|
||||||
@@ -40,14 +104,12 @@ else
|
|||||||
echo "✅ Python environment already set up, skipping..."
|
echo "✅ Python environment already set up, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Systemd service ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
||||||
echo "⚙️ Installing systemd service..."
|
echo "⚙️ Installing sensor service..."
|
||||||
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Weather Station Sensors
|
Description=Weather Station Sensors
|
||||||
After=network.target
|
After=network.target victoriametrics.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=$SCRIPT_DIR/sensors
|
WorkingDirectory=$SCRIPT_DIR/sensors
|
||||||
@@ -62,10 +124,9 @@ EOF
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now weather-sensors
|
sudo systemctl enable --now weather-sensors
|
||||||
else
|
else
|
||||||
echo "✅ Systemd service already installed, skipping..."
|
echo "✅ Sensor service already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ All done!"
|
echo "✅ All done!"
|
||||||
echo " InfluxDB UI: http://localhost:8086"
|
echo " VictoriaMetrics UI: http://localhost:8428"
|
||||||
echo " Sensor logs: journalctl -fu weather-sensors"
|
echo " Sensor logs: journalctl -fu weather-sensors"
|
||||||
|
|
||||||
|
|||||||