MKMapView MKCircle renders a circle with too big radius
Asked Answered
L

3

6

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)

Lumpkin answered 5/3, 2015 at 7:22 Comment(8)
I don't think this has to do with the Earth's "equatorial bulge" (which does exist). I believe this drawing effect has to do with map projection (flattening sphere into 2D stretches objects near the poles).Cb
So that's a bug? Is there any workaround?Lumpkin
just an observation I made: The radius seems to expand by twice the movement of the center in your two pictures. If you had the circle drawn before and then moved it might be the redraw method getting confused and messes up the radius.Periotic
@Sim, thanks for the comment, but I explicitly set the radiusLumpkin
Got to agree with Anna . Note how when you double click around the equator the circle pulls into its minimum. Slightly related is this article dailymail.co.uk/news/article-2445615/…Wonderstricken
@Anna could be on to something regarding map projection, even though it seems a little strange. To rule it out, try adding three or four circles with hard corded center and radius, i.e. In New York, in Panama City and in Brasilia and somewhere way north like Greenland. And compare those circles. Doing this limits the field of errors somewhat...Anaglyph
@AndreyGordeev I have an unrelated question, when adding such a large overlayview, do you experience a flickering effect when panning the map? in other words, does the overlay load by tiles and you can see how these tiles loads as the area where they are supposed to lay becomes visible?Fennie
@Fennie I didn't notice such effect. Do you experience this on iOS Simulator? If yes, try to run the project on device.Lumpkin
J
10

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

Judejudea answered 9/3, 2015 at 2:10 Comment(5)
Interesting calculations... Regarding the pole feature, wouldn't it be possible to use the angular distance somehow to see if you are crossing over to the other side, and if so just limit the polygon to either -90 or 90 in the latitude?Anaglyph
I think so--yes. I started to experiment with it, but felt like I needed to touch up on my trig before going further in depth :)Judejudea
Thanks for this answer, this is the best one so far! However, with this approach, if I set a center near by the north pole, my overlay has an empty space around the center (see my updated question for a screenshot)Lumpkin
Also when I set radius = 8500000, the result seems weird as wellLumpkin
Hi, Andrey, I've got a solution for you. See above.Judejudea
A
6

Answer to OP's question

What's going on is that the map shown will get discrepancies due to the map projection being used. The MKCircle method will produce accurate circles at any given latitude (provided the radius is not covering to many latitudes), but they will vary in size due to map projection.

In order to get similar circles at a large zoom level, you have to change the radius relative to your latitude, and this might effect the longitude distance. In addition what does the circle now represent?

To get equal circles one way is to use MapPoints, which has a latitude relative method, namely MKMetersPerMapPointAtLatitude. If you multiply this with a given number at locations around the world, the circles will be of equal size, but as already stated: What does this circle represent?

In other words: You need to think on what the circle is supposed to represent, because used without correction it does represent the distance from the location, but it sure doesn't seem like it when viewing multiple circles on a world map due to map projection issues!

Example displaying projection feature of MKMapView

I made a little sample application where I added some circles at different locations around the world with hardcoded location and radius. This produced the image to the left, which has varying circle sizes.

Using the latitude relative method as depicted in code below, the circles are of the same size. I also added one circle in Panama City with radius equaling the distance to Mexico City, indicating that the CLLocationDistance (in meters) at a given latitude is somewhat correct.

MKMapView_FixedRadius MKMapView_LatitudeRelativeRadius

Image generating code

The interesting part of the code used to produce the image to the right image is listed below. The left image was based on the same code with removal of the * MKMetersPerMapPointAtLatitude(...) part, and a different radius.

let panamaCityLoc = CLLocationCoordinate2D(latitude: 8.9936000, longitude: -79.51979300)
let mexicoCityLoc = CLLocationCoordinate2D(latitude: 19.4284700, longitude: -99.1276600)
let newYorkLoc = CLLocationCoordinate2D(latitude: 40.7142700, longitude: -74.0059700)
let nuukLoc = CLLocationCoordinate2D(latitude: 64.1834700, longitude: -51.7215700)
let northlyLoc = CLLocationCoordinate2D(latitude: 80.0000000, longitude: -68.00)
var mapCenter = nuukLoc
mapView.centerCoordinate = mapCenter

var radiusInSomething : CLLocationDistance = 10000000.0

mapView.removeOverlays(mapView.overlays)
mapView.addOverlay(MKCircle(centerCoordinate: nuukLoc,
     radius: radiusInSomething * MKMetersPerMapPointAtLatitude(nuukLoc.latitude)))
mapView.addOverlay(MKCircle(centerCoordinate: panamaCityLoc,
    radius: radiusInSomething * MKMetersPerMapPointAtLatitude(panamaCityLoc.latitude)))
mapView.addOverlay(MKCircle(centerCoordinate: newYorkLoc,
    radius: radiusInSomething * MKMetersPerMapPointAtLatitude(newYorkLoc.latitude)))
mapView.addOverlay(MKCircle(centerCoordinate: mexicoCityLoc,
    radius: radiusInSomething * MKMetersPerMapPointAtLatitude(mexicoCityLoc.latitude)))
mapView.addOverlay(MKCircle(centerCoordinate: northlyLoc,
    radius: radiusInSomething * MKMetersPerMapPointAtLatitude(northlyLoc.latitude)))

// Try to figure out something related to distances...
var panamaCityMapPoint = MKMapPointForCoordinate(panamaCityLoc)
var mexicoCityMapPoint = MKMapPointForCoordinate(mexicoCityLoc)
var distancePanamaToMexixo = MKMetersBetweenMapPoints(panamaCityMapPoint, mexicoCityMapPoint)

println("Distance Panama City to Mexico City according to dateandtime.info: 2410 km")
println("Distance Panama City to Mexico: \(distancePanamaToMexixo) CLLocationDistance (or m)")
println("  meters/MapPoint at latitude Panama City: \( MKMetersPerMapPointAtLatitude(panamaCityLoc.latitude) ) ")
println("  in mapPoints: \( distancePanamaToMexixo  / MKMetersPerMapPointAtLatitude(panamaCityLoc.latitude) ) ")

mapView.addOverlay(MKCircle(centerCoordinate: panamaCityLoc, radius: distancePanamaToMexixo))

I added some println regarding different distances, map point, etc. at the end, and these produced the following output:

Distance Panama City to Mexico City according to dateandtime.info: 2410 km
Distance Panama City to Mexico: 2408968.73912751 CLLocationDistance (or m)
  meters/MapPoint at latitude Panama City: 0.146502523951599 
  in mapPoints: 16443189.333198 
Anaglyph answered 9/3, 2015 at 3:0 Comment(0)
P
1

If anybody wants to implement this in Swift 3, I made a subclass of MKPolygon that renders a geodesic circle based on Kellan's excellent answer.

Just create it with

let circle = MKGeodesicCircle(center: CLLocationCoordinate2D, radius: 100000)

This is the Swift file

import UIKit
import Darwin
import CoreLocation
import MapKit
class MKGeodesicCircle: MKPolygon {

    convenience init(center: CLLocationCoordinate2D, radius: CLLocationDistance) {
        self.init(center: center, radius: radius, fromRadial: 0, toRadial: 360)
    }

    convenience init(center: CLLocationCoordinate2D, radius: CLLocationDistance, fromRadial: CLLocationDegrees, toRadial:CLLocationDegrees) {

        let currentCoord:CLLocationCoordinate2D!
        currentCoord = center

        let coords = MKGeodesicCircle.getCoordinates(currentCoord: currentCoord, radius: radius, fromRadial: fromRadial, toRadial: toRadial)
        self.init()
        self.init(coordinates: coords, count: coords.count)
    }

    class func getCoordinates(currentCoord: CLLocationCoordinate2D!, radius: CLLocationDistance, fromRadial: CLLocationDegrees, toRadial: CLLocationDegrees) -> [CLLocationCoordinate2D] {

        let globalRadius: Double = 6371000
        let π = M_PI

        var coordinates = [CLLocationCoordinate2D]()

        let lat1: Double = (currentCoord!.latitude)
        let long1: Double = (currentCoord!.longitude) + 180
        let factor = 3


        let φ1: Double = lat1 * (π / 180)
        let λ1: Double = long1 * (π / 180)
        let angularDistance =  radius / globalRadius

        var metersToNorthPole: Double = 0
        var metersToSouthPole: Double = 0

        for _ in Int(lat1)..<89 {
            metersToNorthPole = metersToNorthPole + 111132.92 - (559.82 * cos(2 * φ1)) + (1.175 * cos(4 * φ1))
        }

        for _ in stride(from: lat1, through: -89, by: -1) {
            metersToSouthPole = metersToSouthPole + 111132.92 - (559.82 * cos(2 * φ1)) + (1.175 * cos(4 * φ1))
        }

        var startingBearing = Int(fromRadial) - 180
        var endingBearing = Int(toRadial) - 180

        if metersToNorthPole - radius <= 0 {
            endingBearing = Int(fromRadial) - 0
            startingBearing = Int(toRadial) * -1
        }

        for i in stride(from: startingBearing, through: endingBearing, by: factor) {
        //for var i = startingBearing; i <= endingBearing; i += factor {

            let bearing = Double(i)

            let bearingInRadians: Double = bearing * (π / 180)

            let φ2: Double = asin(sin(φ1) * cos(angularDistance)
                + cos(φ1) * sin(angularDistance)
                * cos(bearingInRadians)
            )

            let λ2 = atan2(
                sin(bearingInRadians) * sin(angularDistance) * cos(φ1),
                cos(angularDistance) - sin(φ1) * sin(φ2)
                ) + λ1

            let lat2 = φ2 * (180 / π)
            var long2 = ( ((λ2.truncatingRemainder(dividingBy: (2 * π)) ) - π)) * (180.0 / π)

            if long2 < -180 {
                long2 = 180 + (long2.truncatingRemainder(dividingBy: 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
    }

}
Percept answered 7/2, 2017 at 21:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.