refactor(web): replace Google Maps with MapLibre, clean up map components

- Migrate all three map components (Map, GoogleMap, NetworkMap) to MapLibre GL JS
- Extract shared CARTO_DARK_STYLE constants into lib/mapStyle.ts
- Move buildCircleCoords to lib/mapUtils.ts (was duplicated across components)
- Rename exports: Map → LocationMap, GoogleMap → NodeLocationMap
- Remove dead props (width, height, nightMode) from LocationMap interface
- Lazy-mount GL contexts via IntersectionObserver to prevent WebGL exhaustion
- Fix Math.spread RangeError in NetworkMap bounds calculation
- Remove showLinks conditional render in favour of visibility layout property
- Remove cursor state; set canvas cursor style directly on map interactions
- Remove Google Maps API key env vars from .env.example and .env.local
- Move Vite dev server to port 5747 (avoids cached redirect on 3000)
- Fix CORS/404: set VITE_API_BASE_URL="" so browser uses Vite proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel Pupius
2026-03-15 20:22:12 +00:00
parent 6e650a7c57
commit d95a74c1d7
18 changed files with 996 additions and 1039 deletions

View File

@@ -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

View File

@@ -10,13 +10,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Roboto+Mono:wght@400;500&family=Share+Tech+Mono&family=Space+Mono:wght@400;700&family=VT323&display=swap"
rel="stylesheet">
<!-- Google Maps API with environment variable API key and Map ID for Advanced Markers -->
<script>
function gmapsCallback() {
window.dispatchEvent(new Event('google-maps-loaded'));
}
</script>
<script async src="https://maps.googleapis.com/maps/api/js?key=%VITE_GOOGLE_MAPS_API_KEY%&libraries=marker&loading=async&callback=gmapsCallback&map_ids=%VITE_GOOGLE_MAPS_ID%"></script>
</head>
<body>

View File

@@ -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",

438
web/pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<MapProps> = ({
export const LocationMap: React.FC<LocationMapProps> = ({
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<HTMLDivElement>(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 (
<div className={flush ? "p-4 bg-neutral-800/50" : mapContainerClasses}>
<p className="text-sm text-neutral-400">
Map display requires a Google Maps API key.
</p>
<p className="text-xs text-neutral-500 mt-1">
Add VITE_GOOGLE_MAPS_API_KEY to your environment.
</p>
<div className="mt-2 text-sm text-neutral-300">
{latitude.toFixed(6)}, {longitude.toFixed(6)}
</div>
</div>
);
}
return (
<div className={mapContainerClasses}>
<a
href={googleMapsUrl}
target="_blank"
<div ref={containerRef} className={containerClasses}>
{isVisible && (
<ReactMap
mapStyle={CARTO_DARK_STYLE}
initialViewState={{ longitude, latitude, zoom: effectiveZoom }}
style={{ width: "100%", height: "100%" }}
attributionControl={{}}
>
{showAccuracyCircle && (
<Source id="circle" type="geojson" data={circleGeoJSON}>
<Layer id="circle-fill" type="fill" paint={{ "fill-color": "#4ade80", "fill-opacity": 0.15 }} />
<Layer id="circle-outline" type="line" paint={{ "line-color": "#22c55e", "line-width": 1.5, "line-opacity": 0.8 }} />
</Source>
)}
<Source id="marker" type="geojson" data={markerGeoJSON}>
<Layer
id="marker-dot"
type="circle"
paint={{
"circle-radius": 5,
"circle-color": "#4ade80",
"circle-stroke-width": 2,
"circle-stroke-color": "#22c55e",
}}
/>
</Source>
</ReactMap>
)}
{/* External link overlay */}
<a
href={googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full hover:opacity-90 transition-opacity"
className="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded hover:bg-black/70 transition-colors z-10"
title="Open in Google Maps"
>
<img
src={mapUrl}
alt={`Map of ${latitude},${longitude}`}
className="w-full h-full object-cover"
/>
{caption && (
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-3 py-1 text-xs text-white">
{caption}
</div>
)}
</a>
{caption && (
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-3 py-1 text-xs text-white z-10">
{caption}
</div>
)}
</div>
);
};
};
/** @deprecated Use LocationMap */
export const Map = LocationMap;

View File

@@ -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<GoogleMapProps> = ({
export const NodeLocationMap: React.FC<NodeLocationMapProps> = ({
lat,
lng,
zoom,
precisionBits,
fullHeight = false,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
const markerRef = useRef<google.maps.marker.AdvancedMarkerElement | null>(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 = `
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#4ade80" stroke="#22c55e" stroke-width="2" />
</svg>
`;
// 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 (
<div className={`${containerClassName} flex items-center justify-center`}>
<div className="text-gray-400">Loading map...</div>
</div>
);
}
const containerClassName = `w-full ${fullHeight ? "h-full flex-1" : "min-h-[300px]"} rounded-lg overflow-hidden effect-inset`;
return (
<div
ref={mapRef}
className={containerClassName}
/>
<div className={containerClassName}>
<ReactMap
mapStyle={CARTO_DARK_STYLE}
initialViewState={{ longitude: lng, latitude: lat, zoom: effectiveZoom }}
style={{ width: "100%", height: "100%" }}
>
<Source id="circle" type="geojson" data={circleGeoJSON}>
<Layer
id="circle-fill"
type="fill"
paint={{ "fill-color": "#4ade80", "fill-opacity": 0.15 }}
/>
<Layer
id="circle-outline"
type="line"
paint={{ "line-color": "#22c55e", "line-width": 2, "line-opacity": 0.8 }}
/>
</Source>
<Source id="marker" type="geojson" data={markerGeoJSON}>
<Layer
id="marker-dot"
type="circle"
paint={{
"circle-radius": 6,
"circle-color": "#4ade80",
"circle-stroke-width": 2,
"circle-stroke-color": "#22c55e",
"circle-opacity": 1,
}}
/>
</Source>
</ReactMap>
</div>
);
};
/** @deprecated Use NodeLocationMap */
export const GoogleMap = NodeLocationMap;

View File

@@ -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<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
const markersRef = useRef<Record<string, google.maps.marker.AdvancedMarkerElement>>({});
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
const boundsRef = useRef<google.maps.LatLngBounds | null>(null);
const [nodesWithPosition, setNodesWithPosition] = useState<MapNode[]>([]);
const animatingNodesRef = useRef<Record<string, number>>({});
const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
// Using any for the event listener since TypeScript can't find the MapsEventListener interface
const zoomListenerRef = useRef<any>(null);
const polylinesRef = useRef<Record<string, google.maps.Polyline>>({});
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<string>();
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<string, LinkObservation>,
nodePositions: MapNode[],
visible: boolean
): void => {
if (!mapInstanceRef.current || !window.google?.maps) return;
// Build position lookup
const posMap = new Map<number, google.maps.LatLngLiteral>();
for (const node of nodePositions) {
posMap.set(node.id, {
lat: node.position.latitudeI / 10000000,
lng: node.position.longitudeI / 10000000,
});
}
const activeKeys = new Set<string>();
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 (
<div className={wrapperClassName}>
<div
className={`${mapClassName} flex items-center justify-center`}
style={mapContainerStyle}
>
<div className="text-gray-400">Loading map...</div>
</div>
</div>
);
}
return (
<div className={wrapperClassName}>
<div
ref={mapRef}
className={mapClassName}
style={mapContainerStyle}
/>
</div>
);
});
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<MapRef>(null);
const [mapLoaded, setMapLoaded] = useState(false);
const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
const [selectedNode, setSelectedNode] = useState<MapNode | null>(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<number, [number, number]>();
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<string, unknown> }> }) => {
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 (
<div className={wrapperClassName}>
<div className={mapClassName} style={containerStyle}>
<ReactMap
ref={mapRef}
mapStyle={CARTO_DARK_STYLE_LABELLED}
initialViewState={{ longitude: -98, latitude: 39, zoom: 4 }}
style={{ width: "100%", height: "100%" }}
interactiveLayerIds={["nodes-circles"]}
onMouseEnter={() => {
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 */}
<Source id="links" type="geojson" data={linksGeoJSON}>
<Layer
id="links-line"
type="line"
layout={{
"line-join": "round",
"line-cap": "round",
"visibility": showLinks ? "visible" : "none",
}}
paint={{
"line-color": ["get", "color"],
"line-width": 2,
"line-opacity": ["get", "opacity"],
}}
/>
</Source>
{/* Node circles */}
<Source id="nodes" type="geojson" data={nodesGeoJSON}>
<Layer
id="nodes-circles"
type="circle"
paint={{
"circle-radius": ["get", "radius"],
"circle-color": ["get", "fillColor"],
"circle-stroke-width": 2,
"circle-stroke-color": ["get", "strokeColor"],
"circle-opacity": 0.9,
"circle-stroke-opacity": 1,
}}
/>
<Layer
id="nodes-labels"
type="symbol"
layout={{
"text-field": ["get", "name"],
"text-size": 11,
"text-offset": [0, 1.5],
"text-anchor": "top",
"text-optional": true,
}}
paint={{
"text-color": "#e5e7eb",
"text-halo-color": "#111827",
"text-halo-width": 1.5,
}}
/>
</Source>
{/* Node popup */}
{selectedNode && (
<Popup
longitude={selectedNode.position.longitudeI / 10000000}
latitude={selectedNode.position.latitudeI / 10000000}
onClose={() => setSelectedNode(null)}
closeOnClick={false}
maxWidth="240px"
anchor="bottom"
>
<NodePopup
node={selectedNode}
onNavigate={(id) => {
setSelectedNode(null);
navigate({ to: "/node/$nodeId", params: { nodeId: id.toString(16) } });
}}
/>
</Popup>
)}
</ReactMap>
</div>
</div>
);
}
);
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 (
<div style={{ fontFamily: "sans-serif", maxWidth: 220 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: colors.fill, marginBottom: 3 }}>
{nodeName}
</div>
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 6 }}>
{node.isGateway ? "Gateway" : "Node"} · !{node.id.toString(16)}
</div>
<div style={{ display: "flex", alignItems: "center", fontSize: 11, marginBottom: 4 }}>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: colors.fill,
display: "inline-block",
marginRight: 5,
flexShrink: 0,
}}
/>
<span style={{ color: "#374151" }}>
{statusText} · {lastSeenText}
</span>
</div>
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 8 }}>
Packets: {node.messageCount} · Text: {node.textMessageCount}
</div>
<button
onClick={() => onNavigate(node.id)}
style={{
fontSize: 12,
fontWeight: 500,
color: "#3b82f6",
background: "#f1f5f9",
border: "none",
borderRadius: 4,
padding: "4px 8px",
cursor: "pointer",
}}
>
View details
</button>
</div>
);
}
// ─── 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<number, NodeData>,
nodes: Record<number, NodeData>,
gateways: Record<string, GatewayData>
): MapNode[] {
const nodesMap = new Map<number, MapNode>(); // Use a Map to avoid duplicates
// Regular nodes
const nodesMap = new Map<number, MapNode>();
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,
};
}

View File

@@ -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<NodeDetailProps> = ({ nodeId }) => {
className="mt-6"
>
<div className="h-[400px] rounded-lg overflow-hidden relative shadow-inner">
<GoogleMap
<NodeLocationMap
lat={latitude}
lng={longitude}
precisionBits={precisionBits}

View File

@@ -6,7 +6,7 @@ export * from './NodeDetail';
export * from './ChannelDetail';
export * from './BatteryLevel';
export * from './SignalStrength';
export * from './GoogleMap';
export { NodeLocationMap } from './GoogleMap';
export * from './NetworkMap';
export * from './NodePacketList';
export * from './NodePositionData';

View File

@@ -9,7 +9,7 @@ import {
import { Map as MapIcon, MapPin, Network } from "lucide-react";
import { PacketCard } from "./PacketCard";
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
import { Map } from "../Map";
import { LocationMap } from "../Map";
interface MapReportPacketProps {
packet: Packet;
@@ -275,11 +275,9 @@ export const MapReportPacket: React.FC<MapReportPacketProps> = ({ packet }) => {
Gateway Location
</h3>
<div className="h-[300px] rounded-lg overflow-hidden relative">
<Map
<LocationMap
latitude={center.latitude}
longitude={center.longitude}
width={400}
height={300}
flush={true}
caption="Gateway Location"
precisionBits={precisionBits}

View File

@@ -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 PositionPacketProps {
packet: Packet;
@@ -84,11 +84,9 @@ export const PositionPacket: React.FC<PositionPacketProps> = ({ packet }) => {
{latitude !== undefined && longitude !== undefined && (
<div className="h-[240px] w-full rounded-lg overflow-hidden">
<Map
<LocationMap
latitude={latitude}
longitude={longitude}
width={400}
height={240}
flush={true}
/>
</div>

View File

@@ -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<WaypointPacketProps> = ({ packet }) => {
{latitude !== undefined && longitude !== undefined && (
<div className="h-[240px] w-full rounded-lg overflow-hidden">
<Map
<LocationMap
latitude={latitude}
longitude={longitude}
caption={waypoint.name}
width={400}
height={240}
flush={true}
/>
</div>

View File

@@ -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 = {

31
web/src/lib/mapStyle.ts Normal file
View File

@@ -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:
'© <a href="https://carto.com/attributions">CARTO</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
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" }],
};

View File

@@ -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

View File

@@ -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<any>;
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;
}

View File

@@ -1,9 +1,7 @@
/// <reference types="vite/client" />
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;

View File

@@ -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,
},
},