diff --git a/assets/css/app.css b/assets/css/app.css index 8f190ce..046053c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -265,6 +265,14 @@ html { box-shadow: 0 1px 5px color-mix(in oklab, var(--color-base-content) 12%, transparent); } +.request-discovery { + --request-discovery-map-height: clamp(30rem, 68vh, 46rem); +} + +.request-discovery[data-view-mode="map"] { + --request-discovery-map-height: clamp(32rem, 74vh, 52rem); +} + .request-discovery-content { display: grid; gap: 1.25rem; @@ -284,6 +292,15 @@ html { min-width: 0; } +@media (min-width: 768px) { + .request-discovery[data-view-mode="split"] .request-results-list { + height: var(--request-discovery-map-height); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + } +} + .request-result-card { transition: border-color 150ms ease, @@ -313,11 +330,9 @@ html { } .request-discovery-map { - min-height: min(68vh, 46rem); -} - -.request-discovery[data-view-mode="map"] .request-discovery-map { - min-height: min(74vh, 52rem); + width: 100%; + height: var(--request-discovery-map-height); + min-height: 0; } .request-map-search-button { @@ -424,6 +439,10 @@ html { } @media (max-width: 767px) { + .request-discovery { + --request-discovery-map-height: clamp(22rem, 62svh, 32rem); + } + .aid-map .maplibregl-ctrl-group button, .aid-map .maplibregl-ctrl-attrib-button { width: 2.75rem; diff --git a/assets/js/hooks.js b/assets/js/hooks.js index ea54d3e..20fcaf1 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -2,9 +2,15 @@ import maplibregl from "maplibre-gl" import { clusterCountLabel, clusterExpansionTarget, - clusterSizeClass, - pointFeatureCollection + pointFeatureCollection, + pointSourceOptions, + serverClusterDescriptor } from "./map_cluster_features.mjs" +import { + mapSizeChanged, + shouldApplyInitialViewport, + viewportMatchesBounds +} from "./map_viewport_sync.mjs" const trackingMinTimeMs = 5000 @@ -90,6 +96,7 @@ const defaultStyle = { type: "raster", tiles: [document.documentElement.dataset.mapTileUrl], tileSize: 256, + maxzoom: 19, attribution: "© OpenStreetMap contributors" } }, @@ -281,11 +288,14 @@ const createAidMap = element => { element, map: null, active: true, - viewportFrame: null, + viewportTimer: null, resizeFrame: null, resizeObserver: null, - markers: [], + renderedSize: null, + clusterMarkers: new Map(), + visibleClusterKeys: new Set(), markerElements: new Map(), + markerDataSignature: null, points: [], selectedItemId: null, clusterExpansionPending: false, @@ -298,8 +308,6 @@ const createAidMap = element => { areaLineId: `${element.id || "aid-map"}-area-line`, areaSelectedId: `${element.id || "aid-map"}-area-selected`, clusterSourceId: `${element.id || "aid-map"}-clusters`, - clusterCircleId: `${element.id || "aid-map"}-cluster-circle`, - 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 @@ -315,6 +323,14 @@ const createAidMap = element => { state.element.dataset.mapReady = "true" } + state.queueViewportChange = viewport => { + window.clearTimeout(state.viewportTimer) + state.viewportTimer = window.setTimeout(() => { + state.viewportTimer = null + if (state.active) state.onViewportChange?.(viewport) + }, 300) + } + state.retry = () => { if (!state.active) return @@ -459,6 +475,10 @@ const createAidMap = element => { state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""]) } + if (state.map?.getLayer(state.areaFillId)) { + state.map.setFilter(state.areaFillId, ["==", ["get", "id"], id || ""]) + } + if (state.map?.getLayer(state.pointSelectedId)) { state.map.setFilter(state.pointSelectedId, ["==", ["get", "id"], id || ""]) } @@ -474,6 +494,7 @@ const createAidMap = element => { const {width, height} = state.element.getBoundingClientRect() if (width < 160 || height < 160) return + if (!mapSizeChanged(state.renderedSize, {width, height})) return if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame) state.resizeFrame = window.requestAnimationFrame(() => { @@ -481,20 +502,34 @@ const createAidMap = element => { if (!state.active || !state.map) return const current = state.element.getBoundingClientRect() - if (current.width >= 160 && current.height >= 160) { + const currentSize = {width: current.width, height: current.height} + + if ( + current.width >= 160 && + current.height >= 160 && + mapSizeChanged(state.renderedSize, currentSize) + ) { + state.renderedSize = currentSize state.map.resize() } }) } - state.applySavedViewport = () => { - if (!state.active || !state.map) return + state.applyInitialViewport = () => { + if ( + !state.active || + !state.map || + !shouldApplyInitialViewport(state.initializedViewport) + ) return const viewport = savedViewport(state.element) const serialized = viewport ? JSON.stringify(viewport) : "" if (!viewport || state.appliedViewport === serialized) return state.appliedViewport = serialized + + if (viewportMatchesBounds(viewport, state.map.getBounds())) return + state.map.fitBounds( [[viewport.west, viewport.south], [viewport.east, viewport.north]], {padding: 0, duration: 0} @@ -513,48 +548,11 @@ 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 + state.expandServerCluster = point => { + if (!point || !state.map) 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 count = Number(point.count) || 1 + const requestedZoom = Number(point.expansion_zoom) const currentZoom = state.map.getZoom() const expansionZoom = clusterExpansionTarget( currentZoom, @@ -566,7 +564,7 @@ const createAidMap = element => { .replace("%{count}", String(count)) new maplibregl.Popup({offset: 18}) - .setLngLat(feature.geometry.coordinates) + .setLngLat([point.longitude, point.latitude]) .setDOMContent(state.popupContent({ title: label, location: state.element.dataset.clusterMaxZoomLabel || "" @@ -577,77 +575,95 @@ const createAidMap = element => { state.clusterExpansionPending = true state.map.easeTo({ - center: feature.geometry.coordinates, + center: [point.longitude, point.latitude], zoom: expansionZoom, duration: 300 }) } state.syncClusterMarkers = () => { - state.markers.forEach(marker => marker.remove()) - state.markers = [] + if (!state.map || state.element.dataset.discoveryMap !== "true") return - if ( - !state.map || - state.element.dataset.discoveryMap !== "true" || - !state.map.getLayer(state.clusterCircleId) || - !state.map.getLayer(state.serverClusterCircleId) - ) return + const visibleKeys = new Set() - const features = state.map.queryRenderedFeatures({ - layers: [state.clusterCircleId, state.serverClusterCircleId] - }) - const seen = new Set() + state.points.forEach(point => { + const descriptor = serverClusterDescriptor(point) + if (!descriptor) return - 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}` + const {key, count, urgentCount, longitude, latitude} = descriptor + visibleKeys.add(key) - 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) - } - }) + const label = (state.element.dataset.clusterLabel || "%{count} items") + .replace("%{count}", formattedCount) + let entry = state.clusterMarkers.get(key) - state.markers.push( - new maplibregl.Marker({element: button}) - .setLngLat(feature.geometry.coordinates) - .addTo(state.map) - ) + if (!entry) { + const button = document.createElement("button") + const marker = new maplibregl.Marker({element: button}) + .setSubpixelPositioning(true) + + button.type = "button" + button.className = "request-map-cluster" + button.addEventListener("click", event => { + event.stopPropagation() + state.expandServerCluster(entry.point) + }) + + entry = { + button, + marker, + point, + visible: false, + count: null, + urgentCount: null, + label: null, + longitude: null, + latitude: null + } + state.clusterMarkers.set(key, entry) + } + + entry.point = point + + if (entry.count !== count || entry.urgentCount !== urgentCount) { + entry.count = count + entry.urgentCount = urgentCount + entry.button.dataset.clusterCount = String(count) + entry.button.classList.toggle("is-lg", count >= 1_000 && count < 10_000) + entry.button.classList.toggle("is-xl", count >= 10_000 && count < 100_000) + entry.button.classList.toggle("is-xxl", count >= 100_000) + entry.button.classList.toggle("is-urgent", urgentCount > 0) + entry.button.textContent = clusterCountLabel(count, uiLocale()) + } + + if (entry.label !== label) { + entry.label = label + entry.button.setAttribute("aria-label", label) + entry.button.title = label + } + + if (entry.longitude !== longitude || entry.latitude !== latitude) { + entry.longitude = longitude + entry.latitude = latitude + entry.marker.setLngLat([longitude, latitude]) + } + + if (!entry.visible) { + entry.marker.addTo(state.map) + entry.visible = true + } }) + + state.visibleClusterKeys.forEach(key => { + if (visibleKeys.has(key)) return + + const entry = state.clusterMarkers.get(key) + entry?.marker.remove() + state.clusterMarkers.delete(key) + }) + + state.visibleClusterKeys = visibleKeys } state.syncVisibleAreas = () => { @@ -656,26 +672,10 @@ const createAidMap = element => { 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 visiblePoints = + state.element.dataset.discoveryMap === "true" + ? state.points.filter(point => point.type !== "cluster") + : state.points const signature = visiblePoints .filter(point => !point.exact && Number.isFinite(point.radius_meters)) @@ -691,13 +691,23 @@ const createAidMap = element => { state.renderMarkers = () => { if (!state.map) return - state.markers.forEach(marker => marker.remove()) - state.markers = [] - state.markerElements.clear() + const markerDataSignature = + state.element.dataset.items || state.element.dataset.markers || "[]" + const existingAreaSource = state.map.getSource(state.areaSourceId) + const existingPointSource = state.map.getSource(state.clusterSourceId) + + if ( + state.markerDataSignature === markerDataSignature && + existingAreaSource && + existingPointSource + ) { + state.pendingMarkerRender = false + return + } const points = markerPoints(state.element) state.points = points - const pointFeatures = pointFeatureCollection(points) + const pointFeatures = pointFeatureCollection(points, uiLocale()) const areaFeatures = state.element.dataset.discoveryMap === "true" ? emptyFeatureCollection() @@ -720,9 +730,6 @@ const createAidMap = element => { }) 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() @@ -745,6 +752,7 @@ const createAidMap = element => { type: "fill", source: state.areaSourceId, minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0, + filter: ["==", ["get", "id"], state.selectedItemId || ""], paint: {"fill-color": "#278467", "fill-opacity": 0.18} }) state.map.addLayer({ @@ -754,7 +762,8 @@ const createAidMap = element => { minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0, paint: { "line-color": "#16725b", - "line-width": 2, + "line-width": 1.5, + "line-opacity": 0.55, "line-dasharray": [2, 2] } }) @@ -767,22 +776,6 @@ const createAidMap = element => { paint: {"line-color": "#d6573b", "line-width": 5} }) - state.map.on("mouseenter", state.areaFillId, () => { - state.map.getCanvas().style.cursor = "pointer" - }) - state.map.on("mouseleave", state.areaFillId, () => { - state.map.getCanvas().style.cursor = "" - }) - state.map.on("click", state.areaFillId, event => { - const properties = event.features?.[0]?.properties - if (!properties?.id) return - - state.selectRequest(properties.id) - new maplibregl.Popup({offset: 12}) - .setLngLat(event.lngLat) - .setDOMContent(state.popupContent(properties)) - .addTo(state.map) - }) } const pointSource = existingPointSource @@ -790,98 +783,13 @@ const createAidMap = element => { if (pointSource) { pointSource.setData(pointFeatures) } else { - const sourceOptions = { - type: "geojson", - data: pointFeatures, - generateId: false - } - - 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"]] - } - }) - } - - 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.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures)) state.map.addLayer({ id: state.pointCircleId, type: "circle", source: state.clusterSourceId, filter: [ "all", - ["!", ["has", "point_count"]], ["!=", ["get", "item_type"], "cluster"] ], paint: { @@ -911,11 +819,7 @@ const createAidMap = element => { } }) - const interactiveLayers = [ - state.clusterCircleId, - state.serverClusterCircleId, - state.pointCircleId - ] + const interactiveLayers = [state.pointCircleId] interactiveLayers.forEach(layerId => { state.map.on("mouseenter", layerId, () => { @@ -926,16 +830,6 @@ const createAidMap = element => { }) }) - 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 @@ -951,6 +845,8 @@ const createAidMap = element => { } } + state.markerDataSignature = markerDataSignature + state.syncClusterMarkers() if (state.element.dataset.autoFit !== "false") state.fitMarkers() } @@ -984,6 +880,17 @@ const createAidMap = element => { return } + // Restore the URL camera before MapLibre's first complete style load. Waiting + // for `load` briefly exposes the default camera and produces a visible jump + // when slow or unavailable tiles delay that event. + state.applyInitialViewport() + state.initializedViewport = true + + if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) { + const viewport = mapViewport(state.map) + if (viewport) state.onInitializeViewport?.(viewport) + } + if (demoMap) state.map.touchZoomRotate.disableRotation() state.map.addControl( @@ -1014,25 +921,14 @@ const createAidMap = element => { delete state.element.dataset.mapUnavailable delete state.element.dataset.mapUnavailableReason state.markReady() + const {width, height} = state.element.getBoundingClientRect() + state.renderedSize = {width, height} if (state.statusBadge) { state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel } - state.applySavedViewport() state.renderMarkers() - - state.viewportFrame = window.requestAnimationFrame(() => { - state.viewportFrame = null - if (!state.active || !state.map) return - - state.initializedViewport = true - - if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) { - const viewport = mapViewport(state.map) - if (viewport) state.onInitializeViewport?.(viewport) - } - }) } state.onMoveEnd = () => { @@ -1042,9 +938,11 @@ const createAidMap = element => { if (state.clusterExpansionPending) { state.clusterExpansionPending = false + window.clearTimeout(state.viewportTimer) + state.viewportTimer = null state.onClusterExpand?.(viewport) } else { - state.onViewportChange?.(viewport) + state.queueViewportChange(viewport) } } @@ -1057,12 +955,10 @@ const createAidMap = element => { } 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.onIdle) state.map.on("moveend", state.onMoveEnd) @@ -1076,14 +972,16 @@ const createAidMap = element => { state.destroy = () => { state.active = false - if (state.viewportFrame !== null) window.cancelAnimationFrame(state.viewportFrame) + window.clearTimeout(state.viewportTimer) if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame) state.resizeObserver?.disconnect() - state.map?.off("dataloading", state.markLoading) state.map?.off("load", state.onLoad) state.map?.off("idle", state.onIdle) state.map?.off("moveend", state.onMoveEnd) window.removeEventListener("wnh:request-highlight", state.onRequestHighlight) + state.clusterMarkers.forEach(({marker}) => marker.remove()) + state.clusterMarkers.clear() + state.visibleClusterKeys.clear() state.map?.remove() state.map = null state.locationBounds = null @@ -2028,9 +1926,10 @@ export const Hooks = { this.aidMap.mount() }, updated() { - this.aidMap.applySavedViewport() + // A server viewport is only the bounds used to query marker data. Once + // the map is mounted, the user's camera is authoritative and must never + // be moved back to bounds from a delayed LiveView response. this.aidMap.renderMarkers() - this.aidMap.resize() }, destroyed() { this.aidMap.destroy() diff --git a/assets/js/map_cluster_features.mjs b/assets/js/map_cluster_features.mjs index e01b066..60319c4 100644 --- a/assets/js/map_cluster_features.mjs +++ b/assets/js/map_cluster_features.mjs @@ -32,7 +32,26 @@ export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom = return Math.min(maximumZoom, Math.max(nextIntegerZoom, requested)) } -export const pointFeatureCollection = points => ({ +export const serverClusterDescriptor = point => { + if ( + point?.type !== "cluster" || + !point.id || + !Number.isFinite(Number(point.longitude)) || + !Number.isFinite(Number(point.latitude)) + ) { + return null + } + + return { + key: `server:${point.id}`, + count: finiteCount(point.count, 1), + urgentCount: finiteCount(point.urgent_count, 0), + longitude: Number(point.longitude), + latitude: Number(point.latitude) + } +} + +export const pointFeatureCollection = (points, locale = "en") => ({ type: "FeatureCollection", features: points.map(point => ({ type: "Feature", @@ -49,7 +68,11 @@ export const pointFeatureCollection = points => ({ 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) + expansion_zoom: finiteCount(point.expansion_zoom, 22), + count_label: + point.type === "cluster" + ? clusterCountLabel(finiteCount(point.count, 1), locale) + : "" }, geometry: { type: "Point", @@ -57,3 +80,11 @@ export const pointFeatureCollection = points => ({ } })) }) + +export const pointSourceOptions = data => ({ + type: "geojson", + data, + generateId: false, + maxzoom: 22, + cluster: false +}) diff --git a/assets/js/map_cluster_features.test.mjs b/assets/js/map_cluster_features.test.mjs index 3bc4883..ca6243a 100644 --- a/assets/js/map_cluster_features.test.mjs +++ b/assets/js/map_cluster_features.test.mjs @@ -1,61 +1,15 @@ import assert from "node:assert/strict" import test from "node:test" -import {Supercluster} from "@maplibre/geojson-vt" import { clusterCountLabel, clusterExpansionTarget, clusterSizeClass, - pointFeatureCollection + pointFeatureCollection, + pointSourceOptions, + serverClusterDescriptor } 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([ { @@ -76,27 +30,24 @@ test("server hierarchy metadata survives GeoJSON conversion without becoming a n assert.equal(feature.properties.hierarchy_level, 11) assert.equal(feature.properties.expansion_zoom, 13) assert.equal(feature.properties.weight, 27) + assert.equal(feature.properties.count_label, "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} +test("browser source leaves server hierarchy nodes unclustered", () => { + const collection = pointFeatureCollection([ + { + id: "request:11:1197:1378", + type: "cluster", + longitude: 30.52, + latitude: 50.45, + count: 27 + } ]) + const source = pointSourceOptions(collection) - 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 - ) + assert.equal(source.cluster, false) + assert.equal(source.data, collection) }) test("cluster size classes remain stable for accessible count buttons", () => { @@ -113,6 +64,49 @@ test("large visual counts are compact while exact values remain available to acc assert.equal(clusterCountLabel(1_000_000, "en"), "1M") }) +test("server cluster count labels are compacted before rendering", () => { + const feature = pointFeatureCollection([ + { + id: "request:12:2402:2406", + type: "cluster", + longitude: 31.3, + latitude: 30.2, + count: 48_533 + } + ], "en").features[0] + + assert.equal(feature.properties.count_label, "49K") +}) + +test("only server hierarchy nodes become cluster markers", () => { + assert.deepEqual( + serverClusterDescriptor({ + id: "request:12:2402:2406", + type: "cluster", + longitude: 31.3, + latitude: 30.2, + count: 1_727, + urgent_count: 8 + }), + { + key: "server:request:12:2402:2406", + count: 1_727, + urgentCount: 8, + longitude: 31.3, + latitude: 30.2 + } + ) + assert.equal( + serverClusterDescriptor({ + id: "request-1", + type: "request", + longitude: 31.3, + latitude: 30.2 + }), + null + ) +}) + 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) diff --git a/assets/js/map_viewport_sync.mjs b/assets/js/map_viewport_sync.mjs new file mode 100644 index 0000000..1900513 --- /dev/null +++ b/assets/js/map_viewport_sync.mjs @@ -0,0 +1,25 @@ +const serializedCoordinateTolerance = 1e-6 + +export const shouldApplyInitialViewport = initializedViewport => !initializedViewport + +export const viewportMatchesBounds = (viewport, bounds) => { + if (!viewport || !bounds) return false + + return [ + [viewport.west, bounds.getWest()], + [viewport.south, bounds.getSouth()], + [viewport.east, bounds.getEast()], + [viewport.north, bounds.getNorth()] + ].every( + ([saved, current]) => + Number.isFinite(saved) && + Number.isFinite(current) && + Math.abs(saved - current) <= serializedCoordinateTolerance + ) +} + +export const mapSizeChanged = (previous, current) => { + if (!previous) return true + + return previous.width !== current.width || previous.height !== current.height +} diff --git a/assets/js/map_viewport_sync.test.mjs b/assets/js/map_viewport_sync.test.mjs new file mode 100644 index 0000000..62c5116 --- /dev/null +++ b/assets/js/map_viewport_sync.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { + mapSizeChanged, + shouldApplyInitialViewport, + viewportMatchesBounds +} from "./map_viewport_sync.mjs" + +const bounds = ({west, south, east, north}) => ({ + getWest: () => west, + getSouth: () => south, + getEast: () => east, + getNorth: () => north +}) + +test("a server acknowledgement rounded to six decimals does not move the map again", () => { + const viewport = { + west: 30.543427, + south: 50.42413, + east: 30.551643, + north: 50.429139 + } + + assert.equal( + viewportMatchesBounds( + viewport, + bounds({ + west: 30.543426946507708, + south: 50.42413000000005, + east: 30.55164305349291, + north: 50.42913900000016 + }) + ), + true + ) +}) + +test("a materially different saved viewport still repositions the map", () => { + const viewport = {west: 30.5, south: 50.4, east: 30.6, north: 50.5} + + assert.equal( + viewportMatchesBounds( + viewport, + bounds({west: 30.51, south: 50.41, east: 30.61, north: 50.51}) + ), + false + ) +}) + +test("map resize is requested only when the rendered dimensions change", () => { + assert.equal(mapSizeChanged(null, {width: 767, height: 734}), true) + assert.equal( + mapSizeChanged({width: 767, height: 734}, {width: 767, height: 734}), + false + ) + assert.equal( + mapSizeChanged({width: 767, height: 734}, {width: 768, height: 734}), + true + ) +}) + +test("server viewport is never reapplied after the user camera is initialized", () => { + assert.equal(shouldApplyInitialViewport(false), true) + assert.equal(shouldApplyInitialViewport(true), false) +}) diff --git a/lib/who_need_help/help/discovery_cluster.ex b/lib/who_need_help/help/discovery_cluster.ex index ec4c9a9..8c4c0e0 100644 --- a/lib/who_need_help/help/discovery_cluster.ex +++ b/lib/who_need_help/help/discovery_cluster.ex @@ -1,7 +1,6 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do @moduledoc """ - Stable Web Mercator hierarchy used as the server-side input to MapLibre - Supercluster. + Stable Web Mercator hierarchy used as the server-side discovery index. A browser cannot safely receive every discovery point once the data set reaches millions of rows. The server therefore groups points into a fixed @@ -9,10 +8,11 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do 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. + MapLibre renders the resulting hierarchy nodes directly. They must not be + passed through a second browser-side clustering pass, because that changes + cluster identity and center between server responses. For each server node, + `expansion_zoom/5` identifies the first map zoom where it has more than one + child cell. """ @world_half_meters 20_037_508.342_789_244 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 b6ef062..3107088 100644 --- a/lib/who_need_help_web/live/activity_live/index.ex +++ b/lib/who_need_help_web/live/activity_live/index.ex @@ -575,11 +575,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do class="request-discovery mt-6" >