diff --git a/src/Shared/EditorLayer/EditorMapLayer.swift b/src/Shared/EditorLayer/EditorMapLayer.swift index 3259cecc3..97b24b9ab 100644 --- a/src/Shared/EditorLayer/EditorMapLayer.swift +++ b/src/Shared/EditorLayer/EditorMapLayer.swift @@ -608,7 +608,7 @@ final class EditorMapLayer: CALayer { alongScreenClippedWay way: OsmWay, offset initialOffset: Double, interval: Double, - block: @escaping (_ pt: OSMPoint, _ direction: OSMPoint) -> Void) + block: @escaping (_ pt: OSMPoint, _ direction: OSMPoint, _ offsetAlongSegment: Double, _ segmentLength: Double) -> Void) { var offset = initialOffset invoke(alongScreenClippedWay: way, block: { p1, p2, isEntry, _ in @@ -624,7 +624,7 @@ final class EditorMapLayer: CALayer { // found it let pos = OSMPoint(x: p1.x + offset * dx, y: p1.y + offset * dy) let dir = OSMPoint(x: dx, y: dy) - block(pos, dir) + block(pos, dir, offset, len) offset += interval } offset -= len @@ -632,6 +632,57 @@ final class EditorMapLayer: CALayer { }) } + private static let oneWayArrowChevronLength: Double = 15 + private static let oneWayArrowChevronWidth: Double = 5 + /// Along-way distance from motor chevron tip to bicycle chevron tip (motor icon + gap + bicycle icon). + private static let bicycleContraflowArrowLongitudinalOffset: Double = 3 * oneWayArrowChevronLength + + private static func canPlaceBicycleContraflowArrow( + behindAlongWay: Double, + offsetAlongSegment: Double, + segmentLength: Double + ) -> Bool { + if behindAlongWay < 0 { + return offsetAlongSegment >= bicycleContraflowArrowLongitudinalOffset + } + return (segmentLength - offsetAlongSegment) >= bicycleContraflowArrowLongitudinalOffset + } + + private func makeOneWayArrowLayer( + at loc: OSMPoint, + direction dir: OSMPoint, + chevronLength len: Double, + alongWayOffset: Double, + fillColor: UIColor, + zPosition: CGFloat + ) -> CAShapeLayerWithProperties { + let position = OSMPoint(x: loc.x + dir.x * alongWayOffset, + y: loc.y + dir.y * alongWayOffset) + let width = Self.oneWayArrowChevronWidth + + let p1 = OSMPoint(x: position.x - dir.x * len + dir.y * width, + y: position.y - dir.y * len - dir.x * width) + let p2 = OSMPoint(x: position.x - dir.x * len - dir.y * width, + y: position.y - dir.y * len + dir.x * width) + + let arrowPath = CGMutablePath() + arrowPath.move(to: CGPoint(x: p1.x, y: p1.y)) + arrowPath.addLine(to: CGPoint(x: position.x, y: position.y)) + arrowPath.addLine(to: CGPoint(x: p2.x, y: p2.y)) + arrowPath + .addLine(to: CGPoint(x: CGFloat(position.x - dir.x * len * 0.5), + y: CGFloat(position.y - dir.y * len * 0.5))) + arrowPath.closeSubpath() + + let arrow = CAShapeLayerWithProperties() + arrow.path = arrowPath + arrow.fillColor = fillColor.cgColor + arrow.strokeColor = UIColor.white.cgColor + arrow.lineWidth = 0.5 + arrow.zPosition = zPosition + return arrow + } + // clip a way to the path inside the viewable rect so we can draw a name on it func pathClipped(toViewRect way: OsmWay, length pLength: UnsafeMutablePointer?) -> CGPath? { var path: CGMutablePath? @@ -1409,36 +1460,36 @@ final class EditorMapLayer: CALayer { } let isHighlight = highlights.contains(way) if way.isOneWay != .NONE || isHighlight { + let showBicycleContraflow = way.allowsBicycleContraflow() + let arrowZ = isHighlight ? self.Z_HIGHLIGHT_ARROW : self.Z_ARROW // arrow heads - invoke(alongScreenClippedWay: way, offset: 50, interval: 100, block: { loc, dir in - // draw direction arrow at loc/dir - let reversed = way.isOneWay == ONEWAY.BACKWARD - let len: Double = reversed ? -15 : 15 - let width: Double = 5 - - let p1 = OSMPoint(x: loc.x - dir.x * len + dir.y * width, - y: loc.y - dir.y * len - dir.x * width) - let p2 = OSMPoint(x: loc.x - dir.x * len - dir.y * width, - y: loc.y - dir.y * len + dir.x * width) - - let arrowPath = CGMutablePath() - arrowPath.move(to: CGPoint(x: p1.x, y: p1.y)) - arrowPath.addLine(to: CGPoint(x: loc.x, y: loc.y)) - arrowPath.addLine(to: CGPoint(x: p2.x, y: p2.y)) - arrowPath - .addLine(to: CGPoint(x: CGFloat(loc.x - dir.x * len * 0.5), - y: CGFloat(loc.y - dir.y * len * 0.5))) - arrowPath.closeSubpath() - - let arrow = CAShapeLayerWithProperties() - arrow.path = arrowPath - arrow.lineWidth = 1 - arrow.fillColor = UIColor.black.cgColor - arrow.strokeColor = UIColor.white.cgColor - arrow.lineWidth = 0.5 - arrow.zPosition = isHighlight ? self.Z_HIGHLIGHT_ARROW : self.Z_ARROW - - layers.append(arrow) + invoke(alongScreenClippedWay: way, offset: 50, interval: 100, block: { loc, dir, offsetAlongSegment, segmentLength in + let motorLen = way.isOneWay == .BACKWARD + ? -Self.oneWayArrowChevronLength + : Self.oneWayArrowChevronLength + if showBicycleContraflow { + let behindAlongWay = way.isOneWay == .FORWARD + ? -Self.bicycleContraflowArrowLongitudinalOffset + : Self.bicycleContraflowArrowLongitudinalOffset + if Self.canPlaceBicycleContraflowArrow( + behindAlongWay: behindAlongWay, + offsetAlongSegment: offsetAlongSegment, + segmentLength: segmentLength) + { + layers.append(self.makeOneWayArrowLayer(at: loc, + direction: dir, + chevronLength: -motorLen, + alongWayOffset: behindAlongWay, + fillColor: UIColor.systemBlue, + zPosition: arrowZ)) + } + } + layers.append(self.makeOneWayArrowLayer(at: loc, + direction: dir, + chevronLength: motorLen, + alongWayOffset: 0, + fillColor: UIColor.black, + zPosition: arrowZ)) }) } diff --git a/src/Shared/OSMModels/OsmWay.swift b/src/Shared/OSMModels/OsmWay.swift index d79e2962b..22c217d09 100644 --- a/src/Shared/OSMModels/OsmWay.swift +++ b/src/Shared/OSMModels/OsmWay.swift @@ -270,6 +270,15 @@ final class OsmWay: OsmBaseObject, NSSecureCoding { return _isOneWay! } + // https://wiki.openstreetmap.org/wiki/Key:oneway:bicycle + /// True when the way is one-way for general traffic but cyclists may use the opposite direction. + func allowsBicycleContraflow() -> Bool { + guard isOneWay != .NONE else { + return false + } + return tags["oneway:bicycle"] == "no" + } + // return the point on the way closest to the supplied point override func latLonOnObject(forLatLon target: LatLon) -> LatLon { switch nodes.count { diff --git a/src/iOS/Go Map!!.xcodeproj/project.pbxproj b/src/iOS/Go Map!!.xcodeproj/project.pbxproj index 62c74711e..9e549315e 100644 --- a/src/iOS/Go Map!!.xcodeproj/project.pbxproj +++ b/src/iOS/Go Map!!.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ 64348CFE225E867800ADE7FB /* MeasureDirectionViewModelTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64348CFA225E867800ADE7FB /* MeasureDirectionViewModelTestCase.swift */; }; 64348D01225E8D3F00ADE7FB /* OsmNode+Direction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64348D00225E8D3F00ADE7FB /* OsmNode+Direction.swift */; }; 64348D03225E8E4300ADE7FB /* OsmNode_DirectionTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64348D02225E8E4300ADE7FB /* OsmNode_DirectionTestCase.swift */; }; + 64348D15225E8E4300ADE7FB /* OsmWay_BicycleContraflowTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64348D14225E8E4300ADE7FB /* OsmWay_BicycleContraflowTestCase.swift */; }; 64348D13225EAA5D00ADE7FB /* MapViewUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64348D12225EAA5D00ADE7FB /* MapViewUITestCase.swift */; }; 6442666722540EDF00C0D545 /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442666422540EDF00C0D545 /* Lock.swift */; }; 6442666822540EDF00C0D545 /* Disposable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442666522540EDF00C0D545 /* Disposable.swift */; }; @@ -591,6 +592,7 @@ 64348CFA225E867800ADE7FB /* MeasureDirectionViewModelTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MeasureDirectionViewModelTestCase.swift; sourceTree = ""; }; 64348D00225E8D3F00ADE7FB /* OsmNode+Direction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OsmNode+Direction.swift"; sourceTree = ""; }; 64348D02225E8E4300ADE7FB /* OsmNode_DirectionTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OsmNode_DirectionTestCase.swift; sourceTree = ""; }; + 64348D14225E8E4300ADE7FB /* OsmWay_BicycleContraflowTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OsmWay_BicycleContraflowTestCase.swift; sourceTree = ""; }; 64348D08225EA24E00ADE7FB /* GoMapUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GoMapUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 64348D0C225EA24E00ADE7FB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 64348D12225EAA5D00ADE7FB /* MapViewUITestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewUITestCase.swift; sourceTree = ""; }; @@ -1188,6 +1190,7 @@ 64348CFA225E867800ADE7FB /* MeasureDirectionViewModelTestCase.swift */, 64348CF6225E867800ADE7FB /* Mocks */, 64348D02225E8E4300ADE7FB /* OsmNode_DirectionTestCase.swift */, + 64348D14225E8E4300ADE7FB /* OsmWay_BicycleContraflowTestCase.swift */, 64348CEC225E7CD900ADE7FB /* GoMapTests.swift */, 64C072FC22622B9C00598078 /* Vendor */, 64348CEE225E7CD900ADE7FB /* Info.plist */, @@ -1815,6 +1818,7 @@ 64E21EB822651F2D004605D7 /* XCTestCase+UserDefaults.swift in Sources */, 64348CFD225E867800ADE7FB /* CLHeadingMock.swift in Sources */, 64348D03225E8E4300ADE7FB /* OsmNode_DirectionTestCase.swift in Sources */, + 64348D15225E8E4300ADE7FB /* OsmWay_BicycleContraflowTestCase.swift in Sources */, 64E21EB522651C06004605D7 /* OSMMapDataTestCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/src/iOS/GoMapTests/OsmWay_BicycleContraflowTestCase.swift b/src/iOS/GoMapTests/OsmWay_BicycleContraflowTestCase.swift new file mode 100644 index 000000000..fcb9125aa --- /dev/null +++ b/src/iOS/GoMapTests/OsmWay_BicycleContraflowTestCase.swift @@ -0,0 +1,39 @@ +// +// OsmWay_BicycleContraflowTestCase.swift +// GoMapTests +// + +@testable import Go_Map__ +import XCTest + +class OsmWay_BicycleContraflowTestCase: XCTestCase { + func testAllowsBicycleContraflowWhenOneWayAndOnewayBicycleNo() { + let way = OsmWay(asUserCreated: "") + way.constructTag("oneway", value: "yes") + way.constructTag("oneway:bicycle", value: "no") + + XCTAssertTrue(way.allowsBicycleContraflow()) + } + + func testAllowsBicycleContraflowWhenOneWayBackwardAndOnewayBicycleNo() { + let way = OsmWay(asUserCreated: "") + way.constructTag("oneway", value: "-1") + way.constructTag("oneway:bicycle", value: "no") + + XCTAssertTrue(way.allowsBicycleContraflow()) + } + + func testDoesNotAllowBicycleContraflowWithoutOnewayBicycleNo() { + let way = OsmWay(asUserCreated: "") + way.constructTag("oneway", value: "yes") + + XCTAssertFalse(way.allowsBicycleContraflow()) + } + + func testDoesNotAllowBicycleContraflowWhenNotOneWay() { + let way = OsmWay(asUserCreated: "") + way.constructTag("oneway:bicycle", value: "no") + + XCTAssertFalse(way.allowsBicycleContraflow()) + } +}