From 8165a19a23e96c3ad232b5850fdd339af8898b32 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 10 May 2026 00:06:38 +0200 Subject: [PATCH] [Places] Add popularity sort and popularity-scaled markers. Geosearch now requests 30-day pageviews per page, summed into a popularity score. List view gets a sort menu (Popularity / Distance, defaulting to Popularity) persisted in Prefs. Map markers scale logarithmically by pageviews relative to the most popular page in view, so notable places stand out. --- .../java/org/wikipedia/dataclient/Service.kt | 4 +- .../wikipedia/dataclient/page/NearbyPage.kt | 1 + .../org/wikipedia/places/PlacesFragment.kt | 44 +++++++++- .../places/PlacesFragmentViewModel.kt | 40 +++++++-- .../main/java/org/wikipedia/settings/Prefs.kt | 4 + app/src/main/res/layout/fragment_places.xml | 10 +++ app/src/main/res/menu/menu_places_sort.xml | 11 +++ app/src/main/res/values/preference_keys.xml | 1 + app/src/main/res/values/strings.xml | 3 + .../wikipedia/places/SortNearbyPagesTest.kt | 83 +++++++++++++++++++ 10 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 app/src/main/res/menu/menu_places_sort.xml create mode 100644 app/src/test/java/org/wikipedia/places/SortNearbyPagesTest.kt diff --git a/app/src/main/java/org/wikipedia/dataclient/Service.kt b/app/src/main/java/org/wikipedia/dataclient/Service.kt index 2864cab1180..e3b92d5324e 100644 --- a/app/src/main/java/org/wikipedia/dataclient/Service.kt +++ b/app/src/main/java/org/wikipedia/dataclient/Service.kt @@ -264,7 +264,7 @@ interface Service { @Field("url") url: String, ): ShortenUrlResponse - @GET(MW_API_PREFIX + "action=query&generator=geosearch&prop=coordinates|description|pageimages|info&inprop=varianttitles|displaytitle&pilicense=any") + @GET(MW_API_PREFIX + "action=query&generator=geosearch&prop=coordinates|description|pageimages|info|pageviews&pvipdays=$GEO_SEARCH_PAGE_VIEWS_DAYS&inprop=varianttitles|displaytitle&pilicense=any") suspend fun getGeoSearch( @Query("ggscoord", encoded = true) coordinates: String, @Query("ggsradius") radius: Int, @@ -752,6 +752,8 @@ interface Service { const val URL_FRAGMENT_FROM_COMMONS = "/wikipedia/commons/" const val MW_API_PREFIX = "w/api.php?format=json&formatversion=2&errorformat=html&errorsuselocal=1&" const val PREFERRED_THUMB_SIZE = 330 + // Days of pageview history fetched per page; summed as a popularity score in Places. + const val GEO_SEARCH_PAGE_VIEWS_DAYS = 30 // Maximum cache time for site-specific data, and other things not likely to change very often. const val SITE_INFO_MAXAGE = 86400 diff --git a/app/src/main/java/org/wikipedia/dataclient/page/NearbyPage.kt b/app/src/main/java/org/wikipedia/dataclient/page/NearbyPage.kt index 72e212b9267..c1ef12dcc07 100644 --- a/app/src/main/java/org/wikipedia/dataclient/page/NearbyPage.kt +++ b/app/src/main/java/org/wikipedia/dataclient/page/NearbyPage.kt @@ -10,6 +10,7 @@ class NearbyPage( val pageTitle: PageTitle, val latitude: Double, val longitude: Double, + val pageViews: Long = 0L, var annotation: Symbol? = null, var bitmap: Bitmap? = null ) { diff --git a/app/src/main/java/org/wikipedia/places/PlacesFragment.kt b/app/src/main/java/org/wikipedia/places/PlacesFragment.kt index ff59e29c613..6f7d620343a 100644 --- a/app/src/main/java/org/wikipedia/places/PlacesFragment.kt +++ b/app/src/main/java/org/wikipedia/places/PlacesFragment.kt @@ -21,6 +21,7 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat @@ -99,6 +100,7 @@ import org.wikipedia.views.ViewUtil import org.wikipedia.views.imageservice.ImageService import java.util.Locale import kotlin.math.abs +import kotlin.math.ln class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPreviewDialog.DismissCallback, MapLibreMap.OnMapClickListener { @@ -130,6 +132,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi latitudeDiffToMeters(it.projection.visibleRegion.latLngBounds.latitudeSpan / 2) } ?: 50 private var magnifiedMarker: Symbol? = null + private var maxPageViews: Long = 0L private val locationPermissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> when { @@ -245,6 +248,11 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi filterLauncher.launch(PlacesFilterActivity.newIntent(requireActivity())) } + binding.sortButton.setOnClickListener { + PlacesEvent.logAction("sort_click", "search_bar_view") + showSortMenu(it) + } + binding.searchCloseBtn.setOnClickListener { PlacesEvent.logAction("search_clear_click", "search_bar_view") updateSearchText() @@ -432,6 +440,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi binding.listEmptyContainer.isVisible = !isMapVisible && (binding.listRecyclerView.adapter?.itemCount ?: 0) == 0 binding.searchContainer.backgroundTintList = tintColor binding.myLocationButton.isVisible = isMapVisible + binding.sortButton.isVisible = !isMapVisible } private fun showLinkPreview(pageTitle: PageTitle, location: Location) { @@ -443,15 +452,38 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi LinkPreviewDialog.newInstance(entry, location, getLastKnownUserLocation())) } + private fun showSortMenu(anchor: View) { + PopupMenu(requireContext(), anchor).apply { + menuInflater.inflate(R.menu.menu_places_sort, menu) + menu.findItem(viewModel.sortMode.menuId).isChecked = true + setOnMenuItemClickListener { item -> + val newMode = SortMode.entries.first { it.menuId == item.itemId } + if (viewModel.sortMode != newMode) { + viewModel.sortMode = newMode + viewModel.applySort() + PlacesEvent.logAction("sort_select", "search_bar_view", newMode.name.lowercase()) + } + true + } + show() + } + } + private fun resetMagnifiedSymbol() { - // Reset the magnified marker to regular size - magnifiedMarker?.let { - it.iconSize = 1.0f - symbolManager?.update(it) + magnifiedMarker?.let { marker -> + val page = annotationCache.find { it.annotation == marker } + marker.iconSize = page?.let { popularityIconSize(it.pageViews) } ?: 1.0f + symbolManager?.update(marker) } viewModel.highlightedPageTitle = null } + private fun popularityIconSize(pageViews: Long): Float { + if (maxPageViews <= 0L || pageViews <= 0L) return POPULARITY_MIN_SCALE + val ratio = (ln(pageViews.toDouble() + 1.0) / ln(maxPageViews.toDouble() + 1.0)).toFloat().coerceIn(0f, 1f) + return POPULARITY_MIN_SCALE + (POPULARITY_MAX_SCALE - POPULARITY_MIN_SCALE) * ratio + } + private fun setMagnifiedSymbol(symbol: Symbol?) { magnifiedMarker?.symbolSortKey = 0f magnifiedMarker = symbol @@ -588,6 +620,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi } private fun updateMapMarkers(pages: List) { + maxPageViews = pages.maxOfOrNull { it.pageViews } ?: 0L symbolManager?.let { manager -> pages.filter { @@ -598,6 +631,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi .withLatLng(LatLng(it.latitude, it.longitude)) .withTextFont(MARKER_FONT_STACK) .withIconImage(MARKER_DRAWABLE) + .withIconSize(popularityIconSize(it.pageViews)) ) if (viewModel.highlightedPageTitle?.prefixedText.orEmpty() == it.pageTitle.prefixedText) { setMagnifiedSymbol(it.annotation) @@ -810,6 +844,8 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi const val MAX_ANNOTATIONS = 250 const val THUMB_SIZE = 120 const val ITEMS_PER_REQUEST = 75 + const val POPULARITY_MIN_SCALE = 0.7f + const val POPULARITY_MAX_SCALE = 1.4f const val CLUSTER_TEXT_LAYER_ID = "mapbox-android-cluster-text" const val CLUSTER_CIRCLE_LAYER_ID = "mapbox-android-cluster-circle0" const val ZOOM_IN_ANIMATION_DURATION = 1000 diff --git a/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt b/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt index 46a0f287ceb..1e804319db2 100644 --- a/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt +++ b/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import org.wikipedia.Constants +import org.wikipedia.R import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.page.NearbyPage @@ -16,14 +17,36 @@ import org.wikipedia.settings.Prefs import org.wikipedia.util.ImageUrlUtil import org.wikipedia.util.Resource +enum class SortMode(val menuId: Int) { + POPULARITY(R.id.menu_places_sort_popularity), + DISTANCE(R.id.menu_places_sort_distance) +} + +internal fun sortNearbyPages( + pages: List, + sortMode: SortMode, + lastKnownLocation: Location? +): List = when (sortMode) { + SortMode.DISTANCE -> pages.sortedBy { + lastKnownLocation?.run { it.location.distanceTo(this) } + } + SortMode.POPULARITY -> pages.sortedByDescending { it.pageViews } +} + class PlacesFragmentViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { val wikiSite: WikiSite get() = WikiSite.forLanguageCode(Prefs.placesWikiCode) var location: Location? = savedStateHandle[PlacesActivity.EXTRA_LOCATION] var highlightedPageTitle: PageTitle? = savedStateHandle[Constants.ARG_TITLE] var lastKnownLocation: Location? = null + // Default to popularity: easier to discover interesting nearby places. + var sortMode: SortMode + get() = SortMode.entries.firstOrNull { it.name == Prefs.placesSortMode } ?: SortMode.POPULARITY + set(value) { Prefs.placesSortMode = value.name } val nearbyPagesLiveData = MutableLiveData>>() + private var lastResults: List = emptyList() + fun fetchNearbyPages(latitude: Double, longitude: Double, radius: Int, maxResults: Int) { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> nearbyPagesLiveData.postValue(Resource.Error(throwable)) @@ -34,14 +57,17 @@ class PlacesFragmentViewModel(savedStateHandle: SavedStateHandle) : ViewModel() .map { NearbyPage(it.pageId, PageTitle(it.title, wikiSite, if (it.thumbUrl().isNullOrEmpty()) null else ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl()!!, PlacesFragment.THUMB_SIZE), - it.description, it.displayTitle(wikiSite.languageCode)), it.coordinates!![0].lat, it.coordinates[0].lon) - } - .sortedBy { - lastKnownLocation?.run { - it.location.distanceTo(this) - } + it.description, it.displayTitle(wikiSite.languageCode)), it.coordinates!![0].lat, it.coordinates[0].lon, + // pageViewsMap is a date→count map; sum gives the N-day total. + it.pageViewsMap.values.filterNotNull().sum()) } - nearbyPagesLiveData.postValue(Resource.Success(pages)) + lastResults = pages + applySort() } } + + fun applySort() { + val sorted = sortNearbyPages(lastResults, sortMode, lastKnownLocation) + nearbyPagesLiveData.postValue(Resource.Success(sorted)) + } } diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index d2b39424789..d88cd991170 100644 --- a/app/src/main/java/org/wikipedia/settings/Prefs.kt +++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt @@ -682,6 +682,10 @@ object Prefs { get() = PrefsIoUtil.getString(R.string.preference_key_places_wiki_code, WikipediaApp.instance.appOrSystemLanguageCode).orEmpty() set(value) = PrefsIoUtil.setString(R.string.preference_key_places_wiki_code, value) + var placesSortMode + get() = PrefsIoUtil.getString(R.string.preference_key_places_sort_mode, null).orEmpty() + set(value) = PrefsIoUtil.setString(R.string.preference_key_places_sort_mode, value) + var placesDefaultLocationLatLng get(): String? { val lanLng = PrefsIoUtil.getString(R.string.preference_key_default_places_location_latlng, null) diff --git a/app/src/main/res/layout/fragment_places.xml b/app/src/main/res/layout/fragment_places.xml index 45a225ac447..d9c7fb643d3 100644 --- a/app/src/main/res/layout/fragment_places.xml +++ b/app/src/main/res/layout/fragment_places.xml @@ -76,6 +76,16 @@ android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/places_filter_title"/> + + + + + + + + diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index ebd2ed1bf43..ffeaa66c2eb 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -160,6 +160,7 @@ showRecentEditsFeedbackForm filterPlacesWikiCode placesLastLocationAndZoomLevel + placesSortMode recentUsedTemplates donationTestEnv donationResults diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a9ce0184b2..39fe89f9590 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1867,6 +1867,9 @@ Filter language Map List + Distance + Popularity + Sort nearby places on the map.]]> Places nearby More places nearby diff --git a/app/src/test/java/org/wikipedia/places/SortNearbyPagesTest.kt b/app/src/test/java/org/wikipedia/places/SortNearbyPagesTest.kt new file mode 100644 index 00000000000..6bb0d313e1d --- /dev/null +++ b/app/src/test/java/org/wikipedia/places/SortNearbyPagesTest.kt @@ -0,0 +1,83 @@ +package org.wikipedia.places + +import android.location.Location +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.NearbyPage +import org.wikipedia.page.PageTitle + +@RunWith(RobolectricTestRunner::class) +class SortNearbyPagesTest { + + @Test + fun popularitySort_orderedByPageViewsDescending() { + val pages = listOf( + page(id = 1, lat = 0.0, lon = 0.0, views = 100), + page(id = 2, lat = 0.0, lon = 0.0, views = 9000), + page(id = 3, lat = 0.0, lon = 0.0, views = 50) + ) + + val sorted = sortNearbyPages(pages, SortMode.POPULARITY, lastKnownLocation = null) + + assertEquals(listOf(2, 1, 3), sorted.map { it.pageId }) + } + + @Test + fun distanceSort_orderedByProximityToLastKnownLocation() { + val origin = location(0.0, 0.0) + val pages = listOf( + page(id = 1, lat = 1.0, lon = 0.0, views = 0), + page(id = 2, lat = 0.1, lon = 0.0, views = 0), + page(id = 3, lat = 0.5, lon = 0.0, views = 0) + ) + + val sorted = sortNearbyPages(pages, SortMode.DISTANCE, lastKnownLocation = origin) + + assertEquals(listOf(2, 3, 1), sorted.map { it.pageId }) + } + + @Test + fun distanceSort_withoutLocation_isStableNoOp() { + val pages = listOf( + page(id = 1, lat = 5.0, lon = 5.0, views = 100), + page(id = 2, lat = 0.0, lon = 0.0, views = 9000) + ) + + val sorted = sortNearbyPages(pages, SortMode.DISTANCE, lastKnownLocation = null) + + assertEquals(listOf(1, 2), sorted.map { it.pageId }) + } + + @Test + fun popularitySort_zeroViewsSortLast() { + val pages = listOf( + page(id = 1, lat = 0.0, lon = 0.0, views = 0), + page(id = 2, lat = 0.0, lon = 0.0, views = 1), + page(id = 3, lat = 0.0, lon = 0.0, views = 0) + ) + + val sorted = sortNearbyPages(pages, SortMode.POPULARITY, lastKnownLocation = null) + + assertEquals(2, sorted.first().pageId) + } + + private fun page(id: Int, lat: Double, lon: Double, views: Long) = NearbyPage( + pageId = id, + pageTitle = PageTitle("Page $id", WIKI), + latitude = lat, + longitude = lon, + pageViews = views + ) + + private fun location(lat: Double, lon: Double) = Location("test").apply { + latitude = lat + longitude = lon + } + + companion object { + private val WIKI = WikiSite.forLanguageCode("en") + } +}