From a16f6389f61009d699f51c114a9183143790efa1 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Mon, 28 Sep 2026 13:33:45 +0100 Subject: [PATCH] Search for places by type as well as by name A search which names a type of place - "pharmacy", "chemist", "Boots pharmacy", "pharmacie" - now finds the nearest places of that type, whatever they're called, ahead of the usual name search. The type is recognised from the translated osm_* name of every ResourceMapper value, plus new translatable search_synonyms_* strings for commonly searched types. A phrase matches as a whole, or by whole words from its start or end ("butcher" is the "Butcher Shop"); a word which is part of too many phrases ("shop") isn't a type. Photon is searched with /reverse filtered by osm_tag, which is sorted by distance - /api matches its query against names even when filtered by tag. Offline, TileSearch spirals out through the poi layer matching class/subclass, falling back to the loaded grid when there are no extracts. The reader opening, tile spiral and settlement lookup are factored out of TileSearch.search() to share them, which also fixes the hamlet lookup measuring from the user rather than the result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THRgoVbh1G3pVacd7csDqJ --- .../soundscape/network/SearchProvider.kt | 21 ++ .../scottishtecharmy/soundscape/SearchTest.kt | 32 +- .../composeResources/values/strings.xml | 78 +++++ .../soundscape/geoengine/GeoEngine.kt | 4 +- .../geoengine/utils/ResourceMapper.kt | 3 + .../utils/geocoders/MultiGeocoder.kt | 65 +++- .../utils/geocoders/OfflineGeocoder.kt | 45 +++ .../utils/geocoders/PhotonGeocoder.kt | 55 +++- .../utils/geocoders/SearchCategories.kt | 283 ++++++++++++++++ .../utils/geocoders/SoundscapeGeocoder.kt | 11 + .../geoengine/utils/geocoders/TileSearch.kt | 303 +++++++++++++----- .../geoengine/utils/geocoders/TileSearcher.kt | 12 + .../soundscape/network/KmpPhotonSearch.kt | 21 ++ .../soundscape/network/PhotonSearch.kt | 14 + .../soundscape/network/PhotonSearchClient.kt | 28 ++ .../geocoders/SearchCategoryMatcherTest.kt | 93 ++++++ .../network/PhotonSearchClientTest.kt | 36 +++ 17 files changed, 1006 insertions(+), 98 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategories.kt create mode 100644 shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategoryMatcherTest.kt diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt b/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt index 3fa2571ae..482fe1914 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/network/SearchProvider.kt @@ -57,4 +57,25 @@ object PhotonSearchProvider : PhotonSearch { ) ?: return null return adapter.fromJson(json) } + + override suspend fun getNearbyByTag( + latitude: Double, + longitude: Double, + osmTags: List, + 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) + } } diff --git a/app/src/test/java/org/scottishtecharmy/soundscape/SearchTest.kt b/app/src/test/java/org/scottishtecharmy/soundscape/SearchTest.kt index 63217321f..ec6ac3d33 100644 --- a/app/src/test/java/org/scottishtecharmy/soundscape/SearchTest.kt +++ b/app/src/test/java/org/scottishtecharmy/soundscape/SearchTest.kt @@ -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 @@ -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 { @@ -615,4 +618,31 @@ class SearchTest { assertEquals("21", result?.second) } } -} \ No newline at end of file + + @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" }) + } + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index defda02d1..60a6925cb 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -3133,4 +3133,82 @@ Please continue to report any problems, however small, through Contact Support i Your data couldn't be imported. Your previous version of Soundscape still has it — please contact support from Settings, Help. Continue + + pharmacy, chemist, drugstore + + cafe, coffee shop, coffee + + restaurant, place to eat + + fast food, takeaway, takeout + + pub, bar + + toilet, toilets, restroom, bathroom, loo, public toilet + + ATM, cash machine, cashpoint + + bank + + supermarket, grocery store, grocer + + convenience store, corner shop + + bus stop + + train station, railway station, station + + post office + + post box, postbox, mailbox, letter box + + doctor, doctors, GP, surgery, medical centre, clinic + + dentist + + hospital, emergency room + + library + + police, police station + + petrol station, gas station, filling station + + car park, parking lot, parking + + hotel, accommodation + + park + + playground + + place of worship, church, mosque, synagogue, temple + + school + + cinema, movie theater + + bakery, baker + + hairdresser, barber, hair salon + + bench, seat + + drinking water, water fountain + + taxi, taxi rank, taxi stand + + museum + + gym, fitness centre + + swimming pool + + vet, veterinary + + bike shop, bicycle shop, cycle shop + + books, bookshop, bookstore, book shop + + phone shop, mobile phone shop diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt index 00b8ef4bc..bcce27e90 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt @@ -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 @@ -386,7 +387,8 @@ class GeoEngine { PreferenceDefaults.POI_RANK_STRATEGY ) ) - } + }, + categoryMatcher = { SearchCategories.matcher() } ) geocoder = multiGeocoder locationProvider = newLocationProvider diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/ResourceMapper.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/ResourceMapper.kt index 8a4eae4f8..862456969 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/ResourceMapper.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/ResourceMapper.kt @@ -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 = resourceMap + fun getUnfoundKeys(): Set { return unfoundKeys } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/MultiGeocoder.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/MultiGeocoder.kt index fc2b25d34..01be09274 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/MultiGeocoder.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/MultiGeocoder.kt @@ -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 @@ -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) @@ -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? { + 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?, diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/OfflineGeocoder.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/OfflineGeocoder.kt index 59808b821..9c2292b7b 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/OfflineGeocoder.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/OfflineGeocoder.kt @@ -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 @@ -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 { + 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 @@ -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 } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/PhotonGeocoder.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/PhotonGeocoder.kt index 2960ac6c5..6f448e276 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/PhotonGeocoder.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/PhotonGeocoder.kt @@ -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? { + // 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? = 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, nearbyLocation: LngLatAlt): List { val ruler = CheapRuler(nearbyLocation.latitude) - val deduplicate = searchResult.features + return features .fold(mutableListOf()) { accumulator, result -> val point = (result.geometry as? Point) var isDuplicate = false @@ -60,10 +103,6 @@ class PhotonGeocoder( } accumulator } - - return deduplicate.map { feature -> - feature.toPhotonLocationDescription(localizedStrings).also(processor) - } } /** @@ -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 + } } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategories.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategories.kt new file mode 100644 index 000000000..96a97e6bd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategories.kt @@ -0,0 +1,283 @@ +package org.scottishtecharmy.soundscape.geoengine.utils.geocoders + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import org.scottishtecharmy.soundscape.geoengine.utils.ResourceMapper +import org.scottishtecharmy.soundscape.resources.Res +import org.scottishtecharmy.soundscape.resources.search_synonyms_atm +import org.scottishtecharmy.soundscape.resources.search_synonyms_bakery +import org.scottishtecharmy.soundscape.resources.search_synonyms_bank +import org.scottishtecharmy.soundscape.resources.search_synonyms_bench +import org.scottishtecharmy.soundscape.resources.search_synonyms_bicycle_shop +import org.scottishtecharmy.soundscape.resources.search_synonyms_book_shop +import org.scottishtecharmy.soundscape.resources.search_synonyms_bus_stop +import org.scottishtecharmy.soundscape.resources.search_synonyms_cafe +import org.scottishtecharmy.soundscape.resources.search_synonyms_cinema +import org.scottishtecharmy.soundscape.resources.search_synonyms_convenience +import org.scottishtecharmy.soundscape.resources.search_synonyms_dentist +import org.scottishtecharmy.soundscape.resources.search_synonyms_doctors +import org.scottishtecharmy.soundscape.resources.search_synonyms_drinking_water +import org.scottishtecharmy.soundscape.resources.search_synonyms_fast_food +import org.scottishtecharmy.soundscape.resources.search_synonyms_fuel +import org.scottishtecharmy.soundscape.resources.search_synonyms_gym +import org.scottishtecharmy.soundscape.resources.search_synonyms_hairdresser +import org.scottishtecharmy.soundscape.resources.search_synonyms_hospital +import org.scottishtecharmy.soundscape.resources.search_synonyms_hotel +import org.scottishtecharmy.soundscape.resources.search_synonyms_library +import org.scottishtecharmy.soundscape.resources.search_synonyms_museum +import org.scottishtecharmy.soundscape.resources.search_synonyms_park +import org.scottishtecharmy.soundscape.resources.search_synonyms_parking +import org.scottishtecharmy.soundscape.resources.search_synonyms_pharmacy +import org.scottishtecharmy.soundscape.resources.search_synonyms_phone_shop +import org.scottishtecharmy.soundscape.resources.search_synonyms_place_of_worship +import org.scottishtecharmy.soundscape.resources.search_synonyms_playground +import org.scottishtecharmy.soundscape.resources.search_synonyms_police +import org.scottishtecharmy.soundscape.resources.search_synonyms_post_box +import org.scottishtecharmy.soundscape.resources.search_synonyms_post_office +import org.scottishtecharmy.soundscape.resources.search_synonyms_pub +import org.scottishtecharmy.soundscape.resources.search_synonyms_restaurant +import org.scottishtecharmy.soundscape.resources.search_synonyms_school +import org.scottishtecharmy.soundscape.resources.search_synonyms_supermarket +import org.scottishtecharmy.soundscape.resources.search_synonyms_swimming_pool +import org.scottishtecharmy.soundscape.resources.search_synonyms_taxi +import org.scottishtecharmy.soundscape.resources.search_synonyms_toilets +import org.scottishtecharmy.soundscape.resources.search_synonyms_train_station +import org.scottishtecharmy.soundscape.resources.search_synonyms_veterinary +import org.scottishtecharmy.soundscape.screens.onboarding.language.getAppLocale +import org.scottishtecharmy.soundscape.screens.onboarding.language.getSystemLocale +import org.scottishtecharmy.soundscape.utils.fuzzyCompare + +/** + * An OSM tag in Photon's osm_tag form. A null [key] matches [value] under any key, which is what + * lets a [ResourceMapper] value - "pharmacy", with no idea whether it came from amenity= or shop= - + * be searched for as it is. + */ +data class OsmTag(val key: String?, val value: String) { + fun toPhoton() = "${key ?: ""}:$value" +} + +/** A type of place that can be searched for - every OSM tag which means that type of place. */ +data class SearchCategory(val id: String, val tags: List) { + /** The OSM values, which is all the offline tiles' class and subclass have to match against */ + val values: Set get() = tags.map { it.value }.toSet() +} + +/** + * A search which names a [category] of place, and possibly also a name for it to have - the + * "boots" of "boots pharmacy" - as the [remainder]. + */ +data class CategoryMatch(val category: SearchCategory, val remainder: String?) + +/** + * Finds the [SearchCategory] a search is for - "pharmacy", "chemist", "pharmacie" - from the + * phrases which name each category in the current language. + */ +class SearchCategoryMatcher(phrases: List>) { + + private class Phrase(val text: String, val words: List, val category: SearchCategory) + + private val phrases = phrases + .map { (phrase, category) -> + val text = normalizeForSearch(phrase) + Phrase(text, text.split(" ").filter { it.isNotEmpty() }, category) + } + .filter { it.text.length >= MIN_PHRASE_LENGTH } + + /** + * The category which [query] names, as a whole or with a name before or after it. The whole + * query is tried first, and then the longest run of words from its end and then its start, so + * that "boots pharmacy" and "pharmacy boots" are both a pharmacy called "boots". + */ + fun match(query: String): CategoryMatch? { + val words = normalizeForSearch(query).split(" ").filter { it.isNotEmpty() } + for (length in words.size downTo 1) { + val starts = if (length == words.size) listOf(0) else listOf(words.size - length, 0) + for (start in starts) { + val span = words.subList(start, start + length).joinToString(" ") + val category = categoryFor(span) ?: continue + val remainder = (words.subList(0, start) + words.subList(start + length, words.size)) + .joinToString(" ") + return CategoryMatch(category, remainder.ifEmpty { null }) + } + } + return null + } + + /** + * The category whose phrase is closest to [span], if any is close enough. Where several + * categories share the best phrase - French names amenity=pharmacy and shop=chemist both + * "Pharmacie" - they're all searched for. + * + * A phrase matches as a whole or, failing that, by whole words from its start or its end: + * "butcher" is the "Butcher Shop", and "bicicletas" the "Tienda de bicicletas". Only whole words + * count, or "bar" would find the "Barber Shop". A span which matches part of too many phrases - + * "shop" - is too vague to be a category at all. + */ + private fun categoryFor(span: String): SearchCategory? { + if (span.length < MIN_PHRASE_LENGTH) return null + + val whole = bestCategories(phrases.asSequence().map { span.fuzzyCompare(it.text, false) to it.category }) + if (whole.isNotEmpty()) return combine(whole) + + val spanWordCount = span.count { it == ' ' } + 1 + val partial = bestCategories( + phrases.asSequence() + .filter { it.words.size > spanWordCount } + .flatMap { phrase -> + sequenceOf( + phrase.words.take(spanWordCount), + phrase.words.takeLast(spanWordCount) + ).map { words -> span.fuzzyCompare(words.joinToString(" "), false) to phrase.category } + } + ) + if (partial.size > MAX_PARTIAL_MATCH_CATEGORIES) return null + return combine(partial) + } + + /** The categories which share the best of [scores], if it's close enough to be a match */ + private fun bestCategories(scores: Sequence>): List { + var bestScore = MATCH_THRESHOLD + val best = mutableListOf() + for ((score, category) in scores) { + if (score < bestScore) { + bestScore = score + best.clear() + best.add(category) + } else if ((score == bestScore) && (category !in best)) { + best.add(category) + } + } + return best + } + + private fun combine(categories: List): SearchCategory? = when (categories.size) { + 0 -> null + 1 -> categories[0] + else -> SearchCategory( + categories.joinToString("+") { it.id }, + categories.flatMap { it.tags }.distinct() + ) + } + + companion object { + private const val MIN_PHRASE_LENGTH = 3 + private const val MATCH_THRESHOLD = 0.2 + + // The most categories a match on part of a phrase can be for before it's too vague to use + private const val MAX_PARTIAL_MATCH_CATEGORIES = 3 + } +} + +/** + * Builds the [SearchCategoryMatcher] for the app's current language from two sources: the + * translated name of every OSM value in [ResourceMapper], and the translated synonym lists for the + * most commonly searched for types of place. + */ +object SearchCategories { + + /** + * Values which aren't a type of place anyone would search for, or which there are far too many + * of for a search to be any use - roads, settlements and the like. + */ + private val excludedValues = setOf( + "highway", "intersection", "roundabout", "highway_ramp", "merging_lane", "entrance", + "crossing", "unmanaged_crossing", "construction", "walking_path", "pedestrian_street", + "bicycle_path", "residential_street", "service_road", "rail", "railway", "transit", + "service", "road", "primary", "secondary", "tertiary", "minor", "motorway", "trunk", "path", + "raceway", "busway", "bus_guideway", "ferry", "motorway_construction", "trunk_construction", + "primary_construction", "secondary_construction", "tertiary_construction", + "minor_construction", "path_construction", "service_construction", "track_construction", + "raceway_construction", "city", "town", "village", "hamlet", "quarter", "locality", + "neighbourhood", "yes", "no", "unclassified", "building", "sport", + ) + + /** + * The types of place with their own list of synonyms, and the OSM tags each one means. These + * take several tags where OSM has more than one way of tagging the same kind of place. + */ + private val synonyms: List>> = listOf( + Triple("pharmacy", Res.string.search_synonyms_pharmacy, tags(":pharmacy", "shop:chemist", "healthcare:pharmacy")), + Triple("cafe", Res.string.search_synonyms_cafe, tags(":cafe")), + Triple("restaurant", Res.string.search_synonyms_restaurant, tags(":restaurant")), + Triple("fast_food", Res.string.search_synonyms_fast_food, tags(":fast_food")), + Triple("pub", Res.string.search_synonyms_pub, tags(":pub", ":bar")), + Triple("toilets", Res.string.search_synonyms_toilets, tags(":toilets")), + Triple("atm", Res.string.search_synonyms_atm, tags(":atm")), + Triple("bank", Res.string.search_synonyms_bank, tags(":bank")), + Triple("supermarket", Res.string.search_synonyms_supermarket, tags(":supermarket", ":greengrocer")), + Triple("convenience", Res.string.search_synonyms_convenience, tags(":convenience")), + Triple("bus_stop", Res.string.search_synonyms_bus_stop, tags(":bus_stop")), + Triple("train_station", Res.string.search_synonyms_train_station, tags("railway:station", "railway:halt")), + Triple("post_office", Res.string.search_synonyms_post_office, tags(":post_office")), + Triple("post_box", Res.string.search_synonyms_post_box, tags(":post_box")), + Triple("doctors", Res.string.search_synonyms_doctors, tags(":doctors", ":clinic", "healthcare:doctor")), + Triple("dentist", Res.string.search_synonyms_dentist, tags(":dentist")), + Triple("hospital", Res.string.search_synonyms_hospital, tags(":hospital")), + Triple("library", Res.string.search_synonyms_library, tags(":library")), + Triple("police", Res.string.search_synonyms_police, tags(":police")), + Triple("fuel", Res.string.search_synonyms_fuel, tags(":fuel")), + Triple("parking", Res.string.search_synonyms_parking, tags("amenity:parking")), + Triple("hotel", Res.string.search_synonyms_hotel, tags(":hotel", ":guest_house", ":hostel")), + Triple("park", Res.string.search_synonyms_park, tags("leisure:park")), + Triple("playground", Res.string.search_synonyms_playground, tags(":playground")), + Triple("place_of_worship", Res.string.search_synonyms_place_of_worship, tags(":place_of_worship")), + Triple("school", Res.string.search_synonyms_school, tags("amenity:school")), + Triple("cinema", Res.string.search_synonyms_cinema, tags(":cinema")), + Triple("bakery", Res.string.search_synonyms_bakery, tags(":bakery")), + Triple("hairdresser", Res.string.search_synonyms_hairdresser, tags(":hairdresser")), + Triple("bench", Res.string.search_synonyms_bench, tags(":bench")), + Triple("drinking_water", Res.string.search_synonyms_drinking_water, tags(":drinking_water")), + Triple("taxi", Res.string.search_synonyms_taxi, tags(":taxi")), + Triple("museum", Res.string.search_synonyms_museum, tags(":museum")), + Triple("gym", Res.string.search_synonyms_gym, tags(":fitness_centre")), + Triple("swimming_pool", Res.string.search_synonyms_swimming_pool, tags(":swimming_pool", "sport:swimming")), + Triple("veterinary", Res.string.search_synonyms_veterinary, tags(":veterinary")), + Triple("bicycle_shop", Res.string.search_synonyms_bicycle_shop, tags("shop:bicycle")), + Triple("book_shop", Res.string.search_synonyms_book_shop, tags("shop:books")), + Triple("phone_shop", Res.string.search_synonyms_phone_shop, tags("shop:mobile_phone", "shop:telecommunication")), + ) + + private fun tags(vararg photonTags: String) = photonTags.map { + val key = it.substringBefore(':') + OsmTag(key.ifEmpty { null }, it.substringAfter(':')) + } + + private val mutex = Mutex() + private var cachedLanguage: String? = null + private var cachedMatcher: SearchCategoryMatcher? = null + + /** The matcher for the app's current language, built the first time it's asked for */ + suspend fun matcher(): SearchCategoryMatcher = mutex.withLock { + val language = (getAppLocale() ?: getSystemLocale()).let { "${it.language}-${it.region}" } + cachedMatcher?.takeIf { cachedLanguage == language } + ?: SearchCategoryMatcher(buildPhrases()).also { + cachedMatcher = it + cachedLanguage = language + } + } + + private suspend fun buildPhrases(): List> { + val phrases = mutableListOf>() + + // Several values can share a name - "lift" and "elevator" - so there's one category per + // name, which searches for all of them + ResourceMapper.entries() + .filterKeys { it !in excludedValues } + .entries + .groupBy({ it.value }, { it.key }) + .forEach { (resource, values) -> + val category = SearchCategory(resource.key, values.sorted().map { OsmTag(null, it) }) + phrases.add(getString(resource) to category) + } + + for ((id, resource, tags) in synonyms) { + val category = SearchCategory(id, tags) + for (phrase in getString(resource).split(',', '،', '、', ',', ';')) { + if (phrase.isNotBlank()) phrases.add(phrase.trim() to category) + } + } + return phrases + } +} diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SoundscapeGeocoder.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SoundscapeGeocoder.kt index 7ad7ff02a..038039a23 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SoundscapeGeocoder.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SoundscapeGeocoder.kt @@ -19,6 +19,17 @@ open class SoundscapeGeocoder { localizedStrings: LocalizedStrings? ): List? = null + /** + * The places nearest to [nearbyLocation] of [category] - pharmacies, say - nearest first. When + * [name] isn't null, only those which it matches the name of. + */ + open suspend fun searchByCategory( + category: SearchCategory, + name: String?, + nearbyLocation: LngLatAlt, + localizedStrings: LocalizedStrings? + ): List? = null + open suspend fun getAddressFromLngLat( userGeometry: UserGeometry, localizedStrings: LocalizedStrings?, diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearch.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearch.kt index d5883a6c2..1a52490a3 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearch.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearch.kt @@ -18,6 +18,7 @@ import org.scottishtecharmy.soundscape.geoengine.mvttranslation.translatePropert import org.scottishtecharmy.soundscape.geoengine.utils.address.JapaneseAddress import org.scottishtecharmy.soundscape.geoengine.utils.decompressTile import org.scottishtecharmy.soundscape.geoengine.utils.getCentroidOfPolygon +import org.scottishtecharmy.soundscape.geoengine.utils.getLatLonTileWithOffset import org.scottishtecharmy.soundscape.geoengine.utils.getXYTile import org.scottishtecharmy.soundscape.geoengine.utils.pmtiles.PmTilesReader import org.scottishtecharmy.soundscape.geoengine.utils.rulers.CheapRuler @@ -30,6 +31,7 @@ import org.scottishtecharmy.soundscape.utils.findExtractPaths import org.scottishtecharmy.soundscape.utils.fuzzyCompare import org.scottishtecharmy.soundscape.utils.toLocationDescription import vector_tile.Tile +import kotlin.math.abs class TileSearch( val offlineExtractPath: String, @@ -478,6 +480,59 @@ class TileSearch( } } + /** + * The name of the settlement [location] is in, if it's within the settlement grid. Must be + * called within the grid's treeContext. + */ + private fun nearestSettlementName(location: LngLatAlt): String? { + if (!settlementGrid.isLocationWithinGrid(location)) return null + + // Get the nearest settlements. Nominatim uses the following proximities, + // so we do the same: + // + // cities, municipalities, islands | 15 km + // towns, boroughs | 4 km + // villages, suburbs | 2 km + // hamlets, farms, neighbourhoods | 1 km + // + var nearestDistrict: MvtFeature? + nearestDistrict = settlementGrid.getFeatureTree(TreeId.SETTLEMENT_HAMLET) + .getNearestFeature( + location, + settlementGrid.ruler, + 1000.0 + ) as MvtFeature? + if (nearestDistrict?.name == null) { + nearestDistrict = + settlementGrid.getFeatureTree(TreeId.SETTLEMENT_VILLAGE) + .getNearestFeature( + location, + settlementGrid.ruler, + 2000.0 + ) as MvtFeature? + if (nearestDistrict?.name == null) { + nearestDistrict = + settlementGrid.getFeatureTree(TreeId.SETTLEMENT_TOWN) + .getNearestFeature( + location, + settlementGrid.ruler, + 4000.0 + ) as MvtFeature? + if (nearestDistrict?.name == null) { + nearestDistrict = + settlementGrid.getFeatureTree(TreeId.SETTLEMENT_CITY) + .getNearestFeature( + location, + settlementGrid.ruler, + 15000.0 + ) as MvtFeature? + } + } + } + val settlement = nearestDistrict ?: return null + return if (settlement.name != null) settlement.displayName else null + } + private class FeatureLocation(val location: LngLatAlt, val distance: Double) /** @@ -549,13 +604,11 @@ class TileSearch( } @OptIn(ExperimentalCoroutinesApi::class) - override fun search( - location: LngLatAlt, - searchString: String, - localizedStrings: LocalizedStrings?, - settlementNames: Set - ): List { - val tileLocation = getXYTile(location, MAX_ZOOM_LEVEL) + /** + * A reader for the offline extract which has the tile at [tileLocation] - or failing that, the + * last extract which could be read - or null if there are none. The caller closes it. + */ + private fun openReader(tileLocation: Pair): PmTilesReader? { val extracts = findExtractPaths(offlineExtractPath).toMutableList() var reader: PmTilesReader? = null for (extract in extracts) { @@ -573,10 +626,16 @@ class TileSearch( reader = null } } + return reader + } - // We now have a PM tile reader - var x = tileLocation.first - var y = tileLocation.second + /** + * The tiles in a square spiral out from [centreX], [centreY], [MAX_SEARCH_RADIUS] tiles out, + * starting with the tile itself. + */ + private fun spiralTiles(centreX: Int, centreY: Int): Sequence> = sequence { + var x = centreX + var y = centreY var dx = 1 // Change in x per step var dy = 0 // Change in y per step @@ -585,9 +644,42 @@ class TileSearch( var turnCount = 0 var stepsTaken = 0 - // Set a limit to how far out you want to spiral - val maxSearchRadius = 10 - val maxTurns = maxSearchRadius * 2 + val maxTurns = MAX_SEARCH_RADIUS * 2 + while (turnCount < maxTurns) { + yield(Pair(x, y)) + + x += dx + y += dy + stepsTaken++ + + // Check if it's time to turn + if (stepsTaken == steps) { + stepsTaken = 0 + turnCount++ + + // Rotate direction: (1,0) -> (0,1) -> (-1,0) -> (0,-1) + val temp = dx + dx = -dy + dy = temp + + // After every two turns, increase the number of steps + if (turnCount % 2 == 0) { + steps++ + } + } + } + } + + override fun search( + location: LngLatAlt, + searchString: String, + localizedStrings: LocalizedStrings?, + settlementNames: Set + ): List { + val tileLocation = getXYTile(location, MAX_ZOOM_LEVEL) + val reader = openReader(tileLocation) + + // We now have a PM tile reader // Can we decode this into a street number and a street? A word starting with a digit may be // a house number ("21 Kersland Drive", "Avenida Corrientes 1155"), but it may just as well @@ -628,7 +720,7 @@ class TileSearch( val needleWithoutSettlement = generateWithoutSettlement(normalizedNeedle, settlementNames) val wholeNeedleWithoutSettlement = wholeNeedle?.let { generateWithoutSettlement(it, settlementNames) } val tilesUsed = mutableSetOf() - while (turnCount < maxTurns) { + for ((x, y) in spiralTiles(tileLocation.first, tileLocation.second)) { val tileIndex = cacheIndex(x, y) var cache = stringCache[tileIndex] tilesUsed.add(tileIndex) @@ -696,26 +788,6 @@ class TileSearch( addressMatches.add(AddressMatch(address, block = true)) } } - // --- 2. Move to the next position in the spiral --- - x += dx - y += dy - stepsTaken++ - - // --- 3. Check if it's time to turn --- - if (stepsTaken == steps) { - stepsTaken = 0 - turnCount++ - - // Rotate direction: (1,0) -> (0,1) -> (-1,0) -> (0,-1) - val temp = dx - dx = -dy - dy = temp - - // After every two turns, increase the number of steps - if (turnCount % 2 == 0) { - steps++ - } - } } // Free up any tiles that we no longer use trimCache(tilesUsed) @@ -870,54 +942,7 @@ class TileSearch( } } } - if (settlementGrid.isLocationWithinGrid(result.location)) { - - // Get the nearest settlements. Nominatim uses the following proximities, - // so we do the same: - // - // cities, municipalities, islands | 15 km - // towns, boroughs | 4 km - // villages, suburbs | 2 km - // hamlets, farms, neighbourhoods | 1 km - // - var nearestDistrict: MvtFeature? - nearestDistrict = settlementGrid.getFeatureTree(TreeId.SETTLEMENT_HAMLET) - .getNearestFeature( - location, - settlementGrid.ruler, - 1000.0 - ) as MvtFeature? - if (nearestDistrict?.name == null) { - nearestDistrict = - settlementGrid.getFeatureTree(TreeId.SETTLEMENT_VILLAGE) - .getNearestFeature( - result.location, - settlementGrid.ruler, - 2000.0 - ) as MvtFeature? - if (nearestDistrict?.name == null) { - nearestDistrict = - settlementGrid.getFeatureTree(TreeId.SETTLEMENT_TOWN) - .getNearestFeature( - result.location, - settlementGrid.ruler, - 4000.0 - ) as MvtFeature? - if (nearestDistrict?.name == null) { - nearestDistrict = - settlementGrid.getFeatureTree(TreeId.SETTLEMENT_CITY) - .getNearestFeature( - result.location, - settlementGrid.ruler, - 15000.0 - ) as MvtFeature? - } - } - } - if (nearestDistrict?.name != null) { - mvt.properties?.set("city", nearestDistrict.displayName) - } - } + nearestSettlementName(result.location)?.let { mvt.properties?.set("city", it) } } } Pair(mvt, result) @@ -973,12 +998,126 @@ class TileSearch( } } + /** A place found by [searchByCategory] */ + private class CategoryResult( + val properties: HashMap, + val location: LngLatAlt, + val distance: Double, + ) + + override fun searchByCategory( + location: LngLatAlt, + values: Set, + name: String?, + localizedStrings: LocalizedStrings?, + limit: Int + ): List { + val tileLocation = getXYTile(location, MAX_ZOOM_LEVEL) + val reader = openReader(tileLocation) ?: return emptyList() + val ruler = CheapRuler(location.latitude) + val nameNeedle = name?.let { normalizeForSearch(it) } + + // Every tile in a ring of the spiral is at least this much further away than the ring + // inside it, so once the ring is further than the furthest of the results so far there's + // no point looking any further + val tileWidth = ruler.distance( + getLatLonTileWithOffset(tileLocation.first, tileLocation.second, MAX_ZOOM_LEVEL, 0.0, 0.0), + getLatLonTileWithOffset(tileLocation.first, tileLocation.second, MAX_ZOOM_LEVEL, 1.0, 0.0) + ) + + val results = mutableListOf() + for ((x, y) in spiralTiles(tileLocation.first, tileLocation.second)) { + val ring = maxOf(abs(x - tileLocation.first), abs(y - tileLocation.second)) + if (results.size >= limit) { + val furthest = results.sortedBy { it.distance }[limit - 1].distance + if ((ring - 1) * tileWidth > furthest) break + } + + val tileData = try { reader.getTile(MAX_ZOOM_LEVEL, x, y) } catch (_: Exception) { null } + ?: continue + val tile = decompressTile(reader.tileCompression, tileData) ?: continue + val layer = tile.layers.firstOrNull { it.name == "poi" } ?: continue + + val classKeys = layer.keys.withIndex() + .filter { (_, key) -> (key == "class") || (key == "subclass") } + .map { it.index }.toSet() + val matchingValues = layer.values.withIndex() + .filter { (_, value) -> value.string_value in values } + .map { it.index }.toSet() + if (classKeys.isEmpty() || matchingValues.isEmpty()) continue + val nameKeys = layer.keys.withIndex().filter { (_, key) -> isNameKey(key) }.map { it.index }.toSet() + + for (feature in layer.features) { + val tags = feature.tags + var isMatch = false + var nameMatches = (nameNeedle == null) + for (i in 0 until tags.size - 1 step 2) { + if ((tags[i] in classKeys) && (tags[i + 1] in matchingValues)) isMatch = true + if (!nameMatches && (tags[i] in nameKeys)) { + val featureName = layer.values[tags[i + 1]].string_value ?: continue + nameMatches = normalizeForSearch(featureName) + .fuzzyCompare(nameNeedle!!, true) < NAME_MATCH_THRESHOLD + } + } + if (!isMatch || !nameMatches) continue + + val featureLocation = featureLocation(feature, x, y, location, ruler) ?: continue + val properties = featureProperties(layer, feature) + // A polygon which crosses tiles is found in each of them + if (results.any { + (it.properties == properties) && + (ruler.distance(it.location, featureLocation.location) < 100.0) + } + ) continue + results.add(CategoryResult(properties, featureLocation.location, featureLocation.distance)) + } + } + try { reader.close() } catch (_: Exception) {} + + return results + .sortedBy { it.distance } + .take(limit) + .map { result -> + val mvt = MvtFeature() + mvt.name = result.properties["name"] as? String? + mvt.featureClass = result.properties["class"] as? String? + mvt.featureSubClass = result.properties["subclass"] as? String? + mvt.properties = result.properties + mvt.geometry = Point(result.location) + translateProperties(mvt) + + runBlocking { + withContext(gridState.treeContext) { + if ((mvt.properties?.get("street") == null) && + gridState.isLocationWithinGrid(result.location) + ) { + findNearestNamedWay(result.location, null)?.let { + mvt.properties?.set("street", it.displayName) + } + } + nearestSettlementName(result.location)?.let { mvt.properties?.set("city", it) } + } + } + mvt.toLocationDescription( + LocationSource.OfflineGeocoder, + featureName = mvt.getText(localizedStrings) + ) + } + } + companion object { // Added to the score of a match made by taking a house number out of the search string private const val HOUSE_NUMBER_PENALTY = 0.001 // How many differently named features one match can find in each layer of a tile private const val MAX_FEATURES_PER_LAYER = 4 + + // How many tiles out from the search location's tile a search looks + private const val MAX_SEARCH_RADIUS = 10 + + // How close a name has to be to the name given to a category search - the same threshold + // MultiGeocoder uses for markers + private const val NAME_MATCH_THRESHOLD = 0.25 } } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearcher.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearcher.kt index f3380039b..f51493ad1 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearcher.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/TileSearcher.kt @@ -11,4 +11,16 @@ interface TileSearcher { localizedStrings: LocalizedStrings?, settlementNames: Set ): List + + /** + * The places with a class or subclass in [values] nearest to [location], nearest first. When + * [name] isn't null, only those which it matches the name of. + */ + fun searchByCategory( + location: LngLatAlt, + values: Set, + name: String?, + localizedStrings: LocalizedStrings?, + limit: Int + ): List = emptyList() } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/KmpPhotonSearch.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/KmpPhotonSearch.kt index c4498e034..1e1116926 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/KmpPhotonSearch.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/KmpPhotonSearch.kt @@ -43,4 +43,25 @@ class KmpPhotonSearch( ) ?: return null return GeoJsonParser.parseFeatureCollection(json) } + + override suspend fun getNearbyByTag( + latitude: Double, + longitude: Double, + osmTags: List, + 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 GeoJsonParser.parseFeatureCollection(json) + } } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearch.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearch.kt index 2e26ebe4a..9bb4cd133 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearch.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearch.kt @@ -17,4 +17,18 @@ interface PhotonSearch { longitude: Double? = null, language: String? = null, ): FeatureCollection? + + /** + * The places nearest to [latitude], [longitude] which have any of [osmTags], nearest first - + * see [PhotonSearchClient.nearbyByTagJson]. + */ + suspend fun getNearbyByTag( + latitude: Double, + longitude: Double, + osmTags: List, + radius: Double? = null, + limit: UInt? = null, + nameFilter: String? = null, + language: String? = null, + ): FeatureCollection? = null } diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClient.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClient.kt index 64cef209e..5cdf105d6 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClient.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClient.kt @@ -37,6 +37,34 @@ class PhotonSearchClient( parameter("lang", language) } + /** + * The places nearest to [latitude], [longitude] which have any of [osmTags], nearest first. + * This is a reverse geocode filtered by tag rather than a search: /api/ matches its query + * against names even when it's filtered by tag, so it can't find a pharmacy called "Boots". + * + * Each of [osmTags] is in Photon's osm_tag form - "amenity:pharmacy", ":pharmacy" for the + * value under any key, "!building" to exclude a key - and several are OR'd together, except + * the exclusions. [radius] is in km; Photon's default is only 1km. [nameFilter] restricts + * the results to those whose name matches it. + */ + suspend fun nearbyByTagJson( + latitude: Double, + longitude: Double, + osmTags: List, + radius: Double? = null, + limit: UInt? = null, + nameFilter: String? = null, + language: String? = null, + ): String? = doGet("reverse/") { + parameter("lat", latitude) + parameter("lon", longitude) + for (tag in osmTags) parameter("osm_tag", tag) + parameter("radius", radius) + parameter("limit", limit?.toInt()) + parameter("query_string_filter", nameFilter) + parameter("lang", language) + } + private suspend fun doGet( path: String, block: io.ktor.client.request.HttpRequestBuilder.() -> Unit, diff --git a/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategoryMatcherTest.kt b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategoryMatcherTest.kt new file mode 100644 index 000000000..804015645 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/geoengine/utils/geocoders/SearchCategoryMatcherTest.kt @@ -0,0 +1,93 @@ +package org.scottishtecharmy.soundscape.geoengine.utils.geocoders + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SearchCategoryMatcherTest { + + private val pharmacy = SearchCategory("pharmacy", listOf(OsmTag(null, "pharmacy"), OsmTag("shop", "chemist"))) + private val bar = SearchCategory("bar", listOf(OsmTag(null, "bar"))) + private val barber = SearchCategory("barber", listOf(OsmTag("shop", "hairdresser"))) + private val chemistShop = SearchCategory("chemist", listOf(OsmTag(null, "chemist"))) + private val butcher = SearchCategory("butcher", listOf(OsmTag(null, "butcher"))) + private val bicycle = SearchCategory("bicycle", listOf(OsmTag(null, "bicycle"))) + private val books = SearchCategory("books", listOf(OsmTag(null, "books"))) + private val shoes = SearchCategory("shoes", listOf(OsmTag(null, "shoes"))) + + private val matcher = SearchCategoryMatcher( + listOf( + "pharmacy" to pharmacy, + "chemist" to pharmacy, + "drugstore" to pharmacy, + "Bar" to bar, + "barber" to barber, + "Pharmacie" to pharmacy, + "Pharmacie" to chemistShop, + "Butcher Shop" to butcher, + "Barber Shop" to barber, + "Book Shop" to books, + "Shoe Shop" to shoes, + "Tienda de bicicletas" to bicycle, + ) + ) + + @Test + fun synonymsMatchTheirCategory() { + assertEquals(CategoryMatch(pharmacy, null), matcher.match("pharmacy")) + assertEquals(CategoryMatch(pharmacy, null), matcher.match("chemist")) + assertEquals(CategoryMatch(pharmacy, null), matcher.match("Drugstore")) + } + + @Test + fun smallTyposStillMatch() { + assertEquals(CategoryMatch(pharmacy, null), matcher.match("pharmasy")) + } + + @Test + fun aNameBeforeOrAfterTheCategoryIsTheRemainder() { + assertEquals(CategoryMatch(pharmacy, "boots"), matcher.match("Boots pharmacy")) + assertEquals(CategoryMatch(pharmacy, "boots"), matcher.match("pharmacy Boots")) + } + + @Test + fun theWholePhraseHasToMatch() { + assertEquals(CategoryMatch(bar, null), matcher.match("bar")) + assertEquals(CategoryMatch(barber, null), matcher.match("barber")) + assertNull(matcher.match("barb")) + } + + @Test + fun categoriesSharingAPhraseAreSearchedTogether() { + val match = matcher.match("pharmacie")!! + assertEquals(setOf("pharmacy", "chemist"), match.category.values) + assertNull(match.remainder) + } + + @Test + fun namesAreNotCategories() { + assertNull(matcher.match("Tesco")) + assertNull(matcher.match("")) + } + + @Test + fun theFirstOrLastWordsOfAPhraseMatch() { + assertEquals(CategoryMatch(butcher, null), matcher.match("butcher")) + assertEquals(CategoryMatch(butcher, "smith"), matcher.match("Smith butcher")) + assertEquals(CategoryMatch(bicycle, null), matcher.match("bicicletas")) + assertEquals(CategoryMatch(bicycle, null), matcher.match("tienda de bicicletas")) + } + + @Test + fun aWholePhraseMatchBeatsPartOfOne() { + // "barber" is a phrase of its own as well as the start of "Barber Shop", and "bar" is only + // ever whole words + assertEquals(CategoryMatch(barber, null), matcher.match("barber")) + assertEquals(CategoryMatch(bar, null), matcher.match("bar")) + } + + @Test + fun partOfTooManyPhrasesIsNotACategory() { + assertNull(matcher.match("shop")) + } +} diff --git a/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClientTest.kt b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClientTest.kt index 5c9a5efd0..96b82a94b 100644 --- a/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClientTest.kt +++ b/shared/src/commonTest/kotlin/org/scottishtecharmy/soundscape/network/PhotonSearchClientTest.kt @@ -182,4 +182,40 @@ class PhotonSearchClientTest { assertNull(client.reverseGeocodeJson(latitude = 55.86, longitude = -4.25)) } + + // ----- nearbyByTagJson() ----- + + @Test + fun nearbyByTagJson_sendsEachTagAndTheFilters() = runTest { + val client = clientReturning(HttpStatusCode.OK, "{}") + + client.nearbyByTagJson( + latitude = 55.94, + longitude = -4.31, + osmTags = listOf(":pharmacy", "shop:chemist", "!building"), + radius = 5.0, + limit = 10U, + nameFilter = "boots", + ) + + val request = capturedRequest!! + assertEquals("/reverse/", request.url.encodedPath) + val params = request.url.parameters + assertEquals(listOf(":pharmacy", "shop:chemist", "!building"), params.getAll("osm_tag")) + assertEquals("5.0", params.get("radius")) + assertEquals("10", params.get("limit")) + assertEquals("boots", params.get("query_string_filter")) + } + + @Test + fun nearbyByTagJson_omitsOptionalParametersWhenNull() = runTest { + val client = clientReturning(HttpStatusCode.OK, "{}") + + client.nearbyByTagJson(latitude = 55.94, longitude = -4.31, osmTags = listOf(":cafe")) + + val params = capturedRequest!!.url.parameters + assertTrue(params.contains("radius") == false) + assertTrue(params.contains("limit") == false) + assertTrue(params.contains("query_string_filter") == false) + } }