Last active
September 15, 2021 16:58
-
-
Save a-voronov/c5c9b22c58e2347d941de0ed95535b57 to your computer and use it in GitHub Desktop.
proximity-hash
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import CoreLocation | |
/// Geohash algorithm implementation. | |
/// | |
/// It is a hierarchical spatial data structure which subdivides space into buckets of grid shape, | |
/// which is one of the many applications of what is known as a Z-order curve, and generally space-filling curves. | |
/// | |
/// Geohashes offer properties like arbitrary precision | |
/// and the possibility of gradually removing characters from the end of the code to reduce its size (and gradually lose precision). | |
/// Geohashing guarantees that the longer a shared prefix between two geohashes is, the spatially closer they are together. | |
/// The reverse of this is not guaranteed, as two points can be very close but have a short or no shared prefix. | |
/// | |
/// Links: | |
/// - [Wikipedia](https://en.wikipedia.org/wiki/Geohash) | |
/// - [Geohash.org](http://geohash.org) | |
/// - [Live Map](http://mapzen.github.io/leaflet-spatial-prefix-tree/) | |
/// - [JS](http://www.movable-type.co.uk/scripts/geohash.html) and [Python](https://github.com/wdm0006/pygeohash) implementations | |
public enum GeoHash { | |
// bit parity, used while encoding/decoding geohash | |
private enum Parity: Equatable { | |
case even, odd | |
mutating func toggle() { | |
switch self { | |
case .even: self = .odd | |
case .odd: self = .even | |
} | |
} | |
} | |
// geohash-specific base32 alphabet for encoding/decoding | |
private static let base32EncodeTable = Array("0123456789bcdefghjkmnpqrstuvwxyz") | |
private static let base32DecodeTable = base32EncodeTable.charAtIdxTable | |
// each character in geohash-specific base32 alphabet is 5 bits long | |
private static let base32CharFirstBit = 0b10000 | |
private static let base32CharLastBit = 0b00000 | |
// valid bounds of the world | |
static let world = ( | |
lat: (min: -90.0, max: 90.0), | |
lon: (min: -180.0, max: 180.0) | |
) | |
// minimal and maximal geohash lengths allowed | |
private static let geohashLengthBounds = (min: 1, max: 22) | |
// precalculated tables of neigbours for the character at respective position | |
private static let neighborsEncodeTable: [Direction: [Parity: [String.Element]]] = [ | |
.north: [.even: Array("p0r21436x8zb9dcf5h7kjnmqesgutwvy"), .odd: Array("bc01fg45238967deuvhjyznpkmstqrwx")], | |
.south: [.even: Array("14365h7k9dcfesgujnmqp0r2twvyx8zb"), .odd: Array("238967debc01fg45kmstqrwxuvhjyznp")], | |
.east: [.even: Array("bc01fg45238967deuvhjyznpkmstqrwx"), .odd: Array("p0r21436x8zb9dcf5h7kjnmqesgutwvy")], | |
.west: [.even: Array("238967debc01fg45kmstqrwxuvhjyznp"), .odd: Array("14365h7k9dcfesgujnmqp0r2twvyx8zb")] | |
] | |
private static let neighborsDecodeTable = neighborsEncodeTable.compactMapValues { neigborsPerParity in | |
zip(neigborsPerParity[.even], neigborsPerParity[.odd]).map { even, odd in | |
[Parity.even: even.charAtIdxTable, .odd: odd.charAtIdxTable] | |
} | |
} | |
// precalculated tables of borders for the grid | |
private static let bordersEncodeTable: [Direction: [Parity: [String.Element]]] = [ | |
.north: [.even: Array("prxz"), .odd: Array("bcfguvyz")], | |
.south: [.even: Array("028b"), .odd: Array("0145hjnp")], | |
.east: [.even: Array("bcfguvyz"), .odd: Array("prxz")], | |
.west: [.even: Array("0145hjnp"), .odd: Array("028b")] | |
] | |
private static let bordersDecodeTable = bordersEncodeTable.compactMapValues { neigborsPerParity in | |
zip(neigborsPerParity[.even], neigborsPerParity[.odd]).map { even, odd in | |
[Parity.even: even.charAtIdxTable, .odd: odd.charAtIdxTable] | |
} | |
} | |
/// Encodes a coordinate to a geohash string of a given length. | |
/// | |
/// If `length` is out of minimal and maximal bounds, it will be constrained to the closest one. | |
/// - Parameters: | |
/// - coordinate: coordinate to encode. | |
/// - length: min: 1, max: 22 | |
/// - Returns: geohash string of a given length. | |
/// | |
/// Example: | |
/// ``` | |
/// let geohash = GeoHash.encode( | |
/// coordinate: .init(latitude: 40.75798, longitude: -73.991516), | |
/// length: 12 | |
/// ) | |
/// print(geohash) // will print "dr5ru7c02wnv" | |
/// ``` | |
public static func encode(coordinate: CLLocationCoordinate2D, length: Int) -> String { | |
if let geohash = table[Key(coordinate: coordinate, length: length)] { | |
print(geohash) | |
return geohash | |
} | |
let geohash = Box(coordinate: coordinate, length: length).geohash | |
table[Key(coordinate: coordinate, length: length)] = geohash | |
return geohash | |
} | |
static var table = [Key: String]() | |
struct Key: Hashable { | |
let coordinate: CLLocationCoordinate2D, length: Int | |
} | |
/// Decodes given geohash string to a coordinate with the corresponding floating point precision. | |
/// | |
/// Will return nil if string is invalid. | |
/// | |
/// Valid geohash string should consist of the characters from this set: `0123456789bcdefghjkmnpqrstuvwxyz` | |
/// | |
/// Example: | |
/// ``` | |
/// let coord = GeoHash.decode(geohash: "dr5ru7c02wnv") | |
/// // CLLocationCoordinate2D(latitude: 40.75798, longitude: -73.991516) | |
/// ``` | |
public static func decode(geohash: String) -> CLLocationCoordinate2D? { | |
guard let box = Box(geohash: geohash) else { | |
return nil | |
} | |
let center = box.center | |
// calculate correct precision for the center coordinate | |
let latPrecisionMult = pow(10, max(1, (-log(box.northEast.latitude - box.southWest.latitude) / M_LN10).rounded(.down))) | |
let lonPrecisionMult = pow(10, max(1, (-log(box.northEast.longitude - box.southWest.longitude) / M_LN10).rounded(.down))) | |
// fail if result is invalid | |
return latPrecisionMult.isInfinite || latPrecisionMult.isNaN || lonPrecisionMult.isInfinite || lonPrecisionMult.isNaN | |
? nil | |
: CLLocationCoordinate2D( | |
latitude: (center.latitude * latPrecisionMult).rounded() / latPrecisionMult, | |
longitude: (center.longitude * lonPrecisionMult).rounded() / lonPrecisionMult | |
) | |
} | |
public enum Direction: Equatable { | |
case north, east, west, south | |
} | |
/// Represents a bounding box for a given geohash. | |
/// | |
/// Can be constructed by either providing a coordinate with required geohash length, or by providing a geohash string. | |
/// Contains geohash string and four vertices of its bounding box. | |
public struct Box: Equatable { | |
public let geohash: String | |
public let northEast: CLLocationCoordinate2D | |
public let southWest: CLLocationCoordinate2D | |
public var northWest: CLLocationCoordinate2D { return .init(latitude: northEast.latitude, longitude: southWest.longitude) } | |
public var southEast: CLLocationCoordinate2D { return .init(latitude: southWest.latitude, longitude: northEast.longitude) } | |
public var center: CLLocationCoordinate2D { | |
return CLLocationCoordinate2D( | |
latitude: (southWest.latitude + northEast.latitude) / 2, | |
longitude: (southWest.longitude + northEast.longitude) / 2 | |
) | |
} | |
/// Provides 4 vertice coordinates in a clockwise direction starting with `ne`. | |
public var vertices: [CLLocationCoordinate2D] { return [northEast, southEast, southWest, northWest] } | |
/// Construct a box by providing a coordinate and required geohash string length between 1 and 22. | |
public init(coordinate: CLLocationCoordinate2D, length: Int) { | |
// correct inputs to not fail encoding | |
let coord = CLLocationCoordinate2D( | |
latitude: coordinate.latitude.inRange(min: world.lat.min, max: world.lat.max), | |
longitude: coordinate.longitude.inRange(min: world.lon.min, max: world.lon.max) | |
) | |
let length = length.inRange(min: geohashLengthBounds.min, max: geohashLengthBounds.max) | |
var (lat, lon) = world | |
var geohash = "" | |
var charIdx = 0 | |
var bit = base32CharFirstBit | |
var bitParity = Parity.even | |
// construct geohash string of length defined by a length number, | |
// by searching corresponding cell in a grid while zooming in to a given length, | |
// and calculating & storing longitude char per every even bit, and latitude char per every odd bit in bitmask | |
while geohash.count < length { | |
if bitParity == .even { | |
let lonMid = (lon.min + lon.max) / 2 | |
if coord.longitude >= lonMid { | |
charIdx |= bit | |
lon.min = lonMid | |
} else { | |
lon.max = lonMid | |
} | |
} else { | |
let latMid = (lat.min + lat.max) / 2 | |
if coord.latitude >= latMid { | |
charIdx |= bit | |
lat.min = latMid | |
} else { | |
lat.max = latMid | |
} | |
} | |
// shift to the next bit | |
bit >>= 1 | |
bitParity.toggle() | |
// collect character and start over once reached end | |
if bit == base32CharLastBit { | |
geohash.append(base32EncodeTable[charIdx]) | |
bit = base32CharFirstBit | |
charIdx = 0 | |
} | |
} | |
self.northEast = CLLocationCoordinate2D(latitude: lat.max, longitude: lon.max) | |
self.southWest = CLLocationCoordinate2D(latitude: lat.min, longitude: lon.min) | |
self.geohash = geohash | |
} | |
public init?(geohash: String) { | |
guard !geohash.isEmpty else { | |
return nil | |
} | |
var (lat, lon) = world | |
var bitParity = Parity.even | |
for char in geohash { | |
guard let charIdx = base32DecodeTable[char] else { | |
return nil | |
} | |
var bit = base32CharFirstBit | |
while bit != base32CharLastBit { | |
let bitN = charIdx & bit | |
if bitParity == .even { | |
let lonMid = (lon.min + lon.max) / 2 | |
if bitN != 0 { | |
lon.min = lonMid | |
} else { | |
lon.max = lonMid | |
} | |
} else { | |
let latMid = (lat.min + lat.max) / 2 | |
if bitN != 0 { | |
lat.min = latMid | |
} else { | |
lat.max = latMid | |
} | |
} | |
// shift to the next bit | |
bit >>= 1 | |
bitParity.toggle() | |
} | |
} | |
self.northEast = CLLocationCoordinate2D(latitude: lat.max, longitude: lon.max) | |
self.southWest = CLLocationCoordinate2D(latitude: lat.min, longitude: lon.min) | |
self.geohash = geohash | |
} | |
} | |
} | |
// MARK: - Neighbors | |
public typealias GeoHashNeighbors = GeoHash.Neighbors<String> | |
extension GeoHash { | |
public struct Neighbors<T> { | |
public let north: T, east: T, south: T, west: T | |
public let northEast: T, southEast: T, southWest: T, northWest: T | |
/// Provides all neighbors as an array, in a clockwise direction starting with `n`. | |
public var all: [T] { | |
[north, northEast, east, southEast, south, southWest, west, northWest] | |
} | |
} | |
/// Provides an adjacent in specified direction for a rect by a given geohash. | |
public static func adjacent(geohash: String, direction: Direction) -> String? { | |
guard let lastChar = geohash.last else { | |
return nil | |
} | |
var parent: String? = String(geohash.dropLast()) | |
let parity = geohash.count.isMultiple(of: 2) ? Parity.even : .odd | |
// in case they don't share common prefix | |
if bordersDecodeTable[direction]?[parity]?[lastChar] != nil { | |
parent = parent.flatMap { adjacent(geohash: $0, direction: direction) } | |
} | |
// append adjacent character by its position in original alphabet | |
return zip(parent, neighborsDecodeTable[direction]?[parity]?[lastChar].map { base32EncodeTable[$0] }) | |
.map { parent, adjacentChar in | |
var parent = parent | |
parent.append(adjacentChar) | |
return parent | |
} | |
} | |
/// Provides all 8 neighboring geohashes for a given geohash. | |
public static func neighbors(geohash: String) -> GeoHashNeighbors? { | |
return Neighbors<String>(geohash: geohash) | |
} | |
} | |
extension GeoHash.Neighbors: Equatable where T: Equatable {} | |
extension GeoHashNeighbors { | |
public init?(geohash: String) { | |
guard | |
let north = GeoHash.adjacent(geohash: geohash, direction: .north), | |
let east = GeoHash.adjacent(geohash: geohash, direction: .east), | |
let south = GeoHash.adjacent(geohash: geohash, direction: .south), | |
let west = GeoHash.adjacent(geohash: geohash, direction: .west), | |
let northEast = GeoHash.adjacent(geohash: north, direction: .east), | |
let southEast = GeoHash.adjacent(geohash: south, direction: .east), | |
let southWest = GeoHash.adjacent(geohash: south, direction: .west), | |
let northWest = GeoHash.adjacent(geohash: north, direction: .west) | |
else { | |
return nil | |
} | |
self.north = north | |
self.east = east | |
self.south = south | |
self.west = west | |
self.northEast = northEast | |
self.southEast = southEast | |
self.southWest = southWest | |
self.northWest = northWest | |
} | |
} | |
// MARK: - Utils | |
private extension Array where Element == String.Element { | |
/// Constructs a character per its index table out of array. | |
var charAtIdxTable: [String.Element: Int] { | |
enumerated().reduce(into: [:]) { acc, charAtIdx in | |
acc[charAtIdx.element] = charAtIdx.offset | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import CoreLocation | |
import MapKit | |
/// ProximityHash generates a set of geohashes that cover a circular area, | |
/// given the center coordinates, the radius and the required length of geohash. | |
/// | |
/// Based on [proximityhash](https://github.com/ashwin711/proximityhash) . | |
/// However, instead of operating grid cells dimensions using metric distance units (m/km), which change from equator to the poles, | |
/// we operate with degrees deltas that are always the same. This gives us exactly precise results, unlike original version. | |
public enum ProximityHash { | |
private static let geohashLengthBounds = (min: 1, max: 12) | |
private static let minCellsToCheck = 2 | |
// precalculted spans for grid cells at given precision (1-12), | |
// they're always of the same values no matter what location you need, whether close to equator or close to the poles. | |
private static let gridSpans = [ | |
MKCoordinateSpan(latitudeDelta: 45.0, longitudeDelta: 45.0), | |
MKCoordinateSpan(latitudeDelta: 5.625, longitudeDelta: 11.25), | |
MKCoordinateSpan(latitudeDelta: 1.40625, longitudeDelta: 1.40625), | |
MKCoordinateSpan(latitudeDelta: 0.17578125, longitudeDelta: 0.3515625), | |
MKCoordinateSpan(latitudeDelta: 0.0439453125, longitudeDelta: 0.0439453125), | |
MKCoordinateSpan(latitudeDelta: 0.0054931640625, longitudeDelta: 0.010986328125), | |
MKCoordinateSpan(latitudeDelta: 0.001373291015625, longitudeDelta: 0.001373291015625), | |
MKCoordinateSpan(latitudeDelta: 0.000171661376953125, longitudeDelta: 0.00034332275390625), | |
MKCoordinateSpan(latitudeDelta: 4.291534423828125e-05, longitudeDelta: 4.291534423828125e-05), | |
MKCoordinateSpan(latitudeDelta: 5.364418029785156e-06, longitudeDelta: 1.0728836059570312e-05), | |
MKCoordinateSpan(latitudeDelta: 1.341104507446289e-06, longitudeDelta: 1.341104507446289e-06), | |
MKCoordinateSpan(latitudeDelta: 1.6763806343078613e-07, longitudeDelta: 3.3527612686157227e-07) | |
] | |
/// Represents the strategy of constructing geohashes. | |
public enum Inclusion: Equatable { | |
/// Will include geohashes that intersect with the circle area even a bit. | |
case bounding | |
/// Will include geohashes which are only inside the circle area 100%. | |
case bounded | |
} | |
/// Generates a set of geohashes that approximate a circle. | |
/// | |
/// There will always be at least center point's geohash, | |
/// no matter what inclusion is and how small the circle area is, even if it's smaller than the geohash cell. | |
/// | |
/// - Parameters: | |
/// - center: center coordinate (origin of the circle area) | |
/// - radius: radius in meters | |
/// - length: length of geohash strings from 1 to 12. The higher the number, the small the cells will be | |
/// - inclusion: | |
/// a choice to either get fully bound geohashes inside a circle are, | |
/// or to keep bounding geohashes that intersect circle area even a bit | |
/// - Returns: | |
/// - center: | |
/// - geohashes: unqiue set of geohash strings | |
public static func geohashes( | |
aroundCoordinate center: CLLocationCoordinate2D, | |
inRadius radius: CLLocationDistance, | |
ofLength length: Int, | |
inclusion: Inclusion = .bounding | |
) -> Set<String> { | |
var points = Set<CLLocationCoordinate2D>() | |
var geohashes = Set<String>() | |
// keep length in supported range | |
let length = length.inRange(min: geohashLengthBounds.min, max: geohashLengthBounds.max) | |
let span = gridSpans[length - 1] | |
// constructing a bounding rect from radius in metrtic points to get its span deltas in degrees | |
let circleBoundingRect = MKCoordinateRegion(center: center, latitudinalMeters: radius * 2, longitudinalMeters: radius * 2) | |
let circleLatSpan = min(circleBoundingRect.span.latitudeDelta, GeoHash.world.lat.max) | |
let circleLonSpan = min(circleBoundingRect.span.longitudeDelta, GeoHash.world.lon.max) | |
let (circleLatRadius, circleLonRadius) = (circleLatSpan / 2, circleLonSpan / 2) | |
// calculating number of cells inside this rect in vertical and horizontal directions | |
let latCells = max(minCellsToCheck, Int((circleLatSpan / span.latitudeDelta).rounded(.up))) | |
let lonCells = max(minCellsToCheck, Int((circleLonSpan / span.longitudeDelta).rounded(.up))) | |
// creating a circular region from radius in metrtic points, | |
// to help figuring out whether given coordinate is inside of it during future calculations | |
let circle = CLCircularRegion( | |
center: center, | |
radius: radius, | |
identifier: "ProximityHash.geohashes(c:\(center),r:\(radius),l:\(length))" | |
) | |
// calculating center of the geohash bounding box for the given area center, | |
// to help precisely move from one box to another using span deltas for the given precision | |
let bbox = GeoHash.Box(coordinate: center, length: length) | |
let origin = bbox.center | |
// for bounded inclusion, no need to perform any extra work if circle area is smaller than the cell | |
if inclusion == .bounded && (span.latitudeDelta >= circleLatSpan || span.longitudeDelta >= circleLonSpan) { | |
return [bbox.geohash] | |
} | |
// we start moving from the center of the area in the north-east direction, | |
// and for each such move we generate 4 points equally-distanced from the center in all 4 directions (ne, se, sw, nw). | |
// this way we can work within each sector of the circle area and reduce cost of checking if cell falls into it. | |
for latCellIdx in 0 ..< latCells { | |
let latDiff = span.latitudeDelta * Double(latCellIdx) | |
// all coordinates' calculations should respect max and min coordinates on the map to wrap their values around them | |
// i.e. we can't have longitude greater than 180 or less than -180, | |
// and if we do, we should move remainder to the 'other side', so that 185 would become -175, and same for longitude. | |
let northLat = corrected(lat: origin.latitude + latDiff) | |
let southLat = corrected(lat: origin.latitude - latDiff) | |
for lonCellIdx in 0 ..< lonCells { | |
let lonDiff = span.longitudeDelta * Double(lonCellIdx) | |
let eastLon = corrected(lon: origin.longitude + lonDiff) | |
let westLon = corrected(lon: origin.longitude - lonDiff) | |
// constructing 4 cells - 1 for each sector of the circle area | |
let nw = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: northLat, longitude: westLon), span: span) | |
let ne = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: northLat, longitude: eastLon), span: span) | |
let se = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: southLat, longitude: eastLon), span: span) | |
let sw = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: southLat, longitude: westLon), span: span) | |
switch inclusion { | |
case .bounding: | |
if lonCellIdx == 0 { | |
// some hacks to avoid even more complex calculations: | |
// | |
// the only cells that can intersect circle area, without having any of their vertices inside that area, | |
// is when cells intersect it with their edges, yet not too far to intersect it with any of their vertex. | |
// i.e. like this: | |
// ___ | |
// _/ \_ +–––––+ | |
// / \| | | |
// ( |) | | |
// \_ _/| | | |
// \___/ +–––––+ | |
// | |
// this can happen to only 4 cells, coming from the north, south, east and west, | |
// that's why we check each cell only in 0 latitude and 0 longitude positions for exactly this case. | |
// | |
// thus, in longitude 0 position, we check each cell's latitude edge which is closer to the center, | |
// and if that edge is closer to the center than circle's latitude radius, then it intersects that area. | |
// | |
// same is true for the longitude direction, when latitude cell position is 0 | |
// and its longitude edge is closer to the center than circle's longitude radius delta. | |
if abs(corrected(lat: ne.sLat - center.latitude)) < circleLatRadius { points.insert(ne.center) } | |
if abs(corrected(lat: nw.sLat - center.latitude)) < circleLatRadius { points.insert(nw.center) } | |
if abs(corrected(lat: center.latitude - se.nLat)) < circleLatRadius { points.insert(se.center) } | |
if abs(corrected(lat: center.latitude - sw.nLat)) < circleLatRadius { points.insert(sw.center) } | |
} else if latCellIdx == 0 { | |
if abs(corrected(lon: nw.eLon - center.longitude)) < circleLonRadius { points.insert(nw.center) } | |
if abs(corrected(lon: sw.eLon - center.longitude)) < circleLonRadius { points.insert(sw.center) } | |
if abs(corrected(lon: center.longitude - ne.wLon)) < circleLonRadius { points.insert(ne.center) } | |
if abs(corrected(lon: center.longitude - se.wLon)) < circleLonRadius { points.insert(se.center) } | |
} else { | |
// this is the most common case - for each cell in each of 4 sectors, | |
// we check if that cell's closest point to the circle center is contained in the circle area. | |
// i.e. for the cell in `ne` sector, point to check would be `sw` | |
if circle.contains(nw.se) { points.insert(nw.center) } | |
if circle.contains(ne.sw) { points.insert(ne.center) } | |
if circle.contains(se.nw) { points.insert(se.center) } | |
if circle.contains(sw.ne) { points.insert(sw.center) } | |
} | |
case .bounded: | |
// if requesting only bounded cells, apply same strategy - make exceptional checks for 0 lat and 0 lon cells | |
// and check if both furthest from the center vertices are inside the circle area. | |
if lonCellIdx == 0 { | |
if circle.contains(nw.nw) && circle.contains(nw.ne) { points.insert(nw.center) } | |
if circle.contains(ne.ne) && circle.contains(ne.nw) { points.insert(ne.center) } | |
if circle.contains(se.se) && circle.contains(se.sw) { points.insert(se.center) } | |
if circle.contains(sw.sw) && circle.contains(sw.se) { points.insert(sw.center) } | |
} else if latCellIdx == 0 { | |
if circle.contains(nw.nw) && circle.contains(nw.sw) { points.insert(nw.center) } | |
if circle.contains(ne.ne) && circle.contains(ne.se) { points.insert(ne.center) } | |
if circle.contains(se.se) && circle.contains(se.ne) { points.insert(se.center) } | |
if circle.contains(sw.sw) && circle.contains(sw.nw) { points.insert(sw.center) } | |
} else { | |
// this is the most common case - for each cell in each of 4 sectors, | |
// we check if that cell's furthest point from the circle center is contained in the circle area. | |
// i.e. for the cell in `ne` sector, point to check would be `ne` | |
// | |
// basically inverted from what we did with `bounding` strategy, | |
// and it is cheaper than checking if all four vertices fall in a circle area, but gives same result :) | |
if circle.contains(nw.nw) { points.insert(nw.center) } | |
if circle.contains(ne.ne) { points.insert(ne.center) } | |
if circle.contains(se.se) { points.insert(se.center) } | |
if circle.contains(sw.sw) { points.insert(sw.center) } | |
} | |
} | |
} | |
} | |
for point in points { | |
geohashes.insert(GeoHash.encode(coordinate: point, length: length)) | |
} | |
// we always include coordinate's geohash in any case | |
geohashes.insert(bbox.geohash) | |
return geohashes | |
} | |
} | |
extension MKCoordinateRegion { | |
var wLon: CLLocationDegrees { return corrected(lon: center.longitude - span.longitudeDelta / 2) } | |
var eLon: CLLocationDegrees { return corrected(lon: center.longitude + span.longitudeDelta / 2) } | |
var nLat: CLLocationDegrees { return corrected(lat: center.latitude + span.latitudeDelta / 2) } | |
var sLat: CLLocationDegrees { return corrected(lat: center.latitude - span.latitudeDelta / 2) } | |
var nw: CLLocationCoordinate2D { return CLLocationCoordinate2D(latitude: nLat, longitude: wLon) } | |
var se: CLLocationCoordinate2D { return CLLocationCoordinate2D(latitude: sLat, longitude: eLon) } | |
var sw: CLLocationCoordinate2D { return CLLocationCoordinate2D(latitude: sLat, longitude: wLon) } | |
var ne: CLLocationCoordinate2D { return CLLocationCoordinate2D(latitude: nLat, longitude: eLon) } | |
var vertices: [CLLocationCoordinate2D] { return [ne, se, sw, nw] } | |
} | |
/// Corrects longitude if it gets out of bounds. | |
/// i.e. 183 -> -177 or -196 -> 164 | |
private func corrected(lon: CLLocationDegrees) -> CLLocationDegrees { | |
if lon > GeoHash.world.lon.max { | |
return GeoHash.world.lon.min + (lon - GeoHash.world.lon.max) | |
} else if lon < GeoHash.world.lon.min { | |
return GeoHash.world.lon.max + (lon - GeoHash.world.lon.min) | |
} | |
return lon | |
} | |
/// Corrects latitude if it gets out of bounds. | |
/// i.e. 93 -> -87 or -106 -> 74 | |
private func corrected(lat: CLLocationDegrees) -> CLLocationDegrees { | |
if lat > GeoHash.world.lat.max { | |
return GeoHash.world.lat.min + (lat - GeoHash.world.lat.max) | |
} else if lat < GeoHash.world.lat.min { | |
return GeoHash.world.lat.max + (lat - GeoHash.world.lat.min) | |
} | |
return lat | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
extension CLLocationCoordinate2D: Hashable { | |
public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { | |
return lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude | |
} | |
public func hash(into hasher: inout Hasher) { | |
hasher.combine(latitude) | |
hasher.combine(longitude) | |
} | |
} | |
extension Comparable { | |
func inRange(min minValue: Self, max maxValue: Self) -> Self { | |
guard minValue < maxValue else { return self } | |
return min(max(self, minValue), maxValue) | |
} | |
} | |
func zip<A, B>(_ a: A?, _ b: B?) -> (A, B)? { | |
return a.flatMap { a in b.map { b in (a, b) } } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example
You can also ask to only return geohashes for b-boxes fully bounded inside the circle area:
The longer hashes you want to get, the less the area they will take, and the more of them you'll get: