Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,25 @@ object PhotonSearchProvider : PhotonSearch {
) ?: return null
return adapter.fromJson(json)
}

override suspend fun getNearbyByTag(
latitude: Double,
longitude: Double,
osmTags: List<String>,
radius: Double?,
limit: UInt?,
nameFilter: String?,
language: String?,
): FeatureCollection? {
val json = client.nearbyByTagJson(
latitude = latitude,
longitude = longitude,
osmTags = osmTags,
radius = radius,
limit = limit,
nameFilter = nameFilter,
language = language,
) ?: return null
return adapter.fromJson(json)
}
}
32 changes: 31 additions & 1 deletion app/src/test/java/org/scottishtecharmy/soundscape/SearchTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import org.scottishtecharmy.soundscape.geoengine.mvttranslation.MvtFeature
import org.scottishtecharmy.soundscape.geoengine.mvttranslation.Way
import org.scottishtecharmy.soundscape.geoengine.mvttranslation.WayEnd
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.OfflineGeocoder
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.OsmTag
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.SearchCategory
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.StreetDescription
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.TileSearch
import org.scottishtecharmy.soundscape.geoengine.utils.searchFeaturesByName
Expand All @@ -20,6 +22,7 @@ import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
import org.scottishtecharmy.soundscape.geojsonparser.geojson.Point
import org.scottishtecharmy.soundscape.utils.process
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class SearchTest {

Expand Down Expand Up @@ -615,4 +618,31 @@ class SearchTest {
assertEquals("21", result?.second)
}
}
}

@Test
fun categorySearch() {
runBlocking {
// Milngavie town centre, with several pharmacies within a few hundred metres, none of
// which would be found by searching for "pharmacy" as a name
val currentLocation = LngLatAlt(-4.3159285, 55.9420645)
val gridState = getGridStateForLocation(currentLocation, MAX_ZOOM_LEVEL, GRID_SIZE)
val settlementState = getGridStateForLocation(currentLocation, 12, 3)
val tileSearch = TileSearch(offlineExtractPath, gridState, settlementState)
val offlineGeocoder = OfflineGeocoder(gridState, settlementState, tileSearch)
val pharmacy = SearchCategory("pharmacy", listOf(OsmTag(null, "pharmacy"), OsmTag("shop", "chemist")))

val results = offlineGeocoder.searchByCategory(pharmacy, null, currentLocation, null)
for (result in results)
println("${result.name} ${gridState.ruler.distance(currentLocation, result.location)}")
assertEquals(10, results.size)
assertTrue(results.any { it.name == "Boots" })
val distances = results.map { gridState.ruler.distance(currentLocation, it.location) }
assertEquals(distances.sorted(), distances)
assertTrue(distances[0] < 100.0)

val boots = offlineGeocoder.searchByCategory(pharmacy, "boots", currentLocation, null)
assertTrue(boots.isNotEmpty())
assertTrue(boots.all { it.name == "Boots" })
}
}
}
78 changes: 78 additions & 0 deletions shared/src/commonMain/composeResources/values/strings.xml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import org.scottishtecharmy.soundscape.geoengine.utils.SuperCategoryId
import org.scottishtecharmy.soundscape.geoengine.utils.extrapolatePositionForward
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.MultiGeocoder
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.PhotonGeocoder
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.SearchCategories
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.SoundscapeGeocoder
import org.scottishtecharmy.soundscape.geoengine.utils.geocoders.TileSearch
import org.scottishtecharmy.soundscape.geoengine.utils.rulers.CheapRuler
Expand Down Expand Up @@ -386,7 +387,8 @@ class GeoEngine {
PreferenceDefaults.POI_RANK_STRATEGY
)
)
}
},
categoryMatcher = { SearchCategories.matcher() }
)
geocoder = multiGeocoder
locationProvider = newLocationProvider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,9 @@ class ResourceMapper {

fun hasResource(key: String?): Boolean = getStringResource(key) != null

/** Every OSM value which has a translated name, with that name. */
fun entries(): Map<String, StringResource> = resourceMap

fun getUnfoundKeys(): Set<String> {
return unfoundKeys
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.scottishtecharmy.soundscape.geoengine.utils.geocoders

import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.scottishtecharmy.soundscape.components.LocationSource
import org.scottishtecharmy.soundscape.geoengine.GridState
import org.scottishtecharmy.soundscape.geoengine.UserGeometry
Expand All @@ -19,13 +21,18 @@ class MultiGeocoder(
val gridState: GridState,
settlementState: GridState,
tileSearch: TileSearcher?,
photonGeocoder: PhotonGeocoder,
private val photonGeocoder: PhotonGeocoder,
platformGeocoder: SoundscapeGeocoder? = null,
analyticsLogger: (String) -> Unit = {},
private val processor: (LocationDescription) -> Unit = {},
private val hasNetwork: () -> Boolean = { false },
private val geocoderMode: () -> String? = { null },
poiStrategy: () -> PoiRankStrategy = { PoiRankStrategy.default },
/**
* Recognises a search for a type of place - "pharmacy" - rather than for a name. Null when
* there's nothing to recognise it with, and every search is then for a name.
*/
private val categoryMatcher: suspend () -> SearchCategoryMatcher? = { null },
) : SoundscapeGeocoder() {

private val fusedGeocoder = FusedGeocoder(gridState, photonGeocoder, platformGeocoder)
Expand Down Expand Up @@ -80,20 +87,60 @@ class MultiGeocoder(
}
}

val geocoderResults = pickGeocoder()?.getAddressFromLocationName(
locationName,
nearbyLocation,
localizedStrings
)
if (geocoderResults != null) {
for (result in geocoderResults) {
results.add(result)
// A search for a type of place is also searched for as a name, as there may be a place
// called that - a bar called "The Pharmacy" - but the places of that type come first
val categoryMatch = try {
categoryMatcher()?.match(locationName)
} catch (e: Exception) {
null
}
val geocoder = pickGeocoder()
val (categoryResults, geocoderResults) = coroutineScope {
val categorySearch = categoryMatch?.let { match ->
async {
searchByCategory(match.category, match.remainder, nearbyLocation, localizedStrings)
}
}
val nameSearch = async {
geocoder?.getAddressFromLocationName(
locationName,
nearbyLocation,
localizedStrings
)
}
Pair(categorySearch?.await().orEmpty(), nameSearch.await().orEmpty())
}

results.addAll(categoryResults)
for (result in geocoderResults) {
val isDuplicate = categoryResults.any {
(it.name == result.name) &&
(gridState.ruler.distance(it.location, result.location) < 100.0)
}
if (!isDuplicate) results.add(result)
}

return results
}

/**
* Photon searches for a type of place worldwide, so it's used whenever it would be for a name,
* and the offline maps when it isn't or can't be reached.
*/
override suspend fun searchByCategory(
category: SearchCategory,
name: String?,
nearbyLocation: LngLatAlt,
localizedStrings: LocalizedStrings?
): List<LocationDescription>? {
if (pickGeocoder() == fusedGeocoder) {
val photonResults =
photonGeocoder.searchByCategory(category, name, nearbyLocation, localizedStrings)
if (photonResults != null) return photonResults
}
return offlineGeocoder.searchByCategory(category, name, nearbyLocation, localizedStrings)
}

override suspend fun getAddressFromLngLat(
userGeometry: UserGeometry,
localizedStrings: LocalizedStrings?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import org.scottishtecharmy.soundscape.i18n.StringKey
import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription
import org.scottishtecharmy.soundscape.utils.addressCountryCode
import org.scottishtecharmy.soundscape.utils.deferredToLocationDescription
import org.scottishtecharmy.soundscape.utils.fuzzyCompare

/**
* The OfflineGeocoder class abstracts away the use of map tile data on the phone for geocoding and
Expand Down Expand Up @@ -116,6 +117,45 @@ class OfflineGeocoder(
return tileSearch?.search(nearbyLocation, locationName, localizedStrings, settlementNames)
}

override suspend fun searchByCategory(
category: SearchCategory,
name: String?,
nearbyLocation: LngLatAlt,
localizedStrings: LocalizedStrings?
): List<LocationDescription> {
analyticsLogger("offlineCategorySearch")

val values = category.values
val tileResults = tileSearch?.searchByCategory(
nearbyLocation,
values,
name,
localizedStrings,
CATEGORY_SEARCH_LIMIT
)
if (!tileResults.isNullOrEmpty()) return tileResults

// With no offline maps to search there's still the grid that's loaded around the user
val nameNeedle = name?.let { normalizeForSearch(it) }
return withContext(gridState.treeContext) {
gridState.getFeatureTree(TreeId.POIS).getNearestCollection(
nearbyLocation,
CATEGORY_SEARCH_GRID_DISTANCE,
CATEGORY_SEARCH_LIMIT,
gridState.ruler
) { feature ->
val mvt = feature as MvtFeature
((mvt.featureSubClass in values) || (mvt.featureClass in values)) &&
((nameNeedle == null) || (mvt.name?.let {
normalizeForSearch(it).fuzzyCompare(nameNeedle, true) < 0.25
} == true))
}.features.map { feature ->
(feature as MvtFeature).deferredToLocationDescription(LocationSource.OfflineGeocoder)
.also(processor)
}
}
}

private fun getNearestPointOnFeature(
feature: Feature,
location: LngLatAlt
Expand Down Expand Up @@ -471,5 +511,10 @@ class OfflineGeocoder(

companion object {
const val TAG = "OfflineGeocoder"

private const val CATEGORY_SEARCH_LIMIT = 10

// The loaded grid only reaches a little over a km from the user anyway
private const val CATEGORY_SEARCH_GRID_DISTANCE = 2000.0
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,51 @@ class PhotonGeocoder(

if (searchResult == null) return null

return deduplicate(searchResult.features, nearbyLocation).map { feature ->
feature.toPhotonLocationDescription(localizedStrings).also(processor)
}
}

override suspend fun searchByCategory(
category: SearchCategory,
name: String?,
nearbyLocation: LngLatAlt,
localizedStrings: LocalizedStrings?
): List<LocationDescription>? {
// A value-only tag - ":school" - would also find every building=school, which is the
// same school again
val osmTags = category.tags.map { it.toPhoton() } + "!building"
var features: List<Feature>? = null
// Photon only looks 1km away by default, which in a town is plenty but in the country can
// find nothing, so look further if there's not much nearby
for (radius in CATEGORY_SEARCH_RADII_KM) {
val searchResult = try {
photonSearch.getNearbyByTag(
latitude = nearbyLocation.latitude,
longitude = nearbyLocation.longitude,
osmTags = osmTags,
radius = radius,
limit = CATEGORY_SEARCH_LIMIT,
nameFilter = name,
language = languageProvider(),
)
} catch (e: Exception) {
null
} ?: return null
features = searchResult.features
if (searchResult.features.size >= CATEGORY_SEARCH_ENOUGH_RESULTS) break
}
analyticsLogger("photonCategorySearch")

return deduplicate(features.orEmpty(), nearbyLocation).map { feature ->
feature.toPhotonLocationDescription(localizedStrings).also(processor)
}
}

/** [features] without those with the same name as one nearby, which are the same place */
private fun deduplicate(features: List<Feature>, nearbyLocation: LngLatAlt): List<Feature> {
val ruler = CheapRuler(nearbyLocation.latitude)
val deduplicate = searchResult.features
return features
.fold(mutableListOf<Feature>()) { accumulator, result ->
val point = (result.geometry as? Point)
var isDuplicate = false
Expand All @@ -60,10 +103,6 @@ class PhotonGeocoder(
}
accumulator
}

return deduplicate.map { feature ->
feature.toPhotonLocationDescription(localizedStrings).also(processor)
}
}

/**
Expand Down Expand Up @@ -109,4 +148,10 @@ class PhotonGeocoder(
feature.toPhotonLocationDescription(localizedStrings).also(processor)
}
}

companion object {
private val CATEGORY_SEARCH_RADII_KM = listOf(5.0, 25.0)
private const val CATEGORY_SEARCH_LIMIT = 10U
private const val CATEGORY_SEARCH_ENOUGH_RESULTS = 3
}
}
Loading
Loading