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= ALTITUDE=
LATITUDE= LATITUDE=
LONGITUDE= 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=""> <html lang="">
<head> <head>
<title>Weather Station</title> <title>Weather Station</title>
<link rel="icon" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f172a">
<meta name="description" content="Local Weather & Forecasting">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Weather Station">
<link rel="icon" href="/favicon.png"> <!-- OpenGraph -->
<meta property="og:type" content="website">
<meta property="og:title" content="Weather Station">
<meta property="og:description" content="Local Weather & Forecasting">
<meta property="og:image" content="/favicon.png">
<meta property="og:url" content="./">
<!-- Twitter/X card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Weather Station">
<meta name="twitter:description" content="Local Weather & Forecasting">
<meta name="twitter:image" content="/favicon.png">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+78 -23
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@ztimson/css-utils": "^1.3.5", "@ztimson/css-utils": "^1.3.5",
"@ztimson/utils": "^0.29.5", "@ztimson/utils": "^0.29.5",
"ol-mapbox-style": "^13.4.1",
"vue": "^3.5.38" "vue": "^3.5.38"
}, },
"devDependencies": { "devDependencies": {
@@ -589,6 +590,40 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -950,7 +985,6 @@
"version": "3.9.3", "version": "3.9.3",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz",
"integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@polka/url": { "node_modules/@polka/url": {
@@ -1274,7 +1308,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
@@ -1558,7 +1591,6 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
"integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==", "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"reference-spec-reader": "^0.2.0", "reference-spec-reader": "^0.2.0",
@@ -1868,7 +1900,6 @@
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
@@ -1940,7 +1971,6 @@
"version": "0.8.3", "version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fsevents": { "node_modules/fsevents": {
@@ -1972,7 +2002,6 @@
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz",
"integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@petamoriken/float16": "^3.9.3", "@petamoriken/float16": "^3.9.3",
@@ -2141,6 +2170,12 @@
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
} }
}, },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -2165,7 +2200,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
"dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
@@ -2461,6 +2495,12 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mapbox-to-css-font": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-3.2.0.tgz",
"integrity": "sha512-kvsEfzvLik34BiFj+S19bv5d70l9qSdkUzrq99dvZ9d5POaLyB4vJMQmq3BoJ5D6lFG1GYnMM7o7cm5Jh8YEEg==",
"license": "BSD-2-Clause"
},
"node_modules/memorystream": { "node_modules/memorystream": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
@@ -2470,6 +2510,15 @@
"node": ">= 0.10.0" "node": ">= 0.10.0"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -2572,7 +2621,6 @@
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz",
"integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fflate": "^0.8.0" "fflate": "^0.8.0"
@@ -2603,7 +2651,6 @@
"version": "10.9.0", "version": "10.9.0",
"resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz", "resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz",
"integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==", "integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@types/rbush": "4.0.0", "@types/rbush": "4.0.0",
@@ -2618,6 +2665,22 @@
"url": "https://opencollective.com/openlayers" "url": "https://opencollective.com/openlayers"
} }
}, },
"node_modules/ol-mapbox-style": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-13.4.1.tgz",
"integrity": "sha512-da4FKZUXoXMzK5ADeRHAXzd3bTeiKg/Y9LBOTB6qNTP62MQm93y6ISrIXzOS9atfGgQMEHJ2pCgP272IMeZCXA==",
"license": "BSD-2-Clause",
"dependencies": {
"@maplibre/maplibre-gl-style-spec": "^24.4.1",
"mapbox-to-css-font": "^3.2.0"
},
"funding": {
"url": "https://github.com/sponsors/ahocevar"
},
"peerDependencies": {
"ol": "*"
}
},
"node_modules/open": { "node_modules/open": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
@@ -2643,14 +2706,12 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"dev": true,
"license": "(MIT AND Zlib)" "license": "(MIT AND Zlib)"
}, },
"node_modules/parse-headers": { "node_modules/parse-headers": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-browserify": { "node_modules/path-browserify": {
@@ -2681,7 +2742,6 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"resolve-protobuf-schema": "^2.1.0" "resolve-protobuf-schema": "^2.1.0"
@@ -2776,14 +2836,12 @@
"version": "3.6.1", "version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/quick-lru": { "node_modules/quick-lru": {
"version": "6.1.2", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -2796,14 +2854,12 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/rbush": { "node_modules/rbush": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"quickselect": "^3.0.0" "quickselect": "^3.0.0"
@@ -2842,14 +2898,12 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
"integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/resolve-protobuf-schema": { "node_modules/resolve-protobuf-schema": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"protocol-buffers-schema": "^3.3.1" "protocol-buffers-schema": "^3.3.1"
@@ -3417,6 +3471,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/totalist": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -3476,7 +3536,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz", "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
"integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==", "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -3779,7 +3838,6 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
"dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/which": { "node_modules/which": {
@@ -3819,7 +3877,6 @@
"version": "1.10.2", "version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
"dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/yallist": { "node_modules/yallist": {
@@ -3833,7 +3890,6 @@
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz", "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
"integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==", "integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@zarrita/storage": "^0.2.0", "@zarrita/storage": "^0.2.0",
@@ -3844,7 +3900,6 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz",
"integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==",
"dev": true,
"license": "MIT AND BSD-3-Clause" "license": "MIT AND BSD-3-Clause"
} }
} }
+3 -2
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@ztimson/css-utils": "^1.3.5", "@ztimson/css-utils": "^1.3.5",
"@ztimson/utils": "^0.29.5", "@ztimson/utils": "^0.29.5",
"ol-mapbox-style": "^13.4.1",
"vue": "^3.5.38" "vue": "^3.5.38"
}, },
"devDependencies": { "devDependencies": {
@@ -24,9 +25,9 @@
"typescript": "~6.0.0", "typescript": "~6.0.0",
"vite": "^8.0.16", "vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2", "vite-plugin-vue-devtools": "^8.1.2",
"vue": "^3.5.38",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-tsc": "^3.3.5", "vue-tsc": "^3.3.5"
"vue": "^3.5.38"
}, },
"engines": { "engines": {
"node": "^22.18.0 || >=24.12.0" "node": "^22.18.0 || >=24.12.0"
+370
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; position: fixed;
top: 16px; top: 16px;
right: 16px; right: 16px;
z-index: 500; z-index: 50;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 99px; border-radius: 99px;
-1
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

+139 -115
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {BASE} from '@/services/api.ts';
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import AircraftAltitude from '@/components/Altitude.vue'
const BASE_SPEED_UNITS = { const BASE_SPEED_UNITS = {
knots: { label: 'KTS', convert: (v: number) => Math.round(v) }, 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 }, fps: { label: 'FT/S', convert: (v: number) => v },
} }
const photoError = ref(false);
const UNIT_KEY = 'at_units' const UNIT_KEY = 'at_units'
function loadUnits() { function loadUnits() {
const s = localStorage.getItem(UNIT_KEY) const s = localStorage.getItem(UNIT_KEY)
return s ? JSON.parse(s) : { speed: 'knots', altitude: 'meters', vertical: 'mps' } 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 }>() const emit = defineEmits<{ (e: 'close'): void; (e: 'bringToFront'): void }>()
// ── Units ─────────────────────────────────────────────────────────────────────
const prefs = ref(loadUnits()) const prefs = ref(loadUnits())
function saveAndSet(p: any) { function saveAndSet(p: any) {
prefs.value = { ...p } 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 altitude = computed(() => BASE_ALTITUDE_UNITS[prefs.value.altitude as keyof typeof BASE_ALTITUDE_UNITS])
const vertical = computed(() => BASE_VERTICAL_UNITS[prefs.value.vertical as keyof typeof BASE_VERTICAL_UNITS]) const 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 callsign = computed(() => props.plane.name?.trim() || props.plane.flight?.trim())
const altitudeVal = computed(() => props.plane.alt_baro ?? props.plane.alt_geom ?? 0) 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 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 altVal = computed(() => props.plane.landed ? 'LANDED' : altitude.value.convert(altitudeVal.value))
const altUnit = computed(() => props.plane.landed ? '' : altitude.value.label) 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 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 || '') 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 pos = ref({ ...props.position })
const isDragging = ref(false) const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 }) const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
@@ -108,7 +114,6 @@ onUnmounted(() => {
document.removeEventListener('touchcancel', onTouchEnd) document.removeEventListener('touchcancel', onTouchEnd)
}) })
// ── Navball ───────────────────────────────────────────────────────────────────
const navball = computed(() => { const navball = computed(() => {
const data = props.plane const data = props.plane
const heading = data.heading ?? data.track ?? 0 const heading = data.heading ?? data.track ?? 0
@@ -125,10 +130,10 @@ const navball = computed(() => {
const [start, end] = [85, 115] const [start, end] = [85, 115]
if (isMajor) { if (isMajor) {
pitchLines.push(` pitchLines.push(`
<text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text> <text x="${start - Math.abs(p) - 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold" text-anchor="end">${Math.abs(p)}</text>
<line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/> <line x1="${start - Math.abs(p)}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end + Math.abs(p)}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>
<text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text> <text x="${end + Math.abs(p) + 5}" y="${104 + pitch * 2.5 - p * 2.5}" fill="#fff" font-size="8" font-weight="bold">${Math.abs(p)}</text>
`) `)
} else { } else {
pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`) pitchLines.push(`<line x1="${start}" y1="${100 + pitch * 2.5 - p * 2.5}" x2="${end}" y2="${100 + pitch * 2.5 - p * 2.5}" stroke="#fff" stroke-width="2"/>`)
} }
@@ -189,10 +194,118 @@ const navball = computed(() => {
}) })
</script> </script>
<style scoped>
.aircraft-popup {
position: fixed;
z-index: 100;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(0, 255, 0, 0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
max-width: 360px;
}
.ap-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(0, 255, 0, 0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
}
.ap-callsign {
margin: 0 24px 0 0;
color: #7eeee1;
font-size: 16px;
}
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
.ap-meta {
font-size: 11px;
color: #888;
margin-top: 4px;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
}
.ap-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.ap-close:hover { background: rgba(255,255,255,0.35); }
.ap-photo-wrap {
max-width: 360px;
max-height: 160px;
background: #111;
border-bottom: 1px solid rgba(200,200,220,0.15);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.ap-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
.ap-photo--sil { object-fit: contain; box-sizing: border-box; image-rendering: auto; }
.ap-body {
display: flex;
gap: 16px;
padding: 12px 16px;
}
.ap-gauges { flex: 0 0 110px; }
.ap-gauge-wrap { margin-bottom: 12px; }
.ap-gauge-label {
color: #888;
font-size: 11px;
font-weight: bold;
margin-bottom: 3px;
}
.ap-gauge {
padding: 6px;
background: rgba(0,0,0,0.95);
border: 2px solid #0f0;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
transition: border-color 0.15s;
}
.ap-gauge:hover { border-color: #0ff; }
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
.ap-navball { flex: 1; margin-top: 1rem; }
</style>
<template> <template>
<div class="aircraft-popup" :style="{ left: pos.x + 'px', top: pos.y + 'px' }"> <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"> <div ref="header" class="ap-header" @mousedown="onMouseDown" @touchstart.passive="onTouchStart">
<h3 class="ap-callsign"> <h3 class="ap-callsign">
@@ -203,17 +316,21 @@ const navball = computed(() => {
<button class="ap-close" @click.stop="emit('close')">×</button> <button class="ap-close" @click.stop="emit('close')">×</button>
</div> </div>
<!-- Body -->
<div class="ap-meta flex-r justify-between"> <div class="ap-meta flex-r justify-between">
<div class="flex-c"> <div class="flex-c flex-fill">
<span>{{ operator }}</span> <span>{{ operator || 'Unknown Owner' }}</span>
<span>{{ plane.country || 'Unknown Country' }}</span> <span>{{ plane.country || 'Unknown Country' }}</span>
</div> </div>
<div class="flex-c align-x-end"> <div class="flex-c flex-fill align-x-end">
<span>{{ description }}</span> <span>{{ description || 'Unknown Aircraft' }}</span>
<span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} {{ plane.type || 'Unknown' }}</span> <span style="text-transform: capitalize">{{ plane.class || 'Unknown' }} {{ plane.type || 'Unknown' }}</span>
</div> </div>
</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-body">
<div class="ap-gauges"> <div class="ap-gauges">
<div class="ap-gauge-wrap"> <div class="ap-gauge-wrap">
@@ -241,104 +358,11 @@ const navball = computed(() => {
<div class="ap-navball" v-html="navball" /> <div class="ap-navball" v-html="navball" />
</div> </div>
<AircraftAltitude
:history="altitudeHistory"
:current-altitude="currentAltitude"
:unit="altitude.label"
/>
</div> </div>
</template> </template>
<style scoped>
.aircraft-popup {
position: fixed;
z-index: 9999;
pointer-events: auto;
background: rgba(0,0,0,0.88);
border: 1px solid rgba(200,200,220,0.3);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden;
font-family: monospace;
color: #ccc;
min-width: 320px;
}
.ap-header {
position: relative;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
cursor: move;
user-select: none;
-webkit-user-select: none;
}
.ap-callsign {
margin: 0 24px 0 0;
color: #7eeee1;
font-size: 16px;
}
.ap-callsign a { color: #6bb6ff; text-decoration: none; }
.ap-meta {
font-size: 11px;
color: #888;
margin-top: 4px;
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid rgba(200,200,220,0.2);
}
.ap-meta--detail { margin-top: 3px; }
.ap-icao { text-align: right; }
.ap-operator { text-align: right; color: #aaa; }
.ap-close {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.ap-close:hover { background: rgba(255,255,255,0.35); }
.ap-body {
display: flex;
gap: 16px;
padding: 12px 16px;
}
.ap-gauges { flex: 0 0 110px; }
.ap-gauge-wrap { margin-bottom: 12px; }
.ap-gauge-label {
color: #888;
font-size: 11px;
font-weight: bold;
margin-bottom: 3px;
}
.ap-gauge {
padding: 6px;
background: rgba(0,0,0,0.95);
border: 2px solid #0f0;
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
transition: border-color 0.15s;
}
.ap-gauge:hover { border-color: #0ff; }
.ap-gauge-val { font-size: 20px; font-weight: bold; color: #0f0; }
.ap-gauge-unit { font-size: 11px; color: #0f0; margin-bottom: 2px; }
.ap-navball { flex: 1; }
</style>
+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"> <script setup lang="ts">
import Loading from '@/components/Loading.vue'; 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 {inject, onMounted, ref} from 'vue';
import {api, BASE} from '../services/api'; import {api, BASE} from '../services/api';
@@ -108,47 +108,75 @@ onMounted(async () => {
<div class="stat" @click="openGraph?.('heat_index')"> <div class="stat" @click="openGraph?.('heat_index')">
<span class="stat-label">Feels Like</span> <span class="stat-label">Feels Like</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div> <div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ ~~(data.heat_index) }}<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>
<div class="stat" @click="openGraph?.('humidity')"> <div class="stat" @click="openGraph?.('humidity')">
<span class="stat-label">Humidity</span> <span class="stat-label">Humidity</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div> <div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ Math.round(data.humidity) }}%</span> <span v-else class="stat-value">
<template v-if="data.humidity == null"></template>
<template v-else>{{ Math.round(data.humidity) }}<span class="fg-muted">%</span></template>
</span>
</div>
<div class="stat" @click="openGraph?.('precipitation_chance')">
<span class="stat-label">Rain</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">
<template v-if="data.raining == null"></template>
<template v-else-if="data.raining">Raining</template>
<template v-else>{{data.precipitation_chance}}<span class="fg-muted">%</span></template>
</span>
</div> </div>
<div class="stat" @click="openGraph?.('clouds')"> <div class="stat" @click="openGraph?.('clouds')">
<span class="stat-label">Cloud Cover</span> <span class="stat-label">Cloud Cover</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div> <div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ Math.round(data.clouds) }}%</span> <span v-else class="stat-value">
</div> <template v-if="data.clouds == null"></template>
<div class="stat" @click="openGraph?.('pressure_hpa')"> <template v-else>{{ Math.round(data.clouds) }}<span class="fg-muted">%</span></template>
<span class="stat-label">Pressure</span> </span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.pressure_hpa?.toFixed(1) }} <span class="fg-muted">hPa</span></span>
</div> </div>
<div class="stat" @click="openGraph?.('pressure_slp')"> <div class="stat" @click="openGraph?.('pressure_slp')">
<span class="stat-label">Sea Level</span> <span class="stat-label">Sea Level</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div> <div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.pressure_slp?.toFixed(1) }} <span class="fg-muted">({{ 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>
<div class="stat" @click="openGraph?.('vapor_pressure_deficit')"> <div class="stat" @click="openGraph?.('vapor_pressure_deficit')">
<span class="stat-label">VPD KPA</span> <span class="stat-label">VPD KPA</span>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div> <div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading/></div>
<span v-else class="stat-value">{{ data.vapor_pressure_deficit?.toFixed(2) }} <span 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>
<div class="stat" @click="openGraph?.('air_quality')"> <div class="stat" @click="openGraph?.('air_quality')">
<span class="stat-label">Air Quality</span> <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> <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>
<div class="stat" @click="openGraph?.('air_quality_ohms')"> <div class="stat" @click="openGraph?.('air_quality_ohms')">
<span class="stat-label">Gas Resistance</span> <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> <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>
<div class="stat" @click="openGraph?.('visibility')"> <div class="stat" @click="openGraph?.('visibility')">
<span class="stat-label">Visibility</span> <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> <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> </div>
</div> </div>
+3 -3
View File
@@ -73,9 +73,9 @@ onMounted(async () => {
<span>{{ ~~(d.temperature_max) + '°' || '—' }}</span> <span>{{ ~~(d.temperature_max) + '°' || '—' }}</span>
<span class="day-low">{{ ~~(d.temperature_min) + '°' || '—' }}</span> <span class="day-low">{{ ~~(d.temperature_min) + '°' || '—' }}</span>
</div> </div>
<div class="day-humid">🌡 {{ ~~(d.humidity) || 0 }}%</div> <div class="day-humid align-x">🌡 {{ ~~(d.humidity) || 0 }}%</div>
<div class="day-wind">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div> <div class="day-wind align-x">🍃 {{ ~~(d.wind_gusts as any) || 0 }} Kph ({{dir(d.wind_dir as any)}})</div>
<div class="day-rain">🌧 {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div> <div class="day-rain align-x">🌧 {{ d.precipitation_chance || 0 }}% ({{ (d.precipitation as any || 0).toFixed(1) }}mm)</div>
</div> </div>
</div> </div>
<div v-else class="strip"> <div v-else class="strip">
File diff suppressed because it is too large Load Diff
+15 -6
View File
@@ -65,7 +65,11 @@ const {current: d} = useWeather();
<div class="stat" @click="openGraph?.('precipitation_chance')"> <div class="stat" @click="openGraph?.('precipitation_chance')">
<span class="stat-label">Rain</span> <span class="stat-label">Rain</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div> <div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.raining == 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>
<div class="stat" @click="openGraph?.('lightning_rate')"> <div class="stat" @click="openGraph?.('lightning_rate')">
<span class="stat-label">Lightning</span> <span class="stat-label">Lightning</span>
@@ -82,6 +86,11 @@ const {current: d} = useWeather();
<div class="divider" /> <div class="divider" />
<div class="stat-grid"> <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')"> <div class="stat" @click="openGraph?.('pressure_hpa')">
<span class="stat-label">Pressure (SLP)</span> <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> <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> <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> <span v-else class="stat-value">{{ d.precipitation?.toFixed(1) ?? '—' }} <span v-if="d.precipitation != null" class="fg-muted">mm</span></span>
</div> </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"> <div class="stat">
<span class="stat-label">Dew Point</span> <span class="stat-label">Dew Point</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div> <div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
@@ -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> <div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.frost_risk ?? '—' }}</span> <span v-else class="stat-value">{{ d.frost_risk ?? '—' }}</span>
</div> </div>
<div class="stat" @click="openGraph?.('accumulation')">
<span class="stat-label">Accumulation</span>
<div v-if="!d" class="w-100 pos-rel br-2 overflow-hidden" style="height: 1.75em"><Loading /></div>
<span v-else class="stat-value">{{ d.accumulation?.toFixed(1) ?? '—' }} <span v-if="d.accumulation != null" class="fg-muted">cm</span></span>
</div>
</div> </div>
</div> </div>
</template> </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>
+101 -71
View File
@@ -5,47 +5,100 @@ import { api } from '../services/api';
import MetricRow from './MetricRow.vue'; import MetricRow from './MetricRow.vue';
const d = ref<any>(null); const d = ref<any>(null);
const history = ref<number[]>([]);
const canvas = ref<HTMLCanvasElement | null>(null); const 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 SEG_W = W / (MAX_PTS - 1);
const SUB = 4; const SUB = 4;
const RIGHT_PAD = 2 * SEG_W;
let interval: ReturnType<typeof setInterval>; let interval: ReturnType<typeof setInterval>;
let rafId: number; let rafId: number;
let drawProgress = 0; let drawProgress = 0;
let lastTimestamp = 0; let lastTimestamp = 0;
const ANIM_DURATION = 1000; const ANIM_DURATION = 1200;
let pointBuffer: { x: number; y: number }[] = []; type Pt = { x: number; y: number };
let prevMax = 1.0; 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; 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); const newMax = Math.max(...pts, 1.0);
prevMax = prevMax + (newMax - prevMax) * 0.15; t.prevMax = t.prevMax + (newMax - t.prevMax) * 0.15; // eased amplitude scale
const buf: { x: number; y: number }[] = []; const tipX = W - RIGHT_PAD;
const buf: Pt[] = [];
pts.forEach((v, i) => { pts.forEach((v, i) => {
const prev = pts[i - 1] ?? v; const prev = pts[i - 1] ?? v;
const max = prevMax; const prevNorm = prev === 0 ? 0 : (prev / t.prevMax) * amp;
const prevNorm = prev === 0 ? 0 : (prev / max) * amp; const norm = v === 0 ? 0 : (v / t.prevMax) * amp;
const norm = v === 0 ? 0 : (v / max) * amp; const baseX = tipX - (pts.length - i) * SEG_W;
const baseX = i * SEG_W;
const s = SEG_W / SUB; const s = SEG_W / SUB;
// one oscillation per value, amplitude fading prevNorm → norm
const a0 = prevNorm * 0.75 + norm * 0.25; const a0 = prevNorm * 0.75 + norm * 0.25;
const a1 = prevNorm * 0.25 + norm * 0.75; 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 - a0 }); // up
buf.push({ x: baseX + s, y: mid - a1 }); // up peak buf.push({ x: baseX + s * 2, y: mid - a1 }); // up peak
buf.push({ x: baseX + s * 2, y: mid + a2 }); // down buf.push({ x: baseX + s * 3, y: mid + norm }); // down
buf.push({ x: baseX + s * 3, y: mid }); // back to exact center ✓ 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) { function draw(timestamp: number) {
@@ -58,64 +111,38 @@ function draw(timestamp: number) {
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
// Center line — full width AXES.forEach((_, i) => {
ctx.beginPath(); const mid = i * BAND + BAND / 2;
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
ctx.moveTo(0, H / 2);
ctx.lineTo(W, H / 2);
ctx.stroke();
if (pointBuffer.length < 2) { rafId = requestAnimationFrame(draw); return; } // 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;
// Shift the whole path left by PAD_SEGS segments so tip never hits the edge // Center line per band — full width
const xOffset = 3 * SEG_W; ctx.beginPath();
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
ctx.moveTo(0, mid);
ctx.lineTo(W, mid);
ctx.stroke();
const settledCount = pointBuffer.length - SUB; drawTrace(ctx, <any>traces[i], (<any>AXES)[i].color);
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); rafId = requestAnimationFrame(draw);
} }
async function poll() { async function poll() {
d.value = await api.current('seismic_magnitude'); d.value = await api.current('seismic_magnitude,seismic_x,seismic_y,seismic_z');
history.value.push(d.value.seismic_magnitude ?? 0); AXES.forEach((axis, i) => {
if (history.value.length > MAX_PTS) history.value.shift(); const t = <any>traces[i];
buildBuffer(history.value); t.history.push(d.value[axis.key] ?? 0);
if (t.history.length > MAX_PTS) t.history.shift();
buildBuffer(t, i);
});
drawProgress = 0; drawProgress = 0;
} }
@@ -133,14 +160,17 @@ onUnmounted(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
canvas { width: 100%; height: 60px; } canvas { width: 100%; height: 180px; }
</style> </style>
<template> <template>
<div class="card"> <div class="card">
<div class="card-title">🫨 Seismic</div> <div class="card-title">🫨 Seismic</div>
<div v-if="!d" class="p-2"> <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 /> <Loading />
</div> </div>
</div> </div>
+118 -98
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import {BASE} from '@/services/api.ts';
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps<{ boat: any; position: { x: number; y: number } }>() 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) }, kph: { label: 'KPH', convert: (v: number) => (v * 1.852).toFixed(1) },
mph: { label: 'MPH', convert: (v: number) => (v * 1.15078).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() { function loadUnits() {
const s = localStorage.getItem(UNIT_KEY) 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()) const prefs = ref(loadUnits())
function cycleSpeed() { function cycleSpeed() {
const keys = Object.keys(SPEED_UNITS) const keys = Object.keys(SPEED_UNITS)
const p = loadUnits() const p = loadUnits()
@@ -22,12 +26,20 @@ function cycleSpeed() {
prefs.value = { ...p } prefs.value = { ...p }
localStorage.setItem(UNIT_KEY, JSON.stringify(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 speed = computed(() => SPEED_UNITS[prefs.value.speed as keyof typeof SPEED_UNITS])
const name = computed(() => props.boat.name?.trim() || props.boat.callsign?.trim() || '-') const name = computed(() => props.boat.shipname?.trim() || props.boat.callsign?.trim() || '-')
const heading = computed(() => props.boat.heading ?? props.boat.course ?? 0) 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 speedVal = computed(() => speed.value.convert(props.boat.speed ?? 0))
const photoError = ref(false)
// ── Drag ──────────────────────────────────────────────────────────────────────
const pos = ref({ ...props.position }) const pos = ref({ ...props.position })
const isDragging = ref(false) const isDragging = ref(false)
const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 }) const dragStart = ref({ mx: 0, my: 0, ex: 0, ey: 0 })
@@ -78,6 +90,7 @@ onMounted(() => {
document.addEventListener('touchmove', onTouchMove, { passive: false }) document.addEventListener('touchmove', onTouchMove, { passive: false })
document.addEventListener('touchend', onTouchEnd) document.addEventListener('touchend', onTouchEnd)
document.addEventListener('touchcancel', onTouchEnd) document.addEventListener('touchcancel', onTouchEnd)
window.addEventListener('storage', syncUnits)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mousemove', onMouseMove)
@@ -85,14 +98,13 @@ onUnmounted(() => {
document.removeEventListener('touchmove', onTouchMove) document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('touchcancel', onTouchEnd) document.removeEventListener('touchcancel', onTouchEnd)
window.removeEventListener('storage', syncUnits)
}) })
const compass = computed(() => { const compass = computed(() => {
const hdg = heading.value const hdg = heading.value
const course = props.boat.course ?? hdg const course = props.boat.cog ?? hdg
const bearing = props.boat.bearing ?? 0
// ── Compass tape ──────────────────────────────────────────────────────────
const tapeTicks: string[] = [] const tapeTicks: string[] = []
for (let offset = -60; offset <= 60; offset += 5) { for (let offset = -60; offset <= 60; offset += 5) {
const deg = ((hdg + offset) % 360 + 360) % 360 const deg = ((hdg + offset) % 360 + 360) % 360
@@ -113,9 +125,8 @@ const compass = computed(() => {
} }
} }
// ── Rose centre & radii ─────────────────────────────────────────────────── const CX = 100, CY = 140
const CX = 100, CY = 158 // moved down from 145 const R = 68
const R = 68 // grown from 55
const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180)) const rudder = Math.max(-30, Math.min(30, ((course - hdg + 540) % 360) - 180))
const rudX = CX + (rudder / 30) * 40 const rudX = CX + (rudder / 30) * 40
@@ -125,12 +136,11 @@ const compass = computed(() => {
const cNeedleX = CX + 52 * Math.sin(courseRad) const cNeedleX = CX + 52 * Math.sin(courseRad)
const cNeedleY = CY - 52 * Math.cos(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 bearRad = bearRel * Math.PI / 180
const bNeedleX = CX + 52 * Math.sin(bearRad) const bNeedleX = CX + 52 * Math.sin(bearRad)
const bNeedleY = CY - 52 * Math.cos(bearRad) const bNeedleY = CY - 52 * Math.cos(bearRad)
// Ring tick marks scaled to new R
const ringTicks = Array.from({ length: 36 }, (_, i) => { const ringTicks = Array.from({ length: 36 }, (_, i) => {
const a = i * 10 const a = i * 10
const rad = (a - 90) * Math.PI / 180 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>` return `<text x="${x}" y="${y}" text-anchor="middle" fill="#0ff" font-size="10" font-weight="bold" opacity="0.8">${lbl}</text>`
}).join('') }).join('')
// Legend & rudder bar shifted to bottom of taller SVG
const LEG_Y = 190
return ` return `
<svg width="200" height="230" viewBox="0 0 200 230"> <svg width="200" height="210" viewBox="0 0 200 210">
<!-- Tape -->
<rect x="10" y="18" width="180" height="40" fill="rgba(0,20,40,0.95)" rx="3"/> <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="18" x2="190" y2="18" stroke="#0ff" stroke-width="1"/>
<line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/> <line x1="10" y1="58" x2="190" y2="58" stroke="#0ff" stroke-width="1"/>
${tapeTicks.join('')} ${tapeTicks.join('')}
<polygon points="100,20 96,12 104,12" fill="#ff0"/> <polygon points="100,20 96,12 104,12" fill="#ff0"/>
<!-- Heading readout --> <circle cx="${CX}" cy="${CY}" r="${R}" fill="rgba(0,20,40,0.6)" stroke="#0ff" stroke-width="1.5"/>
<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"/> <circle cx="${CX}" cy="${CY}" r="${R - 20}" fill="none" stroke="rgba(0,255,255,0.15)" stroke-width="1"/>
${ringTicks}
${cardinals}
<!-- Course needle --> <g transform="rotate(${-hdg}, ${CX}, ${CY})">
<line x1="${CX}" y1="${CY}" x2="${cNeedleX}" y2="${cNeedleY}" stroke="#0f0" stroke-width="2.5"/> ${ringTicks}
<circle cx="${cNeedleX}" cy="${cNeedleY}" r="3" fill="#0f0"/> ${cardinals}
</g>
<!-- Bearing needle -->
<line x1="${CX}" y1="${CY}" x2="${bNeedleX}" y2="${bNeedleY}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/> <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"/> <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"/> <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="80" y="${CY + 23}" width="40" height="6" fill="rgba(0,50,70,0.9)" rx="3"/>
<rect x="60" y="${LEG_Y - 4}" width="80" height="6" fill="rgba(0,50,70,0.9)" rx="3"/> <rect x="${rudX - 4}" y="${CY + 22}" width="8" height="8" fill="#ff0" rx="1"/>
<line x1="${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"/>
<!-- Legend --> <line x1="10" y1="${CY + 55}" x2="20" y2="${CY + 55}" stroke="#0f0" stroke-width="2"/>
<line x1="12" y1="${LEG_Y + 20}" x2="24" y2="${LEG_Y + 20}" stroke="#0f0" stroke-width="2"/> <text x="25" y="${CY + 57}" fill="#888" font-size="9">COG</text>
<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"/> <line x1="10" y1="${CY + 65}" x2="20" y2="${CY + 65}" stroke="#ff0" stroke-width="1.5" stroke-dasharray="4,3"/>
<text x="27" y="${LEG_Y + 33}" fill="#888" font-size="9">BRG</text> <text x="25" y="${CY + 67}" fill="#888" font-size="9">BRG</text>
</svg> </svg>
` `
}) })
</script> </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> <style scoped>
.ship-popup { .ship-popup {
position: fixed; position: fixed;
z-index: 9999; z-index: 100;
pointer-events: auto; pointer-events: auto;
background: rgba(0,0,0,0.88); 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; border-radius: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.5); box-shadow: 0 4px 24px rgba(0,0,0,0.5);
overflow: hidden; overflow: hidden;
font-family: monospace; font-family: monospace;
color: #ccc; color: #ccc;
min-width: 320px; min-width: 320px;
max-width: 360px;
} }
.sp-header { .sp-header {
position: relative; position: relative;
padding: 12px 16px; 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; cursor: move;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
@@ -312,6 +259,22 @@ const compass = computed(() => {
} }
.sp-close:hover { background: rgba(255,255,255,0.35); } .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 { .sp-body {
display: flex; display: flex;
gap: 16px; gap: 16px;
@@ -342,7 +305,64 @@ const compass = computed(() => {
} }
.sp-gauge:hover { border-color: #0f0; } .sp-gauge:hover { border-color: #0f0; }
.sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; } .sp-gauge-val { font-size: 20px; font-weight: bold; color: #0ff; }
.sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 2px; } .sp-gauge-unit { font-size: 11px; color: #0ff; margin-bottom: 5px; }
.sp-compass { flex: 1; margin-top: 0.25rem; }
.sp-compass { flex: 1; }
</style> </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="sun-card">
<div class="card-title">🌅 Sun</div> <div class="card-title">🌅 Sun</div>
<div v-if="!data" class="w-100 pos-rel br-2 overflow-hidden mx-auto" style="width: 100%; aspect-ratio: 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> <template v-else>
<!-- Top labels --> <!-- 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>
+267 -189
View File
@@ -1,233 +1,311 @@
import { BASE } from '@/services/api.ts' import {BASE} from '@/services/api.ts';
import { createApp, ref } from 'vue' import {greatCircleDist} from '@/services/units.ts';
import Map from 'ol/Map' import {h, render as vueRender} from 'vue';
import { Feature } from 'ol' import Map from 'ol/Map';
import Point from 'ol/geom/Point' import {Feature} from 'ol';
import LineString from 'ol/geom/LineString' import Point from 'ol/geom/Point';
import { fromLonLat } from 'ol/proj' import LineString from 'ol/geom/LineString';
import { Style, Icon, Stroke } from 'ol/style' import {fromLonLat} from 'ol/proj';
import { Vector as VectorLayer } from 'ol/layer' import {Style, Icon, Stroke} from 'ol/style';
import { Vector as VectorSource } from 'ol/source' import {Vector as VectorLayer} from 'ol/layer';
import { adjustedInterval } from '@ztimson/utils' import {Vector as VectorSource} from 'ol/source';
import AircraftPopup from '@/components/Aircraft.vue' 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 text = [
const a = Math.max(0, alt) plane.type,
if (a < 10) return [0, 0, 0] plane.category,
if (a < 10000) return [135, 206, 250] plane.aircraft_type,
if (a < 25000) { plane.operator,
const r = (a - 10000) / 15000 plane.owner,
return [Math.round(135 * (1-r)), Math.round(206 * (1-r)), Math.round(250 * (1-r) + 255 * r)] plane.flight,
} plane.callsign,
if (a < 40000) { plane.description,
const r = (a - 25000) / 15000 ].filter(Boolean).join(' ').toLowerCase();
return [0, 0, Math.round(255 * (1-r) + 139 * r)]
} return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|usaf|rcaf)\b/.test(text);
const r = Math.min((a - 40000) / 10000, 1)
return [Math.round(128 * r), 0, 139]
} }
interface PopupInstance { function getAltColor(alt: number): [number, number, number] {
planeRef: ReturnType<typeof ref> const a = Math.max(0, alt);
posRef: ReturnType<typeof ref> if (a < 10) return [0, 0, 0];
unmount: () => void 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 { export class AirTrafficLayer {
private map: Map private map: Map;
private layer!: VectorLayer<VectorSource> private layer!: VectorLayer<VectorSource>;
private traceLayers: Record<string, VectorLayer<VectorSource>> = {} private traceLayers: Record<string, VectorLayer<VectorSource>> = {};
private traceSegs: Record<string, Feature[]> = {} private traceSegs: Record<string, Feature[]> = {};
private popups: any = {} private popups: any = {};
private historyCache: any = {} private historyCache: any = {};
private data: any[] = [] private data: any[] = [];
private clickHandler: ((e: any) => void) | null = null private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null private refreshTimer: any = null;
visible = false visible = false;
constructor(map: Map) { this.map = map } constructor(map: Map) {
this.map = map;
}
// ── Public ────────────────────────────────────────────────────────────────── private _attachClick() {
this.clickHandler = evt => {
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null);
if (!f) return;
async show() { const plane = f.get('planeData');
if (this.visible) return
this.visible = true if (this.popups[plane.icao]) {
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 }) this.closePopup(plane.icao);
this.map.addLayer(this.layer) return;
await this._fetch()
this._draw()
this._attachClick()
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err)
} }
}, 1_000)
}
hide() { this.openPopup(plane);
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 ────────────────────────────────────────────────────────────────── this.map.on('singleclick', this.clickHandler);
private async _fetch() {
const j = await fetch(`${API}/adsb`).then(r => r.json())
this.data = (j || []).map((a: any) => ({
...a,
icao: a.hex,
latitude: a.lat,
longitude: a.lon,
heading: a.track,
speed: a.gs,
climb: a.baro_rate ?? a.geom_rate ?? 0,
name: a.flight?.trim(),
}))
} }
private _draw() { private _draw() {
const source = this.layer.getSource()! const source = this.layer.getSource()!;
source.clear() source.clear();
for (const plane of this.data) { for (const plane of this.data) {
if (plane.latitude == null) continue if (plane.latitude == null || plane.longitude == null) continue;
const coord = fromLonLat([plane.longitude, plane.latitude])
const f = new Feature({ geometry: new Point(coord) }) const coord = fromLonLat([plane.longitude, plane.latitude]);
f.set('icao', plane.icao) const f = new Feature({geometry: new Point(coord)});
f.set('planeData', plane)
f.set('icao', plane.icao);
f.set('planeData', plane);
f.setStyle(new Style({ f.setStyle(new Style({
image: new Icon({ image: new Icon({
src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`, src: `data:image/svg+xml,${encodeURIComponent(plane.icon)}`,
scale: 1, scale: 1.25,
rotation: (plane.heading ?? 0) * (Math.PI / 180), rotation: (plane.heading ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5], anchor: [0.5, 0.5],
}), }),
})) }));
source.addFeature(f)
source.addFeature(f);
} }
} }
private async _fetch() {
const j = await fetch(`${API}/adsb`).then(r => r.ok ? r.json() : []);
this.data = (j || []).map((a: any) => ({
...a,
icao: a.hex,
latitude: a.lat,
longitude: a.lon,
heading: a.track,
speed: a.gs,
climb: a.baro_rate ?? a.geom_rate ?? 0,
name: a.flight?.trim(),
}));
}
private async _fetchTrace(plane: any) { private async _fetchTrace(plane: any) {
const icao = plane.icao const icao = plane.icao;
if (!this.historyCache[icao]) {
const j = await fetch(`${API}/adsb/${icao}`).then(r => r.json())
this.historyCache[icao] = j.history || []
}
const history = [...this.historyCache[icao]]
const cur = { latitude: plane.latitude, longitude: plane.longitude, altitude: plane.alt_baro ?? plane.alt_geom ?? 0 }
const last = history[history.length - 1]
if (!last || last.latitude !== cur.latitude || last.longitude !== cur.longitude) history.push(cur)
if (history.length < 2) return
let traceLayer = this.traceLayers[icao] if (!this.historyCache[icao]) {
let vs: VectorSource 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) { if (!traceLayer) {
vs = new VectorSource() vs = new VectorSource();
traceLayer = new VectorLayer({ source: vs, zIndex: 99 }) traceLayer = new VectorLayer({source: vs, zIndex: 99});
this.map.addLayer(traceLayer) this.map.addLayer(traceLayer);
this.traceLayers[icao] = traceLayer this.traceLayers[icao] = traceLayer;
this.traceSegs[icao] = [] this.traceSegs[icao] = [];
} else { } else {
vs = traceLayer.getSource()! vs = traceLayer.getSource()!;
;(this.traceSegs[icao] || []).forEach(f => vs.removeFeature(f))
} }
const segs: Feature[] = [] const segs: any = this.traceSegs[icao];
for (let i = 0; i < history.length - 1; i++) {
const s = history[i], e = history[i + 1] for (let i = segs.length; i < traceHistory.length - 1; i++) {
const sc = getAltColor(s.altitude || 0) const s = traceHistory[i], e = traceHistory[i + 1];
const ec = getAltColor(e.altitude || 0) const sc = getAltColor(s.altitude || 0), ec: any = getAltColor(e.altitude || 0);
const avg = sc.map((v, idx) => Math.round((v + (ec[idx] as any)) / 2)) const avg = sc.map((v, idx) => Math.round((v + ec[idx]) / 2));
const f = new Feature(new LineString([fromLonLat([s.longitude, s.latitude]), fromLonLat([e.longitude, e.latitude])]))
f.setStyle(new Style({ stroke: new Stroke({ color: `rgba(${avg.join(',')},0.8)`, width: 3 }) })) const f = new Feature(new LineString([
vs.addFeature(f) fromLonLat([s.longitude, s.latitude]),
segs.push(f) fromLonLat([e.longitude, e.latitude]),
]));
f.setStyle(new Style({
stroke: new Stroke({
color: `rgba(${avg.join(',')},0.8)`,
width: 3,
}),
}));
vs.addFeature(f);
segs.push(f);
} }
this.traceSegs[icao] = segs
}
private _calcPopupPos(plane: any): { x: number; y: number } { const popup = this.popups[icao];
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([plane.longitude, plane.latitude])) if (popup) popup.update(plane, history);
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]
} }
private _refreshPopups() { private _refreshPopups() {
for (const icao of Object.keys(this.popups)) { for (const icao of Object.keys(this.popups)) {
const plane = this.data.find(p => p.icao === icao) const plane = this.data.find(p => p.icao === icao);
if (!plane) { this._closePopup(icao); continue } if (!plane) {
this.popups[icao].planeRef.value = plane this.closePopup(icao);
// Don't reposition — user may have dragged it 🎯 continue;
this._fetchTrace(plane) }
this._fetchTrace(plane);
} }
} }
private _attachClick() { hide() {
this.clickHandler = (evt) => { if(!this.visible) return;
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('planeData') ? f : null) this.visible = false;
if (!f) return
const plane = f.get('planeData') if(this.refreshTimer) {
if (this.popups[plane.icao]) { this._closePopup(plane.icao); return } clearInterval(this.refreshTimer);
this._openPopup(plane) this.refreshTimer = null;
} }
this.map.on('singleclick', this.clickHandler)
if(this.clickHandler) {
this.map.un('singleclick', this.clickHandler);
this.clickHandler = null;
}
for(const icao of Object.keys(this.popups)) this.closePopup(icao);
if(this.layer) this.map.removeLayer(this.layer);
Object.values(this.traceLayers).forEach(l => this.map.removeLayer(l));
this.traceLayers = {};
this.traceSegs = {};
}
async show() {
if(this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 1_000);
}
getById(icao: string): any | null {
return this.data.find(p => String(p.icao) === String(icao)) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
function score(plane: any): number {
const ratio = Math.min(greatCircleDist(latitude, longitude, plane.latitude, plane.longitude) / AUTO_RANGE, 1);
return isPriorityAircraft(plane) ? 2 - ratio : 1 - ratio;
}
return this.data.map(plane => ({...plane, score: score(plane)})).toSorted(sortByProp('score', true));
}
openPopup(plane: any) {
const icao = String(plane.icao);
if(this.popups[icao]) return;
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (p: any, history: any[] = []) => ({
plane: p,
history,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(icao),
onBringToFront: () => bringToFront(container),
});
vueRender(h(AircraftPopup, makeProps(plane, this.historyCache[icao] || [])), container);
this.popups[icao] = {
update: (p: any, history: any[] = []) => vueRender(h(AircraftPopup, makeProps(p, history)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
this._fetchTrace(plane);
}
closePopup(icao: string) {
const popup = this.popups[icao];
if(popup) {
popup.unmount();
delete this.popups[icao];
}
const segs = this.traceSegs[icao], layer = this.traceLayers[icao];
if(segs && layer) segs.forEach(f => layer.getSource()!.removeFeature(f));
if(layer) {
this.map.removeLayer(layer);
delete this.traceLayers[icao];
}
delete this.traceSegs[icao];
} }
} }
+181 -124
View File
@@ -1,32 +1,52 @@
import { BASE } from '@/services/api.ts' import {BASE} from '@/services/api.ts';
import { createApp, ref } from 'vue' import {greatCircleDist} from '@/services/units.ts';
import Map from 'ol/Map' import {h, render as vueRender} from 'vue';
import { Feature } from 'ol' import Map from 'ol/Map';
import Point from 'ol/geom/Point' import {Feature} from 'ol';
import { fromLonLat } from 'ol/proj' import Point from 'ol/geom/Point';
import { Style, Icon } from 'ol/style' import {fromLonLat} from 'ol/proj';
import { Vector as VectorLayer } from 'ol/layer' import {Style, Icon} from 'ol/style';
import { Vector as VectorSource } from 'ol/source' import {Vector as VectorLayer} from 'ol/layer';
import { adjustedInterval } from '@ztimson/utils' import {Vector as VectorSource} from 'ol/source';
import ShipPopup from '@/components/Ship.vue' import {adjustedInterval, sortByProp} from '@ztimson/utils';
import ShipPopup from '@/components/Ship.vue';
import {bringToFront} from './zindex';
const API = BASE + '/api' const API = BASE + '/api';
const AUTO_RANGE = 100;
let highestZIndex = 1000
const SHIP_COLORS: Record<string, string> = { const SHIP_COLORS: Record<string, string> = {
'Base Station': '#00aaff', 'Base Station': '#ffffff',
'Class A': '#00ff00', 'Class A': '#00ff00',
'Class B': '#8450ea', 'Class B': '#8450ea',
'SAR Aircraft': '#ff0000', 'SAR Aircraft': '#ff0000',
'AtoN': '#00aaff', 'AtoN': '#ffffff',
'Class B/CS': '#8450ea', 'Class B/CS': '#8450ea',
'Sart/Epirb/MOB': '#00aaff', 'Sart/Epirb/MOB': '#00aaff',
'Unknown': '#fad106', 'Unknown': '#fad106',
};
function isPriorityBoat(boat: any): boolean {
if (boat.military === true || boat.is_military === true || boat.sar === true || boat.is_sar === true) return true;
const text = [
boat.type,
boat.vesselType,
boat.category,
boat.name,
boat.shipname,
boat.operator,
boat.owner,
boat.callsign,
boat.description,
].filter(Boolean).join(' ').toLowerCase();
return /\b(military|mil|sar|search and rescue|rescue|coast guard|navy|army|air force|airforce|rcaf)\b/.test(text);
} }
function buildBoatIcon(boat: any): string { function buildBoatIcon(boat: any): string {
const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'] const color = SHIP_COLORS[boat.type] || SHIP_COLORS['Unknown'];
if (['Base Station', 'AtoN'].includes(boat.type)) { if (['Base Station', 'AtoN'].includes(boat.type)) {
return `data:image/svg+xml,${encodeURIComponent(` return `data:image/svg+xml,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
@@ -36,8 +56,9 @@ function buildBoatIcon(boat: any): string {
<line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/> <line x1="11" y1="12" x2="21" y2="12" stroke="#000" stroke-width="1.8"/>
<path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/> <path d="M9,18 Q16,26 23,18" fill="none" stroke="#000" stroke-width="1.5"/>
</svg> </svg>
`)}` `)}`;
} }
return `data:image/svg+xml,${encodeURIComponent(` return `data:image/svg+xml,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="32" viewBox="0 0 24 32">
<polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/> <polygon points="12,0 24,30 12,24 0,30" fill="${color}" stroke="#000" stroke-width="1.5"/>
@@ -46,132 +67,168 @@ function buildBoatIcon(boat: any): string {
<line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/> <line x1="8" y1="14" x2="16" y2="14" stroke="#000" stroke-width="1.5"/>
<path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/> <path d="M7,17 Q12,24 17,17" fill="none" stroke="#000" stroke-width="1.3"/>
</svg> </svg>
`)}` `)}`;
} }
export class AISLayer { export class AISLayer {
private map: Map private map: Map;
private layer!: VectorLayer<VectorSource> private layer!: VectorLayer<VectorSource>;
private popups: any = {} private popups: any = {};
private data: any[] = [] private data: any[] = [];
private clickHandler: ((e: any) => void) | null = null private clickHandler: ((e: any) => void) | null = null;
private refreshTimer: any = null private refreshTimer: any = null;
visible = false visible = false;
constructor(map: Map) { this.map = map } constructor(map: Map) {
this.map = map;
}
async show() { private _attachClick() {
if (this.visible) return this.clickHandler = evt => {
this.visible = true const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null);
this.layer = new VectorLayer({ source: new VectorSource(), zIndex: 100 }) if (!f) return;
this.map.addLayer(this.layer)
await this._fetch() const boat = f.get('boatData');
this._draw()
this._attachClick() if (this.popups[boat.mmsi]) {
this.refreshTimer = adjustedInterval(async () => { this.closePopup(boat.mmsi);
try { return;
await this._fetch()
this._draw()
this._refreshPopups()
} catch (err) {
console.error(err);
} }
}, 5_000)
}
hide() { this.openPopup(boat);
if (!this.visible) return };
this.visible = false
if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null }
if (this.clickHandler) { this.map.un('singleclick', this.clickHandler); this.clickHandler = null }
for (const mmsi of Object.keys(this.popups)) this._closePopup(mmsi)
this.map.removeLayer(this.layer)
}
private async _fetch() { this.map.on('singleclick', this.clickHandler);
this.data = await fetch(`${API}/ais`).then(r => r.json()) || []
} }
private _draw() { private _draw() {
const source = this.layer.getSource()! const source = this.layer.getSource()!;
source.clear() source.clear();
for (const boat of this.data) { for (const boat of this.data) {
if (boat.lat == null || boat.lon == null) continue if (boat.lat == null || boat.lon == null) continue;
const coord = fromLonLat([boat.lon, boat.lat])
const f = new Feature({ geometry: new Point(coord) }) const coord = fromLonLat([boat.lon, boat.lat]);
f.set('mmsi', boat.mmsi) const f = new Feature({geometry: new Point(coord)});
f.set('boatData', boat)
f.set('mmsi', boat.mmsi);
f.set('boatData', boat);
f.setStyle(new Style({ f.setStyle(new Style({
image: new Icon({ image: new Icon({
src: buildBoatIcon(boat), src: buildBoatIcon(boat),
scale: 0.8, scale: 0.8,
rotation: (boat.heading ?? boat.course ?? 0) * (Math.PI / 180), rotation: (boat.heading ?? boat.bearing ?? boat.cog ?? boat.course ?? 0) * (Math.PI / 180),
anchor: [0.5, 0.5], anchor: [0.5, 0.5],
}), }),
})) }));
source.addFeature(f)
source.addFeature(f);
} }
} }
private _calcPopupPos(boat: any): { x: number; y: number } { private async _fetch() {
const pixel: any = this.map.getPixelFromCoordinate(fromLonLat([boat.lon, boat.lat])) this.data = await fetch(`${API}/ais`).then(r => r.json()) || [];
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 _refreshPopups() { private _refreshPopups() {
for (const mmsi of Object.keys(this.popups)) { for (const mmsi of Object.keys(this.popups)) {
const boat = this.data.find(b => String(b.mmsi) === mmsi) const boat = this.data.find(b => String(b.mmsi) === mmsi);
if (!boat) { this._closePopup(mmsi); continue } if (!boat) {
this.popups[mmsi].boatRef.value = boat this.closePopup(mmsi);
continue;
}
this.popups[mmsi].update(boat);
} }
} }
private _attachClick() { hide() {
this.clickHandler = (evt) => { if (!this.visible) return;
const f = this.map.forEachFeatureAtPixel(evt.pixel, f => f.get('boatData') ? f : null) this.visible = false;
if (!f) return
const boat = f.get('boatData') if (this.refreshTimer) {
const mmsi = String(boat.mmsi) clearInterval(this.refreshTimer);
if (this.popups[mmsi]) { this._closePopup(mmsi); return } this.refreshTimer = null;
this._openPopup(boat) }
if (this.clickHandler) {
this.map.un('singleclick', this.clickHandler);
this.clickHandler = null;
}
for (const mmsi of Object.keys(this.popups)) this.closePopup(mmsi);
if (this.layer) this.map.removeLayer(this.layer);
}
async show() {
if (this.visible) return;
this.visible = true;
this.layer = new VectorLayer({source: new VectorSource(), zIndex: 100});
this.map.addLayer(this.layer);
await this._fetch();
this._draw();
this._attachClick();
this.refreshTimer = adjustedInterval(async () => {
try {
await this._fetch();
this._draw();
this._refreshPopups();
} catch (err) {
console.error(err);
}
}, 5_000);
}
getById(mmsi: string): any | null {
return this.data.find(b => String(b.mmsi) === String(mmsi)) ?? null;
}
getCandidates(latitude: number, longitude: number, limit = Infinity): any[] {
function score(boat: any): number {
const ratio = Math.min(greatCircleDist(latitude, longitude, boat.lat, boat.lon) / AUTO_RANGE, 1);
return isPriorityBoat(boat) ? 2 - ratio : 1 - ratio;
}
return this.data.filter(b => b.lat != null && b.lon != null)
.map(boat => ({...boat, score: score(boat)}))
.toSorted(sortByProp('score', true));
}
openPopup(boat: any) {
const mmsi = String(boat.mmsi);
if (this.popups[mmsi]) return;
const container = document.createElement('div');
document.body.appendChild(container);
const mobile = window.innerWidth <= 768;
const makeProps = (b: any) => ({
boat: b,
position: mobile
? {x: window.innerWidth / 2 - 180, y: window.innerHeight - 540 - 16}
: {x: 16, y: 16},
onClose: () => this.closePopup(mmsi),
onBringToFront: () => bringToFront(container),
});
vueRender(h(ShipPopup, makeProps(boat)), container);
this.popups[mmsi] = {
update: (b: any) => vueRender(h(ShipPopup, makeProps(b)), container),
unmount: () => {
vueRender(null, container);
container.remove();
},
};
}
closePopup(mmsi: string) {
const popup = this.popups[mmsi];
if (popup) {
popup.unmount();
delete this.popups[mmsi];
} }
this.map.on('singleclick', this.clickHandler)
} }
} }
+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' }, 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) { export function getPressureTrend(delta: number) {
if (delta > 0.5) return 'Rising'; if (delta > 0.5) return 'Rising';
if (delta < -0.5) return 'Falling'; if (delta < -0.5) return 'Falling';
@@ -76,12 +84,12 @@ export function getUVLabel(uv: number) {
return 'Extreme'; return 'Extreme';
} }
export function formatValue(key: string, value: number | string | null): string { const EARTH_R = 6371;
if (value === null || value === undefined) return '—' export function greatCircleDist(lat1: number, lon1: number, lat2: number, lon2: number): number {
const meta = METRICS[key] const toRad = (d: number) => d * Math.PI / 180;
if (!meta) return String(value) const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}` const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return `${value}${meta.unit}` return EARTH_R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} }
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))] export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]
+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. // Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"skipLibCheck": true, "skipLibCheck": true,
"noImplicitAny": false,
"lib": ["DOM","ESNext"],
// Path mapping for cleaner imports. // Path mapping for cleaner imports.
"paths": { "paths": {
+72 -23
View File
@@ -151,42 +151,89 @@ def read_bme(c, sensor, alt_m=None):
return None, None, None return None, None, None
# ── MPU6050 ─────────────────────────────────────────────────────────────────── # ── MPU6050 ───────────────────────────────────────────────────────────────────
# ── MPU6050 ───────────────────────────────────────────────────────────────────
MPU_ADDR = 0x68 MPU_ADDR = 0x68
_mpu_peak = None _mpu_peak = None
_mpu_lock = threading.Lock() _mpu_lock = threading.Lock()
_mpu_offsets = None _mpu_grav = None # slow-tracked gravity vector [x, y, z]
GRAV_ALPHA = 0.05 # EMA rate while still
STILL_SPREAD = 0.03 # g, max peak-to-peak per axis to count as "still"
_still_buf = deque(maxlen=50) # ~0.5s @ 100Hz
def read_accel():
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 6))
if not d: return None
return [
s16((d[0] << 8) | d[1]) / 16384.0,
s16((d[2] << 8) | d[3]) / 16384.0,
s16((d[4] << 8) | d[5]) / 16384.0,
]
def rotation_to_z(g):
"""Rodrigues rotation mapping gravity vector g -> +Z. Returns (R, |g|)."""
mag = math.sqrt(sum(v * v for v in g))
u = [v / mag for v in g]
kx, ky = u[1], -u[0] # axis = u × z
s = math.sqrt(kx * kx + ky * ky) # sin(theta)
c = u[2] # cos(theta)
if s < 1e-8:
return [[1, 0, 0], [0, c, 0], [0, 0, c]], mag
kx, ky = kx / s, ky / s
v = 1 - c
return [
[c + kx * kx * v, kx * ky * v, ky * s],
[ky * kx * v, c + ky * ky * v, -kx * s],
[-ky * s, kx * s, c ],
], mag
def is_still(buf):
if len(buf) < buf.maxlen: return False
return all(
max(s[i] for s in buf) - min(s[i] for s in buf) < STILL_SPREAD
for i in range(3)
)
def calibrate_mpu(samples=200): def calibrate_mpu(samples=200):
if not bus: return None if not bus: return None
print(" Calibrating MPU6050, keep still...") print(" Calibrating MPU6050, keep still...")
sums = [0.0] * 6 sums, n = [0.0, 0.0, 0.0], 0
n = 0
for _ in range(samples): for _ in range(samples):
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14)) a = read_accel()
if d: if a:
sums[0] += s16((d[0] << 8) | d[1]) / 16384.0 sums = [s + v for s, v in zip(sums, a)]
sums[1] += s16((d[2] << 8) | d[3]) / 16384.0
sums[2] += s16((d[4] << 8) | d[5]) / 16384.0
sums[3] += s16((d[8] << 8) | d[9]) / 131.0
sums[4] += s16((d[10] << 8) | d[11]) / 131.0
sums[5] += s16((d[12] << 8) | d[13]) / 131.0
n += 1 n += 1
time.sleep(0.005) time.sleep(0.005)
if n == 0: return None if n == 0: return None
return [sums[0]/n, sums[1]/n, sums[2]/n - 1.0, sums[3]/n, sums[4]/n, sums[5]/n]
g = [s / n for s in sums]
mag = math.sqrt(sum(v * v for v in g))
if not 0.5 < mag < 1.5:
print(f" ❌ MPU6050: bad gravity magnitude {mag:.2f}g")
return None
print(f" ✅ MPU6050: gravity {mag:.3f}g on ({g[0]:.2f}, {g[1]:.2f}, {g[2]:.2f}) → mapped to Z")
return g
def mpu_loop(): def mpu_loop():
global _mpu_peak global _mpu_peak, _mpu_grav
while True: while True:
if bus and _mpu_offsets: if bus and _mpu_grav:
d = try_read(lambda: bus.read_i2c_block_data(MPU_ADDR, 0x3B, 14)) a = read_accel()
if d: if a:
o = _mpu_offsets _still_buf.append(a)
ax = s16((d[0] << 8) | d[1]) / 16384.0 - o[0]
ay = s16((d[2] << 8) | d[3]) / 16384.0 - o[1] R, g = rotation_to_z(_mpu_grav)
az = s16((d[4] << 8) | d[5]) / 16384.0 - o[2] ax = R[0][0] * a[0] + R[0][1] * a[1] + R[0][2] * a[2]
mag = math.sqrt(ax**2 + ay**2 + (az - 1.0)**2) ay = R[1][0] * a[0] + R[1][1] * a[1] + R[1][2] * a[2]
az = R[2][0] * a[0] + R[2][1] * a[1] + R[2][2] * a[2] - g
mag = math.sqrt(ax**2 + ay**2 + az**2)
# Re-track gravity whenever the sensor is *still* —
# absorbs bias shifts of any size, blocks shakes
if is_still(_still_buf):
_mpu_grav = [p + (v - p) * GRAV_ALPHA for p, v in zip(_mpu_grav, a)]
with _mpu_lock: with _mpu_lock:
if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']: if _mpu_peak is None or mag > _mpu_peak['seismic_magnitude']:
_mpu_peak = { _mpu_peak = {
@@ -203,6 +250,8 @@ def flush_mpu(c):
data, _mpu_peak = _mpu_peak, None data, _mpu_peak = _mpu_peak, None
if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']: if data and data['seismic_magnitude'] > c['SEISMIC_NOISE_FLOOR']:
write(c, data) write(c, data)
else:
write(c, {'seismic_x': 0.0, 'seismic_y': 0.0, 'seismic_z': 0.0, 'seismic_magnitude': 0.0})
# ── QMC5883L ────────────────────────────────────────────────────────────────── # ── QMC5883L ──────────────────────────────────────────────────────────────────
@@ -482,9 +531,9 @@ if __name__ == '__main__':
if bus: if bus:
try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0)) try_read(lambda: bus.write_byte_data(MPU_ADDR, 0x6B, 0))
time.sleep(0.1) time.sleep(0.1)
_mpu_offsets = try_init(calibrate_mpu, 'MPU6050') _mpu_grav = try_init(calibrate_mpu, 'MPU6050')
else: else:
_mpu_offsets = None _mpu_grav = None
print(" ❌ MPU6050: no I2C bus") print(" ❌ MPU6050: no I2C bus")
threading.Thread(target=mpu_loop, daemon=True).start() threading.Thread(target=mpu_loop, daemon=True).start()
+615
View File
@@ -15,9 +15,568 @@
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"sharp": "^0.35.4",
"yaml": "^2.9.0" "yaml": "^2.9.0"
} }
}, },
"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": { "node_modules/@scalar/client-side-rendering": {
"version": "0.2.6", "version": "0.2.6",
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz", "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.2.6.tgz",
@@ -1392,6 +1951,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "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": { "node_modules/side-channel": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -1585,6 +2193,13 @@
"node": ">=0.6" "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": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"sharp": "^0.35.4",
"yaml": "^2.9.0" "yaml": "^2.9.0"
} }
} }
+4 -2
View File
@@ -1253,11 +1253,13 @@ function toSvg(shape, fillColor, strokeColor, strokeWidth, scale) {
return svg; return svg;
} }
let svg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="' + shape.viewBox + '" ' let svg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="' + shape.viewBox + '" '
+ (shape.noAspect ? 'preserveAspectRatio="none" ' : '') + (shape.noAspect ? 'preserveAspectRatio="none" ' : '')
+ 'width="' + wi + '" height="' + he + '">' + '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) { if (shape.path) {
let path = shape.path; let path = shape.path;
+414 -232
View File
@@ -4,36 +4,98 @@ import { fileURLToPath } from 'url';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { fromCsv } from '@ztimson/utils'; import { fromCsv } from '@ztimson/utils';
import {getIcon} from './adsb-shapes.mjs'; import { getIcon } from './adsb-shapes.mjs';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import sharp from 'sharp';
const DIR = dirname(fileURLToPath(import.meta.url)); const DIR = dirname(fileURLToPath(import.meta.url));
const DATA = resolve(DIR, '../data'); const DATA = resolve(DIR, '../data');
const DB_PATH = resolve(DATA, 'aircraft.db'); const IMAGES_DIR = resolve(DATA, 'aircraft');
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv'); const DB_PATH = resolve(DATA, 'aircraft.db');
const MAX_HISTORY = 500; const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour const AIRLINES_CACHE = resolve(DATA, 'airlines.csv');
const MAX_HISTORY = 500;
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv'; const ADSB_TTL = 1000;
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js'; const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
const AIRLINES_URL = 'https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat';
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC']; const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight']; const PASSENGER_OPERATORS = ['airlines', 'airways', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', 'jet', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'southwest', 'lufthansa', 'emirates', 'british']; const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
const 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 noRecord = [];
const history = new Map(); const history = new Map();
const milRanges = []; const milRanges = [];
let db;
setInterval(() => { function backfillModel(aircraft) {
const cutoff = Date.now() - HISTORY_TTL; if (!aircraft) return {};
for (const [key, trail] of history) { const similar = db.prepare(`
const last = trail.at(-1); SELECT
if (!last || last.ts < cutoff) history.delete(key); manufacturer,
} model,
}, 1000 * 60 * 5); 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) { function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController(); const controller = new AbortController();
@@ -41,19 +103,131 @@ function fetchWithTimeout(url, ms = 5000) {
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id)); return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(id));
} }
async function syncMilitaryRanges() { export async function initAircraftDb() {
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
const missing = !fs.existsSync(DB_PATH);
if (missing) console.log(`✈️ Building database`);
db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS aircraft (
icao TEXT PRIMARY KEY,
built TEXT,
categoryDescription TEXT,
country TEXT,
engines TEXT,
class TEXT,
manufacturer TEXT,
model TEXT,
modes TEXT,
operator TEXT,
operatorCallsign TEXT,
owner TEXT,
registration TEXT,
serialNumber TEXT,
aircraft TEXT
);
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
`);
await syncAirlines();
await syncMilitaryIcao();
if(missing) {
const res = await fetch(CSV_URL);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
fs.writeFileSync(CSV_CACHE, text);
const csv = fromCsv(text, true);
const insert = db.prepare(`
INSERT OR REPLACE INTO aircraft VALUES (
@icao,
@built,
@categoryDescription,
@country,
@engines,
@class,
@manufacturer,
@model,
@modes,
@operator,
@operatorCallsign,
@owner,
@registration,
@serialNumber,
@aircraft
)
`);
const insertMany = db.transaction(rows => {
for (const row of rows) insert.run(row);
});
insertMany(csv.map(row => ({
icao: (row.icao24 || '').toUpperCase(),
built: row.built || null,
categoryDescription: row.categoryDescription || null,
country: row.country || null,
engines: row.engines || null,
class: row.icaoAircraftClass || null,
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
model: row.model || null,
modes: row.modes || null,
operator: row.operator || null,
operatorCallsign: row.operatorCallsign || null,
owner: row.owner || null,
registration: row.registration || null,
serialNumber: row.serialNumber || null,
aircraft: row.typecode || null
})));
fs.unlinkSync(CSV_CACHE);
console.log(`✈️ Registrations imported: ${csv.length}`);
} else {
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
console.log(`✈️ Registrations loaded: ${count.c}`);
}
}
function isMilitaryIcao(icao) {
if (!icao) return false;
const hex = icao.toUpperCase();
return milRanges.some(([s, e]) => hex >= s && hex <= e);
}
async function syncAirlines() {
try {
if (!fs.existsSync(AIRLINES_CACHE)) {
console.log('✈️ Downloading airline database');
const res = await fetchWithTimeout(AIRLINES_URL, 10000);
if(!res.ok) throw new Error(`HTTP ${res.status}`);
fs.writeFileSync(AIRLINES_CACHE, await res.text());
}
const text = fs.readFileSync(AIRLINES_CACHE, 'utf8');
const airlines = text.split('\n').slice(1).map(row =>
row.split(',')[1]?.slice(1, -1).toLowerCase()).filter(Boolean).flat();
passengerOperators = airlines.sort((a, b) => b.length - a.length);
console.log(`✈️ Airlines loaded: ${airlines.length}`);
} catch (e) {
console.warn(`⚠️ Could not load airline database: ${e.message}`);
}
}
async function syncMilitaryIcao() {
const { ADSB_URL } = cfg(); const { ADSB_URL } = cfg();
try { try {
const res = await fetch(ADSB_URL + MIL_RANGES_URL); const page = await fetch(ADSB_URL + ':8080').then(resp => resp.text());
const db = /databaseFolder = "(db-.+?)"/.exec(page);
const res = await fetch(ADSB_URL + `:8080/${db[1]}/ranges.js`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { military } = await res.json(); const { military } = await res.json();
db.exec('DELETE FROM military_ranges'); db.exec('DELETE FROM military_ranges');
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)'); const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
const insertMany = db.transaction(ranges => { const insertMany = db.transaction(ranges => {
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase()); for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
}); });
insertMany(military); insertMany(military);
milRanges.length = 0; milRanges.length = 0;
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()])); milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
@@ -64,264 +238,272 @@ async function syncMilitaryRanges() {
} }
} }
export async function initAircraftDb() {
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
const missing = !fs.existsSync(DB_PATH);
if (missing) console.log(`✈️ Building database`);
db = new Database(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS aircraft (
icao TEXT PRIMARY KEY,
built TEXT,
categoryDescription TEXT,
country TEXT,
engines TEXT,
class TEXT,
manufacturer TEXT,
model TEXT,
modes TEXT,
operator TEXT,
operatorCallsign TEXT,
owner TEXT,
registration TEXT,
serialNumber TEXT,
aircraft TEXT
);
CREATE TABLE IF NOT EXISTS military_ranges (
start TEXT NOT NULL,
end TEXT NOT NULL
);
`);
await syncMilitaryRanges();
if (missing) {
const res = await fetch(CSV_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
fs.writeFileSync(CSV_CACHE, text);
const csv = fromCsv(text, true);
const insert = db.prepare(`
INSERT OR REPLACE INTO aircraft VALUES (
@icao, @built, @categoryDescription, @country, @engines,
@class, @manufacturer, @model, @modes, @operator,
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
)
`);
const insertMany = db.transaction(rows => {
for (const row of rows) insert.run(row);
});
insertMany(csv.map(row => ({
icao: (row.icao24 || '').toUpperCase(),
built: row.built || null,
categoryDescription: row.categoryDescription || null,
country: row.country || null,
engines: row.engines || null,
class: row.icaoAircraftClass || null,
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
model: row.model || null,
modes: row.modes || null,
operator: row.operator || null,
operatorCallsign: row.operatorCallsign || null,
owner: row.owner || null,
registration: row.registration || null,
serialNumber: row.serialNumber || null,
aircraft: row.typecode || null,
})));
fs.unlinkSync(CSV_CACHE);
console.log(`✈️ Aircraft imported: ${csv.length}`);
} else {
const count = db.prepare('SELECT COUNT(*) as c FROM aircraft').get();
console.log(`✈️ Aircraft loaded: ${count.c}`);
}
}
function isIcaoInMilitaryRange(icao) {
if (!icao) return false;
const hex = icao.toUpperCase();
return milRanges.some(([s, e]) => hex >= s && hex <= e);
}
function wordMatch(text, keyword) {
if (!text) return false;
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?<![\\w])${escaped}(?![\\w])`, 'i').test(text);
}
export function classifyAircraft(row) { export function classifyAircraft(row) {
if (!row) return 'unknown'; if (!row) return 'unknown';
const matchesAny = (fields, list) => const matchesAny = (fields, list) => fields.some(f => {
fields.some(f => list.some(k => wordMatch(f, k))); const value = normalizeName(f);
return value && list.some(k => {
const kw = normalizeName(k);
return kw && new RegExp('\\b' + kw.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + '\\b', 'i').test(value);
});
});
const operatorFields = [row.operator, row.operatorCallsign]; const operators = [row.operator, row.operatorCallsign, row.owner];
const ownerFields = [row.owner]; if (isMilitaryIcao(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(operators, MILITARY_KEYWORDS))
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription]; return 'military';
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military'; if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operators, CARGO_OPERATORS)) return 'cargo';
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo'; if (matchesAny(operators, passengerOperators)) return 'passenger';
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
if (row.owner && !row.operator) return 'private'; if (row.owner && !row.operator) return 'private';
return 'unknown'; return 'unknown';
} }
async function fetchHexDb(icao) { function normalizeName(value) {
const resp = await fetchWithTimeout(`https://hexdb.io/api/v1/aircraft/${icao}`); return String(value || '')
if (!resp.ok) return null; .normalize('NFKD')
const found = await resp.json(); .replace(/[\u0300-\u036f]/g, '')
return { .toLowerCase()
registration: found.Registration || null, .replace(/&/g, ' and ')
manufacturer: found.Manufacturer || null, .replace(/[^a-z0-9]+/g, ' ')
aircraft: found.ICAOTypeCode || null, .trim()
model: found.Type || null, .replace(/\s+/g, ' ');
operator: found.RegisteredOwners || null,
};
} }
async function scrapeHexDatabase(icao) { export async function enrich(a) {
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) {
if (!a.hex) return a; if (!a.hex) return a;
const icao = a.hex.toUpperCase(); const icao = a.hex.toUpperCase();
// 1. Check own DB first
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao); const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
if(row?.manufacturer && row?.model) return { ...a, ...row, type: classifyAircraft(row) }; if (row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
if (noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
// 2. Race the two external sources const hexDbPromise = fetchRegistration2(icao);
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' }; const scrapePromise = fetchRegistration1(icao);
let found = await Promise.any([ let found = await hexDbPromise.catch(() => {});
fetchHexDb(icao).then(resp => { if (!found) found = await scrapePromise.catch(() => {});
if(resp) console.log(`Source1: ${icao}`) if (!found) {
return resp;
}),
scrapeHexDatabase(icao).then(resp => {
if(resp) console.log(`Source2: ${icao}`)
return resp;
}),
]).catch(() => null);
if(!found) {
console.log(`Not Found: ${icao}`)
noRecord.push(icao); noRecord.push(icao);
return { icao, ...a, type: 'unknown' }; return { icao, ...a, type: 'unknown' };
} }
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
const merged = { ...row, ...found }; const merged = { ...row, ...found };
if (merged.aircraft && (!merged.manufacturer || !merged.model)) { if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
const similar = backfillFromSimilar(merged.aircraft); const similar = backfillModel(merged.aircraft);
Object.assign(merged, similar); Object.assign(merged, similar);
} }
if(found) {
// 4. Save back to DB
if (found) {
db.prepare(` db.prepare(`
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class) 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) VALUES (@icao, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
ON CONFLICT(icao) DO UPDATE SET ON CONFLICT(icao) DO UPDATE SET
registration = COALESCE(excluded.registration, registration), registration = COALESCE(excluded.registration, registration),
manufacturer = COALESCE(excluded.manufacturer, manufacturer), manufacturer = COALESCE(excluded.manufacturer, manufacturer),
aircraft = COALESCE(excluded.aircraft, aircraft), aircraft = COALESCE(excluded.aircraft, aircraft),
model = COALESCE(excluded.model, model), model = COALESCE(excluded.model, model),
operator = COALESCE(excluded.operator, operator), operator = COALESCE(excluded.operator, operator),
country = COALESCE(excluded.country, country), country = COALESCE(excluded.country, country),
serialNumber = COALESCE(excluded.serialNumber, serialNumber), serialNumber = COALESCE(excluded.serialNumber, serialNumber),
engines = COALESCE(excluded.engines, engines), engines = COALESCE(excluded.engines, engines),
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription), categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
class = COALESCE(excluded.class, class) class = COALESCE(excluded.class, class)
`).run({ `).run({
icao, icao,
registration: merged.registration || null, registration: merged.registration || null,
manufacturer: merged.manufacturer || null, manufacturer: merged.manufacturer || null,
aircraft: merged.aircraft || null, aircraft: merged.aircraft || null,
model: merged.model || null, model: merged.model || null,
operator: merged.operator || null, operator: merged.operator || null,
country: merged.country || null, country: merged.country || null,
serialNumber: merged.serialNumber || null, serialNumber: merged.serialNumber || null,
engines: merged.engines || null, engines: merged.engines || null,
categoryDescription: merged.categoryDescription || 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(); const { ADSB_URL } = cfg();
if (!ADSB_URL) return []; if(!ADSB_URL) return [];
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`); const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
const j = await r.json(); const j = await r.json();
const aircraft = j.aircraft || []; const aircraft = j.aircraft || [];
for (const a of aircraft) { for (const a of aircraft) {
a.type = 'unknown'; a.type = 'unknown';
if (!a.hex || !a.lat || !a.lon) continue; if(!a.hex || !a.lat || !a.lon) continue;
const key = a.hex.toLowerCase(); const key = a.hex.toLowerCase();
if (!history.has(key)) history.set(key, []); if (!history.has(key)) history.set(key, []);
const trail = history.get(key); const trail = history.get(key);
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() }); trail.push({
latitude: a.lat,
longitude: a.lon,
altitude: a.alt_baro || 0,
ts: Date.now()
});
if (trail.length > MAX_HISTORY) trail.shift(); if (trail.length > MAX_HISTORY) trail.shift();
} }
return Promise.all(aircraft.map(async a => { adsbCache = await Promise.all(aircraft.map(async a => {
a = await enrichAircraft(a); a = await enrich(a);
a.icon = getIcon(a); a.icon = getIcon(a);
return a; return a;
})); }));
adsbCacheTs = Date.now();
return adsbCache;
} }
export async function getADSBHistory(icao) { export async function getAttenuation() {
return { history: history.get(icao?.toLowerCase()) || [] };
}
export async function getADSBRange() {
const { ADSB_URL } = cfg(); const { ADSB_URL } = cfg();
if (!ADSB_URL) return []; if (!ADSB_URL) return [];
const r = await fetch(`${ADSB_URL}:8080/data/outline.json`); const r = await fetch(`${ADSB_URL}:8080/data/outline.json`);
const j = await r.json(); const j = await r.json();
return j?.actualRange?.last24h?.points || []; return j?.actualRange?.last24h?.points || [];
} }
export async function getHistory(icao) {
return { history: history.get(icao?.toLowerCase()) || [] };
}
export async function getIcaoImage(icao) {
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36';
async function duckduckgo(query) {
async function getVqd(query) {
const url = `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`;
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if(!res.ok) return console.warn(`DuckDuckGo Search failed: ${res.status}`);
const html = await res.text();
const match = html.match(/vqd=["']([^"']+)["']/);
return match?.[1] || null;
}
const vqd = await getVqd(query);
if (!vqd) throw new Error('Could not get vqd token');
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 500));
const searchUrl = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${encodeURIComponent(vqd)}&f=,,,,,&p=1`;
const data = await fetch(searchUrl, {
headers: {
'User-Agent': UA,
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`
}
}).then(async resp => {
if (!resp.ok) return console.warn(`DuckDuckGo image failed: ${resp.status}`);
const text = await resp.text();
try {
return JSON.parse(text);
} catch {
console.warn(`DuckDuckGo returned invalid JSON: ${text.slice(0, 200)}`);
return null;
}
});
if (data?.results?.length) {
for (const result of data.results) {
if (!result.image) continue;
try {
const imgRes = await fetch(result.image, {
headers: {
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent': UA,
'Referer': result.url || 'https://duckduckgo.com/'
}
});
if (imgRes.ok) return imgRes.blob();
console.warn(`Image fetch failed: ${imgRes.status} ${result.image}`);
} catch (e) {
console.warn(`Image fetch failed: ${result.image} - ${e.message}`);
}
}
}
return null;
}
async function genericImage(modelType) {
if (!modelType) return null;
const filePath = resolve(IMAGES_DIR, 'generic', `${modelType}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
const blob = await duckduckgo(`${modelType} Aircraft`);
if (!blob) return null;
fs.mkdirSync(dirname(filePath), { recursive: true });
const buffer = Buffer.from(await blob.arrayBuffer());
const metadata = await sharp(buffer).metadata();
const width = metadata.width || 1200;
const height = metadata.height || 800;
const fontSize = Math.max(48, Math.round(Math.min(width, height) * 0.16));
const watermark = Buffer.from(`
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}px"
font-weight="700" fill="white" fill-opacity="0.65"
stroke="black" stroke-opacity="0.35" stroke-width="${Math.max(2, Math.round(fontSize * 0.04))}"
transform="rotate(-20 ${width / 2} ${height / 2})">STOCK IMAGE</text>
</svg>`);
const watermarked = await sharp(buffer).composite([{ input: watermark }]).jpeg().toBuffer();
fs.writeFileSync(filePath, watermarked);
return watermarked;
}
async function icaoImage(icao) {
const filePath = resolve(IMAGES_DIR, `${icao}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
const planeSpotters = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao}`, {
headers: { 'User-Agent': 'open-sight.net (zaktimson@gmail.com)' }
}).then(resp => resp.ok ? resp.json() : null).catch(() => null);
const src = planeSpotters?.photos?.[0]?.thumbnail_large?.src || planeSpotters?.photos?.[0]?.thumbnail?.src;
if (!src) return null;
const resp = await fetch(src, {
headers: {
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'User-Agent': UA,
'Referer': 'https://www.planespotters.net/'
}
});
if (!resp.ok) return null;
fs.mkdirSync(IMAGES_DIR, { recursive: true });
const buffer = Buffer.from(await resp.arrayBuffer());
fs.writeFileSync(filePath, buffer);
return buffer;
}
icao = icao.toLowerCase();
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
const aircraft = await enrich({ hex: icao });
const modelType = aircraft.aircraft || aircraft.model || aircraft.type;
const specific = await icaoImage(icao);
if (specific) return specific;
return genericImage(modelType);
}
// Purge old history
setInterval(() => {
const cutoff = Date.now() - HISTORY_TTL;
for (const [key, trail] of history) {
const last = trail.at(-1);
if (!last || last.ts < cutoff) history.delete(key);
}
}, 1000 * 60 * 5);
+61 -1
View File
@@ -1,4 +1,7 @@
import {cfg} from './config.mjs'; import {cfg} from './config.mjs';
import * as fs from 'node:fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'}; const CHANNEL_MAP = {0: 'A, B', 1: 'A', 2: 'B'};
const SENDER_TYPE = { const SENDER_TYPE = {
@@ -8,6 +11,14 @@ const SENDER_TYPE = {
12: 'AtoN', 13: 'SART/EPIRB' 12: 'AtoN', 13: 'SART/EPIRB'
}; };
const DIR = dirname(fileURLToPath(import.meta.url));
const DATA = resolve(DIR, '../data');
const IMAGES_DIR = resolve(DATA, 'ships');
let aisCache = null;
let aisCacheTs = 0;
const AIS_TTL = 1000;
function decodeMsgTypes(raw) { function decodeMsgTypes(raw) {
const types = []; const types = [];
for (let i = 0; i < 32; i++) { for (let i = 0; i < 32; i++) {
@@ -38,6 +49,7 @@ function decodeFlags(flags) {
} }
export async function getAIS() { export async function getAIS() {
if (aisCache && Date.now() - aisCacheTs < AIS_TTL) return aisCache;
const {ADSB_URL} = cfg(); const {ADSB_URL} = cfg();
if (!ADSB_URL) return {data: []}; if (!ADSB_URL) return {data: []};
const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`); const r = await fetch(`${ADSB_URL}:9990/api/ships_array.json`);
@@ -45,7 +57,7 @@ export async function getAIS() {
const staticMap = new Map(staticRows.map(row => [row[0], row])); const staticMap = new Map(staticRows.map(row => [row[0], row]));
return dynamic.map(row => { aisCache = dynamic.map(row => {
const [mmsi, lat, lon, distance, bearing, heading, const [mmsi, lat, lon, distance, bearing, heading,
cog, speed, status, rrsi, ppm, count, msg_type, cog, speed, status, rrsi, ppm, count, msg_type,
last_signal, last_group, group_mask, flags, altitude, last_signal, last_group, group_mask, flags, altitude,
@@ -79,4 +91,52 @@ export async function getAIS() {
status status
}; };
}); });
aisCacheTs = Date.now();
return aisCache;
}
export async function getAISImage(mmsi) {
if(!mmsi) return null;
mmsi = String(mmsi);
const filePath = resolve(IMAGES_DIR, `${mmsi}.jpg`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
function randomUA() {
const v = 36 + Math.floor(Math.random() * 40);
const builds = [
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
];
return builds[~~(Math.random() * builds.length)];
}
const html = await fetch(`https://www.vesselfinder.com/vessels/details/${mmsi}`, {
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
}).then(resp => {
if(resp.ok) return resp.text();
throw new Error('Failed to fetch vessel page');
});
const match = html.match(/<img.+main-photo.+>/);
if(!match) return null;
const srcMatch = match[0].match(/src="(.+?)"/);
if(!srcMatch) return null;
let imgUrl = srcMatch[1];
if(imgUrl.startsWith('//')) imgUrl = `https:${imgUrl}`;
if(imgUrl.startsWith('/')) imgUrl = `https://www.vesselfinder.com${imgUrl}`;
const resp = await fetch(imgUrl, {
headers: {'Accept': '*/*', 'User-Agent': randomUA(), 'Referer': 'https://www.vesselfinder.com/'}
});
if(!resp.ok) throw new Error('Failed to fetch image');
const buffer = Buffer.from(await resp.arrayBuffer());
fs.mkdirSync(IMAGES_DIR, { recursive: true });
fs.writeFileSync(filePath, buffer);
return buffer;
} }
+3 -6
View File
@@ -1,4 +1,4 @@
import {adjustedInterval, Logger} from '@ztimson/utils'; import {adjustedInterval} from '@ztimson/utils';
const cacheOptions = { const cacheOptions = {
ttl: null, ttl: null,
@@ -12,7 +12,6 @@ export class CacheService {
cached = null; cached = null;
lastUpdate = null; lastUpdate = null;
logger;
name; name;
options; options;
pending; pending;
@@ -24,7 +23,6 @@ export class CacheService {
ttl: options.reload, ttl: options.reload,
...options ...options
}; };
this.logger = new Logger(name);
if(this.options.reload) setTimeout(() => this.startLoop(), 1000); if(this.options.reload) setTimeout(() => this.startLoop(), 1000);
} }
@@ -62,16 +60,15 @@ export class CacheService {
update(catchErr = true) { update(catchErr = true) {
if(this.pending) return this.pending; if(this.pending) return this.pending;
this.logger.info('Fetching latest'); console.log('🌌 Fetching Aurora');
this.pending = this.#fetchWithRetry().then(data => { this.pending = this.#fetchWithRetry().then(data => {
if(data?.err) throw new Error(data.err?.stack || data.err?.message || data.err); if(data?.err) throw new Error(data.err?.stack || data.err?.message || data.err);
if(data?.error) throw new Error(data.error?.stack || data.error?.message || data.error); if(data?.error) throw new Error(data.error?.stack || data.error?.message || data.error);
this.lastUpdate = new Date(); this.lastUpdate = new Date();
this.cached = data || null; this.cached = data || null;
this.logger.debug('Finished updating');
return {timestamp: this.lastUpdate, data: this.cached}; return {timestamp: this.lastUpdate, data: this.cached};
}).catch(err => { }).catch(err => {
if(catchErr) this.logger.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`) if(catchErr) console.error(`Failed: ${typeof err == 'object' ? (err.stackTrace || err.message) : err}`)
else throw err; else throw err;
}).finally(() => this.pending = null); }).finally(() => this.pending = null);
return this.pending; return this.pending;
+7 -1
View File
@@ -10,7 +10,13 @@ export function cfg() {
return { return {
PORT: process.env.PORT || 3000, PORT: process.env.PORT || 3000,
ADSB_URL: process.env.ADSB_URL || '', ADSB_URL: process.env.ADSB_URL || '',
DB_HOST: process.env.DB_HOST || 'http://localhost:8428', 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'), LATITUDE: parseFloat(process.env.LATITUDE || '0'),
LONGITUDE: parseFloat(process.env.LONGITUDE || '0'), LONGITUDE: parseFloat(process.env.LONGITUDE || '0'),
ALTITUDE: parseFloat(process.env.ALTITUDE || '0'), ALTITUDE: parseFloat(process.env.ALTITUDE || '0'),
+3
View File
@@ -1,6 +1,7 @@
import {queryHourly, getCoords, queryCurrent} from './sensors.mjs'; import {queryHourly, getCoords, queryCurrent} from './sensors.mjs';
import {getCelestialCurrent} from './celestial.mjs'; import {getCelestialCurrent} from './celestial.mjs';
import {localDateStr} from './config.mjs'; import {localDateStr} from './config.mjs';
import {frostRisk} from './openweather.mjs';
export let lastForecast = { ts: 0, slots: [], summary: null } export let lastForecast = { ts: 0, slots: [], summary: null }
export const forecastTTL = 5 * 60 * 60_000 export const forecastTTL = 5 * 60 * 60_000
@@ -188,6 +189,7 @@ export async function get24HourForecast(currentSensors) {
slot.heat_index = Math.round(heatIndex(slot.temperature, slot.humidity) * 100) / 100; slot.heat_index = Math.round(heatIndex(slot.temperature, slot.humidity) * 100) / 100;
slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity); slot.vapor_pressure_deficit = vaporPressureDeficit(slot.temperature, slot.humidity);
slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity); slot.humidity_abs = absoluteHumidity(slot.temperature, slot.humidity);
slot.frost_risk = frostRisk(slot.temperature, slot.dew_point, slot.humidity)
slot.solar_wm2 = slot.sun_elevation > 0 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 ? 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: avg(fin('precipitation')),
precipitation_chance: Math.round(peak(fin('precipitation_chance'))), precipitation_chance: Math.round(peak(fin('precipitation_chance'))),
accumulation: finAll('precipitation').reduce((a, b) => a + b, 0), 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_index: peak(fin('uv_index')),
uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0), uv_dose: finAll('uv_dose').reduce((a, b) => a + b, 0),
solar_wm2: avg(fin('solar_wm2')), solar_wm2: avg(fin('solar_wm2')),
+114 -41
View File
@@ -2,6 +2,9 @@ import { existsSync, mkdirSync } from 'fs'
import { resolve, dirname } from 'path' import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { tzOffsetMinutes } from './config.mjs' import { tzOffsetMinutes } from './config.mjs'
import { getWeatherCondition } from './forecast.mjs'
import { getCelestialForecast } from './celestial.mjs'
import {frostRisk} from './openweather.mjs';
const DIR = dirname(fileURLToPath(import.meta.url)) const DIR = dirname(fileURLToPath(import.meta.url))
const ICON_DIR = resolve(DIR, 'public', 'icons') const ICON_DIR = resolve(DIR, 'public', 'icons')
@@ -87,43 +90,79 @@ export async function dailyWeather(lat, lon, start, end) {
const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url) const data = await cachedFetch(`daily_${lat}_${lon}_${startStr}_${endStr}`, url)
if (!data?.daily?.time) return [] if (!data?.daily?.time) return []
return Promise.all(data.daily.time.map(async (time, i) => { const rows = data.daily.time.map((time, i) => {
const code = data.daily.weathercode[i] const code = data.daily.weathercode[i]
const icon = OWM_ICONS[code] || '01d' const icon = OWM_ICONS[code] || '01d'
const temp = data.daily.temperature_2m_max[i] const tempMax = data.daily.temperature_2m_max[i]
const tempMin = data.daily.temperature_2m_min[i]
const temp = tempMax
const humidity = data.daily.relative_humidity_2m_mean[i] const humidity = data.daily.relative_humidity_2m_mean[i]
const uv = data.daily.uv_index_max[i] const precip = data.daily.precipitation_sum[i] ?? 0
const precipChance = data.daily.precipitation_probability_max[i] ?? 0
const windSpeed = data.daily.windspeed_10m_max[i] ?? 0
const windGusts = data.daily.windgusts_10m_max[i] ?? 0
// "2026-03-01" → local midnight by shifting from UTC midnight by tz offset const utcMidnight = new Date(`${time}T00:00:00Z`)
const utcMidnight = new Date(`${time}T00:00:00Z`) const offset = tzOffsetMinutes(utcMidnight)
const offset = tzOffsetMinutes(utcMidnight)
const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000) const localMidnight = new Date(utcMidnight.getTime() - offset * 60_000)
return { const sunrise = data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null
const sunset = data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null
const daylight = sunrise && sunset
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
: null
const clouds = humidity / 100 * 0.8 // rough estimate — not provided by daily endpoint
const row = {
time: localMidnight,
daytime: true,
label: WMO[code] || 'Unknown', label: WMO[code] || 'Unknown',
icon, icon,
code,
time: localMidnight,
temperature: temp, temperature: temp,
temperature_max: temp, temperature_max: tempMax,
temperature_min: data.daily.temperature_2m_min[i], temperature_min: tempMin,
humidity, humidity,
humidity_abs: absoluteHumidity(temp, humidity), humidity_abs: absoluteHumidity(temp, humidity),
dew_point: dewPoint(temp, humidity), dew_point: dewPoint(temp, humidity),
heat_index: heatIndex(temp, humidity), heat_index: heatIndex(temp, humidity),
vapor_pressure_deficit: vaporPressureDeficit(temp, humidity), vapor_pressure_deficit: vaporPressureDeficit(temp, humidity),
precipitation: data.daily.precipitation_sum[i], pressure_hpa: null,
precipitation_chance: data.daily.precipitation_probability_max[i], pressure_slp: null,
wind_speed: data.daily.windspeed_10m_max[i], pressure_rate: null,
wind_gusts: data.daily.windgusts_10m_max[i], storm_trend: 0,
wind_direction: data.daily.winddirection_10m_dominant[i], wind_speed: windSpeed,
uv_index: uv, wind_gusts: windGusts,
solar_wm2: data.daily.shortwave_radiation_sum[i], wind_direction: data.daily.winddirection_10m_dominant[i] ?? 0,
clouds: null, clouds,
sunrise: data.daily.sunrise[i] ? new Date(data.daily.sunrise[i]) : null, raining: precipChance > 60 ? 'True' : 'False',
sunset: data.daily.sunset[i] ? new Date(data.daily.sunset[i]) : null, 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 ──────────────────────────────────────────────────────────────────── // ── Hourly ────────────────────────────────────────────────────────────────────
@@ -146,8 +185,6 @@ export async function hourlyWeather(lat, lon, start, end) {
const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url) const data = await cachedFetch(`hourly_${lat}_${lon}_${startStr}_${endStr}`, url)
if (!data?.hourly?.time) return [] if (!data?.hourly?.time) return []
// Open-Meteo returns local time strings e.g. "2026-03-01T06:00" (no Z)
// sunMap keyed by date portion of those local strings
const sunMap = {} const sunMap = {}
if (data.daily?.time) { if (data.daily?.time) {
data.daily.time.forEach((date, i) => { data.daily.time.forEach((date, i) => {
@@ -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 dateKey = time.slice(0, 10)
const sun = sunMap[dateKey] ?? {} const sun = sunMap[dateKey] ?? {}
const t = new Date(time + 'Z').getTime() const t = new Date(time + 'Z').getTime()
const daytime = sun.sunrise && sun.sunset const daytime = sun.sunrise && sun.sunset
? t >= new Date(sun.sunrise.toISOString().replace('Z', '') + 'Z').getTime() && t < new Date(sun.sunset.toISOString().replace('Z', '') + 'Z').getTime() ? t >= sun.sunrise.getTime() && t < sun.sunset.getTime()
: false : false
const code = data.hourly.weathercode[i] const code = data.hourly.weathercode[i]
const icon = await ensureIcon(code).catch(() => null)
const temp = data.hourly.temperature_2m[i] const temp = data.hourly.temperature_2m[i]
const humidity = data.hourly.relative_humidity_2m[i] const humidity = data.hourly.relative_humidity_2m[i]
const uv = data.hourly.uv_index[i]
const clouds = Math.round(data.hourly.cloudcover[i]) / 100 const clouds = Math.round(data.hourly.cloudcover[i]) / 100
const precip = data.hourly.precipitation[i] ?? 0
const precipChance = data.hourly.precipitation_probability[i] ?? 0
const windSpeed = data.hourly.windspeed_10m[i] ?? 0
const uv = data.hourly.uv_index[i] ?? 0
return { const sunrise = sun.sunrise ?? null
const sunset = sun.sunset ?? null
const daylight = sunrise && sunset
? Math.round((sunset - sunrise) / 3600_000 * 100) / 100
: null
const row = {
time: new Date(time + 'Z'), time: new Date(time + 'Z'),
daytime, daytime,
label: WMO[code] || 'Unknown', label: WMO[code] || 'Unknown',
icon, icon: OWM_ICONS[code] || '01d',
code,
temperature: temp, temperature: temp,
temperature_max: temp,
temperature_min: temp,
humidity, humidity,
humidity_abs: absoluteHumidity(temp, humidity), humidity_abs: absoluteHumidity(temp, humidity),
dew_point: dewPoint(temp, humidity), dew_point: data.hourly.dewpoint_2m[i] ?? dewPoint(temp, humidity),
heat_index: heatIndex(temp, humidity), heat_index: heatIndex(temp, humidity),
vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity), vapor_pressure_deficit: data.hourly.vapour_pressure_deficit[i] ?? vaporPressureDeficit(temp, humidity),
pressure_hpa: data.hourly.surface_pressure[i], pressure_hpa: data.hourly.surface_pressure[i] ?? null,
precipitation: data.hourly.precipitation[i], pressure_slp: null,
precipitation_chance: data.hourly.precipitation_probability[i], pressure_rate: null,
wind_speed: data.hourly.windspeed_10m[i], storm_trend: 0,
wind_gusts: data.hourly.windgusts_10m[i], wind_speed: windSpeed,
wind_direction: data.hourly.winddirection_10m[i], wind_gusts: data.hourly.windgusts_10m[i] ?? 0,
wind_direction: data.hourly.winddirection_10m[i] ?? 0,
clouds,
raining: precipChance > 60 ? 'True' : 'False',
precipitation: precip,
precipitation_chance: precipChance,
accumulation: precip,
frost_risk: frostRisk(temp, dewPoint(temp, humidity), humidity),
uv_index: uv, uv_index: uv,
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 visibility: data.hourly.visibility[i] != null
? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10 ? Math.round(data.hourly.visibility[i] / 1000 * 10) / 10
: null, : 50,
sunrise,
sunset,
daylight,
lightning_rate: 0,
lightning_distance: null,
seismic_magnitude: null,
air_quality: null,
ground_distance: null,
} }
}))
return { ...row, ...getWeatherCondition(row) }
})
return rows
} }
+8
View File
@@ -18,3 +18,11 @@ export async function fetchIcon(icon) {
await downloadIcon(icon, iconPath); await downloadIcon(icon, iconPath);
return 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 {cfg} from './config.mjs';
import {frostRisk} from './openweather.mjs';
async function query(c, q, start, end) { async function query(c, q, start, end) {
const url = new URL('/api/v1/query_range', c.DB_HOST); const url = new URL('/api/v1/query_range', c.DB_HOST);
@@ -30,9 +31,15 @@ function metricToFields(results) {
} }
export async function queryCurrent() { export async function queryCurrent() {
const c = cfg(); const c = cfg()
const results = await queryInstant(c, `{__name__!=""}`); const results = await queryInstant(c, `{__name__!=""}`)
return {time: new Date(), ...metricToFields(results)}; 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) { export async function queryHourly(start, end) {
@@ -101,11 +108,14 @@ export async function queryDaily(start, end) {
export async function getCoords() { export async function getCoords() {
const c = cfg(); 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 results = await queryInstant(c, `{__name__=~"latitude|longitude|altitude"}`);
const fields = metricToFields(results); 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: 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};
} }
+62 -36
View File
@@ -7,11 +7,15 @@ import {getCelestialCurrent, getCelestialForecast} from './celestial.mjs';
import {apiReference} from '@scalar/express-api-reference'; import {apiReference} from '@scalar/express-api-reference';
import {spec} from './spec.mjs'; import {spec} from './spec.mjs';
import {existsSync} from 'fs'; import {existsSync} from 'fs';
import {getAIS} from './ais.mjs'; import {getAIS, getAISImage} from './ais.mjs';
import {getADSB, getADSBHistory, getADSBRange, initAircraftDb} from './adsb.mjs'; import {getIcaoImage, get, getHistory, getAttenuation, initAircraftDb} from './adsb.mjs';
import {fetchIcon} from './openweather.mjs'; import {fetchIcon} from './openweather.mjs';
import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs'; import {getForecast, getWeatherCondition, forecastTTL} from './forecast.mjs';
import {dailyWeather, hourlyWeather} from './openmeteo.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 ─────────────────────────────────────────────────── // ── Uncaught error handlers ───────────────────────────────────────────────────
@@ -47,7 +51,7 @@ function filterArr(arr, fields) {
return arr.map(row => filterFields(row, fields)); return arr.map(row => filterFields(row, fields));
} }
// ── Current ─────────────────────────────────────────────────────────────────── // ── Weather ───────────────────────────────────────────────────────────────────
app.get('/api/current', asyncHandler(async (req, res) => { app.get('/api/current', asyncHandler(async (req, res) => {
const { fields } = req.query const { fields } = req.query
@@ -71,51 +75,61 @@ app.get('/api/current', asyncHandler(async (req, res) => {
res.json(filterFields(merged, fields)) res.json(filterFields(merged, fields))
})); }));
// ── Hourly ────────────────────────────────────────────────────────────────────
app.get('/api/hourly', asyncHandler(async (req, res) => { app.get('/api/hourly', asyncHandler(async (req, res) => {
const { fields } = req.query const { fields } = req.query
const coords = await getCoords() const coords = await getCoords()
const now = new Date() const now = new Date()
now.setMinutes(0, 0, 0) 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 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([ const [history, forecast24, meteo] = await Promise.all([
start.getTime() < now.getTime() ? queryHourly(start, now) : Promise.resolve([]), start < now
end.getTime() > now.getTime() ? getForecast().then(f => f.slots.filter(h => new Date(h.time) <= end)) : Promise.resolve([]), ? queryHourly(start, now)
end.getTime() > forecastLimit.getTime() ? hourlyWeather(coords.latitude, coords.longitude, forecastLimit, end) : Promise.resolve([]), : 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]) const combined = getCelestialForecast(coords.latitude, coords.longitude, [...history, ...forecast24, ...meteo])
res.json(filterArr(hourly, fields)) res.json(filterArr(combined, fields))
})); }))
// ── Daily ─────────────────────────────────────────────────────────────────────
app.get('/api/daily', asyncHandler(async (req, res) => { app.get('/api/daily', asyncHandler(async (req, res) => {
const { fields } = req.query const { fields } = req.query
const coords = await getCoords(); const coords = await getCoords()
const now = new Date() const now = new Date()
const todayStart = new Date(now) const todayStart = new Date(now)
todayStart.setHours(0, 0, 0, 0) todayStart.setHours(0, 0, 0, 0)
const todayEnd = new Date(todayStart) const todayEnd = new Date(todayStart)
todayEnd.setDate(todayStart.getDate() + 1) 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 end = req.query.end ? new Date(req.query.end) : new Date(todayEnd.getTime() + 5 * 24 * 60 * 60_000)
const [history, current, future] = Promise.all([ const [history, current, future] = await Promise.all([
start.getTime() < todayStart.getTime() ? queryDaily(start, todayStart) : Promise.resolve([]), // Historic start < todayStart
start.getTime() <= todayStart.getTime() ? queryCurrent().then(resp => [resp]) : Promise.resolve([]), // Current ? queryDaily(start, todayStart)
start.getTime() >= todayEnd.getTime() ? dailyWeather(coords.latitude, coords.longitude, todayStart, end) : Promise.resolve([]), // Future : Promise.resolve([]),
]); start <= todayStart && end >= todayStart
const daily = [...history, ...current, ...getCelestialForecast(coords.latitude, coords.longitude, future)]; ? 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)) res.json(filterArr(daily, fields))
})); }))
// ── Position ────────────────────────────────────────────────────────────────── // ── Position ──────────────────────────────────────────────────────────────────
app.get('/api/range', asyncHandler(async (req, res) => res.json(await getAttenuation())));
app.get('/api/position', asyncHandler(async (req, res) => { app.get('/api/position', asyncHandler(async (req, res) => {
const {fields} = req.query; const {fields} = req.query;
const data = await getCoords(); const data = await getCoords();
@@ -133,18 +147,30 @@ app.get('/api/icon/:icon', asyncHandler(async (req, res, next) => {
// ── Space ───────────────────────────────────────────────────────────────────── // ── Space ─────────────────────────────────────────────────────────────────────
// app.get('/api/aurora', async (req, res) => res.json(await Aurora.get())); app.get('/api/aurora', async (req, res) => res.json(await Aurora.get()));
// app.get('/api/space', async (req, res) => { app.get('/api/space', async (req, res) => {
// const {fields} = req.query; const {fields} = req.query;
// res.json(filterFields(await getSpaceWeather(), fields)); res.json(filterFields(await getSpaceWeather(), fields));
// }); });
// ── ADSB Proxy ──────────────────────────────────────────────────────────────── // ── ADSB/AIS/SAT/RadioSonde Proxy ────────────────────────────────────────────────────────────
app.get('/api/adsb', 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/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 ────────────────────────────────────────────────────────────────────── // ── DOCS ──────────────────────────────────────────────────────────────────────
File diff suppressed because one or more lines are too long
+67 -142
View File
@@ -10,17 +10,16 @@ export const spec = {
'/api/position': { '/api/position': {
get: { get: {
summary: 'Station position', summary: 'Station position',
description: 'Latest GPS position and info', description: 'Latest GPS position and altitude',
parameters: [ ],
responses: { responses: {
200: { 200: {
description: 'Last known position', description: 'Last known position',
content: { 'application/json': { schema: { $ref: '#/components/schemas/DataRow' } } } content: { 'application/json': { schema: { $ref: '#/components/schemas/Position' } } }
} }
} }
} }
}, },
'/api/data': { '/api/current': {
get: { get: {
summary: 'Current conditions', summary: 'Current conditions',
description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object', description: 'Latest reading of all metrics — sensor, forecast, celestial & space weather merged into a single flat object',
@@ -82,153 +81,79 @@ export const spec = {
Position: { Position: {
type: 'object', type: 'object',
properties: { properties: {
lat: { type: 'number', description: 'Latitude' }, latitude: { type: 'number', description: 'Latitude (°)' },
lon: { type: 'number', description: 'Longitude' }, longitude: { type: 'number', description: 'Longitude (°)' },
alt: { type: 'number', description: 'Altitude' }, altitude: { type: 'number', description: 'Altitude (m)' },
} }
}, },
DataRow: { DataRow: {
type: 'object', type: 'object',
properties: { properties: {
time: { type: 'string', description: 'ISO8601 timestamp or date' }, time: { type: 'string', format: 'date-time', description: 'ISO8601 timestamp' },
// Environment // Environment
temperature: { type: 'number', description: 'Temperature (°C)' }, temperature: { type: 'number', description: 'Temperature (°C)' },
env_temp_f: { type: 'number', description: 'Temperature (°F)' }, env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' },
env_temp_min_c: { type: 'number', description: 'Daily min temperature (°C)' }, env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' },
env_temp_max_c: { type: 'number', description: 'Daily max temperature (°C)' }, humidity: { type: 'number', description: 'Relative humidity (%)' },
humidity: { type: 'number', description: 'Relative humidity (%)' }, humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' },
dew_point: { type: 'number', description: 'Dew point (°C)' }, dew_point: { type: 'number', description: 'Dew point (°C)' },
heat_index: { type: 'number', description: 'Feels like / heat index (°C)' }, heat_index: { type: 'number', description: 'Feels like / heat index (°C)' },
pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' }, vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' },
pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' }, pressure_hpa: { type: 'number', description: 'Station pressure (hPa)' },
pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' }, pressure_slp: { type: 'number', description: 'Sea level pressure (hPa)' },
pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' }, pressure_rate: { type: 'number', description: 'Pressure change rate (hPa/hr)' },
humidity_abs: { type: 'number', description: 'Absolute humidity (g/m³)' }, pressure_trend: { type: 'string', enum: ['Rising','Falling','Stable'], description: 'Pressure trend label' },
vapor_pressure_deficit: { type: 'number', description: 'Vapour pressure deficit (kPa)' }, frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'], description: 'Frost risk level' },
air_quality_ohms: { type: 'number', description: 'BME680 gas resistance (Ω)' }, air_quality: { type: 'number', description: 'Air quality index score (0100)' },
air_quality: { type: 'number', description: 'Air quality index score (0100)' }, air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] },
air_quality_label: { type: 'string', enum: ['Excellent','Good','Fair','Poor','Very Poor'] }, // Light & Solar
frost_risk: { type: 'string', enum: ['None','Low','Moderate','High'] }, lux: { type: 'number', description: 'Illuminance (lux)' },
// Light uv_index: { type: 'number', description: 'UV index' },
lux: { type: 'number', description: 'Illuminance (lux)' }, uv_index_max: { type: 'number', description: 'Daily max UV index' },
uv_index: { type: 'number', description: 'UV index' }, uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' },
solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' }, solar_wm2: { type: 'number', description: 'Solar irradiance (W/m²)' },
uv_dose: { type: 'number', description: 'Accumulated UV dose today (mJ/cm²)' }, daily_light_integral: { type: 'number', description: 'Daily light integral (mol/m²/day)' },
light_burn_time_min: { type: 'number', description: 'Time to sunburn skin type 2 (min)' }, clouds: { type: 'number', description: 'Cloud cover fraction (01)' },
clouds: { type: 'number', description: 'Estimated cloud cover (%)' }, visibility: { type: 'number', description: 'Estimated visibility (km)' },
light_cloud_label: { type: 'string', enum: ['Clear','Partly Cloudy','Mostly Cloudy','Overcast'] }, daylight: { type: 'number', description: 'Hours of daylight' },
light_dli: { type: 'number', description: 'Daily light integral (mol/m²/day)' }, // Weather
daylight: { type: 'number', description: 'Hours above daylight threshold today' }, label: { type: 'string', description: 'Human readable weather label' },
visibility: { type: 'number', description: 'Estimated visibility (km)' }, icon: { type: 'string', description: 'OWM icon code e.g. 01d' },
uv_index_max: { type: 'number', description: 'Daily max UV index' }, raining: { type: 'string', enum: ['True','False'] },
solar_wm2_sum: { type: 'number', description: 'Daily solar radiation sum (MJ/m²)' }, precipitation: { type: 'number', description: 'Precipitation (mm)' },
// Wind (Open-Meteo) precipitation_chance: { type: 'number', description: 'Precipitation probability (%)' },
wind_speed_kmh: { type: 'number', description: 'Wind speed (km/h)' }, accumulation: { type: 'number', description: 'Accumulated precipitation (mm)' },
wind_direction_deg: { type: 'number', description: 'Wind direction (°)' }, storm_trend: { type: 'number', description: '1=worsening, 0=stable, -1=improving' },
wind_gusts_kmh: { type: 'number', description: 'Wind gusts (km/h)' }, // Wind
wind_speed_max_kmh: { type: 'number', description: 'Daily max wind speed (km/h)' }, wind_speed: { type: 'number', description: 'Wind speed (km/h)' },
wind_gusts_max_kmh: { type: 'number', description: 'Daily max wind gusts (km/h)' }, wind_gusts: { type: 'number', description: 'Wind gusts (km/h)' },
// Forecast wind_direction: { type: 'number', description: 'Wind direction (°)' },
forecast_weathercode: { type: 'number', description: 'WMO weather code' },
forecast_weather_label: { type: 'string', description: 'Human readable weather label' },
forecast_weather_icon: { type: 'string', description: 'Local icon path e.g. 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' },
// Lightning // Lightning
lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' }, lightning_rate: { type: 'number', description: 'Strike rate (strikes/hr)' },
lightning_energy: { type: 'number', description: 'Lightning energy' }, lightning_distance: { type: 'number', description: 'Lightning strike distance (km)' },
lightning_strikes_per_hour: { type: 'number', description: 'Strike rate (strikes/hr)' },
storm_direction: { type: 'string', enum: ['Approaching','Retreating','Stationary'] },
lightning_false_positive: { type: 'number', description: '1 if disturber/false positive' },
lightning_detector_sensitivity:{ type: 'number', description: 'AS3935 noise floor setting (07)' },
// GPS
latitude: { type: 'number', description: 'Latitude (°)' },
longitude: { type: 'number', description: 'Longitude (°)' },
altitude: { type: 'number', description: 'Altitude (m)' },
gps_satellites: { type: 'number', description: 'Satellites in view' },
gps_speed_kmh: { type: 'number', description: 'Ground speed (km/h)' },
gps_heading: { type: 'number', description: 'GPS heading (°)' },
// Sun // Sun
sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' }, sun_elevation: { type: 'number', description: 'Solar elevation angle (°)' },
sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' }, sun_azimuth: { type: 'number', description: 'Solar azimuth (°)' },
sun_sunrise: { type: 'string', description: 'Sunrise time (ISO8601)' }, sunrise: { type: 'string', format: 'date-time', description: 'Sunrise time' },
sun_sunset: { type: 'string', description: 'Sunset time (ISO8601)' }, sunset: { type: 'string', format: 'date-time', description: 'Sunset time' },
sun_solar_noon: { type: 'string', description: 'Solar noon (ISO8601)' }, daytime: { type: 'boolean', description: 'True if between sunrise and sunset' },
sun_day_length_hours: { type: 'number', description: 'Day length (hours)' },
sun_golden_hour_morning_start: { type: 'string' },
sun_golden_hour_morning_end: { type: 'string' },
sun_golden_hour_evening_start: { type: 'string' },
sun_golden_hour_evening_end: { type: 'string' },
sun_is_day: { type: 'number', description: '1 if daytime' },
sun_ssn: { type: 'number', description: 'Estimated sunspot number' },
sun_activity_label: { type: 'string', enum: ['Very Low','Low','Moderate','High','Very High'] },
// Moon // Moon
moon_phase: { type: 'string', description: 'Moon phase name' }, moon_phase: { type: 'string', description: 'Moon phase name' },
moon_illumination_pct: { type: 'number', description: 'Moon illumination (%)' }, moon_illumination: { type: 'number', description: 'Moon illumination (%)' },
moon_moonrise: { type: 'string', description: 'Moonrise time (ISO8601)' }, moon_elevation: { type: 'number', description: 'Moon elevation (°)' },
moon_moonset: { type: 'string', description: 'Moonset time (ISO8601)' }, moon_azimuth: { type: 'number', description: 'Moon azimuth (°)' },
moon_next_full: { type: 'string', description: 'Next full moon (ISO8601)' }, moonrise: { type: 'string', format: 'date-time', description: 'Moonrise time' },
moon_next_new: { type: 'string', description: 'Next new moon (ISO8601)' }, moonset: { type: 'string', format: 'date-time', description: 'Moonset time' },
// Season moon_new: { type: 'string', format: 'date-time', description: 'Next new moon' },
season_next_event: { type: 'string', description: 'Next solstice/equinox name' }, moon_full: { type: 'string', format: 'date-time', description: 'Next full moon' },
season_next_event_date: { type: 'string' }, // Seismic
season_last_event: { type: 'string' }, seismic_magnitude: { type: 'number', description: 'Peak seismic magnitude (g)' },
season_last_event_date: { type: 'string' }, // Ground
// Space weather ground_distance: { type: 'number', description: 'LIDAR ground distance (cm)' },
space_kp_index: { type: 'number', description: 'Planetary K index (09)' }, // GPS
space_ap_index: { type: 'number', description: 'Ap geomagnetic index' }, latitude: { type: 'number', description: 'Latitude (°)' },
space_geomagnetic_storm: { type: 'string', enum: ['None','Minor','Moderate','Strong','Severe'] }, longitude: { type: 'number', description: 'Longitude (°)' },
space_solar_wind_speed_kms: { type: 'number', description: 'Solar wind speed (km/s)' }, altitude: { type: 'number', description: 'Altitude (m)' },
space_solar_wind_density: { type: 'number', description: 'Solar wind proton density (p/cm³)' },
space_solar_wind_temp: { type: 'number', description: 'Solar wind temperature (K)' },
space_imf_bz: { type: 'number', description: 'IMF Bz component (nT)' },
space_imf_bt: { type: 'number', description: 'IMF total field Bt (nT)' },
space_solar_flux_f107: { type: 'number', description: 'Solar flux F10.7 index' },
// Marine
wave_height: { type: 'number', description: 'Significant wave height (m)' },
wave_direction: { type: 'number', description: 'Wave direction (°)' },
wave_period: { type: 'number', description: 'Wave period (s)' },
swell_wave_height: { type: 'number', description: 'Swell wave height (m)' },
swell_wave_direction: { type: 'number', description: 'Swell direction (°)' },
swell_wave_period: { type: 'number', description: 'Swell period (s)' },
wind_wave_height: { type: 'number', description: 'Wind wave height (m)' },
wave_height_max: { type: 'number', description: 'Daily max wave height (m)' },
// AQ
pm10: { type: 'number', description: 'PM10 (μg/m³)' },
pm2_5: { type: 'number', description: 'PM2.5 (μg/m³)' },
carbon_monoxide: { type: 'number', description: 'CO (μg/m³)' },
nitrogen_dioxide: { type: 'number', description: 'NO₂ (μg/m³)' },
sulphur_dioxide: { type: 'number', description: 'SO₂ (μg/m³)' },
ozone: { type: 'number', description: 'O₃ (μg/m³)' },
aerosol_optical_depth: { type: 'number' },
dust: { type: 'number', description: 'Dust (μg/m³)' },
alder_pollen: { type: 'number', description: 'Alder pollen (grains/m³)' },
birch_pollen: { type: 'number', description: 'Birch pollen (grains/m³)' },
grass_pollen: { type: 'number', description: 'Grass pollen (grains/m³)' },
mugwort_pollen: { type: 'number', description: 'Mugwort pollen (grains/m³)' },
olive_pollen: { type: 'number', description: 'Olive pollen (grains/m³)' },
ragweed_pollen: { type: 'number', description: 'Ragweed pollen (grains/m³)' },
} }
} }
} }
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