diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 33f3862..8ddd9c3 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -300,6 +300,7 @@ const createAidMap = element => { resizeObserver: null, markers: [], markerElements: new Map(), + clusterExpansionPending: false, initializedViewport: false, locationBounds: null, recenterControl: null, @@ -625,10 +626,37 @@ const createAidMap = element => { element.title = element.getAttribute("aria-label") element.addEventListener("click", event => { event.stopPropagation() - state.map.easeTo({ - center: [point.longitude, point.latitude], - zoom: Math.min(state.map.getZoom() + 2, 20) - }) + 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({ + center: [point.longitude, point.latitude], + zoom: Math.min(state.map.getZoom() + 2, 20), + duration: 300 + }) + } }) const marker = new maplibregl.Marker({element}) @@ -726,7 +754,14 @@ const createAidMap = element => { if (!state.initializedViewport || state.element.dataset.discoveryMap !== "true") return state.renderMarkers() 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) @@ -1692,6 +1727,8 @@ export const Hooks = { this.pushEvent("initialize-map-area", viewport) this.aidMap.onViewportChange = viewport => this.pushEvent("map-viewport-change", viewport) + this.aidMap.onClusterExpand = viewport => + this.pushEvent("expand-map-cluster", viewport) this.aidMap.mount() }, updated() { diff --git a/assets/js/map_cluster_points.mjs b/assets/js/map_cluster_points.mjs index 6404626..41bb833 100644 --- a/assets/js/map_cluster_points.mjs +++ b/assets/js/map_cluster_points.mjs @@ -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 gap = Number.isFinite(options.gap) ? Math.max(options.gap, 0) : 8 @@ -80,17 +119,18 @@ const nearbyMapPointGroups = (map, points, options = {}) => { } const visualCluster = (map, group) => { - const coordinates = coordinatesFromPixel(map, group) - const firstId = group.points[0]?.id || "anonymous" + const coordinates = coordinatesFromPixel(map, group) + const firstId = group.points[0]?.id || "anonymous" - return { - type: "cluster", - id: `visual-cluster-${group.points.length}-${firstId}`, - count: group.count, - urgent_count: group.urgent_count, - longitude: coordinates.longitude, - latitude: coordinates.latitude - } + return { + type: "cluster", + id: `visual-cluster-${group.points.length}-${firstId}`, + count: group.count, + urgent_count: group.urgent_count, + longitude: coordinates.longitude, + latitude: coordinates.latitude, + bounds: groupBounds(group.points) + } } export const mergeNearbyMapClusters = (map, points, options = {}) => diff --git a/assets/js/map_cluster_points.test.mjs b/assets/js/map_cluster_points.test.mjs index 39a459f..66e2804 100644 --- a/assets/js/map_cluster_points.test.mjs +++ b/assets/js/map_cluster_points.test.mjs @@ -15,8 +15,24 @@ const flatMap = scale => ({ test("merges visually overlapping clusters and preserves their total", () => { 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} ] @@ -29,6 +45,7 @@ test("merges visually overlapping clusters and preserves their total", () => { assert.equal(merged.urgent_count, 3) assert.equal(merged.longitude, 10) 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", () => { @@ -52,6 +69,7 @@ test("merges a singleton into an overlapping cluster at a distant zoom", () => { assert.equal(result.length, 1) assert.equal(result[0].type, "cluster") 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", () => { diff --git a/lib/mix/tasks/wnh.scale_fixtures.ex b/lib/mix/tasks/wnh.scale_fixtures.ex index 7bbeece..fda3edf 100644 --- a/lib/mix/tasks/wnh.scale_fixtures.ex +++ b/lib/mix/tasks/wnh.scale_fixtures.ex @@ -11,9 +11,9 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do @default_rows 1_000_000 @synthetic_users 1_000 @viewer_email "scale-viewer@example.invalid" - @fixture_version 6 - @request_open_modulus 100 - @activity_open_modulus 50 + @fixture_version 7 + @request_open_modulus 1 + @activity_open_modulus 1 @places [ {"Kyiv", 30.5234, 50.4501}, {"Lviv", 24.0316, 49.8429}, @@ -586,10 +586,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do ST_MakePoint(coordinates.longitude, coordinates.latitude), 4326 ), - CASE WHEN variation.visibility_key % 10 = 0 - THEN 'hidden' - ELSE 'approximate_public' - END, + 'approximate_public', CASE WHEN variation.status_key % #{@activity_open_modulus} = 0 THEN 'open' WHEN variation.status_key % #{@activity_open_modulus} < 41 THEN 'completed' ELSE 'cancelled' END, @@ -854,7 +851,7 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do assert_distribution_keys!( "request statuses", profile.help_requests.statuses, - ~w(cancelled completed open) + ~w(open) ) assert_distribution_keys!( @@ -872,13 +869,13 @@ defmodule Mix.Tasks.Wnh.ScaleFixtures do assert_distribution_keys!( "activity statuses", profile.activities.statuses, - ~w(cancelled completed open) + ~w(open) ) assert_distribution_keys!( "activity visibility modes", profile.activities.visibility, - ~w(approximate_public hidden) + ~w(approximate_public) ) assert_geographic_profile!("help requests", profile.help_requests, request_count) diff --git a/lib/who_need_help/activities.ex b/lib/who_need_help/activities.ex index 1b34fbc..accf9d2 100644 --- a/lib/who_need_help/activities.ex +++ b/lib/who_need_help/activities.ex @@ -112,6 +112,10 @@ defmodule WhoNeedHelp.Activities do 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: fragment( "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", @@ -987,7 +991,13 @@ defmodule WhoNeedHelp.Activities do id: "activity-cluster-#{digest}", count: row.count, latitude: row.latitude, - longitude: row.longitude + longitude: row.longitude, + bounds: %{ + west: row.west, + south: row.south, + east: row.east, + north: row.north + } } end diff --git a/lib/who_need_help/help.ex b/lib/who_need_help/help.ex index 775d739..09399cd 100644 --- a/lib/who_need_help/help.ex +++ b/lib/who_need_help/help.ex @@ -137,6 +137,10 @@ defmodule WhoNeedHelp.Help do 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: fragment( "ST_X(ST_SnapToGrid(ST_Transform(?, 3857), ?))", @@ -1295,7 +1299,13 @@ defmodule WhoNeedHelp.Help do count: row.count, urgent_count: row.urgent_count, latitude: row.latitude, - longitude: row.longitude + longitude: row.longitude, + bounds: %{ + west: row.west, + south: row.south, + east: row.east, + north: row.north + } } end diff --git a/lib/who_need_help_web/live/activity_live/index.ex b/lib/who_need_help_web/live/activity_live/index.ex index e29bf87..be8bd85 100644 --- a/lib/who_need_help_web/live/activity_live/index.ex +++ b/lib/who_need_help_web/live/activity_live/index.ex @@ -181,6 +181,16 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do 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 page = Activities.paginate_open_activities( diff --git a/lib/who_need_help_web/live/request_live/index.ex b/lib/who_need_help_web/live/request_live/index.ex index b688aeb..65b428f 100644 --- a/lib/who_need_help_web/live/request_live/index.ex +++ b/lib/who_need_help_web/live/request_live/index.ex @@ -132,6 +132,16 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do 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 page = Help.paginate_open_requests( diff --git a/test/who_need_help/activities_test.exs b/test/who_need_help/activities_test.exs index 95f47f0..411b596 100644 --- a/test/who_need_help/activities_test.exs +++ b/test/who_need_help/activities_test.exs @@ -450,6 +450,10 @@ defmodule WhoNeedHelp.ActivitiesTest do assert cluster assert_in_delta cluster.latitude, 50.45, 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 %{type: "activity", id: id} -> id == sparse.id diff --git a/test/who_need_help/mutual_aid_flow_test.exs b/test/who_need_help/mutual_aid_flow_test.exs index e8b7dba..b1802d4 100644 --- a/test/who_need_help/mutual_aid_flow_test.exs +++ b/test/who_need_help/mutual_aid_flow_test.exs @@ -566,6 +566,10 @@ defmodule WhoNeedHelp.MutualAidFlowTest do assert cluster assert_in_delta cluster.latitude, 50.4501, 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 %{type: "request", id: id, exact: true} -> id == sparse_request.id diff --git a/test/who_need_help_web/live/mutual_aid_live_test.exs b/test/who_need_help_web/live/mutual_aid_live_test.exs index a9af558..06dde97 100644 --- a/test/who_need_help_web/live/mutual_aid_live_test.exs +++ b/test/who_need_help_web/live/mutual_aid_live_test.exs @@ -358,6 +358,48 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do assert has_element?(view, "#activity-map[data-items='[]']") 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 previous = Application.get_env(:who_need_help, :rate_limit_policies)