Compare commits
191 Commits
7e36ad5ce0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed3a43a53 | |||
| f3ca75decb | |||
| 88e6cc26f5 | |||
| f5cfd492f3 | |||
| 1494b43ed5 | |||
| 8f5e8edd2e | |||
| 54e86e550c | |||
| 09a08d94aa | |||
| 11012afbe9 | |||
| d4cdb15737 | |||
| b62f7da629 | |||
| 43848c4b35 | |||
| a1c714847d | |||
| 1038b06a46 | |||
| d5697c4e20 | |||
| 7e8ea62f64 | |||
| be40d845bd | |||
| 54c53a5822 | |||
| 0a722edcc6 | |||
| 3e44dbb8eb | |||
| fec6ee8a48 | |||
| 1451145408 | |||
| 4114a0adad | |||
| 7edd0ab8c4 | |||
| 5bd2e786c2 | |||
| bbcc62af7d | |||
| 91205d8050 | |||
| ce86fdfcc8 | |||
| 3838a73e75 | |||
| 76bd8b30c7 | |||
| 30c866b599 | |||
| 9471e904ce | |||
| 8740a1cd69 | |||
| 773d89c378 | |||
| d398cd3a04 | |||
| 3d9a8f82ce | |||
| 96a86c4d12 | |||
| 835856e74c | |||
| 34c52ffb9f | |||
| 946ccd67e1 | |||
| 2ccacf563c | |||
| 3750ece535 | |||
| 93dce28cca | |||
| 73f7908aab | |||
| effdd81114 | |||
| 6bc4d920f8 | |||
| d9fbe0568d | |||
| 404f3971c4 | |||
| 570ed1e749 | |||
| 8364918a0c | |||
| 85909e550b | |||
| 221a200e79 | |||
| bb11b92ec9 | |||
| 579eed746e | |||
| a4f13d87ab | |||
| 85b1d66c02 | |||
| d34f3cfa0e | |||
| f3356ff05c | |||
| c18a4189af | |||
| c5477d05ad | |||
| bea55ac448 | |||
| 765335369c | |||
| 3944daa3ed | |||
| e767a08bb5 | |||
| e4c3da05be | |||
| 44e648ecef | |||
| e4042f06d6 | |||
| 943a6ee085 | |||
| 225002046b | |||
| 9e7afbd98c | |||
| 62d2c28039 | |||
| 74c5e48d8a | |||
| ce332be419 | |||
| 36eda62e7f | |||
| 6eefd1b86b | |||
| 0c0ebfd20b | |||
| 74ddb37446 | |||
| 73f1453daf | |||
| aeffa30417 | |||
| 74a8018f01 | |||
| efd08cbabe | |||
| 738f13e745 | |||
| 216420ad80 | |||
| 5947b61b8f | |||
| 06be7c0b4f | |||
| 70701483f5 | |||
| 1ee386949c | |||
| 9b7b7f49a7 | |||
| 80b9b1bfcc | |||
| b87bcbabb7 | |||
| f6f8a252dc | |||
| a73c5ff95b | |||
| 14d9a43c32 | |||
| 5f462700ad | |||
| 0c2857306f | |||
| 159bf62876 | |||
| a38098e3b7 | |||
| 29ccc7aba9 | |||
| a2441fdb37 | |||
| 40b6a5488f | |||
| 1f5ae434b3 | |||
| 5c51dd52de | |||
| 1b65a8619c | |||
| f2afc09fac | |||
| 86cdaab197 | |||
| 3887654211 | |||
| 7c08d2ab60 | |||
| 5860d02434 | |||
| d3ad4e15c6 | |||
| bd073a4613 | |||
| 257232f6f1 | |||
| afb530a999 | |||
| 19d2dd2d9d | |||
| e91876b7b7 | |||
| 1d3a3be225 | |||
| 84707b2777 | |||
| b8ffc20360 | |||
| 08319ce277 | |||
| 8bece21ddb | |||
| 60f76b06d4 | |||
| d6e799e956 | |||
| ff44b7fed5 | |||
| 82c10d9278 | |||
| fdb752073f | |||
| 704424530b | |||
| 968cd1a669 | |||
| 1d018db749 | |||
| db29f0fdec | |||
| 83c2e655f6 | |||
| d20ef97cf1 | |||
| f377d0b1c8 | |||
| c783a79414 | |||
| cab3cec0e4 | |||
| fedd7ef5e2 | |||
| caf721db6e | |||
| 64bc62b511 | |||
| 5e044dfd68 | |||
| fabf536db4 | |||
| e97dffd874 | |||
| fa66a63d63 | |||
| 50ba52a6d3 | |||
| 4c36062b73 | |||
| fd534ede69 | |||
| 3620c446d0 | |||
| 1c0416c668 | |||
| 122bbab555 | |||
| a162ecf692 | |||
| 1f50ba606b | |||
| 3b355d89cf | |||
| 9e901f8266 | |||
| a5b4f7da45 | |||
| debf68d795 | |||
| d4266baf25 | |||
| 28f69ec1e4 | |||
| 694b9bad24 | |||
| 367c35461b | |||
| 56cbfb3967 | |||
| a69867406b | |||
| 5794eca9f1 | |||
| 7dfd5e6e1f | |||
| f5664fe542 | |||
| 22c391a41c | |||
| 04d5b44f87 | |||
| 5f71bd182a | |||
| 9a9fde7caa | |||
| 88f86646af | |||
| 7dca2a200b | |||
| 7418616a5b | |||
| 2c137348d8 | |||
| 8c6e7cdbcd | |||
| 1f9cdda59f | |||
| de3be07eea | |||
| 89f3f156ce | |||
| d540ab5efb | |||
| 25c6bcce7b | |||
| 9e8f1a7c12 | |||
| 9ac343d9aa | |||
| f3f1c516e0 | |||
| ab9521dd91 | |||
| 6f35c647c2 | |||
| 2476675493 | |||
| 3cf62b8e11 | |||
| 8019dabe2a | |||
| 811780125e | |||
| fa79835572 | |||
| 97cf840847 | |||
| 67c46a935e | |||
| 87d4870235 | |||
| a9f0bd6c9a | |||
| 99467ed321 | |||
| 7c27f74614 |
2
.gitignore
vendored
@@ -4,6 +4,8 @@
|
||||
|
||||
dist
|
||||
node_modules
|
||||
server/data/*
|
||||
server/public/*
|
||||
|
||||
sensors/bin
|
||||
sensors/include
|
||||
|
||||
@@ -2,11 +2,29 @@
|
||||
<html lang="">
|
||||
<head>
|
||||
<title>Weather Station</title>
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="description" content="Local Weather & Forecasting">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Weather Station">
|
||||
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Weather Station">
|
||||
<meta property="og:description" content="Local Weather & Forecasting">
|
||||
<meta property="og:image" content="/favicon.png">
|
||||
<meta property="og:url" content="./">
|
||||
|
||||
<!-- Twitter/X card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Weather Station">
|
||||
<meta name="twitter:description" content="Local Weather & Forecasting">
|
||||
<meta name="twitter:image" content="/favicon.png">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
101
client/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@ztimson/css-utils": "^1.3.5",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"ol-mapbox-style": "^13.4.1",
|
||||
"vue": "^3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -589,6 +590,40 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
|
||||
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
|
||||
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -950,7 +985,6 @@
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
|
||||
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
@@ -1274,7 +1308,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
@@ -1558,7 +1591,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
|
||||
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reference-spec-reader": "^0.2.0",
|
||||
@@ -1868,7 +1900,6 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
@@ -1940,7 +1971,6 @@
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -1972,7 +2002,6 @@
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
|
||||
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@petamoriken/float16": "^3.9.3",
|
||||
@@ -2141,6 +2170,12 @@
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -2165,7 +2200,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
@@ -2461,6 +2495,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-to-css-font": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-3.2.0.tgz",
|
||||
"integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/memorystream": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
||||
@@ -2470,6 +2510,15 @@
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -2572,7 +2621,6 @@
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
|
||||
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.0"
|
||||
@@ -2603,7 +2651,6 @@
|
||||
"version": "10.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
|
||||
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
@@ -2618,6 +2665,22 @@
|
||||
"url": "https://opencollective.com/openlayers"
|
||||
}
|
||||
},
|
||||
"node_modules/ol-mapbox-style": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.4.1.tgz",
|
||||
"integrity": "sha512-da4FKZUXoXMzK5ADeRHAXzd3bTeiKg/Y9LBOTB6qNTP62MQm93y6ISrIXzOS9atfGgQMEHJ2pCgP272IMeZCXA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.4.1",
|
||||
"mapbox-to-css-font": "^3.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ahocevar"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||
@@ -2643,14 +2706,12 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"dev": true,
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-headers": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
@@ -2681,7 +2742,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
@@ -2776,14 +2836,12 @@
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2796,14 +2854,12 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^3.0.0"
|
||||
@@ -2842,14 +2898,12 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
|
||||
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
@@ -3417,6 +3471,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3476,7 +3536,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
|
||||
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -3779,7 +3838,6 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/which": {
|
||||
@@ -3819,7 +3877,6 @@
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
@@ -3833,7 +3890,6 @@
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
|
||||
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zarrita/storage": "^0.2.0",
|
||||
@@ -3844,7 +3900,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
|
||||
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
|
||||
"dev": true,
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@ztimson/css-utils": "^1.3.5",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"ol-mapbox-style": "^13.4.1",
|
||||
"vue": "^3.5.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,9 +25,9 @@
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-vue-devtools": "^8.1.2",
|
||||
"vue": "^3.5.38",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-tsc": "^3.3.5",
|
||||
"vue": "^3.5.38"
|
||||
"vue-tsc": "^3.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
|
||||
370
client/public/dark-theme.json
Normal file
@@ -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"]
|
||||
}
|
||||
@@ -1,48 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import '@ztimson/css-utils/dist/css-utils.min.css';
|
||||
|
||||
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
function toggleDark() { dark.value = !dark.value }
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #f8fafc;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--hover: #f1f5f9;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #111111;
|
||||
--border: #222222;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #64748b;
|
||||
--hover: #1a1a1a;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-wrap {
|
||||
height: 100vh;
|
||||
@@ -53,7 +17,7 @@ body {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 500;
|
||||
z-index: 50;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,4 +1,41 @@
|
||||
@import './base.css';
|
||||
@import '@ztimson/css-utils/dist/css-utils.min.css';
|
||||
|
||||
::-webkit-scrollbar { height: 4px; width: 4px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #e5e9ec;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--hover: #f1f5f9;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #111111;
|
||||
--border: #222222;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #64748b;
|
||||
--hover: #1a1a1a;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
@@ -27,3 +64,25 @@ a,
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
16
client/src/components/AirQuality.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import {useWeather} from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const {current: d} = useWeather();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card" v-if="d">
|
||||
<div class="card-title">💨 Air Quality</div>
|
||||
|
||||
<MetricRow label="AQI" :value="d.air_quality" metric-key="air_quality" :data="d" />
|
||||
<MetricRow label="Label" :value="d.air_quality_label" />
|
||||
<MetricRow label="Resistance (Ohms)" :value="d.air_quality_ohms?.toLocaleString()" metric-key="air_quality_ohms" :data="d" />
|
||||
</div>
|
||||
</template>
|
||||
363
client/src/components/Aircraft.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const BASE_SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
|
||||
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
|
||||
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
|
||||
}
|
||||
const BASE_ALTITUDE_UNITS = {
|
||||
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
|
||||
feet: { label: 'FT', convert: (v: number) => v },
|
||||
}
|
||||
const BASE_VERTICAL_UNITS = {
|
||||
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
|
||||
fps: { label: 'FT/S', convert: (v: number) => v },
|
||||
}
|
||||
|
||||
const photoError = ref(false);
|
||||
const UNIT_KEY = 'at_units'
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
|
||||
}
|
||||
|
||||
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
// ── Units ─────────────────────────────────────────────────────────────────────
|
||||
const prefs = ref(loadUnits())
|
||||
function saveAndSet(p: any) {
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
|
||||
}
|
||||
function cycleSpeed() { const keys = Object.keys(BASE_SPEED_UNITS); const p = loadUnits(); p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]; saveAndSet(p) }
|
||||
function cycleAltitude() { const keys = Object.keys(BASE_ALTITUDE_UNITS); const p = loadUnits(); p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]; p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'; saveAndSet(p) }
|
||||
function cycleVertical() { const keys = Object.keys(BASE_VERTICAL_UNITS); const p = loadUnits(); p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]; p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'; saveAndSet(p) }
|
||||
|
||||
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
|
||||
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
|
||||
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
|
||||
|
||||
// ── Plane data ────────────────────────────────────────────────────────────────
|
||||
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
|
||||
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
|
||||
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
|
||||
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
|
||||
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
|
||||
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
|
||||
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || '')
|
||||
const operator = computed(() => props.plane.operator || props.plane.owner || '')
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const header = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||
e.preventDefault()
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||
}
|
||||
function onMouseUp() { isDragging.value = false }
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('ap-close')) return
|
||||
const touch: any = e.touches[0]
|
||||
const sx = touch.clientX, sy = touch.clientY
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||
if (header.value) header.value.style.opacity = '0.8'
|
||||
}, 500)
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
const touch: any = e.touches[0]
|
||||
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onTouchEnd() {
|
||||
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
|
||||
// ── Navball ───────────────────────────────────────────────────────────────────
|
||||
const navball = computed(() => {
|
||||
const data = props.plane
|
||||
const heading = data.heading ?? data.track ?? 0
|
||||
const airspeedKnots = data.speed || data.gs || 0
|
||||
const vertRateFps = data.climb || data.baro_rate || 0
|
||||
const groundSpeedFps = airspeedKnots * 1.68781
|
||||
const pitchRad = groundSpeedFps > 0 ? Math.atan(vertRateFps / groundSpeedFps) : 0
|
||||
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
||||
|
||||
const pitchLines: string[] = []
|
||||
for (let p = -30; p <= 30; p += 5) {
|
||||
if (p === 0) continue
|
||||
const isMajor = p % 10 === 0
|
||||
const [start, end] = [85, 115]
|
||||
if (isMajor) {
|
||||
pitchLines.push(`
|
||||
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||
`)
|
||||
} else {
|
||||
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const rollTicks: string[] = []
|
||||
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
|
||||
const rad = angle * Math.PI / 180
|
||||
const a = Math.abs(angle)
|
||||
if (a === 45) {
|
||||
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
|
||||
} else {
|
||||
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
|
||||
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
|
||||
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
|
||||
const rad = offset * Math.PI / 180
|
||||
const x = 100 + 70 * Math.sin(rad)
|
||||
const y = 100 - 70 * Math.cos(rad)
|
||||
const h = Math.round((offset + 360) % 360)
|
||||
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
|
||||
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<svg width="200" height="220" viewBox="0 0 200 220">
|
||||
<defs>
|
||||
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
|
||||
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1e90ff"/>
|
||||
<stop offset="100%" style="stop-color:#4169e1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#8b4513"/>
|
||||
<stop offset="100%" style="stop-color:#654321"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
|
||||
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
|
||||
<g transform="translate(0,25)" clip-path="url(#navballClip)">
|
||||
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
|
||||
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
|
||||
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
|
||||
${pitchLines.join('')}
|
||||
</g>
|
||||
<g transform="translate(0,25)">${rollTicks.join('')}</g>
|
||||
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
|
||||
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
|
||||
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
|
||||
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
|
||||
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<!-- Header (drag handle) -->
|
||||
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="ap-callsign">
|
||||
✈️
|
||||
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
|
||||
<span v-else>N/A</span>
|
||||
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
|
||||
</h3>
|
||||
<button class="ap-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="ap-meta flex-r justify-between">
|
||||
<div class="flex-c">
|
||||
<span>{{ operator || 'Unknown Owner' }}</span>
|
||||
<span>{{ plane.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span>{{ description || 'Unknown Aircraft' }}</span>
|
||||
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} • {{ plane.type || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="ap-photo-wrap">
|
||||
<img class="ap-photo" :src="BASE + '/api/adsb-image/' + plane.icao" :alt="callsign" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="ap-body">
|
||||
<div class="ap-gauges">
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Air Speed</div>
|
||||
<div class="ap-gauge" @click.stop="cycleSpeed">
|
||||
<span class="ap-gauge-val">{{ speedVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Altitude</div>
|
||||
<div class="ap-gauge" @click.stop="cycleAltitude">
|
||||
<span class="ap-gauge-val">{{ altVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ altUnit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-gauge-wrap">
|
||||
<div class="ap-gauge-label">Climb</div>
|
||||
<div class="ap-gauge" @click.stop="cycleVertical">
|
||||
<span class="ap-gauge-val">{{ climbVal }}</span>
|
||||
<span class="ap-gauge-unit">{{ vertical.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-navball" v-html="navball" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aircraft-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.ap-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.ap-callsign {
|
||||
margin: 0 24px 0 0;
|
||||
color: #7eeee1;
|
||||
font-size: 16px;
|
||||
}
|
||||
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
|
||||
|
||||
.ap-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
}
|
||||
|
||||
.ap-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ap-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
/* ── Photo ── */
|
||||
.ap-photo-wrap {
|
||||
max-width: 360px;
|
||||
max-height: 160px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
|
||||
.ap-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ap-gauges { flex: 0 0 110px; }
|
||||
.ap-gauge-wrap { margin-bottom: 12px; }
|
||||
|
||||
.ap-gauge-label {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.ap-gauge {
|
||||
padding: 6px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border: 2px solid #0f0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.ap-gauge:hover { border-color: #0ff; }
|
||||
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
|
||||
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
|
||||
.ap-navball { flex: 1; margin-top: 1rem; }
|
||||
</style>
|
||||
@@ -1,29 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref} from 'vue';
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getPressureTrend} from '@/services/units.ts';
|
||||
import {inject, onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
const data = ref<any>({});
|
||||
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'NE';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'E';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'SE';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'S';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'SW';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'W';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'NW';
|
||||
return 'N';
|
||||
}
|
||||
const data = ref<any>();
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
|
||||
onMounted(async () => {
|
||||
data.value = await api.current();
|
||||
console.log(data.value);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.today-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
@@ -32,109 +23,81 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.today-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.today-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.today-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-date {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.today-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hi-lo {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-top: 2px;
|
||||
|
||||
.hi { color: var(--text); }
|
||||
.lo { color: var(--text-muted); font-weight: 400; }
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="today-card">
|
||||
<div class="flex-r align-items-center justify-between px-3">
|
||||
<div class="flex-c justify-center align-items-center">
|
||||
<img v-if="data.icon" :src="'https://openweathermap.org/img/wn/' + data.icon + '@2x.png'" class="today-icon" alt="weather" />
|
||||
<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-else class="flex-c justify-center align-items-center mx-3">
|
||||
<img :src="BASE + '/api/icon/' + data.icon" class="today-icon" alt="weather" />
|
||||
<div class="today-label">{{ data.label }}</div>
|
||||
</div>
|
||||
<div class="flex-r align-items-center gap-2">
|
||||
<div class="fs-6">
|
||||
{{ data.temperature?.toFixed(0) }}°C
|
||||
<div class="fs-6 m-0 p-0 flex-r align-items-center">
|
||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 50px; height: 1.25em"><Loading/></div>
|
||||
<template v-else>{{ Math.round(data.temperature) }}°C</template>
|
||||
</div>
|
||||
<div class="flex-c fg-muted">
|
||||
<span class="hi">↑ {{ 0 }}°</span>
|
||||
<span class="lo">↓ {{ 0 }}°</span>
|
||||
<div class="hi">
|
||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
||||
<template v-else>↑ {{ ~~(data.temperature_max) }}°</template>
|
||||
</div>
|
||||
<div class="lo">
|
||||
<div v-if="!data" class="d-inline-block pos-rel br-2 overflow-hidden" style="width: 25px; height: 1em"><Loading/></div>
|
||||
<template v-else>↓ {{ ~~(data.temperature_min) }}°</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,12 +105,79 @@ onMounted(async () => {
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><span class="stat-label">Precipitation</span><span class="stat-value">{{ !data.precipitation && data.raining == 'True' ? 'Spitting' : (data.precipitation || 0).toFixed(1) + 'mm'}}</span></div>
|
||||
<div class="stat"><span class="stat-label">Humidity</span><span class="stat-value">{{ (data.humidity || 0).toFixed(0) }}%</span></div>
|
||||
<div class="stat"><span class="stat-label">Wind</span><span class="stat-value">{{ data.wind_gusts || 0 }} Kph <span class="fg-muted">({{ dir(data.wind_direction || 0) }})</span></span></div>
|
||||
<div class="stat"><span class="stat-label">Clouds</span><span class="stat-value">{{ (data.clouds || 0).toFixed(0) }}%</span></div>
|
||||
<div class="stat"><span class="stat-label">Air Quality</span><span class="stat-value">{{ data.air_quality }} <span class="fg-muted">({{data.air_quality_label}})</span></span></div>
|
||||
<div class="stat"><span class="stat-label">UV index</span><span class="stat-value">{{ data.uv_index }}</span></div>
|
||||
<div class="stat" @click="openGraph?.('heat_index')">
|
||||
<span class="stat-label">Feels Like</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.heat_index == null">—</template>
|
||||
<template v-else>{{ ~~(data.heat_index) }}<span class="fg-muted">°C</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('humidity')">
|
||||
<span class="stat-label">Humidity</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.humidity == null">—</template>
|
||||
<template v-else>{{ Math.round(data.humidity) }}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||
<span class="stat-label">Rain</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.raining == null">—</template>
|
||||
<template v-else-if="data.raining">Raining</template>
|
||||
<template v-else>{{data.precipitation_chance}}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('clouds')">
|
||||
<span class="stat-label">Cloud Cover</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.clouds == null">—</template>
|
||||
<template v-else>{{ Math.round(data.clouds) }}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_slp')">
|
||||
<span class="stat-label">Sea Level</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.pressure_slp == null">—</template>
|
||||
<template v-else>{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ getPressureTrend(data.pressure_trend) }})</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
|
||||
<span class="stat-label">VPD KPA</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.vapor_pressure_deficit == null">—</template>
|
||||
<template v-else>{{ data.vapor_pressure_deficit?.toFixed(2) }} <span class="fg-muted">kPa</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('air_quality')">
|
||||
<span class="stat-label">Air Quality</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.air_quality == null">—</template>
|
||||
<template v-else>{{ data.air_quality }} <span class="fg-muted">/ 500</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('air_quality_ohms')">
|
||||
<span class="stat-label">Gas Resistance</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.air_quality_ohms == null">—</template>
|
||||
<template v-else>{{ data.air_quality_ohms }} <span class="fg-muted">Ω</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('visibility')">
|
||||
<span class="stat-label">Visibility</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="data.visibility == null">—</template>
|
||||
<template v-else>{{ data.visibility?.toFixed(1) }} <span class="fg-muted">km</span></template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {BASE, type DataRow} from '../services/api';
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
const props = defineProps<{ days: DataRow[] }>()
|
||||
const days = ref<any[]>()
|
||||
const filler = [1, 2, 3, 4, 5];
|
||||
|
||||
const items = computed(() => props.days.slice(0, 7).map(d => ({
|
||||
date: new Date(d.time as string).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
|
||||
icon: d.forecast_weather_icon as string | null,
|
||||
label: d.forecast_weather_label as string,
|
||||
high: d.env_temp_max_c != null ? `${(d.env_temp_max_c as number).toFixed(1)}°` : '—',
|
||||
low: d.env_temp_min_c != null ? `${(d.env_temp_min_c as number).toFixed(1)}°` : '—',
|
||||
rain: d.forecast_precipitation_probability != null ? `${d.forecast_precipitation_probability}%` : null,
|
||||
precip: d.forecast_precipitation_mm != null ? `${(d.forecast_precipitation_mm as number).toFixed(1)}mm` : null,
|
||||
})))
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'NE';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'E';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'SE';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'S';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'SW';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'W';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'NW';
|
||||
return 'N';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const start = new Date(), end = new Date();
|
||||
start.setDate(start.getDate() + 1);
|
||||
end.setDate(end.getDate() + 5);
|
||||
days.value = await api.daily(start, end);
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -26,9 +37,6 @@ const items = computed(() => props.days.slice(0, 7).map(d => ({
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
width: 100%;
|
||||
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.day {
|
||||
@@ -48,23 +56,38 @@ const items = computed(() => props.days.slice(0, 7).map(d => ({
|
||||
.day-label { font-size: 10px; color: var(--text-muted); text-align: center; line-height: 1.2; }
|
||||
.day-temps { display: flex; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.day-low { color: var(--text-muted); font-weight: 400; }
|
||||
.day-rain { font-size: 11px; color: #38bdf8; }
|
||||
.day-precip { font-size: 10px; color: var(--text-muted); }
|
||||
.day-humid { font-size: 11px; color: var(--text-muted); }
|
||||
.day-wind { font-size: 11px; color: var(--text-muted); }
|
||||
.day-rain { font-size: 11px; color: var(--text-muted); }
|
||||
.day-precip { font-size: 9px; color: var(--text-muted); }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="forecast-wrap">
|
||||
<div class="strip">
|
||||
<div v-for="item in items" :key="item.date" class="day">
|
||||
<div class="day-name">{{ item.date }}</div>
|
||||
<img v-if="item.icon" :src="BASE + item.icon" class="day-icon" :alt="item.label" />
|
||||
<div class="day-label">{{ item.label }}</div>
|
||||
<div v-if="days?.length" class="strip">
|
||||
<div v-for="d in days" class="day">
|
||||
<div class="day-name">{{ formatDate('ddd, MMM D', d.time) }}</div>
|
||||
<img :src="BASE + '/api/icon/' + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
|
||||
<div class="day-label">{{ d.label }}</div>
|
||||
<div class="day-temps">
|
||||
<span>{{ item.high }}</span>
|
||||
<span class="day-low">{{ item.low }}</span>
|
||||
<span>↑{{ ~~(d.temperature_max) + '°' || '—' }}</span>
|
||||
<span class="day-low">↓{{ ~~(d.temperature_min) + '°' || '—' }}</span>
|
||||
</div>
|
||||
<div v-if="item.rain" class="day-rain">🌧 {{ item.rain }}</div>
|
||||
<div v-if="item.precip" class="day-precip">{{ item.precip }}</div>
|
||||
<div class="day-humid align-x">🌡️ {{ ~~(d.humidity) || 0 }}%</div>
|
||||
<div class="day-wind align-x">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div>
|
||||
<div class="day-rain align-x">🌧️ {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="strip">
|
||||
<div v-for="d in filler" class="day">
|
||||
<div class="br-2 overflow-hidden pos-rel my-2" style="height: 50px; width: 50px;"><Loading /></div>
|
||||
<div class="day-temps mb-2">
|
||||
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
||||
<div class="br-2 overflow-hidden pos-rel" style="width: 50px; height: 1em"><Loading/></div>
|
||||
</div>
|
||||
<div class="w-100 br-2 overflow-hidden pos-rel mb-2" style="height: 1em"><Loading/></div>
|
||||
<div class="w-100 br-2 overflow-hidden pos-rel mb-2" style="height: 1em"><Loading/></div>
|
||||
<div class="w-100 br-2 overflow-hidden pos-rel mb-2" style="height: 1em"><Loading/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,87 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS } from '../services/units'
|
||||
import MetricChart from './MetricChart.vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
import {computed, nextTick, ref, watch} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
|
||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const props = defineProps<{metricKey: string | null}>();
|
||||
const emit = defineEmits<{(e: 'close'): void}>();
|
||||
|
||||
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
||||
const mode = ref<'hourly' | 'daily'>('hourly');
|
||||
const rows = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedPoint = ref<{t: number, v: number} | null>(null);
|
||||
const W = 600, H = 200, PAD = 30;
|
||||
|
||||
const customStart = ref('');
|
||||
const customEnd = ref('');
|
||||
|
||||
function pad(n: number) { return String(n).padStart(2, '0'); }
|
||||
function toDateTimeLocal(d: Date) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
function toDateLocal(d: Date) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function setDefaults() {
|
||||
const past = new Date(), future = new Date();
|
||||
if(mode.value === 'hourly') {
|
||||
past.setHours(past.getHours() - 24, 0, 0, 0);
|
||||
future.setHours(future.getHours() + 24, 0, 0, 0);
|
||||
customStart.value = toDateTimeLocal(past);
|
||||
customEnd.value = toDateTimeLocal(future);
|
||||
} else {
|
||||
past.setDate(past.getDate() - 3);
|
||||
future.setDate(future.getDate() + 3);
|
||||
customStart.value = toDateLocal(past);
|
||||
customEnd.value = toDateLocal(future);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if(!props.metricKey) return;
|
||||
selectedPoint.value = null;
|
||||
loading.value = true;
|
||||
const past = new Date(customStart.value);
|
||||
const future = new Date(customEnd.value);
|
||||
rows.value = mode.value === 'hourly'
|
||||
? await api.hourly(past, future, `time,${props.metricKey}`)
|
||||
: await api.daily(past, future, `time,${props.metricKey}`);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
watch([() => props.metricKey, mode], async () => {
|
||||
setDefaults();
|
||||
await nextTick();
|
||||
fetchData();
|
||||
}, {immediate: true});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const points = computed(() => {
|
||||
if(!rows.value.length || !props.metricKey) return [];
|
||||
return rows.value.map(r => ({
|
||||
t: new Date(r.time as string).getTime(),
|
||||
v: r[props.metricKey!] as number | null,
|
||||
})).filter(p => p.v != null) as {t: number, v: number}[];
|
||||
});
|
||||
|
||||
const chart = computed(() => {
|
||||
const pts = points.value;
|
||||
if(!pts.length) return {path: {hist:'', fut:''}, currentX: 0, dot: null as any, yLabels: [] as any[], xLabels: [] as any[], dotPoints: [] as any[]};
|
||||
|
||||
const ts = pts.map(p => p.t), vs = pts.map(p => p.v);
|
||||
const minT = Math.min(...ts), maxT = Math.max(...ts);
|
||||
const rawMin = Math.min(...vs), rawMax = Math.max(...vs);
|
||||
const vpad = (rawMax - rawMin) * 0.1 || 1;
|
||||
const minV = rawMin - vpad, maxV = rawMax + vpad;
|
||||
|
||||
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
||||
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
||||
const nowT = now.getTime();
|
||||
const currentX = Math.max(PAD, Math.min(W - PAD, xOf(nowT)));
|
||||
|
||||
// hist/fut always split at nowT — never affected by selected point
|
||||
const hist = pts.filter(p => p.t <= nowT);
|
||||
const fut = pts.filter(p => p.t >= nowT);
|
||||
// stitch the join so lines meet cleanly at the now-line
|
||||
const joinPt = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||
if(!hist.length || hist.at(-1)!.t !== joinPt.t) hist.push(joinPt);
|
||||
if(!fut.length || fut[0]!.t !== joinPt.t) fut.unshift(joinPt);
|
||||
|
||||
const toPath = (arr: typeof pts) =>
|
||||
arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v).toFixed(1)}`).join(' ');
|
||||
|
||||
// selected dot — default to nearest point to now
|
||||
const activePt = selectedPoint.value
|
||||
? pts.reduce((a, b) => Math.abs(a.t - selectedPoint.value!.t) < Math.abs(b.t - selectedPoint.value!.t) ? a : b)
|
||||
: pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||
const dot = {x: xOf(activePt.t), y: yOf(activePt.v), v: activePt.v, t: activePt.t};
|
||||
|
||||
const yLabels = Array.from({length: 5}, (_, i) => {
|
||||
const v = minV + (i / 4) * (maxV - minV);
|
||||
return {y: yOf(v), label: v.toFixed(1)};
|
||||
});
|
||||
|
||||
const xLabels: {x: number, label: string}[] = [];
|
||||
const tickCount = mode.value === 'hourly' ? 9 : 7;
|
||||
for(let i = 0; i <= tickCount; i++) {
|
||||
const t = minT + (i / tickCount) * (maxT - minT);
|
||||
const d = new Date(t);
|
||||
xLabels.push({x: xOf(t), label: mode.value === 'hourly'
|
||||
? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
||||
: d.toLocaleDateString([], {month: 'short', day: 'numeric'})});
|
||||
}
|
||||
|
||||
const dotPoints = pts.map(p => ({x: xOf(p.t), y: yOf(p.v), t: p.t, v: p.v}));
|
||||
|
||||
return {path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels, dotPoints};
|
||||
});
|
||||
|
||||
function onSvgClick(e: MouseEvent) {
|
||||
const svg = e.currentTarget as SVGSVGElement;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const svgX = ((e.clientX - rect.left) / rect.width) * W;
|
||||
const pts = chart.value.dotPoints;
|
||||
if(!pts.length) return;
|
||||
const nearest = pts.reduce((a, b) => Math.abs(a.x - svgX) < Math.abs(b.x - svgX) ? a : b);
|
||||
selectedPoint.value = {t: nearest.t, v: nearest.v};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%; max-width: 700px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
.title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; }
|
||||
.close-btn {
|
||||
background: none; border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; width: 32px; height: 32px; cursor: pointer; font-size: 16px;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.controls {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
button {
|
||||
font-size: 11px; padding: 4px 12px; 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); }
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
.current-val {
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
span { color: var(--accent); font-weight: 700; }
|
||||
}
|
||||
.chart-wrap { position: relative; }
|
||||
.loading-overlay {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.2); border-radius: 8px;
|
||||
.spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid var(--border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||
}
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
svg { width: 100%; overflow: visible; cursor: crosshair; }
|
||||
.tick-label { font-size: 9px; fill: var(--text-muted); }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span>{{ meta?.icon }}</span>
|
||||
<span>{{ meta?.label ?? metricKey }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<MetricChart :metric-key="metricKey" :current-data="currentData" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="title">{{ metricKey?.replace(/_/g, ' ') }}</span>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="controls gap-0 mb-2">
|
||||
<button :class="{active: mode === 'hourly'}" style="border-radius: 6px 0 0 6px" @click="mode = 'hourly'">Hourly</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 class="chart-wrap">
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none" @click="onSvgClick">
|
||||
<template v-for="tick in chart.yLabels" :key="tick.y">
|
||||
<line :x1="PAD" :y1="tick.y" :x2="W-PAD" :y2="tick.y" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>
|
||||
<text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text>
|
||||
</template>
|
||||
<template v-for="tick in chart.xLabels" :key="tick.x">
|
||||
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
|
||||
</template>
|
||||
|
||||
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||
|
||||
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
|
||||
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
|
||||
|
||||
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
|
||||
|
||||
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="5" fill="var(--accent)"/>
|
||||
</svg>
|
||||
|
||||
<div v-if="loading" class="loading-overlay"><div class="spinner"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
109
client/src/components/HourlyForecast.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {api, BASE} from '../services/api';
|
||||
|
||||
const hours = ref<any[]>();
|
||||
const open = ref(false);
|
||||
const filler = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'NE';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'E';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'SE';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'S';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'SW';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'W';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'NW';
|
||||
return 'N';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
hours.value = await api.hourly();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); }
|
||||
.chevron { font-size: 10px; color: var(--text-muted); transition: transform 0.2s; &.open { transform: rotate(180deg); } }
|
||||
.preview { display: flex; gap: 6px; align-items: center; }
|
||||
.prev-icon { width: 20px; height: 20px; object-fit: contain; }
|
||||
.prev-temp { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
}
|
||||
|
||||
.hour-list {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hour-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
|
||||
.hour-time { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; width: 52px; flex-shrink: 0; }
|
||||
.hour-icon { width: 26px; height: 26px; object-fit: contain; flex-shrink: 0; }
|
||||
.hour-label { font-size: 11px; color: var(--text-muted); flex: 1; }
|
||||
.hour-temp { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.hour-meta { display: flex; gap: 8px; font-size: 10px; color: var(--text-muted); }
|
||||
}
|
||||
|
||||
.filler-row {
|
||||
height: 42px;
|
||||
pos-rel: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-header" @click="open = !open">
|
||||
<span class="title">24hr Forecast</span>
|
||||
<span class="chevron" :class="{ open }">▼</span>
|
||||
</div>
|
||||
|
||||
<div v-if="open" style="max-height: 50vh; overflow-y: auto">
|
||||
<div class="hour-list">
|
||||
<template v-if="hours?.length">
|
||||
<div v-for="h in hours" class="hour-row">
|
||||
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
||||
<img :src="BASE + '/api/icon/' + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
|
||||
<span class="hour-label">{{ h.label }}</span>
|
||||
<div class="hour-meta">
|
||||
<span>🌧️ {{ (h.precipitation as any || 0).toFixed(1) }}mm</span>
|
||||
<span>🍃 {{ ~~h.wind_gusts }}kph {{ dir(h.wind_dir) }}</span>
|
||||
</div>
|
||||
<span class="hour-temp">{{ ~~h.temperature }}°</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="f in filler" class="filler-row p-2">
|
||||
<div class="br-1 pos-rel w-100 overflow-hidden" style="height: 100%">
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
43
client/src/components/Loading.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<style scoped lang="scss">
|
||||
.shimmer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.stroke {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #777;
|
||||
}
|
||||
|
||||
.dark .animate {
|
||||
animation: shimmer 3s infinite;
|
||||
background: linear-gradient(90deg, #ffffff10 25%, #ffffff30 50%, #ffffff10 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.animate {
|
||||
animation: shimmer 3s infinite;
|
||||
background: linear-gradient(to right, #e6e6e6 0%, #cccccc 50%, #e6e6e6 100%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="shimmer">
|
||||
<div class="stroke animate"></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { AirTrafficLayer } from '@/services/airtraffic.ts'
|
||||
import {api} from '@/services/api.ts';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { AirTrafficLayer } from '@/services/adsb.ts'
|
||||
import { AISLayer } from '@/services/ais.ts'
|
||||
import { api } from '@/services/api.ts'
|
||||
import { RangeLayer } from '@/services/range.ts'
|
||||
import VectorTileLayer from 'ol/layer/VectorTile'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
import TileLayer from 'ol/layer/Tile'
|
||||
@@ -11,29 +14,26 @@ import XYZ from 'ol/source/XYZ'
|
||||
import Feature from 'ol/Feature'
|
||||
import Point from 'ol/geom/Point'
|
||||
import CircleGeom from 'ol/geom/Circle'
|
||||
import { defaults as defaultInteractions } from 'ol/interaction'
|
||||
import { fromLonLat, toLonLat } from 'ol/proj'
|
||||
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
|
||||
import { current } from '../services/weather'
|
||||
import { applyStyle } from 'ol-mapbox-style'
|
||||
import 'ol/ol.css'
|
||||
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
const current = ref({ latitude: 0, longitude: 0 })
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
const showOverlays = ref(false)
|
||||
const atActive = ref(true)
|
||||
|
||||
const NM = 1852
|
||||
|
||||
const radarFrames = ref<{ path: string; time: number }[]>([])
|
||||
const radarFrameIndex = ref(0)
|
||||
const radarPath = computed(() => radarFrames.value[radarFrameIndex.value]?.path ?? '')
|
||||
const nowcastStartIndex = ref(0)
|
||||
|
||||
const showWind = ref(false)
|
||||
const windSrc = ref('')
|
||||
|
||||
const OVERLAYS = [
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️', url: () => `https://tilecache.rainviewer.com${radarPath.value}/256/{z}/{x}/{y}/2/1_1.png` },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨', url: () => '' },
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️' },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨' },
|
||||
]
|
||||
|
||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||
@@ -43,63 +43,81 @@ let map: Map
|
||||
let stationLayer: VectorLayer<VectorSource>
|
||||
let radarInterval: ReturnType<typeof setInterval>
|
||||
let airTraffic: AirTrafficLayer
|
||||
|
||||
const hourTickIndices = computed(() => {
|
||||
const indices: number[] = []
|
||||
let lastHour = -1
|
||||
radarFrames.value.forEach((f, i) => {
|
||||
const h = new Date(f.time * 1000).getHours()
|
||||
if (h !== lastHour) { indices.push(i); lastHour = h }
|
||||
})
|
||||
return indices
|
||||
})
|
||||
let aisLayer: AISLayer
|
||||
let range: RangeLayer
|
||||
|
||||
function buildWindSrc(lat: number, lon: number, zoom: number) {
|
||||
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
|
||||
}
|
||||
|
||||
function syncWindSrc() {
|
||||
const view = map.getView()
|
||||
const center = toLonLat(view.getCenter()!)
|
||||
const view = map.getView()
|
||||
const center: any = toLonLat(view.getCenter()!)
|
||||
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
|
||||
}
|
||||
|
||||
async function fetchRadarFrames() {
|
||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
||||
const data = await res.json()
|
||||
const past = data.radar.past ?? []
|
||||
const nowcast = data.radar.nowcast ?? []
|
||||
radarFrames.value = [...past, ...nowcast]
|
||||
nowcastStartIndex.value = past.length
|
||||
radarFrameIndex.value = past.length - 1
|
||||
function showWindOverlay() {
|
||||
syncWindSrc()
|
||||
showWind.value = true
|
||||
map.getInteractions().forEach(i => i.setActive(false))
|
||||
}
|
||||
|
||||
function seekRadar(index: number) {
|
||||
radarFrameIndex.value = index
|
||||
const layer = overlayLayers['rain']
|
||||
if (layer) {
|
||||
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url(), maxZoom: 7 }))
|
||||
function hideWindOverlay() {
|
||||
showWind.value = false
|
||||
map.getInteractions().forEach(i => i.setActive(true))
|
||||
}
|
||||
|
||||
async function fetchRadarUrl(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
|
||||
const data = await res.json()
|
||||
const past = data.radar?.past ?? []
|
||||
const latest = past[past.length - 1]
|
||||
if (!latest) return null
|
||||
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function onSliderInput(e: Event) {
|
||||
seekRadar(Number((e.target as HTMLInputElement).value))
|
||||
function buildRainLayer(url: string): TileLayer<XYZ> {
|
||||
return new TileLayer({
|
||||
source: new XYZ({
|
||||
url,
|
||||
maxZoom: 7,
|
||||
tileLoadFunction: (tile: any, src: string) => {
|
||||
const img = tile.getImage()
|
||||
img.onerror = () => tile.setState(3)
|
||||
img.src = src
|
||||
},
|
||||
}),
|
||||
opacity: 0.2,
|
||||
zIndex: 5,
|
||||
})
|
||||
}
|
||||
|
||||
function frameLabel(frame: { time: number }) {
|
||||
return new Date(frame.time * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
async function refreshRain() {
|
||||
const url = await fetchRadarUrl()
|
||||
if (!url) return
|
||||
|
||||
function hourLabel(frame: { time: number }) {
|
||||
return new Date(frame.time * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
|
||||
if (!activeOverlays.value.has('rain')) return
|
||||
|
||||
function tickPercent(index: number) {
|
||||
return (index / (radarFrames.value.length - 1)) * 100
|
||||
const layer = buildRainLayer(url)
|
||||
overlayLayers['rain'] = layer
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
function toggleAT() {
|
||||
atActive.value ? airTraffic.hide() : airTraffic.show()
|
||||
if (atActive.value) {
|
||||
aisLayer.hide()
|
||||
airTraffic.hide()
|
||||
range.hide()
|
||||
} else {
|
||||
aisLayer.show()
|
||||
airTraffic.show()
|
||||
range.show()
|
||||
}
|
||||
atActive.value = !atActive.value
|
||||
}
|
||||
|
||||
@@ -107,57 +125,42 @@ function toggleOverlay(id: string) {
|
||||
if (id === 'wind') {
|
||||
if (activeOverlays.value.has('wind')) {
|
||||
activeOverlays.value.delete('wind')
|
||||
showWind.value = false
|
||||
hideWindOverlay()
|
||||
} else {
|
||||
activeOverlays.value.add('wind')
|
||||
syncWindSrc()
|
||||
showWind.value = true
|
||||
showWindOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (activeOverlays.value.has(id)) {
|
||||
activeOverlays.value.delete(id)
|
||||
const layer = overlayLayers[id]
|
||||
if (layer) { map.removeLayer(layer); delete overlayLayers[id] }
|
||||
} else {
|
||||
activeOverlays.value.add(id)
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers[id] = layer
|
||||
map.addLayer(layer)
|
||||
if (id === 'rain') {
|
||||
if (activeOverlays.value.has('rain')) {
|
||||
activeOverlays.value.delete('rain')
|
||||
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
|
||||
} else {
|
||||
activeOverlays.value.add('rain')
|
||||
refreshRain()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
return new TileLayer({
|
||||
source: new XYZ({
|
||||
url: dark
|
||||
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
|
||||
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
|
||||
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function buildOverlayLayer(id: string): TileLayer<XYZ> {
|
||||
const def = OVERLAYS.find(o => o.id === id)!
|
||||
return new TileLayer({
|
||||
source: new XYZ({ url: def.url(), attributions: '', maxZoom: 7 }),
|
||||
opacity: 0.2,
|
||||
zIndex: 5,
|
||||
})
|
||||
const layer = new VectorTileLayer({ declutter: true })
|
||||
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json')
|
||||
return layer
|
||||
}
|
||||
|
||||
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
|
||||
const center = fromLonLat([lon, lat])
|
||||
const source = new VectorSource()
|
||||
const center = fromLonLat([lon, lat])
|
||||
const source = new VectorSource()
|
||||
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
|
||||
|
||||
for (const nm of [50, 100, 150, 200]) {
|
||||
const ring = new Feature(new CircleGeom(center, nm * NM))
|
||||
ring.setStyle(new Style({
|
||||
stroke: new Stroke({ color: ringStroke, width: 1, lineDash: [6, 4] }),
|
||||
fill: new Fill({ color: 'transparent' }),
|
||||
fill: new Fill({ color: 'transparent' }),
|
||||
}))
|
||||
source.addFeature(ring)
|
||||
}
|
||||
@@ -166,7 +169,7 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
|
||||
dot.setStyle(new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 7,
|
||||
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
|
||||
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
|
||||
stroke: new Stroke({ color: dark ? '#000000' : '#ffffff', width: 2 }),
|
||||
}),
|
||||
}))
|
||||
@@ -176,43 +179,31 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRadarFrames()
|
||||
const position = await api.position();
|
||||
const position = await api.position()
|
||||
current.value = {
|
||||
latitude: position?.latitude || 0,
|
||||
longitude: position?.longitude || 0,
|
||||
}
|
||||
|
||||
const lat = position?.latitude || 0
|
||||
const lon = position?.longitude || 0
|
||||
|
||||
stationLayer = buildStationLayer(lat, lon, props.dark)
|
||||
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark)
|
||||
|
||||
map = new Map({
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark), stationLayer],
|
||||
interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }),
|
||||
view: new View({ center: fromLonLat([current.value.longitude, current.value.latitude]), zoom: 8, maxZoom: 13 }),
|
||||
controls: [],
|
||||
})
|
||||
|
||||
aisLayer = new AISLayer(map)
|
||||
aisLayer.show()
|
||||
airTraffic = new AirTrafficLayer(map)
|
||||
airTraffic.show()
|
||||
range = new RangeLayer(map)
|
||||
range.show()
|
||||
|
||||
for (const id of activeOverlays.value) {
|
||||
if (id === 'wind') continue
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers[id] = layer
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
// Sync windy src when OL map finishes moving
|
||||
map.on('moveend', () => {
|
||||
if (showWind.value) syncWindSrc()
|
||||
})
|
||||
|
||||
radarInterval = setInterval(async () => {
|
||||
await fetchRadarFrames()
|
||||
const layer = overlayLayers['rain']
|
||||
if (layer) {
|
||||
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url() }))
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
refreshRain()
|
||||
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -325,82 +316,14 @@ watch(() => props.dark, dark => {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.radar-timeline {
|
||||
position: absolute;
|
||||
bottom: 72px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border-radius: 16px;
|
||||
padding: 8px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
backdrop-filter: blur(8px);
|
||||
width: min(600px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.slider-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slider-ticks {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
&__line {
|
||||
width: 1px;
|
||||
height: 5px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 9px;
|
||||
color: var(--text-muted, #888);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.nowcast &__line { background: #6ab4ff; }
|
||||
&.nowcast &__label { color: #6ab4ff; }
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop { display: flex; }
|
||||
.mobile { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop { display: none; }
|
||||
.mobile { display: flex; }
|
||||
.radar-timeline { bottom: 10px; }
|
||||
.overlay-toggles { bottom: 70px; }
|
||||
.layers-menu { bottom: 70px; }
|
||||
.overlay-toggles { bottom: 10px; }
|
||||
.layers-menu { bottom: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -408,7 +331,6 @@ input[type='range'] {
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-el" />
|
||||
|
||||
<!-- Windy animated wind iframe -->
|
||||
<iframe
|
||||
v-if="showWind"
|
||||
class="wind-iframe"
|
||||
@@ -417,31 +339,6 @@ input[type='range'] {
|
||||
allowfullscreen
|
||||
/>
|
||||
|
||||
<!-- Radar Timeline -->
|
||||
<!-- <div class="slider-wrap">-->
|
||||
<!-- <div class="slider-ticks">-->
|
||||
<!-- <div-->
|
||||
<!-- v-for="i in hourTickIndices" :key="i"-->
|
||||
<!-- class="tick"-->
|
||||
<!-- :class="{ nowcast: i >= nowcastStartIndex }"-->
|
||||
<!-- :style="{ left: tickPercent(i) + '%' }"-->
|
||||
<!-- >-->
|
||||
<!-- <div class="tick__line" />-->
|
||||
<!-- <span class="tick__label">{{ hourLabel(radarFrames[i]) }}</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
<!-- <input-->
|
||||
<!-- type="range"-->
|
||||
<!-- :min="0"-->
|
||||
<!-- :max="radarFrames.length - 1"-->
|
||||
<!-- :value="radarFrameIndex"-->
|
||||
<!-- @input="onSliderInput"-->
|
||||
<!-- />-->
|
||||
<!-- </div>-->
|
||||
<!-- <span class="timeline-label">{{ frameLabel(radarFrames[radarFrameIndex]) }}</span>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- Desktop toggles -->
|
||||
<div class="overlay-toggles desktop">
|
||||
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
|
||||
✈️ Traffic
|
||||
@@ -456,7 +353,6 @@ input[type='range'] {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile layers button + dropdown -->
|
||||
<div class="layers-menu mobile">
|
||||
<button class="layers-btn" @click="showOverlays = !showOverlays">
|
||||
⚙️ Layers
|
||||
|
||||
@@ -1,160 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import {computed, ref, onMounted} from 'vue';
|
||||
import {Line} from 'vue-chartjs';
|
||||
import {
|
||||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
||||
LineElement, Tooltip, Legend, Filler, type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { METRICS, formatValue } from '../services/units'
|
||||
import { fetchHistoricHourly } from '../services/weather'
|
||||
import type { DataRow } from '../services/api'
|
||||
import { api } from '../services/api'
|
||||
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
|
||||
LineElement, Tooltip, Legend, Filler, type ChartOptions
|
||||
} from 'chart.js';
|
||||
import {METRICS, formatValue} from '../services/units';
|
||||
import type {DataRow} from '../services/api';
|
||||
import {api} from '../services/api';
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler);
|
||||
|
||||
const props = defineProps<{ metricKey: string; currentData: DataRow }>()
|
||||
const props = defineProps<{metricKey: string; currentData: DataRow}>();
|
||||
|
||||
const historic = ref<DataRow[]>([])
|
||||
const forecast = ref<DataRow[]>([])
|
||||
const loading = ref(true)
|
||||
const meta = computed(() => METRICS[props.metricKey])
|
||||
const historic = ref<DataRow[]>([]);
|
||||
const forecast = ref<DataRow[]>([]);
|
||||
const loading = ref(true);
|
||||
const meta = computed(() => METRICS[props.metricKey]);
|
||||
|
||||
onMounted(async () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getTime() - 24 * 3600000).toISOString()
|
||||
const end = new Date(now.getTime() + 48 * 3600000).toISOString()
|
||||
const [hist, fore] = await Promise.allSettled([
|
||||
fetchHistoricHourly(start, now.toISOString()),
|
||||
api.hourly(now.toISOString(), end),
|
||||
])
|
||||
if (hist.status === 'fulfilled') historic.value = hist.value.filter(r => r[props.metricKey] != null)
|
||||
if (fore.status === 'fulfilled') forecast.value = fore.value.filter(r => r[props.metricKey] != null)
|
||||
loading.value = false
|
||||
})
|
||||
const start = new Date(Date.now() - 24 * 3600000);
|
||||
const end = new Date(Date.now() + 48 * 3600000);
|
||||
const hourly = await api.hourly(start, end);
|
||||
historic.value = hourly.filter((h: any) => new Date(h.time).getTime() <= Date.now());
|
||||
forecast.value = hourly.filter((h: any) => new Date(h.time).getTime() > Date.now());
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const nowLabel = computed(() => {
|
||||
const d = new Date()
|
||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`
|
||||
})
|
||||
const d = new Date();
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
const chartData = computed(() => {
|
||||
const color = meta.value?.color ?? '#38bdf8'
|
||||
const histLabels = historic.value.map(r => r.time as string)
|
||||
const foreLabels = forecast.value.map(r => r.time as string)
|
||||
const histVals = historic.value.map(r => r[props.metricKey] as number)
|
||||
const foreVals = forecast.value.map(r => r[props.metricKey] as number)
|
||||
const currentVal = props.currentData[props.metricKey] as number | null
|
||||
const color = meta.value?.color ?? '#38bdf8';
|
||||
const histLabels = historic.value.map(r => r.time as string);
|
||||
const foreLabels = forecast.value.map(r => r.time as string);
|
||||
const histVals = historic.value.map(r => r[props.metricKey] as number);
|
||||
const foreVals = forecast.value.map(r => r[props.metricKey] as number);
|
||||
const currentVal = props.currentData[props.metricKey] as number | null;
|
||||
|
||||
const allLabels = [...histLabels, nowLabel.value, ...foreLabels]
|
||||
const histData = [...histVals, currentVal, ...foreVals.map(() => null)]
|
||||
const foreData = [...histVals.map(() => null), currentVal, ...foreVals]
|
||||
const allLabels = [...histLabels, nowLabel.value, ...foreLabels];
|
||||
const histData = [...histVals, currentVal, ...foreVals.map(() => null)];
|
||||
const foreData = [...histVals.map(() => null), currentVal, ...foreVals];
|
||||
|
||||
return {
|
||||
labels: allLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Historic',
|
||||
data: histData,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}22`,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: 'Forecast',
|
||||
data: foreData,
|
||||
borderColor: color,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 4],
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: 'Now',
|
||||
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
|
||||
borderColor: '#ffffff88',
|
||||
backgroundColor: color,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius:8,
|
||||
borderWidth: 0,
|
||||
showLine: false,
|
||||
spanGaps: false,
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
return {
|
||||
labels: allLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Historic',
|
||||
data: histData,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}22`,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: 'Forecast',
|
||||
data: foreData,
|
||||
borderColor: color,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 4],
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: 'Now',
|
||||
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
|
||||
borderColor: '#ffffff88',
|
||||
backgroundColor: color,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
borderWidth: 0,
|
||||
showLine: false,
|
||||
spanGaps: false,
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: 'var(--text-muted)',
|
||||
maxTicksLimit: 12,
|
||||
maxRotation: 0,
|
||||
callback(val, i) {
|
||||
const lbl = this.getLabelForValue(i)
|
||||
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : ''
|
||||
}
|
||||
},
|
||||
grid: { color: 'var(--border)' },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number) },
|
||||
grid: { color: 'var(--border)' },
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: {
|
||||
nowLine: {
|
||||
type: 'line',
|
||||
xMin: nowLabel.value,
|
||||
xMax: nowLabel.value,
|
||||
borderColor: '#ffffff44',
|
||||
borderWidth: 1,
|
||||
borderDash: [4, 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {mode: 'index', intersect: false},
|
||||
plugins: {
|
||||
legend: {display: false},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: 'var(--text-muted)',
|
||||
maxTicksLimit: 12,
|
||||
maxRotation: 0,
|
||||
callback(val, i) {
|
||||
const lbl = this.getLabelForValue(i);
|
||||
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : '';
|
||||
}
|
||||
},
|
||||
grid: {color: 'var(--border)'},
|
||||
},
|
||||
y: {
|
||||
ticks: {color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number)},
|
||||
grid: {color: 'var(--border)'},
|
||||
}
|
||||
},
|
||||
annotation: {
|
||||
annotations: {
|
||||
nowLine: {
|
||||
type: 'line',
|
||||
xMin: nowLabel.value,
|
||||
xMax: nowLabel.value,
|
||||
borderColor: '#ffffff44',
|
||||
borderWidth: 1,
|
||||
borderDash: [4, 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 260px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 260px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 260px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="loading">Loading chart…</div>
|
||||
<div v-else class="chart-wrap">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
<div v-if="loading" class="loading">Loading chart…</div>
|
||||
<div v-else class="chart-wrap">
|
||||
<Line :data="chartData" :options="chartOptions"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
33
client/src/components/MetricRow.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import {inject} from 'vue';
|
||||
|
||||
const props = defineProps<{label: string; value: any; metricKey?: string; data?: any}>();
|
||||
const openGraph = inject<(key: string, data: any) => void>('openGraph');
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
|
||||
.label { color: var(--text-muted); }
|
||||
.value { font-weight: 600; color: var(--text); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="row" :class="{clickable: !!metricKey}" @click="metricKey && openGraph?.(metricKey, data)">
|
||||
<span class="label">{{ label }}</span>
|
||||
<span class="value">{{ value ?? '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
98
client/src/components/Moon.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {ref, watchEffect} from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const {current: d} = useWeather();
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
watchEffect(() => {
|
||||
if(!d.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const size = 64;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size / 2 - 3;
|
||||
|
||||
// Background circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#1c1c1c';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
const illumination = (d.value.moon_illumination ?? 0) / 100;
|
||||
const phase = d.value.moon_phase as string;
|
||||
const phaseMap: Record<string, number> = {
|
||||
'New Moon': 0, 'Waxing Crescent': 0.125, 'First Quarter': 0.25,
|
||||
'Waxing Gibbous': 0.375, 'Full Moon': 0.5, 'Waning Gibbous': 0.625,
|
||||
'Last Quarter': 0.75, 'Waning Crescent': 0.875,
|
||||
};
|
||||
const phaseVal = phaseMap[phase] ?? 0;
|
||||
|
||||
if(illumination > 0.01) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = '#e8e8c0';
|
||||
ctx.beginPath();
|
||||
|
||||
if(illumination >= 0.99) {
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
||||
} else if(phaseVal < 0.5) {
|
||||
// Waxing: right side lit, growing
|
||||
ctx.arc(cx, cy, r, -Math.PI / 2, Math.PI / 2, false);
|
||||
const ellipseWidth = (phaseVal * 4 - 1) * r;
|
||||
ctx.ellipse(cx, cy, Math.abs(ellipseWidth), r, 0, Math.PI / 2, -Math.PI / 2, ellipseWidth < 0);
|
||||
} else {
|
||||
// Waning: left side lit, shrinking
|
||||
ctx.arc(cx, cy, r, Math.PI / 2, -Math.PI / 2, false);
|
||||
const ellipseWidth = (3 - phaseVal * 4) * r;
|
||||
ctx.ellipse(cx, cy, Math.abs(ellipseWidth), r, 0, -Math.PI / 2, Math.PI / 2, ellipseWidth < 0);
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">🌙 Moon</div>
|
||||
|
||||
<div class="flex-r align-items-center gap-3 p-2">
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 40px">
|
||||
<Loading />
|
||||
</div>
|
||||
<template v-else>
|
||||
<canvas ref="canvasRef" style="width: 40px; height: 40px; border-radius: 50%;" />
|
||||
<div class="flex-c">
|
||||
<span style="font-size: 13px; font-weight: 600; color: var(--text)">{{ d.moon_phase }}</span>
|
||||
<span style="font-size: 11px; color: var(--text-muted)">{{ d.moon_illumination?.toFixed(1) }}% illuminated</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div v-if="!d" class="flex-c p-2 gap-2">
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<MetricRow label="Next Full Moon" :value="formatDate('MMM D', d.moon_full)" />
|
||||
<MetricRow label="Next New Moon" :value="formatDate('MMM D', d.moon_new)" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
121
client/src/components/Precipitation.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getPressureTrend} from '@/services/units.ts';
|
||||
import {inject} from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
const {current: d} = useWeather();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.precip-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="precip-card">
|
||||
<div class="card-title">🌧️ Precipitation</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('precipitation_chance')">
|
||||
<span class="stat-label">Rain</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">
|
||||
<template v-if="d.raining == null">—</template>
|
||||
<template v-else-if="d.raining">Raining</template>
|
||||
<template v-else>{{d.precipitation_chance}}<span class="fg-muted">%</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_rate')">
|
||||
<span class="stat-label">Lightning</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_rate ?? '—' }}<span v-if="d.lightning_rate" class="fg-muted">/hr</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lightning_distance')">
|
||||
<span class="stat-label">Distance</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.lightning_distance ?? '—' }} <span v-if="d.lightning_distance" class="fg-muted">km</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.pressure_hpa?.toFixed(1) ?? '—' }} <span class="fg-muted">hpa</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('pressure_hpa')">
|
||||
<span class="stat-label">Pressure (SLP)</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.pressure_slp?.toFixed(1) ?? '—' }} <span v-if="d.pressure_slp" class="fg-muted">({{getPressureTrend(d.pressure_rate)}})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('precipitation')">
|
||||
<span class="stat-label">Precipitation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.precipitation?.toFixed(1) ?? '—' }} <span v-if="d.precipitation != null" class="fg-muted">mm</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Dew Point</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.dew_point?.toFixed(1) ?? '—' }}<span v-if="d.dew_point" class="fg-muted">°C</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Frost Risk</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.frost_risk ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('accumulation')">
|
||||
<span class="stat-label">Accumulation</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ d.accumulation?.toFixed(1) ?? '—' }} <span v-if="d.accumulation != null" class="fg-muted">cm</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
182
client/src/components/Seismic.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { api } from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const d = ref<any>(null);
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
const AXES = [
|
||||
{ key: 'seismic_x', label: 'X', color: '#ccc' },
|
||||
{ key: 'seismic_y', label: 'Y', color: '#ccc' },
|
||||
{ key: 'seismic_z', label: 'Z', color: '#ccc' },
|
||||
];
|
||||
|
||||
const W = 640, BAND = 80, H = BAND * AXES.length, MAX_PTS = 120;
|
||||
const SEG_W = W / (MAX_PTS - 1);
|
||||
const SUB = 4;
|
||||
const RIGHT_PAD = 2 * SEG_W;
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
let rafId: number;
|
||||
let drawProgress = 0;
|
||||
let lastTimestamp = 0;
|
||||
const ANIM_DURATION = 1200;
|
||||
|
||||
type Pt = { x: number; y: number };
|
||||
const traces = AXES.map(() => ({
|
||||
history: [] as number[],
|
||||
prevMax: 1.0,
|
||||
buffer: [] as Pt[],
|
||||
}));
|
||||
|
||||
function buildBuffer(t: (typeof traces)[number], band: number) {
|
||||
const pts = t.history;
|
||||
if (pts.length < 2) return;
|
||||
const mid = band * BAND + BAND / 2, amp = BAND / 2 - 6;
|
||||
const newMax = Math.max(...pts, 1.0);
|
||||
t.prevMax = t.prevMax + (newMax - t.prevMax) * 0.15; // eased amplitude scale
|
||||
const tipX = W - RIGHT_PAD;
|
||||
const buf: Pt[] = [];
|
||||
|
||||
pts.forEach((v, i) => {
|
||||
const prev = pts[i - 1] ?? v;
|
||||
const prevNorm = prev === 0 ? 0 : (prev / t.prevMax) * amp;
|
||||
const norm = v === 0 ? 0 : (v / t.prevMax) * amp;
|
||||
const baseX = tipX - (pts.length - i) * SEG_W;
|
||||
const s = SEG_W / SUB;
|
||||
|
||||
// one oscillation per value, amplitude fading prevNorm → norm
|
||||
const a0 = prevNorm * 0.75 + norm * 0.25;
|
||||
const a1 = prevNorm * 0.25 + norm * 0.75;
|
||||
|
||||
buf.push({ x: baseX + s, y: mid - a0 }); // up
|
||||
buf.push({ x: baseX + s * 2, y: mid - a1 }); // up peak
|
||||
buf.push({ x: baseX + s * 3, y: mid + norm }); // down
|
||||
buf.push({ x: baseX + s * 4, y: mid }); // back to exact center ✓
|
||||
});
|
||||
|
||||
t.buffer = buf;
|
||||
}
|
||||
|
||||
function drawTrace(ctx: CanvasRenderingContext2D, t: (typeof traces)[number], color: string) {
|
||||
const buf = t.buffer;
|
||||
if (buf.length <= SUB) return;
|
||||
|
||||
// slide everything left as the new segment draws in
|
||||
const slide = (1 - drawProgress) * SEG_W;
|
||||
|
||||
const settledCount = buf.length - SUB;
|
||||
const anchor = buf[settledCount - 1];
|
||||
const animFrac = drawProgress * (SUB + 1);
|
||||
const animFloor = Math.floor(animFrac);
|
||||
const animRemainder = animFrac - animFloor;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
for (let i = 0; i < settledCount; i++) {
|
||||
const p = buf[i];
|
||||
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
|
||||
}
|
||||
|
||||
const animPoints = [anchor, ...buf.slice(settledCount)];
|
||||
|
||||
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
|
||||
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
|
||||
}
|
||||
|
||||
if (animFloor < animPoints.length - 1 && animRemainder > 0) {
|
||||
const curr = animPoints[animFloor];
|
||||
const next = animPoints[animFloor + 1];
|
||||
ctx.lineTo(
|
||||
curr.x + (next.x - curr.x) * animRemainder + slide,
|
||||
curr.y + (next.y - curr.y) * animRemainder,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function draw(timestamp: number) {
|
||||
const ctx = canvas.value?.getContext('2d');
|
||||
if (!ctx) { rafId = requestAnimationFrame(draw); return; }
|
||||
|
||||
const dt = Math.min(timestamp - lastTimestamp, 100);
|
||||
lastTimestamp = timestamp;
|
||||
drawProgress = Math.min(1, drawProgress + dt / ANIM_DURATION);
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
AXES.forEach((_, i) => {
|
||||
const mid = i * BAND + BAND / 2;
|
||||
|
||||
// Axis label — top-left of each band
|
||||
ctx.font = '16px monospace';
|
||||
ctx.fillStyle = AXES[i].color;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.fillText(AXES[i].label, W - 20, i * BAND + 14);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Center line per band — full width
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.moveTo(0, mid);
|
||||
ctx.lineTo(W, mid);
|
||||
ctx.stroke();
|
||||
|
||||
drawTrace(ctx, traces[i], AXES[i].color);
|
||||
});
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
|
||||
AXES.forEach((axis, i) => {
|
||||
const t = traces[i];
|
||||
t.history.push(d.value[axis.key] ?? 0);
|
||||
if (t.history.length > MAX_PTS) t.history.shift();
|
||||
buildBuffer(t, i);
|
||||
});
|
||||
drawProgress = 0;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await poll();
|
||||
lastTimestamp = performance.now();
|
||||
rafId = requestAnimationFrame(draw);
|
||||
interval = setInterval(poll, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval);
|
||||
cancelAnimationFrame(rafId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
canvas { width: 100%; height: 180px; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title">🫨 Seismic</div>
|
||||
<div v-if="!d" class="p-2">
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden mb-2" style="height: 30px">
|
||||
<Loading />
|
||||
</div>
|
||||
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 150px">
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<MetricRow label="Magnitude" :value="d.seismic_magnitude?.toFixed(1)" metric-key="seismic_magnitude" :data="d" />
|
||||
<canvas ref="canvas" :width="W" :height="H" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
356
client/src/components/Ship.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
|
||||
|
||||
const SPEED_UNITS = {
|
||||
knots: { label: 'KTS', convert: (v: number) => v.toFixed(1) },
|
||||
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
|
||||
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
|
||||
}
|
||||
const UNIT_KEY = 'ais_units'
|
||||
function loadUnits() {
|
||||
const s = localStorage.getItem(UNIT_KEY)
|
||||
return s ? JSON.parse(s) : { speed: 'knots' }
|
||||
}
|
||||
const prefs = ref(loadUnits())
|
||||
function cycleSpeed() {
|
||||
const keys = Object.keys(SPEED_UNITS)
|
||||
const p = loadUnits()
|
||||
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
|
||||
prefs.value = { ...p }
|
||||
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
|
||||
}
|
||||
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
|
||||
|
||||
const name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
|
||||
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0)
|
||||
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
|
||||
const photoError = ref(false)
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────────────
|
||||
const pos = ref({ ...props.position })
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const header = ref<HTMLElement | null>(null)
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
|
||||
e.preventDefault()
|
||||
}
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
|
||||
}
|
||||
function onMouseUp() { isDragging.value = false }
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('sp-close')) return
|
||||
const touch: any = e.touches[0]
|
||||
const sx = touch.clientX, sy = touch.clientY
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
emit('bringToFront')
|
||||
isDragging.value = true
|
||||
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
|
||||
if (header.value) header.value.style.opacity = '0.8'
|
||||
}, 500)
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
const touch: any = e.touches[0]
|
||||
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
|
||||
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onTouchEnd() {
|
||||
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
|
||||
if (isDragging.value && header.value) header.value.style.opacity = '1'
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
document.addEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
document.removeEventListener('touchcancel', onTouchEnd)
|
||||
})
|
||||
|
||||
const compass = computed(() => {
|
||||
const hdg = heading.value
|
||||
const course = props.boat.cog ?? hdg
|
||||
|
||||
const tapeTicks: string[] = []
|
||||
for (let offset = -60; offset <= 60; offset += 5) {
|
||||
const deg = ((hdg + offset) % 360 + 360) % 360
|
||||
const x = 100 + (offset / 60) * 90
|
||||
const major = offset % 30 === 0
|
||||
const mid = offset % 15 === 0
|
||||
if (major) {
|
||||
const lbl = ({ 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' } as any)[deg]
|
||||
?? String(deg).padStart(3, '0')
|
||||
tapeTicks.push(`
|
||||
<line x1="${x}" y1="22" x2="${x}" y2="38" stroke="#0ff" stroke-width="2"/>
|
||||
<text x="${x}" y="50" text-anchor="middle" fill="#0ff" font-size="11" font-weight="bold">${lbl}</text>
|
||||
`)
|
||||
} else if (mid) {
|
||||
tapeTicks.push(`<line x1="${x}" y1="26" x2="${x}" y2="38" stroke="#0ff" stroke-width="1.5"/>`)
|
||||
} else {
|
||||
tapeTicks.push(`<line x1="${x}" y1="30" x2="${x}" y2="38" stroke="#0ff" stroke-width="1"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const CX = 100, CY = 140
|
||||
const R = 68
|
||||
|
||||
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
|
||||
const rudX = CX + (rudder / 30) * 40
|
||||
|
||||
const courseRel = ((course - hdg + 540) % 360) - 180
|
||||
const courseRad = courseRel * Math.PI / 180
|
||||
const cNeedleX = CX + 52 * Math.sin(courseRad)
|
||||
const cNeedleY = CY - 52 * Math.cos(courseRad)
|
||||
|
||||
const bearRel = ((course - hdg + 540) % 360) - 180
|
||||
const bearRad = bearRel * Math.PI / 180
|
||||
const bNeedleX = CX + 52 * Math.sin(bearRad)
|
||||
const bNeedleY = CY - 52 * Math.cos(bearRad)
|
||||
|
||||
const ringTicks = Array.from({ length: 36 }, (_, i) => {
|
||||
const a = i * 10
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const r1 = a % 30 === 0 ? R - 11 : R - 5
|
||||
const sw = a % 30 === 0 ? 2 : 1
|
||||
const x1 = CX + r1 * Math.cos(rad)
|
||||
const y1 = CY + r1 * Math.sin(rad)
|
||||
const x2 = CX + R * Math.cos(rad)
|
||||
const y2 = CY + R * Math.sin(rad)
|
||||
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
|
||||
}).join('')
|
||||
|
||||
const cardinals = (['N','E','S','W'] as const).map((lbl, i) => {
|
||||
const a = i * 90
|
||||
const rad = (a - 90) * Math.PI / 180
|
||||
const x = CX + (R - 18) * Math.cos(rad)
|
||||
const y = CY + (R - 18) * Math.sin(rad) + 4
|
||||
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<svg width="200" height="210" viewBox="0 0 200 210">
|
||||
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
|
||||
<line x1="10" y1="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
|
||||
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
|
||||
${tapeTicks.join('')}
|
||||
<polygon points="100,20 96,12 104,12" fill="#ff0"/>
|
||||
|
||||
<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
|
||||
<circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
|
||||
|
||||
<g transform="rotate(${-hdg}, ${CX}, ${CY})">
|
||||
${ringTicks}
|
||||
${cardinals}
|
||||
</g>
|
||||
|
||||
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
|
||||
|
||||
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
|
||||
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
|
||||
|
||||
<polygon points="${CX},${CY - 9} ${CX - 4},${CY + 5} ${CX},${CY + 2} ${CX + 4},${CY + 5}" fill="#fff" stroke="#0ff" stroke-width="1"/>
|
||||
|
||||
<rect x="80" y="${CY + 23}" width="40" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
|
||||
<rect x="${rudX - 4}" y="${CY + 22}" width="8" height="8" fill="#ff0" rx="1"/>
|
||||
|
||||
<line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
|
||||
<text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
|
||||
|
||||
<line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
|
||||
<text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
|
||||
|
||||
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
|
||||
<h3 class="sp-name">
|
||||
🚢
|
||||
<span>{{ name.toUpperCase() }}</span>
|
||||
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
|
||||
</h3>
|
||||
<button class="sp-close" @click.stop="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="sp-meta">
|
||||
<div class="flex-c">
|
||||
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
|
||||
<span>{{ boat.country || 'Unknown Country' }}</span>
|
||||
</div>
|
||||
<div class="flex-c align-x-end">
|
||||
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
|
||||
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo -->
|
||||
<div v-if="!photoError" class="sp-photo-wrap">
|
||||
<img class="sp-photo" :src="BASE + '/api/ais-iamge/' + boat.mmsi" :alt="name" @error="photoError = true" />
|
||||
</div>
|
||||
|
||||
<div class="sp-body">
|
||||
<div class="sp-gauges">
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Speed</div>
|
||||
<div class="sp-gauge" @click.stop="cycleSpeed">
|
||||
<span class="sp-gauge-val">{{ speedVal }}</span>
|
||||
<span class="sp-gauge-unit">{{ speed.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Course</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.course != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sp-gauge-wrap">
|
||||
<div class="sp-gauge-label">Heading</div>
|
||||
<div class="sp-gauge">
|
||||
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '—' }}</span>
|
||||
<span v-if="boat.heading != null" class="sp-gauge-unit">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-compass" v-html="compass"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ship-popup {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
pointer-events: auto;
|
||||
background: rgba(0,0,0,0.88);
|
||||
border: 1px solid rgba(200,200,220,0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
min-width: 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sp-header {
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.sp-name {
|
||||
margin: 0 24px 0 0;
|
||||
color: #7eeee1;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sp-mmsi { color: white; }
|
||||
|
||||
.sp-meta {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.2);
|
||||
}
|
||||
|
||||
.sp-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.sp-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
/* ── Photo ── */
|
||||
.sp-photo-wrap {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
background: #111;
|
||||
border-bottom: 1px solid rgba(200,200,220,0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sp-photo {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sp-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 0; }
|
||||
.sp-gauge-wrap { margin-bottom: 12px; }
|
||||
|
||||
.sp-gauge-label {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.sp-gauge {
|
||||
padding: 6px;
|
||||
background: rgba(0,0,0,0.95);
|
||||
border: 2px solid #0ff;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sp-gauge:hover { border-color: #0f0; }
|
||||
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
|
||||
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
|
||||
.sp-compass { flex: 1; margin-top: 0.25rem; }
|
||||
</style>
|
||||
196
client/src/components/Sun.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {getUVLabel} from '@/services/units.ts';
|
||||
import {inject, computed } from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
const {current: data} = useWeather();
|
||||
|
||||
const W = 320, H = 120, HORIZON = 45;
|
||||
|
||||
const arc = computed(() => {
|
||||
if (!data.value) return { filledPath: '', remainPath: '', cx: 0, cy: 0, pct: 0 };
|
||||
|
||||
const rise = new Date(data.value.sunrise).getTime();
|
||||
const set = new Date(data.value.sunset).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const dayLen = set - rise;
|
||||
const noon = rise + dayLen / 2;
|
||||
const start = noon - 12 * 3600 * 1000;
|
||||
const end = noon + 12 * 3600 * 1000;
|
||||
|
||||
const riseT = (rise - start) / (end - start);
|
||||
const setT = (set - start) / (end - start);
|
||||
const dayPct = Math.max(0, Math.min(1, (now - rise) / dayLen));
|
||||
const sunT = riseT + dayPct * (setT - riseT);
|
||||
|
||||
// After sunset, continue tracking sun below horizon
|
||||
const totalPct = Math.max(0, Math.min(1, (now - start) / (end - start)));
|
||||
|
||||
const x1 = 0, x2 = W;
|
||||
const totalW = x2 - x1;
|
||||
const amplitude = H * 0.45;
|
||||
const midY = H / 2;
|
||||
|
||||
const sineY = (t: number) => midY - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
|
||||
const horizonY = sineY(riseT);
|
||||
|
||||
const pts = Array.from({ length: 201 }, (_, i) => {
|
||||
const t = i / 200;
|
||||
return { x: x1 + totalW * t, y: sineY(t) };
|
||||
});
|
||||
|
||||
const splitIdx = Math.round(totalPct * 200);
|
||||
const { x: cx, y: cy } = <any>pts[Math.min(splitIdx, 200)];
|
||||
|
||||
const traveledPts: any = pts.slice(0, splitIdx + 1);
|
||||
const filledPath = traveledPts.map((p: any, i: any) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
|
||||
+ ` L${traveledPts[traveledPts.length - 1].x.toFixed(1)},${horizonY}`
|
||||
+ ` L${x1},${horizonY} Z`;
|
||||
|
||||
const remainPts = pts.slice(splitIdx);
|
||||
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
||||
|
||||
const riseX = x1 + totalW * riseT;
|
||||
const setX = x1 + totalW * setT;
|
||||
|
||||
return { filledPath, remainPath, cx, cy, pct: dayPct, riseX, setX, horizonY };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sun-card">
|
||||
<div class="card-title">🌅 Sun</div>
|
||||
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 3"><Loading /></div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Top labels -->
|
||||
<div class="sun-top-labels" v-if="data">
|
||||
<div>
|
||||
<div class="stat-label">Sunrise</div>
|
||||
<div class="stat-value">{{ new Date(data.sunrise).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="stat-label">Sunset</div>
|
||||
<div class="stat-value">{{ new Date(data.sunset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||
<!-- Filled traveled area -->
|
||||
<path v-if="arc.filledPath" :d="arc.filledPath" fill="#3b82f6" opacity="0.35" />
|
||||
<!-- Remaining curve stroke -->
|
||||
<path v-if="arc.remainPath" :d="arc.remainPath" fill="none" stroke="#94a3b8" stroke-width="1.5" opacity="0.5" />
|
||||
<!-- Horizon line -->
|
||||
<line :x1="0" :y1="arc.horizonY" :x2="W" :y2="arc.horizonY" stroke="var(--border)" stroke-width="1" />
|
||||
<!-- Sun dot -->
|
||||
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Daylight</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) ?? '—' }} <span v-if="data.daylight" class="fg-muted">hrs</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('lux')">
|
||||
<span class="stat-label">Brightness</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.lux ?? '—' }} <span v-if="data.lux != null" class="fg-muted">lm/m²</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('solar_wm2')">
|
||||
<span class="stat-label">Irradiance</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.solar_wm2 ?? '—' }} <span v-if="data.lux != null" class="fg-muted">W/m²</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('uv_index')">
|
||||
<span class="stat-label">UV Index</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index?.toFixed(1) ?? '—' }} <span v-if="data.uv_index != null" class="fg-muted">({{ getUVLabel(data.uv_index) }})</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_dose')">
|
||||
<span class="stat-label">UV Dose</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) ?? '—' }} <span v-if="data.uv_dose != null" class="fg-muted">J/m²</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('uv_index_max')">
|
||||
<span class="stat-label">UV Max</span>
|
||||
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
|
||||
<span v-else class="stat-value">{{ data.uv_index_max ?? '—' }} <span v-if="data.uv_index_max != null" class="fg-muted">({{getUVLabel(data.uv_index_max)}})</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sun-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sun-top-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
overflow: visible;
|
||||
}
|
||||
.sun-dot {
|
||||
filter: drop-shadow(0 0 5px #f59e0b88);
|
||||
}
|
||||
</style>
|
||||
92
client/src/components/Wind.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import Loading from '@/components/Loading.vue';
|
||||
import {inject} from 'vue';
|
||||
import {useWeather} from '../services/api';
|
||||
|
||||
const openGraph = inject<(key: string) => void>('openGraph');
|
||||
const {current: d} = useWeather();
|
||||
|
||||
function dir(deg: number) {
|
||||
if(deg > 22.5 && deg <= 67.5) return 'North-East';
|
||||
if(deg > 67.5 && deg <= 112.5) return 'East';
|
||||
if(deg > 112.5 && deg <= 157.5) return 'South-East';
|
||||
if(deg > 157.5 && deg <= 202.5) return 'South';
|
||||
if(deg > 202.5 && deg <= 247.5) return 'South-West';
|
||||
if(deg > 247.5 && deg <= 292.5) return 'West';
|
||||
if(deg > 292.5 && deg <= 337.5) return 'North-West';
|
||||
return 'North';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wind-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
margin: -4px -6px;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="wind-card">
|
||||
<div class="card-title">🍃 Wind</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat" @click="openGraph?.('wind_speed')">
|
||||
<span class="stat-label">Speed</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ d.wind_speed != null ? ~~(d.wind_speed) : '—' }} <span v-if="d.wind_speed != null" class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat" @click="openGraph?.('wind_gusts')">
|
||||
<span class="stat-label">Gusts</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ d.wind_gusts != null ? ~~(d.wind_gusts) : '—' }} <span v-if="d.wind_gusts != null" class="fg-muted">Kph</span></span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Direction</span>
|
||||
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
|
||||
<span v-else class="stat-value">{{ (d.wind_speed || d.wind_gusts) ? dir(d.wind_direction) : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
224
client/src/services/adsb.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { BASE } from '@/services/api.ts'
|
||||
import { createApp, ref } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import Point from 'ol/geom/Point'
|
||||
import LineString from 'ol/geom/LineString'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Icon, Stroke } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
import { adjustedInterval } from '@ztimson/utils'
|
||||
import AircraftPopup from '@/components/Aircraft.vue'
|
||||
import { bringToFront } from './zindex'
|
||||
|
||||
const API = BASE + '/api'
|
||||
|
||||
function getAltColor(alt: number): [number, number, number] {
|
||||
const a = Math.max(0, alt)
|
||||
if (a < 10) return [0, 0, 0]
|
||||
if (a < 10000) return [135, 206, 250]
|
||||
if (a < 25000) {
|
||||
const r = (a - 10000) / 15000
|
||||
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
|
||||
}
|
||||
if (a < 40000) {
|
||||
const r = (a - 25000) / 15000
|
||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
||||
}
|
||||
const r = Math.min((a - 40000) / 10000, 1)
|
||||
return [Math.round(128 * r), 0, 139]
|
||||
}
|
||||
|
||||
interface PopupInstance {
|
||||
planeRef: ReturnType<typeof ref>
|
||||
posRef: ReturnType<typeof ref>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
export class AirTrafficLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
||||
private traceSegs: Record<string, Feature[]> = {}
|
||||
private popups: any = {}
|
||||
private historyCache: any = {}
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = null
|
||||
visible = false
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 1_000)
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
||||
this.map.removeLayer(this.layer)
|
||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
||||
this.traceLayers = {}
|
||||
this.traceSegs = {}
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
|
||||
this.data = (j || []).map((a: any) => ({
|
||||
...a,
|
||||
icao: a.hex,
|
||||
latitude: a.lat,
|
||||
longitude: a.lon,
|
||||
heading: a.track,
|
||||
speed: a.gs,
|
||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||
name: a.flight?.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
source.clear()
|
||||
for (const plane of this.data) {
|
||||
if (plane.latitude == null) continue
|
||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('icao', plane.icao)
|
||||
f.set('planeData', plane)
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
|
||||
scale: 1.25,
|
||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}))
|
||||
source.addFeature(f)
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchTrace(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (!this.historyCache[icao]) {
|
||||
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
|
||||
this.historyCache[icao] = j.history || []
|
||||
}
|
||||
const history = [...this.historyCache[icao]]
|
||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
||||
const last = history[history.length - 1]
|
||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
||||
if (history.length < 2) return
|
||||
|
||||
let traceLayer = this.traceLayers[icao]
|
||||
let vs: VectorSource
|
||||
|
||||
if (!traceLayer) {
|
||||
vs = new VectorSource()
|
||||
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
|
||||
this.map.addLayer(traceLayer)
|
||||
this.traceLayers[icao] = traceLayer
|
||||
this.traceSegs[icao] = []
|
||||
} else {
|
||||
vs = traceLayer.getSource()!
|
||||
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
|
||||
}
|
||||
|
||||
const segs: Feature[] = []
|
||||
for (let i = 0; i < history.length - 1; i++) {
|
||||
const s = history[i], e = history[i + 1]
|
||||
const sc = getAltColor(s.altitude || 0)
|
||||
const ec = getAltColor(e.altitude || 0)
|
||||
const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2))
|
||||
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
||||
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
||||
vs.addFeature(f)
|
||||
segs.push(f)
|
||||
}
|
||||
this.traceSegs[icao] = segs
|
||||
}
|
||||
|
||||
private _calcPopupPos(plane: any): { x: number; y: number } {
|
||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
||||
if (!pixel) return { x: 10, y: 60 }
|
||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
||||
}
|
||||
|
||||
private _openPopup(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (this.popups[icao]) return
|
||||
|
||||
const planeRef = ref(plane)
|
||||
const posRef = ref(this._calcPopupPos(plane))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(AircraftPopup, {
|
||||
plane: planeRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(icao),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
|
||||
this.popups[icao] = {
|
||||
planeRef,
|
||||
posRef,
|
||||
unmount: () => { app.unmount(); container.remove() },
|
||||
}
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
|
||||
private _closePopup(icao: string) {
|
||||
const popup = this.popups[icao]
|
||||
if (popup) { popup.unmount(); delete this.popups[icao] }
|
||||
const segs = this.traceSegs[icao]
|
||||
const layer = this.traceLayers[icao]
|
||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
||||
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
|
||||
delete this.traceSegs[icao]
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const icao of Object.keys(this.popups)) {
|
||||
const plane = this.data.find(p => p.icao === icao)
|
||||
if (!plane) { this._closePopup(icao); continue }
|
||||
this.popups[icao].planeRef.value = plane
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = (evt) => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
|
||||
if (!f) return
|
||||
const plane = f.get('planeData')
|
||||
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
|
||||
this._openPopup(plane)
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import Point from 'ol/geom/Point'
|
||||
import LineString from 'ol/geom/LineString'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Icon, Stroke } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
|
||||
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] {
|
||||
const a = Math.max(0, alt)
|
||||
if (a < 10) return [0, 0, 0]
|
||||
if (a < 10000) return [135, 206, 250]
|
||||
if (a < 25000) {
|
||||
const r = (a - 10000) / 15000
|
||||
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
|
||||
}
|
||||
if (a < 40000) {
|
||||
const r = (a - 25000) / 15000
|
||||
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
|
||||
}
|
||||
const r = Math.min((a - 40000) / 10000, 1)
|
||||
return [Math.round(128 * r), 0, 139]
|
||||
}
|
||||
|
||||
function buildPlaneIcon(plane: any, shapes: any): string {
|
||||
const color = COLORS[plane.class] || COLORS['Unknown']
|
||||
const shape = shapes[plane.shape] || shapes['unknown'] || shapes[<any>Object.keys(shapes)[0]]
|
||||
if (!shape) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><polygon points="12,2 22,22 12,17 2,22" fill="${color}" stroke="#000" stroke-width="1"/></svg>`)}`
|
||||
}
|
||||
const paths = (Array.isArray(shape.path) ? shape.path : [shape.path])
|
||||
.map((p: string) => `<path d="${p}" fill="${color}" stroke="#000" stroke-width="${shape.stroke || 1}"/>`)
|
||||
.join('')
|
||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${shape.w * 2}" height="${shape.h * 2}" viewBox="${shape.viewBox}">${paths}</svg>`)}`
|
||||
}
|
||||
|
||||
function buildNavballSvg(data: any): string {
|
||||
const heading = data.heading ?? 0
|
||||
const airspeedKnots = data.speed || 0
|
||||
const verticalRateFps = data.climb || 0
|
||||
const groundSpeedFps = airspeedKnots * 1.68781
|
||||
const pitchRad = groundSpeedFps > 0 ? Math.atan(verticalRateFps / groundSpeedFps) : 0
|
||||
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
|
||||
|
||||
const pitchLines: string[] = []
|
||||
for (let p = -30; p <= 30; p += 5) {
|
||||
if (p === 0) continue
|
||||
const isMajor = p % 10 === 0
|
||||
const [start, end] = [85, 115]
|
||||
if (isMajor) {
|
||||
pitchLines.push(`
|
||||
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
|
||||
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
|
||||
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
|
||||
`)
|
||||
} else {
|
||||
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
|
||||
}
|
||||
}
|
||||
|
||||
const rollTicks: string[] = []
|
||||
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 {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
|
||||
private traceSegs: Record<string, Feature[]> = {}
|
||||
private popups: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
|
||||
private historyCache: Record<string, any[]> = {}
|
||||
private shapes: any = null
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
visible = false
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
// ── Public ────────────────────────────────────────────────────────────────
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
|
||||
if (!this.shapes) this.shapes = await fetch(`${API}/air-traffic-shapes`).then(r => r.json())
|
||||
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
|
||||
this.refreshTimer = setInterval(async () => {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
}, 15_000)
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
|
||||
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
|
||||
|
||||
this.map.removeLayer(this.layer)
|
||||
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
|
||||
this.traceLayers = {}
|
||||
this.traceSegs = {}
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async _fetch() {
|
||||
const j = await fetch(`${API}/air-traffic`).then(r => r.json())
|
||||
this.data = (j.data || []).map((a: any) => ({
|
||||
...a,
|
||||
icao: a.hex,
|
||||
latitude: a.lat,
|
||||
longitude: a.lon,
|
||||
heading: a.track,
|
||||
speed: a.gs,
|
||||
climb: a.baro_rate ?? a.geom_rate ?? 0,
|
||||
name: a.flight?.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
const existing: Record<string, Feature> = {}
|
||||
source.getFeatures().forEach(f => { existing[f.get('icao')] = f })
|
||||
const incoming = new Set(this.data.filter(p => p.latitude != null).map(p => p.icao))
|
||||
|
||||
// Remove stale
|
||||
Object.keys(existing).forEach(icao => {
|
||||
if (!incoming.has(icao)) { source.removeFeature(<any>existing[icao]); this._closePopup(icao) }
|
||||
})
|
||||
|
||||
// Update / add
|
||||
for (const plane of this.data) {
|
||||
if (plane.latitude == null) continue
|
||||
const coord = fromLonLat([plane.longitude, plane.latitude])
|
||||
const style = new Style({
|
||||
image: new Icon({
|
||||
src: buildPlaneIcon(plane, this.shapes),
|
||||
scale: 1,
|
||||
rotation: (plane.heading ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
})
|
||||
|
||||
if (existing[plane.icao]) {
|
||||
;(<any>((<any>existing[plane.icao]).getGeometry() as Point)).setCoordinates(coord)
|
||||
(<any>existing[plane.icao]).setStyle(style)
|
||||
(<any>existing[plane.icao]).set('planeData', plane)
|
||||
} else {
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('icao', plane.icao)
|
||||
f.set('planeData', plane)
|
||||
f.setStyle(style)
|
||||
source.addFeature(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchTrace(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (!this.historyCache[icao]) {
|
||||
const j = await fetch(`${API}/air-traffic/${icao}`).then(r => r.json())
|
||||
this.historyCache[icao] = j.history || []
|
||||
}
|
||||
|
||||
const history = [...(<any>this.historyCache[icao])]
|
||||
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
|
||||
const last = history[history.length - 1]
|
||||
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
|
||||
if (history.length < 2) return
|
||||
|
||||
let traceLayer = this.traceLayers[icao]
|
||||
let vs: VectorSource
|
||||
|
||||
if (!traceLayer) {
|
||||
vs = new VectorSource()
|
||||
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
|
||||
this.map.addLayer(traceLayer)
|
||||
this.traceLayers[icao] = traceLayer
|
||||
this.traceSegs[icao] = []
|
||||
} else {
|
||||
vs = traceLayer.getSource()!
|
||||
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
|
||||
}
|
||||
|
||||
const segs: Feature[] = []
|
||||
for (let i = 0; i < history.length - 1; i++) {
|
||||
const s = history[i], e = history[i + 1]
|
||||
const sc = getAltColor(s.altitude || 0)
|
||||
const ec = getAltColor(e.altitude || 0)
|
||||
const avg = sc.map((v, idx) => Math.round((v + <any>ec[idx]) / 2))
|
||||
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
|
||||
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
|
||||
vs.addFeature(f)
|
||||
segs.push(f)
|
||||
}
|
||||
this.traceSegs[icao] = segs
|
||||
}
|
||||
|
||||
private _openPopup(plane: any) {
|
||||
const icao = plane.icao
|
||||
if (this.popups[icao]) return
|
||||
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `
|
||||
position:absolute; z-index:9999; pointer-events:auto;
|
||||
background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3);
|
||||
border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden;
|
||||
`
|
||||
|
||||
const close = document.createElement('button')
|
||||
close.innerHTML = '✕'
|
||||
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
|
||||
close.onclick = () => this._closePopup(icao)
|
||||
|
||||
el.innerHTML = buildPopupHtml(plane)
|
||||
el.appendChild(close)
|
||||
document.body.appendChild(el)
|
||||
this._positionPopup(el, plane)
|
||||
|
||||
el.querySelectorAll('.at-gauge').forEach(g => {
|
||||
g.addEventListener('click', (e) => {
|
||||
const prefs = getUnits()
|
||||
const type = (e.currentTarget as HTMLElement).dataset.gauge!
|
||||
if (type === 'speed') {
|
||||
const keys = Object.keys(SPEED_UNITS)
|
||||
prefs.speed = keys[(keys.indexOf(prefs.speed) + 1) % keys.length]
|
||||
} else if (type === 'altitude') {
|
||||
const keys = Object.keys(ALTITUDE_UNITS)
|
||||
prefs.altitude = keys[(keys.indexOf(prefs.altitude) + 1) % keys.length]
|
||||
prefs.vertical = prefs.altitude === 'meters' ? 'mps' : 'fps'
|
||||
} else if (type === 'vertical') {
|
||||
const keys = Object.keys(VERTICAL_UNITS)
|
||||
prefs.vertical = keys[(keys.indexOf(prefs.vertical) + 1) % keys.length]
|
||||
prefs.altitude = prefs.vertical === 'mps' ? 'meters' : 'feet'
|
||||
}
|
||||
saveUnits(prefs)
|
||||
el.innerHTML = buildPopupHtml(plane)
|
||||
el.appendChild(close)
|
||||
})
|
||||
g.addEventListener('mouseenter', e => (e.currentTarget as HTMLElement).style.borderColor = '#0ff')
|
||||
g.addEventListener('mouseleave', e => (e.currentTarget as HTMLElement).style.borderColor = '#0f0')
|
||||
})
|
||||
|
||||
this.popups[icao] = { el, destroy: () => el.remove() }
|
||||
this._fetchTrace(plane)
|
||||
}
|
||||
|
||||
private _positionPopup(el: HTMLDivElement, plane: any) {
|
||||
const pixel = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
|
||||
if (!pixel) return
|
||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||
el.style.left = `${rect.left + <any>pixel[0] + 16}px`
|
||||
el.style.top = `${rect.top + <any>pixel[1] - 16}px`
|
||||
}
|
||||
|
||||
private _closePopup(icao: string) {
|
||||
const popup = this.popups[icao]
|
||||
if (popup) { popup.destroy(); delete this.popups[icao] }
|
||||
|
||||
const segs = this.traceSegs[icao]
|
||||
const layer = this.traceLayers[icao]
|
||||
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
|
||||
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
|
||||
delete this.traceSegs[icao]
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const icao of Object.keys(this.popups)) {
|
||||
const plane = this.data.find(p => p.icao === icao)
|
||||
if (!plane) { this._closePopup(icao); continue }
|
||||
const { el } = <any>this.popups[icao]
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = (evt) => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null)
|
||||
if (!f) return
|
||||
const plane = f.get('planeData')
|
||||
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return }
|
||||
this._openPopup(plane)
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
}
|
||||
}
|
||||
173
client/src/services/ais.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { BASE } from '@/services/api.ts'
|
||||
import { createApp, ref } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import Point from 'ol/geom/Point'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Icon } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
import { adjustedInterval } from '@ztimson/utils'
|
||||
import ShipPopup from '@/components/Ship.vue'
|
||||
import { bringToFront } from './zindex'
|
||||
|
||||
const API = BASE + '/api'
|
||||
|
||||
const SHIP_COLORS: Record<string, string> = {
|
||||
'Base Station': '#ffffff',
|
||||
'Class A': '#00ff00',
|
||||
'Class B': '#8450ea',
|
||||
'SAR Aircraft': '#ff0000',
|
||||
'AtoN': '#ffffff',
|
||||
'Class B/CS': '#8450ea',
|
||||
'Sart/Epirb/MOB': '#00aaff',
|
||||
'Unknown': '#fad106',
|
||||
}
|
||||
|
||||
function buildBoatIcon(boat: any): string {
|
||||
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
|
||||
if (['Base Station', 'AtoN'].includes(boat.type)) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<polygon points="16,2 30,16 16,30 2,16" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||
<circle cx="16" cy="9" r="1.8" fill="#000"/>
|
||||
<line x1="16" y1="9" x2="16" y2="23" stroke="#000" stroke-width="1.8"/>
|
||||
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
|
||||
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
|
||||
</svg>
|
||||
`)}`
|
||||
}
|
||||
return `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
|
||||
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#000"/>
|
||||
<line x1="12" y1="12" x2="12" y2="22" stroke="#000" stroke-width="1.5"/>
|
||||
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
|
||||
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
|
||||
</svg>
|
||||
`)}`
|
||||
}
|
||||
|
||||
export class AISLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
private popups: any = {}
|
||||
private data: any[] = []
|
||||
private clickHandler: ((e: any) => void) | null = null
|
||||
private refreshTimer: any = null
|
||||
visible = false
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if (this.visible) return
|
||||
this.visible = true
|
||||
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
|
||||
this.map.addLayer(this.layer)
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._attachClick()
|
||||
this.refreshTimer = adjustedInterval(async () => {
|
||||
try {
|
||||
await this._fetch()
|
||||
this._draw()
|
||||
this._refreshPopups()
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
|
||||
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
|
||||
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
|
||||
this.map.removeLayer(this.layer)
|
||||
}
|
||||
|
||||
private async _fetch() {
|
||||
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
|
||||
}
|
||||
|
||||
private _draw() {
|
||||
const source = this.layer.getSource()!
|
||||
source.clear()
|
||||
for (const boat of this.data) {
|
||||
if (boat.lat == null || boat.lon == null) continue
|
||||
const coord = fromLonLat([boat.lon, boat.lat])
|
||||
const f = new Feature({ geometry: new Point(coord) })
|
||||
f.set('mmsi', boat.mmsi)
|
||||
f.set('boatData', boat)
|
||||
f.setStyle(new Style({
|
||||
image: new Icon({
|
||||
src: buildBoatIcon(boat),
|
||||
scale: 0.8,
|
||||
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
|
||||
anchor: [0.5, 0.5],
|
||||
}),
|
||||
}))
|
||||
source.addFeature(f)
|
||||
}
|
||||
}
|
||||
|
||||
private _calcPopupPos(boat: any): { x: number; y: number } {
|
||||
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
|
||||
if (!pixel) return { x: 10, y: 60 }
|
||||
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
|
||||
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
|
||||
}
|
||||
|
||||
private _openPopup(boat: any) {
|
||||
const mmsi = String(boat.mmsi)
|
||||
if (this.popups[mmsi]) return
|
||||
|
||||
const boatRef = ref(boat)
|
||||
const posRef = ref(this._calcPopupPos(boat))
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
|
||||
const mobile = window.innerWidth <= 768;
|
||||
|
||||
const app = createApp(ShipPopup, {
|
||||
boat: boatRef.value,
|
||||
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
|
||||
onClose: () => this._closePopup(mmsi),
|
||||
onBringToFront: () => bringToFront(container),
|
||||
})
|
||||
|
||||
app.mount(container)
|
||||
this.popups[mmsi] = {
|
||||
boatRef,
|
||||
posRef,
|
||||
unmount: () => { app.unmount(); container.remove() },
|
||||
}
|
||||
}
|
||||
|
||||
private _closePopup(mmsi: string) {
|
||||
const popup = this.popups[mmsi]
|
||||
if (popup) { popup.unmount(); delete this.popups[mmsi] }
|
||||
}
|
||||
|
||||
private _refreshPopups() {
|
||||
for (const mmsi of Object.keys(this.popups)) {
|
||||
const boat = this.data.find(b => String(b.mmsi) === mmsi)
|
||||
if (!boat) { this._closePopup(mmsi); continue }
|
||||
this.popups[mmsi].boatRef.value = boat
|
||||
}
|
||||
}
|
||||
|
||||
private _attachClick() {
|
||||
this.clickHandler = (evt) => {
|
||||
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null)
|
||||
if (!f) return
|
||||
const boat = f.get('boatData')
|
||||
const mmsi = String(boat.mmsi)
|
||||
if (this.popups[mmsi]) { this._closePopup(mmsi); return }
|
||||
this._openPopup(boat)
|
||||
}
|
||||
this.map.on('singleclick', this.clickHandler)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,60 @@
|
||||
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 const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
||||
|
||||
const cache: any = {};
|
||||
const current = ref<any>(null);
|
||||
const hourly = ref<any[]>([]);
|
||||
const daily = ref<any[]>([]);
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
let refCount = 0;
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin)
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
|
||||
if(cache[url.toString()]) return cache[url.toString()];
|
||||
cache[url.toString()] = await fetch(url.toString()).then(resp => resp.json()).finally(() => { delete cache[url.toString()]; });
|
||||
return cache[url.toString()]
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
const key = url.toString();
|
||||
if(cache[key]) return cache[key];
|
||||
cache[key] = fetch(url.toString())
|
||||
.then(resp => resp.json())
|
||||
.finally(() => delete cache[key]);
|
||||
return cache[key];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? { fields } : {}),
|
||||
current: (fields?: string) => get<DataRow>('/api/current', fields ? { fields } : {}),
|
||||
hourly: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/hourly', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(fields ? { fields } : {}) }),
|
||||
daily: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/daily', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(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} : {}),
|
||||
hourly: (start?: Date | string, end?: Date | string, fields?: string) => get<DataRow[]>('/api/hourly', {
|
||||
...(start ? {start: typeof start == 'string' ? start : start.toISOString()} : {}),
|
||||
...(end ? {end: typeof end == 'string' ? end : end.toISOString()} : {}),
|
||||
...(fields ? {fields} : {})
|
||||
}),
|
||||
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
||||
...(start ? {start: typeof start == 'string' ? start : start.toISOString()} : {}),
|
||||
...(end ? {end: typeof end == 'string' ? end : end.toISOString()} : {}),
|
||||
...(fields ? {fields} : {})
|
||||
}),
|
||||
};
|
||||
|
||||
async function refresh() {
|
||||
const start = new Date(), end = new Date();
|
||||
start.setDate(start.getDate() + 1);
|
||||
end.setDate(end.getDate() + 5);
|
||||
current.value = await api.current();
|
||||
return {current}
|
||||
}
|
||||
|
||||
export function useWeather(ms = 5 * 60_000) {
|
||||
if(++refCount === 1) {
|
||||
refresh();
|
||||
interval = setInterval(refresh, ms);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if(--refCount === 0) clearInterval(interval);
|
||||
});
|
||||
|
||||
return {current, hourly, daily, refresh};
|
||||
}
|
||||
|
||||
46
client/src/services/range.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {BASE} from '@/services/api.ts';
|
||||
import Map from 'ol/Map'
|
||||
import { Feature } from 'ol'
|
||||
import { Polygon } from 'ol/geom'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { Style, Stroke } from 'ol/style'
|
||||
import { Vector as VectorLayer } from 'ol/layer'
|
||||
import { Vector as VectorSource } from 'ol/source'
|
||||
|
||||
export class RangeLayer {
|
||||
private map: Map
|
||||
private layer!: VectorLayer<VectorSource>
|
||||
visible = false
|
||||
interval: any;
|
||||
|
||||
constructor(map: Map) { this.map = map }
|
||||
|
||||
async show() {
|
||||
if(this.visible) this.hide(false);
|
||||
this.visible = true
|
||||
|
||||
const points: [number, number][] = await fetch(`${BASE}/api/range`).then(r => r.json())
|
||||
const coords = points.map(([lat, lon]: number[]) => fromLonLat([<number>lon, <number>lat]))
|
||||
coords.push(<any>coords[0]) // close the polygon
|
||||
|
||||
const feature = new Feature({ geometry: new Polygon([coords]) })
|
||||
feature.setStyle(new Style({
|
||||
stroke: new Stroke({ color: 'rgb(0 171 138 / 0.53)', width: 1.5 })
|
||||
}))
|
||||
|
||||
const source = new VectorSource({ features: [feature] })
|
||||
this.layer = new VectorLayer({ source, zIndex: 50 })
|
||||
this.map.addLayer(this.layer)
|
||||
if(!this.interval) this.interval = setInterval(() => this.show, 60_000);
|
||||
}
|
||||
|
||||
hide(stop = true) {
|
||||
if (!this.visible) return
|
||||
this.visible = false
|
||||
this.map.removeLayer(this.layer)
|
||||
if(stop && this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export interface MetricMeta {
|
||||
export const METRICS: Record<string, MetricMeta> = {
|
||||
// Environment
|
||||
temperature: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
|
||||
env_temp_f: { label: 'Temperature', unit: '°F', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
|
||||
humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' },
|
||||
dew_point: { label: 'Dew Point', unit: '°C', icon: '🌫️', group: 'Environment', precision: 1, color: '#818cf8' },
|
||||
heat_index: { label: 'Feels Like', unit: '°C', icon: '🤔', group: 'Environment', precision: 1, color: '#fb923c' },
|
||||
@@ -27,28 +26,28 @@ export const METRICS: Record<string, MetricMeta> = {
|
||||
uv_dose: { label: 'UV Dose Today', unit: ' mJ/cm²', icon: '📡', group: 'Light', precision: 2, color: '#f9a8d4' },
|
||||
light_burn_time_min: { label: 'Burn Time', unit: ' min', icon: '🔥', group: 'Light', precision: 0, color: '#fb7185' },
|
||||
clouds: { label: 'Cloud Cover', unit: '%', icon: '☁️', group: 'Light', precision: 0, color: '#94a3b8' },
|
||||
light_dli: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' },
|
||||
day_light_integral: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' },
|
||||
visibility: { label: 'Visibility', unit: ' km', icon: '👁️', group: 'Light', precision: 1, color: '#7dd3fc' },
|
||||
// Wind
|
||||
wind_speed_kmh: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' },
|
||||
wind_gusts_kmh: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
|
||||
wind_direction_deg: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
|
||||
wind_speed: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' },
|
||||
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' },
|
||||
// Seismic
|
||||
seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
|
||||
seismic_ax: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_ay: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_az: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
seismic_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_z: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
|
||||
// Compass
|
||||
compass_heading: { label: 'Compass Heading', unit: '°', icon: '🧭', group: 'Compass', precision: 1, color: '#c084fc' },
|
||||
heading: { label: 'Compass Heading', unit: '°', icon: '🧭', group: 'Compass', precision: 1, color: '#c084fc' },
|
||||
// Ground
|
||||
ground_distance: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' },
|
||||
accumulation: { label: 'Accumulation Depth', unit: ' cm', icon: '❄️', group: 'Ground', precision: 1, color: '#bfdbfe' },
|
||||
lidar_strength: { label: 'LIDAR Strength', unit: '', icon: '📡', group: 'Ground', precision: 0, color: '#6ee7b7' },
|
||||
// Lightning
|
||||
lightning_distance: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' },
|
||||
lightning_strikes_per_hour: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' },
|
||||
lightning_rate: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' },
|
||||
// Moon
|
||||
moon_illumination_pct: { label: 'Moon Illumination', unit: '%', icon: '🌙', group: 'Celestial', precision: 0, color: '#e2e8f0' },
|
||||
moon_illumination: { label: 'Moon Illumination', unit: '%', icon: '🌙', group: 'Celestial', precision: 0, color: '#e2e8f0' },
|
||||
// Sun
|
||||
sun_elevation: { label: 'Solar Elevation', unit: '°', icon: '☀️', group: 'Celestial', precision: 1, color: '#fef08a' },
|
||||
// Space
|
||||
@@ -58,16 +57,25 @@ export const METRICS: Record<string, MetricMeta> = {
|
||||
// Marine
|
||||
wave_height: { label: 'Wave Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#38bdf8' },
|
||||
swell_wave_height: { label: 'Swell Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#7dd3fc' },
|
||||
wave_period: { label: 'Wave Period', unit: ' s', icon: '⏱️', group: 'Marine', precision: 1, color: '#bae6fd' },
|
||||
// AQ
|
||||
pm2_5: { label: 'PM2.5', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fca5a5' },
|
||||
pm10: { label: 'PM10', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fdba74' },
|
||||
ozone: { label: 'Ozone', unit: ' μg/m³', icon: '🌐', group: 'Air Quality', precision: 1, color: '#6ee7b7' },
|
||||
// Forecast
|
||||
forecast_precipitation_mm: { label: 'Precipitation', unit: ' mm', icon: '🌧️', group: 'Forecast', precision: 1, color: '#38bdf8' },
|
||||
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
|
||||
}
|
||||
|
||||
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 {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const meta = METRICS[key]
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { api, type DataRow } from './api'
|
||||
|
||||
export const current = ref<DataRow>({})
|
||||
export const hourly = ref<DataRow[]>([])
|
||||
export const daily = ref<DataRow[]>([])
|
||||
export const loading = ref(false)
|
||||
export const lastFetch = ref<Date | null>(null)
|
||||
|
||||
export async function fetchAll() {
|
||||
const date = (d: Date = new Date()) => `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
|
||||
|
||||
loading.value = true
|
||||
const start = new Date(), end = new Date();
|
||||
start.setDate(start.getDate() + 1);
|
||||
end.setDate(end.getDate() + 5);
|
||||
const [c, h, d] = await Promise.allSettled([api.current(), api.hourly(), api.daily(date(start), date(end))])
|
||||
if (c.status === 'fulfilled') current.value = c.value
|
||||
if (h.status === 'fulfilled') hourly.value = h.value
|
||||
if (d.status === 'fulfilled') daily.value = d.value
|
||||
lastFetch.value = new Date()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
export async function fetchHistoricHourly(start: string, end: string): Promise<DataRow[]> {
|
||||
return api.hourly(start, end)
|
||||
}
|
||||
|
||||
export async function fetchHistoricDaily(start: string, end: string): Promise<DataRow[]> {
|
||||
return api.daily(start, end)
|
||||
}
|
||||
|
||||
export const isDaytime = computed(() => current.value.sun_is_day === 1)
|
||||
5
client/src/services/zindex.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// src/services/zIndex.ts
|
||||
export let highestZIndex = 1000
|
||||
export function bringToFront(el: HTMLElement) {
|
||||
el.style.zIndex = String(++highestZIndex)
|
||||
}
|
||||
@@ -1,44 +1,38 @@
|
||||
`<script setup lang="ts">
|
||||
import GraphModal from '@/components/GraphModal.vue';
|
||||
import HourlyForecast from '@/components/HourlyForecast.vue';
|
||||
import Moon from '@/components/Moon.vue';
|
||||
import Precipitation from '@/components/Precipitation.vue';
|
||||
import Seismic from '@/components/Seismic.vue';
|
||||
import Sun from '@/components/Sun.vue';
|
||||
import Wind from '@/components/Wind.vue';
|
||||
import {BASE, useWeather} from '@/services/api.ts';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, onUnmounted, ref, computed} from 'vue';
|
||||
import {current, hourly, daily, fetchAll, loading} from '../services/weather';
|
||||
import {METRICS, GROUPS} from '../services/units';
|
||||
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
||||
import MapView from '../components/MapView.vue';
|
||||
import CurrentWeather from '../components/CurrentWeather.vue';
|
||||
import ForecastStrip from '../components/ForecastStrip.vue';
|
||||
import MetricCard from '../components/MetricCard.vue';
|
||||
import GraphModal from '../components/GraphModal.vue';
|
||||
|
||||
const day = ref();
|
||||
const date = ref();
|
||||
const time = ref();
|
||||
const day = ref(formatDate('dddd'));
|
||||
const date = ref(formatDate('MMMM D'));
|
||||
const time = ref(formatDate('h:mm A'));
|
||||
const props = defineProps<{dark: boolean}>();
|
||||
const selectedMetric = ref<string | null>(null);
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
let clock: ReturnType<typeof setInterval>;
|
||||
|
||||
provide('openGraph', (key: string) => selectedMetric.value = key);
|
||||
|
||||
onMounted(async () => {
|
||||
useWeather();
|
||||
clock = setInterval(() => {
|
||||
day.value = formatDate('dddd');
|
||||
date.value = formatDate('MMMM D');
|
||||
time.value = formatDate('h:mm A');
|
||||
}, 1000);
|
||||
await fetchAll();
|
||||
interval = setInterval(fetchAll, 60 * 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(clock);
|
||||
clearInterval(interval);
|
||||
});
|
||||
|
||||
const groupedMetrics = computed(() => {
|
||||
return GROUPS.map(group => ({
|
||||
group,
|
||||
keys: Object.entries(METRICS)
|
||||
.filter(([key, meta]) => meta.group === group && current.value[key] != null)
|
||||
.map(([key]) => key)
|
||||
})).filter(g => g.keys.length > 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -61,10 +55,6 @@ const groupedMetrics = computed(() => {
|
||||
|
||||
.panel-col {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
@@ -161,14 +151,12 @@ const groupedMetrics = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div v-if="loading" class="loading-bar"/>
|
||||
|
||||
<div class="map-col">
|
||||
<MapView :dark="props.dark"/>
|
||||
</div>
|
||||
|
||||
<div class="panel-col">
|
||||
<div class="flex-r align-items-center gap-3">
|
||||
<div class="flex-r align-items-center gap-3 mb-3">
|
||||
<div class="pe-3" style="border-right: 2px solid var(--border)">
|
||||
<h2 class="fs-6 m-0" style="line-height: 1em">{{time}}</h2>
|
||||
</div>
|
||||
@@ -177,28 +165,22 @@ const groupedMetrics = computed(() => {
|
||||
<h2 class="m-0 fs-1 fg-muted" style="line-height: 1em">{{date}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<CurrentWeather />
|
||||
<ForecastStrip :days="daily"/>
|
||||
<CurrentWeather class="mb-3" />
|
||||
<HourlyForecast class="mb-3" />
|
||||
<ForecastStrip class="mb-3" />
|
||||
|
||||
<template v-for="g in groupedMetrics" :key="g.group">
|
||||
<div class="group-label">{{ g.group }}</div>
|
||||
<div class="metric-grid">
|
||||
<MetricCard
|
||||
v-for="key in g.keys"
|
||||
:key="key"
|
||||
:metric-key="key"
|
||||
:data="current"
|
||||
@click="selectedMetric = key"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<Seismic class="mb-3" />
|
||||
<Precipitation class="mb-3" />
|
||||
<Wind class="mb-3" />
|
||||
<Sun class="mb-3" />
|
||||
<Moon class="mb-3" />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphModal
|
||||
:metric-key="selectedMetric"
|
||||
:current-data="current"
|
||||
@close="selectedMetric = null"
|
||||
/>
|
||||
<GraphModal :metric-key="selectedMetric" @close="selectedMetric = null" />
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
|
||||
523
sensors/main.py
@@ -1,9 +1,7 @@
|
||||
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 collections import deque
|
||||
from influxdb_client import InfluxDBClient, Point
|
||||
from influxdb_client.client.write_api import SYNCHRONOUS
|
||||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,10 +14,7 @@ def cfg():
|
||||
'LATITUDE': float(os.getenv('LATITUDE', '0.0')),
|
||||
'LONGITUDE': float(os.getenv('LONGITUDE', '0.0')),
|
||||
'ALTITUDE': float(os.getenv('ALTITUDE', '0.0')),
|
||||
'INFLUX_URL': os.getenv('INFLUX_URL', 'http://localhost:8086'),
|
||||
'INFLUX_TOKEN': os.getenv('INFLUX_TOKEN', ''),
|
||||
'INFLUX_ORG': os.getenv('INFLUX_ORG', 'weather'),
|
||||
'INFLUX_BUCKET': os.getenv('INFLUX_BUCKET', 'station'),
|
||||
'DB_HOST': os.getenv('DB_HOST', 'http://localhost:8428'),
|
||||
'GPS_PORT': os.getenv('GPS_PORT', '/dev/ttyS0'),
|
||||
'GPS_BAUD': int( os.getenv('GPS_BAUD', '9600')),
|
||||
'GAS_REFERENCE': int( os.getenv('GAS_REFERENCE', '250000')),
|
||||
@@ -31,49 +26,27 @@ def cfg():
|
||||
'AS3935_NOISE_FLOOR': int( os.getenv('AS3935_NOISE_FLOOR', '5')),
|
||||
'AS3935_WATCHDOG': int( os.getenv('AS3935_WATCHDOG', '3')),
|
||||
'AS3935_SPIKE_REJ': int( os.getenv('AS3935_SPIKE_REJ', '7')),
|
||||
'SKIN_TYPE_2_MED': int( os.getenv('SKIN_TYPE_2_MED', '200')),
|
||||
'LOOP_INTERVAL': float( os.getenv('LOOP_INTERVAL', '1.0')),
|
||||
'FLOOD_TEMP_THRESHOLD': float( os.getenv('FLOOD_TEMP_THRESHOLD', '10.0')),
|
||||
'SLUSH_TEMP_THRESHOLD': float( os.getenv('SLUSH_TEMP_THRESHOLD', '0.0')),
|
||||
'SNOW_STRENGTH_MIN': int( os.getenv('SNOW_STRENGTH_MIN', '700')),
|
||||
'SEISMIC_NOISE_FLOOR': float( os.getenv('SEISMIC_NOISE_FLOOR', '0.02')),
|
||||
'TEMP_CALIBRATION': float( os.getenv('TEMP_CALIBRATION', '7.0'),
|
||||
'CLOUD_BUCKET_SIZE': int( os.getenv('CLOUD_BUCKET_SIZE', '5')),
|
||||
'CLOUD_HISTORY': int( os.getenv('CLOUD_HISTORY', '20')),
|
||||
'CLOUD_BASELINE_PCT': float( os.getenv('CLOUD_BASELINE_PCT', '0.9')),
|
||||
'CLOUD_VOLATILITY_THRESH':float( os.getenv('CLOUD_VOLATILITY_THRESH', '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')),
|
||||
'TEMP_CALIBRATION': float( os.getenv('TEMP_CALIBRATION', '0.0')),
|
||||
'VISIBILITY_MAX': float( os.getenv('VISIBILITY_MAX', '50.0')),
|
||||
'CLOUD_SUN_MIN_ELEV': float( os.getenv('CLOUD_SUN_MIN_ELEV', '5.0')),
|
||||
'CLOUD_LUX_CLEAR_SKY': float( os.getenv('CLOUD_LUX_CLEAR_SKY', '100000.0')),
|
||||
'CLOUD_HUMIDITY_WEIGHT': float( os.getenv('CLOUD_HUMIDITY_WEIGHT', '0.3')),
|
||||
}
|
||||
|
||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
||||
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||
|
||||
_influx_client = None
|
||||
_influx_writer = None
|
||||
_influx_url = None
|
||||
_influx_token = None
|
||||
|
||||
def get_writer(c):
|
||||
global _influx_client, _influx_writer, _influx_url, _influx_token
|
||||
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}")
|
||||
def write(c, fields, collection='weather'):
|
||||
field_str = ','.join(
|
||||
f"{k}={'true' if v is True else 'false' if v is False else v}"
|
||||
for k, v in fields.items() if v is not None
|
||||
)
|
||||
if not field_str: return
|
||||
requests.post(f"{c['DB_HOST']}/write", data=f"{collection} {field_str}", timeout=2)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -131,54 +104,36 @@ def vpd(tc, rh):
|
||||
return round(es - (rh/100)*es, 3)
|
||||
|
||||
def gas_to_aqi(gas_ohms, humidity, gas_reference):
|
||||
if gas_ohms is None: return None, None
|
||||
score = min(max(round(min(gas_ohms/gas_reference,1.0)*75 + (25 - abs(humidity-40)*0.5)), 0), 100)
|
||||
label = 'Very Poor'
|
||||
if score >= 80: label = 'Excellent'
|
||||
elif score >= 60: label = 'Good'
|
||||
elif score >= 40: label = 'Fair'
|
||||
elif score >= 20: label = 'Poor'
|
||||
return score, label
|
||||
if gas_ohms is None: return None
|
||||
gas_score = min(gas_ohms / gas_reference, 1.0) * 75
|
||||
hum_score = 25 - abs(humidity - 40) * 0.5
|
||||
quality = min(max(gas_score + hum_score, 0), 100)
|
||||
aqi = round((1 - quality / 100) * 500)
|
||||
return aqi
|
||||
|
||||
def sea_level_pressure(pressure_hpa, alt_m):
|
||||
if alt_m is None: return None
|
||||
return round(pressure_hpa / math.pow(1 - (alt_m / 44330.0), 5.255), 2)
|
||||
|
||||
def 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):
|
||||
if pressure_buffer.maxlen != 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]
|
||||
label = 'Rising' if delta > 1.0 else 'Falling' if delta < -1.0 else 'Stable'
|
||||
return round(delta, 2), label
|
||||
return round(delta, 2)
|
||||
|
||||
def read_bme(c, sensor, alt_m=None):
|
||||
null = {
|
||||
'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
|
||||
if not sensor: return None, None, None
|
||||
try:
|
||||
if not sensor.get_sensor_data(): return None, None
|
||||
t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure
|
||||
gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None
|
||||
aqi_score, aqi_label = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
|
||||
if not sensor.get_sensor_data(): return None, None, None
|
||||
t, rh, p = sensor.data.temperature, sensor.data.humidity, sensor.data.pressure
|
||||
gas = round(sensor.data.gas_resistance, 0) if sensor.data.heat_stable else None
|
||||
aqi_score = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
|
||||
pressure_buffer.append(p)
|
||||
p_rate, p_label = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
|
||||
dp = round(t - (100-rh)/5.0, 2)
|
||||
write(c, 'environment', {
|
||||
'temperature': round(t - c[''], 2),
|
||||
p_rate = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
|
||||
dp = round(t - (100 - rh) / 5.0, 2)
|
||||
write(c, {
|
||||
'temperature': round(t + c['TEMP_CALIBRATION'], 2),
|
||||
'humidity': round(rh, 2),
|
||||
'dew_point': dp,
|
||||
'pressure_hpa': round(p, 2),
|
||||
@@ -189,60 +144,103 @@ def read_bme(c, sensor, alt_m=None):
|
||||
'heat_index': heat_index(t, rh),
|
||||
'air_quality_ohms': gas,
|
||||
'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:
|
||||
print(f"[BME680] {e}")
|
||||
return None, None
|
||||
return None, None, None
|
||||
|
||||
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||
# ── MPU6050 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
MPU_ADDR = 0x68
|
||||
_mpu_peak = None
|
||||
_mpu_lock = threading.Lock()
|
||||
_mpu_offsets = None
|
||||
_mpu_grav = None # slow-tracked gravity vector [x, y, z]
|
||||
GRAV_ALPHA = 0.05 # EMA rate while still
|
||||
STILL_SPREAD = 0.03 # g, max peak-to-peak per axis to count as "still"
|
||||
_still_buf = deque(maxlen=50) # ~0.5s @ 100Hz
|
||||
|
||||
def read_accel():
|
||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 6))
|
||||
if not d: return None
|
||||
return [
|
||||
s16((d[0] << 8) | d[1]) / 16384.0,
|
||||
s16((d[2] << 8) | d[3]) / 16384.0,
|
||||
s16((d[4] << 8) | d[5]) / 16384.0,
|
||||
]
|
||||
|
||||
def rotation_to_z(g):
|
||||
"""Rodrigues rotation mapping gravity vector g -> +Z. Returns (R, |g|)."""
|
||||
mag = math.sqrt(sum(v * v for v in g))
|
||||
u = [v / mag for v in g]
|
||||
kx, ky = u[1], -u[0] # axis = u × z
|
||||
s = math.sqrt(kx * kx + ky * ky) # sin(theta)
|
||||
c = u[2] # cos(theta)
|
||||
if s < 1e-8:
|
||||
return [[1, 0, 0], [0, c, 0], [0, 0, c]], mag
|
||||
kx, ky = kx / s, ky / s
|
||||
v = 1 - c
|
||||
return [
|
||||
[c + kx * kx * v, kx * ky * v, ky * s],
|
||||
[ky * kx * v, c + ky * ky * v, -kx * s],
|
||||
[-ky * s, kx * s, c ],
|
||||
], mag
|
||||
|
||||
def is_still(buf):
|
||||
if len(buf) < buf.maxlen: return False
|
||||
return all(
|
||||
max(s[i] for s in buf) - min(s[i] for s in buf) < STILL_SPREAD
|
||||
for i in range(3)
|
||||
)
|
||||
|
||||
def calibrate_mpu(samples=200):
|
||||
if not bus: return None
|
||||
print(" Calibrating MPU6050, keep still...")
|
||||
sums = [0.0]*6
|
||||
n = 0
|
||||
sums, n = [0.0, 0.0, 0.0], 0
|
||||
for _ in range(samples):
|
||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
|
||||
if d:
|
||||
sums[0] += s16((d[0] << 8)|d[1]) / 16384.0
|
||||
sums[1] += s16((d[2] << 8)|d[3]) / 16384.0
|
||||
sums[2] += s16((d[4] << 8)|d[5]) / 16384.0
|
||||
sums[3] += s16((d[8] << 8)|d[9]) / 131.0
|
||||
sums[4] += s16((d[10] << 8)|d[11]) / 131.0
|
||||
sums[5] += s16((d[12] << 8)|d[13]) / 131.0
|
||||
a = read_accel()
|
||||
if a:
|
||||
sums = [s + v for s, v in zip(sums, a)]
|
||||
n += 1
|
||||
time.sleep(0.005)
|
||||
if n == 0: return None
|
||||
return [sums[0]/n, sums[1]/n, sums[2]/n-1.0, sums[3]/n, sums[4]/n, sums[5]/n]
|
||||
|
||||
g = [s / n for s in sums]
|
||||
mag = math.sqrt(sum(v * v for v in g))
|
||||
if not 0.5 < mag < 1.5:
|
||||
print(f" ❌ MPU6050: bad gravity magnitude {mag:.2f}g")
|
||||
return None
|
||||
|
||||
print(f" ✅ MPU6050: gravity {mag:.3f}g on ({g[0]:.2f}, {g[1]:.2f}, {g[2]:.2f}) → mapped to Z")
|
||||
return g
|
||||
|
||||
def mpu_loop():
|
||||
global _mpu_peak
|
||||
global _mpu_peak, _mpu_grav
|
||||
while True:
|
||||
if bus and _mpu_offsets:
|
||||
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14))
|
||||
if d:
|
||||
o = _mpu_offsets
|
||||
ax = s16((d[0] << 8)|d[1]) / 16384.0 - o[0]
|
||||
ay = s16((d[2] << 8)|d[3]) / 16384.0 - o[1]
|
||||
az = s16((d[4] << 8)|d[5]) / 16384.0 - o[2]
|
||||
mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**2)
|
||||
if bus and _mpu_grav:
|
||||
a = read_accel()
|
||||
if a:
|
||||
_still_buf.append(a)
|
||||
|
||||
R, g = rotation_to_z(_mpu_grav)
|
||||
ax = R[0][0] * a[0] + R[0][1] * a[1] + R[0][2] * a[2]
|
||||
ay = R[1][0] * a[0] + R[1][1] * a[1] + R[1][2] * a[2]
|
||||
az = R[2][0] * a[0] + R[2][1] * a[1] + R[2][2] * a[2] - g
|
||||
mag = math.sqrt(ax**2 + ay**2 + az**2)
|
||||
|
||||
# Re-track gravity whenever the sensor is *still* —
|
||||
# absorbs bias shifts of any size, blocks shakes
|
||||
if is_still(_still_buf):
|
||||
_mpu_grav = [p + (v - p) * GRAV_ALPHA for p, v in zip(_mpu_grav, a)]
|
||||
|
||||
with _mpu_lock:
|
||||
if _mpu_peak is None or mag > _mpu_peak['magnitude']:
|
||||
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
|
||||
_mpu_peak = {
|
||||
'seismic_x': round(ax, 3),
|
||||
'seismic_y': round(ay, 3),
|
||||
'seismic_z': round(az, 3),
|
||||
'magnitude': round(mag, 4),
|
||||
'seismic_x': round(ax, 3),
|
||||
'seismic_y': round(ay, 3),
|
||||
'seismic_z': round(az, 3),
|
||||
'seismic_magnitude': round(mag, 4),
|
||||
}
|
||||
time.sleep(0.01)
|
||||
|
||||
@@ -250,171 +248,125 @@ def flush_mpu(c):
|
||||
global _mpu_peak
|
||||
with _mpu_lock:
|
||||
data, _mpu_peak = _mpu_peak, None
|
||||
if data and data['magnitude'] > c['SEISMIC_NOISE_FLOOR']:
|
||||
write(c, 'seismic', data)
|
||||
if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']:
|
||||
write(c, data)
|
||||
else:
|
||||
write(c, {'seismic_x': 0.0, 'seismic_y': 0.0, 'seismic_z': 0.0, 'seismic_magnitude': 0.0})
|
||||
|
||||
# ── QMC5883L ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_compass(c):
|
||||
null = {'x': None, 'y': None, 'z': None, 'heading': None}
|
||||
if not bus: write(c, 'compass', null); return
|
||||
if not bus: return
|
||||
try:
|
||||
addr = 0x1E
|
||||
try_read(lambda: bus.write_byte_data(addr, 0x09, 0x1D))
|
||||
d = try_read(lambda: bus.read_i2c_block_data(addr, 0x00, 6))
|
||||
if not d: write(c, 'compass', null); return
|
||||
x = s16((d[1] << 8)|d[0])
|
||||
y = s16((d[3] << 8)|d[2])
|
||||
z = s16((d[5] << 8)|d[4])
|
||||
if not d: return
|
||||
x = s16((d[1] << 8) | d[0])
|
||||
y = s16((d[3] << 8) | d[2])
|
||||
z = s16((d[5] << 8) | d[4])
|
||||
heading = math.degrees(math.atan2(y, x))
|
||||
if heading < 0: heading += 360
|
||||
write(c, 'compass', {
|
||||
'compass_x': x, 'compass_y': y, 'compass_z': z,
|
||||
'heading': round(heading, 1),
|
||||
})
|
||||
write(c, {'compass_x': x, 'compass_y': y, 'compass_z': z, 'heading': round(heading, 1)})
|
||||
except Exception as e:
|
||||
print(f"[QMC5883L] {e}")
|
||||
write(c, 'compass', null)
|
||||
|
||||
# ── LTR390 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
uv_dose_mj = 0.0
|
||||
uv_dose_date = datetime.now().date()
|
||||
last_uv_time = time.time()
|
||||
dli_lux_acc = 0.0
|
||||
dli_date = datetime.now().date()
|
||||
last_lux_time = time.time()
|
||||
uv_dose_mj = 0.0
|
||||
uv_dose_date = datetime.now().date()
|
||||
last_uv_time = time.time()
|
||||
dli_lux_acc = 0.0
|
||||
dli_date = datetime.now().date()
|
||||
last_lux_time = time.time()
|
||||
daylight_start = None
|
||||
|
||||
DAYLIGHT_LUX_THRESHOLD = 50
|
||||
|
||||
_cloud_baseline = {}
|
||||
_cloud_lux_window = deque(maxlen=10)
|
||||
def sun_elevation(lat, lon):
|
||||
now = datetime.now(timezone.utc)
|
||||
doy = now.timetuple().tm_yday
|
||||
hour_ut = now.hour + now.minute / 60 + now.second / 3600
|
||||
decl = math.radians(23.45 * math.sin(math.radians((360 / 365) * (doy - 81))))
|
||||
lstm = 15 * round(lon / 15)
|
||||
eot = (9.87 * math.sin(math.radians(2 * (360/365) * (doy - 81)))
|
||||
- 7.53 * math.cos(math.radians((360/365) * (doy - 81)))
|
||||
- 1.5 * math.sin(math.radians((360/365) * (doy - 81))))
|
||||
solar_time = hour_ut + (lon - lstm) / 15 + eot / 60
|
||||
ha = math.radians(15 * (solar_time - 12))
|
||||
lat_r = math.radians(lat)
|
||||
elev = math.degrees(math.asin(
|
||||
math.sin(lat_r) * math.sin(decl) +
|
||||
math.cos(lat_r) * math.cos(decl) * math.cos(ha)
|
||||
))
|
||||
return elev
|
||||
|
||||
def _el_bucket(solar_el, bucket_size):
|
||||
if solar_el is None: return None
|
||||
return round(solar_el / bucket_size) * bucket_size
|
||||
def estimate_cloud_cover(c, lux, temp, dew_point, lat, lon):
|
||||
elev = sun_elevation(lat, lon)
|
||||
temp_dp_spread = temp - dew_point
|
||||
hum_cloud = max(0.0, min(100.0, (1 - temp_dp_spread / 20.0) * 100))
|
||||
|
||||
def moon_phase_factor():
|
||||
known_new = datetime(2024, 1, 11, tzinfo=timezone.utc)
|
||||
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)
|
||||
if elev < c['CLOUD_SUN_MIN_ELEV']:
|
||||
return round(hum_cloud, 1), round(elev, 2)
|
||||
|
||||
def update_cloud_baseline(c, solar_el, lux):
|
||||
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
|
||||
if bucket is None: return
|
||||
if bucket not in _cloud_baseline:
|
||||
_cloud_baseline[bucket] = deque(maxlen=c['CLOUD_HISTORY'])
|
||||
_cloud_baseline[bucket].append(lux)
|
||||
theoretical = max(c['CLOUD_LUX_CLEAR_SKY'] * math.sin(math.radians(elev)), 1.0)
|
||||
solar_ratio = min(lux / theoretical, 1.0)
|
||||
solar_cloud = (1.0 - solar_ratio) * 100
|
||||
|
||||
def get_expected_lux(c, solar_el):
|
||||
bucket = _el_bucket(solar_el, c['CLOUD_BUCKET_SIZE'])
|
||||
if bucket is None: return None
|
||||
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)
|
||||
w = c['CLOUD_HUMIDITY_WEIGHT']
|
||||
blended = (1 - w) * solar_cloud + w * hum_cloud
|
||||
return round(min(max(blended, 0.0), 100.0), 1), round(elev, 2)
|
||||
|
||||
def classify_clouds(c, lux, solar_el):
|
||||
# Resize window if config changed
|
||||
if _cloud_lux_window.maxlen != c['CLOUD_LUX_WINDOW']:
|
||||
_cloud_lux_window.__init__(maxlen=c['CLOUD_LUX_WINDOW'])
|
||||
_cloud_lux_window.append(lux)
|
||||
def estimate_visibility(c, rh, aqi):
|
||||
rh_clamped = min(rh, 99.5)
|
||||
beta_rh = 0.000146 * math.exp(0.06 * rh_clamped)
|
||||
aqi_factor = 1.0 + ((aqi or 0) / 500.0) * 0.5
|
||||
extinction = beta_rh * aqi_factor
|
||||
return round(min(3.912 / extinction, c['VISIBILITY_MAX']), 2)
|
||||
|
||||
expected = get_expected_lux(c, solar_el)
|
||||
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 read_ltr(c, sensor, solar_el, humidity=None):
|
||||
def read_ltr(c, sensor, temp=None, dew_point=None, rh=None, aqi=None, lat=0.0, lon=0.0):
|
||||
global uv_dose_mj, uv_dose_date, last_uv_time
|
||||
global dli_lux_acc, dli_date, last_lux_time, daylight_start
|
||||
|
||||
null = {
|
||||
'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
|
||||
|
||||
if not sensor: return
|
||||
try:
|
||||
lux = round(sensor.lux, 2)
|
||||
uvi = round(sensor.uvi, 2)
|
||||
except Exception as e:
|
||||
print(f"[LTR390] {e}")
|
||||
write(c, 'light', null)
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
today = datetime.now().date()
|
||||
|
||||
if today != uv_dose_date:
|
||||
uv_dose_mj, uv_dose_date = 0.0, today
|
||||
if today != dli_date:
|
||||
dli_lux_acc, dli_date, daylight_start = 0.0, today, None
|
||||
if today != uv_dose_date: uv_dose_mj, uv_dose_date = 0.0, today
|
||||
if today != dli_date: dli_lux_acc, dli_date, daylight_start = 0.0, today, None
|
||||
|
||||
dt = now - last_uv_time
|
||||
uv_dose_mj += uvi * 25 * dt
|
||||
uv_dose_mj += uvi * 25 * (now - last_uv_time)
|
||||
dli_lux_acc += lux * (now - last_lux_time)
|
||||
last_uv_time = now
|
||||
last_lux_time = now
|
||||
|
||||
dli = round(dli_lux_acc / 54 / 1_000_000, 4)
|
||||
|
||||
if lux >= DAYLIGHT_LUX_THRESHOLD and daylight_start is None:
|
||||
daylight_start = now
|
||||
daylight_hours = round((now - daylight_start) / 3600, 2) if daylight_start else 0.0
|
||||
|
||||
update_cloud_baseline(c, solar_el, lux)
|
||||
cloud_pct, cloud_label, volatility = classify_clouds(c, lux, solar_el)
|
||||
|
||||
write(c, 'light', {
|
||||
fields = {
|
||||
'lux': lux,
|
||||
'uv_index': uvi,
|
||||
'solar_wm2': round(lux / 120, 2),
|
||||
'uv_dose': round(uv_dose_mj, 2),
|
||||
'clouds': cloud_pct,
|
||||
'daily_light_integral': dli,
|
||||
'daylight': daylight_hours,
|
||||
'visibility': visibility_estimate(lux, humidity, solar_el) if humidity else None,
|
||||
}, tags={'clouds_label': cloud_label or ''})
|
||||
'daily_light_integral': round(dli_lux_acc / 54 / 1_000_000, 4),
|
||||
}
|
||||
|
||||
if temp is not None and dew_point is not None:
|
||||
cloud, sun_elev = estimate_cloud_cover(c, lux, temp, dew_point, lat, lon)
|
||||
fields['clouds'] = cloud
|
||||
fields['sun_elevation'] = sun_elev
|
||||
|
||||
if rh is not None:
|
||||
fields['visibility'] = estimate_visibility(c, rh, aqi)
|
||||
|
||||
write(c, fields)
|
||||
|
||||
# ── TF-Luna ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -428,47 +380,35 @@ def calibrate_luna(c):
|
||||
for _ in range(c['LUNA_CAL_SAMPLES']):
|
||||
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
||||
if d:
|
||||
dist, strength = d[0]|(d[1]<<8), d[2]|(d[3]<<8)
|
||||
dist, strength = d[0] | (d[1] << 8), d[2] | (d[3] << 8)
|
||||
if strength >= 100 and 0 < dist <= c['LUNA_MAX_DIST_CM']:
|
||||
readings.append(dist)
|
||||
time.sleep(0.1)
|
||||
if readings:
|
||||
luna_baseline_cm = round(sum(readings)/len(readings), 1)
|
||||
luna_baseline_cm = round(sum(readings) / len(readings), 1)
|
||||
print(f" ✅ TF-Luna baseline: {luna_baseline_cm} cm")
|
||||
else:
|
||||
print(" ❌ TF-Luna: calibration failed")
|
||||
|
||||
def read_luna(c, temp_c):
|
||||
null = {'ground_distance': None, 'lidar_strength': None}
|
||||
if not bus: write(c, 'accumulation', null); return
|
||||
def read_luna(c):
|
||||
if not bus: return
|
||||
try:
|
||||
d = try_read(lambda: bus.read_i2c_block_data(0x10, 0x00, 6))
|
||||
if not d: write(c, 'ground', null); return
|
||||
dist = d[0]|(d[1]<<8)
|
||||
strength = d[2]|(d[3]<<8)
|
||||
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']:
|
||||
write(c, 'accumulation', null); return
|
||||
|
||||
if not d: return
|
||||
dist = d[0] | (d[1] << 8)
|
||||
strength = d[2] | (d[3] << 8)
|
||||
if strength < 100 or dist <= 0 or dist > c['LUNA_MAX_DIST_CM']: return
|
||||
fields = {'ground_distance': dist, 'ground_calibration': luna_baseline_cm, 'lidar_strength': strength}
|
||||
|
||||
kind = 'none'
|
||||
if luna_baseline_cm and (luna_baseline_cm - dist) >= c['LUNA_MIN_DEPTH_CM']:
|
||||
t = temp_c
|
||||
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'
|
||||
)
|
||||
depth = round(luna_baseline_cm - dist, 1)
|
||||
fields['accumulation'] = depth
|
||||
else:
|
||||
fields['accumulation'] = 0
|
||||
write(c, 'accumulation', fields, tags={'accumulation_type': kind})
|
||||
write(c, fields)
|
||||
except Exception as e:
|
||||
print(f"[TF-Luna] {e}")
|
||||
write(c, 'accumulation', null)
|
||||
|
||||
# ── AS3935 ───────────────────────────────────────────────────────────────────
|
||||
# ── AS3935 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
lightning_distances = deque(maxlen=10)
|
||||
lightning_count = 0
|
||||
@@ -478,8 +418,7 @@ def init_as3935(c):
|
||||
if not bus: return
|
||||
try:
|
||||
addr = c['AS3935_ADDR']
|
||||
val = (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG']
|
||||
bus.write_byte_data(addr, 0x01, val)
|
||||
bus.write_byte_data(addr, 0x01, (c['AS3935_NOISE_FLOOR'] << 4) | c['AS3935_WATCHDOG'])
|
||||
bus.write_byte_data(addr, 0x02, c['AS3935_SPIKE_REJ'])
|
||||
bus.write_byte_data(addr, 0x00, 0x24)
|
||||
print(" ✅ AS3935")
|
||||
@@ -497,26 +436,27 @@ def read_as3935(c):
|
||||
energy = ((data[5] & 0x1F) << 16) | (data[4] << 8) | data[3]
|
||||
try_read(lambda: bus.read_byte_data(addr, 0x03))
|
||||
if interrupt not in (0x04, 0x08) or energy < 1000: return
|
||||
|
||||
dist = data[6] & 0x3F
|
||||
lightning_distances.append(dist)
|
||||
lightning_count += 1
|
||||
|
||||
elapsed = time.time() - lightning_window_start
|
||||
if elapsed >= 3600:
|
||||
lightning_count = 1
|
||||
lightning_window_start = time.time()
|
||||
elapsed = 1
|
||||
strike_rate = round(lightning_count / (elapsed / 3600), 1)
|
||||
lightning_count, lightning_window_start, elapsed = 1, time.time(), 1
|
||||
|
||||
trend = 'Stationary'
|
||||
if len(lightning_distances) >= 3:
|
||||
avg_early = sum(list(lightning_distances)[:3]) / 3
|
||||
avg_early = sum(list(lightning_distances)[:3]) / 3
|
||||
avg_late = sum(list(lightning_distances)[-3:]) / 3
|
||||
if avg_late < avg_early - 2: trend = 'Approaching'
|
||||
elif avg_late > avg_early + 2: trend = 'Retreating'
|
||||
write(c, 'lightning', {
|
||||
|
||||
write(c, {
|
||||
'lightning_distance': dist,
|
||||
'lightning_energy': energy,
|
||||
'lightning_rate': strike_rate,
|
||||
}, tags={'storm_direction': trend})
|
||||
'lightning_rate': round(lightning_count / (elapsed / 3600), 1),
|
||||
'storm_trend': trend,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[AS3935] {e}")
|
||||
|
||||
@@ -538,66 +478,44 @@ def read_gps(c):
|
||||
'gps_satellites': int(msg.num_sats),
|
||||
})
|
||||
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:
|
||||
print(f"[GPS] {e}")
|
||||
write(c, 'gps', {'latitude': None, 'longitude': None, 'altitude': None, 'gps_satellites': None})
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_rain(c):
|
||||
try:
|
||||
write(c, 'rain', {'precipitation': 0}, tags={'raining': False})
|
||||
except Exception as e:
|
||||
print(f"[Rain] {e}")
|
||||
write(c, {'precipitation': 0, 'raining': False})
|
||||
|
||||
# ── Wind ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_wind(c):
|
||||
try:
|
||||
write(c, 'wind', {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
|
||||
except Exception as e:
|
||||
print(f"[Wind] {e}")
|
||||
write(c, {'wind_direction': 0, 'wind_speed': 0, 'wind_gusts': 0})
|
||||
|
||||
# ── System ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def read_system(c):
|
||||
try:
|
||||
cpu_temp = None
|
||||
temps = psutil.sensors_temperatures()
|
||||
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)
|
||||
break
|
||||
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
vm = psutil.virtual_memory()
|
||||
mem = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
write(c, 'system', {
|
||||
write(c, {
|
||||
'cpu_temp': cpu_temp,
|
||||
'cpu_usage': round(psutil.cpu_percent(interval=None), 1),
|
||||
'cpu_freq_mhz': round(cpu_freq.current, 1) if cpu_freq else None,
|
||||
'mem_used_mb': round(vm.used / 1024**2, 1),
|
||||
'mem_total_mb': round(vm.total / 1024**2, 1),
|
||||
'mem_percent': vm.percent,
|
||||
'mem_used_mb': round(mem.used / 1024**2, 1),
|
||||
'mem_total_mb': round(mem.total / 1024**2, 1),
|
||||
'mem_percent': mem.percent,
|
||||
'disk_used_gb': round(disk.used / 1024**3, 2),
|
||||
'disk_total_gb': round(disk.total / 1024**3, 2),
|
||||
'disk_percent': disk.percent,
|
||||
})
|
||||
}, collection='system')
|
||||
except Exception as e:
|
||||
print(f"[System] {e}")
|
||||
|
||||
@@ -613,9 +531,9 @@ if __name__ == '__main__':
|
||||
if bus:
|
||||
try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0))
|
||||
time.sleep(0.1)
|
||||
_mpu_offsets = try_init(calibrate_mpu, 'MPU6050')
|
||||
_mpu_grav = try_init(calibrate_mpu, 'MPU6050')
|
||||
else:
|
||||
_mpu_offsets = None
|
||||
_mpu_grav = None
|
||||
print(" ❌ MPU6050: no I2C bus")
|
||||
|
||||
threading.Thread(target=mpu_loop, daemon=True).start()
|
||||
@@ -626,24 +544,31 @@ if __name__ == '__main__':
|
||||
|
||||
last_temp = None
|
||||
last_rh = None
|
||||
last_aqi = None
|
||||
|
||||
while True:
|
||||
c = cfg()
|
||||
gps = read_gps(c)
|
||||
|
||||
lat = gps.get('latitude') or c['LATITUDE']
|
||||
lat = gps.get('latitude') or c['LATITUDE']
|
||||
lon = gps.get('longitude') or c['LONGITUDE']
|
||||
alt = gps.get('altitude') or c['ALTITUDE']
|
||||
sol = solar_elevation(lat, lon) if lat and lon else None
|
||||
alt = gps.get('altitude') or c['ALTITUDE']
|
||||
|
||||
result = read_bme(c, bme_sensor, alt_m=alt)
|
||||
if result and result[0] is not None:
|
||||
last_temp, last_rh = 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)
|
||||
read_compass(c)
|
||||
read_ltr(c, ltr_sensor, sol, humidity=last_rh)
|
||||
read_luna(c, last_temp)
|
||||
read_ltr(c, ltr_sensor,
|
||||
temp=last_temp, dew_point=dp,
|
||||
rh=last_rh, aqi=last_aqi,
|
||||
lat=lat, lon=lon)
|
||||
read_luna(c)
|
||||
read_as3935(c)
|
||||
read_rain(c)
|
||||
read_wind(c)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
influxdb-client
|
||||
requests
|
||||
python-dotenv
|
||||
smbus2
|
||||
adafruit-circuitpython-ltr390
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { cfg } from './config.mjs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const SHAPES_PATH = resolve(DIR, 'public', 'aircraft.json')
|
||||
|
||||
const history = new Map() // icao -> [{lat, lon, alt, ts}]
|
||||
const MAX_HISTORY = 500
|
||||
|
||||
export async function getShapes() {
|
||||
return JSON.parse(await readFile(SHAPES_PATH, 'utf8'))
|
||||
}
|
||||
|
||||
export async function getAirTraffic() {
|
||||
const { ADSB_URL } = cfg()
|
||||
if (!ADSB_URL) return { data: [] }
|
||||
const r = await fetch(`${ADSB_URL}/data/aircraft.json`)
|
||||
const j = await r.json()
|
||||
const aircraft = j.aircraft || []
|
||||
|
||||
for (const a of aircraft) {
|
||||
if (!a.hex || !a.lat || !a.lon) continue
|
||||
const key = a.hex.toLowerCase()
|
||||
if (!history.has(key)) history.set(key, [])
|
||||
const trail = history.get(key)
|
||||
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() })
|
||||
if (trail.length > MAX_HISTORY) trail.shift()
|
||||
}
|
||||
|
||||
return { data: aircraft }
|
||||
}
|
||||
|
||||
export async function getAirTrafficHistory(icao) {
|
||||
return { history: history.get(icao?.toLowerCase()) || [] }
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { config } from 'dotenv'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
export function cfg() {
|
||||
config({ path: resolve(ROOT, '.env'), override: true })
|
||||
config({ path: resolve(ROOT, '.env.local'), override: true })
|
||||
return {
|
||||
PORT: process.env.PORT || 3000,
|
||||
ADSB_URL: process.env.ADSB_URL || '',
|
||||
INFLUX_URL: process.env.INFLUX_URL || 'http://localhost:8086',
|
||||
INFLUX_TOKEN: process.env.INFLUX_TOKEN || '',
|
||||
INFLUX_ORG: process.env.INFLUX_ORG || 'weather',
|
||||
INFLUX_BUCKET: process.env.INFLUX_BUCKET || 'station',
|
||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
||||
}
|
||||
}
|
||||
BIN
server/data/aircraft.db
Normal file
@@ -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 = {};
|
||||
for(const row of rows) {
|
||||
Object.assign(result, extractRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function queryHourly(start, end) {
|
||||
const {c} = getClient();
|
||||
const flux = `
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => ${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}, stop: ${end})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: min, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
query(`
|
||||
from(bucket: "${c.INFLUX_BUCKET}")
|
||||
|> range(start: ${start}, stop: ${end})
|
||||
|> filter(fn: (r) => r._measurement == "${m}")
|
||||
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|
||||
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|
||||
`),
|
||||
]);
|
||||
|
||||
// Mean values are the base — flat field names, no suffix
|
||||
for(const row of means) {
|
||||
const time = truncateToDay(row._time);
|
||||
if(!results[time]) results[time] = {time};
|
||||
Object.assign(results[time], extractRow(row));
|
||||
}
|
||||
|
||||
// Only promote temp min/max to named fields, ignore the rest
|
||||
for(const row of mins) {
|
||||
const time = truncateToDay(row._time);
|
||||
if(!results[time]) results[time] = {time};
|
||||
if(row.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,273 +0,0 @@
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { pipeline } from 'stream/promises'
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
||||
const CACHE = {}
|
||||
const CACHE_MS = 15 * 60 * 1000
|
||||
|
||||
if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true })
|
||||
|
||||
// WMO weather code map
|
||||
const WMO = {
|
||||
0: { label: 'Clear Sky' },
|
||||
1: { label: 'Mainly Clear' },
|
||||
2: { label: 'Partly Cloudy' },
|
||||
3: { label: 'Overcast' },
|
||||
45: { label: 'Fog' },
|
||||
48: { label: 'Icy Fog' },
|
||||
51: { label: 'Light Drizzle' },
|
||||
53: { label: 'Drizzle' },
|
||||
55: { label: 'Heavy Drizzle' },
|
||||
61: { label: 'Light Rain' },
|
||||
63: { label: 'Rain' },
|
||||
65: { label: 'Heavy Rain' },
|
||||
71: { label: 'Light Snow' },
|
||||
73: { label: 'Snow' },
|
||||
75: { label: 'Heavy Snow' },
|
||||
77: { label: 'Snow Grains' },
|
||||
80: { label: 'Light Showers' },
|
||||
81: { label: 'Showers' },
|
||||
82: { label: 'Heavy Showers' },
|
||||
85: { label: 'Snow Showers' },
|
||||
86: { label: 'Heavy Snow Showers' },
|
||||
95: { label: 'Thunderstorm' },
|
||||
96: { label: 'Thunderstorm w/ Hail' },
|
||||
99: { label: 'Thunderstorm w/ Heavy Hail' },
|
||||
}
|
||||
|
||||
// OWM icon mapping by WMO code + is_day
|
||||
const OWM_ICONS = {
|
||||
0: { day: '01d', night: '01n' },
|
||||
1: { day: '01d', night: '01n' },
|
||||
2: { day: '02d', night: '02n' },
|
||||
3: { day: '04d', night: '04n' },
|
||||
45: { day: '50d', night: '50n' },
|
||||
48: { day: '50d', night: '50n' },
|
||||
51: { day: '09d', night: '09n' },
|
||||
53: { day: '09d', night: '09n' },
|
||||
55: { day: '09d', night: '09n' },
|
||||
61: { day: '10d', night: '10n' },
|
||||
63: { day: '10d', night: '10n' },
|
||||
65: { day: '10d', night: '10n' },
|
||||
71: { day: '13d', night: '13n' },
|
||||
73: { day: '13d', night: '13n' },
|
||||
75: { day: '13d', night: '13n' },
|
||||
77: { day: '13d', night: '13n' },
|
||||
80: { day: '09d', night: '09n' },
|
||||
81: { day: '09d', night: '09n' },
|
||||
82: { day: '09d', night: '09n' },
|
||||
85: { day: '13d', night: '13n' },
|
||||
86: { day: '13d', night: '13n' },
|
||||
95: { day: '11d', night: '11n' },
|
||||
96: { day: '11d', night: '11n' },
|
||||
99: { day: '11d', night: '11n' },
|
||||
}
|
||||
|
||||
async function ensureIcon(code, isDay) {
|
||||
const variant = isDay ? 'day' : 'night'
|
||||
const owmCode = OWM_ICONS[code]?.[variant] || '01d'
|
||||
const filename = `${owmCode}.png`
|
||||
const filepath = resolve(ICON_DIR, filename)
|
||||
if (!existsSync(filepath)) {
|
||||
const url = `https://openweathermap.org/img/wn/${owmCode}@2x.png`
|
||||
const res = await fetch(url)
|
||||
await pipeline(res.body, createWriteStream(filepath))
|
||||
}
|
||||
return `/icons/${filename}`
|
||||
}
|
||||
|
||||
export async function resolveWeather(code, isDay = 1) {
|
||||
const label = WMO[code]?.label || 'Unknown'
|
||||
const icon = await ensureIcon(code, isDay).catch(() => null)
|
||||
return { forecast_weather_label: label, forecast_weather_icon: icon, forecast_weathercode: code }
|
||||
}
|
||||
|
||||
async function cachedFetch(key, url) {
|
||||
const now = Date.now()
|
||||
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
CACHE[key] = { ts: now, data }
|
||||
return data
|
||||
}
|
||||
|
||||
const HOURLY_WEATHER = [
|
||||
'temperature_2m', 'relative_humidity_2m', 'precipitation', 'precipitation_probability',
|
||||
'weathercode', 'windspeed_10m', 'winddirection_10m', 'windgusts_10m',
|
||||
'pressure_msl', 'cloudcover', 'visibility', 'cape', 'uv_index', 'is_day',
|
||||
'et0_fao_evapotranspiration', 'dewpoint_2m', 'apparent_temperature',
|
||||
'snowfall', 'snow_depth', 'freezinglevel_height',
|
||||
].join(',')
|
||||
|
||||
const DAILY_WEATHER = [
|
||||
'temperature_2m_max', 'temperature_2m_min', 'precipitation_sum',
|
||||
'precipitation_probability_max', 'windspeed_10m_max', 'windgusts_10m_max',
|
||||
'winddirection_10m_dominant', 'weathercode', 'sunrise', 'sunset',
|
||||
'uv_index_max', 'snowfall_sum', 'precipitation_hours', 'shortwave_radiation_sum',
|
||||
].join(',')
|
||||
|
||||
const HOURLY_AQ = [
|
||||
'pm10', 'pm2_5', 'carbon_monoxide', 'nitrogen_dioxide', 'sulphur_dioxide',
|
||||
'ozone', 'aerosol_optical_depth', 'dust', 'uv_index',
|
||||
'alder_pollen', 'birch_pollen', 'grass_pollen',
|
||||
'mugwort_pollen', 'olive_pollen', 'ragweed_pollen',
|
||||
].join(',')
|
||||
|
||||
const HOURLY_MARINE = [
|
||||
'wave_height', 'wave_direction', 'wave_period',
|
||||
'wind_wave_height', 'swell_wave_height', 'swell_wave_direction', 'swell_wave_period',
|
||||
].join(',')
|
||||
|
||||
function zipHourly(data, prefix = '') {
|
||||
if (!data?.hourly) return []
|
||||
return data.hourly.time.map((time, i) => {
|
||||
const row = { time }
|
||||
for (const [k, v] of Object.entries(data.hourly)) {
|
||||
if (k !== 'time') row[prefix + k] = v[i]
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
function zipDaily(data, prefix = '') {
|
||||
if (!data?.daily) return []
|
||||
return data.daily.time.map((time, i) => {
|
||||
const row = { time }
|
||||
for (const [k, v] of Object.entries(data.daily)) {
|
||||
if (k !== 'time') row[prefix + k] = v[i]
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
// Remap Open-Meteo field names to our prefixed convention
|
||||
function remapWeatherFields(row) {
|
||||
const map = {
|
||||
temperature_2m: 'temperature',
|
||||
apparent_temperature: 'heat_index',
|
||||
dewpoint_2m: 'dew_point',
|
||||
relative_humidity_2m: 'humidity',
|
||||
pressure_msl: 'pressure_slp',
|
||||
cloudcover: 'clouds',
|
||||
uv_index: 'uv_index',
|
||||
visibility: 'visibility',
|
||||
windspeed_10m: 'wind_speed_kmh',
|
||||
winddirection_10m: 'wind_direction_deg',
|
||||
windgusts_10m: 'wind_gusts_kmh',
|
||||
weathercode: 'forecast_weathercode',
|
||||
precipitation: 'forecast_precipitation_mm',
|
||||
precipitation_probability: 'forecast_precipitation_probability',
|
||||
snowfall: 'forecast_snowfall_mm',
|
||||
snow_depth: 'forecast_snow_depth_m',
|
||||
cape: 'forecast_cape',
|
||||
is_day: 'sun_is_day',
|
||||
freezinglevel_height: 'forecast_freezing_level_m',
|
||||
et0_fao_evapotranspiration: 'forecast_evapotranspiration_mm',
|
||||
// daily
|
||||
temperature_2m_max: 'env_temp_max_c',
|
||||
temperature_2m_min: 'env_temp_min_c',
|
||||
precipitation_sum: 'forecast_precipitation_mm',
|
||||
precipitation_probability_max: 'forecast_precipitation_probability',
|
||||
windspeed_10m_max: 'wind_speed_max_kmh',
|
||||
windgusts_10m_max: 'wind_gusts_max_kmh',
|
||||
winddirection_10m_dominant: 'wind_direction_deg',
|
||||
uv_index_max: 'uv_index_max',
|
||||
snowfall_sum: 'forecast_snowfall_mm',
|
||||
shortwave_radiation_sum: 'solar_wm2_sum',
|
||||
sunrise: 'sun_sunrise',
|
||||
sunset: 'sun_sunset',
|
||||
}
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
out[map[k] || k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function getOpenMeteo(lat, lon, start, end) {
|
||||
const base = start && end
|
||||
? `&start_date=${start.slice(0,10)}&end_date=${end.slice(0,10)}&past_days=0`
|
||||
: `&forecast_days=7`
|
||||
|
||||
const [weather, aq, marine] = await Promise.allSettled([
|
||||
cachedFetch(`weather_${lat}_${lon}_${start}_${end}`,
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_WEATHER}&daily=${DAILY_WEATHER}¤t_weather=true&timezone=auto${base}`),
|
||||
cachedFetch(`aq_${lat}_${lon}_${start}_${end}`,
|
||||
`https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_AQ}&timezone=auto${base}`),
|
||||
cachedFetch(`marine_${lat}_${lon}_${start}_${end}`,
|
||||
`https://marine-api.open-meteo.com/v1/marine?latitude=${lat}&longitude=${lon}&hourly=${HOURLY_MARINE}&daily=wave_height_max,wave_direction_dominant,wave_period_max&timezone=auto${base}`),
|
||||
])
|
||||
|
||||
const w = weather.status === 'fulfilled' ? weather.value : {}
|
||||
const a = aq.status === 'fulfilled' ? aq.value : {}
|
||||
const m = marine.status === 'fulfilled' ? marine.value : {}
|
||||
|
||||
// Build hourly merged map
|
||||
const hourlyMap = {}
|
||||
for (const row of zipHourly(w)) {
|
||||
const remapped = remapWeatherFields(row)
|
||||
hourlyMap[row.time] = remapped
|
||||
}
|
||||
for (const row of zipHourly(a)) {
|
||||
const { time, ...rest } = row
|
||||
hourlyMap[time] = { ...hourlyMap[time], ...rest }
|
||||
}
|
||||
for (const row of zipHourly(m)) {
|
||||
const { time, ...rest } = row
|
||||
hourlyMap[time] = { ...hourlyMap[time], ...rest }
|
||||
}
|
||||
|
||||
// Build daily merged map
|
||||
const dailyMap = {}
|
||||
for (const row of zipDaily(w)) {
|
||||
const remapped = remapWeatherFields(row)
|
||||
dailyMap[row.time] = remapped
|
||||
}
|
||||
for (const row of zipDaily(m)) {
|
||||
const { time, ...rest } = row
|
||||
dailyMap[time] = { ...dailyMap[time], ...rest }
|
||||
}
|
||||
|
||||
// Resolve current weather
|
||||
let current = null
|
||||
if (w.current_weather) {
|
||||
const cw = w.current_weather
|
||||
const wInfo = await resolveWeather(cw.weathercode, cw.is_day).catch(() => ({}))
|
||||
current = {
|
||||
wind_speed_kmh: cw.windspeed,
|
||||
wind_direction_deg: cw.winddirection,
|
||||
sun_is_day: cw.is_day,
|
||||
...wInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve weather labels + icons for all hourly/daily rows
|
||||
const hourlyArr = await Promise.all(
|
||||
Object.values(hourlyMap)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(async row => {
|
||||
if (row.forecast_weathercode != null) {
|
||||
const wInfo = await resolveWeather(row.forecast_weathercode, row.sun_is_day ?? 1).catch(() => ({}))
|
||||
return { ...row, ...wInfo }
|
||||
}
|
||||
return row
|
||||
})
|
||||
)
|
||||
|
||||
const dailyArr = await Promise.all(
|
||||
Object.values(dailyMap)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(async row => {
|
||||
if (row.forecast_weathercode != null) {
|
||||
const wInfo = await resolveWeather(row.forecast_weathercode, 1).catch(() => ({}))
|
||||
return { ...row, ...wInfo }
|
||||
}
|
||||
return row
|
||||
})
|
||||
)
|
||||
|
||||
return { current, hourly: hourlyArr, daily: dailyArr }
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'public', 'icons')
|
||||
|
||||
export const WMO = {
|
||||
0: { label: 'Clear Sky', icon: '01d' },
|
||||
1: { label: 'Mainly Clear', icon: '01d' },
|
||||
2: { label: 'Partly Cloudy', icon: '02d' },
|
||||
3: { label: 'Overcast', icon: '04d' },
|
||||
45: { label: 'Fog', icon: '50d' },
|
||||
48: { label: 'Icy Fog', icon: '50d' },
|
||||
51: { label: 'Light Drizzle', icon: '09d' },
|
||||
53: { label: 'Drizzle', icon: '09d' },
|
||||
55: { label: 'Heavy Drizzle', icon: '09d' },
|
||||
61: { label: 'Light Rain', icon: '10d' },
|
||||
63: { label: 'Rain', icon: '10d' },
|
||||
65: { label: 'Heavy Rain', icon: '10d' },
|
||||
71: { label: 'Light Snow', icon: '13d' },
|
||||
73: { label: 'Snow', icon: '13d' },
|
||||
75: { label: 'Heavy Snow', icon: '13d' },
|
||||
77: { label: 'Snow Grains', icon: '13d' },
|
||||
80: { label: 'Light Showers', icon: '09d' },
|
||||
81: { label: 'Showers', icon: '09d' },
|
||||
82: { label: 'Heavy Showers', icon: '09d' },
|
||||
85: { label: 'Snow Showers', icon: '13d' },
|
||||
86: { label: 'Heavy Snow Showers', icon: '13d' },
|
||||
95: { label: 'Thunderstorm', icon: '11d' },
|
||||
96: { label: 'Thunderstorm w/ Hail', icon: '11d' },
|
||||
99: { label: 'Thunderstorm w/ Hail', icon: '11d' },
|
||||
}
|
||||
|
||||
export async function downloadIcons() {
|
||||
if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true })
|
||||
const icons = new Set(Object.values(WMO).map(w => [w.icon, w.icon.replace('d','n')]).flat())
|
||||
await Promise.all([...icons].map(async icon => {
|
||||
const path = resolve(DIR, `${icon}.png`)
|
||||
if (existsSync(path)) return
|
||||
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`)
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
writeFileSync(path, buf)
|
||||
console.log(` 📥 Downloaded icon: ${icon}.png`)
|
||||
}))
|
||||
console.log('✅ Weather icons ready')
|
||||
}
|
||||
|
||||
export function getWeatherCondition(data) {
|
||||
// Determine primary condition
|
||||
if (data.lightning_rate > 0 && data.raining === "True") {
|
||||
return { label: "Thunderstorm", code: 211, icon: "11d" };
|
||||
}
|
||||
|
||||
if (data.raining === "True") {
|
||||
if (data.precipitation > 7.6) return { label: "Heavy Rain", code: 502, icon: "10d" };
|
||||
if (data.precipitation > 2.5) return { label: "Moderate Rain", code: 501, icon: "10d" };
|
||||
return { label: "Light Rain", code: 500, icon: "09d" };
|
||||
}
|
||||
|
||||
if (data.lightning_rate > 500) {
|
||||
return { label: "Thunderstorm", code: 211, icon: "11d" };
|
||||
}
|
||||
|
||||
if (data.visibility < 10) {
|
||||
return { label: "Mist", code: 701, icon: "50d" };
|
||||
}
|
||||
|
||||
// Cloud-based conditions
|
||||
const isDay = data.daytime;
|
||||
const dayNight = isDay ? "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}` };
|
||||
}
|
||||
819
server/package-lock.json
generated
@@ -4,11 +4,14 @@
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
"start": "node src/server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"@scalar/express-api-reference": "^0.10.4",
|
||||
"@ztimson/utils": "^0.29.5",
|
||||
"adm-zip": "^0.5.17",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"cheerio": "^1.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"yaml": "^2.9.0"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
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 |
@@ -1,131 +0,0 @@
|
||||
import express from 'express';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {cfg} from './config.mjs';
|
||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
|
||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||
import {getSpaceWeather} from './space.mjs';
|
||||
import {getOpenMeteo} from './openmeteo.mjs';
|
||||
import {apiReference} from '@scalar/express-api-reference';
|
||||
import {spec} from './spec.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
import {getAirTraffic, getAirTrafficHistory, getShapes} from './airtraffic.mjs';
|
||||
import {getWeatherCondition} from './openweather.mjs';
|
||||
|
||||
const app = express();
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const CLIENT_DIST = resolve(DIR, 'public');
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/icons', express.static(resolve(DIR, 'public', 'icons')));
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
});
|
||||
app.use(express.static(CLIENT_DIST));
|
||||
|
||||
function filterFields(obj, fields) {
|
||||
if(!fields) return obj;
|
||||
const keys = new Set(fields.split(',').map(f => f.trim()));
|
||||
return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.has(k)));
|
||||
}
|
||||
|
||||
function filterArr(arr, fields) {
|
||||
if(!fields) return arr;
|
||||
return arr.map(row => filterFields(row, fields));
|
||||
}
|
||||
|
||||
// ── Sensor Data ───────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/current', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()]);
|
||||
const condition = getWeatherCondition(sensors);
|
||||
const space = getCelestialCurrent(coords.latitude, coords.longitude);
|
||||
res.json(filterFields({...condition, ...sensors, ...space}, fields));
|
||||
});
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/position', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const data = await getCoords();
|
||||
res.json(filterFields(data, fields));
|
||||
});
|
||||
|
||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/space', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
res.json(filterFields(await getSpaceWeather(), fields));
|
||||
});
|
||||
|
||||
// ── Daily History/Forecast ────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/hourly', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
|
||||
let now = new Date();
|
||||
now.setHours(new Date().getHours() - 1, 0, 0);
|
||||
now = now.toISOString();
|
||||
const end = req.query.end || new Date().toISOString();
|
||||
const coords = await getCoords();
|
||||
const [sensor, meteo] = await Promise.allSettled([
|
||||
queryHourly(start, now),
|
||||
getOpenMeteo(coords.latitude, coords.longitude, now, end),
|
||||
]);
|
||||
const history = sensor.status === 'fulfilled' ? sensor.value : [];
|
||||
const forecast = meteo.status === 'fulfilled' ? meteo.value.hourly : [];
|
||||
const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]);
|
||||
res.json(filterArr(hourly, fields));
|
||||
});
|
||||
|
||||
// ── Hourly History/Forecast ───────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/daily', async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const start = req.query.start || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
|
||||
let now = new Date();
|
||||
now.setDate(now.getDate() - 1);
|
||||
now.setHours(0, 0, 0, 0);
|
||||
now = now.toISOString();
|
||||
const end = req.query.end || new Date().toISOString();
|
||||
const coords = await getCoords();
|
||||
const [sensor, meteo] = await Promise.allSettled([
|
||||
queryDaily(start, now),
|
||||
getOpenMeteo(coords.latitude, coords.longitude, now, end),
|
||||
]);
|
||||
const history = sensor.status === 'fulfilled' ? sensor.value : [];
|
||||
const forecast = meteo.status === 'fulfilled' ? meteo.value.daily : [];
|
||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast]);
|
||||
res.json(filterArr(daily, fields));
|
||||
});
|
||||
|
||||
// ── ADSB Proxy ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/air-traffic', async (req, res) => res.json(await getAirTraffic()));
|
||||
app.get('/api/air-traffic/:icao', async (req, res) => res.json(await getAirTrafficHistory(req.params.icao)));
|
||||
app.get('/api/air-traffic-shapes', async (req, res) => res.json(await getShapes()));
|
||||
|
||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
|
||||
app.get('/openapi.json', (req, res) => res.json(spec));
|
||||
app.get('/openapi.yaml', async (req, res) => {
|
||||
const {stringify} = await import('yaml');
|
||||
res.setHeader('Content-Type', 'text/yaml');
|
||||
res.send(stringify(spec));
|
||||
});
|
||||
|
||||
// ── Website ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
const index = resolve(CLIENT_DIST, 'index.html');
|
||||
if(existsSync(index)) res.sendFile(index);
|
||||
else res.status(404).send('Client not built yet — run npm run build in /client');
|
||||
});
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const c = cfg();
|
||||
app.listen(c.PORT, () => console.log(`🌦 Weather API — http://localhost:${c.PORT}`));
|
||||
236
server/spec.mjs
@@ -1,236 +0,0 @@
|
||||
export const spec = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Weather Station',
|
||||
version: '1.0.0',
|
||||
description: 'Hyperlocal weather station — sensor data, forecasts, celestial & space weather',
|
||||
},
|
||||
servers: [{ url: 'http://localhost:3000' }],
|
||||
paths: {
|
||||
'/api/position': {
|
||||
get: {
|
||||
summary: 'Station position',
|
||||
description: 'Latest GPS position and info',
|
||||
parameters: [ ],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Last known position',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/data': {
|
||||
get: {
|
||||
summary: 'Current conditions',
|
||||
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
|
||||
parameters: [
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current conditions',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/hourly': {
|
||||
get: {
|
||||
summary: 'Hourly data',
|
||||
description: 'Hourly aggregated sensor data merged with Open-Meteo hourly forecast, celestial positions and space weather. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of hourly rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/daily': {
|
||||
get: {
|
||||
summary: 'Daily data',
|
||||
description: 'Daily aggregated sensor data (mean/min/max) merged with Open-Meteo daily forecast and celestial events. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of daily rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Position: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lat: { type: 'number', description: 'Latitude' },
|
||||
lon: { type: 'number', description: 'Longitude' },
|
||||
alt: { type: 'number', description: 'Altitude' },
|
||||
}
|
||||
},
|
||||
DataRow: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
time: { type: 'string', description: 'ISO8601 timestamp or date' },
|
||||
// Environment
|
||||
temperature: { type: 'number', description: 'Temperature (°C)' },
|
||||
env_temp_f: { type: 'number', description: 'Temperature (°F)' },
|
||||
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
|
||||
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
|
||||
humidity: { type: 'number', description: 'Relative humidity (%)' },
|
||||
dew_point: { type: 'number', description: 'Dew point (°C)' },
|
||||
heat_index: { type: 'number', description: 'Feels like / heat index (°C)' },
|
||||
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
|
||||
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
||||
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
|
||||
// Light
|
||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||
uv_index: { type: 'number', description: 'UV index' },
|
||||
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
|
||||
clouds: { type: 'number', description: 'Estimated cloud cover (%)' },
|
||||
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] },
|
||||
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||
daylight: { type: 'number', description: 'Hours above daylight threshold today' },
|
||||
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
|
||||
// Wind (Open-Meteo)
|
||||
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
|
||||
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
|
||||
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' },
|
||||
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' },
|
||||
// Forecast
|
||||
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
|
||||
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
|
||||
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. /icons/01d.png' },
|
||||
forecast_precipitation_mm: { type: 'number', description: 'Precipitation (mm)' },
|
||||
forecast_precipitation_probability: { type: 'number', description: 'Precipitation probability (%)' },
|
||||
forecast_snowfall_mm: { type: 'number', description: 'Snowfall (mm)' },
|
||||
forecast_snow_depth_m: { type: 'number', description: 'Snow depth (m)' },
|
||||
forecast_cape: { type: 'number', description: 'Convective available potential energy (J/kg)' },
|
||||
forecast_freezing_level_m: { type: 'number', description: 'Freezing level altitude (m)' },
|
||||
forecast_evapotranspiration_mm:{ type: 'number', description: 'Evapotranspiration (mm)' },
|
||||
// Seismic
|
||||
seismic_ax: { type: 'number', description: 'Peak acceleration X axis (g)' },
|
||||
seismic_ay: { type: 'number', description: 'Peak acceleration Y axis (g)' },
|
||||
seismic_az: { type: 'number', description: 'Peak acceleration Z axis (g, includes gravity)' },
|
||||
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g, gravity removed)' },
|
||||
// Compass
|
||||
compass_heading: { type: 'number', description: 'Magnetic heading (°)' },
|
||||
compass_x: { type: 'number' },
|
||||
compass_y: { type: 'number' },
|
||||
compass_z: { type: 'number' },
|
||||
// Ground / Accumulation
|
||||
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||
lidar_strength: { type: 'number', description: 'LIDAR signal strength' },
|
||||
ground_calibration: { type: 'number', description: 'Calibrated baseline distance (cm)' },
|
||||
accumulation: { type: 'number', description: 'Snow/flood depth (cm)' },
|
||||
ground_accumulation_type: { type: 'string', enum: ['snow','slush','ice','flood'], description: 'Accumulation type' },
|
||||
// Lightning
|
||||
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||
lightning_energy: { type: 'number', description: 'Lightning energy' },
|
||||
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
|
||||
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
|
||||
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (0–7)' },
|
||||
// GPS
|
||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||
gps_satellites: { type: 'number', description: 'Satellites in view' },
|
||||
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
|
||||
gps_heading: { type: 'number', description: 'GPS heading (°)' },
|
||||
// Sun
|
||||
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||
sun_sunrise: { type: 'string', description: 'Sunrise time (ISO8601)' },
|
||||
sun_sunset: { type: 'string', description: 'Sunset time (ISO8601)' },
|
||||
sun_solar_noon: { type: 'string', description: 'Solar noon (ISO8601)' },
|
||||
sun_day_length_hours: { type: 'number', description: 'Day length (hours)' },
|
||||
sun_golden_hour_morning_start: { type: 'string' },
|
||||
sun_golden_hour_morning_end: { type: 'string' },
|
||||
sun_golden_hour_evening_start: { type: 'string' },
|
||||
sun_golden_hour_evening_end: { type: 'string' },
|
||||
sun_is_day: { type: 'number', description: '1 if daytime' },
|
||||
sun_ssn: { type: 'number', description: 'Estimated sunspot number' },
|
||||
sun_activity_label: { type: 'string', enum: ['Very Low','Low','Moderate','High','Very High'] },
|
||||
// Moon
|
||||
moon_phase: { type: 'string', description: 'Moon phase name' },
|
||||
moon_illumination_pct: { type: 'number', description: 'Moon illumination (%)' },
|
||||
moon_moonrise: { type: 'string', description: 'Moonrise time (ISO8601)' },
|
||||
moon_moonset: { type: 'string', description: 'Moonset time (ISO8601)' },
|
||||
moon_next_full: { type: 'string', description: 'Next full moon (ISO8601)' },
|
||||
moon_next_new: { type: 'string', description: 'Next new moon (ISO8601)' },
|
||||
// Season
|
||||
season_next_event: { type: 'string', description: 'Next solstice/equinox name' },
|
||||
season_next_event_date: { type: 'string' },
|
||||
season_last_event: { type: 'string' },
|
||||
season_last_event_date: { type: 'string' },
|
||||
// Space weather
|
||||
space_kp_index: { type: 'number', description: 'Planetary K index (0–9)' },
|
||||
space_ap_index: { type: 'number', description: 'Ap geomagnetic index' },
|
||||
space_geomagnetic_storm: { type: 'string', enum: ['None','Minor','Moderate','Strong','Severe'] },
|
||||
space_solar_wind_speed_kms: { type: 'number', description: 'Solar wind speed (km/s)' },
|
||||
space_solar_wind_density: { type: 'number', description: 'Solar wind proton density (p/cm³)' },
|
||||
space_solar_wind_temp: { type: 'number', description: 'Solar wind temperature (K)' },
|
||||
space_imf_bz: { type: 'number', description: 'IMF Bz component (nT)' },
|
||||
space_imf_bt: { type: 'number', description: 'IMF total field Bt (nT)' },
|
||||
space_solar_flux_f107: { type: 'number', description: 'Solar flux F10.7 index' },
|
||||
// Marine
|
||||
wave_height: { type: 'number', description: 'Significant wave height (m)' },
|
||||
wave_direction: { type: 'number', description: 'Wave direction (°)' },
|
||||
wave_period: { type: 'number', description: 'Wave period (s)' },
|
||||
swell_wave_height: { type: 'number', description: 'Swell wave height (m)' },
|
||||
swell_wave_direction: { type: 'number', description: 'Swell direction (°)' },
|
||||
swell_wave_period: { type: 'number', description: 'Swell period (s)' },
|
||||
wind_wave_height: { type: 'number', description: 'Wind wave height (m)' },
|
||||
wave_height_max: { type: 'number', description: 'Daily max wave height (m)' },
|
||||
// AQ
|
||||
pm10: { type: 'number', description: 'PM10 (μg/m³)' },
|
||||
pm2_5: { type: 'number', description: 'PM2.5 (μg/m³)' },
|
||||
carbon_monoxide: { type: 'number', description: 'CO (μg/m³)' },
|
||||
nitrogen_dioxide: { type: 'number', description: 'NO₂ (μg/m³)' },
|
||||
sulphur_dioxide: { type: 'number', description: 'SO₂ (μg/m³)' },
|
||||
ozone: { type: 'number', description: 'O₃ (μg/m³)' },
|
||||
aerosol_optical_depth: { type: 'number' },
|
||||
dust: { type: 'number', description: 'Dust (μg/m³)' },
|
||||
alder_pollen: { type: 'number', description: 'Alder pollen (grains/m³)' },
|
||||
birch_pollen: { type: 'number', description: 'Birch pollen (grains/m³)' },
|
||||
grass_pollen: { type: 'number', description: 'Grass pollen (grains/m³)' },
|
||||
mugwort_pollen: { type: 'number', description: 'Mugwort pollen (grains/m³)' },
|
||||
olive_pollen: { type: 'number', description: 'Olive pollen (grains/m³)' },
|
||||
ragweed_pollen: { type: 'number', description: 'Ragweed pollen (grains/m³)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1301
server/src/adsb-shapes.mjs
Normal file
369
server/src/adsb.mjs
Normal file
@@ -0,0 +1,369 @@
|
||||
import { cfg } from './config.mjs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as fs from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { fromCsv } from '@ztimson/utils';
|
||||
import {getIcon} from './adsb-shapes.mjs';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = resolve(DIR, '../data');
|
||||
const DB_PATH = resolve(DATA, 'aircraft.db');
|
||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||
const MAX_HISTORY = 500;
|
||||
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
const ADSB_TTL = 1000;
|
||||
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
||||
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
|
||||
|
||||
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
||||
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
||||
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
||||
|
||||
let adsbCache = null;
|
||||
let adsbCacheTs = 0;
|
||||
const noRecord = [];
|
||||
const history = new Map();
|
||||
const milRanges = [];
|
||||
let db;
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - HISTORY_TTL;
|
||||
for (const [key, trail] of history) {
|
||||
const last = trail.at(-1);
|
||||
if (!last || last.ts < cutoff) history.delete(key);
|
||||
}
|
||||
}, 1000 * 60 * 5);
|
||||
|
||||
function fetchWithTimeout(url, ms = 5000) {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), ms);
|
||||
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
|
||||
}
|
||||
|
||||
async function syncMilitaryRanges() {
|
||||
const { ADSB_URL } = cfg();
|
||||
try {
|
||||
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { military } = await res.json();
|
||||
|
||||
db.exec('DELETE FROM military_ranges');
|
||||
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
||||
const insertMany = db.transaction(ranges => {
|
||||
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
||||
});
|
||||
|
||||
insertMany(military);
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
||||
} catch (e) {
|
||||
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...rows.map(r => [r.start, r.end]));
|
||||
}
|
||||
}
|
||||
|
||||
export async function initAircraftDb() {
|
||||
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
|
||||
|
||||
const missing = !fs.existsSync(DB_PATH);
|
||||
if (missing) console.log(`✈️ Building database`);
|
||||
|
||||
db = new Database(DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS aircraft (
|
||||
icao TEXT PRIMARY KEY,
|
||||
built TEXT,
|
||||
categoryDescription TEXT,
|
||||
country TEXT,
|
||||
engines TEXT,
|
||||
class TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
modes TEXT,
|
||||
operator TEXT,
|
||||
operatorCallsign TEXT,
|
||||
owner TEXT,
|
||||
registration TEXT,
|
||||
serialNumber TEXT,
|
||||
aircraft TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS military_ranges (
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await syncMilitaryRanges();
|
||||
|
||||
if (missing) {
|
||||
const res = await fetch(CSV_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
fs.writeFileSync(CSV_CACHE, text);
|
||||
|
||||
const csv = fromCsv(text, true);
|
||||
const insert = db.prepare(`
|
||||
INSERT OR REPLACE INTO aircraft VALUES (
|
||||
@icao, @built, @categoryDescription, @country, @engines,
|
||||
@class, @manufacturer, @model, @modes, @operator,
|
||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
||||
)
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction(rows => {
|
||||
for (const row of rows) insert.run(row);
|
||||
});
|
||||
|
||||
insertMany(csv.map(row => ({
|
||||
icao: (row.icao24 || '').toUpperCase(),
|
||||
built: row.built || null,
|
||||
categoryDescription: row.categoryDescription || null,
|
||||
country: row.country || null,
|
||||
engines: row.engines || null,
|
||||
class: row.icaoAircraftClass || null,
|
||||
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
|
||||
model: row.model || null,
|
||||
modes: row.modes || null,
|
||||
operator: row.operator || null,
|
||||
operatorCallsign: row.operatorCallsign || null,
|
||||
owner: row.owner || null,
|
||||
registration: row.registration || null,
|
||||
serialNumber: row.serialNumber || null,
|
||||
aircraft: row.typecode || null,
|
||||
})));
|
||||
|
||||
fs.unlinkSync(CSV_CACHE);
|
||||
console.log(`✈️ Aircraft imported: ${csv.length}`);
|
||||
} else {
|
||||
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
|
||||
console.log(`✈️ Aircraft loaded: ${count.c}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isIcaoInMilitaryRange(icao) {
|
||||
if (!icao) return false;
|
||||
const hex = icao.toUpperCase();
|
||||
return milRanges.some(([s, e]) => hex >= s && hex <= e);
|
||||
}
|
||||
|
||||
function wordMatch(text, keyword) {
|
||||
if (!text) return false;
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/gi, '\\$&');
|
||||
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
|
||||
}
|
||||
|
||||
export function classifyAircraft(row) {
|
||||
if (!row) return 'unknown';
|
||||
|
||||
const matchesAny = (fields, list) =>
|
||||
fields.some(f => list.some(k => wordMatch(f, k)));
|
||||
|
||||
const operatorFields = [row.operator, row.operatorCallsign];
|
||||
const ownerFields = [row.owner];
|
||||
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
||||
|
||||
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
|
||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
|
||||
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
||||
if (row.owner && !row.operator) return 'private';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function fetchHexDb(icao) {
|
||||
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
|
||||
if (!resp.ok) return null;
|
||||
const found = await resp.json();
|
||||
return {
|
||||
registration: found.Registration || null,
|
||||
manufacturer: found.Manufacturer || null,
|
||||
aircraft: found.ICAOTypeCode || null,
|
||||
model: found.Type || null,
|
||||
operator: found.RegisteredOwners || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function scrapeHexDatabase(icao) {
|
||||
const url = `https://hexdatabase.com/h/${icao}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const html = await resp.text();
|
||||
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
||||
if (!tableMatch) return null;
|
||||
|
||||
const $ = cheerio.load(tableMatch[0]);
|
||||
const row = $('tr').filter((_, el) => $(el).text().toLowerCase().includes(icao.toLowerCase())).first();
|
||||
if (!row.length) return null;
|
||||
|
||||
const cells = row.find('td');
|
||||
return {
|
||||
registration: $(cells[1]).text().trim() || null,
|
||||
aircraft: $(cells[2]).text().trim() || null,
|
||||
operator: $(cells[3]).text().trim() || null,
|
||||
serialNumber: $(cells[5]).text().trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function backfillFromSimilar(aircraft) {
|
||||
if (!aircraft) return {};
|
||||
const similar = db.prepare(`
|
||||
SELECT manufacturer, model, engines, categoryDescription, class
|
||||
FROM aircraft
|
||||
WHERE aircraft = ?
|
||||
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
|
||||
LIMIT 1
|
||||
`).get(aircraft);
|
||||
if (!similar) return {};
|
||||
return {
|
||||
manufacturer: similar.manufacturer || null,
|
||||
model: similar.model || null,
|
||||
engines: similar.engines || null,
|
||||
categoryDescription: similar.categoryDescription || null,
|
||||
class: similar.class || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrichAircraft(a) {
|
||||
if (!a.hex) return a;
|
||||
const icao = a.hex.toUpperCase();
|
||||
|
||||
// 1. Check own DB first
|
||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
||||
if(row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
|
||||
// 2. Race the two external sources
|
||||
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||
const hexDbPromise = fetchHexDb(icao);
|
||||
const scrapePromise = scrapeHexDatabase(icao);
|
||||
let found = await hexDbPromise.catch(() => {});
|
||||
if(!found) found = await scrapePromise.catch(() => {});
|
||||
if(!found) {
|
||||
noRecord.push(icao);
|
||||
return { icao, ...a, type: 'unknown' };
|
||||
}
|
||||
|
||||
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
|
||||
const merged = { ...row, ...found };
|
||||
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
||||
const similar = backfillFromSimilar(merged.aircraft);
|
||||
Object.assign(merged, similar);
|
||||
}
|
||||
|
||||
// 4. Save back to DB
|
||||
if (found) {
|
||||
db.prepare(`
|
||||
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
||||
VALUES (@icao, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
|
||||
ON CONFLICT(icao) DO UPDATE SET
|
||||
registration = COALESCE(excluded.registration, registration),
|
||||
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
|
||||
aircraft = COALESCE(excluded.aircraft, aircraft),
|
||||
model = COALESCE(excluded.model, model),
|
||||
operator = COALESCE(excluded.operator, operator),
|
||||
country = COALESCE(excluded.country, country),
|
||||
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
|
||||
engines = COALESCE(excluded.engines, engines),
|
||||
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
|
||||
class = COALESCE(excluded.class, class)
|
||||
`).run({
|
||||
icao,
|
||||
registration: merged.registration || null,
|
||||
manufacturer: merged.manufacturer || null,
|
||||
aircraft: merged.aircraft || null,
|
||||
model: merged.model || null,
|
||||
operator: merged.operator || null,
|
||||
country: merged.country || null,
|
||||
serialNumber: merged.serialNumber || null,
|
||||
engines: merged.engines || null,
|
||||
categoryDescription: merged.categoryDescription || null,
|
||||
class: merged.class || null,
|
||||
});
|
||||
}
|
||||
|
||||
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
|
||||
}
|
||||
|
||||
export async function getADSB() {
|
||||
if (adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||
const { ADSB_URL } = cfg();
|
||||
if (!ADSB_URL) return [];
|
||||
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
|
||||
const j = await r.json();
|
||||
const aircraft = j.aircraft || [];
|
||||
|
||||
for (const a of aircraft) {
|
||||
a.type = 'unknown';
|
||||
if (!a.hex || !a.lat || !a.lon) continue;
|
||||
const key = a.hex.toLowerCase();
|
||||
if (!history.has(key)) history.set(key, []);
|
||||
const trail = history.get(key);
|
||||
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() });
|
||||
if (trail.length > MAX_HISTORY) trail.shift();
|
||||
}
|
||||
|
||||
adsbCache = await Promise.all(aircraft.map(async a => {
|
||||
a = await enrichAircraft(a);
|
||||
a.icon = getIcon(a);
|
||||
return a;
|
||||
}));
|
||||
adsbCacheTs = Date.now();
|
||||
return adsbCache;
|
||||
}
|
||||
|
||||
export async function getADSBHistory(icao) {
|
||||
return { history: history.get(icao?.toLowerCase()) || [] };
|
||||
}
|
||||
|
||||
export async function getADSBRange() {
|
||||
const { ADSB_URL } = cfg();
|
||||
if (!ADSB_URL) return [];
|
||||
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
|
||||
const j = await r.json();
|
||||
return j?.actualRange?.last24h?.points || [];
|
||||
}
|
||||
|
||||
export async function getADSBImage(icao) {
|
||||
async function duckduckgo(query) {
|
||||
async function getVqd(query) {
|
||||
const res = await fetch(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' }
|
||||
})
|
||||
const html = await res.text()
|
||||
const match = html.match(/vqd=['"]?([\d-]+)['"]?/)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
const vqd = await getVqd(query)
|
||||
if (!vqd) throw new Error('Could not get vqd token')
|
||||
|
||||
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${vqd}&f=,,,,,&p=1`
|
||||
const data = await fetch(url,
|
||||
{headers: {'User-Agent': 'Mozilla/5.0', 'Referer': 'https://duckduckgo.com/'}}
|
||||
).then(resp => resp.json());
|
||||
return data?.results?.[0]?.image || null;
|
||||
}
|
||||
|
||||
|
||||
const c = cfg();
|
||||
let aircraft = (await adsbCache).find(a => a.icao?.toLowerCase() === icao?.toLowerCase());
|
||||
if(!aircraft) aircraft = enrichAircraft({hex: icao});
|
||||
if(!aircraft) return null;
|
||||
|
||||
const generic = duckduckgo(aircraft.model ? `${aircraft.manufacturer} ${aircraft.model}` : `${aircraft.aircraft} Aircraft`);
|
||||
const specific = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao.toLowerCase()}`,
|
||||
{headers: {'User-Agent': `Personal ADSB Feeder + Dashboard (${c.EMAIL})`}}
|
||||
).then(resp => resp.ok ? resp.json() : null);
|
||||
|
||||
let src;
|
||||
if(specific?.photos?.[0]) src = specific.photos[0].thumbnail_large?.src || specific.photos[0].thumbnail?.src;
|
||||
else src = await generic;
|
||||
|
||||
if(!src) return null;
|
||||
return fetch(src).then(resp => resp.blob());
|
||||
}
|
||||
107
server/src/ais.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import {cfg} from './config.mjs';
|
||||
|
||||
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
|
||||
const SENDER_TYPE = {
|
||||
0: 'Other', 1: 'Unknown', 2: 'Cargo', 3: 'Class B',
|
||||
4: 'Passenger', 5: 'Special', 6: 'Tanker', 7: 'High Speed',
|
||||
8: 'Fishing', 9: 'Aircraft', 10: 'Helicopter', 11: 'Base Station',
|
||||
12: 'AtoN', 13: 'SART/EPIRB'
|
||||
};
|
||||
|
||||
let aisCache = null;
|
||||
let aisCacheTs = 0;
|
||||
const AIS_TTL = 1000;
|
||||
|
||||
function decodeMsgTypes(raw) {
|
||||
const types = [];
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (raw & (1 << i)) types.push(i);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
function decodeFlags(flags) {
|
||||
return {
|
||||
validated: flags & 3,
|
||||
validated2: (flags & 3) == 2 ? -1 : flags & 3,
|
||||
repeat: (flags >> 2) & 3,
|
||||
virtual_aid: (flags >> 4) & 1,
|
||||
approx: (flags >> 5) & 1,
|
||||
channels: (flags >> 6) & 15,
|
||||
cs_unit: (flags >> 10) & 3,
|
||||
raim: (flags >> 12) & 3,
|
||||
dte: (flags >> 14) & 3,
|
||||
assigned: (flags >> 16) & 3,
|
||||
display: (flags >> 18) & 3,
|
||||
dsc: (flags >> 20) & 3,
|
||||
band: (flags >> 22) & 3,
|
||||
msg22: (flags >> 24) & 3,
|
||||
off_position: (flags >> 26) & 3,
|
||||
maneuver: (flags >> 28) & 3,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAIS() {
|
||||
if (aisCache && Date.now() - aisCacheTs < AIS_TTL) return aisCache;
|
||||
const {ADSB_URL} = cfg();
|
||||
if (!ADSB_URL) return {data: []};
|
||||
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
|
||||
const {dynamic, static: staticRows, time} = await r.json();
|
||||
|
||||
const staticMap = new Map(staticRows.map(row => [row[0], row]));
|
||||
|
||||
aisCache = dynamic.map(row => {
|
||||
const [mmsi, lat, lon, distance, bearing, heading,
|
||||
cog, speed, status, rrsi, ppm, count, msg_type,
|
||||
last_signal, last_group, group_mask, flags, altitude,
|
||||
received_stations, mmsi_type, shipclass, country] = row;
|
||||
const s = staticMap.get(mmsi) ?? [];
|
||||
const [_mmsi, shipname, callsign, destination, shiptype,
|
||||
imo, to_bow, to_stern, to_port, to_starboard, draught,
|
||||
eta_month, eta_day, eta_hour, eta_minute] = s;
|
||||
|
||||
const f = decodeFlags(flags);
|
||||
|
||||
return {
|
||||
mmsi,
|
||||
type: SENDER_TYPE[shipclass],
|
||||
lat, lon, altitude,
|
||||
heading, cog, speed,
|
||||
distance, bearing,
|
||||
destination,
|
||||
rrsi, ppm, count,
|
||||
msg_type: decodeMsgTypes(msg_type),
|
||||
channels: CHANNEL_MAP[f.channels] ?? f.channels,
|
||||
...f,
|
||||
shiptype, imo, draught,
|
||||
country, callsign,
|
||||
shipname,
|
||||
to_bow, to_stern, to_port, to_starboard,
|
||||
eta_month, eta_day, eta_hour, eta_minute,
|
||||
last_signal: last_signal ? time - last_signal : null,
|
||||
last_group, group_mask,
|
||||
received_stations, shipclass,
|
||||
status
|
||||
};
|
||||
});
|
||||
aisCacheTs = Date.now();
|
||||
return aisCache;
|
||||
}
|
||||
|
||||
export async function getAISImage(mmsi) {
|
||||
console.log(`https://www.vesselfinder.com/vessels/details/${mmsi}`)
|
||||
const res = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||
})
|
||||
const html = await res.text()
|
||||
const match = html.match(/<img.+main-photo.+>/)
|
||||
if(!match) return null;
|
||||
const srcMatch = match[0].match(/src="(.+?)"/)
|
||||
if (!srcMatch) return null;
|
||||
|
||||
let imgUrl = srcMatch[1]
|
||||
if (imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`
|
||||
return fetch(imgUrl, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' }
|
||||
}).then(resp => resp.blob())
|
||||
}
|
||||
98
server/src/aurora.mjs
Normal file
@@ -0,0 +1,98 @@
|
||||
import {adjustedInterval, Logger} from '@ztimson/utils';
|
||||
|
||||
const cacheOptions = {
|
||||
ttl: null,
|
||||
reload: null,
|
||||
retry: 3,
|
||||
url: null,
|
||||
}
|
||||
|
||||
export class CacheService {
|
||||
#fetchLoop;
|
||||
|
||||
cached = null;
|
||||
lastUpdate = null;
|
||||
logger;
|
||||
name;
|
||||
options;
|
||||
pending;
|
||||
|
||||
constructor(name, options) {
|
||||
this.name = name;
|
||||
this.options = {
|
||||
...cacheOptions,
|
||||
ttl: options.reload,
|
||||
...options
|
||||
};
|
||||
this.logger = new Logger(name);
|
||||
if(this.options.reload) setTimeout(() => this.startLoop(), 1000);
|
||||
}
|
||||
|
||||
async #fetchWithRetry() {
|
||||
let lastError;
|
||||
for(let attempt = 1; attempt <= this.options.retry; attempt++) {
|
||||
try {
|
||||
return await this.fetch();
|
||||
} catch(err) {
|
||||
lastError = err;
|
||||
if(attempt < this.options.retry)
|
||||
this.logger.warn(`Retrying (${attempt + 1}/${this.options.retry}): ${err.message || err.toString()}`);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async fetch() {
|
||||
if(!this.options.url) return Promise.reject('Fetch is not configured');
|
||||
const resp = await fetch(this.options.url);
|
||||
if(!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
startLoop(speed = this.options.reload) {
|
||||
this.stopLoop();
|
||||
this.#fetchLoop = adjustedInterval(() => this.get(), speed);
|
||||
}
|
||||
|
||||
stopLoop() {
|
||||
if(!this.#fetchLoop) return;
|
||||
clearInterval(this.#fetchLoop);
|
||||
this.#fetchLoop = null;
|
||||
}
|
||||
|
||||
update(catchErr = true) {
|
||||
if(this.pending) return this.pending;
|
||||
this.logger.info('Fetching latest');
|
||||
this.pending = this.#fetchWithRetry().then(data => {
|
||||
if(data?.err) throw new Error(data.err?.stack || data.err?.message || data.err);
|
||||
if(data?.error) throw new Error(data.error?.stack || data.error?.message || data.error);
|
||||
this.lastUpdate = new Date();
|
||||
this.cached = data || null;
|
||||
this.logger.debug('Finished updating');
|
||||
return {timestamp: this.lastUpdate, data: this.cached};
|
||||
}).catch(err => {
|
||||
if(catchErr) this.logger.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
|
||||
else throw err;
|
||||
}).finally(() => this.pending = null);
|
||||
return this.pending;
|
||||
}
|
||||
|
||||
async get(wait = false) {
|
||||
if(!this.cached || !this.lastUpdate || this.lastUpdate?.getTime() + this.options.ttl <= Date.now()) {
|
||||
if(wait) await this.update();
|
||||
else this.update();
|
||||
}
|
||||
return Promise.resolve({timestamp: this.lastUpdate || null, data: this.cached || null});
|
||||
}
|
||||
}
|
||||
|
||||
export class AuroraService extends CacheService {
|
||||
constructor() {
|
||||
super('Aurora', {
|
||||
reload: 60_000 * 15,
|
||||
url: 'https://services.swpc.noaa.gov/json/ovation_aurora_latest.json',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const Aurora = new AuroraService();
|
||||
@@ -53,14 +53,10 @@ function sunriseSunset(lat, lon, date = new Date()) {
|
||||
const H = Math.acos(cosH) * DEG;
|
||||
const rise = jdnToDate(jnoon - H / 360);
|
||||
const set = jdnToDate(jnoon + H / 360);
|
||||
const noon_d = jdnToDate(jnoon);
|
||||
|
||||
// Round to nearest second, drop milliseconds
|
||||
const fmt = d => new Date(Math.round(d.getTime() / 1000) * 1000).toISOString();
|
||||
|
||||
return {
|
||||
sunrise: fmt(rise),
|
||||
sunset: fmt(set),
|
||||
sunrise: rise,
|
||||
sunset: set,
|
||||
daylight: Math.round((set - rise) / 36000) / 100,
|
||||
daytime: cosH < -1
|
||||
};
|
||||
@@ -82,12 +78,46 @@ export function sunActivityLabel(f107) {
|
||||
return 'Very High';
|
||||
}
|
||||
|
||||
function moonPosition(lat, lon, date = new Date()) {
|
||||
const d = jdn(date) - 2451545;
|
||||
const L = (218.316 + 13.176396 * d) % 360;
|
||||
const M = (134.963 + 13.064993 * d) % 360;
|
||||
const F = (93.272 + 13.229350 * d) % 360;
|
||||
const lon_ = L + 6.289 * Math.sin(M * RAD);
|
||||
const b = 5.128 * Math.sin(F * RAD);
|
||||
const dec = Math.asin(
|
||||
Math.sin(b * RAD) * Math.cos(23.4397 * RAD) +
|
||||
Math.cos(b * RAD) * Math.sin(23.4397 * RAD) * Math.sin(lon_ * RAD)
|
||||
) * DEG;
|
||||
const ra = Math.atan2(
|
||||
Math.sin(lon_ * RAD) * Math.cos(23.4397 * RAD) - Math.tan(b * RAD) * Math.sin(23.4397 * RAD),
|
||||
Math.cos(lon_ * RAD)
|
||||
) * DEG;
|
||||
const UT = date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600;
|
||||
const GMST = (6.697375 + 0.0657098242 * d + UT) % 24;
|
||||
const LMST = (GMST + lon / 15) % 24;
|
||||
const ha = LMST * 15 - ra;
|
||||
const elev = Math.asin(
|
||||
Math.sin(lat * RAD) * Math.sin(dec * RAD) +
|
||||
Math.cos(lat * RAD) * Math.cos(dec * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
const az = Math.atan2(
|
||||
-Math.sin(ha * RAD),
|
||||
Math.tan(dec * RAD) * Math.cos(lat * RAD) - Math.sin(lat * RAD) * Math.cos(ha * RAD)
|
||||
) * DEG;
|
||||
return {
|
||||
moon_elevation: Math.round(elev * 10) / 10,
|
||||
moon_azimuth: Math.round(((az + 360) % 360) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
function moonPhase(date = new Date()) {
|
||||
const jd = jdn(date);
|
||||
const cycle = 29.53058867;
|
||||
const known = 2451550.1;
|
||||
const phase = ((jd - known) % cycle + cycle) % cycle;
|
||||
const illum = Math.round((1 - Math.cos(phase / cycle * 2 * Math.PI)) / 2 * 100);
|
||||
// More accurate illumination using proper phase angle
|
||||
const illum = Math.round((1 - Math.cos(phase / cycle * 2 * Math.PI)) / 2 * 1000) / 10;
|
||||
let name;
|
||||
if(phase < 1.85) name = 'New Moon';
|
||||
else if(phase < 7.38) name = 'Waxing Crescent';
|
||||
@@ -97,7 +127,7 @@ function moonPhase(date = new Date()) {
|
||||
else if(phase < 22.15) name = 'Waning Gibbous';
|
||||
else if(phase < 23.99) name = 'Last Quarter';
|
||||
else name = 'Waning Crescent';
|
||||
return {moon_phase: name, moon_illumination: illum};
|
||||
return { moon_phase: name, moon_illumination: illum };
|
||||
}
|
||||
|
||||
function nextMoonEvents(date = new Date()) {
|
||||
@@ -108,8 +138,8 @@ function nextMoonEvents(date = new Date()) {
|
||||
const toNew = (cycle - phase) % cycle;
|
||||
const toFull = phase < 14.76 ? 14.76 - phase : cycle - phase + 14.76;
|
||||
return {
|
||||
moon_new: jdnToDate(jd + toNew).toISOString(),
|
||||
moon_full: jdnToDate(jd + toFull).toISOString(),
|
||||
moon_new: jdnToDate(jd + toNew),
|
||||
moon_full: jdnToDate(jd + toFull),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,8 +176,8 @@ function moonriseMoonset(lat, lon, date = new Date()) {
|
||||
) * DEG;
|
||||
|
||||
if(prev !== null) {
|
||||
if(prev < 0 && elev >= 0 && !moonrise) moonrise = new Date(t.getTime() - 5 * 60000).toISOString();
|
||||
if(prev >= 0 && elev < 0 && !moonset) moonset = new Date(t.getTime() - 5 * 60000).toISOString();
|
||||
if(prev < 0 && elev >= 0 && !moonrise) moonrise = new Date(t.getTime() - 5 * 60000);
|
||||
if(prev >= 0 && elev < 0 && !moonset) moonset = new Date(t.getTime() - 5 * 60000);
|
||||
}
|
||||
prev = elev;
|
||||
if(moonrise && moonset) break;
|
||||
@@ -156,25 +186,17 @@ function moonriseMoonset(lat, lon, date = new Date()) {
|
||||
return {moonrise: moonrise, moonset: moonset};
|
||||
}
|
||||
|
||||
function nextSolsticeEquinox(date = new Date()) {
|
||||
const y = date.getFullYear();
|
||||
return {
|
||||
summer_solstice: new Date(Date.UTC(y, 5, 21)),
|
||||
winter_solstice: new Date(Date.UTC(y, 11, 21)),
|
||||
vernal_equinox: new Date(Date.UTC(y, 2, 20)),
|
||||
autumnal_equinox: new Date(Date.UTC(y + 1, 2, 20)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCelestialCurrent(lat, lon, date = new Date()) {
|
||||
return {
|
||||
const data = {
|
||||
...sunPosition(lat, lon, date),
|
||||
...sunriseSunset(lat, lon, date),
|
||||
...moonPhase(date),
|
||||
...moonPosition(lat, lon, date),
|
||||
...nextMoonEvents(date),
|
||||
...moonriseMoonset(lat, lon, date),
|
||||
...nextSolsticeEquinox(date),
|
||||
};
|
||||
data.daytime = date.getTime() >= data.sunrise && date.getTime() <= data.sunset;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getCelestialForecast(lat, lon, hours) {
|
||||
44
server/src/config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import { config } from 'dotenv'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
|
||||
export function cfg() {
|
||||
config({ path: resolve(ROOT, '.env'), override: true })
|
||||
config({ path: resolve(ROOT, '.env.local'), override: true })
|
||||
return {
|
||||
PORT: process.env.PORT || 3000,
|
||||
ADSB_URL: process.env.ADSB_URL || '',
|
||||
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
|
||||
EMAIL: process.env.EMAIL,
|
||||
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
|
||||
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
|
||||
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
|
||||
TZ: process.env.TZ || 'UTC',
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the UTC offset in minutes for the configured TZ at a given date
|
||||
export function tzOffsetMinutes(date = new Date()) {
|
||||
const tz = cfg().TZ
|
||||
// Format the date in the target timezone and compare to UTC
|
||||
const local = new Date(date.toLocaleString('en-US', { timeZone: tz }))
|
||||
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
|
||||
return Math.round((local - utc) / 60_000)
|
||||
}
|
||||
|
||||
// Shift a UTC date to local-time-as-if-UTC (for day bucketing)
|
||||
export function toLocalMidnight(date = new Date()) {
|
||||
const offset = tzOffsetMinutes(date)
|
||||
const local = new Date(date.getTime() + offset * 60_000)
|
||||
local.setUTCHours(0, 0, 0, 0)
|
||||
return local
|
||||
}
|
||||
|
||||
// Get YYYY-MM-DD string in local timezone
|
||||
export function localDateStr(date = new Date()) {
|
||||
const offset = tzOffsetMinutes(date)
|
||||
const shifted = new Date(date.getTime() + offset * 60_000)
|
||||
return shifted.toISOString().slice(0, 10)
|
||||
}
|
||||
310
server/src/forecast.mjs
Normal file
@@ -0,0 +1,310 @@
|
||||
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
|
||||
import {getCelestialCurrent} from './celestial.mjs';
|
||||
import {localDateStr} from './config.mjs';
|
||||
import {frostRisk} from './openweather.mjs';
|
||||
|
||||
export let lastForecast = { ts: 0, slots: [], summary: null }
|
||||
export const forecastTTL = 5 * 60 * 60_000
|
||||
|
||||
function estimatePrecipitation(precipChance, clouds) {
|
||||
if (precipChance < 30) return 0;
|
||||
const intensity = (precipChance / 100) * clouds;
|
||||
if (intensity > 0.7) return Math.round(intensity * 15 * 10) / 10;
|
||||
if (intensity > 0.4) return Math.round(intensity * 6 * 10) / 10;
|
||||
return Math.round(intensity * 2 * 10) / 10;
|
||||
}
|
||||
|
||||
function estimateUV(sunElevation, cloudFraction) {
|
||||
if (sunElevation <= 0) return 0;
|
||||
const clearSkyUV = Math.sin((sunElevation * Math.PI) / 180) * 12;
|
||||
return Math.round(clearSkyUV * (1 - cloudFraction * 0.75) * 10) / 10;
|
||||
}
|
||||
|
||||
function linearTrend(values) {
|
||||
const n = values.length;
|
||||
if (n < 2) return 0;
|
||||
const xMean = (n - 1) / 2;
|
||||
const yMean = values.reduce((a, b) => a + b, 0) / n;
|
||||
const num = values.reduce((s, v, i) => s + (i - xMean) * (v - yMean), 0);
|
||||
const den = values.reduce((s, _, i) => s + (i - xMean) ** 2, 0);
|
||||
return den === 0 ? 0 : num / den;
|
||||
}
|
||||
|
||||
function dewPoint(tempC, humidity) {
|
||||
const a = 17.27, b = 237.7;
|
||||
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100);
|
||||
return (b * gamma) / (a - gamma);
|
||||
}
|
||||
|
||||
function heatIndex(tempC, humidity) {
|
||||
const T = tempC * 9 / 5 + 32;
|
||||
if (T < 80) return tempC;
|
||||
const HI =
|
||||
-42.379 + 2.04901523 * T + 10.14333127 * humidity
|
||||
- 0.22475541 * T * humidity - 0.00683783 * T * T
|
||||
- 0.05481717 * humidity * humidity + 0.00122874 * T * T * humidity
|
||||
+ 0.00085282 * T * humidity * humidity - 0.00000199 * T * T * humidity * humidity;
|
||||
return (HI - 32) * 5 / 9;
|
||||
}
|
||||
|
||||
function vaporPressureDeficit(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3));
|
||||
return Math.round(svp * (1 - humidity / 100) * 1000) / 1000;
|
||||
}
|
||||
|
||||
function absoluteHumidity(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3));
|
||||
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000;
|
||||
}
|
||||
|
||||
function pressureRule(trend3h, currentHpa) {
|
||||
if (trend3h < -2.0) return { label: 'Storm likely', precipitation_chance: 0.85 };
|
||||
if (trend3h < -0.8) return { label: 'Rain likely', precipitation_chance: 0.65 };
|
||||
if (trend3h < -0.3) return { label: 'Cloudy', precipitation_chance: 0.35 };
|
||||
if (trend3h > 1.5) return { label: 'Clearing', precipitation_chance: 0.05 };
|
||||
if (trend3h > 0.3) return { label: 'Improving', precipitation_chance: 0.10 };
|
||||
if (currentHpa < 1000) return { label: 'Unsettled', precipitation_chance: 0.30 };
|
||||
if (currentHpa < 1013) return { label: 'Partly cloudy', precipitation_chance: 0.15 };
|
||||
return { label: 'Fair', precipitation_chance: 0.05 };
|
||||
}
|
||||
|
||||
function solarTempDelta(sunElevation, cloudFraction) {
|
||||
if (sunElevation <= 0) return 0;
|
||||
return Math.sin((sunElevation * Math.PI) / 180) * (1 - cloudFraction) * 6;
|
||||
}
|
||||
|
||||
function nocturnalCooling(cloudFraction) {
|
||||
return (1 - cloudFraction) * 1.5;
|
||||
}
|
||||
|
||||
function forecastHumidity(absHumidity, forecastTempC) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * forecastTempC) / (forecastTempC + 237.3));
|
||||
const rh = (absHumidity * (forecastTempC + 273.15)) / (svp * 2165) * 100;
|
||||
return Math.min(100, Math.max(0, Math.round(rh * 10) / 10));
|
||||
}
|
||||
|
||||
function estimateClouds(humidity, pressureTrend) {
|
||||
let base = humidity / 100 * 0.8;
|
||||
if (pressureTrend < -0.3) base = Math.min(1, base + 0.2);
|
||||
if (pressureTrend > 0.3) base = Math.max(0, base - 0.15);
|
||||
return Math.round(base * 100) / 100;
|
||||
}
|
||||
|
||||
function forecastWind(currentWind, pressureTrend) {
|
||||
const boost = pressureTrend < -1 ? 1.3 : pressureTrend < -0.5 ? 1.1 : 1.0;
|
||||
return Math.round(currentWind * boost * 10) / 10;
|
||||
}
|
||||
|
||||
export async function get24HourForecast(currentSensors) {
|
||||
const now = new Date();
|
||||
const sixAgo = new Date(now - 6 * 3600 * 1000);
|
||||
|
||||
const coords = await getCoords();
|
||||
const history = await queryHourly(sixAgo, now);
|
||||
|
||||
const pressures = history.map(r => r.pressure_hpa).filter(Number.isFinite);
|
||||
const temps = history.map(r => r.temperature).filter(Number.isFinite);
|
||||
const humidities = history.map(r => r.humidity).filter(Number.isFinite);
|
||||
const winds = history.map(r => r.wind_speed).filter(Number.isFinite);
|
||||
|
||||
const pressureTrend = linearTrend(pressures);
|
||||
const tempTrend = linearTrend(temps);
|
||||
const humidityTrend = linearTrend(humidities);
|
||||
const pressure3h = pressureTrend * 3;
|
||||
|
||||
const seedTemp = currentSensors.temperature ?? temps.at(-1) ?? 20;
|
||||
const seedHumidity = currentSensors.humidity ?? humidities.at(-1) ?? 60;
|
||||
const seedPressure = currentSensors.pressure_hpa ?? pressures.at(-1) ?? 1013;
|
||||
const seedWind = currentSensors.wind_speed ?? winds.at(-1) ?? 0;
|
||||
const seedAbsHum = currentSensors.humidity_abs ?? absoluteHumidity(seedTemp, seedHumidity);
|
||||
|
||||
const weather = pressureRule(pressure3h, seedPressure);
|
||||
|
||||
let runningAccumulation = 0;
|
||||
const slots = Array.from({ length: 24 }, (_, i) => {
|
||||
const time = new Date(now.getTime() + (i + 1) * 3600 * 1000);
|
||||
time.setMinutes(0, 0, 0);
|
||||
|
||||
const pressureDamping = Math.exp(-i * 0.08);
|
||||
const pressure = seedPressure + pressureTrend * (i + 1) * pressureDamping;
|
||||
const pressure_slp = Math.round((pressure + (coords.altitude ?? 0) / 8.5) * 100) / 100;
|
||||
const clouds = estimateClouds(seedHumidity + humidityTrend * i, pressureTrend);
|
||||
const bgTemp = seedTemp + tempTrend * (i + 1);
|
||||
const temperature = Math.round(bgTemp * 10) / 10;
|
||||
const humidity = forecastHumidity(seedAbsHum, temperature);
|
||||
const wind_speed = forecastWind(seedWind, pressureTrend);
|
||||
const precipitation_chance = Math.round(weather.precipitation_chance * (1 - i * 0.02) * 100);
|
||||
const raining = precipitation_chance > 60 ? 'True' : 'False';
|
||||
const precipitation = estimatePrecipitation(precipitation_chance, clouds);
|
||||
runningAccumulation = Math.round((runningAccumulation + precipitation) * 100) / 100;
|
||||
|
||||
const celestial = getCelestialCurrent(coords.latitude, coords.longitude, time);
|
||||
const uv_index = estimateUV(celestial.sun_elevation ?? 0, clouds);
|
||||
|
||||
return {
|
||||
time,
|
||||
temperature,
|
||||
humidity,
|
||||
humidity_abs: absoluteHumidity(temperature, humidity),
|
||||
dew_point: Math.round(dewPoint(temperature, humidity) * 100) / 100,
|
||||
heat_index: Math.round(heatIndex(temperature, humidity) * 100) / 100,
|
||||
vapor_pressure_deficit: vaporPressureDeficit(temperature, humidity),
|
||||
pressure_hpa: Math.round(pressure * 100) / 100,
|
||||
pressure_slp,
|
||||
pressure_rate: Math.round(pressureTrend * 100) / 100,
|
||||
storm_trend: pressure3h < -2 ? 1 : pressure3h > 1.5 ? -1 : 0,
|
||||
wind_speed,
|
||||
wind_gusts: Math.round(wind_speed * 1.4 * 10) / 10,
|
||||
wind_direction: currentSensors.wind_direction ?? 0,
|
||||
clouds,
|
||||
raining,
|
||||
precipitation,
|
||||
precipitation_chance,
|
||||
accumulation: runningAccumulation,
|
||||
uv_index,
|
||||
uv_dose: 0,
|
||||
solar_wm2: 0,
|
||||
daily_light_integral: 0,
|
||||
lux: 0,
|
||||
visibility: 50,
|
||||
...celestial,
|
||||
};
|
||||
});
|
||||
|
||||
for (const slot of slots) {
|
||||
if (slot.sunrise && slot.sunset) {
|
||||
const t = slot.time instanceof Date ? slot.time.getTime() : new Date(slot.time).getTime();
|
||||
const rise = slot.sunrise instanceof Date ? slot.sunrise.getTime() : new Date(slot.sunrise).getTime();
|
||||
const set = slot.sunset instanceof Date ? slot.sunset.getTime() : new Date(slot.sunset).getTime();
|
||||
slot.daytime = t >= rise && t < set;
|
||||
} else {
|
||||
slot.daytime = (slot.sun_elevation ?? 0) > 0;
|
||||
}
|
||||
|
||||
const solar = solarTempDelta(slot.sun_elevation ?? 0, slot.clouds);
|
||||
const cool = slot.daytime ? 0 : nocturnalCooling(slot.clouds);
|
||||
slot.temperature = Math.round((slot.temperature + solar - cool) * 10) / 10;
|
||||
slot.humidity = forecastHumidity(seedAbsHum, slot.temperature);
|
||||
slot.dew_point = Math.round(dewPoint(slot.temperature, slot.humidity) * 100) / 100;
|
||||
slot.heat_index = Math.round(heatIndex(slot.temperature, slot.humidity) * 100) / 100;
|
||||
slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity);
|
||||
slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity);
|
||||
slot.frost_risk = frostRisk(slot.temperature, slot.dew_point, slot.humidity)
|
||||
|
||||
slot.solar_wm2 = slot.sun_elevation > 0
|
||||
? Math.round(Math.sin((slot.sun_elevation * Math.PI) / 180) * 1000 * (1 - slot.clouds * 0.75) * 100) / 100
|
||||
: 0;
|
||||
slot.daily_light_integral = Math.round(slot.solar_wm2 * 3600 / 1_000_000 * 4.57 * 100) / 100;
|
||||
slot.lux = slot.solar_wm2 > 0 ? Math.round(slot.solar_wm2 * 120) : 0;
|
||||
slot.visibility = slot.clouds > 0.85 ? 10 : 50;
|
||||
|
||||
Object.assign(slot, getWeatherCondition(slot));
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
function groupByLocalDay(slots) {
|
||||
const days = {}
|
||||
for (const slot of slots) {
|
||||
const key = localDateStr(new Date(slot.time))
|
||||
if (!days[key]) days[key] = []
|
||||
days[key].push(slot)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
function summarise(slots) {
|
||||
if (!slots.length) return null
|
||||
|
||||
const daytime = slots.filter(s => s.daytime)
|
||||
const source = daytime.length ? daytime : slots
|
||||
|
||||
const avg = (arr) => arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length * 100) / 100 : null
|
||||
const peak = (arr) => arr.length ? Math.max(...arr) : null
|
||||
const fin = (key) => source.map(s => s[key]).filter(Number.isFinite)
|
||||
const finAll = (key) => slots.map(s => s[key]).filter(Number.isFinite)
|
||||
|
||||
const labelCount = {}
|
||||
for (const s of source) labelCount[s.label] = (labelCount[s.label] || 0) + 1
|
||||
const label = Object.entries(labelCount).sort((a, b) => b[1] - a[1])[0][0]
|
||||
const dominant = source.find(s => s.label === label)
|
||||
|
||||
return {
|
||||
time: slots[0].time,
|
||||
label: dominant.label,
|
||||
icon: dominant.icon,
|
||||
temperature: peak(fin('temperature')),
|
||||
temperature_max: peak(finAll('temperature')),
|
||||
temperature_min: Math.min(...finAll('temperature')),
|
||||
humidity: avg(fin('humidity')),
|
||||
humidity_abs: avg(fin('humidity_abs')),
|
||||
dew_point: avg(fin('dew_point')),
|
||||
heat_index: peak(fin('heat_index')),
|
||||
vapor_pressure_deficit: avg(fin('vapor_pressure_deficit')),
|
||||
pressure_hpa: avg(fin('pressure_hpa')),
|
||||
pressure_slp: avg(fin('pressure_slp')),
|
||||
pressure_rate: avg(fin('pressure_rate')),
|
||||
storm_trend: slots.at(-1)?.storm_trend ?? 0,
|
||||
wind_speed: peak(fin('wind_speed')),
|
||||
wind_gusts: peak(fin('wind_gusts')),
|
||||
wind_direction: avg(fin('wind_direction')),
|
||||
clouds: avg(fin('clouds')),
|
||||
raining: peak(fin('precipitation_chance')) > 60 ? 'True' : 'False',
|
||||
precipitation: avg(fin('precipitation')),
|
||||
precipitation_chance: Math.round(peak(fin('precipitation_chance'))),
|
||||
accumulation: finAll('precipitation').reduce((a, b) => a + b, 0),
|
||||
frost_risk: slots.find(s => s.frost_risk !== 'None')?.frost_risk ?? slots.at(-1)?.frost_risk ?? 'None',
|
||||
uv_index: peak(fin('uv_index')),
|
||||
uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0),
|
||||
solar_wm2: avg(fin('solar_wm2')),
|
||||
daily_light_integral: Math.round(finAll('daily_light_integral').reduce((a, b) => a + b, 0) * 100) / 100,
|
||||
lux: peak(fin('lux')),
|
||||
visibility: avg(fin('visibility')),
|
||||
sunrise: slots.find(s => s.sunrise)?.sunrise ?? null,
|
||||
sunset: slots.find(s => s.sunset)?.sunset ?? null,
|
||||
daylight: slots.find(s => s.daylight)?.daylight ?? null,
|
||||
moon_phase: dominant.moon_phase ?? null,
|
||||
moon_illumination: dominant.moon_illumination ?? null,
|
||||
moon_elevation: dominant.moon_elevation ?? null,
|
||||
moon_azimuth: dominant.moon_azimuth ?? null,
|
||||
moon_new: dominant.moon_new ?? null,
|
||||
moon_full: dominant.moon_full ?? null,
|
||||
moonrise: slots.find(s => s.moonrise)?.moonrise ?? null,
|
||||
moonset: slots.find(s => s.moonset)?.moonset ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getForecast() {
|
||||
if (Date.now() - lastForecast.ts < forecastTTL) return lastForecast;
|
||||
const sensors = await queryCurrent().catch(() => null)
|
||||
if (!sensors) return lastForecast
|
||||
const slots = await get24HourForecast(sensors).catch(() => null)
|
||||
if (!slots) return lastForecast
|
||||
|
||||
const days = groupByLocalDay(slots)
|
||||
const todayKey = localDateStr(new Date())
|
||||
const todaySlots = days[todayKey] ?? slots
|
||||
lastForecast = { ts: Date.now(), slots, summary: summarise(todaySlots) }
|
||||
return lastForecast
|
||||
}
|
||||
|
||||
export function getWeatherCondition(data) {
|
||||
if(data.lightning_rate > 0 && data.raining === 'True') {
|
||||
return {label: 'Thunderstorm', icon: '11d'};
|
||||
}
|
||||
if(data.raining === 'True') {
|
||||
if(data.precipitation > 7.6) return {label: 'Heavy Rain', icon: '10d'};
|
||||
if(data.precipitation > 2.5) return {label: 'Moderate Rain', icon: '10d'};
|
||||
return {label: 'Light Rain', icon: '09d'};
|
||||
}
|
||||
if(data.visibility < 10) return {label: 'Mist', icon: '50d'};
|
||||
|
||||
const dayNight = data.daytime ? 'd' : 'n';
|
||||
const c = data.clouds > 1 ? data.clouds / 100 : data.clouds;
|
||||
if(c > 0.85) return {label: 'Overcast Clouds', icon: `04${dayNight}`};
|
||||
if(c > 0.50) return {label: 'Broken Clouds', icon: `04${dayNight}`};
|
||||
if(c > 0.25) return {label: 'Scattered Clouds', icon: `03${dayNight}`};
|
||||
if(c > 0.10) return {label: 'Few Clouds', icon: `02${dayNight}`};
|
||||
return {label: 'Clear Sky', icon: `01${dayNight}`};
|
||||
}
|
||||
274
server/src/openmeteo.mjs
Normal file
@@ -0,0 +1,274 @@
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { tzOffsetMinutes } from './config.mjs'
|
||||
import { getWeatherCondition } from './forecast.mjs'
|
||||
import { getCelestialForecast } from './celestial.mjs'
|
||||
import {frostRisk} from './openweather.mjs';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ICON_DIR = resolve(DIR, 'public', 'icons')
|
||||
const CACHE = {}
|
||||
const CACHE_MS = 15 * 60 * 1000
|
||||
|
||||
if (!existsSync(ICON_DIR)) mkdirSync(ICON_DIR, { recursive: true })
|
||||
|
||||
const WMO = {
|
||||
0: 'Clear Sky', 1: 'Mainly Clear', 2: 'Partly Cloudy',
|
||||
3: 'Overcast', 45: 'Fog', 48: 'Icy Fog',
|
||||
51: 'Light Drizzle', 53: 'Drizzle', 55: 'Heavy Drizzle',
|
||||
61: 'Light Rain', 63: 'Rain', 65: 'Heavy Rain',
|
||||
71: 'Light Snow', 73: 'Snow', 75: 'Heavy Snow',
|
||||
77: 'Snow Grains', 80: 'Light Showers', 81: 'Showers',
|
||||
82: 'Heavy Showers', 85: 'Snow Showers', 86: 'Heavy Snow Showers',
|
||||
95: 'Thunderstorm', 96: 'Thunderstorm w/ Hail', 99: 'Thunderstorm w/ Heavy Hail',
|
||||
}
|
||||
|
||||
const OWM_ICONS = {
|
||||
0: '01d', 1: '01d', 2: '02d', 3: '04d',
|
||||
45: '50d', 48: '50d',
|
||||
51: '09d', 53: '09d', 55: '09d',
|
||||
61: '10d', 63: '10d', 65: '10d',
|
||||
71: '13d', 73: '13d', 75: '13d', 77: '13d',
|
||||
80: '09d', 81: '09d', 82: '09d',
|
||||
85: '13d', 86: '13d',
|
||||
95: '11d', 96: '11d', 99: '11d',
|
||||
}
|
||||
|
||||
async function cachedFetch(key, url) {
|
||||
const now = Date.now()
|
||||
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
CACHE[key] = { ts: now, data }
|
||||
return data
|
||||
}
|
||||
|
||||
function dewPoint(tempC, humidity) {
|
||||
const a = 17.27, b = 237.7
|
||||
const gamma = (a * tempC) / (b + tempC) + Math.log(humidity / 100)
|
||||
return Math.round((b * gamma) / (a - gamma) * 100) / 100
|
||||
}
|
||||
|
||||
function heatIndex(tempC, humidity) {
|
||||
const T = tempC * 9 / 5 + 32
|
||||
if (T < 80) return Math.round(tempC * 100) / 100
|
||||
const HI =
|
||||
-42.379 + 2.04901523 * T + 10.14333127 * humidity
|
||||
- 0.22475541 * T * humidity - 0.00683783 * T * T
|
||||
- 0.05481717 * humidity * humidity + 0.00122874 * T * T * humidity
|
||||
+ 0.00085282 * T * humidity * humidity - 0.00000199 * T * T * humidity * humidity
|
||||
return Math.round((HI - 32) * 5 / 9 * 100) / 100
|
||||
}
|
||||
|
||||
function vaporPressureDeficit(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3))
|
||||
return Math.round(svp * (1 - humidity / 100) * 1000) / 1000
|
||||
}
|
||||
|
||||
function absoluteHumidity(tempC, humidity) {
|
||||
const svp = 0.6108 * Math.exp((17.27 * tempC) / (tempC + 237.3))
|
||||
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000
|
||||
}
|
||||
|
||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DAILY_FIELDS = [
|
||||
'temperature_2m_max', 'temperature_2m_min',
|
||||
'precipitation_sum', 'precipitation_probability_max',
|
||||
'windspeed_10m_max', 'windgusts_10m_max', 'winddirection_10m_dominant',
|
||||
'weathercode', 'sunrise', 'sunset', 'relative_humidity_2m_mean',
|
||||
'uv_index_max', 'shortwave_radiation_sum',
|
||||
].join(',')
|
||||
|
||||
export async function dailyWeather(lat, lon, start, end) {
|
||||
const startStr = start.toISOString().slice(0, 10)
|
||||
const endStr = end.toISOString().slice(0, 10)
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
||||
`&daily=${DAILY_FIELDS}&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
||||
|
||||
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||
if (!data?.daily?.time) return []
|
||||
|
||||
const rows = data.daily.time.map((time, i) => {
|
||||
const code = data.daily.weathercode[i]
|
||||
const icon = OWM_ICONS[code] || '01d'
|
||||
const tempMax = data.daily.temperature_2m_max[i]
|
||||
const tempMin = data.daily.temperature_2m_min[i]
|
||||
const temp = tempMax
|
||||
const humidity = data.daily.relative_humidity_2m_mean[i]
|
||||
const precip = data.daily.precipitation_sum[i] ?? 0
|
||||
const precipChance = data.daily.precipitation_probability_max[i] ?? 0
|
||||
const windSpeed = data.daily.windspeed_10m_max[i] ?? 0
|
||||
const windGusts = data.daily.windgusts_10m_max[i] ?? 0
|
||||
|
||||
const utcMidnight = new Date(`${time}T00:00:00Z`)
|
||||
const offset = tzOffsetMinutes(utcMidnight)
|
||||
const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000)
|
||||
|
||||
const sunrise = data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null
|
||||
const sunset = data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null
|
||||
const daylight = sunrise && sunset
|
||||
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
|
||||
: null
|
||||
|
||||
const clouds = humidity / 100 * 0.8 // rough estimate — not provided by daily endpoint
|
||||
|
||||
const row = {
|
||||
time: localMidnight,
|
||||
daytime: true,
|
||||
label: WMO[code] || 'Unknown',
|
||||
icon,
|
||||
temperature: temp,
|
||||
temperature_max: tempMax,
|
||||
temperature_min: tempMin,
|
||||
humidity,
|
||||
humidity_abs: absoluteHumidity(temp, humidity),
|
||||
dew_point: dewPoint(temp, humidity),
|
||||
heat_index: heatIndex(temp, humidity),
|
||||
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
|
||||
pressure_hpa: null,
|
||||
pressure_slp: null,
|
||||
pressure_rate: null,
|
||||
storm_trend: 0,
|
||||
wind_speed: windSpeed,
|
||||
wind_gusts: windGusts,
|
||||
wind_direction: data.daily.winddirection_10m_dominant[i] ?? 0,
|
||||
clouds,
|
||||
raining: precipChance > 60 ? 'True' : 'False',
|
||||
precipitation: precip,
|
||||
precipitation_chance: precipChance,
|
||||
accumulation: precip,
|
||||
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
|
||||
uv_index: data.daily.uv_index_max[i] ?? 0,
|
||||
uv_index_max: data.daily.uv_index_max[i] ?? 0,
|
||||
uv_dose: 0,
|
||||
solar_wm2: data.daily.shortwave_radiation_sum[i] ?? 0,
|
||||
daily_light_integral: data.daily.shortwave_radiation_sum[i]
|
||||
? Math.round(data.daily.shortwave_radiation_sum[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
|
||||
: 0,
|
||||
lux: 0,
|
||||
visibility: 50,
|
||||
sunrise,
|
||||
sunset,
|
||||
daylight,
|
||||
lightning_rate: 0,
|
||||
lightning_distance: null,
|
||||
seismic_magnitude: null,
|
||||
air_quality: null,
|
||||
ground_distance: null,
|
||||
}
|
||||
|
||||
return { ...row, ...getWeatherCondition(row) }
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HOURLY_FIELDS = [
|
||||
'temperature_2m', 'relative_humidity_2m', 'dewpoint_2m',
|
||||
'apparent_temperature', 'precipitation_probability', 'precipitation',
|
||||
'weathercode', 'surface_pressure', 'cloudcover',
|
||||
'windspeed_10m', 'windgusts_10m', 'winddirection_10m',
|
||||
'uv_index', 'shortwave_radiation', 'vapour_pressure_deficit',
|
||||
'visibility',
|
||||
].join(',')
|
||||
|
||||
export async function hourlyWeather(lat, lon, start, end) {
|
||||
const startStr = start instanceof Date ? start.toISOString().slice(0, 10) : start.slice(0, 10)
|
||||
const endStr = end instanceof Date ? end.toISOString().slice(0, 10) : end.slice(0, 10)
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
|
||||
`&hourly=${HOURLY_FIELDS}&daily=sunrise,sunset&timezone=auto&start_date=${startStr}&end_date=${endStr}`
|
||||
|
||||
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
|
||||
if (!data?.hourly?.time) return []
|
||||
|
||||
const sunMap = {}
|
||||
if (data.daily?.time) {
|
||||
data.daily.time.forEach((date, i) => {
|
||||
sunMap[date] = {
|
||||
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
|
||||
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const rows = data.hourly.time.map((time, i) => {
|
||||
const dateKey = time.slice(0, 10)
|
||||
const sun = sunMap[dateKey] ?? {}
|
||||
const t = new Date(time + 'Z').getTime()
|
||||
const daytime = sun.sunrise && sun.sunset
|
||||
? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
|
||||
: false
|
||||
|
||||
const code = data.hourly.weathercode[i]
|
||||
const temp = data.hourly.temperature_2m[i]
|
||||
const humidity = data.hourly.relative_humidity_2m[i]
|
||||
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
|
||||
const precip = data.hourly.precipitation[i] ?? 0
|
||||
const precipChance = data.hourly.precipitation_probability[i] ?? 0
|
||||
const windSpeed = data.hourly.windspeed_10m[i] ?? 0
|
||||
const uv = data.hourly.uv_index[i] ?? 0
|
||||
|
||||
const sunrise = sun.sunrise ?? null
|
||||
const sunset = sun.sunset ?? null
|
||||
const daylight = sunrise && sunset
|
||||
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
|
||||
: null
|
||||
|
||||
const row = {
|
||||
time: new Date(time + 'Z'),
|
||||
daytime,
|
||||
label: WMO[code] || 'Unknown',
|
||||
icon: OWM_ICONS[code] || '01d',
|
||||
temperature: temp,
|
||||
temperature_max: temp,
|
||||
temperature_min: temp,
|
||||
humidity,
|
||||
humidity_abs: absoluteHumidity(temp, humidity),
|
||||
dew_point: data.hourly.dewpoint_2m[i] ?? dewPoint(temp, humidity),
|
||||
heat_index: heatIndex(temp, humidity),
|
||||
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
|
||||
pressure_hpa: data.hourly.surface_pressure[i] ?? null,
|
||||
pressure_slp: null,
|
||||
pressure_rate: null,
|
||||
storm_trend: 0,
|
||||
wind_speed: windSpeed,
|
||||
wind_gusts: data.hourly.windgusts_10m[i] ?? 0,
|
||||
wind_direction: data.hourly.winddirection_10m[i] ?? 0,
|
||||
clouds,
|
||||
raining: precipChance > 60 ? 'True' : 'False',
|
||||
precipitation: precip,
|
||||
precipitation_chance: precipChance,
|
||||
accumulation: precip,
|
||||
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
|
||||
uv_index: uv,
|
||||
uv_index_max: uv,
|
||||
uv_dose: 0,
|
||||
solar_wm2: data.hourly.shortwave_radiation[i] ?? 0,
|
||||
daily_light_integral: data.hourly.shortwave_radiation[i]
|
||||
? Math.round(data.hourly.shortwave_radiation[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
|
||||
: 0,
|
||||
lux: data.hourly.shortwave_radiation[i]
|
||||
? Math.round(data.hourly.shortwave_radiation[i] * 120)
|
||||
: 0,
|
||||
visibility: data.hourly.visibility[i] != null
|
||||
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
|
||||
: 50,
|
||||
sunrise,
|
||||
sunset,
|
||||
daylight,
|
||||
lightning_rate: 0,
|
||||
lightning_distance: null,
|
||||
seismic_magnitude: null,
|
||||
air_quality: null,
|
||||
ground_distance: null,
|
||||
}
|
||||
|
||||
return { ...row, ...getWeatherCondition(row) }
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
28
server/src/openweather.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import {existsSync, mkdirSync, writeFileSync} from 'fs';
|
||||
import * as path from 'node:path';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../data/icons');
|
||||
|
||||
export async function downloadIcon(icon, path) {
|
||||
if(!existsSync(DIR)) mkdirSync(dirname(path), {recursive: true});
|
||||
const res = await fetch(`https://openweathermap.org/img/wn/${icon}@2x.png`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
writeFileSync(path, buf);
|
||||
}
|
||||
|
||||
export async function fetchIcon(icon) {
|
||||
const iconPath = path.join(DIR, icon + '.png');
|
||||
if(!existsSync(DIR) || !existsSync(iconPath))
|
||||
await downloadIcon(icon, iconPath);
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
export function frostRisk(tempC, dewPointC, humidity) {
|
||||
if (tempC > 4) return 'None'
|
||||
if (tempC <= 0 && dewPointC <= 0) return 'High'
|
||||
if (tempC <= 2 && humidity > 85) return 'Moderate'
|
||||
if (tempC <= 4) return 'Low'
|
||||
return 'None'
|
||||
}
|
||||
118
server/src/sensors.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import {cfg} from './config.mjs';
|
||||
import {frostRisk} from './openweather.mjs';
|
||||
|
||||
async function query(c, q, start, end) {
|
||||
const url = new URL('/api/v1/query_range', c.DB_HOST);
|
||||
url.searchParams.set('query', q);
|
||||
url.searchParams.set('step', '60s');
|
||||
if (start) url.searchParams.set('start', start);
|
||||
if (end) url.searchParams.set('end', end);
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
return json.data?.result || [];
|
||||
}
|
||||
|
||||
async function queryInstant(c, q) {
|
||||
const url = new URL('/api/v1/query', c.DB_HOST);
|
||||
url.searchParams.set('query', q);
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
return json.data?.result || [];
|
||||
}
|
||||
|
||||
function metricToFields(results) {
|
||||
const out = {};
|
||||
for (const r of results) {
|
||||
if(r.metric.__name__.startsWith('system_')) continue;
|
||||
const val = r.value?.[1] ?? r.values?.at(-1)?.[1];
|
||||
if (val !== undefined) out[r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function queryCurrent() {
|
||||
const c = cfg()
|
||||
const results = await queryInstant(c, `{__name__!=""}`)
|
||||
const fields = metricToFields(results)
|
||||
const dp = fields.dew_point ?? null
|
||||
return {
|
||||
time: new Date(),
|
||||
...fields,
|
||||
frost_risk: frostRisk(fields.temperature ?? 99, dp ?? 99, fields.humidity ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryHourly(start, end) {
|
||||
const c = cfg();
|
||||
const s = Math.floor(start.getTime() / 1000);
|
||||
const e = Math.floor(end.getTime() / 1000);
|
||||
const byTime = {};
|
||||
|
||||
const results = await query(c, `avg_over_time({__name__!=""}[1h])`, s, e);
|
||||
results.forEach(r => {
|
||||
r.values.forEach(([ts, val]) => {
|
||||
if(r.metric.__name__.startsWith('system_')) return;
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00';
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||
});
|
||||
});
|
||||
return Object.values(byTime)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||
}
|
||||
|
||||
export async function queryDaily(start, end) {
|
||||
const c = cfg();
|
||||
const s = Math.floor(start.getTime() / 1000);
|
||||
const e = Math.floor(end.getTime() / 1000);
|
||||
const byTime = {};
|
||||
|
||||
const [means, mins, maxs] = await Promise.all([
|
||||
query(c, `avg_over_time({__name__!=""}[1d])`, s, e),
|
||||
query(c, `min_over_time({__name__!=""}[1d])`, s, e),
|
||||
query(c, `max_over_time({__name__!=""}[1d])`, s, e),
|
||||
]);
|
||||
|
||||
means.forEach(r => {
|
||||
r.values.forEach(([ts, val]) => {
|
||||
if(r.metric.__name__.startsWith('system_')) return;
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
|
||||
});
|
||||
});
|
||||
|
||||
mins.forEach(r => {
|
||||
if (r.metric.__name__ !== 'temperature') return;
|
||||
r.values.forEach(([ts, val]) => {
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time].env_temp_min_c = parseFloat(val);
|
||||
});
|
||||
});
|
||||
|
||||
maxs.forEach(r => {
|
||||
if (r.metric.__name__ !== 'temperature') return;
|
||||
r.values.forEach(([ts, val]) => {
|
||||
const time = new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!byTime[time]) byTime[time] = {time};
|
||||
byTime[time].env_temp_max_c = parseFloat(val);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(byTime)
|
||||
.sort((a, b) => a.time.localeCompare(b.time))
|
||||
.map(r => ({ ...r, time: new Date(r.time) }));
|
||||
}
|
||||
|
||||
export async function getCoords() {
|
||||
const c = cfg();
|
||||
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
|
||||
const fields = metricToFields(results);
|
||||
|
||||
if (fields.latitude && fields.longitude) {
|
||||
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
|
||||
}
|
||||
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
|
||||
}
|
||||
213
server/src/server.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
import express from 'express';
|
||||
import {resolve, dirname} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {cfg} from './config.mjs';
|
||||
import {queryCurrent, queryHourly, queryDaily, getCoords} from './sensors.mjs';
|
||||
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
|
||||
import {apiReference} from '@scalar/express-api-reference';
|
||||
import {spec} from './spec.mjs';
|
||||
import {existsSync} from 'fs';
|
||||
import {getAIS, getAISImage} from './ais.mjs';
|
||||
import {getADSBImage, getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs';
|
||||
import {fetchIcon} from './openweather.mjs';
|
||||
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
|
||||
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
|
||||
|
||||
// ── Uncaught error handlers ───────────────────────────────────────────────────
|
||||
|
||||
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('[unhandledRejection]', promise, '\nReason:', reason)
|
||||
})
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[uncaughtException]', err)
|
||||
})
|
||||
|
||||
const app = express();
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const CLIENT_DIST = resolve(DIR, '../public');
|
||||
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
});
|
||||
app.use(express.static(CLIENT_DIST));
|
||||
|
||||
function filterFields(obj, fields) {
|
||||
if(!fields) return obj;
|
||||
const keys = new Set(fields.split(',').map(f => f.trim()));
|
||||
return Object.fromEntries(Object.entries(obj).filter(([k]) => keys.has(k)));
|
||||
}
|
||||
|
||||
function filterArr(arr, fields) {
|
||||
if(!fields) return arr;
|
||||
return arr.map(row => filterFields(row, fields));
|
||||
}
|
||||
|
||||
// ── Current ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/current', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
|
||||
const space = getCelestialCurrent(coords.latitude, coords.longitude)
|
||||
const condition = getWeatherCondition(Object.assign(sensors, space))
|
||||
|
||||
const forecast = await getForecast();
|
||||
const merged = {
|
||||
...condition,
|
||||
...sensors,
|
||||
...(forecast?.summary ? {
|
||||
precipitation_chance: forecast.summary.precipitation_chance,
|
||||
temperature_max: forecast.summary.temperature_max,
|
||||
temperature_min: forecast.summary.temperature_min,
|
||||
uv_index_max: forecast.summary.uv_index,
|
||||
} : {}),
|
||||
...space,
|
||||
}
|
||||
|
||||
res.json(filterFields(merged, fields))
|
||||
}));
|
||||
|
||||
// ── Hourly ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/hourly', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
const now = new Date()
|
||||
now.setMinutes(0, 0, 0)
|
||||
const forecast24Limit = new Date(now.getTime() + 24 * 60 * 60_000)
|
||||
const start = req.query.start ? new Date(req.query.start) : now
|
||||
const end = req.query.end ? new Date(req.query.end) : forecast24Limit
|
||||
|
||||
const [history, forecast24, meteo] = await Promise.all([
|
||||
start < now
|
||||
? queryHourly(start, now)
|
||||
: Promise.resolve([]),
|
||||
now < forecast24Limit && end > now
|
||||
? getForecast().then(f => f.slots.filter(h => new Date(h.time) >= now && new Date(h.time) <= Math.min(end, forecast24Limit)))
|
||||
: Promise.resolve([]),
|
||||
end > forecast24Limit
|
||||
? hourlyWeather(coords.latitude, coords.longitude, forecast24Limit, end)
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo])
|
||||
res.json(filterArr(combined, fields))
|
||||
}))
|
||||
|
||||
// ── Daily ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/daily', asyncHandler(async (req, res) => {
|
||||
const { fields } = req.query
|
||||
const coords = await getCoords()
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now)
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const todayEnd = new Date(todayStart)
|
||||
todayEnd.setDate(todayStart.getDate() + 1)
|
||||
const start = req.query.start ? new Date(req.query.start) : todayStart
|
||||
const end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
|
||||
|
||||
const [history, current, future] = await Promise.all([
|
||||
start < todayStart
|
||||
? queryDaily(start, todayStart)
|
||||
: Promise.resolve([]),
|
||||
start <= todayStart && end >= todayStart
|
||||
? queryCurrent().then(resp => [resp])
|
||||
: Promise.resolve([]),
|
||||
end > todayEnd
|
||||
? dailyWeather(coords.latitude, coords.longitude, todayEnd, end)
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...current, ...future])
|
||||
res.json(filterArr(daily, fields))
|
||||
}))
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/position', asyncHandler(async (req, res) => {
|
||||
const {fields} = req.query;
|
||||
const data = await getCoords();
|
||||
res.json(filterFields(data, fields));
|
||||
}));
|
||||
|
||||
// ── Icon ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
|
||||
const { icon } = req.params;
|
||||
if(!icon) return next(); // 404 fallback can handle missing icons
|
||||
res.contentType('image/png');
|
||||
res.sendFile(await fetchIcon(icon));
|
||||
}));
|
||||
|
||||
// ── Space ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
|
||||
// app.get('/api/space', async (req, res) => {
|
||||
// const {fields} = req.query;
|
||||
// res.json(filterFields(await getSpaceWeather(), fields));
|
||||
// });
|
||||
|
||||
// ── ADSB/AIS Proxy ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
|
||||
app.get('/api/adsb-image/:icao', asyncHandler(async (req, res) => {
|
||||
const blob = await getADSBImage(req.params.icao);
|
||||
if (!blob) return res.status(404).send('No image found');
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
res.contentType(blob.type).send(buffer);
|
||||
}));
|
||||
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getADSBHistory(req.params.icao))));
|
||||
app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS())));
|
||||
app.get('/api/ais-image/:mmsi', asyncHandler(async (req, res) => {
|
||||
const blob = await getAISImage(req.params.mmsi);
|
||||
if (!blob) return res.status(404).send('No image found');
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
res.contentType(blob.type).send(buffer);
|
||||
}));
|
||||
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
|
||||
|
||||
// ── DOCS ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use('/docs', apiReference({spec: {url: '/openapi.json'}, theme: 'default'}));
|
||||
app.get('/openapi.json',asyncHandler(async (req, res) => res.json(spec)));
|
||||
app.get('/openapi.yaml',asyncHandler(async (req, res) => {
|
||||
const {stringify} = await import('yaml');
|
||||
res.setHeader('Content-Type', 'text/yaml');
|
||||
res.send(stringify(spec));
|
||||
}));
|
||||
|
||||
// ── Website ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
const index = resolve(CLIENT_DIST, 'index.html');
|
||||
if(existsSync(index)) res.sendFile(index);
|
||||
else res.status(404).send('Client not built yet — run npm run build in /client');
|
||||
});
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.status(404).json({ error: `Not found: ${req.method} ${req.path}` })
|
||||
})
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(`[ERROR] ${req.method} ${req.path}\n${err.stack}`)
|
||||
res.status(500).json({ error: err.message, stack: err.stack })
|
||||
})
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const c = cfg();
|
||||
|
||||
setTimeout(getForecast, 1)
|
||||
setInterval(getForecast, forecastTTL)
|
||||
await initAircraftDb();
|
||||
|
||||
app.listen(c.PORT, () => {
|
||||
console.log(`⛅ Weather API — http://localhost:${c.PORT}`)
|
||||
});
|
||||
161
server/src/spec.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
export const spec = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Weather Station',
|
||||
version: '1.0.0',
|
||||
description: 'Hyperlocal weather station — sensor data, forecasts, celestial & space weather',
|
||||
},
|
||||
servers: [{ url: 'http://localhost:3000' }],
|
||||
paths: {
|
||||
'/api/position': {
|
||||
get: {
|
||||
summary: 'Station position',
|
||||
description: 'Latest GPS position and altitude',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Last known position',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Position' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/current': {
|
||||
get: {
|
||||
summary: 'Current conditions',
|
||||
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
|
||||
parameters: [
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' }
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Current conditions',
|
||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/hourly': {
|
||||
get: {
|
||||
summary: 'Hourly data',
|
||||
description: 'Hourly aggregated sensor data merged with Open-Meteo hourly forecast, celestial positions and space weather. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of hourly rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/daily': {
|
||||
get: {
|
||||
summary: 'Daily data',
|
||||
description: 'Daily aggregated sensor data (mean/min/max) merged with Open-Meteo daily forecast and celestial events. Defaults to today.',
|
||||
parameters: [
|
||||
{ name: 'start', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 start timestamp. Defaults to today 00:00.' },
|
||||
{ name: 'end', in: 'query', required: false, schema: { type: 'string', format: 'date-time' },
|
||||
description: 'ISO8601 end timestamp. Defaults to now.' },
|
||||
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' },
|
||||
description: 'Comma-separated list of fields to return. Omit for all.' },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Array of daily rows',
|
||||
content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/DataRow' } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Position: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||
}
|
||||
},
|
||||
DataRow: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
time: { type: 'string', format: 'date-time', description: 'ISO8601 timestamp' },
|
||||
// Environment
|
||||
temperature: { type: 'number', description: 'Temperature (°C)' },
|
||||
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
|
||||
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
|
||||
humidity: { type: 'number', description: 'Relative humidity (%)' },
|
||||
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
|
||||
dew_point: { type: 'number', description: 'Dew point (°C)' },
|
||||
heat_index: { type: 'number', description: 'Feels like / heat index (°C)' },
|
||||
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
|
||||
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
|
||||
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
|
||||
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
|
||||
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
|
||||
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' },
|
||||
air_quality: { type: 'number', description: 'Air quality index score (0–100)' },
|
||||
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
|
||||
// Light & Solar
|
||||
lux: { type: 'number', description: 'Illuminance (lux)' },
|
||||
uv_index: { type: 'number', description: 'UV index' },
|
||||
uv_index_max: { type: 'number', description: 'Daily max UV index' },
|
||||
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
|
||||
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
|
||||
daily_light_integral: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
|
||||
clouds: { type: 'number', description: 'Cloud cover fraction (0–1)' },
|
||||
visibility: { type: 'number', description: 'Estimated visibility (km)' },
|
||||
daylight: { type: 'number', description: 'Hours of daylight' },
|
||||
// Weather
|
||||
label: { type: 'string', description: 'Human readable weather label' },
|
||||
icon: { type: 'string', description: 'OWM icon code e.g. 01d' },
|
||||
raining: { type: 'string', enum: ['True','False'] },
|
||||
precipitation: { type: 'number', description: 'Precipitation (mm)' },
|
||||
precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' },
|
||||
accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' },
|
||||
storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' },
|
||||
// Wind
|
||||
wind_speed: { type: 'number', description: 'Wind speed (km/h)' },
|
||||
wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' },
|
||||
wind_direction: { type: 'number', description: 'Wind direction (°)' },
|
||||
// Lightning
|
||||
lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' },
|
||||
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
|
||||
// Sun
|
||||
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
|
||||
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
|
||||
sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' },
|
||||
sunset: { type: 'string', format: 'date-time', description: 'Sunset time' },
|
||||
daytime: { type: 'boolean', description: 'True if between sunrise and sunset' },
|
||||
// Moon
|
||||
moon_phase: { type: 'string', description: 'Moon phase name' },
|
||||
moon_illumination: { type: 'number', description: 'Moon illumination (%)' },
|
||||
moon_elevation: { type: 'number', description: 'Moon elevation (°)' },
|
||||
moon_azimuth: { type: 'number', description: 'Moon azimuth (°)' },
|
||||
moonrise: { type: 'string', format: 'date-time', description: 'Moonrise time' },
|
||||
moonset: { type: 'string', format: 'date-time', description: 'Moonset time' },
|
||||
moon_new: { type: 'string', format: 'date-time', description: 'Next new moon' },
|
||||
moon_full: { type: 'string', format: 'date-time', description: 'Next full moon' },
|
||||
// Seismic
|
||||
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g)' },
|
||||
// Ground
|
||||
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
|
||||
// GPS
|
||||
latitude: { type: 'number', description: 'Latitude (°)' },
|
||||
longitude: { type: 'number', description: 'Longitude (°)' },
|
||||
altitude: { type: 'number', description: 'Altitude (m)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
setup.sh
@@ -3,22 +3,46 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
USER_NAME="$(whoami)"
|
||||
VM_DIR="/opt/victoriametrics"
|
||||
|
||||
echo "🔧 Setting up Weather Station..."
|
||||
|
||||
# ── InfluxDB ──────────────────────────────────────────────────────────────────
|
||||
# ── VictoriaMetrics ───────────────────────────────────────────────────────────
|
||||
|
||||
if ! command -v influxd &> /dev/null; then
|
||||
echo "📦 Installing InfluxDB..."
|
||||
curl https://repos.influxdata.com/influxdata-archive.key | gpg --dearmor | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null
|
||||
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
|
||||
sudo apt update && sudo apt install -y influxdb2
|
||||
sudo systemctl enable --now influxdb
|
||||
if ! systemctl is-enabled --quiet victoriametrics 2>/dev/null; then
|
||||
echo "📦 Installing VictoriaMetrics..."
|
||||
sudo mkdir -p $VM_DIR/data
|
||||
cd $VM_DIR
|
||||
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 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
|
||||
echo "✅ InfluxDB already installed, skipping..."
|
||||
echo "✅ VictoriaMetrics already installed, skipping..."
|
||||
fi
|
||||
|
||||
# ── Node.js ───────────────────────────────────────────────────────────────────
|
||||
# ── Server / Node.js ──────────────────────────────────────────────────────────
|
||||
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "📦 Installing Node.js..."
|
||||
@@ -28,7 +52,47 @@ else
|
||||
echo "✅ Node.js already installed, skipping..."
|
||||
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
|
||||
echo "🐍 Setting up Python environment..."
|
||||
@@ -40,14 +104,12 @@ else
|
||||
echo "✅ Python environment already set up, skipping..."
|
||||
fi
|
||||
|
||||
# ── Systemd service ───────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
[Unit]
|
||||
Description=Weather Station Sensors
|
||||
After=network.target
|
||||
After=network.target victoriametrics.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=$SCRIPT_DIR/sensors
|
||||
@@ -62,10 +124,9 @@ EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now weather-sensors
|
||||
else
|
||||
echo "✅ Systemd service already installed, skipping..."
|
||||
echo "✅ Sensor service already installed, skipping..."
|
||||
fi
|
||||
|
||||
echo "✅ All done!"
|
||||
echo " InfluxDB UI: http://localhost:8086"
|
||||
echo " VictoriaMetrics UI: http://localhost:8428"
|
||||
echo " Sensor logs: journalctl -fu weather-sensors"
|
||||
|
||||
|
||||