mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
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:
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
438
web/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
31
web/src/lib/mapStyle.ts
Normal 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" }],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
163
web/src/types/google-maps.d.ts
vendored
163
web/src/types/google-maps.d.ts
vendored
@@ -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;
|
||||
}
|
||||
6
web/src/vite-env.d.ts
vendored
6
web/src/vite-env.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user