From 41f236536775a496d3d5ee6e2357f83923469edc Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Thu, 5 Nov 2026 16:06:25 -0800 Subject: [PATCH] Draw marker tiles to the size of the rounded camera zoom Map SDKs differ in which zoom of tile they show at a fractional camera zoom. MapLibre, Mapbox, HERE and ArcGIS round it; Google Maps and OpenMobileMaps floor it. At 17.75 the first show z18 at 0.84x and the second z17 at 1.68x, so the same icons and labels came out twice as large on the second group (measured on a Pixel 5a and a TB520FU). MarkerTileRenderer takes an optional displayTileZoom: the camera zoom rounded. Given it, a tile of another zoom is drawn so that it looks like the rounded zoom's tile once the map has stretched it: icons, labels, declutter cells and the query padding all scale by 2^(z - displayZoom), and the icon scale callback is asked for the rounded zoom. The renderer drops its own cached tiles when the value changes; the map's cache is the caller's. --- api/android-sdk-core.api | 4 +- .../core/marker/MarkerTileRenderer.kt | 54 +++++++++++++++---- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index f1fcb26..465734e 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -1864,8 +1864,8 @@ public interface com.mapconductor.core.marker.MarkerTileRasterLayerCallback { public abstract java.lang.Object onRasterLayerUpdate(com.mapconductor.core.raster.RasterLayerState, kotlin.coroutines.Continuation); } public final class com.mapconductor.core.marker.MarkerTileRenderer implements com.mapconductor.core.tileserver.TileProviderInterface { - public com.mapconductor.core.marker.MarkerTileRenderer(com.mapconductor.core.marker.MarkerManager, int, int, boolean, kotlin.jvm.functions.Function2, double, int); - public com.mapconductor.core.marker.MarkerTileRenderer(com.mapconductor.core.marker.MarkerManager, int, int, boolean, kotlin.jvm.functions.Function2, double, int, int, kotlin.jvm.internal.DefaultConstructorMarker); + public com.mapconductor.core.marker.MarkerTileRenderer(com.mapconductor.core.marker.MarkerManager, int, int, boolean, kotlin.jvm.functions.Function2, double, int, kotlin.jvm.functions.Function0); + public com.mapconductor.core.marker.MarkerTileRenderer(com.mapconductor.core.marker.MarkerManager, int, int, boolean, kotlin.jvm.functions.Function2, double, int, kotlin.jvm.functions.Function0, int, kotlin.jvm.internal.DefaultConstructorMarker); public final com.mapconductor.core.marker.MarkerManager getMarkerManager(); public final int getTileSize(); public final double getExtraIconScale(); diff --git a/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt b/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt index c0190c6..0357e52 100644 --- a/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt +++ b/src/main/java/com/mapconductor/core/marker/MarkerTileRenderer.kt @@ -56,10 +56,29 @@ class MarkerTileRenderer( val extraIconScale: Double = 1.0, /** See [MarkerTilingOptions.declutterPx]. Zero draws every marker. */ private val declutterPx: Int = 0, + /** + * カメラのズームを四捨五入したズーム。null なら頼まれたタイルのズームのまま描く。 + * + * 地図 SDK がどのズームのタイルを出すかは SDK ごとに違う。MapLibre 系・Mapbox・HERE・ + * ArcGIS はカメラのズームを四捨五入したズームのタイルを出すが、Google Maps と + * OpenMobileMaps は切り捨てたズームのタイルを出す。ズーム 17.75 なら前者は z18 を + * 0.84 倍、後者は z17 を 1.68 倍に伸ばして出すので、同じ大きさで描いたマーカーと + * ラベルが 2 倍違って見える(Pixel 5a で実測)。 + * + * これを渡すと、ほかのズームのタイルを頼まれても、四捨五入したズームのタイルと同じ + * 大きさに見えるように描く。切り捨てる SDK で z17 を頼まれたときカメラが 17.75 なら、 + * z18 で描く大きさの半分で描く。値が変わったらタイルの描き直しが要る + * (内側の描き置きはここで捨てるが、地図 SDK の側のキャッシュは呼び出し側が捨てる)。 + */ + private val displayTileZoom: (() -> Int?)? = null, ) : TileProviderInterface { @Volatile private var cacheVersion: Int = 0 + /** 前のタイルを描いたときの [displayTileZoom]。変わったら描き置きを捨てる。 */ + @Volatile + private var lastDisplayZoom: Int? = null + private val tileCache: LruCache = object : LruCache(cacheSizeBytes) { override fun sizeOf( @@ -209,7 +228,10 @@ class MarkerTileRenderer( * はみ出し量を [LabelledIconExtent] に記録しているので、それも使う。 * 全マーカーを数え直す方法は、14.4 万本で 1 回あたり秒単位になるので採らない(ios-sdk で実測)。 */ - private fun queryHalfExtentPx(zoom: Int): Double { + private fun queryHalfExtentPx( + zoom: Int, + contentScale: Double, + ): Double { val scale: Double? = synchronized(maxScaleByZoom) { maxScaleByZoom[zoom] ?: sampleState?.let { sample -> @@ -217,7 +239,7 @@ class MarkerTileRenderer( } } if (scale == null) return observedHalfExtentPx - return max(observedHalfExtentPx, LabelledIconExtent.maxPx * scale) + return max(observedHalfExtentPx, LabelledIconExtent.maxPx * scale * contentScale) } private fun noteScale( @@ -238,6 +260,19 @@ class MarkerTileRenderer( val normalizedX = normalizeTileX(request.x, worldTileCount) val tileYInt = request.y + // [displayTileZoom] のズームのタイルと同じ大きさに見せる。地図はこのタイルを + // 2^(カメラ - zoomInt) 倍で出すので、中身を 2^(zoomInt - displayZoom) 倍で描けば、 + // 見た目は 2^(カメラ - displayZoom) 倍になり、四捨五入する SDK と揃う。 + // アイコンの倍率もそのズームで問い合わせる。 + val displayZoom = displayTileZoom?.invoke() + if (displayZoom != lastDisplayZoom) { + lastDisplayZoom = displayZoom + invalidate() + } + val scaleZoom = displayZoom ?: zoomInt + val contentScale = 2.0.pow(zoomInt - scaleZoom) + val declutterCellPx = declutterPx * contentScale + val cacheVersionSnapshot = cacheVersion val cacheKey = tileCacheKey( @@ -299,12 +334,13 @@ class MarkerTileRenderer( y = tilePoint.y - tileY, ) val callbackScale = - (iconScaleCallback?.invoke(entity.state, zoomInt) ?: 1.0) + (iconScaleCallback?.invoke(entity.state, scaleZoom) ?: 1.0) .coerceAtLeast(0.0) // 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 zoomScale = (callbackScale * extraIconScale).coerceAtLeast(0.0) + noteScale(zoomScale, scaleZoom, entity.state) + val scale = zoomScale * contentScale 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() @@ -353,7 +389,7 @@ class MarkerTileRenderer( // a marker and the two would not be the same one, which is an icon // cut at the seam. Growing by the cell as well means every cell that // reaches this tile is here in full, so both tiles see all of it. - val padNorm = ((halfExtentPx + declutterPx) / tilePx).coerceAtLeast(0.0) + val padNorm = ((halfExtentPx + declutterCellPx) / tilePx).coerceAtLeast(0.0) val latPad = span.latitude * padNorm val lonPad = span.longitude * padNorm val extended = bounds.expandedByDegrees(latPad, lonPad) @@ -383,7 +419,7 @@ class MarkerTileRenderer( // would ask for a coarser cell here than there. val separationDegrees = if (declutterPx > 0) { - max(span.latitude, span.longitude) * declutterPx / tilePx + max(span.latitude, span.longitude) * declutterCellPx / tilePx } else { 0.0 } @@ -403,7 +439,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 = queryHalfExtentPx(zoomInt) + val assumedHalfExtentPx = queryHalfExtentPx(scaleZoom, contentScale) var entities = queryByHalfExtentPx(assumedHalfExtentPx) @@ -480,7 +516,7 @@ class MarkerTileRenderer( val groups = LongArray(prepared.size) val icons = arrayOfNulls(prepared.size) val declutter = declutterPx > 0 - val cell = declutterPx.coerceAtLeast(1).toDouble() + val cell = declutterCellPx.coerceAtLeast(contentScale) // The cell the tile's own corner falls in. Subtracting it keeps the // group numbers inside the 20 bits [cellKeyOf] packs them into, // without moving the grid: two tiles still agree about which markers