Merge overlapping map clusters

This commit is contained in:
SimpleTest 2026-07-28 22:19:48 +03:00
parent 227c0bdc0c
commit 4138d7bfef
6 changed files with 187 additions and 12 deletions

View File

@ -306,9 +306,9 @@ html {
.request-map-cluster {
display: grid;
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0.35rem;
width: 2.75rem;
height: 2.75rem;
padding: 0.2rem;
place-items: center;
color: white;
font: inherit;
@ -321,6 +321,21 @@ html {
cursor: pointer;
}
.request-map-cluster.is-lg {
width: 3.25rem;
height: 3.25rem;
}
.request-map-cluster.is-xl {
width: 3.5rem;
height: 3.5rem;
}
.request-map-cluster.is-xxl {
width: 3.75rem;
height: 3.75rem;
}
.request-map-cluster.is-urgent {
background: #b63b21;
}
@ -359,11 +374,6 @@ html {
height: 2.75rem;
}
.request-map-cluster {
min-width: 2.75rem;
min-height: 2.75rem;
}
.request-discovery-toolbar {
align-items: stretch;
flex-direction: column;

View File

@ -1,4 +1,8 @@
import maplibregl from "maplibre-gl"
import {
clusterSizeClass,
mergeNearbyMapClusters
} from "./map_cluster_points.mjs"
const trackingMinTimeMs = 5000
@ -479,7 +483,10 @@ const createAidMap = element => {
if (!state.active || !state.map) return
const current = state.element.getBoundingClientRect()
if (current.width >= 160 && current.height >= 160) state.map.resize()
if (current.width >= 160 && current.height >= 160) {
state.map.resize()
if (state.element.dataset.discoveryMap === "true") state.renderMarkers()
}
})
}
@ -520,8 +527,12 @@ const createAidMap = element => {
const exactPoints = points.filter(
point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters))
)
const clusterPoints = mergeNearbyMapClusters(
state.map,
points.filter(point => point.type === "cluster")
)
const areaFeatures = areaFeatureCollection(points)
const clusterFeatures = clusterFeatureCollection(points)
const clusterFeatures = clusterFeatureCollection(clusterPoints)
const bounds = new maplibregl.LngLatBounds()
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
@ -601,10 +612,11 @@ const createAidMap = element => {
state.markers.push(marker)
})
points.filter(point => point.type === "cluster").forEach(point => {
clusterPoints.forEach(point => {
const element = document.createElement("button")
element.type = "button"
element.className = "request-map-cluster"
element.className = `request-map-cluster ${clusterSizeClass(point.count)}`
element.dataset.clusterCount = String(point.count)
element.classList.toggle("is-urgent", Number(point.urgent_count) > 0)
element.textContent = String(point.count)
element.setAttribute(
@ -612,6 +624,7 @@ const createAidMap = element => {
(state.element.dataset.clusterLabel || "%{count} requests")
.replace("%{count}", String(point.count))
)
element.title = element.getAttribute("aria-label")
element.addEventListener("click", event => {
event.stopPropagation()
state.map.easeTo({
@ -713,6 +726,7 @@ const createAidMap = element => {
state.onMoveEnd = () => {
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
state.renderMarkers()
const viewport = mapViewport(state.map)
if (viewport) state.onViewportChange?.(viewport)
}

View File

@ -0,0 +1,92 @@
const clusterCount = point => {
const count = Number(point?.count)
return Number.isFinite(count) && count > 0 ? count : 1
}
const urgentCount = point => {
const count = Number(point?.urgent_count)
return Number.isFinite(count) && count > 0 ? count : 0
}
export const clusterDiameterPixels = count => {
if (count >= 100_000) return 60
if (count >= 10_000) return 56
if (count >= 1_000) return 52
return 44
}
export const clusterSizeClass = count => {
if (count >= 100_000) return "is-xxl"
if (count >= 10_000) return "is-xl"
if (count >= 1_000) return "is-lg"
return "is-md"
}
const mergeDistance = (left, right, gap) =>
(clusterDiameterPixels(left.count) + clusterDiameterPixels(right.count)) / 2 + gap
const coordinatesFromPixel = (map, pixel) => {
const coordinates = map.unproject([pixel.x, pixel.y])
return {
longitude: Number(coordinates.lng ?? coordinates[0]),
latitude: Number(coordinates.lat ?? coordinates[1])
}
}
export const mergeNearbyMapClusters = (map, points, options = {}) => {
const gap = Number.isFinite(options.gap) ? Math.max(options.gap, 0) : 8
const groups = points.map(point => {
const pixel = map.project([point.longitude, point.latitude])
return {
points: [point],
x: pixel.x,
y: pixel.y,
count: clusterCount(point),
urgent_count: urgentCount(point)
}
})
let merged = true
while (merged) {
merged = false
outer:
for (let leftIndex = 0; leftIndex < groups.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < groups.length; rightIndex += 1) {
const left = groups[leftIndex]
const right = groups[rightIndex]
const distance = Math.hypot(left.x - right.x, left.y - right.y)
if (distance >= mergeDistance(left, right, gap)) continue
const count = left.count + right.count
left.x = (left.x * left.count + right.x * right.count) / count
left.y = (left.y * left.count + right.y * right.count) / count
left.count = count
left.urgent_count += right.urgent_count
left.points.push(...right.points)
groups.splice(rightIndex, 1)
merged = true
break outer
}
}
}
return groups.map(group => {
const coordinates = coordinatesFromPixel(map, group)
const firstId = group.points[0]?.id || "anonymous"
return {
type: "cluster",
id: `visual-cluster-${group.points.length}-${firstId}`,
count: group.count,
urgent_count: group.urgent_count,
longitude: coordinates.longitude,
latitude: coordinates.latitude
}
})
}

View File

@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
clusterDiameterPixels,
clusterSizeClass,
mergeNearbyMapClusters
} from "./map_cluster_points.mjs"
const flatMap = scale => ({
project: ([longitude, latitude]) => ({x: longitude * scale, y: latitude * scale}),
unproject: ([x, y]) => ({lng: x / scale, lat: y / scale})
})
test("merges visually overlapping clusters and preserves their total", () => {
const points = [
{id: "a", type: "cluster", count: 100, urgent_count: 2, longitude: 0, latitude: 0},
{id: "b", type: "cluster", count: 50, urgent_count: 1, longitude: 30, latitude: 0},
{id: "c", type: "cluster", count: 7, urgent_count: 0, longitude: 140, latitude: 0}
]
const result = mergeNearbyMapClusters(flatMap(1), points)
assert.equal(result.length, 2)
assert.equal(result.reduce((total, cluster) => total + cluster.count, 0), 157)
const merged = result.find(cluster => cluster.count === 150)
assert.equal(merged.urgent_count, 3)
assert.equal(merged.longitude, 10)
assert.equal(merged.latitude, 0)
})
test("splits the same clusters again at a closer zoom", () => {
const points = [
{id: "a", type: "cluster", count: 20, longitude: 0, latitude: 0},
{id: "b", type: "cluster", count: 30, longitude: 30, latitude: 0}
]
assert.equal(mergeNearbyMapClusters(flatMap(1), points).length, 1)
assert.equal(mergeNearbyMapClusters(flatMap(3), points).length, 2)
})
test("uses larger non-overlapping markers for larger totals", () => {
assert.equal(clusterDiameterPixels(999), 44)
assert.equal(clusterDiameterPixels(1_000), 52)
assert.equal(clusterDiameterPixels(10_000), 56)
assert.equal(clusterDiameterPixels(100_000), 60)
assert.equal(clusterSizeClass(999), "is-md")
assert.equal(clusterSizeClass(1_000), "is-lg")
assert.equal(clusterSizeClass(10_000), "is-xl")
assert.equal(clusterSizeClass(100_000), "is-xxl")
})

View File

@ -1,6 +1,9 @@
{
"name": "who-need-help-assets",
"private": true,
"scripts": {
"test": "node --test js/*.test.mjs"
},
"dependencies": {
"maplibre-gl": "5.24.0"
}

View File

@ -1467,6 +1467,10 @@ docker run --rm \
echo "Auditing locked browser dependencies"
docker build --target node_deps --tag "$assets_image" .
docker run --rm \
--volume "$ROOT/assets/js:/assets/js:ro" \
"$assets_image" \
npm test
docker run --rm "$assets_image" npm audit --audit-level=high
docker build --tag "$e2e_image" e2e
docker run --rm "$e2e_image" npm audit --audit-level=high