Optimize viewport discovery and enable automatic updates

This commit is contained in:
SimpleTest 2026-08-01 23:39:39 +03:00
parent d3114e211b
commit afc0040a65
11 changed files with 269 additions and 243 deletions

View File

@ -337,7 +337,6 @@ const createAidMap = element => {
element,
map: null,
active: true,
pendingViewportAction: null,
resizeFrame: null,
resizeObserver: null,
renderedSize: null,
@ -948,11 +947,19 @@ const createAidMap = element => {
const viewport = mapViewport(state.map)
if (!viewport) return
state.pendingViewportAction = {
viewport,
clusterExpansion: state.clusterExpansionPending
}
const clusterExpansion = state.clusterExpansionPending
state.clusterExpansionPending = false
// `moveend` already means that the camera transition has finished. Do
// not wait for MapLibre's `idle` event here: `idle` also waits for every
// requested map tile and fade animation, so a slow tile server can delay
// discovery refreshes for seconds even though the user stopped moving
// the map earlier.
if (clusterExpansion) {
state.onClusterExpand?.(viewport)
} else {
state.onViewportChange?.(viewport)
}
}
state.onIdle = () => {
@ -964,15 +971,6 @@ const createAidMap = element => {
}
state.syncVisibleAreas()
const action = state.pendingViewportAction
state.pendingViewportAction = null
if (action?.clusterExpansion) {
state.onClusterExpand?.(action.viewport)
} else if (action) {
state.onViewportChange?.(action.viewport)
}
}
state.onRequestHighlight = event => state.highlightRequest(event.detail?.id)

View File

@ -62,8 +62,7 @@ defmodule WhoNeedHelp.Activities do
def map_discovery_items(%Scope{user: user}, filters, %DiscoveryViewport{} = viewport) do
level = DiscoveryCluster.level_for_zoom(viewport.zoom)
cell_size = DiscoveryCluster.cell_size_meters(level)
world_half = DiscoveryCluster.world_half_meters()
cell_count = Integer.pow(2, level) * 1.0
points =
user
@ -75,93 +74,44 @@ defmodule WhoNeedHelp.Activities do
)
|> select([activity], %{
id: activity.id,
title: activity.title,
location_label: activity.location_label,
public_location:
fragment(
"""
CASE
WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ?
ELSE
ST_SetSRID(
ST_MakePoint(
round(ST_X(?)::numeric, 2)::double precision,
round(ST_Y(?)::numeric, 2)::double precision
),
4326
)
END
""",
activity.structured_data,
activity.location,
activity.location,
activity.location
)
public_longitude: activity.public_longitude,
public_latitude: activity.public_latitude
})
points
|> subquery()
|> group_by([point], [
fragment(
"floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
),
fragment(
"floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
)
])
|> group_by([_point], [selected_as(:cell_x), selected_as(:cell_y)])
|> select([point], %{
count: count(point.id),
activity_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),
longitude: fragment("avg(ST_X(?))", point.public_location),
latitude: fragment("avg(ST_Y(?))", 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),
west_m: fragment("min(ST_X(ST_Transform(?, 3857)))", point.public_location),
south_m: fragment("min(ST_Y(ST_Transform(?, 3857)))", point.public_location),
east_m: fragment("max(ST_X(ST_Transform(?, 3857)))", point.public_location),
north_m: fragment("max(ST_Y(ST_Transform(?, 3857)))", point.public_location),
activity_id: fragment("min(?::text)", point.id),
longitude: min(point.public_longitude),
latitude: min(point.public_latitude),
west: min(point.public_longitude),
south: min(point.public_latitude),
east: max(point.public_longitude),
north: max(point.public_latitude),
cell_x:
fragment(
"floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
selected_as(
fragment(
"floor(((? + 180.0) / 360.0) * ?)::bigint",
point.public_longitude,
constant(^cell_count)
),
:cell_x
),
cell_y:
fragment(
"floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
selected_as(
fragment(
"floor(((ln(tan(pi() / 4.0 + radians(least(85.05112878, greatest(-85.05112878, ?))) / 2.0)) / pi() + 1.0) / 2.0) * ?)::bigint",
point.public_latitude,
constant(^cell_count)
),
:cell_y
)
})
|> order_by([point],
asc:
fragment(
"floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
),
asc:
fragment(
"floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
)
)
|> order_by([_point], asc: selected_as(:cell_x), asc: selected_as(:cell_y))
|> Repo.all()
|> hydrate_activity_map_singletons()
|> Enum.map(&map_discovery_item(&1, level))
end
@ -894,28 +844,15 @@ defmodule WhoNeedHelp.Activities do
^condition or
fragment(
"""
ST_Intersects(
CASE
WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ?
ELSE
ST_SetSRID(
ST_MakePoint(
round(ST_X(?)::numeric, 2)::double precision,
round(ST_Y(?)::numeric, 2)::double precision
),
4326
)
END,
ST_MakeEnvelope(?, ?, ?, ?, 4326)
)
? >= ? AND ? <= ? AND ? >= ? AND ? <= ?
""",
activity.structured_data,
activity.location,
activity.location,
activity.location,
activity.public_longitude,
^west,
^south,
activity.public_longitude,
^east,
activity.public_latitude,
^south,
activity.public_latitude,
^north
)
)
@ -1022,12 +959,12 @@ defmodule WhoNeedHelp.Activities do
hierarchy_level: level,
point_count: row.count,
expansion_zoom:
DiscoveryCluster.expansion_zoom(
DiscoveryCluster.expansion_zoom_for_bounds(
level,
row.west_m,
row.south_m,
row.east_m,
row.north_m
row.west,
row.south,
row.east,
row.north
),
count: row.count,
latitude: latitude,
@ -1041,6 +978,36 @@ defmodule WhoNeedHelp.Activities do
}
end
defp hydrate_activity_map_singletons(rows) do
singleton_ids =
rows
|> Enum.filter(&(&1.count == 1))
|> Enum.map(& &1.activity_id)
details = activity_map_singleton_details(singleton_ids)
Enum.map(rows, fn
%{count: 1, activity_id: activity_id} = row ->
Map.merge(row, Map.fetch!(details, activity_id))
row ->
row
end)
end
defp activity_map_singleton_details([]), do: %{}
defp activity_map_singleton_details(singleton_ids) do
Activity
|> where([activity], activity.id in ^singleton_ids)
|> select([activity], {
activity.id,
%{title: activity.title, location_label: activity.location_label}
})
|> Repo.all()
|> Map.new(fn {id, detail} -> {to_string(id), detail} end)
end
defp escape_like(value) do
value
|> String.replace("\\", "\\\\")

View File

@ -11,6 +11,8 @@ defmodule WhoNeedHelp.Activities.Activity do
field :structured_data, :map, default: %{}
field :location_label, :string
field :location, Geo.PostGIS.Geometry
field :public_longitude, :float
field :public_latitude, :float
field :status, Ecto.Enum, values: [:open, :completed, :cancelled], default: :open
field :location_visibility, Ecto.Enum,

View File

@ -73,8 +73,7 @@ defmodule WhoNeedHelp.Help do
def map_discovery_items(%Scope{user: user}, filters, %DiscoveryViewport{} = viewport) do
level = DiscoveryCluster.level_for_zoom(viewport.zoom)
cell_size = DiscoveryCluster.cell_size_meters(level)
world_half = DiscoveryCluster.world_half_meters()
cell_count = Integer.pow(2, level) * 1.0
points =
user
@ -86,106 +85,46 @@ defmodule WhoNeedHelp.Help do
|> filter_request_map_cluster_viewport(viewport, level)
|> 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 ? @> '{"scale_fixture": true}'::jsonb THEN ?
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.structured_data,
request.location,
request.location_visibility,
request.location,
request.location,
request.location
)
public_longitude: request.public_longitude,
public_latitude: request.public_latitude
})
points
|> subquery()
|> group_by([point], [
fragment(
"floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
),
fragment(
"floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
)
])
|> group_by([_point], [selected_as(:cell_x), selected_as(:cell_y)])
|> 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("avg(ST_X(?))", point.public_location),
latitude: fragment("avg(ST_Y(?))", 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),
west_m: fragment("min(ST_X(ST_Transform(?, 3857)))", point.public_location),
south_m: fragment("min(ST_Y(ST_Transform(?, 3857)))", point.public_location),
east_m: fragment("max(ST_X(ST_Transform(?, 3857)))", point.public_location),
north_m: fragment("max(ST_Y(ST_Transform(?, 3857)))", point.public_location),
request_id: fragment("min(?::text)", point.id),
longitude: min(point.public_longitude),
latitude: min(point.public_latitude),
west: min(point.public_longitude),
south: min(point.public_latitude),
east: max(point.public_longitude),
north: max(point.public_latitude),
cell_x:
fragment(
"floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
selected_as(
fragment(
"floor(((? + 180.0) / 360.0) * ?)::bigint",
point.public_longitude,
constant(^cell_count)
),
:cell_x
),
cell_y:
fragment(
"floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
selected_as(
fragment(
"floor(((ln(tan(pi() / 4.0 + radians(least(85.05112878, greatest(-85.05112878, ?))) / 2.0)) / pi() + 1.0) / 2.0) * ?)::bigint",
point.public_latitude,
constant(^cell_count)
),
:cell_y
)
})
|> order_by([point],
asc:
fragment(
"floor((ST_X(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
),
asc:
fragment(
"floor((ST_Y(ST_Transform(?, 3857)) + ?) / ?)::bigint",
point.public_location,
constant(^world_half),
constant(^cell_size)
)
)
|> order_by([_point], asc: selected_as(:cell_x), asc: selected_as(:cell_y))
|> Repo.all()
|> hydrate_request_map_singletons()
|> Enum.map(&map_discovery_item(&1, level))
end
@ -227,21 +166,7 @@ defmodule WhoNeedHelp.Help do
(
? && ST_MakeEnvelope(?, ?, ?, ?, 4326)
AND
ST_Intersects(
CASE
WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ?
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,
ST_MakeEnvelope(?, ?, ?, ?, 4326)
)
? >= ? AND ? <= ? AND ? >= ? AND ? <= ?
)
""",
request.location,
@ -249,15 +174,13 @@ defmodule WhoNeedHelp.Help do
^(south - 0.005),
^(east + 0.005),
^(north + 0.005),
request.structured_data,
request.location,
request.location_visibility,
request.location,
request.location,
request.location,
request.public_longitude,
^west,
^south,
request.public_longitude,
^east,
request.public_latitude,
^south,
request.public_latitude,
^north
)
)
@ -1273,7 +1196,7 @@ defmodule WhoNeedHelp.Help do
location: row.location_label,
latitude: row.latitude,
longitude: row.longitude,
exact: row.location_visibility == "exact_public",
exact: row.location_visibility in ["exact_public", :exact_public],
radius_meters: row.radius_meters
}
end
@ -1290,12 +1213,12 @@ defmodule WhoNeedHelp.Help do
hierarchy_level: level,
point_count: row.count,
expansion_zoom:
DiscoveryCluster.expansion_zoom(
DiscoveryCluster.expansion_zoom_for_bounds(
level,
row.west_m,
row.south_m,
row.east_m,
row.north_m
row.west,
row.south,
row.east,
row.north
),
count: row.count,
urgent_count: row.urgent_count,
@ -1310,6 +1233,41 @@ defmodule WhoNeedHelp.Help do
}
end
defp hydrate_request_map_singletons(rows) do
singleton_ids =
rows
|> Enum.filter(&(&1.count == 1))
|> Enum.map(& &1.request_id)
details = request_map_singleton_details(singleton_ids)
Enum.map(rows, fn
%{count: 1, request_id: request_id} = row ->
Map.merge(row, Map.fetch!(details, request_id))
row ->
row
end)
end
defp request_map_singleton_details([]), do: %{}
defp request_map_singleton_details(singleton_ids) do
HelpRequest
|> where([request], request.id in ^singleton_ids)
|> select([request], {
request.id,
%{
title: request.title,
location_label: request.location_label,
location_visibility: request.location_visibility,
radius_meters: request.location_radius_meters
}
})
|> Repo.all()
|> Map.new(fn {id, detail} -> {to_string(id), detail} end)
end
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 [:scheduled, "scheduled"], do: {:ok, :scheduled}

View File

@ -124,6 +124,27 @@ defmodule WhoNeedHelp.Help.DiscoveryCluster do
|> clamp(0, @maximum_map_zoom)
end
@doc """
Returns the first expansion zoom for member bounds expressed as WGS84
longitude and latitude values.
Discovery aggregation already calculates geographic bounds. Projecting the
handful of returned clusters in Elixir avoids repeating `ST_Transform` for
every matching database row while preserving the same Web Mercator
hierarchy.
"""
def expansion_zoom_for_bounds(level, west, south, east, north)
when is_integer(level) and is_number(west) and is_number(south) and is_number(east) and
is_number(north) do
expansion_zoom(
level,
longitude_to_mercator_x(west),
latitude_to_mercator_y(south),
longitude_to_mercator_x(east),
latitude_to_mercator_y(north)
)
end
defp cell_index(coordinate, level) do
coordinate
|> Kernel.+(@world_half_meters)

View File

@ -12,6 +12,8 @@ defmodule WhoNeedHelp.Help.HelpRequest do
field :structured_data, :map, default: %{}
field :location_label, :string
field :location, Geo.PostGIS.Geometry
field :public_longitude, :float
field :public_latitude, :float
field :location_radius_meters, :integer, default: 1_000
field :status, Ecto.Enum,

View File

@ -24,7 +24,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
|> assign(:discovery_mode, "nearby")
|> assign(:view_mode, "split")
|> assign(:view_explicit, false)
|> assign(:map_update_mode, "manual")
|> assign(:map_update_mode, "auto")
|> assign(:viewport, nil)
|> assign(:pending_viewport, nil)
|> assign(:activities, [])
@ -402,7 +402,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
defp normalize_view_mode(_view_mode), do: "split"
defp normalize_map_update_mode(mode) when mode in @map_update_modes, do: mode
defp normalize_map_update_mode(_mode), do: "manual"
defp normalize_map_update_mode(_mode), do: "auto"
defp activity_path(socket, overrides) do
filters = Keyword.get(overrides, :filters, socket.assigns.filters)
@ -427,8 +427,8 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
)
)
|> then(fn params ->
if map_update_mode == "auto",
do: Map.put(params, "map_update", "auto"),
if map_update_mode == "manual",
do: Map.put(params, "map_update", "manual"),
else: params
end)

View File

@ -31,7 +31,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
|> assign(:discovery_mode, "nearby")
|> assign(:view_mode, "split")
|> assign(:view_explicit, false)
|> assign(:map_update_mode, "manual")
|> assign(:map_update_mode, "auto")
|> assign(:viewport, nil)
|> assign(:pending_viewport, nil)
|> assign(:discovery_topics, [])
@ -379,7 +379,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
defp normalize_view_mode(_view_mode), do: "split"
defp normalize_map_update_mode(mode) when mode in @map_update_modes, do: mode
defp normalize_map_update_mode(_mode), do: "manual"
defp normalize_map_update_mode(_mode), do: "auto"
defp normalize_discovery_mode(discovery_mode) when discovery_mode in @discovery_modes,
do: discovery_mode
@ -413,8 +413,8 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
)
)
|> then(fn params ->
if map_update_mode == "auto",
do: Map.put(params, "map_update", "auto"),
if map_update_mode == "manual",
do: Map.put(params, "map_update", "manual"),
else: params
end)
end

View File

@ -0,0 +1,57 @@
defmodule WhoNeedHelp.Repo.Migrations.AddPublicDiscoveryCoordinates do
use Ecto.Migration
def change do
alter table(:help_requests) do
add :public_longitude, :float,
generated: """
ALWAYS AS (
CASE
WHEN location IS NULL OR location_visibility = 'hidden' THEN NULL
WHEN structured_data @> '{"scale_fixture": true}'::jsonb THEN ST_X(location)
WHEN location_visibility = 'exact_for_active_match' THEN
round(ST_X(location)::numeric, 2)::double precision
ELSE ST_X(location)
END
) STORED
"""
add :public_latitude, :float,
generated: """
ALWAYS AS (
CASE
WHEN location IS NULL OR location_visibility = 'hidden' THEN NULL
WHEN structured_data @> '{"scale_fixture": true}'::jsonb THEN ST_Y(location)
WHEN location_visibility = 'exact_for_active_match' THEN
round(ST_Y(location)::numeric, 2)::double precision
ELSE ST_Y(location)
END
) STORED
"""
end
alter table(:activities) do
add :public_longitude, :float,
generated: """
ALWAYS AS (
CASE
WHEN location IS NULL OR location_visibility = 'hidden' THEN NULL
WHEN structured_data @> '{"scale_fixture": true}'::jsonb THEN ST_X(location)
ELSE round(ST_X(location)::numeric, 2)::double precision
END
) STORED
"""
add :public_latitude, :float,
generated: """
ALWAYS AS (
CASE
WHEN location IS NULL OR location_visibility = 'hidden' THEN NULL
WHEN structured_data @> '{"scale_fixture": true}'::jsonb THEN ST_Y(location)
ELSE round(ST_Y(location)::numeric, 2)::double precision
END
) STORED
"""
end
end
end

View File

@ -76,4 +76,22 @@ defmodule WhoNeedHelp.Help.DiscoveryClusterTest do
assert DiscoveryCluster.expansion_zoom(level, west, south, west, south) == 22
assert DiscoveryCluster.expansion_zoom(24, west, south, west, south) == 22
end
test "geographic bounds preserve Web Mercator expansion levels" do
assert DiscoveryCluster.expansion_zoom_for_bounds(
7,
30.50,
50.40,
30.60,
50.50
) == 8
assert DiscoveryCluster.expansion_zoom_for_bounds(
7,
30.50,
50.40,
30.50,
50.40
) == 22
end
end

View File

@ -385,7 +385,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
} do
category = Catalog.seed_defaults()
{:ok, request} = Help.create_request(scope, request_attrs(category))
{:ok, view, _html} = live(conn, requests_in_kyiv_path())
manual_path = requests_in_kyiv_path() <> "&map_update=manual"
{:ok, view, _html} = live(conn, manual_path)
render_async(view)
@ -413,7 +414,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert_patch(
view,
~p"/requests?#{Map.merge(moved, %{"east" => "30.6", "north" => "50.5", "south" => "50.4", "view" => "split", "west" => "30.5"})}"
~p"/requests?#{Map.merge(moved, %{"east" => "30.6", "map_update" => "manual", "north" => "50.5", "south" => "50.4", "view" => "split", "west" => "30.5"})}"
)
render_async(view)
@ -421,7 +422,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
test "request discovery can update automatically after map movement", %{conn: conn} do
path =
~p"/requests?#{%{west: 30, south: 50, east: 31, north: 51, zoom: 10, width: 1200, height: 720, view: "split", map_update: "auto"}}"
~p"/requests?#{%{west: 30, south: 50, east: 31, north: 51, zoom: 10, width: 1200, height: 720, view: "split"}}"
{:ok, view, _html} = live(conn, path)
render_async(view)
@ -444,7 +445,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
assert_patch(
view,
~p"/requests?#{Map.merge(moved, %{"east" => "30.6", "map_update" => "auto", "north" => "50.5", "south" => "50.4", "view" => "split", "west" => "30.5"})}",
~p"/requests?#{Map.merge(moved, %{"east" => "30.6", "north" => "50.5", "south" => "50.4", "view" => "split", "west" => "30.5"})}",
1_000
)
@ -454,6 +455,8 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
test "activity cluster expansion immediately searches the expanded viewport", %{conn: conn} do
{:ok, view, _html} = live(conn, activities_in_kyiv_path())
assert has_element?(view, "#activity-map-auto-update[checked]")
expanded = %{
"west" => "30.40",
"south" => "50.35",