2026-01-08 19:16:32 +00:00
|
|
|
//
|
|
|
|
|
// POIDataSource.swift
|
|
|
|
|
// iris
|
|
|
|
|
//
|
|
|
|
|
// Created by Codex.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import CoreLocation
|
|
|
|
|
import Foundation
|
2026-01-10 18:50:15 +00:00
|
|
|
import MapKit
|
2026-01-08 19:16:32 +00:00
|
|
|
|
|
|
|
|
struct POIDataSourceConfig: Sendable {
|
2026-01-10 18:50:15 +00:00
|
|
|
var maxCandidates: Int = 2
|
|
|
|
|
var searchRadiusMeters: CLLocationDistance = 600
|
|
|
|
|
var transitBoostRadiusMeters: CLLocationDistance = 200
|
|
|
|
|
var walkingSpeedMps: Double = 1.4
|
|
|
|
|
var minTTLSeconds: Int = 60
|
|
|
|
|
var maxTTLSeconds: Int = 20 * 60
|
2026-01-08 19:16:32 +00:00
|
|
|
init() {}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 18:50:15 +00:00
|
|
|
/// MapKit-backed POI source.
|
2026-01-08 19:16:32 +00:00
|
|
|
final class POIDataSource {
|
2026-01-10 18:50:15 +00:00
|
|
|
enum POIType: String, Codable, CaseIterable, Sendable {
|
|
|
|
|
case transit = "TRANSIT"
|
|
|
|
|
case cafe = "CAFE"
|
|
|
|
|
case food = "FOOD"
|
|
|
|
|
case park = "PARK"
|
|
|
|
|
case shopping = "SHOPPING"
|
|
|
|
|
case grocery = "GROCERY"
|
|
|
|
|
case fitness = "FITNESS"
|
|
|
|
|
case entertainment = "ENTERTAINMENT"
|
|
|
|
|
case health = "HEALTH"
|
|
|
|
|
case lodging = "LODGING"
|
|
|
|
|
case education = "EDUCATION"
|
|
|
|
|
case services = "SERVICES"
|
|
|
|
|
case other = "OTHER"
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
struct POI: Sendable, Equatable {
|
|
|
|
|
let id: String
|
|
|
|
|
let name: String
|
2026-01-10 18:50:15 +00:00
|
|
|
let category: String
|
|
|
|
|
let poiType: POIType
|
|
|
|
|
let distanceMeters: CLLocationDistance
|
|
|
|
|
let walkingMinutes: Int
|
|
|
|
|
let ttlSec: Int
|
|
|
|
|
let confidence: Double
|
|
|
|
|
let isTransit: Bool
|
2026-01-10 00:25:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-08 19:16:32 +00:00
|
|
|
private let config: POIDataSourceConfig
|
|
|
|
|
|
|
|
|
|
init(config: POIDataSourceConfig = .init()) {
|
|
|
|
|
self.config = config
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
func data(for location: CLLocation, now: Int) async throws -> [POI] {
|
2026-01-10 18:50:15 +00:00
|
|
|
print("finding pois for location \(location)")
|
|
|
|
|
|
2026-01-08 19:16:32 +00:00
|
|
|
_ = now
|
2026-01-10 18:50:15 +00:00
|
|
|
|
|
|
|
|
let request = MKLocalPointsOfInterestRequest(
|
|
|
|
|
center: location.coordinate,
|
|
|
|
|
radius: config.searchRadiusMeters
|
|
|
|
|
)
|
|
|
|
|
request.pointOfInterestFilter = .includingAll
|
|
|
|
|
|
|
|
|
|
let items = try await search(request: request)
|
|
|
|
|
|
|
|
|
|
print(items)
|
|
|
|
|
|
|
|
|
|
var results: [POI] = []
|
|
|
|
|
results.reserveCapacity(min(items.count, config.maxCandidates))
|
|
|
|
|
var seen: Set<String> = []
|
|
|
|
|
|
|
|
|
|
for item in items {
|
|
|
|
|
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
guard !name.isEmpty else { continue }
|
|
|
|
|
|
|
|
|
|
let coordinate = item.placemark.coordinate
|
|
|
|
|
let itemLocation = item.placemark.location ?? CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
|
|
|
|
let distance = location.distance(from: itemLocation)
|
|
|
|
|
guard distance <= config.searchRadiusMeters else { continue }
|
|
|
|
|
|
|
|
|
|
let mkCategory = item.pointOfInterestCategory
|
|
|
|
|
let categoryRaw = mkCategory?.rawValue
|
|
|
|
|
let category = categoryLabel(from: categoryRaw)
|
|
|
|
|
let poiType = poiType(for: mkCategory, categoryRaw: categoryRaw, name: name)
|
|
|
|
|
let isTransit = poiType == .transit
|
|
|
|
|
let walkingMinutes = walkingMinutes(for: distance)
|
|
|
|
|
let ttlSec = ttlSeconds(radius: config.searchRadiusMeters, speed: location.speed)
|
|
|
|
|
let confidence = confidenceScore(distance: distance, isTransit: isTransit)
|
|
|
|
|
|
|
|
|
|
let id = stableId(name: name, coordinate: coordinate, category: poiType.rawValue)
|
|
|
|
|
guard !seen.contains(id) else { continue }
|
|
|
|
|
seen.insert(id)
|
|
|
|
|
|
|
|
|
|
results.append(
|
|
|
|
|
POI(
|
|
|
|
|
id: id,
|
|
|
|
|
name: name,
|
|
|
|
|
category: category,
|
|
|
|
|
poiType: poiType,
|
|
|
|
|
distanceMeters: distance,
|
|
|
|
|
walkingMinutes: walkingMinutes,
|
|
|
|
|
ttlSec: ttlSec,
|
|
|
|
|
confidence: confidence,
|
|
|
|
|
isTransit: isTransit
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results.sort {
|
|
|
|
|
if $0.confidence == $1.confidence {
|
|
|
|
|
return $0.distanceMeters < $1.distanceMeters
|
|
|
|
|
}
|
|
|
|
|
return $0.confidence > $1.confidence
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if results.count > config.maxCandidates {
|
|
|
|
|
return Array(results.prefix(config.maxCandidates))
|
|
|
|
|
}
|
|
|
|
|
return results
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func search(request: MKLocalPointsOfInterestRequest) async throws -> [MKMapItem] {
|
|
|
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
|
|
|
let search = MKLocalSearch(request: request)
|
|
|
|
|
search.start { response, error in
|
|
|
|
|
if let error {
|
|
|
|
|
continuation.resume(throwing: error)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
continuation.resume(returning: response?.mapItems ?? [])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func stableId(name: String, coordinate: CLLocationCoordinate2D, category: String) -> String {
|
|
|
|
|
let lat = String(format: "%.5f", coordinate.latitude)
|
|
|
|
|
let lon = String(format: "%.5f", coordinate.longitude)
|
|
|
|
|
let rawCategory = category.lowercased()
|
|
|
|
|
let raw = "poi:\(rawCategory):\(name):\(lat):\(lon)"
|
|
|
|
|
return raw
|
|
|
|
|
.replacingOccurrences(of: " ", with: "_")
|
|
|
|
|
.replacingOccurrences(of: "\n", with: "_")
|
|
|
|
|
.replacingOccurrences(of: "\t", with: "_")
|
2026-01-08 19:16:32 +00:00
|
|
|
}
|
2026-01-10 18:50:15 +00:00
|
|
|
|
|
|
|
|
private func walkingMinutes(for distanceMeters: CLLocationDistance) -> Int {
|
|
|
|
|
let speed = max(config.walkingSpeedMps, 0.5)
|
|
|
|
|
let seconds = distanceMeters / speed
|
|
|
|
|
let minutes = Int(ceil(seconds / 60.0))
|
|
|
|
|
return max(1, minutes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func ttlSeconds(radius: CLLocationDistance, speed: CLLocationSpeed) -> Int {
|
|
|
|
|
let safeSpeed = max(speed, 0.5)
|
|
|
|
|
let secondsToExit = radius / safeSpeed
|
|
|
|
|
let ttl = Int(secondsToExit.rounded())
|
|
|
|
|
return min(max(ttl, config.minTTLSeconds), config.maxTTLSeconds)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func confidenceScore(distance: CLLocationDistance, isTransit: Bool) -> Double {
|
|
|
|
|
let normalized = max(0.0, min(1.0, 1.0 - (distance / max(config.searchRadiusMeters, 1))))
|
|
|
|
|
let base = isTransit ? 0.55 : 0.25
|
|
|
|
|
let range = isTransit ? 0.25 : 0.2
|
|
|
|
|
let transitBoost = (isTransit && distance <= config.transitBoostRadiusMeters) ? 0.1 : 0.0
|
|
|
|
|
return min(max(base + (range * normalized) + transitBoost, 0.0), 0.9)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func categoryLabel(from raw: String?) -> String {
|
|
|
|
|
guard var raw = raw else { return "Place" }
|
|
|
|
|
if raw.hasPrefix("MKPOICategory") {
|
|
|
|
|
raw = String(raw.dropFirst("MKPOICategory".count))
|
|
|
|
|
}
|
|
|
|
|
raw = raw.replacingOccurrences(of: "_", with: " ").replacingOccurrences(of: "-", with: " ")
|
|
|
|
|
if raw.isEmpty { return "Place" }
|
|
|
|
|
|
|
|
|
|
var result = ""
|
|
|
|
|
for scalar in raw.unicodeScalars {
|
|
|
|
|
if CharacterSet.uppercaseLetters.contains(scalar),
|
|
|
|
|
let last = result.unicodeScalars.last,
|
|
|
|
|
!CharacterSet.whitespaces.contains(last) {
|
|
|
|
|
result.append(" ")
|
|
|
|
|
}
|
|
|
|
|
result.append(String(scalar))
|
|
|
|
|
}
|
|
|
|
|
let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
return trimmed.isEmpty ? "Place" : trimmed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isTransitCategory(categoryRaw: String?, name: String) -> Bool {
|
|
|
|
|
let haystack = ([categoryRaw, name].compactMap { $0 }.joined(separator: " ")).lowercased()
|
|
|
|
|
let tokens = ["bus", "train", "rail", "subway", "metro", "tram", "ferry", "transit", "transport"]
|
|
|
|
|
return tokens.contains { haystack.contains($0) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func poiType(for category: MKPointOfInterestCategory?, categoryRaw: String?, name: String) -> POIType {
|
|
|
|
|
if let category {
|
|
|
|
|
switch category {
|
|
|
|
|
case .publicTransport, .airport:
|
|
|
|
|
return .transit
|
|
|
|
|
case .cafe:
|
|
|
|
|
return .cafe
|
|
|
|
|
case .restaurant, .bakery, .brewery, .winery:
|
|
|
|
|
return .food
|
|
|
|
|
case .foodMarket:
|
|
|
|
|
return .grocery
|
|
|
|
|
case .park, .nationalPark, .beach, .campground, .marina:
|
|
|
|
|
return .park
|
|
|
|
|
case .fitnessCenter:
|
|
|
|
|
return .fitness
|
|
|
|
|
case .hotel:
|
|
|
|
|
return .lodging
|
|
|
|
|
case .school, .university, .library:
|
|
|
|
|
return .education
|
|
|
|
|
case .hospital, .pharmacy:
|
|
|
|
|
return .health
|
|
|
|
|
case .store:
|
|
|
|
|
return .shopping
|
|
|
|
|
case .movieTheater, .museum, .nightlife, .amusementPark, .aquarium, .stadium, .theater, .zoo:
|
|
|
|
|
return .entertainment
|
|
|
|
|
case .atm, .bank, .postOffice, .police, .fireStation, .parking, .laundry, .carRental, .evCharger, .restroom:
|
|
|
|
|
return .services
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if isTransitCategory(categoryRaw: categoryRaw, name: name) {
|
|
|
|
|
return .transit
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let lower = name.lowercased()
|
|
|
|
|
if lower.contains("cafe") || lower.contains("coffee") {
|
|
|
|
|
return .cafe
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("restaurant") || lower.contains("pizza") || lower.contains("food") {
|
|
|
|
|
return .food
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("market") || lower.contains("grocery") {
|
|
|
|
|
return .grocery
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("park") {
|
|
|
|
|
return .park
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("gym") || lower.contains("fitness") {
|
|
|
|
|
return .fitness
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("museum") || lower.contains("theater") || lower.contains("cinema") || lower.contains("zoo") {
|
|
|
|
|
return .entertainment
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("school") || lower.contains("university") || lower.contains("college") {
|
|
|
|
|
return .education
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("hotel") {
|
|
|
|
|
return .lodging
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("hospital") || lower.contains("pharmacy") {
|
|
|
|
|
return .health
|
|
|
|
|
}
|
|
|
|
|
if lower.contains("mall") || lower.contains("store") || lower.contains("shop") {
|
|
|
|
|
return .shopping
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return .other
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 19:16:32 +00:00
|
|
|
}
|