mapbox-gl-native
mapbox-gl-native copied to clipboard
Add MGLCircle (with radius expressed in physical units)
Now that v8 has landed in https://github.com/mapbox/mapbox-gl-native/commit/0451aca37e866c3380072b114e2d49353888e4b5 we have a Circle
layer type.
ref #1740
@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.
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
@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 - 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 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 - 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.
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.
Is this still not available with the new release of mapbox studio?
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 can you please provide an example of this workaround?
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.
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).
/cc @friedbunny @jfirebaugh @brunoabinader @tmcw
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 - 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 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 - 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 Can you model this using Polygon annotation? A polygon with 50 or so edges will very closely match a circle.
@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.
@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)
}
thanx @Lengo46 for the swiftcode! Brilliant stuff!
@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")));
@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
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
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?
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...
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, 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:
).
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.
Now, with runtime styling, MGLCircleStyleLayer
is able to created, modified, and added at runtime. Might meet most use cases here.