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) + } }