From 05bf25031daa2f3e06e8d21af23b05fd79427471 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 12 Sep 2026 15:20:27 +0900 Subject: [PATCH 1/4] Make bearing a clockwise map rotation (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bearing を「値を増やすと地図が時計回りに回る」向きに統一し、ネイティブ SDK との変換をコアの CameraBearing に集約する。 --- .../geocell/WebMercatorScreenProjection.swift | 7 +-- .../MapConductorCore/map/CameraBearing.swift | 52 +++++++++++++++++++ .../map/MapCameraPositionBase.swift | 5 ++ ...bMercatorScreenProjectionGoldenTests.swift | 13 +++-- 4 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 Sources/MapConductorCore/map/CameraBearing.swift diff --git a/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift b/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift index de07b24..a18f85d 100644 --- a/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift +++ b/Sources/MapConductorCore/geocell/WebMercatorScreenProjection.swift @@ -45,8 +45,9 @@ public enum WebMercatorScreenProjection { var sx = dx * worldSize var sy = (target.y - center.y) * worldSize if camera.bearing != 0 { - // bearing は「画面の上が指す方位(北から時計回り)」。世界を -bearing 回す。 - let angle = -camera.bearing * .pi / 180.0 + // 画面の上が指す方位はカメラの heading(= bearing の符号反転)。世界を + // -heading 回すと、その方位が画面の上(-y)に来る。 + let angle = -CameraBearing.toNativeHeading(camera.bearing) * .pi / 180.0 let rx = sx * cos(angle) - sy * sin(angle) let ry = sx * sin(angle) + sy * cos(angle) sx = rx @@ -66,7 +67,7 @@ public enum WebMercatorScreenProjection { var sx = offset.x - size.width / 2.0 var sy = offset.y - size.height / 2.0 if camera.bearing != 0 { - let angle = camera.bearing * .pi / 180.0 + let angle = CameraBearing.toNativeHeading(camera.bearing) * .pi / 180.0 let rx = sx * cos(angle) - sy * sin(angle) let ry = sx * sin(angle) + sy * cos(angle) sx = rx diff --git a/Sources/MapConductorCore/map/CameraBearing.swift b/Sources/MapConductorCore/map/CameraBearing.swift new file mode 100644 index 0000000..6f81318 --- /dev/null +++ b/Sources/MapConductorCore/map/CameraBearing.swift @@ -0,0 +1,52 @@ +import Foundation + +/// `MapCameraPosition.bearing` とネイティブ SDK の回転値の変換。 +/// +/// MapConductor の `bearing` は **「値を増やすと地図が時計回り(右)に回る」** 向きで +/// 定義する。0 が北で、90 なら地図全体が右へ 90 度回り、画面の上には西が来る。 +/// +/// ネイティブ SDK は大きく 2 系統に分かれる。 +/// +/// - **heading 系**(Google Maps / MapLibre / Mapbox / HERE / MapKit ほか) +/// 「画面の上が指す方位を北から時計回りに測る」=カメラの向き。値を増やすと地図は +/// 反時計回りに回るので、MapConductor とは符号が反転する。``toNativeHeading(_:)``。 +/// - **rotation 系**(ArcGIS の `Viewpoint.rotation` など)「北を時計回りに回す量」= +/// 地図の回転角。MapConductor と同じ向きなので変換は恒等になる。``toNativeRotation(_:)``。 +/// +/// 符号をプロバイダ各所に散らすと必ずどこかが漏れるので、変換はここだけに置く。 +/// +/// ドライバー実装点なので `@_spi(MapConductorDriver)`(android の +/// `@InternalMapConductorApi`、React の `/** @internal */` に対応)。 +@_spi(MapConductorDriver) +public enum CameraBearing { + + /// 角度を 0 以上 360 未満へ畳む。 + public static func normalizeDegrees360(_ degrees: Double) -> Double { + guard degrees.isFinite else { return 0 } + let wrapped = degrees.truncatingRemainder(dividingBy: 360.0) + return wrapped < 0 ? wrapped + 360.0 : wrapped + } + + /// MapConductor の bearing → heading 系ネイティブ SDK の値。 + /// + /// カメラが向いている方位でもあるので、負 tilt エミュレーションで + /// 「カメラの前進方向」が要るときもこれを使う。 + public static func toNativeHeading(_ bearing: Double) -> Double { + normalizeDegrees360(-bearing) + } + + /// heading 系ネイティブ SDK の値 → MapConductor の bearing。 + public static func bearingFromNativeHeading(_ heading: Double) -> Double { + normalizeDegrees360(-heading) + } + + /// MapConductor の bearing → rotation 系ネイティブ SDK の値(向きが同じなので恒等)。 + public static func toNativeRotation(_ bearing: Double) -> Double { + normalizeDegrees360(bearing) + } + + /// rotation 系ネイティブ SDK の値 → MapConductor の bearing(同上)。 + public static func bearingFromNativeRotation(_ rotation: Double) -> Double { + normalizeDegrees360(rotation) + } +} diff --git a/Sources/MapConductorCore/map/MapCameraPositionBase.swift b/Sources/MapConductorCore/map/MapCameraPositionBase.swift index d3f3efc..f3808ce 100644 --- a/Sources/MapConductorCore/map/MapCameraPositionBase.swift +++ b/Sources/MapConductorCore/map/MapCameraPositionBase.swift @@ -41,6 +41,11 @@ public struct VisibleRegion: Equatable, Hashable { public protocol MapCameraPositionProtocol { var position: GeoPointProtocol { get } var zoom: Double { get } + /// 地図の回転角(度、0 が北)。 + /// + /// 値を増やすと地図は**時計回り(右)**に回る。90 なら地図が右へ 90 度回り、 + /// 画面の上には西が来る。多くのネイティブ SDK の「カメラの向き(heading)」とは + /// 符号が逆なので、変換は ``CameraBearing`` を通すこと。 var bearing: Double { get } var tilt: Double { get } var paddings: MapPaddingsProtocol? { get } diff --git a/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift b/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift index 17e419c..a44d0b7 100644 --- a/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift +++ b/Tests/MapConductorCoreTests/WebMercatorScreenProjectionGoldenTests.swift @@ -79,13 +79,20 @@ final class WebMercatorScreenProjectionGoldenTests: XCTestCase { XCTAssertEqual(ratio, 2, accuracy: 1e-9) } - func testBearing90PutsEastAtTheTop() throws { + /// bearing は「地図を時計回りに回す量」。90 なら地図が右へ 90 度回り、 + /// 画面の上には**西**が来る(東は画面の下)。 + func testBearing90PutsWestAtTheTop() throws { let c = camera(bearing: 90) + let west = try XCTUnwrap( + project(GeoPoint(latitude: c.position.latitude, longitude: c.position.longitude - 0.05), c) + ) + XCTAssertLessThan(west.y, 400) + XCTAssertEqual(west.x, 200, accuracy: 1e-6) + let east = try XCTUnwrap( project(GeoPoint(latitude: c.position.latitude, longitude: c.position.longitude + 0.05), c) ) - XCTAssertLessThan(east.y, 400) - XCTAssertEqual(east.x, 200, accuracy: 1e-6) + XCTAssertGreaterThan(east.y, 400) } func testDatelineWrapsTheShortWay() throws { From dc800b9198c8280006d696516ac57d79d08d6a33 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 26 Sep 2026 21:30:52 -0700 Subject: [PATCH 2/4] fix: zoom 0 is a zoom, not a missing one (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moveCameraTo(cameraPosition:) ran every camera through resolveCameraPosition, which read `zoom == 0 && bearing == 0 && tilt == 0` as "the caller only gave me a position" and replaced the requested camera with the stored one, keeping the position alone. A camera that really does ask for zoom 0 — the whole world — matches that test exactly, so it was the one camera the map would not move to. It shows up as a feature that does nothing rather than as a wrong value: the map stays where it was, so what that invites you to conclude is that zoom 0 is unsupported. Position-only already has a way to be said: the `moveCameraTo(position:)` overload. Inferring the same intent a second time from the *value* costs a legal zoom, and cannot tell `MapCameraPosition(position:)` from one written with `zoom: 0` anyway — zoom defaults to 0, so by the time a camera arrives the two are one value. android-sdk never had this; MapViewState.kt hands the camera straight to the controller. js-sdk-core carried the same code and loses it in the same change (MapConductor/js-sdk-core#4), so all three platforms now agree. --- Sources/MapConductorCore/map/MapViewState.swift | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/Sources/MapConductorCore/map/MapViewState.swift b/Sources/MapConductorCore/map/MapViewState.swift index 302fe4e..ac62264 100644 --- a/Sources/MapConductorCore/map/MapViewState.swift +++ b/Sources/MapConductorCore/map/MapViewState.swift @@ -159,7 +159,9 @@ open class MapViewState: ObservableObject, MapViewStateProt } open func moveCameraTo(cameraPosition: MapCameraPosition, durationMillis: Long? = 0) { - let resolved = resolveCameraPosition(cameraPosition) + // カメラを渡されたらそのまま使う。「位置だけ動かして縮尺は保つ」は + // `moveCameraTo(position:)` のオーバーロードが担う。 + let resolved = cameraPosition guard let controller = attachedMapController else { // まだ地図が無い。接続時に attachController がこの位置へ移動する。 storedCameraPosition = resolved @@ -191,17 +193,6 @@ open class MapViewState: ObservableObject, MapViewStateProt attachedMapController?.holder } - /// ズーム・ベアリング・チルトがすべて 0 の「未指定」カメラは、位置だけを差し替える。 - /// - /// アプリが `MapCameraPosition(position:)` だけを渡してきたときに、 - /// いまの縮尺を保ったまま移動するための救済。全プロバイダが同じ判定をしていた。 - private func resolveCameraPosition(_ target: MapCameraPosition) -> MapCameraPosition { - let isUnspecified = target.zoom == 0.0 && target.bearing == 0.0 && target.tilt == 0.0 - if isUnspecified { - return storedCameraPosition.copy(position: target.position) - } - return target - } } public protocol MapOverlayProtocol: AnyObject { From da81af28890d96da70e4a729b9b54c129468c177 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 22 Oct 2026 19:22:38 -0700 Subject: [PATCH 3/4] Add labelPosition to the default marker icon and pick the marker drawn under the tap (#12) --- .../marker/ColorDefaultIcon.swift | 261 +++++++++++++++--- .../marker/ImageDefaultIcon.swift | 61 ++-- .../marker/MarkerHitTest.swift | 147 +++++++++- .../marker/MarkerTileRenderer.swift | 56 ++-- .../marker/StrategyMarkerController.swift | 26 +- .../DefaultIconLabelPositionTests.swift | 92 ++++++ .../MarkerHitTestTests.swift | 37 ++- api/MapConductorCore.api.swift | 12 +- 8 files changed, 590 insertions(+), 102 deletions(-) create mode 100644 Tests/MapConductorCoreTests/DefaultIconLabelPositionTests.swift diff --git a/Sources/MapConductorCore/marker/ColorDefaultIcon.swift b/Sources/MapConductorCore/marker/ColorDefaultIcon.swift index fa7de2e..8b12432 100644 --- a/Sources/MapConductorCore/marker/ColorDefaultIcon.swift +++ b/Sources/MapConductorCore/marker/ColorDefaultIcon.swift @@ -13,8 +13,9 @@ public final class ColorDefaultIcon: MarkerIconProtocol { public static let defaultLabelStrokeColor: UIColor = .white public static let defaultLabelTypeFace: UIFont = .systemFont(ofSize: 18.0) public static let defaultInfoAnchor = CGPoint(x: 0.5, y: 0.0) + /// ラベルの既定位置。ピンの頭に重ねる(従来どおり)。書き方は `labelPosition` を参照。 + public static let defaultLabelPosition = "center center" - private static let defaultAnchor = CGPoint(x: 0.5, y: 1.0) public let scale: CGFloat public let anchor: CGPoint @@ -30,6 +31,14 @@ public final class ColorDefaultIcon: MarkerIconProtocol { private let labelTextSize: CGFloat private let labelTypeFace: UIFont private let labelStrokeColor: UIColor + /// ラベルをピンに対してどこへ置くか。CSS の `background-position` と同じ書き方で、 + /// 横(left / center / right)と縦(top / center / bottom)のキーワードを空白区切りで + /// 並べる。順序は問わず、片方だけなら残りは center。 + /// - left / right はピンの外側に置き、縦はピンの上端・頭の中心・先端に揃える。 + /// - center 列の top / bottom はピンの上・下の外側に置く。 + public let labelPosition: String + /// 指定された infoAnchor(ピン本体に対する割合)。`infoAnchor` はビットマップに対する割合へ直した値。 + private let requestedInfoAnchor: CGPoint private let bitmapIcon: BitmapIcon public init( @@ -42,6 +51,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTextSize: CGFloat = defaultLabelTextSize, labelTypeFace: UIFont = defaultLabelTypeFace, labelStrokeColor: UIColor = defaultLabelStrokeColor, + labelPosition: String = defaultLabelPosition, infoAnchor: CGPoint = defaultInfoAnchor, iconSize: CGFloat = defaultIconSize, debug: Bool = false @@ -55,11 +65,11 @@ public final class ColorDefaultIcon: MarkerIconProtocol { self.labelTextSize = labelTextSize self.labelTypeFace = labelTypeFace self.labelStrokeColor = labelStrokeColor - self.infoAnchor = infoAnchor + self.labelPosition = labelPosition + self.requestedInfoAnchor = infoAnchor self.iconSize = iconSize self.debug = debug - self.anchor = ColorDefaultIcon.defaultAnchor - self.bitmapIcon = ColorDefaultIcon.makeIcon( + let bitmapIcon = ColorDefaultIcon.makeIcon( iconSize: iconSize, scale: scale, fillColor: fillColor, @@ -70,10 +80,14 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTextSize: labelTextSize, labelTypeFace: labelTypeFace, labelStrokeColor: labelStrokeColor, - anchor: ColorDefaultIcon.defaultAnchor, + labelPosition: labelPosition, infoAnchor: infoAnchor, debug: debug ) + // ラベルを横や上に置くとビットマップが片側へ伸びるので、どちらもレイアウトから求めた値を使う。 + self.anchor = bitmapIcon.anchor + self.infoAnchor = bitmapIcon.infoAnchor + self.bitmapIcon = bitmapIcon } public func toBitmapIcon() -> BitmapIcon { @@ -90,8 +104,9 @@ public final class ColorDefaultIcon: MarkerIconProtocol { result = result &* 31 &+ labelTextSize.hashValue result = result &* 31 &+ labelTypeFace.hashValue result = result &* 31 &+ labelStrokeColor.hashValue - result = result &* 31 &+ infoAnchor.x.hashValue - result = result &* 31 &+ infoAnchor.y.hashValue + result = result &* 31 &+ labelPosition.hashValue + result = result &* 31 &+ requestedInfoAnchor.x.hashValue + result = result &* 31 &+ requestedInfoAnchor.y.hashValue result = result &* 31 &+ iconSize.hashValue result = result &* 31 &+ debug.hashValue return result @@ -107,6 +122,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTextSize: CGFloat? = nil, labelTypeFace: UIFont? = nil, labelStrokeColor: UIColor? = nil, + labelPosition: String? = nil, iconSize: CGFloat? = nil, debug: Bool? = nil ) -> ColorDefaultIcon { @@ -120,7 +136,8 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTextSize: labelTextSize ?? self.labelTextSize, labelTypeFace: labelTypeFace ?? self.labelTypeFace, labelStrokeColor: labelStrokeColor ?? self.labelStrokeColor, - infoAnchor: self.infoAnchor, + labelPosition: labelPosition ?? self.labelPosition, + infoAnchor: self.requestedInfoAnchor, iconSize: iconSize ?? self.iconSize, debug: debug ?? self.debug ) @@ -137,7 +154,7 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelTextSize: CGFloat, labelTypeFace: UIFont, labelStrokeColor: UIColor, - anchor: CGPoint, + labelPosition: String, infoAnchor: CGPoint, debug: Bool ) -> BitmapIcon { @@ -150,10 +167,16 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelSize = measureLabel(label, font: resolvedFont, strokeWidth: outlineStrokeWidth) } - let padding = canvasSize * 0.1 - let bitmapWidth = max(canvasSize, labelSize.width + padding) - let bitmapHeight = canvasSize - let markerOffsetX = (bitmapWidth - canvasSize) / 2.0 + let layout = DefaultIconLayout.make( + canvasSize: canvasSize, + iconScale: scale, + strokeWidth: strokeWidth, + labelSize: labelSize, + outlineStrokeWidth: outlineStrokeWidth, + labelPosition: labelPosition + ) + let bitmapWidth = layout.bitmapWidth + let bitmapHeight = layout.bitmapHeight let format = UIGraphicsImageRendererFormat.default() format.scale = UIScreen.main.scale @@ -169,7 +192,8 @@ public final class ColorDefaultIcon: MarkerIconProtocol { canvasSize: canvasSize, iconScale: scale, strokeWidth: strokeWidth, - horizontalOffset: markerOffsetX + horizontalOffset: layout.markerOffsetX, + verticalOffset: layout.markerOffsetY ) context.cgContext.setFillColor(fillColor.cgColor) @@ -190,8 +214,8 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelFont: resolvedFont, labelTextColor: labelTextColor ?? defaultLabelTextColor, labelStrokeColor: labelStrokeColor, - canvasSize: canvasSize, - offsetX: markerOffsetX, + center: layout.labelCenter, + bitmapHeight: bitmapHeight, outlineStrokeWidth: outlineStrokeWidth, pixelScale: format.scale ) @@ -199,11 +223,11 @@ public final class ColorDefaultIcon: MarkerIconProtocol { } return BitmapIcon( bitmap: image, - anchor: anchor, + anchor: layout.anchor, size: CGSize(width: bitmapWidth, height: bitmapHeight), scale: scale, iconSize: iconSize, - infoAnchor: infoAnchor + infoAnchor: layout.infoAnchor(for: infoAnchor) ) } @@ -225,13 +249,14 @@ public final class ColorDefaultIcon: MarkerIconProtocol { labelFont: UIFont, labelTextColor: UIColor, labelStrokeColor: UIColor, - canvasSize: CGFloat, - offsetX: CGFloat, + center: CGPoint, + bitmapHeight: CGFloat, outlineStrokeWidth: CGFloat, pixelScale: CGFloat ) { - let markerCenterX = canvasSize / 2.0 + offsetX - let markerCenterY = canvasSize * 0.35 + // DefaultIconLayout が決めたラベルの中心。既定ではマーカーの円形部分の中心。 + let markerCenterX = center.x + let markerCenterY = center.y let baseAttributes: [NSAttributedString.Key: Any] = [.font: labelFont] let baseLine = CTLineCreateWithAttributedString(NSAttributedString(string: labelText, attributes: baseAttributes)) @@ -255,10 +280,10 @@ public final class ColorDefaultIcon: MarkerIconProtocol { context.setAllowsFontSubpixelPositioning(false) context.setShouldSubpixelPositionFonts(false) - context.translateBy(x: 0, y: canvasSize) + context.translateBy(x: 0, y: bitmapHeight) context.scaleBy(x: 1.0, y: -1.0) - let flippedBaselineY = canvasSize - drawBaselineY + let flippedBaselineY = bitmapHeight - drawBaselineY context.textPosition = CGPoint(x: drawX, y: flippedBaselineY) let strokeAttributes: [NSAttributedString.Key: Any] = [ @@ -295,25 +320,13 @@ public final class ColorDefaultIcon: MarkerIconProtocol { canvasSize: CGFloat, iconScale: CGFloat, strokeWidth: CGFloat, - horizontalOffset: CGFloat = 0.0 + horizontalOffset: CGFloat = 0.0, + verticalOffset: CGFloat = 0.0 ) -> UIBezierPath { - let originalSize = CGSize(width: 23.5, height: 25.6) - - let scaledStrokeWidth = strokeWidth * iconScale - let epsilon: CGFloat = 0.75 - let padding = max((scaledStrokeWidth / 2.0) - epsilon, 0.0) - let availableWidth = canvasSize - (padding * 2.0) - let availableHeight = canvasSize - padding - - let markerScale = min( - availableWidth / originalSize.width, - availableHeight / originalSize.height - ) - - let scaledWidth = originalSize.width * markerScale - let scaledHeight = originalSize.height * markerScale - let offsetX = (canvasSize - scaledWidth) / 2.0 + horizontalOffset - let offsetY = (canvasSize - scaledHeight + (strokeWidth * markerScale)) / 2.0 + let metrics = markerPathMetrics(canvasSize: canvasSize, iconScale: iconScale, strokeWidth: strokeWidth) + let markerScale = metrics.markerScale + let offsetX = metrics.offsetX + horizontalOffset + let offsetY = metrics.offsetY + verticalOffset let path = UIBezierPath() var current = CGPoint( @@ -398,6 +411,170 @@ public final class ColorDefaultIcon: MarkerIconProtocol { path.close() return path } + + /// ピン形を canvasSize の正方形へ収めたときの倍率と原点(パス外接箱の左上)。 + /// `createMarkerPath` と、ラベルをピンの輪郭に沿わせる `DefaultIconLayout` が共有する。 + static func markerPathMetrics( + canvasSize: CGFloat, + iconScale: CGFloat, + strokeWidth: CGFloat + ) -> (markerScale: CGFloat, offsetX: CGFloat, offsetY: CGFloat) { + let originalSize = CGSize(width: 23.5, height: 25.6) + + let scaledStrokeWidth = strokeWidth * iconScale + let epsilon: CGFloat = 0.75 + let padding = max((scaledStrokeWidth / 2.0) - epsilon, 0.0) + let availableWidth = canvasSize - (padding * 2.0) + let availableHeight = canvasSize - padding + + let markerScale = min( + availableWidth / originalSize.width, + availableHeight / originalSize.height + ) + + let scaledWidth = originalSize.width * markerScale + let scaledHeight = originalSize.height * markerScale + let offsetX = (canvasSize - scaledWidth) / 2.0 + let offsetY = (canvasSize - scaledHeight + (strokeWidth * markerScale)) / 2.0 + return (markerScale, offsetX, offsetY) + } +} + +/// 既定アイコンのビットマップ内でのマーカー本体とラベルの配置。 +/// +/// android-sdk / react-sdk の `computeLayout` と同じ計算。`labelPosition` が中央なら従来どおり +/// ラベルを円形部分の中心に重ね、ビットマップを左右対称に広げる。中央以外なら +/// ラベルをピンの輪郭の外側へ置き、ビットマップをその側へだけ伸ばす。 +struct DefaultIconLayout { + let canvasSize: CGFloat + let bitmapWidth: CGFloat + let bitmapHeight: CGFloat + let markerOffsetX: CGFloat + let markerOffsetY: CGFloat + let labelCenter: CGPoint + + /// ピン先端の位置(ビットマップに対する割合)。 + var anchor: CGPoint { + CGPoint( + x: (markerOffsetX + canvasSize / 2.0) / bitmapWidth, + y: (markerOffsetY + canvasSize) / bitmapHeight + ) + } + + /// ピン本体に対する割合で指定された infoAnchor を、ビットマップに対する割合へ直す。 + func infoAnchor(for requested: CGPoint) -> CGPoint { + CGPoint( + x: (markerOffsetX + requested.x * canvasSize) / bitmapWidth, + y: (markerOffsetY + requested.y * canvasSize) / bitmapHeight + ) + } + + /// `labelPosition` を横・縦それぞれ -1 / 0 / 1 に読み替える。CSS の background-position と + /// 同じく、キーワードの順序は問わず、片方だけなら残りは center。読めない値は中央扱い。 + static func parseLabelPosition(_ position: String) -> (h: Int, v: Int) { + var h = 0 + var v = 0 + for word in position.lowercased().split(whereSeparator: { $0.isWhitespace }) { + switch word { + case "left": h = -1 + case "right": h = 1 + case "top": v = -1 + case "bottom": v = 1 + default: break + } + } + return (h, v) + } + + /// `labelSize` はアウトライン込みのラベル寸法(ラベルが無ければ .zero)。 + static func make( + canvasSize: CGFloat, + iconScale: CGFloat, + strokeWidth: CGFloat, + labelSize: CGSize, + outlineStrokeWidth: CGFloat, + labelPosition: String + ) -> DefaultIconLayout { + // 円形部分の中心 + let headCenterY = canvasSize * 0.35 + let padding = canvasSize * 0.1 + + guard labelSize != .zero else { + return DefaultIconLayout( + canvasSize: canvasSize, + bitmapWidth: canvasSize, + bitmapHeight: canvasSize, + markerOffsetX: 0, + markerOffsetY: 0, + labelCenter: CGPoint(x: canvasSize / 2.0, y: headCenterY) + ) + } + + let (h, v) = parseLabelPosition(labelPosition) + // ピンの見えている輪郭(設計座標で x: 4〜20, y: 0〜24)。外接箱より細いので、 + // ラベルを横に置くときはこちらに沿わせる。 + let metrics = DefaultMarkerIcon.markerPathMetrics( + canvasSize: canvasSize, + iconScale: iconScale, + strokeWidth: strokeWidth + ) + let s = metrics.markerScale + let pinLeft = metrics.offsetX + 4.0 * s + let pinRight = metrics.offsetX + 20.0 * s + let pinTop = metrics.offsetY + let pinBottom = metrics.offsetY + 24.0 * s + let gap = max(outlineStrokeWidth, canvasSize * 0.05) + let labelWidth = labelSize.width + let labelHeight = labelSize.height + + // マーカー本体の左上を原点とした座標でラベルの中心を決める。 + let cx: CGFloat + if h < 0 { + cx = pinLeft - gap - labelWidth / 2.0 + } else if h > 0 { + cx = pinRight + gap + labelWidth / 2.0 + } else { + cx = canvasSize / 2.0 + } + + var cy = headCenterY + if h != 0 { + // 横に置くときは、縦はピンの上端・頭の中心・先端に揃える。 + if v < 0 { cy = pinTop + labelHeight / 2.0 } + if v > 0 { cy = pinBottom - labelHeight / 2.0 } + } else { + // 中央列の top / bottom はピンと重ならないよう上下の外側に置く。 + if v < 0 { cy = pinTop - gap - labelHeight / 2.0 } + if v > 0 { cy = pinBottom + gap + labelHeight / 2.0 } + } + + let bitmapWidth: CGFloat + let markerOffsetX: CGFloat + if h == 0 { + // 中央なら従来どおり(出力を変えない)。 + bitmapWidth = max(canvasSize, labelWidth + padding) + markerOffsetX = (bitmapWidth - canvasSize) / 2.0 + } else { + let minX = min(0, cx - labelWidth / 2.0 - padding / 2.0) + let maxX = max(canvasSize, cx + labelWidth / 2.0 + padding / 2.0) + bitmapWidth = (maxX - minX).rounded(.up) + markerOffsetX = -minX + } + + let minY = min(0, cy - labelHeight / 2.0) + let maxY = max(canvasSize, cy + labelHeight / 2.0) + let markerOffsetY = (-minY).rounded(.up) + let bitmapHeight = markerOffsetY + maxY.rounded(.up) + + return DefaultIconLayout( + canvasSize: canvasSize, + bitmapWidth: bitmapWidth, + bitmapHeight: bitmapHeight, + markerOffsetX: markerOffsetX, + markerOffsetY: markerOffsetY, + labelCenter: CGPoint(x: markerOffsetX + cx, y: markerOffsetY + cy) + ) + } } /// 歴史的経緯で使われてきた `ColorDefaultIcon` の別名。 diff --git a/Sources/MapConductorCore/marker/ImageDefaultIcon.swift b/Sources/MapConductorCore/marker/ImageDefaultIcon.swift index 9043322..5f51dc4 100644 --- a/Sources/MapConductorCore/marker/ImageDefaultIcon.swift +++ b/Sources/MapConductorCore/marker/ImageDefaultIcon.swift @@ -14,6 +14,7 @@ public final class ImageDefaultIcon: MarkerIconProtocol { public static let defaultLabelStrokeColor: UIColor = DefaultMarkerIcon.defaultLabelStrokeColor public static let defaultLabelTypeFace: UIFont = DefaultMarkerIcon.defaultLabelTypeFace public static let defaultInfoAnchor: CGPoint = DefaultMarkerIcon.defaultInfoAnchor + public static let defaultLabelPosition: String = DefaultMarkerIcon.defaultLabelPosition public static let defaultAnchor = CGPoint(x: 0.5, y: 1.0) public let scale: CGFloat @@ -30,6 +31,10 @@ public final class ImageDefaultIcon: MarkerIconProtocol { public let labelTextSize: CGFloat public let labelTypeFace: UIFont public let labelStrokeColor: UIColor + /// ラベルをピンに対してどこへ置くか。書き方は `ColorDefaultIcon.labelPosition` と同じ。 + public let labelPosition: String + /// 指定された infoAnchor(ピン本体に対する割合)。`infoAnchor` はビットマップに対する割合へ直した値。 + private let requestedInfoAnchor: CGPoint private let bitmapIcon: BitmapIcon public init( @@ -42,6 +47,7 @@ public final class ImageDefaultIcon: MarkerIconProtocol { labelTextSize: CGFloat = defaultLabelTextSize, labelTypeFace: UIFont = defaultLabelTypeFace, labelStrokeColor: UIColor = defaultLabelStrokeColor, + labelPosition: String = defaultLabelPosition, infoAnchor: CGPoint = defaultInfoAnchor, iconSize: CGFloat = defaultIconSize, debug: Bool = false @@ -55,11 +61,11 @@ public final class ImageDefaultIcon: MarkerIconProtocol { self.labelTextSize = labelTextSize self.labelTypeFace = labelTypeFace self.labelStrokeColor = labelStrokeColor - self.infoAnchor = infoAnchor + self.labelPosition = labelPosition + self.requestedInfoAnchor = infoAnchor self.iconSize = iconSize self.debug = debug - self.anchor = Self.defaultAnchor - self.bitmapIcon = Self.makeIcon( + let bitmapIcon = Self.makeIcon( backgroundImage: backgroundImage, iconSize: iconSize, scale: scale, @@ -70,10 +76,14 @@ public final class ImageDefaultIcon: MarkerIconProtocol { labelTextSize: labelTextSize, labelTypeFace: labelTypeFace, labelStrokeColor: labelStrokeColor, - anchor: Self.defaultAnchor, + labelPosition: labelPosition, infoAnchor: infoAnchor, debug: debug ) + // ラベルを横や上に置くとビットマップが片側へ伸びるので、どちらもレイアウトから求めた値を使う。 + self.anchor = bitmapIcon.anchor + self.infoAnchor = bitmapIcon.infoAnchor + self.bitmapIcon = bitmapIcon } public func toBitmapIcon() -> BitmapIcon { bitmapIcon } @@ -88,8 +98,9 @@ public final class ImageDefaultIcon: MarkerIconProtocol { result = result &* 31 &+ labelTextSize.hashValue result = result &* 31 &+ labelTypeFace.hashValue result = result &* 31 &+ labelStrokeColor.hashValue - result = result &* 31 &+ infoAnchor.x.hashValue - result = result &* 31 &+ infoAnchor.y.hashValue + result = result &* 31 &+ labelPosition.hashValue + result = result &* 31 &+ requestedInfoAnchor.x.hashValue + result = result &* 31 &+ requestedInfoAnchor.y.hashValue result = result &* 31 &+ iconSize.hashValue result = result &* 31 &+ debug.hashValue return result @@ -105,6 +116,7 @@ public final class ImageDefaultIcon: MarkerIconProtocol { labelTextSize: CGFloat? = nil, labelTypeFace: UIFont? = nil, labelStrokeColor: UIColor? = nil, + labelPosition: String? = nil, iconSize: CGFloat? = nil, debug: Bool? = nil ) -> ImageDefaultIcon { @@ -118,7 +130,8 @@ public final class ImageDefaultIcon: MarkerIconProtocol { labelTextSize: labelTextSize ?? self.labelTextSize, labelTypeFace: labelTypeFace ?? self.labelTypeFace, labelStrokeColor: labelStrokeColor ?? self.labelStrokeColor, - infoAnchor: self.infoAnchor, + labelPosition: labelPosition ?? self.labelPosition, + infoAnchor: self.requestedInfoAnchor, iconSize: iconSize ?? self.iconSize, debug: debug ?? self.debug ) @@ -135,7 +148,7 @@ public final class ImageDefaultIcon: MarkerIconProtocol { labelTextSize: CGFloat, labelTypeFace: UIFont, labelStrokeColor: UIColor, - anchor: CGPoint, + labelPosition: String, infoAnchor: CGPoint, debug: Bool ) -> BitmapIcon { @@ -148,11 +161,22 @@ public final class ImageDefaultIcon: MarkerIconProtocol { labelSize = DefaultMarkerIcon.measureLabel(label, font: resolvedFont, strokeWidth: outlineStrokeWidth) } - let padding = canvasSize * 0.1 - let bitmapWidth = max(canvasSize, labelSize.width + padding) - let bitmapHeight = canvasSize - let markerOffsetX = (bitmapWidth - canvasSize) / 2.0 - let markerRect = CGRect(x: markerOffsetX, y: 0, width: canvasSize, height: canvasSize) + let layout = DefaultIconLayout.make( + canvasSize: canvasSize, + iconScale: scale, + strokeWidth: strokeWidth, + labelSize: labelSize, + outlineStrokeWidth: outlineStrokeWidth, + labelPosition: labelPosition + ) + let bitmapWidth = layout.bitmapWidth + let bitmapHeight = layout.bitmapHeight + let markerRect = CGRect( + x: layout.markerOffsetX, + y: layout.markerOffsetY, + width: canvasSize, + height: canvasSize + ) let format = UIGraphicsImageRendererFormat.default() format.scale = UIScreen.main.scale @@ -171,7 +195,8 @@ public final class ImageDefaultIcon: MarkerIconProtocol { canvasSize: canvasSize, iconScale: scale, strokeWidth: strokeWidth, - horizontalOffset: markerOffsetX + horizontalOffset: layout.markerOffsetX, + verticalOffset: layout.markerOffsetY ) context.cgContext.saveGState() @@ -215,8 +240,8 @@ public final class ImageDefaultIcon: MarkerIconProtocol { labelFont: resolvedFont, labelTextColor: labelTextColor ?? defaultLabelTextColor, labelStrokeColor: labelStrokeColor, - canvasSize: canvasSize, - offsetX: markerOffsetX, + center: layout.labelCenter, + bitmapHeight: bitmapHeight, outlineStrokeWidth: outlineStrokeWidth, pixelScale: format.scale ) @@ -225,11 +250,11 @@ public final class ImageDefaultIcon: MarkerIconProtocol { return BitmapIcon( bitmap: image, - anchor: anchor, + anchor: layout.anchor, size: CGSize(width: bitmapWidth, height: bitmapHeight), scale: scale, iconSize: iconSize, - infoAnchor: infoAnchor, + infoAnchor: layout.infoAnchor(for: infoAnchor), debug: debug ) } diff --git a/Sources/MapConductorCore/marker/MarkerHitTest.swift b/Sources/MapConductorCore/marker/MarkerHitTest.swift index eba7ca1..463a36f 100644 --- a/Sources/MapConductorCore/marker/MarkerHitTest.swift +++ b/Sources/MapConductorCore/marker/MarkerHitTest.swift @@ -29,8 +29,9 @@ public enum MarkerHitTest { defaultIcon: any MarkerIconProtocol = DefaultMarkerIcon() ) -> Bool { let icon = state.icon ?? defaultIcon - let iconWidth = icon.iconSize * icon.scale - let iconHeight = icon.iconSize * icon.scale + let extent = extentRatio(of: icon) + let iconWidth = icon.iconSize * icon.scale * extent.width + let iconHeight = icon.iconSize * icon.scale * extent.height let anchorX = icon.anchor.x let anchorY = icon.anchor.y let tolerance = Settings.Default.tapTolerance @@ -42,4 +43,146 @@ public enum MarkerHitTest { let bottom = (1 - anchorY) * iconHeight + tolerance return dx >= left && dx <= right && dy >= top && dy <= bottom } + + /// アイコンの矩形がピン本体(iconSize × scale の正方形)の何倍か。`anchor` はこの矩形に対する割合。 + /// 既定アイコンはラベルの分だけビットマップが広いことがあるので実寸から求め、それ以外は正方形とする。 + /// android-sdk の `MarkerIconExtent`、react-sdk の `markerIconExtentPx` に対応する。 + static func extentRatio(of icon: any MarkerIconProtocol) -> CGSize { + guard icon is ColorDefaultIcon || icon is ImageDefaultIcon else { return CGSize(width: 1, height: 1) } + let base = icon.iconSize * icon.scale + guard base > 0 else { return CGSize(width: 1, height: 1) } + let size = icon.toBitmapIcon().size + return CGSize(width: size.width / base, height: size.height / base) + } + + /// アンカー点を投影した画面座標と、そのアイコンの矩形(pt)。`pickIndex` の入力。 + @_spi(MapConductorDriver) + public struct ScreenRect { + public let anchorScreen: CGPoint + public let width: CGFloat + public let height: CGFloat + public let anchor: CGPoint + + public init(anchorScreen: CGPoint, width: CGFloat, height: CGFloat, anchor: CGPoint) { + self.anchorScreen = anchorScreen + self.width = width + self.height = height + self.anchor = anchor + } + + func contains(dx: CGFloat, dy: CGFloat, tolerance: CGFloat) -> Bool { + let left = -anchor.x * width - tolerance + let right = (1 - anchor.x) * width + tolerance + let top = -anchor.y * height - tolerance + let bottom = (1 - anchor.y) * height + tolerance + return dx >= left && dx <= right && dy >= top && dy <= bottom + } + } + + /// タップ点に当たるマーカーを選ぶ規則(android-sdk の `MarkerHitTest.pickIndex`、 + /// react-sdk の `pickMarkerIndex` と同じ)。 + /// + /// 1. アイコン(ラベル込み)の矩形そのものにタップ点が入っているものがあれば、その中で + /// 画面上いちばん下にアンカーがあるもの(手前に描かれるもの)を選ぶ。近くに別のマーカーの + /// 先端があっても、指の下に描かれているマーカーが勝つ。 + /// 2. どの矩形にも入っていなければ、矩形 + `tolerance` に入るもののうち、アンカーが + /// タップ点にいちばん近いものを選ぶ。 + @_spi(MapConductorDriver) + public static func pickIndex( + touch: CGPoint, + rects: [ScreenRect], + tolerance: CGFloat = Settings.Default.tapTolerance + ) -> Int? { + var exact: Int? + var exactY = -CGFloat.greatestFiniteMagnitude + var near: Int? + var nearDistance = CGFloat.greatestFiniteMagnitude + for (index, rect) in rects.enumerated() { + let dx = touch.x - rect.anchorScreen.x + let dy = touch.y - rect.anchorScreen.y + if rect.contains(dx: dx, dy: dy, tolerance: 0) { + if rect.anchorScreen.y > exactY { + exact = index + exactY = rect.anchorScreen.y + } + } else if exact == nil, rect.contains(dx: dx, dy: dy, tolerance: tolerance) { + let distance = hypot(dx, dy) + if distance < nearDistance { + near = index + nearDistance = distance + } + } + } + return exact ?? near + } + + /// 既定の大きさ(iconSize × scale × ラベルの広がり)で `ScreenRect` を作る。 + @_spi(MapConductorDriver) + public static func screenRect( + anchorScreen: CGPoint, + state: MarkerState, + defaultIcon: any MarkerIconProtocol = DefaultMarkerIcon() + ) -> ScreenRect { + let icon = state.icon ?? defaultIcon + let extent = extentRatio(of: icon) + let base = icon.iconSize * icon.scale + return ScreenRect( + anchorScreen: anchorScreen, + width: base * extent.width, + height: base * extent.height, + anchor: icon.anchor + ) + } + + /// `candidates` の中からタップ点に当たるマーカーを `pickIndex` の規則で選ぶ。 + @_spi(MapConductorDriver) + public static func pick( + touchScreen: CGPoint, + candidates: [T], + state: (T) -> MarkerState, + project: (GeoPointProtocol) -> CGPoint?, + defaultIcon: any MarkerIconProtocol = DefaultMarkerIcon() + ) -> T? { + var projected: [T] = [] + var rects: [ScreenRect] = [] + projected.reserveCapacity(candidates.count) + rects.reserveCapacity(candidates.count) + for candidate in candidates { + let markerState = state(candidate) + guard let anchorScreen = project(markerState.position) else { continue } + projected.append(candidate) + rects.append(screenRect(anchorScreen: anchorScreen, state: markerState, defaultIcon: defaultIcon)) + } + return pickIndex(touch: touchScreen, rects: rects).map { projected[$0] } + } + + /// 候補を集める範囲。タップ点から画面上 `searchRadius` 以内に先端があるマーカーを含む。 + /// + /// ラベルを横に置くとアイコンはピンより大きく広がるので、最寄りの 1 つだけでは足りない。 + /// pt → 度の換算は、タップ点の近くを投影して局所的に求める(回転していても各軸の長さは保たれる)。 + @_spi(MapConductorDriver) + public static func searchBounds( + position: GeoPointProtocol, + touchScreen: CGPoint, + project: (GeoPointProtocol) -> CGPoint?, + searchRadius: CGFloat = searchRadius + ) -> GeoRectBounds? { + let epsilon = 1e-4 + guard let north = project(GeoPoint(latitude: position.latitude + epsilon, longitude: position.longitude)), + let east = project(GeoPoint(latitude: position.latitude, longitude: position.longitude + epsilon)) + else { return nil } + let ptPerDegLat = Double(hypot(north.x - touchScreen.x, north.y - touchScreen.y)) / epsilon + let ptPerDegLon = Double(hypot(east.x - touchScreen.x, east.y - touchScreen.y)) / epsilon + guard ptPerDegLat > 0, ptPerDegLon > 0 else { return nil } + let halfLat = Double(searchRadius) / ptPerDegLat + let halfLon = Double(searchRadius) / ptPerDegLon + return GeoRectBounds( + southWest: GeoPoint(latitude: max(position.latitude - halfLat, -90), longitude: position.longitude - halfLon), + northEast: GeoPoint(latitude: min(position.latitude + halfLat, 90), longitude: position.longitude + halfLon) + ) + } + + /// 候補を探す半径(pt)。ラベルの長さの上限の目安で、これより遠くまで伸びたラベルの端は拾えない。 + @_spi(MapConductorDriver) + public static let searchRadius: CGFloat = 256 } diff --git a/Sources/MapConductorCore/marker/MarkerTileRenderer.swift b/Sources/MapConductorCore/marker/MarkerTileRenderer.swift index 4f42887..77382b3 100644 --- a/Sources/MapConductorCore/marker/MarkerTileRenderer.swift +++ b/Sources/MapConductorCore/marker/MarkerTileRenderer.swift @@ -56,7 +56,12 @@ public final class MarkerTileRenderer: TileProvider { cacheLock.unlock() } - /// Returns the closest marker whose rendered icon bounds contain the screen point. + /// Returns the tiled marker under the screen point. + /// + /// 選び方はコアの `MarkerHitTest.pickIndex` と同じ。タップ点の周りのタイル化マーカーを候補にし、 + /// 描かれた(ラベル込みの)アイコンの矩形にタップ点が入っているものを、先端の近い別の + /// マーカーより優先する。以前は最寄りの 1 つだけを見ていたので、隣のピンの頭や横に置いた + /// ラベルのタップが外れていた。 public func hitTest( screenPoint: CGPoint, markerIds: Set, @@ -66,29 +71,34 @@ public final class MarkerTileRenderer: TileProvider { unproject: (CGPoint) -> GeoPoint?, project: (GeoPoint) -> CGPoint? ) -> MarkerState? { - // Android resolves one spatially indexed nearest marker before applying the - // rendered-icon bounds check. Do the same here: iterating every tiled marker - // made each tap O(n), including a projection and icon conversion per marker. + let projectAny: (GeoPointProtocol) -> CGPoint? = { project(GeoPoint.from(position: $0)) } guard let position = unproject(screenPoint), - let entity = markerManager.findNearest(position: position), - markerIds.contains(entity.state.id), - entity.state.clickable, - let markerPoint = project(entity.state.position) else { return nil } - - let icon = entity.state.icon?.toBitmapIcon() ?? defaultIcon - let callbackScale = max(iconScaleCallback?(entity.state, zoom) ?? 1, 0) - let scale = CGFloat(callbackScale * extraIconScale) / max(renderScaleToScreenScale, 1) - let width = max(icon.size.width * scale, 1) - let height = max(icon.size.height * scale, 1) - let dx = screenPoint.x - markerPoint.x - let dy = screenPoint.y - markerPoint.y - let left = -icon.anchor.x * width - tolerance - let right = (1 - icon.anchor.x) * width + tolerance - let top = -icon.anchor.y * height - tolerance - let bottom = (1 - icon.anchor.y) * height + tolerance - - guard dx >= left, dx <= right, dy >= top, dy <= bottom else { return nil } - return entity.state + let bounds = MarkerHitTest.searchBounds( + position: position, + touchScreen: screenPoint, + project: projectAny + ) else { return nil } + + var states: [MarkerState] = [] + var rects: [MarkerHitTest.ScreenRect] = [] + for entity in markerManager.findMarkersInBounds(bounds) { + guard markerIds.contains(entity.state.id), + entity.state.clickable, + let markerPoint = project(entity.state.position) else { continue } + let icon = entity.state.icon?.toBitmapIcon() ?? defaultIcon + let callbackScale = max(iconScaleCallback?(entity.state, zoom) ?? 1, 0) + let scale = CGFloat(callbackScale * extraIconScale) / max(renderScaleToScreenScale, 1) + states.append(entity.state) + rects.append( + MarkerHitTest.ScreenRect( + anchorScreen: markerPoint, + width: max(icon.size.width * scale, 1), + height: max(icon.size.height * scale, 1), + anchor: icon.anchor + ) + ) + } + return MarkerHitTest.pickIndex(touch: screenPoint, rects: rects, tolerance: tolerance).map { states[$0] } } public func renderTile(request: TileRequest) -> Data? { diff --git a/Sources/MapConductorCore/marker/StrategyMarkerController.swift b/Sources/MapConductorCore/marker/StrategyMarkerController.swift index d3e66e1..d1653e4 100644 --- a/Sources/MapConductorCore/marker/StrategyMarkerController.swift +++ b/Sources/MapConductorCore/marker/StrategyMarkerController.swift @@ -143,20 +143,22 @@ where Strategy.ActualMarker == ActualMarker, Renderer.ActualMarker == ActualMark } public func find(position: GeoPointProtocol) -> MarkerEntity? { - guard let nearest = markerManager.findNearest(position: position) else { return nil } - // android-sdk の StrategyMarkerController.find() と同じスクリーン空間の - // 「アイコン境界 + tapTolerance」矩形判定。投影 closure 未設定のときは従来どおり - // 最近傍をそのまま返す(geo→screen 投影ができないプロバイダ向けフォールバック)。 - guard let projector = markerProjector, - let touchScreen = projector(position), - let markerScreen = projector(nearest.state.position) else { - return nearest + // android-sdk の StrategyMarkerController.find() と同じ。タップ点の周りのマーカーを候補にし、 + // 指の下にアイコン(ラベル込み)が描かれているものを優先する(MarkerHitTest.pick)。 + // 投影 closure 未設定のときは従来どおり最近傍をそのまま返す + // (geo→screen 投影ができないプロバイダ向けフォールバック)。 + guard let projector = markerProjector else { + return markerManager.findNearest(position: position) } - return MarkerHitTest.hitsIcon( + guard let touchScreen = projector(position), + let bounds = MarkerHitTest.searchBounds(position: position, touchScreen: touchScreen, project: projector) + else { return nil } + return MarkerHitTest.pick( touchScreen: touchScreen, - markerScreen: markerScreen, - state: nearest.state - ) ? nearest : nil + candidates: markerManager.findMarkersInBounds(bounds), + state: { $0.state }, + project: projector + ) } /// カメラ変更を受けて再クラスタリングする。 diff --git a/Tests/MapConductorCoreTests/DefaultIconLabelPositionTests.swift b/Tests/MapConductorCoreTests/DefaultIconLabelPositionTests.swift new file mode 100644 index 0000000..0cc193a --- /dev/null +++ b/Tests/MapConductorCoreTests/DefaultIconLabelPositionTests.swift @@ -0,0 +1,92 @@ +import UIKit +import XCTest + +@testable import MapConductorCore + +/// 既定アイコンの `labelPosition` がビットマップの寸法とアンカーをどう変えるか。 +/// +/// react-sdk の `DefaultIconLabelPosition.test.mjs`、android-sdk の +/// `DefaultIconLabelPositionTest` と同じ規則を見る。ラベルをピンの横や上に置くと +/// ビットマップはその側へだけ伸び、アンカー(ピン先端)が (0.5, 1) からずれる。 +final class DefaultIconLabelPositionTests: XCTestCase { + private let label = "信州大学" + + private func tip(_ icon: ColorDefaultIcon) -> (x: CGFloat, y: CGFloat, size: CGSize) { + let bitmap = icon.toBitmapIcon() + return (bitmap.anchor.x * bitmap.size.width, bitmap.anchor.y * bitmap.size.height, bitmap.size) + } + + func testParseLabelPosition() { + XCTAssertTrue(DefaultIconLayout.parseLabelPosition(ColorDefaultIcon.defaultLabelPosition) == (0, 0)) + XCTAssertTrue(DefaultIconLayout.parseLabelPosition("right center") == (1, 0)) + XCTAssertTrue(DefaultIconLayout.parseLabelPosition("top right") == (1, -1)) + XCTAssertTrue(DefaultIconLayout.parseLabelPosition("bottom") == (0, 1)) + XCTAssertTrue(DefaultIconLayout.parseLabelPosition(" LEFT middle ") == (-1, 0)) + } + + func testDefaultKeepsCenteredLayout() { + let icon = ColorDefaultIcon(label: label) + XCTAssertEqual(icon.anchor, CGPoint(x: 0.5, y: 1.0)) + XCTAssertEqual(icon.toBitmapIcon().size.height, icon.iconSize) + XCTAssertEqual(icon.infoAnchor, ColorDefaultIcon.defaultInfoAnchor) + } + + func testRightCenterGrowsOnlyToTheRight() { + let icon = ColorDefaultIcon(label: label, labelPosition: "right center") + let t = tip(icon) + XCTAssertEqual(t.size.height, icon.iconSize) + XCTAssertEqual(t.x, icon.iconSize / 2.0, accuracy: 0.001) + XCTAssertEqual(t.y, t.size.height, accuracy: 0.001) + XCTAssertLessThan(t.x, t.size.width / 2.0) + XCTAssertEqual(icon.anchor, icon.toBitmapIcon().anchor) + } + + func testLeftIsNearlyMirrorOfRight() { + // ピン形は外接箱の中で 0.25 単位だけ右に寄っているので、1pt までの差は許す。 + let right = tip(ColorDefaultIcon(label: label, labelPosition: "right")) + let left = tip(ColorDefaultIcon(label: label, labelPosition: "left")) + XCTAssertEqual(left.size.width, right.size.width, accuracy: 1.0) + XCTAssertEqual(left.x, left.size.width - right.x, accuracy: 1.0) + } + + func testCenterTopGrowsUpwardsAndBubbleStaysOnPin() { + let icon = ColorDefaultIcon(label: label, labelPosition: "center top") + let t = tip(icon) + XCTAssertGreaterThan(t.size.height, icon.iconSize) + XCTAssertEqual(t.y, t.size.height, accuracy: 0.001) + XCTAssertEqual(icon.infoAnchor.y * t.size.height, t.size.height - icon.iconSize, accuracy: 0.001) + } + + func testCopyKeepsPositionAndRequestedInfoAnchor() { + let icon = ColorDefaultIcon(label: label, labelPosition: "right center") + let copied = icon.copy(fillColor: .green) + XCTAssertEqual(copied.labelPosition, "right center") + XCTAssertEqual(copied.infoAnchor, icon.infoAnchor) + XCTAssertNotEqual(icon.hashCode(), ColorDefaultIcon(label: label).hashCode()) + } + + func testImageDefaultIconUsesSameLayout() { + let image = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8)).image { ctx in + UIColor.blue.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) + } + let color = ColorDefaultIcon(label: label, labelPosition: "left bottom").toBitmapIcon() + let imaged = ImageDefaultIcon(backgroundImage: image, label: label, labelPosition: "left bottom").toBitmapIcon() + XCTAssertEqual(imaged.size, color.size) + XCTAssertEqual(imaged.anchor, color.anchor) + } + + func testHitTestCoversLabelBesideThePin() { + // ラベルを右に置いたとき、ラベルの上をタップしても当たる(正方形で判定すると外れる)。 + let icon = ColorDefaultIcon(label: label, labelPosition: "right center") + let state = MarkerState(position: GeoPoint(latitude: 0, longitude: 0), icon: icon) + let bitmap = icon.toBitmapIcon() + let labelX = bitmap.size.width * (1 - bitmap.anchor.x) - 4 + let markerScreen = CGPoint(x: 100, y: 100) + let onLabel = CGPoint(x: markerScreen.x + labelX, y: markerScreen.y - icon.iconSize * 0.65) + XCTAssertTrue(MarkerHitTest.hitsIcon(touchScreen: onLabel, markerScreen: markerScreen, state: state)) + // 左側(ラベルの無い側)はピンの外へ大きく広がらない。 + let farLeft = CGPoint(x: markerScreen.x - icon.iconSize - Settings.Default.tapTolerance - 1, y: markerScreen.y - 10) + XCTAssertFalse(MarkerHitTest.hitsIcon(touchScreen: farLeft, markerScreen: markerScreen, state: state)) + } +} diff --git a/Tests/MapConductorCoreTests/MarkerHitTestTests.swift b/Tests/MapConductorCoreTests/MarkerHitTestTests.swift index d464135..617073d 100644 --- a/Tests/MapConductorCoreTests/MarkerHitTestTests.swift +++ b/Tests/MapConductorCoreTests/MarkerHitTestTests.swift @@ -1,6 +1,6 @@ import CoreGraphics import XCTest -@testable import MapConductorCore +@_spi(MapConductorDriver) @testable import MapConductorCore /// ``MarkerHitTest`` の判定境界。 /// @@ -85,3 +85,38 @@ final class MarkerHitTestTests: XCTestCase { XCTAssertFalse(hits(0, -height - tolerance - 0.5, state: state)) } } + +/// ``MarkerHitTest/pickIndex(touch:rects:tolerance:)`` の選び方。android-sdk の +/// `MarkerHitTestPickTest`、react-sdk の `MarkerHitPick.test.mjs` と同じ観点を見る。 +final class MarkerHitTestPickTests: XCTestCase { + private func rect(_ x: CGFloat, _ y: CGFloat, width: CGFloat = 48, height: CGFloat = 48, anchorX: CGFloat = 0.5) + -> MarkerHitTest.ScreenRect { + MarkerHitTest.ScreenRect( + anchorScreen: CGPoint(x: x, y: y), + width: width, + height: height, + anchor: CGPoint(x: anchorX, y: 1) + ) + } + + func testIconUnderTheFingerBeatsANearerTip() { + // labelled: 先端 (0, 100)、ラベルを右へ伸ばして幅 200(矩形 x -24..176, y 52..100)。 + // nearTip: 先端 (150, 70) でタップ点にずっと近いが、矩形 (126..174, 22..70) はタップ点に届かない。 + let labelled = rect(0, 100, width: 200, anchorX: 0.12) + let nearTip = rect(150, 70) + XCTAssertEqual(MarkerHitTest.pickIndex(touch: CGPoint(x: 150, y: 80), rects: [nearTip, labelled], tolerance: 14), 1) + } + + func testFrontmostWinsWhenTwoIconsOverlap() { + let back = rect(100, 100) + let front = rect(100, 120) + XCTAssertEqual(MarkerHitTest.pickIndex(touch: CGPoint(x: 100, y: 90), rects: [back, front], tolerance: 14), 1) + } + + func testToleranceOnlyWhenNothingIsUnderTheFinger() { + let far = rect(100, 100) + let near = rect(140, 100) + XCTAssertEqual(MarkerHitTest.pickIndex(touch: CGPoint(x: 170, y: 105), rects: [far, near], tolerance: 40), 1) + XCTAssertNil(MarkerHitTest.pickIndex(touch: CGPoint(x: 400, y: 400), rects: [far, near], tolerance: 14)) + } +} diff --git a/api/MapConductorCore.api.swift b/api/MapConductorCore.api.swift index 3d649bb..e91740f 100644 --- a/api/MapConductorCore.api.swift +++ b/api/MapConductorCore.api.swift @@ -1533,15 +1533,17 @@ final public class ColorDefaultIcon : MapConductorCore.MarkerIconProtocol { public static let defaultLabelStrokeColor: UIKit.UIColor public static let defaultLabelTypeFace: UIKit.UIFont public static let defaultInfoAnchor: CoreFoundation.CGPoint + public static let defaultLabelPosition: Swift.String final public let scale: CoreFoundation.CGFloat final public let anchor: CoreFoundation.CGPoint final public let iconSize: CoreFoundation.CGFloat final public let infoAnchor: CoreFoundation.CGPoint final public let debug: Swift.Bool - public init(fillColor: UIKit.UIColor = defaultFillColor, strokeColor: UIKit.UIColor = defaultStrokeColor, strokeWidth: CoreFoundation.CGFloat = defaultStrokeWidth, scale: CoreFoundation.CGFloat = 1.0, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = defaultLabelTextColor, labelTextSize: CoreFoundation.CGFloat = defaultLabelTextSize, labelTypeFace: UIKit.UIFont = defaultLabelTypeFace, labelStrokeColor: UIKit.UIColor = defaultLabelStrokeColor, infoAnchor: CoreFoundation.CGPoint = defaultInfoAnchor, iconSize: CoreFoundation.CGFloat = defaultIconSize, debug: Swift.Bool = false) + final public let labelPosition: Swift.String + public init(fillColor: UIKit.UIColor = defaultFillColor, strokeColor: UIKit.UIColor = defaultStrokeColor, strokeWidth: CoreFoundation.CGFloat = defaultStrokeWidth, scale: CoreFoundation.CGFloat = 1.0, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = defaultLabelTextColor, labelTextSize: CoreFoundation.CGFloat = defaultLabelTextSize, labelTypeFace: UIKit.UIFont = defaultLabelTypeFace, labelStrokeColor: UIKit.UIColor = defaultLabelStrokeColor, labelPosition: Swift.String = defaultLabelPosition, infoAnchor: CoreFoundation.CGPoint = defaultInfoAnchor, iconSize: CoreFoundation.CGFloat = defaultIconSize, debug: Swift.Bool = false) final public func toBitmapIcon() -> MapConductorCore.BitmapIcon final public func hashCode() -> Swift.Int - final public func copy(fillColor: UIKit.UIColor? = nil, strokeColor: UIKit.UIColor? = nil, strokeWidth: CoreFoundation.CGFloat? = nil, scale: CoreFoundation.CGFloat? = nil, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = nil, labelTextSize: CoreFoundation.CGFloat? = nil, labelTypeFace: UIKit.UIFont? = nil, labelStrokeColor: UIKit.UIColor? = nil, iconSize: CoreFoundation.CGFloat? = nil, debug: Swift.Bool? = nil) -> MapConductorCore.ColorDefaultIcon + final public func copy(fillColor: UIKit.UIColor? = nil, strokeColor: UIKit.UIColor? = nil, strokeWidth: CoreFoundation.CGFloat? = nil, scale: CoreFoundation.CGFloat? = nil, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = nil, labelTextSize: CoreFoundation.CGFloat? = nil, labelTypeFace: UIKit.UIFont? = nil, labelStrokeColor: UIKit.UIColor? = nil, labelPosition: Swift.String? = nil, iconSize: CoreFoundation.CGFloat? = nil, debug: Swift.Bool? = nil) -> MapConductorCore.ColorDefaultIcon @objc deinit } public typealias DefaultMarkerIcon = MapConductorCore.ColorDefaultIcon @@ -1587,6 +1589,7 @@ final public class ImageDefaultIcon : MapConductorCore.MarkerIconProtocol { public static let defaultLabelStrokeColor: UIKit.UIColor public static let defaultLabelTypeFace: UIKit.UIFont public static let defaultInfoAnchor: CoreFoundation.CGPoint + public static let defaultLabelPosition: Swift.String public static let defaultAnchor: CoreFoundation.CGPoint final public let scale: CoreFoundation.CGFloat final public let anchor: CoreFoundation.CGPoint @@ -1600,10 +1603,11 @@ final public class ImageDefaultIcon : MapConductorCore.MarkerIconProtocol { final public let labelTextSize: CoreFoundation.CGFloat final public let labelTypeFace: UIKit.UIFont final public let labelStrokeColor: UIKit.UIColor - public init(backgroundImage: UIKit.UIImage, strokeColor: UIKit.UIColor = defaultStrokeColor, strokeWidth: CoreFoundation.CGFloat = defaultStrokeWidth, scale: CoreFoundation.CGFloat = 1.0, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = defaultLabelTextColor, labelTextSize: CoreFoundation.CGFloat = defaultLabelTextSize, labelTypeFace: UIKit.UIFont = defaultLabelTypeFace, labelStrokeColor: UIKit.UIColor = defaultLabelStrokeColor, infoAnchor: CoreFoundation.CGPoint = defaultInfoAnchor, iconSize: CoreFoundation.CGFloat = defaultIconSize, debug: Swift.Bool = false) + final public let labelPosition: Swift.String + public init(backgroundImage: UIKit.UIImage, strokeColor: UIKit.UIColor = defaultStrokeColor, strokeWidth: CoreFoundation.CGFloat = defaultStrokeWidth, scale: CoreFoundation.CGFloat = 1.0, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = defaultLabelTextColor, labelTextSize: CoreFoundation.CGFloat = defaultLabelTextSize, labelTypeFace: UIKit.UIFont = defaultLabelTypeFace, labelStrokeColor: UIKit.UIColor = defaultLabelStrokeColor, labelPosition: Swift.String = defaultLabelPosition, infoAnchor: CoreFoundation.CGPoint = defaultInfoAnchor, iconSize: CoreFoundation.CGFloat = defaultIconSize, debug: Swift.Bool = false) final public func toBitmapIcon() -> MapConductorCore.BitmapIcon final public func hashCode() -> Swift.Int - final public func copy(backgroundImage: UIKit.UIImage? = nil, strokeColor: UIKit.UIColor? = nil, strokeWidth: CoreFoundation.CGFloat? = nil, scale: CoreFoundation.CGFloat? = nil, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = nil, labelTextSize: CoreFoundation.CGFloat? = nil, labelTypeFace: UIKit.UIFont? = nil, labelStrokeColor: UIKit.UIColor? = nil, iconSize: CoreFoundation.CGFloat? = nil, debug: Swift.Bool? = nil) -> MapConductorCore.ImageDefaultIcon + final public func copy(backgroundImage: UIKit.UIImage? = nil, strokeColor: UIKit.UIColor? = nil, strokeWidth: CoreFoundation.CGFloat? = nil, scale: CoreFoundation.CGFloat? = nil, label: Swift.String? = nil, labelTextColor: UIKit.UIColor? = nil, labelTextSize: CoreFoundation.CGFloat? = nil, labelTypeFace: UIKit.UIFont? = nil, labelStrokeColor: UIKit.UIColor? = nil, labelPosition: Swift.String? = nil, iconSize: CoreFoundation.CGFloat? = nil, debug: Swift.Bool? = nil) -> MapConductorCore.ImageDefaultIcon @objc deinit } final public class ImageIcon : MapConductorCore.MarkerIconProtocol { From 61234e3ded7722a27115d1975ee0153d19cc4d1e Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 22 Oct 2026 21:44:50 -0700 Subject: [PATCH 4/4] Release 1.3.2 --- MapConductorCore.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MapConductorCore.podspec b/MapConductorCore.podspec index 50203a2..a161f7d 100644 --- a/MapConductorCore.podspec +++ b/MapConductorCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "MapConductorCore" - s.version = "1.3.1" + s.version = "1.3.2" s.summary = "MapConductor's provider-agnostic core types and protocols." s.license = { :type => "Apache-2.0", :file => "LICENSE" } s.author = "MapConductor"