Skip to content

Commit

Permalink
Get Geohashes in MKCoordinateRegion (#3)
Browse files Browse the repository at this point in the history
* Get Geohashes in an MKCoordinateRegion
  • Loading branch information
ualch9 committed Apr 29, 2023
1 parent c136935 commit 3c23bf5
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 1 deletion.
67 changes: 67 additions & 0 deletions MapPlayground.playground/Contents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//: A MapKit based Playground

import MapKit
import PlaygroundSupport
import GeohashKit

// MARK: - Boilerplate for MapView delegate
class PlaygroundMapViewDelegate: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
if let polygon = overlay as? GeohashPolygon {
let renderer = MKPolygonRenderer(polygon: polygon)
renderer.strokeColor = .black
renderer.lineWidth = 3.0
renderer.fillColor = UIColor.purple.withAlphaComponent(0.5)

return renderer
}

if let polygon = overlay as? MKPolygon {
let renderer = MKPolygonRenderer(polygon: polygon)
renderer.strokeColor = .black
renderer.lineWidth = 1.5
renderer.fillColor = UIColor.green.withAlphaComponent(0.3)

return renderer
}

fatalError()
}
}

class GeohashPolygon: MKPolygon {
var geohash: Geohash!

static func create(_ geohash: Geohash) -> GeohashPolygon {
let instance = GeohashPolygon(coordinateRegion: geohash.region)
instance.geohash = geohash
return instance
}
}

// MARK: - Playground Code

// Create a MKMapView
let mapViewDelegate = PlaygroundMapViewDelegate()
let mapView = MKMapView(frame: CGRect(x: 0, y: 0, width: 800, height: 800))
mapView.delegate = mapViewDelegate

// Add Seattle Capitol Hill overlay
let capitolHill = MKMapRect(
x: 43000993.632010244,
y: 93716493.6709905,
width: 15247.761742688715,
height: 24753.363981068134
)

let capitolHillRegion = MKCoordinateRegion(capitolHill)
let capitolHillPolygon = MKPolygon(coordinateRegion: capitolHillRegion)

mapView.setRegion(capitolHillRegion, animated: true)
mapView.addOverlay(capitolHillPolygon, level: .aboveRoads)

let regions = capitolHillRegion.geohashes(precision: 7)
mapView.addOverlays(regions.map(GeohashPolygon.create), level: .aboveLabels)

// Add the created mapView to our Playground Live View
PlaygroundPage.current.liveView = mapView
40 changes: 40 additions & 0 deletions MapPlayground.playground/Sources/MapKitHelpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import MapKit

extension MKPolygon {
public convenience init(coordinateRegion: MKCoordinateRegion) {
let coordinates = coordinateRegion.cornerCoordinates
self.init(coordinates: coordinates, count: coordinates.count)
}
}

extension MKCoordinateRegion {
public var cornerCoordinates: [CLLocationCoordinate2D] {
var points: [CLLocationCoordinate2D] = []

let sw = CLLocationCoordinate2D(
latitude: center.latitude - (span.latitudeDelta / 2.0),
longitude: center.longitude - (span.longitudeDelta / 2.0)
)
points.append(sw)

let nw = CLLocationCoordinate2D(
latitude: center.latitude + (span.latitudeDelta / 2.0),
longitude: center.longitude - (span.longitudeDelta / 2.0)
)
points.append(nw)

let ne = CLLocationCoordinate2D(
latitude: center.latitude + (span.latitudeDelta / 2.0),
longitude: center.longitude + (span.longitudeDelta / 2.0)
)
points.append(ne)

let se = CLLocationCoordinate2D(
latitude: center.latitude - (span.latitudeDelta / 2.0),
longitude: center.longitude + (span.longitudeDelta / 2.0)
)
points.append(se)

return points
}
}
4 changes: 4 additions & 0 deletions MapPlayground.playground/contents.xcplayground
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
<timeline fileName='timeline.xctimeline'/>
</playground>
10 changes: 10 additions & 0 deletions MapPlayground.playground/timeline.xctimeline
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Timeline
version = "3.0">
<TimelineItems>
</TimelineItems>
<TimelineItems>
</TimelineItems>
<TimelineItems>
</TimelineItems>
</Timeline>
11 changes: 10 additions & 1 deletion Sources/GeohashKit/Geohash.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ prefix func !(a: Parity) -> Parity {
}

/// A geohash is a rectangular cell expressing a location using an ASCII string, the size of which is determined by how long the string is, the longer, the more precise.
public struct Geohash {
public struct Geohash: Hashable, Equatable {
// MARK: - Types
enum CompassPoint {
/// Top
Expand Down Expand Up @@ -282,4 +282,13 @@ public struct Geohash {

return Geohash(coordinates: (latitude, longitude), precision: self.precision)
}

// MARK: - Hashable methods
public func hash(into hasher: inout Hasher) {
hasher.combine(geohash)
}

public static func ==(_ lhs: Geohash, _ rhs: Geohash) -> Bool {
return lhs.geohash == rhs.geohash
}
}
108 changes: 108 additions & 0 deletions Sources/GeohashKit/MapKit/Geohash+MapKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,114 @@ extension Geohash {

return MKCoordinateRegion(center: coordinates, span: span)
}

func intersects(_ region: MKCoordinateRegion) -> Bool {
let lhsRect = MKMapRect(self.region)
let rhsRect = MKMapRect(region)

return lhsRect.intersects(rhsRect)
}
}

extension MKCoordinateRegion {
/// Calculates the Geohashes that contains this `MKCoordinateRegion`. Includes partial geohashes.
/// - parameter precision: The Geohash precision to calculate with.
public func geohashes(precision: Int) -> Set<Geohash> {
guard let origin = Geohash(self.center, precision: precision) else {
return []
}

// 1. Get the most northwest geohash of the region
// ┌───┬───┬───┬───┬───┬───┬───┐
// │ ★╺│╺╺╺│╺╺╺│╺╮ │ │ │ │
// │ │ │ │ ╏ │ │ │ │
// │ │ │ │ ╏ │ │ │ │
// │ │ │ │ ╏ │ │ │ │ From the center of the region, traverse to the
// │ │ │ │ ● │ │ │ │ most northwest corner by going North then West.
// │ │ │ │ │ │ │ │
// │ │ │ │ │ │ │ │
// │ │ │ │ │ │ │ │
// └───┴───┴───┴───┴───┴───┴───┘
let northwestHash = recursiveUntilBounds(going: .west, recursiveUntilBounds(going: .north, origin))

var hashes: Set<Geohash> = []
var currentLeftMostGeohash: Geohash = northwestHash

// 2. Collect all Geohashes in the MKCoordinateRegion's bounds.
// ┌───┬───┬───┬───┬───┬───┬───┐
// │ → │ → │ → │ → │ → │ → │ ↵ │
// │ → │ → │ → │ → │ → │ → │ ↵ │
// │ … │ │ │ │ │ │ │
// │ │ │ │ │ │ │ │ Treat the geohash boundary as a matrix with an
// │ │ │ │ │ │ │ │ unknown number of columns and rows. Traverse
// │ │ │ │ │ │ │ │ each "cell" of the matrix and include the hash
// │ │ │ │ │ │ │ │ if it is in the bounds of this MKCoordinateRegion.
// │ │ │ │ │ │ │ │
// └───┴───┴───┴───┴───┴───┴───┘
repeat {
var currentGeohash: Geohash = currentLeftMostGeohash

repeat {
hashes.insert(currentGeohash)

// If unable to get the next neighbor, break out of this iteration.
guard let eastNeighbor = currentGeohash.neighbor(direction: .east) else {
break
}

currentGeohash = eastNeighbor
} while currentGeohash.intersects(self)

// If unable to get the next row, break out of this loop.
guard let southNeighbor = currentLeftMostGeohash.neighbor(direction: .south) else {
break
}

currentLeftMostGeohash = southNeighbor
} while currentLeftMostGeohash.intersects(self)

return hashes
}

/// Recursively traverses Geohashes in the specified direction until it reaches the top of this MKCoordinateRegion.
private func recursiveUntilBounds(going direction: Geohash.CompassPoint, _ geohash: Geohash) -> Geohash {
guard let neighbor = geohash.neighbor(direction: direction) else {
return geohash
}

let selfRect = MKMapRect(self)
let neighborRect = MKMapRect(neighbor.region)

if selfRect.intersects(neighborRect) {
return recursiveUntilBounds(going: direction, neighbor)
} else {
return geohash
}
}
}

extension MKMapRect {
init(_ coordinateRegion: MKCoordinateRegion) {
let topLeft = CLLocationCoordinate2D(
latitude: coordinateRegion.center.latitude + (coordinateRegion.span.latitudeDelta/2.0),
longitude: coordinateRegion.center.longitude - (coordinateRegion.span.longitudeDelta/2.0)
)

let bottomRight = CLLocationCoordinate2D(
latitude: coordinateRegion.center.latitude - (coordinateRegion.span.latitudeDelta/2.0),
longitude: coordinateRegion.center.longitude + (coordinateRegion.span.longitudeDelta/2.0)
)

let topLeftMapPoint = MKMapPoint(topLeft)
let bottomRightMapPoint = MKMapPoint(bottomRight)

let origin = MKMapPoint(x: topLeftMapPoint.x,
y: topLeftMapPoint.y)
let size = MKMapSize(width: fabs(bottomRightMapPoint.x - topLeftMapPoint.x),
height: fabs(bottomRightMapPoint.y - topLeftMapPoint.y))

self.init(origin: origin, size: size)
}
}
#endif

Expand Down
40 changes: 40 additions & 0 deletions Tests/GeohashKitTests/GeohashMapKitTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import XCTest
@testable import GeohashKit

#if canImport(MapKit)
import MapKit

final class GeohashMapKitTests: XCTestCase {
func testHashesInRegion() {
let captiolHill = MKMapRect(
x: 43000993.632010244,
y: 93716493.6709905,
width: 15247.761742688715,
height: 24753.363981068134
)

let region = MKCoordinateRegion(captiolHill)

// Test high-precision
XCTAssertGeohashesEqual(
region.geohashes(precision: 6),
["c23nbg", "c23nbm", "c23nbu", "c23nbe", "c23nby", "c23nbx", "c23nbw", "c23nbk", "c23nbz", "c23nbq", "c23nbr", "c23nbv", "c23nbt", "c23nb7", "c23nbs"]
)

XCTAssertGeohashesEqual(
region.geohashes(precision: 5),
["c23nb"]
)
}

private func XCTAssertGeohashesEqual(_ lhs: Set<Geohash>, _ rhs: Set<String>, _ message: String = "") {
let rhsGeohashes = rhs.compactMap(Geohash.init(geohash:))
XCTAssertEqual(lhs, Set(rhsGeohashes), message)
}

static var allTests = [
("testHashesInRegion", testHashesInRegion)
]
}

#endif

0 comments on commit 3c23bf5

Please sign in to comment.