diff --git a/CHANGELOG.md b/CHANGELOG.md index f8fdc464de2..93e7ea2c2e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ * Fixed an issue where tapping on a route duration annotation that overlaps a different route would cause the wrong route to be passed into `NavigationMapViewDelegate.navigationMapView(_:didSelect:)` or `NavigationMapViewDelegate.navigationMapView(_:didSelect:)`. ([#4133](https://github.com/mapbox/mapbox-navigation-ios/pull/4133)) * Fixed an issue where the shields in the instruction are using the style from last navigation session with the `NavigationMapView` injection used in the new session. ([#4197](https://github.com/mapbox/mapbox-navigation-ios/pull/4197)) * Fixed an issue where the `NavigationMapView.localizeLabels()` method only localized map labels according to the user’s Preferred Language Order setting if the application also had a localization in the preferred language. ([#4205](https://github.com/mapbox/mapbox-navigation-ios/pull/4205)) +* Added `NavigationViewController.annotatesIntersectionsAlongRoute` and `CarPlayNavigationViewController.annotatesIntersectionsAlongRoute` to annotate intersections on the current route step during active navigation. ([#4185](https://github.com/mapbox/mapbox-navigation-ios/pull/4185)) ### Banners and guidance instructions diff --git a/MapboxNavigation.xcodeproj/project.pbxproj b/MapboxNavigation.xcodeproj/project.pbxproj index 9f9144f3976..c67282c8f28 100644 --- a/MapboxNavigation.xcodeproj/project.pbxproj +++ b/MapboxNavigation.xcodeproj/project.pbxproj @@ -424,6 +424,7 @@ B4BB0AD72704D1E6006F502D /* multileg_route.json in Resources */ = {isa = PBXBuildFile; fileRef = B4BB0AD62704D1E6006F502D /* multileg_route.json */; }; B4C4FFA028ADB19600C7C253 /* RouteLineLayerPositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C4FF9F28ADB19600C7C253 /* RouteLineLayerPositionTests.swift */; }; B4C8E39E286B72FA004D3EDD /* FeedbackViewControllerSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C8E39D286B72FA004D3EDD /* FeedbackViewControllerSnapshotTests.swift */; }; + B4DA62F128EE0A78004A66B8 /* NavigationMapView+Annotations.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4DA62F028EE0A78004A66B8 /* NavigationMapView+Annotations.swift */; }; B4E19C47268114840011581F /* NavigationLocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E19C46268114840011581F /* NavigationLocationProvider.swift */; }; B4F4FEAD27AB1E11003915A9 /* SpriteRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B61B9627A882E200AA127E /* SpriteRepositoryTests.swift */; }; B4FD842827ED1980002662C4 /* UserPuckStyleKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FD842727ED1980002662C4 /* UserPuckStyleKit.swift */; }; @@ -1060,6 +1061,7 @@ B4C4FF9F28ADB19600C7C253 /* RouteLineLayerPositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteLineLayerPositionTests.swift; sourceTree = ""; }; B4C8E39D286B72FA004D3EDD /* FeedbackViewControllerSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackViewControllerSnapshotTests.swift; sourceTree = ""; }; B4D4291826260D5900EE92A8 /* MBXInfo.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = MBXInfo.plist; sourceTree = ""; }; + B4DA62F028EE0A78004A66B8 /* NavigationMapView+Annotations.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NavigationMapView+Annotations.swift"; sourceTree = ""; }; B4E19C46268114840011581F /* NavigationLocationProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationLocationProvider.swift; sourceTree = ""; }; B4FD842727ED1980002662C4 /* UserPuckStyleKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPuckStyleKit.swift; sourceTree = ""; }; C51511D020EAC89D00372A91 /* CPMapTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPMapTemplate.swift; sourceTree = ""; }; @@ -1550,6 +1552,7 @@ 8A0E0A66257ADD3D00C2E924 /* Navigation */ = { isa = PBXGroup; children = ( + B4DA62F028EE0A78004A66B8 /* NavigationMapView+Annotations.swift */, B4E19C46268114840011581F /* NavigationLocationProvider.swift */, 8A2081C925E07CED00F9B8A6 /* NavigationMapViewIdentifiers.swift */, 8A2081CA25E07CED00F9B8A6 /* RouteLineType.swift */, @@ -2869,6 +2872,7 @@ 8AA0386327F7B3F50007BD2D /* BottomBannerViewControllerDelegate.swift in Sources */, 8D53136B20653FA20044891E /* ExitView.swift in Sources */, 8D3322272200E4CA001D44AA /* NavigationOptions.swift in Sources */, + B4DA62F128EE0A78004A66B8 /* NavigationMapView+Annotations.swift in Sources */, B41299CD26D6DE4D004031A3 /* RouteProgress+Arrival.swift in Sources */, 351BEC011E5BCC63006FE110 /* ManeuverView.swift in Sources */, 35B1E2951F1FF8EC00A13D32 /* UserCourseView.swift in Sources */, diff --git a/Sources/MapboxNavigation/CarPlayNavigationViewController.swift b/Sources/MapboxNavigation/CarPlayNavigationViewController.swift index e00c63fc8fd..d9ce11196e9 100644 --- a/Sources/MapboxNavigation/CarPlayNavigationViewController.swift +++ b/Sources/MapboxNavigation/CarPlayNavigationViewController.swift @@ -80,6 +80,19 @@ open class CarPlayNavigationViewController: UIViewController, BuildingHighlighti } } + /** + A Boolean value that determines whether the map annotates the intersections on current step during active navigation. + + If `true`, the map would display an icon of a traffic control device on the intersection, + such as traffic signal, stop sign, yield sign, or railroad crossing. + Defaults to `true`. + */ + public var annotatesIntersectionsAlongRoute: Bool = true { + didSet { + updateIntersectionsAlongRoute() + } + } + /** `AlternativeRoute`s user might take during this trip to reach the destination using another road. @@ -777,8 +790,13 @@ open class CarPlayNavigationViewController: UIViewController, BuildingHighlighti navigationMapView?.showWaypoints(on: routeProgress.route, legIndex: legIndex) } + if annotatesIntersectionsAlongRoute { + navigationMapView?.updateIntersectionAnnotations(with: routeProgress) + } + navigationMapView?.updateRouteLine(routeProgress: routeProgress, coordinate: location.coordinate, shouldRedraw: legIndex != currentLegIndexMapped) currentLegIndexMapped = legIndex + } private func checkTunnelState(at location: CLLocation, along progress: RouteProgress) { @@ -872,6 +890,10 @@ open class CarPlayNavigationViewController: UIViewController, BuildingHighlighti navigationMapView?.removeArrow() } navigationMapView?.showWaypoints(on: progress.route, legIndex: legIndex) + + if annotatesIntersectionsAlongRoute { + navigationMapView?.updateIntersectionAnnotations(with: progress) + } } func updateManeuvers(_ routeProgress: RouteProgress) { @@ -1013,6 +1035,15 @@ open class CarPlayNavigationViewController: UIViewController, BuildingHighlighti carInterfaceController.dismissTemplate(animated: true) carInterfaceController.presentTemplate(waypointArrival, animated: true) } + + func updateIntersectionsAlongRoute() { + if annotatesIntersectionsAlongRoute { + navigationMapView?.updateIntersectionSymbolImages(styleType: styleManager?.currentStyleType) + navigationMapView?.updateIntersectionAnnotations(with: navigationService.routeProgress) + } else { + navigationMapView?.removeIntersectionAnnotations() + } + } } // MARK: StyleManagerDelegate Methods @@ -1040,6 +1071,7 @@ extension CarPlayNavigationViewController: StyleManagerDelegate { wayNameView?.label.updateStyle(styleURI: styleURI, idiom: .carPlay) updateMapTemplateStyle() updateManeuvers(navigationService.routeProgress) + updateIntersectionsAlongRoute() } public func styleManagerDidRefreshAppearance(_ styleManager: StyleManager) { diff --git a/Sources/MapboxNavigation/NavigationMapView+Annotations.swift b/Sources/MapboxNavigation/NavigationMapView+Annotations.swift new file mode 100644 index 00000000000..9e1eb4fb34c --- /dev/null +++ b/Sources/MapboxNavigation/NavigationMapView+Annotations.swift @@ -0,0 +1,502 @@ +import CoreLocation +import MapboxDirections +import MapboxCoreNavigation +import MapboxMaps +import Turf + +extension NavigationMapView { + + // MARK: Route Duration Annotations + + func showContinuousAlternativeRoutesDurations() { + // Remove any existing route annotation. + removeContinuousAlternativeRoutesDurations() + + guard showsRelativeDurationOnContinuousAlternativeRoutes, + let visibleRoutes = continuousAlternatives, visibleRoutes.count > 0 else { return } + + do { + try updateAnnotationSymbolImages() + } catch { + Log.error("Error occured while updating annotation symbol images: \(error.localizedDescription).", + category: .navigationUI) + } + + updateContinuousAlternativeRoutesDurations(along: visibleRoutes) + } + + /** + Updates the image assets in the map style for the route duration annotations. Useful when the + desired callout colors change, such as when transitioning between light and dark mode on iOS 13 and later. + */ + func updateAnnotationSymbolImages() throws { + let style = mapView.mapboxMap.style + + guard style.image(withId: "RouteInfoAnnotationLeftHanded") == nil, + style.image(withId: "RouteInfoAnnotationRightHanded") == nil else { return } + + // Right-hand pin + if let image = Bundle.mapboxNavigation.image(named: "RouteInfoAnnotationRightHanded") { + // define the "stretchable" areas in the image that will be fitted to the text label + // These numbers are the pixel offsets into the PDF image asset + let stretchX = [ImageStretches(first: Float(33), second: Float(52))] + let stretchY = [ImageStretches(first: Float(32), second: Float(35))] + // define the "content" area of the image which is the portion that the maps sdk will use + // to place the text label within + let imageContent = ImageContent(left: 34, top: 32, right: 56, bottom: 50) + + let regularAnnotationImage = image.tint(routeDurationAnnotationColor) + try style.addImage(regularAnnotationImage, + id: "RouteInfoAnnotationRightHanded", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + + let selectedAnnotationImage = image.tint(routeDurationAnnotationSelectedColor) + try style.addImage(selectedAnnotationImage, + id: "RouteInfoAnnotationRightHanded-Selected", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + } + + // Left-hand pin + if let image = Bundle.mapboxNavigation.image(named: "RouteInfoAnnotationLeftHanded") { + // define the "stretchable" areas in the image that will be fitted to the text label + // These numbers are the pixel offsets into the PDF image asset + let stretchX = [ImageStretches(first: Float(47), second: Float(48))] + let stretchY = [ImageStretches(first: Float(28), second: Float(32))] + // define the "content" area of the image which is the portion that the maps sdk will use + // to place the text label within + let imageContent = ImageContent(left: 47, top: 28, right: 52, bottom: 40) + + let regularAnnotationImage = image.tint(routeDurationAnnotationColor) + try style.addImage(regularAnnotationImage, + id: "RouteInfoAnnotationLeftHanded", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + + let selectedAnnotationImage = image.tint(routeDurationAnnotationSelectedColor) + try style.addImage(selectedAnnotationImage, + id: "RouteInfoAnnotationLeftHanded-Selected", + stretchX: stretchX, + stretchY: stretchY, + content: imageContent) + } + } + + private func updateContinuousAlternativeRoutesDurations(along alternativeRoutes: [AlternativeRoute]?) { + guard let routes = alternativeRoutes else { return } + + let tollRoutes = routes.filter { route -> Bool in + return (route.indexedRouteResponse.currentRoute?.tollIntersections?.count ?? 0) > 0 + } + let routesContainTolls = tollRoutes.count > 0 + + var features = [Turf.Feature]() + + for (index, alternativeRoute) in routes.enumerated() { + guard let routeShape = alternativeRoute.indexedRouteResponse.currentRoute?.shape, + let annotationCoordinate = routeShape.indexedCoordinateFromStart(distance: alternativeRoute.infoFromOrigin.distance + - alternativeRoute.infoFromDeviationPoint.distance + + continuousAlternativeDurationAnnotationOffset)?.coordinate else { + return + } + + // Form the appropriate text string for the annotation. + let labelText = self.annotationLabelForAlternativeRoute(alternativeRoute, + tolls: routesContainTolls) + + let feature = composeCalloutFeature(annotationCoordinate: annotationCoordinate, + labelText: labelText, + index: index, + isSelected: false) + + features.append(feature) + } + + // Add the features to the style. + do { + try addRouteAnnotationSymbolLayer(features: FeatureCollection(features: features), + sourceIdentifier: NavigationMapView.SourceIdentifier.continuousAlternativeRoutesDurationAnnotationsSource, + layerIdentifier: NavigationMapView.LayerIdentifier.continuousAlternativeRoutesDurationAnnotationsLayer) + } catch { + NSLog("Error occured while adding route annotation symbol layer: \(error.localizedDescription).") + } + } + + /** + Remove any old route duration callouts and generate new ones for each passed in route. + */ + func updateRouteDurations(along routes: [Route]?) { + // Remove any existing route annotation. + removeRouteDurations() + + guard let routes = routes else { return } + + let coordinateBounds = mapView.mapboxMap.coordinateBounds(for: mapView.frame) + let visibleBoundingBox = BoundingBox(southWest: coordinateBounds.southwest, northEast: coordinateBounds.northeast) + + let tollRoutes = routes.filter { route -> Bool in + return (route.tollIntersections?.count ?? 0) > 0 + } + let routesContainTolls = tollRoutes.count > 0 + + var features = [Turf.Feature]() + + // Run through our heuristic algorithm looking for a good coordinate along each route line + // to place it's route annotation. + // First, we will look for a set of RouteSteps unique to each route. + var excludedSteps = [RouteStep]() + for (index, route) in routes.enumerated() { + let allSteps = route.legs.flatMap { return $0.steps } + let alternateSteps = allSteps.filter { !excludedSteps.contains($0) } + + excludedSteps.append(contentsOf: alternateSteps) + let visibleAlternateSteps = alternateSteps.filter { $0.intersects(visibleBoundingBox) } + + var coordinate: CLLocationCoordinate2D? + + // Obtain a polyline of the set of steps. We'll look for a good spot along this line to + // place the annotation. + // We will consider a good spot to be somewhere near the middle of the line, making sure + // that the coordinate is visible on-screen. + if let continuousLine = visibleAlternateSteps.continuousShape(), + continuousLine.coordinates.count > 0 { + coordinate = continuousLine.coordinates[0] + + // Pick a coordinate using some randomness in order to give visual variety. + // Take care to snap that coordinate to one that lays on the original route line. + // If the chosen snapped coordinate is not visible on the screen, then we walk back + // along the route coordinates looking for one that is. + // If none of the earlier points are on screen then we walk forward along the route + // coordinates until we find one that is. + if let distance = continuousLine.distance(), + let sampleCoordinate = continuousLine.indexedCoordinateFromStart(distance: distance * CLLocationDistance.random(in: 0.3...0.8))?.coordinate, + let routeShape = route.shape, + let snappedCoordinate = routeShape.closestCoordinate(to: sampleCoordinate) { + var foundOnscreenCoordinate = false + var firstOnscreenCoordinate = snappedCoordinate.coordinate + for indexedCoordinate in routeShape.coordinates.prefix(through: snappedCoordinate.index).reversed() { + if visibleBoundingBox.contains(indexedCoordinate) { + firstOnscreenCoordinate = indexedCoordinate + foundOnscreenCoordinate = true + break + } + } + + if foundOnscreenCoordinate { + // We found a point that is both on the route and on-screen. + coordinate = firstOnscreenCoordinate + } else { + // We didn't find a previous point that is on-screen so we'll move forward + // through the coordinates looking for one. + for indexedCoordinate in routeShape.coordinates.suffix(from: snappedCoordinate.index) { + if visibleBoundingBox.contains(indexedCoordinate) { + firstOnscreenCoordinate = indexedCoordinate + break + } + } + coordinate = firstOnscreenCoordinate + } + } + } + + guard let annotationCoordinate = coordinate else { return } + + // Form the appropriate text string for the annotation. + let labelText = annotationLabelForRoute(route, tolls: routesContainTolls) + + + let feature = composeCalloutFeature(annotationCoordinate: annotationCoordinate, + labelText: labelText, + index: index, + isSelected: index == 0) + features.append(feature) + } + + // Add the features to the style. + do { + try addRouteAnnotationSymbolLayer(features: FeatureCollection(features: features), + sourceIdentifier: NavigationMapView.SourceIdentifier.routeDurationAnnotationsSource, + layerIdentifier: NavigationMapView.LayerIdentifier.routeDurationAnnotationsLayer) + } catch { + Log.error("Error occured while adding route annotation symbol layer: \(error.localizedDescription).", + category: .navigationUI) + } + } + + /** + Add the MGLSymbolStyleLayer for the route duration annotations. + */ + private func addRouteAnnotationSymbolLayer(features: FeatureCollection, + sourceIdentifier: String, + layerIdentifier: String) throws { + let style = mapView.mapboxMap.style + + if style.sourceExists(withId: sourceIdentifier) { + try style.updateGeoJSONSource(withId: sourceIdentifier, geoJSON: .featureCollection(features)) + } else { + var dataSource = GeoJSONSource() + dataSource.data = .featureCollection(features) + try style.addSource(dataSource, id: sourceIdentifier) + } + + var shapeLayer: SymbolLayer + if style.layerExists(withId: layerIdentifier), + let symbolLayer = try style.layer(withId: layerIdentifier) as? SymbolLayer { + shapeLayer = symbolLayer + } else { + shapeLayer = SymbolLayer(id: layerIdentifier) + } + + shapeLayer.source = sourceIdentifier + + shapeLayer.textField = .expression(Exp(.get) { + "text" + }) + + shapeLayer.iconImage = .expression(Exp(.get) { + "imageName" + }) + + shapeLayer.textColor = .expression(Exp(.switchCase) { + Exp(.any) { + Exp(.get) { + "selected" + } + } + routeDurationAnnotationSelectedTextColor + routeDurationAnnotationTextColor + }) + + shapeLayer.textSize = .constant(16) + shapeLayer.iconTextFit = .constant(IconTextFit.both) + shapeLayer.iconAllowOverlap = .constant(true) + shapeLayer.textAllowOverlap = .constant(true) + shapeLayer.textJustify = .constant(TextJustify.left) + shapeLayer.symbolZOrder = .constant(SymbolZOrder.auto) + shapeLayer.textFont = .constant(self.routeDurationAnnotationFontNames) + + shapeLayer.symbolSortKey = .expression(Exp(.get) { + "sortOrder" + }) + + let anchorExpression = Exp(.match) { + Exp(.get) { "tailPosition" } + 0 + "bottom-left" + 1 + "bottom-right" + "center" + } + shapeLayer.iconAnchor = .expression(anchorExpression) + shapeLayer.textAnchor = .expression(anchorExpression) + + let offsetExpression = Exp(.match) { + Exp(.get) { "tailPosition" } + 0 + Exp(.literal) { [0.5, -1.0] } + Exp(.literal) { [-0.5, -1.0] } + } + shapeLayer.iconOffset = .expression(offsetExpression) + shapeLayer.textOffset = .expression(offsetExpression) + + let layerPosition = layerPosition(for: layerIdentifier) + try style.addPersistentLayer(shapeLayer, layerPosition: layerPosition) + } + + private func composeCalloutFeature(annotationCoordinate: LocationCoordinate2D, + labelText: String, + index: Int, + isSelected: Bool) -> Feature { + // Create the feature for this route annotation. Set the styling attributes that will be + // used to render the annotation in the style layer. + var feature = Feature(geometry: .point(Point(annotationCoordinate))) + + // Pick a random tail direction to keep things varied. + guard var tailPosition = [ + RouteDurationAnnotationTailPosition.leading, + RouteDurationAnnotationTailPosition.trailing + ].randomElement() else { return feature } + + // Convert our coordinate to screen space so we can make a choice on which side of the + // coordinate the label ends up on. + let unprojectedCoordinate = mapView.mapboxMap.point(for: annotationCoordinate) + + // Pick the orientation of the bubble "stem" based on how close to the edge of the screen it is. + if tailPosition == .leading && unprojectedCoordinate.x > bounds.width * 0.75 { + tailPosition = .trailing + } else if tailPosition == .trailing && unprojectedCoordinate.x < bounds.width * 0.25 { + tailPosition = .leading + } + + var imageName = tailPosition == .leading ? ImageIdentifier.routeAnnotationLeftHanded : ImageIdentifier.routeAnnotationRightHanded + + // The selected route uses the colored annotation image. + if isSelected { + imageName += "-Selected" + } + + // Set the feature attributes which will be used in styling the symbol style layer. + feature.properties = [ + "selected": .boolean(isSelected), + "tailPosition": .number(Double(tailPosition.rawValue)), + "text": .string(labelText), + "imageName": .string(imageName), + "sortOrder": .number(Double(isSelected ? index : -index)), + "routeIndex": .number(Double(index)) + ] + + return feature + } + + /** + Generate the text for the label to be shown on screen. It will include estimated duration + and info on Tolls, if applicable. + */ + private func annotationLabelForRoute(_ route: Route, tolls: Bool) -> String { + let eta = DateComponentsFormatter.shortDateComponentsFormatter.string(from: route.expectedTravelTime) ?? "" + + return tollAnnotationForLabel(on: route, tolls: tolls, label: eta) + } + + private func tollAnnotationForLabel(on route: Route?, tolls: Bool, label: String) -> String { + var labelWithTolls = label + let hasTolls = (route?.tollIntersections?.count ?? 0) > 0 + if hasTolls { + labelWithTolls += "\n" + NSLocalizedString("ROUTE_HAS_TOLLS", bundle: .mapboxNavigation, value: "Tolls", comment: "This route does have tolls") + if let symbol = Locale.current.currencySymbol { + labelWithTolls += " " + symbol + } + } else if tolls { + // If one of the routes has tolls, but this one does not then it needs to explicitly say that it has no tolls + // If no routes have tolls at all then we can omit this portion of the string. + labelWithTolls += "\n" + NSLocalizedString("ROUTE_HAS_NO_TOLLS", bundle: .mapboxNavigation, value: "No Tolls", comment: "This route does not have tolls") + } + + return labelWithTolls + } + + /** + Generate the text for the label to be shown on screen. It will include estimated duration delta relative to the main route + and info on Tolls, if applicable. + */ + private func annotationLabelForAlternativeRoute(_ alternativeRoute: AlternativeRoute, tolls: Bool) -> String { + let timeDelta = DateComponentsFormatter.travelTimeString(alternativeRoute.expectedTravelTimeDelta, + signed: true, + unitStyle: nil) + + return tollAnnotationForLabel(on: alternativeRoute.indexedRouteResponse.currentRoute, + tolls: tolls, + label: timeDelta) + } + + // MARK: Intersection Signals Annotations + + /** + Removes all the intersection annotations on current route. + */ + func removeIntersectionAnnotations() { + let style = mapView.mapboxMap.style + style.removeLayers([NavigationMapView.LayerIdentifier.intersectionAnnotationsLayer]) + style.removeSources([NavigationMapView.SourceIdentifier.intersectionAnnotationsSource]) + } + + /** + Updates the image assets in the map style for the route intersection signals. + + - parameter styleType: The `StyleType` to choose `Day` or `Night` style of icon images for route intersection signals. + */ + func updateIntersectionSymbolImages(styleType: StyleType?) { + let style = mapView.mapboxMap.style + let styleType = styleType ?? .day + let iconNameToIdentifier: [String: String] = ["trafficSignal": ImageIdentifier.trafficSignal, + "railroadCrossing": ImageIdentifier.railroadCrossing, + "yieldSign": ImageIdentifier.yieldSign, + "stopSign": ImageIdentifier.stopSign] + + do { + for iconType in iconNameToIdentifier.keys { + let iconName = iconType.firstCapitalized + styleType.description.firstCapitalized + if let imageIdentifier = iconNameToIdentifier[iconType], + let iconImage = Bundle.mapboxNavigation.image(named: iconName) { + try style.addImage(iconImage, id: imageIdentifier) + } + } + } catch { + Log.error("Error occured while updating intersection signal images: \(error.localizedDescription).", + category: .navigationUI) + } + } + + func updateIntersectionAnnotations(with routeProgress: RouteProgress) { + guard !routeProgress.routeIsComplete else { + removeIntersectionAnnotations() + return + } + var featureCollection = FeatureCollection(features: []) + + let stepProgress = routeProgress.currentLegProgress.currentStepProgress + let intersectionIndex = stepProgress.intersectionIndex + let stepIntersections = stepProgress.intersectionsIncludingUpcomingManeuverIntersection + + for intersection in stepIntersections?.suffix(from: intersectionIndex) ?? [] { + if let feature = intersectionFeature(from: intersection) { + featureCollection.features.append(feature) + } + } + + let style = mapView.mapboxMap.style + + do { + let sourceIdentifier = NavigationMapView.SourceIdentifier.intersectionAnnotationsSource + if style.sourceExists(withId: sourceIdentifier) { + try style.updateGeoJSONSource(withId: sourceIdentifier, geoJSON: .featureCollection(featureCollection)) + } else { + var source = GeoJSONSource() + source.data = .featureCollection(featureCollection) + try style.addSource(source, id: sourceIdentifier) + } + + let layerIdentifier = NavigationMapView.LayerIdentifier.intersectionAnnotationsLayer + guard !style.layerExists(withId: layerIdentifier) else { return } + + var shapeLayer = SymbolLayer(id: layerIdentifier) + shapeLayer.source = sourceIdentifier + shapeLayer.iconAllowOverlap = .constant(false) + shapeLayer.iconImage = .expression(Exp(.get) { + "imageName" + }) + + let layerPosition = layerPosition(for: layerIdentifier) + try style.addPersistentLayer(shapeLayer, layerPosition: layerPosition) + } catch { + Log.error("Failed to perform operation while adding intersection signals with error: \(error.localizedDescription).", + category: .navigationUI) + } + } + + private func intersectionFeature(from intersection: Intersection) -> Feature? { + var properties: JSONObject? = nil + if intersection.yieldSign == true { + properties = ["imageName": .string(ImageIdentifier.yieldSign)] + } + if intersection.stopSign == true { + properties = ["imageName": .string(ImageIdentifier.stopSign)] + } + if intersection.railroadCrossing == true { + properties = ["imageName": .string(ImageIdentifier.railroadCrossing)] + } + if intersection.trafficSignal == true { + properties = ["imageName": .string(ImageIdentifier.trafficSignal)] + } + + guard let properties = properties else { return nil } + + var feature = Feature(geometry: .point(Point(intersection.location))) + feature.properties = properties + return feature + } +} diff --git a/Sources/MapboxNavigation/NavigationMapView.swift b/Sources/MapboxNavigation/NavigationMapView.swift index a11a668bfdc..ec51a2d7eeb 100755 --- a/Sources/MapboxNavigation/NavigationMapView.swift +++ b/Sources/MapboxNavigation/NavigationMapView.swift @@ -1125,208 +1125,7 @@ open class NavigationMapView: UIView { } } - private let continuousAlternativeDurationAnnotationOffset: LocationDistance = 75 - - func showContinuousAlternativeRoutesDurations() { - // Remove any existing route annotation. - removeContinuousAlternativeRoutesDurations() - - guard showsRelativeDurationOnContinuousAlternativeRoutes, - let visibleRoutes = continuousAlternatives, visibleRoutes.count > 0 else { return } - - do { - try updateAnnotationSymbolImages() - } catch { - NSLog("Error occured while updating annotation symbol images: \(error.localizedDescription).") - } - - updateContinuousAlternativeRoutesDurations(along: visibleRoutes) - } - - private func updateContinuousAlternativeRoutesDurations(along alternativeRoutes: [AlternativeRoute]?) { - guard let routes = alternativeRoutes else { return } - - let tollRoutes = routes.filter { route -> Bool in - return (route.indexedRouteResponse.currentRoute?.tollIntersections?.count ?? 0) > 0 - } - let routesContainTolls = tollRoutes.count > 0 - - var features = [Turf.Feature]() - - for (index, alternativeRoute) in routes.enumerated() { - guard let routeShape = alternativeRoute.indexedRouteResponse.currentRoute?.shape, - let annotationCoordinate = routeShape.indexedCoordinateFromStart(distance: alternativeRoute.infoFromOrigin.distance - - alternativeRoute.infoFromDeviationPoint.distance - + continuousAlternativeDurationAnnotationOffset)?.coordinate else { - return - } - - // Form the appropriate text string for the annotation. - let labelText = self.annotationLabelForAlternativeRoute(alternativeRoute, - tolls: routesContainTolls) - - let feature = composeCalloutFeature(annotationCoordinate: annotationCoordinate, - labelText: labelText, - index: index, - isSelected: false) - - features.append(feature) - } - - // Add the features to the style. - do { - try addRouteAnnotationSymbolLayer(features: FeatureCollection(features: features), - sourceIdentifier: NavigationMapView.SourceIdentifier.continuousAlternativeRoutesDurationAnnotationsSource, - layerIdentifier: NavigationMapView.LayerIdentifier.continuousAlternativeRoutesDurationAnnotationsLayer) - } catch { - NSLog("Error occured while adding route annotation symbol layer: \(error.localizedDescription).") - } - } - /** - Remove any old route duration callouts and generate new ones for each passed in route. - */ - private func updateRouteDurations(along routes: [Route]?) { - // Remove any existing route annotation. - removeRouteDurations() - - guard let routes = routes else { return } - - let coordinateBounds = mapView.mapboxMap.coordinateBounds(for: mapView.frame) - let visibleBoundingBox = BoundingBox(southWest: coordinateBounds.southwest, northEast: coordinateBounds.northeast) - - let tollRoutes = routes.filter { route -> Bool in - return (route.tollIntersections?.count ?? 0) > 0 - } - let routesContainTolls = tollRoutes.count > 0 - - var features = [Turf.Feature]() - - // Run through our heuristic algorithm looking for a good coordinate along each route line - // to place it's route annotation. - // First, we will look for a set of RouteSteps unique to each route. - var excludedSteps = [RouteStep]() - for (index, route) in routes.enumerated() { - let allSteps = route.legs.flatMap { return $0.steps } - let alternateSteps = allSteps.filter { !excludedSteps.contains($0) } - - excludedSteps.append(contentsOf: alternateSteps) - let visibleAlternateSteps = alternateSteps.filter { $0.intersects(visibleBoundingBox) } - - var coordinate: CLLocationCoordinate2D? - - // Obtain a polyline of the set of steps. We'll look for a good spot along this line to - // place the annotation. - // We will consider a good spot to be somewhere near the middle of the line, making sure - // that the coordinate is visible on-screen. - if let continuousLine = visibleAlternateSteps.continuousShape(), - continuousLine.coordinates.count > 0 { - coordinate = continuousLine.coordinates[0] - - // Pick a coordinate using some randomness in order to give visual variety. - // Take care to snap that coordinate to one that lays on the original route line. - // If the chosen snapped coordinate is not visible on the screen, then we walk back - // along the route coordinates looking for one that is. - // If none of the earlier points are on screen then we walk forward along the route - // coordinates until we find one that is. - if let distance = continuousLine.distance(), - let sampleCoordinate = continuousLine.indexedCoordinateFromStart(distance: distance * CLLocationDistance.random(in: 0.3...0.8))?.coordinate, - let routeShape = route.shape, - let snappedCoordinate = routeShape.closestCoordinate(to: sampleCoordinate) { - var foundOnscreenCoordinate = false - var firstOnscreenCoordinate = snappedCoordinate.coordinate - for indexedCoordinate in routeShape.coordinates.prefix(through: snappedCoordinate.index).reversed() { - if visibleBoundingBox.contains(indexedCoordinate) { - firstOnscreenCoordinate = indexedCoordinate - foundOnscreenCoordinate = true - break - } - } - - if foundOnscreenCoordinate { - // We found a point that is both on the route and on-screen. - coordinate = firstOnscreenCoordinate - } else { - // We didn't find a previous point that is on-screen so we'll move forward - // through the coordinates looking for one. - for indexedCoordinate in routeShape.coordinates.suffix(from: snappedCoordinate.index) { - if visibleBoundingBox.contains(indexedCoordinate) { - firstOnscreenCoordinate = indexedCoordinate - break - } - } - coordinate = firstOnscreenCoordinate - } - } - } - - guard let annotationCoordinate = coordinate else { return } - - // Form the appropriate text string for the annotation. - let labelText = self.annotationLabelForRoute(route, tolls: routesContainTolls) - - - let feature = composeCalloutFeature(annotationCoordinate: annotationCoordinate, - labelText: labelText, - index: index, - isSelected: index == 0) - features.append(feature) - } - - // Add the features to the style. - do { - try addRouteAnnotationSymbolLayer(features: FeatureCollection(features: features), - sourceIdentifier: NavigationMapView.SourceIdentifier.routeDurationAnnotationsSource, - layerIdentifier: NavigationMapView.LayerIdentifier.routeDurationAnnotationsLayer) - } catch { - Log.error("Error occured while adding route annotation symbol layer: \(error.localizedDescription).", - category: .navigationUI) - } - } - - private func composeCalloutFeature(annotationCoordinate: LocationCoordinate2D, - labelText: String, - index: Int, - isSelected: Bool) -> Feature { - // Create the feature for this route annotation. Set the styling attributes that will be - // used to render the annotation in the style layer. - var feature = Feature(geometry: .point(Point(annotationCoordinate))) - - // Pick a random tail direction to keep things varied. - guard var tailPosition = [ - RouteDurationAnnotationTailPosition.leading, - RouteDurationAnnotationTailPosition.trailing - ].randomElement() else { return feature } - - // Convert our coordinate to screen space so we can make a choice on which side of the - // coordinate the label ends up on. - let unprojectedCoordinate = mapView.mapboxMap.point(for: annotationCoordinate) - - // Pick the orientation of the bubble "stem" based on how close to the edge of the screen it is. - if tailPosition == .leading && unprojectedCoordinate.x > bounds.width * 0.75 { - tailPosition = .trailing - } else if tailPosition == .trailing && unprojectedCoordinate.x < bounds.width * 0.25 { - tailPosition = .leading - } - - var imageName = tailPosition == .leading ? ImageIdentifier.routeAnnotationLeftHanded : ImageIdentifier.routeAnnotationRightHanded - - // The selected route uses the colored annotation image. - if isSelected { - imageName += "-Selected" - } - - // Set the feature attributes which will be used in styling the symbol style layer. - feature.properties = [ - "selected": .boolean(isSelected), - "tailPosition": .number(Double(tailPosition.rawValue)), - "text": .string(labelText), - "imageName": .string(imageName), - "sortOrder": .number(Double(isSelected ? index : -index)), - "routeIndex": .number(Double(index)) - ] - - return feature - } + let continuousAlternativeDurationAnnotationOffset: LocationDistance = 75 /** Removes all visible route duration callouts. @@ -1505,127 +1304,6 @@ open class NavigationMapView: UIView { return symbolLayer } - /** - Add the MGLSymbolStyleLayer for the route duration annotations. - */ - private func addRouteAnnotationSymbolLayer(features: FeatureCollection, - sourceIdentifier: String, - layerIdentifier: String) throws { - let style = mapView.mapboxMap.style - - if style.sourceExists(withId: sourceIdentifier) { - try style.updateGeoJSONSource(withId: sourceIdentifier, geoJSON: .featureCollection(features)) - } else { - var dataSource = GeoJSONSource() - dataSource.data = .featureCollection(features) - try style.addSource(dataSource, id: sourceIdentifier) - } - - var shapeLayer: SymbolLayer - if style.layerExists(withId: layerIdentifier), - let symbolLayer = try style.layer(withId: layerIdentifier) as? SymbolLayer { - shapeLayer = symbolLayer - } else { - shapeLayer = SymbolLayer(id: layerIdentifier) - } - - shapeLayer.source = sourceIdentifier - - shapeLayer.textField = .expression(Exp(.get) { - "text" - }) - - shapeLayer.iconImage = .expression(Exp(.get) { - "imageName" - }) - - shapeLayer.textColor = .expression(Exp(.switchCase) { - Exp(.any) { - Exp(.get) { - "selected" - } - } - routeDurationAnnotationSelectedTextColor - routeDurationAnnotationTextColor - }) - - shapeLayer.textSize = .constant(16) - shapeLayer.iconTextFit = .constant(IconTextFit.both) - shapeLayer.iconAllowOverlap = .constant(true) - shapeLayer.textAllowOverlap = .constant(true) - shapeLayer.textJustify = .constant(TextJustify.left) - shapeLayer.symbolZOrder = .constant(SymbolZOrder.auto) - shapeLayer.textFont = .constant(self.routeDurationAnnotationFontNames) - - shapeLayer.symbolSortKey = .expression(Exp(.get) { - "sortOrder" - }) - - let anchorExpression = Exp(.match) { - Exp(.get) { "tailPosition" } - 0 - "bottom-left" - 1 - "bottom-right" - "center" - } - shapeLayer.iconAnchor = .expression(anchorExpression) - shapeLayer.textAnchor = .expression(anchorExpression) - - let offsetExpression = Exp(.match) { - Exp(.get) { "tailPosition" } - 0 - Exp(.literal) { [0.5, -1.0] } - Exp(.literal) { [-0.5, -1.0] } - } - shapeLayer.iconOffset = .expression(offsetExpression) - shapeLayer.textOffset = .expression(offsetExpression) - - let layerPosition = layerPosition(for: layerIdentifier) - try style.addPersistentLayer(shapeLayer, layerPosition: layerPosition) - } - - /** - Generate the text for the label to be shown on screen. It will include estimated duration - and info on Tolls, if applicable. - */ - private func annotationLabelForRoute(_ route: Route, tolls: Bool) -> String { - let eta = DateComponentsFormatter.shortDateComponentsFormatter.string(from: route.expectedTravelTime) ?? "" - - return tollAnnotationForLabel(on: route, tolls: tolls, label: eta) - } - - /** - Generate the text for the label to be shown on screen. It will include estimated duration delta relative to the main route - and info on Tolls, if applicable. - */ - private func annotationLabelForAlternativeRoute(_ alternativeRoute: AlternativeRoute, tolls: Bool) -> String { - let timeDelta = DateComponentsFormatter.travelTimeString(alternativeRoute.expectedTravelTimeDelta, - signed: true, - unitStyle: nil) - - return tollAnnotationForLabel(on: alternativeRoute.indexedRouteResponse.currentRoute, - tolls: tolls, - label: timeDelta) - } - - private func tollAnnotationForLabel(on route: Route?, tolls: Bool, label: String) -> String { - var labelWithTolls = label - let hasTolls = (route?.tollIntersections?.count ?? 0) > 0 - if hasTolls { - labelWithTolls += "\n" + NSLocalizedString("ROUTE_HAS_TOLLS", bundle: .mapboxNavigation, value: "Tolls", comment: "This route does have tolls") - if let symbol = Locale.current.currencySymbol { - labelWithTolls += " " + symbol - } - } else if tolls { - // If one of the routes has tolls, but this one does not then it needs to explicitly say that it has no tolls - // If no routes have tolls at all then we can omit this portion of the string. - labelWithTolls += "\n" + NSLocalizedString("ROUTE_HAS_NO_TOLLS", bundle: .mapboxNavigation, value: "No Tolls", comment: "This route does not have tolls") - } - - return labelWithTolls - } - // MARK: Managing Annotations /** @@ -1639,67 +1317,6 @@ open class NavigationMapView: UIView { return pointAnnotationManager?.annotations.filter({ $0.id == identifier }) ?? [] } - /** - Updates the image assets in the map style for the route duration annotations. Useful when the - desired callout colors change, such as when transitioning between light and dark mode on iOS 13 and later. - */ - private func updateAnnotationSymbolImages() throws { - let style = mapView.mapboxMap.style - - guard style.image(withId: "RouteInfoAnnotationLeftHanded") == nil, - style.image(withId: "RouteInfoAnnotationRightHanded") == nil else { return } - - // Right-hand pin - if let image = Bundle.mapboxNavigation.image(named: "RouteInfoAnnotationRightHanded") { - // define the "stretchable" areas in the image that will be fitted to the text label - // These numbers are the pixel offsets into the PDF image asset - let stretchX = [ImageStretches(first: Float(33), second: Float(52))] - let stretchY = [ImageStretches(first: Float(32), second: Float(35))] - // define the "content" area of the image which is the portion that the maps sdk will use - // to place the text label within - let imageContent = ImageContent(left: 34, top: 32, right: 56, bottom: 50) - - let regularAnnotationImage = image.tint(routeDurationAnnotationColor) - try style.addImage(regularAnnotationImage, - id: "RouteInfoAnnotationRightHanded", - stretchX: stretchX, - stretchY: stretchY, - content: imageContent) - - let selectedAnnotationImage = image.tint(routeDurationAnnotationSelectedColor) - try style.addImage(selectedAnnotationImage, - id: "RouteInfoAnnotationRightHanded-Selected", - stretchX: stretchX, - stretchY: stretchY, - content: imageContent) - } - - // Left-hand pin - if let image = Bundle.mapboxNavigation.image(named: "RouteInfoAnnotationLeftHanded") { - // define the "stretchable" areas in the image that will be fitted to the text label - // These numbers are the pixel offsets into the PDF image asset - let stretchX = [ImageStretches(first: Float(47), second: Float(48))] - let stretchY = [ImageStretches(first: Float(28), second: Float(32))] - // define the "content" area of the image which is the portion that the maps sdk will use - // to place the text label within - let imageContent = ImageContent(left: 47, top: 28, right: 52, bottom: 40) - - let regularAnnotationImage = image.tint(routeDurationAnnotationColor) - try style.addImage(regularAnnotationImage, - id: "RouteInfoAnnotationLeftHanded", - stretchX: stretchX, - stretchY: stretchY, - content: imageContent) - - let selectedAnnotationImage = image.tint(routeDurationAnnotationSelectedColor) - try style.addImage(selectedAnnotationImage, - id: "RouteInfoAnnotationLeftHanded-Selected", - stretchX: stretchX, - stretchY: stretchY, - content: imageContent) - } - } - // MARK: Map Rendering and Observing var routes: [Route]? @@ -1841,7 +1458,8 @@ open class NavigationMapView: UIView { LayerIdentifier.arrowStrokeLayer, LayerIdentifier.arrowLayer, LayerIdentifier.arrowSymbolCasingLayer, - LayerIdentifier.arrowSymbolLayer + LayerIdentifier.arrowSymbolLayer, + LayerIdentifier.intersectionAnnotationsLayer ] let uppermostSymbolLayers: [String] = [ LayerIdentifier.waypointCircleLayer, diff --git a/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift b/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift index e79fe67ea6b..9c9682dc1ed 100644 --- a/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift +++ b/Sources/MapboxNavigation/NavigationMapViewIdentifiers.swift @@ -11,6 +11,7 @@ extension NavigationMapView { static let arrowSymbolCasingLayer = "\(identifier)_arrowSymbolCasingLayer" static let voiceInstructionLabelLayer = "\(identifier)_voiceInstructionLabelLayer" static let voiceInstructionCircleLayer = "\(identifier)_voiceInstructionCircleLayer" + static let intersectionAnnotationsLayer = "\(identifier)_intersectionAnnotationsLayer" static let waypointCircleLayer = "\(identifier)_waypointCircleLayer" static let waypointSymbolLayer = "\(identifier)_waypointSymbolLayer" static let buildingExtrusionLayer = "\(identifier)_buildingExtrusionLayer" @@ -25,6 +26,7 @@ extension NavigationMapView { static let arrowStrokeSource = "\(identifier)_arrowStrokeSource" static let arrowSymbolSource = "\(identifier)_arrowSymbolSource" static let voiceInstructionSource = "\(identifier)_instructionSource" + static let intersectionAnnotationsSource = "\(identifier)_intersectionAnnotationsSource" static let waypointSource = "\(identifier)_waypointSource" static let routeDurationAnnotationsSource: String = "\(identifier)_routeDurationAnnotationsSource" static let continuousAlternativeRoutesDurationAnnotationsSource: String = "\(identifier)_continuousAlternativeRoutesDurationAnnotationsSource" @@ -36,6 +38,10 @@ extension NavigationMapView { static let markerImage = "default_marker" static let routeAnnotationLeftHanded = "RouteInfoAnnotationLeftHanded" static let routeAnnotationRightHanded = "RouteInfoAnnotationRightHanded" + static let trafficSignal = "traffic_signal" + static let railroadCrossing = "railroad_crossing" + static let yieldSign = "yield_sign" + static let stopSign = "stop_sign" } struct ModelKeyIdentifier { diff --git a/Sources/MapboxNavigation/NavigationViewController.swift b/Sources/MapboxNavigation/NavigationViewController.swift index 650d06ca71c..0deb78a66ac 100644 --- a/Sources/MapboxNavigation/NavigationViewController.swift +++ b/Sources/MapboxNavigation/NavigationViewController.swift @@ -115,6 +115,23 @@ open class NavigationViewController: UIViewController, NavigationStatusPresenter } } + /** + A Boolean value that determines whether the map annotates the intersections on current step during active navigation. + + If `true`, the map would display an icon of a traffic control device on the intersection, + such as traffic signal, stop sign, yield sign, or railroad crossing. + Defaults to `true`. + */ + public var annotatesIntersectionsAlongRoute: Bool { + get { + routeOverlayController?.annotatesIntersections ?? true + } + set { + routeOverlayController?.annotatesIntersections = newValue + updateIntersectionsAlongRoute() + } + } + /** Toggles displaying alternative routes. @@ -867,6 +884,15 @@ open class NavigationViewController: UIViewController, NavigationStatusPresenter styleManager.applyStyle(type: .day) } } + + func updateIntersectionsAlongRoute() { + if annotatesIntersectionsAlongRoute { + navigationMapView?.updateIntersectionSymbolImages(styleType: styleManager?.currentStyleType) + navigationMapView?.updateIntersectionAnnotations(with: navigationService.routeProgress) + } else { + navigationMapView?.removeIntersectionAnnotations() + } + } } // MARK: - NavigationViewDelegate methods @@ -1209,6 +1235,7 @@ extension NavigationViewController: StyleManagerDelegate { ornamentsController?.updateStyle(styleURI: styleURI) currentStatusBarStyle = style.statusBarStyle ?? .default + updateIntersectionsAlongRoute() setNeedsStatusBarAppearanceUpdate() } diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/Contents.json new file mode 100644 index 00000000000..da4a164c918 --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingDay.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingDay.imageset/Contents.json new file mode 100644 index 00000000000..4fd3bde26ee --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingDay.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "RailroadCrossingDay.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingDay.imageset/RailroadCrossingDay.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingDay.imageset/RailroadCrossingDay.pdf new file mode 100644 index 00000000000..a8e88b87ab6 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingDay.imageset/RailroadCrossingDay.pdf differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingNight.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingNight.imageset/Contents.json new file mode 100644 index 00000000000..465c13a8613 --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingNight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "RailroadCrossingNight.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingNight.imageset/RailroadCrossingNight.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingNight.imageset/RailroadCrossingNight.pdf new file mode 100644 index 00000000000..d28330556f5 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/RailroadCrossingNight.imageset/RailroadCrossingNight.pdf differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignDay.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignDay.imageset/Contents.json new file mode 100644 index 00000000000..f9b9bab2974 --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignDay.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "StopSignDay.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignDay.imageset/StopSignDay.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignDay.imageset/StopSignDay.pdf new file mode 100644 index 00000000000..0d663e23080 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignDay.imageset/StopSignDay.pdf differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignNight.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignNight.imageset/Contents.json new file mode 100644 index 00000000000..073e89fc42a --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignNight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "StopSignNight.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignNight.imageset/StopSignNight.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignNight.imageset/StopSignNight.pdf new file mode 100644 index 00000000000..5cdf6aa6882 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/StopSignNight.imageset/StopSignNight.pdf differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalDay.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalDay.imageset/Contents.json new file mode 100644 index 00000000000..288da1cb6ca --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalDay.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "TrafficSignalDay.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalDay.imageset/TrafficSignalDay.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalDay.imageset/TrafficSignalDay.pdf new file mode 100644 index 00000000000..f185d5df9c5 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalDay.imageset/TrafficSignalDay.pdf differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalNight.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalNight.imageset/Contents.json new file mode 100644 index 00000000000..6898c6ab69f --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalNight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "TrafficSignalNight.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalNight.imageset/TrafficSignalNight.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalNight.imageset/TrafficSignalNight.pdf new file mode 100644 index 00000000000..314f120d2cd Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/TrafficSignalNight.imageset/TrafficSignalNight.pdf differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignDay.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignDay.imageset/Contents.json new file mode 100644 index 00000000000..bb6856290ab --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignDay.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "YieldSignDay.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignDay.imageset/YieldSignDay.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignDay.imageset/YieldSignDay.pdf new file mode 100644 index 00000000000..18911fd72a5 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignDay.imageset/YieldSignDay.pdf differ diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignNight.imageset/Contents.json b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignNight.imageset/Contents.json new file mode 100644 index 00000000000..c6a9e3b84c9 --- /dev/null +++ b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignNight.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "YieldSignNight.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} diff --git a/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignNight.imageset/YieldSignNight.pdf b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignNight.imageset/YieldSignNight.pdf new file mode 100644 index 00000000000..549c126baa1 Binary files /dev/null and b/Sources/MapboxNavigation/Resources/Assets.xcassets/RoadIntersections/YieldSignNight.imageset/YieldSignNight.pdf differ diff --git a/Sources/MapboxNavigation/RouteLineController.swift b/Sources/MapboxNavigation/RouteLineController.swift index 8a08bfd8ba6..2148df46431 100644 --- a/Sources/MapboxNavigation/RouteLineController.swift +++ b/Sources/MapboxNavigation/RouteLineController.swift @@ -54,6 +54,10 @@ extension NavigationMapView { if annotatesSpokenInstructions { navigationMapView.showVoiceInstructionsOnMap(route: router.route) } + + if annotatesIntersections { + navigationMapView.updateIntersectionAnnotations(with: router.routeProgress) + } } func navigationViewDidAppear(_ animated: Bool) { @@ -88,6 +92,10 @@ extension NavigationMapView { if annotatesSpokenInstructions { navigationMapView.showVoiceInstructionsOnMap(route: route) } + + if annotatesIntersections { + navigationMapView.updateIntersectionAnnotations(with: router.routeProgress) + } } func navigationService(_ service: NavigationService, didUpdate progress: RouteProgress, with location: CLLocation, rawLocation: CLLocation) { @@ -109,6 +117,10 @@ extension NavigationMapView { navigationMapView.showVoiceInstructionsOnMap(route: route) } + if annotatesIntersections { + navigationMapView.updateIntersectionAnnotations(with: progress) + } + navigationMapView.updateRouteLine(routeProgress: progress, coordinate: location.coordinate, shouldRedraw: currentLegIndexMapped != legIndex) currentLegIndexMapped = legIndex } @@ -127,6 +139,7 @@ extension NavigationMapView { // MARK: Annotations Overlay var annotatesSpokenInstructions = false + var annotatesIntersections: Bool = true private var currentLegIndexMapped = 0 private var currentStepIndexMapped = 0 diff --git a/Sources/MapboxNavigation/String.swift b/Sources/MapboxNavigation/String.swift index 09d778609bb..d1cd82e3feb 100644 --- a/Sources/MapboxNavigation/String.swift +++ b/Sources/MapboxNavigation/String.swift @@ -27,6 +27,10 @@ extension String { return replacements.reduce(self) { $0.replacingOccurrences(of: $1.of, with: $1.with) } } + var firstCapitalized: String { + return prefix(1).uppercased() + dropFirst() + } + /** Returns the MD5 hash of the string. */ diff --git a/Tests/MapboxNavigationTests/NavigationMapViewTests.swift b/Tests/MapboxNavigationTests/NavigationMapViewTests.swift index 50020e6d010..f7f03c65f6b 100644 --- a/Tests/MapboxNavigationTests/NavigationMapViewTests.swift +++ b/Tests/MapboxNavigationTests/NavigationMapViewTests.swift @@ -13,6 +13,10 @@ class NavigationMapViewTests: TestCase { ])) var navigationMapView: NavigationMapView! + let options: NavigationRouteOptions = .init(coordinates: [ + CLLocationCoordinate2D(latitude: 40.311012, longitude: -112.47926), + CLLocationCoordinate2D(latitude: 29.99908, longitude: -102.828197)]) + lazy var route: Route = { let route = response.routes!.first! return route diff --git a/Tests/MapboxNavigationTests/NavigationViewControllerTests.swift b/Tests/MapboxNavigationTests/NavigationViewControllerTests.swift index 5b5d47da999..7762ae7bdaf 100644 --- a/Tests/MapboxNavigationTests/NavigationViewControllerTests.swift +++ b/Tests/MapboxNavigationTests/NavigationViewControllerTests.swift @@ -525,6 +525,28 @@ class NavigationViewControllerTests: TestCase { dayStyleURI, "Failed to update the style of SpriteRepository singleton with the injected NavigationMapView.") } + + func testAnnotatesIntersectionsAlongRoute() { + let navigationViewController = navigationViewControllerMock() + let imageIdentifier = NavigationMapView.ImageIdentifier.trafficSignal + let layerIdentifier = NavigationMapView.LayerIdentifier.intersectionAnnotationsLayer + let sourceIdentifier = NavigationMapView.SourceIdentifier.intersectionAnnotationsSource + + guard let style = navigationViewController.navigationMapView?.mapView.mapboxMap.style else { + XCTFail("Failed to get the MapView style object.") + return + } + + XCTAssertTrue(navigationViewController.annotatesIntersectionsAlongRoute) + XCTAssertTrue(style.imageExists(withId: imageIdentifier)) + XCTAssertTrue(style.layerExists(withId: layerIdentifier)) + XCTAssertTrue(style.sourceExists(withId: sourceIdentifier)) + + navigationViewController.annotatesIntersectionsAlongRoute = false + XCTAssertTrue(style.imageExists(withId: imageIdentifier)) + XCTAssertFalse(style.layerExists(withId: layerIdentifier)) + XCTAssertFalse(style.sourceExists(withId: sourceIdentifier)) + } } extension NavigationViewControllerTests: NavigationViewControllerDelegate, StyleManagerDelegate { diff --git a/Tests/MapboxNavigationTests/RouteLineLayerPositionTests.swift b/Tests/MapboxNavigationTests/RouteLineLayerPositionTests.swift index 9c4284e77ef..cbab60e7b16 100644 --- a/Tests/MapboxNavigationTests/RouteLineLayerPositionTests.swift +++ b/Tests/MapboxNavigationTests/RouteLineLayerPositionTests.swift @@ -6,15 +6,20 @@ import MapboxMaps @testable import MapboxCoreNavigation class RouteLineLayerPositionTests: TestCase { + let options: NavigationRouteOptions = NavigationRouteOptions(coordinates: [ + CLLocationCoordinate2D(latitude: 40.311012, longitude: -112.47926), + CLLocationCoordinate2D(latitude: 29.99908, longitude: -102.828197)]) lazy var route: Route = { - let response = Fixture.routeResponse(from: "route-with-instructions", options: NavigationRouteOptions(coordinates: [ - CLLocationCoordinate2D(latitude: 40.311012, longitude: -112.47926), - CLLocationCoordinate2D(latitude: 29.99908, longitude: -102.828197), - ])) + let response = Fixture.routeResponse(from: "route-with-instructions", options: options) return response.routes!.first! }() + lazy var routeProgress: RouteProgress = { + let routeProgress = RouteProgress(route: route, options: options, legIndex: 0, spokenInstructionIndex: 0) + return routeProgress + }() + func testRouteLineLayerPosition() { let navigationMapView = NavigationMapView(frame: UIScreen.main.bounds) @@ -239,6 +244,8 @@ class RouteLineLayerPositionTests: TestCase { navigationMapView.removeWaypoints() navigationMapView.showsRestrictedAreasOnRoute = false navigationMapView.routeLineTracksTraversal = true + navigationMapView.updateIntersectionSymbolImages(styleType: .day) + navigationMapView.updateIntersectionAnnotations(with: routeProgress) expectedLayerSequence = [ buildingLayer["id"]!, @@ -251,6 +258,7 @@ class RouteLineLayerPositionTests: TestCase { NavigationMapView.LayerIdentifier.arrowLayer, NavigationMapView.LayerIdentifier.arrowSymbolCasingLayer, NavigationMapView.LayerIdentifier.arrowSymbolLayer, + NavigationMapView.LayerIdentifier.intersectionAnnotationsLayer, roadExitLayer["id"]!, poiLabelLayer["id"]!, poiLabelCircleLayer["id"]! @@ -276,6 +284,7 @@ class RouteLineLayerPositionTests: TestCase { layerPosition: .below(roadLabelLayer["id"]!)) navigationMapView.removeRoutes() navigationMapView.removeArrow() + navigationMapView.removeIntersectionAnnotations() expectedLayerSequence = [ buildingLayer["id"]!, @@ -298,6 +307,7 @@ class RouteLineLayerPositionTests: TestCase { navigationMapView.show([multilegRoute]) navigationMapView.showsRestrictedAreasOnRoute = true navigationMapView.routeLineTracksTraversal = false + navigationMapView.updateIntersectionAnnotations(with: routeProgress) expectedLayerSequence = [ buildingLayer["id"]!, @@ -311,6 +321,7 @@ class RouteLineLayerPositionTests: TestCase { NavigationMapView.LayerIdentifier.arrowLayer, NavigationMapView.LayerIdentifier.arrowSymbolCasingLayer, NavigationMapView.LayerIdentifier.arrowSymbolLayer, + NavigationMapView.LayerIdentifier.intersectionAnnotationsLayer, roadExitLayer["id"]!, poiLabelLayer["id"]!, poiLabelCircleLayer["id"]!, diff --git a/taginfo.json b/taginfo.json index 42df2c49cb3..eba0c152458 100644 --- a/taginfo.json +++ b/taginfo.json @@ -92,6 +92,30 @@ "object_types": ["way"], "description": "πŸ“±πŸ—£πŸ”¨ Shown in top banner and step table and mentioned in voice guidance. In code, the RouteStep.maneuverType property is set to ManeuverType.takeOnRamp or ManeuverType.takeOffRamp. Set the RouteOptions.roadClassesToAvoid property to RoadClasses.motorway to avoid the way." }, + { + "key": "highway", + "value": "traffic_signals", + "object_types": ["node"], + "description": "πŸ“±πŸ”¨ The map shows a traffic light icon at the intersection along the route. In code, use the Intersection.trafficSignal property." + }, + { + "key": "highway", + "value": "stop", + "object_types": ["node"], + "description": "πŸ“±πŸ”¨ The map shows a stop sign icon at the intersection along the route. In code, use the Intersection.stopSign property." + }, + { + "key": "highway", + "value": "give_way", + "object_types": ["node"], + "description": "πŸ“±πŸ”¨ The map shows a yield sign icon at the intersection along the route. In code, use the Intersection.yieldSign property." + }, + { + "key": "railway", + "value": "level_crossing", + "object_types": ["node"], + "description": "πŸ“±πŸ”¨ The map shows a railroad crossing icon at the intersection along the route. In code, use the Intersection.railroadCrossing property." + }, { "key": "hov", "object_types": ["way"],