diff --git a/assets/css/app.css b/assets/css/app.css index d78438d..1cf026a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 35e3a46..2a9b1a6 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -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) } diff --git a/assets/js/map_cluster_points.mjs b/assets/js/map_cluster_points.mjs new file mode 100644 index 0000000..80b6b04 --- /dev/null +++ b/assets/js/map_cluster_points.mjs @@ -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 + } + }) +} diff --git a/assets/js/map_cluster_points.test.mjs b/assets/js/map_cluster_points.test.mjs new file mode 100644 index 0000000..340267f --- /dev/null +++ b/assets/js/map_cluster_points.test.mjs @@ -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") +}) diff --git a/assets/package.json b/assets/package.json index 475f4b5..1a6a197 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,6 +1,9 @@ { "name": "who-need-help-assets", "private": true, + "scripts": { + "test": "node --test js/*.test.mjs" + }, "dependencies": { "maplibre-gl": "5.24.0" } diff --git a/scripts/quality.sh b/scripts/quality.sh index f0fdfe0..50d8e40 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -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