From d95a74c1d7ee06d2ac4e362b5b884e73e4a827b1 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Sun, 15 Mar 2026 20:22:12 +0000 Subject: [PATCH] refactor(web): replace Google Maps with MapLibre, clean up map components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/.env.example | 8 +- web/index.html | 8 +- web/package.json | 3 + web/pnpm-lock.yaml | 438 +++++++++ web/src/components/Map.tsx | 175 ++-- web/src/components/dashboard/GoogleMap.tsx | 206 ++-- web/src/components/dashboard/NetworkMap.tsx | 889 +++++++----------- web/src/components/dashboard/NodeDetail.tsx | 4 +- web/src/components/dashboard/index.ts | 2 +- .../components/packets/MapReportPacket.tsx | 6 +- web/src/components/packets/PositionPacket.tsx | 6 +- web/src/components/packets/WaypointPacket.tsx | 6 +- web/src/lib/config.ts | 2 - web/src/lib/mapStyle.ts | 31 + web/src/lib/mapUtils.ts | 78 +- web/src/types/google-maps.d.ts | 163 ---- web/src/vite-env.d.ts | 6 +- web/vite.config.ts | 4 +- 18 files changed, 996 insertions(+), 1039 deletions(-) create mode 100644 web/src/lib/mapStyle.ts delete mode 100644 web/src/types/google-maps.d.ts 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 ( -
-
Loading map...
-
- ); - } + 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 ( -
-
-
Loading map...
-
-
- ); - } - - 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} +
+ +
+ ); +} + +// ─── 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, }, },