Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding queryRenderedFeatures on iOS #419

Merged
merged 12 commits into from
Sep 27, 2016

Conversation

tsemerad
Copy link
Contributor

I'm trying to make a PR adding queryRenderedFeatures support (or visibleFeaturesAtPoint and visibleFeaturesInRect on iOS), and I need some feedback.

I have the bridge set up to properly send the screen coordinate from JS to Obj C. However, it's failing on line 547 of RCTMapboxGL.m with Exception thrown while executing UI block: -[MGLMapView visibleFeaturesAtPoint:]: unrecognized selector sent to instance. I can't figure out why it's giving me this issue. I apologize if this is a simple issue I've overlooked - this is my first time writing React Native bridge code.

I also want feedback on the name and signature on the method. On iOS, it's called visibleFeaturesAtPoint and visibleFeaturesInRect, and on Android it's called queryRenderedFeatures. I'm planning on calling it queryRenderedFeatures, and allow passing in either a point or a rect.

@1ec5
Copy link
Contributor

1ec5 commented Sep 21, 2016

I have the bridge set up to properly send the screen coordinate from JS to Obj C. However, it's failing on line 547 of RCTMapboxGL.m with Exception thrown while executing UI block: -[MGLMapView visibleFeaturesAtPoint:]: unrecognized selector sent to instance.

That’s strange. The method definitely is implemented. What version of Xcode are you using? This is the SDK’s first method that uses the new NS_SWIFT_NAME attribute, so if you’re on Xcode 7.2 or lower, it might not be possible to call this method directly.

I'm planning on calling it queryRenderedFeatures, and allow passing in either a point or a rect.

That’s fine. The iOS naming is specific to Cocoa naming conventions, where a getter method without side effects would to be named without a verb.

@tsemerad
Copy link
Contributor Author

I'm using XCode 7.3.1. I tested accessing other attributes and methods on _map, and they all worked.

@tsemerad
Copy link
Contributor Author

I got it working now. I just had to remove and re-add Mapbox.framework from the XCode project, just how I originally had according to the manual installation instructions. Now it's recognizing the method properly. Is this something we'll need to warn people about?

@1ec5 1ec5 added the ios label Sep 22, 2016
@tsemerad
Copy link
Contributor Author

I have it working on iOS by passing in an options dictionary with a point parameter screen coordinate and optional layers array of layer ids. Converting the queried features to GeoJSON might need a little more work - it's working for Point, LineString, and Polygon, but haven't implemented MultiLineString or MultiPolygon. If there's already a known way to get GeoJSON from MGLFeatures, let me know. I see that mapbox-gl-native #6302 would provide a convenient method.

The question is, what do we want the queryRenderedFeatures signature to look like?

This is what I currently have, which I think needs some improvement:

map.queryRenderedFeatures(options, callback)
// example
map.queryRenderedFeatures({
    point: {
      screenCoordX: 212,
      screenCoordY: 111
    },
    layers: ['building']
  },
  (features) => console.log('do something with GeoJSON features')
)

I'm thinking of passing in a coordinate, rather than a screen point, to be more in line with Mapbox GL JS (I'm not sure if that's a concern). I started with a screen point since iOS uses visibleFeaturesAtPoint, and Android appears to use a screen point as well, but Mapbox GL JS doesn't. It accepts PointLike input. I'm not sure whether we want to add point-geometry as a dependency just to make it very similar to Mapbox GL JS.

Inspired by Mapbox GL JS:

map.queryRenderedFeatures(geometry, parameters, callback)
// example with point
map.queryRenderedFeatures([44.57, -123.42], {
    layers: ['building']
  },
  (features) => console.log('do something with GeoJSON features')
)

// example with bounding box
map.queryRenderedFeatures([[44.574, -123.426], [44.576, -123.424]], {
    layers: ['building']
  },
  (features) => console.log('do something with GeoJSON features')
)

Another alternative to passing [[latitudeSW, longitudeSW], [latitudeNE, longitudeNE]] would be [latitudeSW, longitudeSW, latitudeNE, longitudeNE] which matches bounds that we get from map.getBounds.

One other option, that might be more than we need (but is a bit more explicit):

map.queryRenderedFeatures(options, callback)
// example
// one of 'screenCoord', 'mapCoord', 'rect', and 'boundingBox' are required
map.queryRenderedFeatures({
    screenCoord: {
      x: 212,
      y: 111
    },
    mapCoord: {
      latitude: 44.57,
      longitude: -123.43
    },
    rect: {
      left: 0,
      top: 0,
      right: 10,
      bottom: 10
    },
    boundingBox: {
      southWest: {
        latitude: 44.574,
        longitude: -123.426
      },
      northEast: {
        latitude: 44.576,
        longitude: -123.424
      }
    },
    layers: ['building']
  },
  (features) => console.log('do something with GeoJSON features')
)

What do you think? Should we accept screen points, lat long coords, or both?

cc @bsudekum, @dapetcu21, @tmcw

@1ec5
Copy link
Contributor

1ec5 commented Sep 23, 2016

If there's already a known way to get GeoJSON from MGLFeatures, let me know. I see that mapbox-gl-native #6302 would provide a convenient method.

mapbox/mapbox-gl-native#6302 would make it possible to create an MGLGeoJSONSource from an array of MGLFeatures, which is essentially opposite the direction you’d like to go. With an MGLGeoJSONSource, you could grab the geoJSONData, but round-tripping through MGLGeoJSONSource would be a pretty circuitous route to take for feature querying. mapbox/mapbox-gl-native#5623 (or the SDK-level wrapper around it) would ultimately expose a way to convert an MGLFeature to GeoJSON directly.

In the meantime, you’re on the right track with your conversion implementation. For collection types like MGLMultiPolyline and especially MGLShapeCollection, you’ll want to do the conversion recursively.

I'm thinking of passing in a coordinate, rather than a screen point, to be more in line with Mapbox GL JS (I'm not sure if that's a concern). I started with a screen point since iOS uses visibleFeaturesAtPoint, and Android appears to use a screen point as well, but Mapbox GL JS doesn't. It accepts PointLike input.

Based on GL JS’s documentation and examples, it’s clear queryRenderedFeatures() actually takes a screen coordinate or screen bounding box as well. PointLike is used for coordinates that aren’t geographic coordinates. You could accept geographic coordinates and convert them to screen coordinates before querying them, but that’ll make less sense once mapbox/mapbox-gl-js#3122 is implemented.

NSSet<NSString *> *styleLayerIdentifiers = options[@"layers"];

CLLocationCoordinate2D location = [_map convertPoint:[sender locationInView:_map] toCoordinateFromView:_map];
CGPoint screenCoord = [sender locationInView:_map];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two variables don’t appear to be used anywhere at the moment.

}
// TODO: checks for MGLMultiPolyline and MGLMultiPolygon

NSDictionary *geoJSON = @{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also set id to the value of the MGLFeature’s identifier property. (We don’t say “ID” much in Objective-C since id is a keyword.)

}
geometryType = @"Polygon";
}
// TODO: checks for MGLMultiPolyline and MGLMultiPolygon
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also MGLMultiPoint and MGLShapeCollection. Note that MGLPolyline and MGLPolygon currently inherit from MGLMultiPoint (mapbox/mapbox-gl-native#5201), so only handle MGLMultiPoint after those two classes.

if ([feature isKindOfClass:[MGLPointFeature class]]) {
geometryType = @"Point";
coordinates = (NSMutableArray *) @[@(feature.coordinate.longitude), @(feature.coordinate.latitude)];
} else if ([feature isKindOfClass:[MGLPolyline class]]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you’re checking for MGLPointFeature rather than MGLPointAnnotation in the first case above, it’d be more consistent to check for MGLPolylineFeature, MGLPolygonFeature, etc.

CLLocationCoordinate2D coord = multiPoint.coordinates[index];
[coordinates addObject:@[@(coord.longitude), @(coord.latitude)]];
}
geometryType = @"Polygon";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A polygon can have multiple holes, represented as MGLPolygons inside MGLPolygon’s interiorPolygons property.


// TODO: also accept Rect to use [mapView visibleFeaturesInRect:]

// TODO: convert features to JS friendly objects
// TODO: switch to using MGLFeature's geoJSONFeature method if implemented - https://github.com/mapbox/mapbox-gl-native/issues/6302
NSMutableArray *geoJSONFeatures = [[NSMutableArray alloc] init];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: you can also write this more succinctly as [NSMutableArray array]. As a possibly premature but reasonable optimization, you can also specify the size upfront: [NSMutableArray arrayWithCapacity:features.count].

if (!styleLayerIdentifiers) {
features = [mapView visibleFeaturesAtPoint:point];
} else {
features = [mapView visibleFeaturesAtPoint:point inStyleLayersWithIdentifiers:styleLayerIdentifiers];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can call this method for both cases. Pass in nil as the second parameter to get the same behavior as -visibleFeaturesAtPoint:.

}
NSNumber *screenCoordX = pointDict[@"screenCoordX"];
NSNumber *screenCoordY = pointDict[@"screenCoordY"];
NSSet<NSString *> *styleLayerIdentifiers = options[@"layers"];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn’t this be an NSArray rather than an NSSet, given the JavaScript representation? If this works, it’s only because the SDK happens to be calling methods common to both classes. Probably better to convert it into an NSArray using allObjects.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I should be pulling it out of options as an NSArray, then convert it to an NSSet. I just pushed some commits with that change.

Probably better to convert it into an NSArray using allObjects.

You mean convert the NSArray into an NSSet as expected by visibleFeaturesAtPoint? Let me know if my most recent change to it looks good. I really appreciate your feedback!

@tsemerad tsemerad changed the title Adding queryRenderedFeatures, need feedback Adding queryRenderedFeatures on iOS Sep 25, 2016
@tsemerad
Copy link
Contributor Author

I think the PR is ready, at least on iOS. I've made all of the requested changes, got queryRenderedFeatures working with both point and rect input, convert the MGLFeatures into the 7 various GeoJSON feature types, and updated the API docs. Let me know what you think!

For Android, I've started looking into implementing it. Since queryRenderedFeatures is available in the latest Android SDK beta, I'm assuming we want to wait until it lands in a production release first, right? I can make a separate PR then.

Copy link
Contributor

@1ec5 1ec5 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking pretty good. A couple code style issues and a missing case.

@"coordinates": coordinates };
}

- (NSMutableArray *)getMGLPolylineCoordinates:(MGLPolylineFeature *)polyline
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An Objective-C method name should only begin with "get" if the method fetches a resource from the network or something of that nature.

A cleaner approach would be to add a category to MGLPolylineFeature with a method called -coordinateArray.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I've created a category for MGLPolylineFeature and MGLPolygonFeature, each in their own files. Do you have a preferred naming convention? Should I call it MGLPolylineFeature+coordinateArray.m, or maybe MGLPolylineFeature+RCTMapboxGL.m?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about: MGL{Polyline,Polygon}Feature+RCTAdditions.{h,m}


if ([feature isKindOfClass:[MGLPointFeature class]]) {
geometryType = @"Point";
coordinates = (NSMutableArray *) @[@(feature.coordinate.longitude), @(feature.coordinate.latitude)];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't causing any problems now, but it's unsafe to cast this NSArray to an NSMutableArray. You could declare coordinates as an NSArray without initializing it, then initialize a local NSMutableArray in each if block below where you need one. Not quite as concise, but also reduces the risk of a crash when someone later tries to treat the mutable array as an actual mutable array down below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about not creating an NSArray in the first place? I'm thinking replacing line 680 with the following:

coordinates = [[NSMutableArray alloc] initWithArray:@[@(feature.coordinate.longitude), @(feature.coordinate.latitude)]];

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That works too. (@[] creates an NSArray, after all.)

CLLocationCoordinate2D coord = multiPoint.coordinates[index];
[coordinates addObject:@[@(coord.longitude), @(coord.latitude)]];
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about MGLShapeCollectionFeature (FeatureCollection)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yikes, never mind, I missed that it's right up there at the top.

[coordinates addObject:outerRingCoordinates];

for (MGLPolygonFeature *interiorRing in polygon.interiorPolygons) {
NSMutableArray *interiorRingCoordinates = [[NSMutableArray alloc] init];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, although you could also call the current method recursively here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't calling it recursively nest the arrays too much?

[outerRing, interiorRing1, interiorRing2, ...]

vs

[outerRing, [interiorRing1], [interiorRing2], ...]

I've left it as is in my refactoring to using a category.

@tsemerad
Copy link
Contributor Author

I pushed a new commit addressing your concerns. I put the category on MGLPolyline and MGLPolygon, rather than their "Feature" counterparts, since MGLMultiPolyline's polylines attribute (and MGLMultiPolygon's polygons attribute) return them. I had overlooked that originally. Thanks again for the great feedback.

Copy link
Contributor

@1ec5 1ec5 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks 👌. There are some additions opportunities for refactoring, for example moving all the GeoJSON conversion logic into more MGLFeature categories, but that can always happen down the line.

Thanks again for taking up this task. @bsudekum, good to merge?

@lyzidiamond
Copy link

HI TANNER!!!!!

@1ec5
Copy link
Contributor

1ec5 commented Sep 27, 2016

Only thing left to do is to resolve the conflicts.

@1ec5 1ec5 merged commit d4cf5d5 into nitaliano:master Sep 27, 2016
@bsudekum
Copy link

@tsemerad want to fix the conflicts? then we can merge

@bsudekum
Copy link

heh, didn't reload the page, 🎉

@tsemerad
Copy link
Contributor Author

Glad I could help. Haha, hi @lyzidiamond! Good to hear from you! You probably didn't expect to see my name pop up in your Github feed.

@1ec5
Copy link
Contributor

1ec5 commented Mar 22, 2017

mapbox/mapbox-gl-native#5623 (or the SDK-level wrapper around it) would ultimately expose a way to convert an MGLFeature to GeoJSON directly.

We ultimately implemented this in the iOS SDK as -[MGLShape geoJSONDataUsingEncoding:]. #8493 will add a similar API to the Android SDK.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants