Nearby: No longer keeps loading until timeout when map is zoomed out (#6070)

* Nearby: Search for actual map center

* Add query syntax and methods

* Nearby: Added binary search for loading pins

* Add NearbyQueryParams and refactor

* Add unit tests and complete implementation

* Nearby: Increase max radius from 100km to 300km

* Nearby: Centermost pins now appear on top

* getNearbyItemCount: Added javadoc

---------

Co-authored-by: Nicolas Raoul <nicolas.raoul@gmail.com>
This commit is contained in:
Tanmay Gupta 2024-12-24 11:51:45 +05:30 committed by GitHub
parent c963cd9ea4
commit 369e79be5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 303 additions and 37 deletions

View file

@ -2,6 +2,7 @@ package fr.free.nrw.commons.mwapi
import android.text.TextUtils import android.text.TextUtils
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonParser
import fr.free.nrw.commons.BuildConfig import fr.free.nrw.commons.BuildConfig
import fr.free.nrw.commons.campaigns.CampaignResponseDTO import fr.free.nrw.commons.campaigns.CampaignResponseDTO
import fr.free.nrw.commons.explore.depictions.DepictsClient import fr.free.nrw.commons.explore.depictions.DepictsClient
@ -10,6 +11,7 @@ import fr.free.nrw.commons.fileusages.GlobalFileUsagesResponse
import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.nearby.Place import fr.free.nrw.commons.nearby.Place
import fr.free.nrw.commons.nearby.model.ItemsClass import fr.free.nrw.commons.nearby.model.ItemsClass
import fr.free.nrw.commons.nearby.model.NearbyQueryParams
import fr.free.nrw.commons.nearby.model.NearbyResponse import fr.free.nrw.commons.nearby.model.NearbyResponse
import fr.free.nrw.commons.nearby.model.PlaceBindings import fr.free.nrw.commons.nearby.model.PlaceBindings
import fr.free.nrw.commons.profile.achievements.FeaturedImages import fr.free.nrw.commons.profile.achievements.FeaturedImages
@ -330,36 +332,130 @@ class OkHttpJsonApiClient @Inject constructor(
throw Exception(response.message) throw Exception(response.message)
} }
/**
* Returns the count of items in the specified area by querying Wikidata.
*
* @param queryParams: a `NearbyQueryParam` specifying the geographical area.
* @return The count of items in the specified area.
*/
@Throws(Exception::class)
fun getNearbyItemCount(
queryParams: NearbyQueryParams
): Int {
val wikidataQuery: String = when (queryParams) {
is NearbyQueryParams.Rectangular -> {
val westCornerLat = queryParams.screenTopRight.latitude
val westCornerLong = queryParams.screenTopRight.longitude
val eastCornerLat = queryParams.screenBottomLeft.latitude
val eastCornerLong = queryParams.screenBottomLeft.longitude
FileUtils.readFromResource("/queries/rectangle_query_for_item_count.rq")
.replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
}
is NearbyQueryParams.Radial -> {
FileUtils.readFromResource("/queries/radius_query_for_item_count.rq")
.replace(
"\${LAT}",
String.format(Locale.ROOT, "%.4f", queryParams.center.latitude)
)
.replace(
"\${LONG}",
String.format(Locale.ROOT, "%.4f", queryParams.center.longitude)
)
.replace("\${RAD}", String.format(Locale.ROOT, "%.2f", queryParams.radiusInKm))
}
}
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
.newBuilder()
.addQueryParameter("query", wikidataQuery)
.addQueryParameter("format", "json")
val request: Request = Request.Builder()
.url(urlBuilder.build())
.build()
val response = okHttpClient.newCall(request).execute()
if (response.body != null && response.isSuccessful) {
val json = response.body!!.string()
return JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("results")
.getAsJsonArray("bindings").get(0).getAsJsonObject().getAsJsonObject("itemCount")
.get("value").asInt
}
throw Exception(response.message)
}
@Throws(Exception::class) @Throws(Exception::class)
fun getNearbyPlaces( fun getNearbyPlaces(
screenTopRight: LatLng, queryParams: NearbyQueryParams, language: String,
screenBottomLeft: LatLng, language: String,
shouldQueryForMonuments: Boolean, customQuery: String? shouldQueryForMonuments: Boolean, customQuery: String?
): List<Place>? { ): List<Place>? {
Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString()) Timber.d("CUSTOM_SPARQL: %s", (customQuery != null).toString())
val locale = Locale.ROOT;
val wikidataQuery: String = if (customQuery != null) { val wikidataQuery: String = if (customQuery != null) {
customQuery when (queryParams) {
} else if (!shouldQueryForMonuments) { is NearbyQueryParams.Rectangular -> {
FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq") val westCornerLat = queryParams.screenTopRight.latitude
} else { val westCornerLong = queryParams.screenTopRight.longitude
FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq") val eastCornerLat = queryParams.screenBottomLeft.latitude
val eastCornerLong = queryParams.screenBottomLeft.longitude
customQuery
.replace("\${LAT_WEST}", String.format(locale, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(locale, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(locale, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(locale, "%.4f", eastCornerLong))
.replace("\${LANG}", language)
}
is NearbyQueryParams.Radial -> {
Timber.e(
"%s%s",
"okHttpJsonApiClient.getNearbyPlaces invoked with custom query",
"and radial coordinates. This is currently not supported."
)
""
}
}
} else when (queryParams) {
is NearbyQueryParams.Radial -> {
val placeHolderQuery: String = if (!shouldQueryForMonuments) {
FileUtils.readFromResource("/queries/radius_query_for_nearby.rq")
} else {
FileUtils.readFromResource("/queries/radius_query_for_nearby_monuments.rq")
}
placeHolderQuery.replace(
"\${LAT}", String.format(locale, "%.4f", queryParams.center.latitude)
).replace(
"\${LONG}", String.format(locale, "%.4f", queryParams.center.longitude)
)
.replace("\${RAD}", String.format(locale, "%.2f", queryParams.radiusInKm))
}
is NearbyQueryParams.Rectangular -> {
val placeHolderQuery: String = if (!shouldQueryForMonuments) {
FileUtils.readFromResource("/queries/rectangle_query_for_nearby.rq")
} else {
FileUtils.readFromResource("/queries/rectangle_query_for_nearby_monuments.rq")
}
val westCornerLat = queryParams.screenTopRight.latitude
val westCornerLong = queryParams.screenTopRight.longitude
val eastCornerLat = queryParams.screenBottomLeft.latitude
val eastCornerLong = queryParams.screenBottomLeft.longitude
placeHolderQuery
.replace("\${LAT_WEST}", String.format(locale, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(locale, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(locale, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(locale, "%.4f", eastCornerLong))
.replace("\${LANG}", language)
}
} }
val westCornerLat = screenTopRight.latitude
val westCornerLong = screenTopRight.longitude
val eastCornerLat = screenBottomLeft.latitude
val eastCornerLong = screenBottomLeft.longitude
val query = wikidataQuery
.replace("\${LAT_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLat))
.replace("\${LONG_WEST}", String.format(Locale.ROOT, "%.4f", westCornerLong))
.replace("\${LAT_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLat))
.replace("\${LONG_EAST}", String.format(Locale.ROOT, "%.4f", eastCornerLong))
.replace("\${LANG}", language)
val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!! val urlBuilder: HttpUrl.Builder = sparqlQueryUrl.toHttpUrlOrNull()!!
.newBuilder() .newBuilder()
.addQueryParameter("query", query) .addQueryParameter("query", wikidataQuery)
.addQueryParameter("format", "json") .addQueryParameter("format", "json")
val request: Request = Request.Builder() val request: Request = Request.Builder()

View file

@ -196,8 +196,9 @@ public class NearbyController extends MapController {
return null; return null;
} }
List<Place> places = nearbyPlaces.getFromWikidataQuery(screenTopRight, screenBottomLeft, List<Place> places = nearbyPlaces.getFromWikidataQuery(currentLatLng, screenTopRight,
Locale.getDefault().getLanguage(), shouldQueryForMonuments, customQuery); screenBottomLeft, Locale.getDefault().getLanguage(), shouldQueryForMonuments,
customQuery);
if (null != places && places.size() > 0) { if (null != places && places.size() > 0) {
LatLng[] boundaryCoordinates = { LatLng[] boundaryCoordinates = {

View file

@ -1,6 +1,8 @@
package fr.free.nrw.commons.nearby; package fr.free.nrw.commons.nearby;
import android.location.Location;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import fr.free.nrw.commons.nearby.model.NearbyQueryParams;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -101,6 +103,7 @@ public class NearbyPlaces {
* Retrieves a list of places from a Wikidata query based on screen coordinates and optional * Retrieves a list of places from a Wikidata query based on screen coordinates and optional
* parameters. * parameters.
* *
* @param centerPoint The center of the map, used for radius queries if required.
* @param screenTopRight The top right corner of the screen (latitude, longitude). * @param screenTopRight The top right corner of the screen (latitude, longitude).
* @param screenBottomLeft The bottom left corner of the screen (latitude, longitude). * @param screenBottomLeft The bottom left corner of the screen (latitude, longitude).
* @param lang The language for the query. * @param lang The language for the query.
@ -111,13 +114,70 @@ public class NearbyPlaces {
* @throws Exception If an error occurs during the retrieval process. * @throws Exception If an error occurs during the retrieval process.
*/ */
public List<Place> getFromWikidataQuery( public List<Place> getFromWikidataQuery(
final fr.free.nrw.commons.location.LatLng centerPoint,
final fr.free.nrw.commons.location.LatLng screenTopRight, final fr.free.nrw.commons.location.LatLng screenTopRight,
final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String lang, final fr.free.nrw.commons.location.LatLng screenBottomLeft, final String lang,
final boolean shouldQueryForMonuments, final boolean shouldQueryForMonuments,
@Nullable final String customQuery) throws Exception { @Nullable final String customQuery) throws Exception {
return okHttpJsonApiClient if (customQuery != null) {
.getNearbyPlaces(screenTopRight, screenBottomLeft, lang, shouldQueryForMonuments, return okHttpJsonApiClient
customQuery); .getNearbyPlaces(
new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft), lang,
shouldQueryForMonuments,
customQuery);
}
final int lowerLimit = 1000, upperLimit = 1500;
final float[] results = new float[1];
Location.distanceBetween(centerPoint.getLatitude(), screenTopRight.getLongitude(),
centerPoint.getLatitude(), screenBottomLeft.getLongitude(), results);
final float longGap = results[0] / 1000f;
Location.distanceBetween(screenTopRight.getLatitude(), centerPoint.getLongitude(),
screenBottomLeft.getLatitude(), centerPoint.getLongitude(), results);
final float latGap = results[0] / 1000f;
if (Math.max(longGap, latGap) < 100f) {
final int itemCount = okHttpJsonApiClient.getNearbyItemCount(
new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft));
if (itemCount < upperLimit) {
return okHttpJsonApiClient.getNearbyPlaces(
new NearbyQueryParams.Rectangular(screenTopRight, screenBottomLeft), lang,
shouldQueryForMonuments, null);
}
}
// minRadius, targetRadius and maxRadius are radii in decameters
// unlike other radii here, which are in kilometers, to avoid looping over
// floating point values
int minRadius = 0, maxRadius = Math.round(Math.min(300f, Math.min(longGap, latGap))) * 100;
int targetRadius = maxRadius / 2;
while (minRadius < maxRadius) {
targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
final int itemCount = okHttpJsonApiClient.getNearbyItemCount(
new NearbyQueryParams.Radial(centerPoint, targetRadius / 100f));
if (itemCount >= lowerLimit && itemCount < upperLimit) {
break;
}
if (targetRadius > maxRadius / 2 && itemCount < lowerLimit / 5) { // fast forward
minRadius = targetRadius;
targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
minRadius = targetRadius;
if (itemCount < lowerLimit / 10 && minRadius < maxRadius) { // fast forward again
targetRadius = minRadius + (maxRadius - minRadius + 1) / 2;
minRadius = targetRadius;
}
continue;
}
if (itemCount < upperLimit) {
minRadius = targetRadius;
} else {
maxRadius = targetRadius - 1;
}
}
return okHttpJsonApiClient.getNearbyPlaces(
new NearbyQueryParams.Radial(centerPoint, targetRadius / 100f), lang, shouldQueryForMonuments,
null);
} }
/** /**

View file

@ -1101,19 +1101,19 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
eastCornerLong, 0); eastCornerLong, 0);
if (currentLatLng.equals( if (currentLatLng.equals(
getLastMapFocus())) { // Means we are checking around current location getLastMapFocus())) { // Means we are checking around current location
populatePlacesForCurrentLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForCurrentLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} else { } else {
populatePlacesForAnotherLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForAnotherLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} }
} else { } else {
if (currentLatLng.equals( if (currentLatLng.equals(
getLastMapFocus())) { // Means we are checking around current location getLastMapFocus())) { // Means we are checking around current location
populatePlacesForCurrentLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForCurrentLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} else { } else {
populatePlacesForAnotherLocation(getLastMapFocus(), screenTopRightLatLng, populatePlacesForAnotherLocation(getMapFocus(), screenTopRightLatLng,
screenBottomLeftLatLng, currentLatLng, null); screenBottomLeftLatLng, currentLatLng, null);
} }
} }
@ -1887,9 +1887,12 @@ public class NearbyParentFragment extends CommonsDaggerSupportFragment
@Override @Override
public void replaceMarkerOverlays(final List<MarkerPlaceGroup> markerPlaceGroups) { public void replaceMarkerOverlays(final List<MarkerPlaceGroup> markerPlaceGroups) {
ArrayList<Marker> newMarkers = new ArrayList<>(markerPlaceGroups.size()); ArrayList<Marker> newMarkers = new ArrayList<>(markerPlaceGroups.size());
for (MarkerPlaceGroup markerPlaceGroup : markerPlaceGroups) { // iterate in reverse so that the nearest pins get rendered on top
for (int i = markerPlaceGroups.size() - 1; i >= 0; i--) {
newMarkers.add( newMarkers.add(
convertToMarker(markerPlaceGroup.getPlace(), markerPlaceGroup.getIsBookmarked())); convertToMarker(markerPlaceGroups.get(i).getPlace(),
markerPlaceGroups.get(i).getIsBookmarked())
);
} }
clearAllMarkers(); clearAllMarkers();
binding.map.getOverlays().addAll(newMarkers); binding.map.getOverlays().addAll(newMarkers);

View file

@ -0,0 +1,10 @@
package fr.free.nrw.commons.nearby.model
import fr.free.nrw.commons.location.LatLng
sealed class NearbyQueryParams {
class Rectangular(val screenTopRight: LatLng, val screenBottomLeft: LatLng) :
NearbyQueryParams()
class Radial(val center: LatLng, val radiusInKm: Float) : NearbyQueryParams()
}

View file

@ -0,0 +1,9 @@
SELECT (COUNT(?item) AS ?itemCount)
WHERE {
# Around given location.
SERVICE wikibase:around {
?item wdt:P625 ?location.
bd:serviceParam wikibase:center "Point(${LONG} ${LAT})"^^geo:wktLiteral.
bd:serviceParam wikibase:radius "${RAD}" . # Radius in kilometers.
}
}

View file

@ -0,0 +1,12 @@
SELECT
?item
(SAMPLE(?location) as ?location)
WHERE {
# Around given location.
SERVICE wikibase:around {
?item wdt:P625 ?location.
bd:serviceParam wikibase:center "Point(${LONG} ${LAT})"^^geo:wktLiteral.
bd:serviceParam wikibase:radius "${RAD}" . # Radius in kilometers.
}
}
GROUP BY ?item

View file

@ -0,0 +1,25 @@
SELECT
?item
(SAMPLE(?location) as ?location)
(SAMPLE(?monument) AS ?monument)
WHERE {
# Around given location.
SERVICE wikibase:around {
?item wdt:P625 ?location.
bd:serviceParam wikibase:center "Point(${LONG} ${LAT})"^^geo:wktLiteral.
bd:serviceParam wikibase:radius "${RAD}" . # Radius in kilometers.
}
# Wiki Loves Monuments
OPTIONAL {?item p:P1435 ?monument}
OPTIONAL {?item p:P2186 ?monument}
OPTIONAL {?item p:P1459 ?monument}
OPTIONAL {?item p:P1460 ?monument}
OPTIONAL {?item p:P1216 ?monument}
OPTIONAL {?item p:P709 ?monument}
OPTIONAL {?item p:P718 ?monument}
OPTIONAL {?item p:P5694 ?monument}
OPTIONAL {?item p:P3426 ?monument}
}
GROUP BY ?item

View file

@ -0,0 +1,8 @@
SELECT (COUNT(?item) AS ?itemCount)
WHERE {
SERVICE wikibase:box {
?item wdt:P625 ?location.
bd:serviceParam wikibase:cornerWest "Point(${LONG_WEST} ${LAT_WEST})"^^geo:wktLiteral.
bd:serviceParam wikibase:cornerEast "Point(${LONG_EAST} ${LAT_EAST})"^^geo:wktLiteral.
}
}

View file

@ -8,6 +8,5 @@ WHERE {
bd:serviceParam wikibase:cornerWest "Point(${LONG_WEST} ${LAT_WEST})"^^geo:wktLiteral. bd:serviceParam wikibase:cornerWest "Point(${LONG_WEST} ${LAT_WEST})"^^geo:wktLiteral.
bd:serviceParam wikibase:cornerEast "Point(${LONG_EAST} ${LAT_EAST})"^^geo:wktLiteral. bd:serviceParam wikibase:cornerEast "Point(${LONG_EAST} ${LAT_EAST})"^^geo:wktLiteral.
} }
} }
GROUP BY ?item GROUP BY ?item

View file

@ -6,6 +6,7 @@ import com.nhaarman.mockitokotlin2.verify
import fr.free.nrw.commons.explore.depictions.DepictsClient import fr.free.nrw.commons.explore.depictions.DepictsClient
import fr.free.nrw.commons.location.LatLng import fr.free.nrw.commons.location.LatLng
import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient import fr.free.nrw.commons.mwapi.OkHttpJsonApiClient
import fr.free.nrw.commons.nearby.model.NearbyQueryParams
import okhttp3.Call import okhttp3.Call
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -14,6 +15,7 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mockito.Mock import org.mockito.Mock
import org.mockito.Mockito import org.mockito.Mockito
import org.mockito.Mockito.times
import org.mockito.MockitoAnnotations import org.mockito.MockitoAnnotations
import java.lang.Exception import java.lang.Exception
@ -64,12 +66,16 @@ class OkHttpJsonApiClientTests {
Mockito.`when`(response.message).thenReturn("test") Mockito.`when`(response.message).thenReturn("test")
try { try {
okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, "test") okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, "test")
okHttpJsonApiClient.getNearbyPlaces(latLng, latLng, "test", true, "test")
} catch (e: Exception) { } catch (e: Exception) {
assert(e.message.equals("test")) assert(e.message.equals("test"))
} }
verify(okhttpClient).newCall(any()) try {
verify(call).execute() okHttpJsonApiClient.getNearbyPlaces(NearbyQueryParams.Rectangular(latLng, latLng), "test", true, "test")
} catch (e: Exception) {
assert(e.message.equals("test"))
}
verify(okhttpClient, times(2)).newCall(any())
verify(call, times(2)).execute()
} }
@Test @Test
@ -77,11 +83,48 @@ class OkHttpJsonApiClientTests {
Mockito.`when`(response.message).thenReturn("test") Mockito.`when`(response.message).thenReturn("test")
try { try {
okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, null) okHttpJsonApiClient.getNearbyPlaces(latLng, "test", 10.0, null)
okHttpJsonApiClient.getNearbyPlaces(latLng, latLng, "test", true, null)
} catch (e: Exception) { } catch (e: Exception) {
assert(e.message.equals("test")) assert(e.message.equals("test"))
} }
verify(okhttpClient).newCall(any()) try {
verify(call).execute() okHttpJsonApiClient.getNearbyPlaces(
NearbyQueryParams.Rectangular(latLng, latLng),
"test",
true,
null
)
} catch (e: Exception) {
assert(e.message.equals("test"))
}
try {
okHttpJsonApiClient.getNearbyPlaces(
NearbyQueryParams.Radial(latLng, 10f),
"test",
true,
null
)
} catch (e: Exception) {
assert(e.message.equals("test"))
}
verify(okhttpClient, times(3)).newCall(any())
verify(call, times(3)).execute()
}
@Test
fun testGetNearbyItemCount() {
Mockito.`when`(response.message).thenReturn("test")
try {
okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Radial(latLng, 10f))
} catch (e: Exception) {
assert(e.message.equals("test"))
}
try {
okHttpJsonApiClient.getNearbyItemCount(NearbyQueryParams.Rectangular(latLng, latLng))
} catch (e: Exception) {
assert(e.message.equals("test"))
}
verify(okhttpClient, times(2)).newCall(any())
verify(call, times(2)).execute()
} }
} }