Stabilize discovery map interactions

This commit is contained in:
SimpleTest 2026-07-30 12:14:03 +03:00
parent af922b015f
commit 040889de1a
9 changed files with 422 additions and 394 deletions

View File

@ -265,6 +265,14 @@ html {
box-shadow: 0 1px 5px color-mix(in oklab, var(--color-base-content) 12%, transparent); 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 { .request-discovery-content {
display: grid; display: grid;
gap: 1.25rem; gap: 1.25rem;
@ -284,6 +292,15 @@ html {
min-width: 0; 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 { .request-result-card {
transition: transition:
border-color 150ms ease, border-color 150ms ease,
@ -313,11 +330,9 @@ html {
} }
.request-discovery-map { .request-discovery-map {
min-height: min(68vh, 46rem); width: 100%;
} height: var(--request-discovery-map-height);
min-height: 0;
.request-discovery[data-view-mode="map"] .request-discovery-map {
min-height: min(74vh, 52rem);
} }
.request-map-search-button { .request-map-search-button {
@ -424,6 +439,10 @@ html {
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.request-discovery {
--request-discovery-map-height: clamp(22rem, 62svh, 32rem);
}
.aid-map .maplibregl-ctrl-group button, .aid-map .maplibregl-ctrl-group button,
.aid-map .maplibregl-ctrl-attrib-button { .aid-map .maplibregl-ctrl-attrib-button {
width: 2.75rem; width: 2.75rem;

View File

@ -2,9 +2,15 @@ import maplibregl from "maplibre-gl"
import { import {
clusterCountLabel, clusterCountLabel,
clusterExpansionTarget, clusterExpansionTarget,
clusterSizeClass, pointFeatureCollection,
pointFeatureCollection pointSourceOptions,
serverClusterDescriptor
} from "./map_cluster_features.mjs" } from "./map_cluster_features.mjs"
import {
mapSizeChanged,
shouldApplyInitialViewport,
viewportMatchesBounds
} from "./map_viewport_sync.mjs"
const trackingMinTimeMs = 5000 const trackingMinTimeMs = 5000
@ -90,6 +96,7 @@ const defaultStyle = {
type: "raster", type: "raster",
tiles: [document.documentElement.dataset.mapTileUrl], tiles: [document.documentElement.dataset.mapTileUrl],
tileSize: 256, tileSize: 256,
maxzoom: 19,
attribution: "© OpenStreetMap contributors" attribution: "© OpenStreetMap contributors"
} }
}, },
@ -281,11 +288,14 @@ const createAidMap = element => {
element, element,
map: null, map: null,
active: true, active: true,
viewportFrame: null, viewportTimer: null,
resizeFrame: null, resizeFrame: null,
resizeObserver: null, resizeObserver: null,
markers: [], renderedSize: null,
clusterMarkers: new Map(),
visibleClusterKeys: new Set(),
markerElements: new Map(), markerElements: new Map(),
markerDataSignature: null,
points: [], points: [],
selectedItemId: null, selectedItemId: null,
clusterExpansionPending: false, clusterExpansionPending: false,
@ -298,8 +308,6 @@ const createAidMap = element => {
areaLineId: `${element.id || "aid-map"}-area-line`, areaLineId: `${element.id || "aid-map"}-area-line`,
areaSelectedId: `${element.id || "aid-map"}-area-selected`, areaSelectedId: `${element.id || "aid-map"}-area-selected`,
clusterSourceId: `${element.id || "aid-map"}-clusters`, 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`, pointCircleId: `${element.id || "aid-map"}-point-circle`,
pointSelectedId: `${element.id || "aid-map"}-point-selected`, pointSelectedId: `${element.id || "aid-map"}-point-selected`,
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
@ -315,6 +323,14 @@ const createAidMap = element => {
state.element.dataset.mapReady = "true" 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 = () => { state.retry = () => {
if (!state.active) return if (!state.active) return
@ -459,6 +475,10 @@ const createAidMap = element => {
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""]) 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)) { if (state.map?.getLayer(state.pointSelectedId)) {
state.map.setFilter(state.pointSelectedId, ["==", ["get", "id"], id || ""]) state.map.setFilter(state.pointSelectedId, ["==", ["get", "id"], id || ""])
} }
@ -474,6 +494,7 @@ const createAidMap = element => {
const {width, height} = state.element.getBoundingClientRect() const {width, height} = state.element.getBoundingClientRect()
if (width < 160 || height < 160) return if (width < 160 || height < 160) return
if (!mapSizeChanged(state.renderedSize, {width, height})) return
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame) if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeFrame = window.requestAnimationFrame(() => { state.resizeFrame = window.requestAnimationFrame(() => {
@ -481,20 +502,34 @@ const createAidMap = element => {
if (!state.active || !state.map) return if (!state.active || !state.map) return
const current = state.element.getBoundingClientRect() const current = state.element.getBoundingClientRect()
if (current.width >= 160 && current.height >= 160) { 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.map.resize()
} }
}) })
} }
state.applySavedViewport = () => { state.applyInitialViewport = () => {
if (!state.active || !state.map) return if (
!state.active ||
!state.map ||
!shouldApplyInitialViewport(state.initializedViewport)
) return
const viewport = savedViewport(state.element) const viewport = savedViewport(state.element)
const serialized = viewport ? JSON.stringify(viewport) : "" const serialized = viewport ? JSON.stringify(viewport) : ""
if (!viewport || state.appliedViewport === serialized) return if (!viewport || state.appliedViewport === serialized) return
state.appliedViewport = serialized state.appliedViewport = serialized
if (viewportMatchesBounds(viewport, state.map.getBounds())) return
state.map.fitBounds( state.map.fitBounds(
[[viewport.west, viewport.south], [viewport.east, viewport.north]], [[viewport.west, viewport.south], [viewport.east, viewport.north]],
{padding: 0, duration: 0} {padding: 0, duration: 0}
@ -513,48 +548,11 @@ const createAidMap = element => {
return true return true
} }
state.expandNativeCluster = feature => { state.expandServerCluster = point => {
const source = state.map?.getSource(state.clusterSourceId) if (!point || !state.map) return
const clusterId = Number(feature?.properties?.cluster_id)
if (!source || !Number.isFinite(clusterId)) return
Promise.resolve(source.getClusterExpansionZoom(clusterId)).then(expansionZoom => { const count = Number(point.count) || 1
if (!state.active || !state.map) return const requestedZoom = Number(point.expansion_zoom)
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 currentZoom = state.map.getZoom()
const expansionZoom = clusterExpansionTarget( const expansionZoom = clusterExpansionTarget(
currentZoom, currentZoom,
@ -566,7 +564,7 @@ const createAidMap = element => {
.replace("%{count}", String(count)) .replace("%{count}", String(count))
new maplibregl.Popup({offset: 18}) new maplibregl.Popup({offset: 18})
.setLngLat(feature.geometry.coordinates) .setLngLat([point.longitude, point.latitude])
.setDOMContent(state.popupContent({ .setDOMContent(state.popupContent({
title: label, title: label,
location: state.element.dataset.clusterMaxZoomLabel || "" location: state.element.dataset.clusterMaxZoomLabel || ""
@ -577,77 +575,95 @@ const createAidMap = element => {
state.clusterExpansionPending = true state.clusterExpansionPending = true
state.map.easeTo({ state.map.easeTo({
center: feature.geometry.coordinates, center: [point.longitude, point.latitude],
zoom: expansionZoom, zoom: expansionZoom,
duration: 300 duration: 300
}) })
} }
state.syncClusterMarkers = () => { state.syncClusterMarkers = () => {
state.markers.forEach(marker => marker.remove()) if (!state.map || state.element.dataset.discoveryMap !== "true") return
state.markers = []
if ( const visibleKeys = new Set()
!state.map ||
state.element.dataset.discoveryMap !== "true" ||
!state.map.getLayer(state.clusterCircleId) ||
!state.map.getLayer(state.serverClusterCircleId)
) return
const features = state.map.queryRenderedFeatures({ state.points.forEach(point => {
layers: [state.clusterCircleId, state.serverClusterCircleId] const descriptor = serverClusterDescriptor(point)
}) if (!descriptor) return
const seen = new Set()
features.forEach(feature => { const {key, count, urgentCount, longitude, latitude} = descriptor
const nativeCluster = visibleKeys.add(key)
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 const formattedCount = new Intl.NumberFormat(uiLocale()).format(count)
seen.add(key) const label = (state.element.dataset.clusterLabel || "%{count} items")
.replace("%{count}", formattedCount)
let entry = state.clusterMarkers.get(key)
const count = Number( if (!entry) {
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") const button = document.createElement("button")
const marker = new maplibregl.Marker({element: button})
.setSubpixelPositioning(true)
button.type = "button" button.type = "button"
button.className = `request-map-cluster ${clusterSizeClass(count)}` button.className = "request-map-cluster"
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 => { button.addEventListener("click", event => {
event.stopPropagation() event.stopPropagation()
if (nativeCluster) { state.expandServerCluster(entry.point)
state.expandNativeCluster(feature) })
} else {
state.expandServerCluster(feature) 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.markers.push( state.visibleClusterKeys.forEach(key => {
new maplibregl.Marker({element: button}) if (visibleKeys.has(key)) return
.setLngLat(feature.geometry.coordinates)
.addTo(state.map) const entry = state.clusterMarkers.get(key)
) entry?.marker.remove()
state.clusterMarkers.delete(key)
}) })
state.visibleClusterKeys = visibleKeys
} }
state.syncVisibleAreas = () => { state.syncVisibleAreas = () => {
@ -656,26 +672,10 @@ const createAidMap = element => {
const areaSource = state.map.getSource(state.areaSourceId) const areaSource = state.map.getSource(state.areaSourceId)
if (!areaSource) return if (!areaSource) return
let visiblePoints = state.points const visiblePoints =
state.element.dataset.discoveryMap === "true"
if (state.element.dataset.discoveryMap === "true") { ? state.points.filter(point => point.type !== "cluster")
if (!state.map.getSource(state.clusterSourceId)?.loaded()) return : state.points
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 const signature = visiblePoints
.filter(point => !point.exact && Number.isFinite(point.radius_meters)) .filter(point => !point.exact && Number.isFinite(point.radius_meters))
@ -691,13 +691,23 @@ const createAidMap = element => {
state.renderMarkers = () => { state.renderMarkers = () => {
if (!state.map) return if (!state.map) return
state.markers.forEach(marker => marker.remove()) const markerDataSignature =
state.markers = [] state.element.dataset.items || state.element.dataset.markers || "[]"
state.markerElements.clear() 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) const points = markerPoints(state.element)
state.points = points state.points = points
const pointFeatures = pointFeatureCollection(points) const pointFeatures = pointFeatureCollection(points, uiLocale())
const areaFeatures = const areaFeatures =
state.element.dataset.discoveryMap === "true" state.element.dataset.discoveryMap === "true"
? emptyFeatureCollection() ? emptyFeatureCollection()
@ -720,9 +730,6 @@ const createAidMap = element => {
}) })
state.locationBounds = points.length > 0 ? bounds : null state.locationBounds = points.length > 0 ? bounds : null
const existingAreaSource = state.map.getSource(state.areaSourceId)
const existingPointSource = state.map.getSource(state.clusterSourceId)
if ( if (
(!existingAreaSource || !existingPointSource) && (!existingAreaSource || !existingPointSource) &&
!state.map.isStyleLoaded() !state.map.isStyleLoaded()
@ -745,6 +752,7 @@ const createAidMap = element => {
type: "fill", type: "fill",
source: state.areaSourceId, source: state.areaSourceId,
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0, minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
filter: ["==", ["get", "id"], state.selectedItemId || ""],
paint: {"fill-color": "#278467", "fill-opacity": 0.18} paint: {"fill-color": "#278467", "fill-opacity": 0.18}
}) })
state.map.addLayer({ state.map.addLayer({
@ -754,7 +762,8 @@ const createAidMap = element => {
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0, minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
paint: { paint: {
"line-color": "#16725b", "line-color": "#16725b",
"line-width": 2, "line-width": 1.5,
"line-opacity": 0.55,
"line-dasharray": [2, 2] "line-dasharray": [2, 2]
} }
}) })
@ -767,22 +776,6 @@ const createAidMap = element => {
paint: {"line-color": "#d6573b", "line-width": 5} 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 const pointSource = existingPointSource
@ -790,98 +783,13 @@ const createAidMap = element => {
if (pointSource) { if (pointSource) {
pointSource.setData(pointFeatures) pointSource.setData(pointFeatures)
} else { } else {
const sourceOptions = { state.map.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures))
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.addLayer({ state.map.addLayer({
id: state.pointCircleId, id: state.pointCircleId,
type: "circle", type: "circle",
source: state.clusterSourceId, source: state.clusterSourceId,
filter: [ filter: [
"all", "all",
["!", ["has", "point_count"]],
["!=", ["get", "item_type"], "cluster"] ["!=", ["get", "item_type"], "cluster"]
], ],
paint: { paint: {
@ -911,11 +819,7 @@ const createAidMap = element => {
} }
}) })
const interactiveLayers = [ const interactiveLayers = [state.pointCircleId]
state.clusterCircleId,
state.serverClusterCircleId,
state.pointCircleId
]
interactiveLayers.forEach(layerId => { interactiveLayers.forEach(layerId => {
state.map.on("mouseenter", 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 => { state.map.on("click", state.pointCircleId, event => {
const feature = event.features?.[0] const feature = event.features?.[0]
const properties = feature?.properties const properties = feature?.properties
@ -951,6 +845,8 @@ const createAidMap = element => {
} }
} }
state.markerDataSignature = markerDataSignature
state.syncClusterMarkers()
if (state.element.dataset.autoFit !== "false") state.fitMarkers() if (state.element.dataset.autoFit !== "false") state.fitMarkers()
} }
@ -984,6 +880,17 @@ const createAidMap = element => {
return 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() if (demoMap) state.map.touchZoomRotate.disableRotation()
state.map.addControl( state.map.addControl(
@ -1014,25 +921,14 @@ const createAidMap = element => {
delete state.element.dataset.mapUnavailable delete state.element.dataset.mapUnavailable
delete state.element.dataset.mapUnavailableReason delete state.element.dataset.mapUnavailableReason
state.markReady() state.markReady()
const {width, height} = state.element.getBoundingClientRect()
state.renderedSize = {width, height}
if (state.statusBadge) { if (state.statusBadge) {
state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel
} }
state.applySavedViewport()
state.renderMarkers() 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 = () => { state.onMoveEnd = () => {
@ -1042,9 +938,11 @@ const createAidMap = element => {
if (state.clusterExpansionPending) { if (state.clusterExpansionPending) {
state.clusterExpansionPending = false state.clusterExpansionPending = false
window.clearTimeout(state.viewportTimer)
state.viewportTimer = null
state.onClusterExpand?.(viewport) state.onClusterExpand?.(viewport)
} else { } else {
state.onViewportChange?.(viewport) state.queueViewportChange(viewport)
} }
} }
@ -1057,12 +955,10 @@ const createAidMap = element => {
} }
state.syncVisibleAreas() state.syncVisibleAreas()
state.syncClusterMarkers()
} }
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id) state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
state.map.on("dataloading", state.markLoading)
state.map.on("load", state.onLoad) state.map.on("load", state.onLoad)
state.map.on("idle", state.onIdle) state.map.on("idle", state.onIdle)
state.map.on("moveend", state.onMoveEnd) state.map.on("moveend", state.onMoveEnd)
@ -1076,14 +972,16 @@ const createAidMap = element => {
state.destroy = () => { state.destroy = () => {
state.active = false state.active = false
if (state.viewportFrame !== null) window.cancelAnimationFrame(state.viewportFrame) window.clearTimeout(state.viewportTimer)
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame) if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
state.resizeObserver?.disconnect() state.resizeObserver?.disconnect()
state.map?.off("dataloading", state.markLoading)
state.map?.off("load", state.onLoad) state.map?.off("load", state.onLoad)
state.map?.off("idle", state.onIdle) state.map?.off("idle", state.onIdle)
state.map?.off("moveend", state.onMoveEnd) state.map?.off("moveend", state.onMoveEnd)
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight) window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
state.clusterMarkers.forEach(({marker}) => marker.remove())
state.clusterMarkers.clear()
state.visibleClusterKeys.clear()
state.map?.remove() state.map?.remove()
state.map = null state.map = null
state.locationBounds = null state.locationBounds = null
@ -2028,9 +1926,10 @@ export const Hooks = {
this.aidMap.mount() this.aidMap.mount()
}, },
updated() { 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.renderMarkers()
this.aidMap.resize()
}, },
destroyed() { destroyed() {
this.aidMap.destroy() this.aidMap.destroy()

View File

@ -32,7 +32,26 @@ export const clusterExpansionTarget = (currentZoom, requestedZoom, maximumZoom =
return Math.min(maximumZoom, Math.max(nextIntegerZoom, requested)) 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", type: "FeatureCollection",
features: points.map(point => ({ features: points.map(point => ({
type: "Feature", type: "Feature",
@ -49,7 +68,11 @@ export const pointFeatureCollection = points => ({
server_cluster_id: point.cluster_id || "", server_cluster_id: point.cluster_id || "",
parent_cluster_id: point.parent_cluster_id || "", parent_cluster_id: point.parent_cluster_id || "",
hierarchy_level: finiteCount(point.hierarchy_level, 0), 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: { geometry: {
type: "Point", type: "Point",
@ -57,3 +80,11 @@ export const pointFeatureCollection = points => ({
} }
})) }))
}) })
export const pointSourceOptions = data => ({
type: "geojson",
data,
generateId: false,
maxzoom: 22,
cluster: false
})

View File

@ -1,61 +1,15 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import test from "node:test" import test from "node:test"
import {Supercluster} from "@maplibre/geojson-vt"
import { import {
clusterCountLabel, clusterCountLabel,
clusterExpansionTarget, clusterExpansionTarget,
clusterSizeClass, clusterSizeClass,
pointFeatureCollection pointFeatureCollection,
pointSourceOptions,
serverClusterDescriptor
} from "./map_cluster_features.mjs" } 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", () => { test("server hierarchy metadata survives GeoJSON conversion without becoming a native cluster", () => {
const feature = pointFeatureCollection([ 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.hierarchy_level, 11)
assert.equal(feature.properties.expansion_zoom, 13) assert.equal(feature.properties.expansion_zoom, 13)
assert.equal(feature.properties.weight, 27) assert.equal(feature.properties.weight, 27)
assert.equal(feature.properties.count_label, "27")
assert.equal("point_count" in feature.properties, false) assert.equal("point_count" in feature.properties, false)
}) })
test("native clusters expose an expansion zoom and eventually split unique coordinates", () => { test("browser source leaves server hierarchy nodes unclustered", () => {
const index = buildIndex([ const collection = pointFeatureCollection([
{id: "a", longitude: 30.52340, latitude: 50.45010}, {
{id: "b", longitude: 30.52341, latitude: 50.45011}, id: "request:11:1197:1378",
{id: "c", longitude: 30.60, latitude: 50.50} type: "cluster",
longitude: 30.52,
latitude: 50.45,
count: 27
}
]) ])
const source = pointSourceOptions(collection)
const world = index.getClusters([-180, -85, 180, 85], 0) assert.equal(source.cluster, false)
const cluster = world.find(feature => feature.properties.cluster) assert.equal(source.data, collection)
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", () => { 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") 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", () => { test("cluster expansion always advances at least one integer zoom level", () => {
assert.equal(clusterExpansionTarget(9, 9), 10) assert.equal(clusterExpansionTarget(9, 9), 10)
assert.equal(clusterExpansionTarget(8.999_999_096_835_52, 9), 10) assert.equal(clusterExpansionTarget(8.999_999_096_835_52, 9), 10)

View File

@ -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
}

View File

@ -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)
})

View File

@ -1,7 +1,6 @@
defmodule WhoNeedHelp.Help.DiscoveryCluster do defmodule WhoNeedHelp.Help.DiscoveryCluster do
@moduledoc """ @moduledoc """
Stable Web Mercator hierarchy used as the server-side input to MapLibre Stable Web Mercator hierarchy used as the server-side discovery index.
Supercluster.
A browser cannot safely receive every discovery point once the data set A browser cannot safely receive every discovery point once the data set
reaches millions of rows. The server therefore groups points into a fixed 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 stable identity and every cell at level `n + 1` belongs to exactly one parent
at level `n`. at level `n`.
MapLibre receives these weighted nodes and applies its native Supercluster MapLibre renders the resulting hierarchy nodes directly. They must not be
implementation for pixel-perfect collision handling. When a server node is passed through a second browser-side clustering pass, because that changes
reached, `expansion_zoom/5` identifies the first map zoom where that node has cluster identity and center between server responses. For each server node,
more than one child cell. `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 @world_half_meters 20_037_508.342_789_244

View File

@ -575,11 +575,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
class="request-discovery mt-6" class="request-discovery mt-6"
> >
<div class="request-discovery-toolbar"> <div class="request-discovery-toolbar">
<div :if={@discovery_loading}> <div class="min-w-0" aria-live="polite">
<strong>{gettext("Loading results…")}</strong>
</div>
<div :if={!@discovery_loading} class="min-w-0" aria-live="polite">
<strong> <strong>
{ngettext( {ngettext(
"%{count} activity in the selected map area", "%{count} activity in the selected map area",
@ -597,25 +593,31 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
</span> </span>
<p <p
:if={@pending_viewport && @map_update_mode == "manual"} :if={@discovery_loading}
class="mt-1 text-sm text-base-content/60"
>
{gettext("Loading results…")}
</p>
<p
:if={!@discovery_loading && @pending_viewport && @map_update_mode == "manual"}
class="mt-1 text-sm font-medium text-warning" class="mt-1 text-sm font-medium text-warning"
> >
{gettext("Map moved. Results still show the previous area.")} {gettext("Map moved. Results still show the previous area.")}
</p> </p>
<p <p
:if={@pending_viewport && @map_update_mode == "auto"} :if={!@discovery_loading && @pending_viewport && @map_update_mode == "auto"}
class="mt-1 text-sm text-base-content/60" class="mt-1 text-sm text-base-content/60"
> >
{gettext("Updating results for the visible map area…")} {gettext("Updating results for the visible map area…")}
</p> </p>
<p <p
:if={!@pending_viewport && @map_update_mode == "auto"} :if={!@discovery_loading && !@pending_viewport && @map_update_mode == "auto"}
class="mt-1 text-sm text-base-content/60" class="mt-1 text-sm text-base-content/60"
> >
{gettext("Results update automatically after you stop moving the map.")} {gettext("Results update automatically after you stop moving the map.")}
</p> </p>
<p <p
:if={!@pending_viewport && @map_update_mode == "manual"} :if={!@discovery_loading && !@pending_viewport && @map_update_mode == "manual"}
class="mt-1 text-sm text-base-content/60" class="mt-1 text-sm text-base-content/60"
> >
{gettext("Move or zoom the map, then choose “Show activities in this area”.")} {gettext("Move or zoom the map, then choose “Show activities in this area”.")}
@ -673,16 +675,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
<div class="request-discovery-content"> <div class="request-discovery-content">
<div id="activity-results-list" class="request-results-list space-y-4"> <div id="activity-results-list" class="request-results-list space-y-4">
<div <div
:if={@discovery_loading} :if={is_nil(@viewport)}
class="flex min-h-32 items-center justify-center rounded-3xl border border-base-300 bg-base-100"
role="status"
>
<span class="loading loading-spinner loading-md text-info" aria-hidden="true" />
<span class="sr-only">{gettext("Loading results…")}</span>
</div>
<div
:if={!@discovery_loading && is_nil(@viewport)}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center" class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
> >
<.icon name="hero-map" class="mx-auto size-8 text-info" /> <.icon name="hero-map" class="mx-auto size-8 text-info" />
@ -693,10 +686,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
</div> </div>
<div <div
:if={!@discovery_loading && @viewport && @activities == []} :if={@viewport && @activities == []}
class="rounded-3xl border border-dashed border-base-300 p-12 text-center" class="rounded-3xl border border-dashed border-base-300 p-12 text-center"
> >
<h2 class="text-xl font-bold">{gettext("No open activities in this area")}</h2> <h2 class="text-xl font-bold">
{if @discovery_loading,
do: gettext("Loading results…"),
else: gettext("No open activities in this area")}
</h2>
<p class="mt-2 text-base-content/60"> <p class="mt-2 text-base-content/60">
{gettext("Move or zoom the map to search somewhere else.")} {gettext("Move or zoom the map to search somewhere else.")}
</p> </p>

View File

@ -607,11 +607,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
class="request-discovery mt-6" class="request-discovery mt-6"
> >
<div class="request-discovery-toolbar"> <div class="request-discovery-toolbar">
<div :if={@discovery_loading}> <div class="min-w-0" aria-live="polite">
<strong>{gettext("Loading results…")}</strong>
</div>
<div :if={!@discovery_loading} class="min-w-0" aria-live="polite">
<strong> <strong>
{ngettext( {ngettext(
"%{count} request in the selected map area", "%{count} request in the selected map area",
@ -629,25 +625,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
</span> </span>
<p <p
:if={@pending_viewport && @map_update_mode == "manual"} :if={@discovery_loading}
class="mt-1 text-sm text-base-content/60"
>
{gettext("Loading results…")}
</p>
<p
:if={!@discovery_loading && @pending_viewport && @map_update_mode == "manual"}
class="mt-1 text-sm font-medium text-warning" class="mt-1 text-sm font-medium text-warning"
> >
{gettext("Map moved. Results still show the previous area.")} {gettext("Map moved. Results still show the previous area.")}
</p> </p>
<p <p
:if={@pending_viewport && @map_update_mode == "auto"} :if={!@discovery_loading && @pending_viewport && @map_update_mode == "auto"}
class="mt-1 text-sm text-base-content/60" class="mt-1 text-sm text-base-content/60"
> >
{gettext("Updating results for the visible map area…")} {gettext("Updating results for the visible map area…")}
</p> </p>
<p <p
:if={!@pending_viewport && @map_update_mode == "auto"} :if={!@discovery_loading && !@pending_viewport && @map_update_mode == "auto"}
class="mt-1 text-sm text-base-content/60" class="mt-1 text-sm text-base-content/60"
> >
{gettext("Results update automatically after you stop moving the map.")} {gettext("Results update automatically after you stop moving the map.")}
</p> </p>
<p <p
:if={!@pending_viewport && @map_update_mode == "manual"} :if={!@discovery_loading && !@pending_viewport && @map_update_mode == "manual"}
class="mt-1 text-sm text-base-content/60" class="mt-1 text-sm text-base-content/60"
> >
{gettext("Move or zoom the map, then choose “Show requests in this area”.")} {gettext("Move or zoom the map, then choose “Show requests in this area”.")}
@ -705,16 +707,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
<div class="request-discovery-content"> <div class="request-discovery-content">
<div id="request-results-list" class="request-results-list space-y-4"> <div id="request-results-list" class="request-results-list space-y-4">
<div <div
:if={@discovery_loading} :if={is_nil(@viewport)}
class="flex min-h-32 items-center justify-center rounded-3xl border border-base-300 bg-base-100"
role="status"
>
<span class="loading loading-spinner loading-md text-success" aria-hidden="true" />
<span class="sr-only">{gettext("Loading results…")}</span>
</div>
<div
:if={!@discovery_loading && is_nil(@viewport)}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center" class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
> >
<.icon name="hero-map" class="mx-auto size-8 text-success" /> <.icon name="hero-map" class="mx-auto size-8 text-success" />
@ -725,10 +718,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
</div> </div>
<div <div
:if={!@discovery_loading && @viewport && @requests == []} :if={@viewport && @requests == []}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center" class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
> >
<h2 class="text-xl font-bold">{gettext("No open requests in this area")}</h2> <h2 class="text-xl font-bold">
{if @discovery_loading,
do: gettext("Loading results…"),
else: gettext("No open requests in this area")}
</h2>
<p class="mt-2 text-base-content/60"> <p class="mt-2 text-base-content/60">
{gettext("Move or zoom the map to search somewhere else.")} {gettext("Move or zoom the map to search somewhere else.")}
</p> </p>