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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/android-for-arcgis.api
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ public final class com.mapconductor.arcgis.GeoPointKt {
public final class com.mapconductor.arcgis.GeoRectBoundsKt {
public static final com.arcgismaps.geometry.Envelope toEnvelope(com.mapconductor.core.features.GeoRectBounds);
}
public final class com.mapconductor.arcgis.IdentityWeakTable<K, V> {
public com.mapconductor.arcgis.IdentityWeakTable();
public final V get(K);
public final void put(K, V);
public final int getSize();
}
public final class com.mapconductor.arcgis.MapCameraPositionKt {
public static final double EARTH_MEAN_RADIUS_METERS;
public static final double DEFAULT_MAX_GMAPS_TILT;
Expand All @@ -361,6 +367,13 @@ public final class com.mapconductor.arcgis.MapCameraPositionKt {
public static final com.arcgismaps.mapping.view.Camera withZoomLevel(com.arcgismaps.mapping.view.Camera, double);
public static final com.mapconductor.core.map.MapCameraPosition toMapCameraPosition(com.arcgismaps.mapping.view.Camera);
}
public final class com.mapconductor.arcgis.WebMercatorTileLevels {
public static final com.mapconductor.arcgis.WebMercatorTileLevels INSTANCE;
public static final int DEFAULT_DPI;
public final double unifiedZoomForLevel(int, int);
public final double resolution(int, int);
public final double scale(double);
}
public final class com.mapconductor.arcgis.WrapMapView extends android.widget.FrameLayout {
public static final com.mapconductor.arcgis.WrapMapView$Companion Companion;
public com.arcgismaps.mapping.view.MapView arcGISMapView;
Expand Down
32 changes: 28 additions & 4 deletions src/main/java/com/mapconductor/arcgis/ArcGISOverlayStacking.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.mapconductor.arcgis
import com.arcgismaps.mapping.layers.Layer
import com.mapconductor.core.controller.OverlayKind
import com.mapconductor.core.controller.OverlayStacking
import java.util.WeakHashMap
import java.lang.ref.WeakReference

/**
* 重なり順([OverlayStacking])。ArcGIS では:
Expand All @@ -18,7 +18,7 @@ import java.util.WeakHashMap
* 下になる(SDK の制約。マーカーの帯には置けない)。operational layer の中では一番上に置く。
*/
internal object ArcGISOverlayStacking {
private val keys = WeakHashMap<Layer, OverlayStacking.Key>()
private val keys = IdentityWeakTable<Layer, OverlayStacking.Key>()

/** マーカーのタイル: operational layer の中で一番上。 */
val MARKER_TILE = OverlayStacking.Key(OverlayKind.Marker, Int.MIN_VALUE)
Expand All @@ -36,7 +36,7 @@ internal object ArcGISOverlayStacking {
) {
val before =
synchronized(keys) {
keys[layer] = key
keys.put(layer, key)
OverlayStacking.insertBefore(layers.toList(), key) { keys[it] }
}
val index = before?.let { layers.indexOf(it) } ?: -1
Expand All @@ -48,6 +48,30 @@ internal object ArcGISOverlayStacking {
old: Layer,
new: Layer,
) {
synchronized(keys) { keys[old]?.let { keys[new] = it } }
synchronized(keys) { keys[old]?.let { keys.put(new, it) } }
}
}

/**
* 層 → 値の表。**同一性(===)で引き**、層は弱参照で持つ。
*
* 以前は `WeakHashMap<Layer, …>` だったが、ArcGIS の `Layer` は `equals` / `hashCode` を
* 上書きしていて、載せた後に同じ層を引いても見つからないことがあった(3D で、GroundImage を
* 先に載せてからラスターを足すと、GroundImage の鍵が引けずにラスターが一番上に積まれた)。
* 層の数は高々数十なので線形探索でよい。呼び出し側で同期する。
*/
internal class IdentityWeakTable<K : Any, V : Any> {
private val entries = ArrayList<Pair<WeakReference<K>, V>>()

operator fun get(key: K): V? = entries.firstOrNull { it.first.get() === key }?.second

fun put(
key: K,
value: V,
) {
entries.removeAll { it.first.get().let { k -> k == null || k === key } }
entries.add(WeakReference(key) to value)
}

val size: Int get() = entries.count { it.first.get() != null }
}
46 changes: 46 additions & 0 deletions src/main/java/com/mapconductor/arcgis/WebMercatorTileLevels.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.mapconductor.arcgis

import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter
import kotlin.math.log2
import kotlin.math.pow

/**
* WebTiledLayer に渡すレベルの梯子の数字。ローカルのタイル(ラスター・GroundImage)で共有する。
*
* 梯子は**統一ズーム**(Google Maps 基準・256px タイル)から組む。
* [AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256] が統一ズーム 0 の
* 1px あたりメートル数。512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、
* level `z` は統一ズーム `z + 1` に対応する。
*
* 1 枚のタイルが覆う地面は `resolution * tileSize` なので、解像度は**必ず tileSize 基準**で
* 刻む。256 基準のまま tileSize に 512 を渡すと level L のタイルが L-1 の広さを覆い、
* ArcGIS は番号だけ 1 段深い (z, x, y) を要求する(ArcGIS 300 で計測。200.x の
* SceneView は自前の 256 格子で (col, row) を数えていたので、その読み替えで合っていた)。
* ラスターは d0f4819 でこれに直したが、GroundImage は同じ読み替えを自前で持ったまま
* 残っていて、地球の別の場所の(空の)タイルを引いて何も表示されなかった。
* 二度と片方だけ直すことにならないよう、ここ 1 か所で持つ。
*/
internal object WebMercatorTileLevels {
/** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */
private const val UNIFIED_TILE_SIZE = 256.0
const val DEFAULT_DPI = 96
private const val INCHES_PER_METER = 39.37

/** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */
fun unifiedZoomForLevel(
level: Int,
tileSize: Int,
): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE)

/** level のタイル 1px あたりのメートル数。`resolution * tileSize` が level のタイル 1 枚の地面の幅。 */
fun resolution(
level: Int,
tileSize: Int,
): Double = AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 / 2.0.pow(unifiedZoomForLevel(level, tileSize))

/**
* 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、解像度と同じ比率で
* 刻んでいないと 1 段ずれる。3D の SceneView は見ていない(自前の 256px の梯子で選ぶ)。
*/
fun scale(resolution: Double): Double = resolution * DEFAULT_DPI * INCHES_PER_METER
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ import com.arcgismaps.mapping.layers.TileInfo
import com.arcgismaps.mapping.layers.WebTiledLayer
import com.mapconductor.arcgis.ArcGISGeoViewHolder
import com.mapconductor.arcgis.ArcGISOverlayStacking
import com.mapconductor.arcgis.WebMercatorTileLevels
import com.mapconductor.core.controller.OverlayKind
import com.mapconductor.core.groundimage.AbstractGroundImageOverlayRenderer
import com.mapconductor.core.groundimage.GroundImageEntityInterface
import com.mapconductor.core.groundimage.GroundImageState
import com.mapconductor.core.groundimage.GroundImageTileProvider
import com.mapconductor.core.projection.Earth
import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS
import com.mapconductor.core.tileserver.LocalTileServer
import kotlin.math.PI
import kotlin.math.pow
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -168,7 +166,7 @@ class ArcGISGroundImageOverlayRenderer(
private fun buildWebMercatorTileInfo(tileSize: Int): TileInfo {
val spatialReference = SpatialReference(WEB_MERCATOR_WKID)
val origin = Point(WEB_MERCATOR_MIN, WEB_MERCATOR_MAX, spatialReference)
val levels = buildWebMercatorLevels(resolveLodReferenceTileSize(tileSize))
val levels = buildWebMercatorLevels(tileSize)
return TileInfo(
DEFAULT_DPI,
TileImageFormat.Png,
Expand All @@ -180,22 +178,14 @@ class ArcGISGroundImageOverlayRenderer(
)
}

private fun buildWebMercatorLevels(tileSize: Int): List<LevelOfDetail> {
val initialResolution =
(2.0 * PI * WEB_MERCATOR_RADIUS_METERS) / tileSize.toDouble()
val levels = mutableListOf<LevelOfDetail>()
for (level in MIN_ZOOM..MAX_ZOOM) {
val resolution = initialResolution / 2.0.pow(level.toDouble())
val scale = resolution * DEFAULT_DPI * INCHES_PER_METER
levels.add(LevelOfDetail(level, resolution, scale))
}
return levels
}

private fun resolveLodReferenceTileSize(tileSize: Int): Int =
when (tileSize) {
512 -> 256
else -> tileSize
// 梯子はラスターと同じもの([WebMercatorTileLevels])を使う。以前は 512 のときだけ
// 256 基準へ読み替えていて、ArcGIS 300 では level L に L-1 の格子の (x, y) を付けて
// 要求された。GroundImageTileProvider はそれを素直に z=L として描くので、どのタイルも
// 画像の外になり透明だった。
private fun buildWebMercatorLevels(tileSize: Int): List<LevelOfDetail> =
(MIN_ZOOM..MAX_ZOOM).map { level ->
val resolution = WebMercatorTileLevels.resolution(level, tileSize)
LevelOfDetail(level, resolution, WebMercatorTileLevels.scale(resolution))
}

private fun buildWebMercatorExtent(): Envelope =
Expand Down Expand Up @@ -232,11 +222,9 @@ class ArcGISGroundImageOverlayRenderer(

companion object {
private const val WEB_MERCATOR_WKID = 3857
private const val WEB_MERCATOR_RADIUS_METERS = Earth.RADIUS_METERS
private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS
private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX
private const val DEFAULT_DPI = 96
private const val INCHES_PER_METER = 39.37
private const val DEFAULT_DPI = WebMercatorTileLevels.DEFAULT_DPI
private const val MIN_ZOOM = 0
private const val MAX_ZOOM = 22
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.arcgismaps.mapping.layers.WebTiledLayer
import com.mapconductor.arcgis.ArcGISGeoViewHolder
import com.mapconductor.arcgis.ArcGISMapViewHolder
import com.mapconductor.arcgis.ArcGISOverlayStacking
import com.mapconductor.arcgis.WebMercatorTileLevels
import com.mapconductor.core.controller.OverlayKind
import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS
import com.mapconductor.core.raster.RasterHeaderRuleSet
Expand All @@ -22,8 +23,6 @@ import com.mapconductor.core.raster.RasterLayerState
import com.mapconductor.core.raster.TileScheme
import com.mapconductor.core.tileserver.TileServerRegistry
import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter
import kotlin.math.log2
import kotlin.math.pow
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -315,7 +314,7 @@ class ArcGISRasterLayerOverlayRenderer(
* 512px のタイルは 1 枚で 256px タイル 2 枚ぶんを覆うので、level `z` は統一ズーム
* `z + 1` に対応する(コアの `WebMercatorZoomAltitudeConverter` が言う
* `zoomOffset`。MapLibre / Mapbox / MapTiler が 1.0、Google / MapKit が 0.0)。
* その対応を [unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も
* その対応を [WebMercatorTileLevels.unifiedZoomForLevel] に書き下しておけば、解像度も縮尺も
* 「統一ズームいくつのときにこの level を引くか」から一意に決まる。
*
* 自前で `2πR / tileSize` と DPI を掛け合わせても同じ数にはなるが、どの物差しに
Expand All @@ -329,10 +328,7 @@ class ArcGISRasterLayerOverlayRenderer(
): List<LevelOfDetail> {
val levels = mutableListOf<LevelOfDetail>()
for (level in minZoom..maxZoom) {
val unifiedZoom = unifiedZoomForLevel(level, tileSize)
val resolution =
AbstractZoomAltitudeConverter.WEB_MERCATOR_INITIAL_MPP_256 /
2.0.pow(unifiedZoom)
val resolution = WebMercatorTileLevels.resolution(level, tileSize)
// 縮尺の分母。2D の MapView はこの梯子を見て level を選ぶので、
// 解像度と同じ比率で刻んでいないと 1 段ずれる。
//
Expand All @@ -341,18 +337,12 @@ class ArcGISRasterLayerOverlayRenderer(
// かった(統一ズーム 6.5 で z=7 のまま)。SceneView は自前の
// 「タイルは 256px」という梯子で選ぶ。解像度のほうは見ているので、
// タイルの位置は申告どおりに決まる。
val scale = resolution * DEFAULT_DPI * INCHES_PER_METER
val scale = WebMercatorTileLevels.scale(resolution)
levels.add(LevelOfDetail(level, resolution, scale))
}
return levels
}

/** この level のタイルを引くべき統一ズーム。256px なら level そのもの。 */
private fun unifiedZoomForLevel(
level: Int,
tileSize: Int,
): Double = level + log2(tileSize.toDouble() / UNIFIED_TILE_SIZE)

private fun buildWebMercatorExtent(): Envelope =
Envelope(
WEB_MERCATOR_MIN,
Expand All @@ -368,11 +358,7 @@ class ArcGISRasterLayerOverlayRenderer(
private const val WEB_MERCATOR_WKID = 3857
private const val WEB_MERCATOR_MAX = WEB_MERCATOR_MAX_EXTENT_METERS
private const val WEB_MERCATOR_MIN = -WEB_MERCATOR_MAX
private const val DEFAULT_DPI = 96
private const val INCHES_PER_METER = 39.37

/** 統一ズームが基準にしているタイルの一辺(Google 準拠の 256px)。 */
private const val UNIFIED_TILE_SIZE = 256.0
private const val DEFAULT_DPI = WebMercatorTileLevels.DEFAULT_DPI
private const val DEFAULT_MIN_ZOOM = 0
private const val DEFAULT_MAX_ZOOM = 22
}
Expand Down
52 changes: 52 additions & 0 deletions src/test/java/com/mapconductor/arcgis/IdentityWeakTableTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.mapconductor.arcgis

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

class IdentityWeakTableTest {
/** ArcGIS の Layer のように、載せた後で hashCode / equals が変わる鍵。 */
private class Shifty(
var hash: Int,
) {
override fun hashCode(): Int = hash

override fun equals(other: Any?): Boolean = other is Shifty && other.hash == hash
}

@Test
fun findsTheKeyAfterItsHashCodeChanges() {
val table = IdentityWeakTable<Shifty, String>()
val layer = Shifty(1)
table.put(layer, "ground")

layer.hash = 2

assertEquals("ground", table[layer])
}

@Test
fun doesNotConfuseEqualButDistinctKeys() {
val table = IdentityWeakTable<Shifty, String>()
val a = Shifty(7)
val b = Shifty(7)
table.put(a, "raster")

assertNull(table[b])
table.put(b, "ground")
assertEquals("raster", table[a])
assertEquals("ground", table[b])
assertEquals(2, table.size)
}

@Test
fun putReplacesTheValueOfTheSameKey() {
val table = IdentityWeakTable<Shifty, String>()
val a = Shifty(1)
table.put(a, "raster")
table.put(a, "ground")

assertEquals("ground", table[a])
assertEquals(1, table.size)
}
}
71 changes: 71 additions & 0 deletions src/test/java/com/mapconductor/arcgis/WebMercatorTileLevelsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.mapconductor.arcgis

import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.tan

/**
* ArcGIS がレベルの梯子から数える (level, col, row) が、ローカルのタイルサーバ
* (GroundImageTileProvider など、素直な XYZ で z を読む)の同じ番号のタイルを指すこと。
*
* 512px のときだけ 256 基準へ読み替えていた頃は、level L に L-1 の格子の col/row が付き
* (Eswatini の z=15 で x=9617 になる。正しくは 19235)、GroundImage が何も描かれなかった。
*/
class WebMercatorTileLevelsTest {
// GroundImage サンプルの画像の中心(University of Eswatini)
private val lon = 31.306239
private val lat = -26.479235

private fun mercatorX(lon: Double) = lon / 180.0 * WEB_MERCATOR_MAX_EXTENT_METERS

private fun mercatorY(lat: Double) = ln(tan(PI / 4 + Math.toRadians(lat) / 2)) / PI * WEB_MERCATOR_MAX_EXTENT_METERS

/** WebTiledLayer の TileInfo(原点は左上)から ArcGIS が出す col / row。 */
private fun arcgisColRow(
level: Int,
tileSize: Int,
): Pair<Int, Int> {
val footprint = WebMercatorTileLevels.resolution(level, tileSize) * tileSize
val col = floor((mercatorX(lon) + WEB_MERCATOR_MAX_EXTENT_METERS) / footprint).toInt()
val row = floor((WEB_MERCATOR_MAX_EXTENT_METERS - mercatorY(lat)) / footprint).toInt()
return col to row
}

/** 素直な XYZ で z=level のときの x / y(タイルの px 数に依らない)。 */
private fun xyz(level: Int): Pair<Int, Int> {
val n = 2.0.pow(level)
val x = floor((lon + 180.0) / 360.0 * n).toInt()
val y = floor((1.0 - ln(tan(Math.toRadians(lat)) + 1.0 / kotlin.math.cos(Math.toRadians(lat))) / PI) / 2.0 * n).toInt()
return x to y
}

@Test
fun levelNamesTheSameTileAsXyz_for512() {
for (level in listOf(0, 5, 13, 15, 18)) {
assertEquals("level $level", xyz(level), arcgisColRow(level, 512))
}
}

@Test
fun levelNamesTheSameTileAsXyz_for256() {
for (level in listOf(0, 5, 13, 15, 18)) {
assertEquals("level $level", xyz(level), arcgisColRow(level, 256))
}
}

@Test
fun aLevelCoversTheGroundOfThatZoom_whateverTheTileSize() {
val circumference = 2 * WEB_MERCATOR_MAX_EXTENT_METERS
for (tileSize in listOf(256, 512)) {
for (level in 0..22) {
val footprint = WebMercatorTileLevels.resolution(level, tileSize) * tileSize
assertEquals("tileSize $tileSize level $level", circumference / 2.0.pow(level), footprint, footprint * 1e-6)
}
}
}
}
Loading