Skip to content

Commit

Permalink
Select title/subtitle highlight as adviced by MKLocalSearch (#327)
Browse files Browse the repository at this point in the history
* Select title/subtitle highlight as adviced by MKLocalSearch

* Highlights for various autocompletion providers

* Add public init

* Test compile fix

* Example compile fixes

* Ranges in calendar manager, too
  • Loading branch information
nighthawk authored Jan 22, 2024
1 parent 8f78919 commit b52941d
Show file tree
Hide file tree
Showing 14 changed files with 201 additions and 106 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ extension InMemoryFavoriteManager {
result.score = 90

} else {
let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title)
let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "")
let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title).score
let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "").score
let rawScore = min(100, (titleScore + locationScore)/2)
result.score = Int(TKAutocompletionResult.rangedScore(for: rawScore, min: 50, max: 90))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ extension InMemoryHistoryManager {
result.score = 90

} else {
let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title)
let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "")
let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.title).score
let locationScore = TKAutocompletionResult.nameScore(searchTerm: searchText, candidate: result.subtitle ?? "").score
let rawScore = min(100, (titleScore + locationScore)/2)
result.score = Int(TKAutocompletionResult.rangedScore(for: rawScore, min: 50, max: 90))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,10 @@ extension TKCalendarManager {
} else {

let titleScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: result.title)
result.titleHighlightRanges = titleScore.ranges
let locationScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: result.subtitle ?? "")
let rawScore = min(100, (titleScore + locationScore) / 2)
result.subtitleHighlightRanges = locationScore.ranges
let rawScore = min(100, (titleScore.score + locationScore.score) / 2)
result.score = Int(TKAutocompletionResult.rangedScore(for:rawScore, min: 50, max: 90))
}

Expand Down
19 changes: 10 additions & 9 deletions Sources/TripKit/managers/TKContactsManager+TKAutocompleting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,20 @@ extension TKContactsManager: TKAutocompleting {

extension TKContactsManager.ContactAddress {
fileprivate func toResult(provider: TKAutocompleting, search: String) -> TKAutocompletionResult {
var result = TKAutocompletionResult(
let nameScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: locationName)
let addressScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: address)
let textScore = min(100, (nameScore.score + addressScore.score) / 2)
let score = Int(TKAutocompletionResult.rangedScore(for: textScore, min: 50, max: 90))

return .init(
object: self,
title: locationName,
titleHighlightRanges: nameScore.ranges,
subtitle: address,
image: image ?? TKAutocompletionResult.image(for: .contact)
subtitleHighlightRanges: addressScore.ranges,
image: image ?? TKAutocompletionResult.image(for: .contact),
score: score
)

let nameScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: name)
let addressScore = TKAutocompletionResult.nameScore(searchTerm: search, candidate: address)
let textScore = min(100, (nameScore + addressScore) / 2)
result.score = Int(TKAutocompletionResult.rangedScore(for: textScore, min: 50, max: 90))

return result
}
}

Expand Down
6 changes: 4 additions & 2 deletions Sources/TripKit/search/TKAppleGeocoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ extension TKAutocompletionResult {
self.init(
object: completion,
title: completion.title,
titleHighlightRanges: completion.titleHighlightRanges.map(\.rangeValue),
subtitle: completion.subtitle,
subtitleHighlightRanges: completion.subtitleHighlightRanges.map(\.rangeValue),
image: TKAutocompletionResult.image(for: .pin),
score: TKGeocodingResultScorer.calculateScore(title: completion.title, subtitle: completion.subtitle, searchTerm: input, minimum: 25, maximum: 65) - index
score: TKGeocodingResultScorer.calculateScore(title: completion.title, subtitle: completion.subtitle, searchTerm: input, minimum: 25, maximum: 65).score - index
)
}

Expand All @@ -168,7 +170,7 @@ extension TKNamedCoordinate {
url = mapItem.url

if let input = input, let region = region {
sortScore = Int(TKGeocodingResultScorer.calculateScore(for: self, searchTerm: input, near: region, allowLongDistance: false, minimum: 15, maximum: 65))
sortScore = Int(TKGeocodingResultScorer.calculateScore(for: self, searchTerm: input, near: region, allowLongDistance: false, minimum: 15, maximum: 65).score)
}
}

Expand Down
52 changes: 43 additions & 9 deletions Sources/TripKit/search/TKAutocompletionResult+Score.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension TKAutocompletionResult {
if abs(world.span.latitudeDelta - region.span.latitudeDelta) < 1, abs(world.span.longitudeDelta - region.span.longitudeDelta) < 1 {
return 100
}

if region.contains(coordinate) {
return 100
}
Expand All @@ -35,14 +35,40 @@ extension TKAutocompletionResult {
return Int(proportion * 100)
}

public struct Score: ExpressibleByIntegerLiteral {
public let score: Int
public var ranges: [NSRange] = []

public init(score: Int, ranges: [NSRange] = []) {
self.score = score
self.ranges = ranges
}

public init(integerLiteral value: IntegerLiteralType) {
self.score = value
}
}

public struct ScoreHighlights {
public init(score: Int, titleHighlight: [NSRange] = [], subtitleHighlight: [NSRange] = []) {
self.score = score
self.titleHighlight = titleHighlight
self.subtitleHighlight = subtitleHighlight
}

public let score: Int
public var titleHighlight: [NSRange] = []
public var subtitleHighlight: [NSRange] = []
}

/// 0: not match, e.g., we're missing a word
/// 25: same words but wrong order
/// 33: has all target words but missing a completed one
/// 50: matches somewhere in the word
/// 66: contains all words in right order
/// 75: matches start of word in search term (but starts don't match)
/// 100: exact match at start
public static func nameScore(searchTerm fullTarget: String, candidate fullCandidate: String) -> Int {
public static func nameScore(searchTerm fullTarget: String, candidate fullCandidate: String) -> Score {
let target = stringForScoring(fullTarget)
let candidate = stringForScoring(fullCandidate)

Expand All @@ -54,7 +80,7 @@ extension TKAutocompletionResult {
}

if target == candidate {
return 100
return .init(score: 100, ranges: [.init(location: 0, length: candidate.utf8.count)])
}

if target.isAbbreviation(for: candidate) || target.isAbbreviation(for: stringForScoring(fullCandidate, removeBrackets: true)) {
Expand All @@ -65,36 +91,44 @@ extension TKAutocompletionResult {
return 90
}

// exact phrase matches
let excess = candidate.utf8.count - target.utf8.count
if let range = candidate.range(of: target) {
let nsRange = NSRange(location: candidate.distance(from: candidate.startIndex, to: range.lowerBound), length: target.utf8.count)
if range.lowerBound == candidate.startIndex {
// matches right at start
return score(100, penalty: excess, min: 75)
return .init(score: score(100, penalty: excess, min: 75), ranges: [nsRange])
}

let before = candidate[candidate.index(before: range.lowerBound)]
if before.isWhitespace {
// matches beginning of word
let offset = candidate.distance(from: candidate.startIndex, to: range.lowerBound)
return score(75, penalty: offset * 2 + excess, min: 33)
return .init(score: score(75, penalty: offset * 2 + excess, min: 33), ranges: [nsRange])

} else {
// in-word match
return score(25, penalty: excess, min: 5)
return .init(score: score(25, penalty: excess, min: 5), ranges: [nsRange])
}
}

// non-subscring matches
let targetWords = target.components(separatedBy: " ")
var lastMatch: String.Index = candidate.startIndex
var ranges: [NSRange] = []
for word in targetWords {
if let match = candidate.range(of: word) {
ranges.append(NSRange(
location: candidate.distance(from: candidate.startIndex, to: match.lowerBound),
length: word.utf8.count
))

if match.lowerBound >= lastMatch {
// still in order, keep going
lastMatch = match.lowerBound
} else {
// wrong order, abort with penalty
return score(10, penalty: excess, min: 0)
return .init(score: score(10, penalty: excess, min: 0), ranges: ranges)
}

} else {
Expand All @@ -119,12 +153,12 @@ extension TKAutocompletionResult {
// full word match, continue with next
} else {
// candidate doesn't have a completed word
return score(33, penalty: excess, min: 10)
return .init(score: score(33, penalty: excess, min: 10), ranges: ranges)
}
}
}

return score(66, penalty: excess, min: 40)
return .init(score: score(66, penalty: excess, min: 40), ranges: ranges)
}

private static func score(_ maximum: Int, penalty: Int, min: Int) -> Int {
Expand Down
8 changes: 7 additions & 1 deletion Sources/TripKit/search/TKAutocompletionResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
import Foundation

public struct TKAutocompletionResult {
public init(object: AnyHashable, title: String, subtitle: String? = nil, image: TKImage, accessoryButtonImage: TKImage? = nil, accessoryAccessibilityLabel: String? = nil, score: Int = 0, isInSupportedRegion: Bool = true) {
public init(object: AnyHashable, title: String, titleHighlightRanges: [NSRange] = [], subtitle: String? = nil, subtitleHighlightRanges: [NSRange] = [], image: TKImage, accessoryButtonImage: TKImage? = nil, accessoryAccessibilityLabel: String? = nil, score: Int = 0, isInSupportedRegion: Bool = true) {
self.object = object
self.title = title
self.titleHighlightRanges = titleHighlightRanges
self.subtitle = subtitle
self.subtitleHighlightRanges = subtitleHighlightRanges
self.image = image
self.accessoryButtonImage = accessoryButtonImage
self.accessoryAccessibilityLabel = accessoryAccessibilityLabel
Expand All @@ -26,8 +28,12 @@ public struct TKAutocompletionResult {

public let title: String

public var titleHighlightRanges: [NSRange] = []

public var subtitle: String? = nil

public var subtitleHighlightRanges: [NSRange] = []

public let image: TKImage

public var accessoryButtonImage: TKImage? = nil
Expand Down
28 changes: 19 additions & 9 deletions Sources/TripKit/search/TKGeocodingResultScorer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,40 +14,50 @@ public class TKGeocodingResultScorer: NSObject {
private override init() {
}

public static func calculateScore(for annotation: MKAnnotation, searchTerm: String, near region: MKCoordinateRegion, allowLongDistance: Bool, minimum: Int, maximum: Int) -> Int {
public static func calculateScore(for annotation: MKAnnotation, searchTerm: String, near region: MKCoordinateRegion, allowLongDistance: Bool, minimum: Int, maximum: Int) -> TKAutocompletionResult.ScoreHighlights {

guard let title = (annotation.title ?? nil) else {
return 0
return .init(score: 0)
}

let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: title)
let addressScore: Int
let addressScore: TKAutocompletionResult.Score
if let subtitle = (annotation.subtitle ?? nil) {
addressScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: subtitle)
} else {
addressScore = 0
}
let stringScore = max(titleScore, addressScore)
let stringScore = max(titleScore.score, addressScore.score)

let distanceScore = TKAutocompletionResult.distanceScore(from: annotation.coordinate, to: region, longDistance: allowLongDistance)

let rawScore = (stringScore + distanceScore) / 2
return TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum)
let ranged = TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum)
return .init(
score: ranged,
titleHighlight: titleScore.ranges,
subtitleHighlight: addressScore.ranges
)
}

public static func calculateScore(title: String, subtitle: String?, searchTerm: String, minimum: Int, maximum: Int) -> Int {
public static func calculateScore(title: String, subtitle: String?, searchTerm: String, minimum: Int, maximum: Int) -> TKAutocompletionResult.ScoreHighlights {
assert(maximum > minimum, "Order must be preserved.")

let titleScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: title)
let addressScore: Int
let addressScore: TKAutocompletionResult.Score
if let subtitle = subtitle {
addressScore = TKAutocompletionResult.nameScore(searchTerm: searchTerm, candidate: subtitle)
} else {
addressScore = 0
}
let rawScore = max(titleScore, addressScore)
let rawScore = max(titleScore.score, addressScore.score)

return TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum)
let ranged = TKAutocompletionResult.rangedScore(for: rawScore, min: minimum, max: maximum)
return .init(
score: ranged,
titleHighlight: titleScore.ranges,
subtitleHighlight: addressScore.ranges
)
}

}
22 changes: 14 additions & 8 deletions Sources/TripKit/search/TKPeliasGeocoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ extension TKPeliasGeocoder: TKGeocoding {

hitSearch(components) { result in
completion(result.map { coordinates in
coordinates.forEach { $0.setScore(searchTerm: input, near: region) }
coordinates.forEach {
$0.sortScore = $0.score(searchTerm: input, near: region).score
}
return TKGeocoderHelper.mergedAndPruned(coordinates, withMaximum: 10)
})
}
Expand All @@ -115,14 +117,19 @@ extension TKPeliasGeocoder: TKAutocompleting {

hitSearch(components) { result in
completion(result.map { coordinates in
coordinates.forEach { $0.setScore(searchTerm: input, near: region) }

// Pelias likes coming back with similar locations near each
// other, so we cluster them.
let clusters = TKAnnotationClusterer.cluster(coordinates)
let unique = clusters.compactMap(TKNamedCoordinate.namedCoordinate(for:))
let pruned = TKGeocoderHelper.mergedAndPruned(unique, withMaximum: 7)
return pruned.map(TKAutocompletionResult.init)
return pruned.map {
let score = $0.score(searchTerm: input, near: region)
var result = TKAutocompletionResult(from: $0)
result.score = score.score
result.titleHighlightRanges = score.titleHighlight
result.subtitleHighlightRanges = score.subtitleHighlight
return result
}
})
}
}
Expand All @@ -134,10 +141,10 @@ extension TKPeliasGeocoder: TKAutocompleting {

}

extension TKNamedCoordinate {
extension MKAnnotation {

func setScore(searchTerm: String, near region: MKCoordinateRegion) {
self.sortScore = Int(TKGeocodingResultScorer.calculateScore(for: self, searchTerm: searchTerm, near: region, allowLongDistance: false, minimum: 10, maximum: 60))
func score(searchTerm: String, near region: MKCoordinateRegion) -> TKAutocompletionResult.ScoreHighlights {
TKGeocodingResultScorer.calculateScore(for: self, searchTerm: searchTerm, near: region, allowLongDistance: false, minimum: 10, maximum: 60)
}

}
Expand All @@ -150,7 +157,6 @@ extension TKAutocompletionResult {
title: coordinate.title ?? Loc.Location,
subtitle: coordinate.subtitle,
image: TKAutocompletionResult.image(for: .pin),
score: coordinate.sortScore,
isInSupportedRegion: TKRegionManager.shared.coordinateIsPartOfAnyRegion(coordinate.coordinate)
)
}
Expand Down
15 changes: 8 additions & 7 deletions Sources/TripKit/search/TKRegionAutocompleter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,18 @@ public class TKRegionAutocompleter: TKAutocompleting {
public func autocomplete(_ input: String, near mapRect: MKMapRect, completion: @escaping (Result<[TKAutocompletionResult], Error>) -> Void) {

let scoredMatches = TKRegionManager.shared.regions
.flatMap { region -> [(TKRegion.City, score: Int)] in
.flatMap { region -> [(TKRegion.City, score: TKAutocompletionResult.ScoreHighlights)] in
if input.isEmpty {
return region.cities.map { ($0, 100) }
return region.cities.map { ($0, .init(score: 100)) }
} else {
return region.cities.compactMap { city in
return region.cities.compactMap { city -> (TKRegion.City, score: TKAutocompletionResult.ScoreHighlights)? in
guard let name = city.title else { return nil }
let titleScore = TKAutocompletionResult.nameScore(searchTerm: input, candidate: name)
guard titleScore > 0 else { return nil }
guard titleScore.score > 0 else { return nil }
let distanceScore = TKAutocompletionResult.distanceScore(from: city.coordinate, to: .init(mapRect), longDistance: true)
let rawScore = (titleScore * 9 + distanceScore) / 10
let rawScore = (titleScore.score * 9 + distanceScore) / 10
let score = TKAutocompletionResult.rangedScore(for: rawScore, min: 10, max: 70)
return (city, score)
return (city, .init(score: score, titleHighlight: titleScore.ranges))
}
}
}
Expand All @@ -43,8 +43,9 @@ public class TKRegionAutocompleter: TKAutocompleting {
return TKAutocompletionResult(
object: tuple.0,
title: tuple.0.title!, // we filtered those out without a name
titleHighlightRanges: tuple.score.titleHighlight,
image: image,
score: tuple.score
score: tuple.score.score
)
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/TripKit/search/TKRouteAutocompleter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public class TKRouteAutocompleter: TKAutocompleting {
let rawScore = [route.shortName, route.routeName]
.compactMap { $0 }
.map {
TKAutocompletionResult.nameScore(searchTerm: input, candidate: $0)
TKAutocompletionResult.nameScore(searchTerm: input, candidate: $0).score
}.max() ?? 0
let score = TKAutocompletionResult.rangedScore(for: rawScore, min: 30, max: 80)
return (route, score)
Expand Down
Loading

0 comments on commit b52941d

Please sign in to comment.