OSM: search by tags "amenity" and "shop" after delocalizing (#1253)

* OSM: search by tags "amenity" and "shop" after delocalizing

* use overpassQL UNION

* document Union usage

* cleanup unused imports

* refine restaurant queries

* two more :)

* Hardcode string -> tag assignment

* Remove log statement

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph 2025-02-08 21:48:42 +01:00 committed by GitHub
parent ce5495418e
commit 3478411d7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 181 additions and 70 deletions

View File

@ -29,4 +29,10 @@ fun String.normalize(): String {
*/
fun String.romanize(): String {
return Pinyin.toPinyin(this, "")
}
}
fun String.stripStartOrNull(s: String): String?
= if (startsWith(s)) removePrefix(s) else null
fun String.stripEndOrNull(s: String): String?
= if (endsWith(s)) removeSuffix(s) else null

View File

@ -5,16 +5,16 @@ import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.locations.providers.AndroidLocation
import de.mm20.launcher2.locations.providers.LocationProvider
import de.mm20.launcher2.openstreetmaps.R
import de.mm20.launcher2.preferences.search.LocationSearchSettings
import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.ResultScore
import de.mm20.launcher2.search.UpdateResult
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@ -38,7 +38,8 @@ internal class OsmLocationProvider(
try {
Retrofit.Builder()
.client(HttpClient)
.baseUrl(it?.takeIf { it.isNotBlank() } ?: LocationSearchSettings.DefaultOverpassUrl)
.baseUrl(it?.takeIf { it.isNotBlank() }
?: LocationSearchSettings.DefaultOverpassUrl)
.addConverterFactory(OverpassQueryConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()
@ -109,62 +110,144 @@ internal class OsmLocationProvider(
HttpClient.dispatcher.cancelAll()
}
suspend fun searchByTag(tag: String): OverpassResponse? =
overpassApi.first()?.runCatching {
this.search(
OverpassFuzzyRadiusQuery(
tag = tag,
query = query,
radius = searchRadiusMeters,
latitude = userLocation.latitude,
longitude = userLocation.longitude,
)
return overpassApi.first()?.runCatching {
search(
OverpassFuzzyRadiusQuery(
query = query,
tagGroups = delocalizeToQueryableTags(query),
radius = searchRadiusMeters,
latitude = userLocation.latitude,
longitude = userLocation.longitude
)
}?.onFailure {
if (it !is HttpException && it !is CancellationException) {
Log.e("OsmLocationProvider", "Failed to search for $tag: $query", it)
}
}?.getOrNull()
val result = awaitAll(
// optionally query by "amenity" or "shop" here
// if we want to make searching for locations fuzzier
// however, this would not account for localized queries like "Bäcker" (shop:bakery)
Scope.async { searchByTag("name") },
Scope.async { searchByTag("brand") },
).flatMap {
it?.let {
OsmLocation.fromOverpassResponse(it, context)
} ?: emptyList()
}
return result
.asSequence()
.filter {
(!hideUncategorized || (it.category != null)) && it.distanceTo(userLocation) < searchRadiusMeters
)
}?.onFailure {
if (it !is HttpException && it !is CancellationException) {
Log.e("OsmLocationProvider", "Failed to search for: $query", it)
}
.groupBy {
it.label.lowercase()
}?.getOrNull()?.let {
OsmLocation.fromOverpassResponse(it, context)
}?.asSequence()?.filter {
(!hideUncategorized || (it.category != null)) && it.distanceTo(userLocation) < searchRadiusMeters
}?.groupBy {
it.label.lowercase()
}?.flatMap { (_, duplicates) ->
// deduplicate results with same labels, if
// - same category
// - distance is less than 100m
if (duplicates.size < 2) duplicates
else {
val luckyFirst = duplicates.first()
duplicates
.drop(1)
.filter {
it.category != luckyFirst.category ||
it.distanceTo(luckyFirst) > 100.0
} + luckyFirst
}
.flatMap { (_, duplicates) ->
// deduplicate results with same labels, if
// - same category
// - distance is less than 100m
if (duplicates.size < 2) duplicates
else {
val luckyFirst = duplicates.first()
duplicates
.drop(1)
.filter {
it.category != luckyFirst.category ||
it.distanceTo(luckyFirst) > 100.0
} + luckyFirst
}
}
.sortedBy {
it.distanceTo(userLocation)
}
.take(7)
.toImmutableList()
}?.sortedBy {
it.distanceTo(userLocation)
}?.take(9)?.toImmutableList() ?: emptyList()
}
private val poiCategories = mapOf(
R.string.poi_category_restaurant to "amenity=restaurant",
R.string.poi_category_fast_food to "amenity=fast_food",
R.string.poi_category_bar to "amenity=bar",
R.string.poi_category_cafe to "amenity=cafe",
R.string.poi_category_hotel to "tourism=hotel",
R.string.poi_category_supermarket to "shop=supermarket",
R.string.poi_category_school to "amenity=school",
R.string.poi_category_parking to "amenity=parking",
R.string.poi_category_fuel to "amenity=fuel",
R.string.poi_category_toilets to "amenity=toilets",
R.string.poi_category_pharmacy to "amenity=pharmacy",
R.string.poi_category_hospital to "amenity=hospital",
R.string.poi_category_post_office to "amenity=post_office",
R.string.poi_category_pub to "amenity=pub",
R.string.poi_category_doctors to "amenity=doctors",
R.string.poi_category_police to "amenity=police",
R.string.poi_category_dentist to "amenity=dentist",
R.string.poi_category_library to "amenity=library",
R.string.poi_category_ice_cream to "amenity=ice_cream",
R.string.poi_category_theater to "amenity=theatre",
R.string.poi_category_cinema to "amenity=cinema",
R.string.poi_category_nightclub to "amenity=nightclub",
R.string.poi_category_clinic to "amenity=clinic",
R.string.poi_category_university to "amenity=university",
R.string.poi_category_clothes to "shop=clothes",
R.string.poi_category_convenience to "shop=convenience",
R.string.poi_category_hairdresser to "shop=hairdresser",
R.string.poi_category_books to "shop=books",
R.string.poi_category_bakery to "shop=bakery",
R.string.poi_category_car_rental to "amenity=car_rental",
R.string.poi_category_car_sharing to "amenity=car_sharing",
R.string.poi_category_mobile_phone to "shop=mobile_phone",
R.string.poi_category_furniture to "shop=furniture",
R.string.poi_category_alcohol to "shop=alcohol",
R.string.poi_category_florist to "shop=florist",
R.string.poi_category_mall to "shop=mall",
R.string.poi_category_optician to "shop=optician",
R.string.poi_category_jewelry to "shop=jewelry",
R.string.poi_category_laundry to "amenity=laundry",
R.string.poi_category_bank to "amenity=bank",
R.string.poi_category_soccer to "leisure=pitch,sport=soccer",
R.string.poi_category_basketball to "leisure=pitch,sport=basketball",
R.string.poi_category_tennis to "leisure=pitch,sport=tennis",
R.string.poi_category_atm to "amenity=atm",
R.string.poi_category_kiosk to "shop=kiosk",
R.string.poi_category_museum to "tourism=museum",
R.string.poi_category_fitness_center to "leisure=fitness_centre",
R.string.poi_category_church to "amenity=place_of_worship,religion=christian",
R.string.poi_category_mosque to "amenity=place_of_worship,religion=muslim",
R.string.poi_category_buddhist_temple to "amenity=place_of_worship,religion=buddhist",
R.string.poi_category_hindu_temple to "amenity=place_of_worship,religion=hindu",
R.string.poi_category_synagogue to "amenity=place_of_worship,religion=jewish",
R.string.poi_category_pizza_restaurant to "amenity=restaurant,cuisine=pizza",
R.string.poi_category_burger_restaurant to "amenity=restaurant,cuisine=burger",
R.string.poi_category_place_of_worship to "amenity=place_of_worship",
R.string.poi_category_chinese_restaurant to "amenity=restaurant,cuisine=chinese",
R.string.poi_category_japanese_restaurant to "amenity=restaurant,cuisine=japanese",
R.string.poi_category_kebab_restaurant to "amenity=restaurant,cuisine=kebab",
R.string.poi_category_asian_restaurant to "amenity=restaurant,cuisine=asian",
R.string.poi_category_ramen_restaurant to "amenity=restaurant,cuisine=ramen",
R.string.poi_category_soup_restaurant to "amenity=restaurant,cuisine=soup",
R.string.poi_category_brunch_restaurant to "amenity=restaurant,cuisine=brunch",
R.string.poi_category_car_wash to "amenity=car_wash",
R.string.poi_category_charging_station to "amenity=charging_station",
R.string.poi_category_motorcycle_rental to "amenity=motorcycle_rental",
R.string.poi_category_gallery to "tourism=gallery",
R.string.poi_category_amusement_park to "tourism=theme_park",
R.string.poi_category_concert_hall to "amenity=concert_hall",
R.string.poi_category_stadium to "leisure=stadium",
R.string.poi_category_casino to "amenity=casino",
R.string.poi_category_discount_store to "shop=discount",
R.string.poi_category_pet to "shop=pet",
R.string.poi_category_shopping to "shop=mall",
R.string.poi_category_swimming to "leisure=swimming_pool",
R.string.poi_category_martial_arts to "leisure=sports_centre,sport=martial_arts",
R.string.poi_category_golf to "leisure=golf_course",
R.string.poi_category_gymnastics to "leisure=sports_hall,sport=gymnastics",
R.string.poi_category_ice_hockey to "leisure=sports_centre,sport=ice_hockey",
R.string.poi_category_baseball to "leisure=pitch,sport=baseball",
R.string.poi_category_american_football to "leisure=pitch,sport=american_football",
R.string.poi_category_handball to "leisure=pitch,sport=handball",
R.string.poi_category_volleyball to "leisure=pitch,sport=volleyball",
R.string.poi_category_skiing to "leisure=piste",
R.string.poi_category_cricket to "leisure=pitch,sport=cricket",
R.string.poi_category_park to "leisure=park",
R.string.poi_category_monument to "historic=monument",
R.string.poi_category_government_building to "building=government",
R.string.poi_category_fire_station to "amenity=fire_station",
R.string.poi_category_courthouse to "amenity=courthouse",
R.string.poi_category_townhall to "amenity=townhall"
).mapKeys { context.getString(it.key) }
private fun delocalizeToQueryableTags(localizedQuery: String): List<String> =
poiCategories.mapNotNull { (string, tags) ->
val score = ResultScore(
localizedQuery,
primaryFields = listOf(string)
)
if (score.score > 0.8f) tags else null
}
}

View File

@ -9,13 +9,28 @@ import retrofit2.http.POST
import java.lang.reflect.Type
import kotlin.math.cos
/**
* Overpass API query builder
* Searches for nodes and ways that at least:
* - match the query string in their name or brand tag
* - match one of the given tag groups
*/
data class OverpassFuzzyRadiusQuery(
val tag: String = "name",
/**
* Free text query to search for.
*/
val query: String,
/**
* Tags groups to search for. Each item represents a group of tags, separated by commas.
* The query matches if all tags in a group are present in the element.
* For example:
* ["amenity=restaurant,cuisine=italian", "amenity=cafe"]
* This query will match elements that are either a restaurant with italian cuisine or a cafe.
*/
val tagGroups: List<String>,
val radius: Int,
val latitude: Double,
val longitude: Double,
val caseInvariant: Boolean = true,
)
data class OverpassIdQuery(
@ -50,12 +65,7 @@ interface OverpassApi {
class OverpassFuzzyRadiusQueryConverter : Converter<OverpassFuzzyRadiusQuery, RequestBody> {
override fun convert(value: OverpassFuzzyRadiusQuery): RequestBody {
// allow other characters in between query words, if there are multiple
// https://dev.overpass-api.de/overpass-doc/en/criteria/per_tag.html#regex
val escapedQueryName = value
.query
.split(' ')
val encodedQuery = value.query.split(' ')
.joinToString(
separator = ".*",
prefix = "\"",
@ -65,11 +75,23 @@ class OverpassFuzzyRadiusQueryConverter : Converter<OverpassFuzzyRadiusQuery, Re
val overpassQlBuilder = StringBuilder()
val latDegreeChange = value.radius * 0.00001 / 1.11
val lonDegreeChange = latDegreeChange / cos(Math.toRadians(value.latitude))
val boundingBox = arrayOf(value.latitude - latDegreeChange, value.longitude - lonDegreeChange,
value.latitude + latDegreeChange, value.longitude + lonDegreeChange)
val boundingBox = arrayOf(
value.latitude - latDegreeChange, value.longitude - lonDegreeChange,
value.latitude + latDegreeChange, value.longitude + lonDegreeChange
)
overpassQlBuilder.append("[out:json][timeout:10][bbox:" + boundingBox.joinToString(",") + "];")
// nw: node or way
overpassQlBuilder.append("nw[", value.tag, "~", escapedQueryName, if (value.caseInvariant) ",i];" else "];")
overpassQlBuilder.append("(")
overpassQlBuilder.append("nw[name~$encodedQuery,i];")
overpassQlBuilder.append("nw[brand~$encodedQuery,i];")
for (tag in value.tagGroups) {
val tags = tag.split(',')
if (tags.isEmpty()) continue
overpassQlBuilder.append("nw[${tags.joinToString("][")}];")
}
overpassQlBuilder.append(");")
// center to add the center coordinate of a way to the result, if applicable
overpassQlBuilder.append("out center;")