diff --git a/Example/ViewController.swift b/Example/ViewController.swift index 3611be349fd..9c9e76ae0f0 100644 --- a/Example/ViewController.swift +++ b/Example/ViewController.swift @@ -573,6 +573,7 @@ class ViewController: UIViewController { completion: CompletionHandler? = nil) { navigationViewController.modalPresentationStyle = .fullScreen activeNavigationViewController = navigationViewController + activeNavigationViewController?.showIntersectionAnnotations = true present(navigationViewController, animated: true) { completion?() @@ -591,6 +592,7 @@ class ViewController: UIViewController { func dismissActiveNavigationViewController() { activeNavigationViewController?.dismiss(animated: true) { + self.activeNavigationViewController?.showIntersectionAnnotations = false self.activeNavigationViewController = nil } } diff --git a/MapboxNavigation.xcodeproj/project.pbxproj b/MapboxNavigation.xcodeproj/project.pbxproj index a9116d4af8f..01c2bfecaf2 100644 --- a/MapboxNavigation.xcodeproj/project.pbxproj +++ b/MapboxNavigation.xcodeproj/project.pbxproj @@ -485,6 +485,10 @@ E2DAFABA27BCF3C200BA12BD /* RoutesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2DAFAB927BCF3C200BA12BD /* RoutesCoordinator.swift */; }; E2F08C70269DB17C002EFDC5 /* AccessToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F08C6F269DB17C002EFDC5 /* AccessToken.swift */; }; F46FF187260277F7007CC0E0 /* DateComponentsFormatter+NavigationAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F46FF186260277F7007CC0E0 /* DateComponentsFormatter+NavigationAdditions.swift */; }; + F43EE329261F98DC0039D56F /* NavigationMapView+RoadAnnotations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43EE328261F98DC0039D56F /* NavigationMapView+RoadAnnotations.swift */; }; + F43EE32A261F98DC0039D56F /* NavigationMapView+RoadAnnotations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F43EE328261F98DC0039D56F /* NavigationMapView+RoadAnnotations.swift */; }; + F488A0BE26261C4600A4CC8C /* NavigationMapView+IntersectionAnnotations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F488A0BD26261C4600A4CC8C /* NavigationMapView+IntersectionAnnotations.swift */; }; + F488A0C826261D8100A4CC8C /* ElectronicHorizon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F488A0C726261D8100A4CC8C /* ElectronicHorizon.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1139,6 +1143,9 @@ E2DAFAB927BCF3C200BA12BD /* RoutesCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoutesCoordinator.swift; sourceTree = ""; }; E2F08C6F269DB17C002EFDC5 /* AccessToken.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessToken.swift; sourceTree = ""; }; F46FF186260277F7007CC0E0 /* DateComponentsFormatter+NavigationAdditions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DateComponentsFormatter+NavigationAdditions.swift"; sourceTree = ""; }; + F43EE328261F98DC0039D56F /* NavigationMapView+RoadAnnotations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationMapView+RoadAnnotations.swift"; sourceTree = ""; }; + F488A0BD26261C4600A4CC8C /* NavigationMapView+IntersectionAnnotations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationMapView+IntersectionAnnotations.swift"; sourceTree = ""; }; + F488A0C726261D8100A4CC8C /* ElectronicHorizon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElectronicHorizon.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1868,6 +1875,8 @@ 8A11FEEF27A3514C00285B6F /* CPRouteChoice.swift */, 8AD220AA27C091EE000734A5 /* Solar.swift */, 8AD220AE27C09544000734A5 /* Date.swift */, + F488A0BD26261C4600A4CC8C /* NavigationMapView+IntersectionAnnotations.swift */, + F488A0C726261D8100A4CC8C /* ElectronicHorizon.swift */, ); name = Extensions; sourceTree = ""; @@ -2712,6 +2721,7 @@ 8AD2210F27C434CD000734A5 /* TitleLabel.swift in Sources */, 8A50A3CB26EC09FB00894A8E /* FeedbackSubtypeCollectionViewCell.swift in Sources */, 8A50A3D326EC0AE100894A8E /* IdleTimerManager.swift in Sources */, + F488A0C826261D8100A4CC8C /* ElectronicHorizon.swift in Sources */, 8D5DFFF1207C04840093765A /* NSAttributedString.swift in Sources */, 8AD2211F27C43D11000734A5 /* FloatingButton.swift in Sources */, 8A2081CB25E07CED00F9B8A6 /* NavigationMapViewIdentifiers.swift in Sources */, @@ -2745,6 +2755,7 @@ 2EBF20AE25D6F89000DB7BF2 /* Utils.swift in Sources */, 160D8279205996DA00D278D6 /* DataCache.swift in Sources */, 351BEBF21E5BCC63006FE110 /* Style.swift in Sources */, + F488A0BE26261C4600A4CC8C /* NavigationMapView+IntersectionAnnotations.swift in Sources */, 43FB386923A202420064481E /* Route.swift in Sources */, 3EA937B1F4DF73EB004BA6BE /* InstructionPresenter.swift in Sources */, 5A1C075824BDEB44000A6330 /* PassiveLocationProvider.swift in Sources */, diff --git a/Sources/MapboxCoreNavigation/CoreConstants.swift b/Sources/MapboxCoreNavigation/CoreConstants.swift index 916f34e3c69..016e2c7056e 100644 --- a/Sources/MapboxCoreNavigation/CoreConstants.swift +++ b/Sources/MapboxCoreNavigation/CoreConstants.swift @@ -483,6 +483,8 @@ extension RoadGraph { /** A key in the user info dictionary of a `Notification.Name.electronicHorizonDidEnterRoadObject` or `Notification.Name.electronicHorizonDidExitRoadObject` notification. The corresponding value is a `RoadObject.Identifier` identifying the road object that the user entered or exited. */ public static let roadObjectIdentifierKey: NotificationUserInfoKey = .init(rawValue: "roadObjectIdentifier") + + public static let roadGraphIdentifierKey: NotificationUserInfoKey = .init(rawValue: "roadGraph") /** A key in the user info dictionary of a `Notification.Name.electronicHorizonDidEnterRoadObject` or `Notification.Name.electronicHorizonDidExitRoadObject` notification. The corresponding value is an `NSNumber` containing a Boolean value set to `true` if the user entered at the beginning or exited at the end of the road object, or `false` if they entered or exited somewhere along the road object. */ diff --git a/Sources/MapboxCoreNavigation/CoreNavigationNavigator.swift b/Sources/MapboxCoreNavigation/CoreNavigationNavigator.swift index b52004e07cd..7260a427ae2 100644 --- a/Sources/MapboxCoreNavigation/CoreNavigationNavigator.swift +++ b/Sources/MapboxCoreNavigation/CoreNavigationNavigator.swift @@ -313,6 +313,7 @@ class NavigatorElectronicHorizonObserver: ElectronicHorizonObserver { .treeKey: RoadGraph.Edge(position.tree().start), .updatesMostProbablePathKey: position.type() == .update, .distancesByRoadObjectKey: distances.map(DistancedRoadObject.init), + .roadGraphIdentifierKey: Navigator.shared.roadGraph, ] NotificationCenter.default.post(name: .electronicHorizonDidUpdatePosition, object: nil, userInfo: userInfo) } diff --git a/Sources/MapboxNavigation/DayStyle.swift b/Sources/MapboxNavigation/DayStyle.swift index 045c8befeaf..07a77267e40 100644 --- a/Sources/MapboxNavigation/DayStyle.swift +++ b/Sources/MapboxNavigation/DayStyle.swift @@ -172,6 +172,7 @@ open class DayStyle: Style { NavigationMapView.appearance().routeDurationAnnotationFontNames = UIFont.defaultNavigationSymbolLayerFontNames NavigationMapView.appearance().routeDurationAnnotationTextColor = #colorLiteral(red: 0.09803921569, green: 0.09803921569, blue: 0.09803921569, alpha: 1) NavigationMapView.appearance().routeDurationAnnotationSelectedTextColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1) + NavigationMapView.appearance().intersectionAnnotationFontNames = ["DIN Pro Medium", "Noto Sans CJK JP Medium", "Arial Unicode MS Regular"] NavigationView.appearance().backgroundColor = #colorLiteral(red: 0.764706, green: 0.752941, blue: 0.733333, alpha: 1) NextBannerView.appearance().backgroundColor = #colorLiteral(red: 0.9675388083, green: 0.9675388083, blue: 0.9675388083, alpha: 1) NextBannerView.appearance(whenContainedInInstancesOf:[InstructionsCardContainerView.self]).backgroundColor = #colorLiteral(red: 0.9675388083, green: 0.9675388083, blue: 0.9675388083, alpha: 1) diff --git a/Sources/MapboxNavigation/ElectronicHorizon.swift b/Sources/MapboxNavigation/ElectronicHorizon.swift new file mode 100644 index 00000000000..9bc77f70783 --- /dev/null +++ b/Sources/MapboxNavigation/ElectronicHorizon.swift @@ -0,0 +1,38 @@ +import MapboxCoreNavigation + +extension RoadGraph.Edge { + var mpp: [RoadGraph.Edge]? { + + guard level == 0 else { return nil } + + var mostProbablePath = [self] + + for child in outletEdges { + if let childMPP = child.mpp { + mostProbablePath.append(contentsOf: childMPP) + } + } + + return mostProbablePath + } + + func edgeNames(roadGraph: RoadGraph) -> [String] { + guard let metadata = roadGraph.edgeMetadata(edgeIdentifier: identifier) else { + return [] + } + let names = metadata.names.map { name -> String in + switch name { + case .name(let name): + return name + case .code(let code): + return "(\(code))" + } + } + + // If the road is unnamed, fall back to the road class. + if names.isEmpty { + return ["\(metadata.mapboxStreetsRoadClass.rawValue)"] + } + return names + } +} diff --git a/Sources/MapboxNavigation/NavigationMapView+IntersectionAnnotations.swift b/Sources/MapboxNavigation/NavigationMapView+IntersectionAnnotations.swift new file mode 100644 index 00000000000..b656a9e6ad1 --- /dev/null +++ b/Sources/MapboxNavigation/NavigationMapView+IntersectionAnnotations.swift @@ -0,0 +1,133 @@ +import CoreLocation +import UIKit +import MapboxDirections +import MapboxCoreNavigation +import Turf +import MapboxMaps + +extension NavigationMapView { + + struct EdgeIntersection { + var root: RoadGraph.Edge + var branch: RoadGraph.Edge + var rootMetadata: RoadGraph.Edge.Metadata + var rootShape: LineString + var branchMetadata: RoadGraph.Edge.Metadata + var branchShape: LineString + + var coordinate: CLLocationCoordinate2D? { + rootShape.coordinates.first + } + + var annotationPoint: CLLocationCoordinate2D? { + guard let length = branchShape.distance() else { return nil } + let targetDistance = min(length / 2, Double.random(in: 15...30)) + guard let annotationPoint = branchShape.coordinateFromStart(distance: targetDistance) else { return nil } + return annotationPoint + } + + var wayName: String? { + guard let roadName = rootMetadata.names.first else { return nil } + + switch roadName { + case .name(let name): + return name + case .code(let code): + return "(\(code))" + } + } + var intersectingWayName: String? { + guard let roadName = branchMetadata.names.first else { return nil } + + switch roadName { + case .name(let name): + return name + case .code(let code): + return "(\(code))" + } + } + + var incidentAngle: CLLocationDegrees { + return (branchMetadata.heading - rootMetadata.heading).wrap(min: 0, max: 360) + } + + var description: String { + return "EdgeIntersection: root: \(wayName ?? "") intersection: \(intersectingWayName ?? "") coordinate: \(String(describing: coordinate))" + } + } + + enum AnnotationTailPosition: Int { + case left + case right + case center + } + + class AnnotationCacheEntry: Equatable, Hashable { + var wayname: String + var coordinate: CLLocationCoordinate2D + var intersection: EdgeIntersection? + var feature: Feature + var lastAccessTime: Date + + init(coordinate: CLLocationCoordinate2D, wayname: String, intersection: EdgeIntersection? = nil, feature: Feature) { + self.wayname = wayname + self.coordinate = coordinate + self.intersection = intersection + self.feature = feature + self.lastAccessTime = Date() + } + + static func == (lhs: AnnotationCacheEntry, rhs: AnnotationCacheEntry) -> Bool { + return lhs.wayname == rhs.wayname + } + + func hash(into hasher: inout Hasher) { + hasher.combine(wayname.hashValue) + } + } + + class AnnotationCache { + private let maxEntryAge = TimeInterval(30) + var entries = Set() + var cachePruningTimer: Timer? + + init() { + // periodically prune the cache to remove entries that have been passed already + cachePruningTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true, block: { [weak self] _ in + self?.prune() + }) + } + + deinit { + cachePruningTimer?.invalidate() + cachePruningTimer = nil + } + + func setValue(feature: Feature, coordinate: CLLocationCoordinate2D, intersection: EdgeIntersection?, for wayname: String) { + entries.insert(AnnotationCacheEntry(coordinate: coordinate, wayname: wayname, intersection: intersection, feature: feature)) + } + + func value(for wayname: String) -> AnnotationCacheEntry? { + let matchingEntry = entries.first { entry -> Bool in + entry.wayname == wayname + } + + if let matchingEntry = matchingEntry { + // update the timestamp used for pruning the cache + matchingEntry.lastAccessTime = Date() + } + + return matchingEntry + } + + private func prune() { + let now = Date() + + entries.filter { now.timeIntervalSince($0.lastAccessTime) > maxEntryAge }.forEach { remove($0) } + } + + public func remove(_ entry: AnnotationCacheEntry) { + entries.remove(entry) + } + } +} diff --git a/Sources/MapboxNavigation/NavigationMapView.swift b/Sources/MapboxNavigation/NavigationMapView.swift index 826bffb6c2d..c7d8d2d6386 100755 --- a/Sources/MapboxNavigation/NavigationMapView.swift +++ b/Sources/MapboxNavigation/NavigationMapView.swift @@ -113,6 +113,11 @@ open class NavigationMapView: UIView { @objc dynamic public var traversedRouteColor: UIColor = .defaultTraversedRouteColor @objc dynamic public var maneuverArrowColor: UIColor = .defaultManeuverArrow @objc dynamic public var maneuverArrowStrokeColor: UIColor = .defaultManeuverArrowStroke + @objc dynamic public var intersectionAnnotationDefaultBackgroundColor: UIColor = .intersectionAnnotationDefaultBackgroundColor + @objc dynamic public var intersectionAnnotationSelectedBackgroundColor: UIColor = .intersectionAnnotationSelectedBackgroundColor + @objc dynamic public var intersectionAnnotationDefaultLabelColor: UIColor = .intersectionAnnotationDefaultLabelColor + @objc dynamic public var intersectionAnnotationSelectedLabelColor: UIColor = .intersectionAnnotationSelectedLabelColor + @objc dynamic public var intersectionAnnotationFontNames: [String] = ["DIN Pro Medium", "Noto Sans CJK JP Medium", "Arial Unicode MS Regular"] /** A pending user location coordinate, which is used to calculate the bottleneck distance for @@ -566,6 +571,10 @@ open class NavigationMapView: UIView { NSLog("Failed to add route layer \(layerIdentifier) with error: \(error.localizedDescription).") } } + + mapView.mapboxMap.onEvery(.mapLoaded) { [weak self] _ in + try? self?.addAnnotationSymbolImages() + } return layerIdentifier } @@ -1553,6 +1562,7 @@ open class NavigationMapView: UIView { makeGestureRecognizersResetFrameRate() setupGestureRecognizers() subscribeForNotifications() + annotationCache = AnnotationCache() setupUserLocation() // To prevent the lengthy animation from the Null Island to the current location use @@ -1563,6 +1573,7 @@ open class NavigationMapView: UIView { } deinit { + annotationCache = nil unsubscribeFromNotifications() } @@ -1899,6 +1910,419 @@ open class NavigationMapView: UIView { zoom: CGFloat(navigationViewportDataSource.options.followingCameraOptions.zoomRange.upperBound))) moveUserLocation(to: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) } + + public var showIntersectionAnnotations: Bool = false { + didSet { + guard oldValue != showIntersectionAnnotations else { return } + intersectionsToAnnotate = nil + if showIntersectionAnnotations { + NotificationCenter.default.addObserver(self, + selector: #selector(didUpdateElectronicHorizonPosition), + name: .electronicHorizonDidUpdatePosition, + object: nil) + } else { + removeRouteAnnotationsLayerFromStyle() + NotificationCenter.default.removeObserver(self, name: .electronicHorizonDidUpdatePosition, object: nil) + } + } + } + + private func updateIntersectionAnnotationSet(tree: RoadGraph.Edge, roadGraph: RoadGraph) { + + guard let currentWayname = tree.edgeNames(roadGraph: roadGraph).first else { return } + + // grab the MPP from the Electronic Horizon + guard let edges = tree.mpp else { return } + + var intersections = [EdgeIntersection]() + + var intersectingWaynames = [String]() + + for mppEdge in edges { + let metadata = roadGraph.edgeMetadata(edgeIdentifier: mppEdge.identifier) + guard metadata?.names != nil else { continue } + // look through all the edges to filter out ones we don't want to consider + // These are ones that lack a name, are very short, or are not on-screen + let level1Edges = mppEdge.outletEdges.filter { outEdge -> Bool in + // Criteria for accepting an edge as a candidate intersecting road + // • Is not on the MPP + // • Is a named road + // • Is not the current road being travelled + // • Is not a road already accepted (happens since there will be more than one outlet edge if a road continues through the current one) + // • Is of a large enough road class + // • Is of a non-trivial length in meters + // • Intersection point is currently visible on screen + + guard outEdge.level != 0 else { return false } + guard let edgeMetadata = roadGraph.edgeMetadata(edgeIdentifier: outEdge.identifier), let geometry = roadGraph.edgeShape(edgeIdentifier: mppEdge.identifier) else { return false } + + let names = edgeMetadata.names.map { name -> String in + switch name { + case .name(let name): + return name + case .code(let code): + return "(\(code))" + } + } + + guard let firstName = names.first, firstName != "" else { + // edge has no name + return false + } + guard firstName != currentWayname else { + // edge is for the currently travelled road + return false + } + + guard !intersectingWaynames.contains(firstName) else { + // an edge for this road is already chosen + return false + } + + guard ![MapboxStreetsRoadClass.service, MapboxStreetsRoadClass.ferry, MapboxStreetsRoadClass.path, MapboxStreetsRoadClass.majorRail, MapboxStreetsRoadClass.minorRail, MapboxStreetsRoadClass.serviceRail, MapboxStreetsRoadClass.aerialway, MapboxStreetsRoadClass.golf].contains(edgeMetadata.mapboxStreetsRoadClass) else { + // edge is of type that we choose not to label + return false + } + + guard edgeMetadata.length >= 5 else { + // edge is at least 5 meters long + return false + } + + guard let length = geometry.distance() else { return false } + + let targetDistance = min(length / 2, Double.random(in: 15...30)) + guard let annotationPoint = geometry.coordinateFromStart(distance: targetDistance) else { + // unable to find a coordinate to label + return false + } + + let onscreenPoint = self.mapView.mapboxMap.point(for: annotationPoint) + + guard mapView.bounds.insetBy(dx: 20, dy: 20).contains(onscreenPoint) else { + // intersection coordinate is not visible on screen + return false + } + + // acceptable intersection to label + intersectingWaynames.append(firstName) + return true + } + + // record the edge information for use in creating the annotation Turf.Feature + let rootMetadata: RoadGraph.Edge.Metadata? = roadGraph.edgeMetadata(edgeIdentifier: mppEdge.identifier) + let rootShape: LineString? = roadGraph.edgeShape(edgeIdentifier: mppEdge.identifier) + for branch in level1Edges { + let branchMetadata: RoadGraph.Edge.Metadata? = roadGraph.edgeMetadata(edgeIdentifier: branch.identifier) + let branchShape: LineString? = roadGraph.edgeShape(edgeIdentifier: branch.identifier) + guard let rootMetadata = rootMetadata, let rootShape = rootShape, let branchInfo = branchMetadata, let branchGeometry = branchShape else { return } + + intersections.append(EdgeIntersection(root: mppEdge, branch: branch, rootMetadata: rootMetadata, rootShape: rootShape, branchMetadata: branchInfo, branchShape: branchGeometry)) + } + } + + // sort the edges by distance from the user + if let userCoordinate = mostRecentUserCourseViewLocation?.coordinate { + intersections.sort { (intersection1, intersection2) -> Bool in + if let edge1Start = intersection1.coordinate, let edge2Start = intersection2.coordinate { + return userCoordinate.distance(to: edge1Start) < userCoordinate.distance(to: edge2Start) + } + return true + } + } + + // form a set of the names of current intersections + // we will use this to check if any old intersections are no longer relevant or any additional ones have been picked + let currentNames = intersections.compactMap { return $0.intersectingWayName } + let currentNameSet = Set(currentNames) + + // if the road name set hasn't changed then we can just short-circuit out + guard previousNameSet != currentNameSet else { return } + + // go ahead and update our list of currently labelled intersections + previousNameSet = currentNameSet + + // take up to 4 intersections to annotate. Limit it to prevent cluttering the map with too many annotations + intersectionsToAnnotate = Array(intersections.prefix(4)) + } + + var previousNameSet: Set? + var intersectionsToAnnotate: [EdgeIntersection]? + + open func updateAnnotations(for routeProgress: RouteProgress) throws { + var features: [Feature] = [] + + // add an annotation for the next step + + if let upcomingStep = routeProgress.upcomingStep { + let maneuverLocation = upcomingStep.maneuverLocation + var labelText = upcomingStep.names?.first ?? "" + let currentLeg = routeProgress.currentLeg + + if upcomingStep == currentLeg.steps.last, let destination = currentLeg.destination?.name { + labelText = destination + } + + if labelText == "", let destinationCodes = upcomingStep.destinationCodes, destinationCodes.count > 0 { + labelText = destinationCodes[0] + + destinationCodes.dropFirst().forEach { destination in + labelText += " / " + destination + } + } + + if labelText == "", let exitCodes = upcomingStep.exitCodes, let code = exitCodes.first { + labelText = "Exit \(code)" + } + + if labelText == "", let destination = upcomingStep.destinations?.first { + labelText = destination + } + + if labelText == "", let exitName = upcomingStep.exitNames?.first { + labelText = exitName + } + + if labelText != "" { + var featurePoint: Feature + if let cachedEntry = cachedAnnotationFeature(for: labelText) { + featurePoint = cachedEntry.feature + } else { + featurePoint = Feature(geometry: Point(maneuverLocation)) + + let tailPosition = AnnotationTailPosition.center + + // set the feature attributes which will be used in styling the symbol style layer + featurePoint.properties = ["highlighted": true, "tailPosition": .number(Double(tailPosition.rawValue)), "text": .string(labelText), "imageName": "AnnotationCentered-Highlighted", "sortOrder": 0] + + annotationCache?.setValue(feature: featurePoint, coordinate: maneuverLocation, intersection: nil, for: labelText) + } + features.append(featurePoint) + } + } + + guard let intersectionsToAnnotate = intersectionsToAnnotate else { return } + for (index, intersection) in intersectionsToAnnotate.enumerated() { + guard let coordinate = intersection.annotationPoint else { continue } + var featurePoint: Feature + + if let intersectingWayName = intersection.intersectingWayName, let cachedEntry = cachedAnnotationFeature(for: intersectingWayName) { + featurePoint = cachedEntry.feature + } else { + featurePoint = Feature(geometry: Point(coordinate)) + + let tailPosition = intersection.incidentAngle < 180 ? AnnotationTailPosition.left : AnnotationTailPosition.right + + let imageName = tailPosition == .left ? "AnnotationLeftHanded" : "AnnotationRightHanded" + + // set the feature attributes which will be used in styling the symbol style layer + var properties: JSONObject = ["highlighted": false, "tailPosition": .number(Double(tailPosition.rawValue)), "imageName": .string(imageName), "sortOrder": .number(Double(-index))] + if let intersectingWayName = intersection.intersectingWayName { + properties["text"] = .string(intersectingWayName) + } + featurePoint.properties = properties + + if let intersectingWayName = intersection.intersectingWayName { + annotationCache?.setValue(feature: featurePoint, coordinate: coordinate, intersection: nil, for: intersectingWayName) + } + } + features.append(featurePoint) + } + + try updateAnnotationLayer(with: FeatureCollection(features: features)) + } + + private func addAnnotationSymbolImages() throws { + let style = mapView.mapboxMap.style + guard style.image(withId: "AnnotationLeftHanded") == nil, style.image(withId: "AnnotationRightHanded") == nil else { return } + + // Centered pin + if let image = UIImage(named: "AnnotationCentered", in: .mapboxNavigation, compatibleWith: nil) { + let stretchX = [ImageStretches(first: Float(20), second: Float(30)), ImageStretches(first: Float(90), second: Float(100))] + let stretchY = [ImageStretches(first: Float(26), second: Float(32))] + let imageContent = ImageContent(left: 20, top: 26, right: 100, bottom: 33) + + let regularAnnotationImage = image.tint(.intersectionAnnotationDefaultBackgroundColor) + + try style.addImage(regularAnnotationImage, + id: "AnnotationCentered", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + + let highlightedAnnotationImage = image.tint(.intersectionAnnotationSelectedBackgroundColor) + try style.addImage(highlightedAnnotationImage, + id: "AnnotationCentered-Highlighted", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + } + + let stretchX = [ImageStretches(first: Float(32), second: Float(42))] + let stretchY = [ImageStretches(first: Float(26), second: Float(32))] + let imageContent = ImageContent(left: 32, top: 26, right: 47, bottom: 33) + + // Right-hand pin + if let image = UIImage(named: "AnnotationRightHanded", in: .mapboxNavigation, compatibleWith: nil) { + let regularAnnotationImage = image.tint(.intersectionAnnotationDefaultBackgroundColor) + + try style.addImage(regularAnnotationImage, + id: "AnnotationRightHanded", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + + let highlightedAnnotationImage = image.tint(.intersectionAnnotationSelectedBackgroundColor) + try style.addImage(highlightedAnnotationImage, + id: "AnnotationRightHanded-Highlighted", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + } + + // Left-hand pin + if let image = UIImage(named: "AnnotationLeftHanded", in: .mapboxNavigation, compatibleWith: nil) { + let regularAnnotationImage = image.tint(.intersectionAnnotationDefaultBackgroundColor) + + try style.addImage(regularAnnotationImage, + id: "AnnotationLeftHanded", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + + let highlightedAnnotationImage = image.tint(.intersectionAnnotationSelectedBackgroundColor) + try style.addImage(highlightedAnnotationImage, + id: "AnnotationLeftHanded-Highlighted", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + } + } + + private func removeRouteAnnotationsLayerFromStyle() { + mapView.mapboxMap.style.removeLayers([LayerIdentifier.intersectionAnnotationsLayer]) + try? mapView.mapboxMap.style.removeSource(withId: SourceIdentifier.intersectionAnnotationsSource) + } + + var annotationCache: AnnotationCache? + + private func cachedAnnotationFeature(for labelText: String) -> AnnotationCacheEntry? { + if let existingFeature = annotationCache?.value(for: labelText) { + // ensure the cached feature is still visible on-screen. If it is not then remove the entry and return nil + let unprojectedCoordinate = self.mapView.mapboxMap.point(for: existingFeature.coordinate) + if mapView.bounds.contains(unprojectedCoordinate) { + return existingFeature + } else { + annotationCache?.remove(existingFeature) + } + } + + return nil + } + + private func updateAnnotationLayer(with features: FeatureCollection) throws { + let style = mapView.mapboxMap.style + let existingDataSource = try? style.source(withId: SourceIdentifier.intersectionAnnotationsSource, type: GeoJSONSource.self) + if existingDataSource != nil { + try style.updateGeoJSONSource(withId: SourceIdentifier.intersectionAnnotationsSource, geoJSON: .featureCollection(features)) + return + } else { + var dataSource = GeoJSONSource() + dataSource.data = .featureCollection(features) + try style.addSource(dataSource, id: SourceIdentifier.intersectionAnnotationsSource) + } + + try? style.removeLayer(withId: LayerIdentifier.intersectionAnnotationsLayer) + + var shapeLayer = SymbolLayer(id: LayerIdentifier.intersectionAnnotationsLayer) + shapeLayer.source = SourceIdentifier.intersectionAnnotationsSource + + shapeLayer.textField = .expression(Exp(.get) { + "text" + }) + + shapeLayer.iconImage = .expression(Exp(.get) { + "imageName" + }) + + shapeLayer.textColor = .expression(Exp(.switchCase) { + Exp(.any) { + Exp(.get) { + "highlighted" + } + } + self.intersectionAnnotationSelectedLabelColor + self.intersectionAnnotationDefaultLabelColor + }) + + shapeLayer.textSize = .constant(16) + shapeLayer.iconTextFit = .constant(.both) + shapeLayer.iconAllowOverlap = .constant(true) + shapeLayer.textAllowOverlap = .constant(true) + shapeLayer.textJustify = .constant(.center) + shapeLayer.symbolZOrder = .constant(.auto) + shapeLayer.textFont = .constant(self.intersectionAnnotationFontNames) + shapeLayer.iconTextFitPadding = .constant([-4, 0, -3, 0]) + shapeLayer.symbolSortKey = .expression(Exp(.get) { "sortOrder" }) + + let anchorExpression = Exp(.match) { + Exp(.get) { "tailPosition" } + 0 + "bottom-left" + 1 + "bottom-right" + 2 + "bottom" + "center" + } + shapeLayer.iconAnchor = .expression(anchorExpression) + shapeLayer.textAnchor = .expression(anchorExpression) + + let offsetExpression = Exp(.match) { + Exp(.get) { "tailPosition" } + 0 + Exp(.literal) { [0.5, -1] } + 1 + Exp(.literal) { [-0.5, -1] } + 2 + Exp(.literal) { [0.0, -1] } + Exp(.literal) { [0.0, 0.0] } + } + shapeLayer.iconOffset = .expression(offsetExpression) + shapeLayer.textOffset = .expression(offsetExpression) + + try style.addLayer(shapeLayer, layerPosition: nil) + } + + @objc func didUpdateElectronicHorizonPosition(_ notification: Notification) { + guard let tree = notification.userInfo?[RoadGraph.NotificationUserInfoKey.treeKey] as? RoadGraph.Edge, let roadGraph = notification.userInfo?[RoadGraph.NotificationUserInfoKey.roadGraphIdentifierKey] as? RoadGraph else { + return + } + + DispatchQueue.main.async { + self.updateIntersectionAnnotationSet(tree: tree, roadGraph: roadGraph) + } + } + + func edgeNames(identifier: RoadGraph.Edge.Identifier, roadGraph: RoadGraph) -> [String] { + guard let metadata = roadGraph.edgeMetadata(edgeIdentifier: identifier) else { + return [] + } + let names = metadata.names.map { name -> String in + switch name { + case .name(let name): + return name + case .code(let code): + return "(\(code))" + } + } + + // If the road is unnamed, fall back to the road class. + if names.isEmpty { + return ["\(metadata.mapboxStreetsRoadClass.rawValue)"] + } + return names + } } // MARK: - UIGestureRecognizerDelegate methods diff --git a/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift b/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift index 614db444fd1..95ffe16a1a4 100644 --- a/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift +++ b/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift @@ -15,6 +15,7 @@ extension NavigationMapView { static let waypointSymbolLayer = "\(identifier)_waypointSymbolLayer" static let buildingExtrusionLayer = "\(identifier)_buildingExtrusionLayer" static let routeDurationAnnotationsLayer: String = "\(identifier)_routeDurationAnnotationsLayer" + static let intersectionAnnotationsLayer = "\(identifier)_intersectionAnnotationsLayer" static let puck2DLayer: String = "puck" static let puck3DLayer: String = "puck-model-layer" } @@ -26,6 +27,7 @@ extension NavigationMapView { static let voiceInstructionSource = "\(identifier)_instructionSource" static let waypointSource = "\(identifier)_waypointSource" static let routeDurationAnnotationsSource: String = "\(identifier)_routeDurationAnnotationsSource" + static let intersectionAnnotationsSource = "\(identifier)_intersectionAnnotationsSource" static let puck3DSource: String = "puck-model-source" } diff --git a/Sources/MapboxNavigation/NavigationViewController.swift b/Sources/MapboxNavigation/NavigationViewController.swift index 06f69bdf579..94e3810d259 100644 --- a/Sources/MapboxNavigation/NavigationViewController.swift +++ b/Sources/MapboxNavigation/NavigationViewController.swift @@ -81,6 +81,22 @@ open class NavigationViewController: UIViewController, NavigationStatusPresenter } } + public var showIntersectionAnnotations: Bool { + get { + navigationMapView?.showIntersectionAnnotations ?? false + } + set { + navigationMapView?.showIntersectionAnnotations = newValue + if let routeController = router as? RouteController { + if newValue { + routeController.startUpdatingElectronicHorizon(with: nil) + } else { + routeController.stopUpdatingElectronicHorizon() + } + } + } + } + // MARK: Configuring Spoken Instructions /** @@ -328,6 +344,7 @@ open class NavigationViewController: UIViewController, NavigationStatusPresenter } deinit { + showIntersectionAnnotations = false navigationService?.stop() } @@ -417,6 +434,8 @@ open class NavigationViewController: UIViewController, NavigationStatusPresenter viewObservers.forEach { $0?.navigationViewDidDisappear(animated) } + + showIntersectionAnnotations = false } open override func viewDidLayoutSubviews() { diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationCentered.imageset/AnnotationCentered.png b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationCentered.imageset/AnnotationCentered.png new file mode 100644 index 00000000000..2f35bbc6bce Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationCentered.imageset/AnnotationCentered.png differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationCentered.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationCentered.imageset/Contents.json new file mode 100644 index 00000000000..4f0a47e94eb --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationCentered.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AnnotationCentered.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationLeftHanded.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationLeftHanded.imageset/Contents.json new file mode 100644 index 00000000000..ae530e2b538 --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationLeftHanded.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "RouteInfoAnnotationLeftHanded.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationLeftHanded.imageset/RouteInfoAnnotationLeftHanded.png b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationLeftHanded.imageset/RouteInfoAnnotationLeftHanded.png new file mode 100644 index 00000000000..d493f2cd057 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationLeftHanded.imageset/RouteInfoAnnotationLeftHanded.png differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationRightHanded.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationRightHanded.imageset/Contents.json new file mode 100644 index 00000000000..748a6ddd2a7 --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationRightHanded.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "RouteInfoAnnotationRightHanded.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationRightHanded.imageset/RouteInfoAnnotationRightHanded.png b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationRightHanded.imageset/RouteInfoAnnotationRightHanded.png new file mode 100644 index 00000000000..4b9e2e7f0f8 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/AnnotationRightHanded.imageset/RouteInfoAnnotationRightHanded.png differ diff --git a/Sources/MapboxNavigation/RouteLineController.swift b/Sources/MapboxNavigation/RouteLineController.swift index 9eadbdd7685..e02b47fc608 100644 --- a/Sources/MapboxNavigation/RouteLineController.swift +++ b/Sources/MapboxNavigation/RouteLineController.swift @@ -83,6 +83,11 @@ extension NavigationMapView { let stepIndex = progress.currentLegProgress.stepIndex navigationMapView.updatePreferredFrameRate(for: progress) + do { + try navigationMapView.updateAnnotations(for: progress) + } catch { + print(error) + } if currentLegIndexMapped != legIndex { navigationMapView.showWaypoints(on: route, legIndex: legIndex) navigationMapView.show([route], legIndex: legIndex) diff --git a/Sources/MapboxNavigation/UIColor.swift b/Sources/MapboxNavigation/UIColor.swift index 27995ffbe02..f5d4169357d 100644 --- a/Sources/MapboxNavigation/UIColor.swift +++ b/Sources/MapboxNavigation/UIColor.swift @@ -41,6 +41,10 @@ extension UIColor { class var alternativeTrafficSevere: UIColor { #colorLiteral(red: 0.71, green: 0.51, blue: 0.51, alpha: 1.0) } class var defaultBuildingColor: UIColor { #colorLiteral(red: 0.9833194452, green: 0.9843137255, blue: 0.9331936657, alpha: 0.8019049658) } class var defaultBuildingHighlightColor: UIColor { #colorLiteral(red: 0.337254902, green: 0.6588235294, blue: 0.9843137255, alpha: 0.949406036) } + class var intersectionAnnotationDefaultBackgroundColor: UIColor { get { return #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1) } } + class var intersectionAnnotationSelectedBackgroundColor: UIColor { get { return #colorLiteral(red: 0.337254902, green: 0.6588235294, blue: 0.9843137255, alpha: 1) } } + class var intersectionAnnotationDefaultLabelColor: UIColor { get { return #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1) } } + class var intersectionAnnotationSelectedLabelColor: UIColor { get { return #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1) } } class var defaultRouteRestrictedAreaColor: UIColor { #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1) } diff --git a/Sources/MapboxNavigation/UIImage.swift b/Sources/MapboxNavigation/UIImage.swift index 1d526a36276..5977e4baa3f 100644 --- a/Sources/MapboxNavigation/UIImage.swift +++ b/Sources/MapboxNavigation/UIImage.swift @@ -74,6 +74,8 @@ extension UIImage { return scaledImage } + // Produce a copy of the image with tint color applied. + // Useful for deployment to iOS versions prior to 13 where tinting support was added to UIImage natively. func tint(_ tintColor: UIColor) -> UIImage { let imageSize = size let imageScale = scale