diff options
Diffstat (limited to 'platform/macos/src')
-rw-r--r-- | platform/macos/src/MGLAnnotationImage.h | 64 | ||||
-rw-r--r-- | platform/macos/src/MGLAnnotationImage.m | 26 | ||||
-rw-r--r-- | platform/macos/src/MGLAnnotationImage_Private.h | 8 | ||||
-rw-r--r-- | platform/macos/src/MGLAttributionButton.h | 15 | ||||
-rw-r--r-- | platform/macos/src/MGLAttributionButton.m | 50 | ||||
-rw-r--r-- | platform/macos/src/MGLCompassCell.h | 5 | ||||
-rw-r--r-- | platform/macos/src/MGLCompassCell.m | 34 | ||||
-rw-r--r-- | platform/macos/src/MGLMapView+IBAdditions.h | 68 | ||||
-rw-r--r-- | platform/macos/src/MGLMapView+IBAdditions.m | 118 | ||||
-rw-r--r-- | platform/macos/src/MGLMapView.h | 903 | ||||
-rw-r--r-- | platform/macos/src/MGLMapView.mm | 2499 | ||||
-rw-r--r-- | platform/macos/src/MGLMapViewDelegate.h | 220 | ||||
-rw-r--r-- | platform/macos/src/MGLMapView_Private.h | 23 | ||||
-rw-r--r-- | platform/macos/src/MGLOpenGLLayer.h | 12 | ||||
-rw-r--r-- | platform/macos/src/MGLOpenGLLayer.mm | 49 | ||||
-rw-r--r-- | platform/macos/src/Mapbox.h | 34 |
16 files changed, 4128 insertions, 0 deletions
diff --git a/platform/macos/src/MGLAnnotationImage.h b/platform/macos/src/MGLAnnotationImage.h new file mode 100644 index 0000000000..ad44993ee1 --- /dev/null +++ b/platform/macos/src/MGLAnnotationImage.h @@ -0,0 +1,64 @@ +#import <AppKit/AppKit.h> + +#import "MGLTypes.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + The `MGLAnnotationImage` class is responsible for presenting point-based + annotations visually on an `MGLMapView` instance. Annotation image objects pair + `NSImage` objects with annotation-related metadata. They may be recycled later + and put into a reuse queue that is maintained by the map view. + */ +@interface MGLAnnotationImage : NSObject + +#pragma mark Initializing and Preparing the Image Object + +/** + Initializes and returns a new annotation image object. + + @param image The image to display for the annotation. + @param reuseIdentifier The string that identifies this annotation image in the + reuse queue. + @return The initialized annotation image object or `nil` if there was a problem + initializing the object. + */ ++ (instancetype)annotationImageWithImage:(NSImage *)image reuseIdentifier:(NSString *)reuseIdentifier; + +#pragma mark Getting and Setting Attributes + +/** The image to display for the annotation. */ +@property (nonatomic, readonly) NSImage *image; + +/** + The string that identifies this annotation image in the reuse queue. + (read-only) + + You specify the reuse identifier when you create the image object. You use this + type later to retrieve an annotation image object that was created previously + but which is currently unused because its annotation is not on-screen. + + If you define distinctly different types of annotations (with distinctly + different annotation images to go with them), you can differentiate between the + annotation types by specifying different reuse identifiers for each one. + */ +@property (nonatomic, readonly) NSString *reuseIdentifier; + +/** + A Boolean value indicating whether the annotation is selectable. + + The default value of this property is `YES`. If the value of this property is + `NO`, the annotation image ignores click events and cannot be selected. + */ +@property (nonatomic, getter=isSelectable) BOOL selectable; + +/** + The cursor that appears above any annotation using this annotation image. + + By default, this property is set to `nil`, representing the current cursor. + */ +@property (nonatomic, nullable) NSCursor *cursor; + +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/macos/src/MGLAnnotationImage.m b/platform/macos/src/MGLAnnotationImage.m new file mode 100644 index 0000000000..1b545651d2 --- /dev/null +++ b/platform/macos/src/MGLAnnotationImage.m @@ -0,0 +1,26 @@ +#import "MGLAnnotationImage_Private.h" + +@interface MGLAnnotationImage () + +@property (nonatomic) NSImage *image; +@property (nonatomic) NSString *reuseIdentifier; +@property (nonatomic, strong, nullable) NSString *styleIconIdentifier; + +@end + +@implementation MGLAnnotationImage + ++ (instancetype)annotationImageWithImage:(NSImage *)image reuseIdentifier:(NSString *)reuseIdentifier { + return [[self alloc] initWithImage:image reuseIdentifier:reuseIdentifier]; +} + +- (instancetype)initWithImage:(NSImage *)image reuseIdentifier:(NSString *)reuseIdentifier { + if (self = [super init]) { + _image = image; + _reuseIdentifier = [reuseIdentifier copy]; + _selectable = YES; + } + return self; +} + +@end diff --git a/platform/macos/src/MGLAnnotationImage_Private.h b/platform/macos/src/MGLAnnotationImage_Private.h new file mode 100644 index 0000000000..21963a86a0 --- /dev/null +++ b/platform/macos/src/MGLAnnotationImage_Private.h @@ -0,0 +1,8 @@ +#import <Mapbox/Mapbox.h> + +@interface MGLAnnotationImage (Private) + +/// Unique identifier of the sprite image used by the style to represent the receiver’s `image`. +@property (nonatomic, strong, nullable) NSString *styleIconIdentifier; + +@end diff --git a/platform/macos/src/MGLAttributionButton.h b/platform/macos/src/MGLAttributionButton.h new file mode 100644 index 0000000000..9ff3137849 --- /dev/null +++ b/platform/macos/src/MGLAttributionButton.h @@ -0,0 +1,15 @@ +#import <Cocoa/Cocoa.h> + +/// Button that looks like a hyperlink and opens a URL. +@interface MGLAttributionButton : NSButton + +/// Returns an `MGLAttributionButton` instance with the given title and URL. +- (instancetype)initWithTitle:(NSString *)title URL:(NSURL *)url; + +/// The URL to open and display as a tooltip. +@property (nonatomic) NSURL *URL; + +/// Opens the URL. +- (IBAction)openURL:(id)sender; + +@end diff --git a/platform/macos/src/MGLAttributionButton.m b/platform/macos/src/MGLAttributionButton.m new file mode 100644 index 0000000000..e21b860794 --- /dev/null +++ b/platform/macos/src/MGLAttributionButton.m @@ -0,0 +1,50 @@ +#import "MGLAttributionButton.h" + +#import "NSBundle+MGLAdditions.h" + +@implementation MGLAttributionButton { + NSTrackingRectTag _trackingAreaTag; +} + +- (instancetype)initWithTitle:(NSString *)title URL:(NSURL *)url { + if (self = [super initWithFrame:NSZeroRect]) { + self.bordered = NO; + self.bezelStyle = NSRegularSquareBezelStyle; + + // Start with a copyright symbol. The whole string will be mini. + NSMutableAttributedString *attributedTitle = [[NSMutableAttributedString alloc] initWithString:NSLocalizedStringWithDefaultValue(@"COPYRIGHT_PREFIX", nil, nil, @"© ", @"Copyright notice prefix") attributes:@{ + NSFontAttributeName: [NSFont systemFontOfSize:[NSFont systemFontSizeForControlSize:NSMiniControlSize]], + }]; + // Append the specified title, underlining it like a hyperlink. + [attributedTitle appendAttributedString: + [[NSAttributedString alloc] initWithString:title + attributes:@{ + NSFontAttributeName: [NSFont systemFontOfSize:[NSFont systemFontSizeForControlSize:NSMiniControlSize]], + NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle), + }]]; + self.attributedTitle = attributedTitle; + [self sizeToFit]; + + _URL = url; + self.toolTip = _URL.absoluteString; + + self.target = self; + self.action = @selector(openURL:); + } + return self; +} + +- (BOOL)wantsLayer { + return YES; +} + +- (void)resetCursorRects { + // The whole button gets a pointing hand cursor, just like a hyperlink. + [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; +} + +- (IBAction)openURL:(__unused id)sender { + [[NSWorkspace sharedWorkspace] openURL:self.URL]; +} + +@end diff --git a/platform/macos/src/MGLCompassCell.h b/platform/macos/src/MGLCompassCell.h new file mode 100644 index 0000000000..5ed70dcb06 --- /dev/null +++ b/platform/macos/src/MGLCompassCell.h @@ -0,0 +1,5 @@ +#import <Cocoa/Cocoa.h> + +/// Circular slider with an arrow pointing north. +@interface MGLCompassCell : NSSliderCell +@end diff --git a/platform/macos/src/MGLCompassCell.m b/platform/macos/src/MGLCompassCell.m new file mode 100644 index 0000000000..b3a4ad4544 --- /dev/null +++ b/platform/macos/src/MGLCompassCell.m @@ -0,0 +1,34 @@ +#import "MGLCompassCell.h" + +@implementation MGLCompassCell + +- (instancetype)init { + if (self = [super init]) { + self.sliderType = NSCircularSlider; + // A tick mark for each cardinal direction. + self.numberOfTickMarks = 4; + // This slider goes backwards! + self.minValue = -360; + self.maxValue = 0; + } + return self; +} + +- (void)drawKnob:(NSRect)knobRect { + // Draw a red triangle pointing whichever way the slider is facing. + NSBezierPath *trianglePath = [NSBezierPath bezierPath]; + [trianglePath moveToPoint:NSMakePoint(NSMinX(knobRect), NSMaxY(knobRect))]; + [trianglePath lineToPoint:NSMakePoint(NSMaxX(knobRect), NSMaxY(knobRect))]; + [trianglePath lineToPoint:NSMakePoint(NSMidX(knobRect), NSMinY(knobRect))]; + [trianglePath closePath]; + NSAffineTransform *transform = [NSAffineTransform transform]; + [transform translateXBy:NSMidX(knobRect) yBy:NSMidY(knobRect)]; + [transform scaleBy:0.8]; + [transform rotateByDegrees:self.doubleValue]; + [transform translateXBy:-NSMidX(knobRect) yBy:-NSMidY(knobRect)]; + [trianglePath transformUsingAffineTransform:transform]; + [[NSColor redColor] setFill]; + [trianglePath fill]; +} + +@end diff --git a/platform/macos/src/MGLMapView+IBAdditions.h b/platform/macos/src/MGLMapView+IBAdditions.h new file mode 100644 index 0000000000..81f4506a57 --- /dev/null +++ b/platform/macos/src/MGLMapView+IBAdditions.h @@ -0,0 +1,68 @@ +#import <Foundation/Foundation.h> + +#import "MGLMapView.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface MGLMapView (IBAdditions) + +#if TARGET_INTERFACE_BUILDER + +// Core properties that can be manipulated in the Attributes inspector in +// Interface Builder. These redeclarations merely add the IBInspectable keyword. +// They appear here to ensure that they appear above the convenience properties; +// inspectables declared in MGLMapView.h are always sorted before those in +// MGLMapView+IBAdditions.h, due to ASCII sort order. + +// We want this property to look like a URL bar in the Attributes inspector, but +// just calling it styleURL would violate Cocoa naming conventions and conflict +// with the existing NSURL property. Fortunately, IB strips out the two +// underscores for display. + +/** URL of the style currently displayed in the receiver. + + The URL may be a full HTTP or HTTPS URL, a Mapbox URL indicating the style’s + map ID (`mapbox://styles/<user>/<style>`), or a path to a local file + relative to the application’s resource path. Leave this field blank for the + default style. */ +@property (nonatomic, nullable) IBInspectable NSString *styleURL__; + +// Convenience properties related to the initial viewport. These properties +// are not meant to be used outside of Interface Builder. latitude and longitude +// are backed by properties of type CLLocationDegrees, but these declarations +// must use the type double because Interface Builder is unaware that +// CLLocationDegrees is a typedef for double. + +/** The initial center latitude. */ +@property (nonatomic) IBInspectable double latitude; + +/** The initial center longitude. */ +@property (nonatomic) IBInspectable double longitude; + +@property (nonatomic) IBInspectable double zoomLevel; + +// Renamed properties. Interface Builder derives the display name of each +// inspectable from the runtime name, but runtime names don’t always make sense +// in UI. + +/** A Boolean value that determines whether the user may zoom the map, changing + its zoom level. */ +@property (nonatomic) IBInspectable BOOL allowsZooming; + +/** A Boolean value that determines whether the user may scroll around the map, + changing its center coordinate. */ +@property (nonatomic) IBInspectable BOOL allowsScrolling; + +/** A Boolean value that determines whether the user may rotate the map, + changing its direction. */ +@property (nonatomic) IBInspectable BOOL allowsRotating; + +/** A Boolean value that determines whether the user may tilt the map, changing + its pitch. */ +@property (nonatomic) IBInspectable BOOL allowsTilting; + +#endif + +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/macos/src/MGLMapView+IBAdditions.m b/platform/macos/src/MGLMapView+IBAdditions.m new file mode 100644 index 0000000000..eada47ef90 --- /dev/null +++ b/platform/macos/src/MGLMapView+IBAdditions.m @@ -0,0 +1,118 @@ +#import "MGLMapView+IBAdditions.h" + +#import "MGLMapView_Private.h" + +@implementation MGLMapView (IBAdditions) + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingStyleURL__ { + return [NSSet setWithObject:@"styleURL"]; +} + +- (nullable NSString *)styleURL__ { + return self.styleURL.absoluteString; +} + +- (void)setStyleURL__:(nullable NSString *)URLString { + URLString = [URLString stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + NSURL *url = URLString.length ? [NSURL URLWithString:URLString] : nil; + if (URLString.length && !url) { + [NSException raise:@"Invalid style URL" + format:@"“%@” is not a valid style URL.", URLString]; + } + self.styleURL = url; +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingLatitude { + return [NSSet setWithObjects:@"centerCoordinate", @"camera", nil]; +} + +- (double)latitude { + return self.centerCoordinate.latitude; +} + +- (void)setLatitude:(double)latitude { + if (!isnan(self.pendingLongitude)) { + // With both components present, set the real center coordinate and + // forget the pending parts. + self.centerCoordinate = CLLocationCoordinate2DMake(latitude, self.pendingLongitude); + self.pendingLatitude = NAN; + self.pendingLongitude = NAN; + } else { + // Not enough info to make a valid center coordinate yet. Stash this + // latitude away until the longitude is set too. + self.pendingLatitude = latitude; + } +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingLongitude { + return [NSSet setWithObjects:@"centerCoordinate", @"camera", nil]; +} + +- (double)longitude { + return self.centerCoordinate.longitude; +} + +- (void)setLongitude:(double)longitude { + if (!isnan(self.pendingLatitude)) { + // With both components present, set the real center coordinate and + // forget the pending parts. + self.centerCoordinate = CLLocationCoordinate2DMake(self.pendingLatitude, longitude); + self.pendingLatitude = NAN; + self.pendingLongitude = NAN; + } else { + // Not enough info to make a valid center coordinate yet. Stash this + // longitude away until the latitude is set too. + self.pendingLongitude = longitude; + } +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingAllowsZooming { + return [NSSet setWithObject:@"zoomEnabled"]; +} + +- (BOOL)allowsZooming { + return self.zoomEnabled; +} + +- (void)setAllowsZooming:(BOOL)allowsZooming { + self.zoomEnabled = allowsZooming; +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingAllowsScrolling { + return [NSSet setWithObject:@"scrollEnabled"]; +} + +- (BOOL)allowsScrolling { + return self.scrollEnabled; +} + +- (void)setAllowsScrolling:(BOOL)allowsScrolling { + self.scrollEnabled = allowsScrolling; +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingAllowsRotating { + return [NSSet setWithObject:@"rotateEnabled"]; +} + +- (BOOL)allowsRotating { + return self.rotateEnabled; +} + +- (void)setAllowsRotating:(BOOL)allowsRotating { + self.rotateEnabled = allowsRotating; +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingAllowsTilting { + return [NSSet setWithObject:@"pitchEnabled"]; +} + +- (BOOL)allowsTilting { + return self.pitchEnabled; +} + +- (void)setAllowsTilting:(BOOL)allowsTilting { + self.pitchEnabled = allowsTilting; +} + +@end diff --git a/platform/macos/src/MGLMapView.h b/platform/macos/src/MGLMapView.h new file mode 100644 index 0000000000..7b3efd293b --- /dev/null +++ b/platform/macos/src/MGLMapView.h @@ -0,0 +1,903 @@ +#import <Cocoa/Cocoa.h> +#import <CoreLocation/CoreLocation.h> + +#import "MGLGeometry.h" + +NS_ASSUME_NONNULL_BEGIN + +/** Options for enabling debugging features in an MGLMapView instance. */ +typedef NS_OPTIONS(NSUInteger, MGLMapDebugMaskOptions) { + /** Edges of tile boundaries are shown as thick, red lines to help diagnose + tile clipping issues. */ + MGLMapDebugTileBoundariesMask = 1 << 1, + + /** Each tile shows its tile coordinate (x/y/z) in the upper-left corner. */ + MGLMapDebugTileInfoMask = 1 << 2, + + /** Each tile shows a timestamp indicating when it was loaded. */ + MGLMapDebugTimestampsMask = 1 << 3, + + /** Edges of glyphs and symbols are shown as faint, green lines to help + diagnose collision and label placement issues. */ + MGLMapDebugCollisionBoxesMask = 1 << 4, + + /** Line widths, backgrounds, and fill colors are ignored to create a + wireframe effect. */ + MGLMapDebugWireframesMask = 1 << 5, + + /** The stencil buffer is shown instead of the color buffer. */ + MGLMapDebugStencilBufferMask = 1 << 6, +}; + +@class MGLAnnotationImage; +@class MGLMapCamera; + +@protocol MGLAnnotation; +@protocol MGLMapViewDelegate; +@protocol MGLOverlay; +@protocol MGLFeature; + +/** + An interactive, customizable map view with an interface similar to the one + provided by Apple’s MapKit. + + Using `MGLMapView`, you can embed the map inside a view, allow users to + manipulate it with standard gestures, animate the map between different + viewpoints, and present information in the form of annotations and overlays. + + The map view loads scalable vector tiles that conform to the + <a href="https://github.com/mapbox/vector-tile-spec">Mapbox Vector Tile Specification</a>. + It styles them with a style that conforms to the + <a href="https://www.mapbox.com/mapbox-gl-style-spec/">Mapbox GL style specification</a>. + Such styles can be designed in + <a href="https://www.mapbox.com/studio/">Mapbox Studio</a> and hosted on + mapbox.com. + + A collection of Mapbox-hosted styles is available through the `MGLStyle` class. + These basic styles use + <a href="https://www.mapbox.com/developers/vector-tiles/mapbox-streets">Mapbox Streets</a> + or <a href="https://www.mapbox.com/satellite/">Mapbox Satellite</a> data + sources, but you can specify a custom style that makes use of your own data. + + Mapbox-hosted vector tiles and styles require an API access token, which you + can obtain from the + <a href="https://www.mapbox.com/studio/account/tokens/">Mapbox account page</a>. + Access tokens associate requests to Mapbox’s vector tile and style APIs with + your Mapbox account. They also deter other developers from using your styles + without your permission. + + @note You are responsible for getting permission to use the map data and for + ensuring that your use adheres to the relevant terms of use. + */ +IB_DESIGNABLE +@interface MGLMapView : NSView + +#pragma mark Creating Instances + +/** + Initializes and returns a newly allocated map view with the specified frame and + the default style. + + @param frame The frame for the view, measured in points. + @return An initialized map view. + */ +- (instancetype)initWithFrame:(NSRect)frame; + +/** + Initializes and returns a newly allocated map view with the specified frame and + style URL. + + @param frame The frame for the view, measured in points. + @param styleURL URL of the map style to display. The URL may be a full HTTP or + HTTPS URL, a Mapbox URL indicating the style’s map ID + (`mapbox://styles/<user>/<style>`), or a path to a local file relative to + the application’s resource path. Specify `nil` for the default style. + @return An initialized map view. + */ +- (instancetype)initWithFrame:(NSRect)frame styleURL:(nullable NSURL *)styleURL; + +#pragma mark Accessing the Delegate + +/** + The receiver’s delegate. + + A map view sends messages to its delegate to notify it of changes to its + contents or the viewpoint. The delegate also provides information about + annotations displayed on the map, such as the styles to apply to individual + annotations. + */ +@property (nonatomic, weak, nullable) IBOutlet id <MGLMapViewDelegate> delegate; + +#pragma mark Configuring the Map’s Appearance + +/** + URL of the style currently displayed in the receiver. + + The URL may be a full HTTP or HTTPS URL, a Mapbox URL indicating the style’s + map ID (`mapbox://styles/<user>/<style>`), or a path to a local file relative + to the application’s resource path. + + If you set this property to `nil`, the receiver will use the default style and + this property will automatically be set to that style’s URL. + */ +@property (nonatomic, null_resettable) NSURL *styleURL; + +/** + Reloads the style. + + You do not normally need to call this method. The map view automatically + responds to changes in network connectivity by reloading the style. You may + need to call this method if you change the access token after a style has + loaded but before loading a style associated with a different Mapbox account. + */ +- (IBAction)reloadStyle:(id)sender; + +/** + A control for zooming in and out, positioned in the lower-right corner. + */ +@property (nonatomic, readonly) NSSegmentedControl *zoomControls; + +/** + A control indicating the map’s direction and allowing the user to manipulate + the direction, positioned above the zoom controls in the lower-right corner. + */ +@property (nonatomic, readonly) NSSlider *compass; + +/** + The Mapbox logo, positioned in the lower-left corner. + + @note The Mapbox terms of service, which governs the use of Mapbox-hosted + vector tiles and styles, + <a href="https://www.mapbox.com/help/mapbox-logo/">requires</a> most Mapbox + customers to display the Mapbox logo. If this applies to you, do not hide + this view or change its contents. + */ +@property (nonatomic, readonly) NSImageView *logoView; + +/** + A view showing legally required copyright notices, positioned along the bottom + of the map view, to the left of the Mapbox logo. + + @note The Mapbox terms of service, which governs the use of Mapbox-hosted + vector tiles and styles, + <a href="https://www.mapbox.com/help/attribution/">requires</a> these + copyright notices to accompany any map that features Mapbox-designed styles, + OpenStreetMap data, or other Mapbox data such as satellite or terrain data. + If that applies to this map view, do not hide this view or remove any + notices from it. + */ +@property (nonatomic, readonly) NSView *attributionView; + +#pragma mark Manipulating the Viewpoint + +/** + The geographic coordinate at the center of the map view. + + Changing the value of this property centers the map on the new coordinate + without changing the current zoom level. + + Changing the value of this property updates the map view immediately. If you + want to animate the change, use the `-setCenterCoordinate:animated:` method + instead. + */ +@property (nonatomic) CLLocationCoordinate2D centerCoordinate; + +/** + Changes the center coordinate of the map and optionally animates the change. + + Changing the center coordinate centers the map on the new coordinate without + changing the current zoom level. + + @param coordinate The new center coordinate for the map. + @param animated Specify `YES` if you want the map view to scroll to the new + location or `NO` if you want the map to display the new location + immediately. + */ +- (void)setCenterCoordinate:(CLLocationCoordinate2D)coordinate animated:(BOOL)animated; + +/** + The zoom level of the receiver. + + In addition to affecting the visual size and detail of features on the map, the + zoom level affects the size of the vector tiles that are loaded. At zoom level + 0, each tile covers the entire world map; at zoom level 1, it covers ¼ of the + world; at zoom level 2, <sup>1</sup>⁄<sub>16</sub> of the world, and so on. + + Changing the value of this property updates the map view immediately. If you + want to animate the change, use the `-setZoomLevel:animated:` method instead. + */ +@property (nonatomic) double zoomLevel; + +/** + The minimum zoom level at which the map can be shown. + + Depending on the map view’s aspect ratio, the map view may be prevented from + reaching the minimum zoom level, in order to keep the map from repeating within + the current viewport. + + If the value of this property is greater than that of the `maximumZoomLevel` + property, the behavior is undefined. + + The default value of this property is 0. + */ +@property (nonatomic) double minimumZoomLevel; + +/** + The maximum zoom level the map can be shown at. + + If the value of this property is smaller than that of the `minimumZoomLevel` + property, the behavior is undefined. + + The default value of this property is 20. + */ +@property (nonatomic) double maximumZoomLevel; + +/** + Changes the zoom level of the map and optionally animates the change. + + Changing the zoom level scales the map without changing the current center + coordinate. + + @param zoomLevel The new zoom level for the map. + @param animated Specify `YES` if you want the map view to animate the change + to the new zoom level or `NO` if you want the map to display the new zoom + level immediately. + */ +- (void)setZoomLevel:(double)zoomLevel animated:(BOOL)animated; + +/** + The heading of the map, measured in degrees clockwise from true north. + + The value `0` means that the top edge of the map view corresponds to true + north. The value `90` means the top of the map is pointing due east. The value + `180` means the top of the map points due south, and so on. + + Changing the value of this property updates the map view immediately. If you + want to animate the change, use the `-setDirection:animated:` method instead. + */ +@property (nonatomic) CLLocationDirection direction; + +/** + Changes the heading of the map and optionally animates the change. + + Changing the heading rotates the map without changing the current center + coordinate or zoom level. + + @param direction The heading of the map, measured in degrees clockwise from + true north. + @param animated Specify `YES` if you want the map view to animate the change + to the new heading or `NO` if you want the map to display the new heading + immediately. + */ +- (void)setDirection:(CLLocationDirection)direction animated:(BOOL)animated; + +/** + A camera representing the current viewpoint of the map. + */ +@property (nonatomic, copy) MGLMapCamera *camera; + +/** + Moves the viewpoint to a different location with respect to the map with an + optional transition animation. + + @param camera The new viewpoint. + @param animated Specify `YES` if you want the map view to animate the change to + the new viewpoint or `NO` if you want the map to display the new viewpoint + immediately. + */ +- (void)setCamera:(MGLMapCamera *)camera animated:(BOOL)animated; + +/** + Moves the viewpoint to a different location with respect to the map with an + optional transition duration and timing function. + + @param camera The new viewpoint. + @param duration The amount of time, measured in seconds, that the transition + animation should take. Specify `0` to jump to the new viewpoint + instantaneously. + @param function A timing function used for the animation. Set this parameter to + `nil` for a transition that matches most system animations. If the duration + is `0`, this parameter is ignored. + @param completion The block to execute after the animation finishes. + */ +- (void)setCamera:(MGLMapCamera *)camera withDuration:(NSTimeInterval)duration animationTimingFunction:(nullable CAMediaTimingFunction *)function completionHandler:(nullable void (^)(void))completion; + +/** + Moves the viewpoint to a different location using a transition animation that + evokes powered flight and a default duration based on the length of the flight + path. + + The transition animation seamlessly incorporates zooming and panning to help + the user find his or her bearings even after traversing a great distance. + + @param camera The new viewpoint. + @param completion The block to execute after the animation finishes. + */ +- (void)flyToCamera:(MGLMapCamera *)camera completionHandler:(nullable void (^)(void))completion; + +/** + Moves the viewpoint to a different location using a transition animation that + evokes powered flight and an optional transition duration. + + The transition animation seamlessly incorporates zooming and panning to help + the user find his or her bearings even after traversing a great distance. + + @param camera The new viewpoint. + @param duration The amount of time, measured in seconds, that the transition + animation should take. Specify `0` to jump to the new viewpoint + instantaneously. Specify a negative value to use the default duration, which + is based on the length of the flight path. + @param completion The block to execute after the animation finishes. + */ +- (void)flyToCamera:(MGLMapCamera *)camera withDuration:(NSTimeInterval)duration completionHandler:(nullable void (^)(void))completion; + +/** + Moves the viewpoint to a different location using a transition animation that + evokes powered flight and an optional transition duration and peak altitude. + + The transition animation seamlessly incorporates zooming and panning to help + the user find his or her bearings even after traversing a great distance. + + @param camera The new viewpoint. + @param duration The amount of time, measured in seconds, that the transition + animation should take. Specify `0` to jump to the new viewpoint + instantaneously. Specify a negative value to use the default duration, which + is based on the length of the flight path. + @param peakAltitude The altitude, measured in meters, at the midpoint of the + animation. The value of this parameter is ignored if it is negative or if + the animation transition resulting from a similar call to + `-setCamera:animated:` would have a midpoint at a higher altitude. + @param completion The block to execute after the animation finishes. + */ +- (void)flyToCamera:(MGLMapCamera *)camera withDuration:(NSTimeInterval)duration peakAltitude:(CLLocationDistance)peakAltitude completionHandler:(nullable void (^)(void))completion; + +/** + The geographic coordinate bounds visible in the receiver’s viewport. + + Changing the value of this property updates the receiver immediately. If you + want to animate the change, use the `-setVisibleCoordinateBounds:animated:` + method instead. + */ +@property (nonatomic) MGLCoordinateBounds visibleCoordinateBounds; + +/** + Changes the receiver’s viewport to fit the given coordinate bounds, optionally + animating the change. + + @param bounds The bounds that the viewport will show in its entirety. + @param animated Specify `YES` to animate the change by smoothly scrolling and + zooming or `NO` to immediately display the given bounds. + */ +- (void)setVisibleCoordinateBounds:(MGLCoordinateBounds)bounds animated:(BOOL)animated; + +/** + Changes the receiver’s viewport to fit the given coordinate bounds and + optionally some additional padding on each side. + + @param bounds The bounds that the viewport will show in its entirety. + @param insets The minimum padding (in screen points) that will be visible + around the given coordinate bounds. + @param animated Specify `YES` to animate the change by smoothly scrolling and + zooming or `NO` to immediately display the given bounds. + */ +- (void)setVisibleCoordinateBounds:(MGLCoordinateBounds)bounds edgePadding:(NSEdgeInsets)insets animated:(BOOL)animated; + +/** + Returns the camera that best fits the given coordinate bounds. + + @param bounds The coordinate bounds to fit to the receiver’s viewport. + @return A camera object centered on the same location as the coordinate bounds + with zoom level as high (close to the ground) as possible while still + including the entire coordinate bounds. The camera object uses the current + direction and pitch. + */ +- (MGLMapCamera *)cameraThatFitsCoordinateBounds:(MGLCoordinateBounds)bounds; + +/** + Returns the camera that best fits the given coordinate bounds, optionally with + some additional padding on each side. + + @param bounds The coordinate bounds to fit to the receiver’s viewport. + @param insets The minimum padding (in screen points) that would be visible + around the returned camera object if it were set as the receiver’s camera. + @return A camera object centered on the same location as the coordinate bounds + with zoom level as high (close to the ground) as possible while still + including the entire coordinate bounds. The camera object uses the current + direction and pitch. + */ +- (MGLMapCamera *)cameraThatFitsCoordinateBounds:(MGLCoordinateBounds)bounds edgePadding:(NSEdgeInsets)insets; + +/** + A Boolean value indicating whether the receiver automatically adjusts its + content insets. + + When the value of this property is `YES`, the map view automatically updates + its `contentInsets` property to account for any overlapping title bar or + toolbar. To overlap with the title bar or toolbar, the containing window’s + style mask must have `NSFullSizeContentViewWindowMask` set, and the title bar + must not be transparent. + + The default value of this property is `YES`. + */ +@property (nonatomic, assign) BOOL automaticallyAdjustsContentInsets; + +/** + The distance from the edges of the map view’s frame to the edges of the map + view’s logical viewport. + + When the value of this property is equal to `NSEdgeInsetsZero`, viewport + properties such as `centerCoordinate` assume a viewport that matches the map + view’s frame. Otherwise, those properties are inset, excluding part of the + frame from the viewport. For instance, if the only the top edge is inset, the + map center is effectively shifted downward. + + When the value of the `automaticallyAdjustsContentInsets` property is `YES`, + the value of this property may be overridden at any time. + + Changing the value of this property updates the map view immediately. If you + want to animate the change, use the `-setContentInsets:animated:` method + instead. + */ +@property (nonatomic, assign) NSEdgeInsets contentInsets; + +/** + Sets the distance from the edges of the map view’s frame to the edges of the + map view’s logical viewport, with an optional transition animation. + + When the value of this property is equal to `NSEdgeInsetsZero`, viewport + properties such as `centerCoordinate` assume a viewport that matches the map + view’s frame. Otherwise, those properties are inset, excluding part of the + frame from the viewport. For instance, if the only the top edge is inset, the + map center is effectively shifted downward. + + When the value of the `automaticallyAdjustsContentInsets` property is `YES`, + the value of this property may be overridden at any time. + + @param contentInsets The new values to inset the content by. + @param animated Specify `YES` if you want the map view to animate the change to + the content insets or `NO` if you want the map to inset the content + immediately. + */ +- (void)setContentInsets:(NSEdgeInsets)contentInsets animated:(BOOL)animated; + +#pragma mark Configuring How the User Interacts with the Map + +/** + A Boolean value that determines whether the user may zoom the map in and out, + changing the zoom level. + + When this property is set to `YES`, the default, the user may zoom the map in + and out by pinching two fingers, by using a scroll wheel on a traditional + mouse, or by dragging the mouse cursor up and down while holding down the Shift + key. When the receiver has focus, the user may also zoom by pressing the up and + down arrow keys while holding down the Option key. + + This property controls only user interactions with the map. If you set the + value of this property to `NO`, you may still change the map zoom + programmatically. + */ +@property (nonatomic, getter=isZoomEnabled) BOOL zoomEnabled; + +/** + A Boolean value that determines whether the user may scroll around the map, + changing the center coordinate. + + When this property is set to `YES`, the default, the user may scroll the map by + swiping with two fingers or dragging the mouse cursor. When the receiver has + focus, the user may also scroll around the map by pressing the arrow keys. + + This property controls only user interactions with the map. If you set the + value of this property to `NO`, you may still change the map location + programmatically. + */ +@property (nonatomic, getter=isScrollEnabled) BOOL scrollEnabled; + +/** + A Boolean value that determines whether the user may rotate the map, changing + the direction. + + When this property is set to `YES`, the default, the user may rotate the map by + moving two fingers in a circular motion or by dragging the mouse cursor left + and right while holding down the Option key. When the receiver has focus, the + user may also zoom by pressing the left and right arrow keys while holding down + the Option key. + + This property controls only user interactions with the map. If you set the + value of this property to `NO`, you may still rotate the map programmatically. + */ +@property (nonatomic, getter=isRotateEnabled) BOOL rotateEnabled; + +/** + A Boolean value that determines whether the user may tilt of the map, changing + the pitch. + + When this property is set to `YES`, the default, the user may rotate the map by + dragging the mouse cursor up and down while holding down the Option key. + + This property controls only user interactions with the map. If you set the + value of this property to `NO`, you may still change the pitch of the map + programmatically. + */ +@property (nonatomic, getter=isPitchEnabled) BOOL pitchEnabled; + +#pragma mark Annotating the Map + +/** + The complete list of annotations associated with the receiver. (read-only) + + The objects in this array must adopt the `MGLAnnotation` protocol. If no + annotations are associated with the map view, the value of this property is + `nil`. + */ +@property (nonatomic, readonly, nullable) NS_ARRAY_OF(id <MGLAnnotation>) *annotations; + +/** + Adds an annotation to the map view. + + @note `MGLMultiPolyline`, `MGLMultiPolygon`, and `MGLShapeCollection` objects + cannot be added to the map view at this time. Nor can `MGLMultiPoint` + objects that are not instances of `MGLPolyline` or `MGLPolygon`. Any + multipoint, multipolyline, multipolygon, or shape collection object that is + specified is silently ignored. + + @param annotation The annotation object to add to the receiver. This object + must conform to the `MGLAnnotation` protocol. The map view retains the + annotation object. + */ +- (void)addAnnotation:(id <MGLAnnotation>)annotation; + +/** + Adds an array of annotations to the map view. + + @note `MGLMultiPolyline`, `MGLMultiPolygon`, and `MGLShapeCollection` objects + cannot be added to the map view at this time. Nor can `MGLMultiPoint` + objects that are not instances of `MGLPolyline` or `MGLPolygon`. Any + multipoint, multipolyline, multipolygon, or shape collection objects that + are specified are silently ignored. + + @param annotations An array of annotation objects. Each object in the array + must conform to the `MGLAnnotation` protocol. The map view retains each + individual annotation object. + */ +- (void)addAnnotations:(NS_ARRAY_OF(id <MGLAnnotation>) *)annotations; + +/** + Removes an annotation from the map view, deselecting it if it is selected. + + Removing an annotation object dissociates it from the map view entirely, + preventing it from being displayed on the map. Thus you would typically call + this method only when you want to hide or delete a given annotation. + + @param annotation The annotation object to remove. This object must conform to + the `MGLAnnotation` protocol. + */ +- (void)removeAnnotation:(id <MGLAnnotation>)annotation; + +/** + Removes an array of annotations from the map view, deselecting any selected + annotations in the array. + + Removing annotation objects dissociates them from the map view entirely, + preventing them from being displayed on the map. Thus you would typically call + this method only when you want to hide or delete the given annotations. + + @param annotations The array of annotation objects to remove. Objects in the + array must conform to the `MGLAnnotation` protocol. + */ +- (void)removeAnnotations:(NS_ARRAY_OF(id <MGLAnnotation>) *)annotations; + +/** + Returns a reusable annotation image object associated with its identifier. + + For performance reasons, you should generally reuse `MGLAnnotationImage` + objects for identical-looking annotations in your map views. Dequeueing saves + time and memory during performance-critical operations such as scrolling. + + @param identifier A string identifying the annotation image to be reused. This + string is the same one you specify when initially returning the annotation + image object using the `-mapView:imageForAnnotation:` method. + @return An annotation image object with the given identifier, or `nil` if no + such object exists in the reuse queue. + */ +- (nullable MGLAnnotationImage *)dequeueReusableAnnotationImageWithIdentifier:(NSString *)identifier; + +#pragma mark Managing Annotation Selections + +/** + The currently selected annotations. + + Assigning a new array to this property selects only the first annotation in the + array. + */ +@property (nonatomic, copy) NS_ARRAY_OF(id <MGLAnnotation>) *selectedAnnotations; + +/** + Selects an annotation and displays a callout popover for it. + + If the given annotation is not visible within the current viewport, this method + has no effect. + + @param annotation The annotation object to select. + */ +- (void)selectAnnotation:(id <MGLAnnotation>)annotation; + +/** + Deselects an annotation and hides its callout popover. + + @param annotation The annotation object to deselect. + */ +- (void)deselectAnnotation:(nullable id <MGLAnnotation>)annotation; + +/** + A common view controller for managing a callout popover’s content view. + + Like any instance of `NSPopover`, an annotation callout manages its contents + with a view controller. The annotation object is the view controller’s + represented object. This means that you can bind controls in the view + controller’s content view to KVO-compliant properties of the annotation object, + such as `title` and `subtitle`. + + This property defines a common view controller that is used for every + annotation’s callout view. If you set this property to `nil`, a default view + controller will be used that manages a simple title label and subtitle label. + If you need distinct view controllers for different annotations, the map view’s + delegate should implement `-mapView:calloutViewControllerForAnnotation:` + instead. + */ +@property (nonatomic, strong, null_resettable) IBOutlet NSViewController *calloutViewController; + +#pragma mark Finding Annotations + +/** + Returns a point annotation located at the given point. + + @param point A point in the view’s coordinate system. + @return A point annotation whose annotation image coincides with the point. If + multiple point annotations coincide with the point, the return value is the + annotation that would be selected if the user clicks at this point. + */ +- (id <MGLAnnotation>)annotationAtPoint:(NSPoint)point; + +#pragma mark Overlaying the Map + +/** + Adds a single overlay to the map. + + To remove an overlay from a map, use the `-removeOverlay:` method. + + @param overlay The overlay object to add. This object must conform to the + `MGLOverlay` protocol. + */ +- (void)addOverlay:(id <MGLOverlay>)overlay; + +/** + Adds an array of overlays to the map. + + To remove multiple overlays from a map, use the `-removeOverlays:` method. + + @param overlays An array of objects, each of which must conform to the + `MGLOverlay` protocol. + */ +- (void)addOverlays:(NS_ARRAY_OF(id <MGLOverlay>) *)overlays; + +/** + Removes a single overlay from the map. + + If the specified overlay is not currently associated with the map view, this + method does nothing. + + @param overlay The overlay object to remove. + */ +- (void)removeOverlay:(id <MGLOverlay>)overlay; + +/** + Removes an array of overlays from the map. + + If a given overlay object is not associated with the map view, it is ignored. + + @param overlays An array of objects, each of which conforms to the `MGLOverlay` + protocol. + */ +- (void)removeOverlays:(NS_ARRAY_OF(id <MGLOverlay>) *)overlays; + +#pragma mark Accessing the Underlying Map Data + +/** + Returns an array of rendered map features that intersect with a given point. + + This method may return features from any of the map’s style layers. To restrict + the search to a particular layer or layers, use the + `-visibleFeaturesAtPoint:inStyleLayersWithIdentifiers:` method. For more + information about searching for map features, see that method’s documentation. + + @param point A point expressed in the map view’s coordinate system. + @return An array of objects conforming to the `MGLFeature` protocol that + represent features in the sources used by the current style. + */ +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesAtPoint:(NSPoint)point NS_SWIFT_NAME(visibleFeatures(_:)); + +/** + Returns an array of rendered map features that intersect with a given point, + restricted to the given style layers. + + Each object in the returned array represents a feature rendered by the + current style and provides access to attributes specified by the relevant + <a href="https://www.mapbox.com/mapbox-gl-style-spec/#sources">tile sources</a>. + The returned array includes features specified in vector and GeoJSON tile + sources but does not include anything from raster, image, or video sources. + + Only visible features are returned. For example, suppose the current style uses + the + <a href="https://www.mapbox.com/vector-tiles/mapbox-streets/">Mapbox Streets source</a>, + but none of the specified style layers includes features that have the `maki` + property set to `bus`. If you pass a point corresponding to the location of a + bus stop into this method, the bus stop feature does not appear in the + resulting array. On the other hand, if the style does include bus stops, an + `MGLFeature` object representing that bus stop is returned and its + `attributes` dictionary has the `maki` key set to `bus` (along with other + attributes). The dictionary contains only the attributes provided by the + tile source; it does not include computed attribute values or rules about how + the feature is rendered by the current style. + + The returned array is sorted by z-order, starting with the topmost rendered + feature and ending with the bottommost rendered feature. A feature that is + rendered multiple times due to wrapping across the antimeridian at low zoom + levels is included only once, subject to the caveat that follows. + + Features come from tiled vector data or GeoJSON data that is converted to tiles + internally, so feature geometries are clipped at tile boundaries and features + may appear duplicated across tiles. For example, suppose the specified point + lies along a road that spans the screen. The resulting array includes those + parts of the road that lie within the map tile that contain the specified + point, even if the road extends into other tiles. + + To find out the layer names in a particular style, view the style in + <a href="https://www.mapbox.com/studio/">Mapbox Studio</a>. + + @param point A point expressed in the map view’s coordinate system. + @param styleLayerIdentifiers A set of strings that correspond to the names of + layers defined in the current style. Only the features contained in these + layers are included in the returned array. + @return An array of objects conforming to the `MGLFeature` protocol that + represent features in the sources used by the current style. + */ +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesAtPoint:(NSPoint)point inStyleLayersWithIdentifiers:(nullable NS_SET_OF(NSString *) *)styleLayerIdentifiers NS_SWIFT_NAME(visibleFeatures(_:styleLayerIdentifiers:)); + +/** + Returns an array of rendered map features that intersect with the given + rectangle. + + This method may return features from any of the map’s style layers. To restrict + the search to a particular layer or layers, use the + `-visibleFeaturesAtPoint:inStyleLayersWithIdentifiers:` method. For more + information about searching for map features, see that method’s documentation. + + @param rect A rectangle expressed in the map view’s coordinate system. + @return An array of objects conforming to the `MGLFeature` protocol that + represent features in the sources used by the current style. + */ +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesInRect:(NSRect)rect NS_SWIFT_NAME(visibleFeatures(_:)); + +/** + Returns an array of rendered map features that intersect with the given + rectangle, restricted to the given style layers. + + Each object in the returned array represents a feature rendered by the + current style and provides access to attributes specified by the relevant + <a href="https://www.mapbox.com/mapbox-gl-style-spec/#sources">tile sources</a>. + The returned array includes features specified in vector and GeoJSON tile + sources but does not include anything from raster, image, or video sources. + + Only visible features are returned. For example, suppose the current style uses + the + <a href="https://www.mapbox.com/vector-tiles/mapbox-streets/">Mapbox Streets source</a>, + but none of the specified style layers includes features that have the `maki` + property set to `bus`. If you pass a rectangle containing the location of a bus + stop into this method, the bus stop feature does not appear in the resulting + array. On the other hand, if the style does include bus stops, an `MGLFeature` + object representing that bus stop is returned and its `attributes` dictionary + has the `maki` key set to `bus` (along with other attributes). The dictionary + contains only the attributes provided by the tile source; it does not include + computed attribute values or rules about how the feature is rendered by the + current style. + + The returned array is sorted by z-order, starting with the topmost rendered + feature and ending with the bottommost rendered feature. A feature that is + rendered multiple times due to wrapping across the antimeridian at low zoom + levels is included only once, subject to the caveat that follows. + + Features come from tiled vector data or GeoJSON data that is converted to tiles + internally, so feature geometries are clipped at tile boundaries and features + may appear duplicated across tiles. For example, suppose the specified + rectangle intersects with a road that spans the screen. The resulting array + includes those parts of the road that lie within the map tiles covering the + specified rectangle, even if the road extends into other tiles. The portion of + the road within each map tile is included individually. + + To find out the layer names in a particular style, view the style in + <a href="https://www.mapbox.com/studio/">Mapbox Studio</a>. + + @param rect A rectangle expressed in the map view’s coordinate system. + @param styleLayerIdentifiers A set of strings that correspond to the names of + layers defined in the current style. Only the features contained in these + layers are included in the returned array. + @return An array of objects conforming to the `MGLFeature` protocol that + represent features in the sources used by the current style. + */ +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesInRect:(NSRect)rect inStyleLayersWithIdentifiers:(nullable NS_SET_OF(NSString *) *)styleLayerIdentifiers NS_SWIFT_NAME(visibleFeatures(_:styleLayerIdentifiers:)); + +#pragma mark Converting Geographic Coordinates + +/** + Converts a geographic coordinate to a point in the given view’s coordinate + system. + + @param coordinate The geographic coordinate to convert. + @param view The view in whose coordinate system the returned point should be + expressed. If this parameter is `nil`, the returned point is expressed in + the window’s coordinate system. If `view` is not `nil`, it must belong to + the same window as the map view. + @return The point (in the appropriate view or window coordinate system) + corresponding to the given geographic coordinate. + */ +- (NSPoint)convertCoordinate:(CLLocationCoordinate2D)coordinate toPointToView:(nullable NSView *)view; + +/** + Converts a point in the given view’s coordinate system to a geographic + coordinate. + + @param point The point to convert. + @param view The view in whose coordinate system the point is expressed. + @return The geographic coordinate at the given point. + */ +- (CLLocationCoordinate2D)convertPoint:(NSPoint)point toCoordinateFromView:(nullable NSView *)view; + +/** + Converts a geographic bounding box to a rectangle in the given view’s + coordinate system. + + @param bounds The geographic bounding box to convert. + @param view The view in whose coordinate system the returned rectangle should + be expressed. If this parameter is `nil`, the returned rectangle is + expressed in the window’s coordinate system. If `view` is not `nil`, it must + belong to the same window as the map view. + */ +- (NSRect)convertCoordinateBounds:(MGLCoordinateBounds)bounds toRectToView:(nullable NSView *)view; + +/** + Converts a rectangle in the given view’s coordinate system to a geographic + bounding box. + + @param rect The rectangle to convert. + @param view The view in whose coordinate system the rectangle is expressed. + @return The geographic bounding box coextensive with the given rectangle. + */ +- (MGLCoordinateBounds)convertRect:(NSRect)rect toCoordinateBoundsFromView:(nullable NSView *)view; + +/** + Returns the distance spanned by one point in the map view’s coordinate system + at the given latitude and current zoom level. + + The distance between points decreases as the latitude approaches the poles. + This relationship parallels the relationship between longitudinal coordinates + at different latitudes. + + @param latitude The latitude of the geographic coordinate represented by the + point. + @return The distance in meters spanned by a single point. + */ +- (CLLocationDistance)metersPerPointAtLatitude:(CLLocationDegrees)latitude; + +#pragma mark Debugging the Map + +/** + The options that determine which debugging aids are shown on the map. + + These options are all disabled by default and should remain disabled in + released software for performance and aesthetic reasons. + */ +@property (nonatomic) MGLMapDebugMaskOptions debugMask; + +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/macos/src/MGLMapView.mm b/platform/macos/src/MGLMapView.mm new file mode 100644 index 0000000000..2f985b85d8 --- /dev/null +++ b/platform/macos/src/MGLMapView.mm @@ -0,0 +1,2499 @@ +#import "MGLMapView_Private.h" +#import "MGLAnnotationImage_Private.h" +#import "MGLAttributionButton.h" +#import "MGLCompassCell.h" +#import "MGLOpenGLLayer.h" +#import "MGLStyle.h" + +#import "MGLFeature_Private.h" +#import "MGLGeometry_Private.h" +#import "MGLMultiPoint_Private.h" +#import "MGLOfflineStorage_Private.h" + +#import "MGLAccountManager.h" +#import "MGLMapCamera.h" +#import "MGLPolygon.h" +#import "MGLPolyline.h" +#import "MGLAnnotationImage.h" +#import "MGLMapViewDelegate.h" + +#import <mbgl/mbgl.hpp> +#import <mbgl/annotation/annotation.hpp> +#import <mbgl/map/camera.hpp> +#import <mbgl/platform/darwin/reachability.h> +#import <mbgl/gl/gl.hpp> +#import <mbgl/sprite/sprite_image.hpp> +#import <mbgl/storage/default_file_source.hpp> +#import <mbgl/storage/network_status.hpp> +#import <mbgl/math/wrap.hpp> +#import <mbgl/util/constants.hpp> +#import <mbgl/util/chrono.hpp> + +#import <map> +#import <unordered_set> + +#import "NSBundle+MGLAdditions.h" +#import "NSProcessInfo+MGLAdditions.h" +#import "NSException+MGLAdditions.h" +#import "NSString+MGLAdditions.h" + +#import <QuartzCore/QuartzCore.h> + +class MGLMapViewImpl; +class MGLAnnotationContext; + +/// Distance from the edge of the view to ornament views (logo, attribution, etc.). +const CGFloat MGLOrnamentPadding = 12; + +/// Alpha value of the ornament views (logo, attribution, etc.). +const CGFloat MGLOrnamentOpacity = 0.9; + +/// Default duration for programmatic animations. +const NSTimeInterval MGLAnimationDuration = 0.3; + +/// Distance in points that a single press of the panning keyboard shortcut pans the map by. +const CGFloat MGLKeyPanningIncrement = 150; + +/// Degrees that a single press of the rotation keyboard shortcut rotates the map by. +const CLLocationDegrees MGLKeyRotationIncrement = 25; + +/// Key for the user default that, when true, causes the map view to zoom in and out on scroll wheel events. +NSString * const MGLScrollWheelZoomsMapViewDefaultKey = @"MGLScrollWheelZoomsMapView"; + +/// Reuse identifier and file name of the default point annotation image. +static NSString * const MGLDefaultStyleMarkerSymbolName = @"default_marker"; + +/// Prefix that denotes a sprite installed by MGLMapView, to avoid collisions +/// with style-defined sprites. +static NSString * const MGLAnnotationSpritePrefix = @"com.mapbox.sprites."; + +/// Slop area around the hit testing point, allowing for imprecise annotation selection. +const CGFloat MGLAnnotationImagePaddingForHitTest = 4; + +/// Distance from the callout’s anchor point to the annotation it points to. +const CGFloat MGLAnnotationImagePaddingForCallout = 4; + +/// Copyright notices displayed in the attribution view. +struct MGLAttribution { + /// Attribution button label text. A copyright symbol is prepended to this string. + NSString *title; + /// URL to open when the attribution button is clicked. + NSString *urlString; +} MGLAttributions[] = { + { + .title = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_MAPBOX", nil, nil, @"Mapbox", @"Linked part of copyright notice"), + .urlString = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_MAPBOX_LINK", nil, nil, @"https://www.mapbox.com/about/maps/", @"Copyright notice link"), + }, + { + .title = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_OSM", nil, nil, @"OpenStreetMap", @"Linked part of copyright notice"), + .urlString = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_OSM_LINK", nil, nil, @"http://www.openstreetmap.org/about/", @"Copyright notice link"), + }, +}; + +/// Unique identifier representing a single annotation in mbgl. +typedef uint32_t MGLAnnotationTag; + +/// An indication that the requested annotation was not found or is nonexistent. +enum { MGLAnnotationTagNotFound = UINT32_MAX }; + +/// Mapping from an annotation tag to metadata about that annotation, including +/// the annotation itself. +typedef std::map<MGLAnnotationTag, MGLAnnotationContext> MGLAnnotationContextMap; + +/// Returns an NSImage for the default marker image. +NSImage *MGLDefaultMarkerImage() { + NSString *path = [[NSBundle mgl_frameworkBundle] pathForResource:MGLDefaultStyleMarkerSymbolName + ofType:@"pdf"]; + return [[NSImage alloc] initWithContentsOfFile:path]; +} + +/// Converts from a duration in seconds to a duration object usable in mbgl. +mbgl::Duration MGLDurationInSeconds(NSTimeInterval duration) { + return std::chrono::duration_cast<mbgl::Duration>(std::chrono::duration<NSTimeInterval>(duration)); +} + +/// Converts a media timing function into a unit bezier object usable in mbgl. +mbgl::util::UnitBezier MGLUnitBezierForMediaTimingFunction(CAMediaTimingFunction *function) { + if (!function) { + function = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionDefault]; + } + float p1[2], p2[2]; + [function getControlPointAtIndex:0 values:p1]; + [function getControlPointAtIndex:1 values:p2]; + return { p1[0], p1[1], p2[0], p2[1] }; +} + +/// Converts the given color into an mbgl::Color in calibrated RGB space. +mbgl::Color MGLColorObjectFromNSColor(NSColor *color) { + if (!color) { + return {{ 0, 0, 0, 0 }}; + } + CGFloat r, g, b, a; + [[color colorUsingColorSpaceName:NSCalibratedRGBColorSpace] getRed:&r green:&g blue:&b alpha:&a]; + return {{ (float)r, (float)g, (float)b, (float)a }}; +} + +/// Lightweight container for metadata about an annotation, including the annotation itself. +class MGLAnnotationContext { +public: + id <MGLAnnotation> annotation; + /// The annotation’s image’s reuse identifier. + NSString *imageReuseIdentifier; +}; + +@interface MGLMapView () <NSPopoverDelegate, MGLMultiPointDelegate> + +@property (nonatomic, readwrite) NSSegmentedControl *zoomControls; +@property (nonatomic, readwrite) NSSlider *compass; +@property (nonatomic, readwrite) NSImageView *logoView; +@property (nonatomic, readwrite) NSView *attributionView; + +/// Mapping from reusable identifiers to annotation images. +@property (nonatomic) NS_MUTABLE_DICTIONARY_OF(NSString *, MGLAnnotationImage *) *annotationImagesByIdentifier; +/// Currently shown popover representing the selected annotation. +@property (nonatomic) NSPopover *calloutForSelectedAnnotation; + +@property (nonatomic, readwrite, getter=isDormant) BOOL dormant; + +@end + +@implementation MGLMapView { + /// Cross-platform map view controller. + mbgl::Map *_mbglMap; + MGLMapViewImpl *_mbglView; + + NSPanGestureRecognizer *_panGestureRecognizer; + NSMagnificationGestureRecognizer *_magnificationGestureRecognizer; + NSRotationGestureRecognizer *_rotationGestureRecognizer; + double _scaleAtBeginningOfGesture; + CLLocationDirection _directionAtBeginningOfGesture; + CGFloat _pitchAtBeginningOfGesture; + BOOL _didHideCursorDuringGesture; + + MGLAnnotationContextMap _annotationContextsByAnnotationTag; + MGLAnnotationTag _selectedAnnotationTag; + MGLAnnotationTag _lastSelectedAnnotationTag; + /// Size of the rectangle formed by unioning the maximum slop area around every annotation image. + NSSize _unionedAnnotationImageSize; + std::vector<MGLAnnotationTag> _annotationsNearbyLastClick; + /// True if any annotations that have tooltips have been installed. + BOOL _wantsToolTipRects; + /// True if any annotation images that have custom cursors have been installed. + BOOL _wantsCursorRects; + + // Cached checks for delegate method implementations that may be called from + // MGLMultiPointDelegate methods. + + BOOL _delegateHasAlphasForShapeAnnotations; + BOOL _delegateHasStrokeColorsForShapeAnnotations; + BOOL _delegateHasFillColorsForShapeAnnotations; + BOOL _delegateHasLineWidthsForShapeAnnotations; + + /// True if the current process is the Interface Builder designable + /// renderer. When drawing the designable, the map is paused, so any call to + /// it may hang the process. + BOOL _isTargetingInterfaceBuilder; + CLLocationDegrees _pendingLatitude; + CLLocationDegrees _pendingLongitude; + + /// True if the view is currently printing itself. + BOOL _isPrinting; +} + +#pragma mark Lifecycle + ++ (void)initialize { + if (self == [MGLMapView class]) { + [[NSUserDefaults standardUserDefaults] registerDefaults:@{ + MGLScrollWheelZoomsMapViewDefaultKey: @NO, + }]; + } +} + +- (instancetype)initWithFrame:(NSRect)frameRect { + if (self = [super initWithFrame:frameRect]) { + [self commonInit]; + self.styleURL = nil; + } + return self; +} + +- (instancetype)initWithFrame:(NSRect)frame styleURL:(nullable NSURL *)styleURL { + if (self = [super initWithFrame:frame]) { + [self commonInit]; + self.styleURL = styleURL; + } + return self; +} + +- (instancetype)initWithCoder:(nonnull NSCoder *)decoder { + if (self = [super initWithCoder:decoder]) { + [self commonInit]; + } + return self; +} + +- (void)awakeFromNib { + [super awakeFromNib]; + + self.styleURL = nil; +} + ++ (NSArray *)restorableStateKeyPaths { + return @[@"camera", @"debugMask"]; +} + +- (void)commonInit { + _isTargetingInterfaceBuilder = NSProcessInfo.processInfo.mgl_isInterfaceBuilderDesignablesAgent; + + // Set up cross-platform controllers and resources. + _mbglView = new MGLMapViewImpl(self, [NSScreen mainScreen].backingScaleFactor); + + // Delete the pre-offline ambient cache at + // ~/Library/Caches/com.mapbox.sdk.ios/cache.db. + NSURL *cachesDirectoryURL = [[NSFileManager defaultManager] URLForDirectory:NSCachesDirectory + inDomain:NSUserDomainMask + appropriateForURL:nil + create:NO + error:nil]; + cachesDirectoryURL = [cachesDirectoryURL URLByAppendingPathComponent: + [NSBundle mgl_frameworkBundle].bundleIdentifier]; + NSURL *legacyCacheURL = [cachesDirectoryURL URLByAppendingPathComponent:@"cache.db"]; + [[NSFileManager defaultManager] removeItemAtURL:legacyCacheURL error:NULL]; + + mbgl::DefaultFileSource *mbglFileSource = [MGLOfflineStorage sharedOfflineStorage].mbglFileSource; + _mbglMap = new mbgl::Map(*_mbglView, *mbglFileSource, mbgl::MapMode::Continuous, mbgl::GLContextMode::Unique, mbgl::ConstrainMode::None, mbgl::ViewportMode::Default); + [self validateTileCacheSize]; + + // Install the OpenGL layer. Interface Builder’s synchronous drawing means + // we can’t display a map, so don’t even bother to have a map layer. + self.layer = _isTargetingInterfaceBuilder ? [CALayer layer] : [MGLOpenGLLayer layer]; + + // Notify map object when network reachability status changes. + MGLReachability *reachability = [MGLReachability reachabilityForInternetConnection]; + reachability.reachableBlock = ^(MGLReachability *) { + mbgl::NetworkStatus::Reachable(); + }; + [reachability startNotifier]; + + // Install ornaments and gesture recognizers. + [self installZoomControls]; + [self installCompass]; + [self installLogoView]; + [self installAttributionView]; + [self installGestureRecognizers]; + + // Set up annotation management and selection state. + _annotationImagesByIdentifier = [NSMutableDictionary dictionary]; + _annotationContextsByAnnotationTag = {}; + _selectedAnnotationTag = MGLAnnotationTagNotFound; + _lastSelectedAnnotationTag = MGLAnnotationTagNotFound; + _annotationsNearbyLastClick = {}; + + // Jump to Null Island initially. + self.automaticallyAdjustsContentInsets = YES; + mbgl::CameraOptions options; + options.center = mbgl::LatLng(0, 0); + options.padding = MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets); + options.zoom = _mbglMap->getMinZoom(); + _mbglMap->jumpTo(options); + _pendingLatitude = NAN; + _pendingLongitude = NAN; +} + +/// Adds zoom controls to the lower-right corner. +- (void)installZoomControls { + _zoomControls = [[NSSegmentedControl alloc] initWithFrame:NSZeroRect]; + _zoomControls.wantsLayer = YES; + _zoomControls.layer.opacity = MGLOrnamentOpacity; + [(NSSegmentedCell *)_zoomControls.cell setTrackingMode:NSSegmentSwitchTrackingMomentary]; + _zoomControls.continuous = YES; + _zoomControls.segmentCount = 2; + [_zoomControls setLabel:NSLocalizedStringWithDefaultValue(@"ZOOM_OUT_LABEL", nil, nil, @"−", @"Label of Zoom Out button; U+2212 MINUS SIGN") forSegment:0]; + [(NSSegmentedCell *)_zoomControls.cell setTag:0 forSegment:0]; + [(NSSegmentedCell *)_zoomControls.cell setToolTip:NSLocalizedStringWithDefaultValue(@"ZOOM_OUT_TOOLTIP", nil, nil, @"Zoom Out", @"Tooltip of Zoom Out button") forSegment:0]; + [_zoomControls setLabel:NSLocalizedStringWithDefaultValue(@"ZOOM_IN_LABEL", nil, nil, @"+", @"Label of Zoom In button") forSegment:1]; + [(NSSegmentedCell *)_zoomControls.cell setTag:1 forSegment:1]; + [(NSSegmentedCell *)_zoomControls.cell setToolTip:NSLocalizedStringWithDefaultValue(@"ZOOM_IN_TOOLTIP", nil, nil, @"Zoom In", @"Tooltip of Zoom In button") forSegment:1]; + _zoomControls.target = self; + _zoomControls.action = @selector(zoomInOrOut:); + _zoomControls.controlSize = NSRegularControlSize; + [_zoomControls sizeToFit]; + _zoomControls.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_zoomControls]; +} + +/// Adds a rudimentary compass control to the lower-right corner. +- (void)installCompass { + _compass = [[NSSlider alloc] initWithFrame:NSZeroRect]; + _compass.wantsLayer = YES; + _compass.layer.opacity = MGLOrnamentOpacity; + _compass.cell = [[MGLCompassCell alloc] init]; + _compass.continuous = YES; + _compass.target = self; + _compass.action = @selector(rotate:); + [_compass sizeToFit]; + _compass.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_compass]; +} + +/// Adds a Mapbox logo to the lower-left corner. +- (void)installLogoView { + _logoView = [[NSImageView alloc] initWithFrame:NSZeroRect]; + _logoView.wantsLayer = YES; + NSImage *logoImage = [[NSImage alloc] initWithContentsOfFile: + [[NSBundle mgl_frameworkBundle] pathForResource:@"mapbox" ofType:@"pdf"]]; + // Account for the image’s built-in padding when aligning other controls to the logo. + logoImage.alignmentRect = NSInsetRect(logoImage.alignmentRect, 3, 3); + _logoView.image = logoImage; + _logoView.translatesAutoresizingMaskIntoConstraints = NO; + _logoView.accessibilityTitle = NSLocalizedStringWithDefaultValue(@"MAP_A11Y_TITLE", nil, nil, @"Mapbox", @"Accessibility title"); + [self addSubview:_logoView]; +} + +/// Adds legally required map attribution to the lower-left corner. +- (void)installAttributionView { + _attributionView = [[NSView alloc] initWithFrame:NSZeroRect]; + _attributionView.wantsLayer = YES; + + // Make the background and foreground translucent to be unobtrusive. + _attributionView.layer.opacity = 0.6; + + // Blur the background to prevent text underneath the view from running into + // the text in the view, rendering it illegible. + CIFilter *attributionBlurFilter = [CIFilter filterWithName:@"CIGaussianBlur"]; + [attributionBlurFilter setDefaults]; + + // Brighten the background. This is similar to applying a translucent white + // background on the view, but the effect is a bit more subtle and works + // well with the blur above. + CIFilter *attributionColorFilter = [CIFilter filterWithName:@"CIColorControls"]; + [attributionColorFilter setDefaults]; + [attributionColorFilter setValue:@(0.1) forKey:kCIInputBrightnessKey]; + + // Apply the background effects and a standard button corner radius. + _attributionView.backgroundFilters = @[attributionColorFilter, attributionBlurFilter]; + _attributionView.layer.cornerRadius = 4; + + _attributionView.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_attributionView]; + [self updateAttributionView]; +} + +/// Adds gesture recognizers for manipulating the viewport and selecting annotations. +- (void)installGestureRecognizers { + _scrollEnabled = YES; + _zoomEnabled = YES; + _rotateEnabled = YES; + _pitchEnabled = YES; + + _panGestureRecognizer = [[NSPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePanGesture:)]; + _panGestureRecognizer.delaysKeyEvents = YES; + [self addGestureRecognizer:_panGestureRecognizer]; + + NSClickGestureRecognizer *clickGestureRecognizer = [[NSClickGestureRecognizer alloc] initWithTarget:self action:@selector(handleClickGesture:)]; + clickGestureRecognizer.delaysPrimaryMouseButtonEvents = NO; + [self addGestureRecognizer:clickGestureRecognizer]; + + NSClickGestureRecognizer *doubleClickGestureRecognizer = [[NSClickGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleClickGesture:)]; + doubleClickGestureRecognizer.numberOfClicksRequired = 2; + doubleClickGestureRecognizer.delaysPrimaryMouseButtonEvents = NO; + [self addGestureRecognizer:doubleClickGestureRecognizer]; + + _magnificationGestureRecognizer = [[NSMagnificationGestureRecognizer alloc] initWithTarget:self action:@selector(handleMagnificationGesture:)]; + [self addGestureRecognizer:_magnificationGestureRecognizer]; + + _rotationGestureRecognizer = [[NSRotationGestureRecognizer alloc] initWithTarget:self action:@selector(handleRotationGesture:)]; + [self addGestureRecognizer:_rotationGestureRecognizer]; +} + +/// Updates the attribution view to reflect the sources used. For now, this is +/// hard-coded to the standard Mapbox and OpenStreetMap attribution. +- (void)updateAttributionView { + self.attributionView.subviews = @[]; + + for (NSUInteger i = 0; i < sizeof(MGLAttributions) / sizeof(MGLAttributions[0]); i++) { + // For each attribution, add a borderless button that responds to clicks + // and feels like a hyperlink. + NSURL *url = [NSURL URLWithString:MGLAttributions[i].urlString]; + NSButton *button = [[MGLAttributionButton alloc] initWithTitle:MGLAttributions[i].title URL:url]; + button.controlSize = NSMiniControlSize; + button.translatesAutoresizingMaskIntoConstraints = NO; + + // Set the new button flush with the buttom of the container and to the + // right of the previous button, with standard spacing. If there is no + // previous button, align to the container instead. + NSView *previousView = self.attributionView.subviews.lastObject; + [self.attributionView addSubview:button]; + [_attributionView addConstraint: + [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:_attributionView + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:0]]; + [_attributionView addConstraint: + [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:previousView ? previousView : _attributionView + attribute:previousView ? NSLayoutAttributeTrailing : NSLayoutAttributeLeading + multiplier:1 + constant:8]]; + } +} + +- (void)dealloc { + [self.window removeObserver:self forKeyPath:@"contentLayoutRect"]; + [self.window removeObserver:self forKeyPath:@"titlebarAppearsTransparent"]; + + // Close any annotation callout immediately. + [self.calloutForSelectedAnnotation close]; + self.calloutForSelectedAnnotation = nil; + + // Removing the annotations unregisters any outstanding KVO observers. + [self removeAnnotations:self.annotations]; + + if (_mbglMap) { + delete _mbglMap; + _mbglMap = nullptr; + } + if (_mbglView) { + delete _mbglView; + _mbglView = nullptr; + } +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(__unused NSDictionary *)change context:(void *)context { + if ([keyPath isEqualToString:@"contentLayoutRect"] || + [keyPath isEqualToString:@"titlebarAppearsTransparent"]) { + [self adjustContentInsets]; + } else if ([keyPath isEqualToString:@"coordinate"] && + [object conformsToProtocol:@protocol(MGLAnnotation)] && + ![object isKindOfClass:[MGLMultiPoint class]]) { + id <MGLAnnotation> annotation = object; + MGLAnnotationTag annotationTag = (MGLAnnotationTag)(NSUInteger)context; + // We can get here because a subclass registered itself as an observer + // of the coordinate key path of a non-multipoint annotation but failed + // to handle the change. This check deters us from treating the + // subclass’s context as an annotation tag. If the context happens to + // match a valid annotation tag, the annotation will be unnecessarily + // but safely updated. + if (annotation == [self annotationWithTag:annotationTag]) { + const mbgl::Point<double> point = MGLPointFromLocationCoordinate2D(annotation.coordinate); + MGLAnnotationImage *annotationImage = [self imageOfAnnotationWithTag:annotationTag]; + _mbglMap->updateAnnotation(annotationTag, mbgl::SymbolAnnotation { point, annotationImage.styleIconIdentifier.UTF8String ?: "" }); + if (annotationTag == _selectedAnnotationTag) { + [self deselectAnnotation:annotation]; + } + } + } +} + ++ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { + return [key isEqualToString:@"annotations"] ? YES : [super automaticallyNotifiesObserversForKey:key]; +} + +- (void)setDelegate:(id<MGLMapViewDelegate>)delegate { + _delegate = delegate; + + // Cache checks for delegate method implementations that may be called in a + // hot loop, namely the annotation style methods. + _delegateHasAlphasForShapeAnnotations = [_delegate respondsToSelector:@selector(mapView:alphaForShapeAnnotation:)]; + _delegateHasStrokeColorsForShapeAnnotations = [_delegate respondsToSelector:@selector(mapView:strokeColorForShapeAnnotation:)]; + _delegateHasFillColorsForShapeAnnotations = [_delegate respondsToSelector:@selector(mapView:fillColorForPolygonAnnotation:)]; + _delegateHasLineWidthsForShapeAnnotations = [_delegate respondsToSelector:@selector(mapView:lineWidthForPolylineAnnotation:)]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wundeclared-selector" + if ([self.delegate respondsToSelector:@selector(mapView:regionWillChangeAnimated:)]) { + NSLog(@"-mapView:regionWillChangeAnimated: is not supported by the macOS SDK, but %@ implements it anyways. " + @"Please implement -[%@ mapView:cameraWillChangeAnimated:] instead.", + NSStringFromClass([delegate class]), NSStringFromClass([delegate class])); + } + if ([self.delegate respondsToSelector:@selector(mapViewRegionIsChanging:)]) { + NSLog(@"-mapViewRegionIsChanging: is not supported by the macOS SDK, but %@ implements it anyways. " + @"Please implement -[%@ mapViewCameraIsChanging:] instead.", + NSStringFromClass([delegate class]), NSStringFromClass([delegate class])); + } + if ([self.delegate respondsToSelector:@selector(mapView:regionDidChangeAnimated:)]) { + NSLog(@"-mapView:regionDidChangeAnimated: is not supported by the macOS SDK, but %@ implements it anyways. " + @"Please implement -[%@ mapView:cameraDidChangeAnimated:] instead.", + NSStringFromClass([delegate class]), NSStringFromClass([delegate class])); + } +#pragma clang diagnostic pop +} + +#pragma mark Style + +- (nonnull NSURL *)styleURL { + NSString *styleURLString = @(_mbglMap->getStyleURL().c_str()).mgl_stringOrNilIfEmpty; + return styleURLString ? [NSURL URLWithString:styleURLString] : [MGLStyle streetsStyleURLWithVersion:MGLStyleDefaultVersion]; +} + +- (void)setStyleURL:(nullable NSURL *)styleURL { + if (_isTargetingInterfaceBuilder) { + return; + } + + // Default to Streets. + if (!styleURL) { + // An access token is required to load any default style, including + // Streets. + if (![MGLAccountManager accessToken]) { + return; + } + styleURL = [MGLStyle streetsStyleURLWithVersion:MGLStyleDefaultVersion]; + } + + if (![styleURL scheme]) { + // Assume a relative path into the application’s resource folder. + styleURL = [NSURL URLWithString:[@"asset://" stringByAppendingString:styleURL.absoluteString]]; + } + + _mbglMap->setStyleURL(styleURL.absoluteString.UTF8String); +} + +- (IBAction)reloadStyle:(__unused id)sender { + NSURL *styleURL = self.styleURL; + _mbglMap->setStyleURL(""); + self.styleURL = styleURL; +} + +#pragma mark View hierarchy and drawing + +- (void)viewWillMoveToWindow:(NSWindow *)newWindow { + [self deselectAnnotation:self.selectedAnnotation]; + if (!self.dormant && !newWindow) { + self.dormant = YES; + } + + [self.window removeObserver:self forKeyPath:@"contentLayoutRect"]; + [self.window removeObserver:self forKeyPath:@"titlebarAppearsTransparent"]; +} + +- (void)viewDidMoveToWindow { + NSWindow *window = self.window; + if (self.dormant && window) { + self.dormant = NO; + } + + if (window && _mbglMap->getConstrainMode() == mbgl::ConstrainMode::None) { + _mbglMap->setConstrainMode(mbgl::ConstrainMode::HeightOnly); + } + + [window addObserver:self + forKeyPath:@"contentLayoutRect" + options:NSKeyValueObservingOptionInitial + context:NULL]; + [window addObserver:self + forKeyPath:@"titlebarAppearsTransparent" + options:NSKeyValueObservingOptionInitial + context:NULL]; +} + +- (BOOL)wantsLayer { + return YES; +} + +- (BOOL)wantsBestResolutionOpenGLSurface { + // Use an OpenGL layer, except when drawing the designable, which is just + // ordinary Cocoa. + return !_isTargetingInterfaceBuilder; +} + +- (void)setFrame:(NSRect)frame { + super.frame = frame; + if (!NSEqualRects(frame, self.frame)) { + [self validateTileCacheSize]; + } + if (!_isTargetingInterfaceBuilder) { + _mbglMap->update(mbgl::Update::Dimensions); + } +} + +- (void)updateConstraints { + // Place the zoom controls at the lower-right corner of the view. + [self addConstraint: + [NSLayoutConstraint constraintWithItem:self + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:_zoomControls + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:MGLOrnamentPadding]]; + [self addConstraint: + [NSLayoutConstraint constraintWithItem:self + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:_zoomControls + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:MGLOrnamentPadding]]; + + // Center the compass above the zoom controls, assuming that the compass is + // narrower than the zoom controls. + [self addConstraint: + [NSLayoutConstraint constraintWithItem:_compass + attribute:NSLayoutAttributeCenterX + relatedBy:NSLayoutRelationEqual + toItem:_zoomControls + attribute:NSLayoutAttributeCenterX + multiplier:1 + constant:0]]; + [self addConstraint: + [NSLayoutConstraint constraintWithItem:_zoomControls + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:_compass + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:8]]; + + // Place the logo view in the lower-left corner of the view, accounting for + // the logo’s alignment rect. + [self addConstraint: + [NSLayoutConstraint constraintWithItem:self + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:_logoView + attribute:NSLayoutAttributeBottom + multiplier:1 + constant:MGLOrnamentPadding - _logoView.image.alignmentRect.origin.y]]; + [self addConstraint: + [NSLayoutConstraint constraintWithItem:_logoView + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:self + attribute:NSLayoutAttributeLeading + multiplier:1 + constant:MGLOrnamentPadding - _logoView.image.alignmentRect.origin.x]]; + + // Place the attribution view to the right of the logo view and size it to + // fit the buttons inside. + [self addConstraint:[NSLayoutConstraint constraintWithItem:_logoView + attribute:NSLayoutAttributeBaseline + relatedBy:NSLayoutRelationEqual + toItem:_attributionView + attribute:NSLayoutAttributeBaseline + multiplier:1 + constant:_logoView.image.alignmentRect.origin.y]]; + [self addConstraint:[NSLayoutConstraint constraintWithItem:_attributionView + attribute:NSLayoutAttributeLeading + relatedBy:NSLayoutRelationEqual + toItem:_logoView + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:8]]; + [self addConstraint:[NSLayoutConstraint constraintWithItem:_attributionView.subviews.firstObject + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:_attributionView + attribute:NSLayoutAttributeTop + multiplier:1 + constant:0]]; + [self addConstraint:[NSLayoutConstraint constraintWithItem:_attributionView + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:_attributionView.subviews.lastObject + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:8]]; + + [super updateConstraints]; +} + +- (void)renderSync { + if (!self.dormant) { + // Enable vertex buffer objects. + mbgl::gl::InitializeExtensions([](const char *name) { + static CFBundleRef framework = CFBundleGetBundleWithIdentifier(CFSTR("com.apple.opengl")); + if (!framework) { + throw std::runtime_error("Failed to load OpenGL framework."); + } + + CFStringRef str = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingASCII); + void *symbol = CFBundleGetFunctionPointerForName(framework, str); + CFRelease(str); + + return reinterpret_cast<mbgl::gl::glProc>(symbol); + }); + + _mbglMap->render(); + + if (_isPrinting) { + _isPrinting = NO; + std::string png = encodePNG(_mbglView->readStillImage()); + NSData *data = [[NSData alloc] initWithBytes:png.data() length:png.size()]; + NSImage *image = [[NSImage alloc] initWithData:data]; + [self performSelector:@selector(printWithImage:) withObject:image afterDelay:0]; + } + +// [self updateUserLocationAnnotationView]; + } +} + +- (void)validateTileCacheSize { + if (!_mbglMap) { + return; + } + + CGFloat zoomFactor = self.maximumZoomLevel - self.minimumZoomLevel + 1; + CGFloat cpuFactor = [NSProcessInfo processInfo].processorCount; + CGFloat memoryFactor = (CGFloat)[NSProcessInfo processInfo].physicalMemory / 1000 / 1000 / 1000; + CGFloat sizeFactor = (NSWidth(self.bounds) / mbgl::util::tileSize) * (NSHeight(self.bounds) / mbgl::util::tileSize); + + NSUInteger cacheSize = zoomFactor * cpuFactor * memoryFactor * sizeFactor * 0.5; + + _mbglMap->setSourceTileCacheSize(cacheSize); +} + +- (void)invalidate { + MGLAssertIsMainThread(); + + [self.layer setNeedsDisplay]; +} + +- (void)notifyMapChange:(mbgl::MapChange)change { + // Ignore map updates when the Map object isn't set. + if (!_mbglMap) { + return; + } + + switch (change) { + case mbgl::MapChangeRegionWillChange: + case mbgl::MapChangeRegionWillChangeAnimated: + { + if ([self.delegate respondsToSelector:@selector(mapView:cameraWillChangeAnimated:)]) { + BOOL animated = change == mbgl::MapChangeRegionWillChangeAnimated; + [self.delegate mapView:self cameraWillChangeAnimated:animated]; + } + break; + } + case mbgl::MapChangeRegionIsChanging: + { + // Update a minimum of UI that needs to stay attached to the map + // while animating. + [self updateCompass]; + [self updateAnnotationCallouts]; + + if ([self.delegate respondsToSelector:@selector(mapViewCameraIsChanging:)]) { + [self.delegate mapViewCameraIsChanging:self]; + } + break; + } + case mbgl::MapChangeRegionDidChange: + case mbgl::MapChangeRegionDidChangeAnimated: + { + // Update all UI at the end of an animation or atomic change to the + // viewport. More expensive updates can happen here, but care should + // still be taken to minimize the work done here because scroll + // gesture recognition and momentum scrolling is performed as a + // series of atomic changes, not an animation. + [self updateZoomControls]; + [self updateCompass]; + [self updateAnnotationCallouts]; + [self updateAnnotationTrackingAreas]; + + if ([self.delegate respondsToSelector:@selector(mapView:cameraDidChangeAnimated:)]) { + BOOL animated = change == mbgl::MapChangeRegionDidChangeAnimated; + [self.delegate mapView:self cameraDidChangeAnimated:animated]; + } + break; + } + case mbgl::MapChangeWillStartLoadingMap: + { + if ([self.delegate respondsToSelector:@selector(mapViewWillStartLoadingMap:)]) { + [self.delegate mapViewWillStartLoadingMap:self]; + } + break; + } + case mbgl::MapChangeDidFinishLoadingMap: + { + if ([self.delegate respondsToSelector:@selector(mapViewDidFinishLoadingMap:)]) { + [self.delegate mapViewDidFinishLoadingMap:self]; + } + break; + } + case mbgl::MapChangeDidFailLoadingMap: + { + // Not yet implemented. + break; + } + case mbgl::MapChangeWillStartRenderingMap: + { + if ([self.delegate respondsToSelector:@selector(mapViewWillStartRenderingMap:)]) { + [self.delegate mapViewWillStartRenderingMap:self]; + } + break; + } + case mbgl::MapChangeDidFinishRenderingMap: + case mbgl::MapChangeDidFinishRenderingMapFullyRendered: + { + if ([self.delegate respondsToSelector:@selector(mapViewDidFinishRenderingMap:fullyRendered:)]) { + BOOL fullyRendered = change == mbgl::MapChangeDidFinishRenderingMapFullyRendered; + [self.delegate mapViewDidFinishRenderingMap:self fullyRendered:fullyRendered]; + } + break; + } + case mbgl::MapChangeWillStartRenderingFrame: + { + if ([self.delegate respondsToSelector:@selector(mapViewWillStartRenderingFrame:)]) { + [self.delegate mapViewWillStartRenderingFrame:self]; + } + break; + } + case mbgl::MapChangeDidFinishRenderingFrame: + case mbgl::MapChangeDidFinishRenderingFrameFullyRendered: + { + if ([self.delegate respondsToSelector:@selector(mapViewDidFinishRenderingFrame:fullyRendered:)]) { + BOOL fullyRendered = change == mbgl::MapChangeDidFinishRenderingFrameFullyRendered; + [self.delegate mapViewDidFinishRenderingFrame:self fullyRendered:fullyRendered]; + } + break; + } + } +} + +#pragma mark Printing + +- (void)print:(__unused id)sender { + _isPrinting = YES; + [self invalidate]; +} + +- (void)printWithImage:(NSImage *)image { + NSImageView *imageView = [[NSImageView alloc] initWithFrame:self.bounds]; + imageView.image = image; + + NSPrintOperation *op = [NSPrintOperation printOperationWithView:imageView]; + [op runOperation]; +} + +#pragma mark Viewport + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingCenterCoordinate { + return [NSSet setWithObjects:@"latitude", @"longitude", @"camera", nil]; +} + +- (CLLocationCoordinate2D)centerCoordinate { + mbgl::EdgeInsets padding = MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets); + return MGLLocationCoordinate2DFromLatLng(_mbglMap->getLatLng(padding)); +} + +- (void)setCenterCoordinate:(CLLocationCoordinate2D)centerCoordinate { + [self setCenterCoordinate:centerCoordinate animated:NO]; +} + +- (void)setCenterCoordinate:(CLLocationCoordinate2D)centerCoordinate animated:(BOOL)animated { + [self willChangeValueForKey:@"centerCoordinate"]; + _mbglMap->setLatLng(MGLLatLngFromLocationCoordinate2D(centerCoordinate), + MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets), + MGLDurationInSeconds(animated ? MGLAnimationDuration : 0)); + [self didChangeValueForKey:@"centerCoordinate"]; +} + +- (void)offsetCenterCoordinateBy:(NSPoint)delta animated:(BOOL)animated { + [self willChangeValueForKey:@"centerCoordinate"]; + _mbglMap->cancelTransitions(); + _mbglMap->moveBy({ delta.x, delta.y }, + MGLDurationInSeconds(animated ? MGLAnimationDuration : 0)); + [self didChangeValueForKey:@"centerCoordinate"]; +} + +- (CLLocationDegrees)pendingLatitude { + return _pendingLatitude; +} + +- (void)setPendingLatitude:(CLLocationDegrees)pendingLatitude { + _pendingLatitude = pendingLatitude; +} + +- (CLLocationDegrees)pendingLongitude { + return _pendingLongitude; +} + +- (void)setPendingLongitude:(CLLocationDegrees)pendingLongitude { + _pendingLongitude = pendingLongitude; +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingZoomLevel { + return [NSSet setWithObject:@"camera"]; +} + +- (double)zoomLevel { + return _mbglMap->getZoom(); +} + +- (void)setZoomLevel:(double)zoomLevel { + [self setZoomLevel:zoomLevel animated:NO]; +} + +- (void)setZoomLevel:(double)zoomLevel animated:(BOOL)animated { + [self willChangeValueForKey:@"zoomLevel"]; + _mbglMap->setZoom(zoomLevel, + MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets), + MGLDurationInSeconds(animated ? MGLAnimationDuration : 0)); + [self didChangeValueForKey:@"zoomLevel"]; +} + +- (void)zoomBy:(double)zoomDelta animated:(BOOL)animated { + [self setZoomLevel:self.zoomLevel + zoomDelta animated:animated]; +} + +- (void)scaleBy:(double)scaleFactor atPoint:(NSPoint)point animated:(BOOL)animated { + [self willChangeValueForKey:@"centerCoordinate"]; + [self willChangeValueForKey:@"zoomLevel"]; + mbgl::ScreenCoordinate center(point.x, self.bounds.size.height - point.y); + _mbglMap->scaleBy(scaleFactor, center, MGLDurationInSeconds(animated ? MGLAnimationDuration : 0)); + [self didChangeValueForKey:@"zoomLevel"]; + [self didChangeValueForKey:@"centerCoordinate"]; +} + +- (void)setMinimumZoomLevel:(double)minimumZoomLevel +{ + _mbglMap->setMinZoom(minimumZoomLevel); + [self validateTileCacheSize]; +} + +- (void)setMaximumZoomLevel:(double)maximumZoomLevel +{ + _mbglMap->setMaxZoom(maximumZoomLevel); + [self validateTileCacheSize]; +} + +- (double)maximumZoomLevel { + return _mbglMap->getMaxZoom(); +} + +- (double)minimumZoomLevel { + return _mbglMap->getMinZoom(); +} + +/// Respond to a click on the zoom control. +- (IBAction)zoomInOrOut:(NSSegmentedControl *)sender { + switch (sender.selectedSegment) { + case 0: + // Zoom out. + [self moveToEndOfParagraph:sender]; + break; + case 1: + // Zoom in. + [self moveToBeginningOfParagraph:sender]; + break; + default: + break; + } +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingDirection { + return [NSSet setWithObject:@"camera"]; +} + +- (CLLocationDirection)direction { + return mbgl::util::wrap(_mbglMap->getBearing(), 0., 360.); +} + +- (void)setDirection:(CLLocationDirection)direction { + [self setDirection:direction animated:NO]; +} + +- (void)setDirection:(CLLocationDirection)direction animated:(BOOL)animated { + [self willChangeValueForKey:@"direction"]; + _mbglMap->setBearing(direction, + MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets), + MGLDurationInSeconds(animated ? MGLAnimationDuration : 0)); + [self didChangeValueForKey:@"direction"]; +} + +- (void)offsetDirectionBy:(CLLocationDegrees)delta animated:(BOOL)animated { + [self setDirection:_mbglMap->getBearing() + delta animated:animated]; +} + ++ (NS_SET_OF(NSString *) *)keyPathsForValuesAffectingCamera { + return [NSSet setWithObjects:@"latitude", @"longitude", @"centerCoordinate", @"zoomLevel", @"direction", nil]; +} + +- (MGLMapCamera *)camera { + mbgl::EdgeInsets padding = MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets); + return [self cameraForCameraOptions:_mbglMap->getCameraOptions(padding)]; +} + +- (void)setCamera:(MGLMapCamera *)camera { + [self setCamera:camera animated:NO]; +} + +- (void)setCamera:(MGLMapCamera *)camera animated:(BOOL)animated { + [self setCamera:camera withDuration:animated ? MGLAnimationDuration : 0 animationTimingFunction:nil completionHandler:NULL]; +} + +- (void)setCamera:(MGLMapCamera *)camera withDuration:(NSTimeInterval)duration animationTimingFunction:(nullable CAMediaTimingFunction *)function completionHandler:(nullable void (^)(void))completion { + _mbglMap->cancelTransitions(); + if ([self.camera isEqual:camera]) { + return; + } + + mbgl::CameraOptions cameraOptions = [self cameraOptionsObjectForAnimatingToCamera:camera]; + mbgl::AnimationOptions animationOptions; + if (duration > 0) { + animationOptions.duration = MGLDurationInSeconds(duration); + animationOptions.easing = MGLUnitBezierForMediaTimingFunction(function); + } + if (completion) { + animationOptions.transitionFinishFn = [completion]() { + // Must run asynchronously after the transition is completely over. + // Otherwise, a call to -setCamera: within the completion handler + // would reenter the completion handler’s caller. + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + }; + } + + [self willChangeValueForKey:@"camera"]; + _mbglMap->easeTo(cameraOptions, animationOptions); + [self didChangeValueForKey:@"camera"]; +} + +- (void)flyToCamera:(MGLMapCamera *)camera completionHandler:(nullable void (^)(void))completion { + [self flyToCamera:camera withDuration:-1 completionHandler:completion]; +} + +- (void)flyToCamera:(MGLMapCamera *)camera withDuration:(NSTimeInterval)duration completionHandler:(nullable void (^)(void))completion { + [self flyToCamera:camera withDuration:duration peakAltitude:-1 completionHandler:completion]; +} + +- (void)flyToCamera:(MGLMapCamera *)camera withDuration:(NSTimeInterval)duration peakAltitude:(CLLocationDistance)peakAltitude completionHandler:(nullable void (^)(void))completion { + _mbglMap->cancelTransitions(); + if ([self.camera isEqual:camera]) { + return; + } + + mbgl::CameraOptions cameraOptions = [self cameraOptionsObjectForAnimatingToCamera:camera]; + mbgl::AnimationOptions animationOptions; + if (duration >= 0) { + animationOptions.duration = MGLDurationInSeconds(duration); + } + if (peakAltitude >= 0) { + CLLocationDegrees peakLatitude = (self.centerCoordinate.latitude + camera.centerCoordinate.latitude) / 2; + CLLocationDegrees peakPitch = (self.camera.pitch + camera.pitch) / 2; + animationOptions.minZoom = MGLZoomLevelForAltitude(peakAltitude, peakPitch, + peakLatitude, self.frame.size); + } + if (completion) { + animationOptions.transitionFinishFn = [completion]() { + // Must run asynchronously after the transition is completely over. + // Otherwise, a call to -setCamera: within the completion handler + // would reenter the completion handler’s caller. + dispatch_async(dispatch_get_main_queue(), ^{ + completion(); + }); + }; + } + + [self willChangeValueForKey:@"camera"]; + _mbglMap->flyTo(cameraOptions, animationOptions); + [self didChangeValueForKey:@"camera"]; +} + +/// Returns a CameraOptions object that specifies parameters for animating to +/// the given camera. +- (mbgl::CameraOptions)cameraOptionsObjectForAnimatingToCamera:(MGLMapCamera *)camera { + mbgl::CameraOptions options; + options.center = MGLLatLngFromLocationCoordinate2D(camera.centerCoordinate); + options.padding = MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets); + options.zoom = MGLZoomLevelForAltitude(camera.altitude, camera.pitch, + camera.centerCoordinate.latitude, + self.frame.size); + if (camera.heading >= 0) { + options.angle = MGLRadiansFromDegrees(-camera.heading); + } + if (camera.pitch >= 0) { + options.pitch = MGLRadiansFromDegrees(camera.pitch); + } + return options; +} + ++ (NSSet *)keyPathsForValuesAffectingVisibleCoordinateBounds { + return [NSSet setWithObjects:@"centerCoordinate", @"zoomLevel", @"direction", @"bounds", nil]; +} + +- (MGLCoordinateBounds)visibleCoordinateBounds { + return [self convertRect:self.bounds toCoordinateBoundsFromView:self]; +} + +- (void)setVisibleCoordinateBounds:(MGLCoordinateBounds)bounds { + [self setVisibleCoordinateBounds:bounds animated:NO]; +} + +- (void)setVisibleCoordinateBounds:(MGLCoordinateBounds)bounds animated:(BOOL)animated { + [self setVisibleCoordinateBounds:bounds edgePadding:NSEdgeInsetsZero animated:animated]; +} + +- (void)setVisibleCoordinateBounds:(MGLCoordinateBounds)bounds edgePadding:(NSEdgeInsets)insets animated:(BOOL)animated { + _mbglMap->cancelTransitions(); + + mbgl::EdgeInsets padding = MGLEdgeInsetsFromNSEdgeInsets(insets); + padding += MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets); + mbgl::CameraOptions cameraOptions = _mbglMap->cameraForLatLngBounds(MGLLatLngBoundsFromCoordinateBounds(bounds), padding); + mbgl::AnimationOptions animationOptions; + if (animated) { + animationOptions.duration = MGLDurationInSeconds(MGLAnimationDuration); + } + + [self willChangeValueForKey:@"visibleCoordinateBounds"]; + animationOptions.transitionFinishFn = ^() { + [self didChangeValueForKey:@"visibleCoordinateBounds"]; + }; + _mbglMap->easeTo(cameraOptions, animationOptions); +} + +- (MGLMapCamera *)cameraThatFitsCoordinateBounds:(MGLCoordinateBounds)bounds { + return [self cameraThatFitsCoordinateBounds:bounds edgePadding:NSEdgeInsetsZero]; +} + +- (MGLMapCamera *)cameraThatFitsCoordinateBounds:(MGLCoordinateBounds)bounds edgePadding:(NSEdgeInsets)insets { + mbgl::EdgeInsets padding = MGLEdgeInsetsFromNSEdgeInsets(insets); + padding += MGLEdgeInsetsFromNSEdgeInsets(self.contentInsets); + mbgl::CameraOptions cameraOptions = _mbglMap->cameraForLatLngBounds(MGLLatLngBoundsFromCoordinateBounds(bounds), padding); + return [self cameraForCameraOptions:cameraOptions]; +} + +- (MGLMapCamera *)cameraForCameraOptions:(const mbgl::CameraOptions &)cameraOptions { + CLLocationCoordinate2D centerCoordinate = MGLLocationCoordinate2DFromLatLng(cameraOptions.center ? *cameraOptions.center : _mbglMap->getLatLng()); + double zoomLevel = cameraOptions.zoom ? *cameraOptions.zoom : self.zoomLevel; + CLLocationDirection direction = cameraOptions.angle ? -MGLDegreesFromRadians(*cameraOptions.angle) : self.direction; + CGFloat pitch = cameraOptions.pitch ? MGLDegreesFromRadians(*cameraOptions.pitch) : _mbglMap->getPitch(); + CLLocationDistance altitude = MGLAltitudeForZoomLevel(zoomLevel, pitch, + centerCoordinate.latitude, + self.frame.size); + return [MGLMapCamera cameraLookingAtCenterCoordinate:centerCoordinate + fromDistance:altitude + pitch:pitch + heading:direction]; +} + +- (void)setAutomaticallyAdjustsContentInsets:(BOOL)automaticallyAdjustsContentInsets { + _automaticallyAdjustsContentInsets = automaticallyAdjustsContentInsets; + [self adjustContentInsets]; +} + +/// Updates `contentInsets` to reflect the current window geometry. +- (void)adjustContentInsets { + if (!_automaticallyAdjustsContentInsets) { + return; + } + + NSEdgeInsets contentInsets = self.contentInsets; + if ((self.window.styleMask & NSFullSizeContentViewWindowMask) + && !self.window.titlebarAppearsTransparent) { + NSRect contentLayoutRect = [self convertRect:self.window.contentLayoutRect fromView:nil]; + if (NSMaxX(contentLayoutRect) > 0 && NSMaxY(contentLayoutRect) > 0) { + contentInsets = NSEdgeInsetsMake(NSHeight(self.bounds) - NSMaxY(contentLayoutRect), + NSMinX(contentLayoutRect), + NSMinY(contentLayoutRect), + NSWidth(self.bounds) - NSMaxX(contentLayoutRect)); + } + } else { + contentInsets = NSEdgeInsetsZero; + } + + self.contentInsets = contentInsets; +} + +- (void)setContentInsets:(NSEdgeInsets)contentInsets { + [self setContentInsets:contentInsets animated:NO]; +} + +- (void)setContentInsets:(NSEdgeInsets)contentInsets animated:(BOOL)animated { + if (NSEdgeInsetsEqual(contentInsets, self.contentInsets)) { + return; + } + + // After adjusting the content insets, move the center coordinate from the + // old frame of reference to the new one represented by the newly set + // content insets. + CLLocationCoordinate2D oldCenter = self.centerCoordinate; + _contentInsets = contentInsets; + [self setCenterCoordinate:oldCenter animated:animated]; +} + +#pragma mark Mouse events and gestures + +- (BOOL)acceptsFirstResponder { + return YES; +} + +/// Drag to pan, plus drag to zoom, rotate, and tilt when a modifier key is held +/// down. +- (void)handlePanGesture:(NSPanGestureRecognizer *)gestureRecognizer { + NSPoint delta = [gestureRecognizer translationInView:self]; + NSPoint endPoint = [gestureRecognizer locationInView:self]; + NSPoint startPoint = NSMakePoint(endPoint.x - delta.x, endPoint.y - delta.y); + + NSEventModifierFlags flags = [NSApp currentEvent].modifierFlags; + if (gestureRecognizer.state == NSGestureRecognizerStateBegan) { + [self.window invalidateCursorRectsForView:self]; + _mbglMap->setGestureInProgress(true); + + if (![self isPanningWithGesture]) { + // Hide the cursor except when panning. + CGDisplayHideCursor(kCGDirectMainDisplay); + _didHideCursorDuringGesture = YES; + } + } else if (gestureRecognizer.state == NSGestureRecognizerStateEnded + || gestureRecognizer.state == NSGestureRecognizerStateCancelled) { + _mbglMap->setGestureInProgress(false); + [self.window invalidateCursorRectsForView:self]; + + if (_didHideCursorDuringGesture) { + _didHideCursorDuringGesture = NO; + // Move the cursor back to the start point and show it again, creating + // the illusion that it has stayed in place during the entire gesture. + CGPoint cursorPoint = [self convertPoint:startPoint toView:nil]; + cursorPoint = [self.window convertRectToScreen:{ startPoint, NSZeroSize }].origin; + cursorPoint.y = [NSScreen mainScreen].frame.size.height - cursorPoint.y; + CGDisplayMoveCursorToPoint(kCGDirectMainDisplay, cursorPoint); + CGDisplayShowCursor(kCGDirectMainDisplay); + } + } + + if (flags & NSShiftKeyMask) { + // Shift-drag to zoom. + if (!self.zoomEnabled) { + return; + } + + _mbglMap->cancelTransitions(); + + if (gestureRecognizer.state == NSGestureRecognizerStateBegan) { + _scaleAtBeginningOfGesture = _mbglMap->getScale(); + } else if (gestureRecognizer.state == NSGestureRecognizerStateChanged) { + CGFloat newZoomLevel = log2f(_scaleAtBeginningOfGesture) - delta.y / 75; + [self scaleBy:powf(2, newZoomLevel) / _mbglMap->getScale() atPoint:startPoint animated:NO]; + } + } else if (flags & NSAlternateKeyMask) { + // Option-drag to rotate and/or tilt. + _mbglMap->cancelTransitions(); + + if (gestureRecognizer.state == NSGestureRecognizerStateBegan) { + _directionAtBeginningOfGesture = self.direction; + _pitchAtBeginningOfGesture = _mbglMap->getPitch(); + } else if (gestureRecognizer.state == NSGestureRecognizerStateChanged) { + mbgl::ScreenCoordinate center(startPoint.x, self.bounds.size.height - startPoint.y); + if (self.rotateEnabled) { + CLLocationDirection newDirection = _directionAtBeginningOfGesture - delta.x / 10; + [self willChangeValueForKey:@"direction"]; + _mbglMap->setBearing(newDirection, center); + [self didChangeValueForKey:@"direction"]; + } + if (self.pitchEnabled) { + _mbglMap->setPitch(_pitchAtBeginningOfGesture + delta.y / 5, center); + } + } + } else if (self.scrollEnabled) { + // Otherwise, drag to pan. + _mbglMap->cancelTransitions(); + + if (gestureRecognizer.state == NSGestureRecognizerStateChanged) { + delta.y *= -1; + [self offsetCenterCoordinateBy:delta animated:NO]; + [gestureRecognizer setTranslation:NSZeroPoint inView:nil]; + } + } +} + +/// Returns whether the user is panning using a gesture. +- (BOOL)isPanningWithGesture { + NSGestureRecognizerState state = _panGestureRecognizer.state; + NSEventModifierFlags flags = [NSApp currentEvent].modifierFlags; + return ((state == NSGestureRecognizerStateBegan || state == NSGestureRecognizerStateChanged) + && !(flags & NSShiftKeyMask || flags & NSAlternateKeyMask)); +} + +/// Pinch to zoom. +- (void)handleMagnificationGesture:(NSMagnificationGestureRecognizer *)gestureRecognizer { + if (!self.zoomEnabled) { + return; + } + + _mbglMap->cancelTransitions(); + + if (gestureRecognizer.state == NSGestureRecognizerStateBegan) { + _mbglMap->setGestureInProgress(true); + _scaleAtBeginningOfGesture = _mbglMap->getScale(); + } else if (gestureRecognizer.state == NSGestureRecognizerStateChanged) { + NSPoint zoomInPoint = [gestureRecognizer locationInView:self]; + mbgl::ScreenCoordinate center(zoomInPoint.x, self.bounds.size.height - zoomInPoint.y); + if (gestureRecognizer.magnification > -1) { + [self willChangeValueForKey:@"zoomLevel"]; + [self willChangeValueForKey:@"centerCoordinate"]; + _mbglMap->setScale(_scaleAtBeginningOfGesture * (1 + gestureRecognizer.magnification), center); + [self didChangeValueForKey:@"centerCoordinate"]; + [self didChangeValueForKey:@"zoomLevel"]; + } + } else if (gestureRecognizer.state == NSGestureRecognizerStateEnded + || gestureRecognizer.state == NSGestureRecognizerStateCancelled) { + _mbglMap->setGestureInProgress(false); + } +} + +/// Click or tap to select an annotation. +- (void)handleClickGesture:(NSClickGestureRecognizer *)gestureRecognizer { + if (gestureRecognizer.state != NSGestureRecognizerStateEnded + || [self subviewContainingGesture:gestureRecognizer]) { + return; + } + + NSPoint gesturePoint = [gestureRecognizer locationInView:self]; + MGLAnnotationTag hitAnnotationTag = [self annotationTagAtPoint:gesturePoint persistingResults:YES]; + if (hitAnnotationTag != MGLAnnotationTagNotFound) { + if (hitAnnotationTag != _selectedAnnotationTag) { + id <MGLAnnotation> annotation = [self annotationWithTag:hitAnnotationTag]; + NSAssert(annotation, @"Cannot select nonexistent annotation with tag %u", hitAnnotationTag); + [self selectAnnotation:annotation]; + } + } else { + [self deselectAnnotation:self.selectedAnnotation]; + } +} + +/// Double-click or double-tap to zoom in. +- (void)handleDoubleClickGesture:(NSClickGestureRecognizer *)gestureRecognizer { + if (!self.zoomEnabled || gestureRecognizer.state != NSGestureRecognizerStateEnded + || [self subviewContainingGesture:gestureRecognizer]) { + return; + } + + _mbglMap->cancelTransitions(); + + NSPoint gesturePoint = [gestureRecognizer locationInView:self]; + [self scaleBy:2 atPoint:gesturePoint animated:YES]; +} + +- (void)smartMagnifyWithEvent:(NSEvent *)event { + if (!self.zoomEnabled) { + return; + } + + _mbglMap->cancelTransitions(); + + // Tap with two fingers (“right-click”) to zoom out on mice but not trackpads. + NSPoint gesturePoint = [self convertPoint:event.locationInWindow fromView:nil]; + [self scaleBy:0.5 atPoint:gesturePoint animated:YES]; +} + +/// Rotate fingers to rotate. +- (void)handleRotationGesture:(NSRotationGestureRecognizer *)gestureRecognizer { + if (!self.rotateEnabled) { + return; + } + + _mbglMap->cancelTransitions(); + + if (gestureRecognizer.state == NSGestureRecognizerStateBegan) { + _mbglMap->setGestureInProgress(true); + _directionAtBeginningOfGesture = self.direction; + } else if (gestureRecognizer.state == NSGestureRecognizerStateChanged) { + NSPoint rotationPoint = [gestureRecognizer locationInView:self]; + mbgl::ScreenCoordinate center(rotationPoint.x, self.bounds.size.height - rotationPoint.y); + _mbglMap->setBearing(_directionAtBeginningOfGesture + gestureRecognizer.rotationInDegrees, center); + } else if (gestureRecognizer.state == NSGestureRecognizerStateEnded + || gestureRecognizer.state == NSGestureRecognizerStateCancelled) { + _mbglMap->setGestureInProgress(false); + } +} + +- (BOOL)wantsScrollEventsForSwipeTrackingOnAxis:(__unused NSEventGestureAxis)axis { + // Track both horizontal and vertical swipes in -scrollWheel:. + return YES; +} + +- (void)scrollWheel:(NSEvent *)event { + // https://developer.apple.com/library/mac/releasenotes/AppKit/RN-AppKitOlderNotes/#10_7Dragging + BOOL isScrollWheel = event.phase == NSEventPhaseNone && event.momentumPhase == NSEventPhaseNone && !event.hasPreciseScrollingDeltas; + if (isScrollWheel || [[NSUserDefaults standardUserDefaults] boolForKey:MGLScrollWheelZoomsMapViewDefaultKey]) { + // A traditional, vertical scroll wheel zooms instead of panning. + if (self.zoomEnabled) { + const double delta = + event.scrollingDeltaY / ([event hasPreciseScrollingDeltas] ? 100 : 10); + if (delta != 0) { + double scale = 2.0 / (1.0 + std::exp(-std::abs(delta))); + + // Zooming out. + if (delta < 0) { + scale = 1.0 / scale; + } + + NSPoint gesturePoint = [self convertPoint:event.locationInWindow fromView:nil]; + [self scaleBy:scale atPoint:gesturePoint animated:NO]; + } + } + } else if (self.scrollEnabled + && _magnificationGestureRecognizer.state == NSGestureRecognizerStatePossible + && _rotationGestureRecognizer.state == NSGestureRecognizerStatePossible) { + // Scroll to pan. + _mbglMap->cancelTransitions(); + + CGFloat x = event.scrollingDeltaX; + CGFloat y = event.scrollingDeltaY; + if (x || y) { + [self offsetCenterCoordinateBy:NSMakePoint(x, y) animated:NO]; + } + + // Drift pan. + if (event.momentumPhase != NSEventPhaseNone) { + [self offsetCenterCoordinateBy:NSMakePoint(x, y) animated:NO]; + } + } +} + +/// Returns the subview that the gesture is located in. +- (NSView *)subviewContainingGesture:(NSGestureRecognizer *)gestureRecognizer { + if (NSPointInRect([gestureRecognizer locationInView:self.compass], self.compass.bounds)) { + return self.compass; + } + if (NSPointInRect([gestureRecognizer locationInView:self.zoomControls], self.zoomControls.bounds)) { + return self.zoomControls; + } + if (NSPointInRect([gestureRecognizer locationInView:self.attributionView], self.attributionView.bounds)) { + return self.attributionView; + } + return nil; +} + +#pragma mark Keyboard events + +- (void)keyDown:(NSEvent *)event { + if (event.modifierFlags & NSNumericPadKeyMask) { + // This is the recommended way to handle arrow key presses, causing + // methods like -moveUp: and -moveToBeginningOfParagraph: to be called + // for various standard keybindings. + [self interpretKeyEvents:@[event]]; + } else { + [super keyDown:event]; + } +} + +- (IBAction)moveUp:(__unused id)sender { + [self offsetCenterCoordinateBy:NSMakePoint(0, MGLKeyPanningIncrement) animated:YES]; +} + +- (IBAction)moveDown:(__unused id)sender { + [self offsetCenterCoordinateBy:NSMakePoint(0, -MGLKeyPanningIncrement) animated:YES]; +} + +- (IBAction)moveLeft:(__unused id)sender { + [self offsetCenterCoordinateBy:NSMakePoint(MGLKeyPanningIncrement, 0) animated:YES]; +} + +- (IBAction)moveRight:(__unused id)sender { + [self offsetCenterCoordinateBy:NSMakePoint(-MGLKeyPanningIncrement, 0) animated:YES]; +} + +- (IBAction)moveToBeginningOfParagraph:(__unused id)sender { + if (self.zoomEnabled) { + [self zoomBy:1 animated:YES]; + } +} + +- (IBAction)moveToEndOfParagraph:(__unused id)sender { + if (self.zoomEnabled) { + [self zoomBy:-1 animated:YES]; + } +} + +- (IBAction)moveWordLeft:(__unused id)sender { + if (self.rotateEnabled) { + [self offsetDirectionBy:MGLKeyRotationIncrement animated:YES]; + } +} + +- (IBAction)moveWordRight:(__unused id)sender { + if (self.rotateEnabled) { + [self offsetDirectionBy:-MGLKeyRotationIncrement animated:YES]; + } +} + +- (void)setZoomEnabled:(BOOL)zoomEnabled { + _zoomEnabled = zoomEnabled; + _zoomControls.enabled = zoomEnabled; + _zoomControls.hidden = !zoomEnabled; +} + +- (void)setRotateEnabled:(BOOL)rotateEnabled { + _rotateEnabled = rotateEnabled; + _compass.enabled = rotateEnabled; + _compass.hidden = !rotateEnabled; +} + +#pragma mark Ornaments + +/// Updates the zoom controls’ enabled state based on the current zoom level. +- (void)updateZoomControls { + [_zoomControls setEnabled:self.zoomLevel > self.minimumZoomLevel forSegment:0]; + [_zoomControls setEnabled:self.zoomLevel < self.maximumZoomLevel forSegment:1]; +} + +/// Updates the compass to point in the same direction as the map. +- (void)updateCompass { + // The circular slider control goes counterclockwise, whereas our map + // measures its direction clockwise. + _compass.doubleValue = -self.direction; +} + +- (IBAction)rotate:(NSSlider *)sender { + [self setDirection:-sender.doubleValue animated:YES]; +} + +#pragma mark Annotations + +- (nullable NS_ARRAY_OF(id <MGLAnnotation>) *)annotations { + if (_annotationContextsByAnnotationTag.empty()) { + return nil; + } + + // Map all the annotation tags to the annotations themselves. + std::vector<id <MGLAnnotation>> annotations; + std::transform(_annotationContextsByAnnotationTag.begin(), + _annotationContextsByAnnotationTag.end(), + std::back_inserter(annotations), + ^ id <MGLAnnotation> (const std::pair<MGLAnnotationTag, MGLAnnotationContext> &pair) { + return pair.second.annotation; + }); + return [NSArray arrayWithObjects:&annotations[0] count:annotations.size()]; +} + +/// Returns the annotation assigned the given tag. Cheap. +- (id <MGLAnnotation>)annotationWithTag:(MGLAnnotationTag)tag { + if (!_annotationContextsByAnnotationTag.count(tag)) { + return nil; + } + + MGLAnnotationContext &annotationContext = _annotationContextsByAnnotationTag[tag]; + return annotationContext.annotation; +} + +/// Returns the annotation tag assigned to the given annotation. Relatively expensive. +- (MGLAnnotationTag)annotationTagForAnnotation:(id <MGLAnnotation>)annotation { + if (!annotation) { + return MGLAnnotationTagNotFound; + } + + for (auto &pair : _annotationContextsByAnnotationTag) { + if (pair.second.annotation == annotation) { + return pair.first; + } + } + return MGLAnnotationTagNotFound; +} + +- (void)addAnnotation:(id <MGLAnnotation>)annotation { + if (annotation) { + [self addAnnotations:@[annotation]]; + } +} + +- (void)addAnnotations:(NS_ARRAY_OF(id <MGLAnnotation>) *)annotations { + if (!annotations) { + return; + } + + [self willChangeValueForKey:@"annotations"]; + + BOOL delegateHasImagesForAnnotations = [self.delegate respondsToSelector:@selector(mapView:imageForAnnotation:)]; + + for (id <MGLAnnotation> annotation in annotations) { + NSAssert([annotation conformsToProtocol:@protocol(MGLAnnotation)], @"Annotation does not conform to MGLAnnotation"); + + if ([annotation isKindOfClass:[MGLMultiPoint class]]) { + // Actual multipoints aren’t supported as annotations. + if ([annotation isMemberOfClass:[MGLMultiPoint class]] + || [annotation isMemberOfClass:[MGLMultiPointFeature class]]) { + continue; + } + + // The multipoint knows how to style itself (with the map view’s help). + MGLMultiPoint *multiPoint = (MGLMultiPoint *)annotation; + if (!multiPoint.pointCount) { + continue; + } + + MGLAnnotationTag annotationTag = _mbglMap->addAnnotation([multiPoint annotationObjectWithDelegate:self]); + MGLAnnotationContext context; + context.annotation = annotation; + _annotationContextsByAnnotationTag[annotationTag] = context; + } else if (![annotation isKindOfClass:[MGLMultiPolyline class]] + || ![annotation isKindOfClass:[MGLMultiPolygon class]] + || ![annotation isKindOfClass:[MGLShapeCollection class]]) { + MGLAnnotationImage *annotationImage = nil; + if (delegateHasImagesForAnnotations) { + annotationImage = [self.delegate mapView:self imageForAnnotation:annotation]; + } + if (!annotationImage) { + annotationImage = [self dequeueReusableAnnotationImageWithIdentifier:MGLDefaultStyleMarkerSymbolName]; + } + if (!annotationImage) { + annotationImage = self.defaultAnnotationImage; + } + + NSString *symbolName = annotationImage.styleIconIdentifier; + if (!symbolName) { + symbolName = [MGLAnnotationSpritePrefix stringByAppendingString:annotationImage.reuseIdentifier]; + annotationImage.styleIconIdentifier = symbolName; + } + + if (!self.annotationImagesByIdentifier[annotationImage.reuseIdentifier]) { + self.annotationImagesByIdentifier[annotationImage.reuseIdentifier] = annotationImage; + [self installAnnotationImage:annotationImage]; + } + + MGLAnnotationTag annotationTag = _mbglMap->addAnnotation(mbgl::SymbolAnnotation { + MGLPointFromLocationCoordinate2D(annotation.coordinate), + symbolName.UTF8String ?: "" + }); + + MGLAnnotationContext context; + context.annotation = annotation; + context.imageReuseIdentifier = annotationImage.reuseIdentifier; + _annotationContextsByAnnotationTag[annotationTag] = context; + + if ([annotation isKindOfClass:[NSObject class]]) { + NSAssert(![annotation isKindOfClass:[MGLMultiPoint class]], @"Point annotation should not be MGLMultiPoint."); + [(NSObject *)annotation addObserver:self forKeyPath:@"coordinate" options:0 context:(void *)(NSUInteger)annotationTag]; + } + + // Opt into potentially expensive tooltip tracking areas. + if (annotation.toolTip.length) { + _wantsToolTipRects = YES; + } + } + } + + [self didChangeValueForKey:@"annotations"]; + + [self updateAnnotationTrackingAreas]; +} + +/// Initializes and returns a default annotation image that depicts a round pin +/// rising from the center, with a shadow slightly below center. The alignment +/// rect therefore excludes the bottom half. +- (MGLAnnotationImage *)defaultAnnotationImage { + NSImage *image = MGLDefaultMarkerImage(); + NSRect alignmentRect = image.alignmentRect; + alignmentRect.origin.y = NSMidY(alignmentRect); + alignmentRect.size.height /= 2; + image.alignmentRect = alignmentRect; + return [MGLAnnotationImage annotationImageWithImage:image + reuseIdentifier:MGLDefaultStyleMarkerSymbolName]; +} + +/// Sends the raw pixel data of the annotation image’s image to mbgl and +/// calculates state needed for hit testing later. +- (void)installAnnotationImage:(MGLAnnotationImage *)annotationImage { + NSString *iconIdentifier = annotationImage.styleIconIdentifier; + self.annotationImagesByIdentifier[annotationImage.reuseIdentifier] = annotationImage; + + NSImage *image = annotationImage.image; + NSSize size = image.size; + if (size.width == 0 || size.height == 0 || !image.valid) { + // Can’t create an empty sprite. An image that hasn’t loaded is also useless. + return; + } + + // Create a bitmap image representation from the image, respecting backing + // scale factor and any resizing done on the image at runtime. + // http://www.cocoabuilder.com/archive/cocoa/82430-nsimage-getting-raw-bitmap-data.html#82431 + [image lockFocus]; + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithFocusedViewRect:{ NSZeroPoint, size }]; + [image unlockFocus]; + + // Get the image’s raw pixel data as an RGBA buffer. + std::string pixelString((const char *)rep.bitmapData, rep.pixelsWide * rep.pixelsHigh * 4 /* RGBA */); + + mbgl::PremultipliedImage cPremultipliedImage(rep.pixelsWide, rep.pixelsHigh); + std::copy(rep.bitmapData, rep.bitmapData + cPremultipliedImage.size(), cPremultipliedImage.data.get()); + auto cSpriteImage = std::make_shared<mbgl::SpriteImage>(std::move(cPremultipliedImage), + (float)(rep.pixelsWide / size.width)); + _mbglMap->addAnnotationIcon(iconIdentifier.UTF8String, cSpriteImage); + + // Create a slop area with a “radius” equal to the annotation image’s entire + // size, allowing the eventual click to be on any point within this image. + // Union this slop area with any existing slop areas. + _unionedAnnotationImageSize = NSMakeSize(MAX(_unionedAnnotationImageSize.width, size.width), + MAX(_unionedAnnotationImageSize.height, size.height)); + + // Opt into potentially expensive cursor tracking areas. + if (annotationImage.cursor) { + _wantsCursorRects = YES; + } +} + +- (void)removeAnnotation:(id <MGLAnnotation>)annotation { + if (annotation) { + [self removeAnnotations:@[annotation]]; + } +} + +- (void)removeAnnotations:(NS_ARRAY_OF(id <MGLAnnotation>) *)annotations { + if (!annotations) { + return; + } + + [self willChangeValueForKey:@"annotations"]; + + for (id <MGLAnnotation> annotation in annotations) { + NSAssert([annotation conformsToProtocol:@protocol(MGLAnnotation)], @"Annotation does not conform to MGLAnnotation"); + + MGLAnnotationTag annotationTag = [self annotationTagForAnnotation:annotation]; + NSAssert(annotationTag != MGLAnnotationTagNotFound, @"No ID for annotation %@", annotation); + + if (annotationTag == _selectedAnnotationTag) { + [self deselectAnnotation:annotation]; + } + if (annotationTag == _lastSelectedAnnotationTag) { + _lastSelectedAnnotationTag = MGLAnnotationTagNotFound; + } + + _annotationContextsByAnnotationTag.erase(annotationTag); + + if ([annotation isKindOfClass:[NSObject class]] && + ![annotation isKindOfClass:[MGLMultiPoint class]]) { + [(NSObject *)annotation removeObserver:self forKeyPath:@"coordinate" context:(void *)(NSUInteger)annotationTag]; + } + + _mbglMap->removeAnnotation(annotationTag); + } + + [self didChangeValueForKey:@"annotations"]; + + [self updateAnnotationTrackingAreas]; +} + +- (nullable MGLAnnotationImage *)dequeueReusableAnnotationImageWithIdentifier:(NSString *)identifier { + return self.annotationImagesByIdentifier[identifier]; +} + +- (id <MGLAnnotation>)annotationAtPoint:(NSPoint)point { + return [self annotationWithTag:[self annotationTagAtPoint:point persistingResults:NO]]; +} + +/** + Returns the tag of the annotation at the given point in the view. + + This is more involved than it sounds: if multiple point annotations overlap + near the point, this method cycles through them so that each of them is + accessible to the user at some point. + + @param persist True to remember the cycleable set of annotations, so that a + different annotation is returned the next time this method is called + with the same point. Setting this parameter to false is useful for + asking “what if?” + */ +- (MGLAnnotationTag)annotationTagAtPoint:(NSPoint)point persistingResults:(BOOL)persist { + // Look for any annotation near the click. An annotation is “near” if the + // distance between its center and the click is less than the maximum height + // or width of an installed annotation image. + NSRect queryRect = NSInsetRect({ point, NSZeroSize }, + -_unionedAnnotationImageSize.width / 2, + -_unionedAnnotationImageSize.height / 2); + queryRect = NSInsetRect(queryRect, -MGLAnnotationImagePaddingForHitTest, + -MGLAnnotationImagePaddingForHitTest); + std::vector<MGLAnnotationTag> nearbyAnnotations = [self annotationTagsInRect:queryRect]; + + if (nearbyAnnotations.size()) { + // Assume that the user is fat-fingering an annotation. + NSRect hitRect = NSInsetRect({ point, NSZeroSize }, + -MGLAnnotationImagePaddingForHitTest, + -MGLAnnotationImagePaddingForHitTest); + + // Filter out any annotation whose image is unselectable or for which + // hit testing fails. + auto end = std::remove_if(nearbyAnnotations.begin(), nearbyAnnotations.end(), [&](const MGLAnnotationTag annotationTag) { + NSAssert(_annotationContextsByAnnotationTag.count(annotationTag) != 0, @"Unknown annotation found nearby click"); + id <MGLAnnotation> annotation = [self annotationWithTag:annotationTag]; + if (!annotation) { + return true; + } + + MGLAnnotationImage *annotationImage = [self imageOfAnnotationWithTag:annotationTag]; + if (!annotationImage.selectable) { + return true; + } + + // Filter out the annotation if the fattened finger didn’t land on a + // translucent or opaque pixel in the image. + NSRect annotationRect = [self frameOfImage:annotationImage.image + centeredAtCoordinate:annotation.coordinate]; + return !!![annotationImage.image hitTestRect:hitRect withImageDestinationRect:annotationRect + context:nil hints:nil flipped:NO]; + }); + nearbyAnnotations.resize(std::distance(nearbyAnnotations.begin(), end)); + } + + MGLAnnotationTag hitAnnotationTag = MGLAnnotationTagNotFound; + if (nearbyAnnotations.size()) { + // The annotation tags need to be stable in order to compare them with + // the remembered tags. + std::sort(nearbyAnnotations.begin(), nearbyAnnotations.end()); + + if (nearbyAnnotations == _annotationsNearbyLastClick) { + // The first selection in the cycle should be the one nearest to the + // click. + CLLocationCoordinate2D currentCoordinate = [self convertPoint:point toCoordinateFromView:self]; + std::sort(nearbyAnnotations.begin(), nearbyAnnotations.end(), [&](const MGLAnnotationTag tagA, const MGLAnnotationTag tagB) { + CLLocationCoordinate2D coordinateA = [[self annotationWithTag:tagA] coordinate]; + CLLocationCoordinate2D coordinateB = [[self annotationWithTag:tagB] coordinate]; + CLLocationDegrees distanceA = hypot(coordinateA.latitude - currentCoordinate.latitude, + coordinateA.longitude - currentCoordinate.longitude); + CLLocationDegrees distanceB = hypot(coordinateB.latitude - currentCoordinate.latitude, + coordinateB.longitude - currentCoordinate.longitude); + return distanceA < distanceB; + }); + + // The last time we persisted a set of annotations, we had the same + // set of annotations as we do now. Cycle through them. + if (_lastSelectedAnnotationTag == MGLAnnotationTagNotFound + || _lastSelectedAnnotationTag == _annotationsNearbyLastClick.back()) { + // Either no annotation is selected or the last annotation in + // the set was selected. Wrap around to the first annotation in + // the set. + hitAnnotationTag = _annotationsNearbyLastClick.front(); + } else { + auto result = std::find(_annotationsNearbyLastClick.begin(), + _annotationsNearbyLastClick.end(), + _lastSelectedAnnotationTag); + if (result == _annotationsNearbyLastClick.end()) { + // An annotation from this set hasn’t been selected before. + // Select the first (nearest) one. + hitAnnotationTag = _annotationsNearbyLastClick.front(); + } else { + // Step to the next annotation in the set. + auto distance = std::distance(_annotationsNearbyLastClick.begin(), result); + hitAnnotationTag = _annotationsNearbyLastClick[distance + 1]; + } + } + } else { + // Remember the nearby annotations for the next time this method is + // called. + if (persist) { + _annotationsNearbyLastClick = nearbyAnnotations; + } + + // Choose the first nearby annotation. + if (nearbyAnnotations.size()) { + hitAnnotationTag = nearbyAnnotations.front(); + } + } + } + + return hitAnnotationTag; +} + +/// Returns the tags of the annotations coincident with the given rectangle. +- (std::vector<MGLAnnotationTag>)annotationTagsInRect:(NSRect)rect { + mbgl::LatLngBounds queryBounds = [self convertRect:rect toLatLngBoundsFromView:self]; + return _mbglMap->getPointAnnotationsInBounds(queryBounds); +} + +- (id <MGLAnnotation>)selectedAnnotation { + if (!_annotationContextsByAnnotationTag.count(_selectedAnnotationTag)) { + return nil; + } + MGLAnnotationContext &annotationContext = _annotationContextsByAnnotationTag.at(_selectedAnnotationTag); + return annotationContext.annotation; +} + +- (void)setSelectedAnnotation:(id <MGLAnnotation>)annotation { + [self willChangeValueForKey:@"selectedAnnotations"]; + _selectedAnnotationTag = [self annotationTagForAnnotation:annotation]; + if (_selectedAnnotationTag != MGLAnnotationTagNotFound) { + _lastSelectedAnnotationTag = _selectedAnnotationTag; + } + [self didChangeValueForKey:@"selectedAnnotations"]; +} + +- (NS_ARRAY_OF(id <MGLAnnotation>) *)selectedAnnotations { + id <MGLAnnotation> selectedAnnotation = self.selectedAnnotation; + return selectedAnnotation ? @[selectedAnnotation] : @[]; +} + +- (void)setSelectedAnnotations:(NS_ARRAY_OF(id <MGLAnnotation>) *)selectedAnnotations { + if (!selectedAnnotations.count) { + return; + } + + id <MGLAnnotation> firstAnnotation = selectedAnnotations[0]; + NSAssert([firstAnnotation conformsToProtocol:@protocol(MGLAnnotation)], @"Annotation does not conform to MGLAnnotation"); + if ([firstAnnotation isKindOfClass:[MGLMultiPoint class]]) { + return; + } + + // Select the annotation if it’s visible. + if (MGLCoordinateInCoordinateBounds(firstAnnotation.coordinate, self.visibleCoordinateBounds)) { + [self selectAnnotation:firstAnnotation]; + } +} + +- (void)selectAnnotation:(id <MGLAnnotation>)annotation +{ + // Only point annotations can be selected. + if (!annotation || [annotation isKindOfClass:[MGLMultiPoint class]]) { + return; + } + + id <MGLAnnotation> selectedAnnotation = self.selectedAnnotation; + if (annotation == selectedAnnotation) { + return; + } + + // Deselect the annotation before reselecting it. + [self deselectAnnotation:selectedAnnotation]; + + // Add the annotation to the map if it hasn’t been added yet. + MGLAnnotationTag annotationTag = [self annotationTagForAnnotation:annotation]; + if (annotationTag == MGLAnnotationTagNotFound) { + [self addAnnotation:annotation]; + } + + // The annotation can’t be selected if no part of it is hittable. + NSRect positioningRect = [self positioningRectForCalloutForAnnotationWithTag:annotationTag]; + if (NSIsEmptyRect(NSIntersectionRect(positioningRect, self.bounds))) { + return; + } + + self.selectedAnnotation = annotation; + + // For the callout to be shown, the annotation must have a title, its + // callout must not already be shown, and the annotation must be able to + // show a callout according to the delegate. + if ([annotation respondsToSelector:@selector(title)] + && annotation.title + && !self.calloutForSelectedAnnotation.shown + && [self.delegate respondsToSelector:@selector(mapView:annotationCanShowCallout:)] + && [self.delegate mapView:self annotationCanShowCallout:annotation]) { + NSPopover *callout = [self calloutForAnnotation:annotation]; + + // Hang the callout off the right edge of the annotation image’s + // alignment rect, or off the left edge in a right-to-left UI. + callout.delegate = self; + self.calloutForSelectedAnnotation = callout; + NSRectEdge edge = (self.userInterfaceLayoutDirection == NSUserInterfaceLayoutDirectionRightToLeft + ? NSMinXEdge + : NSMaxXEdge); + [callout showRelativeToRect:positioningRect ofView:self preferredEdge:edge]; + } +} + +/// Returns a popover detailing the annotation. +- (NSPopover *)calloutForAnnotation:(id <MGLAnnotation>)annotation { + NSPopover *callout = [[NSPopover alloc] init]; + callout.behavior = NSPopoverBehaviorTransient; + + NSViewController *viewController; + if ([self.delegate respondsToSelector:@selector(mapView:calloutViewControllerForAnnotation:)]) { + NSViewController *viewControllerFromDelegate = [self.delegate mapView:self + calloutViewControllerForAnnotation:annotation]; + if (viewControllerFromDelegate) { + viewController = viewControllerFromDelegate; + } + } + if (!viewController) { + viewController = self.calloutViewController; + } + NSAssert(viewController, @"Unable to load MGLAnnotationCallout view controller"); + // The popover’s view controller can bind to KVO-compliant key paths of the + // annotation. + viewController.representedObject = annotation; + callout.contentViewController = viewController; + + return callout; +} + +- (NSViewController *)calloutViewController { + // Lazily load a default view controller. + if (!_calloutViewController) { + _calloutViewController = [[NSViewController alloc] initWithNibName:@"MGLAnnotationCallout" + bundle:[NSBundle mgl_frameworkBundle]]; + } + return _calloutViewController; +} + +/// Returns the rectangle that represents the annotation image of the annotation +/// with the given tag. This rectangle is fitted to the image’s alignment rect +/// and is appropriate for positioning a popover. +- (NSRect)positioningRectForCalloutForAnnotationWithTag:(MGLAnnotationTag)annotationTag { + id <MGLAnnotation> annotation = [self annotationWithTag:annotationTag]; + if (!annotation) { + return NSZeroRect; + } + NSImage *image = [self imageOfAnnotationWithTag:annotationTag].image; + if (!image) { + image = [self dequeueReusableAnnotationImageWithIdentifier:MGLDefaultStyleMarkerSymbolName].image; + } + if (!image) { + return NSZeroRect; + } + + NSRect positioningRect = [self frameOfImage:image centeredAtCoordinate:annotation.coordinate]; + positioningRect = NSOffsetRect(image.alignmentRect, positioningRect.origin.x, positioningRect.origin.y); + return NSInsetRect(positioningRect, -MGLAnnotationImagePaddingForCallout, + -MGLAnnotationImagePaddingForCallout); +} + +/// Returns the rectangle relative to the viewport that represents the given +/// image centered at the given coordinate. +- (NSRect)frameOfImage:(NSImage *)image centeredAtCoordinate:(CLLocationCoordinate2D)coordinate { + NSPoint calloutAnchorPoint = [self convertCoordinate:coordinate toPointToView:self]; + return NSInsetRect({ calloutAnchorPoint, NSZeroSize }, -image.size.width / 2, -image.size.height / 2); +} + +/// Returns the annotation image assigned to the annotation with the given tag. +- (MGLAnnotationImage *)imageOfAnnotationWithTag:(MGLAnnotationTag)annotationTag { + if (annotationTag == MGLAnnotationTagNotFound + || _annotationContextsByAnnotationTag.count(annotationTag) == 0) { + return nil; + } + + NSString *customSymbol = _annotationContextsByAnnotationTag.at(annotationTag).imageReuseIdentifier; + NSString *symbolName = customSymbol.length ? customSymbol : MGLDefaultStyleMarkerSymbolName; + + return [self dequeueReusableAnnotationImageWithIdentifier:symbolName]; +} + +- (void)deselectAnnotation:(id <MGLAnnotation>)annotation { + if (!annotation || self.selectedAnnotation != annotation) { + return; + } + + // Close the callout popover gracefully. + NSPopover *callout = self.calloutForSelectedAnnotation; + [callout performClose:self]; + + self.selectedAnnotation = nil; +} + +/// Move the annotation callout to point to the selected annotation at its +/// current position. +- (void)updateAnnotationCallouts { + NSPopover *callout = self.calloutForSelectedAnnotation; + if (callout) { + callout.positioningRect = [self positioningRectForCalloutForAnnotationWithTag:_selectedAnnotationTag]; + } +} + +#pragma mark MGLMultiPointDelegate methods + +- (double)alphaForShapeAnnotation:(MGLShape *)annotation { + if (_delegateHasAlphasForShapeAnnotations) { + return [self.delegate mapView:self alphaForShapeAnnotation:annotation]; + } + return 1.0; +} + +- (mbgl::Color)strokeColorForShapeAnnotation:(MGLShape *)annotation { + NSColor *color = (_delegateHasStrokeColorsForShapeAnnotations + ? [self.delegate mapView:self strokeColorForShapeAnnotation:annotation] + : [NSColor selectedMenuItemColor]); + return MGLColorObjectFromNSColor(color); +} + +- (mbgl::Color)fillColorForPolygonAnnotation:(MGLPolygon *)annotation { + NSColor *color = (_delegateHasFillColorsForShapeAnnotations + ? [self.delegate mapView:self fillColorForPolygonAnnotation:annotation] + : [NSColor selectedMenuItemColor]); + return MGLColorObjectFromNSColor(color); +} + +- (CGFloat)lineWidthForPolylineAnnotation:(MGLPolyline *)annotation { + if (_delegateHasLineWidthsForShapeAnnotations) { + return [self.delegate mapView:self lineWidthForPolylineAnnotation:(MGLPolyline *)annotation]; + } + return 3.0; +} + +#pragma mark MGLPopoverDelegate methods + +- (void)popoverDidShow:(__unused NSNotification *)notification { + id <MGLAnnotation> annotation = self.selectedAnnotation; + if (annotation && [self.delegate respondsToSelector:@selector(mapView:didSelectAnnotation:)]) { + [self.delegate mapView:self didSelectAnnotation:annotation]; + } +} + +- (void)popoverDidClose:(__unused NSNotification *)notification { + // Deselect the closed popover, in case the popover was closed due to user + // action. + id <MGLAnnotation> annotation = self.calloutForSelectedAnnotation.contentViewController.representedObject; + self.calloutForSelectedAnnotation = nil; + self.selectedAnnotation = nil; + + if ([self.delegate respondsToSelector:@selector(mapView:didDeselectAnnotation:)]) { + [self.delegate mapView:self didDeselectAnnotation:annotation]; + } +} + +#pragma mark Overlays + +- (void)addOverlay:(id <MGLOverlay>)overlay { + [self addOverlays:@[overlay]]; +} + +- (void)addOverlays:(NS_ARRAY_OF(id <MGLOverlay>) *)overlays +{ + for (id <MGLOverlay> overlay in overlays) { + NSAssert([overlay conformsToProtocol:@protocol(MGLOverlay)], @"Overlay does not conform to MGLOverlay"); + } + [self addAnnotations:overlays]; +} + +- (void)removeOverlay:(id <MGLOverlay>)overlay { + [self removeOverlays:@[overlay]]; +} + +- (void)removeOverlays:(NS_ARRAY_OF(id <MGLOverlay>) *)overlays { + for (id <MGLOverlay> overlay in overlays) { + NSAssert([overlay conformsToProtocol:@protocol(MGLOverlay)], @"Overlay does not conform to MGLOverlay"); + } + [self removeAnnotations:overlays]; +} + +#pragma mark Tooltips and cursors + +- (void)updateAnnotationTrackingAreas { + if (_wantsToolTipRects) { + [self removeAllToolTips]; + std::vector<MGLAnnotationTag> annotationTags = [self annotationTagsInRect:self.bounds]; + for (MGLAnnotationTag annotationTag : annotationTags) { + MGLAnnotationImage *annotationImage = [self imageOfAnnotationWithTag:annotationTag]; + id <MGLAnnotation> annotation = [self annotationWithTag:annotationTag]; + if (annotation.toolTip.length) { + // Add a tooltip tracking area over the annotation image’s + // frame, accounting for the image’s alignment rect. + NSImage *image = annotationImage.image; + NSRect annotationRect = [self frameOfImage:image + centeredAtCoordinate:annotation.coordinate]; + annotationRect = NSOffsetRect(image.alignmentRect, annotationRect.origin.x, annotationRect.origin.y); + if (!NSIsEmptyRect(annotationRect)) { + [self addToolTipRect:annotationRect owner:self userData:(void *)(NSUInteger)annotationTag]; + } + } + // Opt into potentially expensive cursor tracking areas. + if (annotationImage.cursor) { + _wantsCursorRects = YES; + } + } + } + + // Blow away any cursor tracking areas and rebuild them. That’s the + // potentially expensive part. + if (_wantsCursorRects) { + [self.window invalidateCursorRectsForView:self]; + } +} + +- (NSString *)view:(__unused NSView *)view stringForToolTip:(__unused NSToolTipTag)tag point:(__unused NSPoint)point userData:(void *)data { + NSAssert((NSUInteger)data < MGLAnnotationTagNotFound, @"Invalid annotation tag in tooltip rect user data."); + MGLAnnotationTag annotationTag = (MGLAnnotationTag)MIN((NSUInteger)data, MGLAnnotationTagNotFound); + id <MGLAnnotation> annotation = [self annotationWithTag:annotationTag]; + return annotation.toolTip; +} + +- (void)resetCursorRects { + // Drag to pan has a grabbing hand cursor. + if ([self isPanningWithGesture]) { + [self addCursorRect:self.bounds cursor:[NSCursor closedHandCursor]]; + return; + } + + // The rest of this method can be expensive, so bail if no annotations have + // ever had custom cursors. + if (!_wantsCursorRects) { + return; + } + + std::vector<MGLAnnotationTag> annotationTags = [self annotationTagsInRect:self.bounds]; + for (MGLAnnotationTag annotationTag : annotationTags) { + id <MGLAnnotation> annotation = [self annotationWithTag:annotationTag]; + MGLAnnotationImage *annotationImage = [self imageOfAnnotationWithTag:annotationTag]; + if (annotationImage.cursor) { + // Add a cursor tracking area over the annotation image, respecting + // the image’s alignment rect. + NSImage *image = annotationImage.image; + NSRect annotationRect = [self frameOfImage:image + centeredAtCoordinate:annotation.coordinate]; + annotationRect = NSOffsetRect(image.alignmentRect, annotationRect.origin.x, annotationRect.origin.y); + [self addCursorRect:annotationRect cursor:annotationImage.cursor]; + } + } +} + +#pragma mark Data + +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesAtPoint:(NSPoint)point { + return [self visibleFeaturesAtPoint:point inStyleLayersWithIdentifiers:nil]; +} + +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesAtPoint:(NSPoint)point inStyleLayersWithIdentifiers:(NS_SET_OF(NSString *) *)styleLayerIdentifiers { + // Cocoa origin is at the lower-left corner. + mbgl::ScreenCoordinate screenCoordinate = { point.x, NSHeight(self.bounds) - point.y }; + + mbgl::optional<std::vector<std::string>> optionalLayerIDs; + if (styleLayerIdentifiers) { + __block std::vector<std::string> layerIDs; + layerIDs.reserve(styleLayerIdentifiers.count); + [styleLayerIdentifiers enumerateObjectsUsingBlock:^(NSString * _Nonnull identifier, BOOL * _Nonnull stop) { + layerIDs.push_back(identifier.UTF8String); + }]; + optionalLayerIDs = layerIDs; + } + + std::vector<mbgl::Feature> features = _mbglMap->queryRenderedFeatures(screenCoordinate, optionalLayerIDs); + return MGLFeaturesFromMBGLFeatures(features); +} + +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesInRect:(NSRect)rect { + return [self visibleFeaturesInRect:rect inStyleLayersWithIdentifiers:nil]; +} + +- (NS_ARRAY_OF(id <MGLFeature>) *)visibleFeaturesInRect:(NSRect)rect inStyleLayersWithIdentifiers:(NS_SET_OF(NSString *) *)styleLayerIdentifiers { + // Cocoa origin is at the lower-left corner. + mbgl::ScreenBox screenBox = { + { NSMinX(rect), NSHeight(self.bounds) - NSMaxY(rect) }, + { NSMaxX(rect), NSHeight(self.bounds) - NSMinY(rect) }, + }; + + mbgl::optional<std::vector<std::string>> optionalLayerIDs; + if (styleLayerIdentifiers) { + __block std::vector<std::string> layerIDs; + layerIDs.reserve(styleLayerIdentifiers.count); + [styleLayerIdentifiers enumerateObjectsUsingBlock:^(NSString * _Nonnull identifier, BOOL * _Nonnull stop) { + layerIDs.push_back(identifier.UTF8String); + }]; + optionalLayerIDs = layerIDs; + } + + std::vector<mbgl::Feature> features = _mbglMap->queryRenderedFeatures(screenBox, optionalLayerIDs); + return MGLFeaturesFromMBGLFeatures(features); +} + +#pragma mark Interface Builder methods + +- (void)prepareForInterfaceBuilder { + [super prepareForInterfaceBuilder]; + + // Color the background a glorious Mapbox teal. + self.layer.borderColor = [NSColor colorWithRed:59/255. + green:178/255. + blue:208/255. + alpha:0.8].CGColor; + self.layer.borderWidth = 2; + self.layer.backgroundColor = [NSColor colorWithRed:59/255. + green:178/255. + blue:208/255. + alpha:0.6].CGColor; + + // Place a playful marker right smack dab in the middle. + self.layer.contents = MGLDefaultMarkerImage(); + self.layer.contentsGravity = kCAGravityCenter; + self.layer.contentsScale = [NSScreen mainScreen].backingScaleFactor; +} + +#pragma mark Geometric methods + +- (NSPoint)convertCoordinate:(CLLocationCoordinate2D)coordinate toPointToView:(nullable NSView *)view { + return [self convertLatLng:MGLLatLngFromLocationCoordinate2D(coordinate) toPointToView:view]; +} + +/// Converts a geographic coordinate to a point in the view’s coordinate system. +- (NSPoint)convertLatLng:(mbgl::LatLng)latLng toPointToView:(nullable NSView *)view { + mbgl::ScreenCoordinate pixel = _mbglMap->pixelForLatLng(latLng); + // Cocoa origin is at the lower-left corner. + pixel.y = NSHeight(self.bounds) - pixel.y; + return [self convertPoint:NSMakePoint(pixel.x, pixel.y) toView:view]; +} + +- (CLLocationCoordinate2D)convertPoint:(NSPoint)point toCoordinateFromView:(nullable NSView *)view { + return MGLLocationCoordinate2DFromLatLng([self convertPoint:point toLatLngFromView:view]); +} + +/// Converts a point in the view’s coordinate system to a geographic coordinate. +- (mbgl::LatLng)convertPoint:(NSPoint)point toLatLngFromView:(nullable NSView *)view { + NSPoint convertedPoint = [self convertPoint:point fromView:view]; + return _mbglMap->latLngForPixel({ + convertedPoint.x, + // mbgl origin is at the top-left corner. + NSHeight(self.bounds) - convertedPoint.y, + }).wrapped(); +} + +- (NSRect)convertCoordinateBounds:(MGLCoordinateBounds)bounds toRectToView:(nullable NSView *)view { + return [self convertLatLngBounds:MGLLatLngBoundsFromCoordinateBounds(bounds) toRectToView:view]; +} + +/// Converts a geographic bounding box to a rectangle in the view’s coordinate +/// system. +- (NSRect)convertLatLngBounds:(mbgl::LatLngBounds)bounds toRectToView:(nullable NSView *)view { + NSRect rect = { [self convertLatLng:bounds.southwest() toPointToView:view], NSZeroSize }; + rect = MGLExtendRect(rect, [self convertLatLng:bounds.northeast() toPointToView:view]); + return rect; +} + +- (MGLCoordinateBounds)convertRect:(NSRect)rect toCoordinateBoundsFromView:(nullable NSView *)view { + return MGLCoordinateBoundsFromLatLngBounds([self convertRect:rect toLatLngBoundsFromView:view]); +} + +/// Converts a rectangle in the given view’s coordinate system to a geographic +/// bounding box. +- (mbgl::LatLngBounds)convertRect:(NSRect)rect toLatLngBoundsFromView:(nullable NSView *)view { + mbgl::LatLngBounds bounds = mbgl::LatLngBounds::empty(); + bounds.extend([self convertPoint:rect.origin toLatLngFromView:view]); + bounds.extend([self convertPoint:{ NSMaxX(rect), NSMinY(rect) } toLatLngFromView:view]); + bounds.extend([self convertPoint:{ NSMaxX(rect), NSMaxY(rect) } toLatLngFromView:view]); + bounds.extend([self convertPoint:{ NSMinX(rect), NSMaxY(rect) } toLatLngFromView:view]); + + // The world is wrapping if a point just outside the bounds is also within + // the rect. + mbgl::LatLng outsideLatLng; + if (bounds.west() > -180) { + outsideLatLng = { + (bounds.south() + bounds.north()) / 2, + bounds.west() - 1, + }; + } else if (bounds.northeast().longitude < 180) { + outsideLatLng = { + (bounds.south() + bounds.north()) / 2, + bounds.east() + 1, + }; + } + + // If the world is wrapping, extend the bounds to cover all longitudes. + if (NSPointInRect([self convertLatLng:outsideLatLng toPointToView:view], rect)) { + bounds.extend(mbgl::LatLng(bounds.south(), -180)); + bounds.extend(mbgl::LatLng(bounds.south(), 180)); + } + + return bounds; +} + +- (CLLocationDistance)metersPerPointAtLatitude:(CLLocationDegrees)latitude { + return _mbglMap->getMetersPerPixelAtLatitude(latitude, self.zoomLevel); +} + +#pragma mark Debugging + +- (MGLMapDebugMaskOptions)debugMask { + mbgl::MapDebugOptions options = _mbglMap->getDebug(); + MGLMapDebugMaskOptions mask = 0; + if (options & mbgl::MapDebugOptions::TileBorders) { + mask |= MGLMapDebugTileBoundariesMask; + } + if (options & mbgl::MapDebugOptions::ParseStatus) { + mask |= MGLMapDebugTileInfoMask; + } + if (options & mbgl::MapDebugOptions::Timestamps) { + mask |= MGLMapDebugTimestampsMask; + } + if (options & mbgl::MapDebugOptions::Collision) { + mask |= MGLMapDebugCollisionBoxesMask; + } + if (options & mbgl::MapDebugOptions::Wireframe) { + mask |= MGLMapDebugWireframesMask; + } + if (options & mbgl::MapDebugOptions::StencilClip) { + mask |= MGLMapDebugStencilBufferMask; + } + return mask; +} + +- (void)setDebugMask:(MGLMapDebugMaskOptions)debugMask { + mbgl::MapDebugOptions options = mbgl::MapDebugOptions::NoDebug; + if (debugMask & MGLMapDebugTileBoundariesMask) { + options |= mbgl::MapDebugOptions::TileBorders; + } + if (debugMask & MGLMapDebugTileInfoMask) { + options |= mbgl::MapDebugOptions::ParseStatus; + } + if (debugMask & MGLMapDebugTimestampsMask) { + options |= mbgl::MapDebugOptions::Timestamps; + } + if (debugMask & MGLMapDebugCollisionBoxesMask) { + options |= mbgl::MapDebugOptions::Collision; + } + if (debugMask & MGLMapDebugWireframesMask) { + options |= mbgl::MapDebugOptions::Wireframe; + } + if (debugMask & MGLMapDebugStencilBufferMask) { + options |= mbgl::MapDebugOptions::StencilClip; + } + _mbglMap->setDebug(options); +} + +/// Adapter responsible for bridging calls from mbgl to MGLMapView and Cocoa. +class MGLMapViewImpl : public mbgl::View { +public: + MGLMapViewImpl(MGLMapView *nativeView_, const float scaleFactor_) + : nativeView(nativeView_), scaleFactor(scaleFactor_) {} + + float getPixelRatio() const override { + return scaleFactor; + } + + std::array<uint16_t, 2> getSize() const override { + return {{ static_cast<uint16_t>(nativeView.bounds.size.width), + static_cast<uint16_t>(nativeView.bounds.size.height) }}; + } + + std::array<uint16_t, 2> getFramebufferSize() const override { + NSRect bounds = [nativeView convertRectToBacking:nativeView.bounds]; + return {{ static_cast<uint16_t>(bounds.size.width), + static_cast<uint16_t>(bounds.size.height) }}; + } + + void notifyMapChange(mbgl::MapChange change) override { + [nativeView notifyMapChange:change]; + } + + void invalidate() override { + [nativeView invalidate]; + } + + void activate() override { + MGLOpenGLLayer *layer = (MGLOpenGLLayer *)nativeView.layer; + [layer.openGLContext makeCurrentContext]; + } + + void deactivate() override { + [NSOpenGLContext clearCurrentContext]; + } + + mbgl::PremultipliedImage readStillImage() override { + auto size = getFramebufferSize(); + const unsigned int w = size[0]; + const unsigned int h = size[1]; + + mbgl::PremultipliedImage image { w, h }; + MBGL_CHECK_ERROR(glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, image.data.get())); + + const size_t stride = image.stride(); + auto tmp = std::make_unique<uint8_t[]>(stride); + uint8_t *rgba = image.data.get(); + for (int i = 0, j = h - 1; i < j; i++, j--) { + std::memcpy(tmp.get(), rgba + i * stride, stride); + std::memcpy(rgba + i * stride, rgba + j * stride, stride); + std::memcpy(rgba + j * stride, tmp.get(), stride); + } + + return image; + } + +private: + /// Cocoa map view that this adapter bridges to. + __weak MGLMapView *nativeView = nullptr; + + /// Backing scale factor of the view. + const float scaleFactor; +}; + +@end diff --git a/platform/macos/src/MGLMapViewDelegate.h b/platform/macos/src/MGLMapViewDelegate.h new file mode 100644 index 0000000000..0b7eec84ac --- /dev/null +++ b/platform/macos/src/MGLMapViewDelegate.h @@ -0,0 +1,220 @@ +#import <Foundation/Foundation.h> + +#import "MGLTypes.h" + +NS_ASSUME_NONNULL_BEGIN + +@class MGLMapView; +@class MGLAnnotationImage; +@class MGLPolygon; +@class MGLPolyline; +@class MGLShape; + +/** + The `MGLMapViewDelegate` protocol defines a set of optional methods that you + can use to receive messages from an `MGLMapView` instance. Because many map + operations require the `MGLMapView` class to load data asynchronously, the map + view calls these methods to notify your application when specific operations + complete. The map view also uses these methods to request information about + annotations displayed on the map, such as the styles and interaction modes to + apply to individual annotations. + */ +@protocol MGLMapViewDelegate <NSObject> + +@optional + +#pragma mark Responding to Map Viewpoint Changes + +/** + Tells the delegate that the viewpoint depicted by the map view is about to + change. + + This method is called whenever the currently displayed map camera will start + changing for any reason. + + @param mapView The map view whose viewpoint will change. + @param animated Whether the change will cause an animated effect on the map. + */ +- (void)mapView:(MGLMapView *)mapView cameraWillChangeAnimated:(BOOL)animated; + +/** + Tells the delegate that the viewpoint depicted by the map view is changing. + + This method is called as the currently displayed map camera changes due to + animation. During movement, this method may be called many times to report + updates to the viewpoint. Therefore, your implementation of this method should + be as lightweight as possible to avoid affecting performance. + + @param mapView The map view whose viewpoint is changing. + */ +- (void)mapViewCameraIsChanging:(MGLMapView *)mapView; + +/** + Tells the delegate that the viewpoint depicted by the map view has finished + changing. + + This method is called whenever the currently displayed map camera has finished + changing. + + @param mapView The map view whose viewpoint has changed. + @param animated Whether the change caused an animated effect on the map. + */ +- (void)mapView:(MGLMapView *)mapView cameraDidChangeAnimated:(BOOL)animated; + +#pragma mark Loading the Map + +/** + Tells the delegate that the map view will begin to load. + + This method is called whenever the map view starts loading, including when a + new style has been set and the map must reload. + + @param mapView The map view that is starting to load. + */ +- (void)mapViewWillStartLoadingMap:(MGLMapView *)mapView; + +/** + Tells the delegate that the map view has finished loading. + + This method is called whenever the map view finishes loading, either after the + initial load or after a style change has forced a reload. + + @param mapView The map view that has finished loading. + */ +- (void)mapViewDidFinishLoadingMap:(MGLMapView *)mapView; + +- (void)mapViewWillStartRenderingMap:(MGLMapView *)mapView; +- (void)mapViewDidFinishRenderingMap:(MGLMapView *)mapView fullyRendered:(BOOL)fullyRendered; +- (void)mapViewWillStartRenderingFrame:(MGLMapView *)mapView; +- (void)mapViewDidFinishRenderingFrame:(MGLMapView *)mapView fullyRendered:(BOOL)fullyRendered; + +#pragma mark Managing the Display of Annotations + +/** + Returns an annotation image object to mark the given point annotation object on + the map. + + @param mapView The map view that requested the annotation image. + @param annotation The object representing the annotation that is about to be + displayed. + @return The image object to display for the given annotation or `nil` if you + want to display the default marker image. + */ +- (nullable MGLAnnotationImage *)mapView:(MGLMapView *)mapView imageForAnnotation:(id <MGLAnnotation>)annotation; + +/** + Returns the alpha value to use when rendering a shape annotation. + + A value of 0.0 results in a completely transparent shape. A value of 1.0, the + default, results in a completely opaque shape. + + @param mapView The map view rendering the shape annotation. + @param annotation The annotation being rendered. + @return An alpha value between 0 and 1.0. + */ +- (CGFloat)mapView:(MGLMapView *)mapView alphaForShapeAnnotation:(MGLShape *)annotation; + +/** + Returns the color to use when rendering the outline of a shape annotation. + + The default stroke color is the selected menu item color. If a pattern color is + specified, the result is undefined. + + @param mapView The map view rendering the shape annotation. + @param annotation The annotation being rendered. + @return A color to use for the shape outline. + */ +- (NSColor *)mapView:(MGLMapView *)mapView strokeColorForShapeAnnotation:(MGLShape *)annotation; + +/** + Returns the color to use when rendering the fill of a polygon annotation. + + The default fill color is selected menu item color. If a pattern color is + specified, the result is undefined. + + @param mapView The map view rendering the polygon annotation. + @param annotation The annotation being rendered. + @return The polygon’s interior fill color. + */ +- (NSColor *)mapView:(MGLMapView *)mapView fillColorForPolygonAnnotation:(MGLPolygon *)annotation; + +/** + Returns the line width in points to use when rendering the outline of a + polyline annotation. + + By default, the polyline is outlined with a line 3.0 points wide. + + @param mapView The map view rendering the polygon annotation. + @param annotation The annotation being rendered. + @return A line width for the polyline, measured in points. + */ +- (CGFloat)mapView:(MGLMapView *)mapView lineWidthForPolylineAnnotation:(MGLPolyline *)annotation; + +#pragma mark Selecting Annotations + +/** + Tells the delegate that one of its annotations has been selected. + + You can use this method to track changes to the selection state of annotations. + + @param mapView The map view containing the annotation. + @param annotation The annotation that was selected. + */ +- (void)mapView:(MGLMapView *)mapView didSelectAnnotation:(id <MGLAnnotation>)annotation; + +/** + Tells the delegate that one of its annotations has been deselected. + + You can use this method to track changes in the selection state of annotations. + + @param mapView The map view containing the annotation. + @param annotation The annotation that was deselected. + */ +- (void)mapView:(MGLMapView *)mapView didDeselectAnnotation:(id <MGLAnnotation>)annotation; + +#pragma mark Displaying Information About Annotations + +/** + Returns a Boolean value indicating whether the annotation is able to display + extra information in a callout popover. + + This method is called after an annotation is selected, before any callout is + displayed for the annotation. + + If the return value is `YES`, a callout popover is shown when the user clicks + on a selected annotation. The default callout displays the annotation’s title + and subtitle. You can customize the popover’s contents by implementing the + `-mapView:calloutViewControllerForAnnotation:` method. + + If the return value is `NO`, or if this method is unimplemented, or if the + annotation lacks a title, the annotation will not show a callout even when + selected. + + @param mapView The map view that has selected the annotation. + @param annotation The object representing the annotation. + @return A Boolean value indicating whether the annotation should show a + callout. + */ +- (BOOL)mapView:(MGLMapView *)mapView annotationCanShowCallout:(id <MGLAnnotation>)annotation; + +/** + Returns a view controller to manage the callout popover’s content view. + + Like any instance of `NSPopover`, an annotation callout manages its contents + with a view controller. The annotation object is the view controller’s + represented object. This means that you can bind controls in the view + controller’s content view to KVO-compliant properties of the annotation object, + such as `title` and `subtitle`. + + If each annotation should have an identical callout, you can set the + `MGLMapView` instance’s `-setCalloutViewController:` method instead. + + @param mapView The map view that is requesting a callout view controller. + @param annotation The object representing the annotation. + @return A view controller for the given annotation. + */ +- (nullable NSViewController *)mapView:(MGLMapView *)mapView calloutViewControllerForAnnotation:(id <MGLAnnotation>)annotation; + +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/macos/src/MGLMapView_Private.h b/platform/macos/src/MGLMapView_Private.h new file mode 100644 index 0000000000..76b1727925 --- /dev/null +++ b/platform/macos/src/MGLMapView_Private.h @@ -0,0 +1,23 @@ +#import "MGLMapView.h" + +@interface MGLMapView (Private) + +/// True if the view or application is in a state where it is not expected to be +/// actively drawing. +@property (nonatomic, readonly, getter=isDormant) BOOL dormant; + +// These properties exist because initially, both the latitude and longitude are +// NaN. You have to set both the latitude and longitude simultaneously. If you +// set the latitude but reuse the current longitude, and the current longitude +// happens to be NaN, there will be no change because the resulting coordinate +// pair is invalid. + +/// Center latitude set independently of the center longitude in an inspectable. +@property (nonatomic) CLLocationDegrees pendingLatitude; +/// Center longitude set independently of the center latitude in an inspectable. +@property (nonatomic) CLLocationDegrees pendingLongitude; + +/// Synchronously render a frame of the map. +- (void)renderSync; + +@end diff --git a/platform/macos/src/MGLOpenGLLayer.h b/platform/macos/src/MGLOpenGLLayer.h new file mode 100644 index 0000000000..5c8ac43e9e --- /dev/null +++ b/platform/macos/src/MGLOpenGLLayer.h @@ -0,0 +1,12 @@ +#import <Cocoa/Cocoa.h> + +#import "MGLTypes.h" + +NS_ASSUME_NONNULL_BEGIN + +/// A subclass of NSOpenGLLayer that creates the environment mbgl needs to +/// render good-looking maps. +@interface MGLOpenGLLayer : NSOpenGLLayer +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/macos/src/MGLOpenGLLayer.mm b/platform/macos/src/MGLOpenGLLayer.mm new file mode 100644 index 0000000000..e8fa521351 --- /dev/null +++ b/platform/macos/src/MGLOpenGLLayer.mm @@ -0,0 +1,49 @@ +#import "MGLOpenGLLayer.h" + +#import "MGLMapView_Private.h" + +#import <mbgl/gl/gl.hpp> + +@implementation MGLOpenGLLayer + +- (MGLMapView *)mapView { + return (MGLMapView *)super.view; +} + +//- (BOOL)isAsynchronous { +// return YES; +//} + +- (BOOL)needsDisplayOnBoundsChange { + return YES; +} + +- (CGRect)frame { + return self.view.bounds; +} + +- (NSOpenGLPixelFormat *)openGLPixelFormatForDisplayMask:(uint32_t)mask { + NSOpenGLPixelFormatAttribute pfas[] = { + NSOpenGLPFAAccelerated, + NSOpenGLPFAClosestPolicy, + NSOpenGLPFAAccumSize, 32, + NSOpenGLPFAColorSize, 24, + NSOpenGLPFAAlphaSize, 8, + NSOpenGLPFADepthSize, 16, + NSOpenGLPFAStencilSize, 8, + NSOpenGLPFAScreenMask, mask, + 0 + }; + return [[NSOpenGLPixelFormat alloc] initWithAttributes:pfas]; +} + +- (BOOL)canDrawInOpenGLContext:(__unused NSOpenGLContext *)context pixelFormat:(__unused NSOpenGLPixelFormat *)pixelFormat forLayerTime:(__unused CFTimeInterval)t displayTime:(__unused const CVTimeStamp *)ts { + return !self.mapView.dormant; +} + +- (void)drawInOpenGLContext:(NSOpenGLContext *)context pixelFormat:(NSOpenGLPixelFormat *)pixelFormat forLayerTime:(CFTimeInterval)t displayTime:(const CVTimeStamp *)ts { + [self.mapView renderSync]; + [super drawInOpenGLContext:context pixelFormat:pixelFormat forLayerTime:t displayTime:ts]; +} + +@end diff --git a/platform/macos/src/Mapbox.h b/platform/macos/src/Mapbox.h new file mode 100644 index 0000000000..e4545e04bc --- /dev/null +++ b/platform/macos/src/Mapbox.h @@ -0,0 +1,34 @@ +#import <Cocoa/Cocoa.h> + +/// Project version number for Mapbox. +FOUNDATION_EXPORT double MapboxVersionNumber; + +/// Project version string for Mapbox. +FOUNDATION_EXPORT const unsigned char MapboxVersionString[]; + +#import "MGLAccountManager.h" +#import "MGLAnnotation.h" +#import "MGLAnnotationImage.h" +#import "MGLClockDirectionFormatter.h" +#import "MGLCompassDirectionFormatter.h" +#import "MGLCoordinateFormatter.h" +#import "MGLFeature.h" +#import "MGLGeometry.h" +#import "MGLMapCamera.h" +#import "MGLMapView.h" +#import "MGLMapView+IBAdditions.h" +#import "MGLMapViewDelegate.h" +#import "MGLMultiPoint.h" +#import "MGLOfflinePack.h" +#import "MGLOfflineRegion.h" +#import "MGLOfflineStorage.h" +#import "MGLOverlay.h" +#import "MGLPointAnnotation.h" +#import "MGLPolygon.h" +#import "MGLPolyline.h" +#import "MGLShape.h" +#import "MGLShapeCollection.h" +#import "MGLStyle.h" +#import "MGLTilePyramidOfflineRegion.h" +#import "MGLTypes.h" +#import "NSValue+MGLAdditions.h" |