From 839b341c21e5bbacce572b61f61f8348d86ae20c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 30 May 2026 15:42:30 +0000 Subject: [PATCH 1/2] Show blue contraflow arrow for bicycle:oneway=no on one-way ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a way is one-way for motor traffic but tagged oneway:bicycle=no, draw the existing black chevron for the general direction plus a second blue chevron in the opposite direction, offset perpendicular to the way so both arrows remain readable. https: //wiki.openstreetmap.org/wiki/Key:oneway:bicycle Increase gap between motor and bicycle chevrons to one icon width Longitudinal offset is now 3× chevron length (motor body + gap + bicycle tip placement) so < > pairs no longer overlap. Co-Authored-By: Tobias --- src/Shared/EditorLayer/EditorMapLayer.swift | 88 ++++++++++++++------- src/Shared/OSMModels/OsmWay.swift | 9 +++ 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/Shared/EditorLayer/EditorMapLayer.swift b/src/Shared/EditorLayer/EditorMapLayer.swift index 3259cecc3..1a8544819 100644 --- a/src/Shared/EditorLayer/EditorMapLayer.swift +++ b/src/Shared/EditorLayer/EditorMapLayer.swift @@ -632,6 +632,47 @@ 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 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 +1450,29 @@ 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) + let motorLen = reversed ? -Self.oneWayArrowChevronLength : Self.oneWayArrowChevronLength + let behindAlongWay = motorLen > 0 + ? -Self.bicycleContraflowArrowLongitudinalOffset + : Self.bicycleContraflowArrowLongitudinalOffset + if showBicycleContraflow { + layers.append(self.makeOneWayArrowLayer(at: loc, + direction: dir, + chevronLength: -motorLen, + alongWayOffset: behindAlongWay, + fillColor: .systemBlue, + zPosition: arrowZ)) + } + layers.append(self.makeOneWayArrowLayer(at: loc, + direction: dir, + chevronLength: motorLen, + alongWayOffset: 0, + fillColor: .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 { From 9e7a1df34d8cbecc4641fa0344b13d16c01dfe0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 05:19:11 +0000 Subject: [PATCH 2/2] Address code review: isOneWay semantics, short segments, tests - Derive contraflow offset from ONEWAY.FORWARD/BACKWARD, not chevron sign - Skip blue chevron when the current screen segment cannot fit full offset - Add OsmWay_BicycleContraflowTestCase - Style nits: remove extra blank line, use UIColor.black/systemBlue Co-authored-by: Tobias --- src/Shared/EditorLayer/EditorMapLayer.swift | 49 +++++++++++++------ src/iOS/Go Map!!.xcodeproj/project.pbxproj | 4 ++ .../OsmWay_BicycleContraflowTestCase.swift | 39 +++++++++++++++ 3 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 src/iOS/GoMapTests/OsmWay_BicycleContraflowTestCase.swift diff --git a/src/Shared/EditorLayer/EditorMapLayer.swift b/src/Shared/EditorLayer/EditorMapLayer.swift index 1a8544819..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,12 +632,22 @@ 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, @@ -1453,25 +1463,32 @@ final class EditorMapLayer: CALayer { 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 - let reversed = way.isOneWay == ONEWAY.BACKWARD - let motorLen = reversed ? -Self.oneWayArrowChevronLength : Self.oneWayArrowChevronLength - let behindAlongWay = motorLen > 0 - ? -Self.bicycleContraflowArrowLongitudinalOffset - : Self.bicycleContraflowArrowLongitudinalOffset + invoke(alongScreenClippedWay: way, offset: 50, interval: 100, block: { loc, dir, offsetAlongSegment, segmentLength in + let motorLen = way.isOneWay == .BACKWARD + ? -Self.oneWayArrowChevronLength + : Self.oneWayArrowChevronLength if showBicycleContraflow { - layers.append(self.makeOneWayArrowLayer(at: loc, - direction: dir, - chevronLength: -motorLen, - alongWayOffset: behindAlongWay, - fillColor: .systemBlue, - zPosition: arrowZ)) + 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: .black, + fillColor: UIColor.black, zPosition: arrowZ)) }) } 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()) + } +}