feat(web): cluster map markers for large node counts

Wrap MapPage node markers in react-leaflet-cluster (leaflet.markercluster)
with spiderfy-at-max-zoom, zoom-to-bounds on click, clustering disabled at
zoom >= 15, and viewport culling so off-screen markers stay out of the DOM.
Cluster bubbles render a plain themed count badge. Marker label HTML is now
only built when the show-labels toggle is on, halving per-marker DOM weight;
hover-to-reveal no longer applies while labels are toggled off.
This commit is contained in:
Louis King committed 2026-08-30 13:01:07 +01:00
1 parent bda6370cb0
commit c1024bed04
5 files changed
+145 -65

No files matched your search

+58 -60
View File
@@ -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": [
+3
View File
@@ -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",
+18
View File
@@ -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;
@@ -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", () => ({
<div data-testid="mock-map">{children}</div>
),
TileLayer: () => null,
Marker: () => null,
Marker: () => <div data-testid="map-marker" />,
Popup: () => null,
useMap: () => ({ fitBounds: () => {}, latLngToContainerPoint: () => ({ x: 0, y: 0 }) }),
}));
vi.mock("react-leaflet-cluster", () => ({
default: ({ children }: { children: ReactNode }) => (
<div data-testid="cluster-group">{children}</div>
),
}));
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(<MapPage />);
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(<MapPage />);
@@ -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 {
? '<div style="width: 12px; height: 12px; background: var(--color-marker-infra); border: 2px solid var(--color-marker-infra-border); border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>'
: '<div style="width: 12px; height: 12px; background: var(--color-marker-public); border: 2px solid var(--color-marker-public-border); border-radius: 50%; box-shadow: 0 0 4px rgba(34,197,94,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>';
if (!showLabels) {
return divIcon({
className: "custom-div-icon",
html: '<div class="map-marker">' + iconHtml + "</div>",
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:
'<div class="map-cluster" style="width: ' +
size +
"px; height: " +
size +
'px;"><span>' +
formatNumber(count) +
"</span></div>",
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function NodePopup({
node,
oidcEnabled,
@@ -342,14 +377,14 @@ export function MapPage() {
<Marker
key={node.public_key}
position={[node.lat, node.lon]}
icon={createNodeIcon(node, oidcEnabled)}
icon={createNodeIcon(node, oidcEnabled, showLabels)}
>
<Popup>
<NodePopup node={node} oidcEnabled={oidcEnabled} />
</Popup>
</Marker>
)),
[filteredNodes, oidcEnabled],
[filteredNodes, oidcEnabled, showLabels],
);
const clearFilters = () => {
@@ -474,7 +509,16 @@ export function MapPage() {
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MarkerClusterGroup
showCoverageOnHover={false}
spiderfyOnMaxZoom
zoomToBoundsOnClick
disableClusteringAtZoom={15}
maxClusterRadius={60}
iconCreateFunction={createClusterIcon}
>
{markers}
</MarkerClusterGroup>
{mapData && (
<MapController
mapData={mapData}