diff --git a/api/android-for-arcgis.api b/api/android-for-arcgis.api index d366041..4624a96 100644 --- a/api/android-for-arcgis.api +++ b/api/android-for-arcgis.api @@ -188,7 +188,7 @@ public final class com.mapconductor.arcgis.ArcGISMapView2DHolder implements com. public java.lang.Object getMap(); } public final class com.mapconductor.arcgis.ArcGISMapView2DKt { - public static final void ArcGISMapView2D(com.mapconductor.arcgis.ArcGISMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void ArcGISMapView2D(com.mapconductor.arcgis.ArcGISMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.arcgis.ArcGISMapViewController extends com.mapconductor.core.controller.BaseMapViewController implements com.mapconductor.arcgis.ArcGISMapViewControllerInterface { public com.mapconductor.arcgis.ArcGISMapViewController(com.mapconductor.arcgis.ArcGISMapViewHolder, com.mapconductor.arcgis.marker.ArcGISMarkerController, com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController, com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController, com.mapconductor.arcgis.circle.ArcGISCircleOverlayController, com.mapconductor.arcgis.groundimage.ArcGISGroundImageController, com.mapconductor.arcgis.raster.ArcGISRasterLayerController, kotlinx.coroutines.CoroutineScope, kotlinx.coroutines.CoroutineScope); @@ -287,7 +287,7 @@ public final class com.mapconductor.arcgis.ArcGISMapViewInitOptions { public boolean equals(java.lang.Object); } public final class com.mapconductor.arcgis.ArcGISMapViewKt { - public static final void ArcGISMapView(com.mapconductor.arcgis.ArcGISMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void ArcGISMapView(com.mapconductor.arcgis.ArcGISMapViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final com.mapconductor.arcgis.circle.ArcGISCircleOverlayController getCircleController(com.mapconductor.arcgis.ArcGISGeoViewHolder, boolean); public static final com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController getPolylineController(com.mapconductor.arcgis.ArcGISGeoViewHolder, boolean); public static final com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController getPolygonController(com.mapconductor.arcgis.ArcGISGeoViewHolder, boolean); diff --git a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt index 46a0557..9f684d4 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGIS2DTiltEmulation.kt @@ -57,7 +57,9 @@ internal object ArcGIS2DTiltEmulation { val tiltAbsRad = Math.toRadians(tiltAbsDeg) val altitude = converter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val target = Spherical.computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) + val target = + Spherical + .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) return target to zoom } @@ -82,7 +84,11 @@ internal object ArcGIS2DTiltEmulation { val tiltAbsRad = Math.toRadians(tiltAbsDeg) val altitude = converter.zoomLevelToAltitude(originalZoom, center.latitude, 0.0) val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val originalPosition: GeoPoint = Spherical.computeOffset(center, distanceBackward, CameraBearing.toNativeHeading(bearing) + 180.0) + val originalPosition: GeoPoint = + Spherical.computeOffset( + center, distanceBackward, + CameraBearing.toNativeHeading(bearing) + 180.0, + ) return originalPosition to originalZoom } } diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt index e0a8c3e..4e54992 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt @@ -13,6 +13,7 @@ import com.arcgismaps.ArcGISEnvironment import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.ArcGISScene import com.arcgismaps.mapping.ArcGISTiledElevationSource +import com.arcgismaps.mapping.Basemap import com.arcgismaps.mapping.view.GraphicsOverlay import com.arcgismaps.mapping.view.GraphicsRenderingMode import com.arcgismaps.mapping.view.SceneView @@ -36,6 +37,7 @@ import com.mapconductor.core.OnMapLoadedHandler import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.marker.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerManager import com.mapconductor.core.marker.MarkerOverlayRendererInterface @@ -43,9 +45,9 @@ import com.mapconductor.core.marker.MarkerRenderingStrategyInterface import com.mapconductor.core.marker.MarkerRenderingSupport import com.mapconductor.core.marker.MarkerRenderingSupportKey import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController import com.mapconductor.core.raster.RasterTilePreference import com.mapconductor.core.raster.RasterTilePreferenceKey -import com.mapconductor.core.marker.StrategyMarkerController import com.mapconductor.core.tileserver.TileServerRegistry import java.util.concurrent.atomic.AtomicLong import android.content.Context @@ -70,13 +72,24 @@ fun ArcGISMapView( onCameraMoveEnd: OnCameraMoveHandler? = null, onMapClick: OnMapEventHandler? = null, onMapLongClick: OnMapEventHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable ArcGISMapViewScope.() -> Unit)? = null, ) { val scope = remember { ArcGISMapViewScope() } // Use specific scope val context = LocalContext.current // Context will be available from MapViewBase too if needed val registry = remember { scope.buildRegistry() } val owner = LocalLifecycleOwner.current - val basemapStyle = remember { ArcGISDesign.toBasemapStyleOrNull(state.mapDesignType) } + // The design the scene was built with; a design set on the state before + // the controller existed is applied once it does. + val createdDesign = remember { Ref() } val cameraState = remember { mutableStateOf(state.cameraPosition) } val controllerRef = remember { Ref() } val controllerGeneration = remember { AtomicLong(0L) } @@ -101,14 +114,20 @@ fun ArcGISMapView( scope = scope, registry = registry, holderProvider = { wrapView -> + val design = state.mapDesignType + createdDesign.value = design.getValue() val options = ArcGISMapViewInitOptions( - basemapStyle = basemapStyle, - elevationSources = state.mapDesignType.elevationSources, + basemapStyle = ArcGISDesign.toBasemapStyleOrNull(design), + elevationSources = design.elevationSources, ) - // No basemap is a scene with only what the app puts on it. - val scene = options.basemapStyle?.let { ArcGISScene(it) } ?: ArcGISScene() + // No basemap is a scene with only what the app puts on it. The + // basemap is set on an empty scene rather than built into it, so + // a scene whose basemap is out of reach is still a scene (a + // global one is always WGS84) that can draw its own layers. + val scene = ArcGISScene() + options.basemapStyle?.let { scene.setBasemap(Basemap(it)) } options.elevationSources.forEach { val source = ArcGISTiledElevationSource(it) @@ -116,6 +135,7 @@ fun ArcGISMapView( } wrapView.sceneView.scene = scene + var retried = false val coroutine = CoroutineScope(Dispatchers.Default) @@ -133,6 +153,16 @@ fun ArcGISMapView( cont.resume(holder) { _, _, _ -> } } is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView", "scene failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the scene needs no + // network, so it gets one more go. + if (scene.basemap.value == null && !retried) { + retried = true + scene.retryLoad() + return@collect + } // Offline or network error: resume with a holder anyway so // the scene view is displayed (possibly with cached tiles) // without crashing. The ArcGIS view may be blank but usable. @@ -238,6 +268,11 @@ fun ArcGISMapView( mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) cameraRestriction?.let { mapController.setCameraRestriction(it) } state.setController(mapController) + if (state.mapDesignType.getValue() != + createdDesign.value + ) { + mapController.setMapDesignType(state.mapDesignType) + } // Set camera listeners immediately so they are ready to receive // camera updates from external sources (e.g. camera sync scenarios). @@ -309,6 +344,8 @@ fun ArcGISMapView( } } }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, ) } diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt index a7c88b4..7f271f4 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2D.kt @@ -9,7 +9,9 @@ import androidx.compose.ui.node.Ref import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import com.arcgismaps.LoadStatus +import com.arcgismaps.geometry.SpatialReference import com.arcgismaps.mapping.ArcGISMap +import com.arcgismaps.mapping.Basemap import com.arcgismaps.mapping.view.GraphicsOverlay import com.arcgismaps.mapping.view.GraphicsRenderingMode import com.arcgismaps.mapping.view.MapView @@ -20,6 +22,7 @@ import com.mapconductor.core.OnMapLoadedHandler import com.mapconductor.core.map.CameraRestriction import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewStyle import com.mapconductor.core.marker.MarkerEventControllerInterface import com.mapconductor.core.marker.MarkerOverlayRendererInterface import com.mapconductor.core.marker.MarkerRenderingStrategyInterface @@ -29,6 +32,7 @@ import com.mapconductor.core.marker.MarkerTilingOptions import com.mapconductor.core.marker.StrategyMarkerController import java.util.concurrent.atomic.AtomicLong import android.content.Context +import android.util.Log import android.widget.FrameLayout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,13 +53,24 @@ fun ArcGISMapView2D( onCameraMoveEnd: OnCameraMoveHandler? = null, onMapClick: OnMapEventHandler? = null, onMapLongClick: OnMapEventHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, content: (@Composable ArcGISMapViewScope.() -> Unit)? = null, ) { val scope = remember { ArcGISMapViewScope() } val context = LocalContext.current val registry = remember { scope.buildRegistry() } val owner = LocalLifecycleOwner.current - val basemapStyle = remember { ArcGISDesign.toBasemapStyleOrNull(state.mapDesignType) } + // The design the map was built with; a design set on the state before the + // controller existed is applied once it does. + val createdDesign = remember { Ref() } val cameraState = remember { mutableStateOf(state.cameraPosition) } val controllerRef = remember { Ref() } val controllerGeneration = remember { AtomicLong(0L) } @@ -78,8 +93,19 @@ fun ArcGISMapView2D( scope = scope, registry = registry, holderProvider = { wrapView -> - val map = basemapStyle?.let { ArcGISMap(it) } ?: ArcGISMap() + // The map is told its spatial reference up front instead of + // learning it from the basemap: a basemap style lives on + // arcgis.com, and a map that waited for it to find out where it + // is can draw nothing while the network is away. Told, it loads + // at once with no basemap, and with one it keeps drawing its own + // layers (a raster layer served on the device, say) when the + // basemap is out of reach. + val design = state.mapDesignType + createdDesign.value = design.getValue() + val map = ArcGISMap(SpatialReference.webMercator()) + ArcGISDesign.toBasemapStyleOrNull(design)?.let { map.setBasemap(Basemap(it)) } wrapView.arcGISMapView.map = map + var retried = false val coroutine = CoroutineScope(Dispatchers.Default) suspendCancellableCoroutine { cont -> @@ -96,6 +122,16 @@ fun ArcGISMapView2D( ) { _, _, _ -> } } is LoadStatus.FailedToLoad -> { + Log.w("ArcGISMapView2D", "map failed to load: ${it.error.message}") + // The basemap was taken away while it was being + // fetched (the app went basemap-less, say for an + // offline raster): without one the map needs no + // network, so it gets one more go. + if (map.basemap.value == null && !retried) { + retried = true + map.retryLoad() + return@collect + } if (cont.isActive) { cont.resume( ArcGISMapView2DHolder( @@ -170,6 +206,11 @@ fun ArcGISMapView2D( mapController.setMapLongClickListener(onMapLongClick) mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) state.setController(mapController) + if (state.mapDesignType.getValue() != + createdDesign.value + ) { + mapController.setMapDesignType(state.mapDesignType) + } // 他プロバイダの *MapView と同じく、コントローラ生成直後に適用する。 cameraRestriction?.let { mapController.setCameraRestriction(it) } @@ -226,6 +267,8 @@ fun ArcGISMapView2D( } } }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, ) } diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt index 9c57dee..16dd4db 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapView2DController.kt @@ -1,7 +1,7 @@ package com.mapconductor.arcgis +import com.arcgismaps.LoadStatus import com.arcgismaps.geometry.SpatialReference -import com.arcgismaps.mapping.Basemap import com.arcgismaps.mapping.Viewpoint import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController @@ -40,6 +40,7 @@ import com.mapconductor.core.projection.Earth import com.mapconductor.core.raster.RasterLayerState import kotlin.math.ln import kotlin.math.pow +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -308,8 +309,18 @@ class ArcGISMapView2DController( } override fun setMapDesignType(value: ArcGISDesignTypeInterface) { - val baseMap = ArcGISDesign.toBasemap(value) - holder.map.map?.setBasemap(baseMap) + val map = holder.map.map ?: return + map.setBasemap(ArcGISDesign.toBasemap(value)) + // A map that failed to load -- its basemap was out of reach -- gets + // another go with the new basemap, or with none, which needs no + // network at all. + if (map.loadStatus.value is LoadStatus.FailedToLoad) { + defaultCoroutine.launch { + val result = map.retryLoad() + Log + .d("ArcGISMapView2D", "map reloaded with basemap=${value.getValue()}: ${result.isSuccess} ${result.exceptionOrNull()?.message ?: ""}") + } + } } override fun setMapDesignTypeChangeListener(listener: ArcGISDesignTypeChangeHandler) { diff --git a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt index d5cfee4..5e6ac77 100644 --- a/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt +++ b/src/main/java/com/mapconductor/arcgis/ArcGISMapViewController.kt @@ -1,6 +1,6 @@ package com.mapconductor.arcgis -import com.arcgismaps.mapping.Basemap +import com.arcgismaps.LoadStatus import com.arcgismaps.mapping.view.GraphicsOverlay import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController import com.mapconductor.arcgis.groundimage.ArcGISGroundImageController @@ -259,6 +259,10 @@ class ArcGISMapViewController( val baseMap = ArcGISDesign.toBasemap(value) defaultCoroutine.launch { scene.setBasemap(baseMap) + // A scene that failed to load -- its basemap was out of + // reach -- gets another go with the new basemap, or with + // none, which needs no network at all. + if (scene.loadStatus.value is LoadStatus.FailedToLoad) scene.retryLoad() // Basemap changes can reset the viewpoint; mark the current request as pending so that // the next viewpointChanged can restore the last requested camera if needed. // pendingCameraRestoreRequest = cameraRequestGeneration.get() diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt index 27d9797..51e06f3 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerController.kt @@ -18,7 +18,8 @@ class ArcGISRasterLayerController( suspend fun rebuildLocalLayersIfNeeded() { if (!renderer.localLayersNeedRebuild()) return val entities = - rasterLayerManager.allEntities() + rasterLayerManager + .allEntities() .filter { renderer.isLocalLayer(it.state) } .sortedBy { it.state.zIndex } for (entity in entities) { diff --git a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt index 4aea178..e1083db 100644 --- a/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/arcgis/raster/ArcGISRasterLayerOverlayRenderer.kt @@ -1,8 +1,5 @@ package com.mapconductor.arcgis.raster -import kotlinx.coroutines.launch -import com.mapconductor.arcgis.ArcGISMapViewHolder -import com.mapconductor.core.tileserver.TileServerRegistry import com.arcgismaps.arcgisservices.LevelOfDetail import com.arcgismaps.geometry.Envelope import com.arcgismaps.geometry.Point @@ -13,6 +10,7 @@ import com.arcgismaps.mapping.layers.TileImageFormat import com.arcgismaps.mapping.layers.TileInfo import com.arcgismaps.mapping.layers.WebTiledLayer import com.mapconductor.arcgis.ArcGISGeoViewHolder +import com.mapconductor.arcgis.ArcGISMapViewHolder import com.mapconductor.core.projection.WEB_MERCATOR_MAX_EXTENT_METERS import com.mapconductor.core.raster.RasterHeaderRuleSet import com.mapconductor.core.raster.RasterLayerEntityInterface @@ -20,12 +18,14 @@ import com.mapconductor.core.raster.RasterLayerOverlayRendererInterface import com.mapconductor.core.raster.RasterLayerSource 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 +import kotlinx.coroutines.launch class ArcGISRasterLayerOverlayRenderer( private val holder: ArcGISGeoViewHolder<*, *>, @@ -113,6 +113,7 @@ class ArcGISRasterLayerOverlayRenderer( if (!template.startsWith(base)) return null return template.removePrefix(base).substringBefore('/') } + override suspend fun onAdd(data: List): List { val results = ArrayList(data.size) for (params in data) {