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);
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@ import maplibregl from "maplibre-gl"
|
|||
import {
|
||||
clusterCountLabel,
|
||||
clusterExpansionTarget,
|
||||
clusterSizeClass,
|
||||
pointFeatureCollection
|
||||
pointFeatureCollection,
|
||||
pointSourceOptions,
|
||||
serverClusterDescriptor
|
||||
} from "./map_cluster_features.mjs"
|
||||
import {
|
||||
mapSizeChanged,
|
||||
shouldApplyInitialViewport,
|
||||
viewportMatchesBounds
|
||||
} from "./map_viewport_sync.mjs"
|
||||
|
||||
const trackingMinTimeMs = 5000
|
||||
|
||||
|
|
@ -90,6 +96,7 @@ const defaultStyle = {
|
|||
type: "raster",
|
||||
tiles: [document.documentElement.dataset.mapTileUrl],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: "© OpenStreetMap contributors"
|
||||
}
|
||||
},
|
||||
|
|
@ -281,11 +288,14 @@ const createAidMap = element => {
|
|||
element,
|
||||
map: null,
|
||||
active: true,
|
||||
viewportFrame: null,
|
||||
viewportTimer: null,
|
||||
resizeFrame: null,
|
||||
resizeObserver: null,
|
||||
markers: [],
|
||||
renderedSize: null,
|
||||
clusterMarkers: new Map(),
|
||||
visibleClusterKeys: new Set(),
|
||||
markerElements: new Map(),
|
||||
markerDataSignature: null,
|
||||
points: [],
|
||||
selectedItemId: null,
|
||||
clusterExpansionPending: false,
|
||||
|
|
@ -298,8 +308,6 @@ const createAidMap = element => {
|
|||
areaLineId: `${element.id || "aid-map"}-area-line`,
|
||||
areaSelectedId: `${element.id || "aid-map"}-area-selected`,
|
||||
clusterSourceId: `${element.id || "aid-map"}-clusters`,
|
||||
clusterCircleId: `${element.id || "aid-map"}-cluster-circle`,
|
||||
serverClusterCircleId: `${element.id || "aid-map"}-server-cluster-circle`,
|
||||
pointCircleId: `${element.id || "aid-map"}-point-circle`,
|
||||
pointSelectedId: `${element.id || "aid-map"}-point-selected`,
|
||||
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
|
||||
|
|
@ -315,6 +323,14 @@ const createAidMap = element => {
|
|||
state.element.dataset.mapReady = "true"
|
||||
}
|
||||
|
||||
state.queueViewportChange = viewport => {
|
||||
window.clearTimeout(state.viewportTimer)
|
||||
state.viewportTimer = window.setTimeout(() => {
|
||||
state.viewportTimer = null
|
||||
if (state.active) state.onViewportChange?.(viewport)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
state.retry = () => {
|
||||
if (!state.active) return
|
||||
|
||||
|
|
@ -459,6 +475,10 @@ const createAidMap = element => {
|
|||
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
|
||||
}
|
||||
|
||||
if (state.map?.getLayer(state.areaFillId)) {
|
||||
state.map.setFilter(state.areaFillId, ["==", ["get", "id"], id || ""])
|
||||
}
|
||||
|
||||
if (state.map?.getLayer(state.pointSelectedId)) {
|
||||
state.map.setFilter(state.pointSelectedId, ["==", ["get", "id"], id || ""])
|
||||
}
|
||||
|
|
@ -474,6 +494,7 @@ const createAidMap = element => {
|
|||
|
||||
const {width, height} = state.element.getBoundingClientRect()
|
||||
if (width < 160 || height < 160) return
|
||||
if (!mapSizeChanged(state.renderedSize, {width, height})) return
|
||||
|
||||
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
|
||||
state.resizeFrame = window.requestAnimationFrame(() => {
|
||||
|
|
@ -481,20 +502,34 @@ const createAidMap = element => {
|
|||
if (!state.active || !state.map) return
|
||||
|
||||
const current = state.element.getBoundingClientRect()
|
||||
if (current.width >= 160 && current.height >= 160) {
|
||||
const currentSize = {width: current.width, height: current.height}
|
||||
|
||||
if (
|
||||
current.width >= 160 &&
|
||||
current.height >= 160 &&
|
||||
mapSizeChanged(state.renderedSize, currentSize)
|
||||
) {
|
||||
state.renderedSize = currentSize
|
||||
state.map.resize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
state.applySavedViewport = () => {
|
||||
if (!state.active || !state.map) return
|
||||
state.applyInitialViewport = () => {
|
||||
if (
|
||||
!state.active ||
|
||||
!state.map ||
|
||||
!shouldApplyInitialViewport(state.initializedViewport)
|
||||
) return
|
||||
|
||||
const viewport = savedViewport(state.element)
|
||||
const serialized = viewport ? JSON.stringify(viewport) : ""
|
||||
if (!viewport || state.appliedViewport === serialized) return
|
||||
|
||||
state.appliedViewport = serialized
|
||||
|
||||
if (viewportMatchesBounds(viewport, state.map.getBounds())) return
|
||||
|
||||
state.map.fitBounds(
|
||||
[[viewport.west, viewport.south], [viewport.east, viewport.north]],
|
||||
{padding: 0, duration: 0}
|
||||
|
|
@ -513,48 +548,11 @@ const createAidMap = element => {
|
|||
return true
|
||||
}
|
||||
|
||||
state.expandNativeCluster = feature => {
|
||||
const source = state.map?.getSource(state.clusterSourceId)
|
||||
const clusterId = Number(feature?.properties?.cluster_id)
|
||||
if (!source || !Number.isFinite(clusterId)) return
|
||||
state.expandServerCluster = point => {
|
||||
if (!point || !state.map) return
|
||||
|
||||
Promise.resolve(source.getClusterExpansionZoom(clusterId)).then(expansionZoom => {
|
||||
if (!state.active || !state.map) return
|
||||
|
||||
const currentZoom = state.map.getZoom()
|
||||
const targetZoom = clusterExpansionTarget(currentZoom, Number(expansionZoom))
|
||||
|
||||
if (currentZoom >= 21.75 && targetZoom <= 22) {
|
||||
const count = Number(
|
||||
feature.properties?.total_count || feature.properties?.point_count
|
||||
) || 1
|
||||
const label = (state.element.dataset.clusterLabel || "%{count} items")
|
||||
.replace("%{count}", new Intl.NumberFormat(uiLocale()).format(count))
|
||||
|
||||
new maplibregl.Popup({offset: 18})
|
||||
.setLngLat(feature.geometry.coordinates)
|
||||
.setDOMContent(state.popupContent({
|
||||
title: label,
|
||||
location: state.element.dataset.clusterMaxZoomLabel || ""
|
||||
}))
|
||||
.addTo(state.map)
|
||||
return
|
||||
}
|
||||
|
||||
state.clusterExpansionPending = true
|
||||
state.map.easeTo({
|
||||
center: feature.geometry.coordinates,
|
||||
zoom: targetZoom,
|
||||
duration: 300
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
state.expandServerCluster = feature => {
|
||||
if (!feature || !state.map) return
|
||||
|
||||
const count = Number(feature.properties?.weight) || 1
|
||||
const requestedZoom = Number(feature.properties?.expansion_zoom)
|
||||
const count = Number(point.count) || 1
|
||||
const requestedZoom = Number(point.expansion_zoom)
|
||||
const currentZoom = state.map.getZoom()
|
||||
const expansionZoom = clusterExpansionTarget(
|
||||
currentZoom,
|
||||
|
|
@ -566,7 +564,7 @@ const createAidMap = element => {
|
|||
.replace("%{count}", String(count))
|
||||
|
||||
new maplibregl.Popup({offset: 18})
|
||||
.setLngLat(feature.geometry.coordinates)
|
||||
.setLngLat([point.longitude, point.latitude])
|
||||
.setDOMContent(state.popupContent({
|
||||
title: label,
|
||||
location: state.element.dataset.clusterMaxZoomLabel || ""
|
||||
|
|
@ -577,77 +575,95 @@ const createAidMap = element => {
|
|||
|
||||
state.clusterExpansionPending = true
|
||||
state.map.easeTo({
|
||||
center: feature.geometry.coordinates,
|
||||
center: [point.longitude, point.latitude],
|
||||
zoom: expansionZoom,
|
||||
duration: 300
|
||||
})
|
||||
}
|
||||
|
||||
state.syncClusterMarkers = () => {
|
||||
state.markers.forEach(marker => marker.remove())
|
||||
state.markers = []
|
||||
if (!state.map || state.element.dataset.discoveryMap !== "true") return
|
||||
|
||||
if (
|
||||
!state.map ||
|
||||
state.element.dataset.discoveryMap !== "true" ||
|
||||
!state.map.getLayer(state.clusterCircleId) ||
|
||||
!state.map.getLayer(state.serverClusterCircleId)
|
||||
) return
|
||||
const visibleKeys = new Set()
|
||||
|
||||
const features = state.map.queryRenderedFeatures({
|
||||
layers: [state.clusterCircleId, state.serverClusterCircleId]
|
||||
})
|
||||
const seen = new Set()
|
||||
state.points.forEach(point => {
|
||||
const descriptor = serverClusterDescriptor(point)
|
||||
if (!descriptor) return
|
||||
|
||||
features.forEach(feature => {
|
||||
const nativeCluster =
|
||||
feature.properties?.cluster === true || feature.properties?.cluster === "true"
|
||||
const key = nativeCluster
|
||||
? `native:${feature.properties.cluster_id}`
|
||||
: `server:${feature.properties?.server_cluster_id || feature.properties?.id}`
|
||||
const {key, count, urgentCount, longitude, latitude} = descriptor
|
||||
visibleKeys.add(key)
|
||||
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
|
||||
const count = Number(
|
||||
nativeCluster
|
||||
? feature.properties?.total_count || feature.properties?.point_count
|
||||
: feature.properties?.weight
|
||||
) || 1
|
||||
const urgentCount = Number(
|
||||
nativeCluster
|
||||
? feature.properties?.urgent_count
|
||||
: feature.properties?.urgent_weight
|
||||
) || 0
|
||||
const button = document.createElement("button")
|
||||
|
||||
button.type = "button"
|
||||
button.className = `request-map-cluster ${clusterSizeClass(count)}`
|
||||
button.dataset.clusterCount = String(count)
|
||||
button.classList.toggle("is-urgent", urgentCount > 0)
|
||||
button.textContent = clusterCountLabel(count, uiLocale())
|
||||
const formattedCount = new Intl.NumberFormat(uiLocale()).format(count)
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
(state.element.dataset.clusterLabel || "%{count} items")
|
||||
.replace("%{count}", formattedCount)
|
||||
)
|
||||
button.title = button.getAttribute("aria-label")
|
||||
button.addEventListener("click", event => {
|
||||
event.stopPropagation()
|
||||
if (nativeCluster) {
|
||||
state.expandNativeCluster(feature)
|
||||
} else {
|
||||
state.expandServerCluster(feature)
|
||||
}
|
||||
})
|
||||
const label = (state.element.dataset.clusterLabel || "%{count} items")
|
||||
.replace("%{count}", formattedCount)
|
||||
let entry = state.clusterMarkers.get(key)
|
||||
|
||||
state.markers.push(
|
||||
new maplibregl.Marker({element: button})
|
||||
.setLngLat(feature.geometry.coordinates)
|
||||
.addTo(state.map)
|
||||
)
|
||||
if (!entry) {
|
||||
const button = document.createElement("button")
|
||||
const marker = new maplibregl.Marker({element: button})
|
||||
.setSubpixelPositioning(true)
|
||||
|
||||
button.type = "button"
|
||||
button.className = "request-map-cluster"
|
||||
button.addEventListener("click", event => {
|
||||
event.stopPropagation()
|
||||
state.expandServerCluster(entry.point)
|
||||
})
|
||||
|
||||
entry = {
|
||||
button,
|
||||
marker,
|
||||
point,
|
||||
visible: false,
|
||||
count: null,
|
||||
urgentCount: null,
|
||||
label: null,
|
||||
longitude: null,
|
||||
latitude: null
|
||||
}
|
||||
state.clusterMarkers.set(key, entry)
|
||||
}
|
||||
|
||||
entry.point = point
|
||||
|
||||
if (entry.count !== count || entry.urgentCount !== urgentCount) {
|
||||
entry.count = count
|
||||
entry.urgentCount = urgentCount
|
||||
entry.button.dataset.clusterCount = String(count)
|
||||
entry.button.classList.toggle("is-lg", count >= 1_000 && count < 10_000)
|
||||
entry.button.classList.toggle("is-xl", count >= 10_000 && count < 100_000)
|
||||
entry.button.classList.toggle("is-xxl", count >= 100_000)
|
||||
entry.button.classList.toggle("is-urgent", urgentCount > 0)
|
||||
entry.button.textContent = clusterCountLabel(count, uiLocale())
|
||||
}
|
||||
|
||||
if (entry.label !== label) {
|
||||
entry.label = label
|
||||
entry.button.setAttribute("aria-label", label)
|
||||
entry.button.title = label
|
||||
}
|
||||
|
||||
if (entry.longitude !== longitude || entry.latitude !== latitude) {
|
||||
entry.longitude = longitude
|
||||
entry.latitude = latitude
|
||||
entry.marker.setLngLat([longitude, latitude])
|
||||
}
|
||||
|
||||
if (!entry.visible) {
|
||||
entry.marker.addTo(state.map)
|
||||
entry.visible = true
|
||||
}
|
||||
})
|
||||
|
||||
state.visibleClusterKeys.forEach(key => {
|
||||
if (visibleKeys.has(key)) return
|
||||
|
||||
const entry = state.clusterMarkers.get(key)
|
||||
entry?.marker.remove()
|
||||
state.clusterMarkers.delete(key)
|
||||
})
|
||||
|
||||
state.visibleClusterKeys = visibleKeys
|
||||
}
|
||||
|
||||
state.syncVisibleAreas = () => {
|
||||
|
|
@ -656,26 +672,10 @@ const createAidMap = element => {
|
|||
const areaSource = state.map.getSource(state.areaSourceId)
|
||||
if (!areaSource) return
|
||||
|
||||
let visiblePoints = state.points
|
||||
|
||||
if (state.element.dataset.discoveryMap === "true") {
|
||||
if (!state.map.getSource(state.clusterSourceId)?.loaded()) return
|
||||
|
||||
const visibleIds = new Set(
|
||||
state.map
|
||||
.querySourceFeatures(state.clusterSourceId, {
|
||||
filter: [
|
||||
"all",
|
||||
["!", ["has", "point_count"]],
|
||||
["!=", ["get", "item_type"], "cluster"]
|
||||
]
|
||||
})
|
||||
.map(feature => feature.properties?.id)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
visiblePoints = state.points.filter(point => visibleIds.has(point.id))
|
||||
}
|
||||
const visiblePoints =
|
||||
state.element.dataset.discoveryMap === "true"
|
||||
? state.points.filter(point => point.type !== "cluster")
|
||||
: state.points
|
||||
|
||||
const signature = visiblePoints
|
||||
.filter(point => !point.exact && Number.isFinite(point.radius_meters))
|
||||
|
|
@ -691,13 +691,23 @@ const createAidMap = element => {
|
|||
state.renderMarkers = () => {
|
||||
if (!state.map) return
|
||||
|
||||
state.markers.forEach(marker => marker.remove())
|
||||
state.markers = []
|
||||
state.markerElements.clear()
|
||||
const markerDataSignature =
|
||||
state.element.dataset.items || state.element.dataset.markers || "[]"
|
||||
const existingAreaSource = state.map.getSource(state.areaSourceId)
|
||||
const existingPointSource = state.map.getSource(state.clusterSourceId)
|
||||
|
||||
if (
|
||||
state.markerDataSignature === markerDataSignature &&
|
||||
existingAreaSource &&
|
||||
existingPointSource
|
||||
) {
|
||||
state.pendingMarkerRender = false
|
||||
return
|
||||
}
|
||||
|
||||
const points = markerPoints(state.element)
|
||||
state.points = points
|
||||
const pointFeatures = pointFeatureCollection(points)
|
||||
const pointFeatures = pointFeatureCollection(points, uiLocale())
|
||||
const areaFeatures =
|
||||
state.element.dataset.discoveryMap === "true"
|
||||
? emptyFeatureCollection()
|
||||
|
|
@ -720,9 +730,6 @@ const createAidMap = element => {
|
|||
})
|
||||
state.locationBounds = points.length > 0 ? bounds : null
|
||||
|
||||
const existingAreaSource = state.map.getSource(state.areaSourceId)
|
||||
const existingPointSource = state.map.getSource(state.clusterSourceId)
|
||||
|
||||
if (
|
||||
(!existingAreaSource || !existingPointSource) &&
|
||||
!state.map.isStyleLoaded()
|
||||
|
|
@ -745,6 +752,7 @@ const createAidMap = element => {
|
|||
type: "fill",
|
||||
source: state.areaSourceId,
|
||||
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
|
||||
filter: ["==", ["get", "id"], state.selectedItemId || ""],
|
||||
paint: {"fill-color": "#278467", "fill-opacity": 0.18}
|
||||
})
|
||||
state.map.addLayer({
|
||||
|
|
@ -754,7 +762,8 @@ const createAidMap = element => {
|
|||
minzoom: state.element.dataset.discoveryMap === "true" ? 12 : 0,
|
||||
paint: {
|
||||
"line-color": "#16725b",
|
||||
"line-width": 2,
|
||||
"line-width": 1.5,
|
||||
"line-opacity": 0.55,
|
||||
"line-dasharray": [2, 2]
|
||||
}
|
||||
})
|
||||
|
|
@ -767,22 +776,6 @@ const createAidMap = element => {
|
|||
paint: {"line-color": "#d6573b", "line-width": 5}
|
||||
})
|
||||
|
||||
state.map.on("mouseenter", state.areaFillId, () => {
|
||||
state.map.getCanvas().style.cursor = "pointer"
|
||||
})
|
||||
state.map.on("mouseleave", state.areaFillId, () => {
|
||||
state.map.getCanvas().style.cursor = ""
|
||||
})
|
||||
state.map.on("click", state.areaFillId, event => {
|
||||
const properties = event.features?.[0]?.properties
|
||||
if (!properties?.id) return
|
||||
|
||||
state.selectRequest(properties.id)
|
||||
new maplibregl.Popup({offset: 12})
|
||||
.setLngLat(event.lngLat)
|
||||
.setDOMContent(state.popupContent(properties))
|
||||
.addTo(state.map)
|
||||
})
|
||||
}
|
||||
|
||||
const pointSource = existingPointSource
|
||||
|
|
@ -790,98 +783,13 @@ const createAidMap = element => {
|
|||
if (pointSource) {
|
||||
pointSource.setData(pointFeatures)
|
||||
} else {
|
||||
const sourceOptions = {
|
||||
type: "geojson",
|
||||
data: pointFeatures,
|
||||
generateId: false
|
||||
}
|
||||
|
||||
if (state.element.dataset.discoveryMap === "true") {
|
||||
Object.assign(sourceOptions, {
|
||||
maxzoom: 22,
|
||||
cluster: true,
|
||||
clusterRadius: 64,
|
||||
clusterMaxZoom: 21,
|
||||
clusterMinPoints: 2,
|
||||
clusterProperties: {
|
||||
total_count: ["+", ["get", "weight"]],
|
||||
urgent_count: ["+", ["get", "urgent_weight"]]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
state.map.addSource(state.clusterSourceId, sourceOptions)
|
||||
state.map.addLayer({
|
||||
id: state.clusterCircleId,
|
||||
type: "circle",
|
||||
source: state.clusterSourceId,
|
||||
filter: ["has", "point_count"],
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"case",
|
||||
[">", ["coalesce", ["get", "urgent_count"], 0], 0],
|
||||
"#c2412d",
|
||||
"#087f5b"
|
||||
],
|
||||
"circle-radius": [
|
||||
"step",
|
||||
["coalesce", ["get", "total_count"], ["get", "point_count"]],
|
||||
22,
|
||||
100,
|
||||
25,
|
||||
1_000,
|
||||
28,
|
||||
10_000,
|
||||
31,
|
||||
100_000,
|
||||
34
|
||||
],
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 3,
|
||||
"circle-opacity": 0.96
|
||||
}
|
||||
})
|
||||
state.map.addLayer({
|
||||
id: state.serverClusterCircleId,
|
||||
type: "circle",
|
||||
source: state.clusterSourceId,
|
||||
filter: [
|
||||
"all",
|
||||
["!", ["has", "point_count"]],
|
||||
["==", ["get", "item_type"], "cluster"]
|
||||
],
|
||||
paint: {
|
||||
"circle-color": [
|
||||
"case",
|
||||
[">", ["coalesce", ["get", "urgent_weight"], 0], 0],
|
||||
"#c2412d",
|
||||
"#087f5b"
|
||||
],
|
||||
"circle-radius": [
|
||||
"step",
|
||||
["get", "weight"],
|
||||
22,
|
||||
100,
|
||||
25,
|
||||
1_000,
|
||||
28,
|
||||
10_000,
|
||||
31,
|
||||
100_000,
|
||||
34
|
||||
],
|
||||
"circle-stroke-color": "#ffffff",
|
||||
"circle-stroke-width": 3,
|
||||
"circle-opacity": 0.96
|
||||
}
|
||||
})
|
||||
state.map.addSource(state.clusterSourceId, pointSourceOptions(pointFeatures))
|
||||
state.map.addLayer({
|
||||
id: state.pointCircleId,
|
||||
type: "circle",
|
||||
source: state.clusterSourceId,
|
||||
filter: [
|
||||
"all",
|
||||
["!", ["has", "point_count"]],
|
||||
["!=", ["get", "item_type"], "cluster"]
|
||||
],
|
||||
paint: {
|
||||
|
|
@ -911,11 +819,7 @@ const createAidMap = element => {
|
|||
}
|
||||
})
|
||||
|
||||
const interactiveLayers = [
|
||||
state.clusterCircleId,
|
||||
state.serverClusterCircleId,
|
||||
state.pointCircleId
|
||||
]
|
||||
const interactiveLayers = [state.pointCircleId]
|
||||
|
||||
interactiveLayers.forEach(layerId => {
|
||||
state.map.on("mouseenter", layerId, () => {
|
||||
|
|
@ -926,16 +830,6 @@ const createAidMap = element => {
|
|||
})
|
||||
})
|
||||
|
||||
state.map.on("click", state.clusterCircleId, event => {
|
||||
const feature = event.features?.[0]
|
||||
if (feature) state.expandNativeCluster(feature)
|
||||
})
|
||||
|
||||
state.map.on("click", state.serverClusterCircleId, event => {
|
||||
const feature = event.features?.[0]
|
||||
if (feature) state.expandServerCluster(feature)
|
||||
})
|
||||
|
||||
state.map.on("click", state.pointCircleId, event => {
|
||||
const feature = event.features?.[0]
|
||||
const properties = feature?.properties
|
||||
|
|
@ -951,6 +845,8 @@ const createAidMap = element => {
|
|||
}
|
||||
}
|
||||
|
||||
state.markerDataSignature = markerDataSignature
|
||||
state.syncClusterMarkers()
|
||||
if (state.element.dataset.autoFit !== "false") state.fitMarkers()
|
||||
}
|
||||
|
||||
|
|
@ -984,6 +880,17 @@ const createAidMap = element => {
|
|||
return
|
||||
}
|
||||
|
||||
// Restore the URL camera before MapLibre's first complete style load. Waiting
|
||||
// for `load` briefly exposes the default camera and produces a visible jump
|
||||
// when slow or unavailable tiles delay that event.
|
||||
state.applyInitialViewport()
|
||||
state.initializedViewport = true
|
||||
|
||||
if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) {
|
||||
const viewport = mapViewport(state.map)
|
||||
if (viewport) state.onInitializeViewport?.(viewport)
|
||||
}
|
||||
|
||||
if (demoMap) state.map.touchZoomRotate.disableRotation()
|
||||
|
||||
state.map.addControl(
|
||||
|
|
@ -1014,25 +921,14 @@ const createAidMap = element => {
|
|||
delete state.element.dataset.mapUnavailable
|
||||
delete state.element.dataset.mapUnavailableReason
|
||||
state.markReady()
|
||||
const {width, height} = state.element.getBoundingClientRect()
|
||||
state.renderedSize = {width, height}
|
||||
|
||||
if (state.statusBadge) {
|
||||
state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel
|
||||
}
|
||||
|
||||
state.applySavedViewport()
|
||||
state.renderMarkers()
|
||||
|
||||
state.viewportFrame = window.requestAnimationFrame(() => {
|
||||
state.viewportFrame = null
|
||||
if (!state.active || !state.map) return
|
||||
|
||||
state.initializedViewport = true
|
||||
|
||||
if (state.element.dataset.discoveryMap === "true" && !savedViewport(state.element)) {
|
||||
const viewport = mapViewport(state.map)
|
||||
if (viewport) state.onInitializeViewport?.(viewport)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
state.onMoveEnd = () => {
|
||||
|
|
@ -1042,9 +938,11 @@ const createAidMap = element => {
|
|||
|
||||
if (state.clusterExpansionPending) {
|
||||
state.clusterExpansionPending = false
|
||||
window.clearTimeout(state.viewportTimer)
|
||||
state.viewportTimer = null
|
||||
state.onClusterExpand?.(viewport)
|
||||
} else {
|
||||
state.onViewportChange?.(viewport)
|
||||
state.queueViewportChange(viewport)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1057,12 +955,10 @@ const createAidMap = element => {
|
|||
}
|
||||
|
||||
state.syncVisibleAreas()
|
||||
state.syncClusterMarkers()
|
||||
}
|
||||
|
||||
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
|
||||
|
||||
state.map.on("dataloading", state.markLoading)
|
||||
state.map.on("load", state.onLoad)
|
||||
state.map.on("idle", state.onIdle)
|
||||
state.map.on("moveend", state.onMoveEnd)
|
||||
|
|
@ -1076,14 +972,16 @@ const createAidMap = element => {
|
|||
|
||||
state.destroy = () => {
|
||||
state.active = false
|
||||
if (state.viewportFrame !== null) window.cancelAnimationFrame(state.viewportFrame)
|
||||
window.clearTimeout(state.viewportTimer)
|
||||
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
|
||||
state.resizeObserver?.disconnect()
|
||||
state.map?.off("dataloading", state.markLoading)
|
||||
state.map?.off("load", state.onLoad)
|
||||
state.map?.off("idle", state.onIdle)
|
||||
state.map?.off("moveend", state.onMoveEnd)
|
||||
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
|
||||
state.clusterMarkers.forEach(({marker}) => marker.remove())
|
||||
state.clusterMarkers.clear()
|
||||
state.visibleClusterKeys.clear()
|
||||
state.map?.remove()
|
||||
state.map = null
|
||||
state.locationBounds = null
|
||||
|
|
@ -2028,9 +1926,10 @@ export const Hooks = {
|
|||
this.aidMap.mount()
|
||||
},
|
||||
updated() {
|
||||
this.aidMap.applySavedViewport()
|
||||
// A server viewport is only the bounds used to query marker data. Once
|
||||
// the map is mounted, the user's camera is authoritative and must never
|
||||
// be moved back to bounds from a delayed LiveView response.
|
||||
this.aidMap.renderMarkers()
|
||||
this.aidMap.resize()
|
||||
},
|
||||
destroyed() {
|
||||
this.aidMap.destroy()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user