Merge overlapping map clusters
This commit is contained in:
parent
227c0bdc0c
commit
4138d7bfef
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
92
assets/js/map_cluster_points.mjs
Normal file
92
assets/js/map_cluster_points.mjs
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
52
assets/js/map_cluster_points.test.mjs
Normal file
52
assets/js/map_cluster_points.test.mjs
Normal 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")
|
||||
})
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"name": "who-need-help-assets",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test js/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "5.24.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user