Make map clusters expand into live viewports

This commit is contained in:
SimpleTest 2026-07-29 00:27:25 +03:00
parent 61ad7923ca
commit 762b72100d
11 changed files with 211 additions and 29 deletions

View File

@ -300,6 +300,7 @@ const createAidMap = element => {
resizeObserver: null, resizeObserver: null,
markers: [], markers: [],
markerElements: new Map(), markerElements: new Map(),
clusterExpansionPending: false,
initializedViewport: false, initializedViewport: false,
locationBounds: null, locationBounds: null,
recenterControl: null, recenterControl: null,
@ -625,10 +626,37 @@ const createAidMap = element => {
element.title = element.getAttribute("aria-label") element.title = element.getAttribute("aria-label")
element.addEventListener("click", event => { element.addEventListener("click", event => {
event.stopPropagation() event.stopPropagation()
state.clusterExpansionPending = true
const bounds = point.bounds
const hasBounds =
bounds &&
[bounds.west, bounds.south, bounds.east, bounds.north]
.every(value => Number.isFinite(Number(value)))
const hasArea =
hasBounds &&
(Math.abs(Number(bounds.east) - Number(bounds.west)) > 0.000001 ||
Math.abs(Number(bounds.north) - Number(bounds.south)) > 0.000001)
if (hasArea) {
state.map.fitBounds(
[
[Number(bounds.west), Number(bounds.south)],
[Number(bounds.east), Number(bounds.north)]
],
{
padding: 72,
maxZoom: Math.min(state.map.getZoom() + 5, 18),
duration: 300
}
)
} else {
state.map.easeTo({ state.map.easeTo({
center: [point.longitude, point.latitude], center: [point.longitude, point.latitude],
zoom: Math.min(state.map.getZoom() + 2, 20) zoom: Math.min(state.map.getZoom() + 2, 20),
duration: 300
}) })
}
}) })
const marker = new maplibregl.Marker({element}) const marker = new maplibregl.Marker({element})
@ -726,7 +754,14 @@ const createAidMap = element => {
if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return
state.renderMarkers() state.renderMarkers()
const viewport = mapViewport(state.map) const viewport = mapViewport(state.map)
if (viewport) state.onViewportChange?.(viewport) if (!viewport) return
if (state.clusterExpansionPending) {
state.clusterExpansionPending = false
state.onClusterExpand?.(viewport)
} else {
state.onViewportChange?.(viewport)
}
} }
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id) state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)
@ -1692,6 +1727,8 @@ export const Hooks = {
this.pushEvent("initialize-map-area", viewport) this.pushEvent("initialize-map-area", viewport)
this.aidMap.onViewportChange = viewport => this.aidMap.onViewportChange = viewport =>
this.pushEvent("map-viewport-change", viewport) this.pushEvent("map-viewport-change", viewport)
this.aidMap.onClusterExpand = viewport =>
this.pushEvent("expand-map-cluster", viewport)
this.aidMap.mount() this.aidMap.mount()
}, },
updated() { updated() {

View File

@ -34,6 +34,45 @@ const coordinatesFromPixel = (map, pixel) => {
} }
} }
const finiteCoordinate = value => {
const coordinate = Number(value)
return Number.isFinite(coordinate) ? coordinate : null
}
const pointBounds = point => {
const longitude = finiteCoordinate(point?.longitude)
const latitude = finiteCoordinate(point?.latitude)
const source = point?.bounds || {}
const west = finiteCoordinate(source.west) ?? longitude
const south = finiteCoordinate(source.south) ?? latitude
const east = finiteCoordinate(source.east) ?? longitude
const north = finiteCoordinate(source.north) ?? latitude
if ([west, south, east, north].some(value => value === null)) return null
return {
west: Math.min(west, east),
south: Math.min(south, north),
east: Math.max(west, east),
north: Math.max(south, north)
}
}
const groupBounds = points => {
const bounds = points.map(pointBounds).filter(Boolean)
if (bounds.length === 0) return null
return bounds.reduce(
(result, current) => ({
west: Math.min(result.west, current.west),
south: Math.min(result.south, current.south),
east: Math.max(result.east, current.east),
north: Math.max(result.north, current.north)
}),
bounds[0]
)
}
const nearbyMapPointGroups = (map, points, options = {}) => { const nearbyMapPointGroups = (map, points, options = {}) => {
const gap = Number.isFinite(options.gap) ? Math.max(options.gap, 0) : 8 const gap = Number.isFinite(options.gap) ? Math.max(options.gap, 0) : 8
@ -89,7 +128,8 @@ const visualCluster = (map, group) => {
count: group.count, count: group.count,
urgent_count: group.urgent_count, urgent_count: group.urgent_count,
longitude: coordinates.longitude, longitude: coordinates.longitude,
latitude: coordinates.latitude latitude: coordinates.latitude,
bounds: groupBounds(group.points)
} }
} }

View File

@ -15,8 +15,24 @@ const flatMap = scale => ({
test("merges visually overlapping clusters and preserves their total", () => { test("merges visually overlapping clusters and preserves their total", () => {
const points = [ const points = [
{id: "a", type: "cluster", count: 100, urgent_count: 2, longitude: 0, latitude: 0}, {
{id: "b", type: "cluster", count: 50, urgent_count: 1, longitude: 30, latitude: 0}, id: "a",
type: "cluster",
count: 100,
urgent_count: 2,
longitude: 0,
latitude: 0,
bounds: {west: -5, south: -2, east: 5, north: 2}
},
{
id: "b",
type: "cluster",
count: 50,
urgent_count: 1,
longitude: 30,
latitude: 0,
bounds: {west: 25, south: -3, east: 35, north: 4}
},
{id: "c", type: "cluster", count: 7, urgent_count: 0, longitude: 140, latitude: 0} {id: "c", type: "cluster", count: 7, urgent_count: 0, longitude: 140, latitude: 0}
] ]
@ -29,6 +45,7 @@ test("merges visually overlapping clusters and preserves their total", () => {
assert.equal(merged.urgent_count, 3) assert.equal(merged.urgent_count, 3)
assert.equal(merged.longitude, 10) assert.equal(merged.longitude, 10)
assert.equal(merged.latitude, 0) assert.equal(merged.latitude, 0)
assert.deepEqual(merged.bounds, {west: -5, south: -3, east: 35, north: 4})
}) })
test("splits the same clusters again at a closer zoom", () => { test("splits the same clusters again at a closer zoom", () => {
@ -52,6 +69,7 @@ test("merges a singleton into an overlapping cluster at a distant zoom", () => {
assert.equal(result.length, 1) assert.equal(result.length, 1)
assert.equal(result[0].type, "cluster") assert.equal(result[0].type, "cluster")
assert.equal(result[0].count, 352) assert.equal(result[0].count, 352)
assert.deepEqual(result[0].bounds, {west: 0, south: 0, east: 28, north: 0})
}) })
test("keeps separated singleton points interactive at a closer zoom", () => { test("keeps separated singleton points interactive at a closer zoom", () => {

View File

@ -11,9 +11,9 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do
@default_rows 1_000_000 @default_rows 1_000_000
@synthetic_users 1_000 @synthetic_users 1_000
@viewer_email "scale-viewer@example.invalid" @viewer_email "scale-viewer@example.invalid"
@fixture_version 6 @fixture_version 7
@request_open_modulus 100 @request_open_modulus 1
@activity_open_modulus 50 @activity_open_modulus 1
@places [ @places [
{"Kyiv", 30.5234, 50.4501}, {"Kyiv", 30.5234, 50.4501},
{"Lviv", 24.0316, 49.8429}, {"Lviv", 24.0316, 49.8429},
@ -586,10 +586,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do
ST_MakePoint(coordinates.longitude, coordinates.latitude), ST_MakePoint(coordinates.longitude, coordinates.latitude),
4326 4326
), ),
CASE WHEN variation.visibility_key % 10 = 0 'approximate_public',
THEN 'hidden'
ELSE 'approximate_public'
END,
CASE WHEN variation.status_key % #{@activity_open_modulus} = 0 THEN 'open' CASE WHEN variation.status_key % #{@activity_open_modulus} = 0 THEN 'open'
WHEN variation.status_key % #{@activity_open_modulus} < 41 THEN 'completed' WHEN variation.status_key % #{@activity_open_modulus} < 41 THEN 'completed'
ELSE 'cancelled' END, ELSE 'cancelled' END,
@ -854,7 +851,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do
assert_distribution_keys!( assert_distribution_keys!(
"request statuses", "request statuses",
profile.help_requests.statuses, profile.help_requests.statuses,
~w(cancelled completed open) ~w(open)
) )
assert_distribution_keys!( assert_distribution_keys!(
@ -872,13 +869,13 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do
assert_distribution_keys!( assert_distribution_keys!(
"activity statuses", "activity statuses",
profile.activities.statuses, profile.activities.statuses,
~w(cancelled completed open) ~w(open)
) )
assert_distribution_keys!( assert_distribution_keys!(
"activity visibility modes", "activity visibility modes",
profile.activities.visibility, profile.activities.visibility,
~w(approximate_public hidden) ~w(approximate_public)
) )
assert_geographic_profile!("help requests", profile.help_requests, request_count) assert_geographic_profile!("help requests", profile.help_requests, request_count)

View File

@ -112,6 +112,10 @@ defmodule WhoNeedHelp.Activities do
point.public_location, point.public_location,
point.public_location point.public_location
), ),
west: fragment("min(ST_X(?))", point.public_location),
south: fragment("min(ST_Y(?))", point.public_location),
east: fragment("max(ST_X(?))", point.public_location),
north: fragment("max(ST_Y(?))", point.public_location),
cell_x: cell_x:
fragment( fragment(
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
@ -987,7 +991,13 @@ defmodule WhoNeedHelp.Activities do
id: "activity-cluster-#{digest}", id: "activity-cluster-#{digest}",
count: row.count, count: row.count,
latitude: row.latitude, latitude: row.latitude,
longitude: row.longitude longitude: row.longitude,
bounds: %{
west: row.west,
south: row.south,
east: row.east,
north: row.north
}
} }
end end

View File

@ -137,6 +137,10 @@ defmodule WhoNeedHelp.Help do
point.public_location, point.public_location,
point.public_location point.public_location
), ),
west: fragment("min(ST_X(?))", point.public_location),
south: fragment("min(ST_Y(?))", point.public_location),
east: fragment("max(ST_X(?))", point.public_location),
north: fragment("max(ST_Y(?))", point.public_location),
cell_x: cell_x:
fragment( fragment(
"ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))",
@ -1295,7 +1299,13 @@ defmodule WhoNeedHelp.Help do
count: row.count, count: row.count,
urgent_count: row.urgent_count, urgent_count: row.urgent_count,
latitude: row.latitude, latitude: row.latitude,
longitude: row.longitude longitude: row.longitude,
bounds: %{
west: row.west,
south: row.south,
east: row.east,
north: row.north
}
} }
end end

View File

@ -181,6 +181,16 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
end end
end end
def handle_event("expand-map-cluster", params, socket) do
case DiscoveryViewport.cast(params) do
{:ok, viewport} ->
{:noreply, push_patch(socket, to: activity_path(socket, viewport: viewport))}
:error ->
{:noreply, socket}
end
end
def handle_event("load-more-activities", _params, socket) do def handle_event("load-more-activities", _params, socket) do
page = page =
Activities.paginate_open_activities( Activities.paginate_open_activities(

View File

@ -132,6 +132,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
end end
end end
def handle_event("expand-map-cluster", params, socket) do
case DiscoveryViewport.cast(params) do
{:ok, viewport} ->
{:noreply, push_patch(socket, to: request_path(socket, viewport: viewport))}
:error ->
{:noreply, socket}
end
end
def handle_event("load-more-requests", _params, socket) do def handle_event("load-more-requests", _params, socket) do
page = page =
Help.paginate_open_requests( Help.paginate_open_requests(

View File

@ -450,6 +450,10 @@ defmodule WhoNeedHelp.ActivitiesTest do
assert cluster assert cluster
assert_in_delta cluster.latitude, 50.45, 0.000_001 assert_in_delta cluster.latitude, 50.45, 0.000_001
assert_in_delta cluster.longitude, 30.52, 0.000_001 assert_in_delta cluster.longitude, 30.52, 0.000_001
assert_in_delta cluster.bounds.west, 30.52, 0.000_001
assert_in_delta cluster.bounds.east, 30.52, 0.000_001
assert_in_delta cluster.bounds.south, 50.45, 0.000_001
assert_in_delta cluster.bounds.north, 50.45, 0.000_001
assert Enum.any?(items, fn assert Enum.any?(items, fn
%{type: "activity", id: id} -> id == sparse.id %{type: "activity", id: id} -> id == sparse.id

View File

@ -566,6 +566,10 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
assert cluster assert cluster
assert_in_delta cluster.latitude, 50.4501, 0.000_001 assert_in_delta cluster.latitude, 50.4501, 0.000_001
assert_in_delta cluster.longitude, 30.52345, 0.000_001 assert_in_delta cluster.longitude, 30.52345, 0.000_001
assert_in_delta cluster.bounds.west, 30.5234, 0.000_001
assert_in_delta cluster.bounds.east, 30.5235, 0.000_001
assert_in_delta cluster.bounds.south, 50.4501, 0.000_001
assert_in_delta cluster.bounds.north, 50.4501, 0.000_001
assert Enum.any?(items, fn assert Enum.any?(items, fn
%{type: "request", id: id, exact: true} -> id == sparse_request.id %{type: "request", id: id, exact: true} -> id == sparse_request.id

View File

@ -358,6 +358,48 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert has_element?(view, "#activity-map[data-items='[]']") assert has_element?(view, "#activity-map[data-items='[]']")
end end
test "request cluster expansion immediately searches the expanded viewport", %{conn: conn} do
{:ok, view, _html} = live(conn, requests_in_kyiv_path())
expanded = %{
"west" => "30.40",
"south" => "50.35",
"east" => "30.60",
"north" => "50.55",
"zoom" => "12",
"width" => "900",
"height" => "700"
}
render_hook(view, "expand-map-cluster", expanded)
assert_patch(
view,
~p"/requests?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
)
end
test "activity cluster expansion immediately searches the expanded viewport", %{conn: conn} do
{:ok, view, _html} = live(conn, activities_in_kyiv_path())
expanded = %{
"west" => "30.40",
"south" => "50.35",
"east" => "30.60",
"north" => "50.55",
"zoom" => "12",
"width" => "900",
"height" => "700"
}
render_hook(view, "expand-map-cluster", expanded)
assert_patch(
view,
~p"/activities?#{Map.merge(expanded, %{"east" => "30.6", "view" => "split", "west" => "30.4"})}"
)
end
test "profile social-link creation uses the shared action limiter", %{conn: conn} do test "profile social-link creation uses the shared action limiter", %{conn: conn} do
previous = Application.get_env(:who_need_help, :rate_limit_policies) previous = Application.get_env(:who_need_help, :rate_limit_policies)