Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 219 additions & 42 deletions Sources/MapConductorCore/marker/ColorDefaultIcon.swift

Large diffs are not rendered by default.

61 changes: 43 additions & 18 deletions Sources/MapConductorCore/marker/ImageDefaultIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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 }
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
)
Expand All @@ -135,7 +148,7 @@ public final class ImageDefaultIcon: MarkerIconProtocol {
labelTextSize: CGFloat,
labelTypeFace: UIFont,
labelStrokeColor: UIColor,
anchor: CGPoint,
labelPosition: String,
infoAnchor: CGPoint,
debug: Bool
) -> BitmapIcon {
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
)
Expand All @@ -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
)
}
Expand Down
147 changes: 145 additions & 2 deletions Sources/MapConductorCore/marker/MarkerHitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T>(
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
}
56 changes: 33 additions & 23 deletions Sources/MapConductorCore/marker/MarkerTileRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ public final class MarkerTileRenderer<ActualMarker>: 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<String>,
Expand All @@ -66,29 +71,34 @@ public final class MarkerTileRenderer<ActualMarker>: 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? {
Expand Down
Loading
Loading