feat: scale request discovery by map viewport
This commit is contained in:
parent
3dfdf7a360
commit
6f22657731
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -37,6 +37,7 @@ who_need_help-*.tar
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
/assets/node_modules/
|
/assets/node_modules/
|
||||||
/e2e/node_modules/
|
/e2e/node_modules/
|
||||||
|
/e2e/output/
|
||||||
/.tools/
|
/.tools/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,11 @@ local Codex CLI authenticated with their ChatGPT subscription.
|
||||||
only the linked evidence to audited moderators.
|
only the linked evidence to audited moderators.
|
||||||
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
|
- Request lifecycle: `open → matched → in_progress → completed`, plus cancel
|
||||||
and expiry paths.
|
and expiry paths.
|
||||||
- PostgreSQL/PostGIS locations, MapLibre map, private matched chat, Phoenix
|
- PostgreSQL/PostGIS locations, viewport-scoped request discovery, server-side
|
||||||
PubSub/Presence, and optional consent-driven live location sharing.
|
map clustering, MapLibre map, private matched chat, Phoenix PubSub/Presence,
|
||||||
|
and optional consent-driven live location sharing. The browser loads only
|
||||||
|
the chosen map area; exact points, privacy areas, and coordinate-free
|
||||||
|
requests retain their distinct disclosure rules.
|
||||||
- Handover code plus both-party confirmation before verified completion.
|
- Handover code plus both-party confirmation before verified completion.
|
||||||
- Double-blind reviews, public trust summaries, and a helper leaderboard that
|
- Double-blind reviews, public trust summaries, and a helper leaderboard that
|
||||||
prioritizes unique location-supported and handover-verified counterparts
|
prioritizes unique location-supported and handover-verified counterparts
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,184 @@ html {
|
||||||
var(--color-base-200);
|
var(--color-base-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.request-discovery-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--color-base-300);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-view-switcher {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: var(--color-base-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-view-switcher .btn {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-view-switcher .btn-active {
|
||||||
|
color: var(--color-base-content);
|
||||||
|
background: var(--color-base-100);
|
||||||
|
box-shadow: 0 1px 5px color-mix(in oklab, var(--color-base-content) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery[data-view-mode="split"] .request-discovery-content {
|
||||||
|
grid-template-columns: minmax(20rem, 0.78fr) minmax(0, 1.22fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery[data-view-mode="list"][data-has-viewport="true"] .request-discovery-map-panel,
|
||||||
|
.request-discovery[data-view-mode="map"] .request-results-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-results-list {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-result-card {
|
||||||
|
transition:
|
||||||
|
border-color 150ms ease,
|
||||||
|
box-shadow 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-result-card:hover,
|
||||||
|
.request-result-card.is-selected {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
box-shadow: 0 10px 28px color-mix(in oklab, var(--color-success) 14%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery-map-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery[data-view-mode="split"] .request-discovery-map-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery-map-shell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery-map {
|
||||||
|
min-height: min(68vh, 46rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery[data-view-mode="map"] .request-discovery-map {
|
||||||
|
min-height: min(74vh, 52rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-map-search-button {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
top: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-map-cluster {
|
||||||
|
display: grid;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
place-items: center;
|
||||||
|
color: white;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
border: 3px solid white;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #16725b;
|
||||||
|
box-shadow: 0 3px 12px rgb(0 0 0 / 24%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-map-cluster.is-urgent {
|
||||||
|
background: #b63b21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-map-cluster:hover,
|
||||||
|
.request-map-cluster:focus-visible {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-marker.is-selected svg {
|
||||||
|
filter: drop-shadow(0 0 5px #d6573b) drop-shadow(0 0 8px white);
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-map-popup {
|
||||||
|
display: grid;
|
||||||
|
min-width: 11rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-map-popup small {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-map-popup a {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #0a6b55;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.request-discovery-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-view-switcher {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-view-switcher .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-view-split {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery[data-view-mode="split"] .request-discovery-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery[data-view-mode="split"] .request-discovery-map-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-discovery-map,
|
||||||
|
.request-discovery[data-view-mode="map"] .request-discovery-map {
|
||||||
|
min-height: max(30rem, calc(100svh - 12rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.home-demo-map {
|
.home-demo-map {
|
||||||
min-height: 32rem;
|
min-height: 32rem;
|
||||||
border-radius: 2rem;
|
border-radius: 2rem;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const defaultStyle = {
|
||||||
|
|
||||||
const markerPoints = element => {
|
const markerPoints = element => {
|
||||||
try {
|
try {
|
||||||
const points = JSON.parse(element.dataset.markers || "[]")
|
const points = JSON.parse(element.dataset.items || element.dataset.markers || "[]")
|
||||||
|
|
||||||
if (!Array.isArray(points)) return []
|
if (!Array.isArray(points)) return []
|
||||||
|
|
||||||
|
|
@ -31,7 +31,8 @@ const markerPoints = element => {
|
||||||
|
|
||||||
const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
|
const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
|
||||||
|
|
||||||
const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => {
|
const areaPolygon = point => {
|
||||||
|
const {longitude, latitude, radius_meters: radiusMeters} = point
|
||||||
const earthRadiusMeters = 6371008.8
|
const earthRadiusMeters = 6371008.8
|
||||||
const angularDistance = radiusMeters / earthRadiusMeters
|
const angularDistance = radiusMeters / earthRadiusMeters
|
||||||
const latitudeRadians = latitude * Math.PI / 180
|
const latitudeRadians = latitude * Math.PI / 180
|
||||||
|
|
@ -54,7 +55,13 @@ const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: {},
|
id: point.id,
|
||||||
|
properties: {
|
||||||
|
id: point.id,
|
||||||
|
title: point.title || "",
|
||||||
|
location: point.location || "",
|
||||||
|
type: point.type || "request"
|
||||||
|
},
|
||||||
geometry: {type: "Polygon", coordinates: [coordinates]}
|
geometry: {type: "Polygon", coordinates: [coordinates]}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +73,55 @@ const areaFeatureCollection = points => ({
|
||||||
.map(areaPolygon)
|
.map(areaPolygon)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clusterFeatureCollection = points => ({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: points
|
||||||
|
.filter(point => point.type === "cluster")
|
||||||
|
.map(point => ({
|
||||||
|
type: "Feature",
|
||||||
|
id: point.id,
|
||||||
|
properties: {
|
||||||
|
id: point.id,
|
||||||
|
count: point.count,
|
||||||
|
urgent_count: point.urgent_count || 0
|
||||||
|
},
|
||||||
|
geometry: {type: "Point", coordinates: [point.longitude, point.latitude]}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapViewport = map => {
|
||||||
|
const bounds = map.getBounds()
|
||||||
|
const container = map.getContainer()
|
||||||
|
if (container.clientWidth < 160 || container.clientHeight < 160) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
west: Math.max(-180, Math.min(180, bounds.getWest())),
|
||||||
|
south: Math.max(-85.05112878, Math.min(85.05112878, bounds.getSouth())),
|
||||||
|
east: Math.max(-180, Math.min(180, bounds.getEast())),
|
||||||
|
north: Math.max(-85.05112878, Math.min(85.05112878, bounds.getNorth())),
|
||||||
|
zoom: map.getZoom(),
|
||||||
|
width: Math.round(container.clientWidth),
|
||||||
|
height: Math.round(container.clientHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedViewport = element => {
|
||||||
|
try {
|
||||||
|
const viewport = JSON.parse(element.dataset.viewport || "")
|
||||||
|
if (!viewport || typeof viewport !== "object") return null
|
||||||
|
|
||||||
|
const west = Number(viewport.west)
|
||||||
|
const south = Number(viewport.south)
|
||||||
|
const east = Number(viewport.east)
|
||||||
|
const north = Number(viewport.north)
|
||||||
|
|
||||||
|
if (![west, south, east, north].every(Number.isFinite)) return null
|
||||||
|
return {west, south, east, north}
|
||||||
|
} catch (_error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const extendBoundsWithFeature = (bounds, feature) => {
|
const extendBoundsWithFeature = (bounds, feature) => {
|
||||||
feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate))
|
feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate))
|
||||||
}
|
}
|
||||||
|
|
@ -85,10 +141,20 @@ const createAidMap = element => {
|
||||||
const state = {
|
const state = {
|
||||||
element,
|
element,
|
||||||
map: null,
|
map: null,
|
||||||
|
active: true,
|
||||||
|
viewportFrame: null,
|
||||||
|
resizeFrame: null,
|
||||||
|
resizeObserver: null,
|
||||||
markers: [],
|
markers: [],
|
||||||
|
markerElements: new Map(),
|
||||||
|
initializedViewport: false,
|
||||||
areaSourceId: `${element.id || "aid-map"}-areas`,
|
areaSourceId: `${element.id || "aid-map"}-areas`,
|
||||||
areaFillId: `${element.id || "aid-map"}-area-fill`,
|
areaFillId: `${element.id || "aid-map"}-area-fill`,
|
||||||
areaLineId: `${element.id || "aid-map"}-area-line`
|
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`,
|
||||||
|
clusterCountId: `${element.id || "aid-map"}-cluster-count`
|
||||||
}
|
}
|
||||||
|
|
||||||
state.markLoading = () => {
|
state.markLoading = () => {
|
||||||
|
|
@ -154,21 +220,92 @@ const createAidMap = element => {
|
||||||
state.element.replaceChildren(fallback)
|
state.element.replaceChildren(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.popupContent = point => {
|
||||||
|
const content = document.createElement("div")
|
||||||
|
content.className = "request-map-popup"
|
||||||
|
|
||||||
|
const title = document.createElement("strong")
|
||||||
|
title.textContent = point.title || ""
|
||||||
|
content.append(title)
|
||||||
|
|
||||||
|
const location = document.createElement("small")
|
||||||
|
location.textContent = point.location || ""
|
||||||
|
content.append(location)
|
||||||
|
|
||||||
|
if (point.id) {
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = `/requests/${encodeURIComponent(point.id)}`
|
||||||
|
link.textContent = state.element.dataset.requestLabel || "Open request"
|
||||||
|
content.append(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
state.highlightRequest = id => {
|
||||||
|
state.markerElements.forEach((element, requestId) => {
|
||||||
|
element.classList.toggle("is-selected", requestId === id)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (state.map?.getLayer(state.areaSelectedId)) {
|
||||||
|
state.map.setFilter(state.areaSelectedId, ["==", ["get", "id"], id || ""])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectRequest = id => {
|
||||||
|
state.highlightRequest(id)
|
||||||
|
window.dispatchEvent(new CustomEvent("wnh:request-selected", {detail: {id}}))
|
||||||
|
}
|
||||||
|
|
||||||
|
state.resize = () => {
|
||||||
|
if (!state.active || !state.map) return
|
||||||
|
|
||||||
|
const {width, height} = state.element.getBoundingClientRect()
|
||||||
|
if (width < 160 || height < 160) return
|
||||||
|
|
||||||
|
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
|
||||||
|
state.resizeFrame = window.requestAnimationFrame(() => {
|
||||||
|
state.resizeFrame = null
|
||||||
|
if (!state.active || !state.map) return
|
||||||
|
|
||||||
|
const current = state.element.getBoundingClientRect()
|
||||||
|
if (current.width >= 160 && current.height >= 160) state.map.resize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
state.applySavedViewport = () => {
|
||||||
|
if (!state.active || !state.map) return
|
||||||
|
|
||||||
|
const viewport = savedViewport(state.element)
|
||||||
|
const serialized = viewport ? JSON.stringify(viewport) : ""
|
||||||
|
if (!viewport || state.appliedViewport === serialized) return
|
||||||
|
|
||||||
|
state.appliedViewport = serialized
|
||||||
|
state.map.fitBounds(
|
||||||
|
[[viewport.west, viewport.south], [viewport.east, viewport.north]],
|
||||||
|
{padding: 0, duration: 0}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
state.renderMarkers = () => {
|
state.renderMarkers = () => {
|
||||||
if (!state.map) return
|
if (!state.map) return
|
||||||
|
|
||||||
state.markers.forEach(marker => marker.remove())
|
state.markers.forEach(marker => marker.remove())
|
||||||
state.markers = []
|
state.markers = []
|
||||||
|
state.markerElements.clear()
|
||||||
|
|
||||||
const points = markerPoints(state.element)
|
const points = markerPoints(state.element)
|
||||||
const exactPoints = points.filter(point => point.exact || !Number.isFinite(point.radius_meters))
|
const exactPoints = points.filter(
|
||||||
|
point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters))
|
||||||
|
)
|
||||||
const areaFeatures = areaFeatureCollection(points)
|
const areaFeatures = areaFeatureCollection(points)
|
||||||
|
const clusterFeatures = clusterFeatureCollection(points)
|
||||||
|
|
||||||
if (state.map.isStyleLoaded()) {
|
if (state.map.isStyleLoaded()) {
|
||||||
const source = state.map.getSource(state.areaSourceId)
|
const areaSource = state.map.getSource(state.areaSourceId)
|
||||||
|
|
||||||
if (source) {
|
if (areaSource) {
|
||||||
source.setData(areaFeatures)
|
areaSource.setData(areaFeatures)
|
||||||
} else {
|
} else {
|
||||||
state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures})
|
state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures})
|
||||||
state.map.addLayer({
|
state.map.addLayer({
|
||||||
|
|
@ -187,28 +324,75 @@ const createAidMap = element => {
|
||||||
"line-dasharray": [2, 2]
|
"line-dasharray": [2, 2]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
state.map.addLayer({
|
||||||
|
id: state.areaSelectedId,
|
||||||
|
type: "line",
|
||||||
|
source: state.areaSourceId,
|
||||||
|
filter: ["==", ["get", "id"], ""],
|
||||||
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exactPoints.forEach(point => {
|
exactPoints.forEach(point => {
|
||||||
const popupContent = document.createElement("div")
|
const popup = new maplibregl.Popup({offset: 18}).setDOMContent(state.popupContent(point))
|
||||||
const title = document.createElement("strong")
|
|
||||||
title.textContent = point.title || ""
|
|
||||||
popupContent.append(title, document.createElement("br"))
|
|
||||||
popupContent.append(document.createTextNode(point.location || ""))
|
|
||||||
|
|
||||||
const popup = new maplibregl.Popup({offset: 18}).setDOMContent(popupContent)
|
|
||||||
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
|
const marker = new maplibregl.Marker({color: point.exact ? "#d6573b" : "#278467"})
|
||||||
.setLngLat([point.longitude, point.latitude])
|
.setLngLat([point.longitude, point.latitude])
|
||||||
.setPopup(popup)
|
.setPopup(popup)
|
||||||
.addTo(state.map)
|
.addTo(state.map)
|
||||||
|
|
||||||
|
const markerElement = marker.getElement()
|
||||||
|
markerElement.dataset.requestId = point.id
|
||||||
|
markerElement.addEventListener("click", () => state.selectRequest(point.id))
|
||||||
|
state.markerElements.set(point.id, markerElement)
|
||||||
state.markers.push(marker)
|
state.markers.push(marker)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (points.length > 0) {
|
points.filter(point => point.type === "cluster").forEach(point => {
|
||||||
|
const element = document.createElement("button")
|
||||||
|
element.type = "button"
|
||||||
|
element.className = "request-map-cluster"
|
||||||
|
element.classList.toggle("is-urgent", Number(point.urgent_count) > 0)
|
||||||
|
element.textContent = String(point.count)
|
||||||
|
element.setAttribute("aria-label", `${point.count} requests`)
|
||||||
|
element.addEventListener("click", event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
state.map.easeTo({
|
||||||
|
center: [point.longitude, point.latitude],
|
||||||
|
zoom: Math.min(state.map.getZoom() + 2, 20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const marker = new maplibregl.Marker({element})
|
||||||
|
.setLngLat([point.longitude, point.latitude])
|
||||||
|
.addTo(state.map)
|
||||||
|
|
||||||
|
state.markers.push(marker)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (points.length > 0 && state.element.dataset.autoFit !== "false") {
|
||||||
const bounds = new maplibregl.LngLatBounds()
|
const bounds = new maplibregl.LngLatBounds()
|
||||||
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
|
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
|
||||||
areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature))
|
areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature))
|
||||||
|
clusterFeatures.features.forEach(feature => bounds.extend(feature.geometry.coordinates))
|
||||||
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0})
|
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +413,9 @@ const createAidMap = element => {
|
||||||
center: [30.5234, 50.4501],
|
center: [30.5234, 50.4501],
|
||||||
zoom: 5,
|
zoom: 5,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
|
renderWorldCopies: false,
|
||||||
|
maxBounds: [[-180, -85.05112878], [180, 85.05112878]],
|
||||||
|
trackResize: false,
|
||||||
dragRotate: !demoMap,
|
dragRotate: !demoMap,
|
||||||
pitchWithRotate: !demoMap,
|
pitchWithRotate: !demoMap,
|
||||||
scrollZoom: !demoMap
|
scrollZoom: !demoMap
|
||||||
|
|
@ -244,22 +431,71 @@ const createAidMap = element => {
|
||||||
new maplibregl.NavigationControl({showCompass: !demoMap}),
|
new maplibregl.NavigationControl({showCompass: !demoMap}),
|
||||||
demoMap ? "bottom-right" : "top-right"
|
demoMap ? "bottom-right" : "top-right"
|
||||||
)
|
)
|
||||||
state.onLoad = () => {
|
|
||||||
state.markReady()
|
if (state.element.dataset.discoveryMap === "true" && navigator.geolocation) {
|
||||||
state.renderMarkers()
|
state.map.addControl(
|
||||||
|
new maplibregl.GeolocateControl({
|
||||||
|
positionOptions: {enableHighAccuracy: false},
|
||||||
|
trackUserLocation: false,
|
||||||
|
showUserLocation: false,
|
||||||
|
fitBoundsOptions: {maxZoom: 13}
|
||||||
|
}),
|
||||||
|
"top-right"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.onLoad = () => {
|
||||||
|
if (!state.active || !state.map) return
|
||||||
|
|
||||||
|
state.markReady()
|
||||||
|
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 = () => {
|
||||||
|
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
|
||||||
|
const viewport = mapViewport(state.map)
|
||||||
|
if (viewport) state.onViewportChange?.(viewport)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
|
||||||
|
|
||||||
state.map.on("dataloading", state.markLoading)
|
state.map.on("dataloading", state.markLoading)
|
||||||
state.map.on("load", state.onLoad)
|
state.map.on("load", state.onLoad)
|
||||||
state.map.on("idle", state.markReady)
|
state.map.on("idle", state.markReady)
|
||||||
|
state.map.on("moveend", state.onMoveEnd)
|
||||||
|
window.addEventListener("wnh:request-highlight", state.onRequestHighlight)
|
||||||
|
if ("ResizeObserver" in window) {
|
||||||
|
state.resizeObserver = new ResizeObserver(state.resize)
|
||||||
|
state.resizeObserver.observe(state.element)
|
||||||
|
}
|
||||||
state.renderMarkers()
|
state.renderMarkers()
|
||||||
}
|
}
|
||||||
|
|
||||||
state.destroy = () => {
|
state.destroy = () => {
|
||||||
|
state.active = false
|
||||||
|
if (state.viewportFrame !== null) window.cancelAnimationFrame(state.viewportFrame)
|
||||||
|
if (state.resizeFrame !== null) window.cancelAnimationFrame(state.resizeFrame)
|
||||||
|
state.resizeObserver?.disconnect()
|
||||||
state.map?.off("dataloading", state.markLoading)
|
state.map?.off("dataloading", state.markLoading)
|
||||||
state.map?.off("load", state.onLoad)
|
state.map?.off("load", state.onLoad)
|
||||||
state.map?.off("idle", state.markReady)
|
state.map?.off("idle", state.markReady)
|
||||||
|
state.map?.off("moveend", state.onMoveEnd)
|
||||||
|
window.removeEventListener("wnh:request-highlight", state.onRequestHighlight)
|
||||||
state.map?.remove()
|
state.map?.remove()
|
||||||
|
state.map = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
@ -557,13 +793,105 @@ export const Hooks = {
|
||||||
AidMap: {
|
AidMap: {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.aidMap = createAidMap(this.el)
|
this.aidMap = createAidMap(this.el)
|
||||||
|
this.el._aidMap = this.aidMap
|
||||||
|
this.aidMap.onInitializeViewport = viewport =>
|
||||||
|
this.pushEvent("initialize-map-area", viewport)
|
||||||
|
this.aidMap.onViewportChange = viewport =>
|
||||||
|
this.pushEvent("map-viewport-change", viewport)
|
||||||
this.aidMap.mount()
|
this.aidMap.mount()
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
|
this.aidMap.applySavedViewport()
|
||||||
this.aidMap.renderMarkers()
|
this.aidMap.renderMarkers()
|
||||||
|
this.aidMap.resize()
|
||||||
},
|
},
|
||||||
destroyed() {
|
destroyed() {
|
||||||
this.aidMap.destroy()
|
this.aidMap.destroy()
|
||||||
|
delete this.el._aidMap
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
RequestDiscovery: {
|
||||||
|
mounted() {
|
||||||
|
this.storageKey = "wnh:request-discovery-view"
|
||||||
|
this.allowedViews = ["list", "split", "map"]
|
||||||
|
|
||||||
|
this.resizeMap = () => {
|
||||||
|
document.querySelector("#request-map")?._aidMap?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persistView = () => {
|
||||||
|
const view = this.el.dataset.viewMode
|
||||||
|
if (!this.allowedViews.includes(view)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(this.storageKey, view)
|
||||||
|
} catch (_error) {
|
||||||
|
// The URL still keeps the selected mode when storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onPointerOver = event => {
|
||||||
|
const card = event.target.closest("[data-discovery-card]")
|
||||||
|
if (!card || !this.el.contains(card)) return
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("wnh:request-highlight", {detail: {id: card.dataset.requestId}})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onPointerOut = event => {
|
||||||
|
const card = event.target.closest("[data-discovery-card]")
|
||||||
|
if (!card || card.contains(event.relatedTarget)) return
|
||||||
|
window.dispatchEvent(new CustomEvent("wnh:request-highlight", {detail: {id: null}}))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onRequestSelected = event => {
|
||||||
|
this.el.querySelectorAll("[data-discovery-card]").forEach(card => {
|
||||||
|
card.classList.toggle("is-selected", card.dataset.requestId === event.detail?.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selected = this.el.querySelector(
|
||||||
|
`[data-discovery-card][data-request-id="${CSS.escape(String(event.detail?.id || ""))}"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selected && this.el.dataset.viewMode !== "map") {
|
||||||
|
selected.scrollIntoView({block: "nearest", behavior: "smooth"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.el.addEventListener("pointerover", this.onPointerOver)
|
||||||
|
this.el.addEventListener("pointerout", this.onPointerOut)
|
||||||
|
window.addEventListener("wnh:request-selected", this.onRequestSelected)
|
||||||
|
|
||||||
|
let savedView = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
savedView = window.localStorage.getItem(this.storageKey)
|
||||||
|
} catch (_error) {
|
||||||
|
savedView = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.el.dataset.viewExplicit !== "true" &&
|
||||||
|
this.allowedViews.includes(savedView) &&
|
||||||
|
savedView !== this.el.dataset.viewMode
|
||||||
|
) {
|
||||||
|
this.pushEvent("restore-view-mode", {view: savedView})
|
||||||
|
} else {
|
||||||
|
this.persistView()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resizeMap()
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.persistView()
|
||||||
|
this.resizeMap()
|
||||||
|
},
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("pointerover", this.onPointerOver)
|
||||||
|
this.el.removeEventListener("pointerout", this.onPointerOut)
|
||||||
|
window.removeEventListener("wnh:request-selected", this.onRequestSelected)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1055,7 +1383,6 @@ function supportsMapCanvas() {
|
||||||
|
|
||||||
if (!context) return false
|
if (!context) return false
|
||||||
|
|
||||||
context.getExtension("WEBGL_lose_context")?.loseContext()
|
|
||||||
return true
|
return true
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,16 @@ Production operators must use a tile service whose policy and capacity fit the
|
||||||
traffic; the public OpenStreetMap tile service is best-effort and has a usage
|
traffic; the public OpenStreetMap tile service is best-effort and has a usage
|
||||||
policy, not an application backend.
|
policy, not an application backend.
|
||||||
|
|
||||||
|
Request discovery is bounded by the validated map viewport sent by the
|
||||||
|
browser. PostGIS filters open requests against that area and groups visible
|
||||||
|
coordinates into Web Mercator grid cells sized from the geographic bounds and
|
||||||
|
rendered map dimensions. Dense cells are returned as aggregate clusters;
|
||||||
|
single cells preserve a request marker or privacy-radius area. Coordinate-free
|
||||||
|
requests are excluded from the map and appear in the list only when the user
|
||||||
|
supplies a matching public area/city search. LiveView subscribes to bounded
|
||||||
|
regional PubSub tiles plus the signed-in user's topic instead of the global
|
||||||
|
request stream.
|
||||||
|
|
||||||
## Data durability and retention
|
## Data durability and retention
|
||||||
|
|
||||||
- Requests, activities, membership decisions, messages, reviews, reports, and
|
- Requests, activities, membership decisions, messages, reviews, reports, and
|
||||||
|
|
|
||||||
117
e2e/tests/request-discovery.spec.ts
Normal file
117
e2e/tests/request-discovery.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
captureBrowserFailures,
|
||||||
|
gotoLiveView,
|
||||||
|
projectEmail,
|
||||||
|
projectText,
|
||||||
|
registerAndConfirm,
|
||||||
|
selectOptionContaining,
|
||||||
|
setRequestLocation,
|
||||||
|
waitForMapReady,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
async function createMedicineRequest(
|
||||||
|
page: Parameters<typeof gotoLiveView>[0],
|
||||||
|
title: string,
|
||||||
|
location: { label: string; latitude: string; longitude: string },
|
||||||
|
) {
|
||||||
|
await gotoLiveView(page, "/requests/new");
|
||||||
|
await selectOptionContaining(page, "Category", "Medicine pickup");
|
||||||
|
await page.getByLabel("Medicine pickup status").selectOption("reserved");
|
||||||
|
await page.getByLabel("Short title").fill(title);
|
||||||
|
await page
|
||||||
|
.getByLabel("What help do you need?")
|
||||||
|
.fill("Please collect a legal medicine order that is already reserved.");
|
||||||
|
await page.getByLabel("Urgency").selectOption("now");
|
||||||
|
await page.getByRole("button", { name: "In 3 hours" }).click();
|
||||||
|
await setRequestLocation(page, {
|
||||||
|
...location,
|
||||||
|
mode: "exact_public",
|
||||||
|
});
|
||||||
|
await page.locator("#request-form input[type=checkbox]").check();
|
||||||
|
await page.getByRole("button", { name: "Publish request" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("request discovery searches the viewport, clusters dense points, and remembers layout", async ({
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
}, testInfo) => {
|
||||||
|
const attemptScope = `${testInfo.project.name}-${Date.now()}-${process.pid}`;
|
||||||
|
const requester = await registerAndConfirm(
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
projectEmail(`discovery-requester-${attemptScope}`, testInfo.project.name),
|
||||||
|
"Discovery requester",
|
||||||
|
);
|
||||||
|
const viewer = await registerAndConfirm(
|
||||||
|
browser,
|
||||||
|
request,
|
||||||
|
projectEmail(`discovery-viewer-${attemptScope}`, testInfo.project.name),
|
||||||
|
"Discovery viewer",
|
||||||
|
);
|
||||||
|
const assertRequesterClean = captureBrowserFailures(requester.page);
|
||||||
|
const assertViewerClean = captureBrowserFailures(viewer.page);
|
||||||
|
|
||||||
|
const denseTitles = [1, 2, 3].map((index) =>
|
||||||
|
projectText(`Kyiv viewport request ${index}`, attemptScope),
|
||||||
|
);
|
||||||
|
const berlinTitle = projectText("Berlin outside viewport", attemptScope);
|
||||||
|
const kyivArea = `Kyiv discovery ${attemptScope}`;
|
||||||
|
|
||||||
|
for (const title of denseTitles) {
|
||||||
|
await createMedicineRequest(requester.page, title, {
|
||||||
|
label: kyivArea,
|
||||||
|
latitude: "50.4501",
|
||||||
|
longitude: "30.5234",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMedicineRequest(requester.page, berlinTitle, {
|
||||||
|
label: "Berlin discovery area",
|
||||||
|
latitude: "52.5200",
|
||||||
|
longitude: "13.4050",
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoLiveView(
|
||||||
|
viewer.page,
|
||||||
|
`/requests?west=30.2&south=50.2&east=30.8&north=50.7&zoom=10&width=1280&height=720&view=split&area=${encodeURIComponent(kyivArea)}`,
|
||||||
|
);
|
||||||
|
await waitForMapReady(viewer.page);
|
||||||
|
|
||||||
|
for (const title of denseTitles) {
|
||||||
|
await expect(viewer.page.getByRole("heading", { name: title })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(viewer.page.getByText(berlinTitle)).toHaveCount(0);
|
||||||
|
await expect(viewer.page.getByText("3 requests in this map area")).toBeVisible();
|
||||||
|
|
||||||
|
const mapItems = JSON.parse((await viewer.page.locator("#request-map").getAttribute("data-items"))!);
|
||||||
|
expect(mapItems).toContainEqual(expect.objectContaining({ type: "cluster", count: 3 }));
|
||||||
|
|
||||||
|
if ((await viewer.page.locator("#request-map canvas").count()) > 0) {
|
||||||
|
await expect(viewer.page.locator(".request-map-cluster", { hasText: "3" })).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(viewer.page.locator("#request-map")).toContainText("The map is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewer.page.getByRole("button", { name: "Map", exact: true }).click();
|
||||||
|
await expect(viewer.page.locator("#request-map")).toBeVisible();
|
||||||
|
await expect(viewer.page.locator("#request-results-list")).toBeHidden();
|
||||||
|
|
||||||
|
await viewer.page.getByRole("button", { name: "List", exact: true }).click();
|
||||||
|
await expect(viewer.page.locator("#request-results-list")).toBeVisible();
|
||||||
|
await expect(viewer.page.locator("#request-map")).toBeHidden();
|
||||||
|
|
||||||
|
await viewer.page.reload();
|
||||||
|
await expect(viewer.page.getByRole("button", { name: "List", exact: true })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(viewer.page.getByText(berlinTitle)).toHaveCount(0);
|
||||||
|
|
||||||
|
assertRequesterClean();
|
||||||
|
assertViewerClean();
|
||||||
|
await requester.context.close();
|
||||||
|
await viewer.context.close();
|
||||||
|
});
|
||||||
|
|
@ -3,10 +3,11 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
|
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
|
|
||||||
@shortdoc "Measures keyset pagination plans on an isolated large data set"
|
@shortdoc "Measures cursor and spatial discovery plans on an isolated large data set"
|
||||||
|
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Seeds and measures the isolated database used by `scripts/db-scale-benchmark.sh`.
|
Seeds and measures cursor pagination plus viewport-scoped map discovery in the
|
||||||
|
isolated database used by `scripts/db-scale-benchmark.sh`.
|
||||||
|
|
||||||
The task records PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output.
|
The task records PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output.
|
||||||
It intentionally does not enforce an execution-time threshold because timings
|
It intentionally does not enforce an execution-time threshold because timings
|
||||||
|
|
@ -54,6 +55,7 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
table_counts: table_counts(),
|
table_counts: table_counts(),
|
||||||
database_bytes: scalar!("SELECT pg_database_size(current_database())"),
|
database_bytes: scalar!("SELECT pg_database_size(current_database())"),
|
||||||
index_bytes: index_sizes(),
|
index_bytes: index_sizes(),
|
||||||
|
viewport_discovery: viewport_discovery_measurements(),
|
||||||
plans: plans
|
plans: plans
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +117,8 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
"""
|
"""
|
||||||
INSERT INTO help_requests (
|
INSERT INTO help_requests (
|
||||||
id, title, description, location_label, location, status, urgency,
|
id, title, description, location_label, location, status, urgency,
|
||||||
location_visibility, expires_at, hidden_at, requester_id, category_id,
|
location_visibility, location_radius_meters, expires_at, hidden_at,
|
||||||
|
requester_id, category_id,
|
||||||
inserted_at, updated_at
|
inserted_at, updated_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -123,10 +126,21 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
'DB scale request ' || value,
|
'DB scale request ' || value,
|
||||||
'Synthetic isolated database scale request ' || value,
|
'Synthetic isolated database scale request ' || value,
|
||||||
'Kyiv benchmark point',
|
'Kyiv benchmark point',
|
||||||
ST_SetSRID(ST_MakePoint(30.5 + (value % 100) / 10000.0, 50.4), 4326),
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(
|
||||||
|
CASE
|
||||||
|
WHEN value <= #{div(rows, 5)}
|
||||||
|
THEN 30.504 + (value % 100) / 200000.0
|
||||||
|
ELSE 30.5 + (value % 10000) / 1000.0
|
||||||
|
END,
|
||||||
|
50.4
|
||||||
|
),
|
||||||
|
4326
|
||||||
|
),
|
||||||
CASE WHEN value % 4 = 0 THEN 'open' ELSE 'completed' END,
|
CASE WHEN value % 4 = 0 THEN 'open' ELSE 'completed' END,
|
||||||
'now',
|
'now',
|
||||||
'approximate_public',
|
'exact_public',
|
||||||
|
NULL,
|
||||||
date_trunc('second', now()) + (value % 30 + 1) * interval '1 day',
|
date_trunc('second', now()) + (value % 30 + 1) * interval '1 day',
|
||||||
CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END,
|
CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END,
|
||||||
md5('user-1')::uuid,
|
md5('user-1')::uuid,
|
||||||
|
|
@ -358,6 +372,48 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
)
|
)
|
||||||
ORDER BY expires_at ASC, id ASC LIMIT 25
|
ORDER BY expires_at ASC, id ASC LIMIT 25
|
||||||
""",
|
""",
|
||||||
|
"help_discovery_viewport" => """
|
||||||
|
SELECT id FROM help_requests
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
|
AND location IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
2000
|
||||||
|
)
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
COALESCE(location_radius_meters, 0)
|
||||||
|
)
|
||||||
|
AND requester_id NOT IN (
|
||||||
|
SELECT blocked_id FROM blocks WHERE blocker_id = #{viewer}
|
||||||
|
)
|
||||||
|
AND requester_id NOT IN (
|
||||||
|
SELECT blocker_id FROM blocks WHERE blocked_id = #{viewer}
|
||||||
|
)
|
||||||
|
ORDER BY expires_at ASC, id ASC LIMIT 25
|
||||||
|
""",
|
||||||
|
"help_discovery_clusters" => """
|
||||||
|
SELECT
|
||||||
|
count(id),
|
||||||
|
ST_X(ST_Centroid(ST_Collect(ST_Transform(location, 3857)))),
|
||||||
|
ST_Y(ST_Centroid(ST_Collect(ST_Transform(location, 3857))))
|
||||||
|
FROM help_requests
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
|
AND location IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
2000
|
||||||
|
)
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
COALESCE(location_radius_meters, 0)
|
||||||
|
)
|
||||||
|
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
|
||||||
|
""",
|
||||||
"help_requester" => """
|
"help_requester" => """
|
||||||
SELECT id FROM help_requests
|
SELECT id FROM help_requests
|
||||||
WHERE requester_id = md5('user-1')::uuid
|
WHERE requester_id = md5('user-1')::uuid
|
||||||
|
|
@ -501,6 +557,8 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
defp expected_indexes do
|
defp expected_indexes do
|
||||||
%{
|
%{
|
||||||
"help_discovery" => "help_requests_discovery_cursor_index",
|
"help_discovery" => "help_requests_discovery_cursor_index",
|
||||||
|
"help_discovery_viewport" => "help_requests_location_geography_index",
|
||||||
|
"help_discovery_clusters" => "help_requests_location_geography_index",
|
||||||
"help_requester" => "help_requests_requester_cursor_index",
|
"help_requester" => "help_requests_requester_cursor_index",
|
||||||
"activity_discovery" => "activities_discovery_cursor_index",
|
"activity_discovery" => "activities_discovery_cursor_index",
|
||||||
"activity_participant" => "activity_participants_user_id_status_index",
|
"activity_participant" => "activity_participants_user_id_status_index",
|
||||||
|
|
@ -573,6 +631,61 @@ defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp viewport_discovery_measurements do
|
||||||
|
visible_points =
|
||||||
|
scalar!("""
|
||||||
|
SELECT count(*) FROM help_requests
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
|
AND location IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
2000
|
||||||
|
)
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
COALESCE(location_radius_meters, 0)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
clusters =
|
||||||
|
scalar!("""
|
||||||
|
SELECT count(*) FROM (
|
||||||
|
SELECT 1 FROM help_requests
|
||||||
|
WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
|
||||||
|
AND location IS NOT NULL
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
2000
|
||||||
|
)
|
||||||
|
AND ST_DWithin(
|
||||||
|
location::geography,
|
||||||
|
ST_MakeEnvelope(30.5035, 50.3900, 30.5045, 50.4100, 4326)::geography,
|
||||||
|
COALESCE(location_radius_meters, 0)
|
||||||
|
)
|
||||||
|
GROUP BY ST_SnapToGrid(ST_Transform(location, 3857), 200.0)
|
||||||
|
) AS viewport_clusters
|
||||||
|
""")
|
||||||
|
|
||||||
|
if visible_points <= 0 do
|
||||||
|
Mix.raise("viewport discovery benchmark did not select any seeded points")
|
||||||
|
end
|
||||||
|
|
||||||
|
if clusters <= 0 or clusters > visible_points do
|
||||||
|
Mix.raise(
|
||||||
|
"viewport cluster count must be positive and no larger than the visible point count"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
visible_points: visible_points,
|
||||||
|
server_cluster_rows: clusters,
|
||||||
|
rows_avoided_in_map_payload: visible_points - clusters
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp fetch_help_page(nil) do
|
defp fetch_help_page(nil) do
|
||||||
result =
|
result =
|
||||||
Repo.query!("""
|
Repo.query!("""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ defmodule WhoNeedHelp.Help do
|
||||||
alias WhoNeedHelp.Accounts.Scope
|
alias WhoNeedHelp.Accounts.Scope
|
||||||
alias WhoNeedHelp.Catalog
|
alias WhoNeedHelp.Catalog
|
||||||
alias WhoNeedHelp.Catalog.Category
|
alias WhoNeedHelp.Catalog.Category
|
||||||
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
alias WhoNeedHelp.Help.{Assignment, DiscoveryViewport, HelpRequest}
|
||||||
alias WhoNeedHelp.Pagination
|
alias WhoNeedHelp.Pagination
|
||||||
alias WhoNeedHelp.Push
|
alias WhoNeedHelp.Push
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
|
|
@ -15,9 +15,24 @@ defmodule WhoNeedHelp.Help do
|
||||||
alias WhoNeedHelp.Trust.Block
|
alias WhoNeedHelp.Trust.Block
|
||||||
|
|
||||||
@topic "help:requests"
|
@topic "help:requests"
|
||||||
|
@hidden_discovery_topic "help:discovery:hidden"
|
||||||
|
|
||||||
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
|
def subscribe, do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, @topic)
|
||||||
|
|
||||||
|
def subscribe_user(user_id),
|
||||||
|
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:user:#{user_id}")
|
||||||
|
|
||||||
|
def subscribe_discovery(%DiscoveryViewport{} = viewport, include_hidden \\ false) do
|
||||||
|
topics = discovery_topics(viewport, include_hidden)
|
||||||
|
Enum.each(topics, &Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, &1))
|
||||||
|
topics
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsubscribe_discovery(topics) when is_list(topics) do
|
||||||
|
Enum.each(topics, &Phoenix.PubSub.unsubscribe(WhoNeedHelp.PubSub, &1))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
def subscribe_request(id),
|
def subscribe_request(id),
|
||||||
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}")
|
do: Phoenix.PubSub.subscribe(WhoNeedHelp.PubSub, "help:request:#{id}")
|
||||||
|
|
||||||
|
|
@ -32,28 +47,12 @@ defmodule WhoNeedHelp.Help do
|
||||||
end
|
end
|
||||||
|
|
||||||
def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do
|
def paginate_open_requests(%Scope{user: user}, filters \\ %{}, options \\ []) do
|
||||||
now = DateTime.utc_now(:second)
|
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
viewport = Keyword.get(options, :viewport)
|
||||||
|
|
||||||
HelpRequest
|
user
|
||||||
|> where(
|
|> open_requests_query(filters, viewport)
|
||||||
[request],
|
|
||||||
request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at)
|
|
||||||
)
|
|
||||||
|> where(
|
|
||||||
[request],
|
|
||||||
request.requester_id not in subquery(
|
|
||||||
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|> where(
|
|
||||||
[request],
|
|
||||||
request.requester_id not in subquery(
|
|
||||||
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|> filter_open_requests(filters)
|
|
||||||
|> after_open_request(cursor)
|
|> after_open_request(cursor)
|
||||||
|> order_by([request], asc: request.expires_at, asc: request.id)
|
|> order_by([request], asc: request.expires_at, asc: request.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|
|
@ -62,6 +61,94 @@ defmodule WhoNeedHelp.Help do
|
||||||
|> Pagination.page(limit, &{&1.expires_at, &1.id})
|
|> Pagination.page(limit, &{&1.expires_at, &1.id})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def map_discovery_items(%Scope{user: user}, filters, %DiscoveryViewport{} = viewport) do
|
||||||
|
cell_size = DiscoveryViewport.cluster_cell_meters(viewport)
|
||||||
|
|
||||||
|
points =
|
||||||
|
user
|
||||||
|
|> open_requests_query(filters, viewport)
|
||||||
|
|> where([request], not is_nil(request.location))
|
||||||
|
|> select([request], %{
|
||||||
|
id: request.id,
|
||||||
|
title: request.title,
|
||||||
|
location_label: request.location_label,
|
||||||
|
location_visibility: request.location_visibility,
|
||||||
|
radius_meters: request.location_radius_meters,
|
||||||
|
urgency: request.urgency,
|
||||||
|
public_location:
|
||||||
|
fragment(
|
||||||
|
"CASE WHEN ? = 'exact_for_active_match' THEN ST_SetSRID(ST_MakePoint(round(ST_X(?)::numeric, 2)::double precision, round(ST_Y(?)::numeric, 2)::double precision), 4326) ELSE ? END",
|
||||||
|
request.location_visibility,
|
||||||
|
request.location,
|
||||||
|
request.location,
|
||||||
|
request.location
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
points
|
||||||
|
|> subquery()
|
||||||
|
|> group_by(
|
||||||
|
[point],
|
||||||
|
fragment(
|
||||||
|
"ST_SnapToGrid(ST_Transform(?, 3857), ?)",
|
||||||
|
point.public_location,
|
||||||
|
constant(^cell_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> select([point], %{
|
||||||
|
count: count(point.id),
|
||||||
|
urgent_count: fragment("count(*) FILTER (WHERE ? = 'now')::bigint", point.urgency),
|
||||||
|
request_id: fragment("CASE WHEN count(*) = 1 THEN min(?::text) END", point.id),
|
||||||
|
title: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.title),
|
||||||
|
location_label: fragment("CASE WHEN count(*) = 1 THEN min(?) END", point.location_label),
|
||||||
|
location_visibility:
|
||||||
|
fragment(
|
||||||
|
"CASE WHEN count(*) = 1 THEN min(?::text) END",
|
||||||
|
point.location_visibility
|
||||||
|
),
|
||||||
|
radius_meters: fragment("CASE WHEN count(*) = 1 THEN max(?) END", point.radius_meters),
|
||||||
|
longitude:
|
||||||
|
fragment(
|
||||||
|
"ST_X(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
||||||
|
point.public_location
|
||||||
|
),
|
||||||
|
latitude:
|
||||||
|
fragment(
|
||||||
|
"ST_Y(ST_Transform(ST_Centroid(ST_Collect(ST_Transform(?, 3857))), 4326))",
|
||||||
|
point.public_location
|
||||||
|
),
|
||||||
|
cell_x:
|
||||||
|
fragment(
|
||||||
|
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||||
|
point.public_location,
|
||||||
|
constant(^cell_size)
|
||||||
|
),
|
||||||
|
cell_y:
|
||||||
|
fragment(
|
||||||
|
"ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||||
|
point.public_location,
|
||||||
|
constant(^cell_size)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|> order_by(
|
||||||
|
[point],
|
||||||
|
asc:
|
||||||
|
fragment(
|
||||||
|
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||||
|
point.public_location,
|
||||||
|
constant(^cell_size)
|
||||||
|
),
|
||||||
|
asc:
|
||||||
|
fragment(
|
||||||
|
"ST_Y(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
|
||||||
|
point.public_location,
|
||||||
|
constant(^cell_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(&map_discovery_item(&1, viewport.zoom))
|
||||||
|
end
|
||||||
|
|
||||||
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
def list_open_requests, do: raise(ArgumentError, "an authenticated scope is required")
|
||||||
|
|
||||||
def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
|
def visible_open_request?(%Scope{user: user}, %HelpRequest{} = request, filters \\ %{}) do
|
||||||
|
|
@ -697,20 +784,59 @@ defmodule WhoNeedHelp.Help do
|
||||||
end
|
end
|
||||||
|
|
||||||
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event)
|
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:request:#{request.id}", event)
|
||||||
|
|
||||||
|
request
|
||||||
|
|> discovery_broadcast_topics()
|
||||||
|
|> Enum.each(&Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, &1, event))
|
||||||
|
|
||||||
|
[request.requester_id, request.assignment && request.assignment.helper_id]
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.each(fn user_id ->
|
||||||
|
Phoenix.PubSub.broadcast(WhoNeedHelp.PubSub, "help:user:#{user_id}", event)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp discovery_topics(viewport, include_hidden) do
|
||||||
|
topics =
|
||||||
|
viewport
|
||||||
|
|> DiscoveryViewport.subscription_tiles()
|
||||||
|
|> Enum.map(&tile_topic/1)
|
||||||
|
|
||||||
|
if include_hidden, do: [@hidden_discovery_topic | topics], else: topics
|
||||||
|
end
|
||||||
|
|
||||||
|
defp discovery_broadcast_topics(%HelpRequest{location: nil}), do: [@hidden_discovery_topic]
|
||||||
|
|
||||||
|
defp discovery_broadcast_topics(%HelpRequest{
|
||||||
|
location: %Geo.Point{coordinates: {longitude, latitude}},
|
||||||
|
location_radius_meters: radius_meters
|
||||||
|
}) do
|
||||||
|
latitude
|
||||||
|
|> DiscoveryViewport.point_tiles(longitude, radius_meters || 0)
|
||||||
|
|> Enum.map(&tile_topic/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp discovery_broadcast_topics(_request), do: []
|
||||||
|
|
||||||
|
defp tile_topic({zoom, x, y}), do: "help:discovery:#{zoom}:#{x}:#{y}"
|
||||||
|
|
||||||
defp filter_open_requests(query, filters) do
|
defp filter_open_requests(query, filters) do
|
||||||
query
|
query
|
||||||
|> maybe_filter(:category_id, filters["category_id"] || filters[:category_id])
|
|> maybe_filter(:category_id, filters["category_id"] || filters[:category_id])
|
||||||
|> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
|
|> maybe_filter(:urgency, filters["urgency"] || filters[:urgency])
|
||||||
|
|> maybe_filter(:area, filters["area"] || filters[:area])
|
||||||
end
|
end
|
||||||
|
|
||||||
defp request_filter_matches?(request, filters) do
|
defp request_filter_matches?(request, filters) do
|
||||||
category_id = filters["category_id"] || filters[:category_id]
|
category_id = filters["category_id"] || filters[:category_id]
|
||||||
urgency = filters["urgency"] || filters[:urgency]
|
urgency = filters["urgency"] || filters[:urgency]
|
||||||
|
area = filters["area"] || filters[:area]
|
||||||
|
|
||||||
(category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and
|
(category_id in [nil, ""] or to_string(request.category_id) == to_string(category_id)) and
|
||||||
(urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency))
|
(urgency in [nil, ""] or to_string(request.urgency) == to_string(urgency)) and
|
||||||
|
(area in [nil, ""] or
|
||||||
|
String.contains?(String.downcase(request.location_label), String.downcase(area)))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
|
defp maybe_filter(query, _field, value) when value in [nil, ""], do: query
|
||||||
|
|
@ -729,6 +855,128 @@ defmodule WhoNeedHelp.Help do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_filter(query, :area, value) when is_binary(value) do
|
||||||
|
value = value |> String.trim() |> String.slice(0, 100)
|
||||||
|
|
||||||
|
if value == "" do
|
||||||
|
query
|
||||||
|
else
|
||||||
|
pattern = "%#{escape_like(value)}%"
|
||||||
|
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[request],
|
||||||
|
fragment("? ILIKE ? ESCAPE E'\\\\'", request.location_label, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_filter(query, :area, _value), do: where(query, [request], false)
|
||||||
|
|
||||||
|
defp escape_like(value) do
|
||||||
|
value
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("%", "\\%")
|
||||||
|
|> String.replace("_", "\\_")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_requests_query(user, filters, viewport) do
|
||||||
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
|
HelpRequest
|
||||||
|
|> where(
|
||||||
|
[request],
|
||||||
|
request.status == :open and request.expires_at > ^now and is_nil(request.hidden_at)
|
||||||
|
)
|
||||||
|
|> where(
|
||||||
|
[request],
|
||||||
|
request.requester_id not in subquery(
|
||||||
|
from block in Block, where: block.blocker_id == ^user.id, select: block.blocked_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> where(
|
||||||
|
[request],
|
||||||
|
request.requester_id not in subquery(
|
||||||
|
from block in Block, where: block.blocked_id == ^user.id, select: block.blocker_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> filter_open_requests(filters)
|
||||||
|
|> filter_discovery_viewport(viewport, filters)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_discovery_viewport(query, nil, _filters), do: query
|
||||||
|
|
||||||
|
defp filter_discovery_viewport(query, %DiscoveryViewport{} = viewport, filters) do
|
||||||
|
maximum_public_radius = Enum.max(HelpRequest.allowed_location_radii())
|
||||||
|
|
||||||
|
spatial_condition =
|
||||||
|
Enum.reduce(DiscoveryViewport.envelopes(viewport), dynamic(false), fn
|
||||||
|
{west, south, east, north}, condition ->
|
||||||
|
dynamic(
|
||||||
|
[request],
|
||||||
|
^condition or
|
||||||
|
fragment(
|
||||||
|
"ST_DWithin((?)::geography, ST_MakeEnvelope(?, ?, ?, ?, 4326)::geography, ?) AND ST_DWithin((?)::geography, ST_MakeEnvelope(?, ?, ?, ?, 4326)::geography, COALESCE(?, 0))",
|
||||||
|
request.location,
|
||||||
|
^west,
|
||||||
|
^south,
|
||||||
|
^east,
|
||||||
|
^north,
|
||||||
|
^maximum_public_radius,
|
||||||
|
request.location,
|
||||||
|
^west,
|
||||||
|
^south,
|
||||||
|
^east,
|
||||||
|
^north,
|
||||||
|
request.location_radius_meters
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
area = filters["area"] || filters[:area]
|
||||||
|
|
||||||
|
condition =
|
||||||
|
if is_binary(area) and String.trim(area) != "" do
|
||||||
|
dynamic(
|
||||||
|
[request],
|
||||||
|
(not is_nil(request.location) and ^spatial_condition) or is_nil(request.location)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
dynamic([request], not is_nil(request.location) and ^spatial_condition)
|
||||||
|
end
|
||||||
|
|
||||||
|
where(query, ^condition)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp map_discovery_item(%{count: 1} = row, _zoom) do
|
||||||
|
%{
|
||||||
|
type: "request",
|
||||||
|
id: row.request_id,
|
||||||
|
title: row.title,
|
||||||
|
location: row.location_label,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
exact: row.location_visibility == "exact_public",
|
||||||
|
radius_meters: row.radius_meters
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp map_discovery_item(row, zoom) do
|
||||||
|
digest =
|
||||||
|
:crypto.hash(:sha256, "#{zoom}:#{row.cell_x}:#{row.cell_y}")
|
||||||
|
|> Base.url_encode64(padding: false)
|
||||||
|
|> binary_part(0, 16)
|
||||||
|
|
||||||
|
%{
|
||||||
|
type: "cluster",
|
||||||
|
id: "cluster-#{digest}",
|
||||||
|
count: row.count,
|
||||||
|
urgent_count: row.urgent_count,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now}
|
defp normalize_urgency(value) when value in [:now, "now"], do: {:ok, :now}
|
||||||
defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today}
|
defp normalize_urgency(value) when value in [:today, "today"], do: {:ok, :today}
|
||||||
defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled}
|
defp normalize_urgency(value) when value in [:scheduled, "scheduled"], do: {:ok, :scheduled}
|
||||||
|
|
|
||||||
291
lib/who_need_help/help/discovery_viewport.ex
Normal file
291
lib/who_need_help/help/discovery_viewport.ex
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
defmodule WhoNeedHelp.Help.DiscoveryViewport do
|
||||||
|
@moduledoc """
|
||||||
|
Validated map bounds used by request discovery.
|
||||||
|
|
||||||
|
The viewport carries its rendered pixel size so server-side clustering can
|
||||||
|
match the amount of detail the browser can actually display. Longitudes
|
||||||
|
crossing the antimeridian are represented as two PostGIS envelopes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@max_mercator_latitude 85.051_128_78
|
||||||
|
@maximum_canvas_dimension 4_096
|
||||||
|
@minimum_canvas_dimension 160
|
||||||
|
@cluster_cell_pixels 64.0
|
||||||
|
@map_tile_pixels 256
|
||||||
|
@maximum_subscription_zoom 12
|
||||||
|
@earth_radius_meters 6_378_137.0
|
||||||
|
|
||||||
|
@enforce_keys [:west, :south, :east, :north, :zoom, :width, :height]
|
||||||
|
defstruct [:west, :south, :east, :north, :zoom, :width, :height]
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
west: float(),
|
||||||
|
south: float(),
|
||||||
|
east: float(),
|
||||||
|
north: float(),
|
||||||
|
zoom: float(),
|
||||||
|
width: pos_integer(),
|
||||||
|
height: pos_integer()
|
||||||
|
}
|
||||||
|
|
||||||
|
def cast(%{} = attrs) do
|
||||||
|
with {:ok, west} <- coordinate(attrs, "west"),
|
||||||
|
{:ok, south} <- coordinate(attrs, "south"),
|
||||||
|
{:ok, east} <- coordinate(attrs, "east"),
|
||||||
|
{:ok, north} <- coordinate(attrs, "north"),
|
||||||
|
{:ok, zoom} <- number(attrs, "zoom"),
|
||||||
|
{:ok, width} <- dimension(attrs, "width", 1_280),
|
||||||
|
{:ok, height} <- dimension(attrs, "height", 720),
|
||||||
|
true <- longitude?(west) and longitude?(east) and west != east,
|
||||||
|
true <- latitude?(south) and latitude?(north) and south < north,
|
||||||
|
true <- zoom >= 0 and zoom <= 22 do
|
||||||
|
{:ok,
|
||||||
|
%__MODULE__{
|
||||||
|
west: west,
|
||||||
|
south: south,
|
||||||
|
east: east,
|
||||||
|
north: north,
|
||||||
|
zoom: zoom,
|
||||||
|
width: width,
|
||||||
|
height: height
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
_invalid -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cast(_attrs), do: :error
|
||||||
|
|
||||||
|
def envelopes(%__MODULE__{west: west, south: south, east: east, north: north})
|
||||||
|
when west < east do
|
||||||
|
[{west, south, east, north}]
|
||||||
|
end
|
||||||
|
|
||||||
|
def envelopes(%__MODULE__{west: west, south: south, east: east, north: north}) do
|
||||||
|
[{west, south, 180.0, north}, {-180.0, south, east, north}]
|
||||||
|
end
|
||||||
|
|
||||||
|
def cluster_cell_meters(%__MODULE__{} = viewport) do
|
||||||
|
horizontal_cells = max(viewport.width / @cluster_cell_pixels, 1.0)
|
||||||
|
vertical_cells = max(viewport.height / @cluster_cell_pixels, 1.0)
|
||||||
|
|
||||||
|
width_meters =
|
||||||
|
viewport
|
||||||
|
|> envelopes()
|
||||||
|
|> Enum.map(fn {west, _south, east, _north} -> mercator_x(east) - mercator_x(west) end)
|
||||||
|
|> Enum.sum()
|
||||||
|
|
||||||
|
height_meters = mercator_y(viewport.north) - mercator_y(viewport.south)
|
||||||
|
|
||||||
|
max(width_meters / horizontal_cells, height_meters / vertical_cells)
|
||||||
|
|> max(0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
def query_params(%__MODULE__{} = viewport) do
|
||||||
|
%{
|
||||||
|
"west" => format_number(viewport.west),
|
||||||
|
"south" => format_number(viewport.south),
|
||||||
|
"east" => format_number(viewport.east),
|
||||||
|
"north" => format_number(viewport.north),
|
||||||
|
"zoom" => format_number(viewport.zoom, 2),
|
||||||
|
"width" => Integer.to_string(viewport.width),
|
||||||
|
"height" => Integer.to_string(viewport.height)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def same_area?(%__MODULE__{} = left, %__MODULE__{} = right) do
|
||||||
|
Enum.all?([:west, :south, :east, :north], fn field ->
|
||||||
|
abs(Map.fetch!(left, field) - Map.fetch!(right, field)) < 0.000_001
|
||||||
|
end) and abs(left.zoom - right.zoom) < 0.01 and left.width == right.width and
|
||||||
|
left.height == right.height
|
||||||
|
end
|
||||||
|
|
||||||
|
def same_area?(_left, _right), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the Web Mercator tiles represented by this viewport.
|
||||||
|
|
||||||
|
The selected tile zoom follows the browser zoom, but is reduced when forged
|
||||||
|
URL bounds would require more tiles than the rendered canvas can display.
|
||||||
|
This keeps PubSub fan-out tied to visible map capacity rather than geography.
|
||||||
|
"""
|
||||||
|
def subscription_tiles(%__MODULE__{} = viewport) do
|
||||||
|
maximum_tiles =
|
||||||
|
(ceil(viewport.width / @map_tile_pixels) + 2) *
|
||||||
|
(ceil(viewport.height / @map_tile_pixels) + 2)
|
||||||
|
|
||||||
|
viewport.zoom
|
||||||
|
|> floor()
|
||||||
|
|> min(@maximum_subscription_zoom)
|
||||||
|
|> max(0)
|
||||||
|
|> then(&tiles_at_displayable_zoom(viewport, &1, maximum_tiles))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns all Web Mercator tiles touched by a point or privacy radius at each
|
||||||
|
discovery subscription zoom.
|
||||||
|
"""
|
||||||
|
def point_tiles(latitude, longitude, radius_meters \\ 0)
|
||||||
|
|
||||||
|
def point_tiles(latitude, longitude, radius_meters)
|
||||||
|
when is_number(latitude) and is_number(longitude) and is_number(radius_meters) do
|
||||||
|
latitude = clamp(latitude * 1.0, -@max_mercator_latitude, @max_mercator_latitude)
|
||||||
|
longitude = clamp(longitude * 1.0, -180.0, 180.0)
|
||||||
|
radius_meters = max(radius_meters * 1.0, 0.0)
|
||||||
|
latitude_delta = radius_meters / 111_320.0
|
||||||
|
longitude_scale = max(:math.cos(degrees_to_radians(latitude)), 0.01)
|
||||||
|
longitude_delta = radius_meters / (111_320.0 * longitude_scale)
|
||||||
|
|
||||||
|
south = clamp(latitude - latitude_delta, -@max_mercator_latitude, @max_mercator_latitude)
|
||||||
|
north = clamp(latitude + latitude_delta, -@max_mercator_latitude, @max_mercator_latitude)
|
||||||
|
|
||||||
|
longitude_intervals(longitude - longitude_delta, longitude + longitude_delta)
|
||||||
|
|> Enum.flat_map(fn {west, east} ->
|
||||||
|
Enum.flat_map(0..@maximum_subscription_zoom, fn zoom ->
|
||||||
|
tiles_for_envelope(west, south, east, north, zoom)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.sort()
|
||||||
|
end
|
||||||
|
|
||||||
|
def point_tiles(_latitude, _longitude, _radius_meters), do: []
|
||||||
|
|
||||||
|
defp coordinate(attrs, key), do: number(attrs, key)
|
||||||
|
|
||||||
|
defp tiles_at_displayable_zoom(viewport, zoom, maximum_tiles) do
|
||||||
|
tiles = viewport_tiles(viewport, zoom)
|
||||||
|
|
||||||
|
if length(tiles) <= maximum_tiles or zoom == 0 do
|
||||||
|
tiles
|
||||||
|
else
|
||||||
|
tiles_at_displayable_zoom(viewport, zoom - 1, maximum_tiles)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp viewport_tiles(viewport, zoom) do
|
||||||
|
viewport
|
||||||
|
|> envelopes()
|
||||||
|
|> Enum.flat_map(fn {west, south, east, north} ->
|
||||||
|
tiles_for_envelope(west, south, east, north, zoom)
|
||||||
|
end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.sort()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tiles_for_envelope(west, south, east, north, zoom) do
|
||||||
|
x_start = tile_x(west, zoom)
|
||||||
|
x_end = tile_x(east - 1.0e-10, zoom)
|
||||||
|
y_start = tile_y(north, zoom)
|
||||||
|
y_end = tile_y(south, zoom)
|
||||||
|
|
||||||
|
for x <- x_start..x_end, y <- y_start..y_end, do: {zoom, x, y}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tile_x(longitude, zoom) do
|
||||||
|
tile_count = Integer.pow(2, zoom)
|
||||||
|
|
||||||
|
longitude
|
||||||
|
|> clamp(-180.0, 180.0)
|
||||||
|
|> then(&floor((&1 + 180.0) / 360.0 * tile_count))
|
||||||
|
|> min(tile_count - 1)
|
||||||
|
|> max(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tile_y(latitude, zoom) do
|
||||||
|
tile_count = Integer.pow(2, zoom)
|
||||||
|
|
||||||
|
latitude
|
||||||
|
|> clamp(-@max_mercator_latitude, @max_mercator_latitude)
|
||||||
|
|> degrees_to_radians()
|
||||||
|
|> then(fn radians ->
|
||||||
|
floor((1.0 - :math.asinh(:math.tan(radians)) / :math.pi()) / 2.0 * tile_count)
|
||||||
|
end)
|
||||||
|
|> min(tile_count - 1)
|
||||||
|
|> max(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp longitude_intervals(west, east) when west < -180.0,
|
||||||
|
do: [{west + 360.0, 180.0}, {-180.0, east}]
|
||||||
|
|
||||||
|
defp longitude_intervals(west, east) when east > 180.0,
|
||||||
|
do: [{west, 180.0}, {-180.0, east - 360.0}]
|
||||||
|
|
||||||
|
defp longitude_intervals(west, east), do: [{west, east}]
|
||||||
|
|
||||||
|
defp number(attrs, key) do
|
||||||
|
attrs
|
||||||
|
|> value(key)
|
||||||
|
|> parse_number()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp dimension(attrs, key, default) do
|
||||||
|
case value(attrs, key) do
|
||||||
|
nil -> {:ok, default}
|
||||||
|
"" -> {:ok, default}
|
||||||
|
value -> parse_dimension(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp value(attrs, "west"), do: Map.get(attrs, "west") || Map.get(attrs, :west)
|
||||||
|
defp value(attrs, "south"), do: Map.get(attrs, "south") || Map.get(attrs, :south)
|
||||||
|
defp value(attrs, "east"), do: Map.get(attrs, "east") || Map.get(attrs, :east)
|
||||||
|
defp value(attrs, "north"), do: Map.get(attrs, "north") || Map.get(attrs, :north)
|
||||||
|
defp value(attrs, "zoom"), do: Map.get(attrs, "zoom") || Map.get(attrs, :zoom)
|
||||||
|
defp value(attrs, "width"), do: Map.get(attrs, "width") || Map.get(attrs, :width)
|
||||||
|
defp value(attrs, "height"), do: Map.get(attrs, "height") || Map.get(attrs, :height)
|
||||||
|
|
||||||
|
defp parse_number(value) when is_integer(value), do: {:ok, value * 1.0}
|
||||||
|
defp parse_number(value) when is_float(value), do: {:ok, value}
|
||||||
|
|
||||||
|
defp parse_number(value) when is_binary(value) do
|
||||||
|
case Float.parse(value) do
|
||||||
|
{number, ""} -> {:ok, number}
|
||||||
|
_invalid -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_number(_value), do: :error
|
||||||
|
|
||||||
|
defp parse_dimension(value) when is_integer(value), do: validate_dimension(value)
|
||||||
|
|
||||||
|
defp parse_dimension(value) when is_binary(value) do
|
||||||
|
case Integer.parse(value) do
|
||||||
|
{dimension, ""} -> validate_dimension(dimension)
|
||||||
|
_invalid -> :error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_dimension(_value), do: :error
|
||||||
|
|
||||||
|
defp validate_dimension(value)
|
||||||
|
when value >= @minimum_canvas_dimension and value <= @maximum_canvas_dimension,
|
||||||
|
do: {:ok, value}
|
||||||
|
|
||||||
|
defp validate_dimension(_value), do: :error
|
||||||
|
|
||||||
|
defp longitude?(value), do: value >= -180.0 and value <= 180.0
|
||||||
|
|
||||||
|
defp latitude?(value),
|
||||||
|
do: value >= -@max_mercator_latitude and value <= @max_mercator_latitude
|
||||||
|
|
||||||
|
defp mercator_x(longitude), do: @earth_radius_meters * degrees_to_radians(longitude)
|
||||||
|
|
||||||
|
defp mercator_y(latitude) do
|
||||||
|
latitude = min(max(latitude, -@max_mercator_latitude), @max_mercator_latitude)
|
||||||
|
radians = degrees_to_radians(latitude)
|
||||||
|
@earth_radius_meters * :math.log(:math.tan(:math.pi() / 4 + radians / 2))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp degrees_to_radians(value), do: value * :math.pi() / 180.0
|
||||||
|
|
||||||
|
defp clamp(value, minimum, maximum), do: min(max(value, minimum), maximum)
|
||||||
|
|
||||||
|
defp format_number(value, precision \\ 6) do
|
||||||
|
value
|
||||||
|
|> Float.round(precision)
|
||||||
|
|> :erlang.float_to_binary(decimals: precision)
|
||||||
|
|> String.trim_trailing("0")
|
||||||
|
|> String.trim_trailing(".")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -2,58 +2,109 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
use WhoNeedHelpWeb, :live_view
|
use WhoNeedHelpWeb, :live_view
|
||||||
|
|
||||||
alias WhoNeedHelp.{Help, Pagination, Trust}
|
alias WhoNeedHelp.{Help, Pagination, Trust}
|
||||||
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
|
|
||||||
|
@view_modes ~w(list split map)
|
||||||
|
@filter_keys ~w(category_id urgency area)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket), do: Help.subscribe()
|
if connected?(socket), do: Help.subscribe_user(socket.assigns.current_scope.user.id)
|
||||||
|
|
||||||
|
filters = empty_filters()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:filters, %{"category_id" => "", "urgency" => ""})
|
|> assign(:filters, filters)
|
||||||
|> load()}
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|
|> assign(:view_mode, "split")
|
||||||
|
|> assign(:view_explicit, false)
|
||||||
|
|> assign(:viewport, nil)
|
||||||
|
|> assign(:pending_viewport, nil)
|
||||||
|
|> assign(:discovery_topics, [])
|
||||||
|
|> assign(:query_params, %{})
|
||||||
|
|> assign(:requests, [])
|
||||||
|
|> assign(:requests_cursor, nil)
|
||||||
|
|> assign(:map_items, Jason.encode!([]))
|
||||||
|
|> assign(:map_result_count, 0)
|
||||||
|
|> assign(:refresh_timer, nil)
|
||||||
|
|> assign(:page_title, gettext("Nearby help"))
|
||||||
|
|> assign(:categories, WhoNeedHelp.Catalog.list_categories())}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
|
def handle_params(params, _uri, socket) do
|
||||||
user = socket.assigns.current_scope.user
|
filters = normalize_filters(params)
|
||||||
|
viewport = parse_viewport(params)
|
||||||
{requests, requests_cursor} =
|
view_mode = normalize_view_mode(params["view"])
|
||||||
update_entry(
|
|
||||||
socket.assigns.requests,
|
|
||||||
request,
|
|
||||||
Help.visible_open_request?(
|
|
||||||
socket.assigns.current_scope,
|
|
||||||
request,
|
|
||||||
socket.assigns.filters
|
|
||||||
),
|
|
||||||
:open,
|
|
||||||
socket.assigns.requests_cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
{my_requests, my_requests_cursor} =
|
|
||||||
update_entry(
|
|
||||||
socket.assigns.my_requests,
|
|
||||||
request,
|
|
||||||
request.requester_id == user.id,
|
|
||||||
:mine,
|
|
||||||
socket.assigns.my_requests_cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:requests, requests)
|
|> assign(:filters, filters)
|
||||||
|> assign(:requests_cursor, requests_cursor)
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|> assign(:my_requests, my_requests)
|
|> assign(:view_mode, view_mode)
|
||||||
|> assign(:my_requests_cursor, my_requests_cursor)
|
|> assign(:view_explicit, params["view"] in @view_modes)
|
||||||
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|
|> assign(:viewport, viewport)
|
||||||
|> maybe_refresh_reputation(request, user.id)
|
|> assign(:pending_viewport, nil)
|
||||||
|
|> assign(:query_params, normalize_query_params(filters, viewport, view_mode))
|
||||||
|
|> refresh_discovery_subscription(viewport, filters)
|
||||||
|
|> load()
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
def handle_event("filter", %{"filters" => filters}, socket) do
|
||||||
{:noreply, socket |> assign(:filters, filters) |> load()}
|
{:noreply, push_patch(socket, to: request_path(socket, filters: normalize_filters(filters)))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("set-view", %{"view" => view_mode}, socket)
|
||||||
|
when view_mode in @view_modes do
|
||||||
|
{:noreply, push_patch(socket, to: request_path(socket, view_mode: view_mode))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("restore-view-mode", %{"view" => view_mode}, socket)
|
||||||
|
when view_mode in @view_modes do
|
||||||
|
if socket.assigns.view_explicit do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
{:noreply, push_patch(socket, to: request_path(socket, view_mode: view_mode))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("map-viewport-change", params, socket) do
|
||||||
|
case DiscoveryViewport.cast(params) do
|
||||||
|
{:ok, viewport} ->
|
||||||
|
pending =
|
||||||
|
if DiscoveryViewport.same_area?(viewport, socket.assigns.viewport),
|
||||||
|
do: nil,
|
||||||
|
else: viewport
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :pending_viewport, pending)}
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("initialize-map-area", params, socket) do
|
||||||
|
case {socket.assigns.viewport, DiscoveryViewport.cast(params)} do
|
||||||
|
{nil, {:ok, viewport}} ->
|
||||||
|
{:noreply, push_patch(socket, to: request_path(socket, viewport: viewport))}
|
||||||
|
|
||||||
|
_already_initialized_or_invalid ->
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("search-map-area", _params, socket) do
|
||||||
|
case socket.assigns.pending_viewport do
|
||||||
|
%DiscoveryViewport{} = viewport ->
|
||||||
|
{:noreply, push_patch(socket, to: request_path(socket, viewport: viewport))}
|
||||||
|
|
||||||
|
_no_pending_viewport ->
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("load-more-requests", _params, socket) do
|
def handle_event("load-more-requests", _params, socket) do
|
||||||
|
|
@ -61,7 +112,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
Help.paginate_open_requests(
|
Help.paginate_open_requests(
|
||||||
socket.assigns.current_scope,
|
socket.assigns.current_scope,
|
||||||
socket.assigns.filters,
|
socket.assigns.filters,
|
||||||
after: socket.assigns.requests_cursor
|
after: socket.assigns.requests_cursor,
|
||||||
|
viewport: socket.assigns.viewport
|
||||||
)
|
)
|
||||||
|
|
||||||
requests = append_unique(socket.assigns.requests, page.entries)
|
requests = append_unique(socket.assigns.requests, page.entries)
|
||||||
|
|
@ -69,8 +121,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:requests, requests)
|
|> assign(:requests, requests)
|
||||||
|> assign(:requests_cursor, page.next_cursor)
|
|> assign(:requests_cursor, page.next_cursor)}
|
||||||
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("load-more-my-requests", _params, socket) do
|
def handle_event("load-more-my-requests", _params, socket) do
|
||||||
|
|
@ -85,24 +136,74 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
|> assign(:my_requests_cursor, page.next_cursor)}
|
|> assign(:my_requests_cursor, page.next_cursor)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({event, request}, socket) when event in [:request_created, :request_updated] do
|
||||||
|
socket = maybe_refresh_reputation(socket, request, socket.assigns.current_scope.user.id)
|
||||||
|
|
||||||
|
if socket.assigns.refresh_timer do
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
|
timer = Process.send_after(self(), :refresh_discovery, 100)
|
||||||
|
{:noreply, assign(socket, :refresh_timer, timer)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:refresh_discovery, socket) do
|
||||||
|
{:noreply, socket |> assign(:refresh_timer, nil) |> load()}
|
||||||
|
end
|
||||||
|
|
||||||
defp load(socket) do
|
defp load(socket) do
|
||||||
requests_page =
|
{requests_page, map_items} =
|
||||||
Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters)
|
case socket.assigns.viewport do
|
||||||
|
%DiscoveryViewport{} = viewport ->
|
||||||
|
page =
|
||||||
|
Help.paginate_open_requests(socket.assigns.current_scope, socket.assigns.filters,
|
||||||
|
viewport: viewport
|
||||||
|
)
|
||||||
|
|
||||||
|
items =
|
||||||
|
Help.map_discovery_items(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
socket.assigns.filters,
|
||||||
|
viewport
|
||||||
|
)
|
||||||
|
|
||||||
|
{page, items}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
{%Pagination.Page{}, []}
|
||||||
|
end
|
||||||
|
|
||||||
my_requests_page = Help.paginate_my_requests(socket.assigns.current_scope)
|
my_requests_page = Help.paginate_my_requests(socket.assigns.current_scope)
|
||||||
requests = requests_page.entries
|
|
||||||
user = socket.assigns.current_scope.user
|
user = socket.assigns.current_scope.user
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Nearby help"))
|
|> assign(:requests, requests_page.entries)
|
||||||
|> assign(:requests, requests)
|
|
||||||
|> assign(:requests_cursor, requests_page.next_cursor)
|
|> assign(:requests_cursor, requests_page.next_cursor)
|
||||||
|> assign(:my_requests, my_requests_page.entries)
|
|> assign(:my_requests, my_requests_page.entries)
|
||||||
|> assign(:my_requests_cursor, my_requests_page.next_cursor)
|
|> assign(:my_requests_cursor, my_requests_page.next_cursor)
|
||||||
|> assign(:reputation, Trust.reputation(user.id))
|
|> assign(:reputation, Trust.reputation(user.id))
|
||||||
|> assign(:categories, WhoNeedHelp.Catalog.list_categories())
|
|> assign(:map_items, Jason.encode!(map_items))
|
||||||
|> assign(:filter_form, to_form(socket.assigns.filters, as: :filters))
|
|> assign(:map_result_count, Enum.reduce(map_items, 0, &(map_item_count(&1) + &2)))
|
||||||
|> assign(:markers, Jason.encode!(Enum.flat_map(requests, &List.wrap(marker(&1)))))
|
end
|
||||||
|
|
||||||
|
defp refresh_discovery_subscription(socket, viewport, filters) do
|
||||||
|
if connected?(socket) do
|
||||||
|
:ok = Help.unsubscribe_discovery(socket.assigns.discovery_topics)
|
||||||
|
|
||||||
|
topics =
|
||||||
|
case viewport do
|
||||||
|
%DiscoveryViewport{} = viewport ->
|
||||||
|
Help.subscribe_discovery(viewport, String.trim(filters["area"]) != "")
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :discovery_topics, topics)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp append_unique(existing, incoming) do
|
defp append_unique(existing, incoming) do
|
||||||
|
|
@ -110,36 +211,6 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
existing ++ Enum.reject(incoming, &MapSet.member?(existing_ids, &1.id))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_entry(entries, request, visible?, order, cursor) do
|
|
||||||
window_limit = max(length(entries), Pagination.limit([]))
|
|
||||||
entries = Enum.reject(entries, &(&1.id == request.id))
|
|
||||||
|
|
||||||
entries =
|
|
||||||
if visible?,
|
|
||||||
do: [request | entries],
|
|
||||||
else: entries
|
|
||||||
|
|
||||||
entries =
|
|
||||||
case order do
|
|
||||||
:open -> Enum.sort_by(entries, &{&1.expires_at, &1.id}, :asc)
|
|
||||||
:mine -> Enum.sort_by(entries, &{&1.inserted_at, &1.id}, :desc)
|
|
||||||
end
|
|
||||||
|
|
||||||
overflow? = length(entries) > window_limit
|
|
||||||
entries = Enum.take(entries, window_limit)
|
|
||||||
|
|
||||||
cursor =
|
|
||||||
if overflow? do
|
|
||||||
request = List.last(entries)
|
|
||||||
timestamp = if order == :open, do: request.expires_at, else: request.inserted_at
|
|
||||||
Pagination.encode(timestamp, request.id)
|
|
||||||
else
|
|
||||||
cursor
|
|
||||||
end
|
|
||||||
|
|
||||||
{entries, cursor}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_refresh_reputation(socket, request, user_id) do
|
defp maybe_refresh_reputation(socket, request, user_id) do
|
||||||
assignment = request.assignment
|
assignment = request.assignment
|
||||||
|
|
||||||
|
|
@ -151,20 +222,43 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp marker(request) do
|
defp map_item_count(%{type: "cluster", count: count}), do: count
|
||||||
case Help.HelpRequest.public_coordinates(request) do
|
defp map_item_count(_request), do: 1
|
||||||
nil ->
|
|
||||||
nil
|
|
||||||
|
|
||||||
coordinates ->
|
defp empty_filters, do: %{"category_id" => "", "urgency" => "", "area" => ""}
|
||||||
Map.merge(coordinates, %{
|
|
||||||
id: request.id,
|
defp normalize_filters(params) do
|
||||||
title: request.title,
|
Map.new(@filter_keys, fn key ->
|
||||||
location: request.location_label
|
{key, params[key] |> to_string() |> String.slice(0, 100)}
|
||||||
})
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_view_mode(view_mode) when view_mode in @view_modes, do: view_mode
|
||||||
|
defp normalize_view_mode(_view_mode), do: "split"
|
||||||
|
|
||||||
|
defp parse_viewport(params) do
|
||||||
|
case DiscoveryViewport.cast(params) do
|
||||||
|
{:ok, viewport} -> viewport
|
||||||
|
:error -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_query_params(filters, viewport, view_mode) do
|
||||||
|
filters
|
||||||
|
|> Map.reject(fn {_key, value} -> value == "" end)
|
||||||
|
|> Map.put("view", view_mode)
|
||||||
|
|> Map.merge(if(viewport, do: DiscoveryViewport.query_params(viewport), else: %{}))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_path(socket, overrides) do
|
||||||
|
filters = Keyword.get(overrides, :filters, socket.assigns.filters)
|
||||||
|
viewport = Keyword.get(overrides, :viewport, socket.assigns.viewport)
|
||||||
|
view_mode = Keyword.get(overrides, :view_mode, socket.assigns.view_mode)
|
||||||
|
params = normalize_query_params(filters, viewport, view_mode)
|
||||||
|
|
||||||
|
~p"/requests?#{params}"
|
||||||
|
end
|
||||||
|
|
||||||
defp urgency_label(:now), do: gettext("Now")
|
defp urgency_label(:now), do: gettext("Now")
|
||||||
defp urgency_label("now"), do: gettext("Now")
|
defp urgency_label("now"), do: gettext("Now")
|
||||||
defp urgency_label(:today), do: gettext("Today")
|
defp urgency_label(:today), do: gettext("Today")
|
||||||
|
|
@ -187,6 +281,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
defp status_label("expired"), do: gettext("Expired")
|
defp status_label("expired"), do: gettext("Expired")
|
||||||
defp status_label(value), do: value
|
defp status_label(value), do: value
|
||||||
|
|
||||||
|
defp viewport_json(nil), do: ""
|
||||||
|
|
||||||
|
defp viewport_json(%DiscoveryViewport{} = viewport) do
|
||||||
|
viewport
|
||||||
|
|> DiscoveryViewport.query_params()
|
||||||
|
|> Jason.encode!()
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -196,7 +298,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
<div class="text-sm font-semibold text-success">{gettext("NEARBY MUTUAL AID")}</div>
|
<div class="text-sm font-semibold text-success">{gettext("NEARBY MUTUAL AID")}</div>
|
||||||
<h1 class="mt-1 text-4xl font-black tracking-tight">{gettext("Who needs help?")}</h1>
|
<h1 class="mt-1 text-4xl font-black tracking-tight">{gettext("Who needs help?")}</h1>
|
||||||
<p class="mt-2 text-base-content/65">
|
<p class="mt-2 text-base-content/65">
|
||||||
{gettext("Only approximate areas are shown before a safe match.")}
|
{gettext(
|
||||||
|
"Explore privacy-friendly areas and public exact meeting points chosen by requesters."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<.link navigate={~p"/requests/new"} class="btn btn-primary">
|
<.link navigate={~p"/requests/new"} class="btn btn-primary">
|
||||||
|
|
@ -217,7 +321,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
for={@filter_form}
|
for={@filter_form}
|
||||||
id="request-filters"
|
id="request-filters"
|
||||||
phx-change="filter"
|
phx-change="filter"
|
||||||
class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 sm:grid-cols-2"
|
class="mt-5 grid gap-3 rounded-2xl bg-base-200 p-4 md:grid-cols-3"
|
||||||
>
|
>
|
||||||
<.input
|
<.input
|
||||||
field={@filter_form[:category_id]}
|
field={@filter_form[:category_id]}
|
||||||
|
|
@ -245,98 +349,206 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|
||||||
{gettext("Scheduled"), "scheduled"}
|
{gettext("Scheduled"), "scheduled"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<.input
|
||||||
|
field={@filter_form[:area]}
|
||||||
|
label={gettext("Area or city")}
|
||||||
|
placeholder={gettext("Optional text search")}
|
||||||
|
phx-debounce="400"
|
||||||
|
/>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
<div class="mt-7 grid gap-7 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,.8fr)]">
|
<section
|
||||||
<div class="space-y-4">
|
id="request-discovery"
|
||||||
<%= if @requests == [] do %>
|
phx-hook="RequestDiscovery"
|
||||||
<div class="rounded-3xl border border-dashed border-base-300 p-12 text-center">
|
data-view-mode={@view_mode}
|
||||||
<h2 class="text-xl font-bold">{gettext("No open requests yet")}</h2>
|
data-view-explicit={to_string(@view_explicit)}
|
||||||
<p class="mt-2 text-base-content/60">
|
data-has-viewport={to_string(not is_nil(@viewport))}
|
||||||
{gettext("You can be the first person to ask the community.")}
|
class="request-discovery mt-6"
|
||||||
</p>
|
>
|
||||||
</div>
|
<div class="request-discovery-toolbar">
|
||||||
<% end %>
|
<div>
|
||||||
|
<strong>
|
||||||
|
{ngettext(
|
||||||
|
"%{count} request in this map area",
|
||||||
|
"%{count} requests in this map area",
|
||||||
|
@map_result_count,
|
||||||
|
count: @map_result_count
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
{gettext("Move the map, then search the newly visible area.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<.link
|
<div class="request-view-switcher" role="group" aria-label={gettext("Results layout")}>
|
||||||
:for={request <- @requests}
|
<button
|
||||||
id={"open-request-#{request.id}"}
|
type="button"
|
||||||
navigate={~p"/requests/#{request.id}"}
|
phx-click="set-view"
|
||||||
class="help-card block rounded-3xl border border-base-300 bg-base-100 p-6"
|
phx-value-view="list"
|
||||||
>
|
aria-pressed={to_string(@view_mode == "list")}
|
||||||
<div class="flex items-start justify-between gap-4">
|
class={["btn btn-sm", @view_mode == "list" && "btn-active"]}
|
||||||
<div>
|
>
|
||||||
<div class="mb-3 flex flex-wrap gap-2">
|
<.icon name="hero-list-bullet" class="size-4" />
|
||||||
<span class="badge badge-success badge-outline">
|
<span>{gettext("List")}</span>
|
||||||
{WhoNeedHelp.Catalog.Category.name(
|
</button>
|
||||||
request.category,
|
<button
|
||||||
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
type="button"
|
||||||
)}
|
phx-click="set-view"
|
||||||
</span>
|
phx-value-view="split"
|
||||||
<span class="badge badge-error badge-outline">{urgency_label(request.urgency)}</span>
|
aria-pressed={to_string(@view_mode == "split")}
|
||||||
</div>
|
class={["btn btn-sm request-view-split", @view_mode == "split" && "btn-active"]}
|
||||||
<h2 class="text-xl font-bold">{request.title}</h2>
|
>
|
||||||
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">{request.description}</p>
|
<.icon name="hero-squares-2x2" class="size-4" />
|
||||||
</div>
|
<span>{gettext("Together")}</span>
|
||||||
<.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55">
|
type="button"
|
||||||
<span>📍 {request.location_label}</span>
|
phx-click="set-view"
|
||||||
<span>
|
phx-value-view="map"
|
||||||
{gettext("by %{name}",
|
aria-pressed={to_string(@view_mode == "map")}
|
||||||
name: request.requester.display_name || gettext("Community member")
|
class={["btn btn-sm", @view_mode == "map" && "btn-active"]}
|
||||||
)}
|
>
|
||||||
</span>
|
<.icon name="hero-map" class="size-4" />
|
||||||
<span>
|
<span>{gettext("Map")}</span>
|
||||||
{gettext("expires %{time}",
|
</button>
|
||||||
time: Calendar.strftime(request.expires_at, "%d.%m.%Y, %H:%M UTC")
|
</div>
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</.link>
|
|
||||||
<button
|
|
||||||
:if={@requests_cursor}
|
|
||||||
type="button"
|
|
||||||
phx-click="load-more-requests"
|
|
||||||
class="btn btn-outline w-full"
|
|
||||||
>
|
|
||||||
{gettext("Load more")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="space-y-5">
|
<div class="request-discovery-content">
|
||||||
<div
|
<div id="request-results-list" class="request-results-list space-y-4">
|
||||||
id="request-map"
|
<div
|
||||||
phx-hook="AidMap"
|
:if={is_nil(@viewport)}
|
||||||
phx-update="ignore"
|
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||||
data-markers={@markers}
|
>
|
||||||
data-map-unavailable-label={
|
<.icon name="hero-map" class="mx-auto size-8 text-success" />
|
||||||
gettext("The map is unavailable in this browser. Request details remain usable.")
|
<h2 class="mt-3 text-xl font-bold">{gettext("Choose an area on the map")}</h2>
|
||||||
}
|
<p class="mt-2 text-base-content/60">
|
||||||
class="aid-map"
|
{gettext("Only requests from the selected map area will be loaded.")}
|
||||||
/>
|
</p>
|
||||||
<div class="rounded-3xl bg-base-200 p-5">
|
</div>
|
||||||
<h3 class="font-bold">{gettext("Your trust summary")}</h3>
|
|
||||||
<div class="mt-4 grid grid-cols-3 gap-2 text-center">
|
<div
|
||||||
<div>
|
:if={@viewport && @requests == []}
|
||||||
<div class="text-2xl font-black">{@reputation.completed}</div><div class="text-[.65rem]">
|
class="rounded-3xl border border-dashed border-base-300 p-10 text-center"
|
||||||
{gettext("completed")}
|
>
|
||||||
|
<h2 class="text-xl font-bold">{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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.link
|
||||||
|
:for={request <- @requests}
|
||||||
|
id={"open-request-#{request.id}"}
|
||||||
|
navigate={~p"/requests/#{request.id}"}
|
||||||
|
data-discovery-card
|
||||||
|
data-request-id={request.id}
|
||||||
|
class="help-card request-result-card block rounded-3xl border border-base-300 bg-base-100 p-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="mb-3 flex flex-wrap gap-2">
|
||||||
|
<span class="badge badge-success badge-outline">
|
||||||
|
{WhoNeedHelp.Catalog.Category.name(
|
||||||
|
request.category,
|
||||||
|
Gettext.get_locale(WhoNeedHelpWeb.Gettext)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-error badge-outline">
|
||||||
|
{urgency_label(request.urgency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-bold">{request.title}</h2>
|
||||||
|
<p class="mt-2 line-clamp-2 text-sm text-base-content/65">
|
||||||
|
{request.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<.icon name="hero-chevron-right" class="mt-2 size-5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-base-300 pt-4 text-xs text-base-content/55">
|
||||||
<div class="text-2xl font-black">{@reputation.unique_people}</div><div class="text-[.65rem]">
|
<span class="inline-flex items-center gap-1.5">
|
||||||
{gettext("people")}
|
<.icon name="hero-map-pin" class="size-4" /> {request.location_label}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{gettext("by %{name}",
|
||||||
|
name: request.requester.display_name || gettext("Community member")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{gettext("expires %{time}",
|
||||||
|
time: Calendar.strftime(request.expires_at, "%d.%m.%Y, %H:%M UTC")
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</.link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:if={@requests_cursor}
|
||||||
|
type="button"
|
||||||
|
phx-click="load-more-requests"
|
||||||
|
class="btn btn-outline w-full"
|
||||||
|
>
|
||||||
|
{gettext("Load more in this area")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="flex items-start gap-2 rounded-2xl bg-base-200 p-4 text-xs text-base-content/60">
|
||||||
|
<.icon name="hero-eye-slash" class="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{gettext(
|
||||||
|
"Requests without map coordinates appear only when their public area description matches the text search."
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="request-discovery-map-panel space-y-4">
|
||||||
|
<div class="request-discovery-map-shell">
|
||||||
|
<div
|
||||||
|
id="request-map"
|
||||||
|
phx-hook="AidMap"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-discovery-map="true"
|
||||||
|
data-auto-fit="false"
|
||||||
|
data-items={@map_items}
|
||||||
|
data-viewport={viewport_json(@viewport)}
|
||||||
|
data-map-unavailable-label={
|
||||||
|
gettext("The map is unavailable in this browser. Request details remain usable.")
|
||||||
|
}
|
||||||
|
data-request-label={gettext("Open request")}
|
||||||
|
class="aid-map request-discovery-map"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:if={@pending_viewport}
|
||||||
|
id="search-map-area"
|
||||||
|
type="button"
|
||||||
|
phx-click="search-map-area"
|
||||||
|
class="btn btn-primary request-map-search-button"
|
||||||
|
>
|
||||||
|
<.icon name="hero-magnifying-glass" class="size-4" />
|
||||||
|
{gettext("Search this area")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-3xl bg-base-200 p-5">
|
||||||
|
<h3 class="font-bold">{gettext("Your trust summary")}</h3>
|
||||||
|
<div class="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-black">{@reputation.completed}</div>
|
||||||
|
<div class="text-[.65rem]">{gettext("completed")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<div class="text-2xl font-black">{@reputation.unique_people}</div>
|
||||||
<div class="text-2xl font-black">{@reputation.verified_handovers}</div><div class="text-[.65rem]">
|
<div class="text-[.65rem]">{gettext("people")}</div>
|
||||||
{gettext("verified")}
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-black">{@reputation.verified_handovers}</div>
|
||||||
|
<div class="text-[.65rem]">{gettext("verified")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</aside>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<section :if={@my_requests != []} class="mt-12">
|
<section :if={@my_requests != []} class="mt-12">
|
||||||
<h2 class="text-2xl font-bold">{gettext("Your requests")}</h2>
|
<h2 class="text-2xl font-bold">{gettext("Your requests")}</h2>
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,9 @@ defmodule WhoNeedHelpWeb.Telemetry do
|
||||||
sum("who_need_help.database.query.execution.duration.microseconds.total",
|
sum("who_need_help.database.query.execution.duration.microseconds.total",
|
||||||
event_name: [:who_need_help, :repo, :query],
|
event_name: [:who_need_help, :repo, :query],
|
||||||
measurement: fn measurements ->
|
measurement: fn measurements ->
|
||||||
System.convert_time_unit(measurements.query_time, :native, :microsecond)
|
measurements
|
||||||
|
|> Map.get(:query_time, 0)
|
||||||
|
|> System.convert_time_unit(:native, :microsecond)
|
||||||
end,
|
end,
|
||||||
description: "Cumulative database execution duration"
|
description: "Cumulative database execution duration"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule WhoNeedHelp.Repo.Migrations.AddHelpRequestGeographyIndex do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create(
|
||||||
|
index(:help_requests, ["(location::geography)"],
|
||||||
|
name: :help_requests_location_geography_index,
|
||||||
|
using: :gist,
|
||||||
|
where: "location IS NOT NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -59,9 +59,9 @@ run_mix() {
|
||||||
"$image" "$@"
|
"$image" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Migrating to the pre-index baseline"
|
echo "Migrating to the pre-spatial-index baseline"
|
||||||
run_mix mix ecto.create
|
run_mix mix ecto.create
|
||||||
run_mix mix ecto.migrate --to 20260719004249
|
run_mix mix ecto.migrate --to 20260722110837
|
||||||
|
|
||||||
echo "Seeding and measuring $row_count rows per large benchmark table"
|
echo "Seeding and measuring $row_count rows per large benchmark table"
|
||||||
run_mix mix wnh.db_scale_benchmark \
|
run_mix mix wnh.db_scale_benchmark \
|
||||||
|
|
@ -69,7 +69,7 @@ run_mix mix wnh.db_scale_benchmark \
|
||||||
--rows "$row_count" \
|
--rows "$row_count" \
|
||||||
--output /benchmark-output
|
--output /benchmark-output
|
||||||
|
|
||||||
echo "Applying the generated cursor-index migration and measuring again"
|
echo "Applying the generated geography-index migration and measuring again"
|
||||||
run_mix mix ecto.migrate
|
run_mix mix ecto.migrate
|
||||||
run_mix mix wnh.db_scale_benchmark \
|
run_mix mix wnh.db_scale_benchmark \
|
||||||
--phase after \
|
--phase after \
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,22 @@ esac
|
||||||
|
|
||||||
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
|
||||||
|
# Compose still resolves required interpolation values for `down`. These values
|
||||||
|
# mirror the isolated runner and do not select containers; the project label
|
||||||
|
# passed above is the scope used by Compose for removal.
|
||||||
|
export APP_IMAGE="who-need-help:e2e"
|
||||||
|
export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e"
|
||||||
|
export POSTGIS_IMAGE="who-need-help:postgis-e2e"
|
||||||
|
export E2E_OUTPUT_DIR="$ROOT/output/e2e/stop-placeholder"
|
||||||
|
export E2E_UID
|
||||||
|
E2E_UID=$(id -u)
|
||||||
|
export E2E_GID
|
||||||
|
E2E_GID=$(id -g)
|
||||||
|
export TRAEFIK_PROJECT_CONSTRAINT="$project"
|
||||||
|
export TRAEFIK_APP_NAME="$project"
|
||||||
|
export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
|
||||||
|
export MAP_TILE_URL="https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}"
|
||||||
|
|
||||||
docker compose \
|
docker compose \
|
||||||
--project-name "$project" \
|
--project-name "$project" \
|
||||||
--env-file "$ROOT/.env.e2e" \
|
--env-file "$ROOT/.env.e2e" \
|
||||||
|
|
|
||||||
186
test/who_need_help/help/discovery_viewport_test.exs
Normal file
186
test/who_need_help/help/discovery_viewport_test.exs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
defmodule WhoNeedHelp.Help.DiscoveryViewportTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
|
|
||||||
|
test "casts string and atom parameters into a validated viewport" do
|
||||||
|
assert {:ok, viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
"west" => "30.1",
|
||||||
|
"south" => "50.1",
|
||||||
|
"east" => "30.9",
|
||||||
|
"north" => "50.9",
|
||||||
|
"zoom" => "11.25",
|
||||||
|
"width" => "1280",
|
||||||
|
"height" => "720"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert viewport.west == 30.1
|
||||||
|
assert viewport.zoom == 11.25
|
||||||
|
assert viewport.width == 1280
|
||||||
|
|
||||||
|
assert {:ok, atom_viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 30.1,
|
||||||
|
south: 50.1,
|
||||||
|
east: 30.9,
|
||||||
|
north: 50.9,
|
||||||
|
zoom: 11,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
assert atom_viewport.height == 600
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid bounds, zoom, and unbounded browser dimensions" do
|
||||||
|
base = %{
|
||||||
|
"west" => "30",
|
||||||
|
"south" => "50",
|
||||||
|
"east" => "31",
|
||||||
|
"north" => "51",
|
||||||
|
"zoom" => "10",
|
||||||
|
"width" => "800",
|
||||||
|
"height" => "600"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert :error = DiscoveryViewport.cast(Map.put(base, "north", "49"))
|
||||||
|
assert :error = DiscoveryViewport.cast(Map.put(base, "west", "-181"))
|
||||||
|
assert :error = DiscoveryViewport.cast(Map.put(base, "zoom", "23"))
|
||||||
|
assert :error = DiscoveryViewport.cast(Map.put(base, "width", "1000000"))
|
||||||
|
assert :error = DiscoveryViewport.cast(Map.put(base, "south", "not-a-number"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "splits an antimeridian viewport into two PostGIS envelopes" do
|
||||||
|
{:ok, viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 170,
|
||||||
|
south: -10,
|
||||||
|
east: -170,
|
||||||
|
north: 10,
|
||||||
|
zoom: 3,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
assert DiscoveryViewport.envelopes(viewport) == [
|
||||||
|
{170.0, -10.0, 180.0, 10.0},
|
||||||
|
{-180.0, -10.0, -170.0, 10.0}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "derives cluster detail from both geographic bounds and rendered pixels" do
|
||||||
|
{:ok, city} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 30,
|
||||||
|
south: 50,
|
||||||
|
east: 31,
|
||||||
|
north: 51,
|
||||||
|
zoom: 9,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, neighborhood} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 30.4,
|
||||||
|
south: 50.4,
|
||||||
|
east: 30.6,
|
||||||
|
north: 50.6,
|
||||||
|
zoom: 13,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, wide_canvas} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 30,
|
||||||
|
south: 50,
|
||||||
|
east: 31,
|
||||||
|
north: 51,
|
||||||
|
zoom: 9,
|
||||||
|
width: 1600,
|
||||||
|
height: 1200
|
||||||
|
})
|
||||||
|
|
||||||
|
assert DiscoveryViewport.cluster_cell_meters(city) >
|
||||||
|
DiscoveryViewport.cluster_cell_meters(neighborhood)
|
||||||
|
|
||||||
|
assert DiscoveryViewport.cluster_cell_meters(city) >
|
||||||
|
DiscoveryViewport.cluster_cell_meters(wide_canvas)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "serializes stable URL parameters and compares visible areas" do
|
||||||
|
{:ok, viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 30.1234567,
|
||||||
|
south: 50,
|
||||||
|
east: 31,
|
||||||
|
north: 51,
|
||||||
|
zoom: 10.126,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
assert DiscoveryViewport.query_params(viewport) == %{
|
||||||
|
"west" => "30.123457",
|
||||||
|
"south" => "50",
|
||||||
|
"east" => "31",
|
||||||
|
"north" => "51",
|
||||||
|
"zoom" => "10.13",
|
||||||
|
"width" => "800",
|
||||||
|
"height" => "600"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert DiscoveryViewport.same_area?(viewport, viewport)
|
||||||
|
refute DiscoveryViewport.same_area?(viewport, %{viewport | zoom: 11.0})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "subscription tiles stay bounded by rendered map capacity" do
|
||||||
|
{:ok, viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: -180,
|
||||||
|
south: -80,
|
||||||
|
east: 180,
|
||||||
|
north: 80,
|
||||||
|
zoom: 22,
|
||||||
|
width: 800,
|
||||||
|
height: 600
|
||||||
|
})
|
||||||
|
|
||||||
|
tiles = DiscoveryViewport.subscription_tiles(viewport)
|
||||||
|
maximum_visible_tiles = (ceil(800 / 256) + 2) * (ceil(600 / 256) + 2)
|
||||||
|
|
||||||
|
assert length(tiles) <= maximum_visible_tiles
|
||||||
|
assert Enum.all?(tiles, fn {zoom, _x, _y} -> zoom < 22 end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a privacy radius publishes to every intersected regional tile" do
|
||||||
|
point_tiles = DiscoveryViewport.point_tiles(50.4501, 30.5234, 0)
|
||||||
|
radius_tiles = DiscoveryViewport.point_tiles(50.4501, 30.5234, 2_000)
|
||||||
|
|
||||||
|
assert MapSet.subset?(MapSet.new(point_tiles), MapSet.new(radius_tiles))
|
||||||
|
assert length(radius_tiles) > length(point_tiles)
|
||||||
|
assert {0, 0, 0} in radius_tiles
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a city viewport overlaps local point topics but not another country's at its zoom" do
|
||||||
|
{:ok, kyiv_viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: 30.0,
|
||||||
|
south: 50.0,
|
||||||
|
east: 31.0,
|
||||||
|
north: 51.0,
|
||||||
|
zoom: 10,
|
||||||
|
width: 1_200,
|
||||||
|
height: 720
|
||||||
|
})
|
||||||
|
|
||||||
|
viewport_tiles = MapSet.new(DiscoveryViewport.subscription_tiles(kyiv_viewport))
|
||||||
|
kyiv_tiles = MapSet.new(DiscoveryViewport.point_tiles(50.4501, 30.5234, 1_000))
|
||||||
|
berlin_tiles = MapSet.new(DiscoveryViewport.point_tiles(52.52, 13.405, 1_000))
|
||||||
|
|
||||||
|
refute MapSet.disjoint?(viewport_tiles, kyiv_tiles)
|
||||||
|
assert MapSet.disjoint?(viewport_tiles, berlin_tiles)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
import WhoNeedHelp.AccountsFixtures
|
import WhoNeedHelp.AccountsFixtures
|
||||||
|
|
||||||
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
|
alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
|
||||||
|
alias WhoNeedHelp.Help.DiscoveryViewport
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
alias WhoNeedHelp.Tracking.Position
|
alias WhoNeedHelp.Tracking.Position
|
||||||
alias WhoNeedHelpWeb.Presence
|
alias WhoNeedHelpWeb.Presence
|
||||||
|
|
@ -299,6 +300,214 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
assert third.next_cursor == nil
|
assert third.next_cursor == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "request discovery returns only the visible map area", context do
|
||||||
|
{:ok, kyiv_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Visible Kyiv request")
|
||||||
|
|> Map.put("location_label", "Kyiv center")
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, intersecting_area} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Area intersects viewport")
|
||||||
|
|> Map.put("location_label", "Kyiv western edge")
|
||||||
|
|> Map.put("longitude", "29.99")
|
||||||
|
|> Map.put("location_radius_meters", 2_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, berlin_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Berlin request")
|
||||||
|
|> Map.put("location_label", "Berlin Mitte")
|
||||||
|
|> Map.put("latitude", "52.5200")
|
||||||
|
|> Map.put("longitude", "13.4050")
|
||||||
|
)
|
||||||
|
|
||||||
|
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
|
||||||
|
|
||||||
|
ids =
|
||||||
|
context.helper_scope
|
||||||
|
|> Help.paginate_open_requests(%{}, viewport: viewport, limit: 100)
|
||||||
|
|> Map.fetch!(:entries)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
assert kyiv_request.id in ids
|
||||||
|
assert intersecting_area.id in ids
|
||||||
|
refute berlin_request.id in ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "hidden coordinates require an explicit area-label search and never become map points",
|
||||||
|
context do
|
||||||
|
{:ok, hidden_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Hidden Kyiv request")
|
||||||
|
|> Map.put("location_label", "Kyiv Podil")
|
||||||
|
|> Map.put("location_visibility", "hidden")
|
||||||
|
|> Map.delete("latitude")
|
||||||
|
|> Map.delete("longitude")
|
||||||
|
)
|
||||||
|
|
||||||
|
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
|
||||||
|
|
||||||
|
without_search =
|
||||||
|
Help.paginate_open_requests(context.helper_scope, %{}, viewport: viewport, limit: 100)
|
||||||
|
|
||||||
|
refute Enum.any?(without_search.entries, &(&1.id == hidden_request.id))
|
||||||
|
|
||||||
|
with_search =
|
||||||
|
Help.paginate_open_requests(
|
||||||
|
context.helper_scope,
|
||||||
|
%{"area" => "Kyiv Podil"},
|
||||||
|
viewport: viewport,
|
||||||
|
limit: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Enum.any?(with_search.entries, &(&1.id == hidden_request.id))
|
||||||
|
|
||||||
|
refute Enum.any?(
|
||||||
|
Help.map_discovery_items(
|
||||||
|
context.helper_scope,
|
||||||
|
%{"area" => "Kyiv Podil"},
|
||||||
|
viewport
|
||||||
|
),
|
||||||
|
&(&1[:id] == hidden_request.id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "map discovery aggregates dense points on the server and preserves sparse requests",
|
||||||
|
context do
|
||||||
|
dense_requests =
|
||||||
|
for {offset, index} <- Enum.with_index([0.0, 0.00005, 0.0001], 1) do
|
||||||
|
{:ok, request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Dense request #{index}")
|
||||||
|
|> Map.put("longitude", Float.to_string(30.5234 + offset))
|
||||||
|
)
|
||||||
|
|
||||||
|
request
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, sparse_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Sparse exact request")
|
||||||
|
|> Map.put("latitude", "50.8")
|
||||||
|
|> Map.put("longitude", "30.8")
|
||||||
|
|> Map.put("location_visibility", "exact_public")
|
||||||
|
)
|
||||||
|
|
||||||
|
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 9)
|
||||||
|
items = Help.map_discovery_items(context.helper_scope, %{}, viewport)
|
||||||
|
|
||||||
|
assert Enum.any?(items, fn
|
||||||
|
%{type: "cluster", count: 3} -> true
|
||||||
|
_item -> false
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert Enum.any?(items, fn
|
||||||
|
%{type: "request", id: id, exact: true} -> id == sparse_request.id
|
||||||
|
_item -> false
|
||||||
|
end)
|
||||||
|
|
||||||
|
dense_ids = MapSet.new(dense_requests, & &1.id)
|
||||||
|
|
||||||
|
refute Enum.any?(items, fn
|
||||||
|
%{type: "request", id: id} -> MapSet.member?(dense_ids, id)
|
||||||
|
_item -> false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "map discovery does not expose an exact-for-match coordinate", context do
|
||||||
|
{:ok, request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Private until matched")
|
||||||
|
|> Map.put("latitude", "50.4567")
|
||||||
|
|> Map.put("longitude", "30.5267")
|
||||||
|
|> Map.put("location_visibility", "exact_for_active_match")
|
||||||
|
)
|
||||||
|
|
||||||
|
item =
|
||||||
|
context.helper_scope
|
||||||
|
|> Help.map_discovery_items(%{}, viewport!(30.50, 50.40, 30.60, 50.50, 15))
|
||||||
|
|> Enum.find(&(&1[:id] == request.id))
|
||||||
|
|
||||||
|
assert %{type: "request", exact: false, radius_meters: 1_000} = item
|
||||||
|
assert_in_delta item.latitude, 50.46, 0.000_001
|
||||||
|
assert_in_delta item.longitude, 30.53, 0.000_001
|
||||||
|
refute_in_delta item.latitude, 50.4567, 0.000_001
|
||||||
|
refute_in_delta item.longitude, 30.5267, 0.000_001
|
||||||
|
end
|
||||||
|
|
||||||
|
test "area search treats SQL wildcard characters as literal text", context do
|
||||||
|
{:ok, literal_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Literal wildcard label")
|
||||||
|
|> Map.put("location_label", "Sector 100%_safe")
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _ordinary_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Ordinary label")
|
||||||
|
|> Map.put("location_label", "Sector 100 percent safe")
|
||||||
|
)
|
||||||
|
|
||||||
|
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
|
||||||
|
|
||||||
|
page =
|
||||||
|
Help.paginate_open_requests(
|
||||||
|
context.helper_scope,
|
||||||
|
%{"area" => "%_"},
|
||||||
|
viewport: viewport,
|
||||||
|
limit: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert Enum.map(page.entries, & &1.id) == [literal_request.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "regional discovery PubSub ignores requests from another map area", context do
|
||||||
|
viewport = viewport!(30.0, 50.0, 31.0, 51.0, 10)
|
||||||
|
topics = Help.subscribe_discovery(viewport)
|
||||||
|
on_exit(fn -> Help.unsubscribe_discovery(topics) end)
|
||||||
|
|
||||||
|
{:ok, berlin_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
context.request_attrs
|
||||||
|
|> Map.put("title", "Berlin regional event")
|
||||||
|
|> Map.put("latitude", "52.5200")
|
||||||
|
|> Map.put("longitude", "13.4050")
|
||||||
|
)
|
||||||
|
|
||||||
|
berlin_request_id = berlin_request.id
|
||||||
|
refute_receive {:request_created, %{id: ^berlin_request_id}}
|
||||||
|
|
||||||
|
{:ok, kyiv_request} =
|
||||||
|
Help.create_request(
|
||||||
|
context.requester_scope,
|
||||||
|
Map.put(context.request_attrs, "title", "Kyiv regional event")
|
||||||
|
)
|
||||||
|
|
||||||
|
kyiv_request_id = kyiv_request.id
|
||||||
|
assert_receive {:request_created, %{id: ^kyiv_request_id}}
|
||||||
|
end
|
||||||
|
|
||||||
test "matched chat loads newest cursor pages and preserves chronological order", context do
|
test "matched chat loads newest cursor pages and preserves chronological order", context do
|
||||||
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
{:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
|
||||||
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
{:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
|
||||||
|
|
@ -339,6 +548,21 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
|
||||||
assert oldest.next_cursor == nil
|
assert oldest.next_cursor == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp viewport!(west, south, east, north, zoom) do
|
||||||
|
{:ok, viewport} =
|
||||||
|
DiscoveryViewport.cast(%{
|
||||||
|
west: west,
|
||||||
|
south: south,
|
||||||
|
east: east,
|
||||||
|
north: north,
|
||||||
|
zoom: zoom,
|
||||||
|
width: 1_200,
|
||||||
|
height: 720
|
||||||
|
})
|
||||||
|
|
||||||
|
viewport
|
||||||
|
end
|
||||||
|
|
||||||
test "chat and review return not found for a stale assignment reference", context do
|
test "chat and review return not found for a stale assignment reference", context do
|
||||||
stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()}
|
stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,15 @@ defmodule WhoNeedHelpWeb.MetricsControllerTest do
|
||||||
assert body =~ "who_need_help_database_query_queue_duration_microseconds_total "
|
assert body =~ "who_need_help_database_query_queue_duration_microseconds_total "
|
||||||
assert body =~ "who_need_help_database_query_decode_duration_microseconds_total "
|
assert body =~ "who_need_help_database_query_decode_duration_microseconds_total "
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "database execution metric tolerates events without query_time" do
|
||||||
|
metric =
|
||||||
|
WhoNeedHelpWeb.Telemetry.prometheus_metrics()
|
||||||
|
|> Enum.find(fn metric ->
|
||||||
|
metric.name ==
|
||||||
|
[:who_need_help, :database, :query, :execution, :duration, :microseconds, :total]
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert metric.measurement.(%{queue_time: 100, total_time: 100}) == 0
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
category = Catalog.seed_defaults()
|
category = Catalog.seed_defaults()
|
||||||
requester = user_fixture(display_name: "Realtime requester")
|
requester = user_fixture(display_name: "Realtime requester")
|
||||||
helper = user_fixture(display_name: "Realtime helper")
|
helper = user_fixture(display_name: "Realtime helper")
|
||||||
{:ok, view, _html} = build_conn() |> log_in_user(helper) |> live(~p"/requests")
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
build_conn() |> log_in_user(helper) |> live(requests_in_kyiv_path())
|
||||||
|
|
||||||
{:ok, request} =
|
{:ok, request} =
|
||||||
Help.create_request(Accounts.Scope.for_user(requester), %{
|
Help.create_request(Accounts.Scope.for_user(requester), %{
|
||||||
|
|
@ -144,17 +146,22 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
"safety_confirmed" => true
|
"safety_confirmed" => true
|
||||||
})
|
})
|
||||||
|
|
||||||
assert render(view) =~ "Realtime medicine pickup"
|
assert_eventually(fn -> render(view) =~ "Realtime medicine pickup" end)
|
||||||
|
|
||||||
{:ok, _assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id)
|
{:ok, _assignment} = Help.accept_request(Accounts.Scope.for_user(helper), request.id)
|
||||||
refute render(view) =~ "Realtime medicine pickup"
|
|
||||||
|
assert_eventually(fn ->
|
||||||
|
not String.contains?(render(view), "Realtime medicine pickup")
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "request realtime updates keep a bounded window without skipping pagination" do
|
test "request realtime updates keep a bounded window without skipping pagination" do
|
||||||
category = Catalog.seed_defaults()
|
category = Catalog.seed_defaults()
|
||||||
requester = user_fixture(display_name: "Bounded realtime requester")
|
requester = user_fixture(display_name: "Bounded realtime requester")
|
||||||
viewer = user_fixture(display_name: "Bounded realtime viewer")
|
viewer = user_fixture(display_name: "Bounded realtime viewer")
|
||||||
{:ok, view, _html} = build_conn() |> log_in_user(viewer) |> live(~p"/requests")
|
|
||||||
|
{:ok, view, _html} =
|
||||||
|
build_conn() |> log_in_user(viewer) |> live(requests_in_kyiv_path())
|
||||||
|
|
||||||
requests =
|
requests =
|
||||||
for index <- 1..25 do
|
for index <- 1..25 do
|
||||||
|
|
@ -171,14 +178,13 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
request
|
request
|
||||||
end
|
end
|
||||||
|
|
||||||
render(view)
|
|
||||||
first = List.first(requests)
|
first = List.first(requests)
|
||||||
overflow = List.last(requests)
|
overflow = List.last(requests)
|
||||||
|
|
||||||
assert has_element?(view, "#open-request-#{first.id}")
|
assert_eventually(fn -> has_element?(view, "#open-request-#{first.id}") end)
|
||||||
refute has_element?(view, "#open-request-#{overflow.id}")
|
refute has_element?(view, "#open-request-#{overflow.id}")
|
||||||
|
|
||||||
view |> element("button", "Load more") |> render_click()
|
view |> element("button", "Load more in this area") |> render_click()
|
||||||
assert has_element?(view, "#open-request-#{overflow.id}")
|
assert has_element?(view, "#open-request-#{overflow.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1062,6 +1068,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp requests_in_kyiv_path do
|
||||||
|
~p"/requests?#{%{west: 30.0, south: 50.0, east: 31.0, north: 51.0, zoom: 10, width: 1200, height: 720, view: "split"}}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_eventually(fun, attempts \\ 50)
|
||||||
|
|
||||||
|
defp assert_eventually(fun, attempts) when attempts > 0 do
|
||||||
|
if fun.() do
|
||||||
|
assert true
|
||||||
|
else
|
||||||
|
Process.sleep(10)
|
||||||
|
assert_eventually(fun, attempts - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assert_eventually(_fun, 0), do: flunk("condition did not become true")
|
||||||
|
|
||||||
defp activity_attrs(category) do
|
defp activity_attrs(category) do
|
||||||
%{
|
%{
|
||||||
"title" => "Public coffee meetup",
|
"title" => "Public coffee meetup",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user