Merge overlapping map clusters
This commit is contained in:
parent
227c0bdc0c
commit
4138d7bfef
|
|
@ -306,9 +306,9 @@ html {
|
||||||
|
|
||||||
.request-map-cluster {
|
.request-map-cluster {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 2.5rem;
|
width: 2.75rem;
|
||||||
min-height: 2.5rem;
|
height: 2.75rem;
|
||||||
padding: 0.35rem;
|
padding: 0.2rem;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: white;
|
color: white;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|
@ -321,6 +321,21 @@ html {
|
||||||
cursor: pointer;
|
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 {
|
.request-map-cluster.is-urgent {
|
||||||
background: #b63b21;
|
background: #b63b21;
|
||||||
}
|
}
|
||||||
|
|
@ -359,11 +374,6 @@ html {
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-map-cluster {
|
|
||||||
min-width: 2.75rem;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.request-discovery-toolbar {
|
.request-discovery-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import maplibregl from "maplibre-gl"
|
import maplibregl from "maplibre-gl"
|
||||||
|
import {
|
||||||
|
clusterSizeClass,
|
||||||
|
mergeNearbyMapClusters
|
||||||
|
} from "./map_cluster_points.mjs"
|
||||||
|
|
||||||
const trackingMinTimeMs = 5000
|
const trackingMinTimeMs = 5000
|
||||||
|
|
||||||
|
|
@ -479,7 +483,10 @@ const createAidMap = element => {
|
||||||
if (!state.active || !state.map) return
|
if (!state.active || !state.map) return
|
||||||
|
|
||||||
const current = state.element.getBoundingClientRect()
|
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(
|
const exactPoints = points.filter(
|
||||||
point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters))
|
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 areaFeatures = areaFeatureCollection(points)
|
||||||
const clusterFeatures = clusterFeatureCollection(points)
|
const clusterFeatures = clusterFeatureCollection(clusterPoints)
|
||||||
const bounds = new maplibregl.LngLatBounds()
|
const bounds = new maplibregl.LngLatBounds()
|
||||||
|
|
||||||
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
|
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
|
||||||
|
|
@ -601,10 +612,11 @@ const createAidMap = element => {
|
||||||
state.markers.push(marker)
|
state.markers.push(marker)
|
||||||
})
|
})
|
||||||
|
|
||||||
points.filter(point => point.type === "cluster").forEach(point => {
|
clusterPoints.forEach(point => {
|
||||||
const element = document.createElement("button")
|
const element = document.createElement("button")
|
||||||
element.type = "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.classList.toggle("is-urgent", Number(point.urgent_count) > 0)
|
||||||
element.textContent = String(point.count)
|
element.textContent = String(point.count)
|
||||||
element.setAttribute(
|
element.setAttribute(
|
||||||
|
|
@ -612,6 +624,7 @@ const createAidMap = element => {
|
||||||
(state.element.dataset.clusterLabel || "%{count} requests")
|
(state.element.dataset.clusterLabel || "%{count} requests")
|
||||||
.replace("%{count}", String(point.count))
|
.replace("%{count}", String(point.count))
|
||||||
)
|
)
|
||||||
|
element.title = element.getAttribute("aria-label")
|
||||||
element.addEventListener("click", event => {
|
element.addEventListener("click", event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
state.map.easeTo({
|
state.map.easeTo({
|
||||||
|
|
@ -713,6 +726,7 @@ const createAidMap = element => {
|
||||||
|
|
||||||
state.onMoveEnd = () => {
|
state.onMoveEnd = () => {
|
||||||
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
|
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
|
||||||
|
state.renderMarkers()
|
||||||
const viewport = mapViewport(state.map)
|
const viewport = mapViewport(state.map)
|
||||||
if (viewport) state.onViewportChange?.(viewport)
|
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",
|
"name": "who-need-help-assets",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test js/*.test.mjs"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "5.24.0"
|
"maplibre-gl": "5.24.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1467,6 +1467,10 @@ docker run --rm \
|
||||||
|
|
||||||
echo "Auditing locked browser dependencies"
|
echo "Auditing locked browser dependencies"
|
||||||
docker build --target node_deps --tag "$assets_image" .
|
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 run --rm "$assets_image" npm audit --audit-level=high
|
||||||
docker build --tag "$e2e_image" e2e
|
docker build --tag "$e2e_image" e2e
|
||||||
docker run --rm "$e2e_image" npm audit --audit-level=high
|
docker run --rm "$e2e_image" npm audit --audit-level=high
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user