diff --git a/package-lock.json b/package-lock.json index 5e8a9cc..343ef0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,13 @@ "i18next": "^26.0.0", "i18next-browser-languagedetector": "^8", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "react": "^19", "react-chartjs-2": "^5", "react-dom": "^19", "react-i18next": "^17.0.0", "react-leaflet": "^5", + "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", "react-qr-code": "^2.2.0", "react-router": "^8.0.0", @@ -33,6 +35,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "^24.0.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -492,9 +495,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -515,9 +515,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -538,9 +535,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -561,9 +555,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -584,9 +575,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -607,9 +595,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -832,9 +817,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -852,9 +834,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -872,9 +851,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -892,9 +868,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -912,9 +885,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -932,9 +902,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1152,9 +1119,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1171,9 +1135,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1190,9 +1151,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1209,9 +1167,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1527,6 +1482,16 @@ "@types/geojson": "*" } }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1584,6 +1549,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1600,6 +1566,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1616,6 +1583,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1632,6 +1600,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1648,6 +1617,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1664,6 +1634,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1680,6 +1651,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1696,6 +1668,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1712,6 +1685,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1728,6 +1702,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1744,6 +1719,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1760,6 +1736,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1776,6 +1753,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1792,6 +1770,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1808,6 +1787,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1824,6 +1804,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1840,6 +1821,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1856,6 +1838,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1872,6 +1855,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1888,6 +1872,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2813,6 +2798,15 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4338,6 +4332,22 @@ "react-dom": "^19.0.0" } }, + "node_modules/react-leaflet-cluster": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-4.1.3.tgz", + "integrity": "sha512-b3ICS7r9Fs5hBWrFPMVnmkCuDewyG3m1GaGzamXY0eryyAXDfQ+ikSkiR/mVethueIIA5MSyLok2/KsZVotspQ==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "leaflet.markercluster": "^1.5.3" + }, + "peerDependencies": { + "@react-leaflet/core": "^3.0.0", + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -5207,9 +5217,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5231,9 +5238,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5255,9 +5259,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5279,9 +5280,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index f5ffb86..ca35010 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/leaflet": "^1.9.17", + "@types/leaflet.markercluster": "^1.5.6", "@types/node": "^24.0.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -37,11 +38,13 @@ "i18next": "^26.0.0", "i18next-browser-languagedetector": "^8", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "react": "^19", "react-chartjs-2": "^5", "react-dom": "^19", "react-i18next": "^17.0.0", "react-leaflet": "^5", + "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", "react-qr-code": "^2.2.0", "react-router": "^8.0.0", diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index a331740..e687181 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -383,6 +383,24 @@ opacity: 1; } +/* Marker cluster badges (MapPage) */ +.map-cluster { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-marker-public); + border: 2px solid var(--color-marker-public-border); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.6), 0 1px 2px rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 11px; + font-weight: bold; +} + +.map-cluster:hover { + filter: brightness(1.15); +} + /* Bring hovered marker to front */ .leaflet-marker-icon:hover { z-index: 10000 !important; diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx index 6d4c44b..fb095a2 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from "@testing-library/react"; +import { screen, waitFor, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; @@ -7,11 +7,19 @@ vi.mock("react-leaflet", () => ({
{children}
), TileLayer: () => null, - Marker: () => null, + Marker: () =>
, Popup: () => null, useMap: () => ({ fitBounds: () => {}, latLngToContainerPoint: () => ({ x: 0, y: 0 }) }), })); +vi.mock("react-leaflet-cluster", () => ({ + default: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("leaflet.markercluster", () => ({})); + vi.mock("leaflet", () => ({ divIcon: () => ({}), latLngBounds: () => ({}), @@ -60,6 +68,15 @@ describe("MapPage", () => { ).not.toBeNull(); }); + it("renders node markers inside the cluster group", async () => { + vi.spyOn(api, "apiGet").mockResolvedValue(MAP_DATA); + renderWithProviders(); + const group = await screen.findByTestId("cluster-group"); + expect( + within(group).getAllByTestId("map-marker").length, + ).toBeGreaterThan(0); + }); + it("shows an error on fetch failure", async () => { vi.spyOn(api, "apiGet").mockRejectedValue(new Error("map error")); const { container } = renderWithProviders(); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx index 5140758..ccddf86 100644 --- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx +++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx @@ -3,11 +3,15 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; +import MarkerClusterGroup from "react-leaflet-cluster"; +import "leaflet.markercluster"; +import "leaflet.markercluster/dist/MarkerCluster.css"; import { divIcon, latLngBounds, type DivIcon, type Map as LeafletMap, + type MarkerCluster, } from "leaflet"; import "leaflet/dist/leaflet.css"; @@ -91,7 +95,11 @@ function getTypeDisplay(node: MapNode, t: TFunction): string { : t("node_types.unknown"); } -function createNodeIcon(node: MapNode, oidcEnabled: boolean): DivIcon { +function createNodeIcon( + node: MapNode, + oidcEnabled: boolean, + showLabels: boolean, +): DivIcon { const displayName = node.name || ""; const relativeTime = formatRelativeTime(node.last_seen); const timeDisplay = relativeTime ? " (" + relativeTime + ")" : ""; @@ -101,6 +109,15 @@ function createNodeIcon(node: MapNode, oidcEnabled: boolean): DivIcon { ? '
' : '
'; + if (!showLabels) { + return divIcon({ + className: "custom-div-icon", + html: '
' + iconHtml + "
", + iconSize: [24, 24], + iconAnchor: [12, 12], + }); + } + return divIcon({ className: "custom-div-icon", html: @@ -116,6 +133,24 @@ function createNodeIcon(node: MapNode, oidcEnabled: boolean): DivIcon { }); } +function createClusterIcon(cluster: MarkerCluster): DivIcon { + const count = cluster.getChildCount(); + const size = count >= 1000 ? 48 : count >= 100 ? 42 : count >= 10 ? 36 : 30; + return divIcon({ + className: "custom-div-icon", + html: + '
' + + formatNumber(count) + + "
", + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} + function NodePopup({ node, oidcEnabled, @@ -342,14 +377,14 @@ export function MapPage() { )), - [filteredNodes, oidcEnabled], + [filteredNodes, oidcEnabled, showLabels], ); const clearFilters = () => { @@ -474,7 +509,16 @@ export function MapPage() { attribution='© OpenStreetMap contributors' url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> - {markers} + + {markers} + {mapData && (