From 957cd030b0408c150174b87f453ea947ff47f2a4 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 04:07:33 -0800 Subject: [PATCH] Draw marker labels into tiles as text, add vertical labels, and hear in-place changes from Markers(list) Same design as ios-sdk-core#23. - Tiles draw the body (dot, pin) as a shared bitmap and the label text after all bodies, so relabelling many markers no longer bakes a bitmap per text and position. Bodies are shared between icons differing only in label. A labelled marker wins its declutter cell; among labelled ones the world position decides, so neighbouring tiles agree. - ImageIcon gains the label options ColorDefaultIcon has, plus copy(). The pin and ImageIcon share one layout computation (IconLabelLayout). - labelWritingMode (CSS writing-mode: horizontal-tb, vertical-rl): vertical text stacks one glyph per em with OpenType 'vert', so the long-vowel mark turns and small kana sit as in vertical type. - Labelled icons record their overhang when created, and tiles search by it, so a label reaching in from the next tile is not cut right after a relabel. - OverlayCollector listens to states written straight into its flow. Since the switch to states reporting their own changes, Markers(list) wrote its list there without listening, and an in-place change to such a marker (an icon swapped to relabel a street tree) never reached the controller. --- api/android-sdk-core.api | 40 +- .../com/mapconductor/core/OverlayCollector.kt | 53 ++- .../core/marker/AbstractDefaultIcon.kt | 233 ++++++----- .../core/marker/ColorDefaultIcon.kt | 4 + .../com/mapconductor/core/marker/ImageIcon.kt | 286 ++++++++++++-- .../core/marker/MarkerIconExtent.kt | 9 +- .../core/marker/MarkerTileLabel.kt | 372 ++++++++++++++++++ .../core/marker/MarkerTileRenderer.kt | 113 +++++- 8 files changed, 962 insertions(+), 148 deletions(-) create mode 100644 src/main/java/com/mapconductor/core/marker/MarkerTileLabel.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 4515c0d..f1fcb26 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -1345,6 +1345,7 @@ public final class com.mapconductor.core.map.VisibleRegionBuilderKt { 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 static final java.lang.String DEFAULT_LABEL_WRITING_MODE; 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(); @@ -1356,6 +1357,7 @@ public abstract class com.mapconductor.core.marker.AbstractDefaultIcon extends c public final android.graphics.Typeface getLabelTypeFace(); public final long getLabelStrokeColor-0d7_KjU(); public final java.lang.String getLabelPosition(); + public final java.lang.String getLabelWritingMode(); public final float getIconSize-D9Ej5fM(); public final boolean getDebug(); public final long getAnchor-F1C5BW0(); @@ -1385,6 +1387,7 @@ public final class com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProp public final float getIconSize-D9Ej5fM(); public final boolean getDebug(); public final java.lang.String getLabelPosition(); + public final java.lang.String getLabelWritingMode(); public final long component1-0d7_KjU(); public final float component2-D9Ej5fM(); public final float component3(); @@ -1397,7 +1400,8 @@ public final class com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProp public final float component10-D9Ej5fM(); public final boolean component11(); 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 final java.lang.String component13(); + public final com.mapconductor.core.marker.AbstractDefaultIcon$BaseIconProperties copy-KrVDhP0(long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, long, float, boolean, java.lang.String, java.lang.String); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); @@ -1412,6 +1416,7 @@ final class com.mapconductor.core.marker.AbstractDefaultIcon$IconLayout { public final float getMarkerOffsetY(); public final float getLabelCenterX(); public final float getLabelCenterY(); + public final com.mapconductor.core.marker.LabelTypesetter$Metrics getLabel(); } final class com.mapconductor.core.marker.AbstractDefaultIcon$MarkerPathMetrics { public final float getMarkerScale(); @@ -1537,13 +1542,13 @@ 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, java.lang.String, 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, 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-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-9HuT76Q(long, long, float, float, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, 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, java.lang.String, 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, java.lang.String, long, float, boolean, kotlin.jvm.internal.DefaultConstructorMarker); } public final class com.mapconductor.core.marker.ColorDefaultIconKt { } @@ -1566,16 +1571,31 @@ public final class com.mapconductor.core.marker.ImageDefaultIcon extends com.map 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); + public com.mapconductor.core.marker.ImageIcon(android.graphics.drawable.Drawable, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, int, kotlin.jvm.internal.DefaultConstructorMarker); public float getIconSize-D9Ej5fM(); public float getScale(); + public boolean getDebug(); + public final java.lang.String getLabel(); + public final androidx.compose.ui.graphics.Color getLabelTextColor-QN2ZGVo(); + 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 java.lang.String getLabelWritingMode(); public long getAnchor-F1C5BW0(); public long getInfoAnchor-F1C5BW0(); - public boolean getDebug(); public boolean equals(java.lang.Object); public int hashCode(); public com.mapconductor.core.marker.BitmapIcon toBitmapIcon(); - public com.mapconductor.core.marker.ImageIcon(android.graphics.drawable.Drawable, float, float, long, long, boolean, kotlin.jvm.internal.DefaultConstructorMarker); + public final com.mapconductor.core.marker.ImageIcon copy-MQQ_14o(android.graphics.drawable.Drawable, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String); + public com.mapconductor.core.marker.ImageIcon(android.graphics.drawable.Drawable, float, float, long, long, boolean, java.lang.String, androidx.compose.ui.graphics.Color, long, android.graphics.Typeface, long, java.lang.String, java.lang.String, kotlin.jvm.internal.DefaultConstructorMarker); +} +final class com.mapconductor.core.marker.ImageIcon$Layout { + public final int getCanvasSize(); + public final com.mapconductor.core.marker.IconLabelLayout$Result getResult(); + public final com.mapconductor.core.marker.LabelTypesetter$Metrics getLabel(); + public final float getTextSizePx(); + public final float getOutlinePx(); } public final class com.mapconductor.core.marker.MarkerAnimation extends java.lang.Enum { public static final com.mapconductor.core.marker.MarkerAnimation Drop; @@ -1862,12 +1882,16 @@ public final class com.mapconductor.core.marker.MarkerTileRenderer$renderTile$Pr public final float getDrawW(); public final float getDrawH(); public final long getAnchor(); + public final com.mapconductor.core.marker.MarkerTileLabel getLabel(); + public final float getScale(); public final android.graphics.Bitmap component1(); public final com.mapconductor.core.marker.PointD component2(); public final float component3(); public final float component4(); public final long component5(); - public final com.mapconductor.core.marker.MarkerTileRenderer$renderTile$PreparedMarker copy(android.graphics.Bitmap, com.mapconductor.core.marker.PointD, float, float, long); + public final com.mapconductor.core.marker.MarkerTileLabel component6(); + public final float component7(); + public final com.mapconductor.core.marker.MarkerTileRenderer$renderTile$PreparedMarker copy(android.graphics.Bitmap, com.mapconductor.core.marker.PointD, float, float, long, com.mapconductor.core.marker.MarkerTileLabel, float); public java.lang.String toString(); public int hashCode(); public boolean equals(java.lang.Object); diff --git a/src/main/java/com/mapconductor/core/OverlayCollector.kt b/src/main/java/com/mapconductor/core/OverlayCollector.kt index 73155c4..cd986b8 100644 --- a/src/main/java/com/mapconductor/core/OverlayCollector.kt +++ b/src/main/java/com/mapconductor/core/OverlayCollector.kt @@ -91,6 +91,20 @@ class OverlayCollector( override val flow = MutableStateFlow>(mutableMapOf()) + /** + * Who is currently reporting mutations, kept here rather than read back out + * of [flow]. + * + * Membership reaches [flow] through a debounce, so a state added and + * removed inside the same window is not in the map when the removal + * arrives — clearing by id there found nothing and left it reporting for + * the rest of the collector's life. + */ + private val listening = ConcurrentHashMap() + + /** Ids this collector started listening to because they appeared in [flow], not through [add]. */ + private val listenedFromFlow = ConcurrentHashMap.newKeySet() + init { scope.launch { addSharedFlow.debounceBatch(5.milliseconds, 100).collect { states -> @@ -111,6 +125,35 @@ class OverlayCollector( flow.value = newMap } } + + // [flow] is writable from outside, and the compose `Markers(list)` writes + // its whole list straight into it rather than calling [add] 144k times. + // Those states were never listened to, so an in-place change to one of + // them -- an icon swapped to relabel a street tree -- reached nobody, and + // the tiles kept drawing the old icon. Listen to whatever arrives this + // way too. Runs per membership change, not per mutation. + scope.launch { + flow.collect { states -> listenToWrittenStates(states) } + } + } + + private fun listenToWrittenStates(states: Map) { + for ((id, state) in states) { + if (listening[id] !== state) { + listen(state) + listenedFromFlow.add(id) + } + } + // Only stop what this path started: a state added through [add] may not + // have reached [flow] yet (membership is debounced). + val iterator = listenedFromFlow.iterator() + while (iterator.hasNext()) { + val id = iterator.next() + if (id !in states) { + unlisten(id) + iterator.remove() + } + } } override suspend fun add(state: T) { @@ -123,16 +166,6 @@ class OverlayCollector( removeSharedFlow.tryEmit(id) } - /** - * Who is currently reporting mutations, kept here rather than read back out - * of [flow]. - * - * Membership reaches [flow] through a debounce, so a state added and - * removed inside the same window is not in the map when the removal - * arrives — clearing by id there found nothing and left it reporting for - * the rest of the collector's life. - */ - private val listening = ConcurrentHashMap() private fun listen(state: T) { val previous = listening.put(state.id, state) diff --git a/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt b/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt index c4bf259..28d9eef 100644 --- a/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/AbstractDefaultIcon.kt @@ -11,9 +11,6 @@ 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 @@ -43,6 +40,7 @@ abstract class AbstractDefaultIcon( val iconSize: Dp, val debug: Boolean, val labelPosition: String = DEFAULT_LABEL_POSITION, + val labelWritingMode: String = DEFAULT_LABEL_WRITING_MODE, ) // 共通プロパティの委譲 @@ -55,6 +53,13 @@ abstract class AbstractDefaultIcon( val labelTypeFace: Typeface by baseProperties::labelTypeFace val labelStrokeColor: Color by baseProperties::labelStrokeColor val labelPosition: String by baseProperties::labelPosition + + /** + * ラベルの書字方向。CSS の `writing-mode` と同じ書き方で、`horizontal-tb`(既定)か + * `vertical-rl` / `vertical-lr`(縦書き)。縦書きは 1 文字ぶんの幅で縦に並ぶので、 + * 横に詰まって並ぶ点にも名前を付けやすい。 + */ + val labelWritingMode: String by baseProperties::labelWritingMode final override val iconSize: Dp by baseProperties::iconSize final override val debug: Boolean by baseProperties::debug @@ -104,10 +109,86 @@ abstract class AbstractDefaultIcon( val markerOffsetY: Float, val labelCenterX: Float, val labelCenterY: Float, + val label: LabelTypesetter.Metrics?, ) private val layout: IconLayout by lazy { computeLayout() } + init { + // ラベル付きは、はみ出し量を作った時点で記録する([LabelledIconExtent])。 + // JVM の単体テストでは Paint が空実装なので、測れなければ記録しない。 + if (baseProperties.label != null) { + runCatching { layout }.getOrNull()?.let { layout -> + LabelledIconExtent.note( + width = layout.bitmapWidth.toFloat(), + height = layout.bitmapHeight.toFloat(), + anchorX = (layout.markerOffsetX + layout.markerSize / 2f) / layout.bitmapWidth, + anchorY = (layout.markerOffsetY + layout.markerSize) / layout.bitmapHeight, + ) + } + } + } + + /** + * ラベルを除いたピン。タイル描画が使う。 + * + * 色や大きさが同じなら、ラベルの違うアイコンの間で 1 枚を共有する([BitmapIconCache] の + * キーにラベルを含めない)。街路樹のように「樹種 × 向き」でラベルを付け替えても、 + * 焼き込む画像は本体の種類だけで済む。 + */ + internal val tileBody: BitmapIcon + get() { + if (label == null) return toBitmapIcon() + val bodyProperties = + baseProperties.copy( + label = null, + labelPosition = DEFAULT_LABEL_POSITION, + labelWritingMode = DEFAULT_LABEL_WRITING_MODE, + ) + val id = "default_icon_body_${bodyProperties.hashCode() * 31 + getUniqueProperties().hashCode()}".hashCode() + BitmapIconCache.get(id)?.let { return it } + val markerSize = layout.markerSize + val bitmap = createBitmap(markerSize, markerSize) + ResourceProvider.getBitmapDensity().let { density -> + bitmap.density = (density * android.util.DisplayMetrics.DENSITY_DEFAULT).toInt() + } + drawMarker( + canvas = Canvas(bitmap), + canvasSize = markerSize.toFloat(), + iconScale = scale, + ) + val result = + BitmapIcon( + bitmap = bitmap, + anchor = Offset(0.5f, 1f), + size = Size(markerSize.toFloat(), markerSize.toFloat()), + ) + BitmapIconCache.put(id, result) + return result + } + + internal val tileLabel: MarkerTileLabel? + get() { + val text = label ?: return null + val layout = layout + val metrics = layout.label ?: return null + val anchorX = layout.markerOffsetX + layout.markerSize / 2f + val anchorY = layout.markerOffsetY + layout.markerSize + return MarkerTileLabel( + text = text, + textSizePx = convertTextUnitToPx(labelTextSize, 1f), + typeface = labelTypeFace, + textColor = (labelTextColor ?: Color.Black).toArgb(), + strokeColor = labelStrokeColor.toArgb(), + outlinePx = labelOutlineStrokeWidth(scale), + centerOffsetX = layout.labelCenterX - anchorX, + centerOffsetY = layout.labelCenterY - anchorY, + width = metrics.width, + height = metrics.height, + vertical = LabelTypesetter.isVertical(labelWritingMode), + ) + } + /** * サブクラスでマーカーのフィル描画を実装する */ @@ -178,74 +259,42 @@ abstract class AbstractDefaultIcon( 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) - } - - 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) { - // 横に置くときは、縦はピンの上端・上端と先端の中間・先端に揃える。 - // center を頭の中心にすると、ピン全体の中央より上に見える。 - when { - v < 0 -> pin.top + labelHeight / 2f - v > 0 -> pin.bottom - labelHeight / 2f - else -> (pin.top + pin.bottom) / 2f - } - } else { - // 中央列の top / bottom はピンと重ならないよう上下の外側に置く。 - when { - v < 0 -> pin.top - gap - labelHeight / 2f - v > 0 -> pin.bottom + gap + labelHeight / 2f - else -> headCenterY - } + val outline = labelOutlineStrokeWidth(scale) + val metrics = + label?.let { text -> + LabelTypesetter.measure( + text = text, + textSizePx = convertTextUnitToPx(labelTextSize, 1f), + typeface = labelTypeFace, + outlinePx = outline, + vertical = LabelTypesetter.isVertical(labelWritingMode), + ) } - - 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() - + val result = + IconLabelLayout.compute( + markerSize = markerSize, + bodyLeft = pin.left, + bodyRight = pin.right, + bodyTop = pin.top, + bodyBottom = pin.bottom, + centerY = headCenterY, + // 横に置くときの縦の中央は、ピンの上端と先端の中間。頭の中心にすると、 + // ピン全体の中央より上に見える。 + sideCenterY = (pin.top + pin.bottom) / 2f, + label = metrics, + outlinePx = outline, + labelPosition = labelPosition, + ) return IconLayout( markerSize = markerSize, - bitmapWidth = bitmapWidth, - bitmapHeight = bitmapHeight, - markerOffsetX = markerOffsetX, - markerOffsetY = markerOffsetY, - labelCenterX = markerOffsetX + cx, - labelCenterY = markerOffsetY + cy, + bitmapWidth = result.bitmapWidth, + bitmapHeight = result.bitmapHeight, + markerOffsetX = result.markerOffsetX, + markerOffsetY = result.markerOffsetY, + labelCenterX = result.labelCenterX, + labelCenterY = result.labelCenterY, + label = metrics, ) } @@ -443,23 +492,8 @@ 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, - ) + private fun labelOutlineStrokeWidth(iconScale: Float): Float = LabelTypesetter.outlinePx(iconScale) /** * ラベルテキストの描画。(centerX, centerY) は [computeLayout] が決めたラベルの中心で、 @@ -473,23 +507,19 @@ abstract class AbstractDefaultIcon( centerY: Float, ) { label?.let { labelText -> - val textPaint = createLabelPaint(textSize) - - val metrics = textPaint.fontMetrics - val textHeight = metrics.descent - metrics.ascent - val baselineOffset = textHeight / 2f - metrics.descent - - val outlinePaint = - Paint(textPaint).apply { - style = Paint.Style.STROKE - strokeWidth = labelOutlineStrokeWidth(iconScale) - color = labelStrokeColor.toArgb() - strokeJoin = Paint.Join.ROUND - strokeCap = Paint.Cap.ROUND - } - - canvas.drawText(labelText, centerX, centerY + baselineOffset, outlinePaint) - canvas.drawText(labelText, centerX, centerY + baselineOffset, textPaint) + LabelTypesetter.draw( + canvas = canvas, + text = labelText, + centerX = centerX, + centerY = centerY, + textSizePx = convertTextUnitToPx(textSize, 1f), + typeface = labelTypeFace, + textColor = (labelTextColor ?: Color.Black).toArgb(), + strokeColor = labelStrokeColor.toArgb(), + outlinePx = labelOutlineStrokeWidth(iconScale), + vertical = LabelTypesetter.isVertical(labelWritingMode), + alignToPixel = false, + ) } } @@ -541,6 +571,9 @@ abstract class AbstractDefaultIcon( */ const val DEFAULT_LABEL_POSITION: String = "center center" + /** ラベルの既定の書字方向。横書き。書き方は [labelWritingMode] を参照。 */ + const val DEFAULT_LABEL_WRITING_MODE: String = LabelTypesetter.HORIZONTAL + /** `labelPosition` を横・縦それぞれ -1 / 0 / 1 に読み替える。読めない値は中央扱い。 */ internal fun parseLabelPosition(position: String): Pair { var h = 0 diff --git a/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt b/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt index 50b1002..614388a 100644 --- a/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/ColorDefaultIcon.kt @@ -32,6 +32,7 @@ class ColorDefaultIcon( labelTypeFace: Typeface = Typeface.DEFAULT, labelStrokeColor: Color = Color.White, labelPosition: String = DEFAULT_LABEL_POSITION, + labelWritingMode: String = DEFAULT_LABEL_WRITING_MODE, infoAnchor: Offset = Offset(0.5f, 0f), iconSize: Dp = Settings.Default.iconSize, debug: Boolean = false, @@ -48,6 +49,7 @@ class ColorDefaultIcon( labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, labelPosition = labelPosition, + labelWritingMode = labelWritingMode, infoAnchor = infoAnchor, iconSize = iconSize, debug = debug, @@ -81,6 +83,7 @@ class ColorDefaultIcon( labelTypeFace: Typeface = this.labelTypeFace, labelStrokeColor: Color = this.labelStrokeColor, labelPosition: String = this.labelPosition, + labelWritingMode: String = this.labelWritingMode, iconSize: Dp = this.iconSize, debug: Boolean = this.debug, ): ColorDefaultIcon = @@ -97,6 +100,7 @@ class ColorDefaultIcon( labelTypeFace = labelTypeFace, labelStrokeColor = labelStrokeColor, labelPosition = labelPosition, + labelWritingMode = labelWritingMode, iconSize = iconSize, debug = debug, ), diff --git a/src/main/java/com/mapconductor/core/marker/ImageIcon.kt b/src/main/java/com/mapconductor/core/marker/ImageIcon.kt index a592b77..8c55e3b 100644 --- a/src/main/java/com/mapconductor/core/marker/ImageIcon.kt +++ b/src/main/java/com/mapconductor/core/marker/ImageIcon.kt @@ -2,25 +2,145 @@ package com.mapconductor.core.marker import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import androidx.core.graphics.createBitmap import com.mapconductor.core.BitmapIconCache import com.mapconductor.core.ResourceProvider import com.mapconductor.settings.Settings +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.Typeface import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable +/** + * 画像をそのまま(大きさだけ合わせて)使うアイコン。既定のピンの形にはしない。 + * + * ## ラベル + * + * [label] を渡すと画像に文字を添える。書き方と既定値は [ColorDefaultIcon] と同じで、 + * ios-sdk / react-sdk の `ImageIcon` とも揃えてある。 + * + * - `left` / `right` は画像の外側に置き、縦は画像の上端・中間・下端に揃える。 + * - `center` 列の `top` / `bottom` は画像の上・下の外側に置く。 + * - `"center center"`(既定)は画像の中央に重ねる。 + * + * [labelWritingMode] に `"vertical-rl"` を渡すと縦書きになる([AbstractDefaultIcon.labelWritingMode])。 + * + * コンストラクタの `anchor` / `infoAnchor` は、ラベルの有無によらず**画像に対する割合**。 + * 公開している [anchor] / [infoAnchor] は、ラベルで広がった全体に対する割合へ直した値。 + * + * タイル描画は本体の画像と文字を別々に使うので、ラベル付きのアイコンをいくつ作っても、 + * 本体の画像は元の画像と大きさごとに 1 枚で済む。 + */ class ImageIcon( image: Drawable, override val iconSize: Dp = Settings.Default.iconSize, override val scale: Float = 1.0f, - override val anchor: Offset = Offset(0.5f, 0.5f), - override val infoAnchor: Offset = Offset(0.5f, 0.5f), + anchor: Offset = Offset(0.5f, 0.5f), + infoAnchor: Offset = Offset(0.5f, 0.5f), override val debug: Boolean = false, + val label: String? = null, + val labelTextColor: Color? = Color.Black, + val labelTextSize: TextUnit = 18.sp, + val labelTypeFace: Typeface = Typeface.DEFAULT, + val labelStrokeColor: Color = Color.White, + val labelPosition: String = AbstractDefaultIcon.DEFAULT_LABEL_POSITION, + val labelWritingMode: String = AbstractDefaultIcon.DEFAULT_LABEL_WRITING_MODE, ) : AndroidDrawableIcon( drawable = image, ) { + private val requestedAnchor = anchor + private val requestedInfoAnchor = infoAnchor + + private class Layout( + val canvasSize: Int, + val result: IconLabelLayout.Result, + val label: LabelTypesetter.Metrics?, + val textSizePx: Float, + val outlinePx: Float, + ) + + private val layout: Layout by lazy { + val canvasSize = (ResourceProvider.dpToPxForBitmap(iconSize.value) * scale).toInt() + val size = canvasSize.toFloat() + val textSizePx = LabelTypesetter.textSizePx(labelTextSize) + val outline = LabelTypesetter.outlinePx(scale) + val metrics = + label?.let { text -> + LabelTypesetter.measure( + text = text, + textSizePx = textSizePx, + typeface = labelTypeFace, + outlinePx = outline, + vertical = LabelTypesetter.isVertical(labelWritingMode), + ) + } + Layout( + canvasSize = canvasSize, + result = + IconLabelLayout.compute( + markerSize = canvasSize, + bodyLeft = 0f, + bodyRight = size, + bodyTop = 0f, + bodyBottom = size, + centerY = size / 2f, + sideCenterY = size / 2f, + label = metrics, + outlinePx = outline, + labelPosition = labelPosition, + ), + label = metrics, + textSizePx = textSizePx, + outlinePx = outline, + ) + } + + override val anchor: Offset by lazy { toBitmapFraction(requestedAnchor) } + + override val infoAnchor: Offset by lazy { toBitmapFraction(requestedInfoAnchor) } + + /** ビットマップの幅と高さが、画像(iconSize × scale の正方形)の何倍か。 */ + internal val extentRatio: Size by lazy { + val layout = layout + Size( + layout.result.bitmapWidth.toFloat() / layout.canvasSize, + layout.result.bitmapHeight.toFloat() / layout.canvasSize, + ) + } + + init { + // ラベル付きは、はみ出し量を作った時点で記録する([LabelledIconExtent])。 + // JVM の単体テストでは Paint が空実装なので、測れなければ記録しない。 + if (label != null) { + runCatching { layout }.getOrNull()?.let { layout -> + val result = layout.result + LabelledIconExtent.note( + width = result.bitmapWidth.toFloat(), + height = result.bitmapHeight.toFloat(), + anchorX = (result.markerOffsetX + requestedAnchor.x * layout.canvasSize) / result.bitmapWidth, + anchorY = (result.markerOffsetY + requestedAnchor.y * layout.canvasSize) / result.bitmapHeight, + ) + } + } + } + + private fun toBitmapFraction(requested: Offset): Offset { + val layout = layout + val result = layout.result + return Offset( + (result.markerOffsetX + requested.x * layout.canvasSize) / result.bitmapWidth, + (result.markerOffsetY + requested.y * layout.canvasSize) / result.bitmapHeight, + ) + } + private fun getDrawableIdentity(): Int = when (drawable) { is BitmapDrawable -> { @@ -51,47 +171,169 @@ class ImageIcon( return getDrawableIdentity() == other.getDrawableIdentity() && iconSize == other.iconSize && scale == other.scale && - anchor == other.anchor && - infoAnchor == other.infoAnchor && - debug == other.debug + requestedAnchor == other.requestedAnchor && + requestedInfoAnchor == other.requestedInfoAnchor && + debug == other.debug && + label == other.label && + labelTextColor == other.labelTextColor && + labelTextSize == other.labelTextSize && + labelTypeFace == other.labelTypeFace && + labelStrokeColor == other.labelStrokeColor && + labelPosition == other.labelPosition && + labelWritingMode == other.labelWritingMode } - override fun hashCode(): Int { + /** ラベルを除いた見た目の hash。本体の画像のキャッシュキーに使い、ラベル違いで共有する。 */ + private fun bodyHashCode(): Int { var result = getDrawableIdentity() result = 31 * result + iconSize.hashCode() result = 31 * result + scale.hashCode() - result = 31 * result + anchor.hashCode() - result = 31 * result + infoAnchor.hashCode() + result = 31 * result + requestedAnchor.hashCode() + result = 31 * result + requestedInfoAnchor.hashCode() result = 31 * result + debug.hashCode() return result } + override fun hashCode(): Int { + var result = bodyHashCode() + result = 31 * result + (label?.hashCode() ?: 0) + result = 31 * result + (labelTextColor?.hashCode() ?: 0) + result = 31 * result + labelTextSize.hashCode() + result = 31 * result + labelTypeFace.hashCode() + result = 31 * result + labelStrokeColor.hashCode() + result = 31 * result + labelPosition.hashCode() + result = 31 * result + labelWritingMode.hashCode() + return result + } + + internal val tileBody: BitmapIcon + get() { + val id = bodyHashCode() + BitmapIconCache.get(id)?.let { + return it + } + + val scaledSize = layout.canvasSize + + val bitmap = + this.toBitmap( + drawable = drawable, + width = scaledSize, + height = scaledSize, + ) + // 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 result = + BitmapIcon( + bitmap = bitmap, + anchor = requestedAnchor, + size = Size(scaledSize.toFloat(), scaledSize.toFloat()), + ) + BitmapIconCache.put(id, result) + return result + } + + internal val tileLabel: MarkerTileLabel? + get() { + val text = label ?: return null + val layout = layout + val metrics = layout.label ?: return null + val result = layout.result + val anchorX = result.markerOffsetX + requestedAnchor.x * layout.canvasSize + val anchorY = result.markerOffsetY + requestedAnchor.y * layout.canvasSize + return MarkerTileLabel( + text = text, + textSizePx = layout.textSizePx, + typeface = labelTypeFace, + textColor = (labelTextColor ?: Color.Black).toArgb(), + strokeColor = labelStrokeColor.toArgb(), + outlinePx = layout.outlinePx, + centerOffsetX = result.labelCenterX - anchorX, + centerOffsetY = result.labelCenterY - anchorY, + width = metrics.width, + height = metrics.height, + vertical = LabelTypesetter.isVertical(labelWritingMode), + ) + } + override fun toBitmapIcon(): BitmapIcon { - val id = hashCode() + val body = tileBody + val text = label ?: return body + val id = "image_icon_${hashCode()}".hashCode() BitmapIconCache.get(id)?.let { return it } - val scaledSize = ResourceProvider.dpToPxForBitmap(iconSize.value) * scale - - val bitmap = - this.toBitmap( - drawable = drawable, - width = scaledSize.toInt(), - height = scaledSize.toInt(), - ) - // Set bitmap density based on override (e.g., 1.0 for MapLibre to prevent auto-scaling) + val layout = layout + val result = layout.result + val bitmap = createBitmap(result.bitmapWidth, result.bitmapHeight) ResourceProvider.getBitmapDensity().let { density -> bitmap.density = (density * android.util.DisplayMetrics.DENSITY_DEFAULT).toInt() } + val canvas = Canvas(bitmap) + val left = Math.round(result.markerOffsetX) + val top = Math.round(result.markerOffsetY) + canvas.drawBitmap( + body.bitmap, + null, + Rect(left, top, left + layout.canvasSize, top + layout.canvasSize), + null, + ) + LabelTypesetter.draw( + canvas = canvas, + text = text, + centerX = result.labelCenterX, + centerY = result.labelCenterY, + textSizePx = layout.textSizePx, + typeface = labelTypeFace, + textColor = (labelTextColor ?: Color.Black).toArgb(), + strokeColor = labelStrokeColor.toArgb(), + outlinePx = layout.outlinePx, + vertical = LabelTypesetter.isVertical(labelWritingMode), + alignToPixel = false, + ) - val result = + val icon = BitmapIcon( bitmap = bitmap, anchor = anchor, - size = Size(scaledSize.toFloat(), scaledSize.toFloat()), + size = Size(result.bitmapWidth.toFloat(), result.bitmapHeight.toFloat()), ) - BitmapIconCache.put(id, result) - return result + BitmapIconCache.put(id, icon) + return icon } + + fun copy( + image: Drawable = this.drawable, + iconSize: Dp = this.iconSize, + scale: Float = this.scale, + anchor: Offset = this.requestedAnchor, + infoAnchor: Offset = this.requestedInfoAnchor, + debug: Boolean = this.debug, + label: String? = this.label, + labelTextColor: Color? = this.labelTextColor, + labelTextSize: TextUnit = this.labelTextSize, + labelTypeFace: Typeface = this.labelTypeFace, + labelStrokeColor: Color = this.labelStrokeColor, + labelPosition: String = this.labelPosition, + labelWritingMode: String = this.labelWritingMode, + ): ImageIcon = + ImageIcon( + image = image, + iconSize = iconSize, + scale = scale, + anchor = anchor, + infoAnchor = infoAnchor, + debug = debug, + label = label, + labelTextColor = labelTextColor, + labelTextSize = labelTextSize, + labelTypeFace = labelTypeFace, + labelStrokeColor = labelStrokeColor, + labelPosition = labelPosition, + labelWritingMode = labelWritingMode, + ) } diff --git a/src/main/java/com/mapconductor/core/marker/MarkerIconExtent.kt b/src/main/java/com/mapconductor/core/marker/MarkerIconExtent.kt index 4b587fb..f0b9aeb 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerIconExtent.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerIconExtent.kt @@ -7,7 +7,7 @@ import com.mapconductor.core.InternalMapConductorApi * アイコンが画面上で占める矩形が、ピン本体(iconSize × scale の正方形)の何倍か。 * `anchor` / `infoAnchor` はこの矩形に対する割合なので、当たり判定と吹き出しはこれを掛けて使う。 * - * 既定アイコンはラベルの分だけビットマップが広いことがあるのでその倍率を返し、 + * 既定アイコンと画像アイコンはラベルの分だけビットマップが広いことがあるのでその倍率を返し、 * それ以外のアイコンは従来どおり正方形(1 × 1)として扱う。 * react-sdk の `markerIconExtentPx`、ios-sdk の `MarkerIconMetrics` に対応する。 */ @@ -15,5 +15,10 @@ import com.mapconductor.core.InternalMapConductorApi object MarkerIconExtent { private val SQUARE = Size(1f, 1f) - fun ratio(icon: MarkerIconInterface): Size = (icon as? AbstractDefaultIcon)?.extentRatio ?: SQUARE + fun ratio(icon: MarkerIconInterface): Size = + when (icon) { + is AbstractDefaultIcon -> icon.extentRatio + is ImageIcon -> icon.extentRatio + else -> SQUARE + } } diff --git a/src/main/java/com/mapconductor/core/marker/MarkerTileLabel.kt b/src/main/java/com/mapconductor/core/marker/MarkerTileLabel.kt new file mode 100644 index 0000000..1d97f19 --- /dev/null +++ b/src/main/java/com/mapconductor/core/marker/MarkerTileLabel.kt @@ -0,0 +1,372 @@ +package com.mapconductor.core.marker + +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.ResourceProvider +import java.text.BreakIterator +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface + +/** + * タイルに描くとき、アイコン本体とは別に描くラベル。 + * + * ## なぜ分けるか + * + * ラベルを焼き込んだビットマップは、文字列と置き場所の組の数だけ要る。 + * 街路樹のように「樹種 × 向き」でラベルを付け替えると、そのたびに画像を作ることになる。 + * 本体(点やピン)だけを焼き込み、文字はタイルを描くときに直接描けば、画像は本体の種類だけで済む。 + * + * ネイティブマーカーとして描くときは、従来どおり焼き込んだビットマップ(`toBitmapIcon()`)を使う。 + * どちらも配置は [IconLabelLayout] の同じ計算から出す。ios-sdk の `MarkerTileLabel` と同じ。 + * + * 長さはすべてアイコンのビットマップの px(`BitmapIcon.size` と同じ単位)。タイルではさらに描画倍率を掛ける。 + */ +@InternalMapConductorApi +internal class MarkerTileLabel( + val text: String, + val textSizePx: Float, + val typeface: Typeface, + val textColor: Int, + val strokeColor: Int, + val outlinePx: Float, + /** ラベルの中心の、アンカー点からのずれ。 */ + val centerOffsetX: Float, + val centerOffsetY: Float, + /** 縁取り込みの大きさ。 */ + val width: Float, + val height: Float, + val vertical: Boolean, +) { + /** + * アンカー点 ([anchorX], [anchorY]) を基準に、[scale] 倍で描く。 + * + * 書き出し位置は整数の画素に揃える。タイルの原点も整数の画素なので、 + * 境界をまたぐラベルは両側のタイルが同じ画素から描き、継ぎ目で字が割れない。 + */ + fun draw( + canvas: Canvas, + anchorX: Float, + anchorY: Float, + scale: Float, + ) { + LabelTypesetter.draw( + canvas = canvas, + text = text, + centerX = anchorX + centerOffsetX * scale, + centerY = anchorY + centerOffsetY * scale, + textSizePx = textSizePx * scale, + typeface = typeface, + textColor = textColor, + strokeColor = strokeColor, + outlinePx = outlinePx * scale, + vertical = vertical, + alignToPixel = true, + ) + } +} + +/** + * 本体とラベルを分けて渡せるアイコンなら、その 2 つ。それ以外は null。 + * + * タイル描画は本体を描き、ラベルをタイルに直接描く。どちらも焼き込みのビットマップを作らずに + * 得られるので、ラベル付きのアイコンを大量に作っても、ネイティブマーカーとして描かれない限り + * 画像は増えない。型で振り分けるのは [MarkerIconExtent] と同じで、アプリ向けの公開 API に + * 実装点を出さないため。 + */ +@InternalMapConductorApi +internal fun MarkerIconInterface.tileParts(): Pair? = + when (this) { + is AbstractDefaultIcon -> tileBody to tileLabel + is ImageIcon -> tileBody to tileLabel + else -> null + } + +/** + * ラベル付きのアイコンのはみ出し量(アンカーからいちばん遠い辺まで、ビットマップの px)の、 + * プロセス全体での最大。 + * + * ラベル付きのアイコンは作った時点で配置を計算するので、ここへ記録する。タイル描画は、 + * 隣のタイルからはみ出してくるラベルを取りこぼさないよう、この値で探す範囲を広げる + * ([MarkerTileRenderer])。マーカーに付ける前にアイコンは作られるので、タイルが描き直される + * 時点ではもう記録されている。 + * + * 小さくはならない。大きなラベルを一度作ると、以後は少し広めに探す。ios-sdk と同じ。 + */ +@InternalMapConductorApi +internal object LabelledIconExtent { + @Volatile + var maxPx: Double = 0.0 + private set + + fun note( + width: Float, + height: Float, + anchorX: Float, + anchorY: Float, + ) { + val extent = + maxOf( + max(width * anchorX, width * (1f - anchorX)), + max(height * anchorY, height * (1f - anchorY)), + ).toDouble() + synchronized(this) { + if (extent > maxPx) maxPx = extent + } + } +} + +/** + * ラベルの文字の測り方と描き方。焼き込み([AbstractDefaultIcon] / [ImageIcon])とタイル + * ([MarkerTileLabel])の両方がここを通るので、どちらで描いても同じ字面になる。 + * + * ## 縦書き + * + * `labelWritingMode` は CSS の `writing-mode` と同じ書き方で、`vertical-rl` / `vertical-lr` を + * 縦書きとして扱う(1 行しかないので左右の違いは出ない)。それ以外は横書き。 + * + * Canvas には縦組みの機能が無いので、OpenType の縦書き用字形(`vert`)を有効にして、 + * 1 文字ずつ 1em の升に積む。長音の「ー」は縦棒に、小書きの「ョ」は縦書きの位置になる + * (ios-sdk は CoreText の縦組みで同じ字面を出す)。横の通りに沿って並ぶ街路樹のように、 + * 横書きでは幅を取りすぎて並ばないラベルに使う。 + */ +@InternalMapConductorApi +internal object LabelTypesetter { + const val HORIZONTAL: String = "horizontal-tb" + + /** ラベルの文字の大きさ(px)。sp と em は文字の拡大設定に従い、それ以外は dp として読む。 */ + fun textSizePx(textUnit: TextUnit): Float = + when (textUnit.type) { + TextUnitType.Sp -> ResourceProvider.spToPx(textUnit.value.toDouble()).toFloat() + TextUnitType.Em -> ResourceProvider.spToPx(16.0 * textUnit.value).toFloat() + else -> ResourceProvider.dpToPx(textUnit.value.toDouble()).toFloat() + } + + /** 縁取りの太さ(アイコンの拡大率を考慮、最小 2px)。 */ + fun outlinePx(iconScale: Float): Float = max(ResourceProvider.dpToPxForBitmap(1f * iconScale).toFloat(), 2f) + + fun isVertical(writingMode: String): Boolean { + val mode = writingMode.trim().lowercase() + return mode == "vertical-rl" || mode == "vertical-lr" + } + + /** + * 縁取り込みの大きさ。[contentWidth] は縁取りを除いた字面の幅(横書きは文字列の幅、 + * 縦書きは列の幅)で、ラベルをピンに重ねるときのビットマップの幅に使う。 + */ + @InternalMapConductorApi + class Metrics( + val width: Float, + val height: Float, + val contentWidth: Float, + ) + + fun measure( + text: String, + textSizePx: Float, + typeface: Typeface, + outlinePx: Float, + vertical: Boolean, + ): Metrics { + val paint = textPaint(textSizePx, typeface, 0, vertical) + if (!vertical) { + val width = paint.measureText(text) + val fm = paint.fontMetrics + return Metrics(width + outlinePx * 2f, fm.descent - fm.ascent + outlinePx * 2f, width) + } + val glyphs = graphemes(text) + var column = 0f + for (glyph in glyphs) column = max(column, paint.measureText(glyph)) + column = ceil(max(column, textSizePx * VERTICAL_COLUMN_MIN_EM)) + return Metrics(column + outlinePx * 2f, glyphs.size * textSizePx + outlinePx * 2f, column) + } + + /** 中心 ([centerX], [centerY]) に描く。縁取りを先に、塗りを後に重ねる。 */ + fun draw( + canvas: Canvas, + text: String, + centerX: Float, + centerY: Float, + textSizePx: Float, + typeface: Typeface, + textColor: Int, + strokeColor: Int, + outlinePx: Float, + vertical: Boolean, + alignToPixel: Boolean, + ) { + val fill = textPaint(textSizePx, typeface, textColor, vertical) + val outline = + Paint(fill).apply { + style = Paint.Style.STROKE + strokeWidth = outlinePx + color = strokeColor + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + val fm = fill.fontMetrics + + fun align(value: Float): Float = if (alignToPixel) value.roundToInt().toFloat() else value + + if (!vertical) { + val width = fill.measureText(text) + val baseline = align(centerY + (fm.descent - fm.ascent) / 2f - fm.descent) + // 中央揃えで描くので、左端を画素に揃えてから中央へ戻す。 + val x = align(centerX - width / 2f) + width / 2f + canvas.drawText(text, x, baseline, outline) + canvas.drawText(text, x, baseline, fill) + return + } + + val glyphs = graphemes(text) + val em = textSizePx + val x = align(centerX) + val top = align(centerY - glyphs.size * em / 2f) + // 1em の升の中央に字面の中央が来るベースライン。 + val baselineInCell = em / 2f - (fm.ascent + fm.descent) / 2f + for ((index, glyph) in glyphs.withIndex()) { + val baseline = top + index * em + baselineInCell + canvas.drawText(glyph, x, baseline, outline) + canvas.drawText(glyph, x, baseline, fill) + } + } + + private fun textPaint( + textSizePx: Float, + typeface: Typeface, + color: Int, + vertical: Boolean, + ): Paint = + Paint().apply { + this.color = color + this.textSize = textSizePx + textAlign = Paint.Align.CENTER + this.typeface = typeface + isAntiAlias = true + isSubpixelText = true + if (vertical) fontFeatureSettings = "'vert' 1" + } + + /** 書記素(見た目の 1 文字)ごとに分ける。結合文字やサロゲートペアを割らない。 */ + private fun graphemes(text: String): List { + val iterator = BreakIterator.getCharacterInstance() + iterator.setText(text) + val result = ArrayList(text.length) + var start = iterator.first() + var end = iterator.next() + while (end != BreakIterator.DONE) { + result.add(text.substring(start, end)) + start = end + end = iterator.next() + } + return result + } + + /** 縦書きの列の幅の下限(em)。細い字だけの列でも、隣の列や点と間を空ける。 */ + private const val VERTICAL_COLUMN_MIN_EM = 1f +} + +/** + * アイコンのビットマップの中での、本体とラベルの配置。すべて px。 + * + * 既定のピンと画像アイコンで共通の計算。react-sdk の `computeIconLabelLayout`、 + * ios-sdk の `DefaultIconLayout` と同じ。 + */ +@InternalMapConductorApi +internal object IconLabelLayout { + @InternalMapConductorApi + class Result( + val bitmapWidth: Int, + val bitmapHeight: Int, + val markerOffsetX: Float, + val markerOffsetY: Float, + val labelCenterX: Float, + val labelCenterY: Float, + ) + + /** + * @param markerSize 本体の正方形の一辺。 + * @param bodyLeft 〜 [bodyBottom] ラベルを避けて置く、本体の描かれている範囲(本体の左上が原点)。 + * @param centerY `center center` のときのラベルの縦位置。 + * @param sideCenterY 左右に置き、縦を指定しないときのラベルの縦位置。 + * @param label ラベルの寸法。無ければ本体だけ。 + */ + fun compute( + markerSize: Int, + bodyLeft: Float, + bodyRight: Float, + bodyTop: Float, + bodyBottom: Float, + centerY: Float, + sideCenterY: Float, + label: LabelTypesetter.Metrics?, + outlinePx: Float, + labelPosition: String, + ): Result { + val size = markerSize.toFloat() + if (label == null) { + return Result(markerSize, markerSize, 0f, 0f, size / 2f, centerY) + } + val padding = size * 0.1f // 左右に5%ずつの余白 + val labelWidth = label.width + val labelHeight = label.height + val (h, v) = AbstractDefaultIcon.parseLabelPosition(labelPosition) + val gap = max(outlinePx, size * 0.05f) + + // 本体の左上を原点とした座標でラベルの中心を決める。 + val cx = + when { + h < 0 -> bodyLeft - gap - labelWidth / 2f + h > 0 -> bodyRight + gap + labelWidth / 2f + else -> size / 2f + } + val cy = + if (h != 0) { + // 横に置くときは、縦は本体の上端・指定の中心・下端に揃える。 + when { + v < 0 -> bodyTop + labelHeight / 2f + v > 0 -> bodyBottom - labelHeight / 2f + else -> sideCenterY + } + } else { + // 中央列の top / bottom は本体と重ならないよう上下の外側に置く。 + when { + v < 0 -> bodyTop - gap - labelHeight / 2f + v > 0 -> bodyBottom + gap + labelHeight / 2f + else -> centerY + } + } + + val bitmapWidth: Int + val markerOffsetX: Float + if (h == 0) { + // 中央なら従来どおり(出力を変えない)。 + bitmapWidth = max(size, label.contentWidth + 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 Result( + bitmapWidth = bitmapWidth, + bitmapHeight = bitmapHeight, + markerOffsetX = markerOffsetX, + markerOffsetY = markerOffsetY, + labelCenterX = markerOffsetX + cx, + labelCenterY = markerOffsetY + cy, + ) + } +} diff --git a/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt b/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt index 0ba3133..c0190c6 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt @@ -186,6 +186,51 @@ class MarkerTileRenderer( ) } + /** + * ズームごとの、アイコンに掛かった倍率の最大(`iconScaleCallback` × [extraIconScale])。 + * [LabelledIconExtent] の px(アイコンのビットマップの px)をタイルの px へ直すのに使う。 + */ + private val maxScaleByZoom = HashMap() + + /** + * 倍率をまだ見ていないズームで `iconScaleCallback` に渡す見本。どこかのズームで描いた + * マーカーを 1 つ覚えておく。一斉に描き直すとき、そのズームの最初のタイルたちは + * 倍率を誰も記録していないので、これが無いと育っていない値で探すことになる。 + */ + private var sampleState: MarkerState? = null + + /** + * 探す範囲に使う、はみ出し量(px)。 + * + * [observedHalfExtentPx] は「これまでに描いたタイルで見た最大」なので、ラベルを付けた + * 直後に全タイルが一斉に描き直されると、まだ育っていない値で探すタイルが出る。 + * そのタイルに自分のマーカーが無ければ空として返し、2 回目の探索もしないので、 + * 隣から**はみ出してくるラベルが境目で切れる**。ラベル付きのアイコンは作った時点で + * はみ出し量を [LabelledIconExtent] に記録しているので、それも使う。 + * 全マーカーを数え直す方法は、14.4 万本で 1 回あたり秒単位になるので採らない(ios-sdk で実測)。 + */ + private fun queryHalfExtentPx(zoom: Int): Double { + val scale: Double? = + synchronized(maxScaleByZoom) { + maxScaleByZoom[zoom] ?: sampleState?.let { sample -> + (iconScaleCallback?.invoke(sample, zoom) ?: 1.0).coerceAtLeast(0.0) * extraIconScale + } + } + if (scale == null) return observedHalfExtentPx + return max(observedHalfExtentPx, LabelledIconExtent.maxPx * scale) + } + + private fun noteScale( + scale: Double, + zoom: Int, + state: MarkerState, + ) { + synchronized(maxScaleByZoom) { + if (scale > (maxScaleByZoom[zoom] ?: 0.0)) maxScaleByZoom[zoom] = scale + if (sampleState == null) sampleState = state + } + } + override fun renderTile(request: TileRequest): ByteArray? { val zoomInt = request.z val worldTileCount = 1 shl zoomInt @@ -231,6 +276,9 @@ class MarkerTileRenderer( val drawW: Float, val drawH: Float, val anchor: Offset, + /** 本体とは別に、本体をすべて描いたあとで描く文字。 */ + val label: MarkerTileLabel?, + val scale: Float, ) fun prepareMarkers(entities: List>): Pair, Double> { @@ -238,7 +286,11 @@ class MarkerTileRenderer( val prepared = ArrayList(entities.size) for (entity in entities) { val stateIcon = entity.state.icon - val icon = stateIcon?.toBitmapIcon() ?: defaultIcon.toBitmapIcon() + // 本体とラベルを分けて渡せるアイコンは、ラベルを焼き込んだ画像を作らせない。 + // 本体だけを焼き込み、文字はタイルに直接描く([MarkerTileLabel])。 + val parts = stateIcon?.tileParts() + val icon = parts?.first ?: stateIcon?.toBitmapIcon() ?: defaultIcon.toBitmapIcon() + val label = parts?.second val pos = entity.state.position val tilePoint = geoToTilePoint(pos.longitude, pos.latitude, zoomInt) val centerNorm = @@ -252,6 +304,7 @@ class MarkerTileRenderer( // icon.size already includes MarkerIconInterface.scale (baked into the // bitmap by toBitmapIcon), so it must not be applied again here. val scale = (callbackScale * extraIconScale).coerceAtLeast(0.0) + noteScale(scale, zoomInt, entity.state) val drawW = (icon.size.width.toDouble() * scale).coerceAtLeast(1.0) val drawH = (icon.size.height.toDouble() * scale).coerceAtLeast(1.0) val anchorX = icon.anchor.x.toDouble() @@ -261,12 +314,19 @@ class MarkerTileRenderer( kotlin.math.abs(drawW * anchorX), kotlin.math.abs(drawW * (1.0 - anchorX)), ) - val halfExtentY = + var halfExtentY = max( kotlin.math.abs(drawH * anchorY), kotlin.math.abs(drawH * (1.0 - anchorY)), ) - maxHalfExtentPx = max(maxHalfExtentPx, max(halfExtentX, halfExtentY)) + var halfExtent = max(halfExtentX, halfExtentY) + if (label != null) { + // ラベルもはみ出しに数える。境界をまたぐ文字を隣のタイルも描くため。 + halfExtentY = (kotlin.math.abs(label.centerOffsetY) + label.height / 2f) * scale + val labelX = (kotlin.math.abs(label.centerOffsetX) + label.width / 2f) * scale + halfExtent = maxOf(halfExtent, labelX, halfExtentY) + } + maxHalfExtentPx = max(maxHalfExtentPx, halfExtent) prepared.add( PreparedMarker( bitmap = icon.bitmap, @@ -274,6 +334,8 @@ class MarkerTileRenderer( drawW = drawW.toFloat(), drawH = drawH.toFloat(), anchor = icon.anchor, + label = label, + scale = scale.toFloat(), ), ) } @@ -341,7 +403,7 @@ class MarkerTileRenderer( // every tile measured, so the "conservative first pass" never paid off // and query+prepare simply ran twice for every tile. Widening it as // soon as one tile knows better costs one extra pass in total. - val assumedHalfExtentPx = observedHalfExtentPx + val assumedHalfExtentPx = queryHalfExtentPx(zoomInt) var entities = queryByHalfExtentPx(assumedHalfExtentPx) @@ -495,18 +557,44 @@ class MarkerTileRenderer( java.util.Arrays.sort(sortedGroups) val lastInGroup = IntArray(sortedGroups.size) { -1 } val mixedIcons = BooleanArray(sortedGroups.size) + // ラベル付きを先に残す。ラベルは利用者が「この点の名前を見せたい」と選んだもので、 + // 同じマス目の点のどれかと入れ替えてよいものではない。ラベル付きどうしは世界座標 + // (worldY 優先、次に worldX)で決める。列挙順はタイルごとに違うので、順番で決めると + // 隣のタイルと別の勝者を選び、境界でラベルが割れる。 + val labelledWinner = IntArray(sortedGroups.size) { -1 } + + fun worldKey(index: Int): Double { + val m = prepared[index] + return (m.centerNorm.y + tileY) * tilePx * 1_048_576.0 + (m.centerNorm.x + tileX) * tilePx + } for (index in prepared.indices) { val groupIndex = java.util.Arrays.binarySearch(sortedGroups, groups[index]) val previous = lastInGroup[groupIndex] - if (previous >= 0 && icons[previous] !== icons[index]) { + if (previous >= 0 && + ( + icons[previous] !== icons[index] || + prepared[previous].label != null || + prepared[index].label != null + ) + ) { + // 本体が同じでも文字が違えば重ねたまま描く(間引きなしのとき)。 mixedIcons[groupIndex] = true } lastInGroup[groupIndex] = index + if (prepared[index].label != null) { + val winner = labelledWinner[groupIndex] + if (winner < 0 || worldKey(index) > worldKey(winner)) labelledWinner[groupIndex] = index + } } val draw = BooleanArray(prepared.size) for (index in prepared.indices) { val groupIndex = java.util.Arrays.binarySearch(sortedGroups, groups[index]) - draw[index] = lastInGroup[groupIndex] == index || (!declutter && mixedIcons[groupIndex]) + draw[index] = + if (declutter && labelledWinner[groupIndex] >= 0) { + labelledWinner[groupIndex] == index + } else { + lastInGroup[groupIndex] == index || (!declutter && mixedIcons[groupIndex]) + } } for (index in prepared.indices) { @@ -523,6 +611,19 @@ class MarkerTileRenderer( dstRect.set(left, top, left + width, top + height) canvas.drawBitmap(icon, null, dstRect, bmpPaint) } + + // 文字は本体をすべて描いたあと。ほかの点の下に字が隠れない。 + for (index in prepared.indices) { + if (!draw[index]) continue + val m = prepared[index] + val label = m.label ?: continue + label.draw( + canvas = canvas, + anchorX = (m.centerNorm.x * tilePx + paddingPx).toFloat(), + anchorY = (m.centerNorm.y * tilePx + paddingPx).toFloat(), + scale = m.scale, + ) + } } val finalBitmap = acquireBitmap(tilePxInt)