Merge pull request #377 from ipnet-mesh/feat/map-node-clustering

feat(web): cluster map markers for large node counts
This commit is contained in:
JingleManSweep authored and GitHub committed 2026-08-30 13:20:23 +01:00
commit e1f421f7bd
6 files changed
+192 -72

No files matched your search

+46 -6
View File
@@ -1,6 +1,24 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { openFilters } from "../utils/helpers";
/** Count nodes represented on the map: individual markers plus the totals
* shown on cluster badges (markers merge into clusters at low zooms). */
async function mapNodeCount(page: Page): Promise<number> {
return page.evaluate(() => {
const singles = document.querySelectorAll(".map-marker").length;
const clustered = Array.from(
document.querySelectorAll<HTMLElement>("[data-testid='cluster-count']"),
).reduce((sum, badge) => {
const count = Number.parseInt(
(badge.textContent ?? "").replace(/[^\d]/g, ""),
10,
);
return sum + (Number.isNaN(count) ? 0 : count);
}, 0);
return singles + clustered;
});
}
test.describe("map", () => {
test("renders markers and filter options work (incl. show labels)", async ({
page,
@@ -8,33 +26,55 @@ test.describe("map", () => {
await page.goto("/map");
const markers = page.locator(".map-marker");
const clusters = page.locator("[data-testid='cluster-count']");
await expect(markers.first()).toBeVisible();
await expect(markers).toHaveCount(7);
await expect.poll(() => mapNodeCount(page)).toBe(7);
await expect(page.getByText("7 nodes on map")).toBeVisible();
// Densely spaced nodes merge into count badges; clicking one zooms in
// and spreads its children into individual markers (nodes outside the
// zoomed viewport are culled from the DOM, so count them only when the
// whole network is on screen).
await expect(clusters.first()).toBeVisible();
await clusters.first().click();
await expect.poll(() => markers.count()).toBeGreaterThanOrEqual(2);
await openFilters(page);
await page
.locator('select:has(option[value="repeater"])')
.selectOption("repeater");
await expect(markers).toHaveCount(5);
await expect.poll(() => mapNodeCount(page)).toBe(5);
await expect(page.getByText("5 shown")).toBeVisible();
// Zoom into a cluster so at least one marker is unclustered before the
// label visibility check (clustered markers are not in the DOM).
await clusters.first().click();
await expect.poll(() => markers.count()).toBeGreaterThanOrEqual(2);
await page.getByLabel("Show Labels").check();
await expect(page.locator(".show-labels").first()).toBeVisible();
await expect(page.locator(".map-label").first()).toBeVisible();
await page.getByRole("button", { name: "Clear Filters" }).click();
await expect(markers).toHaveCount(7);
await expect.poll(() => mapNodeCount(page)).toBe(7);
await expect(page.locator(".show-labels")).toHaveCount(0);
await expect(page.getByLabel("Show Labels")).not.toBeChecked();
});
test("marker popup links to node detail", async ({ page }) => {
await page.goto("/map");
await expect(page.locator(".map-marker").first()).toBeVisible();
await page.locator(".map-marker").first().click();
// Zoom into a cluster first: its markers separate near the middle of the
// map, clear of the sticky navbar and the announcement banners that push
// the initial markers toward the page top.
const clusters = page.locator("[data-testid='cluster-count']");
await expect(clusters.first()).toBeVisible();
await clusters.first().click();
const firstMarker = page.locator(".map-marker").first();
await expect(firstMarker).toBeVisible();
await firstMarker.click();
const popup = page.locator(".leaflet-popup");
await expect(popup).toBeVisible();
await popup.getByRole("link", { name: "View Details" }).click();
+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 data-testid="cluster-count">' +
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"
/>
{markers}
<MarkerClusterGroup
showCoverageOnHover={false}
spiderfyOnMaxZoom
zoomToBoundsOnClick
disableClusteringAtZoom={15}
maxClusterRadius={60}
iconCreateFunction={createClusterIcon}
>
{markers}
</MarkerClusterGroup>
{mapData && (
<MapController
mapData={mapData}