Compare commits

..
130 Commits
Author SHA1 Message Date
assistant cf693e6f08 Fix military aircraft classification regex to reduce false positives (issue-1) 2026-09-16 23:36:58 -04:00
assistant 4520447156 Fix military aircraft classification regex to reduce false positives (issue-1) 2026-09-16 23:27:47 -04:00
ztimson 534dc6d2df Better military plane ID 2026-09-15 15:42:02 -04:00
ztimson 4c7a7fd1f5 Better AutoMode 2026-09-15 11:47:29 -04:00
ztimson 28478251cd Fixed sonde freq 2026-09-14 08:49:27 -04:00
ztimson 1cf7545c93 Fixed sonde climb rate and freq 2026-09-14 08:46:54 -04:00
ztimson 6e21a8626c Fixed sonde climb rate and TTL 2026-09-14 08:43:46 -04:00
ztimson 9f65318aee Reactive sonde icon 2026-09-14 01:31:00 -04:00
ztimson 5ac3871da0 Reactive sonde icon 2026-09-14 01:15:02 -04:00
ztimson eaef6e8066 Image lookup fix? 2026-09-14 00:48:24 -04:00
ztimson a0c15f1631 Better aircraft categorizing 2026-09-14 00:20:39 -04:00
ztimson d57faa486c More military fixes 2026-09-13 23:59:50 -04:00
ztimson 3c6cc71346 Fix military classification 2026-09-13 23:41:47 -04:00
ztimson a255638f73 better military icao detection, airlines and stock image indicator 2026-09-13 23:33:47 -04:00
ztimson 746ec877da better categorizing? 2026-09-13 14:45:43 -04:00
ztimson 8eb3e49308 Finished Sonde support 2026-09-13 14:04:03 -04:00
ztimson 1d1e7351db Radiosonde + sat track updates 2026-09-13 13:16:40 -04:00
ztimson 0e152b4dc7 Test radiosonde.mjs 2026-09-13 11:49:50 -04:00
ztimson fca0e942e7 Radio sonde 2026-09-13 09:51:27 -04:00
ztimson 6c4310753f Radio sonde on map 2026-09-13 09:34:41 -04:00
ztimson 0ebf997924 Radio sonde on map 2026-09-13 09:32:49 -04:00
ztimson 2b299753ca Radio sonde on map 2026-09-13 09:29:19 -04:00
ztimson 83c48bf3e7 Radio sonde + auto mode 2026-09-13 09:12:06 -04:00
ztimson a99cbf3354 Save ais / adsb images to disk 2026-09-12 12:04:57 -04:00
ztimson a60f605e6b Fixed icao images 2026-09-12 11:11:06 -04:00
ztimson 6bcc5bec19 Hooked up auroras 2026-09-11 22:08:14 -04:00
ztimson fc306f277a Reverted tinygs mqtt 2026-09-11 12:17:55 -04:00
ztimson 396a1f4cab WIP: MQTT 2026-09-11 11:28:34 -04:00
ztimson b86e4613bf Use default non tls port for mqtt 2026-09-11 11:22:56 -04:00
ztimson a0cfe64188 updated mqtt address to match tinygs defaults 2026-09-11 11:12:14 -04:00
ztimson 033e34a971 new mqtt server for tinygs 2026-09-11 11:08:38 -04:00
ztimson 688552d0d3 Fixed plane trace history 2026-09-11 10:36:33 -04:00
ztimson 7cc60afbae Use frequency in sat id 2026-09-11 07:34:49 -04:00
ztimson 1db910cb4a Sat updates 2026-09-11 00:47:22 -04:00
ztimson 2b31bdde8f Sat updates 2026-09-11 00:40:48 -04:00
ztimson 79acad8c07 Sats 2026-09-10 22:52:44 -04:00
ztimson 2c25251864 Merge remote-tracking branch 'origin/master' 2026-09-04 08:57:17 -04:00
ztimson 9e1ef69b16 Fixed boat direction 2026-09-04 08:57:13 -04:00
ztimson 7ed3a43a53 added update script and better plane icon clicking 2026-07-06 12:50:39 -04:00
ztimson f3ca75decb Make icons easier to click 2026-07-06 12:42:19 -04:00
ztimson 88e6cc26f5 Loading animation 2026-07-06 12:35:46 -04:00
ztimson f5cfd492f3 Popup fixes 2026-07-06 12:31:27 -04:00
ztimson 1494b43ed5 ADSB fixes 2026-07-06 12:17:09 -04:00
ztimson 8f5e8edd2e plane spotter debugging 2026-07-05 10:58:13 -04:00
ztimson 54e86e550c plane spotter debugging 2026-07-05 10:57:41 -04:00
ztimson 09a08d94aa Fixed image fetching 2026-07-05 10:54:57 -04:00
ztimson 11012afbe9 cleanup 2026-07-05 02:32:55 -04:00
ztimson d4cdb15737 Fixed ship name on popup 2026-07-04 22:48:46 -04:00
ztimson b62f7da629 Updated image handling for adsb/ais 2026-07-04 22:40:43 -04:00
ztimson 43848c4b35 AIS images 2026-07-04 22:36:19 -04:00
ztimson a1c714847d AIS images 2026-07-04 22:32:17 -04:00
ztimson 1038b06a46 AIS images 2026-07-04 22:30:31 -04:00
ztimson d5697c4e20 AIS images 2026-07-04 22:28:05 -04:00
ztimson 7e8ea62f64 AIS images 2026-07-04 22:23:16 -04:00
ztimson be40d845bd ADSB images 2026-07-04 22:09:13 -04:00
ztimson 54c53a5822 ADSB images 2026-07-04 22:04:12 -04:00
ztimson 0a722edcc6 ADSB offline fixes? 2026-07-04 00:38:58 -04:00
ztimson 3e44dbb8eb ADSB/AIS image proxy 2026-07-03 18:54:40 -04:00
ztimson fec6ee8a48 ADSB/AIS image proxy 2026-07-03 18:52:34 -04:00
ztimson 1451145408 ADSB/AIS image proxy 2026-07-03 18:48:20 -04:00
ztimson 4114a0adad Added some padding around the gages 2026-07-03 15:13:00 -04:00
ztimson 7edd0ab8c4 plane image fallback + seismic calibrations 2026-07-03 14:21:00 -04:00
ztimson 5bd2e786c2 Fixed seismic sensors 2026-07-03 13:37:09 -04:00
ztimson bbcc62af7d Updated adsb + ais popups to include images 2026-07-03 13:15:19 -04:00
ztimson 91205d8050 Fixed ship guage 2026-07-01 19:36:14 -04:00
ztimson ce86fdfcc8 Cache ADSB/AIS calls 2026-06-27 19:39:14 -04:00
ztimson 3838a73e75 Fixed import 2026-06-27 18:37:35 -04:00
ztimson 76bd8b30c7 Added frost risk 2026-06-27 18:32:03 -04:00
ztimson 30c866b599 Added frost risk 2026-06-27 18:31:44 -04:00
ztimson 9471e904ce Updated meta 2026-06-27 18:08:00 -04:00
ztimson 8740a1cd69 Fix client build 2026-06-27 18:00:23 -04:00
ztimson 773d89c378 Fix client build 2026-06-27 17:58:22 -04:00
ztimson d398cd3a04 Supress adsb errors 2026-06-27 17:55:51 -04:00
ztimson 3d9a8f82ce Supress adsb errors 2026-06-27 17:54:27 -04:00
ztimson 96a86c4d12 OpenMeteo fixes 2026-06-27 17:52:32 -04:00
ztimson 835856e74c Popup fixes 2026-06-27 17:47:38 -04:00
ztimson 34c52ffb9f ADSB backfill fix? 2026-06-27 17:28:53 -04:00
ztimson 946ccd67e1 ADSB backfill fix? 2026-06-27 17:16:59 -04:00
ztimson 2ccacf563c ADSB backfill fix? 2026-06-27 17:11:00 -04:00
ztimson 3750ece535 ADSB backfill fix? 2026-06-27 17:09:39 -04:00
ztimson 93dce28cca ADSB backfill fix? 2026-06-27 17:05:59 -04:00
ztimson 73f7908aab ADSB backfill fix? 2026-06-27 17:03:24 -04:00
ztimson effdd81114 ADSB backfill fix? 2026-06-27 17:03:01 -04:00
ztimson 6bc4d920f8 ADSB backfill fix? 2026-06-27 16:58:46 -04:00
ztimson d9fbe0568d Fix icon 2026-06-27 16:47:05 -04:00
ztimson 404f3971c4 Fix aircraft backfill 2026-06-27 16:44:16 -04:00
ztimson 570ed1e749 Fix aircraft backfill 2026-06-27 16:32:27 -04:00
ztimson 8364918a0c Fresh Aircraft DB 2026-06-27 16:29:09 -04:00
ztimson 85909e550b Fix aircraft db import 2026-06-27 16:26:34 -04:00
ztimson 221a200e79 New aircraft DB 2026-06-27 16:23:26 -04:00
ztimson bb11b92ec9 ADSB updates 2026-06-27 16:20:15 -04:00
ztimson 579eed746e ADSB debugging 2026-06-27 16:14:06 -04:00
ztimson a4f13d87ab ADSB & Seismic fixes 2026-06-27 16:02:55 -04:00
ztimson 85b1d66c02 moved seismic graph to the top 2026-06-27 15:49:40 -04:00
ztimson d34f3cfa0e better seismic drawings, and adsb enrichment 2026-06-27 15:20:21 -04:00
ztimson f3356ff05c Fixed icon 2026-06-27 13:47:38 -04:00
ztimson c18a4189af Catch ADSB timeouts 2026-06-27 13:44:33 -04:00
ztimson c5477d05ad AIS fixes + daily 2026-06-27 13:38:52 -04:00
ztimson bea55ac448 Fixing hourly 2026-06-27 01:33:16 -04:00
ztimson 765335369c Fixing hourly 2026-06-27 01:28:45 -04:00
ztimson 3944daa3ed Fixing hourly 2026-06-27 01:26:00 -04:00
ztimson e767a08bb5 Fixing hourly 2026-06-27 01:19:43 -04:00
ztimson e4c3da05be backfill all data points 2026-06-27 01:08:48 -04:00
ztimson 44e648ecef backfill all data points 2026-06-27 00:55:17 -04:00
ztimson e4042f06d6 backfill all data points 2026-06-27 00:49:17 -04:00
ztimson 943a6ee085 Fixing 24 hour forecast 2026-06-27 00:30:11 -04:00
ztimson 225002046b Fixing 24 hour forecast 2026-06-27 00:27:42 -04:00
ztimson 9e7afbd98c Fixing 24 hour forecast 2026-06-27 00:26:02 -04:00
ztimson 62d2c28039 Fix data names 2026-06-27 00:17:33 -04:00
ztimson 74c5e48d8a Switched from influx to victoria metrics (more light weight for pi) 2026-06-27 00:14:10 -04:00
ztimson ce332be419 Switched from influx to victoria metrics (more light weight for pi) 2026-06-27 00:04:22 -04:00
ztimson 36eda62e7f Switched from influx to victoria metrics (more light weight for pi) 2026-06-26 23:59:56 -04:00
ztimson 6eefd1b86b Switched from influx to victoria metrics (more light weight for pi) 2026-06-26 23:58:32 -04:00
ztimson 0c0ebfd20b Switched from influx to victoria metrics (more light weight for pi) 2026-06-26 22:40:27 -04:00
ztimson 74ddb37446 Switched from influx to victoria metrics (more light weight for pi) 2026-06-26 21:52:27 -04:00
ztimson 73f1453daf Icon caching 2026-06-26 20:52:54 -04:00
ztimson aeffa30417 updated graph 2026-06-26 01:42:16 -04:00
ztimson 74a8018f01 updated graph 2026-06-26 01:41:27 -04:00
ztimson efd08cbabe Graph fixes 2026-06-26 01:31:19 -04:00
ztimson 738f13e745 Graph fixes 2026-06-26 01:13:52 -04:00
ztimson 216420ad80 Fixed daily and graphs 2026-06-26 01:07:25 -04:00
ztimson 5947b61b8f hourly endpoint fix? 2026-06-26 00:54:29 -04:00
ztimson 06be7c0b4f hourly endpoint fix? 2026-06-26 00:32:39 -04:00
ztimson 70701483f5 hourly endpoint fix? 2026-06-26 00:29:30 -04:00
ztimson 1ee386949c Cleanup 2026-06-26 00:05:28 -04:00
ztimson 9b7b7f49a7 Cleanup adsb history after 1 hour 2026-06-25 21:51:16 -04:00
ztimson 80b9b1bfcc Removed old adsb shapes call 2026-06-25 21:46:13 -04:00
ztimson b87bcbabb7 Build aircraft shapes 2026-06-25 21:43:56 -04:00
ztimson f6f8a252dc Merge branch 'master' of git.zakscode.com:ztimson/weather-station
# Conflicts:
#	server/src/ais.mjs
2026-06-25 20:52:51 -04:00
ztimson a73c5ff95b AIS fix 2026-06-25 20:52:12 -04:00
72 changed files with 8726 additions and 2905 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
INFLUX_TOKEN=
EMAIL=
ALTITUDE=
LATITUDE=
LONGITUDE=
View File
+20 -2
View File
@@ -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>
+78 -23
View File
@@ -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"
}
}
+3 -2
View File
@@ -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
View File
@@ -0,0 +1,370 @@
{
"version": 8,
"name": "Smooth Dark",
"sources": {
"osm": {
"type": "vector",
"tiles": ["https://tiles.zakscode.com/data/osm/{z}/{x}/{y}.pbf"],
"maxzoom": 14
}
},
"glyphs": "http://localhost:8080/fonts/{fontstack}/{range}.pbf",
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#2b2b2e" }
},
{
"id": "water",
"type": "fill",
"source": "osm",
"source-layer": "water",
"paint": { "fill-color": "#1e2832" }
},
{
"id": "landcover-grass",
"type": "fill",
"source": "osm",
"source-layer": "landcover",
"filter": ["==", "class", "grass"],
"paint": { "fill-color": "#2a3028", "fill-opacity": 0.6 }
},
{
"id": "landcover-wood",
"type": "fill",
"source": "osm",
"source-layer": "landcover",
"filter": ["==", "class", "wood"],
"paint": { "fill-color": "#262e24", "fill-opacity": 0.5 }
},
{
"id": "landuse-park",
"type": "fill",
"source": "osm",
"source-layer": "landuse",
"filter": ["==", "class", "park"],
"paint": { "fill-color": "#283028", "fill-opacity": 0.7 }
},
{
"id": "landuse-cemetery",
"type": "fill",
"source": "osm",
"source-layer": "landuse",
"filter": ["==", "class", "cemetery"],
"paint": { "fill-color": "#2c2e2a", "fill-opacity": 0.7 }
},
{
"id": "landuse-hospital",
"type": "fill",
"source": "osm",
"source-layer": "landuse",
"filter": ["==", "class", "hospital"],
"paint": { "fill-color": "#322828", "fill-opacity": 0.6 }
},
{
"id": "landuse-school",
"type": "fill",
"source": "osm",
"source-layer": "landuse",
"filter": ["==", "class", "school"],
"paint": { "fill-color": "#302c28", "fill-opacity": 0.6 }
},
{
"id": "building",
"type": "fill",
"source": "osm",
"source-layer": "building",
"minzoom": 13,
"paint": {
"fill-color": "#333336",
"fill-opacity": { "stops": [[13, 0.3], [16, 0.6]] }
}
},
{
"id": "boundary-country",
"type": "line",
"source": "osm",
"source-layer": "boundary",
"filter": ["==", "admin_level", 2],
"paint": {
"line-color": "#4a4448",
"line-width": { "stops": [[2, 0.5], [6, 1.5], [10, 2.5]] }
}
},
{
"id": "boundary-state",
"type": "line",
"source": "osm",
"source-layer": "boundary",
"filter": ["==", "admin_level", 4],
"minzoom": 4,
"paint": {
"line-color": "#3e3a3c",
"line-width": { "stops": [[4, 0.3], [8, 1]] },
"line-dasharray": [3, 2]
}
},
{
"id": "waterway",
"type": "line",
"source": "osm",
"source-layer": "waterway",
"paint": {
"line-color": "#1e2832",
"line-width": { "stops": [[8, 0.5], [14, 2]] }
}
},
{
"id": "tunnel",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["==", "brunnel", "tunnel"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
"paint": {
"line-color": "#2e2e30",
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] },
"line-dasharray": [3, 3]
}
},
{
"id": "road-motorway",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["==", "class", "motorway"], ["!=", "brunnel", "tunnel"]],
"paint": {
"line-color": "#4a4240",
"line-width": { "stops": [[5, 0.5], [10, 2], [14, 5], [18, 12]] }
}
},
{
"id": "road-trunk",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["==", "class", "trunk"], ["!=", "brunnel", "tunnel"]],
"paint": {
"line-color": "#443e3c",
"line-width": { "stops": [[6, 0.4], [10, 1.5], [14, 4], [18, 10]] }
}
},
{
"id": "road-primary",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["==", "class", "primary"], ["!=", "brunnel", "tunnel"]],
"paint": {
"line-color": "#3e3a38",
"line-width": { "stops": [[7, 0.3], [10, 1], [14, 3.5], [18, 9]] }
}
},
{
"id": "road-secondary",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["==", "class", "secondary"], ["!=", "brunnel", "tunnel"]],
"paint": {
"line-color": "#383636",
"line-width": { "stops": [[8, 0.3], [12, 1], [14, 3], [18, 7]] }
}
},
{
"id": "road-tertiary",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["==", "class", "tertiary"], ["!=", "brunnel", "tunnel"]],
"paint": {
"line-color": "#353334",
"line-width": { "stops": [[9, 0.2], [14, 2], [18, 5]] }
}
},
{
"id": "road-minor",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["in", "class", "minor", "service"], ["!=", "brunnel", "tunnel"]],
"minzoom": 12,
"paint": {
"line-color": "#333234",
"line-width": { "stops": [[12, 0.3], [16, 2], [18, 4]] }
}
},
{
"id": "road-path",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["==", "class", "path"],
"minzoom": 14,
"paint": {
"line-color": "#3a3638",
"line-width": 1,
"line-dasharray": [4, 2]
}
},
{
"id": "rail",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["==", "class", "rail"],
"minzoom": 10,
"paint": {
"line-color": "#3c3a3e",
"line-width": { "stops": [[10, 0.5], [14, 1.5]] },
"line-dasharray": [5, 5]
}
},
{
"id": "bridge",
"type": "line",
"source": "osm",
"source-layer": "transportation",
"filter": ["all", ["==", "brunnel", "bridge"], ["in", "class", "motorway", "trunk", "primary", "secondary", "tertiary"]],
"paint": {
"line-color": "#403c3a",
"line-width": { "stops": [[8, 0.5], [14, 4], [18, 10]] }
}
},
{
"id": "aero-runway",
"type": "line",
"source": "osm",
"source-layer": "aeroway",
"filter": ["==", "class", "runway"],
"minzoom": 11,
"paint": {
"line-color": "#3a383c",
"line-width": { "stops": [[11, 2], [16, 12]] }
}
},
{
"id": "aero-taxiway",
"type": "line",
"source": "osm",
"source-layer": "aeroway",
"filter": ["==", "class", "taxiway"],
"minzoom": 12,
"paint": {
"line-color": "#3a383c",
"line-width": { "stops": [[12, 0.5], [16, 4]] }
}
},
{
"id": "label-water",
"type": "symbol",
"source": "osm",
"source-layer": "water_name",
"layout": {
"text-field": "{name}",
"text-font": ["Noto Sans Regular"],
"text-size": { "stops": [[3, 10], [8, 14]] },
"text-letter-spacing": 0.1
},
"paint": {
"text-color": "#4a6068",
"text-halo-color": "#2b2b2e",
"text-halo-width": 1.5
}
},
{
"id": "label-road",
"type": "symbol",
"source": "osm",
"source-layer": "transportation_name",
"minzoom": 13,
"layout": {
"text-field": "{name}",
"text-font": ["Noto Sans Regular"],
"text-size": { "stops": [[13, 10], [16, 13]] },
"symbol-placement": "line",
"text-rotation-alignment": "map"
},
"paint": {
"text-color": "#6a6268",
"text-halo-color": "#2b2b2e",
"text-halo-width": 1.5
}
},
{
"id": "label-place-city",
"type": "symbol",
"source": "osm",
"source-layer": "place",
"filter": ["==", "class", "city"],
"layout": {
"text-field": "{name}",
"text-font": ["Noto Sans Regular"],
"text-size": { "stops": [[4, 11], [8, 16], [12, 20]] },
"text-anchor": "center"
},
"paint": {
"text-color": "#a0969a",
"text-halo-color": "#2b2b2e",
"text-halo-width": 2
}
},
{
"id": "label-place-town",
"type": "symbol",
"source": "osm",
"source-layer": "place",
"filter": ["==", "class", "town"],
"minzoom": 7,
"layout": {
"text-field": "{name}",
"text-font": ["Noto Sans Regular"],
"text-size": { "stops": [[7, 10], [12, 15]] },
"text-anchor": "center"
},
"paint": {
"text-color": "#8a8088",
"text-halo-color": "#2b2b2e",
"text-halo-width": 1.5
}
},
{
"id": "label-place-village",
"type": "symbol",
"source": "osm",
"source-layer": "place",
"filter": ["==", "class", "village"],
"minzoom": 10,
"layout": {
"text-field": "{name}",
"text-font": ["Noto Sans Regular"],
"text-size": { "stops": [[10, 9], [14, 13]] },
"text-anchor": "center"
},
"paint": {
"text-color": "#787078",
"text-halo-color": "#2b2b2e",
"text-halo-width": 1.5
}
},
{
"id": "label-country",
"type": "symbol",
"source": "osm",
"source-layer": "place",
"filter": ["==", "class", "country"],
"layout": {
"text-field": "{name}",
"text-font": ["Noto Sans Regular"],
"text-size": { "stops": [[2, 10], [5, 16]] },
"text-transform": "uppercase",
"text-letter-spacing": 0.15
},
"paint": {
"text-color": "#908690",
"text-halo-color": "#2b2b2e",
"text-halo-width": 2
}
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+370
View 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
View 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"]
}
+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Allow: /
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+1 -1
View File
@@ -17,7 +17,7 @@ function toggleDark() { dark.value = !dark.value }
position: fixed;
top: 16px;
right: 16px;
z-index: 500;
z-index: 50;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 99px;
-1
View File
@@ -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 -1
View File
@@ -6,7 +6,7 @@
:root {
--bg: #ffffff;
--surface: #d0dae1;
--surface: #e5e9ec;
--border: #e2e8f0;
--text: #0f172a;
--text-muted: #64748b;
+2 -4
View File
@@ -1,10 +1,8 @@
<script setup lang="ts">
import {ref, onMounted} from 'vue';
import {api} from '../services/api';
import {useWeather} from '../services/api';
import MetricRow from './MetricRow.vue';
const d = ref<any>(null);
onMounted(async () => d.value = await api.current());
const {current: d} = useWeather();
</script>
<template>
+368
View File
@@ -0,0 +1,368 @@
<script setup lang="ts">
import {BASE} from '@/services/api.ts';
import { ref, computed, onMounted, onUnmounted } from 'vue'
import AircraftAltitude from '@/components/Altitude.vue'
const BASE_SPEED_UNITS = {
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 1.852) },
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 1.15078) },
}
const BASE_ALTITUDE_UNITS = {
meters: { label: 'M', convert: (v: number) => Math.round(v * 0.3048) },
feet: { label: 'FT', convert: (v: number) => v },
}
const BASE_VERTICAL_UNITS = {
mps: { label: 'M/S', convert: (v: number) => Math.round(v * 0.3048) },
fps: { label: 'FT/S', convert: (v: number) => v },
}
const photoError = ref(false);
const UNIT_KEY = 'at_units'
function loadUnits() {
const s = localStorage.getItem(UNIT_KEY)
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
}
const props = defineProps<{ plane: any; position: { x: number; y: number }; history?: any[] }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
const prefs = ref(loadUnits())
function saveAndSet(p: any) {
prefs.value = { ...p }
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
}
function cycleSpeed() { const keys = Object.keys(BASE_SPEED_UNITS); const p = loadUnits(); p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]; saveAndSet(p) }
function cycleAltitude() { const keys = Object.keys(BASE_ALTITUDE_UNITS); const p = loadUnits(); p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]; p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'; saveAndSet(p) }
function cycleVertical() { const keys = Object.keys(BASE_VERTICAL_UNITS); const p = loadUnits(); p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]; p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'; saveAndSet(p) }
const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS])
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || props.plane.aircraft || '')
const operator = computed(() => props.plane.operator || props.plane.owner || '')
const altitudeHistory = computed(() => (props.history || [])
.filter(p => !p.live && p.ts != null)
.map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))})))
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
const pos = ref({ ...props.position })
const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
const longPressTimer = ref<number | null>(null)
const header = ref<HTMLElement | null>(null)
function onMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('ap-close')) return
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
e.preventDefault()
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
}
function onMouseUp() { isDragging.value = false }
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).classList.contains('ap-close')) return
const touch: any = e.touches[0]
const sx = touch.clientX, sy = touch.clientY
longPressTimer.value = window.setTimeout(() => {
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
if (header.value) header.value.style.opacity = '0.8'
}, 500)
}
function onTouchMove(e: TouchEvent) {
if (!isDragging.value) return
const touch: any = e.touches[0]
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
e.preventDefault()
}
function onTouchEnd() {
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
if (isDragging.value && header.value) header.value.style.opacity = '1'
isDragging.value = false
}
onMounted(() => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', onTouchEnd)
})
const navball = computed(() => {
const data = props.plane
const heading = data.heading ?? data.track ?? 0
const airspeedKnots = data.speed || data.gs || 0
const vertRateFps = data.climb || data.baro_rate || 0
const groundSpeedFps = airspeedKnots * 1.68781
const pitchRad = groundSpeedFps > 0 ? Math.atan(vertRateFps / groundSpeedFps) : 0
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
const pitchLines: string[] = []
for (let p = -30; p <= 30; p += 5) {
if (p === 0) continue
const isMajor = p % 10 === 0
const [start, end] = [85, 115]
if (isMajor) {
pitchLines.push(`
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
`)
} else {
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
}
}
const rollTicks: string[] = []
for (const angle of [-90, -60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60, 90]) {
const rad = angle * Math.PI / 180
const a = Math.abs(angle)
if (a === 45) {
rollTicks.push(`<circle cx="${100 + 78 * Math.sin(rad)}" cy="${100 - 78 * Math.cos(rad)}" r="2.5" fill="#fff"/>`)
} else {
const inner = (a === 0 || a % 30 === 0) ? 72 : 77
const sw = (a === 0 || a % 30 === 0) ? 2.5 : 1.5
rollTicks.push(`<line x1="${100 + inner * Math.sin(rad)}" y1="${100 - inner * Math.cos(rad)}" x2="${100 + 85 * Math.sin(rad)}" y2="${100 - 85 * Math.cos(rad)}" stroke="#fff" stroke-width="${sw}"/>`)
}
}
const compassLabels = [-60, -45, -30, -15, 0, 15, 30, 45, 60].map(offset => {
const rad = offset * Math.PI / 180
const x = 100 + 70 * Math.sin(rad)
const y = 100 - 70 * Math.cos(rad)
const h = Math.round((offset + 360) % 360)
const lbl = ({ 0: 'N', 90: 'E', 180: 'S', 270: 'W' } as any)[h] || ''
return lbl ? `<text x="${x}" y="${y + 4}" text-anchor="middle" fill="#0f0" font-size="13" font-weight="bold">${lbl}</text>` : ''
}).join('')
return `
<svg width="200" height="220" viewBox="0 0 200 220">
<defs>
<clipPath id="navballClip"><circle cx="100" cy="100" r="85"/></clipPath>
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#1e90ff"/>
<stop offset="100%" style="stop-color:#4169e1"/>
</linearGradient>
<linearGradient id="groundGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8b4513"/>
<stop offset="100%" style="stop-color:#654321"/>
</linearGradient>
</defs>
<rect x="77" y="5" width="46" height="18" fill="rgba(0,0,0,0.95)" stroke="#0f0" stroke-width="2" rx="2"/>
<text x="100" y="18" text-anchor="middle" fill="#0f0" font-size="14" font-weight="bold">${heading.toFixed(0).padStart(3, '0')}°</text>
<g transform="translate(0,25)" clip-path="url(#navballClip)">
<rect x="15" y="${15 + pitch * 2.5 - 200}" width="170" height="285" fill="url(#skyGrad)"/>
<rect x="15" y="${100 + pitch * 2.5}" width="170" height="285" fill="url(#groundGrad)"/>
<line x1="15" y1="${100 + pitch * 2.5}" x2="185" y2="${100 + pitch * 2.5}" stroke="#fff" stroke-width="3"/>
${pitchLines.join('')}
</g>
<g transform="translate(0,25)">${rollTicks.join('')}</g>
<circle cx="100" cy="125" r="85" fill="none" stroke="#0f0" stroke-width="2.5"/>
<line x1="40" y1="125" x2="80" y2="125" stroke="#ff0" stroke-width="3.5"/>
<line x1="120" y1="125" x2="160" y2="125" stroke="#ff0" stroke-width="3.5"/>
<circle cx="100" cy="125" r="4" fill="none" stroke="#ff0" stroke-width="2.5"/>
<g transform="rotate(${-heading} 100 125)">${compassLabels}</g>
<polygon points="100,37 95,30 105,30" fill="#ff0"/>
</svg>
`
})
</script>
<style scoped>
.aircraft-popup {
position: fixed;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(0, 255, 0, 0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.ap-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
}
.ap-callsign {
margin: 0 24px 0 0;
color: #7eeee1;
font-size: 16px;
}
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
.ap-meta {
font-size: 11px;
color: #888;
margin-top: 4px;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
}
.ap-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.ap-close:hover { background: rgba(255,255,255,0.35); }
.ap-photo-wrap {
max-width: 360px;
max-height: 160px;
background: #111;
border-bottom: 1px solid rgba(200,200,220,0.15);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
.ap-body {
display: flex;
gap: 16px;
padding: 12px 16px;
}
.ap-gauges { flex: 0 0 110px; }
.ap-gauge-wrap { margin-bottom: 12px; }
.ap-gauge-label {
color: #888;
font-size: 11px;
font-weight: bold;
margin-bottom: 3px;
}
.ap-gauge {
padding: 6px;
background: rgba(0,0,0,0.95);
border: 2px solid #0f0;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
transition: border-color 0.15s;
}
.ap-gauge:hover { border-color: #0ff; }
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
.ap-navball { flex: 1; margin-top: 1rem; }
</style>
<template>
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="ap-callsign">
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
<span v-else>N/A</span>
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
</h3>
<button class="ap-close" @click.stop="emit('close')">×</button>
</div>
<div class="ap-meta flex-r justify-between">
<div class="flex-c flex-fill">
<span>{{ operator || 'Unknown Owner' }}</span>
<span>{{ plane.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c flex-fill align-x-end">
<span>{{ description || 'Unknown Aircraft' }}</span>
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} {{ plane.type || 'Unknown' }}</span>
</div>
</div>
<div v-if="!photoError" class="ap-photo-wrap">
<img class="ap-photo" :src="BASE + '/api/adsb/' + plane.icao + '/image'" :alt="callsign" @error="photoError = true" />
</div>
<div class="ap-body">
<div class="ap-gauges">
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Air Speed</div>
<div class="ap-gauge" @click.stop="cycleSpeed">
<span class="ap-gauge-val">{{ speedVal }}</span>
<span class="ap-gauge-unit">{{ speed.label }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Altitude</div>
<div class="ap-gauge" @click.stop="cycleAltitude">
<span class="ap-gauge-val">{{ altVal }}</span>
<span class="ap-gauge-unit">{{ altUnit }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Climb</div>
<div class="ap-gauge" @click.stop="cycleVertical">
<span class="ap-gauge-val">{{ climbVal }}</span>
<span class="ap-gauge-unit">{{ vertical.label }}</span>
</div>
</div>
</div>
<div class="ap-navball" v-html="navball" />
</div>
<AircraftAltitude
:history="altitudeHistory"
:current-altitude="currentAltitude"
:unit="altitude.label"
/>
</div>
</template>
+166
View File
@@ -0,0 +1,166 @@
<script setup lang="ts">
import {computed} from 'vue'
const props = withDefaults(defineProps<{
history: any[]
currentAltitude: number | null
color?: string,
unit: string,
startTime?: string,
endTime?: string,
}>(), {
color: '#0f0'
});
const WIDTH = 320
const HEIGHT = 120
const PAD = {top: 10, right: 12, bottom: 24, left: 42}
const points = computed(() => {
const history = [...(props.history || [])]
.filter(p => p.altitude != null && p.altitude !== 0 && !p.live)
.sort((a, b) => {
if (a.ts == null || b.ts == null) return 0
return Number(a.ts) - Number(b.ts)
})
const now = Math.floor(Date.now() / 1000)
const points = history.map((p, i) => ({
...p,
ts: p.ts != null ? Number(p.ts) : now - (history.length - i - 1),
}))
if (props.currentAltitude != null && points.length) {
const last = points[points.length - 1]
points.push({
ts: Math.max(last.ts + 1, now),
altitude: props.currentAltitude,
live: true,
})
}
return points
})
const graph = computed(() => {
const pts = points.value
if (pts.length < 2) return null
const minTs = Number(pts[0].ts)
const maxTs = Number(pts[pts.length - 1].ts)
const rawAltitudes = pts.map(p => Number(p.altitude))
const minAlt = Math.min(...rawAltitudes)
const maxAlt = Math.max(...rawAltitudes)
const altRange = Math.max(maxAlt - minAlt, 1)
const timeRange = Math.max(maxTs - minTs, 1)
const x = (ts: number) => PAD.left + ((ts - minTs) / timeRange) * (WIDTH - PAD.left - PAD.right)
const y = (alt: number) => PAD.top + (1 - (alt - minAlt) / altRange) * (HEIGHT - PAD.top - PAD.bottom)
const line = pts.map(p => `${x(Number(p.ts)).toFixed(1)},${y(Number(p.altitude)).toFixed(1)}`).join(' ')
const latest = pts[pts.length - 1]
const latestX = x(Number(latest.ts))
const latestY = y(Number(latest.altitude))
const formatTime = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
}
return {
line,
latestX,
latestY,
minAlt,
maxAlt,
startTime: formatTime(minTs),
endTime: formatTime(maxTs),
}
})
</script>
<style scoped>
.altitude-graph {
padding: 0 16px 12px;
}
.altitude-graph-title {
display: flex;
justify-content: space-between;
color: #888;
font-size: 11px;
font-weight: bold;
margin-bottom: 4px;
}
.altitude-graph svg {
display: block;
width: 100%;
height: 120px;
overflow: visible;
}
.altitude-graph text {
fill: #777;
font-family: monospace;
font-size: 9px;
}
</style>
<template>
<div v-if="graph" class="altitude-graph">
<div class="altitude-graph-title">
<span>ALTITUDE</span>
<span>{{ unit }}</span>
</div>
<svg :viewBox="`0 0 ${WIDTH} ${HEIGHT}`" preserveAspectRatio="none">
<line
:x1="PAD.left"
:y1="PAD.top"
:x2="PAD.left"
:y2="HEIGHT - PAD.bottom"
stroke="rgba(255,255,255,0.2)"
/>
<line
:x1="PAD.left"
:y1="HEIGHT - PAD.bottom"
:x2="WIDTH - PAD.right"
:y2="HEIGHT - PAD.bottom"
stroke="rgba(255,255,255,0.2)"
/>
<text :x="PAD.left - 5" :y="PAD.top + 4" text-anchor="end">
{{ graph.maxAlt }}
</text>
<text :x="PAD.left - 5" :y="HEIGHT - PAD.bottom + 4" text-anchor="end">
{{ graph.minAlt }}
</text>
<polyline
:points="graph.line"
fill="none"
:stroke="color"
stroke-width="2"
/>
<circle
:cx="graph.latestX"
:cy="graph.latestY"
r="3"
:fill="color"
/>
<text :x="PAD.left" :y="HEIGHT - 6">
{{ startTime || graph.startTime }}
</text>
<text :x="WIDTH - PAD.right" :y="HEIGHT - 6" text-anchor="end">
{{ endTime || graph.endTime }}
</text>
</svg>
</div>
</template>
+56 -32
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import {getPressureTrend} from '@/services/units.ts';
import {inject, onMounted, ref} from 'vue';
import {api} from '../services/api';
import {api, BASE} from '../services/api';
const data = ref<any>();
const openGraph = inject<(key: string) => void>('openGraph');
@@ -80,7 +81,7 @@ onMounted(async () => {
<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="'https://openweathermap.org/img/wn/' + data.icon + '@2x.png'" class="today-icon" alt="weather" />
<img :src="BASE + '/api/icon/' + data.icon" class="today-icon" alt="weather" />
<div class="today-label">{{ data.label }}</div>
</div>
<div class="flex-r align-items-center gap-2">
@@ -107,52 +108,75 @@ onMounted(async () => {
<div class="stat" @click="openGraph?.('heat_index')">
<span class="stat-label">Feels Like</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ ~~(data.heat_index) }}°C</span>
<span v-else class="stat-value">
<template v-if="data.heat_index == null"></template>
<template v-else>{{ ~~(data.heat_index) }}<span class="fg-muted">°C</span></template>
</span>
</div>
<div class="stat" @click="openGraph?.('humidity')">
<span class="stat-label">Humidity</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ Math.round(data.humidity) }}% <span class="fg-muted">({{ data.humidity_abs.toFixed(2) }})</span></span>
<span v-else class="stat-value">
<template v-if="data.humidity == null"></template>
<template v-else>{{ Math.round(data.humidity) }}<span class="fg-muted">%</span></template>
</span>
</div>
<div class="stat" @click="openGraph?.('precipitation_chance')">
<span class="stat-label">Rain</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">
<template v-if="data.raining == null"></template>
<template v-else-if="data.raining">Raining</template>
<template v-else>{{data.precipitation_chance}}<span class="fg-muted">%</span></template>
</span>
</div>
<div class="stat" @click="openGraph?.('clouds')">
<span class="stat-label">Cloud Cover</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ Math.round(data.clouds) }}%</span>
</div>
<div class="stat" @click="openGraph?.('air_quality')">
<span class="stat-label">Air Quality</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.air_quality }} <span class="fg-muted">({{ data.air_quality_label }})</span></span>
</div>
<div class="stat" @click="openGraph?.('air_quality_ohms')">
<span class="stat-label">Gas Resistance</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.air_quality_ohms }} <span class="fg-muted">Ω</span></span>
</div>
<div class="stat" @click="openGraph?.('visibility')">
<span class="stat-label">Visibility</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.visibility?.toFixed(1) }} km</span>
</div>
</div>
<div class="divider" />
<div class="stat-grid">
<div class="stat" @click="openGraph?.('pressure_hpa')">
<span class="stat-label">Pressure</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.pressure_hpa?.toFixed(1) }} <span class="fg-muted">({{ (data.pressure_rate > 0 ? '+' : '') + data.pressure_rate }})</span></span>
<span v-else class="stat-value">
<template v-if="data.clouds == null"></template>
<template v-else>{{ Math.round(data.clouds) }}<span class="fg-muted">%</span></template>
</span>
</div>
<div class="stat" @click="openGraph?.('pressure_slp')">
<span class="stat-label">Sea Level</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ data.pressure_trend }})</span></span>
<span v-else class="stat-value">
<template v-if="data.pressure_slp == null"></template>
<template v-else>{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ getPressureTrend(data.pressure_trend) }})</span></template>
</span>
</div>
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
<span class="stat-label">VPD KPA</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.vapor_pressure_deficit?.toFixed(2) }}</span>
<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>
+5 -6
View File
@@ -67,21 +67,20 @@ onMounted(async () => {
<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 + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
<img :src="BASE + '/api/icon/' + d.icon" class="day-icon" :alt="(d.label?.toString() || '')" />
<div class="day-label">{{ d.label }}</div>
<div class="day-temps">
<span>{{ ~~(d.temperature_max) + '°' || '—' }}</span>
<span class="day-low">{{ ~~(d.temperature_min) + '°' || '—' }}</span>
</div>
<div class="day-humid">🌡 {{ ~~(d.humidity) || 0 }}%</div>
<div class="day-wind">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div>
<div class="day-rain">🌧 {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
<div class="day-humid align-x">🌡 {{ ~~(d.humidity) || 0 }}%</div>
<div class="day-wind align-x">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div>
<div class="day-rain align-x">🌧 {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
</div>
</div>
<div v-else class="strip">
<div v-for="d in filler" class="day">
<div class="day-name w-100 br-2 overflow-hidden pos-rel"><Loading /></div>
<div class="br-2 overflow-hidden pos-rel mb-2" style="height: 80px; width: 80px;"><Loading /></div>
<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>
+123 -78
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue';
import {computed, nextTick, ref, watch} from 'vue';
import {api} from '../services/api';
const props = defineProps<{metricKey: string | null}>();
@@ -7,20 +7,52 @@ const emit = defineEmits<{(e: 'close'): void}>();
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;
watch([() => props.metricKey, mode], async ([key]) => {
if(!key) return;
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);
future.setHours(future.getHours() + 24);
rows.value = await api.hourly(past, future);
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);
rows.value = await api.daily(past, future);
customStart.value = toDateLocal(past);
customEnd.value = toDateLocal(future);
}
}
async function fetchData() {
if(!props.metricKey) return;
selectedPoint.value = null;
loading.value = true;
const past = new Date(customStart.value);
const future = new Date(customEnd.value);
rows.value = mode.value === 'hourly'
? await api.hourly(past, future, `time,${props.metricKey}`)
: await api.daily(past, future, `time,${props.metricKey}`);
loading.value = false;
}
watch([() => props.metricKey, mode], async () => {
setDefaults();
await nextTick();
fetchData();
}, {immediate: true});
const now = new Date();
@@ -30,67 +62,78 @@ const points = computed(() => {
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);
})).filter(p => p.v != null) as {t: number, v: number}[];
});
const chart = computed(() => {
const pts = points.value;
if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:{hist:'',fut:''}, currentX:0, dot:null as any, yLabels:[] as any[]};
if(!pts.length) return {path: {hist:'', fut:''}, currentX: 0, dot: null as any, yLabels: [] as any[], xLabels: [] as any[], dotPoints: [] as any[]};
const ts = pts.map(p => p.t), vs = pts.map(p => p.v as number);
const ts = pts.map(p => p.t), vs = pts.map(p => p.v);
const minT = Math.min(...ts), maxT = Math.max(...ts);
const rawMin = Math.min(...vs), rawMax = Math.max(...vs);
const pad = (rawMax - rawMin) * 0.1 || 1;
const minV = rawMin - pad, maxV = rawMax + pad;
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 current point into both paths so lines meet
const nearest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
const dot = {x: xOf(nearest.t), y: yOf(nearest.v as number), v: nearest.v};
// 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 as number).toFixed(1)}`).join(' ');
arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v).toFixed(1)}`).join(' ');
// selected dot — default to nearest point to now
const activePt = selectedPoint.value
? pts.reduce((a, b) => Math.abs(a.t - selectedPoint.value!.t) < Math.abs(b.t - selectedPoint.value!.t) ? a : b)
: pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
const dot = {x: xOf(activePt.t), y: yOf(activePt.v), v: activePt.v, t: activePt.t};
// y-axis labels (5 ticks)
const yLabels = Array.from({length: 5}, (_, i) => {
const frac = i / 4;
const v = minV + frac * (maxV - minV);
const v = minV + (i / 4) * (maxV - minV);
return {y: yOf(v), label: v.toFixed(1)};
});
// x-axis labels
const xLabels: {x: number, label: string}[] = [];
const span = maxT - minT;
const tickCount = mode.value === 'hourly' ? 9 : 7;
for(let i = 0; i <= tickCount; i++) {
const t = minT + (i / tickCount) * span;
const t = minT + (i / tickCount) * (maxT - minT);
const d = new Date(t);
const label = mode.value === 'hourly'
xLabels.push({x: xOf(t), label: mode.value === 'hourly'
? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
: d.toLocaleDateString([], {month: 'short', day: 'numeric'});
xLabels.push({x: xOf(t), label});
: d.toLocaleDateString([], {month: 'short', day: 'numeric'})});
}
return {minV, maxV, path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels};
const dotPoints = pts.map(p => ({x: xOf(p.t), y: yOf(p.v), t: p.t, v: p.v}));
return {path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels, dotPoints};
});
function onSvgClick(e: MouseEvent) {
const svg = e.currentTarget as SVGSVGElement;
const rect = svg.getBoundingClientRect();
const svgX = ((e.clientX - rect.left) / rect.width) * W;
const pts = chart.value.dotPoints;
if(!pts.length) return;
const nearest = pts.reduce((a, b) => Math.abs(a.x - svgX) < Math.abs(b.x - svgX) ? a : b);
selectedPoint.value = {t: nearest.t, v: nearest.v};
}
</script>
<style scoped lang="scss">
.backdrop {
position: fixed;
inset: 0;
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
display: flex; align-items: center; justify-content: center;
padding: 16px;
backdrop-filter: blur(4px);
}
@@ -99,53 +142,52 @@ const chart = computed(() => {
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 700px;
width: 100%; max-width: 700px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
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;
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); }
}
}
.mode-toggle {
display: flex;
gap: 6px;
margin-bottom: 16px;
.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;
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;
}
}
.current-val {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 12px;
font-size: 13px; color: var(--text-muted);
span { color: var(--accent); font-weight: 700; }
}
svg { width: 100%; overflow: visible; }
.tick-label {
font-size: 9px;
fill: var(--text-muted);
.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>
@@ -158,41 +200,44 @@ svg { width: 100%; overflow: visible; }
<button class="close-btn" @click="emit('close')"></button>
</div>
<div class="mode-toggle">
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly ±24h</button>
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily ±3d</button>
<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 v-if="chart.dot" class="current-val">Now: <span>{{ (chart.dot.v as number).toFixed(2) }}</span></div>
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
<!-- y-axis grid lines & labels -->
<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>
<!-- x-axis labels -->
<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>
<!-- axes -->
<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"/>
<!-- now line -->
<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"/>
<!-- historic solid -->
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
<!-- future dashed -->
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
<!-- current dot -->
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="4" fill="var(--accent)"/>
<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>
+1 -1
View File
@@ -87,7 +87,7 @@ onMounted(async () => {
<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 + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
<img :src="BASE + '/api/icon/' + h.icon" class="hour-icon" :alt="h.label?.toString() || ''" />
<span class="hour-label">{{ h.label }}</span>
<div class="hour-meta">
<span>🌧 {{ (h.precipitation as any || 0).toFixed(1) }}mm</span>
+589 -251
View File
@@ -1,234 +1,517 @@
<script setup lang="ts">
import { AirTrafficLayer } from '@/services/adsb.ts'
import { AISLayer } from '@/services/ais.ts'
import {AirTrafficLayer} from '@/services/adsb.ts';
import {AISLayer} from '@/services/ais.ts';
import {api} from '@/services/api.ts';
import {RangeLayer} from '@/services/range.ts';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import XYZ from 'ol/source/XYZ'
import Feature from 'ol/Feature'
import Point from 'ol/geom/Point'
import CircleGeom from 'ol/geom/Circle'
import { fromLonLat, toLonLat } from 'ol/proj'
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
import 'ol/ol.css'
import {SatsLayer} from '@/services/sats.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';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import XYZ from 'ol/source/XYZ';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import CircleGeom from 'ol/geom/Circle';
import {defaults as defaultInteractions} from 'ol/interaction';
import {fromLonLat, toLonLat} from 'ol/proj';
import {Style, Fill, Stroke, Circle as CircleStyle} from 'ol/style';
import {applyStyle} from 'ol-mapbox-style';
import {Aurora} from '@/services/aurora.ts';
import {SondesLayer} from '@/services/sondes.ts';
import 'ol/ol.css';
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 props = defineProps<{dark: boolean}>();
const mapEl = ref<HTMLDivElement>();
const showOverlays = ref(false);
const openMenu = ref<string | null>(null);
const NM = 1852;
const showWind = ref(false);
const windSrc = ref('');
const NM = 1852
const OVERLAY_GROUPS = [
{
id: 'traffic',
label: 'Traffic',
icon: '✈️',
items: [
{id: 'aircraft', label: 'Aircraft', icon: '✈️'},
{id: 'marine', label: 'Marine', icon: '🚢'},
{id: 'satellites', label: 'Satellites', icon: '🛰️'},
{id: 'sondes', label: 'Weather Balloons', icon: '️🎈'},
],
},
{
id: 'weather',
label: 'Weather',
icon: '🌦️',
items: [
{id: 'aurora', label: 'Aurora', icon: '🌌'},
{id: 'rain', label: 'Rain', icon: '🌧️'},
{id: 'wind', label: 'Wind', icon: '💨'},
],
},
];
const radarFrames = ref<{ path: string; time: number }[]>([])
const radarFrameIndex = ref(0)
const radarPath = computed(() => radarFrames.value[radarFrameIndex.value]?.path ?? '')
const nowcastStartIndex = ref(0)
const DEFAULT_OVERLAYS = ['aircraft', 'marine', 'satellites', 'rain'];
const activeOverlays = ref<Set<string>>(new Set(DEFAULT_OVERLAYS));
const overlayLayers: {[key: string]: any} = {};
const showWind = ref(false)
const windSrc = ref('')
const MAP_STATE_KEY = 'situation-center-map-state';
const MAP_LAYERS_KEY = 'situation-center-map-layers';
const AUTO_MODE_KEY = 'situation-center-auto-mode';
const AUTO_INTERVAL_MS = 15_000;
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: () => '' },
]
const AUTO_CATEGORIES = ['sondes', 'aircraft', 'marine', 'satellites'] as const;
type AutoCategory = typeof AUTO_CATEGORIES[number];
const activeOverlays = ref<Set<string>>(new Set(['rain']))
const overlayLayers: { [key: string]: any } = {}
interface AutoObject {
category: AutoCategory;
id: string;
score: number;
}
let map: Map
let stationLayer: VectorLayer<VectorSource>
let radarInterval: ReturnType<typeof setInterval>
let airTraffic: AirTrafficLayer
let aisLayer: AISLayer
let range: RangeLayer
let map: Map;
let stationLayer: VectorLayer<VectorSource>;
let radarInterval: ReturnType<typeof setInterval>;
let autoInterval: ReturnType<typeof setInterval>;
let airTraffic: AirTrafficLayer;
let aisLayer: AISLayer;
let range: RangeLayer;
let sats: SatsLayer;
let sondes: SondesLayer;
let aurora: Aurora;
const autoMode = ref(false);
const autoCategory = ref<AutoCategory | null>(null);
const autoTarget = ref<any>(null);
let autoTargetKey: string | null = null;
// Per-category lookup so auto-mode logic never branches on category directly.
const AUTO_CONFIG: Record<AutoCategory, {
layer: () => any;
id: (target: any) => string;
position: (target: any) => [number, number] | null;
zoom: number;
}> = {
sondes: {
layer: () => sondes,
id: t => String(t.id),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 8,
},
aircraft: {
layer: () => airTraffic,
id: t => String(t.icao),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 10,
},
marine: {
layer: () => aisLayer,
id: t => String(t.mmsi),
position: t => t.lat != null && t.lon != null ? [t.lon, t.lat] : null,
zoom: 12,
},
satellites: {
layer: () => sats,
id: t => String(t.id),
position: t => t.latitude != null && t.longitude != null ? [t.longitude, t.latitude] : null,
zoom: 6,
},
};
function loadMapState() {
try {
const raw = localStorage.getItem(MAP_STATE_KEY);
if (!raw) return null;
const state = JSON.parse(raw);
if (
typeof state.latitude !== 'number' ||
typeof state.longitude !== 'number' ||
typeof state.zoom !== 'number'
) return null;
return state;
} catch {
return null;
}
}
function saveMapState() {
if (!map) return;
try {
const view = map.getView();
const center = view.getCenter();
if (!center) return;
const [longitude, latitude] = toLonLat(center);
const zoom = view.getZoom();
if (typeof zoom !== 'number') return;
localStorage.setItem(MAP_STATE_KEY, JSON.stringify({latitude, longitude, zoom}));
} catch {
// Ignore localStorage failures.
}
}
function loadLayers() {
try {
const raw = localStorage.getItem(MAP_LAYERS_KEY);
if (!raw) return new Set(DEFAULT_OVERLAYS);
const layers = JSON.parse(raw);
if (!Array.isArray(layers)) return new Set(DEFAULT_OVERLAYS);
const validLayers = OVERLAY_GROUPS.flatMap(group => group.items.map(item => item.id));
return new Set(
layers.filter((id: unknown): id is string =>
typeof id === 'string' && validLayers.includes(id),
),
);
} catch {
return new Set(DEFAULT_OVERLAYS);
}
}
function saveLayers() {
try {
localStorage.setItem(MAP_LAYERS_KEY, JSON.stringify([...activeOverlays.value]));
} catch {
// Ignore localStorage failures.
}
}
function loadAutoMode() {
try {
return localStorage.getItem(AUTO_MODE_KEY) === 'true';
} catch {
return false;
}
}
function saveAutoMode() {
try {
localStorage.setItem(AUTO_MODE_KEY, String(autoMode.value));
} catch {
// Ignore localStorage failures.
}
}
function buildWindSrc(lat: number, lon: number, zoom: number) {
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`;
}
function syncWindSrc() {
const view = map.getView()
const center: any = toLonLat(view.getCenter()!)
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
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
function showWindOverlay() {
syncWindSrc();
showWind.value = true;
map.getInteractions().forEach(i => i.setActive(false));
}
function buildRainSource(url: string): XYZ {
return new XYZ({
function hideWindOverlay() {
showWind.value = false;
map.getInteractions().forEach(i => i.setActive(true));
}
async function fetchRadarUrl(): Promise<string | null> {
try {
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json');
const data = await res.json();
const past = data.radar?.past ?? [];
const latest = past[past.length - 1];
if (!latest) return null;
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`;
} catch {
return null;
}
}
function buildRainLayer(url: string): TileLayer<XYZ> {
return new TileLayer({
source: new XYZ({
url,
attributions: '',
maxZoom: 7,
tileLoadFunction: (tile: any, src: string) => {
const img = tile.getImage()
img.onerror = () => tile.setState(3)
img.src = src
const img = tile.getImage();
img.onerror = () => tile.setState(3);
img.src = src;
},
})
}),
opacity: 0.2,
zIndex: 5,
});
}
function seekRadar(index: number) {
radarFrameIndex.value = index
const layer = overlayLayers['rain']
if (layer) {
layer.setSource(buildRainSource(OVERLAYS.find(o => o.id === 'rain')!.url()))
}
function removeRainLayer() {
if (!overlayLayers['rain']) return;
map.removeLayer(overlayLayers['rain']);
delete overlayLayers['rain'];
}
function toggleAT() {
if(atActive.value) {
aisLayer.hide()
airTraffic.hide()
range.hide()
} else {
aisLayer.show()
airTraffic.show()
range.show()
}
atActive.value = !atActive.value
async function refreshRain() {
const url = await fetchRadarUrl();
removeRainLayer();
if (!url || !activeOverlays.value.has('rain')) return;
const layer = buildRainLayer(url);
overlayLayers['rain'] = layer;
map.addLayer(layer);
}
// One handler per overlay id — toggleOverlay/restoreOverlayState just drive these.
const OVERLAY_HANDLERS: Record<string, {show: () => void; hide: () => void}> = {
aircraft: {show: () => { airTraffic.show(); range.show(); }, hide: () => { airTraffic.hide(); range.hide(); }},
marine: {show: () => aisLayer.show(), hide: () => aisLayer.hide()},
satellites: {show: () => sats.show(), hide: () => sats.hide()},
sondes: {show: () => sondes.show(), hide: () => sondes.hide()},
aurora: {show: () => aurora.show(), hide: () => aurora.hide()},
wind: {show: showWindOverlay, hide: hideWindOverlay},
rain: {show: refreshRain, hide: removeRainLayer},
};
function toggleOverlay(id: string) {
if (id === 'wind') {
if (activeOverlays.value.has('wind')) {
activeOverlays.value.delete('wind')
showWind.value = false
} else {
activeOverlays.value.add('wind')
syncWindSrc()
showWind.value = true
}
return
}
const handler = OVERLAY_HANDLERS[id];
if (!handler) return;
if (activeOverlays.value.has(id)) {
activeOverlays.value.delete(id)
const layer = overlayLayers[id]
if (layer) { map.removeLayer(layer); delete overlayLayers[id] }
activeOverlays.value.delete(id);
handler.hide();
} else {
activeOverlays.value.add(id)
const layer = buildOverlayLayer(id)
overlayLayers[id] = layer
map.addLayer(layer)
activeOverlays.value.add(id);
handler.show();
}
saveLayers();
}
function restoreOverlayState() {
for (const id of Object.keys(OVERLAY_HANDLERS)) {
activeOverlays.value.has(id) ? (<any>OVERLAY_HANDLERS)[id].show() : (<any>OVERLAY_HANDLERS)[id].hide();
}
}
function toggleMenu(id: string) {
openMenu.value = openMenu.value === id ? null : id;
}
function buildBaseLayer(dark: boolean) {
return new TileLayer({
source: new XYZ({
url: dark
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
}),
})
}
function buildOverlayLayer(id: string): TileLayer<XYZ> {
const def = OVERLAYS.find(o => o.id === id)!
return new TileLayer({
source: buildRainSource(def.url()),
opacity: 0.2,
zIndex: 5,
})
const layer = new VectorTileLayer({declutter: true});
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json');
return layer;
}
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
const center = fromLonLat([lon, lat])
const source = new VectorSource()
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
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))
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'}),
}))
source.addFeature(ring)
}));
source.addFeature(ring);
}
const dot = new Feature(new Point(center))
const dot = new Feature(new Point(center));
dot.setStyle(new Style({
image: new CircleStyle({
radius: 7,
fill: new Fill({color: dark ? '#ffffff' : '#000000'}),
stroke: new Stroke({color: dark ? '#000000' : '#ffffff', width: 2}),
}),
}))
source.addFeature(dot)
}));
return new VectorLayer({ source, zIndex: 20 })
source.addFeature(dot);
return new VectorLayer({source, zIndex: 20});
}
function getAutoObjects(): AutoObject[] {
const objects: AutoObject[] = [];
for (const category of AUTO_CATEGORIES) {
if (!activeOverlays.value.has(category)) continue;
const config = AUTO_CONFIG[category];
const layer = config.layer();
if (!layer.visible) continue;
for (const target of layer.getCandidates(current.value.latitude, current.value.longitude)) {
objects.push({category, id: config.id(target), score: target.score});
}
}
return objects;
}
function focusAutoTarget(category: AutoCategory, target: any) {
const config = AUTO_CONFIG[category];
const position = config.position(target);
if (!position) return;
map.getView().animate({center: fromLonLat(position), zoom: config.zoom, duration: 900});
}
function clearAutoTarget() {
if (autoCategory.value && autoTarget.value) {
const config = AUTO_CONFIG[autoCategory.value];
config.layer().closePopup(config.id(autoTarget.value));
}
autoCategory.value = null;
autoTarget.value = null;
autoTargetKey = null;
}
function setAutoTarget(object: AutoObject) {
const config = AUTO_CONFIG[object.category];
const target = config.layer().getById(object.id);
if (!target) return;
clearAutoTarget();
autoCategory.value = object.category;
autoTarget.value = target;
autoTargetKey = `${object.category}:${object.id}`;
config.layer().openPopup(target);
focusAutoTarget(object.category, target);
}
function pickBestAndFocus() {
if (!autoMode.value) return;
const objects = getAutoObjects();
if (!objects.length) return clearAutoTarget();
const best: any = objects.sort((a, b) => b.score - a.score)[0];
const bestKey = `${best.category}:${best.id}`;
if (bestKey === autoTargetKey) {
const fresh = AUTO_CONFIG[best.category].layer().getById(best.id);
if (!fresh) return clearAutoTarget();
autoTarget.value = fresh;
focusAutoTarget(best.category, fresh);
return;
}
setAutoTarget(best);
}
function startAutoMode() {
if (autoMode.value) return;
autoMode.value = true;
saveAutoMode();
clearAutoTarget();
pickBestAndFocus();
autoInterval = setInterval(pickBestAndFocus, AUTO_INTERVAL_MS);
}
function stopAutoMode() {
if (autoInterval) {
clearInterval(autoInterval);
autoInterval = undefined as any;
}
clearAutoTarget();
autoMode.value = false;
saveAutoMode();
}
function toggleAutoMode() {
autoMode.value ? stopAutoMode() : startAutoMode();
}
onMounted(async () => {
await fetchRadarFrames()
radarFrameIndex.value = nowcastStartIndex.value - 1
activeOverlays.value = loadLayers();
const savedMapState = loadMapState();
const position = await api.position();
const lat = position?.latitude || 0
const lon = position?.longitude || 0
current.value = {
latitude: position?.latitude || 0,
longitude: position?.longitude || 0,
};
stationLayer = buildStationLayer(lat, lon, props.dark)
stationLayer = buildStationLayer(current.value.latitude, current.value.longitude, props.dark);
const initialLatitude = savedMapState?.latitude ?? current.value.latitude;
const initialLongitude = savedMapState?.longitude ?? current.value.longitude;
const initialZoom = savedMapState?.zoom ?? 8;
map = new Map({
target: mapEl.value!,
layers: [buildBaseLayer(props.dark), stationLayer],
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
interactions: defaultInteractions({altShiftDragRotate: false, pinchRotate: false}),
view: new View({
center: fromLonLat([initialLongitude, initialLatitude]),
zoom: initialZoom,
maxZoom: 13,
}),
controls: [],
})
});
map.on('moveend', saveMapState);
aisLayer = new AISLayer(map);
aisLayer.show();
airTraffic = new AirTrafficLayer(map)
airTraffic.show()
airTraffic = new AirTrafficLayer(map);
sats = new SatsLayer(map);
sondes = new SondesLayer(map);
aurora = new Aurora(map);
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)
}
restoreOverlayState();
map.on('moveend', () => {
if (showWind.value) syncWindSrc()
})
radarInterval = setInterval(refreshRain, 5 * 60 * 1000);
radarInterval = setInterval(async () => {
await fetchRadarFrames()
radarFrameIndex.value = nowcastStartIndex.value - 1
seekRadar(radarFrameIndex.value)
}, 5 * 60 * 1000)
})
if (loadAutoMode()) setTimeout(() => { if (!autoMode.value) startAutoMode(); }, 1_000);
});
onUnmounted(() => {
clearInterval(radarInterval)
airTraffic.hide()
})
stopAutoMode();
clearInterval(radarInterval);
if (map) saveMapState();
airTraffic?.hide();
aisLayer?.hide();
sats?.hide();
sondes?.hide();
aurora?.hide();
range?.hide();
});
watch(() => props.dark, dark => {
map.getLayers().setAt(0, buildBaseLayer(dark))
map.removeLayer(stationLayer)
const lat = (current.value.latitude as number) || 0
const lon = (current.value.longitude as number) || 0
stationLayer = buildStationLayer(lat, lon, dark)
map.addLayer(stationLayer)
})
if (!map) return;
map.getLayers().setAt(0, buildBaseLayer(dark));
map.removeLayer(stationLayer);
stationLayer = buildStationLayer(current.value.latitude || 0, current.value.longitude || 0, dark);
map.addLayer(stationLayer);
});
</script>
<style scoped lang="scss">
@@ -254,9 +537,29 @@ watch(() => props.dark, dark => {
z-index: 10;
}
.overlay-toggles {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
background: var(--surface);
border-radius: 99px;
padding: 6px 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(8px);
}
.overlay-group {
position: relative;
}
.overlay-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 12px;
border-radius: 99px;
@@ -274,24 +577,91 @@ watch(() => props.dark, dark => {
color: #fff;
}
&:hover:not(.active) { background: var(--hover); }
&:hover:not(.active) {
background: var(--hover);
}
}
.overlay-toggles {
.group-btn {
position: relative;
&.has-active {
border-color: var(--accent);
}
}
.group-indicator {
font-size: 9px;
opacity: 0.7;
margin-left: 2px;
}
.overlay-submenu {
position: absolute;
bottom: 16px;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
flex-direction: column;
gap: 4px;
min-width: 130px;
background: var(--surface);
border-radius: 99px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 12px;
padding: 6px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(8px);
}
.submenu-btn {
width: 100%;
justify-content: flex-start;
border: none;
border-radius: 8px;
padding: 8px 10px;
&.active {
background: var(--accent);
color: #fff;
}
}
.auto-btn {
&.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
}
.auto-status {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
padding: 0 4px;
}
.auto-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: auto-pulse 1.5s infinite;
}
@keyframes auto-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.layers-menu {
position: absolute;
bottom: 16px;
@@ -319,6 +689,7 @@ watch(() => props.dark, dark => {
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
background: var(--surface);
border-radius: 12px;
padding: 8px;
@@ -326,82 +697,43 @@ 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));
.mobile-group {
position: relative;
}
.slider-wrap {
flex: 1;
.mobile-submenu {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
padding-left: 8px;
}
.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'] {
.mobile-submenu .overlay-btn {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
justify-content: flex-start;
}
.timeline-label {
font-size: 12px;
color: var(--text);
min-width: 42px;
text-align: right;
flex-shrink: 0;
.desktop {
display: flex;
}
.desktop { display: flex; }
.mobile { display: none; }
.mobile {
display: none;
}
@media (max-width: 768px) {
.desktop { display: none; }
.mobile { display: flex; }
.radar-timeline { bottom: 10px; }
.overlay-toggles { bottom: 10px; }
.layers-menu { bottom: 10px; }
.desktop {
display: none;
}
.mobile {
display: flex;
}
.layers-menu {
bottom: 10px;
}
}
</style>
@@ -409,46 +741,52 @@ input[type='range'] {
<div class="map-wrap">
<div ref="mapEl" class="map-el" />
<!-- Windy animated wind iframe -->
<iframe
v-if="showWind"
class="wind-iframe"
:src="windSrc"
frameborder="0"
allowfullscreen
/>
<iframe v-if="showWind" class="wind-iframe" :src="windSrc" frameborder="0" allowfullscreen/>
<!-- Desktop toggles -->
<div class="overlay-toggles desktop">
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="overlay-group">
<button class="overlay-btn group-btn" :class="{'has-active': group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
{{ group.icon }} {{ group.label }}
<span class="group-indicator">
{{ openMenu === group.id ? '▲' : '▼' }}
</span>
</button>
<button
v-for="o in OVERLAYS" :key="o.id"
class="overlay-btn"
:class="{ active: activeOverlays.has(o.id) }"
@click="toggleOverlay(o.id)"
>
{{ o.icon }} {{ o.label }}
<div v-if="openMenu === group.id" class="overlay-submenu">
<button v-for="item in group.items" :key="item.id" class="overlay-btn submenu-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
{{ item.icon }} {{ item.label }}
</button>
</div>
</div>
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
{{ autoMode ? '' : '' }} Auto
</button>
</div>
<!-- Mobile layers button + dropdown -->
<div class="layers-menu mobile">
<button class="layers-btn" @click="showOverlays = !showOverlays">
Layers
</button>
<div v-if="showOverlays" class="layers-dropdown">
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
<div v-for="group in OVERLAY_GROUPS" :key="group.id" class="mobile-group">
<button class="overlay-btn" :class="{active: group.items.some(item =>activeOverlays.has(item.id),),}" @click="toggleMenu(group.id)">
{{ group.icon }} {{ group.label }}
<span class="group-indicator">
{{ openMenu === group.id ? '▲' : '▼' }}
</span>
</button>
<button
v-for="o in OVERLAYS" :key="o.id"
class="overlay-btn"
:class="{ active: activeOverlays.has(o.id) }"
@click="toggleOverlay(o.id)"
>
{{ o.icon }} {{ o.label }}
<div v-if="openMenu === group.id" class="mobile-submenu">
<button v-for="item in group.items" :key="item.id" class="overlay-btn" :class="{active: activeOverlays.has(item.id)}" @click="toggleOverlay(item.id)">
{{ item.icon }} {{ item.label }}
</button>
</div>
</div>
<button class="overlay-btn auto-btn" :class="{active: autoMode}" @click="toggleAutoMode">
{{ autoMode ? ' Stop Auto' : ' Auto Mode' }}
</button>
</div>
</div>
+16 -17
View File
@@ -1,23 +1,11 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import {formatDate} from '@ztimson/utils';
import {ref, onMounted, computed, watchEffect} from 'vue';
import {api} from '../services/api';
import {ref, watchEffect} from 'vue';
import {useWeather} from '../services/api';
import MetricRow from './MetricRow.vue';
const d = ref<any>(null);
onMounted(async () => d.value = await api.current());
const moonIcon = computed(() => {
if(!d.value) return '🌑';
const phase = d.value.moon_phase as string;
const map: Record<string, string> = {
'New Moon': '🌑', 'Waxing Crescent': '🌒', 'First Quarter': '🌓',
'Waxing Gibbous': '🌔', 'Full Moon': '🌕', 'Waning Gibbous': '🌖',
'Last Quarter': '🌗', 'Waning Crescent': '🌘',
};
return map[phase] ?? '🌕';
});
const {current: d} = useWeather();
const canvasRef = ref<HTMLCanvasElement | null>(null);
watchEffect(() => {
@@ -80,20 +68,31 @@ watchEffect(() => {
</script>
<template>
<div class="card" v-if="d">
<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>
+28 -25
View File
@@ -1,12 +1,11 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import { inject, onMounted, ref } from 'vue';
import { api } from '../services/api';
import {getPressureTrend} from '@/services/units.ts';
import {inject} from 'vue';
import {useWeather} from '../services/api';
const d = ref<any>(null);
const openGraph = inject<(key: string) => void>('openGraph');
onMounted(async () => d.value = await api.current());
const {current: d} = useWeather();
</script>
<style scoped lang="scss">
@@ -59,59 +58,63 @@ onMounted(async () => d.value = await api.current());
</style>
<template>
<div class="precip-card" v-if="d">
<div class="precip-card">
<div class="card-title">🌧 Precipitation</div>
<div class="stat-grid">
<div class="stat">
<span class="stat-label">Raining</span>
<div class="stat" @click="openGraph?.('precipitation_chance')">
<span class="stat-label">Rain</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.raining == 'True' ? d.storm_direction : 'No' }}</span>
<span v-else class="stat-value">
<template v-if="d.raining == null"></template>
<template v-else-if="d.raining">Raining</template>
<template v-else>{{d.precipitation_chance}}<span class="fg-muted">%</span></template>
</span>
</div>
<div 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 class="fg-muted">/hr</span></span>
<span v-else class="stat-value">{{ d.lightning_rate ?? '—' }}<span v-if="d.lightning_rate" class="fg-muted">/hr</span></span>
</div>
<div 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 class="fg-muted">km</span></span>
<span v-else class="stat-value">{{ d.lightning_distance ?? '—' }} <span v-if="d.lightning_distance" class="fg-muted">km</span></span>
</div>
</div>
<div class="divider" />
<div class="stat-grid">
<div class="stat" @click="openGraph?.('precipitation_chance')">
<span class="stat-label">Chance</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.precipitation_chance }}<span class="fg-muted">%</span></span>
</div>
<div class="stat" @click="openGraph?.('pressure_hpa')">
<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">({{d.pressure_trend}})</span></span>
<span v-else class="stat-value">{{ d.pressure_hpa?.toFixed(1) ?? '—' }} <span class="fg-muted">hpa</span></span>
</div>
<div class="stat" @click="openGraph?.('pressure_hpa')">
<span class="stat-label">Pressure (SLP)</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.pressure_slp?.toFixed(1) ?? '—' }} <span v-if="d.pressure_slp" class="fg-muted">({{getPressureTrend(d.pressure_rate)}})</span></span>
</div>
<div 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 class="fg-muted">mm</span></span>
</div>
<div class="stat" @click="openGraph?.('accumulation')">
<span class="stat-label">Accumulation</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.accumulation.toFixed(1) }} <span class="fg-muted">mm {{ d.accumulation_type != 'none' ? `(${d.accumulation_type})` : '' }}</span></span>
<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 class="fg-muted">°C</span></span>
<span v-else class="stat-value">{{ d.dew_point?.toFixed(1) ?? '—' }}<span v-if="d.dew_point" class="fg-muted">°C</span></span>
</div>
<div 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>
<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>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
history: any[]
range: { altitude: number; slantRange: number; horizonRadius: number; groundDist: number } | null
eta: number | null
position: { x: number; y: number }
}>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
const UNIT_KEY = 'at_units'
function loadUnits() {
const s = localStorage.getItem(UNIT_KEY)
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
}
const prefs = ref(loadUnits())
function saveAndSet(p: any) {
prefs.value = { ...p }
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
}
function cycleAltitude() {
const p = loadUnits()
p.altitude = p.altitude === 'meters' ? 'feet' : 'meters'
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
saveAndSet(p)
}
function syncUnits(e: StorageEvent) {
if (e.key === UNIT_KEY) prefs.value = loadUnits()
}
const distance = computed(() => prefs.value.altitude === 'feet'
? { label: 'mi', convert: (v: number) => v * 0.621371 }
: { label: 'km', convert: (v: number) => v })
const latest = computed(() => props.history[props.history.length - 1])
const rangeVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.slantRange)) : null)
const altVal = computed(() => props.range ? Math.round(distance.value.convert(props.range.altitude)) : null)
// ── Drag ──────────────────────────────────────────────────────────────────────
const pos = ref({ ...props.position })
const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
const longPressTimer = ref<number | null>(null)
const header = ref<HTMLElement | null>(null)
function onMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('tp-close')) return
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
e.preventDefault()
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
}
function onMouseUp() { isDragging.value = false }
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).classList.contains('tp-close')) return
const touch: any = e.touches[0]
const sx = touch.clientX, sy = touch.clientY
longPressTimer.value = window.setTimeout(() => {
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
if (header.value) header.value.style.opacity = '0.8'
}, 500)
}
function onTouchMove(e: TouchEvent) {
if (!isDragging.value) return
const touch: any = e.touches[0]
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
e.preventDefault()
}
function onTouchEnd() {
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
if (isDragging.value && header.value) header.value.style.opacity = '1'
isDragging.value = false
}
onMounted(() => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
window.addEventListener('storage', syncUnits)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', onTouchEnd)
window.removeEventListener('storage', syncUnits)
})
// ── Radar HUD ─────────────────────────────────────────────────────────────────
/** Center = zenith (el 90), edge = horizon (el 0). */
function polarPoint(az: number, el: number, radius = 100) {
const r = radius * (1 - el / 90)
const rad = (az - 90) * Math.PI / 180
return { x: 120 + r * Math.cos(rad), y: 120 + r * Math.sin(rad) }
}
const trackPoints = computed(() => props.history.map(r => polarPoint(r.az, r.el)).map(p => `${p.x},${p.y}`).join(' '))
const currentPoint = computed(() => latest.value ? polarPoint(latest.value.az, latest.value.el) : null)
</script>
<style scoped>
.sat-popup {
position: fixed;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(212,164,255,0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.tp-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(212,164,255,0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
}
.tp-name { margin: 0 24px 0 0; color: #d4a4ff; font-size: 16px; }
.tp-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.tp-close:hover { background: rgba(255,255,255,0.35); }
.tp-meta {
font-size: 11px;
color: #888;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid rgba(212,164,255,0.2);
}
.tp-body { display: flex; gap: 16px; padding: 12px 16px; }
.tp-gauges { flex: 0 0 110px; }
.tp-gauge-wrap { margin-bottom: 12px; }
.tp-gauge-label { color: #888; font-size: 11px; font-weight: bold; margin-bottom: 3px; }
.tp-gauge {
padding: 6px;
background: rgba(0,0,0,0.95);
border: 2px solid #d4a4ff;
border-radius: 3px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
}
.tp-gauge-val { font-size: 20px; font-weight: bold; color: #d4a4ff; }
.tp-gauge-unit { font-size: 11px; color: #d4a4ff; margin-bottom: 0.75em; }
.tp-gauge-click { cursor: pointer; }
.tp-gauge-click:hover { border-color: #fff; }
.tp-radar { flex: 1; }
.ring, .axis { fill: none; stroke: rgba(212,164,255,0.3); stroke-width: 1; }
.dir { fill: #d4a4ff; font-size: 10px; font-weight: bold; }
.track { fill: none; stroke: #d4a4ff; stroke-width: 1.5; }
.current { fill: #d4a4ff; }
.tp-fields { width: 100%; font-size: 11px; padding: 0 16px 12px; border-collapse: collapse; }
.tp-fields td { padding: 2px 4px; color: #888; }
.tp-fields td:last-child { color: #ccc; text-align: right; }
.ok { color: #3fdb6d !important; }
.bad { color: #e0475a !important; }
</style>
<template>
<div class="sat-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="tp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="tp-name">🛰 {{ latest?.satellite || 'Unknown' }}</h3>
<button class="tp-close" @click.stop="emit('close')"></button>
</div>
<div class="tp-meta">
<div class="flex-c flex-fill">
<span>Range: {{ rangeVal != null ? rangeVal + ' ' + distance.label : 'Range —' }}</span>
<span>Freq: {{ latest?.freqMHz }} MHz</span>
</div>
<div class="flex-c flex-fill align-x-end">
<span>RSSI: {{ latest?.packetRssi }} dBm</span>
<span>SNR: {{ latest?.packetSnr }} dB</span>
</div>
</div>
<div class="tp-body">
<div class="tp-gauges">
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Altitude (±10%)</div>
<div class="tp-gauge tp-gauge-click" @click.stop="cycleAltitude">
<span class="tp-gauge-val">{{ altVal != null ? altVal + ' ' + distance.label : '—' }}</span>
</div>
</div>
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Azimuth</div>
<div class="tp-gauge">
<span class="tp-gauge-val">{{ latest?.az?.toFixed(2) ?? '—' }}</span>
<span class="tp-gauge-unit">°</span>
</div>
</div>
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Elevation</div>
<div class="tp-gauge">
<span class="tp-gauge-val">{{ latest?.el?.toFixed(2) ?? '—' }}</span>
<span class="tp-gauge-unit">°</span>
</div>
</div>
</div>
<svg viewBox="0 0 240 240" class="tp-radar">
<circle cx="120" cy="120" r="100" class="ring" /><circle cx="120" cy="120" r="66" class="ring" /><circle cx="120" cy="120" r="33" class="ring" />
<line x1="120" y1="20" x2="120" y2="220" class="axis" /><line x1="20" y1="120" x2="220" y2="120" class="axis" />
<text x="120" y="14" text-anchor="middle" class="dir">N</text>
<text x="228" y="124" text-anchor="middle" class="dir">E</text>
<text x="120" y="234" text-anchor="middle" class="dir">S</text>
<text x="12" y="124" text-anchor="middle" class="dir">W</text>
<polyline :points="trackPoints" class="track" />
<circle v-if="currentPoint" :cx="currentPoint.x" :cy="currentPoint.y" r="5" class="current" />
</svg>
</div>
</div>
</template>
+154 -35
View File
@@ -1,63 +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 history = ref<number[]>([]);
const W = 320, H = 60, MAX_PTS = 120;
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;
function toPath(pts: number[]) {
if (!pts.length) return '';
const mid = H / 2, amp = H / 2 - 4;
const max = Math.max(Math.max(...pts), 1.0); // 1.0 = min scale floor
type Pt = { x: number; y: number };
const traces = AXES.map(() => ({
history: [] as number[],
prevMax: 1.0,
buffer: [] as Pt[],
}));
// Fixed px per point — path grows left→right, never stretches
const segW = W / (MAX_PTS - 1);
const subPts: { x: number; y: number }[] = [];
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 norm = (v / max) * amp;
const baseX = i * segW;
const s = segW / 4;
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;
subPts.push({ x: baseX, y: mid - norm * 0.5 });
subPts.push({ x: baseX + s, y: mid - norm });
subPts.push({ x: baseX + s * 2, y: mid + norm });
subPts.push({ x: baseX + s * 3, y: mid });
// 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 ✓
});
return subPts.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`
).join(' ');
t.buffer = buf;
}
function drawTrace(ctx: CanvasRenderingContext2D, t: (typeof traces)[number], color: string) {
const buf = t.buffer;
if (buf.length <= SUB) return;
// slide everything left as the new segment draws in
const slide = (1 - drawProgress) * SEG_W;
const settledCount = buf.length - SUB;
const anchor = buf[settledCount - 1];
const animFrac = drawProgress * (SUB + 1);
const animFloor = Math.floor(animFrac);
const animRemainder = animFrac - animFloor;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
for (let i = 0; i < settledCount; i++) {
const p = <any>buf[i];
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
}
const animPoints = <any>[anchor, ...buf.slice(settledCount)];
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
}
if (animFloor < animPoints.length - 1 && animRemainder > 0) {
const curr = animPoints[animFloor];
const next = animPoints[animFloor + 1];
ctx.lineTo(
curr.x + (next.x - curr.x) * animRemainder + slide,
curr.y + (next.y - curr.y) * animRemainder,
);
}
ctx.stroke();
}
function draw(timestamp: number) {
const ctx = canvas.value?.getContext('2d');
if (!ctx) { rafId = requestAnimationFrame(draw); return; }
const dt = Math.min(timestamp - lastTimestamp, 100);
lastTimestamp = timestamp;
drawProgress = Math.min(1, drawProgress + dt / ANIM_DURATION);
ctx.clearRect(0, 0, W, H);
AXES.forEach((_, i) => {
const mid = i * BAND + BAND / 2;
// Axis label — top-left of each band
ctx.font = '16px monospace';
ctx.fillStyle = (<any>AXES)[i].color;
ctx.globalAlpha = 0.6;
ctx.fillText((<any>AXES)[i].label, W - 20, i * BAND + 14);
ctx.globalAlpha = 1;
// Center line per band — full width
ctx.beginPath();
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
ctx.moveTo(0, mid);
ctx.lineTo(W, mid);
ctx.stroke();
drawTrace(ctx, <any>traces[i], (<any>AXES)[i].color);
});
rafId = requestAnimationFrame(draw);
}
async function poll() {
d.value = await api.current('magnitude');
history.value.push(d.value.magnitude ?? 0);
if(history.value.length > MAX_PTS) history.value.shift();
d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
AXES.forEach((axis, i) => {
const t = <any>traces[i];
t.history.push(d.value[axis.key] ?? 0);
if (t.history.length > MAX_PTS) t.history.shift();
buildBuffer(t, i);
});
drawProgress = 0;
}
onMounted(async () => { await poll(); interval = setInterval(poll, 5000); });
onUnmounted(() => clearInterval(interval));
onMounted(async () => {
await poll();
lastTimestamp = performance.now();
rafId = requestAnimationFrame(draw);
interval = setInterval(poll, 1000);
});
onUnmounted(() => {
clearInterval(interval);
cancelAnimationFrame(rafId);
});
</script>
<style scoped lang="scss">
svg { width: 100%; height: 60px; overflow: visible; }
canvas { width: 100%; height: 180px; }
</style>
<template>
<div class="card" v-if="d">
<div class="card">
<div class="card-title">🫨 Seismic</div>
<MetricRow label="Magnitude" :value="d.magnitude?.toFixed(1)" metric-key="magnitude" :data="d" />
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
<line :x1="0" :y1="H/2" :x2="W" :y2="H/2" stroke="var(--border)" stroke-width="1" />
<path :d="toPath(history)" fill="none" stroke="var(--accent)" stroke-width="1.5" />
</svg>
<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>
+368
View File
@@ -0,0 +1,368 @@
<script setup lang="ts">
import {BASE} from '@/services/api.ts';
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{ boat: any; position: { x: number; y: number } }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
const SPEED_UNITS = {
knots: { label: 'KTS', convert: (v: number) => v.toFixed(1) },
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
}
const UNIT_KEY = 'at_units'
function loadUnits() {
const s = localStorage.getItem(UNIT_KEY)
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
}
const prefs = ref(loadUnits())
function cycleSpeed() {
const keys = Object.keys(SPEED_UNITS)
const p = loadUnits()
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
prefs.value = { ...p }
localStorage.setItem(UNIT_KEY, JSON.stringify(p))
}
function syncUnits(e: StorageEvent) {
if (e.key === UNIT_KEY) prefs.value = loadUnits()
}
const speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
const name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
const heading = computed(() => props.boat.heading ?? props.boat.bearing)
const course = computed(() => props.boat.cog ?? props.boat.course)
const speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
const photoError = ref(false)
// ── Drag ──────────────────────────────────────────────────────────────────────
const pos = ref({ ...props.position })
const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
const longPressTimer = ref<number | null>(null)
const header = ref<HTMLElement | null>(null)
function onMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('sp-close')) return
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: e.clientX, my: e.clientY, ex: pos.value.x, ey: pos.value.y }
e.preventDefault()
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
}
function onMouseUp() { isDragging.value = false }
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).classList.contains('sp-close')) return
const touch: any = e.touches[0]
const sx = touch.clientX, sy = touch.clientY
longPressTimer.value = window.setTimeout(() => {
emit('bringToFront')
isDragging.value = true
dragStart.value = { mx: sx, my: sy, ex: pos.value.x, ey: pos.value.y }
if (header.value) header.value.style.opacity = '0.8'
}, 500)
}
function onTouchMove(e: TouchEvent) {
if (!isDragging.value) return
const touch: any = e.touches[0]
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
e.preventDefault()
}
function onTouchEnd() {
if (longPressTimer.value) { clearTimeout(longPressTimer.value); longPressTimer.value = null }
if (isDragging.value && header.value) header.value.style.opacity = '1'
isDragging.value = false
}
onMounted(() => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
window.addEventListener('storage', syncUnits)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', onTouchEnd)
window.removeEventListener('storage', syncUnits)
})
const compass = computed(() => {
const hdg = heading.value
const course = props.boat.cog ?? hdg
const tapeTicks: string[] = []
for (let offset = -60; offset <= 60; offset += 5) {
const deg = ((hdg + offset) % 360 + 360) % 360
const x = 100 + (offset / 60) * 90
const major = offset % 30 === 0
const mid = offset % 15 === 0
if (major) {
const lbl = ({ 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' } as any)[deg]
?? String(deg).padStart(3, '0')
tapeTicks.push(`
<line x1="${x}" y1="22" x2="${x}" y2="38" stroke="#0ff" stroke-width="2"/>
<text x="${x}" y="50" text-anchor="middle" fill="#0ff" font-size="11" font-weight="bold">${lbl}</text>
`)
} else if (mid) {
tapeTicks.push(`<line x1="${x}" y1="26" x2="${x}" y2="38" stroke="#0ff" stroke-width="1.5"/>`)
} else {
tapeTicks.push(`<line x1="${x}" y1="30" x2="${x}" y2="38" stroke="#0ff" stroke-width="1"/>`)
}
}
const CX = 100, CY = 140
const R = 68
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
const rudX = CX + (rudder / 30) * 40
const courseRel = ((course - hdg + 540) % 360) - 180
const courseRad = courseRel * Math.PI / 180
const cNeedleX = CX + 52 * Math.sin(courseRad)
const cNeedleY = CY - 52 * Math.cos(courseRad)
const bearRel = ((course - hdg + 540) % 360) - 180
const bearRad = bearRel * Math.PI / 180
const bNeedleX = CX + 52 * Math.sin(bearRad)
const bNeedleY = CY - 52 * Math.cos(bearRad)
const ringTicks = Array.from({ length: 36 }, (_, i) => {
const a = i * 10
const rad = (a - 90) * Math.PI / 180
const r1 = a % 30 === 0 ? R - 11 : R - 5
const sw = a % 30 === 0 ? 2 : 1
const x1 = CX + r1 * Math.cos(rad)
const y1 = CY + r1 * Math.sin(rad)
const x2 = CX + R * Math.cos(rad)
const y2 = CY + R * Math.sin(rad)
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#0ff" stroke-width="${sw}" opacity="0.5"/>`
}).join('')
const cardinals = (['N','E','S','W'] as const).map((lbl, i) => {
const a = i * 90
const rad = (a - 90) * Math.PI / 180
const x = CX + (R - 18) * Math.cos(rad)
const y = CY + (R - 18) * Math.sin(rad) + 4
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
}).join('')
return `
<svg width="200" height="210" viewBox="0 0 200 210">
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/>
<line x1="10" y1="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
${tapeTicks.join('')}
<polygon points="100,20 96,12 104,12" fill="#ff0"/>
<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
<circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
<g transform="rotate(${-hdg}, ${CX}, ${CY})">
${ringTicks}
${cardinals}
</g>
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
<polygon points="${CX},${CY - 9} ${CX - 4},${CY + 5} ${CX},${CY + 2} ${CX + 4},${CY + 5}" fill="#fff" stroke="#0ff" stroke-width="1"/>
<rect x="80" y="${CY + 23}" width="40" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
<rect x="${rudX - 4}" y="${CY + 22}" width="8" height="8" fill="#ff0" rx="1"/>
<line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
<text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
<line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
<text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
</svg>
`
})
</script>
<style scoped>
.ship-popup {
position: fixed;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(0,255,255,0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.sp-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(0,255,255,0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
}
.sp-name {
margin: 0 24px 0 0;
color: #7eeee1;
font-size: 16px;
}
.sp-mmsi { color: white; }
.sp-meta {
font-size: 11px;
color: #888;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
}
.sp-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.sp-close:hover { background: rgba(255,255,255,0.35); }
/* ── Photo ── */
.sp-photo-wrap {
width: 100%;
height: 140px;
background: #111;
border-bottom: 1px solid rgba(200,200,220,0.15);
overflow: hidden;
}
.sp-photo {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.sp-body {
display: flex;
gap: 16px;
padding: 12px 16px;
}
.sp-gauges { flex: 0 0 110px; display: flex; flex-direction: column; gap: 0; }
.sp-gauge-wrap { margin-bottom: 12px; }
.sp-gauge-label {
color: #888;
font-size: 11px;
font-weight: bold;
margin-bottom: 3px;
}
.sp-gauge {
padding: 6px;
background: rgba(0,0,0,0.95);
border: 2px solid #0ff;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
transition: border-color 0.15s;
}
.sp-gauge:hover { border-color: #0f0; }
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
.sp-compass { flex: 1; margin-top: 0.25rem; }
</style>
<template>
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="sp-name">
🚢
<span>{{ name.toUpperCase() }}</span>
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
</h3>
<button class="sp-close" @click.stop="emit('close')">×</button>
</div>
<div class="sp-meta">
<div class="flex-c flex-fill">
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
<span>{{ boat.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c flex-fill align-x-end">
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
</div>
</div>
<!-- Photo -->
<div v-if="!photoError" class="sp-photo-wrap">
<img class="sp-photo" :src="BASE + '/api/ais/' + boat.mmsi + '/image'" :alt="name" @error="photoError = true" />
</div>
<div class="sp-body">
<div class="sp-gauges">
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Speed</div>
<div class="sp-gauge" @click.stop="cycleSpeed">
<span class="sp-gauge-val">{{ speedVal }}</span>
<span class="sp-gauge-unit">{{ speed.label }}</span>
</div>
</div>
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Course</div>
<div class="sp-gauge">
<span class="sp-gauge-val">{{ course != null ? course.toFixed(0) : '—' }}</span>
<span v-if="course != null" class="sp-gauge-unit">°</span>
</div>
</div>
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Heading</div>
<div class="sp-gauge">
<span class="sp-gauge-val">{{ heading != null ? heading.toFixed(0) : '—' }}</span>
<span v-if="heading != null" class="sp-gauge-unit">°</span>
</div>
</div>
</div>
<div class="sp-compass" v-html="compass"/>
</div>
</div>
</template>
+38 -40
View File
@@ -1,12 +1,11 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import { inject, onMounted, ref, computed } from 'vue';
import { api } from '../services/api';
import {getUVLabel} from '@/services/units.ts';
import {inject, computed } from 'vue';
import {useWeather} from '../services/api';
const data = ref<any>(null);
const openGraph = inject<(key: string) => void>('openGraph');
onMounted(async () => data.value = await api.current());
const {current: data} = useWeather();
const W = 320, H = 120, HORIZON = 45;
@@ -22,47 +21,42 @@ const arc = computed(() => {
const start = noon - 12 * 3600 * 1000;
const end = noon + 12 * 3600 * 1000;
const pct = Math.max(0, Math.min(1, (now - start) / (end - start)));
const riseT = (rise - start) / (end - start);
const setT = (set - start) / (end - start);
const dayPct = Math.max(0, Math.min(1, (now - rise) / dayLen));
const sunT = riseT + dayPct * (setT - riseT);
// After sunset, continue tracking sun below horizon
const totalPct = Math.max(0, Math.min(1, (now - start) / (end - start)));
const x1 = 0, x2 = W;
const totalW = x2 - x1;
const amplitude = H * 0.45;
const midY = H / 2;
// Sine curve: below horizon at edges, peaks above at noon
const getPoint = (t: number) => ({
x: x1 + totalW * t,
y: HORIZON - Math.sin(t * Math.PI * 2 - Math.PI / 2) * (HORIZON) + (HORIZON)
// Simpler: just a single-hump arc above horizon + dip below
});
const sineY = (t: number) => midY - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
const horizonY = sineY(riseT);
// Generate points along full sine path
const pts = Array.from({ length: 201 }, (_, i) => {
const t = i / 200;
const x = x1 + totalW * t;
// Single smooth S: -sin gives us below->above->below
const amplitude = (HORIZON);
const y = HORIZON - Math.sin(t * Math.PI * 2 - Math.PI / 2) * amplitude;
return { x, y };
return { x: x1 + totalW * t, y: sineY(t) };
});
const splitIdx = Math.round(pct * 200);
const { x: cx, y: cy } = <any>pts[splitIdx];
const splitIdx = Math.round(totalPct * 200);
const { x: cx, y: cy } = <any>pts[Math.min(splitIdx, 200)];
// Filled traveled path: close down to horizon baseline for fill
const traveledPts = pts.slice(0, splitIdx + 1);
const filledPath = traveledPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ')
+ ` L${(<any>traveledPts[traveledPts.length - 1]).x.toFixed(1)},${HORIZON}`
+ ` L${x1},${HORIZON} Z`;
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`;
// Remaining path stroke only
const remainPts = pts.slice(splitIdx);
const remainPath = remainPts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
// Sunrise / sunset x positions
const riseX = x1 + totalW * ((rise - start) / (end - start));
const setX = x1 + totalW * ((set - start) / (end - start));
const noonX = x1 + totalW * 0.5;
const riseX = x1 + totalW * riseT;
const setX = x1 + totalW * setT;
return { filledPath, remainPath, cx, cy, pct, riseX, setX, noonX };
return { filledPath, remainPath, cx, cy, pct: dayPct, riseX, setX, horizonY };
});
</script>
@@ -70,6 +64,9 @@ const arc = computed(() => {
<div class="sun-card">
<div class="card-title">🌅 Sun</div>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 3"><Loading /></div>
<template v-else>
<!-- Top labels -->
<div class="sun-top-labels" v-if="data">
<div>
@@ -88,10 +85,11 @@ const arc = computed(() => {
<!-- 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="HORIZON" :x2="W" :y2="HORIZON" stroke="var(--border)" stroke-width="1" />
<line :x1="0" :y1="arc.horizonY" :x2="W" :y2="arc.horizonY" stroke="var(--border)" stroke-width="1" />
<!-- Sun dot -->
<circle v-if="data" :cx="arc.cx" :cy="arc.cy" r="8" fill="#f59e0b" class="sun-dot" />
</svg>
</template>
<div class="divider" />
@@ -99,17 +97,17 @@ const arc = computed(() => {
<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 class="fg-muted">hrs</span></span>
<span v-else class="stat-value">{{ data.daylight?.toFixed(2) ?? '—' }} <span v-if="data.daylight" class="fg-muted">hrs</span></span>
</div>
<div class="stat" @click="openGraph?.('lux')">
<span class="stat-label">Lux</span>
<span class="stat-label">Brightness</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ data.lux }}</span>
<span v-else class="stat-value">{{ data.lux ?? '—' }} <span v-if="data.lux != null" class="fg-muted">lm/m²</span></span>
</div>
<div class="stat" @click="openGraph?.('solar_wm2')">
<span class="stat-label">Solar W/</span>
<span class="stat-label">Irradiance</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ data.solar_wm2 }}</span>
<span v-else class="stat-value">{{ data.solar_wm2 ?? '—' }} <span v-if="data.lux != null" class="fg-muted">W/m²</span></span>
</div>
</div>
@@ -119,17 +117,17 @@ const arc = computed(() => {
<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 class="fg-muted">({{ data.uv_index_label }})</span></span>
<span v-else class="stat-value">{{ data.uv_index?.toFixed(1) ?? '—' }} <span v-if="data.uv_index != null" class="fg-muted">({{ getUVLabel(data.uv_index) }})</span></span>
</div>
<div 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 class="fg-muted">J/</span></span>
<span v-else class="stat-value">{{ data.uv_dose?.toFixed(0) ?? '—' }} <span v-if="data.uv_dose != null" class="fg-muted">J/m²</span></span>
</div>
<div 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>
<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>
+542
View File
@@ -0,0 +1,542 @@
<script setup lang="ts">
import {formatDate} from '@ztimson/utils';
import { ref, computed, onMounted, onUnmounted } from 'vue'
import Altitude from '@/components/Altitude.vue'
const props = defineProps<{
history: any[]
position: { x: number; y: number }
}>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
// ── Units ─────────────────────────────────────────────────────────────────────
const BASE_SPEED_UNITS = {
knots: { label: 'KTS', convert: (v: number) => Math.round(v) },
kph: { label: 'KPH', convert: (v: number) => Math.round(v * 3.6) },
mph: { label: 'MPH', convert: (v: number) => Math.round(v * 2.23694) },
}
const BASE_ALTITUDE_UNITS = {
meters: { label: 'M', convert: (v: number) => Math.round(v) },
feet: { label: 'FT', convert: (v: number) => Math.round(v * 3.28084) },
}
const BASE_VERTICAL_UNITS = {
mps: { label: 'M/S', convert: (v: number) => Number(v.toFixed(1)) },
fps: { label: 'FT/S', convert: (v: number) => Number((v * 3.28084).toFixed(1)) },
}
const UNIT_KEY = 'at_units'
function loadUnits() {
const s = localStorage.getItem(UNIT_KEY)
return s
? JSON.parse(s)
: { speed: 'knots', altitude: 'meters', vertical: 'mps' }
}
const prefs = ref(loadUnits())
function saveAndSet(p: any) {
prefs.value = { ...p }
localStorage.setItem(UNIT_KEY, JSON.stringify(prefs.value))
}
function cycleSpeed() {
const keys = Object.keys(BASE_SPEED_UNITS)
const p = loadUnits()
p.speed = keys[(keys.indexOf(p.speed) + 1) % keys.length]
saveAndSet(p)
}
function cycleAltitude() {
const keys = Object.keys(BASE_ALTITUDE_UNITS)
const p = loadUnits()
p.altitude = keys[(keys.indexOf(p.altitude) + 1) % keys.length]
p.vertical = p.altitude === 'meters' ? 'mps' : 'fps'
saveAndSet(p)
}
function cycleVertical() {
const keys = Object.keys(BASE_VERTICAL_UNITS)
const p = loadUnits()
p.vertical = keys[(keys.indexOf(p.vertical) + 1) % keys.length]
p.altitude = p.vertical === 'mps' ? 'meters' : 'feet'
saveAndSet(p)
}
function syncUnits(e: StorageEvent) {
if (e.key === UNIT_KEY) prefs.value = loadUnits()
}
const speed = computed(() =>
BASE_SPEED_UNITS[prefs.value.speed as keyof typeof BASE_SPEED_UNITS]
)
const altitudeUnit = computed(() =>
BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS]
)
const vertical = computed(() =>
BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS]
)
// ── Data ──────────────────────────────────────────────────────────────────────
const latest = computed(() => props.history[props.history.length - 1])
const altitude = computed(() => {
if (latest.value?.altitude == null) return null
return altitudeUnit.value.convert(Number(latest.value.altitude))
})
const speedVal = computed(() => {
if (latest.value?.speed == null) return null
return speed.value.convert(Number(latest.value.speed))
})
const climbVal = computed(() => {
if (latest.value?.climb == null) return null
const v = vertical.value.convert(Number(latest.value.climb))
return v >= 0 ? `+${v}` : String(v)
})
const altitudeHistory = computed(() => props.history.map(p => ({
...p,
altitude: p.altitude != null
? altitudeUnit.value.convert(Number(p.altitude))
: null,
})))
const currentAltitude = computed(() =>
latest.value?.altitude != null
? altitudeUnit.value.convert(Number(latest.value.altitude))
: null
)
// ── Drag ──────────────────────────────────────────────────────────────────────
const pos = ref({ ...props.position })
const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
const longPressTimer = ref<number | null>(null)
const header = ref<HTMLElement | null>(null)
function onMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('tp-close')) return
emit('bringToFront')
isDragging.value = true
dragStart.value = {
mx: e.clientX,
my: e.clientY,
ex: pos.value.x,
ey: pos.value.y
}
e.preventDefault()
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
pos.value.x = dragStart.value.ex + (e.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (e.clientY - dragStart.value.my)
}
function onMouseUp() {
isDragging.value = false
}
function onTouchStart(e: TouchEvent) {
if ((e.target as HTMLElement).classList.contains('tp-close')) return
const touch: any = e.touches[0]
const sx = touch.clientX
const sy = touch.clientY
longPressTimer.value = window.setTimeout(() => {
emit('bringToFront')
isDragging.value = true
dragStart.value = {
mx: sx,
my: sy,
ex: pos.value.x,
ey: pos.value.y
}
if (header.value) header.value.style.opacity = '0.8'
}, 500)
}
function onTouchMove(e: TouchEvent) {
if (!isDragging.value) return
const touch: any = e.touches[0]
pos.value.x = dragStart.value.ex + (touch.clientX - dragStart.value.mx)
pos.value.y = dragStart.value.ey + (touch.clientY - dragStart.value.my)
e.preventDefault()
}
function onTouchEnd() {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (isDragging.value && header.value) {
header.value.style.opacity = '1'
}
isDragging.value = false
}
onMounted(() => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
window.addEventListener('storage', syncUnits)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', onTouchEnd)
window.removeEventListener('storage', syncUnits)
})
</script>
<style scoped>
.sonde-popup {
position: fixed;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(255,170,0,0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.tp-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(255,170,0,0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
}
.tp-name {
margin: 0 24px 0 0;
color: #ffaa00;
font-size: 16px;
}
.tp-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.tp-close:hover {
background: rgba(255,255,255,0.35);
}
.tp-meta {
font-size: 11px;
color: #888;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid rgba(255,170,0,0.2);
}
.flex-c {
display: flex;
flex-direction: column;
gap: 3px;
}
.align-x-end {
align-items: flex-end;
}
.tp-body {
display: flex;
gap: 16px;
padding: 12px 16px;
}
.tp-gauges {
flex: 0 0 110px;
}
.tp-gauge-wrap {
margin-bottom: 12px;
}
.tp-gauge-label {
color: #888;
font-size: 11px;
font-weight: bold;
margin-bottom: 3px;
}
.tp-gauge {
padding: 6px;
background: rgba(0,0,0,0.95);
border: 2px solid #ffaa00;
border-radius: 3px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
cursor: pointer;
}
.tp-gauge:hover {
border-color: #fff;
}
.tp-gauge-val {
font-size: 20px;
font-weight: bold;
color: #ffaa00;
}
.tp-gauge-unit {
font-size: 11px;
color: #ffaa00;
margin-bottom: 0.25em;
}
.tp-environment {
flex: 1;
min-width: 0;
}
.tp-environment-label {
color: #888;
font-size: 11px;
font-weight: bold;
margin-bottom: 3px;
}
.tp-environment-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.tp-environment-card {
min-height: 42px;
padding: 6px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
display: flex;
align-items: center;
gap: 7px;
}
.tp-environment-icon {
width: 26px;
height: 26px;
flex: 0 0 26px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,170,0,0.08);
border-radius: 3px;
font-size: 14px;
}
.tp-environment-info {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.tp-environment-stat-label {
color: #888;
font-size: 10px;
font-weight: bold;
}
.tp-environment-stat-value {
color: white;
font-size: 13px;
font-weight: bold;
white-space: nowrap;
}
.ok {
color: #3fdb6d !important;
}
.bad {
color: #e0475a !important;
}
</style>
<template>
<div
class="sonde-popup"
:style="{ left: pos.x + 'px', top: pos.y + 'px' }"
>
<div
ref="header"
class="tp-header"
@mousedown="onMouseDown"
@touchstart.passive="onTouchStart"
>
<h3 class="tp-name">🎈 {{ latest?.id || 'Unknown Sonde' }}</h3>
<button
class="tp-close"
@click.stop="emit('close')"
></button>
</div>
<div class="tp-meta">
<div class="flex-c flex-fill">
<span>Type: {{ latest?.type || 'Unknown' }}</span>
<span>Freq: {{ latest?.freqMHz?.toFixed(2) || '—' }} MHz</span>
</div>
<div class="flex-c flex-fill align-x-end">
<span>Bat: {{ latest?.battery ?? '—' }} V</span>
<span>SNR: {{ latest?.snr ?? '—' }} dB</span>
</div>
</div>
<div class="tp-body">
<div class="tp-gauges">
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Air Speed</div>
<div
class="tp-gauge"
@click.stop="cycleSpeed"
>
<span class="tp-gauge-val">
{{ speedVal ?? '—' }}
</span>
<span class="tp-gauge-unit">
{{ speed.label }}
</span>
</div>
</div>
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Altitude</div>
<div
class="tp-gauge"
@click.stop="cycleAltitude"
>
<span class="tp-gauge-val">
{{ altitude != null ? altitude : '—' }}
</span>
<span class="tp-gauge-unit">
{{ altitudeUnit.label }}
</span>
</div>
</div>
<div class="tp-gauge-wrap">
<div class="tp-gauge-label">Climb</div>
<div
class="tp-gauge"
@click.stop="cycleVertical"
>
<span class="tp-gauge-val">
{{ climbVal ?? '—' }}
</span>
<span class="tp-gauge-unit">
{{ vertical.label }}
</span>
</div>
</div>
</div>
<div class="tp-environment">
<div class="tp-environment-label">Environment</div>
<div class="tp-environment-cards gap-1">
<div class="tp-environment-card">
<span class="tp-environment-icon">🌡</span>
<div class="tp-environment-info">
<span class="tp-environment-stat-label">Temperature</span>
<span class="tp-environment-stat-value">
{{ latest?.temperature ?? '—' }} °C
</span>
</div>
</div>
<div class="tp-environment-card">
<span class="tp-environment-icon">💧</span>
<div class="tp-environment-info">
<span class="tp-environment-stat-label">Humidity</span>
<span class="tp-environment-stat-value">
{{ latest?.humidity ?? '—' }} %
</span>
</div>
</div>
<div class="tp-environment-card">
<span class="tp-environment-icon"></span>
<div class="tp-environment-info">
<span class="tp-environment-stat-label">Pressure</span>
<span class="tp-environment-stat-value">
{{ latest?.pressure > 0 ? latest.pressure : '—' }} hpa
</span>
</div>
</div>
<div class="tp-environment-card">
<span class="tp-environment-icon">🫧</span>
<div class="tp-environment-info">
<span class="tp-environment-stat-label">PPM</span>
<span class="tp-environment-stat-value">
{{ latest?.ppm?.toFixed(2) ?? '—' }}
</span>
</div>
</div>
</div>
</div>
</div>
<Altitude
:history="altitudeHistory"
:currentAltitude="currentAltitude"
:unit="altitudeUnit.label"
start-time=" "
:end-time="formatDate('HH:mm:ss', latest.timestamp)"
color="#ffaa00"
/>
</div>
</template>
+7 -9
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import { inject, onMounted, ref } from 'vue';
import { api } from '../services/api';
import {inject} from 'vue';
import {useWeather} from '../services/api';
const d = ref<any>(null);
const openGraph = inject<(key: string) => void>('openGraph');
const {current: d} = useWeather();
function dir(deg: number) {
if(deg > 22.5 && deg <= 67.5) return 'North-East';
@@ -16,8 +16,6 @@ function dir(deg: number) {
if(deg > 292.5 && deg <= 337.5) return 'North-West';
return 'North';
}
onMounted(async () => d.value = await api.current());
</script>
<style scoped lang="scss">
@@ -70,24 +68,24 @@ onMounted(async () => d.value = await api.current());
</style>
<template>
<div class="wind-card" v-if="d">
<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) }} <span class="fg-muted">Kph</span></span>
<span v-else class="stat-value">{{ d.wind_speed != null ? ~~(d.wind_speed) : '—' }} <span v-if="d.wind_speed != null" class="fg-muted">Kph</span></span>
</div>
<div 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) }} <span class="fg-muted">Kph</span></span>
<span v-else class="stat-value">{{ d.wind_gusts != null ? ~~(d.wind_gusts) : '—' }} <span v-if="d.wind_gusts != null" class="fg-muted">Kph</span></span>
</div>
<div 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">{{ dir(d.wind_direction) }}</span>
<span v-else class="stat-value">{{ (d.wind_speed || d.wind_gusts) ? dir(d.wind_direction) : '—' }}</span>
</div>
</div>
</div>
+254 -359
View File
@@ -1,249 +1,119 @@
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'
import {adjustedInterval} from '@ztimson/utils';
import {greatCircleDist} from '@/services/units.ts';
import {h, render as vueRender} 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, sortByProp} from '@ztimson/utils';
import AircraftPopup from '@/components/Aircraft.vue';
import {bringToFront} from './zindex';
const API = BASE + '/api'
const API = BASE + '/api';
const AUTO_RANGE = 92.6;
const COLORS: Record<string, string> = {
'cargo': '#00ff00',
'military': '#ff0000',
'private': '#8450ea',
'passenger': '#009dff',
'unknown': '#fad106',
function isPriorityAircraft(plane: any): boolean {
if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true;
const text = [
plane.type,
plane.category,
plane.aircraft_type,
plane.operator,
plane.owner,
plane.flight,
plane.callsign,
plane.description,
].filter(Boolean).join(' ').toLowerCase();
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|usaf|rcaf)\b/.test(text);
}
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]
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)]
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]
const r = (a - 25000) / 15000;
return [0, 0, Math.round(255 * (1 - r) + 139 * r)];
}
function buildPlaneIcon(plane: any, shapes: any): string {
const color = COLORS[plane.type] || COLORS['unknown']
const shape = shapes[plane.aircraft?.toLowerCase()] || shapes['unknown'];
const paths = (Array.isArray(shape.path) ? shape.path : [shape.path]).map((p: string) => `<path d="${p}" fill="${color}" stroke="#000" stroke-width="${shape.stroke || 1}"/>`).join('')
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${shape.w * 2}" height="${shape.h * 2}" viewBox="${shape.viewBox}">${paths}</svg>`)}`
}
function buildNavballSvg(data: any): string {
const heading = data.heading ?? 0
const airspeedKnots = data.speed || 0
const verticalRateFps = data.climb || 0
const groundSpeedFps = airspeedKnots * 1.68781
const pitchRad = groundSpeedFps > 0 ? Math.atan(verticalRateFps / groundSpeedFps) : 0
const pitch = Math.max(-25, Math.min(25, pitchRad * (180 / Math.PI)))
const pitchLines: string[] = []
for (let p = -30; p <= 30; p += 5) {
if (p === 0) continue
const isMajor = p % 10 === 0
const [start, end] = [85, 115]
if (isMajor) {
pitchLines.push(`
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
`)
} else {
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
}
}
const rollTicks: string[] = []
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>
`
const r = Math.min((a - 40000) / 10000, 1);
return [Math.round(128 * r), 0, 139];
}
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 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
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}/vehicles`).then(r => r.json())
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
this.map.addLayer(this.layer)
await this._fetch()
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
await this._fetch()
this._draw()
this._refreshPopups()
}, 1_000)
constructor(map: Map) {
this.map = map;
}
hide() {
if (!this.visible) return
this.visible = false
private _attachClick() {
this.clickHandler = evt => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
if (!f) return;
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
const plane = f.get('planeData');
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 = {}
if (this.popups[plane.icao]) {
this.closePopup(plane.icao);
return;
}
// ── Private ───────────────────────────────────────────────────────────────
this.openPopup(plane);
};
this.map.on('singleclick', this.clickHandler);
}
private _draw() {
const source = this.layer.getSource()!;
source.clear();
for (const plane of this.data) {
if (plane.latitude == null || plane.longitude == null) continue;
const coord = fromLonLat([plane.longitude, plane.latitude]);
const f = new Feature({geometry: new Point(coord)});
f.set('icao', plane.icao);
f.set('planeData', plane);
f.setStyle(new Style({
image: new Icon({
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
scale: 1.25,
rotation: (plane.heading ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}));
source.addFeature(f);
}
}
private async _fetch() {
const j = await fetch(`${API}/adsb`).then(r => r.json())
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
this.data = (j || []).map((a: any) => ({
...a,
icao: a.hex,
@@ -253,164 +123,189 @@ export class AirTrafficLayer {
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: buildPlaneIcon(plane, this.shapes),
scale: 1,
rotation: (plane.heading ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}))
source.addFeature(f)
}
}));
}
private async _fetchTrace(plane: any) {
const icao = plane.icao
const icao = plane.icao;
if (!this.historyCache[icao]) {
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
this.historyCache[icao] = j.history || []
const j = await fetch(`${API}/adsb/${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
const history = this.historyCache[icao];
const cur = {
ts: Math.floor(Date.now() / 1000),
latitude: plane.latitude,
longitude: plane.longitude,
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
live: true,
};
let traceLayer = this.traceLayers[icao]
let vs: VectorSource
const last = history[history.length - 1];
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
const traceHistory = history;
if (traceHistory.length < 2) {
const popup = this.popups[icao];
if (popup) popup.update(plane, history);
return;
}
let traceLayer = this.traceLayers[icao];
let vs: VectorSource;
if (!traceLayer) {
vs = new VectorSource()
traceLayer = new VectorLayer({ source: vs, zIndex: 99 })
this.map.addLayer(traceLayer)
this.traceLayers[icao] = traceLayer
this.traceSegs[icao] = []
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))
vs = traceLayer.getSource()!;
}
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
const segs: any = this.traceSegs[icao];
for (let i = segs.length; i < traceHistory.length - 1; i++) {
const s = traceHistory[i], e = traceHistory[i + 1];
const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
const f = new Feature(new LineString([
fromLonLat([s.longitude, s.latitude]),
fromLonLat([e.longitude, e.latitude]),
]));
f.setStyle(new Style({
stroke: new Stroke({
color: `rgba(${avg.join(',')},0.8)`,
width: 3,
}),
}));
vs.addFeature(f);
segs.push(f);
}
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]
const popup = this.popups[icao];
if (popup) popup.update(plane, history);
}
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)
const plane = this.data.find(p => p.icao === icao);
if (!plane) {
this.closePopup(icao);
continue;
}
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)
hide() {
if(!this.visible) return;
this.visible = false;
if(this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
this.map.on('singleclick', this.clickHandler)
if(this.clickHandler) {
this.map.un('singleclick', this.clickHandler);
this.clickHandler = null;
}
for(const icao of Object.keys(this.popups)) this.closePopup(icao);
if(this.layer) this.map.removeLayer(this.layer);
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l));
this.traceLayers = {};
this.traceSegs = {};
}
async show() {
if(this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 1_000);
}
getById(icao: string): any | null {
return this.data.find(p => String(p.icao) === String(icao)) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
function score(plane: any): number {
const ratio = Math.min(greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) / AUTO_RANGE, 1);
return isPriorityAircraft(plane) ? 2 - ratio : 1 - ratio;
}
return this.data.map(plane => ({...plane, score: score(plane)})).toSorted(sortByProp('score', true));
}
openPopup(plane: any) {
const icao = String(plane.icao);
if(this.popups[icao]) return;
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (p: any, history: any[] = []) => ({
plane: p,
history,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(icao),
onBringToFront: () => bringToFront(container),
});
vueRender(h(AircraftPopup, makeProps(plane, this.historyCache[icao] || [])), container);
this.popups[icao] = {
update: (p: any, history: any[] = []) => vueRender(h(AircraftPopup, makeProps(p, history)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
this._fetchTrace(plane);
}
closePopup(icao: string) {
const popup = this.popups[icao];
if(popup) {
popup.unmount();
delete this.popups[icao];
}
const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
if(segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
if(layer) {
this.map.removeLayer(layer);
delete this.traceLayers[icao];
}
delete this.traceSegs[icao];
}
}
+172 -168
View File
@@ -1,30 +1,53 @@
import {BASE} from '@/services/api.ts';
import Map from 'ol/Map'
import { Feature } from 'ol'
import Point from 'ol/geom/Point'
import { fromLonLat } from 'ol/proj'
import { Style, Icon, Text, Fill, Stroke } from 'ol/style'
import { Vector as VectorLayer } from 'ol/layer'
import { Vector as VectorSource } from 'ol/source'
import { adjustedInterval } from '@ztimson/utils'
import {greatCircleDist} from '@/services/units.ts';
import {h, render as vueRender} 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, sortByProp} from '@ztimson/utils';
import ShipPopup from '@/components/Ship.vue';
import {bringToFront} from './zindex';
const API = BASE + '/api'
const API = BASE + '/api';
const AUTO_RANGE = 100;
const SHIP_COLORS: Record<string, string> = {
'Base Station': '#00aaff',
'Base Station': '#ffffff',
'Class A': '#00ff00',
'Class B': '#8450ea',
'SAR Aircraft': '#ff6600',
'Aid to Navigation': '#ffff00',
'Class B CS': '#8450ea',
'Sart/Epirb/MOB': '#ff0000',
'SAR Aircraft': '#ff0000',
'AtoN': '#ffffff',
'Class B/CS': '#8450ea',
'Sart/Epirb/MOB': '#00aaff',
'Unknown': '#fad106',
};
function isPriorityBoat(boat: any): boolean {
if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
const text = [
boat.type,
boat.vesselType,
boat.category,
boat.name,
boat.shipname,
boat.operator,
boat.owner,
boat.callsign,
boat.description,
].filter(Boolean).join(' ').toLowerCase();
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|rcaf)\b/.test(text);
}
function buildBoatIcon(boat: any): string {
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown']
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'];
if (boat.type === 'Base Station') {
if (['Base Station', 'AtoN'].includes(boat.type)) {
return `data:image/svg+xml,${encodeURIComponent(`
<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"/>
@@ -33,198 +56,179 @@ function buildBoatIcon(boat: any): string {
<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,28 12,22 0,28" fill="${color}" stroke="#000" stroke-width="1.5"/>
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
<circle cx="12" cy="12" r="1.5" fill="#000"/>
<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>
`)}`
}
function buildPopupHtml(boat: any): string {
const name = boat.name?.trim() || boat.callsign?.trim() || `MMSI: ${boat.mmsi}`
const rows: [string, any][] = [
['Type', boat.type],
['MMSI', boat.mmsi],
['Country', boat.country],
['Callsign', boat.callsign || '-'],
['IMO', boat.imo || '-'],
['Ship Type', boat.ship_type ?? '-'],
['Speed', boat.speed != null ? `${boat.speed} kts` : '-'],
['Heading', boat.heading != null ? `${boat.heading}°` : '-'],
['Course', boat.course != null ? `${boat.course}°` : '-'],
['Lat / Lon', `${boat.lat?.toFixed(5)}, ${boat.lon?.toFixed(5)}`],
['Distance', `${boat.distance?.toFixed(2)} nmi`],
['Bearing', `${boat.bearing}°`],
['Destination', boat.destination || '-'],
['ETA', boat.eta || '-'],
['Draught', boat.draught != null ? `${boat.draught}m` : '-'],
['RSSI', `${boat.rssi?.toFixed(1)} dB`],
['Drift', `${boat.drift?.toFixed(2)} ppm`],
['Msg Types', boat.msg_type?.join(', ') || '-'],
['Channels', boat.channels || '-'],
['Sources', boat.sources],
['Receiver', boat.receiver],
['Count', boat.count],
['Last Signal', boat.last_signal != null ? `${boat.last_signal}s ago` : '-'],
['In Range', boat.in_range],
]
return `
<div style="font-family:monospace;color:#ccc;min-width:280px">
<div style="padding:12px 16px;border-bottom:1px solid rgba(200,200,220,0.2)">
<h3 style="margin:0;color:#7eeee1;font-size:16px">🚢 ${name}</h3>
<div style="font-size:11px;color:#888;margin-top:4px">${boat.type || 'Unknown'}</div>
</div>
<div style="padding:12px 16px;display:grid;grid-template-columns:1fr 1fr;gap:6px 16px">
${rows.map(([label, val]) => `
<div>
<div style="color:#888;font-size:10px;font-weight:bold">${label}</div>
<div style="color:#0f0;font-size:13px">${val ?? '-'}</div>
</div>
`).join('')}
</div>
</div>
`
`)}`;
}
export class AISLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
private popups: Record<string, { el: HTMLDivElement, destroy: () => void }> = {}
private data: any[] = []
private clickHandler: ((e: any) => void) | null = null
private refreshTimer: any = null
visible = false
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 }
// ── Public ────────────────────────────────────────────────────────────────
async show() {
if (this.visible) return
this.visible = true
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
this.map.addLayer(this.layer)
await this._fetch()
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
await this._fetch()
this._draw()
this._refreshPopups()
}, 5_000)
constructor(map: Map) {
this.map = map;
}
hide() {
if (!this.visible) return
this.visible = false
private _attachClick() {
this.clickHandler = evt => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
if (!f) return;
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
const boat = f.get('boatData');
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
this.map.removeLayer(this.layer)
if (this.popups[boat.mmsi]) {
this.closePopup(boat.mmsi);
return;
}
// ── Private ───────────────────────────────────────────────────────────────
this.openPopup(boat);
};
private async _fetch() {
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
this.map.on('singleclick', this.clickHandler);
}
private _draw() {
const source = this.layer.getSource()!
source.clear()
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)
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: 1,
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
scale: 0.8,
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}))
source.addFeature(f)
}));
source.addFeature(f);
}
}
private _openPopup(boat: any) {
const mmsi = String(boat.mmsi)
if (this.popups[mmsi]) return
const el = document.createElement('div')
el.style.cssText = `
position:absolute; z-index:9999; pointer-events:auto;
background:rgba(0,0,0,0.88); border:1px solid rgba(200,200,220,0.3);
border-radius:8px; box-shadow:0 4px 24px rgba(0,0,0,0.5); overflow:hidden;
`
const close = document.createElement('button')
close.innerHTML = '✕'
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
close.onclick = () => this._closePopup(mmsi)
el.innerHTML = buildPopupHtml(boat)
el.appendChild(close)
document.body.appendChild(el)
this._positionPopup(el, boat)
this.popups[mmsi] = { el, destroy: () => el.remove() }
}
private _positionPopup(el: HTMLDivElement, boat: any) {
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
if (!pixel) return
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
el.style.left = `${rect.left + pixel[0] + 16}px`
el.style.top = `${rect.top + pixel[1] - 16}px`
}
private _closePopup(mmsi: string) {
const popup = this.popups[mmsi]
if (popup) { popup.destroy(); delete this.popups[mmsi] }
private async _fetch() {
this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
}
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 }
const { el } = <any>this.popups[mmsi]
el.innerHTML = buildPopupHtml(boat)
const close = document.createElement('button')
close.innerHTML = '✕'
close.style.cssText = 'position:absolute;top:8px;right:8px;background:none;border:none;color:#888;font-size:14px;cursor:pointer;z-index:1'
close.onclick = () => this._closePopup(mmsi)
el.appendChild(close)
this._positionPopup(el, boat)
const boat = this.data.find(b => String(b.mmsi) === mmsi);
if (!boat) {
this.closePopup(mmsi);
continue;
}
this.popups[mmsi].update(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)
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);
if (this.layer) this.map.removeLayer(this.layer);
}
async show() {
if (this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 5_000);
}
getById(mmsi: string): any | null {
return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
function score(boat: any): number {
const ratio = Math.min(greatCircleDist(latitude, longitude, boat.lat, boat.lon) / AUTO_RANGE, 1);
return isPriorityBoat(boat) ? 2 - ratio : 1 - ratio;
}
return this.data.filter(b => b.lat != null && b.lon != null)
.map(boat => ({...boat, score: score(boat)}))
.toSorted(sortByProp('score', true));
}
openPopup(boat: any) {
const mmsi = String(boat.mmsi);
if (this.popups[mmsi]) return;
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (b: any) => ({
boat: b,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(mmsi),
onBringToFront: () => bringToFront(container),
});
vueRender(h(ShipPopup, makeProps(boat)), container);
this.popups[mmsi] = {
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
}
closePopup(mmsi: string) {
const popup = this.popups[mmsi];
if (popup) {
popup.unmount();
delete this.popups[mmsi];
}
this.map.on('singleclick', this.clickHandler)
}
}
+35 -6
View File
@@ -1,8 +1,16 @@
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
import {ref, onUnmounted} from 'vue';
export type DataRow = Record<string, number | string | null>
export 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);
@@ -18,14 +26,35 @@ async function get<T>(path: string, params: Record<string, string> = {}): Promis
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?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
...(start ? {start: start.toISOString().slice(0, 10)} : {}),
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
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: start.toISOString().slice(0, 10)} : {}),
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
...(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};
}
+104
View File
@@ -0,0 +1,104 @@
import 'ol/ol.css';
import {BASE} from '@/services/api.ts';
import {Feature} from 'ol';
import Point from 'ol/geom/Point';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import {Style} from 'ol/style';
export function normalize(coordinates: number | {lon: number} | {long: number} | {longitude: number}) {
const convert = (n) => {
if (n > 180) return n - 360;
if (n < -180) return n + 360;
return n;
}
if(typeof coordinates == 'number') return convert(coordinates);
if(typeof coordinates['lon'] == 'number') return convert(coordinates['lon']);
if(typeof coordinates['long'] == 'number') return convert(coordinates['long']);
if(typeof coordinates['longitude'] == 'number') return convert(coordinates['longitude']);
}
export class Aurora {
data: any = null;
layer;
map;
name = 'aurora';
refresh;
refreshRate = 60_000 * 60;
timestamp;
visible = false;
constructor(map) {
this.map = map;
}
private style(intensity: number) {
return new Style({renderer: (coordinates, state) => {
const ctx = state.context as CanvasRenderingContext2D;
const [x, y] = <[number, number]>coordinates;
const previousComposite = ctx.globalCompositeOperation;
ctx.globalCompositeOperation = 'screen';
const zoom = this.map.getView().getZoom() || 1;
const baseRadius = 50;
const radius = baseRadius * Math.pow(2, zoom - 3);
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
const alpha = intensity / 50;
gradient.addColorStop(0, `rgba(0, 255, 0, ${alpha})`);
gradient.addColorStop(1, 'rgba(0, 255, 0, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fill();
ctx.globalCompositeOperation = previousComposite;
}});
};
async update() {
const d = await fetch(BASE + '/api/aurora').then(resp => resp.json());
this.timestamp = new Date(d.timestamp).getTime();
this.data = d.data;
}
async redraw() {
const threshold = 5;
const features = (this.data?.coordinates || [])
.filter(([lon, lat, intensity]: [number, number, number], i) => i % 4 == 0 && lat != 0 && intensity >= threshold)
.map(([lon, lat, intensity]: [number, number, number]) => {
const feature = new Feature({geometry: new Point([normalize(lon), lat])});
const geom = feature.getGeometry() as Point;
geom.transform('EPSG:4326', this.map.getView().getProjection());
feature.setStyle(this.style(intensity));
return feature;
});
if(this.layer) this.map.removeLayer(this.layer);
if(!features.length) return;
this.layer = new VectorLayer({source: new VectorSource({features, wrapX: true}), zIndex: 5030, opacity: 0.4});
this.layer.set('name', this.name);
this.map.addLayer(this.layer);
}
async show() {
if(this.visible) return;
this.visible = true;
if(!this.data || Date.now() - this.timestamp > this.refreshRate) await this.update();
if(!this.visible) return;
await this.redraw();
if(!this.refresh) {
this.refresh = setInterval(async () => {
await this.update();
if(!this.visible) return;
await this.redraw();
}, this.refreshRate);
}
}
hide() {
if(!this.visible) return;
this.visible = false;
if(this.refresh) this.refresh = clearInterval(this.refresh);
this.map.getLayers().forEach(layer => {
if(layer?.get('name') == this.name) this.map.removeLayer(layer);
});
}
}
+8 -2
View File
@@ -11,11 +11,12 @@ 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) return
if(this.visible) this.hide(false);
this.visible = true
const points: [number, number][] = await fetch(`${BASE}/api/range`).then(r => r.json())
@@ -30,11 +31,16 @@ export class RangeLayer {
const source = new VectorSource({ features: [feature] })
this.layer = new VectorLayer({ source, zIndex: 50 })
this.map.addLayer(this.layer)
if(!this.interval) this.interval = setInterval(() => this.show, 60_000);
}
hide() {
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;
}
}
}
+306
View File
@@ -0,0 +1,306 @@
import {api, BASE} from '@/services/api.ts';
import {greatCircleDist} from '@/services/units.ts';
import {h, render as vueRender} from 'vue';
import Map from 'ol/Map';
import {Feature} from 'ol';
import {LineString, Point, Circle as CircleGeom} from 'ol/geom';
import {fromLonLat} from 'ol/proj';
import {Style, Stroke, Icon, Fill} from 'ol/style';
import {Vector as VectorLayer} from 'ol/layer';
import {Vector as VectorSource} from 'ol/source';
import {adjustedInterval, sortByProp} from '@ztimson/utils';
import Satellite from '@/components/Satellite.vue';
import {bringToFront} from './zindex';
const API = BASE + '/api';
const EARTH_R = 6371;
interface TinyGSReading {
id: number;
timestamp: number;
freqMHz: number;
satellite: string;
latitude: number;
longitude: number;
az: number;
el: number;
packetRssi: number;
packetSnr: number;
freqError: number;
crcOk: boolean;
}
interface TinyGSSatellite extends TinyGSReading {
history: TinyGSReading[];
}
interface RangeEstimate {
altitude: number;
slantRange: number;
horizonRadius: number;
groundDist: number;
}
function estimateRange(gsLat: number, gsLon: number, satLat: number, satLon: number, elDeg: number): RangeEstimate | null {
const d = greatCircleDist(gsLat, gsLon, satLat, satLon);
const gamma = d / EARTH_R;
const el = elDeg * Math.PI / 180;
const k = Math.cos(gamma) - Math.tan(el) * Math.sin(gamma);
if (k <= 0) return null;
const altitude = EARTH_R * (1 - k) / k;
const slantRange = Math.sqrt(EARTH_R ** 2 + (EARTH_R + altitude) ** 2 - 2 * EARTH_R * (EARTH_R + altitude) * Math.cos(gamma));
const horizonRadius = Math.sqrt(2 * EARTH_R * altitude + altitude ** 2);
return {altitude, slantRange, horizonRadius, groundDist: d};
}
function estimateEtaOut(history: TinyGSReading[]): number | null {
const pts: any = history.slice(-6);
if (pts.length < 2) return null;
const t0 = pts[0].timestamp;
const xs = pts.map(p => (p.timestamp - t0) / 1000);
const ys = pts.map(p => p.el);
const n = xs.length;
const sumX = xs.reduce((a, b) => a + b, 0);
const sumY = ys.reduce((a, b) => a + b, 0);
const sumXY = xs.reduce((a, x, i) => a + x * ys[i], 0);
const sumXX = xs.reduce((a, x) => a + x * x, 0);
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
if (!isFinite(slope) || slope >= 0) return null;
const lastEl: any = ys[ys.length - 1], lastX: any = xs[xs.length - 1];
const secsFromNow = (lastX - lastEl / slope) - lastX;
return secsFromNow > 0 ? secsFromNow * 1000 : null;
}
export class SatsLayer {
private map: Map;
private layer!: VectorLayer<VectorSource>;
private popups: any = {};
private receptionCircles: Record<string, VectorLayer<VectorSource>> = {};
private data: TinyGSSatellite[] = [];
private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null;
visible = false;
gs: any;
constructor(map: Map) {
this.map = map;
api.position().then(gs => this.gs = gs);
}
private _attachClick() {
this.clickHandler = evt => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('satData') ? f : null);
if (!f) return;
const sat = f.get('satData') as TinyGSSatellite;
if (this.popups[sat.id]) return this.closePopup(sat.id);
this.openPopup(sat);
};
this.map.on('singleclick', this.clickHandler);
}
private _draw() {
const source = this.layer.getSource()!;
source.clear();
for (const sat of this.data) {
if (sat.latitude == null || sat.longitude == null) continue;
const points: any = [...sat.history, sat].map(r => fromLonLat([r.longitude, r.latitude]));
if (points.length > 1) {
const trail = new Feature({geometry: new LineString(points)});
trail.setStyle(new Style({
stroke: new Stroke({
color: 'rgba(212,164,255,0.5)',
width: 2,
lineDash: [6, 4],
}),
}));
source.addFeature(trail);
}
const marker = new Feature({geometry: new Point(points[points.length - 1])});
marker.set('satellite', sat.id);
marker.set('satData', sat);
marker.setStyle(new Style({
image: new Icon({
src: '/satellite.png',
scale: 0.1,
anchor: [0.5, 0.5],
}),
}));
source.addFeature(marker);
}
}
private async _fetch() {
this.data = await fetch(`${API}/sats`).then(r => r.json()) || [];
}
private _calcRange(sat: TinyGSSatellite): RangeEstimate | null {
if (!this.gs) return null;
return estimateRange(this.gs.latitude, this.gs.longitude, sat.latitude, sat.longitude, sat.el);
}
private _drawReceptionCircle(sat: TinyGSSatellite, radiusKm?: number | null) {
this._removeReceptionCircle(sat.id);
if (!radiusKm) return;
const feature = new Feature({
geometry: new CircleGeom(fromLonLat([sat.longitude, sat.latitude]), radiusKm * 1000),
});
feature.setStyle(new Style({
stroke: new Stroke({
color: 'rgba(212,164,255,0.6)',
width: 2,
}),
fill: new Fill({color: 'rgba(212,164,255,0.12)'}),
}));
const layer = new VectorLayer({
source: new VectorSource({features: [feature]}),
zIndex: 104,
});
this.map.addLayer(layer);
this.receptionCircles[sat.id] = layer;
}
private _removeReceptionCircle(id: number) {
const layer = this.receptionCircles[id];
if (layer) {
this.map.removeLayer(layer);
delete this.receptionCircles[id];
}
}
private _refreshPopups() {
for (const id of Object.keys(this.popups).map(Number)) {
const sat = this.data.find(s => s.id === id);
if (!sat) {
this.closePopup(id);
continue;
}
const history = [...sat.history, sat];
const range = this._calcRange(sat);
this.popups[id].update(history, range, estimateEtaOut(history));
this._drawReceptionCircle(sat, range?.horizonRadius);
}
}
hide() {
if (!this.visible) return;
this.visible = false;
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
if (this.clickHandler) {
this.map.un('singleclick', this.clickHandler);
this.clickHandler = null;
}
for (const id of Object.keys(this.popups).map(Number)) this.closePopup(id);
if (this.layer) this.map.removeLayer(this.layer);
}
async show() {
if (this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 5_000);
}
getById(id: number): TinyGSSatellite | null {
return this.data.find(s => s.id === id) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): TinyGSSatellite[] {
const dist = (sat: TinyGSSatellite) => this._calcRange(sat)?.slantRange ?? greatCircleDist(latitude, longitude, sat.latitude, sat.longitude);
return this.data.filter(s => s.latitude != null && s.longitude != null)
.map(sat => ({...sat, score: -dist(sat)}))
.toSorted(sortByProp('score', true));
}
getRange(sat: TinyGSSatellite): number | null {
return this._calcRange(sat)?.slantRange ?? null;
}
openPopup(sat: TinyGSSatellite) {
if (this.popups[sat.id]) return;
const history = [...sat.history, sat];
const range = this._calcRange(sat);
const eta = estimateEtaOut(history);
this._drawReceptionCircle(sat, range?.horizonRadius);
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => ({
history: h_,
range: r,
eta: e,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(sat.id),
onBringToFront: () => bringToFront(container),
});
vueRender(h(Satellite, makeProps(history, range, eta)), container);
this.popups[sat.id] = {
update: (h_: TinyGSReading[], r: RangeEstimate | null, e: number | null) => vueRender(h(Satellite, makeProps(h_, r, e)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
}
closePopup(id: number) {
const popup = this.popups[id];
if (popup) {
popup.unmount();
delete this.popups[id];
}
this._removeReceptionCircle(id);
}
}
+231
View File
@@ -0,0 +1,231 @@
import {BASE} from '@/services/api.ts';
import {greatCircleDist} from '@/services/units.ts';
import {h, render as vueRender} from 'vue';
import Map from 'ol/Map';
import {Feature} from 'ol';
import {LineString, Point} from 'ol/geom';
import {fromLonLat} from 'ol/proj';
import {Style, Stroke, Icon} from 'ol/style';
import {Vector as VectorLayer} from 'ol/layer';
import {Vector as VectorSource} from 'ol/source';
import {adjustedInterval, sortByProp} from '@ztimson/utils';
import SondePopup from '@/components/WeatherBalloon.vue';
import {bringToFront} from './zindex';
const API = BASE + '/api';
const SONDE_STALE_TTL = 60_000 * 5;
interface SondeReading {
timestamp: number;
id: string;
type: string;
subtype: string;
latitude: number;
longitude: number;
altitude: number;
heading: number;
speed: number;
climb: number;
sats: number;
battery: number;
temperature: number;
humidity: number;
pressure: number;
freqMHz: number;
snr: number;
ppm: number;
}
interface Sonde extends SondeReading {
history: SondeReading[];
}
export class SondesLayer {
private map: Map;
private layer!: VectorLayer<VectorSource>;
private popups: any = {};
private data: Sonde[] = [];
private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null;
visible = false;
constructor(map: Map) {
this.map = map;
}
private _attachClick() {
this.clickHandler = evt => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('sondeData') ? f : null);
if (!f) return;
const sonde = f.get('sondeData') as Sonde;
if (this.popups[sonde.id]) {
this.closePopup(sonde.id);
return;
}
this.openPopup(sonde);
};
this.map.on('singleclick', this.clickHandler);
}
private _draw() {
const source = this.layer.getSource()!;
source.clear();
for (const sonde of this.data) {
if (sonde.latitude == null || sonde.longitude == null) continue;
const points: any = [...sonde.history, sonde].map(r => fromLonLat([r.longitude, r.latitude]));
if (points.length > 1) {
const trail = new Feature({geometry: new LineString(points)});
trail.setStyle(new Style({
stroke: new Stroke({
color: 'rgba(255,170,0,0.5)',
width: 2,
lineDash: [6, 4],
}),
}));
source.addFeature(trail);
}
const marker = new Feature({geometry: new Point(points[points.length - 1])});
marker.set('sonde', sonde.id);
marker.set('sondeData', sonde);
marker.setStyle(new Style({
image: new Icon({
src: this._getIcon(sonde),
scale: 0.75,
anchor: [0.5, 0.5],
}),
}));
source.addFeature(marker);
}
}
private _getIcon(sonde: Sonde): string {
const opacity = (Date.now() - sonde.timestamp > SONDE_STALE_TTL) ? 0.5 : 1;
const descending = sonde.climb < 0;
const svg = descending
? `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="${opacity}">
<path d="M8 20a16 16 0 0 1 32 0Z" fill="#FFAA00" stroke-width="2" stroke="black"/>
<path d="M8 20l10 12M40 20L30 32" fill="#FFAA00" stroke-width="2" stroke="black"/>
<rect x="16" y="32" width="16" height="11" rx="2" fill="#FFAA00"/>
</svg>`
: `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="${opacity}">
<circle cx="24" cy="13" r="12" fill="#FFAA00"/>
<path d="M24 27v5" fill="#FFAA00" stroke-width="2" stroke="black" />
<rect x="16" y="32" width="16" height="11" rx="2" fill="#FFAA00"/>
</svg>`;
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
}
private async _fetch() {
this.data = await fetch(`${API}/sondes`).then(r => r.json()) || [];
}
private _refreshPopups() {
for (const id of Object.keys(this.popups)) {
const sonde = this.data.find(s => s.id === id);
if (!sonde) {
this.closePopup(id);
continue;
}
this.popups[id].update([...sonde.history, sonde]);
}
}
hide() {
if (!this.visible) return;
this.visible = false;
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
if (this.clickHandler) {
this.map.un('singleclick', this.clickHandler);
this.clickHandler = null;
}
for (const id of Object.keys(this.popups)) this.closePopup(id);
if (this.layer) this.map.removeLayer(this.layer);
}
async show() {
if (this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 105});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 5_000);
}
getById(id: string): Sonde | null {
return this.data.find(s => s.id === id) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): Sonde[] {
return this.data.filter(s => s.latitude != null && s.longitude != null)
.map(sonde => ({...sonde, score: -greatCircleDist(latitude, longitude, sonde.latitude, sonde.longitude)}))
.toSorted(sortByProp('score', true));
}
openPopup(sonde: Sonde) {
if (this.popups[sonde.id]) return;
const history = [...sonde.history, sonde];
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (h_: SondeReading[]) => ({
history: h_,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(sonde.id),
onBringToFront: () => bringToFront(container),
});
vueRender(h(SondePopup, makeProps(history)), container);
this.popups[sonde.id] = {
update: (h_: SondeReading[]) => vueRender(h(SondePopup, makeProps(h_)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
}
closePopup(id: string) {
const popup = this.popups[id];
if (popup) {
popup.unmount();
delete this.popups[id];
}
}
}
+23 -1
View File
@@ -33,7 +33,7 @@ export const METRICS: Record<string, MetricMeta> = {
wind_gusts: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
wind_direction: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
// Seismic
magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
seismic_x: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
seismic_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' },
@@ -70,4 +70,26 @@ export function formatValue(key: string, value: number | string | null): string
return `${value}${meta.unit}`
}
export function getPressureTrend(delta: number) {
if (delta > 0.5) return 'Rising';
if (delta < -0.5) return 'Falling';
return 'Stable';
}
export function getUVLabel(uv: number) {
if (uv < 3) return 'Low';
if (uv < 6) return 'Moderate';
if (uv < 8) return 'High';
if (uv < 11) return 'Very High';
return 'Extreme';
}
const EARTH_R = 6371;
export function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
const toRad = (d: number) => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
+5
View File
@@ -0,0 +1,5 @@
// src/services/zIndex.ts
export let highestZIndex = 1000
export function bringToFront(el: HTMLElement) {
el.style.zIndex = String(++highestZIndex)
}
+4 -3
View File
@@ -6,7 +6,7 @@ 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} from '@/services/api.ts';
import {BASE, useWeather} from '@/services/api.ts';
import {formatDate} from '@ztimson/utils';
import {onMounted, onUnmounted, provide, ref} from 'vue';
import MapView from '../components/MapView.vue';
@@ -23,6 +23,7 @@ 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');
@@ -168,11 +169,11 @@ onUnmounted(() => {
<HourlyForecast class="mb-3" />
<ForecastStrip class="mb-3" />
<Seismic class="mb-3" />
<Precipitation class="mb-3" />
<Wind class="mb-3" />
<Seismic class="mb-3" />
<Sun class="mb-3" />
<Moon class="mb-2" />
<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>
+2
View File
@@ -6,6 +6,8 @@
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"noImplicitAny": false,
"lib": ["DOM","ESNext"],
// Path mapping for cleaner imports.
"paths": {
+195 -283
View File
@@ -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', '0.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')),
'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,57 +104,35 @@ 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
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)
if aqi <= 50: label = 'Minimal'
elif aqi <= 100: label = 'Low'
elif aqi <= 150: label = 'Moderate'
elif aqi <= 200: label = 'Medium'
elif aqi <= 300: label = 'High'
else: label = 'Hazard'
return aqi, label
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
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, aqi_label = gas_to_aqi(gas, rh, c['GAS_REFERENCE'])
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'])
p_rate = get_pressure_trend(c['PRESSURE_TREND_WINDOW'])
dp = round(t - (100 - rh) / 5.0, 2)
write(c, 'environment', {
write(c, {
'temperature': round(t + c['TEMP_CALIBRATION'], 2),
'humidity': round(rh, 2),
'dew_point': dp,
@@ -193,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_magnitude': round(mag, 4),
}
time.sleep(0.01)
@@ -254,31 +248,28 @@ 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
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 ───────────────────────────────────────────────────────────────────
@@ -289,146 +280,93 @@ 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 uv_index_label(uvi):
if uvi < 3: return 'Low'
elif uvi < 6: return 'Moderate'
elif uvi < 8: return 'High'
elif uvi < 11: return 'Very High'
else: return 'Extreme'
def read_ltr(c, sensor, solar_el, humidity=None):
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 '',
'uv_index_label': uv_index_label(uvi),
})
'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 ───────────────────────────────────────────────────────────────────
@@ -452,37 +390,25 @@ def calibrate_luna(c):
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
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']:
write(c, 'accumulation', null); return
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'
)
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
@@ -492,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")
@@ -511,25 +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_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_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}")
@@ -551,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}")
@@ -626,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()
@@ -639,6 +544,7 @@ if __name__ == '__main__':
last_temp = None
last_rh = None
last_aqi = None
while True:
c = cfg()
@@ -647,16 +553,22 @@ if __name__ == '__main__':
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
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 -1
View File
@@ -1,4 +1,4 @@
influxdb-client
requests
python-dotenv
smbus2
adafruit-circuitpython-ltr390
Binary file not shown.
-683
View File
@@ -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"
}
}
+946 -33
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -7,13 +7,14 @@
"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",
"sharp": "^0.35.4",
"yaml": "^2.9.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because it is too large Load Diff
+380 -90
View File
@@ -4,59 +4,114 @@ 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';
import sharp from 'sharp';
const DIR = dirname(fileURLToPath(import.meta.url));
const DATA = resolve(DIR, '../data');
const IMAGES_DIR = resolve(DATA, 'aircraft');
const DB_PATH = resolve(DATA, 'aircraft.db');
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
const SHAPES_PATH = resolve(DATA, 'shapes.json');
const history = new Map();
const AIRLINES_CACHE = resolve(DATA, 'airlines.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 AIRLINES_URL = 'https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat';
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'southwest', 'lufthansa', 'emirates', 'british'];
const PASSENGER_OPERATORS = ['airlines', 'airways', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
const MILITARY_KEYWORDS = [
'air force', 'airforce', 'navy', 'army', 'marine corps', 'coast guard',
'national guard', 'military', 'defence', 'defense', 'police', 'luftwaffe',
'aeronautica militare', 'armee de l air', 'raf', 'usaf'
];
const milRanges = [];
let db;
let passengerOperators = PASSENGER_OPERATORS;
let adsbCache = null;
let adsbCacheTs = 0;
const noRecord = [];
const history = new Map();
const milRanges = [];
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]));
function backfillModel(aircraft) {
if (!aircraft) return {};
const similar = db.prepare(`
SELECT
manufacturer,
model,
engines,
categoryDescription,
class
FROM aircraft
WHERE aircraft = ?
AND (
manufacturer IS NOT NULL
OR model IS NOT NULL
OR engines IS NOT NULL
)
LIMIT 1
`).get(aircraft);
if (!similar) return {};
return {
manufacturer: similar.manufacturer || null,
model: similar.model || null,
engines: similar.engines || null,
categoryDescription: similar.categoryDescription || null,
class: similar.class || null
};
}
async function fetchRegistration1(icao) {
const url = `https://hexdatabase.com/h/${icao}`;
const resp = await fetch(url);
if (!resp.ok) return null;
const html = await resp.text();
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
if (!tableMatch) return null;
const $ = cheerio.load(tableMatch[0]);
const row = $('tr').filter((_, el) => $(el).text()?.toLowerCase()?.includes(icao.toLowerCase())).first();
if (!row.length) return null;
const cells = row.find('td');
return {
registration: $(cells[1]).text().trim() || null,
aircraft: $(cells[2]).text().trim() || null,
operator: $(cells[3]).text().trim() || null,
serialNumber: $(cells[5]).text().trim() || null
};
}
async function fetchRegistration2(icao) {
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
if (!resp.ok) return null;
const found = await resp.json();
return {
registration: found.Registration || null,
manufacturer: found.Manufacturer || null,
aircraft: found.ICAOTypeCode || null,
model: found.Type || null,
operator: found.RegisteredOwners || null
};
}
function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
}
export async function initAircraftDb() {
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
const missing = !fs.existsSync(DB_PATH);
if (missing) console.log(`✈️ Building database`);
db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS aircraft (
icao24 TEXT PRIMARY KEY,
icao TEXT PRIMARY KEY,
built TEXT,
categoryDescription TEXT,
country TEXT,
@@ -72,26 +127,36 @@ export async function initAircraftDb() {
serialNumber TEXT,
aircraft TEXT
);
CREATE TABLE IF NOT EXISTS military_ranges (
start TEXT NOT NULL,
end TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
`);
await syncMilitaryRanges();
await syncAirlines();
await syncMilitaryIcao();
if(missing) {
const res = await fetch(CSV_URL);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
fs.writeFileSync(CSV_CACHE, text);
const csv = fromCsv(text, true);
const insert = db.prepare(`
INSERT OR REPLACE INTO aircraft VALUES (
@icao24, @built, @categoryDescription, @country, @engines,
@class, @manufacturer, @model, @modes, @operator,
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
@icao,
@built,
@categoryDescription,
@country,
@engines,
@class,
@manufacturer,
@model,
@modes,
@operator,
@operatorCallsign,
@owner,
@registration,
@serialNumber,
@aircraft
)
`);
@@ -100,7 +165,7 @@ export async function initAircraftDb() {
});
insertMany(csv.map(row => ({
icao24: (row.icao24 || '').toUpperCase(),
icao: (row.icao24 || '').toUpperCase(),
built: row.built || null,
categoryDescription: row.categoryDescription || null,
country: row.country || null,
@@ -114,82 +179,156 @@ export async function initAircraftDb() {
owner: row.owner || null,
registration: row.registration || null,
serialNumber: row.serialNumber || null,
aircraft: row.typecode || null,
aircraft: row.typecode || null
})));
fs.unlinkSync(CSV_CACHE);
console.log(`✈️ Aircraft imported: ${csv.length}`);
console.log(`✈️ Registrations imported: ${csv.length}`);
} else {
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
console.log(`✈️ Aircraft loaded: ${count.c}`);
console.log(`✈️ Registrations loaded: ${count.c}`);
}
}
function isIcaoInMilitaryRange(icao) {
function isMilitaryIcao(icao) {
if (!icao) return false;
const hex = icao.toUpperCase();
return milRanges.some(([s, e]) => hex >= s && hex <= e);
}
function wordMatch(text, keyword) {
if (!text) return false;
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
async function syncAirlines() {
try {
if (!fs.existsSync(AIRLINES_CACHE)) {
console.log('✈️ Downloading airline database');
const res = await fetchWithTimeout(AIRLINES_URL, 10000);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
fs.writeFileSync(AIRLINES_CACHE, await res.text());
}
const text = fs.readFileSync(AIRLINES_CACHE, 'utf8');
const airlines = text.split('\n').slice(1).map(row =>
row.split(',')[1]?.slice(1, -1).toLowerCase()).filter(Boolean).flat();
passengerOperators = airlines.sort((a, b) => b.length - a.length);
console.log(`✈️ Airlines loaded: ${airlines.length}`);
} catch (e) {
console.warn(`⚠️ Could not load airline database: ${e.message}`);
}
}
async function syncMilitaryIcao() {
const { ADSB_URL } = cfg();
try {
const page = await fetch(ADSB_URL + ':8080').then(resp => resp.text());
const db = /databaseFolder = "(db-.+?)"/.exec(page);
const res = await fetch(ADSB_URL + `:8080/${db[1]}/ranges.js`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { military } = await res.json();
db.exec('DELETE FROM military_ranges');
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
const insertMany = db.transaction(ranges => {
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
});
insertMany(military);
milRanges.length = 0;
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
} catch (e) {
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
milRanges.length = 0;
milRanges.push(...rows.map(r => [r.start, r.end]));
}
}
export function classifyAircraft(row) {
if (!row) return 'unknown';
const matchesAny = (fields, list) =>
fields.some(f => list.some(k => wordMatch(f, k)));
const matchesAny = (fields, list) => fields.some(f => {
const value = normalizeName(f);
return value && list.some(k => {
const kw = normalizeName(k);
return kw && new RegExp('\\b' + kw.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + '\\b', 'i').test(value);
});
});
const operatorFields = [row.operator, row.operatorCallsign];
const ownerFields = [row.owner];
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
const operators = [row.operator, row.operatorCallsign, row.owner];
if (isMilitaryIcao(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(operators, MILITARY_KEYWORDS))
return 'military';
if (isIcaoInMilitaryRange(row.icao24) || 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.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operators, CARGO_OPERATORS)) return 'cargo';
if (matchesAny(operators, passengerOperators)) return 'passenger';
if (row.owner && !row.operator) return 'private';
return 'unknown';
}
export async function enrichAircraft(a) {
function normalizeName(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
export async function enrich(a) {
if (!a.hex) return a;
const row = db.prepare('SELECT * FROM aircraft WHERE icao24 = ?').get(a.hex.toUpperCase());
if (!row) {
const resp = await fetch(`https://hexdb.io/api/v1/aircraft/${a.hex.toUpperCase()}`);
if (resp.ok) {
const found = await resp.json();
const insert = db.prepare(`
INSERT OR IGNORE INTO aircraft (icao24, registration, manufacturer, aircraft, model, operator)
VALUES (@icao24, @registration, @manufacturer, @aircraft, @model, @operator)
`);
const mapped = {
icao24: a.hex.toUpperCase(),
registration: found.Registration || null,
manufacturer: found.Manufacturer || null,
aircraft: found.ICAOTypeCode || null,
model: found.Type || null,
operator: found.RegisteredOwners || null,
};
insert.run(mapped);
return { ...a, ...mapped, type: classifyAircraft(mapped) };
const icao = a.hex.toUpperCase();
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
if (row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
if (noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
const hexDbPromise = fetchRegistration2(icao);
const scrapePromise = fetchRegistration1(icao);
let found = await hexDbPromise.catch(() => {});
if (!found) found = await scrapePromise.catch(() => {});
if (!found) {
noRecord.push(icao);
return { icao, ...a, type: 'unknown' };
}
return { ...a, type: isIcaoInMilitaryRange(a.hex) ? 'military' : 'unknown' };
const merged = { ...row, ...found };
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
const similar = backfillModel(merged.aircraft);
Object.assign(merged, similar);
}
return { ...a, ...row, type: classifyAircraft(row) };
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
});
}
export async function getShapes() {
return JSON.parse(await fs.promises.readFile(SHAPES_PATH, 'utf8'));
return { icao, ...a, ...merged, type: classifyAircraft(merged) };
}
export async function getADSB() {
export async function get() {
if(adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
const { ADSB_URL } = cfg();
if(!ADSB_URL) return [];
const r = await fetch(`${ADSB_URL}:8080/data/aircraft.json`);
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
const j = await r.json();
const aircraft = j.aircraft || [];
@@ -199,21 +338,172 @@ export async function getADSB() {
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() });
trail.push({
latitude: a.lat,
longitude: a.lon,
altitude: a.alt_baro || 0,
ts: Date.now()
});
if (trail.length > MAX_HISTORY) trail.shift();
}
return Promise.all(aircraft.map(enrichAircraft));
adsbCache = await Promise.all(aircraft.map(async a => {
a = await enrich(a);
a.icon = getIcon(a);
return a;
}));
adsbCacheTs = Date.now();
return adsbCache;
}
export async function getADSBHistory(icao) {
return { history: history.get(icao?.toLowerCase()) || [] };
}
export async function getADSBRange() {
export async function getAttenuation() {
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 getHistory(icao) {
return { history: history.get(icao?.toLowerCase()) || [] };
}
export async function getIcaoImage(icao) {
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
async function duckduckgo(query) {
async function getVqd(query) {
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`;
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if(!res.ok) return console.warn(`DuckDuckGo Search failed: ${res.status}`);
const html = await res.text();
const match = html.match(/vqd=["']([^"']+)["']/);
return match?.[1] || null;
}
const vqd = await getVqd(query);
if (!vqd) throw new Error('Could not get vqd token');
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 500));
const searchUrl = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${encodeURIComponent(vqd)}&f=,,,,,&p=1`;
const data = await fetch(searchUrl, {
headers: {
'User-Agent': UA,
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`
}
}).then(async resp => {
if (!resp.ok) return console.warn(`DuckDuckGo image failed: ${resp.status}`);
const text = await resp.text();
try {
return JSON.parse(text);
} catch {
console.warn(`DuckDuckGo returned invalid JSON: ${text.slice(0, 200)}`);
return null;
}
});
if (data?.results?.length) {
for (const result of data.results) {
if (!result.image) continue;
try {
const imgRes = await fetch(result.image, {
headers: {
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent': UA,
'Referer': result.url || 'https://duckduckgo.com/'
}
});
if (imgRes.ok) return imgRes.blob();
console.warn(`Image fetch failed: ${imgRes.status} ${result.image}`);
} catch (e) {
console.warn(`Image fetch failed: ${result.image} - ${e.message}`);
}
}
}
return null;
}
async function genericImage(modelType) {
if (!modelType) return null;
const filePath = resolve(IMAGES_DIR, 'generic', `${modelType}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
const blob = await duckduckgo(`${modelType} Aircraft`);
if (!blob) return null;
fs.mkdirSync(dirname(filePath), { recursive: true });
const buffer = Buffer.from(await blob.arrayBuffer());
const metadata = await sharp(buffer).metadata();
const width = metadata.width || 1200;
const height = metadata.height || 800;
const fontSize = Math.max(48, Math.round(Math.min(width, height) * 0.16));
const watermark = Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}px"
font-weight="700" fill="white" fill-opacity="0.65"
stroke="black" stroke-opacity="0.35" stroke-width="${Math.max(2, Math.round(fontSize * 0.04))}"
transform="rotate(-20 ${width / 2} ${height / 2})">STOCK IMAGE</text>
</svg>`);
const watermarked = await sharp(buffer).composite([{ input: watermark }]).jpeg().toBuffer();
fs.writeFileSync(filePath, watermarked);
return watermarked;
}
async function icaoImage(icao) {
const filePath = resolve(IMAGES_DIR, `${icao}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
const planeSpotters = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao}`, {
headers: { 'User-Agent': 'open-sight.net (zaktimson@gmail.com)' }
}).then(resp => resp.ok ? resp.json() : null).catch(() => null);
const src = planeSpotters?.photos?.[0]?.thumbnail_large?.src || planeSpotters?.photos?.[0]?.thumbnail?.src;
if (!src) return null;
const resp = await fetch(src, {
headers: {
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent': UA,
'Referer': 'https://www.planespotters.net/'
}
});
if (!resp.ok) return null;
fs.mkdirSync(IMAGES_DIR, { recursive: true });
const buffer = Buffer.from(await resp.arrayBuffer());
fs.writeFileSync(filePath, buffer);
return buffer;
}
icao = icao.toLowerCase();
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
const aircraft = await enrich({ hex: icao });
const modelType = aircraft.aircraft || aircraft.model || aircraft.type;
const specific = await icaoImage(icao);
if (specific) return specific;
return genericImage(modelType);
}
// Purge old history
setInterval(() => {
const cutoff = Date.now() - HISTORY_TTL;
for (const [key, trail] of history) {
const last = trail.at(-1);
if (!last || last.ts < cutoff) history.delete(key);
}
}, 1000 * 60 * 5);
+70 -7
View File
@@ -1,10 +1,24 @@
import {cfg} from './config.mjs';
import * as fs from 'node:fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
const SENDER_TYPE = {
0: 'Unknown', 1: 'Base Station', 2: 'Class A',
3: 'Class B', 4: 'SAR Aircraft', 5: 'Aid to Navigation',
6: 'Class B CS', 7: 'Sart/Epirb/MOB'
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'
};
const DIR = dirname(fileURLToPath(import.meta.url));
const DATA = resolve(DIR, '../data');
const IMAGES_DIR = resolve(DATA, 'ships');
let aisCache = null;
let aisCacheTs = 0;
const AIS_TTL = 1000;
function decodeMsgTypes(raw) {
const types = [];
for (let i = 0; i < 32; i++) {
@@ -35,6 +49,7 @@ function decodeFlags(flags) {
}
export async function getAIS() {
if (aisCache && Date.now() - aisCacheTs < AIS_TTL) return aisCache;
const {ADSB_URL} = cfg();
if (!ADSB_URL) return {data: []};
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
@@ -42,9 +57,9 @@ export async function getAIS() {
const staticMap = new Map(staticRows.map(row => [row[0], row]));
return dynamic.map(row => {
aisCache = dynamic.map(row => {
const [mmsi, lat, lon, distance, bearing, heading,
cog, speed, status, level, ppm, count, msg_type,
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) ?? [];
@@ -56,12 +71,12 @@ export async function getAIS() {
return {
mmsi,
type: SENDER_TYPE[mmsi_type],
type: SENDER_TYPE[shipclass],
lat, lon, altitude,
heading, cog, speed,
distance, bearing,
destination,
level, ppm, count,
rrsi, ppm, count,
msg_type: decodeMsgTypes(msg_type),
channels: CHANNEL_MAP[f.channels] ?? f.channels,
...f,
@@ -76,4 +91,52 @@ export async function getAIS() {
status
};
});
aisCacheTs = Date.now();
return aisCache;
}
export async function getAISImage(mmsi) {
if(!mmsi) return null;
mmsi = String(mmsi);
const filePath = resolve(IMAGES_DIR, `${mmsi}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
function randomUA() {
const v = 36 + Math.floor(Math.random() * 40);
const builds = [
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
];
return builds[~~(Math.random() * builds.length)];
}
const html = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
}).then(resp => {
if(resp.ok) return resp.text();
throw new Error('Failed to fetch vessel page');
});
const match = html.match(/<img.+main-photo.+>/);
if(!match) return null;
const srcMatch = match[0].match(/src="(.+?)"/);
if(!srcMatch) return null;
let imgUrl = srcMatch[1];
if(imgUrl.startsWith('//')) imgUrl = `https:${imgUrl}`;
if(imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`;
const resp = await fetch(imgUrl, {
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
});
if(!resp.ok) throw new Error('Failed to fetch image');
const buffer = Buffer.from(await resp.arrayBuffer());
fs.mkdirSync(IMAGES_DIR, { recursive: true });
fs.writeFileSync(filePath, buffer);
return buffer;
}
+3 -6
View File
@@ -1,4 +1,4 @@
import {adjustedInterval, Logger} from '@ztimson/utils';
import {adjustedInterval} from '@ztimson/utils';
const cacheOptions = {
ttl: null,
@@ -12,7 +12,6 @@ export class CacheService {
cached = null;
lastUpdate = null;
logger;
name;
options;
pending;
@@ -24,7 +23,6 @@ export class CacheService {
ttl: options.reload,
...options
};
this.logger = new Logger(name);
if(this.options.reload) setTimeout(() => this.startLoop(), 1000);
}
@@ -62,16 +60,15 @@ export class CacheService {
update(catchErr = true) {
if(this.pending) return this.pending;
this.logger.info('Fetching latest');
console.log('🌌 Fetching Aurora');
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}`)
if(catchErr) console.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
else throw err;
}).finally(() => this.pending = null);
return this.pending;
+7 -4
View File
@@ -10,10 +10,13 @@ export function cfg() {
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',
TINYGS_URL: process.env.TINYGS_URL || '',
TINYGS_AUTH: process.env.TINYGS_AUTH || '',
TINYGS_MQTT_PORT: process.env.TINYGS_MQTT_PORT || 1883,
TINYGS_MQTT_USER: process.env.TINYGS_MQTT_USER || '',
TINYGS_MQTT_PASS: process.env.TINYGS_MQTT_PASS || '',
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
EMAIL: process.env.EMAIL,
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
+119 -41
View File
@@ -1,11 +1,25 @@
import {queryHourly, getCoords, queryCurrent} from './influx.mjs';
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
import {getCelestialCurrent} from './celestial.mjs';
import {getWeatherCondition} from './openweather.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;
@@ -44,14 +58,14 @@ function absoluteHumidity(tempC, humidity) {
}
function pressureRule(trend3h, currentHpa) {
if (trend3h < -2.0) return { label: 'Storm likely', code: 211, precipChance: 0.85 };
if (trend3h < -0.8) return { label: 'Rain likely', code: 501, precipChance: 0.65 };
if (trend3h < -0.3) return { label: 'Cloudy', code: 803, precipChance: 0.35 };
if (trend3h > 1.5) return { label: 'Clearing', code: 801, precipChance: 0.05 };
if (trend3h > 0.3) return { label: 'Improving', code: 800, precipChance: 0.10 };
if (currentHpa < 1000) return { label: 'Unsettled', code: 802, precipChance: 0.30 };
if (currentHpa < 1013) return { label: 'Partly cloudy', code: 802, precipChance: 0.15 };
return { label: 'Fair', code: 800, precipChance: 0.05 };
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) {
@@ -106,20 +120,28 @@ export async function get24HourForecast(currentSensors) {
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 snapshot = {
const uv_index = estimateUV(celestial.sun_elevation ?? 0, clouds);
return {
time,
temperature,
humidity,
@@ -128,15 +150,25 @@ export async function get24HourForecast(currentSensors) {
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,
precipitation_chance: Math.round(weather.precipChance * (1 - i * 0.02) * 100),
...celestial
raining,
precipitation,
precipitation_chance,
accumulation: runningAccumulation,
uv_index,
uv_dose: 0,
solar_wm2: 0,
daily_light_integral: 0,
lux: 0,
visibility: 50,
...celestial,
};
Object.assign(snapshot, getWeatherCondition(snapshot));
snapshot.icon = `/icons/${snapshot.icon}.png`;
return snapshot;
});
for (const slot of slots) {
@@ -157,12 +189,21 @@ export async function get24HourForecast(currentSensors) {
slot.heat_index = Math.round(heatIndex(slot.temperature, slot.humidity) * 100) / 100;
slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity);
slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity);
slot.frost_risk = frostRisk(slot.temperature, slot.dew_point, slot.humidity)
slot.solar_wm2 = slot.sun_elevation > 0
? Math.round(Math.sin((slot.sun_elevation * Math.PI) / 180) * 1000 * (1 - slot.clouds * 0.75) * 100) / 100
: 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;
}
// Group slots into local-time day buckets, summarise each as daytime-only
function groupByLocalDay(slots) {
const days = {}
for (const slot of slots) {
@@ -176,19 +217,13 @@ function groupByLocalDay(slots) {
function summarise(slots) {
if (!slots.length) return null
// Use only daytime slots for representative label/stats, fallback to all if none
const daytime = slots.filter(s => s.daytime)
const source = daytime.length ? daytime : slots
const temps = source.map(s => s.temperature).filter(Number.isFinite)
const precips = source.map(s => s.precipitation_chance).filter(Number.isFinite)
const winds = source.map(s => s.wind_speed).filter(Number.isFinite)
const gusts = source.map(s => s.wind_gusts).filter(Number.isFinite)
const uvs = source.map(s => s.uv_index).filter(Number.isFinite)
const solar = source.map(s => s.solar_wm2).filter(Number.isFinite)
// Min/max temp uses ALL slots for the full day range
const allTemps = slots.map(s => s.temperature).filter(Number.isFinite)
const 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
@@ -198,21 +233,45 @@ function summarise(slots) {
return {
time: slots[0].time,
label: dominant.label,
code: dominant.code,
icon: dominant.icon,
temperature: Math.max(...temps),
temperature_max: Math.max(...allTemps),
temperature_min: Math.min(...allTemps),
precipitation_chance: Math.round(Math.max(...precips)),
wind_speed: Math.round(Math.max(...winds) * 10) / 10,
wind_gusts: gusts.length ? Math.round(Math.max(...gusts) * 10) / 10 : null,
uv_index: uvs.length ? Math.max(...uvs) : null,
uv_index_label: dominant.uv_index_label ?? null,
solar_wm2: solar.length ? Math.round(solar.reduce((a, b) => a + b, 0) / solar.length * 10) / 10 : null,
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,
humidity: Math.round(source.map(s => s.humidity).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
pressure_hpa: Math.round(source.map(s => s.pressure_hpa).filter(Number.isFinite).reduce((a, b) => a + b, 0) / source.length * 10) / 10,
daylight: slots.find(s => s.daylight)?.daylight ?? null,
moon_phase: dominant.moon_phase ?? null,
moon_illumination: dominant.moon_illumination ?? null,
moon_elevation: dominant.moon_elevation ?? null,
moon_azimuth: dominant.moon_azimuth ?? null,
moon_new: dominant.moon_new ?? null,
moon_full: dominant.moon_full ?? null,
moonrise: slots.find(s => s.moonrise)?.moonrise ?? null,
moonset: slots.find(s => s.moonset)?.moonset ?? null,
}
}
@@ -223,10 +282,29 @@ export async function getForecast() {
const slots = await get24HourForecast(sensors).catch(() => null)
if (!slots) return lastForecast
// Summarise today's local-day slots only
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}`};
}
-150
View 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 = {time: new Date()};
for(const row of rows) {
Object.assign(result, extractRow(row));
}
return result;
}
export async function queryHourly(start, end) {
const {c} = getClient();
const flux = `
from(bucket: "${c.INFLUX_BUCKET}")
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|> filter(fn: (r) => ${MEASUREMENTS.map(m => `r._measurement == "${m}"`).join(' or ')})
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
`;
const rows = await query(flux);
return rows.map(row => ({
time: truncateToHour(row._time),
...extractRow(row),
}));
}
export async function queryDaily(start, end) {
const {c} = getClient();
const measurements = ['accumulation', 'environment', 'light', 'lightning', 'seismic'];
const results = {};
for(const m of measurements) {
const [means, mins, maxs] = await Promise.all([
query(`
from(bucket: "${c.INFLUX_BUCKET}")
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|> filter(fn: (r) => r._measurement == "${m}")
|> aggregateWindow(every: 1d, fn: mean, createEmpty: false)
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
`),
query(`
from(bucket: "${c.INFLUX_BUCKET}")
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|> filter(fn: (r) => r._measurement == "${m}")
|> aggregateWindow(every: 1d, fn: min, createEmpty: false)
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
`),
query(`
from(bucket: "${c.INFLUX_BUCKET}")
|> range(start: ${start.toISOString()}, stop: ${end.toISOString()})
|> filter(fn: (r) => r._measurement == "${m}")
|> aggregateWindow(every: 1d, fn: max, createEmpty: false)
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
`),
]);
// Mean values are the base — flat field names, no suffix
for(const row of means) {
const time = truncateToDay(row._time);
if(!results[time]) results[time] = {time};
Object.assign(results[time], extractRow(row));
}
// Only promote temp min/max to named fields, ignore the rest
for(const row of mins) {
const time = truncateToDay(row._time);
if(!results[time]) results[time] = {time};
if(row.temperature != null) results[time].env_temp_min_c = row.temperature;
}
for(const row of maxs) {
const time = truncateToDay(row._time);
if(!results[time]) results[time] = {time};
if(row.temperature != null) results[time].env_temp_max_c = row.temperature;
}
}
return Object.values(results).sort((a, b) => a.time.localeCompare(b.time));
}
export async function getCoords() {
const {c} = getClient();
const flux = `
from(bucket: "${c.INFLUX_BUCKET}")
|> range(start: -24h)
|> filter(fn: (r) => r._measurement == "gps")
|> last()
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
`;
const rows = await query(flux);
if(rows.length && rows[0].latitude && rows[0].longitude) {
return {latitude: rows[0].latitude, longitude: rows[0].longitude, altitude: rows[0].altitude || c.ALTITUDE};
}
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
}
+115 -76
View File
@@ -1,8 +1,10 @@
import { createWriteStream, existsSync, mkdirSync } from 'fs'
import { existsSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { pipeline } from 'stream/promises'
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')
@@ -33,17 +35,6 @@ const OWM_ICONS = {
95: '11d', 96: '11d', 99: '11d',
}
async function ensureIcon(code) {
const owmCode = OWM_ICONS[code] || '01d'
const filename = `${owmCode}.png`
const filepath = resolve(ICON_DIR, filename)
if (!existsSync(filepath)) {
const res = await fetch(`https://openweathermap.org/img/wn/${owmCode}@2x.png`)
await pipeline(res.body, createWriteStream(filepath))
}
return `/icons/${filename}`
}
async function cachedFetch(key, url) {
const now = Date.now()
if (CACHE[key] && now - CACHE[key].ts < CACHE_MS) return CACHE[key].data
@@ -80,22 +71,6 @@ function absoluteHumidity(tempC, humidity) {
return Math.round((humidity / 100 * svp * 2165) / (tempC + 273.15) * 1000) / 1000
}
function uvLabel(uv) {
if (uv < 3) return 'Low'
if (uv < 6) return 'Moderate'
if (uv < 8) return 'High'
if (uv < 11) return 'Very High'
return 'Extreme'
}
function cloudsLabel(fraction) {
if (fraction < 0.1) return 'Clear'
if (fraction < 0.3) return 'Mostly Clear'
if (fraction < 0.6) return 'Partly Cloudy'
if (fraction < 0.9) return 'Mostly Cloudy'
return 'Overcast'
}
// ── Daily ─────────────────────────────────────────────────────────────────────
const DAILY_FIELDS = [
@@ -115,44 +90,79 @@ export async function dailyWeather(lat, lon, start, end) {
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
if (!data?.daily?.time) return []
return Promise.all(data.daily.time.map(async (time, i) => {
const rows = data.daily.time.map((time, i) => {
const code = data.daily.weathercode[i]
const icon = await ensureIcon(code).catch(() => null)
const temp = data.daily.temperature_2m_max[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 uv = data.daily.uv_index_max[i]
const precip = data.daily.precipitation_sum[i] ?? 0
const precipChance = data.daily.precipitation_probability_max[i] ?? 0
const windSpeed = data.daily.windspeed_10m_max[i] ?? 0
const windGusts = data.daily.windgusts_10m_max[i] ?? 0
// "2026-03-01" → local midnight by shifting from UTC midnight by tz offset
const utcMidnight = new Date(`${time}T00:00:00Z`)
const offset = tzOffsetMinutes(utcMidnight)
const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000)
return {
const sunrise = data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null
const sunset = data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null
const daylight = sunrise && sunset
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
: null
const clouds = humidity / 100 * 0.8 // rough estimate — not provided by daily endpoint
const row = {
time: localMidnight,
daytime: true,
label: WMO[code] || 'Unknown',
icon,
code,
time: localMidnight,
temperature: temp,
temperature_max: temp,
temperature_min: data.daily.temperature_2m_min[i],
temperature_max: tempMax,
temperature_min: tempMin,
humidity,
humidity_abs: absoluteHumidity(temp, humidity),
dew_point: dewPoint(temp, humidity),
heat_index: heatIndex(temp, humidity),
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
precipitation: data.daily.precipitation_sum[i],
precipitation_chance: data.daily.precipitation_probability_max[i],
wind_speed: data.daily.windspeed_10m_max[i],
wind_gusts: data.daily.windgusts_10m_max[i],
wind_direction: data.daily.winddirection_10m_dominant[i],
uv_index: uv,
uv_index_label: uvLabel(uv),
solar_wm2: data.daily.shortwave_radiation_sum[i],
clouds: null,
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null,
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null,
pressure_hpa: null,
pressure_slp: null,
pressure_rate: null,
storm_trend: 0,
wind_speed: windSpeed,
wind_gusts: windGusts,
wind_direction: data.daily.winddirection_10m_dominant[i] ?? 0,
clouds,
raining: precipChance > 60 ? 'True' : 'False',
precipitation: precip,
precipitation_chance: precipChance,
accumulation: precip,
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
uv_index: data.daily.uv_index_max[i] ?? 0,
uv_index_max: data.daily.uv_index_max[i] ?? 0,
uv_dose: 0,
solar_wm2: data.daily.shortwave_radiation_sum[i] ?? 0,
daily_light_integral: data.daily.shortwave_radiation_sum[i]
? Math.round(data.daily.shortwave_radiation_sum[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
: 0,
lux: 0,
visibility: 50,
sunrise,
sunset,
daylight,
lightning_rate: 0,
lightning_distance: null,
seismic_magnitude: null,
air_quality: null,
ground_distance: null,
}
}))
return { ...row, ...getWeatherCondition(row) }
})
return rows
}
// ── Hourly ────────────────────────────────────────────────────────────────────
@@ -175,8 +185,6 @@ export async function hourlyWeather(lat, lon, start, end) {
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
if (!data?.hourly?.time) return []
// Open-Meteo returns local time strings e.g. "2026-03-01T06:00" (no Z)
// sunMap keyed by date portion of those local strings
const sunMap = {}
if (data.daily?.time) {
data.daily.time.forEach((date, i) => {
@@ -187,49 +195,80 @@ export async function hourlyWeather(lat, lon, start, end) {
})
}
return Promise.all(data.hourly.time.map(async (time, i) => {
const dateKey = time.slice(0, 10) // "2026-03-01" from local string
const rows = data.hourly.time.map((time, i) => {
const dateKey = time.slice(0, 10)
const sun = sunMap[dateKey] ?? {}
// Open-Meteo local strings have no Z — append Z to treat as UTC for ms comparison
// This works because Open-Meteo already returns times in the location's local tz
// and sunrise/sunset are parsed the same way, so the comparison is consistent
const t = new Date(time + 'Z').getTime()
const daytime = sun.sunrise && sun.sunset
? t >= new Date(sun.sunrise.toISOString().replace('Z', '') + 'Z').getTime() && t < new Date(sun.sunset.toISOString().replace('Z', '') + 'Z').getTime()
? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
: false
const code = data.hourly.weathercode[i]
const icon = await ensureIcon(code).catch(() => null)
const temp = data.hourly.temperature_2m[i]
const humidity = data.hourly.relative_humidity_2m[i]
const uv = data.hourly.uv_index[i]
const clouds = Math.round(data.hourly.cloudcover[i]) / 100
const precip = data.hourly.precipitation[i] ?? 0
const precipChance = data.hourly.precipitation_probability[i] ?? 0
const windSpeed = data.hourly.windspeed_10m[i] ?? 0
const uv = data.hourly.uv_index[i] ?? 0
return {
const sunrise = sun.sunrise ?? null
const sunset = sun.sunset ?? null
const daylight = sunrise && sunset
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
: null
const row = {
time: new Date(time + 'Z'),
daytime,
label: WMO[code] || 'Unknown',
icon,
code,
icon: OWM_ICONS[code] || '01d',
temperature: temp,
temperature_max: temp,
temperature_min: temp,
humidity,
humidity_abs: absoluteHumidity(temp, humidity),
dew_point: dewPoint(temp, humidity),
dew_point: data.hourly.dewpoint_2m[i] ?? dewPoint(temp, humidity),
heat_index: heatIndex(temp, humidity),
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
pressure_hpa: data.hourly.surface_pressure[i],
precipitation: data.hourly.precipitation[i],
precipitation_chance: data.hourly.precipitation_probability[i],
wind_speed: data.hourly.windspeed_10m[i],
wind_gusts: data.hourly.windgusts_10m[i],
wind_direction: data.hourly.winddirection_10m[i],
pressure_hpa: data.hourly.surface_pressure[i] ?? null,
pressure_slp: null,
pressure_rate: null,
storm_trend: 0,
wind_speed: windSpeed,
wind_gusts: data.hourly.windgusts_10m[i] ?? 0,
wind_direction: data.hourly.winddirection_10m[i] ?? 0,
clouds,
raining: precipChance > 60 ? 'True' : 'False',
precipitation: precip,
precipitation_chance: precipChance,
accumulation: precip,
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
uv_index: uv,
uv_index_label: uvLabel(uv),
solar_wm2: data.hourly.shortwave_radiation[i],
clouds_label: cloudsLabel(clouds),
uv_index_max: uv,
uv_dose: 0,
solar_wm2: data.hourly.shortwave_radiation[i] ?? 0,
daily_light_integral: data.hourly.shortwave_radiation[i]
? Math.round(data.hourly.shortwave_radiation[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
: 0,
lux: data.hourly.shortwave_radiation[i]
? Math.round(data.hourly.shortwave_radiation[i] * 120)
: 0,
visibility: data.hourly.visibility[i] != null
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
: null,
: 50,
sunrise,
sunset,
daylight,
lightning_rate: 0,
lightning_distance: null,
seismic_magnitude: null,
air_quality: null,
ground_distance: null,
}
}))
return { ...row, ...getWeatherCondition(row) }
})
return rows
}
+15 -58
View File
@@ -1,71 +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)), 'public', 'icons');
const DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../data/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;
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);
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'};
export async function fetchIcon(icon) {
const iconPath = path.join(DIR, icon + '.png');
if(!existsSync(DIR) || !existsSync(iconPath))
await downloadIcon(icon, iconPath);
return iconPath;
}
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.visibility < 10) {
return {label: 'Mist', code: 701, icon: '50d'};
}
const dayNight = data.daytime ? 'd' : 'n';
if(data.clouds > 85) return {label: 'Overcast Clouds', code: 804, icon: `04${dayNight}`};
if(data.clouds > 50) return {label: 'Broken Clouds', code: 803, icon: `04${dayNight}`};
if(data.clouds > 25) return {label: 'Scattered Clouds', code: 802, icon: `03${dayNight}`};
if(data.clouds > 10) return {label: 'Few Clouds', code: 801, icon: `02${dayNight}`};
return {label: 'Clear Sky', code: 800, icon: `01${dayNight}`};
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'
}
+256
View File
@@ -0,0 +1,256 @@
import {cfg} from './config.mjs';
import {isEqual} from '@ztimson/utils';
import {randomUUID} from 'node:crypto';
const HISTORY_LIMIT = 500;
const POLL_MS = 5_000;
const TTL_MS = 3 * 60_000;
const MAX_PREDICTION_DT = 30;
const MAX_AZ_ERROR = 35;
const MAX_EL_ERROR = 20;
const MAX_GROUND_DISTANCE_KM = 1000;
const MAX_FREQ_ERROR_KHZ = 15;
const satellites = new Map();
function normalizeAzimuth(az) {
return ((az % 360) + 360) % 360;
}
function circularDistance(a, b) {
const diff = Math.abs(normalizeAzimuth(a) - normalizeAzimuth(b));
return Math.min(diff, 360 - diff);
}
function circularDelta(from, to) {
let delta = normalizeAzimuth(to) - normalizeAzimuth(from);
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
return delta;
}
function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function calculateBearing(lat1, lon1, lat2, lon2) {
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const λ = (lon2 - lon1) * Math.PI / 180;
const y = Math.sin(λ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(λ);
return normalizeAzimuth(Math.atan2(y, x) * 180 / Math.PI);
}
function parseWm(raw) {
const f = raw.replace(/<[^>]+>/g, '').split(',').map(s => s.trim());
const [latitude, longitude] = (f[15] || '').split('/').map(s => parseFloat(s));
const [az, el] = (f[16] || '').split('/').map(s => parseFloat(s));
return {
timestamp: Date.now(),
freqMHz: parseFloat(f[3]),
satellite: f[14],
latitude,
longitude,
az: normalizeAzimuth(az),
el,
packetRssi: parseFloat(f[21]),
packetSnr: parseFloat(f[22]),
freqError: parseFloat(f[23]),
crcOk: !/CRC ERROR/i.test(f[24]),
};
}
function createTrack(reading) {
const id = randomUUID();
const track = {
id,
name: reading.satellite,
history: [],
lastSeen: reading.timestamp,
state: {
az: reading.az,
el: reading.el,
latitude: reading.latitude,
longitude: reading.longitude,
freqError: reading.freqError,
azVelocity: 0,
elVelocity: 0,
latitudeVelocity: 0,
longitudeVelocity: 0,
groundSpeedKmh: 0,
heading: null,
freqErrorVelocity: 0,
confidence: 0.25,
},
};
track.history.push(reading);
satellites.set(id, track);
return track;
}
function predictTrack(track, timestamp) {
const dtRaw = (timestamp - track.lastSeen) / 1000;
const dt = Math.max(0, Math.min(dtRaw, MAX_PREDICTION_DT));
const state = track.state;
return {
az: normalizeAzimuth(state.az + state.azVelocity * dt),
el: state.el + state.elVelocity * dt,
latitude: state.latitude + state.latitudeVelocity * dt,
longitude: state.longitude + state.longitudeVelocity * dt,
freqError: state.freqError + state.freqErrorVelocity * dt,
dt,
};
}
function scoreTrack(track, reading) {
if(!track || !reading) return Infinity;
const dt = (reading.timestamp - track.lastSeen) / 1000;
if(dt <= 0 || dt > MAX_PREDICTION_DT) return Infinity;
if(track.name !== reading.satellite) return Infinity;
const predicted = predictTrack(track, reading.timestamp);
const azError = circularDistance(predicted.az, reading.az);
const elError = Math.abs(predicted.el - reading.el);
if(azError > MAX_AZ_ERROR || elError > MAX_EL_ERROR) return Infinity;
let groundDistance = 0;
if(Number.isFinite(reading.latitude) && Number.isFinite(reading.longitude) && Number.isFinite(predicted.latitude) && Number.isFinite(predicted.longitude)) {
groundDistance = haversineDistance(predicted.latitude, predicted.longitude, reading.latitude, reading.longitude);
if(groundDistance > MAX_GROUND_DISTANCE_KM) return Infinity;
}
let freqErrorDifference = 0;
if(Number.isFinite(reading.freqError) && Number.isFinite(predicted.freqError)) {
freqErrorDifference = Math.abs(reading.freqError - predicted.freqError);
if(freqErrorDifference > MAX_FREQ_ERROR_KHZ) return Infinity;
}
return (azError * 4 + elError * 5 + groundDistance * 0.03 + freqErrorDifference * 0.15); // Lower = better
}
function updateTrack(track, reading) {
const previous = track.history.at(-1);
if (!previous) {
track.history.push(reading);
track.lastSeen = reading.timestamp;
return;
}
const dt = (reading.timestamp - previous.timestamp) / 1000;
if(dt <= 0) return;
const state = track.state;
const azVelocity = circularDelta(previous.az, reading.az) / dt;
const elVelocity = (reading.el - previous.el) / dt;
const latitudeVelocity = (reading.latitude - previous.latitude) / dt;
const longitudeVelocity = (reading.longitude - previous.longitude) / dt;
const distanceKm = haversineDistance(previous.latitude, previous.longitude, reading.latitude, reading.longitude);
const groundSpeedKmh = distanceKm / dt * 3600;
const heading = distanceKm > 0.01 ? calculateBearing(previous.latitude, previous.longitude, reading.latitude, reading.longitude) : state.heading;
const freqErrorVelocity = Number.isFinite(reading.freqError) && Number.isFinite(previous.freqError) ? (reading.freqError - previous.freqError) / dt : state.freqErrorVelocity;
const SMOOTHING = 0.35;
state.azVelocity = state.azVelocity * (1 - SMOOTHING) + azVelocity * SMOOTHING;
state.elVelocity = state.elVelocity * (1 - SMOOTHING) + elVelocity * SMOOTHING;
state.latitudeVelocity = state.latitudeVelocity * (1 - SMOOTHING) + latitudeVelocity * SMOOTHING;
state.longitudeVelocity = state.longitudeVelocity * (1 - SMOOTHING) + longitudeVelocity * SMOOTHING;
state.freqErrorVelocity = state.freqErrorVelocity * (1 - SMOOTHING) + freqErrorVelocity * SMOOTHING;
state.az = reading.az;
state.el = reading.el;
state.latitude = reading.latitude;
state.longitude = reading.longitude;
state.freqError = reading.freqError;
state.groundSpeedKmh = groundSpeedKmh;
state.heading = heading;
const prediction = predictTrack(track, reading.timestamp);
const predictionError = circularDistance(prediction.az, reading.az) + Math.abs(prediction.el - reading.el);
if(predictionError < 5) {
state.confidence = Math.min(1, state.confidence + 0.08);
} else if(predictionError < 15) {
state.confidence = Math.min(1, state.confidence + 0.03);
} else {
state.confidence = Math.max(0, state.confidence - 0.08);
}
track.lastSeen = reading.timestamp;
if(!isEqual(track.history.at(-1), reading)) {
track.history.push(reading);
if(track.history.length > HISTORY_LIMIT) track.history.shift();
}
}
function pruneStale() {
const now = Date.now();
for(const [id, track] of satellites) {
if(now - track.lastSeen > TTL_MS) satellites.delete(id);
}
}
function findBestTrack(reading) {
let bestTrack = null;
let bestScore = Infinity;
for(const track of satellites.values()) {
const score = scoreTrack(track, reading);
if(score < bestScore) {
bestScore = score;
bestTrack = track;
}
}
return {track: bestTrack, score: bestScore,};
}
export async function pollTinyGS() {
const {TINYGS_URL, TINYGS_AUTH} = cfg();
try {
const raw = await fetch(TINYGS_URL + '/wm', {
headers: TINYGS_AUTH ? {Authorization: `Basic ${TINYGS_AUTH}`} : {},
}).then(r => r.text());
const reading = parseWm(raw);
if(!reading.satellite || reading.satellite === '-') return;
pruneStale();
const {track, score,} = findBestTrack(reading);
if(!track || !Number.isFinite(score)) {
createTrack(reading);
return;
}
updateTrack(track, reading);
} catch (err) {
console.error('[tinygs] poll failed', err);
}
}
setInterval(pollTinyGS, POLL_MS);
export const getTinyGSData = () => {
pruneStale();
return Array.from(satellites.values()).map(track => {
const latest = track.history.at(-1);
return {
id: track.id,
satellite: track.name,
...latest,
velocity: {
v: track.state.groundSpeedKmh,
az: track.state.azVelocity,
el: track.state.elVelocity,
},
heading: track.state.heading,
history: track.history.slice(0, -1),
};
});
};
+121
View File
@@ -0,0 +1,121 @@
import {cfg} from './config.mjs';
import {frostRisk} from './openweather.mjs';
async function query(c, q, start, end) {
const url = new URL('/api/v1/query_range', c.DB_HOST);
url.searchParams.set('query', q);
url.searchParams.set('step', '60s');
if (start) url.searchParams.set('start', start);
if (end) url.searchParams.set('end', end);
const res = await fetch(url);
const json = await res.json();
return json.data?.result || [];
}
async function queryInstant(c, q) {
const url = new URL('/api/v1/query', c.DB_HOST);
url.searchParams.set('query', q);
const res = await fetch(url);
const json = await res.json();
return json.data?.result || [];
}
function metricToFields(results) {
const out = {};
for (const r of results) {
if(r.metric.__name__.startsWith('system_')) continue;
const val = r.value?.[1] ?? r.values?.at(-1)?.[1];
if (val !== undefined) out[r.metric.__name__.replace('weather_', '')] = parseFloat(val);
}
return out;
}
export async function queryCurrent() {
const c = cfg()
const results = await queryInstant(c, `{__name__!=""}`)
const fields = metricToFields(results)
const dp = fields.dew_point ?? null
return {
time: new Date(),
...fields,
frost_risk: frostRisk(fields.temperature ?? 99, dp ?? 99, fields.humidity ?? 0),
}
}
export async function queryHourly(start, end) {
const c = cfg();
const s = Math.floor(start.getTime() / 1000);
const e = Math.floor(end.getTime() / 1000);
const byTime = {};
const results = await query(c, `avg_over_time({__name__!=""}[1h])`, s, e);
results.forEach(r => {
r.values.forEach(([ts, val]) => {
if(r.metric.__name__.startsWith('system_')) return;
const time = new Date(ts * 1000).toISOString().slice(0, 13) + ':00';
if (!byTime[time]) byTime[time] = {time};
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
});
});
return Object.values(byTime)
.sort((a, b) => a.time.localeCompare(b.time))
.map(r => ({ ...r, time: new Date(r.time) }));
}
export async function queryDaily(start, end) {
const c = cfg();
const s = Math.floor(start.getTime() / 1000);
const e = Math.floor(end.getTime() / 1000);
const byTime = {};
const [means, mins, maxs] = await Promise.all([
query(c, `avg_over_time({__name__!=""}[1d])`, s, e),
query(c, `min_over_time({__name__!=""}[1d])`, s, e),
query(c, `max_over_time({__name__!=""}[1d])`, s, e),
]);
means.forEach(r => {
r.values.forEach(([ts, val]) => {
if(r.metric.__name__.startsWith('system_')) return;
const time = new Date(ts * 1000).toISOString().slice(0, 10);
if (!byTime[time]) byTime[time] = {time};
byTime[time][r.metric.__name__.replace('weather_', '')] = parseFloat(val);
});
});
mins.forEach(r => {
if (r.metric.__name__ !== 'temperature') return;
r.values.forEach(([ts, val]) => {
const time = new Date(ts * 1000).toISOString().slice(0, 10);
if (!byTime[time]) byTime[time] = {time};
byTime[time].env_temp_min_c = parseFloat(val);
});
});
maxs.forEach(r => {
if (r.metric.__name__ !== 'temperature') return;
r.values.forEach(([ts, val]) => {
const time = new Date(ts * 1000).toISOString().slice(0, 10);
if (!byTime[time]) byTime[time] = {time};
byTime[time].env_temp_max_c = parseFloat(val);
});
});
return Object.values(byTime)
.sort((a, b) => a.time.localeCompare(b.time))
.map(r => ({ ...r, time: new Date(r.time) }));
}
export async function getCoords() {
const c = cfg();
if(c.LATITUDE !== 0 && c.LONGITUDE !== 0 && c.ALTITUDE !== 0)
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
const results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
const fields = metricToFields(results);
if(fields.latitude && fields.longitude) {
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
}
return {latitude: 0, longitude: 0, altitude: 0};
}
+87 -76
View File
@@ -1,22 +1,26 @@
import express from 'express';
import {resolve, dirname} from 'path';
import {fileURLToPath} from 'url';
import {cfg, localDateStr} from './config.mjs';
import {queryCurrent, queryHourly, queryDaily, getCoords} from './influx.mjs';
import {cfg} from './config.mjs';
import {queryCurrent, queryHourly, queryDaily, getCoords} from './sensors.mjs';
import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
import {getSpaceWeather} from './space.mjs';
import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs';
import {existsSync} from 'fs';
import {getAIS} from './ais.mjs';
import {getADSB, getADSBHistory, getADSBRange, getShapes, initAircraftDb} from './adsb.mjs';
import {getWeatherCondition} from './openweather.mjs';
import {lastForecast, getForecast, forecastTTL} from './forecast.mjs';
import {getAIS, getAISImage} from './ais.mjs';
import {getIcaoImage, get, getHistory, getAttenuation, initAircraftDb} from './adsb.mjs';
import {fetchIcon} from './openweather.mjs';
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
import {dailyWeather, hourlyWeather} from './openmeteo.mjs';
// import {Aurora} from './aurora.mjs';
import {getTinyGSData} from './sats.mjs';
import {getSpaceWeather} from './space.mjs';
import {Aurora} from './aurora.mjs';
import {getSondes} from './sonde.mjs';
// ── Uncaught error handlers ───────────────────────────────────────────────────
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)
})
@@ -30,7 +34,6 @@ 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();
@@ -48,9 +51,9 @@ function filterArr(arr, fields) {
return arr.map(row => filterFields(row, fields));
}
// ── Current ───────────────────────────────────────────────────────────────────
// ── Weather ───────────────────────────────────────────────────────────────────
app.get('/api/current', async (req, res) => {
app.get('/api/current', asyncHandler(async (req, res) => {
const { fields } = req.query
const [sensors, coords] = await Promise.all([queryCurrent(), getCoords()])
const space = getCelestialCurrent(coords.latitude, coords.longitude)
@@ -58,118 +61,126 @@ app.get('/api/current', async (req, res) => {
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,
} : {}),
...condition,
...sensors,
...space,
}
res.json(filterFields(merged, fields))
})
}));
// ── Hourly ────────────────────────────────────────────────────────────────────
app.get('/api/hourly', async (req, res) => {
app.get('/api/hourly', asyncHandler(async (req, res) => {
const { fields } = req.query
const now = new Date()
const plus24 = new Date(now.getTime() + 24 * 3600 * 1000)
const start = req.query.start ? new Date(req.query.start) : new Date(now.setHours(0, 0, 0, 0))
const end = req.query.end ? new Date(req.query.end) : plus24
const coords = await getCoords()
const now = new Date()
now.setMinutes(0, 0, 0)
const forecast24Limit = new Date(now.getTime() + 24 * 60 * 60_000)
const start = req.query.start ? new Date(req.query.start) : now
const end = req.query.end ? new Date(req.query.end) : forecast24Limit
const historyEnd = new Date(Math.min(now, end))
historyEnd.setMinutes(0, 0, 0)
const [historyResult] = await Promise.allSettled([
start < now ? queryHourly(start, historyEnd) : Promise.resolve([])
const [history, forecast24, meteo] = await Promise.all([
start < now
? queryHourly(start, now)
: Promise.resolve([]),
now < forecast24Limit && end > now
? getForecast().then(f => f.slots.filter(h => new Date(h.time) >= now && new Date(h.time) <= Math.min(end, forecast24Limit)))
: Promise.resolve([]),
end > forecast24Limit
? hourlyWeather(coords.latitude, coords.longitude, forecast24Limit, end)
: Promise.resolve([]),
])
const history = historyResult.status === 'fulfilled' ? historyResult.value : []
const physics = end > now
? lastForecast.slots.filter(s => { const t = new Date(s.time); return t >= now && t <= new Date(Math.min(end, plus24)) })
: []
const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo])
res.json(filterArr(combined, fields))
}))
let meteo = []
if (end > plus24) {
const [meteoResult] = await Promise.allSettled([hourlyWeather(coords.latitude, coords.longitude, plus24, end)])
meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
}
const hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...physics, ...meteo])
res.json(filterArr(hourly, fields))
})
// ── Daily ─────────────────────────────────────────────────────────────────────
app.get('/api/daily', async (req, res) => {
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 next = new Date(todayStart)
next.setDate(todayStart.getDate() + 1)
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) : next
const end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
const coords = await getCoords()
const todayStr = localDateStr(now)
const usePhysics = lastForecast.summary && start <= now && end > now
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 [sensorResult] = await Promise.allSettled([start < now ? queryDaily(start, now) : Promise.resolve([])])
const history = sensorResult.status === 'fulfilled' ? sensorResult.value : []
const meteoFrom = new Date(Math.max(next.getTime(), start.getTime()))
const [meteoResult] = await Promise.allSettled([end > next ? dailyWeather(coords.latitude, coords.longitude, meteoFrom, end) : Promise.resolve([])])
const meteo = meteoResult.status === 'fulfilled' ? meteoResult.value : []
const todaySlot = usePhysics ? [lastForecast.summary] : []
const meteoClean = meteo.filter(r => localDateStr(new Date(r.time)) !== todayStr)
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...todaySlot, ...meteoClean])
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...current, ...future])
res.json(filterArr(daily, fields))
})
}))
// ── Position ──────────────────────────────────────────────────────────────────
app.get('/api/position', async (req, res) => {
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getAttenuation())));
app.get('/api/position', asyncHandler(async (req, res) => {
const {fields} = req.query;
const 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/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 Proxy ────────────────────────────────────────────────────────────────
// ── ADSB/AIS/SAT/RadioSonde Proxy ────────────────────────────────────────────────────────────
app.get('/api/adsb', async (req, res) => res.json(await getADSB()));
app.get('/api/adsb/:icao', async (req, res) => res.json(await getADSBHistory(req.params.icao)));
app.get('/api/ais', async (req, res) => res.json(await getAIS()));
app.get('/api/range', async (req, res) => res.json(await getADSBRange()));
app.get('/api/vehicles', async (req, res) => res.json(await getShapes()));
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await get())));
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
const buffer = await getIcaoImage(req.params.icao);
res.contentType('image/jpeg').send(buffer)
}));
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getHistory(req.params.icao))));
app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS())));
app.get('/api/ais/:mmsi/image', asyncHandler(async (req, res) => {
const buffer = await getAISImage(req.params.mmsi);
res.contentType('image/jpeg').send(buffer)
}));
app.get('/api/sondes', asyncHandler(async (req, res) => res.json(await getSondes())));
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
// ── DOCS ──────────────────────────────────────────────────────────────────────
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) => {
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 ───────────────────────────────────────────────────────────────────
File diff suppressed because one or more lines are too long
+49 -124
View File
@@ -10,17 +10,16 @@ export const spec = {
'/api/position': {
get: {
summary: 'Station position',
description: 'Latest GPS position and info',
parameters: [ ],
description: 'Latest GPS position and altitude',
responses: {
200: {
description: 'Last known position',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } }
content: { 'application/json': { schema: { $ref: '#/components/schemas/Position' } } }
}
}
}
},
'/api/data': {
'/api/current': {
get: {
summary: 'Current conditions',
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
@@ -82,153 +81,79 @@ export const spec = {
Position: {
type: 'object',
properties: {
lat: { type: 'number', description: 'Latitude' },
lon: { type: 'number', description: 'Longitude' },
alt: { type: 'number', description: 'Altitude' },
latitude: { type: 'number', description: 'Latitude (°)' },
longitude: { type: 'number', description: 'Longitude (°)' },
altitude: { type: 'number', description: 'Altitude (m)' },
}
},
DataRow: {
type: 'object',
properties: {
time: { type: 'string', description: 'ISO8601 timestamp or date' },
time: { type: 'string', format: 'date-time', description: 'ISO8601 timestamp' },
// Environment
temperature: { type: 'number', description: 'Temperature (°C)' },
env_temp_f: { type: 'number', description: 'Temperature (°F)' },
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
humidity: { type: 'number', description: 'Relative humidity (%)' },
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
dew_point: { type: 'number', description: 'Dew point (°C)' },
heat_index: { type: 'number', description: 'Feels like / heat index (°C)' },
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' },
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' },
air_quality: { type: 'number', description: 'Air quality index score (0100)' },
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] },
// Light
// Light & Solar
lux: { type: 'number', description: 'Illuminance (lux)' },
uv_index: { type: 'number', description: 'UV index' },
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' },
clouds: { type: 'number', description: 'Estimated cloud cover (%)' },
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] },
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
daylight: { type: 'number', description: 'Hours above daylight threshold today' },
visibility: { type: 'number', description: 'Estimated visibility (km)' },
uv_index_max: { type: 'number', description: 'Daily max UV index' },
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' },
// Wind (Open-Meteo)
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' },
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' },
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' },
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' },
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' },
// Forecast
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. /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' },
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 (01)' },
visibility: { type: 'number', description: 'Estimated visibility (km)' },
daylight: { type: 'number', description: 'Hours of daylight' },
// Weather
label: { type: 'string', description: 'Human readable weather label' },
icon: { type: 'string', description: 'OWM icon code e.g. 01d' },
raining: { type: 'string', enum: ['True','False'] },
precipitation: { type: 'number', description: 'Precipitation (mm)' },
precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' },
accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' },
storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' },
// Wind
wind_speed: { type: 'number', description: 'Wind speed (km/h)' },
wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' },
wind_direction: { type: 'number', description: 'Wind direction (°)' },
// Lightning
lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' },
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
lightning_energy: { type: 'number', description: 'Lightning energy' },
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (07)' },
// Sun
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' },
sunset: { type: 'string', format: 'date-time', description: 'Sunset time' },
daytime: { type: 'boolean', description: 'True if between sunrise and sunset' },
// Moon
moon_phase: { type: 'string', description: 'Moon phase name' },
moon_illumination: { type: 'number', description: 'Moon illumination (%)' },
moon_elevation: { type: 'number', description: 'Moon elevation (°)' },
moon_azimuth: { type: 'number', description: 'Moon azimuth (°)' },
moonrise: { type: 'string', format: 'date-time', description: 'Moonrise time' },
moonset: { type: 'string', format: 'date-time', description: 'Moonset time' },
moon_new: { type: 'string', format: 'date-time', description: 'Next new moon' },
moon_full: { type: 'string', format: 'date-time', description: 'Next full moon' },
// Seismic
seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g)' },
// Ground
ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
// GPS
latitude: { type: 'number', description: 'Latitude (°)' },
longitude: { type: 'number', description: 'Longitude (°)' },
altitude: { type: 'number', description: 'Altitude (m)' },
gps_satellites: { type: 'number', description: 'Satellites in view' },
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
gps_heading: { type: 'number', description: 'GPS heading (°)' },
// Sun
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
sun_sunrise: { type: 'string', description: 'Sunrise time (ISO8601)' },
sun_sunset: { type: 'string', description: 'Sunset time (ISO8601)' },
sun_solar_noon: { type: 'string', description: 'Solar noon (ISO8601)' },
sun_day_length_hours: { type: 'number', description: 'Day length (hours)' },
sun_golden_hour_morning_start: { type: 'string' },
sun_golden_hour_morning_end: { type: 'string' },
sun_golden_hour_evening_start: { type: 'string' },
sun_golden_hour_evening_end: { type: 'string' },
sun_is_day: { type: 'number', description: '1 if daytime' },
sun_ssn: { type: 'number', description: 'Estimated sunspot number' },
sun_activity_label: { type: 'string', enum: ['Very Low','Low','Moderate','High','Very High'] },
// Moon
moon_phase: { type: 'string', description: 'Moon phase name' },
moon_illumination_pct: { type: 'number', description: 'Moon illumination (%)' },
moon_moonrise: { type: 'string', description: 'Moonrise time (ISO8601)' },
moon_moonset: { type: 'string', description: 'Moonset time (ISO8601)' },
moon_next_full: { type: 'string', description: 'Next full moon (ISO8601)' },
moon_next_new: { type: 'string', description: 'Next new moon (ISO8601)' },
// Season
season_next_event: { type: 'string', description: 'Next solstice/equinox name' },
season_next_event_date: { type: 'string' },
season_last_event: { type: 'string' },
season_last_event_date: { type: 'string' },
// Space weather
space_kp_index: { type: 'number', description: 'Planetary K index (09)' },
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³)' },
}
}
}
+78 -17
View File
@@ -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"
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
git pull
cd client && npm i && npx vite build
cd ../server && npm i
sudo systemctl restart weather-sensors
sudo systemctl restart weather-station