Search code examples
iosswiftgeolocationmkmapview

MKMapView MKCircle renders a circle with too big radius


I'm facing with a strange behaviour of MKCircle appearance. Basically I'm trying to draw a circle with a radius of 8500 km with an arbitrary center. Here is my code:

private func addCircle() {
    mapView.removeOverlays(mapView.overlays)
    let circle = MKCircle(centerCoordinate: mapCenter, radius: 8500000.0)
    mapView.addOverlay(circle)
}

I also have a custom double tap gesture handler, which overwrites the standard one for map view and allows to change the map center by double tapping on the map view:

private func configureGestureRecognizer() {
    doubleTapGestureRecognizer.addTarget(self, action: Selector("handleDoubleTap:"))
    doubleTapGestureRecognizer.numberOfTapsRequired = 2
    if let subview = mapView.subviews.first as? UIView {
        subview.addGestureRecognizer(doubleTapGestureRecognizer)
    }
    else {
        println("Can't add a gesture recognizer")
    }
}

@objc private func handleDoubleTap(sender: UITapGestureRecognizer) {
    let point = sender.locationInView(mapView)
    let location = mapView.convertPoint(point, toCoordinateFromView: mapView)
    mapCenter = location
    addCircles()
}

The results are very strange:

Center in New York City

Center in far north from NYC

You may notice a significant difference between those two radiuses: the second one is a way bigger than the first one!

What's going on and how do I make them appear correctly?

EDIT

Thanks to @blacksquare I could get closer to solution, but still have an issue with the north pole:

enter image description here

(Small circle jsut represents a center)


Solution

  • According to Apple's documentation of MKCircle: "As latitude values move away from the equator and toward the poles, the physical distance between map points gets smaller. This means that more map points are needed to represent the same distance. As a result, the bounding rectangle of a circle overlay gets larger as the center point of that circle moves away from the equator and toward the poles."

    So as Anna and Warren both mentioned, this isn't a bug--this is the intended behavior. There seems, however, to be a discrepancy in the documentation between boundingMapRect and radius. The documentation suggests that the radius is the measure in meters from the center point, which is clearly not the case in your example.

    I think what's going on here is that Apple probably never intended MKCircle to be used on the scale that you're using it on. MKCircle creates a 2D circle, which can't be both a circle and an accurate representation of a circular area on a projection map.

    Now if all you want to do is create a uniform circle that isn't distorted and has a radius relative to its length at the equator, you can set the length of the circle at the equator as the base radius and then calculate the proportion of the radius at the current point like this:

    let baseCoord = CLLocationCoordinate2D(latitude: 0, longitude: 0)
    let radius: Double = 850000.0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        mapView.region = MKCoordinateRegion(
            center: baseCoord,
            span: MKCoordinateSpan(
                latitudeDelta: 90,
                longitudeDelta: 180
            )
        )
        mapCenter = baseCoord
        let circle = MKCircle(centerCoordinate: mapCenter, radius: radius)
        baseRadius = circle.boundingMapRect.size.height / 2
    
        mapView.delegate = self
        configureGestureRecognizer()
    }
    
    private func addCircle() {
    
        mapView.removeOverlays(mapView.overlays)
        let circle = MKCircle(centerCoordinate: mapCenter, radius: radius)
    
        var currentRadius = circle.boundingMapRect.size.height / 2
        let factor = baseRadius / currentRadius
        var updatedRadius = factor * radius
    
        let circleToDraw = MKCircle(centerCoordinate: mapCenter, radius: updatedRadius)
        mapView.addOverlay(circleToDraw)
    }
    

    But if your plan is to accurately cover all space within x meters of the click, it's a bit trickier. First you'll grab the click-coordinate in the double-click action and then use that as the center of a polygon.

    @objc private func handleDoubleTap(sender: UITapGestureRecognizer) {
        let point = sender.locationInView(mapView)
        currentCoord = mapView.convertPoint(point, toCoordinateFromView: mapView)
        mapCenter = currentCoord
        addPolygon()
    }
    

    In addPolygon, get your coordinates and set up your overlays:

    private func addPolygon() {
        var mapCoords = getCoordinates()
        mapView.removeOverlays(mapView.overlays)
    
        let polygon = MKPolygon(coordinates: &mapCoords, count: mapCoords.count)
        mapView.addOverlay(polygon)
    }
    

    Given a point, a bearing, and an angular distance (distance between coordinates divided by the earth's radius), you can calculate the location of another coordinate using the following formula. Be sure to import Darwin so you can have access to a library of trigonometric functions

    let globalRadius: Double = 6371000
    let π = M_PI
    
    private func getCoordinates() -> [CLLocationCoordinate2D] {
        var coordinates = [CLLocationCoordinate2D]()
    
        let lat1: Double = (currentCoord!.latitude)
        let long1: Double = (currentCoord!.longitude) + 180
        let factor = 30
    
        if let a = annotation {
            mapView.removeAnnotation(annotation)
        }
    
        annotation = MKPointAnnotation()
        annotation!.setCoordinate(currentCoord!)
        annotation!.title = String(format: "%1.2f°, %1.2f°", lat1, long1)
        mapView.addAnnotation(annotation)
    
        var φ1: Double = lat1 * (π / 180)
        var λ1: Double = long1 * (π / 180)
        var angularDistance =  radius / globalRadius
    
        var metersToNorthPole: Double = 0
        var metersToSouthPole: Double = 0
    
        for i in Int(lat1)..<89 {
            metersToNorthPole = metersToNorthPole + 111132.92 - (559.82 * cos(2 * φ1)) + (1.175 * cos(4 * φ1))
        }
    
        for var i = lat1; i > -89; --i {
            metersToSouthPole = metersToSouthPole + 111132.92 - (559.82 * cos(2 * φ1)) + (1.175 * cos(4 * φ1))
        }
    
        var startingBearing = -180
        var endingBearing = 180
    
        if metersToNorthPole - radius <= 0 {
            endingBearing = 0
            startingBearing = -360
        }
    
        for var i = startingBearing; i <= endingBearing; i += factor {
    
            var bearing = Double(i)
    
            var bearingInRadians: Double = bearing * (π / 180)
    
            var φ2: Double = asin(sin(φ1) * cos(angularDistance)
                + cos(φ1) * sin(angularDistance)
                * cos(bearingInRadians)
            )
    
            var λ2 = atan2(
                sin(bearingInRadians) * sin(angularDistance) * cos(φ1),
                cos(angularDistance) - sin(φ1) * sin(φ2)
            ) + λ1
    
            var lat2 = φ2 * (180 / π)
            var long2 = ( ((λ2 % (2 * π)) - π)) * (180.0 / π)
    
            if long2 < -180 {
                long2 = 180 + (long2 % 180)
            }
    
            if i == startingBearing && metersToNorthPole - radius <= 0 {
                coordinates.append(CLLocationCoordinate2D(latitude: 90, longitude: long2))
            } else if i == startingBearing && metersToSouthPole - radius <= 0 {
                coordinates.append(CLLocationCoordinate2D(latitude: -90, longitude: long2))
            }
    
            coordinates.append(CLLocationCoordinate2D(latitude: lat2, longitude: long2))
        }
    
        if metersToNorthPole - radius <= 0 {
            coordinates.append(CLLocationCoordinate2D(latitude: 90, longitude: coordinates[coordinates.count - 1].longitude))
        } else if metersToSouthPole - radius <= 0 {
            coordinates.append(CLLocationCoordinate2D(latitude: -90, longitude: coordinates[coordinates.count - 1].longitude))
        }
    
        return coordinates
    }
    

    In getCoordinates we translate degrees to radians, and then add a few more anchoring coordinate in the event that our radius is greater than the distance to the north or south poles.

    Here are a couple examples of curves near the pole with radiuses of 8500km and 850km, respectively:

    enter image description here enter image description here

    Here's a sample of the final output with an additional MKGeodesicPolyline overlay (Geodesics represent the shortest possible curve over a spherical surface), that shows how the curve is actually being built:

    enter image description here