1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import CoreLocation
import Foundation
import SwiftyH3
struct NominatimResult {
let placeId: Int
let latitude: Double
let longitude: Double
let name: String
let context: String?
let address: [String: AnyCodable]?
init?(from json: [String: Any]) {
guard let placeId = json["place_id"] as? Int else { return nil }
self.placeId = placeId
if let lat = json["lat"] as? String, let lon = json["lon"] as? String,
let latD = Double(lat), let lonD = Double(lon)
{
latitude = latD
longitude = lonD
} else if let lat = json["lat"] as? Double, let lon = json["lon"] as? Double {
latitude = lat
longitude = lon
} else {
return nil
}
let addr = json["address"] as? [String: Any]
let city = addr?["city"] as? String ?? addr?["town"] as? String ?? addr?["village"] as? String
var locationParts: [String] = []
if let city {
locationParts.append(city)
}
if let state = addr?["state"] as? String {
locationParts.append(state)
}
if let country = addr?["country"] as? String {
locationParts.append(country)
}
if let placeName = json["name"] as? String, !placeName.isEmpty {
name = placeName
} else {
name = locationParts.isEmpty
? (json["display_name"] as? String ?? "Unknown").components(separatedBy: ",").first ?? "Unknown"
: locationParts.joined(separator: ", ")
}
// Use display_name for context, stripping the place name prefix for more detail
if let displayName = json["display_name"] as? String {
let stripped = displayName.drop(while: { $0 != "," })
.dropFirst() // drop the comma
.trimmingCharacters(in: .whitespaces)
context = stripped.isEmpty ? (locationParts.isEmpty ? nil : locationParts.joined(separator: ", ")) : stripped
} else {
context = locationParts.isEmpty ? nil : locationParts.joined(separator: ", ")
}
if let countryCode = (addr?["country_code"] as? String)?.uppercased() {
var addressFields: [String: AnyCodable] = ["country": AnyCodable(countryCode)]
if let city {
addressFields["locality"] = AnyCodable(city)
}
if let state = addr?["state"] as? String {
addressFields["region"] = AnyCodable(state)
}
if let road = addr?["road"] as? String {
if let houseNumber = addr?["house_number"] as? String {
addressFields["street"] = AnyCodable("\(houseNumber) \(road)")
} else {
addressFields["street"] = AnyCodable(road)
}
}
if let postcode = addr?["postcode"] as? String {
addressFields["postalCode"] = AnyCodable(postcode)
}
address = addressFields
} else {
address = nil
}
}
}
enum LocationServices {
/// Convert lat/lon to H3 index string at resolution 10.
static func latLonToH3(latitude: Double, longitude: Double) -> String {
let latLng = H3LatLng(latitudeDegs: latitude, longitudeDegs: longitude)
guard let cell = try? latLng.cell(at: .res10) else { return "" }
return cell.description
}
/// Convert an H3 index string to a CLLocationCoordinate2D.
static func h3ToCoordinate(_ h3Index: String) -> CLLocationCoordinate2D? {
guard let cell = H3Cell(h3Index),
let center = try? cell.center else { return nil }
return center.coordinates
}
/// Reverse geocode coordinates via Nominatim.
static func reverseGeocode(latitude: Double, longitude: Double) async -> NominatimResult? {
var components = URLComponents(string: "https://nominatim.openstreetmap.org/reverse")!
components.queryItems = [
URLQueryItem(name: "format", value: "json"),
URLQueryItem(name: "lat", value: String(latitude)),
URLQueryItem(name: "lon", value: String(longitude)),
URLQueryItem(name: "addressdetails", value: "1"),
]
guard let url = components.url else { return nil }
var request = URLRequest(url: url)
request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent")
guard let (data, _) = try? await NetworkEnvironment.session.data(for: request),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return nil
}
return NominatimResult(from: json)
}
/// Search for locations via Nominatim.
static func searchLocation(query: String) async -> [NominatimResult] {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard trimmed.count >= 2 else { return [] }
var components = URLComponents(string: "https://nominatim.openstreetmap.org/search")!
components.queryItems = [
URLQueryItem(name: "format", value: "json"),
URLQueryItem(name: "q", value: trimmed),
URLQueryItem(name: "limit", value: "5"),
URLQueryItem(name: "addressdetails", value: "1"),
]
guard let url = components.url else { return [] }
var request = URLRequest(url: url)
request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent")
guard let (data, _) = try? await NetworkEnvironment.session.data(for: request),
let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
return []
}
return json.compactMap { NominatimResult(from: $0) }
}
}