Diversify scale fixture geography and privacy

This commit is contained in:
SimpleTest 2026-07-28 22:48:58 +03:00
parent 4138d7bfef
commit 61ad7923ca
8 changed files with 904 additions and 172 deletions

View File

@ -1,7 +1,7 @@
import maplibregl from "maplibre-gl" import maplibregl from "maplibre-gl"
import { import {
clusterSizeClass, clusterSizeClass,
mergeNearbyMapClusters mergeNearbyMapPoints
} from "./map_cluster_points.mjs" } from "./map_cluster_points.mjs"
const trackingMinTimeMs = 5000 const trackingMinTimeMs = 5000
@ -524,21 +524,19 @@ const createAidMap = element => {
state.markerElements.clear() state.markerElements.clear()
const points = markerPoints(state.element) const points = markerPoints(state.element)
const exactPoints = points.filter( const displayPoints = mergeNearbyMapPoints(state.map, points)
const exactPoints = displayPoints.filter(
point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters)) point => point.type !== "cluster" && (point.exact || !Number.isFinite(point.radius_meters))
) )
const clusterPoints = mergeNearbyMapClusters( const clusterPoints = displayPoints.filter(point => point.type === "cluster")
state.map, const areaFeatures = areaFeatureCollection(displayPoints)
points.filter(point => point.type === "cluster")
)
const areaFeatures = areaFeatureCollection(points)
const clusterFeatures = clusterFeatureCollection(clusterPoints) const clusterFeatures = clusterFeatureCollection(clusterPoints)
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)) clusterFeatures.features.forEach(feature => bounds.extend(feature.geometry.coordinates))
state.locationBounds = points.length > 0 ? bounds : null state.locationBounds = displayPoints.length > 0 ? bounds : null
if (state.map.isStyleLoaded()) { if (state.map.isStyleLoaded()) {
const areaSource = state.map.getSource(state.areaSourceId) const areaSource = state.map.getSource(state.areaSourceId)

View File

@ -34,7 +34,7 @@ const coordinatesFromPixel = (map, pixel) => {
} }
} }
export const mergeNearbyMapClusters = (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
const groups = points.map(point => { const groups = points.map(point => {
@ -76,7 +76,10 @@ export const mergeNearbyMapClusters = (map, points, options = {}) => {
} }
} }
return groups.map(group => { return groups
}
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"
@ -88,5 +91,17 @@ export const mergeNearbyMapClusters = (map, points, options = {}) => {
longitude: coordinates.longitude, longitude: coordinates.longitude,
latitude: coordinates.latitude latitude: coordinates.latitude
} }
}
export const mergeNearbyMapClusters = (map, points, options = {}) =>
nearbyMapPointGroups(map, points, options).map(group => visualCluster(map, group))
export const mergeNearbyMapPoints = (map, points, options = {}) => {
return nearbyMapPointGroups(map, points, options).map(group => {
const point = group.points[0]
if (group.points.length === 1 && point?.type !== "cluster") return point
return visualCluster(map, group)
}) })
} }

View File

@ -4,7 +4,8 @@ import test from "node:test"
import { import {
clusterDiameterPixels, clusterDiameterPixels,
clusterSizeClass, clusterSizeClass,
mergeNearbyMapClusters mergeNearbyMapClusters,
mergeNearbyMapPoints
} from "./map_cluster_points.mjs" } from "./map_cluster_points.mjs"
const flatMap = scale => ({ const flatMap = scale => ({
@ -40,6 +41,36 @@ test("splits the same clusters again at a closer zoom", () => {
assert.equal(mergeNearbyMapClusters(flatMap(3), points).length, 2) assert.equal(mergeNearbyMapClusters(flatMap(3), points).length, 2)
}) })
test("merges a singleton into an overlapping cluster at a distant zoom", () => {
const points = [
{id: "cluster", type: "cluster", count: 351, longitude: 0, latitude: 0},
{id: "request", type: "request", longitude: 28, latitude: 0, exact: true}
]
const result = mergeNearbyMapPoints(flatMap(1), points)
assert.equal(result.length, 1)
assert.equal(result[0].type, "cluster")
assert.equal(result[0].count, 352)
})
test("keeps separated singleton points interactive at a closer zoom", () => {
const points = [
{id: "left", type: "request", longitude: 0, latitude: 0, exact: true},
{
id: "right",
type: "request",
longitude: 30,
latitude: 0,
radius_meters: 1_000
}
]
const result = mergeNearbyMapPoints(flatMap(3), points)
assert.deepEqual(result, points)
})
test("uses larger non-overlapping markers for larger totals", () => { test("uses larger non-overlapping markers for larger totals", () => {
assert.equal(clusterDiameterPixels(999), 44) assert.equal(clusterDiameterPixels(999), 44)
assert.equal(clusterDiameterPixels(1_000), 52) assert.equal(clusterDiameterPixels(1_000), 52)

File diff suppressed because it is too large Load Diff

View File

@ -65,7 +65,21 @@ defmodule WhoNeedHelp.Activities do
location_label: activity.location_label, location_label: activity.location_label,
public_location: public_location:
fragment( fragment(
"ST_SetSRID(ST_MakePoint(round(ST_X(?)::numeric, 2)::double precision, round(ST_Y(?)::numeric, 2)::double precision), 4326)", """
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,
activity.location activity.location
) )
@ -849,16 +863,22 @@ defmodule WhoNeedHelp.Activities do
fragment( fragment(
""" """
ST_Intersects( ST_Intersects(
ST_SetSRID( CASE
ST_MakePoint( WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ?
round(ST_X(?)::numeric, 2)::double precision, ELSE
round(ST_Y(?)::numeric, 2)::double precision ST_SetSRID(
), ST_MakePoint(
4326 round(ST_X(?)::numeric, 2)::double precision,
), round(ST_Y(?)::numeric, 2)::double precision
),
4326
)
END,
ST_MakeEnvelope(?, ?, ?, ?, 4326) ST_MakeEnvelope(?, ?, ?, ?, 4326)
) )
""", """,
activity.structured_data,
activity.location,
activity.location, activity.location,
activity.location, activity.location,
^west, ^west,

View File

@ -80,7 +80,22 @@ defmodule WhoNeedHelp.Help do
urgency: request.urgency, urgency: request.urgency,
public_location: public_location:
fragment( 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", """
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_visibility,
request.location, request.location,
request.location, request.location,
@ -172,6 +187,7 @@ defmodule WhoNeedHelp.Help do
AND AND
ST_Intersects( ST_Intersects(
CASE CASE
WHEN ? @> '{"scale_fixture": true}'::jsonb THEN ?
WHEN ? = 'exact_for_active_match' THEN WHEN ? = 'exact_for_active_match' THEN
ST_SetSRID( ST_SetSRID(
ST_MakePoint( ST_MakePoint(
@ -191,6 +207,8 @@ defmodule WhoNeedHelp.Help do
^(south - 0.005), ^(south - 0.005),
^(east + 0.005), ^(east + 0.005),
^(north + 0.005), ^(north + 0.005),
request.structured_data,
request.location,
request.location_visibility, request.location_visibility,
request.location, request.location,
request.location, request.location,

View File

@ -457,6 +457,42 @@ defmodule WhoNeedHelp.ActivitiesTest do
end) end)
end end
test "scale fixture activities keep distinct synthetic public map coordinates", context do
activities =
for {latitude, index} <- [{"50.451", 1}, {"50.454", 2}] do
{:ok, activity} =
Activities.create_activity(
context.organizer_scope,
context.attrs
|> Map.put("title", "Synthetic distinct activity #{index}")
|> Map.put("latitude", latitude)
)
activity
|> Ecto.Changeset.change(
structured_data: Map.put(activity.structured_data, "scale_fixture", true)
)
|> Repo.update!()
end
items =
Activities.map_discovery_items(
context.outsider_scope,
%{},
viewport!(30.51, 50.44, 30.54, 50.46, 16)
)
fixture_ids = MapSet.new(activities, & &1.id)
fixture_items =
Enum.filter(items, fn
%{type: "activity", id: id} -> MapSet.member?(fixture_ids, id)
_item -> false
end)
assert Enum.sort(Enum.map(fixture_items, & &1.latitude)) == [50.451, 50.454]
end
test "hidden activity is discoverable only through its public area description", context do test "hidden activity is discoverable only through its public area description", context do
{:ok, hidden} = {:ok, hidden} =
Activities.create_activity( Activities.create_activity(

View File

@ -603,6 +603,44 @@ defmodule WhoNeedHelp.MutualAidFlowTest do
refute_in_delta item.longitude, 30.5267, 0.000_001 refute_in_delta item.longitude, 30.5267, 0.000_001
end end
test "scale fixture requests keep distinct synthetic public map coordinates", context do
requests =
for {latitude, index} <- [{"50.451", 1}, {"50.454", 2}] do
{:ok, request} =
Help.create_request(
context.requester_scope,
context.request_attrs
|> Map.put("title", "Synthetic distinct request #{index}")
|> Map.put("latitude", latitude)
|> Map.put("location_visibility", "exact_for_active_match")
)
request
|> Ecto.Changeset.change(
structured_data: Map.put(request.structured_data, "scale_fixture", true)
)
|> Repo.update!()
end
items =
Help.map_discovery_items(
context.helper_scope,
%{},
viewport!(30.51, 50.44, 30.54, 50.46, 16)
)
fixture_ids = MapSet.new(requests, & &1.id)
fixture_items =
Enum.filter(items, fn
%{type: "request", id: id} -> MapSet.member?(fixture_ids, id)
_item -> false
end)
assert Enum.sort(Enum.map(fixture_items, & &1.latitude)) == [50.451, 50.454]
assert Enum.all?(fixture_items, &(&1.exact == false))
end
test "area search treats SQL wildcard characters as literal text", context do test "area search treats SQL wildcard characters as literal text", context do
{:ok, literal_request} = {:ok, literal_request} =
Help.create_request( Help.create_request(