Compare commits
92 Commits
14d9a43c32
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed3a43a53 | |||
| f3ca75decb | |||
| 88e6cc26f5 | |||
| f5cfd492f3 | |||
| 1494b43ed5 | |||
| 8f5e8edd2e | |||
| 54e86e550c | |||
| 09a08d94aa | |||
| 11012afbe9 | |||
| d4cdb15737 | |||
| b62f7da629 | |||
| 43848c4b35 | |||
| a1c714847d | |||
| 1038b06a46 | |||
| d5697c4e20 | |||
| 7e8ea62f64 | |||
| be40d845bd | |||
| 54c53a5822 | |||
| 0a722edcc6 | |||
| 3e44dbb8eb | |||
| fec6ee8a48 | |||
| 1451145408 | |||
| 4114a0adad | |||
| 7edd0ab8c4 | |||
| 5bd2e786c2 | |||
| bbcc62af7d | |||
| 91205d8050 | |||
| ce86fdfcc8 | |||
| 3838a73e75 | |||
| 76bd8b30c7 | |||
| 30c866b599 | |||
| 9471e904ce | |||
| 8740a1cd69 | |||
| 773d89c378 | |||
| d398cd3a04 | |||
| 3d9a8f82ce | |||
| 96a86c4d12 | |||
| 835856e74c | |||
| 34c52ffb9f | |||
| 946ccd67e1 | |||
| 2ccacf563c | |||
| 3750ece535 | |||
| 93dce28cca | |||
| 73f7908aab | |||
| effdd81114 | |||
| 6bc4d920f8 | |||
| d9fbe0568d | |||
| 404f3971c4 | |||
| 570ed1e749 | |||
| 8364918a0c | |||
| 85909e550b | |||
| 221a200e79 | |||
| bb11b92ec9 | |||
| 579eed746e | |||
| a4f13d87ab | |||
| 85b1d66c02 | |||
| d34f3cfa0e | |||
| f3356ff05c | |||
| c18a4189af | |||
| c5477d05ad | |||
| bea55ac448 | |||
| 765335369c | |||
| 3944daa3ed | |||
| e767a08bb5 | |||
| e4c3da05be | |||
| 44e648ecef | |||
| e4042f06d6 | |||
| 943a6ee085 | |||
| 225002046b | |||
| 9e7afbd98c | |||
| 62d2c28039 | |||
| 74c5e48d8a | |||
| ce332be419 | |||
| 36eda62e7f | |||
| 6eefd1b86b | |||
| 0c0ebfd20b | |||
| 74ddb37446 | |||
| 73f1453daf | |||
| aeffa30417 | |||
| 74a8018f01 | |||
| efd08cbabe | |||
| 738f13e745 | |||
| 216420ad80 | |||
| 5947b61b8f | |||
| 06be7c0b4f | |||
| 70701483f5 | |||
| 1ee386949c | |||
| 9b7b7f49a7 | |||
| 80b9b1bfcc | |||
| b87bcbabb7 | |||
| f6f8a252dc | |||
| a73c5ff95b |
2
.env
@@ -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>
|
||||||
|
|||||||
101
client/package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
370
client/public/dark-theme.json
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
{
|
||||||
|
"version": 8,
|
||||||
|
"name": "Smooth Dark",
|
||||||
|
"sources": {
|
||||||
|
"osm": {
|
||||||
|
"type": "vector",
|
||||||
|
"tiles": ["https://tiles.zakscode.com/data/osm/{z}/{x}/{y}.pbf"],
|
||||||
|
"maxzoom": 14
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"glyphs": "http://localhost:8080/fonts/{fontstack}/{range}.pbf",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"id": "background",
|
||||||
|
"type": "background",
|
||||||
|
"paint": { "background-color": "#2b2b2e" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "water",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "water",
|
||||||
|
"paint": { "fill-color": "#1e2832" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landcover-grass",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landcover",
|
||||||
|
"filter": ["==", "class", "grass"],
|
||||||
|
"paint": { "fill-color": "#2a3028", "fill-opacity": 0.6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landcover-wood",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landcover",
|
||||||
|
"filter": ["==", "class", "wood"],
|
||||||
|
"paint": { "fill-color": "#262e24", "fill-opacity": 0.5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-park",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "park"],
|
||||||
|
"paint": { "fill-color": "#283028", "fill-opacity": 0.7 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-cemetery",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "cemetery"],
|
||||||
|
"paint": { "fill-color": "#2c2e2a", "fill-opacity": 0.7 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-hospital",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "hospital"],
|
||||||
|
"paint": { "fill-color": "#322828", "fill-opacity": 0.6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-school",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "school"],
|
||||||
|
"paint": { "fill-color": "#302c28", "fill-opacity": 0.6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "building",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "building",
|
||||||
|
"minzoom": 13,
|
||||||
|
"paint": {
|
||||||
|
"fill-color": "#333336",
|
||||||
|
"fill-opacity": { "stops": [[13, 0.3], [16, 0.6]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "boundary-country",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "boundary",
|
||||||
|
"filter": ["==", "admin_level", 2],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#4a4448",
|
||||||
|
"line-width": { "stops": [[2, 0.5], [6, 1.5], [10, 2.5]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "boundary-state",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "boundary",
|
||||||
|
"filter": ["==", "admin_level", 4],
|
||||||
|
"minzoom": 4,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#3e3a3c",
|
||||||
|
"line-width": { "stops": [[4, 0.3], [8, 1]] },
|
||||||
|
"line-dasharray": [3, 2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "waterway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "waterway",
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#1e2832",
|
||||||
|
"line-width": { "stops": [[8, 0.5], [14, 2]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tunnel",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#2e2e30",
|
||||||
|
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] },
|
||||||
|
"line-dasharray": [3, 3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-motorway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "motorway"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#4a4240",
|
||||||
|
"line-width": { "stops": [[5, 0.5], [10, 2], [14, 5], [18, 12]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-trunk",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "trunk"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#443e3c",
|
||||||
|
"line-width": { "stops": [[6, 0.4], [10, 1.5], [14, 4], [18, 10]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-primary",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "primary"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#3e3a38",
|
||||||
|
"line-width": { "stops": [[7, 0.3], [10, 1], [14, 3.5], [18, 9]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-secondary",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "secondary"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#383636",
|
||||||
|
"line-width": { "stops": [[8, 0.3], [12, 1], [14, 3], [18, 7]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-tertiary",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "tertiary"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#353334",
|
||||||
|
"line-width": { "stops": [[9, 0.2], [14, 2], [18, 5]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-minor",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["in", "class", "minor", "service"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"minzoom": 12,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#333234",
|
||||||
|
"line-width": { "stops": [[12, 0.3], [16, 2], [18, 4]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-path",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["==", "class", "path"],
|
||||||
|
"minzoom": 14,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#3a3638",
|
||||||
|
"line-width": 1,
|
||||||
|
"line-dasharray": [4, 2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rail",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["==", "class", "rail"],
|
||||||
|
"minzoom": 10,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#3c3a3e",
|
||||||
|
"line-width": { "stops": [[10, 0.5], [14, 1.5]] },
|
||||||
|
"line-dasharray": [5, 5]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bridge",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#403c3a",
|
||||||
|
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aero-runway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "aeroway",
|
||||||
|
"filter": ["==", "class", "runway"],
|
||||||
|
"minzoom": 11,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#3a383c",
|
||||||
|
"line-width": { "stops": [[11, 2], [16, 12]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aero-taxiway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "aeroway",
|
||||||
|
"filter": ["==", "class", "taxiway"],
|
||||||
|
"minzoom": 12,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#3a383c",
|
||||||
|
"line-width": { "stops": [[12, 0.5], [16, 4]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-water",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "water_name",
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[3, 10], [8, 14]] },
|
||||||
|
"text-letter-spacing": 0.1
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#4a6068",
|
||||||
|
"text-halo-color": "#2b2b2e",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-road",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation_name",
|
||||||
|
"minzoom": 13,
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[13, 10], [16, 13]] },
|
||||||
|
"symbol-placement": "line",
|
||||||
|
"text-rotation-alignment": "map"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#6a6268",
|
||||||
|
"text-halo-color": "#2b2b2e",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-place-city",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "city"],
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[4, 11], [8, 16], [12, 20]] },
|
||||||
|
"text-anchor": "center"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#a0969a",
|
||||||
|
"text-halo-color": "#2b2b2e",
|
||||||
|
"text-halo-width": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-place-town",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "town"],
|
||||||
|
"minzoom": 7,
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[7, 10], [12, 15]] },
|
||||||
|
"text-anchor": "center"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#8a8088",
|
||||||
|
"text-halo-color": "#2b2b2e",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-place-village",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "village"],
|
||||||
|
"minzoom": 10,
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[10, 9], [14, 13]] },
|
||||||
|
"text-anchor": "center"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#787078",
|
||||||
|
"text-halo-color": "#2b2b2e",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-country",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "country"],
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[2, 10], [5, 16]] },
|
||||||
|
"text-transform": "uppercase",
|
||||||
|
"text-letter-spacing": 0.15
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#908690",
|
||||||
|
"text-halo-color": "#2b2b2e",
|
||||||
|
"text-halo-width": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
BIN
client/public/favicon.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
370
client/public/light-theme.json
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
{
|
||||||
|
"version": 8,
|
||||||
|
"name": "Smooth Light",
|
||||||
|
"sources": {
|
||||||
|
"osm": {
|
||||||
|
"type": "vector",
|
||||||
|
"tiles": ["https://tiles.zakscode.com/data/osm/{z}/{x}/{y}.pbf"],
|
||||||
|
"maxzoom": 14
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"glyphs": "http://localhost:8080/fonts/{fontstack}/{range}.pbf",
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"id": "background",
|
||||||
|
"type": "background",
|
||||||
|
"paint": { "background-color": "#f2efe9" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "water",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "water",
|
||||||
|
"paint": { "fill-color": "#c8d7d5" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landcover-grass",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landcover",
|
||||||
|
"filter": ["==", "class", "grass"],
|
||||||
|
"paint": { "fill-color": "#e0e8d8", "fill-opacity": 0.6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landcover-wood",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landcover",
|
||||||
|
"filter": ["==", "class", "wood"],
|
||||||
|
"paint": { "fill-color": "#d4dfc4", "fill-opacity": 0.5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-park",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "park"],
|
||||||
|
"paint": { "fill-color": "#dce8d0", "fill-opacity": 0.7 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-cemetery",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "cemetery"],
|
||||||
|
"paint": { "fill-color": "#e0e4dd", "fill-opacity": 0.7 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-hospital",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "hospital"],
|
||||||
|
"paint": { "fill-color": "#f0e4e4", "fill-opacity": 0.6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "landuse-school",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "landuse",
|
||||||
|
"filter": ["==", "class", "school"],
|
||||||
|
"paint": { "fill-color": "#eae0d8", "fill-opacity": 0.6 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "building",
|
||||||
|
"type": "fill",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "building",
|
||||||
|
"minzoom": 13,
|
||||||
|
"paint": {
|
||||||
|
"fill-color": "#dfdbd7",
|
||||||
|
"fill-opacity": { "stops": [[13, 0.3], [16, 0.6]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "boundary-country",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "boundary",
|
||||||
|
"filter": ["==", "admin_level", 2],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#c4b8aa",
|
||||||
|
"line-width": { "stops": [[2, 0.5], [6, 1.5], [10, 2.5]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "boundary-state",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "boundary",
|
||||||
|
"filter": ["==", "admin_level", 4],
|
||||||
|
"minzoom": 4,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#d0c8bc",
|
||||||
|
"line-width": { "stops": [[4, 0.3], [8, 1]] },
|
||||||
|
"line-dasharray": [3, 2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "waterway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "waterway",
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#c8d7d5",
|
||||||
|
"line-width": { "stops": [[8, 0.5], [14, 2]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tunnel",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#e8e4e0",
|
||||||
|
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] },
|
||||||
|
"line-dasharray": [3, 3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-motorway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "motorway"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#e0d0b8",
|
||||||
|
"line-width": { "stops": [[5, 0.5], [10, 2], [14, 5], [18, 12]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-trunk",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "trunk"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#e4d8c4",
|
||||||
|
"line-width": { "stops": [[6, 0.4], [10, 1.5], [14, 4], [18, 10]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-primary",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "primary"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#e8dfd4",
|
||||||
|
"line-width": { "stops": [[7, 0.3], [10, 1], [14, 3.5], [18, 9]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-secondary",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "secondary"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#ece6dc",
|
||||||
|
"line-width": { "stops": [[8, 0.3], [12, 1], [14, 3], [18, 7]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-tertiary",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "class", "tertiary"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#f0ebe4",
|
||||||
|
"line-width": { "stops": [[9, 0.2], [14, 2], [18, 5]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-minor",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["in", "class", "minor", "service"], ["!=", "brunnel", "tunnel"]],
|
||||||
|
"minzoom": 12,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#f0ebe4",
|
||||||
|
"line-width": { "stops": [[12, 0.3], [16, 2], [18, 4]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "road-path",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["==", "class", "path"],
|
||||||
|
"minzoom": 14,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#d8d0c4",
|
||||||
|
"line-width": 1,
|
||||||
|
"line-dasharray": [4, 2]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rail",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["==", "class", "rail"],
|
||||||
|
"minzoom": 10,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#d0ccc4",
|
||||||
|
"line-width": { "stops": [[10, 0.5], [14, 1.5]] },
|
||||||
|
"line-dasharray": [5, 5]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bridge",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation",
|
||||||
|
"filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#e0d8cc",
|
||||||
|
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aero-runway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "aeroway",
|
||||||
|
"filter": ["==", "class", "runway"],
|
||||||
|
"minzoom": 11,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#d8d4cc",
|
||||||
|
"line-width": { "stops": [[11, 2], [16, 12]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aero-taxiway",
|
||||||
|
"type": "line",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "aeroway",
|
||||||
|
"filter": ["==", "class", "taxiway"],
|
||||||
|
"minzoom": 12,
|
||||||
|
"paint": {
|
||||||
|
"line-color": "#d8d4cc",
|
||||||
|
"line-width": { "stops": [[12, 0.5], [16, 4]] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-water",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "water_name",
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[3, 10], [8, 14]] },
|
||||||
|
"text-letter-spacing": 0.1
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#8aacaa",
|
||||||
|
"text-halo-color": "#f2efe9",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-road",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "transportation_name",
|
||||||
|
"minzoom": 13,
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[13, 10], [16, 13]] },
|
||||||
|
"symbol-placement": "line",
|
||||||
|
"text-rotation-alignment": "map"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#9c9284",
|
||||||
|
"text-halo-color": "#f2efe9",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-place-city",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "city"],
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[4, 11], [8, 16], [12, 20]] },
|
||||||
|
"text-anchor": "center"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#6b6156",
|
||||||
|
"text-halo-color": "#f2efe9",
|
||||||
|
"text-halo-width": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-place-town",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "town"],
|
||||||
|
"minzoom": 7,
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[7, 10], [12, 15]] },
|
||||||
|
"text-anchor": "center"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#7a7068",
|
||||||
|
"text-halo-color": "#f2efe9",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-place-village",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "village"],
|
||||||
|
"minzoom": 10,
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[10, 9], [14, 13]] },
|
||||||
|
"text-anchor": "center"
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#8a8078",
|
||||||
|
"text-halo-color": "#f2efe9",
|
||||||
|
"text-halo-width": 1.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "label-country",
|
||||||
|
"type": "symbol",
|
||||||
|
"source": "osm",
|
||||||
|
"source-layer": "place",
|
||||||
|
"filter": ["==", "class", "country"],
|
||||||
|
"layout": {
|
||||||
|
"text-field": "{name}",
|
||||||
|
"text-font": ["Noto Sans Regular"],
|
||||||
|
"text-size": { "stops": [[2, 10], [5, 16]] },
|
||||||
|
"text-transform": "uppercase",
|
||||||
|
"text-letter-spacing": 0.15
|
||||||
|
},
|
||||||
|
"paint": {
|
||||||
|
"text-color": "#8a7e72",
|
||||||
|
"text-halo-color": "#f2efe9",
|
||||||
|
"text-halo-width": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
client/public/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Weather Station",
|
||||||
|
"short_name": "Weather",
|
||||||
|
"description": "Local Weather & Forecasting",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f172a",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/favicon.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
],
|
||||||
|
"categories": ["weather", "utilities"]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
363
client/src/components/Aircraft.vue
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const BASE_SPEED_UNITS = {
|
||||||
|
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||||
|
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
||||||
|
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
||||||
|
}
|
||||||
|
const BASE_ALTITUDE_UNITS = {
|
||||||
|
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
||||||
|
feet: { label: 'FT', convert: (v: number) => v },
|
||||||
|
}
|
||||||
|
const BASE_VERTICAL_UNITS = {
|
||||||
|
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
||||||
|
fps: { label: 'FT/S', convert: (v: number) => v },
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoError = ref(false);
|
||||||
|
const UNIT_KEY = 'at_units'
|
||||||
|
function loadUnits() {
|
||||||
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
|
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||||
|
const prefs = ref(loadUnits())
|
||||||
|
function saveAndSet(p: any) {
|
||||||
|
prefs.value = { ...p }
|
||||||
|
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||||
|
}
|
||||||
|
function cycleSpeed() { const keys = Object.keys(BASE_SPEED_UNITS); const p = loadUnits(); p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]; saveAndSet(p) }
|
||||||
|
function cycleAltitude() { const keys = Object.keys(BASE_ALTITUDE_UNITS); const p = loadUnits(); p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]; p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'; saveAndSet(p) }
|
||||||
|
function cycleVertical() { const keys = Object.keys(BASE_VERTICAL_UNITS); const p = loadUnits(); p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]; p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'; saveAndSet(p) }
|
||||||
|
|
||||||
|
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
|
||||||
|
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
|
||||||
|
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
|
||||||
|
|
||||||
|
// ── Plane data ────────────────────────────────────────────────────────────────
|
||||||
|
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
|
||||||
|
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
|
||||||
|
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
|
||||||
|
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
|
||||||
|
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
||||||
|
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
|
||||||
|
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || '')
|
||||||
|
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pos = ref({ ...props.position })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
function onMouseUp() { isDragging.value = false }
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX, sy = touch.clientY
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||||
|
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Navball ───────────────────────────────────────────────────────────────────
|
||||||
|
const navball = computed(() => {
|
||||||
|
const data = props.plane
|
||||||
|
const heading = data.heading ?? data.track ?? 0
|
||||||
|
const airspeedKnots = data.speed || data.gs || 0
|
||||||
|
const vertRateFps = data.climb || data.baro_rate || 0
|
||||||
|
const groundSpeedFps = airspeedKnots * 1.68781
|
||||||
|
const pitchRad = groundSpeedFps > 0 ? Math.atan(vertRateFps / groundSpeedFps) : 0
|
||||||
|
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
||||||
|
|
||||||
|
const pitchLines: string[] = []
|
||||||
|
for (let p = -30; p <= 30; p += 5) {
|
||||||
|
if (p === 0) continue
|
||||||
|
const isMajor = p % 10 === 0
|
||||||
|
const [start, end] = [85, 115]
|
||||||
|
if (isMajor) {
|
||||||
|
pitchLines.push(`
|
||||||
|
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||||
|
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||||
|
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||||
|
`)
|
||||||
|
} else {
|
||||||
|
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollTicks: string[] = []
|
||||||
|
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
|
||||||
|
const rad = angle * Math.PI / 180
|
||||||
|
const a = Math.abs(angle)
|
||||||
|
if (a === 45) {
|
||||||
|
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
|
||||||
|
} else {
|
||||||
|
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
|
||||||
|
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
|
||||||
|
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
|
||||||
|
const rad = offset * Math.PI / 180
|
||||||
|
const x = 100 + 70 * Math.sin(rad)
|
||||||
|
const y = 100 - 70 * Math.cos(rad)
|
||||||
|
const h = Math.round((offset + 360) % 360)
|
||||||
|
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
|
||||||
|
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="200" height="220" viewBox="0 0 200 220">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
|
||||||
|
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1e90ff"/>
|
||||||
|
<stop offset="100%" style="stop-color:#4169e1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#8b4513"/>
|
||||||
|
<stop offset="100%" style="stop-color:#654321"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
||||||
|
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
||||||
|
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
||||||
|
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
||||||
|
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
||||||
|
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
||||||
|
${pitchLines.join('')}
|
||||||
|
</g>
|
||||||
|
<g transform="translate(0,25)">${rollTicks.join('')}</g>
|
||||||
|
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
|
||||||
|
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||||
|
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||||
|
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
|
||||||
|
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
|
||||||
|
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
|
<!-- Header (drag handle) -->
|
||||||
|
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
|
<h3 class="ap-callsign">
|
||||||
|
✈️
|
||||||
|
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||||
|
<span v-else>N/A</span>
|
||||||
|
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||||
|
</h3>
|
||||||
|
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="ap-meta flex-r justify-between">
|
||||||
|
<div class="flex-c">
|
||||||
|
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||||
|
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c align-x-end">
|
||||||
|
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||||
|
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo -->
|
||||||
|
<div v-if="!photoError" class="ap-photo-wrap">
|
||||||
|
<img class="ap-photo" :src="BASE + '/api/adsb-image/' + plane.icao" :alt="callsign" @error="photoError = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="ap-body">
|
||||||
|
<div class="ap-gauges">
|
||||||
|
<div class="ap-gauge-wrap">
|
||||||
|
<div class="ap-gauge-label">Air Speed</div>
|
||||||
|
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||||
|
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||||
|
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ap-gauge-wrap">
|
||||||
|
<div class="ap-gauge-label">Altitude</div>
|
||||||
|
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||||
|
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||||
|
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ap-gauge-wrap">
|
||||||
|
<div class="ap-gauge-label">Climb</div>
|
||||||
|
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||||
|
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||||
|
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ap-navball" v-html="navball" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aircraft-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(200,200,220,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-callsign {
|
||||||
|
margin: 0 24px 0 0;
|
||||||
|
color: #7eeee1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
|
||||||
|
|
||||||
|
.ap-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
/* ── Photo ── */
|
||||||
|
.ap-photo-wrap {
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 160px;
|
||||||
|
background: #111;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
|
||||||
|
.ap-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-gauges { flex: 0 0 110px; }
|
||||||
|
.ap-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.ap-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #0f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.ap-gauge:hover { border-color: #0ff; }
|
||||||
|
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
||||||
|
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
||||||
|
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
@@ -1,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">
|
||||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
|
||||||
<!-- y-axis grid lines & labels -->
|
|
||||||
<template v-for="tick in chart.yLabels" :key="tick.y">
|
<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"/>
|
<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>
|
<text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- x-axis labels -->
|
|
||||||
<template v-for="tick in chart.xLabels" :key="tick.x">
|
<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>
|
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- axes -->
|
|
||||||
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||||
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||||
|
|
||||||
<!-- now line -->
|
|
||||||
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
|
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
|
||||||
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
|
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
|
||||||
|
|
||||||
<!-- historic solid -->
|
|
||||||
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||||
<!-- future dashed -->
|
|
||||||
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
|
<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="5" fill="var(--accent)"/>
|
||||||
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="4" fill="var(--accent)"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-overlay"><div class="spinner"/></div>
|
||||||
|
</div>
|
||||||
</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,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AirTrafficLayer } from '@/services/adsb.ts'
|
import { AirTrafficLayer } from '@/services/adsb.ts'
|
||||||
import { AISLayer } from '@/services/ais.ts'
|
import { AISLayer } from '@/services/ais.ts'
|
||||||
import {api} from '@/services/api.ts';
|
import { api } from '@/services/api.ts'
|
||||||
import {RangeLayer} from '@/services/range.ts';
|
import { RangeLayer } from '@/services/range.ts'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import VectorTileLayer from 'ol/layer/VectorTile'
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map'
|
||||||
import View from 'ol/View'
|
import View from 'ol/View'
|
||||||
import TileLayer from 'ol/layer/Tile'
|
import TileLayer from 'ol/layer/Tile'
|
||||||
@@ -13,11 +14,13 @@ import XYZ from 'ol/source/XYZ'
|
|||||||
import Feature from 'ol/Feature'
|
import Feature from 'ol/Feature'
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point'
|
||||||
import CircleGeom from 'ol/geom/Circle'
|
import CircleGeom from 'ol/geom/Circle'
|
||||||
|
import { defaults as defaultInteractions } from 'ol/interaction'
|
||||||
import { fromLonLat, toLonLat } from 'ol/proj'
|
import { fromLonLat, toLonLat } from 'ol/proj'
|
||||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
||||||
|
import { applyStyle } from 'ol-mapbox-style'
|
||||||
import 'ol/ol.css'
|
import 'ol/ol.css'
|
||||||
|
|
||||||
const current = ref({latitude: 0, longitude: 0});
|
const current = ref({ latitude: 0, longitude: 0 })
|
||||||
const props = defineProps<{ dark: boolean }>()
|
const props = defineProps<{ dark: boolean }>()
|
||||||
const mapEl = ref<HTMLDivElement>()
|
const mapEl = ref<HTMLDivElement>()
|
||||||
const showOverlays = ref(false)
|
const showOverlays = ref(false)
|
||||||
@@ -25,17 +28,12 @@ const atActive = ref(true)
|
|||||||
|
|
||||||
const NM = 1852
|
const NM = 1852
|
||||||
|
|
||||||
const radarFrames = ref<{ path: string; time: number }[]>([])
|
|
||||||
const radarFrameIndex = ref(0)
|
|
||||||
const radarPath = computed(() => radarFrames.value[radarFrameIndex.value]?.path ?? '')
|
|
||||||
const nowcastStartIndex = ref(0)
|
|
||||||
|
|
||||||
const showWind = ref(false)
|
const showWind = ref(false)
|
||||||
const windSrc = ref('')
|
const windSrc = ref('')
|
||||||
|
|
||||||
const OVERLAYS = [
|
const OVERLAYS = [
|
||||||
{ id: 'rain', label: 'Rain', icon: '🌧️', url: () => `https://tilecache.rainviewer.com${radarPath.value}/256/{z}/{x}/{y}/2/1_1.png` },
|
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||||
{ id: 'wind', label: 'Wind', icon: '💨', url: () => '' },
|
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||||
@@ -58,38 +56,60 @@ function syncWindSrc() {
|
|||||||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
|
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRadarFrames() {
|
function showWindOverlay() {
|
||||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
syncWindSrc()
|
||||||
const data = await res.json()
|
showWind.value = true
|
||||||
const past = data.radar.past ?? []
|
map.getInteractions().forEach(i => i.setActive(false))
|
||||||
const nowcast = data.radar.nowcast ?? []
|
|
||||||
radarFrames.value = [...past, ...nowcast]
|
|
||||||
nowcastStartIndex.value = past.length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRainSource(url: string): XYZ {
|
function hideWindOverlay() {
|
||||||
return new XYZ({
|
showWind.value = false
|
||||||
|
map.getInteractions().forEach(i => i.setActive(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRadarUrl(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
||||||
|
const data = await res.json()
|
||||||
|
const past = data.radar?.past ?? []
|
||||||
|
const latest = past[past.length - 1]
|
||||||
|
if (!latest) return null
|
||||||
|
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRainLayer(url: string): TileLayer<XYZ> {
|
||||||
|
return new TileLayer({
|
||||||
|
source: new XYZ({
|
||||||
url,
|
url,
|
||||||
attributions: '',
|
|
||||||
maxZoom: 7,
|
maxZoom: 7,
|
||||||
tileLoadFunction: (tile: any, src: string) => {
|
tileLoadFunction: (tile: any, src: string) => {
|
||||||
const img = tile.getImage()
|
const img = tile.getImage()
|
||||||
img.onerror = () => tile.setState(3)
|
img.onerror = () => tile.setState(3)
|
||||||
img.src = src
|
img.src = src
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
opacity: 0.2,
|
||||||
|
zIndex: 5,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekRadar(index: number) {
|
async function refreshRain() {
|
||||||
radarFrameIndex.value = index
|
const url = await fetchRadarUrl()
|
||||||
const layer = overlayLayers['rain']
|
if (!url) return
|
||||||
if (layer) {
|
|
||||||
layer.setSource(buildRainSource(OVERLAYS.find(o => o.id === 'rain')!.url()))
|
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
|
||||||
}
|
if (!activeOverlays.value.has('rain')) return
|
||||||
|
|
||||||
|
const layer = buildRainLayer(url)
|
||||||
|
overlayLayers['rain'] = layer
|
||||||
|
map.addLayer(layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAT() {
|
function toggleAT() {
|
||||||
if(atActive.value) {
|
if (atActive.value) {
|
||||||
aisLayer.hide()
|
aisLayer.hide()
|
||||||
airTraffic.hide()
|
airTraffic.hide()
|
||||||
range.hide()
|
range.hide()
|
||||||
@@ -105,45 +125,30 @@ function toggleOverlay(id: string) {
|
|||||||
if (id === 'wind') {
|
if (id === 'wind') {
|
||||||
if (activeOverlays.value.has('wind')) {
|
if (activeOverlays.value.has('wind')) {
|
||||||
activeOverlays.value.delete('wind')
|
activeOverlays.value.delete('wind')
|
||||||
showWind.value = false
|
hideWindOverlay()
|
||||||
} else {
|
} else {
|
||||||
activeOverlays.value.add('wind')
|
activeOverlays.value.add('wind')
|
||||||
syncWindSrc()
|
showWindOverlay()
|
||||||
showWind.value = true
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeOverlays.value.has(id)) {
|
if (id === 'rain') {
|
||||||
activeOverlays.value.delete(id)
|
if (activeOverlays.value.has('rain')) {
|
||||||
const layer = overlayLayers[id]
|
activeOverlays.value.delete('rain')
|
||||||
if (layer) { map.removeLayer(layer); delete overlayLayers[id] }
|
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
|
||||||
} else {
|
} else {
|
||||||
activeOverlays.value.add(id)
|
activeOverlays.value.add('rain')
|
||||||
const layer = buildOverlayLayer(id)
|
refreshRain()
|
||||||
overlayLayers[id] = layer
|
}
|
||||||
map.addLayer(layer)
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBaseLayer(dark: boolean) {
|
function buildBaseLayer(dark: boolean) {
|
||||||
return new TileLayer({
|
const layer = new VectorTileLayer({ declutter: true })
|
||||||
source: new XYZ({
|
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json')
|
||||||
url: dark
|
return layer
|
||||||
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
|
|
||||||
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
|
|
||||||
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOverlayLayer(id: string): TileLayer<XYZ> {
|
|
||||||
const def = OVERLAYS.find(o => o.id === id)!
|
|
||||||
return new TileLayer({
|
|
||||||
source: buildRainSource(def.url()),
|
|
||||||
opacity: 0.2,
|
|
||||||
zIndex: 5,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||||||
@@ -174,46 +179,31 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchRadarFrames()
|
const position = await api.position()
|
||||||
radarFrameIndex.value = nowcastStartIndex.value - 1
|
current.value = {
|
||||||
|
latitude: position?.latitude || 0,
|
||||||
|
longitude: position?.longitude || 0,
|
||||||
|
}
|
||||||
|
|
||||||
const position = await api.position();
|
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark)
|
||||||
|
|
||||||
const lat = position?.latitude || 0
|
|
||||||
const lon = position?.longitude || 0
|
|
||||||
|
|
||||||
stationLayer = buildStationLayer(lat, lon, props.dark)
|
|
||||||
|
|
||||||
map = new Map({
|
map = new Map({
|
||||||
target: mapEl.value!,
|
target: mapEl.value!,
|
||||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||||
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
|
interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }),
|
||||||
|
view: new View({ center: fromLonLat([current.value.longitude, current.value.latitude]), zoom: 8, maxZoom: 13 }),
|
||||||
controls: [],
|
controls: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
aisLayer = new AISLayer(map);
|
aisLayer = new AISLayer(map)
|
||||||
aisLayer.show();
|
aisLayer.show()
|
||||||
airTraffic = new AirTrafficLayer(map)
|
airTraffic = new AirTrafficLayer(map)
|
||||||
airTraffic.show()
|
airTraffic.show()
|
||||||
range = new RangeLayer(map);
|
range = new RangeLayer(map)
|
||||||
range.show();
|
range.show()
|
||||||
|
|
||||||
for (const id of activeOverlays.value) {
|
refreshRain()
|
||||||
if (id === 'wind') continue
|
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
|
||||||
const layer = buildOverlayLayer(id)
|
|
||||||
overlayLayers[id] = layer
|
|
||||||
map.addLayer(layer)
|
|
||||||
}
|
|
||||||
|
|
||||||
map.on('moveend', () => {
|
|
||||||
if (showWind.value) syncWindSrc()
|
|
||||||
})
|
|
||||||
|
|
||||||
radarInterval = setInterval(async () => {
|
|
||||||
await fetchRadarFrames()
|
|
||||||
radarFrameIndex.value = nowcastStartIndex.value - 1
|
|
||||||
seekRadar(radarFrameIndex.value)
|
|
||||||
}, 5 * 60 * 1000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -326,80 +316,12 @@ watch(() => props.dark, dark => {
|
|||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-timeline {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 72px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
background: var(--surface);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
width: min(600px, calc(100vw - 32px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-wrap {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-ticks {
|
|
||||||
position: relative;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tick {
|
|
||||||
position: absolute;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
&__line {
|
|
||||||
width: 1px;
|
|
||||||
height: 5px;
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--text-muted, #888);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.nowcast &__line { background: #6ab4ff; }
|
|
||||||
&.nowcast &__label { color: #6ab4ff; }
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='range'] {
|
|
||||||
width: 100%;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text);
|
|
||||||
min-width: 42px;
|
|
||||||
text-align: right;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop { display: flex; }
|
.desktop { display: flex; }
|
||||||
.mobile { display: none; }
|
.mobile { display: none; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.desktop { display: none; }
|
.desktop { display: none; }
|
||||||
.mobile { display: flex; }
|
.mobile { display: flex; }
|
||||||
.radar-timeline { bottom: 10px; }
|
|
||||||
.overlay-toggles { bottom: 10px; }
|
.overlay-toggles { bottom: 10px; }
|
||||||
.layers-menu { bottom: 10px; }
|
.layers-menu { bottom: 10px; }
|
||||||
}
|
}
|
||||||
@@ -409,7 +331,6 @@ input[type='range'] {
|
|||||||
<div class="map-wrap">
|
<div class="map-wrap">
|
||||||
<div ref="mapEl" class="map-el" />
|
<div ref="mapEl" class="map-el" />
|
||||||
|
|
||||||
<!-- Windy animated wind iframe -->
|
|
||||||
<iframe
|
<iframe
|
||||||
v-if="showWind"
|
v-if="showWind"
|
||||||
class="wind-iframe"
|
class="wind-iframe"
|
||||||
@@ -418,7 +339,6 @@ input[type='range'] {
|
|||||||
allowfullscreen
|
allowfullscreen
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Desktop toggles -->
|
|
||||||
<div class="overlay-toggles desktop">
|
<div class="overlay-toggles desktop">
|
||||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||||
✈️ Traffic
|
✈️ Traffic
|
||||||
@@ -433,7 +353,6 @@ input[type='range'] {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile layers button + dropdown -->
|
|
||||||
<div class="layers-menu mobile">
|
<div class="layers-menu mobile">
|
||||||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||||||
⚙️ Layers
|
⚙️ Layers
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 40px">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
||||||
<div class="flex-c">
|
<div class="flex-c">
|
||||||
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
<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>
|
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
||||||
|
<div v-if="!d" class="flex-c p-2 gap-2">
|
||||||
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
|
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
||||||
<MetricRow label="Next New Moon" :value="formatDate('MMM D', d.moon_new)" />
|
<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>
|
||||||
|
|||||||
@@ -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 = buf[i];
|
||||||
|
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const animPoints = [anchor, ...buf.slice(settledCount)];
|
||||||
|
|
||||||
|
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
||||||
|
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animFloor < animPoints.length - 1 && animRemainder > 0) {
|
||||||
|
const curr = animPoints[animFloor];
|
||||||
|
const next = animPoints[animFloor + 1];
|
||||||
|
ctx.lineTo(
|
||||||
|
curr.x + (next.x - curr.x) * animRemainder + slide,
|
||||||
|
curr.y + (next.y - curr.y) * animRemainder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(timestamp: number) {
|
||||||
|
const ctx = canvas.value?.getContext('2d');
|
||||||
|
if (!ctx) { rafId = requestAnimationFrame(draw); return; }
|
||||||
|
|
||||||
|
const dt = Math.min(timestamp - lastTimestamp, 100);
|
||||||
|
lastTimestamp = timestamp;
|
||||||
|
drawProgress = Math.min(1, drawProgress + dt / ANIM_DURATION);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
AXES.forEach((_, i) => {
|
||||||
|
const mid = i * BAND + BAND / 2;
|
||||||
|
|
||||||
|
// Axis label — top-left of each band
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.fillStyle = AXES[i].color;
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
ctx.fillText(AXES[i].label, W - 20, i * BAND + 14);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
// Center line per band — full width
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.moveTo(0, mid);
|
||||||
|
ctx.lineTo(W, mid);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
drawTrace(ctx, traces[i], AXES[i].color);
|
||||||
|
});
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(draw);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll() {
|
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 = 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>
|
||||||
|
|||||||
356
client/src/components/Ship.vue
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {BASE} from '@/services/api.ts';
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||||
|
|
||||||
|
const SPEED_UNITS = {
|
||||||
|
knots: { label: 'KTS', convert: (v: number) => v.toFixed(1) },
|
||||||
|
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||||
|
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||||
|
}
|
||||||
|
const UNIT_KEY = 'ais_units'
|
||||||
|
function loadUnits() {
|
||||||
|
const s = localStorage.getItem(UNIT_KEY)
|
||||||
|
return s ? JSON.parse(s) : { speed: 'knots' }
|
||||||
|
}
|
||||||
|
const prefs = ref(loadUnits())
|
||||||
|
function cycleSpeed() {
|
||||||
|
const keys = Object.keys(SPEED_UNITS)
|
||||||
|
const p = loadUnits()
|
||||||
|
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||||
|
prefs.value = { ...p }
|
||||||
|
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||||
|
}
|
||||||
|
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||||
|
|
||||||
|
const name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
|
||||||
|
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0)
|
||||||
|
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
||||||
|
const photoError = ref(false)
|
||||||
|
|
||||||
|
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||||
|
const pos = ref({ ...props.position })
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||||
|
const longPressTimer = ref<number | null>(null)
|
||||||
|
const header = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function onMouseDown(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||||
|
}
|
||||||
|
function onMouseUp() { isDragging.value = false }
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
const sx = touch.clientX, sy = touch.clientY
|
||||||
|
longPressTimer.value = window.setTimeout(() => {
|
||||||
|
emit('bringToFront')
|
||||||
|
isDragging.value = true
|
||||||
|
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||||
|
if (header.value) header.value.style.opacity = '0.8'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
const touch: any = e.touches[0]
|
||||||
|
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||||
|
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||||
|
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', onTouchEnd)
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
|
document.removeEventListener('touchend', onTouchEnd)
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
})
|
||||||
|
|
||||||
|
const compass = computed(() => {
|
||||||
|
const hdg = heading.value
|
||||||
|
const course = props.boat.cog ?? hdg
|
||||||
|
|
||||||
|
const tapeTicks: string[] = []
|
||||||
|
for (let offset = -60; offset <= 60; offset += 5) {
|
||||||
|
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||||
|
const x = 100 + (offset / 60) * 90
|
||||||
|
const major = offset % 30 === 0
|
||||||
|
const mid = offset % 15 === 0
|
||||||
|
if (major) {
|
||||||
|
const lbl = ({ 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' } as any)[deg]
|
||||||
|
?? String(deg).padStart(3, '0')
|
||||||
|
tapeTicks.push(`
|
||||||
|
<line x1="${x}" y1="22" x2="${x}" y2="38" stroke="#0ff" stroke-width="2"/>
|
||||||
|
<text x="${x}" y="50" text-anchor="middle" fill="#0ff" font-size="11" font-weight="bold">${lbl}</text>
|
||||||
|
`)
|
||||||
|
} else if (mid) {
|
||||||
|
tapeTicks.push(`<line x1="${x}" y1="26" x2="${x}" y2="38" stroke="#0ff" stroke-width="1.5"/>`)
|
||||||
|
} else {
|
||||||
|
tapeTicks.push(`<line x1="${x}" y1="30" x2="${x}" y2="38" stroke="#0ff" stroke-width="1"/>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CX = 100, CY = 140
|
||||||
|
const R = 68
|
||||||
|
|
||||||
|
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
||||||
|
const rudX = CX + (rudder / 30) * 40
|
||||||
|
|
||||||
|
const courseRel = ((course - hdg + 540) % 360) - 180
|
||||||
|
const courseRad = courseRel * Math.PI / 180
|
||||||
|
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
||||||
|
const cNeedleY = CY - 52 * Math.cos(courseRad)
|
||||||
|
|
||||||
|
const bearRel = ((course - hdg + 540) % 360) - 180
|
||||||
|
const bearRad = bearRel * Math.PI / 180
|
||||||
|
const bNeedleX = CX + 52 * Math.sin(bearRad)
|
||||||
|
const bNeedleY = CY - 52 * Math.cos(bearRad)
|
||||||
|
|
||||||
|
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
||||||
|
const a = i * 10
|
||||||
|
const rad = (a - 90) * Math.PI / 180
|
||||||
|
const r1 = a % 30 === 0 ? R - 11 : R - 5
|
||||||
|
const sw = a % 30 === 0 ? 2 : 1
|
||||||
|
const x1 = CX + r1 * Math.cos(rad)
|
||||||
|
const y1 = CY + r1 * Math.sin(rad)
|
||||||
|
const x2 = CX + R * Math.cos(rad)
|
||||||
|
const y2 = CY + R * Math.sin(rad)
|
||||||
|
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
const cardinals = (['N','E','S','W'] as const).map((lbl, i) => {
|
||||||
|
const a = i * 90
|
||||||
|
const rad = (a - 90) * Math.PI / 180
|
||||||
|
const x = CX + (R - 18) * Math.cos(rad)
|
||||||
|
const y = CY + (R - 18) * Math.sin(rad) + 4
|
||||||
|
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg width="200" height="210" viewBox="0 0 200 210">
|
||||||
|
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
|
||||||
|
<line x1="10" y1="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
|
||||||
|
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
||||||
|
${tapeTicks.join('')}
|
||||||
|
<polygon points="100,20 96,12 104,12" fill="#ff0"/>
|
||||||
|
|
||||||
|
<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
|
||||||
|
<circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
|
||||||
|
|
||||||
|
<g transform="rotate(${-hdg}, ${CX}, ${CY})">
|
||||||
|
${ringTicks}
|
||||||
|
${cardinals}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||||
|
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
|
||||||
|
|
||||||
|
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||||
|
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
|
||||||
|
|
||||||
|
<polygon points="${CX},${CY - 9} ${CX - 4},${CY + 5} ${CX},${CY + 2} ${CX + 4},${CY + 5}" fill="#fff" stroke="#0ff" stroke-width="1"/>
|
||||||
|
|
||||||
|
<rect x="80" y="${CY + 23}" width="40" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
||||||
|
<rect x="${rudX - 4}" y="${CY + 22}" width="8" height="8" fill="#ff0" rx="1"/>
|
||||||
|
|
||||||
|
<line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
|
||||||
|
<text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
|
||||||
|
|
||||||
|
<line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||||
|
<text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||||
|
|
||||||
|
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||||
|
<h3 class="sp-name">
|
||||||
|
🚢
|
||||||
|
<span>{{ name.toUpperCase() }}</span>
|
||||||
|
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||||
|
</h3>
|
||||||
|
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-meta">
|
||||||
|
<div class="flex-c">
|
||||||
|
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||||
|
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-c align-x-end">
|
||||||
|
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||||
|
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo -->
|
||||||
|
<div v-if="!photoError" class="sp-photo-wrap">
|
||||||
|
<img class="sp-photo" :src="BASE + '/api/ais-iamge/' + boat.mmsi" :alt="name" @error="photoError = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-body">
|
||||||
|
<div class="sp-gauges">
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Speed</div>
|
||||||
|
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||||
|
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||||
|
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Course</div>
|
||||||
|
<div class="sp-gauge">
|
||||||
|
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '—' }}</span>
|
||||||
|
<span v-if="boat.course != null" class="sp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sp-gauge-wrap">
|
||||||
|
<div class="sp-gauge-label">Heading</div>
|
||||||
|
<div class="sp-gauge">
|
||||||
|
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '—' }}</span>
|
||||||
|
<span v-if="boat.heading != null" class="sp-gauge-unit">°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sp-compass" v-html="compass"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ship-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
border: 1px solid rgba(200,200,220,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-header {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-name {
|
||||||
|
margin: 0 24px 0 0;
|
||||||
|
color: #7eeee1;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.sp-mmsi { color: white; }
|
||||||
|
|
||||||
|
.sp-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.sp-close:hover { background: rgba(255,255,255,0.35); }
|
||||||
|
|
||||||
|
/* ── Photo ── */
|
||||||
|
.sp-photo-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
background: #111;
|
||||||
|
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sp-photo {
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.sp-gauge-wrap { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.sp-gauge-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-gauge {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(0,0,0,0.95);
|
||||||
|
border: 2px solid #0ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.sp-gauge:hover { border-color: #0f0; }
|
||||||
|
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
|
||||||
|
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||||
|
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||||
|
</style>
|
||||||
@@ -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,6 +64,9 @@ const arc = computed(() => {
|
|||||||
<div class="sun-card">
|
<div class="sun-card">
|
||||||
<div class="card-title">🌅 Sun</div>
|
<div class="card-title">🌅 Sun</div>
|
||||||
|
|
||||||
|
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 3"><Loading /></div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<!-- Top labels -->
|
<!-- Top labels -->
|
||||||
<div class="sun-top-labels" v-if="data">
|
<div class="sun-top-labels" v-if="data">
|
||||||
<div>
|
<div>
|
||||||
@@ -88,10 +85,11 @@ const arc = computed(() => {
|
|||||||
<!-- Remaining curve stroke -->
|
<!-- Remaining curve stroke -->
|
||||||
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
||||||
<!-- Horizon line -->
|
<!-- Horizon line -->
|
||||||
<line :x1="0" :y1="HORIZON" :x2="W" :y2="HORIZON" stroke="var(--border)" stroke-width="1" />
|
<line :x1="0" :y1="arc.horizonY" :x2="W" :y2="arc.horizonY" stroke="var(--border)" stroke-width="1" />
|
||||||
<!-- Sun dot -->
|
<!-- Sun dot -->
|
||||||
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
||||||
</svg>
|
</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>
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import {BASE} from '@/services/api.ts';
|
import { BASE } from '@/services/api.ts'
|
||||||
|
import { createApp, ref } from 'vue'
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map'
|
||||||
import { Feature } from 'ol'
|
import { Feature } from 'ol'
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point'
|
||||||
@@ -7,41 +8,12 @@ import { fromLonLat } from 'ol/proj'
|
|||||||
import { Style, Icon, Stroke } from 'ol/style'
|
import { Style, Icon, Stroke } from 'ol/style'
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import { Vector as VectorLayer } from 'ol/layer'
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import { Vector as VectorSource } from 'ol/source'
|
||||||
import {adjustedInterval} from '@ztimson/utils';
|
import { adjustedInterval } from '@ztimson/utils'
|
||||||
|
import AircraftPopup from '@/components/Aircraft.vue'
|
||||||
|
import { bringToFront } from './zindex'
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api'
|
||||||
|
|
||||||
const COLORS: Record<string, string> = {
|
|
||||||
'cargo': '#00ff00',
|
|
||||||
'military': '#ff0000',
|
|
||||||
'private': '#8450ea',
|
|
||||||
'passenger': '#009dff',
|
|
||||||
'unknown': '#fad106',
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
||||||
@@ -58,134 +30,10 @@ function getAltColor(alt: number): [number, number, number] {
|
|||||||
return [Math.round(128 * r), 0, 139]
|
return [Math.round(128 * r), 0, 139]
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPlaneIcon(plane: any, shapes: any): string {
|
interface PopupInstance {
|
||||||
const color = COLORS[plane.type] || COLORS['unknown']
|
planeRef: ReturnType<typeof ref>
|
||||||
const shape = shapes[plane.aircraft?.toLowerCase()] || shapes['unknown'];
|
posRef: ReturnType<typeof ref>
|
||||||
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('')
|
unmount: () => void
|
||||||
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[] = []
|
|
||||||
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 + 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 {
|
||||||
@@ -193,57 +41,48 @@ export class AirTrafficLayer {
|
|||||||
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() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return
|
||||||
this.visible = true
|
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.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||||
this.map.addLayer(this.layer)
|
this.map.addLayer(this.layer)
|
||||||
|
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
this._attachClick()
|
this._attachClick()
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
|
try {
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
this._refreshPopups()
|
this._refreshPopups()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
}, 1_000)
|
}, 1_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
this.visible = false
|
this.visible = false
|
||||||
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||||
|
|
||||||
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
||||||
|
|
||||||
this.map.removeLayer(this.layer)
|
this.map.removeLayer(this.layer)
|
||||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
||||||
this.traceLayers = {}
|
this.traceLayers = {}
|
||||||
this.traceSegs = {}
|
this.traceSegs = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async _fetch() {
|
private async _fetch() {
|
||||||
const j = await fetch(`${API}/adsb`).then(r => r.json())
|
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
||||||
this.data = (j || []).map((a: any) => ({
|
this.data = (j || []).map((a: any) => ({
|
||||||
...a,
|
...a,
|
||||||
icao: a.hex,
|
icao: a.hex,
|
||||||
@@ -259,7 +98,6 @@ export class AirTrafficLayer {
|
|||||||
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) continue
|
||||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
const coord = fromLonLat([plane.longitude, plane.latitude])
|
||||||
@@ -268,8 +106,8 @@ export class AirTrafficLayer {
|
|||||||
f.set('planeData', plane)
|
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],
|
||||||
}),
|
}),
|
||||||
@@ -284,8 +122,7 @@ export class AirTrafficLayer {
|
|||||||
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 = [...this.historyCache[icao]]
|
||||||
const history = [...(<any>this.historyCache[icao])]
|
|
||||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
||||||
const last = history[history.length - 1]
|
const last = history[history.length - 1]
|
||||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
||||||
@@ -310,7 +147,7 @@ export class AirTrafficLayer {
|
|||||||
const s = history[i], e = history[i + 1]
|
const s = history[i], e = history[i + 1]
|
||||||
const sc = getAltColor(s.altitude || 0)
|
const sc = getAltColor(s.altitude || 0)
|
||||||
const ec = getAltColor(e.altitude || 0)
|
const ec = 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] as any)) / 2))
|
||||||
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
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 }) }))
|
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
||||||
vs.addFeature(f)
|
vs.addFeature(f)
|
||||||
@@ -319,67 +156,45 @@ export class AirTrafficLayer {
|
|||||||
this.traceSegs[icao] = segs
|
this.traceSegs[icao] = segs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _calcPopupPos(plane: any): { x: number; y: number } {
|
||||||
|
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
||||||
|
if (!pixel) return { x: 10, y: 60 }
|
||||||
|
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||||
|
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
||||||
|
}
|
||||||
|
|
||||||
private _openPopup(plane: any) {
|
private _openPopup(plane: any) {
|
||||||
const icao = plane.icao
|
const icao = plane.icao
|
||||||
if (this.popups[icao]) return
|
if (this.popups[icao]) return
|
||||||
|
|
||||||
const el = document.createElement('div')
|
const planeRef = ref(plane)
|
||||||
el.style.cssText = `
|
const posRef = ref(this._calcPopupPos(plane))
|
||||||
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')
|
const container = document.createElement('div')
|
||||||
close.innerHTML = '✕'
|
document.body.appendChild(container)
|
||||||
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)
|
const mobile = window.innerWidth <= 768;
|
||||||
el.appendChild(close)
|
|
||||||
document.body.appendChild(el)
|
|
||||||
this._positionPopup(el, plane)
|
|
||||||
|
|
||||||
el.querySelectorAll('.at-gauge').forEach(g => {
|
const app = createApp(AircraftPopup, {
|
||||||
g.addEventListener('click', (e) => {
|
plane: planeRef.value,
|
||||||
const prefs = getUnits()
|
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||||
const type = (e.currentTarget as HTMLElement).dataset.gauge!
|
onClose: () => this._closePopup(icao),
|
||||||
if (type === 'speed') {
|
onBringToFront: () => bringToFront(container),
|
||||||
const keys = Object.keys(SPEED_UNITS)
|
})
|
||||||
prefs.speed = keys[(keys.indexOf(prefs.speed) + 1) % keys.length]
|
|
||||||
} else if (type === 'altitude') {
|
app.mount(container)
|
||||||
const keys = Object.keys(ALTITUDE_UNITS)
|
|
||||||
prefs.altitude = keys[(keys.indexOf(prefs.altitude) + 1) % keys.length]
|
this.popups[icao] = {
|
||||||
prefs.vertical = prefs.altitude === 'meters' ? 'mps' : 'fps'
|
planeRef,
|
||||||
} else if (type === 'vertical') {
|
posRef,
|
||||||
const keys = Object.keys(VERTICAL_UNITS)
|
unmount: () => { app.unmount(); container.remove() },
|
||||||
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)
|
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) {
|
private _closePopup(icao: string) {
|
||||||
const popup = this.popups[icao]
|
const popup = this.popups[icao]
|
||||||
if (popup) { popup.destroy(); delete this.popups[icao] }
|
if (popup) { popup.unmount(); delete this.popups[icao] }
|
||||||
|
|
||||||
const segs = this.traceSegs[icao]
|
const segs = this.traceSegs[icao]
|
||||||
const layer = this.traceLayers[icao]
|
const layer = this.traceLayers[icao]
|
||||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
||||||
@@ -391,14 +206,7 @@ export class AirTrafficLayer {
|
|||||||
for (const icao of Object.keys(this.popups)) {
|
for (const icao of Object.keys(this.popups)) {
|
||||||
const plane = this.data.find(p => p.icao === icao)
|
const plane = this.data.find(p => p.icao === icao)
|
||||||
if (!plane) { this._closePopup(icao); continue }
|
if (!plane) { this._closePopup(icao); continue }
|
||||||
const { el } = <any>this.popups[icao]
|
this.popups[icao].planeRef.value = plane
|
||||||
el.innerHTML = buildPopupHtml(plane)
|
|
||||||
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.appendChild(close)
|
|
||||||
this._positionPopup(el, plane)
|
|
||||||
this._fetchTrace(plane)
|
this._fetchTrace(plane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
import {BASE} from '@/services/api.ts';
|
import { BASE } from '@/services/api.ts'
|
||||||
|
import { createApp, ref } from 'vue'
|
||||||
import Map from 'ol/Map'
|
import Map from 'ol/Map'
|
||||||
import { Feature } from 'ol'
|
import { Feature } from 'ol'
|
||||||
import Point from 'ol/geom/Point'
|
import Point from 'ol/geom/Point'
|
||||||
import { fromLonLat } from 'ol/proj'
|
import { fromLonLat } from 'ol/proj'
|
||||||
import { Style, Icon, Text, Fill, Stroke } from 'ol/style'
|
import { Style, Icon } from 'ol/style'
|
||||||
import { Vector as VectorLayer } from 'ol/layer'
|
import { Vector as VectorLayer } from 'ol/layer'
|
||||||
import { Vector as VectorSource } from 'ol/source'
|
import { Vector as VectorSource } from 'ol/source'
|
||||||
import { adjustedInterval } from '@ztimson/utils'
|
import { adjustedInterval } from '@ztimson/utils'
|
||||||
|
import ShipPopup from '@/components/Ship.vue'
|
||||||
|
import { bringToFront } from './zindex'
|
||||||
|
|
||||||
const API = BASE + '/api'
|
const API = BASE + '/api'
|
||||||
|
|
||||||
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 buildBoatIcon(boat: any): string {
|
function buildBoatIcon(boat: any): string {
|
||||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
||||||
|
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
||||||
if (boat.type === 'Base Station') {
|
|
||||||
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"/>
|
||||||
@@ -35,10 +37,9 @@ function buildBoatIcon(boat: any): string {
|
|||||||
</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"/>
|
||||||
@@ -47,57 +48,10 @@ function buildBoatIcon(boat: any): string {
|
|||||||
`)}`
|
`)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -105,39 +59,34 @@ export class AISLayer {
|
|||||||
|
|
||||||
constructor(map: Map) { this.map = map }
|
constructor(map: Map) { this.map = map }
|
||||||
|
|
||||||
// ── Public ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async show() {
|
async show() {
|
||||||
if (this.visible) return
|
if (this.visible) return
|
||||||
this.visible = true
|
this.visible = true
|
||||||
|
|
||||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||||
this.map.addLayer(this.layer)
|
this.map.addLayer(this.layer)
|
||||||
|
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
this._attachClick()
|
this._attachClick()
|
||||||
|
|
||||||
this.refreshTimer = adjustedInterval(async () => {
|
this.refreshTimer = adjustedInterval(async () => {
|
||||||
|
try {
|
||||||
await this._fetch()
|
await this._fetch()
|
||||||
this._draw()
|
this._draw()
|
||||||
this._refreshPopups()
|
this._refreshPopups()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
}, 5_000)
|
}, 5_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
this.visible = false
|
this.visible = false
|
||||||
|
|
||||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||||
|
|
||||||
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
||||||
this.map.removeLayer(this.layer)
|
this.map.removeLayer(this.layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async _fetch() {
|
private async _fetch() {
|
||||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
||||||
}
|
}
|
||||||
@@ -145,7 +94,6 @@ export class AISLayer {
|
|||||||
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 coord = fromLonLat([boat.lon, boat.lat])
|
||||||
@@ -155,7 +103,7 @@ export class AISLayer {
|
|||||||
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.course ?? 0) * (Math.PI / 180),
|
||||||
anchor: [0.5, 0.5],
|
anchor: [0.5, 0.5],
|
||||||
}),
|
}),
|
||||||
@@ -164,55 +112,50 @@ export class AISLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _calcPopupPos(boat: any): { x: number; y: number } {
|
||||||
|
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
||||||
|
if (!pixel) return { x: 10, y: 60 }
|
||||||
|
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||||
|
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
||||||
|
}
|
||||||
|
|
||||||
private _openPopup(boat: any) {
|
private _openPopup(boat: any) {
|
||||||
const mmsi = String(boat.mmsi)
|
const mmsi = String(boat.mmsi)
|
||||||
if (this.popups[mmsi]) return
|
if (this.popups[mmsi]) return
|
||||||
|
|
||||||
const el = document.createElement('div')
|
const boatRef = ref(boat)
|
||||||
el.style.cssText = `
|
const posRef = ref(this._calcPopupPos(boat))
|
||||||
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')
|
const container = document.createElement('div')
|
||||||
close.innerHTML = '✕'
|
document.body.appendChild(container)
|
||||||
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)
|
const mobile = window.innerWidth <= 768;
|
||||||
el.appendChild(close)
|
|
||||||
document.body.appendChild(el)
|
|
||||||
this._positionPopup(el, boat)
|
|
||||||
|
|
||||||
this.popups[mmsi] = { el, destroy: () => el.remove() }
|
const app = createApp(ShipPopup, {
|
||||||
|
boat: boatRef.value,
|
||||||
|
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||||
|
onClose: () => this._closePopup(mmsi),
|
||||||
|
onBringToFront: () => bringToFront(container),
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount(container)
|
||||||
|
this.popups[mmsi] = {
|
||||||
|
boatRef,
|
||||||
|
posRef,
|
||||||
|
unmount: () => { app.unmount(); container.remove() },
|
||||||
}
|
}
|
||||||
|
|
||||||
private _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) {
|
private _closePopup(mmsi: string) {
|
||||||
const popup = this.popups[mmsi]
|
const popup = this.popups[mmsi]
|
||||||
if (popup) { popup.destroy(); delete this.popups[mmsi] }
|
if (popup) { popup.unmount(); delete this.popups[mmsi] }
|
||||||
}
|
}
|
||||||
|
|
||||||
private _refreshPopups() {
|
private _refreshPopups() {
|
||||||
for (const mmsi of Object.keys(this.popups)) {
|
for (const mmsi of Object.keys(this.popups)) {
|
||||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
||||||
if (!boat) { this._closePopup(mmsi); continue }
|
if (!boat) { this._closePopup(mmsi); continue }
|
||||||
const { el } = <any>this.popups[mmsi]
|
this.popups[mmsi].boatRef.value = boat
|
||||||
el.innerHTML = buildPopupHtml(boat)
|
|
||||||
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.appendChild(close)
|
|
||||||
this._positionPopup(el, boat)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
@@ -62,6 +62,20 @@ export const METRICS: Record<string, MetricMeta> = {
|
|||||||
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPressureTrend(delta: number) {
|
||||||
|
if (delta > 0.5) return 'Rising';
|
||||||
|
if (delta < -0.5) return 'Falling';
|
||||||
|
return 'Stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUVLabel(uv: number) {
|
||||||
|
if (uv < 3) return 'Low';
|
||||||
|
if (uv < 6) return 'Moderate';
|
||||||
|
if (uv < 8) return 'High';
|
||||||
|
if (uv < 11) return 'Very High';
|
||||||
|
return 'Extreme';
|
||||||
|
}
|
||||||
|
|
||||||
export function formatValue(key: string, value: number | string | null): string {
|
export function formatValue(key: string, value: number | string | null): string {
|
||||||
if (value === null || value === undefined) return '—'
|
if (value === null || value === undefined) return '—'
|
||||||
const meta = METRICS[key]
|
const meta = METRICS[key]
|
||||||
|
|||||||
5
client/src/services/zindex.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// src/services/zIndex.ts
|
||||||
|
export let highestZIndex = 1000
|
||||||
|
export function bringToFront(el: HTMLElement) {
|
||||||
|
el.style.zIndex = String(++highestZIndex)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
494
sensors/main.py
@@ -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,31 +248,28 @@ 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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -289,146 +280,93 @@ 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,6 +544,7 @@ 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()
|
||||||
@@ -647,16 +553,22 @@ if __name__ == '__main__':
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
366
server/package-lock.json
generated
@@ -8,30 +8,24 @@
|
|||||||
"name": "weather-station",
|
"name": "weather-station",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"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",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@influxdata/influxdb-client": {
|
|
||||||
"version": "1.35.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz",
|
|
||||||
"integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@scalar/client-side-rendering": {
|
"node_modules/@scalar/client-side-rendering": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz",
|
||||||
"integrity": "sha512-IQC39aQQXKsZXOqQw0LNiZ/gQ1WxHpF+WLTo/hurn0S/FmNHlBGUAhFyt1BeDSUYxics7ixgzQatKJDggafBTw==",
|
"integrity": "sha512-wvnyBtNdYob6CdmpFqLSQoGM5q5Ra8bXyq713T/u8Nq1NXbULk1znz/LyR78u8g++0G8bkxnVzhvOf7s9FwHFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/schemas": "0.5.0",
|
"@scalar/schemas": "0.7.0",
|
||||||
"@scalar/types": "0.14.0",
|
"@scalar/types": "0.16.0",
|
||||||
"@scalar/validation": "0.6.0"
|
"@scalar/validation": "0.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -39,33 +33,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/express-api-reference": {
|
"node_modules/@scalar/express-api-reference": {
|
||||||
"version": "0.10.4",
|
"version": "0.10.6",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/express-api-reference/-/express-api-reference-0.10.6.tgz",
|
||||||
"integrity": "sha512-/FkuP+B9ppWiQbf0Bhygx8CwoLXiEGFgS7OK14FM/JH0SeAaGfQiacUgPEpxJYI2qnqFHkrys8YH2jl400wDfg==",
|
"integrity": "sha512-5gXV4Cv6EA8/hUo1e3ZMHKo5w5D45QTCzsA2Qvrf4OEe141iKdtSErYYxaZ/c2RIVidRUohDr3WC2HN+HRDosw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/client-side-rendering": "0.2.4"
|
"@scalar/client-side-rendering": "0.2.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/helpers": {
|
"node_modules/@scalar/helpers": {
|
||||||
"version": "0.8.2",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.0.tgz",
|
||||||
"integrity": "sha512-qNbqUjSB3S4Gr4A0oANcm5G1Ip+EqBxICYKhe9YzmnaBpbmW6shxqpiivApTvvuDf+uIhR3uMwWyVQbYcGLsxA==",
|
"integrity": "sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/schemas": {
|
"node_modules/@scalar/schemas": {
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.0.tgz",
|
||||||
"integrity": "sha512-aV0PWqFpQft9a6d4Z6HOTHKDaA+K4AquxSPfBDI1c5f6lMe5l3zViww/iBQA/Yw+V1+Y2iwSVzhuxaktC1UiMg==",
|
"integrity": "sha512-Roj0e7S29yTbGojwdx/b8C1H5arVN4h/VSPrtQ5vb9NT2ZAbrfPQVmpQYrF845rKOWX7kSzIvyBwrKNEMIAkxA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/helpers": "0.8.2",
|
"@scalar/helpers": "0.9.0",
|
||||||
"@scalar/validation": "0.6.0"
|
"@scalar/validation": "0.6.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -73,12 +67,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@scalar/types": {
|
"node_modules/@scalar/types": {
|
||||||
"version": "0.14.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.0.tgz",
|
||||||
"integrity": "sha512-aS1FvHbKhBC51mWqXmhkPe6lmhCd9+u//DQ1YTiCWOz4/fXAdWfwHvW9UZ7AzzcGwGiCo9diE81xV+UvSI1qIg==",
|
"integrity": "sha512-26OSrvZjKWZ7F236wWmJajBGDVFJuvXFJqKPFqbt/PxlgZKXQXfJsZorASRQmdNogT576nxYalQ7oaYWEnQwfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scalar/helpers": "0.8.2",
|
"@scalar/helpers": "0.9.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"type-fest": "^5.3.1",
|
"type-fest": "^5.3.1",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
@@ -211,6 +205,12 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
@@ -273,6 +273,48 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
@@ -315,6 +357,34 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -376,6 +446,61 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -417,6 +542,31 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -426,6 +576,18 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -662,6 +824,37 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -838,9 +1031,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "5.1.15",
|
"version": "5.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||||
"integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
|
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -882,6 +1075,18 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -915,6 +1120,55 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -981,12 +1235,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -1370,6 +1625,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
@@ -1409,6 +1673,40 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
"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",
|
||||||
"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 |
1301
server/src/adsb-shapes.mjs
Normal file
@@ -4,26 +4,46 @@ 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';
|
||||||
|
|
||||||
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 DB_PATH = resolve(DATA, 'aircraft.db');
|
||||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||||
const SHAPES_PATH = resolve(DATA, 'shapes.json');
|
|
||||||
const history = new Map();
|
|
||||||
const MAX_HISTORY = 500;
|
const MAX_HISTORY = 500;
|
||||||
|
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
||||||
|
|
||||||
|
const ADSB_TTL = 1000;
|
||||||
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
||||||
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
|
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
|
||||||
|
|
||||||
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
||||||
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
||||||
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'southwest', 'lufthansa', 'emirates', 'british'];
|
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
||||||
|
|
||||||
|
let adsbCache = null;
|
||||||
|
let adsbCacheTs = 0;
|
||||||
|
const noRecord = [];
|
||||||
|
const history = new Map();
|
||||||
const milRanges = [];
|
const milRanges = [];
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const cutoff = Date.now() - HISTORY_TTL;
|
||||||
|
for (const [key, trail] of history) {
|
||||||
|
const last = trail.at(-1);
|
||||||
|
if (!last || last.ts < cutoff) history.delete(key);
|
||||||
|
}
|
||||||
|
}, 1000 * 60 * 5);
|
||||||
|
|
||||||
|
function fetchWithTimeout(url, ms = 5000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id = setTimeout(() => controller.abort(), ms);
|
||||||
|
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
|
||||||
|
}
|
||||||
|
|
||||||
async function syncMilitaryRanges() {
|
async function syncMilitaryRanges() {
|
||||||
const { ADSB_URL } = cfg();
|
const { ADSB_URL } = cfg();
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +76,7 @@ export async function initAircraftDb() {
|
|||||||
db = new Database(DB_PATH);
|
db = new Database(DB_PATH);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS aircraft (
|
CREATE TABLE IF NOT EXISTS aircraft (
|
||||||
icao24 TEXT PRIMARY KEY,
|
icao TEXT PRIMARY KEY,
|
||||||
built TEXT,
|
built TEXT,
|
||||||
categoryDescription TEXT,
|
categoryDescription TEXT,
|
||||||
country TEXT,
|
country TEXT,
|
||||||
@@ -89,7 +109,7 @@ export async function initAircraftDb() {
|
|||||||
const csv = fromCsv(text, true);
|
const csv = fromCsv(text, true);
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
INSERT OR REPLACE INTO aircraft VALUES (
|
INSERT OR REPLACE INTO aircraft VALUES (
|
||||||
@icao24, @built, @categoryDescription, @country, @engines,
|
@icao, @built, @categoryDescription, @country, @engines,
|
||||||
@class, @manufacturer, @model, @modes, @operator,
|
@class, @manufacturer, @model, @modes, @operator,
|
||||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
||||||
)
|
)
|
||||||
@@ -100,7 +120,7 @@ export async function initAircraftDb() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
insertMany(csv.map(row => ({
|
insertMany(csv.map(row => ({
|
||||||
icao24: (row.icao24 || '').toUpperCase(),
|
icao: (row.icao24 || '').toUpperCase(),
|
||||||
built: row.built || null,
|
built: row.built || null,
|
||||||
categoryDescription: row.categoryDescription || null,
|
categoryDescription: row.categoryDescription || null,
|
||||||
country: row.country || null,
|
country: row.country || null,
|
||||||
@@ -133,7 +153,7 @@ function isIcaoInMilitaryRange(icao) {
|
|||||||
|
|
||||||
function wordMatch(text, keyword) {
|
function wordMatch(text, keyword) {
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/gi, '\\$&');
|
||||||
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +167,7 @@ export function classifyAircraft(row) {
|
|||||||
const ownerFields = [row.owner];
|
const ownerFields = [row.owner];
|
||||||
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
||||||
|
|
||||||
if (isIcaoInMilitaryRange(row.icao24) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
|
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
|
||||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
|
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
|
||||||
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
||||||
if (row.owner && !row.operator) return 'private';
|
if (row.owner && !row.operator) return 'private';
|
||||||
@@ -155,41 +175,125 @@ export function classifyAircraft(row) {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrichAircraft(a) {
|
async function fetchHexDb(icao) {
|
||||||
if (!a.hex) return a;
|
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
|
||||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(a.hex.toUpperCase());
|
if (!resp.ok) return null;
|
||||||
if (!row) {
|
|
||||||
const resp = await fetch(`https://hexdb.io/api/v1/aircraft/${a.hex.toUpperCase()}`);
|
|
||||||
if (resp.ok) {
|
|
||||||
const found = await resp.json();
|
const found = await resp.json();
|
||||||
const insert = db.prepare(`
|
return {
|
||||||
INSERT OR IGNORE INTO aircraft (icao24, registration, manufacturer, aircraft, model, operator)
|
|
||||||
VALUES (@icao24, @registration, @manufacturer, @aircraft, @model, @operator)
|
|
||||||
`);
|
|
||||||
const mapped = {
|
|
||||||
icao24: a.hex.toUpperCase(),
|
|
||||||
registration: found.Registration || null,
|
registration: found.Registration || null,
|
||||||
manufacturer: found.Manufacturer || null,
|
manufacturer: found.Manufacturer || null,
|
||||||
aircraft: found.ICAOTypeCode || null,
|
aircraft: found.ICAOTypeCode || null,
|
||||||
model: found.Type || null,
|
model: found.Type || null,
|
||||||
operator: found.RegisteredOwners || 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) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getShapes() {
|
async function scrapeHexDatabase(icao) {
|
||||||
return JSON.parse(await fs.promises.readFile(SHAPES_PATH, 'utf8'));
|
const url = `https://hexdatabase.com/h/${icao}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
|
||||||
|
const html = await resp.text();
|
||||||
|
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
||||||
|
if (!tableMatch) return null;
|
||||||
|
|
||||||
|
const $ = cheerio.load(tableMatch[0]);
|
||||||
|
const row = $('tr').filter((_, el) => $(el).text().toLowerCase().includes(icao.toLowerCase())).first();
|
||||||
|
if (!row.length) return null;
|
||||||
|
|
||||||
|
const cells = row.find('td');
|
||||||
|
return {
|
||||||
|
registration: $(cells[1]).text().trim() || null,
|
||||||
|
aircraft: $(cells[2]).text().trim() || null,
|
||||||
|
operator: $(cells[3]).text().trim() || null,
|
||||||
|
serialNumber: $(cells[5]).text().trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function backfillFromSimilar(aircraft) {
|
||||||
|
if (!aircraft) return {};
|
||||||
|
const similar = db.prepare(`
|
||||||
|
SELECT manufacturer, model, engines, categoryDescription, class
|
||||||
|
FROM aircraft
|
||||||
|
WHERE aircraft = ?
|
||||||
|
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
|
||||||
|
LIMIT 1
|
||||||
|
`).get(aircraft);
|
||||||
|
if (!similar) return {};
|
||||||
|
return {
|
||||||
|
manufacturer: similar.manufacturer || null,
|
||||||
|
model: similar.model || null,
|
||||||
|
engines: similar.engines || null,
|
||||||
|
categoryDescription: similar.categoryDescription || null,
|
||||||
|
class: similar.class || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrichAircraft(a) {
|
||||||
|
if (!a.hex) return a;
|
||||||
|
const icao = a.hex.toUpperCase();
|
||||||
|
|
||||||
|
// 1. Check own DB first
|
||||||
|
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
||||||
|
if(row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||||
|
|
||||||
|
// 2. Race the two external sources
|
||||||
|
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||||
|
const hexDbPromise = fetchHexDb(icao);
|
||||||
|
const scrapePromise = scrapeHexDatabase(icao);
|
||||||
|
let found = await hexDbPromise.catch(() => {});
|
||||||
|
if(!found) found = await scrapePromise.catch(() => {});
|
||||||
|
if(!found) {
|
||||||
|
noRecord.push(icao);
|
||||||
|
return { icao, ...a, type: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
|
||||||
|
const merged = { ...row, ...found };
|
||||||
|
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
||||||
|
const similar = backfillFromSimilar(merged.aircraft);
|
||||||
|
Object.assign(merged, similar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save back to DB
|
||||||
|
if (found) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
||||||
|
VALUES (@icao, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
|
||||||
|
ON CONFLICT(icao) DO UPDATE SET
|
||||||
|
registration = COALESCE(excluded.registration, registration),
|
||||||
|
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
|
||||||
|
aircraft = COALESCE(excluded.aircraft, aircraft),
|
||||||
|
model = COALESCE(excluded.model, model),
|
||||||
|
operator = COALESCE(excluded.operator, operator),
|
||||||
|
country = COALESCE(excluded.country, country),
|
||||||
|
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
|
||||||
|
engines = COALESCE(excluded.engines, engines),
|
||||||
|
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
|
||||||
|
class = COALESCE(excluded.class, class)
|
||||||
|
`).run({
|
||||||
|
icao,
|
||||||
|
registration: merged.registration || null,
|
||||||
|
manufacturer: merged.manufacturer || null,
|
||||||
|
aircraft: merged.aircraft || null,
|
||||||
|
model: merged.model || null,
|
||||||
|
operator: merged.operator || null,
|
||||||
|
country: merged.country || null,
|
||||||
|
serialNumber: merged.serialNumber || null,
|
||||||
|
engines: merged.engines || null,
|
||||||
|
categoryDescription: merged.categoryDescription || null,
|
||||||
|
class: merged.class || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getADSB() {
|
export async function getADSB() {
|
||||||
|
if (adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||||
const { ADSB_URL } = cfg();
|
const { ADSB_URL } = cfg();
|
||||||
if (!ADSB_URL) return [];
|
if (!ADSB_URL) return [];
|
||||||
const r = await 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 || [];
|
||||||
|
|
||||||
@@ -203,7 +307,13 @@ export async function getADSB() {
|
|||||||
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 enrichAircraft(a);
|
||||||
|
a.icon = getIcon(a);
|
||||||
|
return a;
|
||||||
|
}));
|
||||||
|
adsbCacheTs = Date.now();
|
||||||
|
return adsbCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getADSBHistory(icao) {
|
export async function getADSBHistory(icao) {
|
||||||
@@ -217,3 +327,43 @@ export async function getADSBRange() {
|
|||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
return j?.actualRange?.last24h?.points || [];
|
return j?.actualRange?.last24h?.points || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getADSBImage(icao) {
|
||||||
|
async function duckduckgo(query) {
|
||||||
|
async function getVqd(query) {
|
||||||
|
const res = await fetch(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||||
|
})
|
||||||
|
const html = await res.text()
|
||||||
|
const match = html.match(/vqd=['"]?([\d-]+)['"]?/)
|
||||||
|
return match?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const vqd = await getVqd(query)
|
||||||
|
if (!vqd) throw new Error('Could not get vqd token')
|
||||||
|
|
||||||
|
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${vqd}&f=,,,,,&p=1`
|
||||||
|
const data = await fetch(url,
|
||||||
|
{headers: {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://duckduckgo.com/'}}
|
||||||
|
).then(resp => resp.json());
|
||||||
|
return data?.results?.[0]?.image || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const c = cfg();
|
||||||
|
let aircraft = (await adsbCache).find(a => a.icao?.toLowerCase() === icao?.toLowerCase());
|
||||||
|
if(!aircraft) aircraft = enrichAircraft({hex: icao});
|
||||||
|
if(!aircraft) return null;
|
||||||
|
|
||||||
|
const generic = duckduckgo(aircraft.model ? `${aircraft.manufacturer} ${aircraft.model}` : `${aircraft.aircraft} Aircraft`);
|
||||||
|
const specific = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao.toLowerCase()}`,
|
||||||
|
{headers: {'User-Agent': `Personal ADSB Feeder + Dashboard (${c.EMAIL})`}}
|
||||||
|
).then(resp => resp.ok ? resp.json() : null);
|
||||||
|
|
||||||
|
let src;
|
||||||
|
if(specific?.photos?.[0]) src = specific.photos[0].thumbnail_large?.src || specific.photos[0].thumbnail?.src;
|
||||||
|
else src = await generic;
|
||||||
|
|
||||||
|
if(!src) return null;
|
||||||
|
return fetch(src).then(resp => resp.blob());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import {cfg} from './config.mjs';
|
||||||
|
|
||||||
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'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 +42,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 +50,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 +64,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 +84,24 @@ export async function getAIS() {
|
|||||||
status
|
status
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
aisCacheTs = Date.now();
|
||||||
|
return aisCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAISImage(mmsi) {
|
||||||
|
console.log(`https://www.vesselfinder.com/vessels/details/${mmsi}`)
|
||||||
|
const res = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||||
|
})
|
||||||
|
const html = await res.text()
|
||||||
|
const match = html.match(/<img.+main-photo.+>/)
|
||||||
|
if(!match) return null;
|
||||||
|
const srcMatch = match[0].match(/src="(.+?)"/)
|
||||||
|
if (!srcMatch) return null;
|
||||||
|
|
||||||
|
let imgUrl = srcMatch[1]
|
||||||
|
if (imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`
|
||||||
|
return fetch(imgUrl, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||||
|
}).then(resp => resp.blob())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ 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',
|
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
||||||
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
|
EMAIL: process.env.EMAIL,
|
||||||
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
|
|
||||||
INFLUX_BUCKET: process.env.INFLUX_BUCKET || 'station',
|
|
||||||
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 pressure_slp = Math.round((pressure + (coords.altitude ?? 0) / 8.5) * 100) / 100;
|
||||||
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
||||||
const bgTemp = seedTemp + tempTrend * (i + 1);
|
const bgTemp = seedTemp + tempTrend * (i + 1);
|
||||||
const temperature = Math.round(bgTemp * 10) / 10;
|
const temperature = Math.round(bgTemp * 10) / 10;
|
||||||
const humidity = forecastHumidity(seedAbsHum, temperature);
|
const humidity = forecastHumidity(seedAbsHum, temperature);
|
||||||
const wind_speed = forecastWind(seedWind, pressureTrend);
|
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
|
||||||
@@ -198,21 +233,45 @@ function summarise(slots) {
|
|||||||
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: Math.max(...temps),
|
temperature: peak(fin('temperature')),
|
||||||
temperature_max: Math.max(...allTemps),
|
temperature_max: peak(finAll('temperature')),
|
||||||
temperature_min: Math.min(...allTemps),
|
temperature_min: Math.min(...finAll('temperature')),
|
||||||
precipitation_chance: Math.round(Math.max(...precips)),
|
humidity: avg(fin('humidity')),
|
||||||
wind_speed: Math.round(Math.max(...winds) * 10) / 10,
|
humidity_abs: avg(fin('humidity_abs')),
|
||||||
wind_gusts: gusts.length ? Math.round(Math.max(...gusts) * 10) / 10 : null,
|
dew_point: avg(fin('dew_point')),
|
||||||
uv_index: uvs.length ? Math.max(...uvs) : null,
|
heat_index: peak(fin('heat_index')),
|
||||||
uv_index_label: dominant.uv_index_label ?? null,
|
vapor_pressure_deficit: avg(fin('vapor_pressure_deficit')),
|
||||||
solar_wm2: solar.length ? Math.round(solar.reduce((a, b) => a + b, 0) / solar.length * 10) / 10 : null,
|
pressure_hpa: avg(fin('pressure_hpa')),
|
||||||
|
pressure_slp: avg(fin('pressure_slp')),
|
||||||
|
pressure_rate: avg(fin('pressure_rate')),
|
||||||
|
storm_trend: slots.at(-1)?.storm_trend ?? 0,
|
||||||
|
wind_speed: peak(fin('wind_speed')),
|
||||||
|
wind_gusts: peak(fin('wind_gusts')),
|
||||||
|
wind_direction: avg(fin('wind_direction')),
|
||||||
|
clouds: avg(fin('clouds')),
|
||||||
|
raining: peak(fin('precipitation_chance')) > 60 ? 'True' : 'False',
|
||||||
|
precipitation: avg(fin('precipitation')),
|
||||||
|
precipitation_chance: Math.round(peak(fin('precipitation_chance'))),
|
||||||
|
accumulation: finAll('precipitation').reduce((a, b) => a + b, 0),
|
||||||
|
frost_risk: slots.find(s => s.frost_risk !== 'None')?.frost_risk ?? slots.at(-1)?.frost_risk ?? 'None',
|
||||||
|
uv_index: peak(fin('uv_index')),
|
||||||
|
uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0),
|
||||||
|
solar_wm2: avg(fin('solar_wm2')),
|
||||||
|
daily_light_integral: Math.round(finAll('daily_light_integral').reduce((a, b) => a + b, 0) * 100) / 100,
|
||||||
|
lux: peak(fin('lux')),
|
||||||
|
visibility: avg(fin('visibility')),
|
||||||
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
||||||
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
||||||
humidity: Math.round(source.map(s => s.humidity).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
daylight: slots.find(s => s.daylight)?.daylight ?? null,
|
||||||
pressure_hpa: Math.round(source.map(s => s.pressure_hpa).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
|
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'},
|
|
||||||
2: {label: 'Partly Cloudy', icon: '02d'},
|
|
||||||
3: {label: 'Overcast', icon: '04d'},
|
|
||||||
45: {label: 'Fog', icon: '50d'},
|
|
||||||
48: {label: 'Icy Fog', icon: '50d'},
|
|
||||||
51: {label: 'Light Drizzle', icon: '09d'},
|
|
||||||
53: {label: 'Drizzle', icon: '09d'},
|
|
||||||
55: {label: 'Heavy Drizzle', icon: '09d'},
|
|
||||||
61: {label: 'Light Rain', icon: '10d'},
|
|
||||||
63: {label: 'Rain', icon: '10d'},
|
|
||||||
65: {label: 'Heavy Rain', icon: '10d'},
|
|
||||||
71: {label: 'Light Snow', icon: '13d'},
|
|
||||||
73: {label: 'Snow', icon: '13d'},
|
|
||||||
75: {label: 'Heavy Snow', icon: '13d'},
|
|
||||||
77: {label: 'Snow Grains', icon: '13d'},
|
|
||||||
80: {label: 'Light Showers', icon: '09d'},
|
|
||||||
81: {label: 'Showers', icon: '09d'},
|
|
||||||
82: {label: 'Heavy Showers', icon: '09d'},
|
|
||||||
85: {label: 'Snow Showers', icon: '13d'},
|
|
||||||
86: {label: 'Heavy Snow Showers', icon: '13d'},
|
|
||||||
95: {label: 'Thunderstorm', icon: '11d'},
|
|
||||||
96: {label: 'Thunderstorm w/ Hail', icon: '11d'},
|
|
||||||
99: {label: 'Thunderstorm w/ Hail', icon: '11d'},
|
|
||||||
};
|
|
||||||
|
|
||||||
export 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 res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`);
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
writeFileSync(path, buf);
|
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}`};
|
|
||||||
}
|
}
|
||||||
|
|||||||
118
server/src/sensors.mjs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import {cfg} from './config.mjs';
|
||||||
|
import {frostRisk} from './openweather.mjs';
|
||||||
|
|
||||||
|
async function query(c, q, start, end) {
|
||||||
|
const url = new URL('/api/v1/query_range', c.DB_HOST);
|
||||||
|
url.searchParams.set('query', q);
|
||||||
|
url.searchParams.set('step', '60s');
|
||||||
|
if (start) url.searchParams.set('start', start);
|
||||||
|
if (end) url.searchParams.set('end', end);
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.result || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryInstant(c, q) {
|
||||||
|
const url = new URL('/api/v1/query', c.DB_HOST);
|
||||||
|
url.searchParams.set('query', q);
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
return json.data?.result || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricToFields(results) {
|
||||||
|
const out = {};
|
||||||
|
for (const r of results) {
|
||||||
|
if(r.metric.__name__.startsWith('system_')) continue;
|
||||||
|
const val = r.value?.[1] ?? r.values?.at(-1)?.[1];
|
||||||
|
if (val !== undefined) out[r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryCurrent() {
|
||||||
|
const c = cfg()
|
||||||
|
const results = await queryInstant(c, `{__name__!=""}`)
|
||||||
|
const fields = metricToFields(results)
|
||||||
|
const dp = fields.dew_point ?? null
|
||||||
|
return {
|
||||||
|
time: new Date(),
|
||||||
|
...fields,
|
||||||
|
frost_risk: frostRisk(fields.temperature ?? 99, dp ?? 99, fields.humidity ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryHourly(start, end) {
|
||||||
|
const c = cfg();
|
||||||
|
const s = Math.floor(start.getTime() / 1000);
|
||||||
|
const e = Math.floor(end.getTime() / 1000);
|
||||||
|
const byTime = {};
|
||||||
|
|
||||||
|
const results = await query(c, `avg_over_time({__name__!=""}[1h])`, s, e);
|
||||||
|
results.forEach(r => {
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
if(r.metric.__name__.startsWith('system_')) return;
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00';
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Object.values(byTime)
|
||||||
|
.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryDaily(start, end) {
|
||||||
|
const c = cfg();
|
||||||
|
const s = Math.floor(start.getTime() / 1000);
|
||||||
|
const e = Math.floor(end.getTime() / 1000);
|
||||||
|
const byTime = {};
|
||||||
|
|
||||||
|
const [means, mins, maxs] = await Promise.all([
|
||||||
|
query(c, `avg_over_time({__name__!=""}[1d])`, s, e),
|
||||||
|
query(c, `min_over_time({__name__!=""}[1d])`, s, e),
|
||||||
|
query(c, `max_over_time({__name__!=""}[1d])`, s, e),
|
||||||
|
]);
|
||||||
|
|
||||||
|
means.forEach(r => {
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
if(r.metric.__name__.startsWith('system_')) return;
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mins.forEach(r => {
|
||||||
|
if (r.metric.__name__ !== 'temperature') return;
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time].env_temp_min_c = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
maxs.forEach(r => {
|
||||||
|
if (r.metric.__name__ !== 'temperature') return;
|
||||||
|
r.values.forEach(([ts, val]) => {
|
||||||
|
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||||
|
if (!byTime[time]) byTime[time] = {time};
|
||||||
|
byTime[time].env_temp_max_c = parseFloat(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(byTime)
|
||||||
|
.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCoords() {
|
||||||
|
const c = cfg();
|
||||||
|
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
||||||
|
const fields = metricToFields(results);
|
||||||
|
|
||||||
|
if (fields.latitude && fields.longitude) {
|
||||||
|
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||||
|
}
|
||||||
|
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
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 {getADSBImage, getADSB, getADSBHistory, getADSBRange, 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';
|
|
||||||
|
|
||||||
// ── 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 +30,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();
|
||||||
@@ -50,7 +49,7 @@ function filterArr(arr, fields) {
|
|||||||
|
|
||||||
// ── Current ───────────────────────────────────────────────────────────────────
|
// ── Current ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 +57,129 @@ 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 ────────────────────────────────────────────────────────────────────
|
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/hourly', async (req, res) => {
|
app.get('/api/hourly', asyncHandler(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 = []
|
|
||||||
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 ─────────────────────────────────────────────────────────────────────
|
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/daily', async (req, res) => {
|
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||||
const { fields } = req.query
|
const { fields } = req.query
|
||||||
|
const coords = await getCoords()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayStart = new Date(now)
|
const todayStart = new Date(now)
|
||||||
todayStart.setHours(0, 0, 0, 0)
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
const 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/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 Proxy ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/adsb', async (req, res) => res.json(await getADSB()));
|
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
||||||
app.get('/api/adsb/:icao', async (req, res) => res.json(await getADSBHistory(req.params.icao)));
|
app.get('/api/adsb-image/:icao', asyncHandler(async (req, res) => {
|
||||||
app.get('/api/ais', async (req, res) => res.json(await getAIS()));
|
const blob = await getADSBImage(req.params.icao);
|
||||||
app.get('/api/range', async (req, res) => res.json(await getADSBRange()));
|
if (!blob) return res.status(404).send('No image found');
|
||||||
app.get('/api/vehicles', async (req, res) => res.json(await getShapes()));
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||||
|
res.contentType(blob.type).send(buffer);
|
||||||
|
}));
|
||||||
|
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getADSBHistory(req.params.icao))));
|
||||||
|
app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS())));
|
||||||
|
app.get('/api/ais-image/:mmsi', asyncHandler(async (req, res) => {
|
||||||
|
const blob = await getAISImage(req.params.mmsi);
|
||||||
|
if (!blob) return res.status(404).send('No image found');
|
||||||
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||||
|
res.contentType(blob.type).send(buffer);
|
||||||
|
}));
|
||||||
|
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
||||||
|
|
||||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
// ── 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)' },
|
||||||
|
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||||
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||||
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||||
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||||
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' },
|
||||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
|
||||||
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
|
|
||||||
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
||||||
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
||||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
|
// Light & Solar
|
||||||
// Light
|
|
||||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||||
uv_index: { type: 'number', description: 'UV index' },
|
uv_index: { type: 'number', description: 'UV index' },
|
||||||
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
|
||||||
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
|
||||||
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
|
|
||||||
clouds: { type: 'number', description: 'Estimated cloud cover (%)' },
|
|
||||||
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] },
|
|
||||||
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
|
||||||
daylight: { type: 'number', description: 'Hours above daylight threshold today' },
|
|
||||||
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
|
||||||
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||||
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
|
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||||
// Wind (Open-Meteo)
|
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||||
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
|
daily_light_integral: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||||
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
|
clouds: { type: 'number', description: 'Cloud cover fraction (0–1)' },
|
||||||
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' },
|
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||||
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' },
|
daylight: { type: 'number', description: 'Hours of daylight' },
|
||||||
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' },
|
// Weather
|
||||||
// Forecast
|
label: { type: 'string', description: 'Human readable weather label' },
|
||||||
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
|
icon: { type: 'string', description: 'OWM icon code e.g. 01d' },
|
||||||
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
|
raining: { type: 'string', enum: ['True','False'] },
|
||||||
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. /icons/01d.png' },
|
precipitation: { type: 'number', description: 'Precipitation (mm)' },
|
||||||
forecast_precipitation_mm: { type: 'number', description: 'Precipitation (mm)' },
|
precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' },
|
||||||
forecast_precipitation_probability: { type: 'number', description: 'Precipitation probability (%)' },
|
accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' },
|
||||||
forecast_snowfall_mm: { type: 'number', description: 'Snowfall (mm)' },
|
storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' },
|
||||||
forecast_snow_depth_m: { type: 'number', description: 'Snow depth (m)' },
|
// Wind
|
||||||
forecast_cape: { type: 'number', description: 'Convective available potential energy (J/kg)' },
|
wind_speed: { type: 'number', description: 'Wind speed (km/h)' },
|
||||||
forecast_freezing_level_m: { type: 'number', description: 'Freezing level altitude (m)' },
|
wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||||
forecast_evapotranspiration_mm:{ type: 'number', description: 'Evapotranspiration (mm)' },
|
wind_direction: { type: 'number', description: 'Wind direction (°)' },
|
||||||
// Seismic
|
|
||||||
seismic_ax: { type: 'number', description: 'Peak acceleration X axis (g)' },
|
|
||||||
seismic_ay: { type: 'number', description: 'Peak acceleration Y axis (g)' },
|
|
||||||
seismic_az: { type: 'number', description: 'Peak acceleration Z axis (g, includes gravity)' },
|
|
||||||
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g, gravity removed)' },
|
|
||||||
// Compass
|
|
||||||
compass_heading: { type: 'number', description: 'Magnetic heading (°)' },
|
|
||||||
compass_x: { type: 'number' },
|
|
||||||
compass_y: { type: 'number' },
|
|
||||||
compass_z: { type: 'number' },
|
|
||||||
// Ground / Accumulation
|
|
||||||
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
|
||||||
lidar_strength: { type: 'number', description: 'LIDAR signal strength' },
|
|
||||||
ground_calibration: { type: 'number', description: 'Calibrated baseline distance (cm)' },
|
|
||||||
accumulation: { type: 'number', description: 'Snow/flood depth (cm)' },
|
|
||||||
ground_accumulation_type: { type: 'string', enum: ['snow','slush','ice','flood'], description: 'Accumulation type' },
|
|
||||||
// Lightning
|
// Lightning
|
||||||
|
lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||||
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||||
lightning_energy: { type: 'number', description: 'Lightning energy' },
|
// Sun
|
||||||
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||||
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
|
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||||
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
|
sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' },
|
||||||
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (0–7)' },
|
sunset: { type: 'string', format: 'date-time', description: 'Sunset time' },
|
||||||
|
daytime: { type: 'boolean', description: 'True if between sunrise and sunset' },
|
||||||
|
// Moon
|
||||||
|
moon_phase: { type: 'string', description: 'Moon phase name' },
|
||||||
|
moon_illumination: { type: 'number', description: 'Moon illumination (%)' },
|
||||||
|
moon_elevation: { type: 'number', description: 'Moon elevation (°)' },
|
||||||
|
moon_azimuth: { type: 'number', description: 'Moon azimuth (°)' },
|
||||||
|
moonrise: { type: 'string', format: 'date-time', description: 'Moonrise time' },
|
||||||
|
moonset: { type: 'string', format: 'date-time', description: 'Moonset time' },
|
||||||
|
moon_new: { type: 'string', format: 'date-time', description: 'Next new moon' },
|
||||||
|
moon_full: { type: 'string', format: 'date-time', description: 'Next full moon' },
|
||||||
|
// Seismic
|
||||||
|
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g)' },
|
||||||
|
// Ground
|
||||||
|
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||||
// GPS
|
// GPS
|
||||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||||
gps_satellites: { type: 'number', description: 'Satellites in view' },
|
|
||||||
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
|
|
||||||
gps_heading: { type: 'number', description: 'GPS heading (°)' },
|
|
||||||
// Sun
|
|
||||||
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
|
||||||
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
|
||||||
sun_sunrise: { type: 'string', description: 'Sunrise time (ISO8601)' },
|
|
||||||
sun_sunset: { type: 'string', description: 'Sunset time (ISO8601)' },
|
|
||||||
sun_solar_noon: { type: 'string', description: 'Solar noon (ISO8601)' },
|
|
||||||
sun_day_length_hours: { type: 'number', description: 'Day length (hours)' },
|
|
||||||
sun_golden_hour_morning_start: { type: 'string' },
|
|
||||||
sun_golden_hour_morning_end: { type: 'string' },
|
|
||||||
sun_golden_hour_evening_start: { type: 'string' },
|
|
||||||
sun_golden_hour_evening_end: { type: 'string' },
|
|
||||||
sun_is_day: { type: 'number', description: '1 if daytime' },
|
|
||||||
sun_ssn: { type: 'number', description: 'Estimated sunspot number' },
|
|
||||||
sun_activity_label: { type: 'string', enum: ['Very Low','Low','Moderate','High','Very High'] },
|
|
||||||
// Moon
|
|
||||||
moon_phase: { type: 'string', description: 'Moon phase name' },
|
|
||||||
moon_illumination_pct: { type: 'number', description: 'Moon illumination (%)' },
|
|
||||||
moon_moonrise: { type: 'string', description: 'Moonrise time (ISO8601)' },
|
|
||||||
moon_moonset: { type: 'string', description: 'Moonset time (ISO8601)' },
|
|
||||||
moon_next_full: { type: 'string', description: 'Next full moon (ISO8601)' },
|
|
||||||
moon_next_new: { type: 'string', description: 'Next new moon (ISO8601)' },
|
|
||||||
// Season
|
|
||||||
season_next_event: { type: 'string', description: 'Next solstice/equinox name' },
|
|
||||||
season_next_event_date: { type: 'string' },
|
|
||||||
season_last_event: { type: 'string' },
|
|
||||||
season_last_event_date: { type: 'string' },
|
|
||||||
// Space weather
|
|
||||||
space_kp_index: { type: 'number', description: 'Planetary K index (0–9)' },
|
|
||||||
space_ap_index: { type: 'number', description: 'Ap geomagnetic index' },
|
|
||||||
space_geomagnetic_storm: { type: 'string', enum: ['None','Minor','Moderate','Strong','Severe'] },
|
|
||||||
space_solar_wind_speed_kms: { type: 'number', description: 'Solar wind speed (km/s)' },
|
|
||||||
space_solar_wind_density: { type: 'number', description: 'Solar wind proton density (p/cm³)' },
|
|
||||||
space_solar_wind_temp: { type: 'number', description: 'Solar wind temperature (K)' },
|
|
||||||
space_imf_bz: { type: 'number', description: 'IMF Bz component (nT)' },
|
|
||||||
space_imf_bt: { type: 'number', description: 'IMF total field Bt (nT)' },
|
|
||||||
space_solar_flux_f107: { type: 'number', description: 'Solar flux F10.7 index' },
|
|
||||||
// Marine
|
|
||||||
wave_height: { type: 'number', description: 'Significant wave height (m)' },
|
|
||||||
wave_direction: { type: 'number', description: 'Wave direction (°)' },
|
|
||||||
wave_period: { type: 'number', description: 'Wave period (s)' },
|
|
||||||
swell_wave_height: { type: 'number', description: 'Swell wave height (m)' },
|
|
||||||
swell_wave_direction: { type: 'number', description: 'Swell direction (°)' },
|
|
||||||
swell_wave_period: { type: 'number', description: 'Swell period (s)' },
|
|
||||||
wind_wave_height: { type: 'number', description: 'Wind wave height (m)' },
|
|
||||||
wave_height_max: { type: 'number', description: 'Daily max wave height (m)' },
|
|
||||||
// AQ
|
|
||||||
pm10: { type: 'number', description: 'PM10 (μg/m³)' },
|
|
||||||
pm2_5: { type: 'number', description: 'PM2.5 (μg/m³)' },
|
|
||||||
carbon_monoxide: { type: 'number', description: 'CO (μg/m³)' },
|
|
||||||
nitrogen_dioxide: { type: 'number', description: 'NO₂ (μg/m³)' },
|
|
||||||
sulphur_dioxide: { type: 'number', description: 'SO₂ (μg/m³)' },
|
|
||||||
ozone: { type: 'number', description: 'O₃ (μg/m³)' },
|
|
||||||
aerosol_optical_depth: { type: 'number' },
|
|
||||||
dust: { type: 'number', description: 'Dust (μg/m³)' },
|
|
||||||
alder_pollen: { type: 'number', description: 'Alder pollen (grains/m³)' },
|
|
||||||
birch_pollen: { type: 'number', description: 'Birch pollen (grains/m³)' },
|
|
||||||
grass_pollen: { type: 'number', description: 'Grass pollen (grains/m³)' },
|
|
||||||
mugwort_pollen: { type: 'number', description: 'Mugwort pollen (grains/m³)' },
|
|
||||||
olive_pollen: { type: 'number', description: 'Olive pollen (grains/m³)' },
|
|
||||||
ragweed_pollen: { type: 'number', description: 'Ragweed pollen (grains/m³)' },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
setup.sh
@@ -3,22 +3,46 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
USER_NAME="$(whoami)"
|
USER_NAME="$(whoami)"
|
||||||
|
VM_DIR="/opt/victoriametrics"
|
||||||
|
|
||||||
echo "🔧 Setting up Weather Station..."
|
echo "🔧 Setting up Weather Station..."
|
||||||
|
|
||||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v influxd &> /dev/null; then
|
if ! systemctl is-enabled --quiet victoriametrics 2>/dev/null; then
|
||||||
echo "📦 Installing InfluxDB..."
|
echo "📦 Installing VictoriaMetrics..."
|
||||||
curl https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null
|
sudo mkdir -p $VM_DIR/data
|
||||||
echo "deb [signed-by=/usr/share/keyrings/influxdata-archive-keyring.gpg] https://repos.influxdata.com/debian stable main" | sudo tee /etc/apt/sources.list.d/influxdata.list
|
cd $VM_DIR
|
||||||
sudo apt update && sudo apt install -y influxdb2
|
wget -qO vm.tar.gz "https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/v1.146.0/victoria-metrics-linux-arm64-v1.146.0.tar.gz"
|
||||||
sudo systemctl enable --now influxdb
|
sudo tar -xzf vm.tar.gz
|
||||||
|
sudo chmod +x $VM_DIR/victoria-metrics-prod
|
||||||
|
rm /tmp/vm.tar.gz
|
||||||
|
|
||||||
|
sudo tee /etc/systemd/system/victoriametrics.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=VictoriaMetrics
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=$VM_DIR/victoria-metrics-prod \
|
||||||
|
-storageDataPath=$VM_DIR/data \
|
||||||
|
-httpListenAddr=:8428 \
|
||||||
|
-retentionPeriod=12
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now victoriametrics
|
||||||
else
|
else
|
||||||
echo "✅ InfluxDB already installed, skipping..."
|
echo "✅ VictoriaMetrics already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
# ── Server / Node.js ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if ! command -v node &> /dev/null; then
|
if ! command -v node &> /dev/null; then
|
||||||
echo "📦 Installing Node.js..."
|
echo "📦 Installing Node.js..."
|
||||||
@@ -28,7 +52,47 @@ else
|
|||||||
echo "✅ Node.js already installed, skipping..."
|
echo "✅ Node.js already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Python venv ───────────────────────────────────────────────────────────────
|
if [ ! -d "$SCRIPT_DIR/server/public/index.html" ]; then
|
||||||
|
echo "🌐 Building Client..."
|
||||||
|
cd "$SCRIPT_DIR/client"
|
||||||
|
npm ci
|
||||||
|
npx vite build
|
||||||
|
else
|
||||||
|
echo "✅ Client installed, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$SCRIPT_DIR/server/node_modules" ]; then
|
||||||
|
echo "🌐 Installing Server..."
|
||||||
|
cd "$SCRIPT_DIR/server"
|
||||||
|
npm ci
|
||||||
|
else
|
||||||
|
echo "✅ Server installed, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! systemctl is-enabled --quiet weather-station 2>/dev/null; then
|
||||||
|
echo "⚙️ Installing server service..."
|
||||||
|
sudo tee /etc/systemd/system/weather-station.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Weather Station API
|
||||||
|
After=network.target victoriametrics.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=$SCRIPT_DIR/server
|
||||||
|
ExecStart=npm run start
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=$USER_NAME
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now weather-sensors
|
||||||
|
else
|
||||||
|
echo "✅ Server service already installed, skipping..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Sensors / Python venv ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
if [ ! -d "$SCRIPT_DIR/sensors/venv" ]; then
|
||||||
echo "🐍 Setting up Python environment..."
|
echo "🐍 Setting up Python environment..."
|
||||||
@@ -40,14 +104,12 @@ else
|
|||||||
echo "✅ Python environment already set up, skipping..."
|
echo "✅ Python environment already set up, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Systemd service ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
if ! systemctl is-enabled --quiet weather-sensors 2>/dev/null; then
|
||||||
echo "⚙️ Installing systemd service..."
|
echo "⚙️ Installing sensor service..."
|
||||||
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
sudo tee /etc/systemd/system/weather-sensors.service > /dev/null <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Weather Station Sensors
|
Description=Weather Station Sensors
|
||||||
After=network.target
|
After=network.target victoriametrics.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=$SCRIPT_DIR/sensors
|
WorkingDirectory=$SCRIPT_DIR/sensors
|
||||||
@@ -62,10 +124,9 @@ EOF
|
|||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now weather-sensors
|
sudo systemctl enable --now weather-sensors
|
||||||
else
|
else
|
||||||
echo "✅ Systemd service already installed, skipping..."
|
echo "✅ Sensor service already installed, skipping..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ All done!"
|
echo "✅ All done!"
|
||||||
echo " InfluxDB UI: http://localhost:8086"
|
echo " VictoriaMetrics UI: http://localhost:8428"
|
||||||
echo " Sensor logs: journalctl -fu weather-sensors"
|
echo " Sensor logs: journalctl -fu weather-sensors"
|
||||||
|
|
||||||
|
|||||||