diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index dc7901e..dea4482 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -111,6 +111,9 @@ public final class com.mapconductor.arcgis.ArcGISGeoViewHolder$DefaultImpls { public static void setNavigationEnabled(com.mapconductor.arcgis.ArcGISGeoViewHolder, boolean); public static java.lang.Object fromScreenOffset-3MmeM6k(com.mapconductor.arcgis.ArcGISGeoViewHolder, long, kotlin.coroutines.Continuation); public static com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(com.mapconductor.arcgis.ArcGISGeoViewHolder, long); + public static com.mapconductor.core.projection.ProjectedPoint toProjectedPoint(com.mapconductor.arcgis.ArcGISGeoViewHolder, com.mapconductor.core.features.GeoPointInterface); + public static com.mapconductor.core.features.GeoPoint fromProjectedPoint(com.mapconductor.arcgis.ArcGISGeoViewHolder, com.mapconductor.core.projection.ProjectedPoint); + public static com.mapconductor.core.projection.MapCoordinateSystem getCoordinateSystem(com.mapconductor.arcgis.ArcGISGeoViewHolder); } public final class com.mapconductor.arcgis.ArcGISGraphicsZIndex { public com.mapconductor.arcgis.ArcGISGraphicsZIndex(); @@ -194,6 +197,9 @@ public final class com.mapconductor.arcgis.ArcGISMapView2DHolder implements com. public com.arcgismaps.mapping.view.GeoView getGeoView(); public com.arcgismaps.geometry.SpatialReference getSpatialReference(); public java.util.List getOperationalLayers(); + public com.mapconductor.core.projection.MapCoordinateSystem getCoordinateSystem(); + public com.mapconductor.core.projection.ProjectedPoint toProjectedPoint(com.mapconductor.core.features.GeoPointInterface); + public com.mapconductor.core.features.GeoPoint fromProjectedPoint(com.mapconductor.core.projection.ProjectedPoint); public androidx.compose.ui.geometry.Offset toScreenOffset-x-9fifI(com.mapconductor.core.features.GeoPointInterface); public java.lang.Object fromScreenOffset-3MmeM6k(long, kotlin.coroutines.Continuation); public com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(long); @@ -285,6 +291,9 @@ public final class com.mapconductor.arcgis.ArcGISMapViewHolder implements com.ma public com.mapconductor.core.features.GeoPoint fromScreenOffsetSync-k-4lQ0M(long); public boolean getUsesSceneView(); public void setNavigationEnabled(boolean); + public com.mapconductor.core.projection.ProjectedPoint toProjectedPoint(com.mapconductor.core.features.GeoPointInterface); + public com.mapconductor.core.features.GeoPoint fromProjectedPoint(com.mapconductor.core.projection.ProjectedPoint); + public com.mapconductor.core.projection.MapCoordinateSystem getCoordinateSystem(); public java.lang.Object getMapView(); public java.lang.Object getMap(); } @@ -348,6 +357,7 @@ public final class com.mapconductor.arcgis.ArcGISOverlayStacking { public static final com.mapconductor.arcgis.ArcGISOverlayStacking INSTANCE; public final com.mapconductor.core.controller.OverlayStacking$Key getMARKER_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYGON_TILE(); + public final com.mapconductor.core.controller.OverlayStacking$Key getCIRCLE_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key getPOLYLINE_TILE(); public final com.mapconductor.core.controller.OverlayStacking$Key key(com.mapconductor.core.controller.OverlayKind, int); public final void add(java.util.List, com.arcgismaps.mapping.layers.Layer, com.mapconductor.core.controller.OverlayStacking$Key); diff --git a/src/androidTest/java/com/mapconductor/arcgis/ArcGISProjectedPointTest.kt b/src/androidTest/java/com/mapconductor/arcgis/ArcGISProjectedPointTest.kt new file mode 100644 index 0000000..782f1b2 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/arcgis/ArcGISProjectedPointTest.kt @@ -0,0 +1,79 @@ +package com.mapconductor.arcgis + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.geometry.SpatialReference +import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.view.MapView +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.projection.ProjectedUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * 2D の投影座標は地図の空間参照で返る。 + * + * サンプルの 2D 地図は Web Mercator 固定なので、それ以外の空間参照(UTM・WGS84)は + * ここでしか通らない。 + */ +@RunWith(AndroidJUnit4::class) +class ArcGISProjectedPointTest { + private val tokyo = GeoPoint(35.681236, 139.767125) + + private fun holderWith(spatialReference: SpatialReference): ArcGISMapView2DHolder { + lateinit var holder: ArcGISMapView2DHolder + val context = InstrumentationRegistry.getInstrumentation().targetContext + InstrumentationRegistry.getInstrumentation().runOnMainSync { + ArcGISEnvironment.applicationContext = context.applicationContext + val map = MapView(context) + map.map = ArcGISMap(spatialReference) + val wrap = WrapMapView(context).also { it.arcGISMapView = map } + holder = ArcGISMapView2DHolder(wrap, map) + } + return holder + } + + @Test + fun utmZone54NReturnsEastingNorthingInMeters() { + val holder = holderWith(SpatialReference(32654)) + assertEquals("EPSG:32654", holder.coordinateSystem.code) + assertEquals(ProjectedUnit.Meters, holder.coordinateSystem.unit) + + val projected = holder.toProjectedPoint(tokyo) + assertNotNull(projected) + // 東京駅は中央子午線(東経 141 度)の西 1.23 度: 偽東距 500000 m から約 11 万 m 西。 + assertTrue("easting ${projected!!.x}", projected.x in 380_000.0..400_000.0) + assertTrue("northing ${projected.y}", projected.y in 3_940_000.0..3_960_000.0) + + val back = holder.fromProjectedPoint(projected) + assertNotNull(back) + assertEquals(tokyo.latitude, back!!.latitude, 1e-7) + assertEquals(tokyo.longitude, back.longitude, 1e-7) + } + + @Test + fun wgs84ReturnsDegrees() { + val holder = holderWith(SpatialReference.wgs84()) + assertEquals("EPSG:4326", holder.coordinateSystem.code) + assertEquals(ProjectedUnit.Degrees, holder.coordinateSystem.unit) + + val projected = holder.toProjectedPoint(tokyo)!! + assertEquals(tokyo.longitude, projected.x, 1e-9) + assertEquals(tokyo.latitude, projected.y, 1e-9) + } + + @Test + fun webMercatorMatchesTheCoreDefault() { + val holder = holderWith(SpatialReference.webMercator()) + assertEquals("EPSG:3857", holder.coordinateSystem.code) + assertEquals(ProjectedUnit.Meters, holder.coordinateSystem.unit) + + val projected = holder.toProjectedPoint(tokyo)!! + assertEquals(15558805.18, projected.x, 0.01) + assertEquals(4256848.12, projected.y, 0.01) + } +} diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt index 10be3b8..1f986fd 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt @@ -2,6 +2,10 @@ package com.mapconductor.arcgis import androidx.compose.ui.geometry.Offset import androidx.lifecycle.LifecycleOwner +import com.arcgismaps.geometry.GeometryEngine +import com.arcgismaps.geometry.LinearUnit +import com.arcgismaps.geometry.LinearUnitId +import com.arcgismaps.geometry.Point import com.arcgismaps.geometry.SpatialReference import com.arcgismaps.mapping.layers.Layer import com.arcgismaps.mapping.view.DoubleXY @@ -11,6 +15,9 @@ import com.arcgismaps.mapping.view.SceneView import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.map.MapViewHolderInterface +import com.mapconductor.core.projection.MapCoordinateSystem +import com.mapconductor.core.projection.ProjectedPoint +import com.mapconductor.core.projection.ProjectedUnit import kotlin.math.abs import kotlin.math.max import android.content.Context @@ -268,6 +275,24 @@ class ArcGISMapView2DHolder( override val operationalLayers: MutableList? get() = runCatching { map.map?.operationalLayers }.getOrNull() + /** + * 2D の地図は空間参照を持つので、投影座標はその空間参照で返す(いまの 2D は + * Web Mercator 固定だが、地図の空間参照が変われば追従する)。地図がまだ無いときは + * 既定の EPSG:3857。3D([ArcGISMapViewHolder])は地球儀なので既定のまま。 + */ + override val coordinateSystem: MapCoordinateSystem + get() = spatialReference?.toMapCoordinateSystem() ?: MapCoordinateSystem.WebMercator + + override fun toProjectedPoint(position: GeoPointInterface): ProjectedPoint? { + val reference = spatialReference ?: return super.toProjectedPoint(position) + return projectToSpatialReference(position, reference) + } + + override fun fromProjectedPoint(point: ProjectedPoint): GeoPoint? { + val reference = spatialReference ?: return super.fromProjectedPoint(point) + return unprojectFromSpatialReference(point, reference) + } + /** * `GeoPoint.toPoint(spatialReference)` は経度緯度の値にその空間参照を「貼る」だけで座標変換は * しない。2D の `ArcGISMap` は既定で Web メルカトルなので、ビューの空間参照をそのまま渡すと @@ -351,3 +376,55 @@ internal fun Context.getArcGisApiKey(): String? = .getApplicationInfo(packageName, PackageManager.GET_META_DATA) .metaData ?.getString("ARCGIS_API_KEY") + +/** + * ArcGIS の空間参照 → [MapCoordinateSystem]。 + * + * - WKID が 32767 以下なら EPSG の番号と同じなので `EPSG:`。 + * - Esri 独自の番号は `ESRI:`。ただし Web Mercator の旧番号 + * (102100 / 102113)は EPSG:3857 にそろえる。300.x の SDK には `latestWkid` が無い。 + * - WKID を持たない(WKT だけで定義された)空間参照は null。 + */ +private fun SpatialReference.toMapCoordinateSystem(): MapCoordinateSystem { + val code = + when (val wkid = wkid) { + 0 -> null + 102100, 102113 -> "EPSG:3857" + in 1..32767 -> "EPSG:$wkid" + else -> "ESRI:$wkid" + } + val unit = + when { + isGeographic -> ProjectedUnit.Degrees + (unit as? LinearUnit)?.linearUnitId == LinearUnitId.Meters -> ProjectedUnit.Meters + else -> ProjectedUnit.Other + } + return MapCoordinateSystem(code, unit) +} + +/** 地理座標 → [spatialReference] の座標。ArcGIS が投影できないときは null。 */ +private fun projectToSpatialReference( + position: GeoPointInterface, + spatialReference: SpatialReference, +): ProjectedPoint? { + val wgs84 = GeoPoint.from(position).toPoint(SpatialReference.wgs84()) + val projected = runCatching { GeometryEngine.projectOrNull(wgs84, spatialReference) as? Point }.getOrNull() + return projected?.let { ProjectedPoint(it.x, it.y) }?.takeIf { it.x.isFinite() && it.y.isFinite() } +} + +/** + * [spatialReference] の座標 → 地理座標。 + * + * [toGeoPoint] は投影に失敗すると元の値をそのまま経度緯度として返すので使わない。 + */ +private fun unprojectFromSpatialReference( + point: ProjectedPoint, + spatialReference: SpatialReference, +): GeoPoint? { + val source = Point(x = point.x, y = point.y, spatialReference = spatialReference) + val wgs84 = + runCatching { GeometryEngine.projectOrNull(source, SpatialReference.wgs84()) as? Point }.getOrNull() + ?: return null + if (!wgs84.x.isFinite() || !wgs84.y.isFinite()) return null + return GeoPoint(latitude = wgs84.y, longitude = wgs84.x, altitude = 0.0) +}