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);
}
.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;

View File

@ -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 formattedCount = new Intl.NumberFormat(uiLocale()).format(count)
const label = (state.element.dataset.clusterLabel || "%{count} items")
.replace("%{count}", formattedCount)
let entry = state.clusterMarkers.get(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
if (!entry) {
const button = document.createElement("button")
const marker = new maplibregl.Marker({element: button})
.setSubpixelPositioning(true)
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.className = "request-map-cluster"
button.addEventListener("click", event => {
event.stopPropagation()
if (nativeCluster) {
state.expandNativeCluster(feature)
} else {
state.expandServerCluster(feature)
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.markers.push(
new maplibregl.Marker({element: button})
.setLngLat(feature.geometry.coordinates)
.addTo(state.map)
)
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()

View File

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

View File

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

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

View File

@ -575,11 +575,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
class="request-discovery mt-6"
>
<div class="request-discovery-toolbar">
<div :if={@discovery_loading}>
<strong>{gettext("Loading results…")}</strong>
</div>
<div :if={!@discovery_loading} class="min-w-0" aria-live="polite">
<div class="min-w-0" aria-live="polite">
<strong>
{ngettext(
"%{count} activity in the selected map area",
@ -597,25 +593,31 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
</span>
<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"
>
{gettext("Map moved. Results still show the previous area.")}
</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"
>
{gettext("Updating results for the visible map area…")}
</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"
>
{gettext("Results update automatically after you stop moving the map.")}
</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"
>
{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 id="activity-results-list" class="request-results-list space-y-4">
<div
:if={@discovery_loading}
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)}
:if={is_nil(@viewport)}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
>
<.icon name="hero-map" class="mx-auto size-8 text-info" />
@ -693,10 +686,14 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
</div>
<div
:if={!@discovery_loading && @viewport && @activities == []}
:if={@viewport && @activities == []}
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">
{gettext("Move or zoom the map to search somewhere else.")}
</p>

View File

@ -607,11 +607,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
class="request-discovery mt-6"
>
<div class="request-discovery-toolbar">
<div :if={@discovery_loading}>
<strong>{gettext("Loading results…")}</strong>
</div>
<div :if={!@discovery_loading} class="min-w-0" aria-live="polite">
<div class="min-w-0" aria-live="polite">
<strong>
{ngettext(
"%{count} request in the selected map area",
@ -629,25 +625,31 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
</span>
<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"
>
{gettext("Map moved. Results still show the previous area.")}
</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"
>
{gettext("Updating results for the visible map area…")}
</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"
>
{gettext("Results update automatically after you stop moving the map.")}
</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"
>
{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 id="request-results-list" class="request-results-list space-y-4">
<div
:if={@discovery_loading}
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)}
:if={is_nil(@viewport)}
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
>
<.icon name="hero-map" class="mx-auto size-8 text-success" />
@ -725,10 +718,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
</div>
<div
:if={!@discovery_loading && @viewport && @requests == []}
:if={@viewport && @requests == []}
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">
{gettext("Move or zoom the map to search somewhere else.")}
</p>