diff --git a/web/.env.example b/web/.env.example
index f369a7a..3a59bc4 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -2,15 +2,9 @@
# Copy this file to .env.local and fill in your values
# Development environment variables
-VITE_API_BASE_URL="http://localhost:8080"
+VITE_API_BASE_URL="http://localhost:5446"
VITE_APP_ENV="development"
# Application customization
VITE_SITE_TITLE="ERSN Mesh"
VITE_SITE_DESCRIPTION="Meshtastic activity in the Ebbett's Pass region of Highway 4, CA."
-
-# Get an API key: https://developers.google.com/maps/documentation/javascript/get-api-key
-VITE_GOOGLE_MAPS_API_KEY=OVERRIDE_IN_LOCAL_ENV
-
-# Create a Map ID for Advanced Markers support and custom map styles and customize in Google Cloud Console.
-VITE_GOOGLE_MAPS_ID=OVERRIDE_IN_LOCAL_ENV
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
index 81c56e7..9437c34 100644
--- a/web/index.html
+++ b/web/index.html
@@ -10,13 +10,7 @@
-
-
-
+
diff --git a/web/package.json b/web/package.json
index 9c79905..84a55f5 100644
--- a/web/package.json
+++ b/web/package.json
@@ -32,9 +32,11 @@
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.503.0",
+ "maplibre-gl": "^5.20.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
+ "react-map-gl": "^8.1.0",
"react-redux": "^9.2.0",
"tailwind-merge": "^3.2.0"
},
@@ -43,6 +45,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.17",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index b56a3e5..4fbc69a 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
lucide-react:
specifier: ^0.503.0
version: 0.503.0(react@19.1.0)
+ maplibre-gl:
+ specifier: ^5.20.1
+ version: 5.20.1
react:
specifier: ^19.1.0
version: 19.1.0
@@ -50,6 +53,9 @@ importers:
react-leaflet:
specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react-map-gl:
+ specifier: ^8.1.0
+ version: 8.1.0(maplibre-gl@5.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-redux:
specifier: ^9.2.0
version: 9.2.0(@types/react@19.1.2)(react@19.1.0)(redux@5.0.1)
@@ -69,6 +75,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.0)
+ '@types/geojson':
+ specifier: ^7946.0.16
+ version: 7946.0.16
'@types/leaflet':
specifier: ^1.9.17
version: 1.9.17
@@ -489,6 +498,46 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ '@mapbox/jsonlint-lines-primitives@2.0.2':
+ resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==}
+ engines: {node: '>= 0.6'}
+
+ '@mapbox/point-geometry@1.1.0':
+ resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
+
+ '@mapbox/tiny-sdf@2.0.7':
+ resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==}
+
+ '@mapbox/unitbezier@0.0.1':
+ resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
+
+ '@mapbox/vector-tile@2.0.4':
+ resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==}
+
+ '@mapbox/whoots-js@3.1.0':
+ resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
+ engines: {node: '>=6.0.0'}
+
+ '@maplibre/geojson-vt@5.0.4':
+ resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==}
+
+ '@maplibre/geojson-vt@6.0.2':
+ resolution: {integrity: sha512-OnXnV2m1yBULKOlUanNFTiOeXCktvWYY4yWoHVETlp6ShJGUhY3DNt9XzPByL24h4JcoJRccPBlMhH1o8cvmyQ==}
+
+ '@maplibre/maplibre-gl-style-spec@19.3.3':
+ resolution: {integrity: sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==}
+ hasBin: true
+
+ '@maplibre/maplibre-gl-style-spec@24.7.0':
+ resolution: {integrity: sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==}
+ hasBin: true
+
+ '@maplibre/mlt@1.1.7':
+ resolution: {integrity: sha512-HZSsXrgn2V6T3o0qklMwKERfKaAxjO8shmiFnVygCtXTg4SPKWVX+U99RkvxUfCsjYBEcT4ltor8lSlBSCca7Q==}
+
+ '@maplibre/vt-pbf@4.3.0':
+ resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -553,56 +602,67 @@ packages:
resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.40.1':
resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.40.1':
resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.40.1':
resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.40.1':
resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.40.1':
resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.40.1':
resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.40.1':
resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==}
@@ -663,24 +723,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.4':
resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.4':
resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.4':
resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.4':
resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==}
@@ -897,6 +961,9 @@ packages:
'@types/react@19.1.2':
resolution: {integrity: sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==}
+ '@types/supercluster@7.1.3':
+ resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
+
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -947,6 +1014,26 @@ packages:
resolution: {integrity: sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@vis.gl/react-mapbox@8.1.0':
+ resolution: {integrity: sha512-FwvH822oxEjWYOr+pP2L8hpv+7cZB2UsQbHHHT0ryrkvvqzmTgt7qHDhamv0EobKw86e1I+B4ojENdJ5G5BkyQ==}
+ peerDependencies:
+ mapbox-gl: '>=3.5.0'
+ react: '>=16.3.0'
+ react-dom: '>=16.3.0'
+ peerDependenciesMeta:
+ mapbox-gl:
+ optional: true
+
+ '@vis.gl/react-maplibre@8.1.0':
+ resolution: {integrity: sha512-PkAK/gp3mUfhCLhUuc+4gc3PN9zCtVGxTF2hB6R5R5yYUw+hdg84OZ770U5MU4tPMTCG6fbduExuIW6RRKN6qQ==}
+ peerDependencies:
+ maplibre-gl: '>=4.0.0'
+ react: '>=16.3.0'
+ react-dom: '>=16.3.0'
+ peerDependenciesMeta:
+ maplibre-gl:
+ optional: true
+
'@vitejs/plugin-react@4.4.1':
resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1029,6 +1116,10 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
+ arr-union@3.1.0:
+ resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==}
+ engines: {node: '>=0.10.0'}
+
array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
@@ -1061,6 +1152,10 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ assign-symbols@1.0.0:
+ resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
+ engines: {node: '>=0.10.0'}
+
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1101,6 +1196,12 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ bytewise-core@1.2.3:
+ resolution: {integrity: sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==}
+
+ bytewise@1.1.0:
+ resolution: {integrity: sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==}
+
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -1244,6 +1345,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ earcut@3.0.2:
+ resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
+
electron-to-chromium@1.5.144:
resolution: {integrity: sha512-eJIaMRKeAzxfBSxtjYnoIAw/tdD6VIH6tHBZepZnAbE3Gyqqs5mGN87DvcldPUbVkIljTK8pY0CMcUljP64lfQ==}
@@ -1369,6 +1473,14 @@ packages:
resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
engines: {node: '>=12.0.0'}
+ extend-shallow@2.0.1:
+ resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
+ engines: {node: '>=0.10.0'}
+
+ extend-shallow@3.0.2:
+ resolution: {integrity: sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==}
+ engines: {node: '>=0.10.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1453,6 +1565,13 @@ packages:
get-tsconfig@4.10.0:
resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==}
+ get-value@2.0.6:
+ resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==}
+ engines: {node: '>=0.10.0'}
+
+ gl-matrix@3.4.4:
+ resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1590,6 +1709,14 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
+ is-extendable@0.1.1:
+ resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
+ engines: {node: '>=0.10.0'}
+
+ is-extendable@1.0.1:
+ resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==}
+ engines: {node: '>=0.10.0'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -1618,6 +1745,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-plain-object@2.0.4:
+ resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
+ engines: {node: '>=0.10.0'}
+
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -1663,6 +1794,10 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isobject@3.0.1:
+ resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
+ engines: {node: '>=0.10.0'}
+
iterator.prototype@1.1.5:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
@@ -1701,6 +1836,12 @@ packages:
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+ json-stringify-pretty-compact@3.0.0:
+ resolution: {integrity: sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==}
+
+ json-stringify-pretty-compact@4.0.0:
+ resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -1710,6 +1851,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ kdbush@4.0.2:
+ resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1749,24 +1893,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.29.2:
resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.29.2:
resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.29.2:
resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.29.2:
resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}
@@ -1819,6 +1967,10 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
+ maplibre-gl@5.20.1:
+ resolution: {integrity: sha512-57YIgfRct+rrk78ldoWRuLWRnXV/1vM2Rk0QYfEDQmsXdpgbACwvGoREIOZtyDIaq/GJK/ORYEriaAdVZuNfvw==}
+ engines: {node: '>=16.14.0', npm: '>=8.1.0'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1842,9 +1994,15 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ murmurhash-js@1.0.0:
+ resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1936,6 +2094,10 @@ packages:
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
engines: {node: '>= 14.16'}
+ pbf@4.0.1:
+ resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==}
+ hasBin: true
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1958,6 +2120,9 @@ packages:
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14}
+ potpack@2.1.0:
+ resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -1974,6 +2139,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ protocol-buffers-schema@3.6.0:
+ resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1981,6 +2149,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ quickselect@3.0.0:
+ resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
+
react-dom@19.1.0:
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
peerDependencies:
@@ -1999,6 +2170,19 @@ packages:
react: ^19.0.0
react-dom: ^19.0.0
+ react-map-gl@8.1.0:
+ resolution: {integrity: sha512-vDx/QXR3Tb+8/ap/z6gdMjJQ8ZEyaZf6+uMSPz7jhWF5VZeIsKsGfPvwHVPPwGF43Ryn+YR4bd09uEFNR5OPdg==}
+ peerDependencies:
+ mapbox-gl: '>=1.13.0'
+ maplibre-gl: '>=1.13.0'
+ react: '>=16.3.0'
+ react-dom: '>=16.3.0'
+ peerDependenciesMeta:
+ mapbox-gl:
+ optional: true
+ maplibre-gl:
+ optional: true
+
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -2056,6 +2240,9 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+ resolve-protobuf-schema@2.1.0:
+ resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
+
resolve@2.0.0-next.5:
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
hasBin: true
@@ -2075,6 +2262,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+ rw@1.3.3:
+ resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+
safe-array-concat@1.1.3:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
@@ -2128,6 +2318,10 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
+ set-value@2.0.1:
+ resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==}
+ engines: {node: '>=0.10.0'}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -2158,10 +2352,26 @@ packages:
solid-js@1.9.5:
resolution: {integrity: sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==}
+ sort-asc@0.2.0:
+ resolution: {integrity: sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==}
+ engines: {node: '>=0.10.0'}
+
+ sort-desc@0.2.0:
+ resolution: {integrity: sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==}
+ engines: {node: '>=0.10.0'}
+
+ sort-object@3.0.3:
+ resolution: {integrity: sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==}
+ engines: {node: '>=0.10.0'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ split-string@3.1.0:
+ resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
+ engines: {node: '>=0.10.0'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -2195,6 +2405,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ supercluster@8.0.1:
+ resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2236,6 +2449,9 @@ packages:
resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==}
engines: {node: ^18.0.0 || >=20.0.0}
+ tinyqueue@3.0.0:
+ resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
+
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
@@ -2306,10 +2522,20 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ typewise-core@1.2.0:
+ resolution: {integrity: sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==}
+
+ typewise@1.0.3:
+ resolution: {integrity: sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==}
+
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
+ union-value@1.0.1:
+ resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==}
+ engines: {node: '>=0.10.0'}
+
unplugin@2.3.2:
resolution: {integrity: sha512-3n7YA46rROb3zSj8fFxtxC/PqoyvYQ0llwz9wtUPUutr9ig09C8gGo5CWCwHrUzlqC1LLR43kxp5vEIyH1ac1w==}
engines: {node: '>=18.12.0'}
@@ -2795,6 +3021,61 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
+ '@mapbox/jsonlint-lines-primitives@2.0.2': {}
+
+ '@mapbox/point-geometry@1.1.0': {}
+
+ '@mapbox/tiny-sdf@2.0.7': {}
+
+ '@mapbox/unitbezier@0.0.1': {}
+
+ '@mapbox/vector-tile@2.0.4':
+ dependencies:
+ '@mapbox/point-geometry': 1.1.0
+ '@types/geojson': 7946.0.16
+ pbf: 4.0.1
+
+ '@mapbox/whoots-js@3.1.0': {}
+
+ '@maplibre/geojson-vt@5.0.4': {}
+
+ '@maplibre/geojson-vt@6.0.2':
+ dependencies:
+ kdbush: 4.0.2
+
+ '@maplibre/maplibre-gl-style-spec@19.3.3':
+ dependencies:
+ '@mapbox/jsonlint-lines-primitives': 2.0.2
+ '@mapbox/unitbezier': 0.0.1
+ json-stringify-pretty-compact: 3.0.0
+ minimist: 1.2.8
+ rw: 1.3.3
+ sort-object: 3.0.3
+
+ '@maplibre/maplibre-gl-style-spec@24.7.0':
+ dependencies:
+ '@mapbox/jsonlint-lines-primitives': 2.0.2
+ '@mapbox/unitbezier': 0.0.1
+ json-stringify-pretty-compact: 4.0.0
+ minimist: 1.2.8
+ quickselect: 3.0.0
+ rw: 1.3.3
+ tinyqueue: 3.0.0
+
+ '@maplibre/mlt@1.1.7':
+ dependencies:
+ '@mapbox/point-geometry': 1.1.0
+
+ '@maplibre/vt-pbf@4.3.0':
+ dependencies:
+ '@mapbox/point-geometry': 1.1.0
+ '@mapbox/vector-tile': 2.0.4
+ '@maplibre/geojson-vt': 5.0.4
+ '@types/geojson': 7946.0.16
+ '@types/supercluster': 7.1.3
+ pbf: 4.0.1
+ supercluster: 8.0.1
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3171,6 +3452,10 @@ snapshots:
dependencies:
csstype: 3.1.3
+ '@types/supercluster@7.1.3':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.25.1(jiti@2.4.2))(typescript@5.8.3)':
@@ -3250,6 +3535,19 @@ snapshots:
'@typescript-eslint/types': 8.31.1
eslint-visitor-keys: 4.2.0
+ '@vis.gl/react-mapbox@8.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
+ '@vis.gl/react-maplibre@8.1.0(maplibre-gl@5.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@maplibre/maplibre-gl-style-spec': 19.3.3
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ maplibre-gl: 5.20.1
+
'@vitejs/plugin-react@4.4.1(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.19.4))':
dependencies:
'@babel/core': 7.26.10
@@ -3339,6 +3637,8 @@ snapshots:
aria-query@5.3.2: {}
+ arr-union@3.1.0: {}
+
array-buffer-byte-length@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -3396,6 +3696,8 @@ snapshots:
assertion-error@2.0.1: {}
+ assign-symbols@1.0.0: {}
+
async-function@1.0.0: {}
autoprefixer@10.4.21(postcss@8.5.3):
@@ -3445,6 +3747,15 @@ snapshots:
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.4)
+ bytewise-core@1.2.3:
+ dependencies:
+ typewise-core: 1.2.0
+
+ bytewise@1.1.0:
+ dependencies:
+ bytewise-core: 1.2.3
+ typewise: 1.0.3
+
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
@@ -3592,6 +3903,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ earcut@3.0.2: {}
+
electron-to-chromium@1.5.144: {}
enhanced-resolve@5.18.1:
@@ -3838,6 +4151,15 @@ snapshots:
expect-type@1.2.1: {}
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
+ extend-shallow@3.0.2:
+ dependencies:
+ assign-symbols: 1.0.0
+ is-extendable: 1.0.1
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -3932,6 +4254,10 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
+ get-value@2.0.6: {}
+
+ gl-matrix@3.4.4: {}
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -4066,6 +4392,12 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
+ is-extendable@0.1.1: {}
+
+ is-extendable@1.0.1:
+ dependencies:
+ is-plain-object: 2.0.4
+
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -4092,6 +4424,10 @@ snapshots:
is-number@7.0.0: {}
+ is-plain-object@2.0.4:
+ dependencies:
+ isobject: 3.0.1
+
is-potential-custom-element-name@1.0.1: {}
is-regex@1.2.1:
@@ -4137,6 +4473,8 @@ snapshots:
isexe@2.0.0: {}
+ isobject@3.0.1: {}
+
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -4189,6 +4527,10 @@ snapshots:
json-stable-stringify-without-jsonify@1.0.1: {}
+ json-stringify-pretty-compact@3.0.0: {}
+
+ json-stringify-pretty-compact@4.0.0: {}
+
json5@2.2.3: {}
jsx-ast-utils@3.3.5:
@@ -4198,6 +4540,8 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ kdbush@4.0.2: {}
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -4284,6 +4628,28 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
+ maplibre-gl@5.20.1:
+ dependencies:
+ '@mapbox/jsonlint-lines-primitives': 2.0.2
+ '@mapbox/point-geometry': 1.1.0
+ '@mapbox/tiny-sdf': 2.0.7
+ '@mapbox/unitbezier': 0.0.1
+ '@mapbox/vector-tile': 2.0.4
+ '@mapbox/whoots-js': 3.1.0
+ '@maplibre/geojson-vt': 6.0.2
+ '@maplibre/maplibre-gl-style-spec': 24.7.0
+ '@maplibre/mlt': 1.1.7
+ '@maplibre/vt-pbf': 4.3.0
+ '@types/geojson': 7946.0.16
+ earcut: 3.0.2
+ gl-matrix: 3.4.4
+ kdbush: 4.0.2
+ murmurhash-js: 1.0.0
+ pbf: 4.0.1
+ potpack: 2.1.0
+ quickselect: 3.0.0
+ tinyqueue: 3.0.0
+
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -4303,8 +4669,12 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
+ minimist@1.2.8: {}
+
ms@2.1.3: {}
+ murmurhash-js@1.0.0: {}
+
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
@@ -4394,6 +4764,10 @@ snapshots:
pathval@2.0.0: {}
+ pbf@4.0.1:
+ dependencies:
+ resolve-protobuf-schema: 2.1.0
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -4410,6 +4784,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ potpack@2.1.0: {}
+
prelude-ls@1.2.1: {}
prettier@3.5.3: {}
@@ -4426,10 +4802,14 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ protocol-buffers-schema@3.6.0: {}
+
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
+ quickselect@3.0.0: {}
+
react-dom@19.1.0(react@19.1.0):
dependencies:
react: 19.1.0
@@ -4446,6 +4826,15 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
+ react-map-gl@8.1.0(maplibre-gl@5.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@vis.gl/react-mapbox': 8.1.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@vis.gl/react-maplibre': 8.1.0(maplibre-gl@5.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ maplibre-gl: 5.20.1
+
react-redux@9.2.0(@types/react@19.1.2)(react@19.1.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -4502,6 +4891,10 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
+ resolve-protobuf-schema@2.1.0:
+ dependencies:
+ protocol-buffers-schema: 3.6.0
+
resolve@2.0.0-next.5:
dependencies:
is-core-module: 2.16.1
@@ -4542,6 +4935,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
+ rw@1.3.3: {}
+
safe-array-concat@1.1.3:
dependencies:
call-bind: 1.0.8
@@ -4601,6 +4996,13 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
+ set-value@2.0.1:
+ dependencies:
+ extend-shallow: 2.0.1
+ is-extendable: 0.1.1
+ is-plain-object: 2.0.4
+ split-string: 3.1.0
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -4643,8 +5045,25 @@ snapshots:
seroval: 1.2.1
seroval-plugins: 1.2.1(seroval@1.2.1)
+ sort-asc@0.2.0: {}
+
+ sort-desc@0.2.0: {}
+
+ sort-object@3.0.3:
+ dependencies:
+ bytewise: 1.1.0
+ get-value: 2.0.6
+ is-extendable: 0.1.1
+ sort-asc: 0.2.0
+ sort-desc: 0.2.0
+ union-value: 1.0.1
+
source-map-js@1.2.1: {}
+ split-string@3.1.0:
+ dependencies:
+ extend-shallow: 3.0.2
+
stackback@0.0.2: {}
std-env@3.9.0: {}
@@ -4699,6 +5118,10 @@ snapshots:
strip-json-comments@3.1.1: {}
+ supercluster@8.0.1:
+ dependencies:
+ kdbush: 4.0.2
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -4728,6 +5151,8 @@ snapshots:
tinypool@1.0.2: {}
+ tinyqueue@3.0.0: {}
+
tinyrainbow@2.0.0: {}
tinyspy@3.0.2: {}
@@ -4810,6 +5235,12 @@ snapshots:
typescript@5.8.3: {}
+ typewise-core@1.2.0: {}
+
+ typewise@1.0.3:
+ dependencies:
+ typewise-core: 1.2.0
+
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -4817,6 +5248,13 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
+ union-value@1.0.1:
+ dependencies:
+ arr-union: 3.1.0
+ get-value: 2.0.6
+ is-extendable: 0.1.1
+ set-value: 2.0.1
+
unplugin@2.3.2:
dependencies:
acorn: 8.14.1
diff --git a/web/src/components/Map.tsx b/web/src/components/Map.tsx
index 67513ea..ee734cc 100644
--- a/web/src/components/Map.tsx
+++ b/web/src/components/Map.tsx
@@ -1,100 +1,125 @@
-import React from "react";
-import { getStaticMapUrl, getGoogleMapsUrl } from "../lib/mapUtils";
+import React, { useRef, useState, useEffect, useMemo } from "react";
+import ReactMap, { Source, Layer } from "react-map-gl/maplibre";
+import type { FeatureCollection } from "geojson";
+import "maplibre-gl/dist/maplibre-gl.css";
+import { CARTO_DARK_STYLE } from "../lib/mapStyle";
+import { buildCircleCoords, calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy, getGoogleMapsUrl } from "../lib/mapUtils";
-interface MapProps {
+interface LocationMapProps {
latitude: number;
longitude: number;
zoom?: number;
- width?: number;
- height?: number;
caption?: string;
className?: string;
flush?: boolean;
- nightMode?: boolean;
- precisionBits?: number; // Added for position precision
+ precisionBits?: number;
}
-// Helper function to calculate zoom level based on precision bits
-const calculateZoomFromPrecisionBits = (precisionBits?: number): number => {
- if (!precisionBits) return 14; // Default zoom
-
- // Each precision bit roughly halves the area, so we can map bits to zoom level
- // Starting with Earth at zoom 0, each bit roughly adds 1 zoom level
- // Typical values: 21 bits ~= zoom 13-14, 24 bits ~= zoom 16-17
- const baseZoom = 8; // Start with a basic zoom level
- const additionalZoom = Math.max(0, precisionBits - 16); // Each 2 bits above 16 adds ~1 zoom level
-
- return Math.min(18, baseZoom + (additionalZoom / 2)); // Cap at zoom 18
-};
-
-export const Map: React.FC = ({
+export const LocationMap: React.FC = ({
latitude,
longitude,
zoom,
- width = 300,
- height = 200,
caption,
className = "",
flush = false,
- nightMode = true,
- precisionBits
+ precisionBits,
}) => {
- // Calculate zoom level based on precision bits if zoom is not provided
- const effectiveZoom = zoom || calculateZoomFromPrecisionBits(precisionBits);
-
- const mapUrl = getStaticMapUrl(
- latitude,
- longitude,
- effectiveZoom,
- width,
- height,
- nightMode,
- precisionBits
- );
- const googleMapsUrl = getGoogleMapsUrl(latitude, longitude);
-
- // Check if Google Maps API key is available
- const apiKeyAvailable = Boolean(import.meta.env.VITE_GOOGLE_MAPS_API_KEY);
+ const containerRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
- const mapContainerClasses = flush
+ // Only mount the WebGL map when the container enters the viewport.
+ // This prevents exhausting the browser's WebGL context limit (~8-16)
+ // when many LocationMap thumbnails are rendered in a long packet list.
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => setIsVisible(entry.isIntersecting),
+ { rootMargin: "200px" }
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []);
+
+ const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
+ const effectiveZoom = zoom ?? calculateZoomFromAccuracy(accuracyMeters);
+ const googleMapsUrl = getGoogleMapsUrl(latitude, longitude);
+ const showAccuracyCircle = precisionBits !== undefined;
+
+ const markerGeoJSON = useMemo((): FeatureCollection => ({
+ type: "FeatureCollection",
+ features: [
+ { type: "Feature", geometry: { type: "Point", coordinates: [longitude, latitude] }, properties: {} },
+ ],
+ }), [latitude, longitude]);
+
+ const circleGeoJSON = useMemo((): FeatureCollection => ({
+ type: "FeatureCollection",
+ features: showAccuracyCircle
+ ? [
+ {
+ type: "Feature",
+ geometry: { type: "Polygon", coordinates: [buildCircleCoords(longitude, latitude, accuracyMeters)] },
+ properties: {},
+ },
+ ]
+ : [],
+ }), [latitude, longitude, accuracyMeters, showAccuracyCircle]);
+
+ const containerClasses = flush
? `w-full h-full overflow-hidden relative ${className}`
: `${className} relative overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800/50`;
-
- if (!apiKeyAvailable) {
- return (
-
-
- Map display requires a Google Maps API key.
-
-
- Add VITE_GOOGLE_MAPS_API_KEY to your environment.
-
-
- {latitude.toFixed(6)}, {longitude.toFixed(6)}
-
-
- );
- }
-
+
return (
-
);
-};
\ No newline at end of file
+};
+
+/** @deprecated Use LocationMap */
+export const Map = LocationMap;
diff --git a/web/src/components/dashboard/GoogleMap.tsx b/web/src/components/dashboard/GoogleMap.tsx
index dc3318a..abbcaa9 100644
--- a/web/src/components/dashboard/GoogleMap.tsx
+++ b/web/src/components/dashboard/GoogleMap.tsx
@@ -1,8 +1,11 @@
-import React, { useRef, useEffect, useState, useCallback } from "react";
-import { calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy } from "../../lib/mapUtils";
-import { GOOGLE_MAPS_ID } from "../../lib/config";
+import React, { useMemo } from "react";
+import ReactMap, { Source, Layer } from "react-map-gl/maplibre";
+import type { FeatureCollection } from "geojson";
+import "maplibre-gl/dist/maplibre-gl.css";
+import { CARTO_DARK_STYLE } from "../../lib/mapStyle";
+import { buildCircleCoords, calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy } from "../../lib/mapUtils";
-interface GoogleMapProps {
+interface NodeLocationMapProps {
/** Latitude coordinate */
lat: number;
/** Longitude coordinate */
@@ -16,155 +19,76 @@ interface GoogleMapProps {
}
/**
- * Google Maps component that uses the API loaded via script tag
+ * Single-node location map with accuracy circle
*/
-export const GoogleMap: React.FC = ({
+export const NodeLocationMap: React.FC = ({
lat,
lng,
zoom,
precisionBits,
fullHeight = false,
}) => {
- const mapRef = useRef(null);
- const mapInstanceRef = useRef(null);
- const markerRef = useRef(null);
- const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
-
- // Calculate accuracy in meters based on precision bits
const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
+ const effectiveZoom = zoom ?? calculateZoomFromAccuracy(accuracyMeters);
+ const showCenterDot = precisionBits === undefined || accuracyMeters < 100;
- // If zoom is not provided, calculate based on accuracy
- const effectiveZoom = zoom || calculateZoomFromAccuracy(accuracyMeters);
+ const markerGeoJSON = useMemo((): FeatureCollection => ({
+ type: "FeatureCollection",
+ features: showCenterDot
+ ? [{ type: "Feature", geometry: { type: "Point", coordinates: [lng, lat] }, properties: {} }]
+ : [],
+ }), [lat, lng, showCenterDot]);
- // Track whether the map has been initialized
- const isInitializedRef = useRef(false);
+ const circleGeoJSON = useMemo((): FeatureCollection => ({
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Polygon", coordinates: [buildCircleCoords(lng, lat, accuracyMeters)] },
+ properties: {},
+ },
+ ],
+ }), [lat, lng, accuracyMeters]);
- const initializeMap = useCallback(() => {
- if (
- mapRef.current &&
- window.google &&
- window.google.maps &&
- !isInitializedRef.current
- ) {
- isInitializedRef.current = true;
- // Create map instance
- const mapOptions: google.maps.MapOptions = {
- center: { lat, lng },
- zoom: effectiveZoom,
- mapTypeId: google.maps.MapTypeId.HYBRID,
- mapTypeControl: false,
- streetViewControl: false,
- fullscreenControl: false,
- zoomControl: true,
- mapId: GOOGLE_MAPS_ID,
- };
-
- mapInstanceRef.current = new google.maps.Map(mapRef.current, mapOptions);
-
- // Only add the center marker if we don't have precision information or
- // it's very accurate.
- if (precisionBits === undefined || accuracyMeters < 100) {
- // Create a marker with a custom SVG circle to match the old style
- const markerContent = document.createElement('div');
- markerContent.innerHTML = `
-
-
-
- `;
-
- // Create the advanced marker element
- markerRef.current = new google.maps.marker.AdvancedMarkerElement({
- position: { lat, lng },
- map: mapInstanceRef.current,
- title: `Node Position`,
- content: markerContent,
- });
- }
-
- // Circle will always be shown, using default 300m accuracy if no
- // precision bits.
- new google.maps.Circle({
- strokeColor: "#22c55e",
- strokeOpacity: 0.8,
- strokeWeight: 2.5,
- fillColor: "#4ade80",
- fillOpacity: 0.4,
- map: mapInstanceRef.current,
- center: { lat, lng },
- radius: accuracyMeters,
- });
- }
- }, [lat, lng, effectiveZoom, accuracyMeters, precisionBits]);
-
- // Check for Google Maps API loading - make sure all required objects are available
- useEffect(() => {
- // Function to check if all required Google Maps components are loaded
- const checkGoogleMapsLoaded = () => {
- return window.google &&
- window.google.maps &&
- window.google.maps.Map &&
- window.google.maps.Circle &&
- window.google.maps.marker &&
- window.google.maps.marker.AdvancedMarkerElement;
- };
-
- // Check if Google Maps is already loaded with all required components
- if (checkGoogleMapsLoaded()) {
- setIsGoogleMapsLoaded(true);
- return;
- }
-
- // Set up a listener for when the API loads
- const handleGoogleMapsLoaded = () => {
- // Wait a bit to ensure all Maps objects are initialized
- setTimeout(() => {
- if (checkGoogleMapsLoaded()) {
- setIsGoogleMapsLoaded(true);
- }
- }, 100);
- };
-
- // Add event listener for Google Maps API loading
- window.addEventListener('google-maps-loaded', handleGoogleMapsLoaded);
-
- // Also try checking after a short delay (backup)
- const timeoutId = setTimeout(() => {
- if (checkGoogleMapsLoaded()) {
- setIsGoogleMapsLoaded(true);
- } else {
- console.warn("Google Maps API didn't fully load after timeout");
- }
- }, 2000);
-
- // Cleanup
- return () => {
- window.removeEventListener('google-maps-loaded', handleGoogleMapsLoaded);
- clearTimeout(timeoutId);
- };
- }, []);
-
- // Initialize map when Google Maps is loaded and props change
- useEffect(() => {
- if (isGoogleMapsLoaded && mapRef.current) {
- initializeMap();
- }
- }, [isGoogleMapsLoaded, initializeMap]);
-
- // Prepare the container classes based on fullHeight flag
- const containerClassName = `w-full ${fullHeight ? 'h-full flex-1' : 'min-h-[300px]'} rounded-lg overflow-hidden effect-inset`;
-
- if (!isGoogleMapsLoaded) {
- return (
-
- );
- }
+ const containerClassName = `w-full ${fullHeight ? "h-full flex-1" : "min-h-[300px]"} rounded-lg overflow-hidden effect-inset`;
return (
-
+
+
+
+
+
+
+
+
+
+
+
+
);
};
+
+/** @deprecated Use NodeLocationMap */
+export const GoogleMap = NodeLocationMap;
diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx
index d7e9860..33a8969 100644
--- a/web/src/components/dashboard/NetworkMap.tsx
+++ b/web/src/components/dashboard/NetworkMap.tsx
@@ -1,14 +1,16 @@
-import React, { useRef, useEffect, useState, useCallback } from "react";
+import React, { useRef, useCallback, useEffect, useState, useMemo } from "react";
+import ReactMap, { Source, Layer, Popup, MapRef } from "react-map-gl/maplibre";
+import type { FeatureCollection } from "geojson";
+import "maplibre-gl/dist/maplibre-gl.css";
+import { CARTO_DARK_STYLE_LABELLED } from "../../lib/mapStyle";
import { useAppSelector } from "../../hooks";
import { useNavigate } from "@tanstack/react-router";
import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice";
-import { LinkObservation } from "../../store/slices/topologySlice";
import { Position } from "../../lib/types";
import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity";
-import { GOOGLE_MAPS_ID } from "../../lib/config";
interface NetworkMapProps {
- /** Height of the map in CSS units (optional, will use flex-grow by default) */
+ /** Height of the map in CSS units (optional) */
height?: string;
/** Callback for when auto-zoom state changes */
onAutoZoomChange?: (enabled: boolean) => void;
@@ -18,494 +20,11 @@ interface NetworkMapProps {
showLinks?: boolean;
}
-/**
- * NetworkMap displays all nodes with position data on a Google Map
- */
-export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>(
- ({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => {
- const navigate = useNavigate();
- const mapRef = useRef(null);
- const mapInstanceRef = useRef(null);
- const markersRef = useRef>({});
- const infoWindowRef = useRef(null);
- const boundsRef = useRef(null);
- const [nodesWithPosition, setNodesWithPosition] = useState([]);
- const animatingNodesRef = useRef>({});
- const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
- // Using any for the event listener since TypeScript can't find the MapsEventListener interface
- const zoomListenerRef = useRef(null);
- const polylinesRef = useRef>({});
- const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
-
- // Get nodes data from the store
- const { nodes, gateways } = useAppSelector((state) => state.aggregator);
- const topologyLinks = useAppSelector((state) => state.topology.links);
-
- // Expose the resetAutoZoom function via ref
- React.useImperativeHandle(ref, () => ({
- resetAutoZoom: () => {
- resetAutoZoom();
- }
- }));
-
- // Function to fit map to bounds
- const fitMapToBounds = useCallback(() => {
- if (!mapInstanceRef.current || !window.google || !window.google.maps) return;
-
- boundsRef.current = new google.maps.LatLngBounds();
-
- nodesWithPosition.forEach(node => {
- const lat = node.position.latitudeI / 10000000;
- const lng = node.position.longitudeI / 10000000;
- boundsRef.current?.extend({ lat, lng });
- });
-
- if (boundsRef.current) {
- mapInstanceRef.current.fitBounds(boundsRef.current);
-
- if (nodesWithPosition.length === 1) {
- setTimeout(() => {
- if (mapInstanceRef.current) {
- const currentZoom = mapInstanceRef.current.getZoom() || 15;
- mapInstanceRef.current.setZoom(Math.min(currentZoom, 15));
- }
- }, 100);
- }
- }
- }, [nodesWithPosition]);
-
- // Reset auto-zoom behavior
- const resetAutoZoom = useCallback(() => {
- setAutoZoomEnabled(true);
-
- if (onAutoZoomChange) {
- onAutoZoomChange(true);
- }
-
- if (mapInstanceRef.current && nodesWithPosition.length > 0) {
- fitMapToBounds();
- }
- }, [nodesWithPosition, onAutoZoomChange, fitMapToBounds]);
-
- // Setup zoom change listener
- const setupZoomListener = useCallback(() => {
- if (!mapInstanceRef.current || !window.google || !window.google.maps) {
- console.warn("Cannot set up zoom listener - map or Google Maps API not ready");
- return;
- }
-
- try {
- // Remove previous listener if it exists
- if (zoomListenerRef.current) {
- // Use google.maps.event.removeListener for better compatibility
- window.google.maps.event.removeListener(zoomListenerRef.current);
- zoomListenerRef.current = null;
- }
-
- zoomListenerRef.current = window.google.maps.event.addListener(
- mapInstanceRef.current,
- 'zoom_changed',
- () => {
- console.log("Zoom changed detected");
- // Disable auto-zoom when user manually zooms
- setAutoZoomEnabled(false);
-
- // Notify parent component of auto-zoom state change
- if (onAutoZoomChange) {
- onAutoZoomChange(false);
- }
- }
- );
-
- } catch (error) {
- console.error("Error setting up zoom listener:", error);
- }
- }, [onAutoZoomChange]);
-
- // Effect to build the list of nodes with position data
- useEffect(() => {
- const nodeArray = getNodesWithPosition(nodes, gateways);
- setNodesWithPosition(nodeArray);
- }, [nodes, gateways]);
-
- // Show info window for a node
- const showInfoWindow = useCallback((
- node: MapNode,
- marker: google.maps.marker.AdvancedMarkerElement
- ): void => {
- if (!infoWindowRef.current || !mapInstanceRef.current) return;
-
- const nodeName = node.longName || node.shortName || `!${node.id.toString(16)}`;
- const secondsAgo = node.lastHeard ? Math.floor(Date.now() / 1000) - node.lastHeard : 0;
- const lastSeenText = formatLastSeen(secondsAgo);
- const activityLevel = getActivityLevel(node.lastHeard, node.isGateway);
- const colors = getNodeColors(activityLevel, node.isGateway);
- const statusText = getStatusText(activityLevel);
- const statusDotColor = colors.fill;
-
- const container = document.createElement('div');
- container.style.cssText = 'font-family: sans-serif; max-width: 240px; color: #999999;';
-
- const heading = document.createElement('h3');
- heading.style.cssText = `margin: 0 0 8px; font-size: 16px; color: ${statusDotColor}; font-weight: 600;`;
- heading.textContent = nodeName;
- container.appendChild(heading);
-
- const subtitle = document.createElement('div');
- subtitle.style.cssText = 'font-size: 12px; color: #555; margin-bottom: 8px; font-weight: 500;';
- subtitle.textContent = `${node.isGateway ? 'Gateway' : 'Node'} · !${node.id.toString(16)}`;
- container.appendChild(subtitle);
-
- const statusRow = document.createElement('div');
- statusRow.style.cssText = 'font-size: 12px; margin-bottom: 4px; color: #333; display: flex; align-items: center;';
- const dot = document.createElement('span');
- dot.style.cssText = `display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: ${statusDotColor}; margin-right: 6px;`;
- const statusLabel = document.createElement('span');
- statusLabel.textContent = `${statusText} - Last seen: ${lastSeenText}`;
- statusRow.appendChild(dot);
- statusRow.appendChild(statusLabel);
- container.appendChild(statusRow);
-
- const counts = document.createElement('div');
- counts.style.cssText = 'font-size: 12px; margin-bottom: 8px; color: #333;';
- counts.textContent = `Packets: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0}`;
- container.appendChild(counts);
-
- const link = document.createElement('a');
- link.href = `/node/${node.id.toString(16)}`;
- link.style.cssText = 'font-size: 13px; color: #3b82f6; text-decoration: none; font-weight: 500; display: inline-block; padding: 4px 8px; background-color: #f1f5f9; border-radius: 4px;';
- link.textContent = 'View details →';
- container.appendChild(link);
-
- infoWindowRef.current.setContent(container);
- infoWindowRef.current.open(mapInstanceRef.current, marker);
- }, []);
-
- // Update an existing marker
- const updateMarker = useCallback((node: MapNode, position: google.maps.LatLngLiteral): void => {
- const key = `node-${node.id}`;
- const marker = markersRef.current[key];
- marker.position = position;
- marker.content = buildMarkerContent(node);
- }, []);
-
- // Create a new marker
- const createMarker = useCallback((
- node: MapNode,
- position: google.maps.LatLngLiteral,
- nodeName: string
- ): void => {
- if (!mapInstanceRef.current || !infoWindowRef.current) return;
-
- const key = `node-${node.id}`;
- const marker = new google.maps.marker.AdvancedMarkerElement({
- position,
- map: mapInstanceRef.current,
- title: nodeName,
- zIndex: node.isGateway ? 10 : 5,
- content: buildMarkerContent(node),
- });
-
- marker.addListener('gmp-click', () => {
- showInfoWindow(node, marker);
- });
-
- markersRef.current[key] = marker;
- }, [showInfoWindow]);
-
- // Helper function to initialize the map
- const initializeMap = useCallback((element: HTMLDivElement): void => {
- const mapOptions: google.maps.MapOptions = {
- zoom: 10,
- colorScheme: 'DARK',
- mapTypeControl: false,
- streetViewControl: false,
- fullscreenControl: false,
- zoomControl: true,
- mapId: GOOGLE_MAPS_ID,
- };
- mapInstanceRef.current = new google.maps.Map(element, mapOptions);
- infoWindowRef.current = new google.maps.InfoWindow();
- }, []);
-
- // Helper function to update node markers on the map
- const updateNodeMarkers = useCallback((nodes: MapNode[]): void => {
- if (!mapInstanceRef.current) return;
-
- if (window.google && window.google.maps) {
- boundsRef.current = new google.maps.LatLngBounds();
- } else {
- boundsRef.current = null;
- }
- const allKeys = new Set();
-
- nodes.forEach(node => {
- const key = `node-${node.id}`;
- allKeys.add(key);
-
- const lat = node.position.latitudeI / 10000000;
- const lng = node.position.longitudeI / 10000000;
- const position = { lat, lng };
-
- if (boundsRef.current) {
- boundsRef.current.extend(position);
- }
-
- const nodeName = node.shortName || node.longName ||
- `${node.isGateway ? 'Gateway' : 'Node'} ${node.id.toString(16)}`;
-
- if (!markersRef.current[key]) {
- createMarker(node, position, nodeName);
- } else {
- updateMarker(node, position);
- }
- });
-
- Object.keys(markersRef.current).forEach(key => {
- if (!allKeys.has(key)) {
- markersRef.current[key].map = null;
- delete markersRef.current[key];
- }
- });
-
- if (autoZoomEnabled && nodes.length > 0) {
- fitMapToBounds();
- }
- }, [autoZoomEnabled, fitMapToBounds, createMarker, updateMarker]);
-
- // Update topology polylines on the map
- const updateLinks = useCallback((
- links: Record,
- nodePositions: MapNode[],
- visible: boolean
- ): void => {
- if (!mapInstanceRef.current || !window.google?.maps) return;
-
- // Build position lookup
- const posMap = new Map();
- for (const node of nodePositions) {
- posMap.set(node.id, {
- lat: node.position.latitudeI / 10000000,
- lng: node.position.longitudeI / 10000000,
- });
- }
-
- const activeKeys = new Set();
-
- for (const link of Object.values(links)) {
- const posA = posMap.get(link.nodeA);
- const posB = posMap.get(link.nodeB);
- if (!posA || !posB) continue;
-
- activeKeys.add(link.key);
-
- // Determine color based on best available SNR
- const snr = link.snrAtoB ?? link.snrBtoA;
- let strokeColor: string;
- if (snr === undefined) {
- strokeColor = "#6b7280"; // gray — no SNR data
- } else if (snr >= 5) {
- strokeColor = "#22c55e"; // green — strong
- } else if (snr >= 0) {
- strokeColor = "#eab308"; // yellow — marginal
- } else {
- strokeColor = "#ef4444"; // red — weak
- }
- const strokeOpacity = link.viaMqtt ? 0.4 : 0.7;
-
- if (polylinesRef.current[link.key]) {
- const pl = polylinesRef.current[link.key];
- pl.setPath([posA, posB]);
- pl.setOptions({ strokeColor, strokeOpacity, visible });
- } else {
- polylinesRef.current[link.key] = new google.maps.Polyline({
- path: [posA, posB],
- geodesic: true,
- strokeColor,
- strokeOpacity,
- strokeWeight: 2,
- map: visible ? mapInstanceRef.current : null,
- });
- }
- }
-
- // Remove polylines for edges no longer in state
- for (const key of Object.keys(polylinesRef.current)) {
- if (!activeKeys.has(key)) {
- polylinesRef.current[key].setMap(null);
- delete polylinesRef.current[key];
- }
- }
- }, []);
-
- // Check for Google Maps API and initialize
- const tryInitializeMap = useCallback(() => {
- if (mapRef.current && window.google && window.google.maps) {
- try {
- // Initialize map if not already done
- if (!mapInstanceRef.current) {
- initializeMap(mapRef.current);
- }
-
- // Create info window if not already done
- if (!infoWindowRef.current) {
- infoWindowRef.current = new google.maps.InfoWindow();
- }
-
- // Update markers and fit the map
- updateNodeMarkers(nodesWithPosition);
- updateLinks(topologyLinks, nodesWithPosition, showLinks);
- return true;
- } catch (error) {
- console.error("Error initializing map:", error);
- return false;
- }
- }
- console.warn("Cannot initialize map - prerequisites not met");
- return false;
- }, [nodesWithPosition, topologyLinks, showLinks, updateNodeMarkers, updateLinks, initializeMap]);
-
- // Check for Google Maps API loading - make sure all required objects are available
- useEffect(() => {
- // Function to check if all required Google Maps components are loaded
- const checkGoogleMapsLoaded = () => {
- return window.google &&
- window.google.maps &&
- window.google.maps.Map &&
- window.google.maps.InfoWindow &&
- window.google.maps.marker &&
- window.google.maps.marker.AdvancedMarkerElement;
- };
-
- // Check if Google Maps is already loaded with all required components
- if (checkGoogleMapsLoaded()) {
- setIsGoogleMapsLoaded(true);
- return;
- }
-
- // Set up a listener for when the API loads
- const handleGoogleMapsLoaded = () => {
- // Wait a bit to ensure all Maps objects are initialized
- setTimeout(() => {
- if (checkGoogleMapsLoaded()) {
- setIsGoogleMapsLoaded(true);
- }
- }, 100);
- };
-
- // Add event listener for Google Maps API loading
- window.addEventListener('google-maps-loaded', handleGoogleMapsLoaded);
-
- // Also try checking after a short delay (backup)
- const timeoutId = setTimeout(() => {
- if (checkGoogleMapsLoaded()) {
- setIsGoogleMapsLoaded(true);
- } else {
- console.warn("Google Maps API didn't fully load after timeout");
- }
- }, 2000);
-
- // Cleanup
- return () => {
- window.removeEventListener('google-maps-loaded', handleGoogleMapsLoaded);
- clearTimeout(timeoutId);
- };
- }, []);
-
- // Don't try to initialize map until we're sure Google Maps is fully loaded
- useEffect(() => {
- if (isGoogleMapsLoaded &&
- mapRef.current &&
- window.google?.maps?.Map &&
- window.google?.maps?.InfoWindow &&
- window.google?.maps?.marker?.AdvancedMarkerElement) {
- const initialized = tryInitializeMap();
-
- // If we successfully initialized the map, also set up the zoom listener
- if (initialized && mapInstanceRef.current) {
- setupZoomListener();
- }
- }
- }, [isGoogleMapsLoaded, nodesWithPosition, navigate, tryInitializeMap, setupZoomListener]);
-
- // Also set up zoom listener whenever the map instance changes
- useEffect(() => {
- if (mapInstanceRef.current && window.google && window.google.maps && isGoogleMapsLoaded) {
- setupZoomListener();
- }
- }, [setupZoomListener, isGoogleMapsLoaded]);
-
- // Update parent component when auto-zoom state changes
- useEffect(() => {
- if (onAutoZoomChange) {
- onAutoZoomChange(autoZoomEnabled);
- }
- }, [autoZoomEnabled, onAutoZoomChange]);
-
-
- // Cleanup on unmount
- useEffect(() => {
- const zoomListener = zoomListenerRef;
- const markers = markersRef;
- const animatingNodes = animatingNodesRef;
- const infoWindow = infoWindowRef;
- const polylines = polylinesRef;
- return () => {
- if (zoomListener.current && window.google && window.google.maps) {
- window.google.maps.event.removeListener(zoomListener.current);
- zoomListener.current = null;
- }
- Object.values(markers.current).forEach(marker => marker.map = null);
- Object.values(animatingNodes.current).forEach(timeoutId =>
- window.clearTimeout(timeoutId)
- );
- Object.values(polylines.current).forEach(pl => pl.setMap(null));
- if (infoWindow.current) {
- infoWindow.current.close();
- }
- };
- }, []);
-
- const mapContainerStyle = {
- ...(height && !fullHeight ? { height } : {}),
- ...(fullHeight ? { height: '100%' } : {})
- };
-
- const wrapperClassName = `w-full ${fullHeight ? 'h-full flex flex-col' : ''}`;
- const mapClassName = `w-full overflow-hidden effect-inset rounded-lg relative ${fullHeight ? 'flex-1' : ''}`;
-
- if (!isGoogleMapsLoaded) {
- return (
-
- );
- }
-
- return (
-
- );
-});
-
-NetworkMap.displayName = "NetworkMap";
-
-// Define interface for nodes with position data for map display
interface MapNode {
id: number;
position: Position & {
- latitudeI: number; // Override to make required
- longitudeI: number; // Override to make required
+ latitudeI: number;
+ longitudeI: number;
};
isGateway: boolean;
gatewayId?: string;
@@ -516,135 +35,367 @@ interface MapNode {
textMessageCount: number;
}
-// Helper function to determine if a node has valid position data
-function hasValidPosition(node: NodeData): boolean {
- return Boolean(
- node.position &&
- node.position.latitudeI !== undefined &&
- node.position.longitudeI !== undefined
+/**
+ * NetworkMap displays all nodes with position data on a MapLibre GL map
+ */
+export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>(
+ ({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => {
+ const navigate = useNavigate();
+ const mapRef = useRef(null);
+ const [mapLoaded, setMapLoaded] = useState(false);
+ const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
+ const [selectedNode, setSelectedNode] = useState(null);
+
+ const { nodes, gateways } = useAppSelector((state) => state.aggregator);
+ const topologyLinks = useAppSelector((state) => state.topology.links);
+
+ const nodesWithPosition = useMemo(
+ () => getNodesWithPosition(nodes, gateways),
+ [nodes, gateways]
+ );
+
+ // Build GeoJSON for node circles
+ const nodesGeoJSON = useMemo((): FeatureCollection => ({
+ type: "FeatureCollection",
+ features: nodesWithPosition.map((node) => {
+ const level = getActivityLevel(node.lastHeard, node.isGateway);
+ const colors = getNodeColors(level, node.isGateway);
+ return {
+ type: "Feature",
+ id: node.id,
+ geometry: {
+ type: "Point",
+ coordinates: [
+ node.position.longitudeI / 10000000,
+ node.position.latitudeI / 10000000,
+ ],
+ },
+ properties: {
+ nodeId: node.id,
+ name: node.shortName || node.longName || `!${node.id.toString(16)}`,
+ fillColor: colors.fill,
+ strokeColor: colors.stroke,
+ radius: node.isGateway ? 12 : 8,
+ },
+ };
+ }),
+ }), [nodesWithPosition]);
+
+ // Build GeoJSON for topology links
+ const linksGeoJSON = useMemo((): FeatureCollection => {
+ const posMap = new Map();
+ for (const node of nodesWithPosition) {
+ posMap.set(node.id, [
+ node.position.longitudeI / 10000000,
+ node.position.latitudeI / 10000000,
+ ]);
+ }
+ return {
+ type: "FeatureCollection",
+ features: Object.values(topologyLinks)
+ .filter((link) => posMap.has(link.nodeA) && posMap.has(link.nodeB))
+ .map((link) => {
+ const snr = link.snrAtoB ?? link.snrBtoA;
+ const color =
+ snr === undefined ? "#6b7280"
+ : snr >= 5 ? "#22c55e"
+ : snr >= 0 ? "#eab308"
+ : "#ef4444";
+ return {
+ type: "Feature" as const,
+ geometry: {
+ type: "LineString" as const,
+ coordinates: [posMap.get(link.nodeA)!, posMap.get(link.nodeB)!],
+ },
+ properties: { color, opacity: link.viaMqtt ? 0.4 : 0.7 },
+ };
+ }),
+ };
+ }, [topologyLinks, nodesWithPosition]);
+
+ // Fit map bounds when auto-zoom is enabled and nodes change
+ useEffect(() => {
+ if (!autoZoomEnabled || nodesWithPosition.length === 0 || !mapRef.current || !mapLoaded) return;
+ let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
+ for (const n of nodesWithPosition) {
+ const lng = n.position.longitudeI / 10000000;
+ const lat = n.position.latitudeI / 10000000;
+ if (lng < minLng) minLng = lng;
+ if (lng > maxLng) maxLng = lng;
+ if (lat < minLat) minLat = lat;
+ if (lat > maxLat) maxLat = lat;
+ }
+ mapRef.current.fitBounds(
+ [[minLng, minLat], [maxLng, maxLat]],
+ { padding: 60, maxZoom: 15, duration: 500 }
+ );
+ }, [autoZoomEnabled, nodesWithPosition, mapLoaded]);
+
+ // Notify parent of auto-zoom state
+ useEffect(() => {
+ onAutoZoomChange?.(autoZoomEnabled);
+ }, [autoZoomEnabled, onAutoZoomChange]);
+
+ // Expose resetAutoZoom via ref
+ React.useImperativeHandle(ref, () => ({
+ resetAutoZoom: () => setAutoZoomEnabled(true),
+ }));
+
+ // Disable auto-zoom on user interaction
+ const handleUserInteraction = useCallback(() => {
+ setAutoZoomEnabled(false);
+ }, []);
+
+ // Handle node click via interactiveLayerIds
+ const handleMapClick = useCallback(
+ (e: { features?: Array<{ properties: Record }> }) => {
+ const features = e.features;
+ if (!features || features.length === 0) {
+ setSelectedNode(null);
+ return;
+ }
+ const nodeId = features[0].properties?.nodeId as number | undefined;
+ if (nodeId === undefined) return;
+ const node = nodesWithPosition.find((n) => n.id === nodeId);
+ if (node) setSelectedNode(node);
+ },
+ [nodesWithPosition]
+ );
+
+ const wrapperClassName = `w-full ${fullHeight ? "h-full flex flex-col" : ""}`;
+ const mapClassName = `w-full overflow-hidden effect-inset rounded-lg relative ${fullHeight ? "flex-1" : ""}`;
+ const containerStyle = height && !fullHeight ? { height } : fullHeight ? { height: "100%" } : {};
+
+ return (
+
+
+
{
+ if (mapRef.current) mapRef.current.getMap().getCanvas().style.cursor = "pointer";
+ }}
+ onMouseLeave={() => {
+ if (mapRef.current) mapRef.current.getMap().getCanvas().style.cursor = "grab";
+ }}
+ onClick={handleMapClick as never}
+ onDragStart={handleUserInteraction}
+ onZoomStart={handleUserInteraction}
+ onLoad={() => setMapLoaded(true)}
+ >
+ {/* Topology links — always mounted, visibility controlled via layout property */}
+
+
+
+
+ {/* Node circles */}
+
+
+
+
+
+ {/* Node popup */}
+ {selectedNode && (
+ setSelectedNode(null)}
+ closeOnClick={false}
+ maxWidth="240px"
+ anchor="bottom"
+ >
+ {
+ setSelectedNode(null);
+ navigate({ to: "/node/$nodeId", params: { nodeId: id.toString(16) } });
+ }}
+ />
+
+ )}
+
+
+
+ );
+ }
+);
+
+NetworkMap.displayName = "NetworkMap";
+
+// ─── Popup content ────────────────────────────────────────────────────────────
+
+function NodePopup({
+ node,
+ onNavigate,
+}: {
+ node: MapNode;
+ onNavigate: (id: number) => void;
+}) {
+ const level = getActivityLevel(node.lastHeard, node.isGateway);
+ const colors = getNodeColors(level, node.isGateway);
+ const statusText = getStatusText(level);
+ const secondsAgo = node.lastHeard ? Math.floor(Date.now() / 1000) - node.lastHeard : 0;
+ const lastSeenText = formatLastSeen(secondsAgo);
+ const nodeName = node.longName || node.shortName || `!${node.id.toString(16)}`;
+
+ return (
+
+
+ {nodeName}
+
+
+ {node.isGateway ? "Gateway" : "Node"} · !{node.id.toString(16)}
+
+
+
+
+ {statusText} · {lastSeenText}
+
+
+
+ Packets: {node.messageCount} · Text: {node.textMessageCount}
+
+
onNavigate(node.id)}
+ style={{
+ fontSize: 12,
+ fontWeight: 500,
+ color: "#3b82f6",
+ background: "#f1f5f9",
+ border: "none",
+ borderRadius: 4,
+ padding: "4px 8px",
+ cursor: "pointer",
+ }}
+ >
+ View details →
+
+
+ );
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function hasValidPosition(node: NodeData): boolean {
+ return Boolean(
+ node.position &&
+ node.position.latitudeI !== undefined &&
+ node.position.longitudeI !== undefined
);
}
-// Get a list of nodes that have position data
function getNodesWithPosition(
- nodes: Record,
+ nodes: Record,
gateways: Record
): MapNode[] {
- const nodesMap = new Map(); // Use a Map to avoid duplicates
-
- // Regular nodes
+ const nodesMap = new Map();
+
Object.entries(nodes).forEach(([nodeIdStr, nodeData]) => {
if (hasValidPosition(nodeData)) {
const nodeId = parseInt(nodeIdStr);
- const position = nodeData.position as MapNode['position'];
+ const position = nodeData.position as MapNode["position"];
nodesMap.set(nodeId, {
...nodeData,
id: nodeId,
isGateway: !!nodeData.isGateway,
position,
messageCount: nodeData.messageCount || 0,
- textMessageCount: nodeData.textMessageCount || 0
+ textMessageCount: nodeData.textMessageCount || 0,
});
}
});
- // Gateways - we need to find the corresponding node for each gateway
Object.entries(gateways).forEach(([gatewayId, gatewayData]) => {
- // Extract node ID from gateway ID (removing the '!' prefix)
const nodeId = parseInt(gatewayId.substring(1), 16);
-
- // First priority: Check if we already have the node with a mapReport
- // (since mapReport is stored on NodeData, not GatewayData)
const nodeWithMapReport = nodes[nodeId];
-
+
if (
- nodeWithMapReport?.mapReport &&
- nodeWithMapReport.mapReport.latitudeI !== undefined &&
+ nodeWithMapReport?.mapReport &&
+ nodeWithMapReport.mapReport.latitudeI !== undefined &&
nodeWithMapReport.mapReport.longitudeI !== undefined
) {
- // Use mapReport position from the node data if we haven't already added this node
if (!nodesMap.has(nodeId)) {
nodesMap.set(nodeId, {
id: nodeId,
isGateway: true,
- gatewayId: gatewayId,
+ gatewayId,
position: {
latitudeI: nodeWithMapReport.mapReport.latitudeI!,
longitudeI: nodeWithMapReport.mapReport.longitudeI!,
precisionBits: nodeWithMapReport.mapReport.positionPrecision,
- time: nodeWithMapReport.lastHeard || Math.floor(Date.now() / 1000)
+ time: nodeWithMapReport.lastHeard || Math.floor(Date.now() / 1000),
},
- // Include other data
lastHeard: nodeWithMapReport.lastHeard,
messageCount: nodeWithMapReport.messageCount || gatewayData.messageCount || 0,
textMessageCount: nodeWithMapReport.textMessageCount || gatewayData.textMessageCount || 0,
shortName: nodeWithMapReport.shortName,
- longName: nodeWithMapReport.longName
+ longName: nodeWithMapReport.longName,
});
}
- }
- // Second priority: Mark existing node as gateway if it already has position data
- else if (nodesMap.has(nodeId)) {
+ } else if (nodesMap.has(nodeId)) {
const existingNode = nodesMap.get(nodeId)!;
nodesMap.set(nodeId, {
...existingNode,
isGateway: true,
- gatewayId: gatewayId,
- // Update data from gateway information
+ gatewayId,
lastHeard: Math.max(existingNode.lastHeard || 0, gatewayData.lastHeard || 0),
messageCount: existingNode.messageCount || gatewayData.messageCount || 0,
- textMessageCount: existingNode.textMessageCount || gatewayData.textMessageCount || 0
+ textMessageCount: existingNode.textMessageCount || gatewayData.textMessageCount || 0,
});
}
});
return Array.from(nodesMap.values());
}
-
-// Interface for marker icon configuration
-interface MarkerIconConfig {
- path: number;
- scale: number;
- fillColor: string;
- fillOpacity: number;
- strokeColor: string;
- strokeWeight: number;
-}
-
-// Build marker content element for a node (pure, no React state)
-function buildMarkerContent(node: MapNode): HTMLElement {
- const iconStyle = getMarkerIcon(node);
- const size = iconStyle.scale * 2;
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.setAttribute('width', String(size));
- svg.setAttribute('height', String(size));
- svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
- const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- circle.setAttribute('cx', String(iconStyle.scale));
- circle.setAttribute('cy', String(iconStyle.scale));
- circle.setAttribute('r', String(iconStyle.scale - iconStyle.strokeWeight));
- circle.setAttribute('fill', iconStyle.fillColor);
- circle.setAttribute('fill-opacity', String(iconStyle.fillOpacity));
- circle.setAttribute('stroke', iconStyle.strokeColor);
- circle.setAttribute('stroke-width', String(iconStyle.strokeWeight));
- svg.appendChild(circle);
- const wrapper = document.createElement('div');
- wrapper.style.cursor = 'pointer';
- wrapper.appendChild(svg);
- return wrapper;
-}
-
-// Get marker icon for a node
-function getMarkerIcon(node: MapNode): MarkerIconConfig {
- const activityLevel = getActivityLevel(node.lastHeard, node.isGateway);
- const colors = getNodeColors(activityLevel, node.isGateway);
-
- return {
- path: google.maps.SymbolPath.CIRCLE,
- scale: 12,
- fillColor: colors.fill,
- fillOpacity: 1,
- strokeColor: colors.stroke,
- strokeWeight: 2,
- };
-}
diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx
index 9bbff27..6003cb6 100644
--- a/web/src/components/dashboard/NodeDetail.tsx
+++ b/web/src/components/dashboard/NodeDetail.tsx
@@ -37,7 +37,7 @@ import { Separator } from "../Separator";
import { KeyValuePair } from "../ui/KeyValuePair";
import { Section } from "../ui/Section";
import { BatteryLevel } from "./BatteryLevel";
-import { GoogleMap } from "./GoogleMap";
+import { NodeLocationMap } from "./GoogleMap";
import { NodePositionData } from "./NodePositionData";
import { EnvironmentMetrics } from "./EnvironmentMetrics";
import { NodePacketList } from "./NodePacketList";
@@ -585,7 +585,7 @@ export const NodeDetail: React.FC = ({ nodeId }) => {
className="mt-6"
>
-
= ({ packet }) => {
Gateway Location
-
= ({ packet }) => {
{latitude !== undefined && longitude !== undefined && (
-
diff --git a/web/src/components/packets/WaypointPacket.tsx b/web/src/components/packets/WaypointPacket.tsx
index 816f21a..7229d1c 100644
--- a/web/src/components/packets/WaypointPacket.tsx
+++ b/web/src/components/packets/WaypointPacket.tsx
@@ -3,7 +3,7 @@ import { Packet } from "../../lib/types";
import { MapPin } from "lucide-react";
import { PacketCard } from "./PacketCard";
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
-import { Map } from "../Map";
+import { LocationMap } from "../Map";
interface WaypointPacketProps {
packet: Packet;
@@ -80,12 +80,10 @@ export const WaypointPacket: React.FC = ({ packet }) => {
{latitude !== undefined && longitude !== undefined && (
-
diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts
index c9c1863..a645d9d 100644
--- a/web/src/lib/config.ts
+++ b/web/src/lib/config.ts
@@ -13,8 +13,6 @@ export const SITE_DESCRIPTION =
import.meta.env.VITE_SITE_DESCRIPTION ||
"Realtime Meshtastic activity via MQTT.";
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
-export const GOOGLE_MAPS_ID = import.meta.env.VITE_GOOGLE_MAPS_ID || "demo-map-id";
-export const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
// API endpoints
export const API_ENDPOINTS = {
diff --git a/web/src/lib/mapStyle.ts b/web/src/lib/mapStyle.ts
new file mode 100644
index 0000000..dd8ba93
--- /dev/null
+++ b/web/src/lib/mapStyle.ts
@@ -0,0 +1,31 @@
+import type { StyleSpecification } from "maplibre-gl";
+
+const CARTO_TILES = [
+ "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
+ "https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
+ "https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
+];
+
+const CARTO_SOURCE = {
+ type: "raster" as const,
+ tiles: CARTO_TILES,
+ tileSize: 256,
+ attribution:
+ '© CARTO © OpenStreetMap contributors ',
+ maxzoom: 19,
+};
+
+/** Base CartoDB Dark Matter style — raster tiles, no labels */
+export const CARTO_DARK_STYLE: StyleSpecification = {
+ version: 8,
+ sources: { carto: CARTO_SOURCE },
+ layers: [{ id: "carto-dark", type: "raster", source: "carto" }],
+};
+
+/** CartoDB Dark Matter style with glyph support for GL text labels */
+export const CARTO_DARK_STYLE_LABELLED: StyleSpecification = {
+ version: 8,
+ glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
+ sources: { carto: CARTO_SOURCE },
+ layers: [{ id: "carto-dark", type: "raster", source: "carto" }],
+};
diff --git a/web/src/lib/mapUtils.ts b/web/src/lib/mapUtils.ts
index 71f5f1b..5c46499 100644
--- a/web/src/lib/mapUtils.ts
+++ b/web/src/lib/mapUtils.ts
@@ -45,63 +45,33 @@ export const calculateZoomFromAccuracy = (accuracyMeters: number): number => {
return 10;
};
+
/**
- * Generate a Google Maps Static API URL for a given latitude and longitude
- * @param latitude The latitude in decimal degrees
- * @param longitude The longitude in decimal degrees
- * @param zoom The zoom level (1-20)
- * @param width The image width in pixels
- * @param height The image height in pixels
- * @param nightMode Whether to use dark styling for the map
- * @param precisionBits Optional precision bits to determine how to display the marker
- * @returns A URL string for the Google Maps Static API
+ * Approximate a geographic circle as a GeoJSON polygon ring.
+ * @param lng Center longitude
+ * @param lat Center latitude
+ * @param radiusMeters Radius in meters
+ * @param points Number of polygon vertices (more = smoother)
*/
-export const getStaticMapUrl = (
- latitude: number,
- longitude: number,
- zoom: number = 15,
- width: number = 300,
- height: number = 200,
- nightMode: boolean = true,
- precisionBits?: number
-): string => {
- // Get API key from environment variable
- const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
-
- // Build the URL
- const mapUrl = new URL("https://maps.googleapis.com/maps/api/staticmap");
-
- // Add parameters
- mapUrl.searchParams.append("center", `${latitude},${longitude}`);
- mapUrl.searchParams.append("zoom", zoom.toString());
- mapUrl.searchParams.append("size", `${width}x${height}`);
- mapUrl.searchParams.append("key", apiKey);
- mapUrl.searchParams.append("format", "png");
- mapUrl.searchParams.append("scale", "2"); // Retina display support
-
- // Only add marker if we don't have precision information
- if (precisionBits === undefined) {
- mapUrl.searchParams.append(
- "markers",
- `color:green|${latitude},${longitude}`
- );
+export function buildCircleCoords(
+ lng: number,
+ lat: number,
+ radiusMeters: number,
+ points = 64
+): [number, number][] {
+ const earthRadius = 6371000;
+ const coords: [number, number][] = [];
+ for (let i = 0; i <= points; i++) {
+ const angle = (i / points) * 2 * Math.PI;
+ const dx = radiusMeters * Math.cos(angle);
+ const dy = radiusMeters * Math.sin(angle);
+ const pLat = lat + (dy / earthRadius) * (180 / Math.PI);
+ const pLng = lng + (dx / (earthRadius * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI);
+ coords.push([pLng, pLat]);
}
- // With static maps we can't draw circles directly, so we use a marker with different color
- // even when we have precision information, but we'll show it differently in the interactive map
- else {
- mapUrl.searchParams.append(
- "markers",
- `color:green|${latitude},${longitude}`
- );
- }
-
- // Apply night mode styling using the simpler approach
- if (nightMode) {
- mapUrl.searchParams.append("style", "invert_lightness:true");
- }
-
- return mapUrl.toString();
-};
+ coords.push(coords[0]); // close the ring
+ return coords;
+}
/**
* Create a Google Maps URL to open the location in Google Maps
diff --git a/web/src/types/google-maps.d.ts b/web/src/types/google-maps.d.ts
deleted file mode 100644
index a86fc28..0000000
--- a/web/src/types/google-maps.d.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-// Type definitions for Google Maps JavaScript API
-declare namespace google {
- namespace maps {
- class Map {
- constructor(
- mapDiv: Element,
- opts?: MapOptions
- );
- setZoom(zoom: number): void;
- getZoom(): number | undefined;
- fitBounds(bounds: LatLngBounds): void;
- addListener(event: string, handler: () => void): MapsEventListener;
- }
-
- class Marker {
- constructor(opts?: MarkerOptions);
- setMap(map: Map | null): void;
- setPosition(position: LatLngLiteral): void;
- setIcon(icon: any): void;
- addListener(event: string, handler: () => void): MapsEventListener;
- }
-
- namespace marker {
- class AdvancedMarkerElement {
- constructor(opts?: AdvancedMarkerElementOptions);
- position: LatLngLiteral | null;
- map: Map | null;
- title: string | null;
- zIndex: number | null;
- content: HTMLElement | null;
- addListener(event: string, handler: () => void): MapsEventListener;
- }
- }
-
- interface AdvancedMarkerElementOptions {
- position?: LatLngLiteral;
- map?: Map;
- title?: string;
- zIndex?: number;
- content?: HTMLElement;
- }
-
- class Circle {
- constructor(opts?: CircleOptions);
- setMap(map: Map | null): void;
- }
-
- class InfoWindow {
- constructor(opts?: InfoWindowOptions);
- setContent(content: string | HTMLElement): void;
- open(map?: Map, anchor?: any): void;
- close(): void;
- }
-
- class Polyline {
- constructor(opts?: PolylineOptions);
- setMap(map: Map | null): void;
- setPath(path: LatLngLiteral[]): void;
- setOptions(opts: PolylineOptions): void;
- setVisible(visible: boolean): void;
- }
-
- class LatLngBounds {
- constructor();
- extend(point: LatLngLiteral): void;
- }
-
- interface LatLngLiteral {
- lat: number;
- lng: number;
- }
-
- interface MapOptions {
- center?: LatLngLiteral;
- zoom?: number;
- mapTypeId?: string;
- colorScheme?: string;
- mapTypeControl?: boolean;
- streetViewControl?: boolean;
- fullscreenControl?: boolean;
- zoomControl?: boolean;
- styles?: Array;
- mapId?: string;
- }
-
- interface MarkerOptions {
- position?: LatLngLiteral;
- map?: Map;
- title?: string;
- icon?: any;
- zIndex?: number;
- }
-
- interface CircleOptions {
- strokeColor?: string;
- strokeOpacity?: number;
- strokeWeight?: number;
- fillColor?: string;
- fillOpacity?: number;
- map?: Map;
- center?: LatLngLiteral;
- radius?: number;
- }
-
- interface InfoWindowOptions {
- content?: string;
- position?: LatLngLiteral;
- }
-
- interface PolylineOptions {
- path?: LatLngLiteral[];
- geodesic?: boolean;
- strokeColor?: string;
- strokeOpacity?: number;
- strokeWeight?: number;
- map?: Map | null;
- visible?: boolean;
- }
-
- // Event-related functionality
- const event: {
- addListener(instance: object, event: string, listener: (Event) => void): MapsEventListener;
- /**
- * Removes the given listener, which should have been returned by
- * google.maps.event.addListener.
- */
- removeListener(listener: MapsEventListener): void;
- /**
- * Removes all listeners for all events for the given instance.
- */
- clearInstanceListeners(instance: object): void;
- };
-
- // Maps Event Listener
- interface MapsEventListener {
- /**
- * Removes the listener.
- * Equivalent to calling google.maps.event.removeListener(listener).
- */
- remove(): void;
- }
-
- const MapTypeId: {
- ROADMAP: string;
- SATELLITE: string;
- HYBRID: string;
- TERRAIN: string;
- };
-
- const SymbolPath: {
- CIRCLE: number;
- FORWARD_CLOSED_ARROW: number;
- FORWARD_OPEN_ARROW: number;
- BACKWARD_CLOSED_ARROW: number;
- BACKWARD_OPEN_ARROW: number;
- };
- }
-}
-
-// Extend the Window interface
-interface Window {
- google: typeof google;
-}
\ No newline at end of file
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
index b7e73e9..1daa9cf 100644
--- a/web/src/vite-env.d.ts
+++ b/web/src/vite-env.d.ts
@@ -1,9 +1,7 @@
///
-interface ImportMetaEnv {
- readonly VITE_GOOGLE_MAPS_API_KEY: string;
- // Add other environment variables as needed
-}
+// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+interface ImportMetaEnv {}
interface ImportMeta {
readonly env: ImportMetaEnv;
diff --git a/web/vite.config.ts b/web/vite.config.ts
index b3c1789..fac8819 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -21,10 +21,10 @@ export default defineConfig({
css: true,
},
server: {
- port: 3000,
+ port: 5747,
proxy: {
'/api': {
- target: 'http://localhost:8080',
+ target: 'http://localhost:5446',
changeOrigin: true,
},
},