Compare commits

..
81 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
50 changed files with 5725 additions and 1412 deletions
+1
View File
@@ -1,3 +1,4 @@
EMAIL=
ALTITUDE=
LATITUDE=
LONGITUDE=
-5
View File
@@ -1,5 +0,0 @@
## TODO
- Proxy ICAO lookups on failure
- Make sure openmeteo properties match + we backfill any missing
- Fix daily (missing future dates + timestamp on 24 hour forecast is wrong)
- Update openapi doc
+20 -2
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

+93 -69
View File
@@ -1,5 +1,7 @@
<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) },
@@ -15,16 +17,16 @@ const BASE_VERTICAL_UNITS = {
fps: { label: 'FT/S', convert: (v: number) => v },
}
const photoError = ref(false);
const UNIT_KEY = 'at_units'
function loadUnits() {
const s = localStorage.getItem(UNIT_KEY)
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
}
const props = defineProps<{ plane: any; position: { x: number; y: number } }>()
const props = defineProps<{ plane: any; position: { x: number; y: number }; history?: any[] }>()
const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
// ── Units ─────────────────────────────────────────────────────────────────────
const prefs = ref(loadUnits())
function saveAndSet(p: any) {
prefs.value = { ...p }
@@ -38,17 +40,21 @@ const speed = computed(() => BASE_SPEED_UNITS[prefs.value.speed as keyo
const altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS])
// ── Plane data ────────────────────────────────────────────────────────────────
const callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0)
const speedVal = computed(() => speed.value.convert(props.plane.speed || props.plane.gs || 0))
const altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label)
const climbVal = computed(() => { const v = vertical.value.convert(props.plane.climb || props.plane.baro_rate || 0); return v >= 0 ? `+${v}` : String(v) })
const description = computed(() => props.plane.desc || [props.plane.manufacturer, props.plane.model].filter(Boolean).join(' ') || '')
const 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 || '')
// ── Drag ──────────────────────────────────────────────────────────────────────
const altitudeHistory = computed(() => (props.history || [])
.filter(p => !p.live && p.ts != null)
.map(p => ({...p, altitude: altitude.value.convert(Number(p.altitude))})))
const currentAltitude = computed(() => props.plane.landed ? null : altitude.value.convert(Number(altitudeVal.value)))
const pos = ref({ ...props.position })
const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
@@ -108,7 +114,6 @@ onUnmounted(() => {
document.removeEventListener('touchcancel', onTouchEnd)
})
// ── Navball ───────────────────────────────────────────────────────────────────
const navball = computed(() => {
const data = props.plane
const heading = data.heading ?? data.track ?? 0
@@ -189,80 +194,26 @@ const navball = computed(() => {
})
</script>
<template>
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<!-- Header (drag handle) -->
<div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="ap-callsign">
<a v-if="callsign" :href="`https://www.flightaware.com/live/flight/${callsign}`" target="_blank" @click.stop>{{ callsign.toUpperCase() }}</a>
<span v-else>N/A</span>
<span style="color: white"> / {{ (plane.icao || plane.hex || '').toUpperCase() }}</span>
</h3>
<button class="ap-close" @click.stop="emit('close')">×</button>
</div>
<!-- Body -->
<div class="ap-meta flex-r justify-between">
<div class="flex-c">
<span>{{ operator }}</span>
<span>{{ plane.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c align-x-end">
<span>{{ description }}</span>
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} {{ plane.type || 'Unknown' }}</span>
</div>
</div>
<div class="ap-body">
<div class="ap-gauges">
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Air Speed</div>
<div class="ap-gauge" @click.stop="cycleSpeed">
<span class="ap-gauge-val">{{ speedVal }}</span>
<span class="ap-gauge-unit">{{ speed.label }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Altitude</div>
<div class="ap-gauge" @click.stop="cycleAltitude">
<span class="ap-gauge-val">{{ altVal }}</span>
<span class="ap-gauge-unit">{{ altUnit }}</span>
</div>
</div>
<div class="ap-gauge-wrap">
<div class="ap-gauge-label">Climb</div>
<div class="ap-gauge" @click.stop="cycleVertical">
<span class="ap-gauge-val">{{ climbVal }}</span>
<span class="ap-gauge-unit">{{ vertical.label }}</span>
</div>
</div>
</div>
<div class="ap-navball" v-html="navball" />
</div>
</div>
</template>
<style scoped>
.aircraft-popup {
position: fixed;
z-index: 9999;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(200,200,220,0.3);
border: 1px solid rgba(0, 255, 0, 0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.ap-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
@@ -285,10 +236,6 @@ const navball = computed(() => {
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
}
.ap-meta--detail { margin-top: 3px; }
.ap-icao { text-align: right; }
.ap-operator { text-align: right; color: #aaa; }
.ap-close {
position: absolute;
@@ -309,6 +256,19 @@ const navball = computed(() => {
}
.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;
@@ -340,5 +300,69 @@ const navball = computed(() => {
.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; }
.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>
+42 -14
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import Loading from '@/components/Loading.vue';
import {getAQILabel, getPressureTrend} from '@/services/units.ts';
import {getPressureTrend} from '@/services/units.ts';
import {inject, onMounted, ref} from 'vue';
import {api, BASE} from '../services/api';
@@ -108,47 +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) }}<span class="fg-muted">°C</span></span>
<span v-else class="stat-value">
<template v-if="data.heat_index == null"></template>
<template v-else>{{ ~~(data.heat_index) }}<span class="fg-muted">°C</span></template>
</span>
</div>
<div 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>
<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?.('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">hPa</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">({{ getPressureTrend(data.pressure_trend) }})</span></span>
<span v-else class="stat-value">
<template v-if="data.pressure_slp == null"></template>
<template v-else>{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ getPressureTrend(data.pressure_trend) }})</span></template>
</span>
</div>
<div 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 class="fg-muted">kPa</span></span>
<span v-else class="stat-value">
<template v-if="data.vapor_pressure_deficit == null"></template>
<template v-else>{{ data.vapor_pressure_deficit?.toFixed(2) }} <span class="fg-muted">kPa</span></template>
</span>
</div>
<div 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">/ 500</span></span>
<span v-else class="stat-value">
<template v-if="data.air_quality == null"></template>
<template v-else>{{ data.air_quality }} <span class="fg-muted">/ 500</span></template>
</span>
</div>
<div 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>
<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">{{ data.visibility?.toFixed(1) }} <span class="fg-muted">km</span></span>
<span v-else class="stat-value">
<template v-if="data.visibility == null"></template>
<template v-else>{{ data.visibility?.toFixed(1) }} <span class="fg-muted">km</span></template>
</span>
</div>
</div>
</div>
+3 -3
View File
@@ -73,9 +73,9 @@ onMounted(async () => {
<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">
+593 -255
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)
stroke: new Stroke({color: ringStroke, width: 1, lineDash: [6, 4]}),
fill: new Fill({color: 'transparent'}),
}));
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 }),
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>
+15 -6
View File
@@ -65,7 +65,11 @@ const {current: d} = useWeather();
<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 == null ? '—' : d.raining ? 'Raining' : d.precipitation_chance + '%' }}</span>
<span v-else class="stat-value">
<template v-if="d.raining == null"></template>
<template v-else-if="d.raining">Raining</template>
<template v-else>{{d.precipitation_chance}}<span class="fg-muted">%</span></template>
</span>
</div>
<div class="stat" @click="openGraph?.('lightning_rate')">
<span class="stat-label">Lightning</span>
@@ -82,6 +86,11 @@ const {current: d} = useWeather();
<div class="divider" />
<div class="stat-grid">
<div class="stat" @click="openGraph?.('pressure_hpa')">
<span class="stat-label">Pressure</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.pressure_hpa?.toFixed(1) ?? '—' }} <span class="fg-muted">hpa</span></span>
</div>
<div class="stat" @click="openGraph?.('pressure_hpa')">
<span class="stat-label">Pressure (SLP)</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
@@ -92,11 +101,6 @@ const {current: d} = useWeather();
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.precipitation?.toFixed(1) ?? '—' }} <span v-if="d.precipitation != null" class="fg-muted">mm</span></span>
</div>
<div class="stat" @click="openGraph?.('accumulation')">
<span class="stat-label">Accumulation</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.accumulation?.toFixed(1) ?? '—' }} <span v-if="d.accumulation != null" class="fg-muted">cm</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>
@@ -107,6 +111,11 @@ const {current: d} = useWeather();
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.frost_risk ?? '—' }}</span>
</div>
<div class="stat" @click="openGraph?.('accumulation')">
<span class="stat-label">Accumulation</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.accumulation?.toFixed(1) ?? '—' }} <span v-if="d.accumulation != null" class="fg-muted">cm</span></span>
</div>
</div>
</div>
</template>
+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>
+99 -69
View File
@@ -5,47 +5,100 @@ import { api } from '../services/api';
import MetricRow from './MetricRow.vue';
const d = ref<any>(null);
const history = ref<number[]>([]);
const canvas = ref<HTMLCanvasElement | null>(null);
const W = 640, H = 120, MAX_PTS = 120;
const AXES = [
{ key: 'seismic_x', label: 'X', color: '#ccc' },
{ key: 'seismic_y', label: 'Y', color: '#ccc' },
{ key: 'seismic_z', label: 'Z', color: '#ccc' },
];
const W = 640, BAND = 80, H = BAND * AXES.length, MAX_PTS = 120;
const SEG_W = W / (MAX_PTS - 1);
const 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 = 1000;
const ANIM_DURATION = 1200;
let pointBuffer: { x: number; y: number }[] = [];
let prevMax = 1.0;
type Pt = { x: number; y: number };
const traces = AXES.map(() => ({
history: [] as number[],
prevMax: 1.0,
buffer: [] as Pt[],
}));
function buildBuffer(pts: number[]) {
function buildBuffer(t: (typeof traces)[number], band: number) {
const pts = t.history;
if (pts.length < 2) return;
const mid = H / 2, amp = H / 2 - 6;
const mid = band * BAND + BAND / 2, amp = BAND / 2 - 6;
const newMax = Math.max(...pts, 1.0);
prevMax = prevMax + (newMax - prevMax) * 0.15;
const buf: { x: number; y: number }[] = [];
t.prevMax = t.prevMax + (newMax - t.prevMax) * 0.15; // eased amplitude scale
const tipX = W - RIGHT_PAD;
const buf: Pt[] = [];
pts.forEach((v, i) => {
const prev = pts[i - 1] ?? v;
const max = prevMax;
const prevNorm = prev === 0 ? 0 : (prev / max) * amp;
const norm = v === 0 ? 0 : (v / max) * amp;
const baseX = i * SEG_W;
const prevNorm = prev === 0 ? 0 : (prev / t.prevMax) * amp;
const norm = v === 0 ? 0 : (v / t.prevMax) * amp;
const baseX = tipX - (pts.length - i) * SEG_W;
const s = SEG_W / SUB;
// one oscillation per value, amplitude fading prevNorm → norm
const a0 = prevNorm * 0.75 + norm * 0.25;
const a1 = prevNorm * 0.25 + norm * 0.75;
const a2 = norm;
buf.push({ x: baseX, y: mid - a0 }); // up
buf.push({ x: baseX + s, y: mid - a1 }); // up peak
buf.push({ x: baseX + s * 2, y: mid + a2 }); // down
buf.push({ x: baseX + s * 3, y: mid }); // back to exact center ✓
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 ✓
});
pointBuffer = buf;
t.buffer = buf;
}
function drawTrace(ctx: CanvasRenderingContext2D, t: (typeof traces)[number], color: string) {
const buf = t.buffer;
if (buf.length <= SUB) return;
// slide everything left as the new segment draws in
const slide = (1 - drawProgress) * SEG_W;
const settledCount = buf.length - SUB;
const anchor = buf[settledCount - 1];
const animFrac = drawProgress * (SUB + 1);
const animFloor = Math.floor(animFrac);
const animRemainder = animFrac - animFloor;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.lineJoin = 'round';
for (let i = 0; i < settledCount; i++) {
const p = <any>buf[i];
i === 0 ? ctx.moveTo(p.x + slide, p.y) : ctx.lineTo(p.x + slide, p.y);
}
const animPoints = <any>[anchor, ...buf.slice(settledCount)];
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
ctx.lineTo(animPoints[i].x + slide, animPoints[i].y);
}
if (animFloor < animPoints.length - 1 && animRemainder > 0) {
const curr = animPoints[animFloor];
const next = animPoints[animFloor + 1];
ctx.lineTo(
curr.x + (next.x - curr.x) * animRemainder + slide,
curr.y + (next.y - curr.y) * animRemainder,
);
}
ctx.stroke();
}
function draw(timestamp: number) {
@@ -58,64 +111,38 @@ function draw(timestamp: number) {
ctx.clearRect(0, 0, W, H);
// Center line — full width
AXES.forEach((_, i) => {
const mid = i * BAND + BAND / 2;
// Axis label — top-left of each band
ctx.font = '16px monospace';
ctx.fillStyle = (<any>AXES)[i].color;
ctx.globalAlpha = 0.6;
ctx.fillText((<any>AXES)[i].label, W - 20, i * BAND + 14);
ctx.globalAlpha = 1;
// Center line per band — full width
ctx.beginPath();
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
ctx.moveTo(0, H / 2);
ctx.lineTo(W, H / 2);
ctx.moveTo(0, mid);
ctx.lineTo(W, mid);
ctx.stroke();
if (pointBuffer.length < 2) { rafId = requestAnimationFrame(draw); return; }
drawTrace(ctx, <any>traces[i], (<any>AXES)[i].color);
});
// Shift the whole path left by PAD_SEGS segments so tip never hits the edge
const xOffset = 3 * SEG_W;
const settledCount = pointBuffer.length - SUB;
const anchor = pointBuffer[settledCount - 1];
const totalSteps = SUB + 1;
const animFrac = drawProgress * totalSteps;
const animFloor = Math.floor(animFrac);
const animRemainder = animFrac - animFloor;
ctx.beginPath();
ctx.strokeStyle = '#4d9fff';
ctx.lineWidth = 1.5;
ctx.lineJoin = 'round';
for (let i = 0; i < settledCount; i++) {
const p = pointBuffer[i];
const x = p.x - xOffset;
i === 0 ? ctx.moveTo(x, p.y) : ctx.lineTo(x, p.y);
}
const animPoints = [
anchor,
...Array.from({ length: SUB }, (_, i) => pointBuffer[settledCount + i]),
];
for (let i = 1; i <= animFloor && i < animPoints.length; i++) {
ctx.lineTo(animPoints[i].x - xOffset, animPoints[i].y);
}
if (animFloor < animPoints.length - 1 && animRemainder > 0) {
const curr = animPoints[animFloor];
const next = animPoints[animFloor + 1];
ctx.lineTo(
curr.x + (next.x - curr.x) * animRemainder - xOffset,
curr.y + (next.y - curr.y) * animRemainder,
);
}
ctx.stroke();
rafId = requestAnimationFrame(draw);
}
async function poll() {
d.value = await api.current('seismic_magnitude');
history.value.push(d.value.seismic_magnitude ?? 0);
if (history.value.length > MAX_PTS) history.value.shift();
buildBuffer(history.value);
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;
}
@@ -133,14 +160,17 @@ onUnmounted(() => {
</script>
<style scoped lang="scss">
canvas { width: 100%; height: 60px; }
canvas { width: 100%; height: 180px; }
</style>
<template>
<div class="card">
<div class="card-title">🫨 Seismic</div>
<div v-if="!d" class="p-2">
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 80px">
<div class="w-100 pos-rel br-2 overflow-hidden mb-2" style="height: 30px">
<Loading />
</div>
<div class="w-100 pos-rel br-2 overflow-hidden" style="height: 150px">
<Loading />
</div>
</div>
+116 -96
View File
@@ -1,4 +1,5 @@
<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 } }>()
@@ -9,12 +10,15 @@ const SPEED_UNITS = {
kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).toFixed(1) },
}
const UNIT_KEY = 'ais_units'
const UNIT_KEY = 'at_units'
function loadUnits() {
const s = localStorage.getItem(UNIT_KEY)
return s ? JSON.parse(s) : { speed: 'knots' }
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' }
}
const prefs = ref(loadUnits())
function cycleSpeed() {
const keys = Object.keys(SPEED_UNITS)
const p = loadUnits()
@@ -22,12 +26,20 @@ function cycleSpeed() {
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.name?.trim() || props.boat.callsign?.trim() || '-')
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0)
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 })
@@ -78,6 +90,7 @@ onMounted(() => {
document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd)
window.addEventListener('storage', syncUnits)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove)
@@ -85,14 +98,13 @@ onUnmounted(() => {
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.course ?? hdg
const bearing = props.boat.bearing ?? 0
const course = props.boat.cog ?? hdg
// ── Compass tape ──────────────────────────────────────────────────────────
const tapeTicks: string[] = []
for (let offset = -60; offset <= 60; offset += 5) {
const deg = ((hdg + offset) % 360 + 360) % 360
@@ -113,9 +125,8 @@ const compass = computed(() => {
}
}
// ── Rose centre & radii ───────────────────────────────────────────────────
const CX = 100, CY = 158 // moved down from 145
const R = 68 // grown from 55
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
@@ -125,12 +136,11 @@ const compass = computed(() => {
const cNeedleX = CX + 52 * Math.sin(courseRad)
const cNeedleY = CY - 52 * Math.cos(courseRad)
const bearRel = ((bearing - hdg + 540) % 360) - 180
const bearRel = ((course - hdg + 540) % 360) - 180
const bearRad = bearRel * Math.PI / 180
const bNeedleX = CX + 52 * Math.sin(bearRad)
const bNeedleY = CY - 52 * Math.cos(bearRad)
// Ring tick marks scaled to new R
const ringTicks = Array.from({ length: 36 }, (_, i) => {
const a = i * 10
const rad = (a - 90) * Math.PI / 180
@@ -151,126 +161,63 @@ const compass = computed(() => {
return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
}).join('')
// Legend & rudder bar shifted to bottom of taller SVG
const LEG_Y = 190
return `
<svg width="200" height="230" viewBox="0 0 200 230">
<!-- Tape -->
<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"/>
<!-- Heading readout -->
<rect x="72" y="64" width="56" height="20" fill="rgba(0,0,0,0.9)" stroke="#0ff" stroke-width="1" rx="2"/>
<text x="100" y="78" text-anchor="middle" fill="#0ff" font-size="13" font-weight="bold">${String(Math.round(hdg)).padStart(3, '0')}°</text>
<!-- Rose -->
<circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
<circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
<g transform="rotate(${-hdg}, ${CX}, ${CY})">
${ringTicks}
${cardinals}
</g>
<!-- Course needle -->
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
<!-- Bearing needle -->
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
<circle cx="${bNeedleX}" cy="${bNeedleY}" r="2.5" fill="#ff0"/>
<!-- Ship symbol -->
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/>
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/>
<polygon points="${CX},${CY - 9} ${CX - 4},${CY + 5} ${CX},${CY + 2} ${CX + 4},${CY + 5}" fill="#fff" stroke="#0ff" stroke-width="1"/>
<!-- Rudder bar -->
<rect x="60" y="${LEG_Y - 4}" width="80" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
<line x1="${CX}" y1="${LEG_Y - 4}" x2="${CX}" y2="${LEG_Y + 2}" stroke="#0ff" stroke-width="1" opacity="0.5"/>
<rect x="${rudX - 4}" y="${LEG_Y - 3}" width="8" height="8" fill="#ff0" rx="1"/>
<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"/>
<!-- Legend -->
<line x1="12" y1="${LEG_Y + 20}" x2="24" y2="${LEG_Y + 20}" stroke="#0f0" stroke-width="2"/>
<text x="27" y="${LEG_Y + 22}" fill="#888" font-size="9">COG</text>
<line x1="12" y1="${LEG_Y + 30}" x2="24" y2="${LEG_Y + 30}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
<text x="27" y="${LEG_Y + 33}" fill="#888" font-size="9">BRG</text>
<line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
<text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
<line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
<text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
</svg>
`
})
</script>
<template>
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="sp-name">
🚢
<span>{{ name.toUpperCase() }}</span>
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
</h3>
<button class="sp-close" @click.stop="emit('close')">×</button>
</div>
<div class="sp-meta">
<div class="flex-c">
<span>{{ boat.operator || boat.owner || 'Unknown' }}</span>
<span>{{ boat.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c align-x-end">
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
<span>{{ boat.ship_type || 'Unknown' }}</span>
</div>
</div>
<div class="sp-body">
<div class="sp-gauges">
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Speed</div>
<div class="sp-gauge" @click.stop="cycleSpeed">
<span class="sp-gauge-val">{{ speedVal }}</span>
<span class="sp-gauge-unit">{{ speed.label }}</span>
</div>
</div>
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Course</div>
<div class="sp-gauge">
<span class="sp-gauge-val">{{ boat.course != null ? boat.course.toFixed(0) : '—' }}</span>
<span v-if="boat.course != null" class="sp-gauge-unit">°</span>
</div>
</div>
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Heading</div>
<div class="sp-gauge">
<span class="sp-gauge-val">{{ boat.heading != null ? boat.heading.toFixed(0) : '—' }}</span>
<span v-if="boat.heading != null" class="sp-gauge-unit">°</span>
</div>
</div>
</div>
<div class="sp-compass" v-html="compass"/>
</div>
</div>
</template>
<style scoped>
.ship-popup {
position: fixed;
z-index: 9999;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(200,200,220,0.3);
border: 1px solid rgba(0,255,255,0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.sp-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
border-bottom: 1px solid rgba(0,255,255,0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
@@ -312,6 +259,22 @@ const compass = computed(() => {
}
.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;
@@ -342,7 +305,64 @@ const compass = computed(() => {
}
.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: 2px; }
.sp-compass { flex: 1; }
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
.sp-compass { flex: 1; margin-top: 0.25rem; }
</style>
<template>
<div class="ship-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }">
<div ref="header" class="sp-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="sp-name">
🚢
<span>{{ name.toUpperCase() }}</span>
<span class="sp-mmsi"> / {{ boat.mmsi }}</span>
</h3>
<button class="sp-close" @click.stop="emit('close')">×</button>
</div>
<div class="sp-meta">
<div class="flex-c flex-fill">
<span>{{ boat.operator || boat.owner || 'Unknown Owner' }}</span>
<span>{{ boat.country || 'Unknown Country' }}</span>
</div>
<div class="flex-c flex-fill align-x-end">
<span style="text-transform:capitalize">{{ boat.type || 'Unknown' }}</span>
<span>{{ boat.ship_type || 'Unknown Class' }}</span>
</div>
</div>
<!-- Photo -->
<div v-if="!photoError" class="sp-photo-wrap">
<img class="sp-photo" :src="BASE + '/api/ais/' + boat.mmsi + '/image'" :alt="name" @error="photoError = true" />
</div>
<div class="sp-body">
<div class="sp-gauges">
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Speed</div>
<div class="sp-gauge" @click.stop="cycleSpeed">
<span class="sp-gauge-val">{{ speedVal }}</span>
<span class="sp-gauge-unit">{{ speed.label }}</span>
</div>
</div>
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Course</div>
<div class="sp-gauge">
<span class="sp-gauge-val">{{ course != null ? course.toFixed(0) : '—' }}</span>
<span v-if="course != null" class="sp-gauge-unit">°</span>
</div>
</div>
<div class="sp-gauge-wrap">
<div class="sp-gauge-label">Heading</div>
<div class="sp-gauge">
<span class="sp-gauge-val">{{ heading != null ? heading.toFixed(0) : '—' }}</span>
<span v-if="heading != null" class="sp-gauge-unit">°</span>
</div>
</div>
</div>
<div class="sp-compass" v-html="compass"/>
</div>
</div>
</template>
+1 -1
View File
@@ -64,7 +64,7 @@ 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: 1.5"><Loading /></div>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 3"><Loading /></div>
<template v-else>
<!-- Top labels -->
+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>
+266 -188
View File
@@ -1,93 +1,119 @@
import { BASE } from '@/services/api.ts'
import { createApp, ref } from 'vue'
import Map from 'ol/Map'
import { Feature } from 'ol'
import Point from 'ol/geom/Point'
import LineString from 'ol/geom/LineString'
import { fromLonLat } from 'ol/proj'
import { Style, Icon, Stroke } from 'ol/style'
import { Vector as VectorLayer } from 'ol/layer'
import { Vector as VectorSource } from 'ol/source'
import { adjustedInterval } from '@ztimson/utils'
import AircraftPopup from '@/components/Aircraft.vue'
import {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 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;
let highestZIndex = 1000
function isPriorityAircraft(plane: any): boolean {
if (plane.military === true || plane.is_military === true || plane.sar === true || plane.is_sar === true) return true;
function getAltColor(alt: number): [number, number, number] {
const a = Math.max(0, alt)
if (a < 10) return [0, 0, 0]
if (a < 10000) return [135, 206, 250]
if (a < 25000) {
const r = (a - 10000) / 15000
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)]
}
if (a < 40000) {
const r = (a - 25000) / 15000
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
}
const r = Math.min((a - 40000) / 10000, 1)
return [Math.round(128 * r), 0, 139]
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);
}
interface PopupInstance {
planeRef: ReturnType<typeof ref>
posRef: ReturnType<typeof ref>
unmount: () => void
function getAltColor(alt: number): [number, number, number] {
const a = Math.max(0, alt);
if (a < 10) return [0, 0, 0];
if (a < 10000) return [135, 206, 250];
if (a < 25000) {
const r = (a - 10000) / 15000;
return [Math.round(135 * (1 - r)), Math.round(206 * (1 - r)), Math.round(250 * (1 - r) + 255 * r)];
}
if (a < 40000) {
const r = (a - 25000) / 15000;
return [0, 0, Math.round(255 * (1 - r) + 139 * r)];
}
const r = Math.min((a - 40000) / 10000, 1);
return [Math.round(128 * r), 0, 139];
}
export class AirTrafficLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
private traceLayers: Record<string, VectorLayer<VectorSource>> = {}
private traceSegs: Record<string, Feature[]> = {}
private popups: any = {}
private historyCache: any = {}
private data: any[] = []
private clickHandler: ((e: any) => void) | null = null
private refreshTimer: any = null
visible = false
private map: Map;
private layer!: VectorLayer<VectorSource>;
private traceLayers: Record<string, VectorLayer<VectorSource>> = {};
private traceSegs: Record<string, Feature[]> = {};
private popups: any = {};
private historyCache: any = {};
private data: any[] = [];
private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null;
visible = false;
constructor(map: Map) { this.map = map }
// ── Public ──────────────────────────────────────────────────────────────────
async show() {
if (this.visible) return
this.visible = true
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
this.map.addLayer(this.layer)
await this._fetch()
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err)
}
}, 1_000)
constructor(map: Map) {
this.map = map;
}
hide() {
if (!this.visible) return
this.visible = false
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
for (const icao of Object.keys(this.popups)) this._closePopup(icao)
this.map.removeLayer(this.layer)
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l))
this.traceLayers = {}
this.traceSegs = {}
private _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;
}
// ── 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,
@@ -97,137 +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: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
scale: 1,
rotation: (plane.heading ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}))
source.addFeature(f)
}
}));
}
private async _fetchTrace(plane: any) {
const icao = plane.icao
if (!this.historyCache[icao]) {
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
this.historyCache[icao] = j.history || []
}
const history = [...this.historyCache[icao]]
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
const last = history[history.length - 1]
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
if (history.length < 2) return
const icao = plane.icao;
let traceLayer = this.traceLayers[icao]
let vs: VectorSource
if (!this.historyCache[icao]) {
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json());
this.historyCache[icao] = j.history || [];
}
const history = this.historyCache[icao];
const cur = {
ts: Math.floor(Date.now() / 1000),
latitude: plane.latitude,
longitude: plane.longitude,
altitude: plane.alt_baro ?? plane.alt_geom ?? 0,
live: true,
};
const last = history[history.length - 1];
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur);
const traceHistory = history;
if (traceHistory.length < 2) {
const popup = this.popups[icao];
if (popup) popup.update(plane, history);
return;
}
let traceLayer = this.traceLayers[icao];
let vs: VectorSource;
if (!traceLayer) {
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 + (ec[idx] as any)) / 2))
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) }))
vs.addFeature(f)
segs.push(f)
}
this.traceSegs[icao] = segs
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 _calcPopupPos(plane: any): { x: number; y: number } {
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude]))
if (!pixel) return { x: 10, y: 60 }
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
}
private _openPopup(plane: any) {
const icao = plane.icao
if (this.popups[icao]) return
const planeRef = ref(plane)
const posRef = ref(this._calcPopupPos(plane))
const container = document.createElement('div')
document.body.appendChild(container)
const mobile = window.innerWidth <= 768;
const app = createApp(AircraftPopup, {
plane: planeRef.value,
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
onClose: () => this._closePopup(icao),
onBringToFront: () => {
highestZIndex++
container.style.zIndex = String(highestZIndex)
},
})
app.mount(container)
this.popups[icao] = {
planeRef,
posRef,
unmount: () => { app.unmount(); container.remove() },
}
this._fetchTrace(plane)
}
private _closePopup(icao: string) {
const popup = this.popups[icao]
if (popup) { popup.unmount(); delete this.popups[icao] }
const segs = this.traceSegs[icao]
const layer = this.traceLayers[icao]
if (segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f))
if (layer) { this.map.removeLayer(layer); delete this.traceLayers[icao] }
delete this.traceSegs[icao]
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 }
this.popups[icao].planeRef.value = plane
// Don't reposition — user may have dragged it 🎯
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];
}
}
+173 -116
View File
@@ -1,32 +1,52 @@
import { BASE } from '@/services/api.ts'
import { createApp, ref } from 'vue'
import Map from 'ol/Map'
import { Feature } from 'ol'
import Point from 'ol/geom/Point'
import { fromLonLat } from 'ol/proj'
import { Style, Icon } from 'ol/style'
import { Vector as VectorLayer } from 'ol/layer'
import { Vector as VectorSource } from 'ol/source'
import { adjustedInterval } from '@ztimson/utils'
import ShipPopup from '@/components/Ship.vue'
import {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 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'
let highestZIndex = 1000
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': '#ff0000',
'AtoN': '#00aaff',
'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 (['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">
@@ -36,8 +56,9 @@ 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,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
@@ -46,132 +67,168 @@ function buildBoatIcon(boat: any): string {
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
</svg>
`)}`
`)}`;
}
export class AISLayer {
private map: Map
private layer!: VectorLayer<VectorSource>
private popups: any = {}
private data: any[] = []
private clickHandler: ((e: any) => void) | null = null
private refreshTimer: any = null
visible = false
private map: Map;
private layer!: VectorLayer<VectorSource>;
private popups: any = {};
private data: any[] = [];
private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null;
visible = false;
constructor(map: Map) { this.map = map }
async show() {
if (this.visible) return
this.visible = true
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 })
this.map.addLayer(this.layer)
await this._fetch()
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err);
}
}, 5_000)
constructor(map: Map) {
this.map = map;
}
hide() {
if (!this.visible) return
this.visible = false
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
this.map.removeLayer(this.layer)
private _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');
if (this.popups[boat.mmsi]) {
this.closePopup(boat.mmsi);
return;
}
private async _fetch() {
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
this.openPopup(boat);
};
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: 0.8,
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180),
rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5],
}),
}))
source.addFeature(f)
}));
source.addFeature(f);
}
}
private _calcPopupPos(boat: any): { x: number; y: number } {
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat]))
if (!pixel) return { x: 10, y: 60 }
const rect = (this.map.getTargetElement() as HTMLElement).getBoundingClientRect()
return { x: rect.left + pixel[0] + 16, y: rect.top + pixel[1] - 16 }
}
private _openPopup(boat: any) {
const mmsi = String(boat.mmsi)
if (this.popups[mmsi]) return
const boatRef = ref(boat)
const posRef = ref(this._calcPopupPos(boat))
const container = document.createElement('div')
document.body.appendChild(container)
const mobile = window.innerWidth <= 768;
const app = createApp(ShipPopup, {
boat: boatRef.value,
position: mobile ? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 380 - 16} : {x: window.innerWidth - 360 - 16, y: 16},
onClose: () => this._closePopup(mmsi),
onBringToFront: () => {
highestZIndex++
container.style.zIndex = String(highestZIndex)
},
})
app.mount(container)
this.popups[mmsi] = {
boatRef,
posRef,
unmount: () => { app.unmount(); container.remove() },
}
}
private _closePopup(mmsi: string) {
const popup = this.popups[mmsi]
if (popup) { popup.unmount(); delete this.popups[mmsi] }
private 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 }
this.popups[mmsi].boatRef.value = 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)
}
}
+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);
});
}
}
+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];
}
}
}
+14 -6
View File
@@ -62,6 +62,14 @@ export const METRICS: Record<string, MetricMeta> = {
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
}
export function formatValue(key: string, value: number | string | null): string {
if (value === null || value === undefined) return '—'
const meta = METRICS[key]
if (!meta) return String(value)
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
return `${value}${meta.unit}`
}
export function getPressureTrend(delta: number) {
if (delta > 0.5) return 'Rising';
if (delta < -0.5) return 'Falling';
@@ -76,12 +84,12 @@ export function getUVLabel(uv: number) {
return 'Extreme';
}
export function formatValue(key: string, value: number | string | null): string {
if (value === null || value === undefined) return '—'
const meta = METRICS[key]
if (!meta) return String(value)
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
return `${value}${meta.unit}`
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)
}
+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": {
+72 -23
View File
@@ -151,42 +151,89 @@ def read_bme(c, sensor, alt_m=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['seismic_magnitude']:
_mpu_peak = {
@@ -203,6 +250,8 @@ def flush_mpu(c):
data, _mpu_peak = _mpu_peak, None
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 ──────────────────────────────────────────────────────────────────
@@ -482,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()
+615
View File
@@ -15,9 +15,568 @@
"cheerio": "^1.2.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"sharp": "^0.35.4",
"yaml": "^2.9.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@scalar/client-side-rendering": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz",
@@ -1392,6 +1951,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -1585,6 +2193,13 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"cheerio": "^1.2.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"sharp": "^0.35.4",
"yaml": "^2.9.0"
}
}
+4 -2
View File
@@ -1253,11 +1253,13 @@ function toSvg(shape, fillColor, strokeColor, strokeWidth, scale) {
return svg;
}
let svg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="' + shape.viewBox + '" '
+ (shape.noAspect ? 'preserveAspectRatio="none" ' : '')
+ 'width="' + wi + '" height="' + he + '">'
+ '<g' + (shape.transform ? (' transform="' + shape.transform + '"') : '') + '>';
+ '<g' + (shape.transform ? (' transform="' + shape.transform + '"') : '') + '>'
+ '<rect x="' + shape.viewBox.split(' ')[0] + '" y="' + shape.viewBox.split(' ')[1]
+ '" width="' + shape.viewBox.split(' ')[2] + '" height="' + shape.viewBox.split(' ')[3]
+ '" fill="#ffffff01" stroke="none"/>';
if (shape.path) {
let path = shape.path;
+335 -153
View File
@@ -4,36 +4,98 @@ 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 { 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 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'
];
let db;
let passengerOperators = PASSENGER_OPERATORS;
let adsbCache = null;
let adsbCacheTs = 0;
const noRecord = [];
const history = new Map();
const milRanges = [];
let db;
setInterval(() => {
const cutoff = Date.now() - HISTORY_TTL;
for (const [key, trail] of history) {
const last = trail.at(-1);
if (!last || last.ts < cutoff) history.delete(key);
}
}, 1000 * 60 * 5);
function 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();
@@ -41,35 +103,11 @@ function fetchWithTimeout(url, ms = 5000) {
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
}
async function syncMilitaryRanges() {
const { ADSB_URL } = cfg();
try {
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { military } = await res.json();
db.exec('DELETE FROM military_ranges');
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
const insertMany = db.transaction(ranges => {
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
});
insertMany(military);
milRanges.length = 0;
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
} catch (e) {
const rows = db.prepare('SELECT start, end FROM military_ranges').all();
milRanges.length = 0;
milRanges.push(...rows.map(r => [r.start, r.end]));
}
}
export async function initAircraftDb() {
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
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 (
@@ -89,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) {
if(missing) {
const res = await fetch(CSV_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
fs.writeFileSync(CSV_CACHE, text);
const csv = fromCsv(text, true);
const insert = db.prepare(`
INSERT OR REPLACE INTO aircraft VALUES (
@icao, @built, @categoryDescription, @country, @engines,
@class, @manufacturer, @model, @modes, @operator,
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
@icao,
@built,
@categoryDescription,
@country,
@engines,
@class,
@manufacturer,
@model,
@modes,
@operator,
@operatorCallsign,
@owner,
@registration,
@serialNumber,
@aircraft
)
`);
@@ -131,133 +179,117 @@ 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.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
if (row.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';
}
async function fetchHexDb(icao) {
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`);
if (!resp.ok) return null;
const found = await resp.json();
return {
registration: found.Registration || null,
manufacturer: found.Manufacturer || null,
aircraft: found.ICAOTypeCode || null,
model: found.Type || null,
operator: found.RegisteredOwners || null,
};
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, ' ');
}
async function scrapeHexDatabase(icao) {
const url = `https://hexdatabase.com/h/${icao}`;
const resp = await fetch(url);
if (!resp.ok) return null;
const $ = cheerio.load(await resp.text());
const row = $('tr').filter((_, el) => $(el).text().toLowerCase().includes(icao.toLowerCase())).first();
if (!row.length) return null;
const cells = row.find('td');
return {
country: $(cells[1]).text().trim() || null,
registration: $(cells[2]).text().trim() || null,
aircraft: $(cells[3]).text().trim() || null,
operator: $(cells[4]).text().trim() || null,
serialNumber: $(cells[6]).text().trim() || null,
};
}
function backfillFromSimilar(aircraft) {
if (!aircraft) return {};
const similar = db.prepare(`
SELECT manufacturer, model, engines, categoryDescription, class
FROM aircraft
WHERE aircraft = ?
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
LIMIT 1
`).get(aircraft);
if (!similar) return {};
return {
manufacturer: similar.manufacturer || null,
model: similar.model || null,
engines: similar.engines || null,
categoryDescription: similar.categoryDescription || null,
class: similar.class || null,
};
}
export async function enrichAircraft(a) {
export async function enrich(a) {
if (!a.hex) return a;
const icao = a.hex.toUpperCase();
// 1. Check own DB first
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
if(row?.manufacturer && row?.model) return { ...a, ...row, type: classifyAircraft(row) };
// 2. Race the two external sources
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
let found = await Promise.any([
fetchHexDb(icao).then(resp => {
if(resp) console.log(`Source1: ${icao}`)
return resp;
}),
scrapeHexDatabase(icao).then(resp => {
if(resp) console.log(`Source2: ${icao}`)
return resp;
}),
]).catch(() => null);
if(!found) {
console.log(`Not Found: ${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' };
}
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
const merged = { ...row, ...found };
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
const similar = backfillFromSimilar(merged.aircraft);
const similar = backfillModel(merged.aircraft);
Object.assign(merged, similar);
}
// 4. Save back to DB
if (found) {
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)
@@ -283,45 +315,195 @@ export async function enrichAircraft(a) {
serialNumber: merged.serialNumber || null,
engines: merged.engines || null,
categoryDescription: merged.categoryDescription || null,
class: merged.class || null,
class: merged.class || null
});
}
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
return { icao, ...a, ...merged, type: classifyAircraft(merged) };
}
export async function getADSB() {
export async function get() {
if(adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
const { ADSB_URL } = cfg();
if (!ADSB_URL) return [];
if(!ADSB_URL) return [];
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
const j = await r.json();
const aircraft = j.aircraft || [];
for (const a of aircraft) {
a.type = 'unknown';
if (!a.hex || !a.lat || !a.lon) continue;
if(!a.hex || !a.lat || !a.lon) continue;
const key = a.hex.toLowerCase();
if (!history.has(key)) history.set(key, []);
const trail = history.get(key);
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() });
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(async a => {
a = await enrichAircraft(a);
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);
+61 -1
View File
@@ -1,4 +1,7 @@
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 = {
@@ -8,6 +11,14 @@ const SENDER_TYPE = {
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++) {
@@ -38,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`);
@@ -45,7 +57,7 @@ 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, rrsi, ppm, count, msg_type,
last_signal, last_group, group_mask, flags, altitude,
@@ -79,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;
+6
View File
@@ -10,7 +10,13 @@ export function cfg() {
return {
PORT: process.env.PORT || 3000,
ADSB_URL: process.env.ADSB_URL || '',
TINYGS_URL: process.env.TINYGS_URL || '',
TINYGS_AUTH: process.env.TINYGS_AUTH || '',
TINYGS_MQTT_PORT: process.env.TINYGS_MQTT_PORT || 1883,
TINYGS_MQTT_USER: process.env.TINYGS_MQTT_USER || '',
TINYGS_MQTT_PASS: process.env.TINYGS_MQTT_PASS || '',
DB_HOST: process.env.DB_HOST || 'http://localhost:8428',
EMAIL: process.env.EMAIL,
LATITUDE: parseFloat(process.env.LATITUDE || '0'),
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
+3
View File
@@ -1,6 +1,7 @@
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
import {getCelestialCurrent} from './celestial.mjs';
import {localDateStr} from './config.mjs';
import {frostRisk} from './openweather.mjs';
export let lastForecast = { ts: 0, slots: [], summary: null }
export const forecastTTL = 5 * 60 * 60_000
@@ -188,6 +189,7 @@ 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
@@ -252,6 +254,7 @@ function summarise(slots) {
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')),
+112 -39
View File
@@ -2,6 +2,9 @@ import { existsSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { tzOffsetMinutes } from './config.mjs'
import { getWeatherCondition } from './forecast.mjs'
import { getCelestialForecast } from './celestial.mjs'
import {frostRisk} from './openweather.mjs';
const DIR = dirname(fileURLToPath(import.meta.url))
const ICON_DIR = resolve(DIR, 'public', 'icons')
@@ -87,43 +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 = OWM_ICONS[code] || '01d'
const temp = data.daily.temperature_2m_max[i]
const tempMax = data.daily.temperature_2m_max[i]
const tempMin = data.daily.temperature_2m_min[i]
const temp = tempMax
const humidity = data.daily.relative_humidity_2m_mean[i]
const 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,
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 ────────────────────────────────────────────────────────────────────
@@ -146,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) => {
@@ -158,44 +195,80 @@ export async function hourlyWeather(lat, lon, start, end) {
})
}
return Promise.all(data.hourly.time.map(async (time, i) => {
const rows = data.hourly.time.map((time, i) => {
const dateKey = time.slice(0, 10)
const sun = sunMap[dateKey] ?? {}
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,
solar_wm2: data.hourly.shortwave_radiation[i],
uv_index_max: uv,
uv_dose: 0,
solar_wm2: data.hourly.shortwave_radiation[i] ?? 0,
daily_light_integral: data.hourly.shortwave_radiation[i]
? Math.round(data.hourly.shortwave_radiation[i] * 3600 / 1_000_000 * 4.57 * 100) / 100
: 0,
lux: data.hourly.shortwave_radiation[i]
? Math.round(data.hourly.shortwave_radiation[i] * 120)
: 0,
visibility: data.hourly.visibility[i] != null
? 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
}
+8
View File
@@ -18,3 +18,11 @@ export async function fetchIcon(icon) {
await downloadIcon(icon, iconPath);
return iconPath;
}
export function frostRisk(tempC, dewPointC, humidity) {
if (tempC > 4) return 'None'
if (tempC <= 0 && dewPointC <= 0) return 'High'
if (tempC <= 2 && humidity > 85) return 'Moderate'
if (tempC <= 4) return 'Low'
return 'None'
}
+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),
};
});
};
+15 -5
View File
@@ -1,4 +1,5 @@
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);
@@ -30,9 +31,15 @@ function metricToFields(results) {
}
export async function queryCurrent() {
const c = cfg();
const results = await queryInstant(c, `{__name__!=""}`);
return {time: new Date(), ...metricToFields(results)};
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) {
@@ -101,11 +108,14 @@ export async function queryDaily(start, end) {
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) {
if(fields.latitude && fields.longitude) {
return {latitude: fields.latitude, longitude: fields.longitude, altitude: fields.altitude || c.ALTITUDE};
}
return {latitude: c.LATITUDE, longitude: c.LONGITUDE, altitude: c.ALTITUDE};
return {latitude: 0, longitude: 0, altitude: 0};
}
+60 -34
View File
@@ -7,11 +7,15 @@ import {getCelestialCurrent, getCelestialForecast} from './celestial.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, initAircraftDb} from './adsb.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 {getTinyGSData} from './sats.mjs';
import {getSpaceWeather} from './space.mjs';
import {Aurora} from './aurora.mjs';
import {getSondes} from './sonde.mjs';
// ── Uncaught error handlers ───────────────────────────────────────────────────
@@ -47,7 +51,7 @@ function filterArr(arr, fields) {
return arr.map(row => filterFields(row, fields));
}
// ── Current ───────────────────────────────────────────────────────────────────
// ── Weather ───────────────────────────────────────────────────────────────────
app.get('/api/current', asyncHandler(async (req, res) => {
const { fields } = req.query
@@ -71,51 +75,61 @@ app.get('/api/current', asyncHandler(async (req, res) => {
res.json(filterFields(merged, fields))
}));
// ── Hourly ────────────────────────────────────────────────────────────────────
app.get('/api/hourly', asyncHandler(async (req, res) => {
const { fields } = req.query
const coords = await getCoords()
const now = new Date()
now.setMinutes(0, 0, 0)
const forecastLimit = new Date(now.getTime() + 24 * 60 * 60_000)
const forecast24Limit = new Date(now.getTime() + 24 * 60 * 60_000)
const start = req.query.start ? new Date(req.query.start) : now
const end = req.query.end ? new Date(req.query.end) : forecastLimit
const end = req.query.end ? new Date(req.query.end) : forecast24Limit
const [history, forecast24, forecast3rdParty] = await Promise.all([
start.getTime() < now.getTime() ? queryHourly(start, now) : Promise.resolve([]),
end.getTime() > now.getTime() ? getForecast().then(f => f.slots.filter(h => new Date(h.time) <= end)) : Promise.resolve([]),
end.getTime() > forecastLimit.getTime() ? hourlyWeather(coords.latitude, coords.longitude, forecastLimit, end) : 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 hourly = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...forecast3rdParty])
res.json(filterArr(hourly, fields))
}));
// ── Daily ─────────────────────────────────────────────────────────────────────
const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo])
res.json(filterArr(combined, fields))
}))
app.get('/api/daily', asyncHandler(async (req, res) => {
const { fields } = req.query
const coords = await getCoords();
const coords = await getCoords()
const now = new Date()
const todayStart = new Date(now)
todayStart.setHours(0, 0, 0, 0)
const todayEnd = new Date(todayStart)
todayEnd.setDate(todayStart.getDate() + 1)
const start = req.query.start ? new Date(req.query.start) : todayEnd
const start = req.query.start ? new Date(req.query.start) : todayStart
const end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
const [history, current, future] = Promise.all([
start.getTime() < todayStart.getTime() ? queryDaily(start, todayStart) : Promise.resolve([]), // Historic
start.getTime() <= todayStart.getTime() ? queryCurrent().then(resp => [resp]) : Promise.resolve([]), // Current
start.getTime() >= todayEnd.getTime() ? dailyWeather(coords.latitude, coords.longitude, todayStart, end) : Promise.resolve([]), // Future
]);
const daily = [...history, ...current, ...getCelestialForecast(coords.latitude, coords.longitude, future)];
const [history, current, future] = await Promise.all([
start < todayStart
? queryDaily(start, todayStart)
: Promise.resolve([]),
start <= todayStart && end >= todayStart
? queryCurrent().then(resp => [resp])
: Promise.resolve([]),
end > todayEnd
? dailyWeather(coords.latitude, coords.longitude, todayEnd, end)
: Promise.resolve([]),
])
const daily = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...current, ...future])
res.json(filterArr(daily, fields))
}));
}))
// ── Position ──────────────────────────────────────────────────────────────────
app.get('/api/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();
@@ -133,18 +147,30 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
// ── Space ─────────────────────────────────────────────────────────────────────
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
// app.get('/api/space', async (req, res) => {
// const {fields} = req.query;
// res.json(filterFields(await getSpaceWeather(), fields));
// });
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', asyncHandler(async (req, res) => res.json(await get())));
app.get('/api/adsb/:icao/image', asyncHandler(async (req, res) => {
const buffer = await getIcaoImage(req.params.icao);
res.contentType('image/jpeg').send(buffer)
}));
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getHistory(req.params.icao))));
app.get('/api/adsb', asyncHandler(async (req, res) => res.json(await getADSB())));
app.get('/api/adsb/:icao', asyncHandler(async (req, res) => res.json(await getADSBHistory(req.params.icao))));
app.get('/api/ais', asyncHandler(async (req, res) => res.json(await getAIS())));
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getADSBRange())));
app.get('/api/ais/:mmsi/image', asyncHandler(async (req, res) => {
const buffer = await getAISImage(req.params.mmsi);
res.contentType('image/jpeg').send(buffer)
}));
app.get('/api/sondes', asyncHandler(async (req, res) => res.json(await getSondes())));
app.get('/api/sats', (_req, res) => res.json(getTinyGSData()));
// ── DOCS ──────────────────────────────────────────────────────────────────────
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. 01d' },
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³)' },
}
}
}
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