// // POIDataSource.swift // iris // // Created by Codex. // import CoreLocation import Foundation import MapKit struct POIDataSourceConfig: Sendable { 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 init() {} } /// MapKit-backed POI source. final class POIDataSource { 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" } struct POI: Sendable, Equatable { let id: String let name: String let category: String let poiType: POIType let distanceMeters: CLLocationDistance let walkingMinutes: Int let ttlSec: Int let confidence: Double let isTransit: Bool } private let config: POIDataSourceConfig init(config: POIDataSourceConfig = .init()) { self.config = config } func data(for location: CLLocation, now: Int) async throws -> [POI] { print("finding pois for location \(location)") _ = now 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 = [] 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: "_") } 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 } }