mapbox-gl-native icon indicating copy to clipboard operation
mapbox-gl-native copied to clipboard

Add MGLCircle (with radius expressed in physical units)

Open incanus opened this issue 9 years ago • 53 comments

Now that v8 has landed in https://github.com/mapbox/mapbox-gl-native/commit/0451aca37e866c3380072b114e2d49353888e4b5 we have a Circle layer type.

incanus avatar Aug 24 '15 20:08 incanus

ref #1740

1ec5 avatar Aug 24 '15 20:08 1ec5

@incanus is there a roadmap plan for when MGLCircle will be implemented? Trying to update from the old "RMMapView" based SDK and the lack of replacement for an RMCircle is blocking me.

cowgp avatar Oct 27 '15 21:10 cowgp

Thanks for the ping on this @cowgp. It hasn't been on the roadmap, but thinking this was relatively straightforward, I took a look today in 2167-MGLCircle.

The Cocoa side was pretty easy, but as it turns out, even though we have support for circles in the renderer and styles as of https://github.com/mapbox/mapbox-gl-native/commit/0451aca37e866c3380072b114e2d49353888e4b5, we don't have support for circle annotations in that API.

It's an interesting bind, since circles seems like shapes (have alpha & color) but behave like points (have a single coordinate and a scalar icon-like property radius). So the going has been a little more than I expected there, but I think I'm close.

Stay tuned — will try to push this through soon.

/cc @friedbunny @jfirebaugh

incanus avatar Oct 28 '15 00:10 incanus

@cowgp Sorry, I'm gonna have to say we're going to push this out a ways right now — too many higher priorities. If it was as easy as I had initially guessed, with pure Cocoa API, it would have been easy to rationalize, but increasing the surface area of our annotations API right now is not ideal given the other things we're integrating them with.

Stay tuned; I'd love to get this in, but it's not a priority roadmap right now.

incanus avatar Oct 28 '15 22:10 incanus

@incanus - Thanks so much for looking into it, I appreciate the effort. I do hope it can remain a priority, seems like an important feature from my perspective. That said, totally understand that this update is all new underpinnings and thus there's a lot to build up.

cowgp avatar Oct 28 '15 22:10 cowgp

@cowgp Depending on if your needs are truly at runtime, you could possibly use the existing support in the style spec for rendering circles in the meantime? https://github.com/mapbox/mapbox-gl-js/issues/1446 shows an example of how this looks in the style. You could also consider mutating the style JSON and custom setting it at runtime as a workaround.

incanus avatar Oct 28 '15 22:10 incanus

@incanus - I looked into the style spec you linked, but no it doesn't seem to be a viable solve for our needs. We have ~45k active users each with one or more geofences that they can set/modify at will. This is accomplished in the existing deprecated SDK by adding an annotation at the center point of the geofence and then in the layerForAnnotation delegate method returning an RMCircle with the correct radius in meters for the fence. It seems to me a clear case where an MGLCircle is necessary to have in the new SDK.

cowgp avatar Nov 09 '15 18:11 cowgp

What I meant about mutating the style, though, could be a workaround here. The idea would be that you'd have a parsed representation of the style JSON (as, say, an NSMutableDictionary) to which you could add/modify items in the layers array using the circle primitive. Then, you'd send this out to JSON data with NSJSONSerialization and use it to set the styleJSON in the library.

Admittedly more work than just having MGLCircle, but an idea.

incanus avatar Nov 09 '15 19:11 incanus

Is this still not available with the new release of mapbox studio?

EasyAsABC123 avatar Nov 19 '15 17:11 EasyAsABC123

Nope, no further movement here just yet. Workaround is to code circle primitives into your style and/or add them to the style JSON at runtime for now.

incanus avatar Nov 19 '15 18:11 incanus

@incanus can you please provide an example of this workaround?

corydolphin avatar Nov 28 '15 23:11 corydolphin

Ah, yes, this would require something like the GeoJSON source in #2161 in order to modify runtime data for the circle layer(s). In that case, I would recommend instead adding UIView objects atop the map for now and modifying this routine to update each's placement using the same call as here to find the new screen center point based on circle center coordinate and using -metersPerPixelAtLatitude to determine radius on screen. It's not ideal, but a stopgap for now.

incanus avatar Nov 30 '15 20:11 incanus

I take that back — this could also be accomplished with the existing annotations API, with the slight downside that unlike a true MGLCircle, it must be layered atop all the other map layers (and below any point annotations). However, that aside, it's pretty trivial to do. Here's a sample project that pulls a GeoJSON with points, parses it, picks the first 50, and adds point annotations using imagery generated on-the-fly with Core Graphics having random radius and color (reusing sprites efficiently where available).

simulator screen shot nov 30 2015 10 29 56 pm

/cc @friedbunny @jfirebaugh @brunoabinader @tmcw

incanus avatar Dec 01 '15 06:12 incanus

Of course the other main hack here is that circle radii are only valid for a single zoom since they don't scale as the map does. You could combat this by pre-generating circles of varying radii for given integer zoom levels and only allowing external control to zoom to each, or else disallowing map zoom once you've added a particular image. Either way you'd be using MGLMapView.metersPerPixelAtLatitude() to determine the draw radius for the current zoom.

incanus avatar Dec 01 '15 06:12 incanus

@incanus - Thanks for showing a functional work around - helpful for sure. The performance hit when zooming is significant - and disabling zoom is not an option for me. That loops me back to your original suggestion, I haven't tried it yet, but I was about to - mutating the style that is. You've made subsequent suggestions here and a POC workaround that did not head down that path. Is there anything wrong with still pursuing circles in the style JSON?

cowgp avatar Dec 01 '15 07:12 cowgp

@cowgp It should work, the trick is updating your vector tile source data rapidly enough. You would put circle primitives in your style JSON directly and reapply the mapView.styleURL to update the appearance (which itself would be a little dramatic visually, but could work for a prep, load, then reveal scenario). The trick is you can only put those circles where there are data points in the tiles, and so you would need to do something like upload your GeoJSON or other source the Mapbox backend and then reload it to the client. Or else manually hack a solution with the underlying AnnotationTile live-tile structures used for point/shape annotations, but at that point you're in the territory that I was towards building MGLCircle anyway.

incanus avatar Dec 02 '15 19:12 incanus

@incanus - I've initially implemented my work around in a similar manner to the sample you provided, but I've hit a snag and am now banging my head. using a standard MGLPointAnnotation and providing the circle as a UIImage does a fine job of simply rendering a circle... our use case is a radial geofence, which unfortunately means that the circle needs to adjust it's size correctly when the zoom level on the map changes. the head banging comes in that I have tried implementing both: - (void)mapViewRegionIsChanging:(MGLMapView *)mapView - (void)mapView:(MGLMapView *)mapView regionDidChangeAnimated:(BOOL)animated as trigger points to try and force calculate/draw a new circle based on the new metersPerPixel value of the geofence annotation, but the annotation intermittently is not displayed/rendered. To prove it is not my circle drawing code, I just used a standard PNG image and it similarly would only appear intermittently.

The trick is that to get a new image displayed for the annotation, you have to somehow get - mapView: imageForAnnotation: to be called for that annotation. I first tried removing the annotation and then adding it right back, then tried removing the annotation and alloc/init-ing a new one in it's place. The later will consistently trigger imageForAnnotation: and I provide an image, whether that is a static PNG or a custom drawn circle at runtime, but as I said above, it only appears on the screen some of the time even though the method always returns something.

Do you have a better trick for forcing imageForAnnotation: to be called so I can provide resized circles?

cowgp avatar Jan 06 '16 02:01 cowgp

@cowgp Can you model this using Polygon annotation? A polygon with 50 or so edges will very closely match a circle.

mb12 avatar Jan 06 '16 02:01 mb12

@mb12 - the polygon is a much much better solve than rendering circles as images and struggling with the zoom. I went with 45 sides (evenly divisible into 360) and it's reasonably performant and it correctly scales when the map is zoomed. Thanks a bunch for the suggestion. For anyone else following along, below is my method, and what seems to be the best work around for current lack of MGLCircle.

- (MGLPolygon*)polygonCircleForCoordinate:(CLLocationCoordinate2D)coordinate withMeterRadius:(double)meterRadius
{
    NSUInteger degreesBetweenPoints = 8; //45 sides
    NSUInteger numberOfPoints = floor(360 / degreesBetweenPoints);
    double distRadians = meterRadius / 6371000.0; // earth radius in meters
    double centerLatRadians = coordinate.latitude * M_PI / 180;
    double centerLonRadians = coordinate.longitude * M_PI / 180;
    CLLocationCoordinate2D coordinates[numberOfPoints]; //array to hold all the points
    for (NSUInteger index = 0; index < numberOfPoints; index++) {
        double degrees = index * degreesBetweenPoints;
        double degreeRadians = degrees * M_PI / 180;
        double pointLatRadians = asin( sin(centerLatRadians) * cos(distRadians) + cos(centerLatRadians) * sin(distRadians) * cos(degreeRadians));
        double pointLonRadians = centerLonRadians + atan2( sin(degreeRadians) * sin(distRadians) * cos(centerLatRadians),
                                              cos(distRadians) - sin(centerLatRadians) * sin(pointLatRadians) );
        double pointLat = pointLatRadians * 180 / M_PI;
        double pointLon = pointLonRadians * 180 / M_PI;
        CLLocationCoordinate2D point = CLLocationCoordinate2DMake(pointLat, pointLon);
        coordinates[index] = point;
    }
    MGLPolygon *polygon = [MGLPolygon polygonWithCoordinates:coordinates count:numberOfPoints];
    return polygon;
}

The delegate methods for stroke color and fill color are then called for you to set your colors: - (UIColor *)mapView:(MGLMapView *)mapView strokeColorForShapeAnnotation:(MGLShape *)annotation - (UIColor *)mapView:(MGLMapView *)mapView fillColorForPolygonAnnotation:(MGLPolygon *)annotation

It is unfortunate however that supplying a color with less than 1 alpha value for the fill does weird things with the GL blend mode. your only option is to return the alpha in the delegate method: - (CGFloat)mapView:(MGLMapView *)mapView alphaForShapeAnnotation:(MGLShape *)annotation which applies the alpha to the stroke as well as the fill. In an ideal scenario, I'd be able to have a semi-transparent fill with an opaque stroke. Regardless, the above polygon hack gets me way closer to functional that I was before.

circle

cowgp avatar Jan 06 '16 08:01 cowgp

@cowgp thanks!

Rewrote your version to swift 2.0

func polygonCircleForCoordinate(coordinate: CLLocationCoordinate2D, withMeterRadius: Double) {
    let degreesBetweenPoints = 8.0        
    let numberOfPoints = floor(360.0 / degreesBetweenPoints)
    let distRadians: Double = withMeterRadius / 6371000.0   
    let centerLatRadians: Double = coordinate.latitude * M_PI / 180
    let centerLonRadians: Double = coordinate.longitude * M_PI / 180
    var coordinates = [CLLocationCoordinate2D]()

    for var index = 0; index < Int(numberOfPoints); index++ {
        let degrees: Double = Double(index) * Double(degreesBetweenPoints)
        let degreeRadians: Double = degrees * M_PI / 180
        let pointLatRadians: Double = asin(sin(centerLatRadians) * cos(distRadians) + cos(centerLatRadians) * sin(distRadians) * cos(degreeRadians))
        let pointLonRadians: Double = centerLonRadians + atan2(sin(degreeRadians) * sin(distRadians) * cos(centerLatRadians), cos(distRadians) - sin(centerLatRadians) * sin(pointLatRadians))
        let pointLat: Double = pointLatRadians * 180 / M_PI
        let pointLon: Double = pointLonRadians * 180 / M_PI
        let point: CLLocationCoordinate2D = CLLocationCoordinate2DMake(pointLat, pointLon)
        coordinates.append(point)
    }

    let polygon = MGLPolygon(coordinates: &coordinates, count: UInt(coordinates.count))
    mapview.addAnnotation(polygon)
}

Lengo46 avatar Mar 05 '16 17:03 Lengo46

thanx @Lengo46 for the swiftcode! Brilliant stuff!

doedje avatar Mar 12 '16 16:03 doedje

@cowgp Thanks man! I rewrote it for Android!

private ArrayList<LatLng> polygonCircleForCoordinate(LatLng location, double radius){
        int degreesBetweenPoints = 8; //45 sides
        int numberOfPoints = (int) Math.floor(360 / degreesBetweenPoints);
        double distRadians = radius / 6371000.0; // earth radius in meters
        double centerLatRadians = location.getLatitude() * Math.PI / 180;
        double centerLonRadians = location.getLongitude() * Math.PI / 180;
        ArrayList<LatLng> polygons = new ArrayList<>(); //array to hold all the points
        for (int index = 0; index < numberOfPoints; index++) {
            double degrees = index * degreesBetweenPoints;
            double degreeRadians = degrees * Math.PI / 180;
            double pointLatRadians = Math.asin(Math.sin(centerLatRadians) * Math.cos(distRadians) + Math.cos(centerLatRadians) * Math.sin(distRadians) * Math.cos(degreeRadians));
            double pointLonRadians = centerLonRadians + Math.atan2(Math.sin(degreeRadians) * Math.sin(distRadians) * Math.cos(centerLatRadians),
                    Math.cos(distRadians) - Math.sin(centerLatRadians) * Math.sin(pointLatRadians));
            double pointLat = pointLatRadians * 180 / Math.PI;
            double pointLon = pointLonRadians * 180 / Math.PI;
            LatLng point = new LatLng(pointLat, pointLon);
            polygons.add(point);
        }
        return polygons;
    }

Add to your mapboxMap by using

mapboxMap.addPolygon(new PolygonOptions().addAll(polygonCircleForCoordinate(point, 500.0)).fillColor(Color.parseColor("#12121212")));

Ruben2112 avatar Mar 23 '16 11:03 Ruben2112

@cowgp Also many thanks! I rewrote it for RubyMotion:

  def polygon_circle(coordinates, radius)
    degrees_between_points = 8.0
    number_of_points = (360.0/degrees_between_points).floor
    dist_radians = radius / 6371000.0
    center_lat_radians = coordinates.latitude * Math::PI / 180
    center_lon_radians = coordinates.longitude * Math::PI / 180
    polygon_coordinates = Array.new()

    for index in 0...number_of_points
      degrees = index * degrees_between_points
      degree_radians = degrees * Math::PI / 180
      point_lat_radians = Math.asin(Math.sin(center_lat_radians) * Math.cos(dist_radians) + Math.cos(center_lat_radians) * Math.sin(dist_radians) * Math.cos(degree_radians))
      point_lon_radians = center_lon_radians + Math.atan2(Math.sin(degree_radians) * Math.sin(dist_radians) * Math.cos(center_lat_radians), Math.cos(dist_radians) - Math.sin(center_lat_radians) * Math.sin(point_lat_radians))
      point_lat = point_lat_radians * 180 / Math::PI
      point_lon = point_lon_radians * 180 / Math::PI
      point = CLLocationCoordinate2DMake(point_lat, point_lon)
      polygon_coordinates << point
    end

    polygon_coordinates_ptr = Pointer.new(CLLocationCoordinate2D.type, polygon_coordinates.length)
    polygon_coordinates.each_index { |index| polygon_coordinates_ptr[index] = polygon_coordinates[index] }
    polygon = MGLPolygon.polygonWithCoordinates(polygon_coordinates_ptr, count: polygon_coordinates.length)

    polygon
  end

jelmerderonde avatar May 05 '16 10:05 jelmerderonde

I merged the two examples with swift example here -> https://github.com/johndpope/CircleAnnotationStopgap

Beware - I'm seeing some memory leaks on this method in conjunction with clustering manager. (no time to fix this) func circleImageWithRadius(radius: Int, color: UIColor) -> UIImage

img_5120

johndpope avatar May 16 '16 13:05 johndpope

This probably deserves a new ticket - but did anyone have a crack at adding heatmap overlays? looking at the code to do the polygon overlays / uiimages - seems feasible. I'm looking for a shortcut.

In this example - they are using GMSGroundOverlay ontop of GMSMapView https://github.com/kenzan8000/Avoid-Crime


  let overlay = GMSGroundOverlay(
            position: self.projection.coordinateForPoint(CGPointMake(self.frame.size.width / 2.0, self.frame.size.height / 2.0)),
            icon: UIImage.heatmapImage(map: self, crimes: drawingCrimes),
            zoomLevel: CGFloat(self.camera.zoom)
        )



 class func heatmapImage(map map: GMSMapView, crimes: [DACrime]) -> UIImage {
        var locations: [CLLocation] = []
        var weights: [NSNumber] = []
        for crime in crimes {
            let lat = crime.lat.doubleValue
            let long = crime.long.doubleValue
            locations.append(CLLocation(latitude: lat, longitude: long))
            var weight = DASFGovernment.Crime.Weights_number[crime.category]
            if weight == nil { weight = DASFGovernment.Crime.Weights_number["THE OTHERS"] }
            weights.append(weight!)
        }
    var points: [NSValue] = []
    for var i = 0; i < locations.count; i++ {
        let location = locations[i]
        points.append(NSValue(CGPoint: map.projection.pointForCoordinate(location.coordinate)))
    }

    let image = DACrimeHeatmap.crimeHeatmapWithRect(
        map.frame,
        boost: 1.0,
        points: points,
        weights: weights
    )

    return image
}

this appears that an entire uiimage is dropped onto the google maps overlay. re-rendering this when need be. Did anyone attempt this with mapbox?

img_5119

johndpope avatar May 16 '16 15:05 johndpope

Yeah, I'm also in a scenario where I need to indicate intensity radiating out from a central location; some visual meaning is lost with a solid colored overlay for me. I might have to fix zoom level for now...

brendancallahan avatar May 24 '16 01:05 brendancallahan

This also probably deserves a new ticket, or not, since it is more like a question than a particular issue, the issue would be to add some helper method to the API to do this, and with this I mean, if anyone has ever needed to calculate the maximum required zoomLevel to display an entire circle centered in the map given its radius and its center position?

mgtitimoli avatar Sep 07 '16 14:09 mgtitimoli

@mgtitimoli, that's worth filing a separate issue over. If we add any API like that, it'd probably be more general, to compute the map camera needed to show an annotation of any kind. But if you need to know this information for a shape you haven't added to the map yet, this might be a better role for a generalized geometry library than this SDK.

To be sure, there are already methods for fitting the map to a given annotation already on the map (-showAnnotations:animated:).

1ec5 avatar Sep 07 '16 15:09 1ec5

For those interested in drawing a circle whose radius corresponds to a screen distance rather than a geographic coordinate span, check out MGLCircleStyleLayer (in conjunction with ~~MGLGeoJSONSource~~ MGLShapeSource) in the latest iOS SDK alpha.

1ec5 avatar Sep 14 '16 14:09 1ec5

Now, with runtime styling, MGLCircleStyleLayer is able to created, modified, and added at runtime. Might meet most use cases here.

incanus avatar Feb 08 '17 17:02 incanus