diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 1bb95a5..59298c9 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -1216,6 +1216,8 @@ public final class com.mapconductor.core.map.VisibleRegionBuilderKt { public static final androidx.compose.ui.geometry.Size viewportSizePx(com.mapconductor.core.map.MapViewHolderInterface); } public abstract class com.mapconductor.core.marker.AbstractDefaultIcon extends com.mapconductor.core.marker.AbstractMarkerIcon { + public static final com.mapconductor.core.marker.AbstractDefaultIcon$Companion Companion; + public static final java.lang.String DEFAULT_LABEL_POSITION; public com.mapconductor.core.marker.AbstractDefaultIcon(com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties); protected final com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties getBaseProperties(); public final long getStrokeColor-0d7_KjU(); @@ -1226,17 +1228,18 @@ public abstract class com.mapconductor.core.marker.AbstractDefaultIcon extends c public final long getLabelTextSize-XSAIIZE(); public final android.graphics.Typeface getLabelTypeFace(); public final long getLabelStrokeColor-0d7_KjU(); + public final java.lang.String getLabelPosition(); public final float getIconSize-D9Ej5fM(); + public final boolean getDebug(); public final long getAnchor-F1C5BW0(); public final long getInfoAnchor-F1C5BW0(); - public final boolean getDebug(); protected abstract void drawMarkerFill(android.graphics.Canvas, android.graphics.Path, float, float); protected abstract java.lang.Object getUniqueProperties(); public com.mapconductor.core.marker.BitmapIcon toBitmapIcon(); - protected final void drawMarker(android.graphics.Canvas, float, float, float); + protected final void drawMarker(android.graphics.Canvas, float, float, float, float); protected final android.graphics.Path createMarkerPath(float, float, float); protected final android.graphics.Paint createStrokePaint(float); - protected final void drawLabel-oYZNtH4(android.graphics.Canvas, float, float, long, float); + protected final void drawLabel-INMd_9Y(android.graphics.Canvas, float, long, float, float); protected final float convertTextUnitToPx-eAf_CNQ(long, float); public boolean equals(java.lang.Object); public int hashCode(); @@ -1254,6 +1257,7 @@ public final class com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProp public final long getInfoAnchor-F1C5BW0(); public final float getIconSize-D9Ej5fM(); public final boolean getDebug(); + public final java.lang.String getLabelPosition(); public final long component1-0d7_KjU(); public final float component2-D9Ej5fM(); public final float component3(); @@ -1265,11 +1269,34 @@ public final class com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProp public final long component9-F1C5BW0(); public final float component10-D9Ej5fM(); public final boolean component11(); - public final com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties copy-CIYJXf0(long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean); + public final java.lang.String component12(); + public final com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties copy-eLRmdhM(long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, java.lang.String); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); } +public final class com.mapconductor.core.marker.AbstractDefaultIcon$Companion { +} +final class com.mapconductor.core.marker.AbstractDefaultIcon$IconLayout { + public final int getMarkerSize(); + public final int getBitmapWidth(); + public final int getBitmapHeight(); + public final float getMarkerOffsetX(); + public final float getMarkerOffsetY(); + public final float getLabelCenterX(); + public final float getLabelCenterY(); +} +final class com.mapconductor.core.marker.AbstractDefaultIcon$MarkerPathMetrics { + public final float getMarkerScale(); + public final float getOffsetX(); + public final float getOffsetY(); +} +final class com.mapconductor.core.marker.AbstractDefaultIcon$PinBounds { + public final float getLeft(); + public final float getRight(); + public final float getTop(); + public final float getBottom(); +} public abstract class com.mapconductor.core.marker.AbstractMarkerController implements com.mapconductor.core.controller.SlottedOverlayController>, com.mapconductor.core.marker.MarkerEventHostInterface { public com.mapconductor.core.marker.AbstractMarkerController(com.mapconductor.core.marker.MarkerManager, com.mapconductor.core.marker.MarkerOverlayRendererInterface, kotlin.jvm.functions.Function1); public com.mapconductor.core.marker.AbstractMarkerController(com.mapconductor.core.marker.MarkerManager, com.mapconductor.core.marker.MarkerOverlayRendererInterface, kotlin.jvm.functions.Function1, int, kotlin.jvm.internal.DefaultConstructorMarker); @@ -1383,33 +1410,33 @@ public final class com.mapconductor.core.marker.ClickableMarkerKt { public static final com.mapconductor.core.marker.MarkerEntityInterface clickableOnly(com.mapconductor.core.marker.MarkerEntityInterface); } public final class com.mapconductor.core.marker.ColorDefaultIcon extends com.mapconductor.core.marker.AbstractDefaultIcon { - public com.mapconductor.core.marker.ColorDefaultIcon(long, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.ColorDefaultIcon(long, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, long, float, boolean, int, kotlin.jvm.internal.DefaultConstructorMarker); protected void drawMarkerFill(android.graphics.Canvas, android.graphics.Path, float, float); protected java.lang.Object getUniqueProperties(); - public final com.mapconductor.core.marker.ColorDefaultIcon copy-ArqVBMg(long, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, float, boolean); + public final com.mapconductor.core.marker.ColorDefaultIcon copy-iu843mM(long, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, float, boolean); public final com.mapconductor.core.marker.ColorDefaultIcon copy-3ABfNKs(float, float); public com.mapconductor.core.marker.ColorDefaultIcon(long, com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties, kotlin.jvm.internal.DefaultConstructorMarker); - public com.mapconductor.core.marker.ColorDefaultIcon(long, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.ColorDefaultIcon(long, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, long, float, boolean, kotlin.jvm.internal.DefaultConstructorMarker); } public final class com.mapconductor.core.marker.ColorDefaultIconKt { } public final class com.mapconductor.core.marker.DrawableDefaultIcon extends com.mapconductor.core.marker.AbstractDefaultIcon { public com.mapconductor.core.marker.DrawableDefaultIcon(android.graphics.drawable.Drawable, com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties); - public com.mapconductor.core.marker.DrawableDefaultIcon(android.graphics.drawable.Drawable, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.DrawableDefaultIcon(android.graphics.drawable.Drawable, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, long, float, boolean, int, kotlin.jvm.internal.DefaultConstructorMarker); protected void drawMarkerFill(android.graphics.Canvas, android.graphics.Path, float, float); protected java.lang.Object getUniqueProperties(); - public final com.mapconductor.core.marker.DrawableDefaultIcon copy-NoBYCTc(android.graphics.drawable.Drawable, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, float, boolean); + public final com.mapconductor.core.marker.DrawableDefaultIcon copy-cvAzyPI(android.graphics.drawable.Drawable, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, float, boolean); public final com.mapconductor.core.marker.DrawableDefaultIcon copy-3ABfNKs(float, float); - public com.mapconductor.core.marker.DrawableDefaultIcon(android.graphics.drawable.Drawable, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.DrawableDefaultIcon(android.graphics.drawable.Drawable, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, long, float, boolean, kotlin.jvm.internal.DefaultConstructorMarker); } public final class com.mapconductor.core.marker.ImageDefaultIcon extends com.mapconductor.core.marker.AbstractDefaultIcon { public com.mapconductor.core.marker.ImageDefaultIcon(android.graphics.Bitmap, com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties); - public com.mapconductor.core.marker.ImageDefaultIcon(android.graphics.Bitmap, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.ImageDefaultIcon(android.graphics.Bitmap, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, long, float, boolean, int, kotlin.jvm.internal.DefaultConstructorMarker); protected void drawMarkerFill(android.graphics.Canvas, android.graphics.Path, float, float); protected java.lang.Object getUniqueProperties(); - public final com.mapconductor.core.marker.ImageDefaultIcon copy-NoBYCTc(android.graphics.Bitmap, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, float, boolean); + public final com.mapconductor.core.marker.ImageDefaultIcon copy-cvAzyPI(android.graphics.Bitmap, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, float, boolean); public final com.mapconductor.core.marker.ImageDefaultIcon copy-3ABfNKs(float, float); - public com.mapconductor.core.marker.ImageDefaultIcon(android.graphics.Bitmap, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.ImageDefaultIcon(android.graphics.Bitmap, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, long, float, boolean, kotlin.jvm.internal.DefaultConstructorMarker); } public final class com.mapconductor.core.marker.ImageIcon extends com.mapconductor.core.marker.AndroidDrawableIcon { public com.mapconductor.core.marker.ImageIcon(android.graphics.drawable.Drawable, float, float, long, long, boolean, int, kotlin.jvm.internal.DefaultConstructorMarker); diff --git a/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt b/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt index 5b31241..7c45506 100644 --- a/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt @@ -8,9 +8,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.core.graphics.createBitmap +import androidx.core.graphics.withTranslation import com.mapconductor.core.BitmapIconCache import com.mapconductor.core.ResourceProvider +import kotlin.math.ceil import kotlin.math.max +import kotlin.math.min import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path @@ -39,6 +42,7 @@ abstract class AbstractDefaultIcon( val infoAnchor: Offset, val iconSize: Dp, val debug: Boolean, + val labelPosition: String = DEFAULT_LABEL_POSITION, ) // 共通プロパティの委譲 @@ -50,11 +54,60 @@ abstract class AbstractDefaultIcon( val labelTextSize: TextUnit by baseProperties::labelTextSize val labelTypeFace: Typeface by baseProperties::labelTypeFace val labelStrokeColor: Color by baseProperties::labelStrokeColor + val labelPosition: String by baseProperties::labelPosition final override val iconSize: Dp by baseProperties::iconSize - final override val anchor: Offset = Offset(0.5f, 1f) - final override val infoAnchor: Offset by baseProperties::infoAnchor final override val debug: Boolean by baseProperties::debug + /** + * ピン先端の位置(ビットマップに対する割合)。ラベルをピンの横や上に置くと + * ビットマップが片側へ伸びるので、固定の (0.5, 1) ではなくレイアウトから求める。 + */ + final override val anchor: Offset by lazy { + val layout = layout + Offset( + (layout.markerOffsetX + layout.markerSize / 2f) / layout.bitmapWidth, + (layout.markerOffsetY + layout.markerSize) / layout.bitmapHeight, + ) + } + + /** + * 指定値はピン本体に対する割合として扱い、ビットマップに対する割合へ直して返す。 + * ラベルが中央にある限りは指定値と一致する(従来どおり)。 + */ + final override val infoAnchor: Offset by lazy { + val layout = layout + val requested = baseProperties.infoAnchor + Offset( + (layout.markerOffsetX + requested.x * layout.markerSize) / layout.bitmapWidth, + (layout.markerOffsetY + requested.y * layout.markerSize) / layout.bitmapHeight, + ) + } + + /** + * ビットマップの幅と高さが、ピン本体(iconSize × scale の正方形)の何倍か。 + * ラベルを横や上に置くと 1 を超える。当たり判定と吹き出しがアイコンの実寸を使うためのもの。 + */ + internal val extentRatio: Size by lazy { + val layout = layout + Size( + layout.bitmapWidth.toFloat() / layout.markerSize, + layout.bitmapHeight.toFloat() / layout.markerSize, + ) + } + + /** ビットマップ内でのマーカー本体とラベルの配置。すべてピクセル空間。 */ + private class IconLayout( + val markerSize: Int, + val bitmapWidth: Int, + val bitmapHeight: Int, + val markerOffsetX: Float, + val markerOffsetY: Float, + val labelCenterX: Float, + val labelCenterY: Float, + ) + + private val layout: IconLayout by lazy { computeLayout() } + /** * サブクラスでマーカーのフィル描画を実装する */ @@ -76,106 +129,160 @@ abstract class AbstractDefaultIcon( return it } - // Calculate canvas size with scale applied - val baseCanvasSize = ResourceProvider.dpToPxForBitmap(iconSize.value) - val markerSize = (baseCanvasSize * scale).toInt() - - // ラベルの幅を測定 - var labelWidth = 0f - var labelHeight = 0f - label?.let { labelText -> - val baseTextSize = convertTextUnitToPx(labelTextSize, 1f) - val textPaint = - Paint().apply { - textSize = baseTextSize - textAlign = Paint.Align.CENTER - typeface = labelTypeFace - isAntiAlias = true - } - labelWidth = textPaint.measureText(labelText) - val metrics = textPaint.fontMetrics - labelHeight = metrics.descent - metrics.ascent - } - - // ビットマップのサイズを決定(マーカーとラベルの両方が収まるように) - // 横幅: マーカーまたはラベルの大きい方 + 余白 - val padding = markerSize * 0.1f // 左右に5%ずつの余白 - val bitmapWidth = max(markerSize.toFloat(), labelWidth + padding).toInt() - // 高さ: マーカーの高さを基準(ラベルはマーカー内に収まる想定) - val bitmapHeight = markerSize - - val bitmap = createBitmap(bitmapWidth, bitmapHeight) + val layout = layout + val bitmap = createBitmap(layout.bitmapWidth, layout.bitmapHeight) // Set bitmap density based on override (e.g., 1.0 for MapLibre to prevent auto-scaling) ResourceProvider.getBitmapDensity().let { density -> bitmap.density = (density * android.util.DisplayMetrics.DENSITY_DEFAULT).toInt() } val canvas = Canvas(bitmap) - // マーカーを中央に配置するためのオフセット - val markerOffsetX = (bitmapWidth - markerSize) / 2f - // Draw marker (scale is already applied in markerSize) drawMarker( canvas = canvas, - canvasSize = markerSize.toFloat(), + canvasSize = layout.markerSize.toFloat(), iconScale = scale, - offsetX = markerOffsetX, + offsetX = layout.markerOffsetX, + offsetY = layout.markerOffsetY, ) // Draw label drawLabel( canvas = canvas, - canvasSize = markerSize.toFloat(), iconScale = scale, textSize = labelTextSize, - offsetX = markerOffsetX, + centerX = layout.labelCenterX, + centerY = layout.labelCenterY, ) - // アンカーを調整(マーカーの底部中央) - val adjustedAnchor = Offset(0.5f, 1f) + val adjustedAnchor = anchor val result = BitmapIcon( bitmap = bitmap, anchor = adjustedAnchor, - size = Size(bitmapWidth.toFloat(), bitmapHeight.toFloat()), + size = Size(layout.bitmapWidth.toFloat(), layout.bitmapHeight.toFloat()), ) BitmapIconCache.put(id, result) return result } /** - * マーカー本体の描画(共通処理) + * react-sdk / ios-sdk と同じレイアウト計算。`labelPosition` が中央なら従来どおり + * ラベルを円形部分の中心に重ね、ビットマップを左右対称に広げる。中央以外なら + * ラベルをピンの輪郭の外側へ置き、ビットマップをその側へだけ伸ばす。 */ - protected fun drawMarker( - canvas: Canvas, - canvasSize: Float, - iconScale: Float, - offsetX: Float = 0f, - ) { - val strokePath = createMarkerPath(canvasSize, iconScale, offsetX) - - // デバッグ用の枠描画 - if (debug) { - drawDebugFrame(canvas) + private fun computeLayout(): IconLayout { + val baseCanvasSize = ResourceProvider.dpToPxForBitmap(iconSize.value) + val markerSize = (baseCanvasSize * scale).toInt() + val size = markerSize.toFloat() + // 円形部分の中心 + val headCenterY = size * 0.35f + val padding = size * 0.1f // 左右に5%ずつの余白 + + val labelText = label + if (labelText == null) { + return IconLayout(markerSize, markerSize, markerSize, 0f, 0f, size / 2f, headCenterY) } - // フィル描画(サブクラス実装) - drawMarkerFill(canvas, strokePath, canvasSize, iconScale) + val textPaint = createLabelPaint(labelTextSize) + val textWidth = textPaint.measureText(labelText) + val metrics = textPaint.fontMetrics + val outline = labelOutlineStrokeWidth(scale) + val labelWidth = textWidth + outline * 2f + val labelHeight = metrics.descent - metrics.ascent + outline * 2f + val (h, v) = parseLabelPosition(labelPosition) + val pin = markerPinBounds(size, scale) + val gap = max(outline, size * 0.05f) + + // マーカー本体の左上を原点とした座標でラベルの中心を決める。 + val cx = + when { + h < 0 -> pin.left - gap - labelWidth / 2f + h > 0 -> pin.right + gap + labelWidth / 2f + else -> size / 2f + } + val cy = + if (h != 0) { + // 横に置くときは、縦はピンの上端・頭の中心・先端に揃える。 + when { + v < 0 -> pin.top + labelHeight / 2f + v > 0 -> pin.bottom - labelHeight / 2f + else -> headCenterY + } + } else { + // 中央列の top / bottom はピンと重ならないよう上下の外側に置く。 + when { + v < 0 -> pin.top - gap - labelHeight / 2f + v > 0 -> pin.bottom + gap + labelHeight / 2f + else -> headCenterY + } + } - // ストローク描画(共通) - val strokePaint = createStrokePaint(iconScale) - canvas.drawPath(strokePath, strokePaint) + val bitmapWidth: Int + val markerOffsetX: Float + if (h == 0) { + // 中央なら従来どおり(出力を変えない)。 + bitmapWidth = max(size, textWidth + padding).toInt() + markerOffsetX = (bitmapWidth - markerSize) / 2f + } else { + val minX = min(0f, cx - labelWidth / 2f - padding / 2f) + val maxX = max(size, cx + labelWidth / 2f + padding / 2f) + bitmapWidth = ceil(maxX - minX).toInt() + markerOffsetX = -minX + } + + val minY = min(0f, cy - labelHeight / 2f) + val maxY = max(size, cy + labelHeight / 2f) + val markerOffsetY = ceil(-minY) + val bitmapHeight = (markerOffsetY + ceil(maxY)).toInt() + + return IconLayout( + markerSize = markerSize, + bitmapWidth = bitmapWidth, + bitmapHeight = bitmapHeight, + markerOffsetX = markerOffsetX, + markerOffsetY = markerOffsetY, + labelCenterX = markerOffsetX + cx, + labelCenterY = markerOffsetY + cy, + ) } + private class PinBounds( + val left: Float, + val right: Float, + val top: Float, + val bottom: Float, + ) + /** - * マーカーのパスを生成 + * ピンの見えている輪郭(設計座標で x: 4〜20, y: 0〜24)を px で返す。 + * 外接箱(23.5 × 25.6)より細いので、ラベルを横に置くときはこちらに沿わせる。 */ - protected fun createMarkerPath( + private fun markerPinBounds( canvasSize: Float, iconScale: Float, - horizontalOffset: Float = 0f, - ): Path { + ): PinBounds { + val metrics = markerPathMetrics(canvasSize, iconScale) + val s = metrics.markerScale + return PinBounds( + left = metrics.offsetX + 4f * s, + right = metrics.offsetX + 20f * s, + top = metrics.offsetY, + bottom = metrics.offsetY + 24f * s, + ) + } + + private class MarkerPathMetrics( + val markerScale: Float, + val offsetX: Float, + val offsetY: Float, + ) + + private fun markerPathMetrics( + canvasSize: Float, + iconScale: Float, + ): MarkerPathMetrics { val originalSize = Size(23.5f, 25.6f) // Since canvasSize is already scaled (baseCanvasSize * scale), @@ -204,8 +311,53 @@ abstract class AbstractDefaultIcon( // Center horizontally, align bottom point to canvas edge // The path's bottom point should touch the canvas bottom, the stroke will extend beyond - val offsetX = (canvasSize - scaledWidth) / 2f + horizontalOffset + val offsetX = (canvasSize - scaledWidth) / 2f val offsetY = (canvasSize - scaledHeight + (strokeWidth.value * markerScale)) / 2f + return MarkerPathMetrics(markerScale, offsetX, offsetY) + } + + /** + * マーカー本体の描画(共通処理) + * + * マーカーは canvas を (offsetX, offsetY) だけずらしてから描く。サブクラスの + * [drawMarkerFill] は常に原点にある canvasSize の正方形を前提にしてよい。 + */ + protected fun drawMarker( + canvas: Canvas, + canvasSize: Float, + iconScale: Float, + offsetX: Float = 0f, + offsetY: Float = 0f, + ) { + // デバッグ用の枠描画 + if (debug) { + drawDebugFrame(canvas) + } + + canvas.withTranslation(offsetX, offsetY) { + val strokePath = createMarkerPath(canvasSize, iconScale) + + // フィル描画(サブクラス実装) + drawMarkerFill(this, strokePath, canvasSize, iconScale) + + // ストローク描画(共通) + val strokePaint = createStrokePaint(iconScale) + drawPath(strokePath, strokePaint) + } + } + + /** + * マーカーのパスを生成 + */ + protected fun createMarkerPath( + canvasSize: Float, + iconScale: Float, + horizontalOffset: Float = 0f, + ): Path { + val metrics = markerPathMetrics(canvasSize, iconScale) + val markerScale = metrics.markerScale + val offsetX = metrics.offsetX + horizontalOffset + val offsetY = metrics.offsetY return Path().apply { moveTo(12f * markerScale + offsetX, 0f * markerScale + offsetY) @@ -290,56 +442,53 @@ abstract class AbstractDefaultIcon( strokeCap = Paint.Cap.ROUND // 追加(尖り部のにじみ軽減) } + private fun createLabelPaint(textSize: TextUnit): Paint = + Paint().apply { + color = labelTextColor?.toArgb() ?: Color.Black.toArgb() + // 基本テキストサイズを計算(スケーリング適用) + this.textSize = convertTextUnitToPx(textSize, 1f) + textAlign = Paint.Align.CENTER + typeface = labelTypeFace + isAntiAlias = true + isSubpixelText = true + } + + // アウトライン幅(アイコンスケールを考慮したストローク幅、最小2px) + private fun labelOutlineStrokeWidth(iconScale: Float): Float = + max( + ResourceProvider.dpToPxForBitmap(1f * iconScale).toFloat(), + 2f, + ) + /** - * ラベルテキストの描画 + * ラベルテキストの描画。(centerX, centerY) は [computeLayout] が決めたラベルの中心で、 + * 既定ではマーカーの円形部分の中心になる。 */ protected fun drawLabel( canvas: Canvas, - canvasSize: Float, iconScale: Float, textSize: TextUnit, - offsetX: Float = 0f, + centerX: Float, + centerY: Float, ) { label?.let { labelText -> - // 基本テキストサイズを計算(スケーリング適用) - val baseTextSize = convertTextUnitToPx(textSize, 1f) - - val textPaint = - Paint().apply { - color = labelTextColor?.toArgb() ?: Color.Black.toArgb() - this.textSize = baseTextSize - textAlign = Paint.Align.CENTER - typeface = labelTypeFace - isAntiAlias = true - isSubpixelText = true - } - - // マーカーの円形部分の中心に配置 - val markerCenterX = canvasSize / 2f + offsetX - val markerCenterY = canvasSize * 0.35f // 円形部分の中心 + val textPaint = createLabelPaint(textSize) val metrics = textPaint.fontMetrics val textHeight = metrics.descent - metrics.ascent val baselineOffset = textHeight / 2f - metrics.descent - // アウトライン描画(アイコンスケールを考慮したストローク幅) - val outlineStrokeWidth = - max( - ResourceProvider.dpToPxForBitmap(1f * iconScale).toFloat(), - 2f, // 最小2px - ) - val outlinePaint = Paint(textPaint).apply { style = Paint.Style.STROKE - strokeWidth = outlineStrokeWidth + strokeWidth = labelOutlineStrokeWidth(iconScale) color = labelStrokeColor.toArgb() strokeJoin = Paint.Join.ROUND strokeCap = Paint.Cap.ROUND } - canvas.drawText(labelText, markerCenterX, markerCenterY + baselineOffset, outlinePaint) - canvas.drawText(labelText, markerCenterX, markerCenterY + baselineOffset, textPaint) + canvas.drawText(labelText, centerX, centerY + baselineOffset, outlinePaint) + canvas.drawText(labelText, centerX, centerY + baselineOffset, textPaint) } } @@ -378,4 +527,32 @@ abstract class AbstractDefaultIcon( override fun toString(): String = "${this::class.simpleName}(baseProperties=$baseProperties, uniqueProperties=${getUniqueProperties()})" + + companion object { + /** + * ラベルの既定位置。ピンの頭に重ねる(従来どおり)。 + * + * `labelPosition` は CSS の `background-position` と同じ書き方で、横 + * (left / center / right)と縦(top / center / bottom)のキーワードを空白区切りで + * 並べる。順序は問わず、片方だけなら残りは center。 + * - left / right はピンの外側に置き、縦はピンの上端・頭の中心・先端に揃える。 + * - center 列の top / bottom はピンの上・下の外側に置く。 + */ + const val DEFAULT_LABEL_POSITION: String = "center center" + + /** `labelPosition` を横・縦それぞれ -1 / 0 / 1 に読み替える。読めない値は中央扱い。 */ + internal fun parseLabelPosition(position: String): Pair { + var h = 0 + var v = 0 + position.trim().lowercase().split(Regex("\\s+")).forEach { word -> + when (word) { + "left" -> h = -1 + "right" -> h = 1 + "top" -> v = -1 + "bottom" -> v = 1 + } + } + return h to v + } + } } diff --git a/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt b/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt index 1312872..4f05cc5 100644 --- a/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt +++ b/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.marker +import com.mapconductor.core.InternalMapConductorApi import com.mapconductor.core.controller.OverlayHit import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.controller.SlottedOverlayController @@ -336,9 +337,11 @@ abstract class AbstractMarkerController( * * ## 判定は画面座標で行う * - * 最近傍のマーカーを 1 つだけ取り出し、タップ点とマーカーの**アイコン矩形**を - * 画面座標で突き合わせる([MarkerHitTest])。地理距離ではなくアイコンの大きさを - * 見るので、幅広ラベルやクラスタのように大きいアイコンでも端が反応する。 + * タップ点の周り([MarkerHitPick.SEARCH_RADIUS_DP])のマーカーを候補にし、各候補の + * **アイコン矩形**(ラベル込み)を画面座標で突き合わせる([MarkerHitPick.pick])。 + * 指の下にアイコンかラベルが描かれているマーカーが、先端の近い別のマーカーより優先する。 + * 以前は先端が最寄りの 1 つだけを見ていたので、隣のピンの頭や横に置いたラベルを + * タップすると外れていた。 * * 投影できない([com.mapconductor.core.map.MapViewHolderInterface.toScreenOffset] が * null)ときは「当たらなかった」として null。画面外のマーカーは当たらないのが正しい。 @@ -349,16 +352,12 @@ abstract class AbstractMarkerController( * `clickable = false` かつ `draggable = true` のマーカーがドラッグできなくなる。 * クリックの可否は配送側([dispatchClick] / [clickableOnly])で判断する。 */ + @OptIn(InternalMapConductorApi::class) override fun find(position: GeoPointInterface): MarkerEntityInterface? { - val nearest = markerManager.findNearest(position) ?: return null - val touchScreen = renderer.holder.toScreenOffset(position) ?: return null - val markerScreen = renderer.holder.toScreenOffset(nearest.state.position) ?: return null - - return if (MarkerHitTest.hitsIcon(touchScreen, markerScreen, nearest.state)) { - nearest - } else { - null - } + val holder = renderer.holder + val touchScreen = holder.toScreenOffset(position) ?: return null + val bounds = MarkerHitPick.searchBounds(position, touchScreen, holder::toScreenOffset) ?: return null + return MarkerHitPick.pick(touchScreen, markerManager.findMarkersInBounds(bounds), holder::toScreenOffset) } override val kind: OverlayKind = OverlayKind.Marker diff --git a/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt b/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt index c06398a..50b1002 100644 --- a/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt @@ -31,6 +31,7 @@ class ColorDefaultIcon( labelTextSize: TextUnit = 18.sp, labelTypeFace: Typeface = Typeface.DEFAULT, labelStrokeColor: Color = Color.White, + labelPosition: String = DEFAULT_LABEL_POSITION, infoAnchor: Offset = Offset(0.5f, 0f), iconSize: Dp = Settings.Default.iconSize, debug: Boolean = false, @@ -46,6 +47,7 @@ class ColorDefaultIcon( labelTextSize = labelTextSize, labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, infoAnchor = infoAnchor, iconSize = iconSize, debug = debug, @@ -78,6 +80,7 @@ class ColorDefaultIcon( labelTextSize: TextUnit = this.labelTextSize, labelTypeFace: Typeface = this.labelTypeFace, labelStrokeColor: Color = this.labelStrokeColor, + labelPosition: String = this.labelPosition, iconSize: Dp = this.iconSize, debug: Boolean = this.debug, ): ColorDefaultIcon = @@ -93,6 +96,7 @@ class ColorDefaultIcon( labelTextSize = labelTextSize, labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, iconSize = iconSize, debug = debug, ), diff --git a/src/main/java/com/mapconductor/core/marker/DrawableDefaultIcon.kt b/src/main/java/com/mapconductor/core/marker/DrawableDefaultIcon.kt index d89dcbf..7ed55c0 100644 --- a/src/main/java/com/mapconductor/core/marker/DrawableDefaultIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/DrawableDefaultIcon.kt @@ -37,6 +37,7 @@ class DrawableDefaultIcon( labelTextSize: TextUnit = 18.sp, labelTypeFace: Typeface = Typeface.DEFAULT, labelStrokeColor: Color = Color.White, + labelPosition: String = DEFAULT_LABEL_POSITION, infoAnchor: Offset = Offset(0.5f, 0f), iconSize: Dp = Settings.Default.iconSize, debug: Boolean = false, @@ -52,6 +53,7 @@ class DrawableDefaultIcon( labelTextSize = labelTextSize, labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, infoAnchor = infoAnchor, iconSize = iconSize, debug = debug, @@ -162,6 +164,7 @@ class DrawableDefaultIcon( labelTextSize: TextUnit = this.labelTextSize, labelTypeFace: Typeface = this.labelTypeFace, labelStrokeColor: Color = this.labelStrokeColor, + labelPosition: String = this.labelPosition, iconSize: Dp = this.iconSize, debug: Boolean = this.debug, ): DrawableDefaultIcon = @@ -177,6 +180,7 @@ class DrawableDefaultIcon( labelTextSize = labelTextSize, labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, iconSize = iconSize, debug = debug, ), diff --git a/src/main/java/com/mapconductor/core/marker/ImageDefaultIcon.kt b/src/main/java/com/mapconductor/core/marker/ImageDefaultIcon.kt index 4ada27b..8770444 100644 --- a/src/main/java/com/mapconductor/core/marker/ImageDefaultIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/ImageDefaultIcon.kt @@ -34,6 +34,7 @@ class ImageDefaultIcon( labelTextSize: TextUnit = 18.sp, labelTypeFace: Typeface = Typeface.DEFAULT, labelStrokeColor: Color = Color.White, + labelPosition: String = DEFAULT_LABEL_POSITION, infoAnchor: Offset = Offset(0.5f, 0f), iconSize: Dp = Settings.Default.iconSize, debug: Boolean = false, @@ -49,6 +50,7 @@ class ImageDefaultIcon( labelTextSize = labelTextSize, labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, infoAnchor = infoAnchor, iconSize = iconSize, debug = debug, @@ -113,6 +115,7 @@ class ImageDefaultIcon( labelTextSize: TextUnit = this.labelTextSize, labelTypeFace: Typeface = this.labelTypeFace, labelStrokeColor: Color = this.labelStrokeColor, + labelPosition: String = this.labelPosition, iconSize: Dp = this.iconSize, debug: Boolean = this.debug, ): ImageDefaultIcon = @@ -128,6 +131,7 @@ class ImageDefaultIcon( labelTextSize = labelTextSize, labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, iconSize = iconSize, debug = debug, ), diff --git a/src/main/java/com/mapconductor/core/marker/MarkerHitPick.kt b/src/main/java/com/mapconductor/core/marker/MarkerHitPick.kt new file mode 100644 index 0000000..7a05eb5 --- /dev/null +++ b/src/main/java/com/mapconductor/core/marker/MarkerHitPick.kt @@ -0,0 +1,145 @@ +package com.mapconductor.core.marker + +import androidx.compose.ui.geometry.Offset +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.settings.Settings +import kotlin.math.hypot + +/** + * タップ点に当たるマーカーを候補の中から選ぶ(ドライバー実装点)。 + * + * 1 つのマーカーが当たるかどうかは [MarkerHitTest.hitsIcon]、どれを選ぶかはここ。 + * ios-sdk の `MarkerHitTest.pickIndex` / `pick`、react-sdk の `pickMarkerHit` と同じ規則。 + */ +@InternalMapConductorApi +object MarkerHitPick { + /** + * アンカー点を投影した画面座標と、そのアイコンの矩形(px)。[pickIndex] の入力。 + */ + @InternalMapConductorApi + class ScreenRect( + val anchorScreen: Offset, + val widthPx: Double, + val heightPx: Double, + val anchorX: Double, + val anchorY: Double, + ) + + /** + * タップ点に当たるマーカーを選ぶ規則(ios-sdk の `MarkerHitTest.pickIndex`、react-sdk の + * `pickMarkerIndex` と同じ)。 + * + * 1. アイコン(ラベル込み)の矩形そのものにタップ点が入っているものがあれば、その中で + * 画面上いちばん下にアンカーがあるもの(手前に描かれるもの)を選ぶ。近くに別のマーカーの + * 先端があっても、指の下に描かれているマーカーが勝つ。 + * 2. どの矩形にも入っていなければ、矩形 + [tolerancePx] に入るもののうち、アンカーが + * タップ点にいちばん近いものを選ぶ。 + */ + fun pickIndex( + touch: Offset, + rects: List, + tolerancePx: Double, + ): Int? { + var exact: Int? = null + var exactY = Double.NEGATIVE_INFINITY + var near: Int? = null + var nearDistance = Double.POSITIVE_INFINITY + rects.forEachIndexed { index, rect -> + val dx = (touch.x - rect.anchorScreen.x).toDouble() + val dy = (touch.y - rect.anchorScreen.y).toDouble() + if (MarkerHitTest.hitsIconRect(dx, dy, rect.widthPx, rect.heightPx, rect.anchorX, rect.anchorY, 0.0)) { + if (rect.anchorScreen.y > exactY) { + exact = index + exactY = rect.anchorScreen.y.toDouble() + } + } else if (exact == null && + MarkerHitTest.hitsIconRect(dx, dy, rect.widthPx, rect.heightPx, rect.anchorX, rect.anchorY, tolerancePx) + ) { + val distance = hypot(dx, dy) + if (distance < nearDistance) { + near = index + nearDistance = distance + } + } + } + return exact ?: near + } + + /** + * 候補を集める範囲。タップ点から画面上 [SEARCH_RADIUS_DP] 以内に先端があるマーカーを含む。 + * + * ラベルを横に置くとアイコンはピンより大きく広がるので、最寄りの 1 つだけでは足りない。 + * px → 度の換算は、タップ点の近くを投影して局所的に求める(回転していても各軸の長さは保たれる)。 + */ + fun searchBounds( + position: GeoPointInterface, + touchScreen: Offset, + project: (GeoPointInterface) -> Offset?, + density: Double = ResourceProvider.getDensity().toDouble(), + ): GeoRectBounds? { + val epsilon = 1e-4 + val north = project(GeoPoint(position.latitude + epsilon, position.longitude)) ?: return null + val east = project(GeoPoint(position.latitude, position.longitude + epsilon)) ?: return null + val pxPerDegLat = hypot((north.x - touchScreen.x).toDouble(), (north.y - touchScreen.y).toDouble()) / epsilon + val pxPerDegLon = hypot((east.x - touchScreen.x).toDouble(), (east.y - touchScreen.y).toDouble()) / epsilon + if (pxPerDegLat <= 0.0 || pxPerDegLon <= 0.0) return null + val radiusPx = SEARCH_RADIUS_DP * density + val halfLat = radiusPx / pxPerDegLat + val halfLon = radiusPx / pxPerDegLon + return GeoRectBounds( + southWest = GeoPoint((position.latitude - halfLat).coerceAtLeast(-90.0), position.longitude - halfLon), + northEast = GeoPoint((position.latitude + halfLat).coerceAtMost(90.0), position.longitude + halfLon), + ) + } + + /** + * [candidates] の中からタップ点に当たるマーカーを [pickIndex] の規則で選ぶ。 + */ + fun > pick( + touchScreen: Offset, + candidates: List, + project: (GeoPointInterface) -> Offset?, + defaultIcon: MarkerIconInterface = DefaultMarkerIcon(), + density: Double = ResourceProvider.getDensity().toDouble(), + ): E? = pickBy(touchScreen, candidates, { it.state }, project, defaultIcon, density) + + /** [pick] の、候補が [MarkerState] 以外の型で渡ってくる版。 */ + fun pickBy( + touchScreen: Offset, + candidates: List, + stateOf: (T) -> MarkerState, + project: (GeoPointInterface) -> Offset?, + defaultIcon: MarkerIconInterface = DefaultMarkerIcon(), + density: Double = ResourceProvider.getDensity().toDouble(), + ): T? { + val projected = ArrayList(candidates.size) + val rects = ArrayList(candidates.size) + for (candidate in candidates) { + val state = stateOf(candidate) + val anchorScreen = project(state.position) ?: continue + val icon = state.icon ?: defaultIcon + val iconSizePx = icon.iconSize.value.toDouble() * icon.scale.toDouble() * density + val extent = MarkerIconExtent.ratio(icon) + projected += candidate + rects += + ScreenRect( + anchorScreen = anchorScreen, + widthPx = iconSizePx * extent.width, + heightPx = iconSizePx * extent.height, + anchorX = icon.anchor.x.toDouble(), + anchorY = icon.anchor.y.toDouble(), + ) + } + val tolerancePx = Settings.Default.tapTolerance.value.toDouble() * density + return pickIndex(touchScreen, rects, tolerancePx)?.let { projected[it] } + } + + /** + * 候補を探す半径(dp)。ラベルの長さの上限の目安で、これより遠くまで伸びたラベルの端は拾えない。 + */ + const val SEARCH_RADIUS_DP: Double = 256.0 +} diff --git a/src/main/java/com/mapconductor/core/marker/MarkerHitTest.kt b/src/main/java/com/mapconductor/core/marker/MarkerHitTest.kt index 27c05b0..119e2ab 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerHitTest.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerHitTest.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.marker import androidx.compose.ui.geometry.Offset +import com.mapconductor.core.InternalMapConductorApi import com.mapconductor.core.ResourceProvider import com.mapconductor.settings.Settings @@ -28,9 +29,10 @@ object MarkerHitTest { * アンカーは「アイコン内のどこがマーカー位置に一致するか」を 0..1 で表す。例えば * ピン形状の既定アンカー `(0.5, 1.0)` なら、矩形はアンカー点から上方向へ広がる。 * - * アイコンは正方形のキャンバスに描かれる前提で、`iconSize`(dp)× `scale` を - * 画面密度でピクセルへ換算して使う。 + * `iconSize`(dp)× `scale` を画面密度でピクセルへ換算し、既定アイコンでラベルの分だけ + * ビットマップが広いときは [MarkerIconExtent] の倍率を掛けて使う。 */ + @OptIn(InternalMapConductorApi::class) fun hitsIcon( touchScreen: Offset, markerScreen: Offset, @@ -40,12 +42,13 @@ object MarkerHitTest { ): Boolean { val icon = state.icon ?: defaultIcon val iconSizePx = icon.iconSize.value.toDouble() * icon.scale.toDouble() * density + val extent = MarkerIconExtent.ratio(icon) return hitsIconRect( dx = (touchScreen.x - markerScreen.x).toDouble(), dy = (touchScreen.y - markerScreen.y).toDouble(), - iconWidthPx = iconSizePx, - iconHeightPx = iconSizePx, + iconWidthPx = iconSizePx * extent.width, + iconHeightPx = iconSizePx * extent.height, anchorX = icon.anchor.x.toDouble(), anchorY = icon.anchor.y.toDouble(), tolerancePx = diff --git a/src/main/java/com/mapconductor/core/marker/MarkerIconExtent.kt b/src/main/java/com/mapconductor/core/marker/MarkerIconExtent.kt new file mode 100644 index 0000000..4b587fb --- /dev/null +++ b/src/main/java/com/mapconductor/core/marker/MarkerIconExtent.kt @@ -0,0 +1,19 @@ +package com.mapconductor.core.marker + +import androidx.compose.ui.geometry.Size +import com.mapconductor.core.InternalMapConductorApi + +/** + * アイコンが画面上で占める矩形が、ピン本体(iconSize × scale の正方形)の何倍か。 + * `anchor` / `infoAnchor` はこの矩形に対する割合なので、当たり判定と吹き出しはこれを掛けて使う。 + * + * 既定アイコンはラベルの分だけビットマップが広いことがあるのでその倍率を返し、 + * それ以外のアイコンは従来どおり正方形(1 × 1)として扱う。 + * react-sdk の `markerIconExtentPx`、ios-sdk の `MarkerIconMetrics` に対応する。 + */ +@InternalMapConductorApi +object MarkerIconExtent { + private val SQUARE = Size(1f, 1f) + + fun ratio(icon: MarkerIconInterface): Size = (icon as? AbstractDefaultIcon)?.extentRatio ?: SQUARE +} diff --git a/src/main/java/com/mapconductor/core/marker/StrategyMarkerController.kt b/src/main/java/com/mapconductor/core/marker/StrategyMarkerController.kt index da18b40..a52c6a7 100644 --- a/src/main/java/com/mapconductor/core/marker/StrategyMarkerController.kt +++ b/src/main/java/com/mapconductor/core/marker/StrategyMarkerController.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.marker +import com.mapconductor.core.InternalMapConductorApi import com.mapconductor.core.controller.OnCameraChangeReceiverInterface import com.mapconductor.core.controller.OverlayHit import com.mapconductor.core.controller.OverlayKind @@ -110,16 +111,12 @@ class StrategyMarkerController( override fun getEntity(id: String): MarkerEntityInterface? = strategy.markerManager.getEntity(id) + @OptIn(InternalMapConductorApi::class) override fun find(position: GeoPointInterface): MarkerEntityInterface? { - val nearest = strategy.markerManager.findNearest(position) ?: return null - val touchScreen = renderer.holder.toScreenOffset(position) ?: return null - val markerScreen = renderer.holder.toScreenOffset(nearest.state.position) ?: return null - - return if (MarkerHitTest.hitsIcon(touchScreen, markerScreen, nearest.state)) { - nearest - } else { - null - } + val holder = renderer.holder + val touchScreen = holder.toScreenOffset(position) ?: return null + val bounds = MarkerHitPick.searchBounds(position, touchScreen, holder::toScreenOffset) ?: return null + return MarkerHitPick.pick(touchScreen, strategy.markerManager.findMarkersInBounds(bounds), holder::toScreenOffset) } override suspend fun onCameraChanged(mapCameraPosition: MapCameraPosition) { diff --git a/src/test/java/com/mapconductor/core/marker/DefaultIconLabelPositionTest.kt b/src/test/java/com/mapconductor/core/marker/DefaultIconLabelPositionTest.kt new file mode 100644 index 0000000..628c979 --- /dev/null +++ b/src/test/java/com/mapconductor/core/marker/DefaultIconLabelPositionTest.kt @@ -0,0 +1,41 @@ +package com.mapconductor.core.marker + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * `labelPosition` の読み取り。react-sdk の `DefaultIconLabelPosition.test.mjs`、 + * ios-sdk の `DefaultIconLabelPositionTests` と同じ規則を見る。 + */ +class DefaultIconLabelPositionTest { + private fun parse(value: String) = AbstractDefaultIcon.parseLabelPosition(value) + + @Test + fun defaultIsCenterCenter() { + assertEquals(0 to 0, parse(AbstractDefaultIcon.DEFAULT_LABEL_POSITION)) + } + + @Test + fun readsBothAxes() { + assertEquals(1 to 0, parse("right center")) + assertEquals(-1 to -1, parse("left top")) + assertEquals(0 to 1, parse("center bottom")) + } + + @Test + fun keywordOrderDoesNotMatter() { + assertEquals(parse("right top"), parse("top right")) + } + + @Test + fun singleKeywordLeavesOtherAxisCentered() { + assertEquals(1 to 0, parse("right")) + assertEquals(0 to -1, parse("top")) + } + + @Test + fun unknownWordsFallBackToCenter() { + assertEquals(0 to 0, parse(" middle ")) + assertEquals(1 to 1, parse(" RIGHT Bottom ")) + } +} diff --git a/src/test/java/com/mapconductor/core/marker/MarkerHitTestTest.kt b/src/test/java/com/mapconductor/core/marker/MarkerHitTestTest.kt index fb89978..3df704b 100644 --- a/src/test/java/com/mapconductor/core/marker/MarkerHitTestTest.kt +++ b/src/test/java/com/mapconductor/core/marker/MarkerHitTestTest.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.marker +import androidx.compose.ui.geometry.Offset import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -95,3 +96,47 @@ class MarkerHitTestTest { assertFalse(centered(0.0, 24.0 + tolerance + 0.5)) } } + +/** + * [MarkerHitPick.pickIndex] の選び方。ios-sdk の `MarkerHitTestTests`、react-sdk の + * `MarkerHitPick.test.mjs` と同じ観点を見る。 + */ +@OptIn(com.mapconductor.core.InternalMapConductorApi::class) +class MarkerHitTestPickTest { + private val tolerance = 14.0 + + private fun rect( + x: Float, + y: Float, + width: Double = 48.0, + height: Double = 48.0, + anchorX: Double = 0.5, + ) = MarkerHitPick.ScreenRect(Offset(x, y), width, height, anchorX, 1.0) + + @Test + fun iconUnderTheFingerBeatsANearerTip() { + // labelled: 先端 (0, 100)、ラベルを右へ伸ばして幅 200(矩形 x -24..176, y 52..100)。 + // nearTip: 先端 (150, 70) でタップ点にずっと近いが、矩形 (126..174, 22..70) はタップ点に届かない。 + // タップ (150, 80) は labelled のラベルの上なので、先端の近さに関係なく labelled が選ばれる。 + val labelled = rect(0f, 100f, width = 200.0, anchorX = 0.12) + val nearTip = rect(150f, 70f) + assertTrue(MarkerHitPick.pickIndex(Offset(150f, 80f), listOf(nearTip, labelled), tolerance) == 1) + } + + @Test + fun frontmostWinsWhenTwoIconsOverlap() { + val back = rect(100f, 100f) + val front = rect(100f, 120f) + assertTrue(MarkerHitPick.pickIndex(Offset(100f, 90f), listOf(back, front), tolerance) == 1) + } + + @Test + fun toleranceOnlyWhenNothingIsUnderTheFinger() { + val far = rect(100f, 100f) + val near = rect(140f, 100f) + // どちらの矩形にも入らないが、許容範囲には両方入る点。アンカーの近い方。 + val touch = Offset(170f, 105f) + assertTrue(MarkerHitPick.pickIndex(touch, listOf(far, near), 40.0) == 1) + assertTrue(MarkerHitPick.pickIndex(Offset(400f, 400f), listOf(far, near), tolerance) == null) + } +}