diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 8ddd9c3..ea54d3e 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -1,8 +1,10 @@ import maplibregl from "maplibre-gl" import { + clusterCountLabel, + clusterExpansionTarget, clusterSizeClass, - mergeNearbyMapPoints -} from "./map_cluster_points.mjs" + pointFeatureCollection +} from "./map_cluster_features.mjs" const trackingMinTimeMs = 5000 @@ -152,22 +154,6 @@ const areaFeatureCollection = points => ({ .map(areaPolygon) }) -const clusterFeatureCollection = points => ({ - type: "FeatureCollection", - features: points - .filter(point => point.type === "cluster") - .map(point => ({ - type: "Feature", - id: point.id, - properties: { - id: point.id, - count: point.count, - urgent_count: point.urgent_count || 0 - }, - geometry: {type: "Point", coordinates: [point.longitude, point.latitude]} - })) -}) - const mapViewport = map => { const bounds = map.getBounds() const container = map.getContainer() @@ -300,7 +286,10 @@ const createAidMap = element => { resizeObserver: null, markers: [], markerElements: new Map(), + points: [], + selectedItemId: null, clusterExpansionPending: false, + pendingMarkerRender: false, initializedViewport: false, locationBounds: null, recenterControl: null, @@ -310,7 +299,9 @@ const createAidMap = element => { areaSelectedId: `${element.id || "aid-map"}-area-selected`, clusterSourceId: `${element.id || "aid-map"}-clusters`, clusterCircleId: `${element.id || "aid-map"}-cluster-circle`, - clusterCountId: `${element.id || "aid-map"}-cluster-count`, + serverClusterCircleId: `${element.id || "aid-map"}-server-cluster-circle`, + pointCircleId: `${element.id || "aid-map"}-point-circle`, + pointSelectedId: `${element.id || "aid-map"}-point-selected`, statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null } @@ -458,6 +449,8 @@ const createAidMap = element => { } state.highlightRequest = id => { + state.selectedItemId = id || null + state.markerElements.forEach((element, requestId) => { element.classList.toggle("is-selected", requestId === id) }) @@ -465,6 +458,10 @@ const createAidMap = element => { if (state.map?.getLayer(state.areaSelectedId)) { state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""]) } + + if (state.map?.getLayer(state.pointSelectedId)) { + state.map.setFilter(state.pointSelectedId, ["==", ["get", "id"], id || ""]) + } } state.selectRequest = id => { @@ -486,7 +483,6 @@ const createAidMap = element => { const current = state.element.getBoundingClientRect() if (current.width >= 160 && current.height >= 160) { state.map.resize() - if (state.element.dataset.discoveryMap === "true") state.renderMarkers() } }) } @@ -517,6 +513,181 @@ const createAidMap = element => { return true } + state.expandNativeCluster = feature => { + const source = state.map?.getSource(state.clusterSourceId) + const clusterId = Number(feature?.properties?.cluster_id) + if (!source || !Number.isFinite(clusterId)) return + + Promise.resolve(source.getClusterExpansionZoom(clusterId)).then(expansionZoom => { + if (!state.active || !state.map) return + + const currentZoom = state.map.getZoom() + const targetZoom = clusterExpansionTarget(currentZoom, Number(expansionZoom)) + + if (currentZoom >= 21.75 && targetZoom <= 22) { + const count = Number( + feature.properties?.total_count || feature.properties?.point_count + ) || 1 + const label = (state.element.dataset.clusterLabel || "%{count} items") + .replace("%{count}", new Intl.NumberFormat(uiLocale()).format(count)) + + new maplibregl.Popup({offset: 18}) + .setLngLat(feature.geometry.coordinates) + .setDOMContent(state.popupContent({ + title: label, + location: state.element.dataset.clusterMaxZoomLabel || "" + })) + .addTo(state.map) + return + } + + state.clusterExpansionPending = true + state.map.easeTo({ + center: feature.geometry.coordinates, + zoom: targetZoom, + duration: 300 + }) + }) + } + + state.expandServerCluster = feature => { + if (!feature || !state.map) return + + const count = Number(feature.properties?.weight) || 1 + const requestedZoom = Number(feature.properties?.expansion_zoom) + const currentZoom = state.map.getZoom() + const expansionZoom = clusterExpansionTarget( + currentZoom, + Number.isFinite(requestedZoom) ? requestedZoom : currentZoom + 2 + ) + + if (currentZoom >= 21.75 && expansionZoom <= 22) { + const label = (state.element.dataset.clusterLabel || "%{count} items") + .replace("%{count}", String(count)) + + new maplibregl.Popup({offset: 18}) + .setLngLat(feature.geometry.coordinates) + .setDOMContent(state.popupContent({ + title: label, + location: state.element.dataset.clusterMaxZoomLabel || "" + })) + .addTo(state.map) + return + } + + state.clusterExpansionPending = true + state.map.easeTo({ + center: feature.geometry.coordinates, + zoom: expansionZoom, + duration: 300 + }) + } + + state.syncClusterMarkers = () => { + state.markers.forEach(marker => marker.remove()) + state.markers = [] + + if ( + !state.map || + state.element.dataset.discoveryMap !== "true" || + !state.map.getLayer(state.clusterCircleId) || + !state.map.getLayer(state.serverClusterCircleId) + ) return + + const features = state.map.queryRenderedFeatures({ + layers: [state.clusterCircleId, state.serverClusterCircleId] + }) + const seen = new Set() + + features.forEach(feature => { + const nativeCluster = + feature.properties?.cluster === true || feature.properties?.cluster === "true" + const key = nativeCluster + ? `native:${feature.properties.cluster_id}` + : `server:${feature.properties?.server_cluster_id || feature.properties?.id}` + + if (seen.has(key)) return + seen.add(key) + + const count = Number( + nativeCluster + ? feature.properties?.total_count || feature.properties?.point_count + : feature.properties?.weight + ) || 1 + const urgentCount = Number( + nativeCluster + ? feature.properties?.urgent_count + : feature.properties?.urgent_weight + ) || 0 + const button = document.createElement("button") + + button.type = "button" + button.className = `request-map-cluster ${clusterSizeClass(count)}` + button.dataset.clusterCount = String(count) + button.classList.toggle("is-urgent", urgentCount > 0) + button.textContent = clusterCountLabel(count, uiLocale()) + const formattedCount = new Intl.NumberFormat(uiLocale()).format(count) + button.setAttribute( + "aria-label", + (state.element.dataset.clusterLabel || "%{count} items") + .replace("%{count}", formattedCount) + ) + button.title = button.getAttribute("aria-label") + button.addEventListener("click", event => { + event.stopPropagation() + if (nativeCluster) { + state.expandNativeCluster(feature) + } else { + state.expandServerCluster(feature) + } + }) + + state.markers.push( + new maplibregl.Marker({element: button}) + .setLngLat(feature.geometry.coordinates) + .addTo(state.map) + ) + }) + } + + state.syncVisibleAreas = () => { + if (!state.map) return + + const areaSource = state.map.getSource(state.areaSourceId) + if (!areaSource) return + + let visiblePoints = state.points + + if (state.element.dataset.discoveryMap === "true") { + if (!state.map.getSource(state.clusterSourceId)?.loaded()) return + + const visibleIds = new Set( + state.map + .querySourceFeatures(state.clusterSourceId, { + filter: [ + "all", + ["!", ["has", "point_count"]], + ["!=", ["get", "item_type"], "cluster"] + ] + }) + .map(feature => feature.properties?.id) + .filter(Boolean) + ) + + visiblePoints = state.points.filter(point => visibleIds.has(point.id)) + } + + const signature = visiblePoints + .filter(point => !point.exact && Number.isFinite(point.radius_meters)) + .map(point => point.id) + .sort() + .join(":") + + if (signature === state.visibleAreaSignature) return + state.visibleAreaSignature = signature + areaSource.setData(areaFeatureCollection(visiblePoints)) + } + state.renderMarkers = () => { if (!state.map) return @@ -525,22 +696,45 @@ const createAidMap = element => { state.markerElements.clear() const points = markerPoints(state.element) - const displayPoints = mergeNearbyMapPoints(state.map, points) - const exactPoints = displayPoints.filter( - point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters)) - ) - const clusterPoints = displayPoints.filter(point => point.type === "cluster") - const areaFeatures = areaFeatureCollection(displayPoints) - const clusterFeatures = clusterFeatureCollection(clusterPoints) + state.points = points + const pointFeatures = pointFeatureCollection(points) + const areaFeatures = + state.element.dataset.discoveryMap === "true" + ? emptyFeatureCollection() + : areaFeatureCollection(points) const bounds = new maplibregl.LngLatBounds() - exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude])) - areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature)) - clusterFeatures.features.forEach(feature => bounds.extend(feature.geometry.coordinates)) - state.locationBounds = displayPoints.length > 0 ? bounds : null + points.forEach(point => { + const clusterBounds = point.bounds - if (state.map.isStyleLoaded()) { - const areaSource = state.map.getSource(state.areaSourceId) + if ( + clusterBounds && + [clusterBounds.west, clusterBounds.south, clusterBounds.east, clusterBounds.north] + .every(value => Number.isFinite(Number(value))) + ) { + bounds.extend([Number(clusterBounds.west), Number(clusterBounds.south)]) + bounds.extend([Number(clusterBounds.east), Number(clusterBounds.north)]) + } else { + bounds.extend([point.longitude, point.latitude]) + } + }) + state.locationBounds = points.length > 0 ? bounds : null + + const existingAreaSource = state.map.getSource(state.areaSourceId) + const existingPointSource = state.map.getSource(state.clusterSourceId) + + if ( + (!existingAreaSource || !existingPointSource) && + !state.map.isStyleLoaded() + ) { + state.pendingMarkerRender = true + return + } + + state.pendingMarkerRender = false + + { + const areaSource = existingAreaSource if (areaSource) { areaSource.setData(areaFeatures) @@ -550,12 +744,14 @@ const createAidMap = element => { id: state.areaFillId, type: "fill", source: state.areaSourceId, + minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0, paint: {"fill-color": "#278467", "fill-opacity": 0.18} }) state.map.addLayer({ id: state.areaLineId, type: "line", source: state.areaSourceId, + minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0, paint: { "line-color": "#16725b", "line-width": 2, @@ -566,6 +762,7 @@ const createAidMap = element => { id: state.areaSelectedId, type: "line", source: state.areaSourceId, + minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0, filter: ["==", ["get", "id"], ""], paint: {"line-color": "#d6573b", "line-width": 5} }) @@ -588,83 +785,171 @@ const createAidMap = element => { }) } - } + const pointSource = existingPointSource - exactPoints.forEach(point => { - const popup = new maplibregl.Popup({offset: 18}).setDOMContent(state.popupContent(point)) - const markerColor = point.type === "live" ? "#2563eb" : point.exact ? "#d6573b" : "#278467" - const marker = new maplibregl.Marker({color: markerColor}) - .setLngLat([point.longitude, point.latitude]) - .setPopup(popup) - .addTo(state.map) + if (pointSource) { + pointSource.setData(pointFeatures) + } else { + const sourceOptions = { + type: "geojson", + data: pointFeatures, + generateId: false + } - const markerElement = marker.getElement() - markerElement.dataset.markerType = point.type || "request" - markerElement.setAttribute("aria-label", point.title || uiText("mapMarkerLabel")) - - if (point.id) { - markerElement.dataset.requestId = point.id - markerElement.addEventListener("click", () => state.selectRequest(point.id)) - state.markerElements.set(point.id, markerElement) - } - - state.markers.push(marker) - }) - - clusterPoints.forEach(point => { - const element = document.createElement("button") - element.type = "button" - 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( - "aria-label", - (state.element.dataset.clusterLabel || "%{count} requests") - .replace("%{count}", String(point.count)) - ) - element.title = element.getAttribute("aria-label") - element.addEventListener("click", event => { - event.stopPropagation() - state.clusterExpansionPending = true - - const bounds = point.bounds - const hasBounds = - bounds && - [bounds.west, bounds.south, bounds.east, bounds.north] - .every(value => Number.isFinite(Number(value))) - const hasArea = - hasBounds && - (Math.abs(Number(bounds.east) - Number(bounds.west)) > 0.000001 || - Math.abs(Number(bounds.north) - Number(bounds.south)) > 0.000001) - - if (hasArea) { - state.map.fitBounds( - [ - [Number(bounds.west), Number(bounds.south)], - [Number(bounds.east), Number(bounds.north)] - ], - { - padding: 72, - maxZoom: Math.min(state.map.getZoom() + 5, 18), - duration: 300 + if (state.element.dataset.discoveryMap === "true") { + Object.assign(sourceOptions, { + maxzoom: 22, + cluster: true, + clusterRadius: 64, + clusterMaxZoom: 21, + clusterMinPoints: 2, + clusterProperties: { + total_count: ["+", ["get", "weight"]], + urgent_count: ["+", ["get", "urgent_weight"]] } - ) - } else { - state.map.easeTo({ - center: [point.longitude, point.latitude], - zoom: Math.min(state.map.getZoom() + 2, 20), - duration: 300 }) } - }) - const marker = new maplibregl.Marker({element}) - .setLngLat([point.longitude, point.latitude]) - .addTo(state.map) + state.map.addSource(state.clusterSourceId, sourceOptions) + state.map.addLayer({ + id: state.clusterCircleId, + type: "circle", + source: state.clusterSourceId, + filter: ["has", "point_count"], + paint: { + "circle-color": [ + "case", + [">", ["coalesce", ["get", "urgent_count"], 0], 0], + "#c2412d", + "#087f5b" + ], + "circle-radius": [ + "step", + ["coalesce", ["get", "total_count"], ["get", "point_count"]], + 22, + 100, + 25, + 1_000, + 28, + 10_000, + 31, + 100_000, + 34 + ], + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 3, + "circle-opacity": 0.96 + } + }) + state.map.addLayer({ + id: state.serverClusterCircleId, + type: "circle", + source: state.clusterSourceId, + filter: [ + "all", + ["!", ["has", "point_count"]], + ["==", ["get", "item_type"], "cluster"] + ], + paint: { + "circle-color": [ + "case", + [">", ["coalesce", ["get", "urgent_weight"], 0], 0], + "#c2412d", + "#087f5b" + ], + "circle-radius": [ + "step", + ["get", "weight"], + 22, + 100, + 25, + 1_000, + 28, + 10_000, + 31, + 100_000, + 34 + ], + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 3, + "circle-opacity": 0.96 + } + }) + state.map.addLayer({ + id: state.pointCircleId, + type: "circle", + source: state.clusterSourceId, + filter: [ + "all", + ["!", ["has", "point_count"]], + ["!=", ["get", "item_type"], "cluster"] + ], + paint: { + "circle-color": [ + "case", + ["==", ["get", "item_type"], "live"], + "#2563eb", + ["==", ["get", "exact"], true], + "#d6573b", + "#278467" + ], + "circle-radius": 9, + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 3 + } + }) + state.map.addLayer({ + id: state.pointSelectedId, + type: "circle", + source: state.clusterSourceId, + filter: ["==", ["get", "id"], state.selectedItemId || ""], + paint: { + "circle-color": "rgba(0,0,0,0)", + "circle-radius": 15, + "circle-stroke-color": "#111827", + "circle-stroke-width": 3 + } + }) - state.markers.push(marker) - }) + const interactiveLayers = [ + state.clusterCircleId, + state.serverClusterCircleId, + state.pointCircleId + ] + + interactiveLayers.forEach(layerId => { + state.map.on("mouseenter", layerId, () => { + state.map.getCanvas().style.cursor = "pointer" + }) + state.map.on("mouseleave", layerId, () => { + state.map.getCanvas().style.cursor = "" + }) + }) + + state.map.on("click", state.clusterCircleId, event => { + const feature = event.features?.[0] + if (feature) state.expandNativeCluster(feature) + }) + + state.map.on("click", state.serverClusterCircleId, event => { + const feature = event.features?.[0] + if (feature) state.expandServerCluster(feature) + }) + + state.map.on("click", state.pointCircleId, event => { + const feature = event.features?.[0] + const properties = feature?.properties + if (!feature || !properties) return + + if (properties.id) state.selectRequest(properties.id) + + new maplibregl.Popup({offset: 18}) + .setLngLat(feature.geometry.coordinates) + .setDOMContent(state.popupContent(properties)) + .addTo(state.map) + }) + } + } if (state.element.dataset.autoFit !== "false") state.fitMarkers() } @@ -752,7 +1037,6 @@ const createAidMap = element => { state.onMoveEnd = () => { if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return - state.renderMarkers() const viewport = mapViewport(state.map) if (!viewport) return @@ -764,11 +1048,23 @@ const createAidMap = element => { } } + state.onIdle = () => { + state.markReady() + + if (state.pendingMarkerRender) { + state.renderMarkers() + if (state.pendingMarkerRender) return + } + + state.syncVisibleAreas() + state.syncClusterMarkers() + } + state.onRequestHighlight = event => state.highlightRequest(event.detail?.id) state.map.on("dataloading", state.markLoading) state.map.on("load", state.onLoad) - state.map.on("idle", state.markReady) + state.map.on("idle", state.onIdle) state.map.on("moveend", state.onMoveEnd) window.addEventListener("wnh:request-highlight", state.onRequestHighlight) if ("ResizeObserver" in window) { @@ -785,7 +1081,7 @@ const createAidMap = element => { state.resizeObserver?.disconnect() state.map?.off("dataloading", state.markLoading) state.map?.off("load", state.onLoad) - state.map?.off("idle", state.markReady) + state.map?.off("idle", state.onIdle) state.map?.off("moveend", state.onMoveEnd) window.removeEventListener("wnh:request-highlight", state.onRequestHighlight) state.map?.remove() diff --git a/assets/js/map_cluster_features.mjs b/assets/js/map_cluster_features.mjs new file mode 100644 index 0000000..e01b066 --- /dev/null +++ b/assets/js/map_cluster_features.mjs @@ -0,0 +1,59 @@ +const finiteCount = (value, fallback) => { + const count = Number(value) + return Number.isFinite(count) && count >= 0 ? count : fallback +} + +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" +} + +export const clusterCountLabel = (count, locale = "en") => { + const value = finiteCount(count, 0) + + if (value < 1_000) return new Intl.NumberFormat(locale).format(value) + + return new Intl.NumberFormat(locale, { + notation: "compact", + maximumFractionDigits: value < 10_000 ? 1 : 0 + }).format(value) +} + +export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom = 22) => { + const current = finiteCount(currentZoom, 0) + const requested = finiteCount(requestedZoom, current + 1) + const nearestIntegerZoom = Math.round(current) + const normalizedCurrent = + Math.abs(current - nearestIntegerZoom) < 0.001 ? nearestIntegerZoom : current + const nextIntegerZoom = Math.floor(normalizedCurrent) + 1 + + return Math.min(maximumZoom, Math.max(nextIntegerZoom, requested)) +} + +export const pointFeatureCollection = points => ({ + type: "FeatureCollection", + features: points.map(point => ({ + type: "Feature", + id: point.id, + properties: { + id: point.id, + item_type: point.type || "request", + title: point.title || "", + location: point.location || "", + exact: point.exact === true, + radius_meters: Number.isFinite(point.radius_meters) ? point.radius_meters : 0, + weight: finiteCount(point.count, 1), + urgent_weight: finiteCount(point.urgent_count, 0), + server_cluster_id: point.cluster_id || "", + parent_cluster_id: point.parent_cluster_id || "", + hierarchy_level: finiteCount(point.hierarchy_level, 0), + expansion_zoom: finiteCount(point.expansion_zoom, 22) + }, + geometry: { + type: "Point", + coordinates: [Number(point.longitude), Number(point.latitude)] + } + })) +}) diff --git a/assets/js/map_cluster_features.test.mjs b/assets/js/map_cluster_features.test.mjs new file mode 100644 index 0000000..3bc4883 --- /dev/null +++ b/assets/js/map_cluster_features.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import {Supercluster} from "@maplibre/geojson-vt" +import { + clusterCountLabel, + clusterExpansionTarget, + clusterSizeClass, + pointFeatureCollection +} from "./map_cluster_features.mjs" + +const buildIndex = points => { + const index = new Supercluster({ + radius: 64, + maxZoom: 21, + minPoints: 2, + map: properties => ({ + total_count: properties.weight, + urgent_count: properties.urgent_weight + }), + reduce: (accumulated, properties) => { + accumulated.total_count += properties.total_count + accumulated.urgent_count += properties.urgent_count + } + }) + + index.load(pointFeatureCollection(points).features) + return index +} + +test("weighted server nodes preserve the represented record total", () => { + const index = buildIndex([ + {id: "request:4:1:1", type: "cluster", longitude: 30.50, latitude: 50.45, count: 700}, + { + id: "request:4:1:2", + type: "cluster", + longitude: 30.51, + latitude: 50.46, + count: 300, + urgent_count: 10 + }, + {id: "one", type: "request", longitude: -74.0, latitude: 40.7} + ]) + + const world = index.getClusters([-180, -85, 180, 85], 0) + const represented = world.reduce( + (sum, feature) => sum + (feature.properties.total_count || feature.properties.weight), + 0 + ) + const urgent = world.reduce( + (sum, feature) => sum + (feature.properties.urgent_count || feature.properties.urgent_weight), + 0 + ) + + assert.equal(represented, 1_001) + assert.equal(urgent, 10) +}) + +test("server hierarchy metadata survives GeoJSON conversion without becoming a native cluster", () => { + const feature = pointFeatureCollection([ + { + id: "request:11:1197:1378", + type: "cluster", + cluster_id: "request:11:1197:1378", + parent_cluster_id: "request:10:598:689", + hierarchy_level: 11, + expansion_zoom: 13, + longitude: 30.52, + latitude: 50.45, + count: 27 + } + ]).features[0] + + assert.equal(feature.properties.server_cluster_id, "request:11:1197:1378") + assert.equal(feature.properties.parent_cluster_id, "request:10:598:689") + assert.equal(feature.properties.hierarchy_level, 11) + assert.equal(feature.properties.expansion_zoom, 13) + assert.equal(feature.properties.weight, 27) + assert.equal("point_count" in feature.properties, false) +}) + +test("native clusters expose an expansion zoom and eventually split unique coordinates", () => { + const index = buildIndex([ + {id: "a", longitude: 30.52340, latitude: 50.45010}, + {id: "b", longitude: 30.52341, latitude: 50.45011}, + {id: "c", longitude: 30.60, latitude: 50.50} + ]) + + const world = index.getClusters([-180, -85, 180, 85], 0) + const cluster = world.find(feature => feature.properties.cluster) + + assert.ok(cluster) + assert.ok(index.getClusterExpansionZoom(cluster.properties.cluster_id) > 0) + assert.equal( + index.getClusters([30.5, 50.4, 30.7, 50.6], 22) + .filter(feature => !feature.properties.cluster) + .length, + 3 + ) +}) + +test("cluster size classes remain stable for accessible count buttons", () => { + 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") +}) + +test("large visual counts are compact while exact values remain available to accessibility text", () => { + assert.equal(clusterCountLabel(999, "en"), "999") + assert.equal(clusterCountLabel(1_250, "en"), "1.3K") + assert.equal(clusterCountLabel(48_533, "en"), "49K") + assert.equal(clusterCountLabel(1_000_000, "en"), "1M") +}) + +test("cluster expansion always advances at least one integer zoom level", () => { + assert.equal(clusterExpansionTarget(9, 9), 10) + assert.equal(clusterExpansionTarget(8.999_999_096_835_52, 9), 10) + assert.equal(clusterExpansionTarget(10.999_994_193_019_71, 11), 12) + assert.equal(clusterExpansionTarget(9.6, 10), 10) + assert.equal(clusterExpansionTarget(9.6, 13), 13) + assert.equal(clusterExpansionTarget(21.9, 24), 22) +}) diff --git a/assets/js/map_cluster_points.mjs b/assets/js/map_cluster_points.mjs deleted file mode 100644 index 41bb833..0000000 --- a/assets/js/map_cluster_points.mjs +++ /dev/null @@ -1,147 +0,0 @@ -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]) - } -} - -const finiteCoordinate = value => { - const coordinate = Number(value) - return Number.isFinite(coordinate) ? coordinate : null -} - -const pointBounds = point => { - const longitude = finiteCoordinate(point?.longitude) - const latitude = finiteCoordinate(point?.latitude) - const source = point?.bounds || {} - const west = finiteCoordinate(source.west) ?? longitude - const south = finiteCoordinate(source.south) ?? latitude - const east = finiteCoordinate(source.east) ?? longitude - const north = finiteCoordinate(source.north) ?? latitude - - if ([west, south, east, north].some(value => value === null)) return null - - return { - west: Math.min(west, east), - south: Math.min(south, north), - east: Math.max(west, east), - north: Math.max(south, north) - } -} - -const groupBounds = points => { - const bounds = points.map(pointBounds).filter(Boolean) - if (bounds.length === 0) return null - - return bounds.reduce( - (result, current) => ({ - west: Math.min(result.west, current.west), - south: Math.min(result.south, current.south), - east: Math.max(result.east, current.east), - north: Math.max(result.north, current.north) - }), - bounds[0] - ) -} - -const nearbyMapPointGroups = (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 -} - -const visualCluster = (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, - bounds: groupBounds(group.points) - } -} - -export const mergeNearbyMapClusters = (map, points, options = {}) => - nearbyMapPointGroups(map, points, options).map(group => visualCluster(map, group)) - -export const mergeNearbyMapPoints = (map, points, options = {}) => { - return nearbyMapPointGroups(map, points, options).map(group => { - const point = group.points[0] - - if (group.points.length === 1 && point?.type !== "cluster") return point - - return visualCluster(map, group) - }) -} diff --git a/assets/js/map_cluster_points.test.mjs b/assets/js/map_cluster_points.test.mjs deleted file mode 100644 index 66e2804..0000000 --- a/assets/js/map_cluster_points.test.mjs +++ /dev/null @@ -1,101 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { - clusterDiameterPixels, - clusterSizeClass, - mergeNearbyMapClusters, - mergeNearbyMapPoints -} 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, - bounds: {west: -5, south: -2, east: 5, north: 2} - }, - { - id: "b", - type: "cluster", - count: 50, - urgent_count: 1, - longitude: 30, - latitude: 0, - bounds: {west: 25, south: -3, east: 35, north: 4} - }, - {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) - assert.deepEqual(merged.bounds, {west: -5, south: -3, east: 35, north: 4}) -}) - -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("merges a singleton into an overlapping cluster at a distant zoom", () => { - const points = [ - {id: "cluster", type: "cluster", count: 351, longitude: 0, latitude: 0}, - {id: "request", type: "request", longitude: 28, latitude: 0, exact: true} - ] - - const result = mergeNearbyMapPoints(flatMap(1), points) - - assert.equal(result.length, 1) - assert.equal(result[0].type, "cluster") - assert.equal(result[0].count, 352) - assert.deepEqual(result[0].bounds, {west: 0, south: 0, east: 28, north: 0}) -}) - -test("keeps separated singleton points interactive at a closer zoom", () => { - const points = [ - {id: "left", type: "request", longitude: 0, latitude: 0, exact: true}, - { - id: "right", - type: "request", - longitude: 30, - latitude: 0, - radius_meters: 1_000 - } - ] - - const result = mergeNearbyMapPoints(flatMap(3), points) - - assert.deepEqual(result, points) -}) - -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/lib/mix/tasks/wnh.scale_fixtures.ex b/lib/mix/tasks/wnh.scale_fixtures.ex index fda3edf..250f954 100644 --- a/lib/mix/tasks/wnh.scale_fixtures.ex +++ b/lib/mix/tasks/wnh.scale_fixtures.ex @@ -11,7 +11,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do @default_rows 1_000_000 @synthetic_users 1_000 @viewer_email "scale-viewer@example.invalid" - @fixture_version 7 + @fixture_version 8 @request_open_modulus 1 @activity_open_modulus 1 @places [ @@ -425,10 +425,11 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do 'Synthetic data only; no real-world action is requested.', jsonb_build_object( 'scale_fixture', true, - 'scale_fixture_version', $7::integer + 'scale_fixture_version', $7::integer, + 'scale_place', place.name, + 'scale_location_index', local.local_index ), - place.name || ' synthetic area ' || - ((variation.category_key + variation.time_key) % 100 + 1), + place.name || ' synthetic location ' || (local.local_index + 1), CASE WHEN variation.visibility_key % 20 = 0 THEN NULL ELSE ST_SetSRID( ST_MakePoint(coordinates.longitude, coordinates.latitude), @@ -469,8 +470,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do FROM generate_series(1, $1) AS value CROSS JOIN LATERAL ( SELECT - md5('wnh-scale-request-v5-' || value) AS fixture_hash, - md5('wnh-scale-request-geography-v5-' || value) AS geography_hash + md5('wnh-scale-request-v8-' || value) AS fixture_hash ) AS fixture CROSS JOIN LATERAL ( SELECT @@ -482,52 +482,56 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do AS time_key, ('x' || substr(fixture.fixture_hash, 25, 8))::bit(32)::bigint AS category_key, - ('x' || substr(fixture.geography_hash, 1, 8))::bit(32)::bigint - AS place_key, - ('x' || substr(fixture.geography_hash, 9, 8))::bit(32)::bigint - AS angle_key, - ('x' || substr(fixture.geography_hash, 17, 8))::bit(32)::bigint - AS distance_key, - ('x' || substr(fixture.geography_hash, 25, 8))::bit(32)::bigint - AS owner_key + ('x' || substr(md5('wnh-scale-request-owner-v8-' || value), 1, 8)) + ::bit(32)::bigint AS owner_key ) AS variation CROSS JOIN LATERAL ( - SELECT name, longitude, latitude + SELECT name, longitude, latitude, position FROM unnest( $4::text[], $5::double precision[], $6::double precision[] ) WITH ORDINALITY AS places(name, longitude, latitude, position) - WHERE places.position = variation.place_key % cardinality($4::text[]) + 1 + WHERE places.position = ((value - 1) % cardinality($4::text[])) + 1 ) AS place CROSS JOIN LATERAL ( SELECT - 2 * pi() * - ((variation.angle_key % 1000003)::double precision / 1000003.0) AS angle, - sqrt( - ((variation.distance_key / 20 % 1000003)::double precision + 1) / - 1000004.0 - ) * - CASE WHEN variation.distance_key % 20 < 12 THEN 15.0 - WHEN variation.distance_key % 20 < 18 THEN 60.0 - ELSE 220.0 - END AS distance_km - ) AS geography + (value - 1) / cardinality($4::text[]) AS local_index + ) AS local CROSS JOIN LATERAL ( SELECT - place.latitude + - geography.distance_km / 111.32 * sin(geography.angle) AS raw_latitude, + ( + ((local.local_index % 256) * 73 + + ((local.local_index / 256) % 256) * 16) % 256 + )::double precision AS grid_x, + ( + (((local.local_index / 256) % 256) * 53 + + (local.local_index % 256) * 29) % 256 + )::double precision AS grid_y + ) AS grid + CROSS JOIN LATERAL ( + SELECT + (grid.grid_x / 255.0 - 0.5) * 12.0 AS east_km, + (grid.grid_y / 255.0 - 0.5) * 12.0 AS north_km, + radians((place.position * 137.50776405) % 360.0) AS rotation + ) AS local_offset + CROSS JOIN LATERAL ( + SELECT + local_offset.east_km * cos(local_offset.rotation) - + local_offset.north_km * sin(local_offset.rotation) AS east_km, + local_offset.east_km * sin(local_offset.rotation) + + local_offset.north_km * cos(local_offset.rotation) AS north_km + ) AS rotated_offset + CROSS JOIN LATERAL ( + SELECT + greatest( + -84.0, + least(84.0, place.latitude + rotated_offset.north_km / 111.32) + ) AS latitude, place.longitude + - geography.distance_km / - (111.32 * greatest(abs(cos(radians(place.latitude))), 0.2)) * - cos(geography.angle) AS raw_longitude - ) AS raw_coordinates - CROSS JOIN LATERAL ( - SELECT - greatest(-84.0, least(84.0, raw_coordinates.raw_latitude)) AS latitude, - (raw_coordinates.raw_longitude + 180.0) - - 360.0 * floor((raw_coordinates.raw_longitude + 180.0) / 360.0) - - 180.0 AS longitude + rotated_offset.east_km / + (111.32 * greatest(abs(cos(radians(place.latitude))), 0.2)) + AS longitude ) AS coordinates ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, @@ -578,10 +582,11 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do 'Synthetic scale activity used to test geographic discovery, privacy, and UI.', jsonb_build_object( 'scale_fixture', true, - 'scale_fixture_version', $7::integer + 'scale_fixture_version', $7::integer, + 'scale_place', place.name, + 'scale_location_index', local.local_index ), - place.name || ' synthetic area ' || - ((variation.category_key + variation.time_key) % 100 + 1), + place.name || ' synthetic location ' || (local.local_index + 1), ST_SetSRID( ST_MakePoint(coordinates.longitude, coordinates.latitude), 4326 @@ -615,8 +620,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do FROM generate_series(1, $1) AS value CROSS JOIN LATERAL ( SELECT - md5('wnh-scale-activity-v5-' || value) AS fixture_hash, - md5('wnh-scale-activity-geography-v5-' || value) AS geography_hash + md5('wnh-scale-activity-v8-' || value) AS fixture_hash ) AS fixture CROSS JOIN LATERAL ( SELECT @@ -628,52 +632,56 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do AS time_key, ('x' || substr(fixture.fixture_hash, 25, 8))::bit(32)::bigint AS category_key, - ('x' || substr(fixture.geography_hash, 1, 8))::bit(32)::bigint - AS place_key, - ('x' || substr(fixture.geography_hash, 9, 8))::bit(32)::bigint - AS angle_key, - ('x' || substr(fixture.geography_hash, 17, 8))::bit(32)::bigint - AS distance_key, - ('x' || substr(fixture.geography_hash, 25, 8))::bit(32)::bigint - AS owner_key + ('x' || substr(md5('wnh-scale-activity-owner-v8-' || value), 1, 8)) + ::bit(32)::bigint AS owner_key ) AS variation CROSS JOIN LATERAL ( - SELECT name, longitude, latitude + SELECT name, longitude, latitude, position FROM unnest( $4::text[], $5::double precision[], $6::double precision[] ) WITH ORDINALITY AS places(name, longitude, latitude, position) - WHERE places.position = variation.place_key % cardinality($4::text[]) + 1 + WHERE places.position = ((value - 1) % cardinality($4::text[])) + 1 ) AS place CROSS JOIN LATERAL ( SELECT - 2 * pi() * - ((variation.angle_key % 1000003)::double precision / 1000003.0) AS angle, - sqrt( - ((variation.distance_key / 20 % 1000003)::double precision + 1) / - 1000004.0 - ) * - CASE WHEN variation.distance_key % 20 < 12 THEN 15.0 - WHEN variation.distance_key % 20 < 18 THEN 60.0 - ELSE 220.0 - END AS distance_km - ) AS geography + (value - 1) / cardinality($4::text[]) AS local_index + ) AS local CROSS JOIN LATERAL ( SELECT - place.latitude + - geography.distance_km / 111.32 * sin(geography.angle) AS raw_latitude, + ( + ((local.local_index % 256) * 73 + + ((local.local_index / 256) % 256) * 16 + 31) % 256 + )::double precision AS grid_x, + ( + (((local.local_index / 256) % 256) * 53 + + (local.local_index % 256) * 29 + 47) % 256 + )::double precision AS grid_y + ) AS grid + CROSS JOIN LATERAL ( + SELECT + (grid.grid_x / 255.0 - 0.5) * 12.0 AS east_km, + (grid.grid_y / 255.0 - 0.5) * 12.0 AS north_km, + radians((place.position * 137.50776405 + 19.0) % 360.0) AS rotation + ) AS local_offset + CROSS JOIN LATERAL ( + SELECT + local_offset.east_km * cos(local_offset.rotation) - + local_offset.north_km * sin(local_offset.rotation) AS east_km, + local_offset.east_km * sin(local_offset.rotation) + + local_offset.north_km * cos(local_offset.rotation) AS north_km + ) AS rotated_offset + CROSS JOIN LATERAL ( + SELECT + greatest( + -84.0, + least(84.0, place.latitude + rotated_offset.north_km / 111.32) + ) AS latitude, place.longitude + - geography.distance_km / - (111.32 * greatest(abs(cos(radians(place.latitude))), 0.2)) * - cos(geography.angle) AS raw_longitude - ) AS raw_coordinates - CROSS JOIN LATERAL ( - SELECT - greatest(-84.0, least(84.0, raw_coordinates.raw_latitude)) AS latitude, - (raw_coordinates.raw_longitude + 180.0) - - 360.0 * floor((raw_coordinates.raw_longitude + 180.0) / 360.0) - - 180.0 AS longitude + rotated_offset.east_km / + (111.32 * greatest(abs(cos(radians(place.latitude))), 0.2)) + AS longitude ) AS coordinates ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, @@ -1125,7 +1133,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do defp fixture_place_count!("help_requests") do scalar!(""" - SELECT count(DISTINCT regexp_replace(location_label, ' synthetic area [0-9]+$', '')) + SELECT count(DISTINCT regexp_replace(location_label, ' synthetic location [0-9]+$', '')) FROM help_requests WHERE structured_data @> '{"scale_fixture": true}'::jsonb """) @@ -1133,7 +1141,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do defp fixture_place_count!("activities") do scalar!(""" - SELECT count(DISTINCT regexp_replace(location_label, ' synthetic area [0-9]+$', '')) + SELECT count(DISTINCT regexp_replace(location_label, ' synthetic location [0-9]+$', '')) FROM activities WHERE structured_data @> '{"scale_fixture": true}'::jsonb """) @@ -1146,7 +1154,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do SELECT count(*) AS place_rows FROM help_requests WHERE structured_data @> '{"scale_fixture": true}'::jsonb - GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + GROUP BY regexp_replace(location_label, ' synthetic location [0-9]+$', '') ) AS grouped_places """) end @@ -1158,7 +1166,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do SELECT count(*) AS place_rows FROM activities WHERE structured_data @> '{"scale_fixture": true}'::jsonb - GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + GROUP BY regexp_replace(location_label, ' synthetic location [0-9]+$', '') ) AS grouped_places """) end @@ -1173,7 +1181,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do AND status = 'open' AND expires_at > now() AND location_visibility <> 'hidden' - GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + GROUP BY regexp_replace(location_label, ' synthetic location [0-9]+$', '') ) AS grouped_places """) end @@ -1188,7 +1196,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do AND status = 'open' AND starts_at > now() AND location_visibility = 'approximate_public' - GROUP BY regexp_replace(location_label, ' synthetic area [0-9]+$', '') + GROUP BY regexp_replace(location_label, ' synthetic location [0-9]+$', '') ) AS grouped_places """) end diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index accf9d2..6774bff 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -10,7 +10,7 @@ defmodule WhoNeedHelp.Activities do alias WhoNeedHelp.Activities.{Activity, Message, Participant} alias WhoNeedHelp.Catalog alias WhoNeedHelp.Catalog.Category - alias WhoNeedHelp.Help.DiscoveryViewport + alias WhoNeedHelp.Help.{DiscoveryCluster, DiscoveryViewport} alias WhoNeedHelp.Pagination alias WhoNeedHelp.Repo alias WhoNeedHelp.Trust @@ -53,7 +53,9 @@ defmodule WhoNeedHelp.Activities do end def map_discovery_items(%Scope{user: user}, filters, %DiscoveryViewport{} = viewport) do - cell_size = DiscoveryViewport.cluster_cell_meters(viewport) + level = DiscoveryCluster.level_for_zoom(viewport.zoom) + cell_size = DiscoveryCluster.cell_size_meters(level) + world_half = DiscoveryCluster.world_half_meters() points = user @@ -87,65 +89,75 @@ defmodule WhoNeedHelp.Activities do points |> subquery() - |> group_by( - [point], + |> group_by([point], [ fragment( - "ST_SnapToGrid(ST_Transform(?, 3857), ?)", + "floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), + constant(^cell_size) + ), + fragment( + "floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint", + point.public_location, + constant(^world_half), constant(^cell_size) ) - ) + ]) |> select([point], %{ count: count(point.id), activity_id: fragment("CASE WHEN count(*) = 1 THEN min(?::text) END", point.id), title: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.title), location_label: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.location_label), - longitude: - fragment( - "CASE WHEN count(*) = 1 THEN min(ST_X(?)) ELSE avg(ST_X(?)) END", - point.public_location, - point.public_location - ), - latitude: - fragment( - "CASE WHEN count(*) = 1 THEN min(ST_Y(?)) ELSE avg(ST_Y(?)) END", - point.public_location, - point.public_location - ), + longitude: fragment("avg(ST_X(?))", point.public_location), + latitude: fragment("avg(ST_Y(?))", point.public_location), west: fragment("min(ST_X(?))", point.public_location), south: fragment("min(ST_Y(?))", point.public_location), east: fragment("max(ST_X(?))", point.public_location), north: fragment("max(ST_Y(?))", point.public_location), + west_m: fragment("min(ST_X(ST_Transform(?, 3857)))", point.public_location), + south_m: fragment("min(ST_Y(ST_Transform(?, 3857)))", point.public_location), + east_m: fragment("max(ST_X(ST_Transform(?, 3857)))", point.public_location), + north_m: fragment("max(ST_Y(ST_Transform(?, 3857)))", point.public_location), cell_x: fragment( - "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ), cell_y: fragment( - "ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ) }) - |> order_by( - [point], + |> order_by([point], asc: fragment( - "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ), asc: fragment( - "ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ) ) |> Repo.all() - |> Enum.map(&map_discovery_item(&1, viewport.zoom)) + |> Enum.map(&map_discovery_item(&1, level)) + end + + def count_map_discovery_items(%Scope{user: user}, filters) do + user + |> open_activities_query(filters, nil) + |> where([activity], activity.location_visibility == :approximate_public) + |> Repo.aggregate(:count, :id) end def list_my_activities(%Scope{user: user}) do @@ -968,10 +980,12 @@ defmodule WhoNeedHelp.Activities do defp longitude_in_viewport?(longitude, %DiscoveryViewport{west: west, east: east}), do: longitude >= west or longitude <= east - defp map_discovery_item(%{count: 1} = row, _zoom) do + defp map_discovery_item(%{count: 1} = row, level) do %{ type: "activity", id: row.activity_id, + parent_cluster_id: DiscoveryCluster.parent_id(:activity, level, row.cell_x, row.cell_y), + hierarchy_level: level, title: row.title, location: row.location_label, latitude: row.latitude, @@ -980,15 +994,24 @@ defmodule WhoNeedHelp.Activities do } end - defp map_discovery_item(row, zoom) do - digest = - :crypto.hash(:sha256, "activity:#{zoom}:#{row.cell_x}:#{row.cell_y}") - |> Base.url_encode64(padding: false) - |> binary_part(0, 16) + defp map_discovery_item(row, level) do + cluster_id = DiscoveryCluster.cluster_id(:activity, level, row.cell_x, row.cell_y) %{ type: "cluster", - id: "activity-cluster-#{digest}", + id: cluster_id, + cluster_id: cluster_id, + parent_cluster_id: DiscoveryCluster.parent_id(:activity, level, row.cell_x, row.cell_y), + hierarchy_level: level, + point_count: row.count, + expansion_zoom: + DiscoveryCluster.expansion_zoom( + level, + row.west_m, + row.south_m, + row.east_m, + row.north_m + ), count: row.count, latitude: row.latitude, longitude: row.longitude, diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index 09399cd..6da2c6d 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Help do alias WhoNeedHelp.Accounts.Scope alias WhoNeedHelp.Catalog alias WhoNeedHelp.Catalog.Category - alias WhoNeedHelp.Help.{Assignment, DiscoveryViewport, HelpRequest} + alias WhoNeedHelp.Help.{Assignment, DiscoveryCluster, DiscoveryViewport, HelpRequest} alias WhoNeedHelp.Pagination alias WhoNeedHelp.ProductAnalytics alias WhoNeedHelp.Push @@ -64,12 +64,17 @@ defmodule WhoNeedHelp.Help do end def map_discovery_items(%Scope{user: user}, filters, %DiscoveryViewport{} = viewport) do - cell_size = DiscoveryViewport.cluster_cell_meters(viewport) + level = DiscoveryCluster.level_for_zoom(viewport.zoom) + cell_size = DiscoveryCluster.cell_size_meters(level) + world_half = DiscoveryCluster.world_half_meters() points = user |> open_requests_query(filters, nil) - |> where([request], not is_nil(request.location)) + |> where( + [request], + not is_nil(request.location) and request.location_visibility != :hidden + ) |> filter_request_map_viewport(viewport) |> select([request], %{ id: request.id, @@ -105,14 +110,20 @@ defmodule WhoNeedHelp.Help do points |> subquery() - |> group_by( - [point], + |> group_by([point], [ fragment( - "ST_SnapToGrid(ST_Transform(?, 3857), ?)", + "floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), + constant(^cell_size) + ), + fragment( + "floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint", + point.public_location, + constant(^world_half), constant(^cell_size) ) - ) + ]) |> select([point], %{ count: count(point.id), urgent_count: fragment("count(*) FILTER (WHERE ? = 'now')::bigint", point.urgency), @@ -125,52 +136,59 @@ defmodule WhoNeedHelp.Help do point.location_visibility ), radius_meters: fragment("CASE WHEN count(*) = 1 THEN max(?) END", point.radius_meters), - longitude: - fragment( - "CASE WHEN count(*) = 1 THEN min(ST_X(?)) ELSE avg(ST_X(?)) END", - point.public_location, - point.public_location - ), - latitude: - fragment( - "CASE WHEN count(*) = 1 THEN min(ST_Y(?)) ELSE avg(ST_Y(?)) END", - point.public_location, - point.public_location - ), + longitude: fragment("avg(ST_X(?))", point.public_location), + latitude: fragment("avg(ST_Y(?))", point.public_location), west: fragment("min(ST_X(?))", point.public_location), south: fragment("min(ST_Y(?))", point.public_location), east: fragment("max(ST_X(?))", point.public_location), north: fragment("max(ST_Y(?))", point.public_location), + west_m: fragment("min(ST_X(ST_Transform(?, 3857)))", point.public_location), + south_m: fragment("min(ST_Y(ST_Transform(?, 3857)))", point.public_location), + east_m: fragment("max(ST_X(ST_Transform(?, 3857)))", point.public_location), + north_m: fragment("max(ST_Y(ST_Transform(?, 3857)))", point.public_location), cell_x: fragment( - "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ), cell_y: fragment( - "ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ) }) - |> order_by( - [point], + |> order_by([point], asc: fragment( - "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ), asc: fragment( - "ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))", + "floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint", point.public_location, + constant(^world_half), constant(^cell_size) ) ) |> Repo.all() - |> Enum.map(&map_discovery_item(&1, viewport.zoom)) + |> Enum.map(&map_discovery_item(&1, level)) + end + + def count_map_discovery_items(%Scope{user: user}, filters) do + user + |> open_requests_query(filters, nil) + |> where( + [request], + not is_nil(request.location) and request.location_visibility != :hidden + ) + |> Repo.aggregate(:count, :id) end # Discovery lists include a privacy area's intersection with the viewport. @@ -1274,10 +1292,12 @@ defmodule WhoNeedHelp.Help do where(query, ^condition) end - defp map_discovery_item(%{count: 1} = row, _zoom) do + defp map_discovery_item(%{count: 1} = row, level) do %{ type: "request", id: row.request_id, + parent_cluster_id: DiscoveryCluster.parent_id(:request, level, row.cell_x, row.cell_y), + hierarchy_level: level, title: row.title, location: row.location_label, latitude: row.latitude, @@ -1287,15 +1307,24 @@ defmodule WhoNeedHelp.Help do } end - defp map_discovery_item(row, zoom) do - digest = - :crypto.hash(:sha256, "#{zoom}:#{row.cell_x}:#{row.cell_y}") - |> Base.url_encode64(padding: false) - |> binary_part(0, 16) + defp map_discovery_item(row, level) do + cluster_id = DiscoveryCluster.cluster_id(:request, level, row.cell_x, row.cell_y) %{ type: "cluster", - id: "cluster-#{digest}", + id: cluster_id, + cluster_id: cluster_id, + parent_cluster_id: DiscoveryCluster.parent_id(:request, level, row.cell_x, row.cell_y), + hierarchy_level: level, + point_count: row.count, + expansion_zoom: + DiscoveryCluster.expansion_zoom( + level, + row.west_m, + row.south_m, + row.east_m, + row.north_m + ), count: row.count, urgent_count: row.urgent_count, latitude: row.latitude, diff --git a/lib/who_need_help/help/discovery_cluster.ex b/lib/who_need_help/help/discovery_cluster.ex new file mode 100644 index 0000000..ec4c9a9 --- /dev/null +++ b/lib/who_need_help/help/discovery_cluster.ex @@ -0,0 +1,99 @@ +defmodule WhoNeedHelp.Help.DiscoveryCluster do + @moduledoc """ + Stable Web Mercator hierarchy used as the server-side input to MapLibre + Supercluster. + + A browser cannot safely receive every discovery point once the data set + reaches millions of rows. The server therefore groups points into a fixed + quadtree. Unlike viewport-sized `ST_SnapToGrid` buckets, every cell has a + stable identity and every cell at level `n + 1` belongs to exactly one parent + at level `n`. + + MapLibre receives these weighted nodes and applies its native Supercluster + implementation for pixel-perfect collision handling. When a server node is + reached, `expansion_zoom/5` identifies the first map zoom where that node has + more than one child cell. + """ + + @world_half_meters 20_037_508.342_789_244 + @world_width_meters @world_half_meters * 2.0 + @cluster_level_offset 2 + @minimum_level 0 + @maximum_map_zoom 22 + @maximum_level @maximum_map_zoom + @cluster_level_offset + + @type kind :: :request | :activity + + def level_for_zoom(zoom) when is_number(zoom) do + zoom + |> floor() + |> Kernel.+(@cluster_level_offset) + |> clamp(@minimum_level, @maximum_level) + end + + def cell_size_meters(level) when is_integer(level) and level in 0..@maximum_level do + @world_width_meters / :math.pow(2.0, level) + end + + def world_half_meters, do: @world_half_meters + + def cluster_id(kind, level, cell_x, cell_y) + when kind in [:request, :activity] and is_integer(level) and is_integer(cell_x) and + is_integer(cell_y) do + "#{kind}:#{level}:#{cell_x}:#{cell_y}" + end + + def parent_id(_kind, 0, _cell_x, _cell_y), do: nil + + def parent_id(kind, level, cell_x, cell_y) + when kind in [:request, :activity] and is_integer(level) and level > 0 and + is_integer(cell_x) and is_integer(cell_y) do + cluster_id(kind, level - 1, div(cell_x, 2), div(cell_y, 2)) + end + + @doc """ + Returns the first map zoom at which a fixed hierarchy cell separates. + + The bounds must be expressed in EPSG:3857 meters and describe the actual + member points, not the cell envelope. For coincident points the result is the + maximum supported map zoom. + """ + def expansion_zoom(level, west, south, east, north) + when is_integer(level) and is_number(west) and is_number(south) and is_number(east) and + is_number(north) do + west = clamp_coordinate(west) + east = clamp_coordinate(east) + south = clamp_coordinate(south) + north = clamp_coordinate(north) + + candidate_level = + if level >= @maximum_level do + @maximum_level + else + Enum.find((level + 1)..@maximum_level, @maximum_level, fn candidate -> + cell_index(west, candidate) != cell_index(east, candidate) or + cell_index(south, candidate) != cell_index(north, candidate) + end) + end + + candidate_level + |> Kernel.-(@cluster_level_offset) + |> clamp(0, @maximum_map_zoom) + end + + defp cell_index(coordinate, level) do + coordinate + |> Kernel.+(@world_half_meters) + |> Kernel./(cell_size_meters(level)) + |> floor() + end + + defp clamp_coordinate(value) do + value + |> Kernel.*(1.0) + |> max(-@world_half_meters) + |> min(@world_half_meters - 1.0e-7) + end + + defp clamp(value, minimum, maximum), do: value |> max(minimum) |> min(maximum) +end diff --git a/lib/who_need_help/help/discovery_viewport.ex b/lib/who_need_help/help/discovery_viewport.ex index 58db4cb..615c50b 100644 --- a/lib/who_need_help/help/discovery_viewport.ex +++ b/lib/who_need_help/help/discovery_viewport.ex @@ -2,18 +2,18 @@ defmodule WhoNeedHelp.Help.DiscoveryViewport do @moduledoc """ Validated map bounds used by request discovery. - The viewport carries its rendered pixel size so server-side clustering can - match the amount of detail the browser can actually display. Longitudes - crossing the antimeridian are represented as two PostGIS envelopes. + The viewport carries its rendered pixel size for subscription capacity and + input validation. Discovery clustering itself uses a stable zoom-indexed + hierarchy, so a cluster does not change identity when the canvas is resized. + Longitudes crossing the antimeridian are represented as two PostGIS + envelopes. """ @max_mercator_latitude 85.051_128_78 @maximum_canvas_dimension 4_096 @minimum_canvas_dimension 160 - @cluster_cell_pixels 64.0 @map_tile_pixels 256 @maximum_subscription_zoom 12 - @earth_radius_meters 6_378_137.0 @enforce_keys [:west, :south, :east, :north, :zoom, :width, :height] defstruct [:west, :south, :east, :north, :zoom, :width, :height] @@ -106,22 +106,6 @@ defmodule WhoNeedHelp.Help.DiscoveryViewport do |> Enum.uniq() end - def cluster_cell_meters(%__MODULE__{} = viewport) do - horizontal_cells = max(viewport.width / @cluster_cell_pixels, 1.0) - vertical_cells = max(viewport.height / @cluster_cell_pixels, 1.0) - - width_meters = - viewport - |> envelopes() - |> Enum.map(fn {west, _south, east, _north} -> mercator_x(east) - mercator_x(west) end) - |> Enum.sum() - - height_meters = mercator_y(viewport.north) - mercator_y(viewport.south) - - max(width_meters / horizontal_cells, height_meters / vertical_cells) - |> max(0.01) - end - def query_params(%__MODULE__{} = viewport) do %{ "west" => format_number(viewport.west), @@ -310,14 +294,6 @@ defmodule WhoNeedHelp.Help.DiscoveryViewport do defp latitude?(value), do: value >= -@max_mercator_latitude and value <= @max_mercator_latitude - defp mercator_x(longitude), do: @earth_radius_meters * degrees_to_radians(longitude) - - defp mercator_y(latitude) do - latitude = min(max(latitude, -@max_mercator_latitude), @max_mercator_latitude) - radians = degrees_to_radians(latitude) - @earth_radius_meters * :math.log(:math.tan(:math.pi() / 4 + radians / 2)) - end - defp degrees_to_radians(value), do: value * :math.pi() / 180.0 defp clamp(value, minimum, maximum), do: min(max(value, minimum), maximum) diff --git a/lib/who_need_help_web/live/activity_live/index.ex b/lib/who_need_help_web/live/activity_live/index.ex index be8bd85..ddec43e 100644 --- a/lib/who_need_help_web/live/activity_live/index.ex +++ b/lib/who_need_help_web/live/activity_live/index.ex @@ -29,6 +29,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do |> assign(:activities_cursor, nil) |> assign(:map_items, Jason.encode!([])) |> assign(:map_result_count, 0) + |> assign(:map_total_count, 0) |> assign(:discovery_loading, true) |> assign(:refresh_timer, nil) |> assign(:my_activities, []) @@ -266,6 +267,15 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do activities = activities_page.entries + map_total_count = + if discovery_mode == "nearby" and view_mode != "list" do + DiscoveryQueryCache.fetch(map_total_cache_key(scope, filters), fn -> + Activities.count_map_discovery_items(scope, filters) + end) + else + 0 + end + %{ discovery_loading: false, activities: activities, @@ -273,7 +283,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do my_activities: my_activities_page.entries, my_activities_cursor: my_activities_page.next_cursor, map_items: Jason.encode!(map_items), - map_result_count: Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)) + map_result_count: Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)), + map_total_count: map_total_count } end @@ -338,6 +349,11 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do ) ) + :ok = + DiscoveryQueryCache.invalidate( + map_total_cache_key(socket.assigns.current_scope, socket.assigns.filters) + ) + socket end @@ -345,6 +361,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do {:activities, scope.user.id, filters, discovery_mode, view_mode, viewport} end + defp map_total_cache_key(scope, filters), do: {:activity_map_total, scope.user.id, filters} + defp empty_filters, do: %{"category_id" => "", "area" => ""} defp normalize_filters(params) do @@ -513,6 +531,11 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do count: @map_result_count )} + + {gettext("of %{count} matching activities with map locations", + count: @map_total_count + )} +

{gettext("Move the map, then search the newly visible area.")}

@@ -649,6 +672,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do data-item-path-prefix="/activities" data-item-label={gettext("Open activity")} data-cluster-label={gettext("%{count} activities", count: "%{count}")} + data-cluster-max-zoom-label={ + gettext("Maximum zoom reached. Matching activities are listed beside the map.") + } data-map-unavailable-label={ gettext("The map is unavailable in this browser. Activity details remain usable.") } diff --git a/lib/who_need_help_web/live/request_live/index.ex b/lib/who_need_help_web/live/request_live/index.ex index 65b428f..9a11d6f 100644 --- a/lib/who_need_help_web/live/request_live/index.ex +++ b/lib/who_need_help_web/live/request_live/index.ex @@ -41,6 +41,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do |> assign(:reputation, @empty_reputation) |> assign(:map_items, Jason.encode!([])) |> assign(:map_result_count, 0) + |> assign(:map_total_count, 0) |> assign(:discovery_loading, true) |> assign(:refresh_timer, nil) |> assign(:page_title, gettext("Nearby help")) @@ -259,6 +260,15 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do %Pagination.Page{} end + map_total_count = + if discovery_mode == "nearby" and view_mode != "list" do + DiscoveryQueryCache.fetch(map_total_cache_key(scope, filters), fn -> + Help.count_map_discovery_items(scope, filters) + end) + else + 0 + end + %{ discovery_loading: false, requests: requests_page.entries, @@ -267,7 +277,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do my_requests_cursor: my_requests_page.next_cursor, reputation: Trust.reputation(scope.user.id), map_items: Jason.encode!(map_items), - map_result_count: Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)) + map_result_count: Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)), + map_total_count: map_total_count } end @@ -321,6 +332,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do ) ) + :ok = + DiscoveryQueryCache.invalidate( + map_total_cache_key(socket.assigns.current_scope, socket.assigns.filters) + ) + socket end @@ -328,6 +344,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do {:requests, scope.user.id, filters, discovery_mode, view_mode, viewport} end + defp map_total_cache_key(scope, filters), do: {:request_map_total, scope.user.id, filters} + defp empty_filters, do: %{"category_id" => "", "urgency" => "", "area" => ""} defp normalize_filters(params) do @@ -525,6 +543,11 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do count: @map_result_count )} + + {gettext("of %{count} matching requests with map locations", + count: @map_total_count + )} +

{gettext("Move the map, then search the newly visible area.")}

@@ -680,6 +703,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do data-map-retry-label={gettext("Try map again")} data-item-label={gettext("Open request")} data-cluster-label={gettext("%{count} requests", count: "%{count}")} + data-cluster-max-zoom-label={ + gettext("Maximum zoom reached. Matching requests are listed beside the map.") + } class="aid-map request-discovery-map" />