Make map clusters expand into live viewports
This commit is contained in:
parent
61ad7923ca
commit
762b72100d
|
|
@ -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.map.easeTo({
|
state.clusterExpansionPending = true
|
||||||
center: [point.longitude, point.latitude],
|
|
||||||
zoom: Math.min(state.map.getZoom() + 2, 20)
|
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})
|
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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -80,17 +119,18 @@ const nearbyMapPointGroups = (map, points, options = {}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const visualCluster = (map, group) => {
|
const visualCluster = (map, group) => {
|
||||||
const coordinates = coordinatesFromPixel(map, group)
|
const coordinates = coordinatesFromPixel(map, group)
|
||||||
const firstId = group.points[0]?.id || "anonymous"
|
const firstId = group.points[0]?.id || "anonymous"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "cluster",
|
type: "cluster",
|
||||||
id: `visual-cluster-${group.points.length}-${firstId}`,
|
id: `visual-cluster-${group.points.length}-${firstId}`,
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mergeNearbyMapClusters = (map, points, options = {}) =>
|
export const mergeNearbyMapClusters = (map, points, options = {}) =>
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user