Stabilize discovery map interactions
This commit is contained in:
parent
af922b015f
commit
040889de1a
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
25
assets/js/map_viewport_sync.mjs
Normal file
25
assets/js/map_viewport_sync.mjs
Normal 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
|
||||||
|
}
|
||||||
66
assets/js/map_viewport_sync.test.mjs
Normal file
66
assets/js/map_viewport_sync.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user