diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 53b29b3f..d7494b4f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,9 +16,12 @@ "Bash(for:*)", "WebFetch(domain:github.com)", "Bash(grep:*)", - "Bash(rm:*)" + "Bash(rm:*)", + "Bash(cat:*)", + "Read(//Users/masashi/.gradle/caches/**)", + "Bash(xargs jar tf:*)" ], "deny": [], "ask": [] } -} \ No newline at end of file +} diff --git a/build.gradle.kts b/build.gradle.kts index 0d2baf80..41cbd700 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -43,12 +43,14 @@ val modules: List = rootDir .resolve("projects.properties") .readLines() - .firstOrNull { it.startsWith("modules=") } - ?.removePrefix("modules=") - ?.split(",") - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - ?: emptyList() + .dropWhile { !it.startsWith("modules=") } + .takeWhile { it.startsWith("modules=") || it.trim().endsWith(",\\") || it.trim().matches(Regex("^[a-zA-Z0-9-]+$")) } + .joinToString("") + .removePrefix("modules=") + .replace("\\", "") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } tasks.register("allLintChecks") { group = "verification" diff --git a/example-app/build.gradle.kts b/example-app/build.gradle.kts index 77ad8d21..67b275d2 100644 --- a/example-app/build.gradle.kts +++ b/example-app/build.gradle.kts @@ -139,29 +139,19 @@ android { dependencies { implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.material3) - implementation(libs.androidx.appcompat) // Google Maps SDK - implementation(libs.play.services.maps) // Here Maps SDK - implementation( fileTree( mapOf( @@ -172,19 +162,18 @@ dependencies { ) // Mapbox SDK - implementation(libs.mapbox.android) // ArcGIS Maps for Kotlin - SDK dependency - implementation(libs.arcgis.maps.kotlin) - implementation(platform(libs.arcgis.maps.kotlin.toolkit.bom)) - implementation(libs.arcgis.maps.kotlin.toolkit.geoview.compose) - implementation(libs.arcgis.maps.kotlin.toolkit.authentication) + // MapLibre SDK + implementation(libs.maplibre.sdk) + implementation(libs.maplibre.annotation) + // Map Conductor // implementation("com.mapconductor:core") // implementation("com.mapconductor:icons") @@ -204,6 +193,7 @@ dependencies { releaseImplementation(libs.mapconductor.here) releaseImplementation(libs.mapconductor.mapbox) releaseImplementation(libs.mapconductor.arcgis) + releaseImplementation(libs.mapconductor.maplibre) releaseImplementation(libs.mapconductor.marker.strategy) releaseImplementation(libs.mapconductor.marker.native.strategy) @@ -213,28 +203,19 @@ dependencies { debugImplementation(project(":mapconductor-for-here")) debugImplementation(project(":mapconductor-for-mapbox")) debugImplementation(project(":mapconductor-for-arcgis")) + debugImplementation(project(":mapconductor-for-maplibre")) debugImplementation(project(":mapconductor-marker-strategy")) debugImplementation(project(":mapconductor-marker-native-strategy")) implementation(libs.androidx.vectordrawable) - testImplementation(libs.junit) - testImplementation(libs.androidx.core) - testImplementation(libs.androidx.junit) - testImplementation(libs.androidx.runner) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) } diff --git a/example-app/src/main/java/com/mapconductor/example/DeferredMapExample.kt b/example-app/src/main/java/com/mapconductor/example/DeferredMapExample.kt deleted file mode 100644 index aea9f392..00000000 --- a/example-app/src/main/java/com/mapconductor/example/DeferredMapExample.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.mapconductor.example - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.mapconductor.core.map.MapViewState - -/** - * Example demonstrating deferred MapView initialization. - * - * This shows how to defer map initialization until after UI elements - * like sidebars are closed or specific user interactions occur. - * - * Benefits: - * - Faster app startup (map initialization can be expensive) - * - Better UX (no black screen flash during complex UI transitions) - * - Conditional loading based on user preferences or network state - */ -@Composable -fun DeferredMapExample( - mapViewState: MapViewState<*>?, - modifier: Modifier = Modifier, -) { - var shouldInitializeMap by remember { mutableStateOf(false) } - var isSidebarClosed by remember { mutableStateOf(false) } - - Box(modifier = modifier.fillMaxSize()) { - // Main map content - only initialize when ready - mapViewState?.let { currentMapViewState -> - MapViewContainer( - modifier = Modifier.fillMaxSize(), - state = currentMapViewState, - onMapClick = { /* Handle map clicks */ }, // Key parameter for deferred init - shouldInitialize = shouldInitializeMap, - ) - } - - // Example sidebar or overlay UI - if (!isSidebarClosed) { - Card( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - ) { - Text("Sidebar Content") - Text("Map will initialize after closing this sidebar") - - Button( - onClick = { - isSidebarClosed = true - shouldInitializeMap = true // Trigger map initialization - }, - ) { - Text("Close Sidebar & Initialize Map") - } - } - } - } - - // Alternative: Initialize map based on other conditions - if (!shouldInitializeMap && isSidebarClosed) { - Card( - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("Ready to load map!") - Button( - onClick = { shouldInitializeMap = true }, - ) { - Text("Load Map Now") - } - } - } - } - } -} diff --git a/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt b/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt index 099c83e4..6f08e7d3 100644 --- a/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt +++ b/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt @@ -27,6 +27,8 @@ import com.mapconductor.example.pages.marker.animation.AnimationMapPage import com.mapconductor.example.pages.marker.icons.MarkerBasicPage import com.mapconductor.example.pages.marker.postoffice.PostOfficeMapPage import com.mapconductor.example.pages.polygon.basic.PolygonMapPage +import com.mapconductor.example.pages.polygon.click.PolygonClickPage +import com.mapconductor.example.pages.polygon.geodesic.PolygonGeodesicPage import com.mapconductor.example.pages.polyline.PolylineClickMapPage import com.mapconductor.example.pages.polyline.PolylineMapPage import com.mapconductor.example.pages.startup.StartUpPage @@ -134,6 +136,14 @@ fun DemoAppScreen(initPage: String = "map") { id = "polygon", title = "Polygon ", ), + SidebarItem( + id = "polygon-click", + title = "Polygon Click", + ), + SidebarItem( + id = "polygon-geodesic", + title = "Geodesic polygons", + ), ) AppTheme { @@ -226,6 +236,16 @@ fun DemoAppScreen(initPage: String = "map") { onToggleSidebar = navigationViewModel::toggleSidebar, ) } + "polygon-click" -> { + PolygonClickPage( + onToggleSidebar = navigationViewModel::toggleSidebar, + ) + } + "polygon-geodesic" -> { + PolygonGeodesicPage( + onToggleSidebar = navigationViewModel::toggleSidebar, + ) + } "groundImage" -> { GroundImageMapPage( groundImageResources = groundImageResources, diff --git a/example-app/src/main/java/com/mapconductor/example/MainActivity.kt b/example-app/src/main/java/com/mapconductor/example/MainActivity.kt index 26aa6230..9e37ebf0 100644 --- a/example-app/src/main/java/com/mapconductor/example/MainActivity.kt +++ b/example-app/src/main/java/com/mapconductor/example/MainActivity.kt @@ -12,8 +12,8 @@ class MainActivity : ComponentActivity() { setContent { DemoAppScreen( -// initPage = "polyline-click", - initPage = "startup", + initPage = "polygon-geodesic", +// initPage = "startup", ) } } diff --git a/example-app/src/main/java/com/mapconductor/example/MapViewContainer.kt b/example-app/src/main/java/com/mapconductor/example/MapViewContainer.kt index 942ae0bf..8e7e6b5a 100644 --- a/example-app/src/main/java/com/mapconductor/example/MapViewContainer.kt +++ b/example-app/src/main/java/com/mapconductor/example/MapViewContainer.kt @@ -11,7 +11,6 @@ import com.mapconductor.core.groundimage.OnGroundImageEventHandler import com.mapconductor.core.map.MapViewState import com.mapconductor.core.map.OnMapEventHandler import com.mapconductor.core.map.OnMapLoadedHandler -import com.mapconductor.core.map.OnMapViewInitializedHandler import com.mapconductor.core.marker.MarkerRenderingStrategy import com.mapconductor.core.marker.OnMarkerEventHandler import com.mapconductor.core.polygon.OnPolygonEventHandler @@ -25,13 +24,15 @@ import com.mapconductor.here.HereViewStateImpl import com.mapconductor.mapbox.MapboxActualMarker import com.mapconductor.mapbox.MapboxMapView import com.mapconductor.mapbox.MapboxViewStateImpl +import com.mapconductor.maplibre.MapLibreActualMarker +import com.mapconductor.maplibre.MapLibreMapView +import com.mapconductor.maplibre.MapLibreViewStateImpl @Composable fun MapViewContainer( modifier: Modifier = Modifier, renderingStrategy: MarkerRenderingStrategy<*>? = null, state: MapViewState<*>? = null, - onMapViewInitialized: OnMapViewInitializedHandler? = null, onMapLoaded: OnMapLoadedHandler? = null, onMapClick: OnMapEventHandler? = null, onMarkerClick: OnMarkerEventHandler? = null, @@ -44,7 +45,6 @@ fun MapViewContainer( onPolylineClick: OnPolylineEventHandler? = null, onPolygonClick: OnPolygonEventHandler? = null, onGroundImageClick: OnGroundImageEventHandler? = null, - shouldInitialize: Boolean = true, // Allow deferring initialization content: (@Composable MapViewScope.() -> Unit)? = null, ) { @Suppress("UNCHECKED_CAST") @@ -54,7 +54,6 @@ fun MapViewContainer( modifier = modifier, markerRenderingStrategy = renderingStrategy as? MarkerRenderingStrategy?, state = state, - onMapViewInitialized = onMapViewInitialized, onMapLoaded = onMapLoaded, onMapClick = onMapClick, onMarkerClick = onMarkerClick, @@ -67,7 +66,6 @@ fun MapViewContainer( onPolylineClick = onPolylineClick, onPolygonClick = onPolygonClick, onGroundImageClick = onGroundImageClick, - shouldInitialize = shouldInitialize, content = content, ) @@ -76,7 +74,6 @@ fun MapViewContainer( modifier = modifier, markerRenderingStrategy = renderingStrategy as? MarkerRenderingStrategy?, state = state, - onMapViewInitialized = onMapViewInitialized, onMapLoaded = onMapLoaded, onMapClick = onMapClick, onMarkerClick = onMarkerClick, @@ -96,7 +93,6 @@ fun MapViewContainer( modifier = modifier, markerRenderingStrategy = renderingStrategy as? MarkerRenderingStrategy?, state = state, - onMapViewInitialized = onMapViewInitialized, onMapLoaded = onMapLoaded, onMapClick = onMapClick, onMarkerClick = onMarkerClick, @@ -116,7 +112,25 @@ fun MapViewContainer( modifier = modifier, markerRenderingStrategy = renderingStrategy as? MarkerRenderingStrategy?, state = state, - onMapViewInitialized = onMapViewInitialized, + onMapLoaded = onMapLoaded, + onMapClick = onMapClick, + onMarkerClick = onMarkerClick, + onMarkerDragStart = onMarkerDragStart, + onMarkerDrag = onMarkerDrag, + onMarkerDragEnd = onMarkerDragEnd, + onMarkerAnimateStart = onMarkerAnimateStart, + onMarkerAnimateEnd = onMarkerAnimateEnd, + onCircleClick = onCircleClick, + onPolylineClick = onPolylineClick, + onPolygonClick = onPolygonClick, + content = content, + ) + + is MapLibreViewStateImpl -> + MapLibreMapView( + modifier = modifier, + markerRenderingStrategy = renderingStrategy as? MarkerRenderingStrategy?, + state = state, onMapLoaded = onMapLoaded, onMapClick = onMapClick, onMarkerClick = onMarkerClick, diff --git a/example-app/src/main/java/com/mapconductor/example/SidebarAwareMapComponent.kt b/example-app/src/main/java/com/mapconductor/example/SidebarAwareMapComponent.kt deleted file mode 100644 index 2a6fc80b..00000000 --- a/example-app/src/main/java/com/mapconductor/example/SidebarAwareMapComponent.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.mapconductor.example - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import com.mapconductor.core.map.MapViewState -import com.mapconductor.core.map.OnMapEventHandler -import com.mapconductor.core.marker.OnMarkerEventHandler - -/** - * Example of a map component that defers initialization until sidebar is closed. - * - * Usage in your pages: - * ```kotlin - * SidebarAwareMapComponent( - * modifier = Modifier.fillMaxSize(), - * mapViewState = currentMapViewState, - * isSidebarExpanded = isSidebarExpanded, // From NavigationViewModel - * onMapClick = { position -> /* handle */ }, - * onMarkerClick = { marker -> /* handle */ } - * ) { - * // Your map content (markers, circles, etc.) - * markers.forEach { Marker(it) } - * } - * ``` - */ -@Composable -fun SidebarAwareMapComponent( - modifier: Modifier = Modifier, - mapViewState: MapViewState<*>?, - isSidebarExpanded: Boolean, - onMapClick: OnMapEventHandler? = null, - onMarkerClick: OnMarkerEventHandler? = null, - content: @Composable (() -> Unit)? = null, -) { - mapViewState?.let { currentMapViewState -> - MapViewContainer( - modifier = modifier, - state = currentMapViewState, - // Only initialize map when sidebar is closed - onMapClick = onMapClick, - onMarkerClick = onMarkerClick, - shouldInitialize = !isSidebarExpanded, - ) { - content?.invoke() - } - } -} - -/** - * Alternative approach: Initialize after a delay once sidebar is closed - */ -@Composable -fun DelayedInitMapComponent( - modifier: Modifier = Modifier, - mapViewState: MapViewState<*>?, - isSidebarExpanded: Boolean, - initDelayMs: Long = 300L, // Small delay for smoother UX - onMapClick: OnMapEventHandler? = null, - onMarkerClick: OnMarkerEventHandler? = null, - content: @Composable (() -> Unit)? = null, -) { - // You can implement a delayed initialization using LaunchedEffect - // if you want the map to initialize a few milliseconds after sidebar closes - LaunchedEffect(!isSidebarExpanded) { - if (!isSidebarExpanded) { - kotlinx.coroutines.delay(initDelayMs) - } - } - - mapViewState?.let { currentMapViewState -> - MapViewContainer( - modifier = modifier, - state = currentMapViewState, - onMapClick = onMapClick, - onMarkerClick = onMarkerClick, - shouldInitialize = !isSidebarExpanded, - ) { - content?.invoke() - } - } -} diff --git a/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficePage.kt b/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficePage.kt index e333d159..3a497660 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficePage.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficePage.kt @@ -2,13 +2,21 @@ package com.mapconductor.example.pages.marker.postoffice import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel @@ -19,8 +27,7 @@ import com.mapconductor.example.ui.DemoMapPageScaffold import com.mapconductor.googlemaps.GoogleMapActualMarker import com.mapconductor.here.HereActualMarker import com.mapconductor.mapbox.MapboxActualMarker -import com.mapconductor.marker.nativestrategy.NativeParallelMarkerStrategy -import com.mapconductor.marker.nativestrategy.NativeSpatialMarkerRenderingStrategy +import com.mapconductor.maplibre.MapLibreActualMarker import com.mapconductor.marker.nativestrategy.spatial.NativeRemoteSpatialMarkerStrategy @Composable @@ -33,15 +40,24 @@ fun PostOfficeMapPage( val strategies = remember { val google = NativeRemoteSpatialMarkerStrategy(context) -// val google = NativeParallelMarkerStrategy() - val mapbox = NativeParallelMarkerStrategy() - val here = NativeParallelMarkerStrategy() - val arcgis = NativeSpatialMarkerRenderingStrategy() + val mapbox = + NativeRemoteSpatialMarkerStrategy( + context = context, + addOnlyMode = true, + ) + val here = NativeRemoteSpatialMarkerStrategy(context) + val arcgis = NativeRemoteSpatialMarkerStrategy(context) + val maplibre = + NativeRemoteSpatialMarkerStrategy( + context = context, + addOnlyMode = true, + ) Strategies( google = google, mapbox = mapbox, here = here, arcgis = arcgis, + maplibre = maplibre, ) } @@ -63,7 +79,7 @@ fun PostOfficeMapPage( }, ) - // Show loading indicator while map is loading or data is being loaded + // Show loading dialog while map or data is loading; start data load once Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -72,6 +88,11 @@ fun PostOfficeMapPage( val markers = viewModel.markerList.collectAsState().value val mapViewState = viewModel.mapViewState.collectAsState().value val isMapLoaded = viewModel.isMapLoaded.collectAsState().value + val isDataLoading = viewModel.isDataLoading.collectAsState().value + + LaunchedEffect(Unit) { + viewModel.loadPostOfficeData() + } DemoMapPageScaffold( menuItems = DefaultMapViewItems(viewModel.initCameraPosition), @@ -92,9 +113,36 @@ fun PostOfficeMapPage( } } - if (!isMapLoaded) { - viewModel.loadPostOfficeData() - CircularProgressIndicator() + if (!isMapLoaded || isDataLoading) { + LoadingDialog( + title = "Loading Post Offices", + message = if (!isMapLoaded) "Preparing map..." else "Generating markers...", + ) + } + } +} + +@Composable +private fun LoadingDialog( + title: String, + message: String, +) { + Dialog(onDismissRequest = { /* block dismiss while loading */ }) { + Card( + shape = MaterialTheme.shapes.medium, + ) { + androidx.compose.foundation.layout.Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + androidx.compose.foundation.layout + .Spacer(modifier = Modifier.padding(top = 8.dp)) + CircularProgressIndicator() + androidx.compose.foundation.layout + .Spacer(modifier = Modifier.padding(top = 8.dp)) + Text(text = message, style = MaterialTheme.typography.bodyMedium) + } } } } diff --git a/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficeViewModel.kt b/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficeViewModel.kt index 71d61626..cd01dc5b 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficeViewModel.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/marker/postoffice/PostOfficeViewModel.kt @@ -15,11 +15,13 @@ import com.mapconductor.here.HereActualMarker import com.mapconductor.here.HereViewState import com.mapconductor.mapbox.MapboxActualMarker import com.mapconductor.mapbox.MapboxViewState +import com.mapconductor.maplibre.MapLibreActualMarker +import com.mapconductor.maplibre.MapLibreViewState import com.mapconductor.marker.strategy.SimpleMarkerStrategy import com.mapconductor.marker.strategy.spatial.RemoteSpatialMarkerStrategy +import java.lang.Thread.sleep import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -31,6 +33,7 @@ interface PostOfficeViewModel { val markerList: StateFlow> val mapViewState: StateFlow?> val isMapLoaded: StateFlow + val isDataLoading: StateFlow val renderingStrategy: StateFlow?> @@ -52,6 +55,7 @@ data class Strategies( val mapbox: MarkerRenderingStrategy, val here: MarkerRenderingStrategy, val arcgis: MarkerRenderingStrategy, + val maplibre: MarkerRenderingStrategy, ) class PostOfficeViewModelImpl( @@ -79,6 +83,9 @@ class PostOfficeViewModelImpl( private val _isMapLoaded: MutableStateFlow = MutableStateFlow(false) override val isMapLoaded: StateFlow = _isMapLoaded.asStateFlow() + private val _isDataLoading: MutableStateFlow = MutableStateFlow(false) + override val isDataLoading: StateFlow = _isDataLoading.asStateFlow() + private var _mapViewState: MutableStateFlow?> = MutableStateFlow(null) override val mapViewState: StateFlow?> = _mapViewState.asStateFlow() @@ -91,9 +98,10 @@ class PostOfficeViewModelImpl( override fun loadPostOfficeData() { if (_markerList.value.isNotEmpty()) return + coroutine.launch { - // Wait until map tiles are rendered. - delay(2500) + _isDataLoading.value = true + sleep(3000) val postOffices = dataLoader.loadAllPostOffices() val markerStates = @@ -106,6 +114,15 @@ class PostOfficeViewModelImpl( ) } _markerList.value = markerStates + _isDataLoading.value = false + sleep(1000) + } + } + + // Convenience: in case of error paths, ensure the dialog is hidden + private fun markLoadingFinished() { + if (_isDataLoading.value) { + _isDataLoading.value = false } } @@ -139,15 +156,16 @@ class PostOfficeViewModelImpl( renderingStrategy.value?.clear() this._selectedMarker.value = null _mapViewState.value = mapViewState + _isMapLoaded.value = false _renderingStrategy.value = when (mapViewState) { is GoogleMapViewState -> strategies.google is MapboxViewState -> strategies.mapbox is HereViewState -> strategies.here is ArcGISMapViewState -> strategies.arcgis + is MapLibreViewState -> strategies.maplibre else -> SimpleMarkerStrategy() } as MarkerRenderingStrategy? - _isMapLoaded.value = false } override fun onCleared() { diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/basic/PolygonMapPage.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/basic/PolygonMapPage.kt index a488dbff..e0fa3c63 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/polygon/basic/PolygonMapPage.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/basic/PolygonMapPage.kt @@ -14,16 +14,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import com.mapconductor.example.ui.DefaultMapViewItems import com.mapconductor.example.ui.DemoMapPageScaffold import com.mapconductor.example.ui.MessageCard -import com.mapconductor.example.ui.PolygonCapableMapViewItems @Composable fun PolygonMapPage(onToggleSidebar: () -> Unit = {}) { val viewModel = remember { PolygonMapPageViewModelImpl() } DemoMapPageScaffold( - menuItems = PolygonCapableMapViewItems(viewModel.initCameraPosition), + menuItems = DefaultMapViewItems(viewModel.initCameraPosition), onToggleSidebar = onToggleSidebar, onMapViewStateChanged = viewModel::onMapViewChanged, ) { paddingValues -> diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/California.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/California.kt new file mode 100644 index 00000000..a3d0c715 --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/California.kt @@ -0,0 +1,774 @@ +package com.mapconductor.example.pages.polygon.click + +import com.mapconductor.core.features.GeoPointImpl + +val california = + listOf( + listOf( + GeoPointImpl.fromLatLong(37.77205, -123.00111), + GeoPointImpl.fromLatLong(37.77078, -122.99754), + GeoPointImpl.fromLatLong(37.76913, -122.99509), + GeoPointImpl.fromLatLong(37.76387, -122.98741), + GeoPointImpl.fromLatLong(37.75892, -122.98143), + GeoPointImpl.fromLatLong(37.75498, -122.9776), + GeoPointImpl.fromLatLong(37.75258, -122.97545), + GeoPointImpl.fromLatLong(37.74993, -122.97406), + GeoPointImpl.fromLatLong(37.74865, -122.97326), + GeoPointImpl.fromLatLong(37.74782, -122.97045), + GeoPointImpl.fromLatLong(37.7463, -122.96727), + GeoPointImpl.fromLatLong(37.74428, -122.9641), + GeoPointImpl.fromLatLong(37.74195, -122.96106), + GeoPointImpl.fromLatLong(37.73973, -122.95839), + GeoPointImpl.fromLatLong(37.73683, -122.95464), + GeoPointImpl.fromLatLong(37.73219, -122.94998), + GeoPointImpl.fromLatLong(37.72531, -122.94552), + GeoPointImpl.fromLatLong(37.72106, -122.94229), + GeoPointImpl.fromLatLong(37.71535, -122.93971), + GeoPointImpl.fromLatLong(37.70996, -122.93792), + GeoPointImpl.fromLatLong(37.70924, -122.93768), + GeoPointImpl.fromLatLong(37.70246, -122.93586), + GeoPointImpl.fromLatLong(37.69762, -122.93571), + GeoPointImpl.fromLatLong(37.69093, -122.93734), + GeoPointImpl.fromLatLong(37.68654, -122.93903), + GeoPointImpl.fromLatLong(37.68235, -122.93966), + GeoPointImpl.fromLatLong(37.67658, -122.94136), + GeoPointImpl.fromLatLong(37.66977, -122.94501), + GeoPointImpl.fromLatLong(37.66338, -122.94907), + GeoPointImpl.fromLatLong(37.65835, -122.95488), + GeoPointImpl.fromLatLong(37.65198, -122.96301), + GeoPointImpl.fromLatLong(37.64658, -122.97301), + GeoPointImpl.fromLatLong(37.6439, -122.98051), + GeoPointImpl.fromLatLong(37.64178, -122.98675), + GeoPointImpl.fromLatLong(37.64102, -122.99259), + GeoPointImpl.fromLatLong(37.63993, -123.00091), + GeoPointImpl.fromLatLong(37.63983, -123.00111), + GeoPointImpl.fromLatLong(37.64177, -123.00111), + GeoPointImpl.fromLatLong(37.64151, -123.0116), + GeoPointImpl.fromLatLong(37.64522, -123.02715), + GeoPointImpl.fromLatLong(37.65033, -123.0407), + GeoPointImpl.fromLatLong(37.65865, -123.05358), + GeoPointImpl.fromLatLong(37.66732, -123.06358), + GeoPointImpl.fromLatLong(37.67304, -123.06859), + GeoPointImpl.fromLatLong(37.68258, -123.07306), + GeoPointImpl.fromLatLong(37.69007, -123.07492), + GeoPointImpl.fromLatLong(37.69232, -123.07715), + GeoPointImpl.fromLatLong(37.69684, -123.08188), + GeoPointImpl.fromLatLong(37.70075, -123.08606), + GeoPointImpl.fromLatLong(37.70579, -123.08878), + GeoPointImpl.fromLatLong(37.71187, -123.0918), + GeoPointImpl.fromLatLong(37.71369, -123.09285), + GeoPointImpl.fromLatLong(37.71407, -123.0948), + GeoPointImpl.fromLatLong(37.71359, -123.09965), + GeoPointImpl.fromLatLong(37.71265, -123.10492), + GeoPointImpl.fromLatLong(37.71405, -123.11557), + GeoPointImpl.fromLatLong(37.71544, -123.11964), + GeoPointImpl.fromLatLong(37.71673, -123.12611), + GeoPointImpl.fromLatLong(37.71939, -123.1326), + GeoPointImpl.fromLatLong(37.72616, -123.14149), + GeoPointImpl.fromLatLong(37.73228, -123.14913), + GeoPointImpl.fromLatLong(37.73784, -123.15676), + GeoPointImpl.fromLatLong(37.74353, -123.16275), + GeoPointImpl.fromLatLong(37.74993, -123.1667), + GeoPointImpl.fromLatLong(37.75593, -123.16998), + GeoPointImpl.fromLatLong(37.7636, -123.17293), + GeoPointImpl.fromLatLong(37.77183, -123.17376), + GeoPointImpl.fromLatLong(37.77573, -123.17382), + GeoPointImpl.fromLatLong(37.77611, -123.17371), + GeoPointImpl.fromLatLong(37.79236, -123.16911), + GeoPointImpl.fromLatLong(37.79688, -123.16601), + GeoPointImpl.fromLatLong(37.802, -123.16094), + GeoPointImpl.fromLatLong(37.80707, -123.15386), + GeoPointImpl.fromLatLong(37.80744, -123.15333), + GeoPointImpl.fromLatLong(37.81195, -123.14721), + GeoPointImpl.fromLatLong(37.81672, -123.13944), + GeoPointImpl.fromLatLong(37.81968, -123.13227), + GeoPointImpl.fromLatLong(37.82166, -123.12611), + GeoPointImpl.fromLatLong(37.82306, -123.11829), + GeoPointImpl.fromLatLong(37.82254, -123.10826), + GeoPointImpl.fromLatLong(37.82188, -123.09526), + GeoPointImpl.fromLatLong(37.81815, -123.08165), + GeoPointImpl.fromLatLong(37.81383, -123.07049), + GeoPointImpl.fromLatLong(37.79982, -123.04882), + GeoPointImpl.fromLatLong(37.78888, -123.03873), + GeoPointImpl.fromLatLong(37.78043, -123.0351), + GeoPointImpl.fromLatLong(37.77837, -123.03384), + GeoPointImpl.fromLatLong(37.77715, -123.03144), + GeoPointImpl.fromLatLong(37.77709, -123.02585), + GeoPointImpl.fromLatLong(37.77607, -123.01697), + GeoPointImpl.fromLatLong(37.77493, -123.01059), + GeoPointImpl.fromLatLong(37.77352, -123.00615), + ), + listOf( + GeoPointImpl.fromLatLong(33.53589, -119.00093), + GeoPointImpl.fromLatLong(33.53582, -119.00093), + GeoPointImpl.fromLatLong(33.5342, -118.99784), + GeoPointImpl.fromLatLong(33.53271, -118.99445), + GeoPointImpl.fromLatLong(33.53185, -118.9928), + GeoPointImpl.fromLatLong(33.53029, -118.99005), + GeoPointImpl.fromLatLong(33.52743, -118.9862), + GeoPointImpl.fromLatLong(33.52426, -118.98215), + GeoPointImpl.fromLatLong(33.52065, -118.9791), + GeoPointImpl.fromLatLong(33.51768, -118.97654), + GeoPointImpl.fromLatLong(33.51384, -118.97289), + GeoPointImpl.fromLatLong(33.51066, -118.97101), + GeoPointImpl.fromLatLong(33.50556, -118.96831), + GeoPointImpl.fromLatLong(33.50002, -118.96602), + GeoPointImpl.fromLatLong(33.4953, -118.9649), + GeoPointImpl.fromLatLong(33.48521, -118.96276), + GeoPointImpl.fromLatLong(33.4733, -118.96273), + GeoPointImpl.fromLatLong(33.46683, -118.96375), + GeoPointImpl.fromLatLong(33.45792, -118.96676), + GeoPointImpl.fromLatLong(33.45289, -118.96983), + GeoPointImpl.fromLatLong(33.44317, -118.97268), + GeoPointImpl.fromLatLong(33.43111, -118.97848), + GeoPointImpl.fromLatLong(33.42859, -118.98051), + GeoPointImpl.fromLatLong(33.42344, -118.98873), + GeoPointImpl.fromLatLong(33.41996, -118.99598), + GeoPointImpl.fromLatLong(33.41849, -119.00093), + GeoPointImpl.fromLatLong(33.4213, -119.00093), + GeoPointImpl.fromLatLong(33.41677, -119.00952), + GeoPointImpl.fromLatLong(33.41505, -119.018), + GeoPointImpl.fromLatLong(33.41287, -119.02704), + GeoPointImpl.fromLatLong(33.41222, -119.03963), + GeoPointImpl.fromLatLong(33.41102, -119.05307), + GeoPointImpl.fromLatLong(33.41255, -119.06412), + GeoPointImpl.fromLatLong(33.4138, -119.07189), + GeoPointImpl.fromLatLong(33.41677, -119.07851), + GeoPointImpl.fromLatLong(33.42139, -119.08651), + GeoPointImpl.fromLatLong(33.42687, -119.09413), + GeoPointImpl.fromLatLong(33.43524, -119.10076), + GeoPointImpl.fromLatLong(33.44044, -119.10378), + GeoPointImpl.fromLatLong(33.44501, -119.10615), + GeoPointImpl.fromLatLong(33.45115, -119.10888), + GeoPointImpl.fromLatLong(33.45632, -119.1088), + GeoPointImpl.fromLatLong(33.46229, -119.10965), + GeoPointImpl.fromLatLong(33.46894, -119.10908), + GeoPointImpl.fromLatLong(33.47428, -119.10957), + GeoPointImpl.fromLatLong(33.48007, -119.1092), + GeoPointImpl.fromLatLong(33.48578, -119.10836), + GeoPointImpl.fromLatLong(33.49119, -119.10791), + GeoPointImpl.fromLatLong(33.49542, -119.10747), + GeoPointImpl.fromLatLong(33.49518, -119.10794), + GeoPointImpl.fromLatLong(33.50002, -119.10721), + GeoPointImpl.fromLatLong(33.50002, -119.11895), + GeoPointImpl.fromLatLong(33.5056, -119.11717), + GeoPointImpl.fromLatLong(33.50986, -119.11508), + GeoPointImpl.fromLatLong(33.51547, -119.11166), + GeoPointImpl.fromLatLong(33.51926, -119.10872), + GeoPointImpl.fromLatLong(33.52289, -119.10421), + GeoPointImpl.fromLatLong(33.52644, -119.0987), + GeoPointImpl.fromLatLong(33.5303, -119.09049), + GeoPointImpl.fromLatLong(33.53291, -119.08374), + GeoPointImpl.fromLatLong(33.53515, -119.0765), + GeoPointImpl.fromLatLong(33.53642, -119.07014), + GeoPointImpl.fromLatLong(33.53671, -119.06616), + GeoPointImpl.fromLatLong(33.53643, -119.06041), + GeoPointImpl.fromLatLong(33.53603, -119.05655), + GeoPointImpl.fromLatLong(33.53676, -119.05394), + GeoPointImpl.fromLatLong(33.53716, -119.0508), + GeoPointImpl.fromLatLong(33.53729, -119.04847), + GeoPointImpl.fromLatLong(33.53722, -119.04706), + GeoPointImpl.fromLatLong(33.53862, -119.04139), + GeoPointImpl.fromLatLong(33.53962, -119.03625), + GeoPointImpl.fromLatLong(33.54012, -119.03074), + GeoPointImpl.fromLatLong(33.54, -119.02394), + GeoPointImpl.fromLatLong(33.53919, -119.01735), + GeoPointImpl.fromLatLong(33.53834, -119.01213), + GeoPointImpl.fromLatLong(33.53755, -119.00807), + ), + listOf( + GeoPointImpl.fromLatLong(32.87504, -118.3289), + GeoPointImpl.fromLatLong(32.87173, -118.32571), + GeoPointImpl.fromLatLong(32.86377, -118.31739), + GeoPointImpl.fromLatLong(32.85347, -118.30489), + GeoPointImpl.fromLatLong(32.83639, -118.2924), + GeoPointImpl.fromLatLong(32.82083, -118.28878), + GeoPointImpl.fromLatLong(32.81053, -118.2899), + GeoPointImpl.fromLatLong(32.79826, -118.29584), + GeoPointImpl.fromLatLong(32.78885, -118.30285), + GeoPointImpl.fromLatLong(32.7768, -118.31853), + GeoPointImpl.fromLatLong(32.77057, -118.33197), + GeoPointImpl.fromLatLong(32.76581, -118.35349), + GeoPointImpl.fromLatLong(32.76708, -118.36427), + GeoPointImpl.fromLatLong(32.76885, -118.37386), + GeoPointImpl.fromLatLong(32.77023, -118.37838), + GeoPointImpl.fromLatLong(32.76808, -118.3812), + GeoPointImpl.fromLatLong(32.76356, -118.38535), + GeoPointImpl.fromLatLong(32.7585, -118.39256), + GeoPointImpl.fromLatLong(32.75523, -118.39952), + GeoPointImpl.fromLatLong(32.75191, -118.40832), + GeoPointImpl.fromLatLong(32.75059, -118.4162), + GeoPointImpl.fromLatLong(32.75004, -118.43654), + GeoPointImpl.fromLatLong(32.75103, -118.44091), + GeoPointImpl.fromLatLong(32.75511, -118.45381), + GeoPointImpl.fromLatLong(32.76018, -118.46471), + GeoPointImpl.fromLatLong(32.76682, -118.47391), + GeoPointImpl.fromLatLong(32.77472, -118.48167), + GeoPointImpl.fromLatLong(32.77916, -118.48497), + GeoPointImpl.fromLatLong(32.7855, -118.49421), + GeoPointImpl.fromLatLong(32.79203, -118.50091), + GeoPointImpl.fromLatLong(32.79688, -118.50765), + GeoPointImpl.fromLatLong(32.79957, -118.51581), + GeoPointImpl.fromLatLong(32.80472, -118.52481), + GeoPointImpl.fromLatLong(32.80877, -118.53178), + GeoPointImpl.fromLatLong(32.81277, -118.5382), + GeoPointImpl.fromLatLong(32.82068, -118.54673), + GeoPointImpl.fromLatLong(32.8288, -118.55319), + GeoPointImpl.fromLatLong(32.83735, -118.55714), + GeoPointImpl.fromLatLong(32.84944, -118.56253), + GeoPointImpl.fromLatLong(32.85456, -118.56735), + GeoPointImpl.fromLatLong(32.86573, -118.57505), + GeoPointImpl.fromLatLong(32.87152, -118.58132), + GeoPointImpl.fromLatLong(32.87884, -118.58794), + GeoPointImpl.fromLatLong(32.88383, -118.59114), + GeoPointImpl.fromLatLong(32.89047, -118.59693), + GeoPointImpl.fromLatLong(32.90356, -118.60352), + GeoPointImpl.fromLatLong(32.90661, -118.60433), + GeoPointImpl.fromLatLong(32.91243, -118.60552), + GeoPointImpl.fromLatLong(32.9182, -118.60547), + GeoPointImpl.fromLatLong(32.92612, -118.60817), + GeoPointImpl.fromLatLong(32.92931, -118.61079), + GeoPointImpl.fromLatLong(32.93685, -118.6186), + GeoPointImpl.fromLatLong(32.94723, -118.62592), + GeoPointImpl.fromLatLong(32.95363, -118.62957), + GeoPointImpl.fromLatLong(32.96221, -118.63387), + GeoPointImpl.fromLatLong(32.96909, -118.63649), + GeoPointImpl.fromLatLong(32.97536, -118.63771), + GeoPointImpl.fromLatLong(32.98261, -118.64049), + GeoPointImpl.fromLatLong(32.99284, -118.64343), + GeoPointImpl.fromLatLong(32.99777, -118.64433), + GeoPointImpl.fromLatLong(33.00003, -118.67017), + GeoPointImpl.fromLatLong(33.01214, -118.67484), + GeoPointImpl.fromLatLong(33.02636, -118.67855), + GeoPointImpl.fromLatLong(33.04935, -118.6756), + GeoPointImpl.fromLatLong(33.06685, -118.66385), + GeoPointImpl.fromLatLong(33.08188, -118.6413), + GeoPointImpl.fromLatLong(33.08566, -118.62592), + GeoPointImpl.fromLatLong(33.08632, -118.60834), + GeoPointImpl.fromLatLong(33.08711, -118.58953), + GeoPointImpl.fromLatLong(33.08222, -118.56492), + GeoPointImpl.fromLatLong(33.07863, -118.55263), + GeoPointImpl.fromLatLong(33.06786, -118.53419), + GeoPointImpl.fromLatLong(33.04989, -118.51497), + GeoPointImpl.fromLatLong(33.03788, -118.50766), + GeoPointImpl.fromLatLong(33.03059, -118.50091), + GeoPointImpl.fromLatLong(33.01792, -118.49113), + GeoPointImpl.fromLatLong(33.00818, -118.48714), + GeoPointImpl.fromLatLong(33.00462, -118.48475), + GeoPointImpl.fromLatLong(33.00003, -118.48141), + GeoPointImpl.fromLatLong(32.99578, -118.47779), + GeoPointImpl.fromLatLong(32.98912, -118.47119), + GeoPointImpl.fromLatLong(32.97525, -118.46018), + GeoPointImpl.fromLatLong(32.96797, -118.45454), + GeoPointImpl.fromLatLong(32.96175, -118.44886), + GeoPointImpl.fromLatLong(32.95795, -118.44215), + GeoPointImpl.fromLatLong(32.94958, -118.42769), + GeoPointImpl.fromLatLong(32.94211, -118.41874), + GeoPointImpl.fromLatLong(32.93647, -118.41127), + GeoPointImpl.fromLatLong(32.93179, -118.40604), + GeoPointImpl.fromLatLong(32.91784, -118.39079), + GeoPointImpl.fromLatLong(32.91508, -118.38644), + GeoPointImpl.fromLatLong(32.91185, -118.38157), + GeoPointImpl.fromLatLong(32.9096, -118.37762), + GeoPointImpl.fromLatLong(32.90869, -118.37591), + GeoPointImpl.fromLatLong(32.90507, -118.36992), + GeoPointImpl.fromLatLong(32.90012, -118.36252), + GeoPointImpl.fromLatLong(32.8945, -118.35639), + GeoPointImpl.fromLatLong(32.88968, -118.35031), + GeoPointImpl.fromLatLong(32.88443, -118.34358), + GeoPointImpl.fromLatLong(32.87975, -118.33746), + GeoPointImpl.fromLatLong(32.87678, -118.33197), + ), + listOf( + GeoPointImpl.fromLatLong(33.41669, -118.30868), + GeoPointImpl.fromLatLong(33.40972, -118.30713), + GeoPointImpl.fromLatLong(33.40363, -118.3041), + GeoPointImpl.fromLatLong(33.38984, -118.28581), + GeoPointImpl.fromLatLong(33.38074, -118.27721), + GeoPointImpl.fromLatLong(33.37502, -118.26818), + GeoPointImpl.fromLatLong(33.36413, -118.25771), + GeoPointImpl.fromLatLong(33.3534, -118.25138), + GeoPointImpl.fromLatLong(33.33834, -118.2451), + GeoPointImpl.fromLatLong(33.31303, -118.24105), + GeoPointImpl.fromLatLong(33.29106, -118.24594), + GeoPointImpl.fromLatLong(33.28047, -118.2509), + GeoPointImpl.fromLatLong(33.2725, -118.25918), + GeoPointImpl.fromLatLong(33.25909, -118.2807), + GeoPointImpl.fromLatLong(33.25101, -118.30284), + GeoPointImpl.fromLatLong(33.25003, -118.30872), + GeoPointImpl.fromLatLong(33.24847, -118.31862), + GeoPointImpl.fromLatLong(33.24861, -118.33475), + GeoPointImpl.fromLatLong(33.25003, -118.34377), + GeoPointImpl.fromLatLong(33.25507, -118.36331), + GeoPointImpl.fromLatLong(33.2623, -118.38149), + GeoPointImpl.fromLatLong(33.2652, -118.38913), + GeoPointImpl.fromLatLong(33.26794, -118.40256), + GeoPointImpl.fromLatLong(33.26596, -118.42752), + GeoPointImpl.fromLatLong(33.26746, -118.44046), + GeoPointImpl.fromLatLong(33.27289, -118.47146), + GeoPointImpl.fromLatLong(33.28407, -118.50091), + GeoPointImpl.fromLatLong(33.28966, -118.51039), + GeoPointImpl.fromLatLong(33.31518, -118.53488), + GeoPointImpl.fromLatLong(33.33105, -118.54252), + GeoPointImpl.fromLatLong(33.35065, -118.54961), + GeoPointImpl.fromLatLong(33.36935, -118.55119), + GeoPointImpl.fromLatLong(33.37503, -118.54858), + GeoPointImpl.fromLatLong(33.38023, -118.55338), + GeoPointImpl.fromLatLong(33.38103, -118.56363), + GeoPointImpl.fromLatLong(33.39087, -118.59108), + GeoPointImpl.fromLatLong(33.40619, -118.61857), + GeoPointImpl.fromLatLong(33.41507, -118.62592), + GeoPointImpl.fromLatLong(33.42868, -118.63694), + GeoPointImpl.fromLatLong(33.44531, -118.65506), + GeoPointImpl.fromLatLong(33.46733, -118.66592), + GeoPointImpl.fromLatLong(33.48594, -118.6682), + GeoPointImpl.fromLatLong(33.50003, -118.66371), + GeoPointImpl.fromLatLong(33.50576, -118.65992), + GeoPointImpl.fromLatLong(33.51082, -118.65573), + GeoPointImpl.fromLatLong(33.51821, -118.64786), + GeoPointImpl.fromLatLong(33.52403, -118.6384), + GeoPointImpl.fromLatLong(33.52898, -118.62592), + GeoPointImpl.fromLatLong(33.53185, -118.61216), + GeoPointImpl.fromLatLong(33.53232, -118.59976), + GeoPointImpl.fromLatLong(33.53198, -118.58972), + GeoPointImpl.fromLatLong(33.53088, -118.58582), + GeoPointImpl.fromLatLong(33.52841, -118.57914), + GeoPointImpl.fromLatLong(33.52926, -118.57529), + GeoPointImpl.fromLatLong(33.52947, -118.57152), + GeoPointImpl.fromLatLong(33.52948, -118.56719), + GeoPointImpl.fromLatLong(33.52929, -118.56317), + GeoPointImpl.fromLatLong(33.52833, -118.55775), + GeoPointImpl.fromLatLong(33.52921, -118.55502), + GeoPointImpl.fromLatLong(33.53019, -118.55093), + GeoPointImpl.fromLatLong(33.53084, -118.5466), + GeoPointImpl.fromLatLong(33.53126, -118.53894), + GeoPointImpl.fromLatLong(33.53144, -118.53364), + GeoPointImpl.fromLatLong(33.53118, -118.52938), + GeoPointImpl.fromLatLong(33.53015, -118.52324), + GeoPointImpl.fromLatLong(33.52869, -118.51765), + GeoPointImpl.fromLatLong(33.52692, -118.51238), + GeoPointImpl.fromLatLong(33.52491, -118.50676), + GeoPointImpl.fromLatLong(33.52267, -118.50261), + GeoPointImpl.fromLatLong(33.52163, -118.50091), + GeoPointImpl.fromLatLong(33.51838, -118.49671), + GeoPointImpl.fromLatLong(33.51573, -118.49333), + GeoPointImpl.fromLatLong(33.51615, -118.48788), + GeoPointImpl.fromLatLong(33.51606, -118.48262), + GeoPointImpl.fromLatLong(33.51543, -118.47728), + GeoPointImpl.fromLatLong(33.513, -118.46896), + GeoPointImpl.fromLatLong(33.51043, -118.46285), + GeoPointImpl.fromLatLong(33.50688, -118.45682), + GeoPointImpl.fromLatLong(33.50339, -118.4522), + GeoPointImpl.fromLatLong(33.50003, -118.44755), + GeoPointImpl.fromLatLong(33.48944, -118.43683), + GeoPointImpl.fromLatLong(33.48894, -118.43243), + GeoPointImpl.fromLatLong(33.48716, -118.42709), + GeoPointImpl.fromLatLong(33.48387, -118.42287), + GeoPointImpl.fromLatLong(33.48178, -118.42084), + GeoPointImpl.fromLatLong(33.48078, -118.41187), + GeoPointImpl.fromLatLong(33.47927, -118.40323), + GeoPointImpl.fromLatLong(33.47671, -118.39513), + GeoPointImpl.fromLatLong(33.47449, -118.38939), + GeoPointImpl.fromLatLong(33.47345, -118.38605), + GeoPointImpl.fromLatLong(33.47157, -118.37798), + GeoPointImpl.fromLatLong(33.4711, -118.37591), + GeoPointImpl.fromLatLong(33.469, -118.36922), + GeoPointImpl.fromLatLong(33.46492, -118.35939), + GeoPointImpl.fromLatLong(33.45938, -118.35255), + GeoPointImpl.fromLatLong(33.45782, -118.35092), + GeoPointImpl.fromLatLong(33.45388, -118.34206), + GeoPointImpl.fromLatLong(33.44804, -118.33277), + GeoPointImpl.fromLatLong(33.44198, -118.32465), + GeoPointImpl.fromLatLong(33.43414, -118.31635), + GeoPointImpl.fromLatLong(33.42514, -118.31095), + ), + listOf( + GeoPointImpl.fromLatLong(34.06867, -119.37595), + GeoPointImpl.fromLatLong(34.06506, -119.33093), + GeoPointImpl.fromLatLong(34.03658, -119.29764), + GeoPointImpl.fromLatLong(34.00001, -119.29647), + GeoPointImpl.fromLatLong(33.98654, -119.30409), + GeoPointImpl.fromLatLong(33.96543, -119.34154), + GeoPointImpl.fromLatLong(33.95636, -119.36976), + GeoPointImpl.fromLatLong(33.95332, -119.38467), + GeoPointImpl.fromLatLong(33.95378, -119.40293), + GeoPointImpl.fromLatLong(33.95591, -119.43296), + GeoPointImpl.fromLatLong(33.97065, -119.47265), + GeoPointImpl.fromLatLong(33.97634, -119.49695), + GeoPointImpl.fromLatLong(33.95492, -119.52331), + GeoPointImpl.fromLatLong(33.94496, -119.56329), + GeoPointImpl.fromLatLong(33.93808, -119.59594), + GeoPointImpl.fromLatLong(33.93588, -119.61853), + GeoPointImpl.fromLatLong(33.93626, -119.64054), + GeoPointImpl.fromLatLong(33.9264, -119.66396), + GeoPointImpl.fromLatLong(33.91844, -119.68239), + GeoPointImpl.fromLatLong(33.90963, -119.71883), + GeoPointImpl.fromLatLong(33.9114, -119.75096), + GeoPointImpl.fromLatLong(33.91061, -119.77831), + GeoPointImpl.fromLatLong(33.90132, -119.81022), + GeoPointImpl.fromLatLong(33.90749, -119.85522), + GeoPointImpl.fromLatLong(33.92265, -119.87823), + GeoPointImpl.fromLatLong(33.93967, -119.90476), + GeoPointImpl.fromLatLong(33.9239, -119.91102), + GeoPointImpl.fromLatLong(33.89438, -119.95029), + GeoPointImpl.fromLatLong(33.88525, -120.00096), + GeoPointImpl.fromLatLong(33.875, -120.01553), + GeoPointImpl.fromLatLong(33.86432, -120.04253), + GeoPointImpl.fromLatLong(33.85051, -120.08011), + GeoPointImpl.fromLatLong(33.84235, -120.12596), + GeoPointImpl.fromLatLong(33.85343, -120.15708), + GeoPointImpl.fromLatLong(33.86629, -120.183), + GeoPointImpl.fromLatLong(33.87598, -120.20238), + GeoPointImpl.fromLatLong(33.89233, -120.22963), + GeoPointImpl.fromLatLong(33.90844, -120.25097), + GeoPointImpl.fromLatLong(33.94336, -120.2777), + GeoPointImpl.fromLatLong(33.96224, -120.29187), + GeoPointImpl.fromLatLong(33.96637, -120.31739), + GeoPointImpl.fromLatLong(33.96139, -120.35863), + GeoPointImpl.fromLatLong(33.96743, -120.39564), + GeoPointImpl.fromLatLong(33.97495, -120.42259), + GeoPointImpl.fromLatLong(33.97627, -120.45579), + GeoPointImpl.fromLatLong(33.98428, -120.48815), + GeoPointImpl.fromLatLong(33.99735, -120.50308), + GeoPointImpl.fromLatLong(34.02282, -120.52314), + GeoPointImpl.fromLatLong(34.05169, -120.53106), + GeoPointImpl.fromLatLong(34.06864, -120.56699), + GeoPointImpl.fromLatLong(34.09856, -120.5819), + GeoPointImpl.fromLatLong(34.12647, -120.57509), + GeoPointImpl.fromLatLong(34.14642, -120.5487), + GeoPointImpl.fromLatLong(34.15134, -120.50717), + GeoPointImpl.fromLatLong(34.14508, -120.48691), + GeoPointImpl.fromLatLong(34.13929, -120.46529), + GeoPointImpl.fromLatLong(34.15748, -120.43272), + GeoPointImpl.fromLatLong(34.15873, -120.40117), + GeoPointImpl.fromLatLong(34.15242, -120.37598), + GeoPointImpl.fromLatLong(34.14145, -120.35049), + GeoPointImpl.fromLatLong(34.12328, -120.33918), + GeoPointImpl.fromLatLong(34.1138, -120.32434), + GeoPointImpl.fromLatLong(34.10412, -120.29779), + GeoPointImpl.fromLatLong(34.0818, -120.27428), + GeoPointImpl.fromLatLong(34.06508, -120.26241), + GeoPointImpl.fromLatLong(34.05803, -120.25285), + GeoPointImpl.fromLatLong(34.06114, -120.231), + GeoPointImpl.fromLatLong(34.06022, -120.20611), + GeoPointImpl.fromLatLong(34.06136, -120.1911), + GeoPointImpl.fromLatLong(34.0757, -120.15901), + GeoPointImpl.fromLatLong(34.07881, -120.13501), + GeoPointImpl.fromLatLong(34.07805, -120.11496), + GeoPointImpl.fromLatLong(34.07449, -120.10292), + GeoPointImpl.fromLatLong(34.0864, -120.08028), + GeoPointImpl.fromLatLong(34.09061, -120.04515), + GeoPointImpl.fromLatLong(34.08404, -120.01021), + GeoPointImpl.fromLatLong(34.06487, -119.99164), + GeoPointImpl.fromLatLong(34.09674, -119.97439), + GeoPointImpl.fromLatLong(34.11256, -119.96547), + GeoPointImpl.fromLatLong(34.12866, -119.93615), + GeoPointImpl.fromLatLong(34.12935, -119.90557), + GeoPointImpl.fromLatLong(34.12608, -119.87988), + GeoPointImpl.fromLatLong(34.125, -119.86883), + GeoPointImpl.fromLatLong(34.12019, -119.83796), + GeoPointImpl.fromLatLong(34.10638, -119.7926), + GeoPointImpl.fromLatLong(34.11194, -119.76903), + GeoPointImpl.fromLatLong(34.11092, -119.74522), + GeoPointImpl.fromLatLong(34.10175, -119.71804), + GeoPointImpl.fromLatLong(34.09031, -119.68607), + GeoPointImpl.fromLatLong(34.07122, -119.65493), + GeoPointImpl.fromLatLong(34.08673, -119.63523), + GeoPointImpl.fromLatLong(34.0927, -119.62597), + GeoPointImpl.fromLatLong(34.10564, -119.59056), + GeoPointImpl.fromLatLong(34.10811, -119.57412), + GeoPointImpl.fromLatLong(34.10778, -119.54825), + GeoPointImpl.fromLatLong(34.09871, -119.52459), + GeoPointImpl.fromLatLong(34.08584, -119.50095), + GeoPointImpl.fromLatLong(34.07183, -119.47731), + GeoPointImpl.fromLatLong(34.06474, -119.46004), + GeoPointImpl.fromLatLong(34.06769, -119.42373), + GeoPointImpl.fromLatLong(34.06604, -119.38975), + ), + listOf( + GeoPointImpl.fromLatLong(33.23455, -119.62594), + GeoPointImpl.fromLatLong(33.24497, -119.63128), + GeoPointImpl.fromLatLong(33.24891, -119.63163), + GeoPointImpl.fromLatLong(33.25322, -119.63154), + GeoPointImpl.fromLatLong(33.25902, -119.63284), + GeoPointImpl.fromLatLong(33.27304, -119.6363), + GeoPointImpl.fromLatLong(33.28596, -119.63479), + GeoPointImpl.fromLatLong(33.29934, -119.62908), + GeoPointImpl.fromLatLong(33.30925, -119.62165), + GeoPointImpl.fromLatLong(33.3148, -119.61453), + GeoPointImpl.fromLatLong(33.3204, -119.60482), + GeoPointImpl.fromLatLong(33.32582, -119.59252), + GeoPointImpl.fromLatLong(33.32779, -119.58268), + GeoPointImpl.fromLatLong(33.32828, -119.57491), + GeoPointImpl.fromLatLong(33.32764, -119.56493), + GeoPointImpl.fromLatLong(33.33118, -119.5546), + GeoPointImpl.fromLatLong(33.33416, -119.54354), + GeoPointImpl.fromLatLong(33.33526, -119.53418), + GeoPointImpl.fromLatLong(33.33486, -119.52531), + GeoPointImpl.fromLatLong(33.331, -119.50855), + GeoPointImpl.fromLatLong(33.32911, -119.50094), + GeoPointImpl.fromLatLong(33.32715, -119.49512), + GeoPointImpl.fromLatLong(33.32514, -119.49054), + GeoPointImpl.fromLatLong(33.31683, -119.4713), + GeoPointImpl.fromLatLong(33.30574, -119.44327), + GeoPointImpl.fromLatLong(33.30103, -119.43), + GeoPointImpl.fromLatLong(33.30033, -119.42872), + GeoPointImpl.fromLatLong(33.30003, -119.42816), + GeoPointImpl.fromLatLong(33.29894, -119.42618), + GeoPointImpl.fromLatLong(33.29877, -119.42594), + GeoPointImpl.fromLatLong(33.29623, -119.4225), + GeoPointImpl.fromLatLong(33.28951, -119.41337), + GeoPointImpl.fromLatLong(33.28351, -119.40539), + GeoPointImpl.fromLatLong(33.28007, -119.39991), + GeoPointImpl.fromLatLong(33.27438, -119.39098), + GeoPointImpl.fromLatLong(33.27092, -119.38522), + GeoPointImpl.fromLatLong(33.26763, -119.37973), + GeoPointImpl.fromLatLong(33.26476, -119.37594), + GeoPointImpl.fromLatLong(33.26324, -119.37594), + GeoPointImpl.fromLatLong(33.25578, -119.36908), + GeoPointImpl.fromLatLong(33.2534, -119.36723), + GeoPointImpl.fromLatLong(33.25003, -119.36472), + GeoPointImpl.fromLatLong(33.24522, -119.36212), + GeoPointImpl.fromLatLong(33.24206, -119.36093), + GeoPointImpl.fromLatLong(33.23487, -119.36013), + GeoPointImpl.fromLatLong(33.22832, -119.36018), + GeoPointImpl.fromLatLong(33.22145, -119.3607), + GeoPointImpl.fromLatLong(33.21482, -119.36244), + GeoPointImpl.fromLatLong(33.20851, -119.3654), + GeoPointImpl.fromLatLong(33.2026, -119.36845), + GeoPointImpl.fromLatLong(33.19763, -119.37196), + GeoPointImpl.fromLatLong(33.19503, -119.37386), + GeoPointImpl.fromLatLong(33.19219, -119.37594), + GeoPointImpl.fromLatLong(33.19071, -119.37899), + GeoPointImpl.fromLatLong(33.18948, -119.38146), + GeoPointImpl.fromLatLong(33.18628, -119.38614), + GeoPointImpl.fromLatLong(33.18084, -119.39454), + GeoPointImpl.fromLatLong(33.16699, -119.43372), + GeoPointImpl.fromLatLong(33.16527, -119.44497), + GeoPointImpl.fromLatLong(33.16461, -119.46836), + GeoPointImpl.fromLatLong(33.1644, -119.47223), + GeoPointImpl.fromLatLong(33.16364, -119.47526), + GeoPointImpl.fromLatLong(33.16349, -119.47649), + GeoPointImpl.fromLatLong(33.16367, -119.47874), + GeoPointImpl.fromLatLong(33.16391, -119.47959), + GeoPointImpl.fromLatLong(33.1666, -119.48944), + GeoPointImpl.fromLatLong(33.16701, -119.49142), + GeoPointImpl.fromLatLong(33.16739, -119.50094), + GeoPointImpl.fromLatLong(33.16739, -119.50803), + GeoPointImpl.fromLatLong(33.16742, -119.51025), + GeoPointImpl.fromLatLong(33.16776, -119.512), + GeoPointImpl.fromLatLong(33.169, -119.51688), + GeoPointImpl.fromLatLong(33.17089, -119.52286), + GeoPointImpl.fromLatLong(33.17266, -119.52671), + GeoPointImpl.fromLatLong(33.17325, -119.52901), + GeoPointImpl.fromLatLong(33.17392, -119.53224), + GeoPointImpl.fromLatLong(33.17647, -119.54126), + GeoPointImpl.fromLatLong(33.17882, -119.54676), + GeoPointImpl.fromLatLong(33.18028, -119.55007), + GeoPointImpl.fromLatLong(33.18061, -119.55154), + GeoPointImpl.fromLatLong(33.18126, -119.55319), + GeoPointImpl.fromLatLong(33.18152, -119.55495), + GeoPointImpl.fromLatLong(33.18248, -119.56224), + GeoPointImpl.fromLatLong(33.18462, -119.56932), + GeoPointImpl.fromLatLong(33.18648, -119.57419), + GeoPointImpl.fromLatLong(33.1885, -119.57832), + GeoPointImpl.fromLatLong(33.19019, -119.58097), + GeoPointImpl.fromLatLong(33.19553, -119.58726), + GeoPointImpl.fromLatLong(33.19673, -119.58909), + GeoPointImpl.fromLatLong(33.199, -119.59395), + GeoPointImpl.fromLatLong(33.20324, -119.60026), + GeoPointImpl.fromLatLong(33.20692, -119.60528), + GeoPointImpl.fromLatLong(33.21273, -119.61147), + GeoPointImpl.fromLatLong(33.21725, -119.61481), + GeoPointImpl.fromLatLong(33.22248, -119.61824), + GeoPointImpl.fromLatLong(33.22501, -119.61949), + GeoPointImpl.fromLatLong(33.22683, -119.61992), + GeoPointImpl.fromLatLong(33.22786, -119.62092), + GeoPointImpl.fromLatLong(33.22874, -119.62192), + GeoPointImpl.fromLatLong(33.23041, -119.62356), + GeoPointImpl.fromLatLong(33.23247, -119.62519), + ), + listOf( + GeoPointImpl.fromLatLong(34.87057, -114.63332), + GeoPointImpl.fromLatLong(34.86997, -114.63305), + GeoPointImpl.fromLatLong(34.79181, -114.56953), + GeoPointImpl.fromLatLong(34.71453, -114.48236), + GeoPointImpl.fromLatLong(34.64288, -114.44166), + GeoPointImpl.fromLatLong(34.47903, -114.38169), + GeoPointImpl.fromLatLong(34.41527, -114.29195), + GeoPointImpl.fromLatLong(34.31087, -114.14737), + GeoPointImpl.fromLatLong(34.17212, -114.26017), + GeoPointImpl.fromLatLong(34.12866, -114.35765), + GeoPointImpl.fromLatLong(34.04257, -114.4355), + GeoPointImpl.fromLatLong(33.96372, -114.49813), + GeoPointImpl.fromLatLong(33.91285, -114.51318), + GeoPointImpl.fromLatLong(33.84446, -114.52801), + GeoPointImpl.fromLatLong(33.6969, -114.49649), + GeoPointImpl.fromLatLong(33.58709, -114.5402), + GeoPointImpl.fromLatLong(33.47131, -114.61185), + GeoPointImpl.fromLatLong(33.39691, -114.72123), + GeoPointImpl.fromLatLong(33.23376, -114.68157), + GeoPointImpl.fromLatLong(33.03255, -114.62973), + GeoPointImpl.fromLatLong(32.97206, -114.48131), + GeoPointImpl.fromLatLong(32.87408, -114.46563), + GeoPointImpl.fromLatLong(32.73487, -114.58576), + GeoPointImpl.fromLatLong(32.73137, -114.63501), + GeoPointImpl.fromLatLong(32.73946, -114.69096), + GeoPointImpl.fromLatLong(32.71943, -114.71919), + GeoPointImpl.fromLatLong(32.71875, -114.71972), + GeoPointImpl.fromLatLong(32.70253, -114.9559), + GeoPointImpl.fromLatLong(32.66605, -115.47927), + GeoPointImpl.fromLatLong(32.66438, -115.50314), + GeoPointImpl.fromLatLong(32.64163, -115.80199), + GeoPointImpl.fromLatLong(32.6211, -116.0738), + GeoPointImpl.fromLatLong(32.61112, -116.19899), + GeoPointImpl.fromLatLong(32.59913, -116.3481), + GeoPointImpl.fromLatLong(32.58952, -116.46732), + GeoPointImpl.fromLatLong(32.57725, -116.61646), + GeoPointImpl.fromLatLong(32.56578, -116.75596), + GeoPointImpl.fromLatLong(32.55977, -116.82902), + GeoPointImpl.fromLatLong(32.54863, -116.95778), + GeoPointImpl.fromLatLong(32.54234, -117.02945), + GeoPointImpl.fromLatLong(32.5395, -117.06674), + GeoPointImpl.fromLatLong(32.6209, -117.22314), + GeoPointImpl.fromLatLong(32.65404, -117.30735), + GeoPointImpl.fromLatLong(32.83452, -117.34004), + GeoPointImpl.fromLatLong(33.07321, -117.37526), + GeoPointImpl.fromLatLong(33.3123, -117.57153), + GeoPointImpl.fromLatLong(33.40862, -117.71501), + GeoPointImpl.fromLatLong(33.49087, -117.81636), + GeoPointImpl.fromLatLong(33.55979, -117.94957), + GeoPointImpl.fromLatLong(33.63031, -118.06299), + GeoPointImpl.fromLatLong(33.66343, -118.34541), + GeoPointImpl.fromLatLong(33.93905, -118.51367), + GeoPointImpl.fromLatLong(33.98697, -118.62007), + GeoPointImpl.fromLatLong(33.97556, -118.74596), + GeoPointImpl.fromLatLong(33.98382, -118.87592), + GeoPointImpl.fromLatLong(34.07434, -119.22693), + GeoPointImpl.fromLatLong(34.23687, -119.33489), + GeoPointImpl.fromLatLong(34.34814, -119.56331), + GeoPointImpl.fromLatLong(34.34275, -119.73923), + GeoPointImpl.fromLatLong(34.36437, -119.90542), + GeoPointImpl.fromLatLong(34.41671, -120.12095), + GeoPointImpl.fromLatLong(34.41798, -120.24944), + GeoPointImpl.fromLatLong(34.39674, -120.42603), + GeoPointImpl.fromLatLong(34.4893, -120.56388), + GeoPointImpl.fromLatLong(34.51999, -120.6724), + GeoPointImpl.fromLatLong(34.60609, -120.70856), + GeoPointImpl.fromLatLong(34.7177, -120.683), + GeoPointImpl.fromLatLong(34.80895, -120.68251), + GeoPointImpl.fromLatLong(34.9015, -120.73438), + GeoPointImpl.fromLatLong(35.03457, -120.6942), + GeoPointImpl.fromLatLong(35.11198, -120.78292), + GeoPointImpl.fromLatLong(35.24362, -120.96025), + GeoPointImpl.fromLatLong(35.37806, -120.93094), + GeoPointImpl.fromLatLong(35.4377, -121.05951), + GeoPointImpl.fromLatLong(35.60068, -121.25103), + GeoPointImpl.fromLatLong(35.81771, -121.4561), + GeoPointImpl.fromLatLong(36.14645, -121.7489), + GeoPointImpl.fromLatLong(36.20183, -121.86514), + GeoPointImpl.fromLatLong(36.25918, -121.92971), + GeoPointImpl.fromLatLong(36.32734, -121.96481), + GeoPointImpl.fromLatLong(36.37682, -121.97196), + GeoPointImpl.fromLatLong(36.42509, -121.98576), + GeoPointImpl.fromLatLong(36.50891, -122.022), + GeoPointImpl.fromLatLong(36.58872, -122.04406), + GeoPointImpl.fromLatLong(36.87495, -122.06832), + GeoPointImpl.fromLatLong(37.02483, -122.27637), + GeoPointImpl.fromLatLong(37.14967, -122.4452), + GeoPointImpl.fromLatLong(37.31479, -122.47161), + GeoPointImpl.fromLatLong(37.47338, -122.56299), + GeoPointImpl.fromLatLong(37.62752, -122.57267), + GeoPointImpl.fromLatLong(37.82781, -122.63247), + GeoPointImpl.fromLatLong(37.89376, -122.78903), + GeoPointImpl.fromLatLong(37.97575, -122.89742), + GeoPointImpl.fromLatLong(37.98809, -123.08461), + GeoPointImpl.fromLatLong(38.28431, -123.12347), + GeoPointImpl.fromLatLong(38.40068, -123.18504), + GeoPointImpl.fromLatLong(38.49991, -123.3304), + GeoPointImpl.fromLatLong(38.66066, -123.49508), + GeoPointImpl.fromLatLong(38.84412, -123.7253), + GeoPointImpl.fromLatLong(39.03837, -123.76207), + GeoPointImpl.fromLatLong(39.1249, -123.80553), + GeoPointImpl.fromLatLong(39.21873, -123.84839), + GeoPointImpl.fromLatLong(39.34835, -123.89494), + GeoPointImpl.fromLatLong(39.44394, -123.88889), + GeoPointImpl.fromLatLong(39.55492, -123.83841), + GeoPointImpl.fromLatLong(39.68453, -123.87391), + GeoPointImpl.fromLatLong(39.82699, -123.9536), + GeoPointImpl.fromLatLong(39.95662, -124.06357), + GeoPointImpl.fromLatLong(40.09338, -124.2312), + GeoPointImpl.fromLatLong(40.27564, -124.42868), + GeoPointImpl.fromLatLong(40.45264, -124.47916), + GeoPointImpl.fromLatLong(40.71898, -124.33411), + GeoPointImpl.fromLatLong(40.9617, -124.1945), + GeoPointImpl.fromLatLong(41.03142, -124.2151), + GeoPointImpl.fromLatLong(41.09222, -124.24743), + GeoPointImpl.fromLatLong(41.13756, -124.25119), + GeoPointImpl.fromLatLong(41.18092, -124.21142), + GeoPointImpl.fromLatLong(41.25705, -124.17435), + GeoPointImpl.fromLatLong(41.29199, -124.19233), + GeoPointImpl.fromLatLong(41.31575, -124.23454), + GeoPointImpl.fromLatLong(41.36383, -124.23517), + GeoPointImpl.fromLatLong(41.38412, -124.14406), + GeoPointImpl.fromLatLong(41.51075, -124.15357), + GeoPointImpl.fromLatLong(41.6897, -124.22986), + GeoPointImpl.fromLatLong(41.78831, -124.41076), + GeoPointImpl.fromLatLong(41.85879, -124.31194), + GeoPointImpl.fromLatLong(41.99807, -124.32829), + GeoPointImpl.fromLatLong(41.99833, -124.32883), + GeoPointImpl.fromLatLong(41.99703, -124.11879), + GeoPointImpl.fromLatLong(41.99625, -123.96782), + GeoPointImpl.fromLatLong(41.99569, -123.79381), + GeoPointImpl.fromLatLong(41.99984, -123.62007), + GeoPointImpl.fromLatLong(42.00086, -123.51413), + GeoPointImpl.fromLatLong(42.00197, -123.2737), + GeoPointImpl.fromLatLong(42.00302, -123.03178), + GeoPointImpl.fromLatLong(42.00388, -122.78389), + GeoPointImpl.fromLatLong(42.00482, -122.64619), + GeoPointImpl.fromLatLong(42.00869, -122.40756), + GeoPointImpl.fromLatLong(42.00755, -122.18647), + GeoPointImpl.fromLatLong(42.00397, -122.00032), + GeoPointImpl.fromLatLong(42.00262, -121.81573), + GeoPointImpl.fromLatLong(42.00077, -121.70538), + GeoPointImpl.fromLatLong(41.99933, -121.6122), + GeoPointImpl.fromLatLong(41.99827, -121.51946), + GeoPointImpl.fromLatLong(41.99738, -121.43715), + GeoPointImpl.fromLatLong(41.99668, -121.36025), + GeoPointImpl.fromLatLong(41.99759, -121.26065), + GeoPointImpl.fromLatLong(41.99336, -120.97395), + GeoPointImpl.fromLatLong(41.99387, -120.76508), + GeoPointImpl.fromLatLong(41.99309, -120.60306), + GeoPointImpl.fromLatLong(41.99313, -120.30731), + GeoPointImpl.fromLatLong(41.99443, -120.19996), + GeoPointImpl.fromLatLong(41.99514, -120.00104), + GeoPointImpl.fromLatLong(41.99454, -119.99917), + GeoPointImpl.fromLatLong(41.97905, -119.99919), + GeoPointImpl.fromLatLong(41.26742, -120.00002), + GeoPointImpl.fromLatLong(40.86934, -119.99926), + GeoPointImpl.fromLatLong(40.39719, -119.99567), + GeoPointImpl.fromLatLong(40.08934, -119.99733), + GeoPointImpl.fromLatLong(39.79567, -120.00049), + GeoPointImpl.fromLatLong(39.57782, -120.0015), + GeoPointImpl.fromLatLong(39.37557, -120.00608), + GeoPointImpl.fromLatLong(38.98156, -119.9748), + GeoPointImpl.fromLatLong(38.83427, -119.76041), + GeoPointImpl.fromLatLong(38.60904, -119.43506), + GeoPointImpl.fromLatLong(38.30368, -119.00097), + GeoPointImpl.fromLatLong(37.96065, -118.51722), + GeoPointImpl.fromLatLong(37.75309, -118.22972), + GeoPointImpl.fromLatLong(37.6185, -118.04392), + GeoPointImpl.fromLatLong(37.43715, -117.79563), + GeoPointImpl.fromLatLong(37.08441, -117.31883), + GeoPointImpl.fromLatLong(36.75057, -116.87227), + GeoPointImpl.fromLatLong(36.37205, -116.37528), + GeoPointImpl.fromLatLong(36.14577, -116.08072), + GeoPointImpl.fromLatLong(36.0018, -115.89512), + GeoPointImpl.fromLatLong(35.81231, -115.65233), + GeoPointImpl.fromLatLong(35.59033, -115.36992), + GeoPointImpl.fromLatLong(35.38796, -115.11622), + GeoPointImpl.fromLatLong(35.15341, -114.82052), + GeoPointImpl.fromLatLong(35.00195, -114.63361), + GeoPointImpl.fromLatLong(35.00186, -114.63349), + ), + ) diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/PolygonClickPage.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/PolygonClickPage.kt new file mode 100644 index 00000000..912e92b1 --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/PolygonClickPage.kt @@ -0,0 +1,86 @@ +package com.mapconductor.example.pages.polygon.click + +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.mapconductor.core.info.InfoBubble +import com.mapconductor.core.marker.Marker +import com.mapconductor.core.polygon.Polygon +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.example.MapViewContainer +import com.mapconductor.example.ui.DefaultMapViewItems +import com.mapconductor.example.ui.DemoMapPageScaffold +import com.mapconductor.example.ui.MessageCard + +@Composable +fun PolygonClickPage(onToggleSidebar: () -> Unit = {}) { + val viewModel = remember { PolygonClickPageViewModelImpl() } + + DemoMapPageScaffold( + menuItems = DefaultMapViewItems(viewModel.initCameraPosition), + onToggleSidebar = onToggleSidebar, + onMapViewStateChanged = viewModel::onMapViewChanged, + ) { paddingValues -> + val mapViewState = viewModel.mapViewState.collectAsState() + val marker = viewModel.markerState.collectAsState() + val message = viewModel.message.collectAsState() + + mapViewState.value?.let { + MapViewContainer( + state = it, + onPolygonClick = viewModel::onPolygonClicked, + onMapClick = viewModel::onMapClicked, + ) { + key(california) { + california.forEach { points -> + val state = + PolygonState( + points = points, + strokeColor = Color.Red.copy(alpha = 0.7f), + strokeWidth = 3.dp, + fillColor = Color.Blue.copy(alpha = 0.4f), + ) + Polygon(state) + } + } + + marker.value?.let { markerState -> + Marker(markerState) + + InfoBubble( + marker = markerState, + ) { + Text( + text = message.value, + color = Color.Black, + ) + } + } + } + } + + MessageCard( + title = "Polygon Example", + modifier = + Modifier + .align(Alignment.BottomStart) + .padding( + bottom = paddingValues.calculateBottomPadding() + 16.dp, + start = paddingValues.calculateStartPadding(LayoutDirection.Ltr) + 16.dp, + end = paddingValues.calculateEndPadding(LayoutDirection.Ltr) + 16.dp, + ), + ) { + Text("Tap inside & outside the polygon!") + } + } +} diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/PolygonClickPageViewModel.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/PolygonClickPageViewModel.kt new file mode 100644 index 00000000..b78079b0 --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/click/PolygonClickPageViewModel.kt @@ -0,0 +1,68 @@ +package com.mapconductor.example.pages.polygon.click + +import androidx.lifecycle.ViewModel +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.map.MapCameraPositionImpl +import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.polygon.PolygonEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +interface PolygonClickPageViewModel { + val initCameraPosition: MapCameraPositionImpl + val mapViewState: StateFlow?> + val markerState: StateFlow + val message: StateFlow + + fun onMapViewChanged(state: MapViewState<*>) + + fun onMapClicked(clicked: GeoPointImpl) + + fun onPolygonClicked(event: PolygonEvent) +} + +class PolygonClickPageViewModelImpl : + ViewModel(), + PolygonClickPageViewModel { + private val _mapViewState = MutableStateFlow?>(null) + override val mapViewState: StateFlow?> = _mapViewState.asStateFlow() + + private val _markerState = MutableStateFlow(null) + override val markerState: StateFlow = _markerState.asStateFlow() + + private val _message = MutableStateFlow("") + override val message: StateFlow = _message.asStateFlow() + + override val initCameraPosition = + MapCameraPositionImpl( + position = GeoPointImpl(36.73030, -120.24512), + zoom = 5.0, + ) + + override fun onMapViewChanged(state: MapViewState<*>) { + _mapViewState.value = state + } + + override fun onMapClicked(clicked: GeoPointImpl) { + _message.value = "Outside" + + _markerState.value = + MarkerState( + id = "clicked", + position = clicked, + ) + } + + override fun onPolygonClicked(event: PolygonEvent) { + val latLng = GeoPointImpl.from(event.clicked).toUrlValue() + _message.value = "Inside\n$latLng" + + _markerState.value = + MarkerState( + id = "clicked", + position = event.clicked, + ) + } +} diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/geodesic/PolygonGeodesicPage.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/geodesic/PolygonGeodesicPage.kt new file mode 100644 index 00000000..3dfac1c2 --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/geodesic/PolygonGeodesicPage.kt @@ -0,0 +1,103 @@ +package com.mapconductor.example.pages.polygon.geodesic + +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.marker.Marker +import com.mapconductor.core.polygon.Polygon +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.example.MapViewContainer +import com.mapconductor.example.ui.DefaultMapViewItems +import com.mapconductor.example.ui.DemoMapPageScaffold +import com.mapconductor.example.ui.MessageCard + +@Composable +fun PolygonGeodesicPage(onToggleSidebar: () -> Unit = {}) { + val viewModel = remember { PolygonGeodesicPageViewModelImpl() } + + val points = + listOf( + GeoPointImpl.fromLongLat(23.66, 56.42), + GeoPointImpl.fromLongLat(13.39, 2.95), + GeoPointImpl.fromLongLat(-87.82, 38.58), + GeoPointImpl.fromLongLat(23.66, 56.42), + ) + + val polylineState = + remember { + PolygonState( + points = points, + strokeColor = Color.Yellow.copy(alpha = 0.3f), + strokeWidth = 3.dp, + fillColor = Color.Green.copy(alpha = 0.5f), + geodesic = false, + zIndex = 0, + ) + } + + val geodesicPolylineState = + remember { + PolygonState( + points = points, + strokeColor = Color.Red.copy(alpha = 0.3f), + strokeWidth = 3.dp, + fillColor = Color.Blue.copy(alpha = 0.5f), + geodesic = true, + zIndex = 1, + ) + } + + DemoMapPageScaffold( + menuItems = DefaultMapViewItems(viewModel.initCameraPosition), + onToggleSidebar = onToggleSidebar, + onMapViewStateChanged = viewModel::onMapViewChanged, + ) { paddingValues -> + val mapViewState = viewModel.mapViewState.collectAsState() + val marker = viewModel.markerState.collectAsState() + + mapViewState.value?.let { + MapViewContainer( + state = it, + onPolygonClick = viewModel::onPolygonClicked, + ) { + Polygon(polylineState) + Polygon(geodesicPolylineState) + + marker.value?.let { markerState -> + Marker(markerState) + } + } + } + + MessageCard( + title = "Polygon Geodesic Example", + modifier = + Modifier + .align(Alignment.BottomStart) + .padding( + bottom = paddingValues.calculateBottomPadding() + 16.dp, + start = paddingValues.calculateStartPadding(LayoutDirection.Ltr) + 16.dp, + end = paddingValues.calculateEndPadding(LayoutDirection.Ltr) + 16.dp, + ), + ) { + Text( + """ + Tap on the polygons! + This example shows the ability of the polygon click detection. + Place a green marker if you tap on the green polygon, + and place a blue marker on the blue polygon if you tap on it. + """.trimIndent(), + ) + } + } +} diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polygon/geodesic/PolygonGeodesicPageViewModel.kt b/example-app/src/main/java/com/mapconductor/example/pages/polygon/geodesic/PolygonGeodesicPageViewModel.kt new file mode 100644 index 00000000..ced6c853 --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/polygon/geodesic/PolygonGeodesicPageViewModel.kt @@ -0,0 +1,54 @@ +package com.mapconductor.example.pages.polygon.geodesic + +import androidx.lifecycle.ViewModel +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.map.MapCameraPositionImpl +import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.marker.DefaultIcon +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.polygon.PolygonEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +interface PolygonGeodesicPageViewModel { + val initCameraPosition: MapCameraPositionImpl + val mapViewState: StateFlow?> + val markerState: StateFlow + + fun onMapViewChanged(state: MapViewState<*>) + + fun onPolygonClicked(event: PolygonEvent) +} + +class PolygonGeodesicPageViewModelImpl : + ViewModel(), + PolygonGeodesicPageViewModel { + private val _mapViewState = MutableStateFlow?>(null) + override val mapViewState: StateFlow?> = _mapViewState.asStateFlow() + + private val _markerState = MutableStateFlow(null) + override val markerState: StateFlow = _markerState.asStateFlow() + + override val initCameraPosition = + MapCameraPositionImpl( + position = GeoPointImpl(30.0, 0.0), + zoom = 1.0, + ) + + override fun onMapViewChanged(state: MapViewState<*>) { + _mapViewState.value = state + } + + override fun onPolygonClicked(event: PolygonEvent) { + _markerState.value = + MarkerState( + id = "clicked", + position = event.clicked, + icon = + DefaultIcon( + fillColor = event.state.fillColor.copy(alpha = 1.0f), + ), + ) + } +} diff --git a/example-app/src/main/java/com/mapconductor/example/pages/polyline/click/PolylineClickPageViewModel.kt b/example-app/src/main/java/com/mapconductor/example/pages/polyline/click/PolylineClickPageViewModel.kt index 5ef0c99f..98402f97 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/polyline/click/PolylineClickPageViewModel.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/polyline/click/PolylineClickPageViewModel.kt @@ -8,6 +8,7 @@ import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.MapCameraPositionImpl import com.mapconductor.core.map.MapViewState import com.mapconductor.core.marker.DefaultIcon +import com.mapconductor.core.marker.MarkerAnimation import com.mapconductor.core.marker.MarkerState import com.mapconductor.core.polyline.PolylineEvent import com.mapconductor.core.polyline.PolylineState @@ -76,6 +77,7 @@ class PolylineClickPageViewModelImpl : _markers.value = _markers.value + MarkerState( position = clicked.clicked, + animation = MarkerAnimation.Drop, icon = DefaultIcon( fillColor = clicked.state.strokeColor, diff --git a/example-app/src/main/java/com/mapconductor/example/ui/DemoMapPageScaffold.kt b/example-app/src/main/java/com/mapconductor/example/ui/DemoMapPageScaffold.kt index e30dcb9a..bae49599 100644 --- a/example-app/src/main/java/com/mapconductor/example/ui/DemoMapPageScaffold.kt +++ b/example-app/src/main/java/com/mapconductor/example/ui/DemoMapPageScaffold.kt @@ -42,6 +42,9 @@ import com.mapconductor.here.rememberHereMapViewState import com.mapconductor.mapbox.MapboxMapDesign import com.mapconductor.mapbox.MapboxViewStateImpl import com.mapconductor.mapbox.rememberMapboxMapViewState +import com.mapconductor.maplibre.MapLibreMapDesign +import com.mapconductor.maplibre.MapLibreViewStateImpl +import com.mapconductor.maplibre.rememberMapLibreMapViewState @Composable fun GetGoogleMapViewItem(initCameraPosition: MapCameraPosition): IconItem { @@ -111,6 +114,22 @@ fun GetArcGISViewItem(initCameraPosition: MapCameraPosition): IconItem { + val mapLibreMapState = + rememberMapLibreMapViewState( + mapDesign = MapLibreMapDesign.DemoTiles, + cameraPosition = initCameraPosition, + ) + return IconItem( + key = "maplibre", + label = "MapLibre", + lightIconResId = R.drawable.maplibre_logo, + darkIconResId = R.drawable.maplibre_logo, + value = mapLibreMapState, + ) +} + @Composable fun DefaultMapViewItems(initCameraPosition: MapCameraPosition): List>> = listOf( @@ -118,6 +137,7 @@ fun DefaultMapViewItems(initCameraPosition: MapCameraPosition): List>> = - listOf( - GetGoogleMapViewItem(initCameraPosition), - GetMapboxViewItem(initCameraPosition), - GetHereViewItem(initCameraPosition), - GetArcGISViewItem(initCameraPosition), - ) - @Composable fun DemoMapPageScaffold( menuItems: List>>, diff --git a/example-app/src/main/res/drawable/maplibre_logo.webp b/example-app/src/main/res/drawable/maplibre_logo.webp new file mode 100644 index 00000000..3e3efc24 Binary files /dev/null and b/example-app/src/main/res/drawable/maplibre_logo.webp differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df71a8b5..2714687d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,10 @@ uiToolingPreviewAndroid = "1.9.1" uiToolingPreview = "1.9.1" uiTooling = "1.9.1" geographiclib = "2.1" +material = "1.10.0" +mapLibreSdk = "12.0.0" +mapLibreAnnotation = "3.0.2" +runtime = "1.9.3" [libraries] # Android 基本 @@ -65,10 +69,15 @@ mapconductor-googlemaps = { module = "com.mapconductor:for-googlemaps" } mapconductor-mapbox = { module = "com.mapconductor:for-mapbox" } mapconductor-here = { module = "com.mapconductor:for-here" } mapconductor-arcgis = { module = "com.mapconductor:for-arcgis" } +mapconductor-maplibre = { module = "com.mapconductor:for-maplibre" } mapconductor-marker-strategy = { module = "com.mapconductor:marker-strategy" } mapconductor-marker-native-strategy = { module = "com.mapconductor:marker-native-strategy" } mapbox-android = { module = "com.mapbox.maps:android-ndk27", version.ref = "mapboxAndroid" } +# MapLibre +maplibre-sdk = { module = "org.maplibre.gl:android-sdk", version.ref = "mapLibreSdk" } +maplibre-annotation = { module = "org.maplibre.gl:android-plugin-annotation-v9", version.ref = "mapLibreAnnotation"} + # Google Maps play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" } @@ -85,12 +94,11 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" } # Firebase -firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } -androidx-compose-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } net-sf-geographiclib = { group = "net.sf.geographiclib", name="GeographicLib-Java", version.ref="geographiclib"} +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/OverlayProvider.kt b/mapconductor-core/src/main/java/com/mapconductor/core/OverlayProvider.kt index 30377262..5d44eb1a 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/OverlayProvider.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/OverlayProvider.kt @@ -27,16 +27,21 @@ import kotlinx.coroutines.launch open class MapViewScope { val markerAddSharedFlow = MutableSharedFlow(1000) + val markerRemoveSharedFlow = MutableSharedFlow(1000) val markerFlow = MutableStateFlow>(mutableMapOf()) val bubbleFlow = MutableStateFlow>(mutableMapOf()) val polylineFlow = MutableStateFlow>(mutableMapOf()) + val polylineRemoveSharedFlow = MutableSharedFlow(1000) val circleFlow = MutableStateFlow>(mutableMapOf()) + val circleRemoveSharedFlow = MutableSharedFlow(1000) val polygonFlow = MutableStateFlow>(mutableMapOf()) + val polygonRemoveSharedFlow = MutableSharedFlow(1000) val groundImageFlow = MutableStateFlow>(mutableMapOf()) + val groundImageRemoveSharedFlow = MutableSharedFlow(1000) init { CoroutineScope(Dispatchers.IO).launch { - markerAddSharedFlow.debounceBatch(5.milliseconds, 300).collect { states -> + markerAddSharedFlow.debounceBatch(5.milliseconds, 100).collect { states -> val newMap = markerFlow.value.toMutableMap() states.forEach { state -> newMap.set(state.id, state) @@ -44,6 +49,56 @@ open class MapViewScope { markerFlow.value = newMap } } + + CoroutineScope(Dispatchers.IO).launch { + markerRemoveSharedFlow.debounceBatch(5.milliseconds, 300).collect { ids -> + val newMap = markerFlow.value.toMutableMap() + ids.forEach { id -> + newMap.remove(id) + } + markerFlow.value = newMap + } + } + + CoroutineScope(Dispatchers.IO).launch { + polylineRemoveSharedFlow.debounceBatch(5.milliseconds, 300).collect { ids -> + val newMap = polylineFlow.value.toMutableMap() + ids.forEach { id -> + newMap.remove(id) + } + polylineFlow.value = newMap + } + } + + CoroutineScope(Dispatchers.IO).launch { + circleRemoveSharedFlow.debounceBatch(5.milliseconds, 300).collect { ids -> + val newMap = circleFlow.value.toMutableMap() + ids.forEach { id -> + newMap.remove(id) + } + circleFlow.value = newMap + } + } + + CoroutineScope(Dispatchers.IO).launch { + polygonRemoveSharedFlow.debounceBatch(5.milliseconds, 300).collect { ids -> + val newMap = polygonFlow.value.toMutableMap() + ids.forEach { id -> + newMap.remove(id) + } + polygonFlow.value = newMap + } + } + + CoroutineScope(Dispatchers.IO).launch { + groundImageRemoveSharedFlow.debounceBatch(5.milliseconds, 300).collect { ids -> + val newMap = groundImageFlow.value.toMutableMap() + ids.forEach { id -> + newMap.remove(id) + } + groundImageFlow.value = newMap + } + } } fun buildRegistry(): MapOverlayRegistry { diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/ResourceProvider.kt b/mapconductor-core/src/main/java/com/mapconductor/core/ResourceProvider.kt index 6d62e264..99b069c4 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/ResourceProvider.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/ResourceProvider.kt @@ -20,6 +20,7 @@ data class IconResource( object ResourceProvider { private lateinit var appContext: Context + private var bitmapDensityOverride: Float? = null fun getDisplayMetrics(): DisplayMetrics = Resources.getSystem().displayMetrics @@ -31,6 +32,22 @@ object ResourceProvider { fun getDensity(): Float = getDisplayMetrics().density + /** + * Set the density used for bitmap creation. This is useful for map providers + * that automatically scale bitmaps based on their density property. + * + * @param density The density to use for bitmap creation, or null to use system density + */ + fun setBitmapDensity(density: Float?) { + bitmapDensityOverride = density + } + + /** + * Get the density to use for bitmap creation. + * Returns the override density if set, otherwise returns system density. + */ + fun getBitmapDensity(): Float = bitmapDensityOverride ?: getDensity() + fun dpToPx(dp: Float): Double = dpToPx(dp.toDouble()) fun dpToPx(dp: Dp): Double = dpToPx(dp.value.toDouble()) @@ -43,6 +60,18 @@ object ResourceProvider { getDisplayMetrics(), ).toDouble() + /** + * Convert dp to px using bitmap density instead of system density. + * This is used for creating bitmaps that will be used by map providers. + * Note: Always uses device density for bitmap pixel size, regardless of bitmapDensityOverride. + * The bitmapDensityOverride is used to set Bitmap.density property after creation. + */ + fun dpToPxForBitmap(dp: Double): Double = dp * getDensity() + + fun dpToPxForBitmap(dp: Float): Double = dpToPxForBitmap(dp.toDouble()) + + fun dpToPxForBitmap(dp: Dp): Double = dpToPxForBitmap(dp.value.toDouble()) + fun pxToSp(px: Double): Double { val displayMetrics = getDisplayMetrics() val scaledDensity = diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/circle/Circle.kt b/mapconductor-core/src/main/java/com/mapconductor/core/circle/Circle.kt index 15e49782..66bf6cdb 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/circle/Circle.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/circle/Circle.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged class CircleState( center: GeoPoint, radiusMeters: Double, + geodesic: Boolean = true, clickable: Boolean = true, strokeColor: Color = Color.Red, strokeWidth: Dp = 1.dp, @@ -33,6 +34,7 @@ class CircleState( var center by mutableStateOf(center) var clickable by mutableStateOf(clickable) var radiusMeters by mutableStateOf(radiusMeters) + var geodesic by mutableStateOf(geodesic) var strokeColor by mutableStateOf(strokeColor) var strokeWidth by mutableStateOf(strokeWidth) var fillColor by mutableStateOf(fillColor) @@ -46,6 +48,7 @@ class CircleState( center.hashCode(), radiusMeters.hashCode(), clickable.hashCode(), + geodesic.hashCode(), extra?.hashCode() ?: 0, strokeColor.hashCode(), strokeWidth.hashCode(), @@ -66,6 +69,7 @@ class CircleState( center = center.hashCode(), radiusMeters = radiusMeters.hashCode(), clickable = clickable.hashCode(), + geodesic = geodesic.hashCode(), strokeColor = strokeColor.hashCode(), strokeWidth = strokeWidth.hashCode(), fillColor = fillColor.hashCode(), @@ -78,6 +82,7 @@ class CircleState( fun copy( center: GeoPoint = this.center, radiusMeters: Double = this.radiusMeters, + geodesic: Boolean = this.geodesic, strokeColor: Color = this.strokeColor, strokeWidth: Dp = this.strokeWidth, fillColor: Color = @@ -95,6 +100,7 @@ class CircleState( center = center, clickable = clickable, radiusMeters = radiusMeters, + geodesic = geodesic, strokeColor = strokeColor, strokeWidth = strokeWidth, fillColor = fillColor, @@ -112,6 +118,7 @@ class CircleState( var result = extra?.hashCode() ?: 0 result = 31 * result + center.hashCode() result = 31 * result + clickable.hashCode() + result = 31 * result + geodesic.hashCode() result = 31 * result + radiusMeters.hashCode() result = 31 * result + strokeColor.hashCode() result = 31 * result + strokeWidth.hashCode() @@ -126,6 +133,7 @@ data class CircleFingerPrint( val center: Int, val radiusMeters: Int, val clickable: Int, + val geodesic: Int, val strokeColor: Int, val strokeWidth: Int, val fillColor: Int, diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/circle/CircleCompose.kt b/mapconductor-core/src/main/java/com/mapconductor/core/circle/CircleCompose.kt index 6e8b08a2..55653aba 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/circle/CircleCompose.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/circle/CircleCompose.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.circle import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp @@ -16,6 +17,12 @@ fun MapViewScope.Circle(state: CircleState) { newMap.set(state.id, state) circleFlow.value = newMap } + + DisposableEffect(state.id) { + onDispose { + circleRemoveSharedFlow.tryEmit(state.id) + } + } } @Composable diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt b/mapconductor-core/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt index 3a76a0c6..8fe040d9 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt @@ -22,17 +22,13 @@ abstract class BaseMapViewController : MapViewController { } override fun setMapLongClickListener(listener: OnMapEventHandler?) { - this.mapClickCallback = listener + this.mapLongClickCallback = listener } protected fun registerController(controller: OverlayController<*, *, *>) { overlayControllers.add(controller) } - fun setMapLoadedListener(listener: InternalOnMapLoadedHandler?) { - this.mapLoadedCallback = listener - } - protected suspend fun notifyMapCameraPosition(mapCameraPosition: MapCameraPositionImpl) { overlayControllers.forEach { it.onCameraChanged(mapCameraPosition) diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/groundimage/GroundImageComponent.kt b/mapconductor-core/src/main/java/com/mapconductor/core/groundimage/GroundImageComponent.kt index 487772c4..57cb6d21 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/groundimage/GroundImageComponent.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/groundimage/GroundImageComponent.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.groundimage import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import com.mapconductor.core.MapViewScope import com.mapconductor.core.features.GeoRectBounds @@ -14,6 +15,12 @@ fun MapViewScope.GroundImage(state: GroundImageState) { newMap.set(state.id, state) groundImageFlow.value = newMap } + + DisposableEffect(state.id) { + onDispose { + groundImageRemoveSharedFlow.tryEmit(state.id) + } + } } @Composable diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewBase.kt b/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewBase.kt index c7ef472a..e641c343 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewBase.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewBase.kt @@ -11,7 +11,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +48,7 @@ import com.mapconductor.core.polygon.PolygonCapable import com.mapconductor.core.polyline.LocalPolylineCollector import com.mapconductor.core.polyline.PolylineCapable import com.mapconductor.settings.Settings +import android.util.Log import android.view.View import android.view.ViewGroup import kotlinx.coroutines.FlowPreview @@ -54,7 +57,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -typealias OnMapViewInitializedHandler = (MapViewState<*>) -> Unit typealias OnMapLoadedHandler = (MapViewState<*>) -> Unit internal typealias InternalOnMapLoadedHandler = () -> Unit typealias OnMapEventHandler = (GeoPointImpl) -> Unit @@ -72,31 +74,33 @@ fun < ActualMap : Any, // SpecificViewHolder is now constrained by your MapViewHolder interface // and uses the ActualMapView and ActualMap generic types. - SpecificViewHolder : MapViewHolder, SpecificScope : MapViewScope, + SpecificHolder : MapViewHolder, > MapViewBase( state: SpecificState, modifier: Modifier = Modifier, - holderRef: Ref, - controllerRef: Ref, - viewProvider: SpecificViewHolder.() -> ActualMapView, // Function to get the Android View from ViewHolder + viewProvider: () -> ActualMapView, // Function to get the Android View from ViewHolder scope: SpecificScope, registry: MapOverlayRegistry, // Replace with your actual registry type from scope.buildRegistry() - onInitialize: suspend () -> Boolean, - onMapViewInitialized: OnMapViewInitializedHandler? = null, - customDisposableEffect: (@Composable (SpecificState, Ref) -> Unit)? = null, - shouldInitialize: Boolean = true, // Allow deferring initialization + sdkInitialize: suspend () -> Boolean = { true }, + holderProvider: suspend (mapView: ActualMapView) -> SpecificHolder, + controllerProvider: suspend (holder: SpecificHolder) -> SpecificController, + onMapLoaded: OnMapLoadedHandler? = null, + customDisposableEffect: (@Composable (InitState, Ref) -> Unit)? = null, content: (@Composable SpecificScope.() -> Unit)? = null, ) { ResourceProvider.init(LocalContext.current) - val initState by state.isInitialized.collectAsState() + val mapViewRef = remember { Ref() } + val controllerRef = remember { Ref() } + val holderRef = remember { Ref() } + var initState by remember { mutableStateOf(InitState.NotStarted) } val cameraPosition by state.cameraPosition.collectAsState() val bubbles by scope.bubbleFlow.collectAsState() - val controller = controllerRef.value val cameraTick = remember { mutableIntStateOf(0) } + val controller = controllerRef.value - if (initState == InitState.Initialized && controller != null) { - // 収集した子コンポーネントを描画する + if (initState == InitState.MapCreated && controller != null) { + // 5. 収集した子コンポーネントを描画する CollectAndRenderOverlays( registry = registry, // This should come from the specific scope or be passed controller = controller, @@ -176,27 +180,30 @@ fun < } SubcomposeLayout(modifier = modifier.fillMaxSize().clipToBounds().background(Color.LightGray)) { constraints -> - // 1) Map フェーズ:先に Map の AndroidView をレイアウト + // 2. Map フェーズ:先に Map の AndroidView をレイアウト val mapPlaceables = subcompose("map") { when (initState) { InitState.NotStarted -> BasicMessage("Not initialized yet") InitState.Failed -> BasicMessage("Failed to initialize") - InitState.Initializing -> BasicMessage("Initializing") - InitState.Initialized -> { - if (holderRef.value == null) { - state.resetInitState() // Or handle error appropriately - } else { - AndroidView(factory = { _ -> - viewProvider(holderRef.value!!).also { view -> - (view as ViewGroup).layoutParams = - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - } - }) + InitState.Initializing -> BasicMessage("SDK Initializing") + InitState.SdkInitialized -> { + // 3. Create a map view + viewProvider().also { mapView -> + (mapView as ViewGroup).layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + mapViewRef.value = mapView + initState = InitState.MapViewCreated } + BasicMessage("Loading.") + } + else -> { + AndroidView(factory = { context -> + mapViewRef.value!! + }) } } }.map { it.measure(constraints) } @@ -207,11 +214,11 @@ fun < // 2) Overlay フェーズ:Map のサイズが確定し、かつ controller などが揃っているときだけ合成 val canOverlay = - initState == InitState.Initialized && - controller != null && - mapSize.width > 0 && - mapSize.height > 0 && - holderRef.value != null + initState >= InitState.MapViewCreated // && + controller != null && + mapSize.width > 0 && + mapSize.height > 0 && + holderRef.value != null val overlayPlaceables = if (canOverlay) { @@ -277,14 +284,32 @@ fun < } } - LaunchedEffect(initState, shouldInitialize) { - if (!shouldInitialize) return@LaunchedEffect // Don't initialize if deferred + // 1. Start initialization + LaunchedEffect(initState) { if (initState != InitState.NotStarted) return@LaunchedEffect - state.initAsync(onInitialize) - onMapViewInitialized?.invoke(state) + initState = InitState.Initializing + try { + val success = sdkInitialize() + initState = if (success) InitState.SdkInitialized else InitState.Failed + } catch (e: Exception) { + initState = InitState.Failed + Log.e("MapConductor", "Failed to initialize the Map view", e) + } + } + + // 4. Create a map instance, then returns as a holder + LaunchedEffect(initState) { + if (initState != InitState.MapViewCreated) return@LaunchedEffect + mapViewRef.value?.let { mapView -> + val holder = holderProvider(mapView) + holderRef.value = holder + controllerRef.value = controllerProvider(holder) + initState = InitState.MapCreated + onMapLoaded?.invoke(state) + } } - customDisposableEffect?.invoke(state, holderRef) + customDisposableEffect?.invoke(initState, holderRef) } @Composable diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewHolder.kt b/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewHolder.kt index 38632902..039a19e1 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewHolder.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewHolder.kt @@ -4,9 +4,9 @@ import androidx.compose.ui.geometry.Offset import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl -interface MapViewHolder { - val mapView: ActualMapViewType - val map: ActualMapType +interface MapViewHolder { + val mapView: ActualMapView + val map: ActualMap fun toScreenOffset(position: GeoPoint): Offset? diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewState.kt b/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewState.kt index 7802bc95..83d7c0af 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewState.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/map/MapViewState.kt @@ -2,18 +2,16 @@ package com.mapconductor.core.map import com.mapconductor.core.controller.MapViewController import com.mapconductor.core.features.GeoPointImpl -import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch enum class InitState { NotStarted, Initializing, - Initialized, + SdkInitialized, + MapViewCreated, + MapCreated, Failed, } @@ -24,14 +22,9 @@ interface MapViewState { val id: String val initCameraPosition: MapCameraPositionImpl - val isInitialized: StateFlow val cameraPosition: StateFlow var mapDesignType: ActualMapDesignType - fun initAsync(init: suspend () -> Boolean) - - fun resetInitState() - fun moveCameraTo( cameraPosition: MapCameraPositionImpl, durationMs: Long? = 0, @@ -51,36 +44,6 @@ abstract class MapViewStateImpl( protected val mainCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), ) : MapViewState { private val tag = this.javaClass.name - - private val _isInitialized = MutableStateFlow(InitState.NotStarted) - override val isInitialized: StateFlow = _isInitialized.asStateFlow() - - override fun resetInitState() { - this._isInitialized.value = InitState.NotStarted - } - - protected fun warningLog(message: String) { - Log.w(tag, message) - } - - protected fun debugLog(message: String) { - Log.d(tag, message) - } - - override fun initAsync(init: suspend () -> Boolean) { - if (isInitialized.value != InitState.NotStarted) return - _isInitialized.value = InitState.Initializing - - mainCoroutine.launch { - try { - val success = init() - _isInitialized.value = if (success) InitState.Initialized else InitState.Failed - } catch (e: Exception) { - _isInitialized.value = InitState.Failed - Log.e("MapConductor", "Failed to initialize the Map view", e) - } - } - } } interface MapOverlay { diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt b/mapconductor-core/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt index 768490a9..765e0c39 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/marker/AbstractMarkerController.kt @@ -392,11 +392,13 @@ abstract class AbstractMarkerController( } override suspend fun onCameraChanged(mapCameraPosition: MapCameraPositionImpl) { - // Use timer-based debounce instead of Flow to avoid ArcGIS SDK conflicts - processCameraChangeDebounced(mapCameraPosition) - -// this.mapCameraPosition = mapCameraPosition -// renderingStrategy?.onCameraChanged(mapCameraPosition, renderer) + if (this.mapCameraPosition == null) { + // Set the initial camera position + this.mapCameraPosition = mapCameraPosition + } else { + // Use timer-based debounce instead of Flow to avoid ArcGIS SDK conflicts + processCameraChangeDebounced(mapCameraPosition) + } } /** diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/marker/DefaultIcon.kt b/mapconductor-core/src/main/java/com/mapconductor/core/marker/DefaultIcon.kt index abe8222b..77c69555 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/marker/DefaultIcon.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/marker/DefaultIcon.kt @@ -82,10 +82,14 @@ abstract class AbstractDefaultIcon( } // Calculate canvas size with scale applied - val baseCanvasSize = ResourceProvider.dpToPx(iconSize.value) + val baseCanvasSize = ResourceProvider.dpToPxForBitmap(iconSize.value) val canvasSize = (baseCanvasSize * scale).toInt() val bitmap = createBitmap(canvasSize, canvasSize) + // 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 canvas = Canvas(bitmap) // Draw marker (scale is already applied in canvasSize) @@ -145,7 +149,7 @@ abstract class AbstractDefaultIcon( // we don't need to apply iconScale again to the markerScale calculation val scaledStrokeWidth = ResourceProvider - .dpToPx(strokeWidth.value * iconScale) + .dpToPxForBitmap(strokeWidth.value * iconScale) .toFloat() // Reserve space for stroke on sides and top, but not bottom (point should touch edge) @@ -245,7 +249,7 @@ abstract class AbstractDefaultIcon( style = Paint.Style.STROKE strokeWidth = ResourceProvider - .dpToPx( + .dpToPxForBitmap( this@AbstractDefaultIcon.strokeWidth.value * iconScale, ).toFloat() isAntiAlias = true @@ -287,7 +291,7 @@ abstract class AbstractDefaultIcon( // アウトライン描画(アイコンスケールを考慮したストローク幅) val outlineStrokeWidth = max( - ResourceProvider.dpToPx(1f * iconScale).toFloat(), + ResourceProvider.dpToPxForBitmap(1f * iconScale).toFloat(), 2f, // 最小2px ) diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/marker/ImageIcon.kt b/mapconductor-core/src/main/java/com/mapconductor/core/marker/ImageIcon.kt index 021d0bb0..4b84f01d 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/marker/ImageIcon.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/marker/ImageIcon.kt @@ -24,33 +24,24 @@ class ImageIcon( private fun getDrawableIdentity(): Any = when (drawable) { is BitmapDrawable -> { - drawable.bitmap?.let { bitmap -> - if (bitmap.isRecycled) { - drawable.hashCode() - } else { - try { - // Sample a small portion for efficient hashing - val sampleWidth = minOf(bitmap.width, 32) - val sampleHeight = minOf(bitmap.height, 32) - val buffer = IntArray(sampleWidth * sampleHeight) - bitmap.getPixels( - buffer, - 0, - sampleWidth, - 0, - 0, - sampleWidth, - sampleHeight, - ) - buffer.contentHashCode() - } catch (e: Exception) { - drawable.hashCode() - } + val bmp = drawable.bitmap + if (bmp == null || bmp.isRecycled) { + "BMP_NULL_${drawable.hashCode()}" + } else { + try { + val w = bmp.width + val h = bmp.height + val buffer = IntArray(w * h) + bmp.getPixels(buffer, 0, w, 0, 0, w, h) + // Combine dimensions and content for stability + "BMP_${w}x${h}_${buffer.contentHashCode()}" + } catch (e: Exception) { + "BMP_ERR_${drawable.hashCode()}" } - } ?: drawable.hashCode() + } } - is ColorDrawable -> drawable.color - is GradientDrawable -> drawable.hashCode() + is ColorDrawable -> "COLOR_${drawable.color}" + is GradientDrawable -> "GRADIENT_${drawable.hashCode()}" else -> "${drawable::class.java.name}_${drawable.hashCode()}" } @@ -82,7 +73,7 @@ class ImageIcon( return it } - val scaledSize = ResourceProvider.dpToPx(iconSize.value) * scale + val scaledSize = ResourceProvider.dpToPxForBitmap(iconSize.value) * scale val bitmap = this.toBitmap( @@ -90,6 +81,10 @@ class ImageIcon( width = scaledSize.toInt(), height = scaledSize.toInt(), ) + // 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( diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/marker/MarkerCompose.kt b/mapconductor-core/src/main/java/com/mapconductor/core/marker/MarkerCompose.kt index 7b399e95..7fc437ab 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/marker/MarkerCompose.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/marker/MarkerCompose.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.marker import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import com.mapconductor.core.MapViewScope import com.mapconductor.core.features.GeoPoint @@ -11,6 +12,12 @@ fun MapViewScope.Marker(state: MarkerState) { LaunchedEffect(state) { markerAddSharedFlow.emit(state) } + + DisposableEffect(state.id) { + onDispose { + markerRemoveSharedFlow.tryEmit(state.id) + } + } } @Composable diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/polygon/Polygon.kt b/mapconductor-core/src/main/java/com/mapconductor/core/polygon/Polygon.kt index 3a923a2f..44c63550 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/polygon/Polygon.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/polygon/Polygon.kt @@ -20,6 +20,7 @@ class PolygonState( strokeWidth: Dp = 2.dp, fillColor: Color = Color.Transparent, geodesic: Boolean = false, + zIndex: Int = 0, extra: Serializable? = null, ) { val id = @@ -39,6 +40,7 @@ class PolygonState( var strokeWidth by mutableStateOf(strokeWidth) var fillColor by mutableStateOf(fillColor) var geodesic by mutableStateOf(geodesic) + var zIndex by mutableStateOf(zIndex) var points by StateFlowDelegate>(points) var extra by mutableStateOf(extra) @@ -58,6 +60,7 @@ class PolygonState( result = 31 * result + this@PolygonState.strokeWidth.hashCode() result = 31 * result + this@PolygonState.fillColor.hashCode() result = 31 * result + geodesic.hashCode() + result = 31 * result + zIndex.hashCode() result = 31 * result + points.hashCode() return result } @@ -77,6 +80,7 @@ class PolygonState( strokeWidth = this@PolygonState.strokeWidth.hashCode(), fillColor = this@PolygonState.fillColor.hashCode(), geodesic = geodesic.toString().hashCode(), + zIndex = zIndex, points = listHashCode(points), extra = extra?.hashCode() ?: 0, ) @@ -90,13 +94,14 @@ data class PolygonFingerPrint( val strokeWidth: Int, val fillColor: Int, val geodesic: Int, + val zIndex: Int, val points: Int, val extra: Int, ) data class PolygonEvent( val state: PolygonState, - val clicked: GeoPoint?, + val clicked: GeoPoint, ) typealias OnPolygonEventHandler = (PolygonEvent) -> Unit diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonComponent.kt b/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonComponent.kt index 98df7078..20879af6 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonComponent.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonComponent.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.polygon import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp @@ -16,6 +17,12 @@ fun MapViewScope.Polygon(state: PolygonState) { newMap.set(state.id, state) polygonFlow.value = newMap } + + DisposableEffect(state.id) { + onDispose { + polygonRemoveSharedFlow.tryEmit(state.id) + } + } } @Composable diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt b/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt index 61cfa2ca..e74b22d9 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt @@ -1,6 +1,12 @@ package com.mapconductor.core.polygon +import com.mapconductor.core.createInterpolatePoints import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.normalizeLng +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt interface PolygonManager { fun registerEntity(entity: PolygonEntity) @@ -37,5 +43,143 @@ class PolygonManagerImpl : PolygonManager { entities.clear() } - override fun find(position: GeoPoint): PolygonEntity? = entities.values.firstOrNull() + override fun find(position: GeoPoint): PolygonEntity? { + val testX = normalizeLng(position.longitude) + val testY = position.latitude + + // Iterate from top-most to bottom-most by zIndex + for (entity in entities.values.sortedByDescending { it.state.zIndex }) { + val state = entity.state + val basePoints = state.points + if (basePoints.size < 3) continue + + // Densify edges to better approximate geodesic/linear edges + val ring = + try { + if (state.geodesic) createInterpolatePoints(basePoints) else basePoints + } catch (_: Exception) { + basePoints + } + + // Ensure closed ring + val closedRing = + if (ring.first() != ring.last()) ring + ring.first() else ring + + if (pointInPolygonWindingNumber(testX, testY, closedRing)) { + return entity + } + } + return null + } + + private fun pointInPolygonWindingNumber( + testX: Double, + testY: Double, + ring: List, + ): Boolean { + if (ring.size < 3) return false + + // Unwrap longitudes around the test longitude to handle antimeridian + val unwrapped = unwrapLongitudesAround(ring, testX) + + // Quick bounding box check + var minY = Double.POSITIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + var minX = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + for (p in unwrapped) { + minY = min(minY, p.second) + maxY = max(maxY, p.second) + minX = min(minX, p.first) + maxX = max(maxX, p.first) + } + if (testY < minY || testY > maxY || testX < minX - 1.0 || testX > maxX + 1.0) return false + + val eps = 1e-6 + var wn = 0 // winding number + + var i = 0 + while (i < unwrapped.size - 1) { + val ax = unwrapped[i].first + val ay = unwrapped[i].second + val bx = unwrapped[i + 1].first + val by = unwrapped[i + 1].second + + // On-edge check + if (pointOnSegment(testX, testY, ax, ay, bx, by, eps)) return true + + // Upward crossing + if (ay <= testY) { + if (by > testY && isLeft(ax, ay, bx, by, testX, testY) > 0) { + wn++ + } + } else { + // Downward crossing + if (by <= testY && isLeft(ax, ay, bx, by, testX, testY) < 0) { + wn-- + } + } + i++ + } + return wn != 0 + } + + private fun isLeft( + ax: Double, + ay: Double, + bx: Double, + by: Double, + px: Double, + py: Double, + ): Double = (bx - ax) * (py - ay) - (by - ay) * (px - ax) + + private fun pointOnSegment( + px: Double, + py: Double, + ax: Double, + ay: Double, + bx: Double, + by: Double, + eps: Double, + ): Boolean { + val dx = bx - ax + val dy = by - ay + val cross = dx * (py - ay) - dy * (px - ax) + val segLen = sqrt(dx * dx + dy * dy) + if (abs(cross) > eps * max(1.0, segLen)) return false + val dot = (px - ax) * (px - bx) + (py - ay) * (py - by) + return dot <= eps * max(1.0, segLen) + } + + private fun unwrapLongitudesAround( + points: List, + refLng: Double, + ): List> { + if (points.isEmpty()) return emptyList() + val result = ArrayList>(points.size) + + var prevX = Double.NaN + for (p in points) { + var x = normalizeLng(p.longitude) + val y = p.latitude + if (prevX.isNaN()) { + // Shift first near reference + val k = Math.round((refLng - x) / 360.0).toInt() + x += 360.0 * k + } else { + // Keep continuity with previous + var delta = x - prevX + if (delta > 180.0) { + val k = Math.floor((delta + 180.0) / 360.0).toInt() + x -= 360.0 * k + } else if (delta < -180.0) { + val k = Math.floor((-delta + 180.0) / 360.0).toInt() + x += 360.0 * k + } + } + result.add(Pair(x, y)) + prevX = x + } + return result + } } diff --git a/mapconductor-core/src/main/java/com/mapconductor/core/polyline/PolylineComponent.kt b/mapconductor-core/src/main/java/com/mapconductor/core/polyline/PolylineComponent.kt index 4afeb67b..b47bea56 100644 --- a/mapconductor-core/src/main/java/com/mapconductor/core/polyline/PolylineComponent.kt +++ b/mapconductor-core/src/main/java/com/mapconductor/core/polyline/PolylineComponent.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.polyline import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp @@ -16,6 +17,12 @@ fun MapViewScope.Polyline(state: PolylineState) { newMap.set(state.id, state) polylineFlow.value = newMap } + + DisposableEffect(state.id) { + onDispose { + polylineRemoveSharedFlow.tryEmit(state.id) + } + } } @Composable diff --git a/mapconductor-for-arcgis/build.gradle.kts b/mapconductor-for-arcgis/build.gradle.kts index 774a2215..5d7f4960 100644 --- a/mapconductor-for-arcgis/build.gradle.kts +++ b/mapconductor-for-arcgis/build.gradle.kts @@ -63,7 +63,8 @@ android { dependencies { -// implementation(libs.play.services.maps) + implementation(libs.androidx.compose.runtime) + // implementation(libs.play.services.maps) compileOnly(libs.androidx.ui) compileOnly(libs.androidx.ui.tooling.preview) compileOnly(libs.androidx.core.ktx) diff --git a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt index e11000e0..f9ba91d2 100644 --- a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt +++ b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapView.kt @@ -3,25 +3,45 @@ package com.mapconductor.arcgis import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.node.Ref import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner +import com.arcgismaps.ApiKey +import com.arcgismaps.ArcGISEnvironment +import com.arcgismaps.LoadStatus +import com.arcgismaps.mapping.ArcGISScene +import com.arcgismaps.mapping.ArcGISTiledElevationSource +import com.arcgismaps.mapping.view.GraphicsOverlay +import com.arcgismaps.mapping.view.SceneView +import com.arcgismaps.mapping.view.SurfacePlacement +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController +import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer +import com.mapconductor.arcgis.marker.ArcGISMarkerController +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController +import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController +import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer import com.mapconductor.core.circle.OnCircleEventHandler import com.mapconductor.core.map.MapViewBase import com.mapconductor.core.map.OnMapEventHandler import com.mapconductor.core.map.OnMapLoadedHandler -import com.mapconductor.core.map.OnMapViewInitializedHandler import com.mapconductor.core.marker.MarkerRenderingStrategy import com.mapconductor.core.marker.OnMarkerEventHandler import com.mapconductor.core.polygon.OnPolygonEventHandler import com.mapconductor.core.polyline.OnPolylineEventHandler +import android.util.Log +import android.widget.FrameLayout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +@OptIn(ExperimentalCoroutinesApi::class) @Composable fun ArcGISMapView( state: ArcGISMapViewStateImpl, modifier: Modifier = Modifier, markerRenderingStrategy: MarkerRenderingStrategy? = null, - onMapViewInitialized: OnMapViewInitializedHandler? = null, onMapLoaded: OnMapLoadedHandler? = null, onMapClick: OnMapEventHandler? = null, onMarkerClick: OnMarkerEventHandler? = null, @@ -35,8 +55,6 @@ fun ArcGISMapView( onPolygonClick: OnPolygonEventHandler? = null, content: (@Composable ArcGISMapViewScope.() -> Unit)? = null, ) { - val holderRef = remember { Ref() } - val controllerRef = remember { Ref() } 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() } @@ -47,90 +65,175 @@ fun ArcGISMapView( MapViewBase( state = state, modifier = modifier, - holderRef = holderRef, - controllerRef = controllerRef, - viewProvider = { this.mapView }, + viewProvider = { + val sceneView = SceneView(context) + val wrapView = + WrapSceneView(context).apply { + addView(sceneView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + } + wrapView.sceneView = sceneView + // Ensure lifecycle owner is set before the view is attached/drawn + // to avoid GeoView.lifeCycleOwner UninitializedPropertyAccessException + sceneView.onCreate(owner) + sceneView.onResume(owner) + wrapView + }, scope = scope, registry = registry, - onInitialize = { + holderProvider = { wrapView -> val options = ArcGISMapViewInitOptions( basemapStyle = basemapStyle, elevationSources = state.mapDesignType.elevationSources, ) - val controller = - ArcGISViewControllerStore.getOrCreate( - context = context, - id = state.id, - options = options, - markerRenderingStrategy = markerRenderingStrategy, - ) - controller.holder.mapView.onCreate(owner) - controller.holder.mapView.onResume(owner) - controller.setCameraMoveListener(state::onCameraChange) - controller.setMapClickListener(onMapClick) - controller.setOnCircleClickListener(onCircleClick) - controller.setOnPolylineClickListener(onPolylineClick) - controller.setOnPolygonClickListener(onPolygonClick) - controller.setOnMarkerClickListener(onMarkerClick) - controller.setOnMarkerDragStart(onMarkerDragStart) - controller.setOnMarkerDrag(onMarkerDrag) - controller.setOnMarkerDragEnd(onMarkerDragEnd) - controller.setOnMarkerAnimateStart(onMarkerAnimateStart) - controller.setOnMarkerAnimateEnd(onMarkerAnimateEnd) - controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - controller.setMapLoadedListener { - onMapLoaded?.invoke(state) + val scene = ArcGISScene(options.basemapStyle) + options.elevationSources.forEach { + val source = ArcGISTiledElevationSource(it) + scene.baseSurface.elevationSources.add(source) } - state.setController(controller) - val restoreCameraPosition = state.cameraPosition.value - controller.moveCamera(restoreCameraPosition) + wrapView.sceneView.scene = scene - controllerRef.value = controller - holderRef.value = controller.holder - true + val coroutine = CoroutineScope(Dispatchers.Default) + + suspendCancellableCoroutine { cont -> + coroutine.launch { + scene.loadStatus.collect { + when (it) { + is LoadStatus.Loaded, + is LoadStatus.FailedToLoad, + -> { + val holder = + ArcGISMapViewHolderImpl( + mapView = wrapView, + map = wrapView.sceneView, + ) + cont.resume(holder) {} + } + else -> { + // Do nothing here + } + } + } + } + } + }, + controllerProvider = { holder -> + + val markerController = + getMarkerController( + holder = holder, + renderingStrategy = markerRenderingStrategy, + ) + val polylineController = getPolylineController(holder) + val polygonController = getPolygonController(holder) + val circleController = getCircleController(holder) + + // Defer initial camera update until controller is created and view is laid out + + ArcGISMapViewControllerImpl( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + ).also { controller -> + controller.setCameraMoveListener(state::onCameraChange) + controller.setMapClickListener(onMapClick) + controller.setOnCircleClickListener(onCircleClick) + controller.setOnPolylineClickListener(onPolylineClick) + controller.setOnPolygonClickListener(onPolygonClick) + controller.setOnMarkerClickListener(onMarkerClick) + controller.setOnMarkerDragStart(onMarkerDragStart) + controller.setOnMarkerDrag(onMarkerDrag) + controller.setOnMarkerDragEnd(onMarkerDragEnd) + controller.setOnMarkerAnimateStart(onMarkerAnimateStart) + controller.setOnMarkerAnimateEnd(onMarkerAnimateEnd) + controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + state.setController(controller) + + val restoreCameraPosition = state.cameraPosition.value + controller.moveCamera(restoreCameraPosition) + // Post an initial camera update after layout to compute visibleRegion correctly + holder.mapView.post { controller.sendInitialCameraUpdate() } + } }, - onMapViewInitialized = onMapViewInitialized, - customDisposableEffect = { _state, _holderRef -> - - // ArcGIS specific DisposableEffect logic -// DisposableEffect(lifecycle) { -// val stateId = _stateId // from BaseMapViewState -// val observer = -// object : DefaultLifecycleObserver { -// override fun onResume(owner: LifecycleOwner) { -// _holderRef.value?.mapView?.onResume(owner) -// } -// -// override fun onPause(owner: LifecycleOwner) { -// _holderRef.value?.mapView?.onPause(owner) -// } -// -// override fun onDestroy(owner: LifecycleOwner) { -// val currentHolder = _holderRef.value -// if (currentHolder != null) { -// val activity = context.findActivity() -// if (activity?.isChangingConfigurations == true) { -// (currentHolder.mapView.parent as? ViewGroup)?.removeView(currentHolder.mapView) -// } else { -// // Ensure these calls are safe if mapView might be null or already destroyed -// currentHolder.mapView.onPause(owner) -// currentHolder.mapView.onDestroy(owner) -// _state.controller = null -// ArcGISMapViewHolderStore.remove(stateId) // Clean up from your store -// } -// } -// } -// } -// lifecycle.addObserver(observer) -// onDispose { -// _state.resetInitState() -// lifecycle.removeObserver(observer) -// } -// } + sdkInitialize = { + val apiKey = context.applicationContext.getArcGisApiKey() + if (apiKey == null) { + Log.e("ArcGISMapView", " is required") + return@MapViewBase false + } + ArcGISEnvironment.apiKey = ApiKey.create(apiKey) + true }, + onMapLoaded = onMapLoaded, content = content, ) } + +private fun getCircleController(holder: ArcGISMapViewHolder): ArcGISCircleOverlayController { + val circleLayer: GraphicsOverlay = + GraphicsOverlay().apply { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedFlat + } + + val renderer = + ArcGISCircleOverlayRenderer( + circleLayer = circleLayer, + holder = holder, + ) + + val controller = + ArcGISCircleOverlayController( + renderer = renderer, + ) + return controller +} + +private fun getPolylineController(holder: ArcGISMapViewHolder): ArcGISPolylineOverlayController { + val polylineLayer: GraphicsOverlay = + GraphicsOverlay().apply { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded + } + + val renderer = + ArcGISPolylineOverlayRenderer( + polylineLayer = polylineLayer, + holder = holder, + ) + + val controller = + ArcGISPolylineOverlayController( + renderer = renderer, + ) + return controller +} + +private fun getPolygonController(holder: ArcGISMapViewHolder): ArcGISPolygonOverlayController { + val polygonLayer: GraphicsOverlay = + GraphicsOverlay().apply { + sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded + } + + val renderer = + ArcGISPolygonOverlayRenderer( + polygonLayer = polygonLayer, + holder = holder, + ) + + val controller = + ArcGISPolygonOverlayController( + renderer = renderer, + ) + return controller +} + +private fun getMarkerController( + holder: ArcGISMapViewHolder, + renderingStrategy: MarkerRenderingStrategy? = null, +) = ArcGISMarkerController.create( + holder = holder, + renderingStrategy = renderingStrategy, +) diff --git a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerImpl.kt b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerImpl.kt index 7491fef0..9055358f 100644 --- a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerImpl.kt +++ b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewControllerImpl.kt @@ -416,4 +416,16 @@ class ArcGISMapViewControllerImpl( mapDesignTypeChangeListener = listener listener(mapDesignType) } + + // Trigger an initial camera update after the view and scene are ready + fun sendInitialCameraUpdate() { + coroutine.launch { + val mapWidth = holder.map.width + val mapHeight = holder.map.height + if (mapWidth <= 0 || mapHeight <= 0) return@launch + getMapCameraPosition()?.let { mapCameraPosition -> + notifyMapCameraPosition(mapCameraPosition) + } + } + } } diff --git a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt index e70aadd8..f5d8143c 100644 --- a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt +++ b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewHolderImpl.kt @@ -2,26 +2,16 @@ package com.mapconductor.arcgis import androidx.compose.ui.geometry.Offset import androidx.lifecycle.LifecycleOwner -import com.arcgismaps.ApiKey -import com.arcgismaps.ArcGISEnvironment -import com.arcgismaps.LoadStatus -import com.arcgismaps.mapping.ArcGISScene -import com.arcgismaps.mapping.ArcGISTiledElevationSource import com.arcgismaps.mapping.view.SceneView import com.arcgismaps.mapping.view.ScreenCoordinate import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.MapViewHolder -import kotlin.coroutines.resume import android.content.Context import android.content.pm.PackageManager import android.util.AttributeSet import android.widget.FrameLayout -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.suspendCancellableCoroutine class WrapSceneView : FrameLayout { lateinit var sceneView: SceneView @@ -51,11 +41,10 @@ class WrapSceneView : FrameLayout { } } -class ArcGISMapViewHolderImpl private constructor( +class ArcGISMapViewHolderImpl( override val mapView: WrapSceneView, + override val map: SceneView, ) : MapViewHolder { - override lateinit var map: SceneView - override fun toScreenOffset(position: GeoPoint): Offset? { val result = mapView.sceneView.locationToScreen( @@ -89,54 +78,45 @@ class ArcGISMapViewHolderImpl private constructor( fromScreenOffset(offset) } - companion object { - suspend fun create( - context: Context, - options: ArcGISMapViewInitOptions, - ): MapViewHolder { - val apiKey = context.applicationContext.getArcGisApiKey() - if (apiKey == null) throw Exception(" is required") - ArcGISEnvironment.apiKey = ApiKey.create(apiKey) - - val sceneView = SceneView(context) - val wrapView = - WrapSceneView(context).apply { - addView(sceneView, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - } - wrapView.sceneView = sceneView - - val holder = ArcGISMapViewHolderImpl(wrapView) - val scene = ArcGISScene(options.basemapStyle) - options.elevationSources.forEach { - val source = ArcGISTiledElevationSource(it) - scene.baseSurface.elevationSources.add(source) - } - - holder.map = sceneView - sceneView.scene = scene - val coroutine = CoroutineScope(Dispatchers.Default) - - val result = - suspendCancellableCoroutine { cont -> - coroutine.launch { - scene.loadStatus.collect { - when (it) { - is LoadStatus.Loaded -> cont.resume(true) - is LoadStatus.FailedToLoad -> cont.resume(false) - else -> { - // Do nothing here - } - } - } - } - } - if (!result) { - throw Exception("Can not load the scene") - } - - return holder - } - } +// companion object { +// suspend fun create( +// context: Context, +// options: ArcGISMapViewInitOptions, +// ): MapViewHolder { +// +// +// val holder = ArcGISMapViewHolderImpl(wrapView) +// val scene = ArcGISScene(options.basemapStyle) +// options.elevationSources.forEach { +// val source = ArcGISTiledElevationSource(it) +// scene.baseSurface.elevationSources.add(source) +// } +// +// holder.map = sceneView +// sceneView.scene = scene +// val coroutine = CoroutineScope(Dispatchers.Default) +// +// val result = +// suspendCancellableCoroutine { cont -> +// coroutine.launch { +// scene.loadStatus.collect { +// when (it) { +// is LoadStatus.Loaded -> cont.resume(true) +// is LoadStatus.FailedToLoad -> cont.resume(false) +// else -> { +// // Do nothing here +// } +// } +// } +// } +// } +// if (!result) { +// throw Exception("Can not load the scene") +// } +// +// return holder +// } +// } } internal fun Context.getArcGisApiKey(): String? = diff --git a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewStateImpl.kt b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewStateImpl.kt index 4627ae26..104d9799 100644 --- a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewStateImpl.kt +++ b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISMapViewStateImpl.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.BaseMapViewSaver -import com.mapconductor.core.map.InitState import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionImpl import com.mapconductor.core.map.MapPaddings @@ -68,15 +67,13 @@ class ArcGISMapViewStateImpl( listener: MapViewState.MoveCameraCallback?, ) { controller?.let { ctrl -> - if (this.isInitialized.value == InitState.Initialized) { - val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) - if (durationMs == null || durationMs == 0L) { - ctrl.moveCamera(dstCameraPosition, listener) - } else { - ctrl.animateCamera(dstCameraPosition, durationMs, listener) - } - return + val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) + if (durationMs == null || durationMs == 0L) { + ctrl.moveCamera(dstCameraPosition, listener) + } else { + ctrl.animateCamera(dstCameraPosition, durationMs, listener) } + return@let } _cameraPosition.value = cameraPosition listener?.onComplete() @@ -87,14 +84,6 @@ class ArcGISMapViewStateImpl( durationMs: Long?, listener: MapViewState.MoveCameraCallback?, ) { - if (this.isInitialized.value != InitState.Initialized) { - _cameraPosition.value = - MapCameraPositionImpl( - position = position, - ) - listener?.onComplete() - return - } val currentPosition = this.cameraPosition.value val newPosition = currentPosition.copy( diff --git a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISViewControllerStore.kt b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISViewControllerStore.kt index 5e4df629..a980359d 100644 --- a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISViewControllerStore.kt +++ b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/ArcGISViewControllerStore.kt @@ -1,119 +1,10 @@ package com.mapconductor.arcgis -import com.arcgismaps.mapping.view.GraphicsOverlay import com.arcgismaps.mapping.view.SceneView -import com.arcgismaps.mapping.view.SurfacePlacement -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayController -import com.mapconductor.arcgis.circle.ArcGISCircleOverlayRenderer -import com.mapconductor.arcgis.marker.ArcGISMarkerController -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayController -import com.mapconductor.arcgis.polygon.ArcGISPolygonOverlayRenderer -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayController -import com.mapconductor.arcgis.polyline.ArcGISPolylineOverlayRenderer import com.mapconductor.core.map.MapViewHolder import com.mapconductor.core.map.StaticHolder -import com.mapconductor.core.marker.MarkerRenderingStrategy -import android.content.Context typealias ArcGISMapViewHolder = MapViewHolder object ArcGISViewControllerStore : - StaticHolder() { - fun hasCache(id: String): Boolean = this.has(id) - - suspend fun getOrCreate( - context: Context, - id: String, - options: ArcGISMapViewInitOptions, - markerRenderingStrategy: MarkerRenderingStrategy? = null, - ): ArcGISMapViewControllerImpl { - val existing = this.get(id) - if (existing != null) return existing - - val holder = - ArcGISMapViewHolderImpl.create( - context = context.applicationContext, - options = options, - ) - - val controller = - ArcGISMapViewControllerImpl( - holder = holder, - markerController = - getMarkerController( - holder = holder, - renderingStrategy = markerRenderingStrategy, - ), - polylineController = getPolylineController(holder), - polygonController = getPolygonController(holder), - circleController = getCircleController(holder), - ) - this.set(id, controller) - return controller - } - - private fun getCircleController(holder: ArcGISMapViewHolder): ArcGISCircleOverlayController { - val circleLayer: GraphicsOverlay = - GraphicsOverlay().apply { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedFlat - } - - val renderer = - ArcGISCircleOverlayRenderer( - circleLayer = circleLayer, - holder = holder, - ) - - val controller = - ArcGISCircleOverlayController( - renderer = renderer, - ) - return controller - } - - private fun getPolylineController(holder: ArcGISMapViewHolder): ArcGISPolylineOverlayController { - val polylineLayer: GraphicsOverlay = - GraphicsOverlay().apply { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded - } - - val renderer = - ArcGISPolylineOverlayRenderer( - polylineLayer = polylineLayer, - holder = holder, - ) - - val controller = - ArcGISPolylineOverlayController( - renderer = renderer, - ) - return controller - } - - private fun getPolygonController(holder: ArcGISMapViewHolder): ArcGISPolygonOverlayController { - val polygonLayer: GraphicsOverlay = - GraphicsOverlay().apply { - sceneProperties.surfacePlacement = SurfacePlacement.DrapedBillboarded - } - - val renderer = - ArcGISPolygonOverlayRenderer( - polygonLayer = polygonLayer, - holder = holder, - ) - - val controller = - ArcGISPolygonOverlayController( - renderer = renderer, - ) - return controller - } - - private fun getMarkerController( - holder: ArcGISMapViewHolder, - renderingStrategy: MarkerRenderingStrategy? = null, - ) = ArcGISMarkerController.create( - holder = holder, - renderingStrategy = renderingStrategy, - ) -} + StaticHolder() diff --git a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt index bec77854..94cbb7e4 100644 --- a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt +++ b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/circle/ArcGISCircleOverlayRenderer.kt @@ -35,13 +35,21 @@ class ArcGISCircleOverlayRenderer( ?.spatialReference val centerPoint = GeoPointImpl.from(state.center).toPoint(spec) val circleGeometry = - GeometryEngine.bufferGeodeticOrNull( - geometry = centerPoint, - distance = state.radiusMeters, - distanceUnit = LinearUnit(LinearUnitId.Meters), - maxDeviation = Double.NaN, - curveType = GeodeticCurveType.NormalSection, - ) + if (state.geodesic) { + GeometryEngine.bufferGeodeticOrNull( + geometry = centerPoint, + distance = state.radiusMeters, + distanceUnit = LinearUnit(LinearUnitId.Meters), + maxDeviation = Double.NaN, + curveType = GeodeticCurveType.NormalSection, + ) + } else { + // Planar buffer in the map's spatial reference + GeometryEngine.bufferOrNull( + geometry = centerPoint, + distance = state.radiusMeters, + ) + } val stroke = SimpleLineSymbol( style = SimpleLineSymbolStyle.Solid, @@ -80,17 +88,26 @@ class ArcGISCircleOverlayRenderer( val prevFinger = prev.fingerPrint val graphic = current.circle - if (finger.center != prevFinger.center || finger.radiusMeters != prevFinger.radiusMeters) { + if (finger.center != prevFinger.center || + finger.radiusMeters != prevFinger.radiusMeters || + finger.geodesic != prevFinger.geodesic + ) { val centerPoint = GeoPointImpl.from(current.state.center).toPoint(spec) - val newGeometry = - GeometryEngine.bufferGeodeticOrNull( - geometry = centerPoint, - distance = current.state.radiusMeters, - distanceUnit = LinearUnit(LinearUnitId.Meters), - maxDeviation = Double.NaN, - curveType = GeodeticCurveType.NormalSection, - ) + if (current.state.geodesic) { + GeometryEngine.bufferGeodeticOrNull( + geometry = centerPoint, + distance = current.state.radiusMeters, + distanceUnit = LinearUnit(LinearUnitId.Meters), + maxDeviation = Double.NaN, + curveType = GeodeticCurveType.NormalSection, + ) + } else { + GeometryEngine.bufferOrNull( + geometry = centerPoint, + distance = current.state.radiusMeters, + ) + } newGeometry?.let { graphic.geometry = it } diff --git a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt index 22f9cb21..6490c858 100644 --- a/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt +++ b/mapconductor-for-arcgis/src/main/java/com/mapconductor/arcgis/polygon/ArcGISPolygonOverlayRenderer.kt @@ -13,6 +13,9 @@ import com.mapconductor.arcgis.ArcGISMapViewHolder import com.mapconductor.arcgis.toArcGISColor import com.mapconductor.arcgis.toPoint import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.createInterpolatePoints +import com.mapconductor.core.createLinearInterpolatePoints +import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer import com.mapconductor.core.polygon.PolygonEntity @@ -48,6 +51,7 @@ class ArcGISPolygonOverlayRenderer( val graphic = Graphic(geometry, fillSymbol).also { it.attributes.set("id", state.id) + it.attributes.set("zIndex", state.zIndex) } polygonLayer.graphics.add(graphic) @@ -62,7 +66,7 @@ class ArcGISPolygonOverlayRenderer( withContext(coroutine.coroutineContext) { val finger = current.fingerPrint val prevFinger = prev.fingerPrint - if (finger.points != prevFinger.points) { + if (finger.points != prevFinger.points || finger.geodesic != prevFinger.geodesic) { current.polygon.geometry = createGeometry(current.state) } @@ -83,6 +87,9 @@ class ArcGISPolygonOverlayRenderer( } } } + if (finger.zIndex != prevFinger.zIndex) { + current.polygon.attributes.set("zIndex", current.state.zIndex) + } polygon } @@ -92,10 +99,27 @@ class ArcGISPolygonOverlayRenderer( } } + override suspend fun onPostProcess() { + // Sort graphics by zIndex to ensure correct rendering order + withContext(coroutine.coroutineContext) { + val sortedGraphics = + polygonLayer.graphics.toList().sortedBy { graphic -> + (graphic.attributes.get("zIndex") as? Int) ?: 0 + } + polygonLayer.graphics.clear() + polygonLayer.graphics.addAll(sortedGraphics) + } + } + private fun createGeometry(state: PolygonState): Geometry { + val geoPoints: List = + when (state.geodesic) { + true -> createInterpolatePoints(state.points) + false -> createLinearInterpolatePoints(state.points) + } val polygonBuilder = PolygonBuilder().also { builder -> - state.points.forEach { + geoPoints.forEach { builder.addPoint(GeoPointImpl.from(it).toPoint()) } } diff --git a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt index 8a94cbc6..9fc692b7 100644 --- a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt +++ b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt @@ -4,12 +4,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.node.Ref import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.android.gms.maps.GoogleMapOptions +import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.CameraPosition import com.mapconductor.core.circle.OnCircleEventHandler import com.mapconductor.core.features.GeoPointImpl @@ -17,19 +17,27 @@ import com.mapconductor.core.groundimage.OnGroundImageEventHandler import com.mapconductor.core.map.MapViewBase import com.mapconductor.core.map.OnMapEventHandler import com.mapconductor.core.map.OnMapLoadedHandler -import com.mapconductor.core.map.OnMapViewInitializedHandler import com.mapconductor.core.marker.MarkerRenderingStrategy import com.mapconductor.core.marker.OnMarkerEventHandler import com.mapconductor.core.polygon.OnPolygonEventHandler import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.googlemaps.circle.GoogleMapCircleController +import com.mapconductor.googlemaps.circle.GoogleMapCircleOverlayRenderer +import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageController +import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageOverlayRenderer +import com.mapconductor.googlemaps.marker.GoogleMapMarkerController +import com.mapconductor.googlemaps.polygon.GoogleMapPolygonController +import com.mapconductor.googlemaps.polygon.GoogleMapPolygonOverlayRenderer +import com.mapconductor.googlemaps.polyline.GoogleMapPolylineController +import com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlayRenderer import android.view.ViewGroup +import kotlinx.coroutines.suspendCancellableCoroutine @Composable fun GoogleMapsView( state: GoogleMapViewStateImpl, modifier: Modifier = Modifier, markerRenderingStrategy: MarkerRenderingStrategy? = null, - onMapViewInitialized: OnMapViewInitializedHandler? = null, onMapLoaded: OnMapLoadedHandler? = null, onMapClick: OnMapEventHandler? = null, onMarkerClick: OnMarkerEventHandler? = null, @@ -42,11 +50,8 @@ fun GoogleMapsView( onPolylineClick: OnPolylineEventHandler? = null, onPolygonClick: OnPolygonEventHandler? = null, onGroundImageClick: OnGroundImageEventHandler? = null, - shouldInitialize: Boolean = true, // Allow deferring initialization content: (@Composable GoogleMapViewScope.() -> Unit)? = null, ) { - val holderRef = remember { Ref() } - val controllerRef = remember { Ref() } val scope = remember { GoogleMapViewScope() } // Use specific scope val context = LocalContext.current // Context will be available from MapViewBase too if needed val registry = remember { scope.buildRegistry() } @@ -54,16 +59,9 @@ fun GoogleMapsView( MapViewBase( state = state, modifier = modifier, - holderRef = holderRef, - controllerRef = controllerRef, - viewProvider = { this.mapView }, // Assuming GoogleMapViewHolder has a 'mapView' property - scope = scope, - registry = registry, - onInitialize = { - // Specific Google Maps initialization logic - // This lambda will be executed within state.initAsync by MapViewBase + viewProvider = { val cameraPosition = - state.cameraPosition.value?.let { camera -> + state.cameraPosition.value.let { camera -> CameraPosition .Builder() .apply { @@ -76,56 +74,85 @@ fun GoogleMapsView( val mapInitOptions = GoogleMapOptions() - .mapType(state.mapDesignType?.getValue() ?: GoogleMapDesign.None.getValue()) + .mapType(state.mapDesignType.getValue()) .camera(cameraPosition) - val controller = - GoogleMapViewControllerStore.getOrCreate( - context = context, // Use context from the outer scope - id = state.id, - options = mapInitOptions, + MapView(context, mapInitOptions).apply { + onCreate(null) + } + }, + holderProvider = { mapView -> + + suspendCancellableCoroutine { cont -> + mapView.getMapAsync { map -> + val holder = GoogleMapViewHolderImpl(mapView, map) + cont.resume(holder) {} + } + } + }, + controllerProvider = { holder -> + val markerController = + getMarkerController( + holder = holder, markerRenderingStrategy = markerRenderingStrategy, ) - state.setController(controller) - controller.setCameraMoveListener(state::onCameraChange) - controller.setMapClickListener(onMapClick) - controller.setOnMarkerClickListener(onMarkerClick) - controller.setOnMarkerDragStart(onMarkerDragStart) - controller.setOnMarkerDrag(onMarkerDrag) - controller.setOnMarkerDragEnd(onMarkerDragEnd) - controller.setOnCircleClickListener(onCircleClick) - controller.setOnPolylineClickListener(onPolylineClick) - controller.setOnPolygonClickListener(onPolygonClick) - controller.setOnMarkerAnimateStart(onMarkerAnimateStart) - controller.setOnMarkerAnimateEnd(onMarkerAnimateEnd) - controller.setOnGroundImageClickListener(onGroundImageClick) - controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - controller.setMapLoadedListener { - onMapLoaded?.invoke(state) - } + val groundImageController = getGroundImageController(holder) + val polylineController = getPolylineController(holder) + val polygonController = getPolygonController(holder) + val circleController = getCircleController(holder) + + // Defer initial camera update until controller is created and view is laid out - holderRef.value = controller.holder - controllerRef.value = controller - true // Return success/failure of initialization + GoogleMapViewControllerImpl( + markerController = markerController, + groundImageController = groundImageController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + holder = holder, + ).also { controller -> + state.setController(controller) + controller.setCameraMoveListener(state::onCameraChange) + controller.setMapClickListener(onMapClick) + controller.setOnMarkerClickListener(onMarkerClick) + controller.setOnMarkerDragStart(onMarkerDragStart) + controller.setOnMarkerDrag(onMarkerDrag) + controller.setOnMarkerDragEnd(onMarkerDragEnd) + controller.setOnCircleClickListener(onCircleClick) + controller.setOnPolylineClickListener(onPolylineClick) + controller.setOnPolygonClickListener(onPolygonClick) + controller.setOnMarkerAnimateStart(onMarkerAnimateStart) + controller.setOnMarkerAnimateEnd(onMarkerAnimateEnd) + controller.setOnGroundImageClickListener(onGroundImageClick) + controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + // Post an initial camera update once the MapView is laid out + holder.mapView.post { controller.sendInitialCameraUpdate() } + } }, - onMapViewInitialized = onMapViewInitialized, - shouldInitialize = shouldInitialize, // Pass through the deferred initialization parameter - customDisposableEffect = { _state, _holderRef -> + scope = scope, + registry = registry, + onMapLoaded = onMapLoaded, + customDisposableEffect = { initState, holderRef -> // Specific Google Maps DisposableEffect logic val lifecycle = LocalLifecycleOwner.current.lifecycle // Get lifecycle here DisposableEffect(lifecycle) { - val stateId = _state.id + val stateId = state.id val observer = object : DefaultLifecycleObserver { - override fun onResume(owner: LifecycleOwner) {} + override fun onResume(owner: LifecycleOwner) { + holderRef.value?.mapView?.onResume() + } - override fun onPause(owner: LifecycleOwner) {} + override fun onPause(owner: LifecycleOwner) { + holderRef.value?.mapView?.onPause() + } override fun onDestroy(owner: LifecycleOwner) { val activity = context.findActivity() if (activity?.isChangingConfigurations == true) { - _holderRef.value?.mapView?.let { + holderRef.value?.mapView?.let { (it.parent as? ViewGroup)?.removeView(it) + it.onDestroy() } } else { GoogleMapViewControllerStore.remove(stateId) @@ -134,7 +161,6 @@ fun GoogleMapsView( } lifecycle.addObserver(observer) onDispose { - _state.resetInitState() lifecycle.removeObserver(observer) } } @@ -145,3 +171,63 @@ fun GoogleMapsView( content = content, // This might need adjustment based on how overlays are handled ) } + +private fun getPolygonController(holder: GoogleMapViewHolder): GoogleMapPolygonController { + val renderer = + GoogleMapPolygonOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapPolygonController( + renderer = renderer, + ) + return controller +} + +private fun getGroundImageController(holder: GoogleMapViewHolder): GoogleMapGroundImageController { + val renderer = + GoogleMapGroundImageOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapGroundImageController( + renderer = renderer, + ) + return controller +} + +private fun getCircleController(holder: GoogleMapViewHolder): GoogleMapCircleController { + val renderer = + GoogleMapCircleOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapCircleController( + renderer = renderer, + ) + return controller +} + +private fun getPolylineController(holder: GoogleMapViewHolder): GoogleMapPolylineController { + val renderer = + GoogleMapPolylineOverlayRenderer( + holder = holder, + ) + + val controller = + GoogleMapPolylineController( + renderer = renderer, + ) + return controller +} + +private fun getMarkerController( + holder: GoogleMapViewHolder, + markerRenderingStrategy: MarkerRenderingStrategy? = null, +) = GoogleMapMarkerController.create( + holder = holder, + renderingStrategy = markerRenderingStrategy, +) diff --git a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerImpl.kt b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerImpl.kt index 415ae048..e2da1dc9 100644 --- a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerImpl.kt +++ b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerImpl.kt @@ -315,4 +315,13 @@ class GoogleMapViewControllerImpl( val mapDesignType = GoogleMapDesign.toMapDesignType(holder.map.mapType) mapDesignTypeChangeListener?.invoke(mapDesignType) } + + // Trigger an initial camera update after the view and map are ready + fun sendInitialCameraUpdate() { + val w = holder.mapView.width + val h = holder.mapView.height + if (w <= 0 || h <= 0) return + val mapCameraPosition = getMapCameraPosition() + backCoroutine.launch { notifyMapCameraPosition(mapCameraPosition) } + } } diff --git a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerStore.kt b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerStore.kt index 4d300b16..aa8c0e1f 100644 --- a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerStore.kt +++ b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewControllerStore.kt @@ -1,129 +1,16 @@ package com.mapconductor.googlemaps import com.google.android.gms.maps.GoogleMap -import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.MapView import com.mapconductor.core.map.MapViewHolder import com.mapconductor.core.map.StaticHolder -import com.mapconductor.core.marker.MarkerRenderingStrategy -import com.mapconductor.googlemaps.circle.GoogleMapCircleController -import com.mapconductor.googlemaps.circle.GoogleMapCircleOverlayRenderer -import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageController -import com.mapconductor.googlemaps.groundimage.GoogleMapGroundImageOverlayRenderer -import com.mapconductor.googlemaps.marker.GoogleMapMarkerController -import com.mapconductor.googlemaps.polygon.GoogleMapPolygonController -import com.mapconductor.googlemaps.polygon.GoogleMapPolygonOverlayRenderer -import com.mapconductor.googlemaps.polyline.GoogleMapPolylineController -import com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlayRenderer import android.app.Activity import android.content.Context import android.content.ContextWrapper typealias GoogleMapViewHolder = MapViewHolder -object GoogleMapViewControllerStore : StaticHolder() { - suspend fun getOrCreate( - context: Context, - id: String, - options: GoogleMapOptions, - markerRenderingStrategy: MarkerRenderingStrategy? = null, - ): GoogleMapViewControllerImpl { - val existing = this.get(id) - if (existing != null) { - existing.setMapDesignType(GoogleMapDesign.toMapDesignType(options.mapType)) - options.camera?.let { camera -> - existing.moveCamera( - position = camera.toMapCameraPosition(), - listener = null, - ) - } - return existing - } - - val holder = - GoogleMapViewHolderImpl.create( - context = context, - options = options, - ) - - val controller = - GoogleMapViewControllerImpl( - markerController = - getMarkerController( - holder = holder, - markerRenderingStrategy = markerRenderingStrategy, - ), - groundImageController = getGroundImageController(holder), - polylineController = getPolylineController(holder), - polygonController = getPolygonController(holder), - circleController = getCircleController(holder), - holder = holder, - ) - this.set(id, controller) - - return controller - } - - private fun getPolygonController(holder: GoogleMapViewHolder): GoogleMapPolygonController { - val renderer = - GoogleMapPolygonOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapPolygonController( - renderer = renderer, - ) - return controller - } - - private fun getGroundImageController(holder: GoogleMapViewHolder): GoogleMapGroundImageController { - val renderer = - GoogleMapGroundImageOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapGroundImageController( - renderer = renderer, - ) - return controller - } - - private fun getCircleController(holder: GoogleMapViewHolder): GoogleMapCircleController { - val renderer = - GoogleMapCircleOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapCircleController( - renderer = renderer, - ) - return controller - } - - private fun getPolylineController(holder: GoogleMapViewHolder): GoogleMapPolylineController { - val renderer = - GoogleMapPolylineOverlayRenderer( - holder = holder, - ) - - val controller = - GoogleMapPolylineController( - renderer = renderer, - ) - return controller - } - - private fun getMarkerController( - holder: GoogleMapViewHolder, - markerRenderingStrategy: MarkerRenderingStrategy? = null, - ) = GoogleMapMarkerController.create( - holder = holder, - renderingStrategy = markerRenderingStrategy, - ) -} +object GoogleMapViewControllerStore : StaticHolder() internal fun Context.findActivity(): Activity? = when (this) { diff --git a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewHolderImpl.kt b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewHolderImpl.kt index 72eb6848..2743d1a4 100644 --- a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewHolderImpl.kt +++ b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewHolderImpl.kt @@ -2,21 +2,16 @@ package com.mapconductor.googlemaps import androidx.compose.ui.geometry.Offset import com.google.android.gms.maps.GoogleMap -import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.MapView import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.MapViewHolder -import android.content.Context import android.graphics.Point -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.suspendCancellableCoroutine -internal class GoogleMapViewHolderImpl private constructor( +internal class GoogleMapViewHolderImpl( override val mapView: MapView, + override val map: GoogleMap, ) : MapViewHolder { - override lateinit var map: GoogleMap - override fun toScreenOffset(position: GeoPoint): Offset? { val point = map.projection.toScreenLocation( @@ -36,25 +31,4 @@ internal class GoogleMapViewHolderImpl private constructor( offset.y.toInt(), ), ).toGeoPoint() - - companion object { - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun create( - context: Context, - options: GoogleMapOptions? = null, - ): MapViewHolder { - val mapView = MapView(context, options).apply { onCreate(null) } - - val holder = GoogleMapViewHolderImpl(mapView) - - suspendCancellableCoroutine { cont -> - mapView.getMapAsync { - holder.map = it - cont.resume(Unit) {} - } - } - - return holder - } - } } diff --git a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt index fea4af1d..02cdb2fd 100644 --- a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt +++ b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.BaseMapViewSaver -import com.mapconductor.core.map.InitState import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionImpl import com.mapconductor.core.map.MapPaddingsImpl @@ -38,17 +37,15 @@ class GoogleMapViewStateImpl( override var mapDesignType: GoogleMapDesignType set(value) { - value?.let { - _mapDesignType = value - this.controller?.setMapDesignType(value) - } + _mapDesignType = value + this.controller?.setMapDesignType(value) } get() = _mapDesignType private var controller: GoogleMapViewController? = null internal fun setController(controller: GoogleMapViewController) { this.controller = controller - _mapDesignType?.let { + _mapDesignType.let { controller.setMapDesignType(it) } controller.moveCamera(_cameraPosition.value) @@ -63,14 +60,6 @@ class GoogleMapViewStateImpl( durationMs: Long?, listener: MapViewState.MoveCameraCallback?, ) { - if (this.isInitialized.value != InitState.Initialized) { - _cameraPosition.value = - MapCameraPositionImpl( - position = position, - ) - listener?.onComplete() - return - } val currentPosition = this.cameraPosition.value val newPosition = currentPosition.copy( @@ -88,15 +77,13 @@ class GoogleMapViewStateImpl( listener: MapViewState.MoveCameraCallback?, ) { controller?.let { ctrl -> - if (this.isInitialized.value == InitState.Initialized) { - val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) - if (durationMs == null || durationMs == 0L) { - ctrl.moveCamera(dstCameraPosition, listener) - } else { - ctrl.animateCamera(dstCameraPosition, durationMs, listener) - } - return + val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) + if (durationMs == null || durationMs == 0L) { + ctrl.moveCamera(dstCameraPosition, listener) + } else { + ctrl.animateCamera(dstCameraPosition, durationMs, listener) } + return@let } _cameraPosition.value = cameraPosition listener?.onComplete() @@ -116,7 +103,7 @@ class GoogleMapViewSaver : BaseMapViewSaver() { state: GoogleMapViewStateImpl, bundle: Bundle, ) { - bundle.putInt("id", state.mapDesignType?.id ?: GoogleMapDesign.None.id) + bundle.putInt("id", state.mapDesignType.id) } override fun createState( diff --git a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/polygon/GoogleMapPolygonOverlayRenderer.kt b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/polygon/GoogleMapPolygonOverlayRenderer.kt index b575a6cb..aaac8f32 100644 --- a/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/polygon/GoogleMapPolygonOverlayRenderer.kt +++ b/mapconductor-for-googlemaps/src/main/java/com/mapconductor/googlemaps/polygon/GoogleMapPolygonOverlayRenderer.kt @@ -3,6 +3,8 @@ package com.mapconductor.googlemaps.polygon import androidx.compose.ui.graphics.toArgb import com.google.android.gms.maps.model.PolygonOptions import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.createInterpolatePoints +import com.mapconductor.core.createLinearInterpolatePoints import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer import com.mapconductor.core.polygon.PolygonEntity @@ -28,13 +30,19 @@ class GoogleMapPolygonOverlayRenderer( override suspend fun createPolygon(state: PolygonState) = withContext(coroutine.coroutineContext) { - val points = state.points.map { GeoPointImpl.from(it).toLatLng() } + val geoPoints = + when (state.geodesic) { + true -> createInterpolatePoints(state.points) + false -> createLinearInterpolatePoints(state.points) + } + val points = geoPoints.map { GeoPointImpl.from(it).toLatLng() } val options = PolygonOptions() .addAll(points) .strokeColor(state.strokeColor.toArgb()) .strokeWidth(ResourceProvider.dpToPx(state.strokeWidth).toFloat()) .fillColor(state.fillColor.toArgb()) + .zIndex(state.zIndex.toFloat()) .clickable(false) holder.map.addPolygon(options)?.also { it.tag = state.id @@ -51,10 +59,13 @@ class GoogleMapPolygonOverlayRenderer( val finger = current.fingerPrint val prevFinger = prev.fingerPrint Log.d("GoogleMaps", "----->$finger, $prevFinger") - if (finger.points != prevFinger.points) { - val points = - current.state.points - .map { GeoPointImpl.from(it).toLatLng() } + if (finger.points != prevFinger.points || finger.geodesic != prevFinger.geodesic) { + val geoPoints = + when (current.state.geodesic) { + true -> createInterpolatePoints(current.state.points) + false -> createLinearInterpolatePoints(current.state.points) + } + val points = geoPoints.map { GeoPointImpl.from(it).toLatLng() } polygon.points = points } polygon.strokeWidth = ResourceProvider.dpToPx(current.state.strokeWidth).toFloat() @@ -62,6 +73,7 @@ class GoogleMapPolygonOverlayRenderer( current.state.strokeColor.toArgb() polygon.fillColor = current.state.fillColor.toArgb() + polygon.zIndex = current.state.zIndex.toFloat() polygon } } diff --git a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereMapView.kt b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereMapView.kt index 531a28fe..b669dc0c 100644 --- a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereMapView.kt +++ b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereMapView.kt @@ -1,5 +1,6 @@ package com.mapconductor.here +import HerePolygonOverlayRenderer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember @@ -9,17 +10,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner +import com.here.sdk.mapview.MapRenderMode +import com.here.sdk.mapview.MapView +import com.here.sdk.mapview.MapViewOptions import com.mapconductor.core.circle.OnCircleEventHandler import com.mapconductor.core.map.MapViewBase import com.mapconductor.core.map.MapViewState import com.mapconductor.core.map.OnMapEventHandler import com.mapconductor.core.map.OnMapLoadedHandler -import com.mapconductor.core.map.OnMapViewInitializedHandler import com.mapconductor.core.marker.MarkerRenderingStrategy import com.mapconductor.core.marker.OnMarkerEventHandler import com.mapconductor.core.polygon.OnPolygonEventHandler import com.mapconductor.core.polyline.OnPolylineEventHandler -import android.util.Log +import com.mapconductor.here.circle.HereCircleController +import com.mapconductor.here.circle.HereCircleOverlayRenderer +import com.mapconductor.here.marker.HereMarkerController +import com.mapconductor.here.polygon.HerePolygonController +import com.mapconductor.here.polyline.HerePolylineController +import com.mapconductor.here.polyline.HerePolylineOverlayRenderer import android.view.ViewGroup import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine @@ -30,7 +38,6 @@ fun HereMapView( state: HereViewStateImpl, modifier: Modifier = Modifier, markerRenderingStrategy: MarkerRenderingStrategy? = null, - onMapViewInitialized: OnMapViewInitializedHandler? = null, onMapLoaded: OnMapLoadedHandler? = null, onMapClick: OnMapEventHandler? = null, onMarkerClick: OnMarkerEventHandler? = null, @@ -54,27 +61,45 @@ fun HereMapView( MapViewBase( state = state, modifier = modifier, - holderRef = holderRef, - controllerRef = controllerRef, - viewProvider = { this.mapView }, - scope = scope, - registry = registry, - onInitialize = { - HereMapViewControllerStore.initSDK(context) + sdkInitialize = { + HereMapViewControllerStore.initSDK(context.applicationContext) + true + }, + viewProvider = { + // TEXTUREモードにしないとデバイスが回転したときに再描画を適切に行わない + val viewOptions = + MapViewOptions().also { + it.renderMode = MapRenderMode.TEXTURE + } - val mapInitOptions = - HereViewInitOptions( - scheme = state.mapDesignType.getValue(), + MapView(context, viewOptions).apply { + onCreate(null) + onResume() + } + }, + holderProvider = { mapView -> + HereViewHolderImpl(mapView, mapView.mapScene) + }, + controllerProvider = { holder -> + val markerController = + getMarkerController( + holder = holder, + renderingStrategy = markerRenderingStrategy, ) + val polylineController = getPolylineController(holder) + val polygonController = getPolygonController(holder) + val circleController = getHereCircleController(holder) + + // Defer initial camera update until after controller is created and camera is moved val controller = - HereMapViewControllerStore.getOrCreate( - context = context, - id = state.id, - options = mapInitOptions, - markerRenderingStrategy = markerRenderingStrategy, + HereMapViewControllerImpl( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, ) - controller.setCameraMoveListener(state::onCameraChange) controller.setMapClickListener(onMapClick) controller.setOnMarkerClickListener(onMarkerClick) @@ -88,56 +113,50 @@ fun HereMapView( controller.setOnPolygonClickListener(onPolygonClick) state.setController(controller) controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - controller.setMapLoadedListener { - onMapLoaded?.invoke(state) - } controller.holder.mapView.mapScene.loadScene(state.mapDesignType.getValue()) { mapError -> if (mapError != null) { throw Throwable("Loading map failed: mapError: " + mapError.name) } } - try { - holderRef.value = controller.holder - controllerRef.value = controller - - return@MapViewBase suspendCancellableCoroutine { cont -> - val restoreCameraPosition = state.cameraPosition.value - controller.moveCamera( - position = restoreCameraPosition, - listener = - object : MapViewState.MoveCameraCallback { - override fun onComplete() { - cont.resume(true) { } - } - }, - ) - } - } catch (e: Exception) { - Log.e("HereMap", "failed to initialize", e) - false // Scene loading failed + holderRef.value = controller.holder + controllerRef.value = controller + + return@MapViewBase suspendCancellableCoroutine { cont -> + val restoreCameraPosition = state.cameraPosition.value + controller.moveCamera( + position = restoreCameraPosition, + listener = + object : MapViewState.MoveCameraCallback { + override fun onComplete() { + cont.resume(controller) { } + } + }, + ) } }, - onMapViewInitialized = onMapViewInitialized, - customDisposableEffect = { _state, _holderRef -> + scope = scope, + registry = registry, + onMapLoaded = onMapLoaded, + customDisposableEffect = { initState, holderRef -> // HERE specific DisposableEffect logic DisposableEffect(lifecycle) { - val stateId = _state.id // from BaseMapViewState + val stateId = state.id // from BaseMapViewState val observer = object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { // Do not call here to keep the MapView instance - // _holderRef.value?.mapView?.onResume() + // holderRef.value?.mapView?.onResume() } override fun onPause(owner: LifecycleOwner) { // Do not call here to keep the MapView instance - // _holderRef.value?.mapView?.onPause() + // holderRef.value?.mapView?.onPause() } override fun onDestroy(owner: LifecycleOwner) { - val currentHolder = _holderRef.value + val currentHolder = holderRef.value if (currentHolder != null) { val activity = context.findActivity() if (activity?.isChangingConfigurations == true) { @@ -153,7 +172,6 @@ fun HereMapView( } lifecycle.addObserver(observer) onDispose { - _state.resetInitState() lifecycle.removeObserver(observer) } } @@ -164,3 +182,50 @@ fun HereMapView( content = content, // This might need adjustment based on how overlays are handled ) } + +private fun getPolylineController(holder: HereViewHolder): HerePolylineController { + val renderer = + HerePolylineOverlayRenderer( + holder = holder, + ) + + val controller = + HerePolylineController( + renderer = renderer, + ) + return controller +} + +private fun getMarkerController( + holder: HereViewHolder, + renderingStrategy: MarkerRenderingStrategy? = null, +) = HereMarkerController.create( + holder = holder, + renderingStrategy = renderingStrategy, +) + +private fun getHereCircleController(holder: HereViewHolder): HereCircleController { + val renderer = + HereCircleOverlayRenderer( + holder = holder, + ) + + val controller = + HereCircleController( + renderer = renderer, + ) + return controller +} + +private fun getPolygonController(holder: HereViewHolder): HerePolygonController { + val renderer = + HerePolygonOverlayRenderer( + holder = holder, + ) + + val controller = + HerePolygonController( + renderer = renderer, + ) + return controller +} diff --git a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewControllerStore.kt b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewControllerStore.kt index 38225373..36f63ebe 100644 --- a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewControllerStore.kt +++ b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewControllerStore.kt @@ -1,6 +1,5 @@ package com.mapconductor.here -import HerePolygonOverlayRenderer import com.here.sdk.core.engine.AuthenticationMode import com.here.sdk.core.engine.SDKNativeEngine import com.here.sdk.core.engine.SDKOptions @@ -8,13 +7,6 @@ import com.here.sdk.mapview.MapScene import com.here.sdk.mapview.MapView import com.mapconductor.core.map.MapViewHolder import com.mapconductor.core.map.StaticHolder -import com.mapconductor.core.marker.MarkerRenderingStrategy -import com.mapconductor.here.circle.HereCircleController -import com.mapconductor.here.circle.HereCircleOverlayRenderer -import com.mapconductor.here.marker.HereMarkerController -import com.mapconductor.here.polygon.HerePolygonController -import com.mapconductor.here.polyline.HerePolylineController -import com.mapconductor.here.polyline.HerePolylineOverlayRenderer import android.content.Context import android.content.pm.PackageManager @@ -51,113 +43,6 @@ object HereMapViewControllerStore : StaticHolder() { SDKNativeEngine.makeSharedInstance(context.applicationContext, sdkOption) this.mapCount++ } - - fun getOrCreate( - context: Context, - id: String, - options: HereViewInitOptions, - markerRenderingStrategy: MarkerRenderingStrategy? = null, - ): HereMapViewControllerImpl { - val existing = this.get(id) - if (existing != null) { - return existing - } - initSDK(context.applicationContext) - - val holder = - HereViewHolderImpl.create( - context.applicationContext, - ) - -// val mapView = newHolder.mapView -// options.let { it -> -// suspendCancellableCoroutine { cont -> -// mapView.mapScene.loadScene(it.scheme) { mapError -> -// if (mapError != null) { -// // Log.e("HereMapViewState", "Loading map failed: mapError: " + mapError.name) -// cont.resumeWithException(IllegalStateException(mapError.toString())) -// return@loadScene -// } -// -// mapView.camera.applyUpdate( -// options.camera.toMapCameraUpdate(), -// ) -// cont.resume(Unit) -// } -// } -// } - - val controller = - HereMapViewControllerImpl( - holder = holder, - markerController = - getMarkerController( - holder = holder, - renderingStrategy = markerRenderingStrategy, - ), - polylineController = getPolylineController(holder), - polygonController = getPolygonController(holder), - circleController = getHereCircleController(holder), - ) - this.set(id, controller) - return controller - } - - private fun getPolylineController(holder: HereViewHolder): HerePolylineController { - val renderer = - HerePolylineOverlayRenderer( - holder = holder, - ) - - val controller = - HerePolylineController( - renderer = renderer, - ) - return controller - } - - private fun getMarkerController( - holder: HereViewHolder, - renderingStrategy: MarkerRenderingStrategy? = null, - ) = HereMarkerController.create( - holder = holder, - renderingStrategy = renderingStrategy, - ) - - private fun getHereCircleController(holder: HereViewHolder): HereCircleController { - val renderer = - HereCircleOverlayRenderer( - holder = holder, - ) - - val controller = - HereCircleController( - renderer = renderer, - ) - return controller - } - - private fun getPolygonController(holder: HereViewHolder): HerePolygonController { - val renderer = - HerePolygonOverlayRenderer( - holder = holder, - ) - - val controller = - HerePolygonController( - renderer = renderer, - ) - return controller - } - -// fun release() { -// mapCount-- -// if (mapCount > 0) return -// -// // Dispose the shared instance when all maps are removed. -// SDKNativeEngine.getSharedInstance()?.dispose() -// SDKNativeEngine.setSharedInstance(null) -// } } internal fun Context.getHereAccessKeyId(): String? = diff --git a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewHolderImpl.kt b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewHolderImpl.kt index 2c2dafee..143b9c58 100644 --- a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewHolderImpl.kt +++ b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewHolderImpl.kt @@ -2,20 +2,16 @@ package com.mapconductor.here import androidx.compose.ui.geometry.Offset import com.here.sdk.core.Point2D -import com.here.sdk.mapview.MapRenderMode import com.here.sdk.mapview.MapScene import com.here.sdk.mapview.MapView -import com.here.sdk.mapview.MapViewOptions import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.MapViewHolder -import android.content.Context -internal class HereViewHolderImpl private constructor( +internal class HereViewHolderImpl( override val mapView: MapView, + override val map: MapScene, ) : MapViewHolder { - override lateinit var map: MapScene - override fun toScreenOffset(position: GeoPoint): Offset? { val result = mapView.geoToViewCoordinates( @@ -39,24 +35,4 @@ internal class HereViewHolderImpl private constructor( .viewToGeoCoordinates( Point2D(offset.x.toDouble(), offset.y.toDouble()), )?.toGeoPoint() - - companion object { - fun create(context: Context): MapViewHolder { - // TEXTUREモードにしないとデバイスが回転したときに再描画を適切に行わない - val viewOptions = - MapViewOptions().also { - it.renderMode = MapRenderMode.TEXTURE - } - - val mapView = - MapView(context, viewOptions).apply { - onCreate(null) - onResume() - } - - val holder = HereViewHolderImpl(mapView) - holder.map = mapView.mapScene - return holder - } - } } diff --git a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewStateImpl.kt b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewStateImpl.kt index 885700a8..407e0d38 100644 --- a/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewStateImpl.kt +++ b/mapconductor-for-here/src/main/java/com/mapconductor/here/HereViewStateImpl.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.BaseMapViewSaver -import com.mapconductor.core.map.InitState import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionImpl import com.mapconductor.core.map.MapPaddings @@ -53,14 +52,6 @@ class HereViewStateImpl( durationMs: Long?, listener: MoveCameraCallback?, ) { - if (this.isInitialized.value != InitState.Initialized) { - _cameraPosition.value = - MapCameraPositionImpl( - position = position, - ) - listener?.onComplete() - return - } val currentPosition = this.cameraPosition.value val newPosition = currentPosition.copy( @@ -78,15 +69,13 @@ class HereViewStateImpl( listener: MoveCameraCallback?, ) { controller?.let { ctrl -> - if (this.isInitialized.value == InitState.Initialized) { - val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) - if (durationMs == null || durationMs == 0L) { - ctrl.moveCamera(dstCameraPosition, listener) - } else { - ctrl.animateCamera(dstCameraPosition, durationMs, listener) - } - return + val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) + if (durationMs == null || durationMs == 0L) { + ctrl.moveCamera(dstCameraPosition, listener) + } else { + ctrl.animateCamera(dstCameraPosition, durationMs, listener) } + return@let } _cameraPosition.value = cameraPosition listener?.onComplete() diff --git a/mapconductor-for-here/src/main/java/com/mapconductor/here/circle/HereCircleOverlayRenderer.kt b/mapconductor-for-here/src/main/java/com/mapconductor/here/circle/HereCircleOverlayRenderer.kt index 8111108b..e0730fba 100644 --- a/mapconductor-for-here/src/main/java/com/mapconductor/here/circle/HereCircleOverlayRenderer.kt +++ b/mapconductor-for-here/src/main/java/com/mapconductor/here/circle/HereCircleOverlayRenderer.kt @@ -57,7 +57,10 @@ class HereCircleOverlayRenderer( val prevFinger = prev.fingerPrint // Update geometry if center or radius changed - if (finger.center != prevFinger.center || finger.radiusMeters != prevFinger.radiusMeters) { + if (finger.center != prevFinger.center || + finger.radiusMeters != prevFinger.radiusMeters || + finger.geodesic != prevFinger.geodesic + ) { val geoCircle = createCirclePolygon(current.state) current.circle.geometry = geoCircle } @@ -101,25 +104,22 @@ class HereCircleOverlayRenderer( */ private fun createCirclePolygon(state: CircleState): GeoPolygon { val center = GeoPointImpl.from(state.center).toGeoCoordinates() - // val radiusMeters = state.radiusMeters - -// val points = mutableListOf() -// -// // Generate points around the circle -// for (i in 0 until CIRCLE_POINT_COUNT) { -// val angle = 2.0 * PI * i / CIRCLE_POINT_COUNT -// val point = calculateCirclePoint(center, radiusMeters, angle) -// points.add(point) -// } -// -// // Close the circle by adding the first point at the end -// if (points.isNotEmpty()) { -// points.add(points.first()) -// } - val geoCircle = GeoCircle(center, state.radiusMeters) - val geoPolygon = GeoPolygon(geoCircle) - - return geoPolygon + if (state.geodesic) { + // Native geodesic circle + val geoCircle = GeoCircle(center, state.radiusMeters) + return GeoPolygon(geoCircle) + } else { + // Approximate planar circle by sampling points + val segments = 128 + val pts = ArrayList(segments + 1) + val twoPi = kotlin.math.PI * 2.0 + for (i in 0 until segments) { + val angle = twoPi * i / segments + pts.add(calculateCirclePoint(center, state.radiusMeters, angle)) + } + if (pts.isNotEmpty()) pts.add(pts.first()) + return GeoPolygon(pts) + } } /** diff --git a/mapconductor-for-here/src/main/java/com/mapconductor/here/polygon/HerePolygonOverlayRenderer.kt b/mapconductor-for-here/src/main/java/com/mapconductor/here/polygon/HerePolygonOverlayRenderer.kt index 1812d60b..04f6a646 100644 --- a/mapconductor-for-here/src/main/java/com/mapconductor/here/polygon/HerePolygonOverlayRenderer.kt +++ b/mapconductor-for-here/src/main/java/com/mapconductor/here/polygon/HerePolygonOverlayRenderer.kt @@ -3,6 +3,9 @@ import com.here.sdk.core.Color import com.here.sdk.core.GeoPolygon import com.here.sdk.mapview.MapPolygon import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.createInterpolatePoints +import com.mapconductor.core.createLinearInterpolatePoints +import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer import com.mapconductor.core.polygon.PolygonEntity @@ -34,7 +37,9 @@ class HerePolygonOverlayRenderer( Color.valueOf(state.fillColor.toArgb()), Color.valueOf(state.strokeColor.toArgb()), lineWidth, - ) + ).apply { + drawOrder = state.zIndex + } coroutine.launch { holder.map.addMapPolygon(mapPolygon) } @@ -50,7 +55,7 @@ class HerePolygonOverlayRenderer( val finger = current.fingerPrint val prevFinger = prev.fingerPrint - if (finger.points != prevFinger.points) { + if (finger.points != prevFinger.points || finger.geodesic != prevFinger.geodesic) { val geoPolygon = createGeoPolygon(current.state) current.polygon.geometry = geoPolygon } @@ -74,11 +79,19 @@ class HerePolygonOverlayRenderer( current.state.fillColor.toArgb(), ) } + if (finger.zIndex != prevFinger.zIndex) { + current.polygon.drawOrder = current.state.zIndex + } polygon } private fun createGeoPolygon(state: PolygonState): GeoPolygon { - val points = state.points.map { GeoPointImpl.from(it).toGeoCoordinates() } + val geoPoints: List = + when (state.geodesic) { + true -> createInterpolatePoints(state.points) + false -> createLinearInterpolatePoints(state.points) + } + val points = geoPoints.map { GeoPointImpl.from(it).toGeoCoordinates() } // Ensure the polygon is closed by adding the first point at the end if not already closed val closedPoints = if (points.first() != points.last()) { diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/GeoPoint.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/GeoPoint.kt index ccb55425..5edcd600 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/GeoPoint.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/GeoPoint.kt @@ -3,8 +3,13 @@ package com.mapconductor.mapbox import com.mapbox.geojson.Point import com.mapconductor.core.features.GeoPointImpl -fun GeoPointImpl.toPoint(): Point = Point.fromLngLat(longitude, latitude) - -fun GeoPointImpl.Companion.from(point: Point) = GeoPointImpl(point.latitude(), point.longitude()) +fun GeoPointImpl.toPoint(): Point = Point.fromLngLat(longitude, latitude, altitude) + +fun GeoPointImpl.Companion.from(point: Point) = + GeoPointImpl( + latitude = point.latitude(), + longitude = point.longitude(), + altitude = point.altitude(), + ) fun Point.toGeoPoint() = GeoPointImpl.fromLongLat(longitude(), latitude()) diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt index 92ec1703..2ed2581b 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapView.kt @@ -1,17 +1,21 @@ package com.mapconductor.mapbox import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.node.Ref import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import com.mapbox.maps.MapInitOptions +import com.mapbox.maps.MapView import com.mapconductor.core.circle.CircleManagerImpl import com.mapconductor.core.circle.OnCircleEventHandler import com.mapconductor.core.map.MapViewBase import com.mapconductor.core.map.OnMapEventHandler import com.mapconductor.core.map.OnMapLoadedHandler -import com.mapconductor.core.map.OnMapViewInitializedHandler import com.mapconductor.core.marker.MarkerManager import com.mapconductor.core.marker.MarkerRenderingStrategy import com.mapconductor.core.marker.OnMarkerEventHandler @@ -35,13 +39,13 @@ import com.mapconductor.mapbox.polyline.MapboxPolylineOverlayRenderer import android.app.Activity import android.content.Context import android.content.ContextWrapper +import android.view.ViewGroup @Composable fun MapboxMapView( state: MapboxViewStateImpl, modifier: Modifier = Modifier, markerRenderingStrategy: MarkerRenderingStrategy? = null, - onMapViewInitialized: OnMapViewInitializedHandler? = null, onMapLoaded: OnMapLoadedHandler? = null, onMapClick: OnMapEventHandler? = null, onMarkerClick: OnMarkerEventHandler? = null, @@ -60,23 +64,18 @@ fun MapboxMapView( val controllerRef = remember { Ref() } val scope = remember { MapboxMapViewScope() } val registry = remember { scope.buildRegistry() } + val lifecycle = LocalLifecycleOwner.current.lifecycle MapViewBase( state = state, modifier = modifier, - holderRef = holderRef, - controllerRef = controllerRef, - viewProvider = { this.mapView }, - scope = scope, - registry = registry, - onInitialize = { - MapboxInitSDK(context) - + viewProvider = { val cameraOptions = state.cameraPosition.value.toCameraOptions() val styleUri = state.mapDesignType.getValue() - val mapInitOptions = + + val mapOptions = MapInitOptions( context = context, textureView = true, @@ -84,46 +83,98 @@ fun MapboxMapView( cameraOptions = cameraOptions, ) - val holder = MapboxMapViewHolderImpl.create(context, mapInitOptions) + MapView(context, mapOptions).also { + it.onStart() + } + }, + holderProvider = { mapView -> MapboxMapViewHolderImpl(mapView, mapView.mapboxMap) }, + controllerProvider = { holder -> - val controller = - MapboxMapViewControllerImpl( + val markerController = + getMarkerController( holder = holder, - markerController = - getMarkerController( - holder = holder, - renderingStrategy = markerRenderingStrategy, - ), - polylineController = getPolylineController(holder), - polygonController = getPolygonController(holder), - circleController = getCircleController(holder), + renderingStrategy = markerRenderingStrategy, ) - controller.setCameraMoveListener(state::onCameraChange) - controller.setMapClickListener(onMapClick) - controller.setOnCircleClickListener(onCircleClick) - controller.setOnPolylineClickListener(onPolylineClick) - controller.setOnPolygonClickListener(onPolygonClick) - controller.setOnMarkerClickListener(onMarkerClick) - controller.setOnMarkerDragStart(onMarkerDragStart) - controller.setOnMarkerDrag(onMarkerDrag) - controller.setOnMarkerDragEnd(onMarkerDragEnd) - controller.setOnMarkerAnimateStart(onMarkerAnimateStart) - controller.setOnMarkerAnimateEnd(onMarkerAnimateEnd) - controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - state.setController(controller) - controller.setMapLoadedListener { - onMapLoaded?.invoke(state) - } + val polylineController = getPolylineController(holder) + val polygonController = getPolygonController(holder) + val circleController = getCircleController(holder) + + // Defer initial camera update until after controller is created and view is laid out + + MapboxMapViewControllerImpl( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + ).also { controller -> + controller.setCameraMoveListener(state::onCameraChange) + controller.setMapClickListener(onMapClick) + controller.setOnCircleClickListener(onCircleClick) + controller.setOnPolylineClickListener(onPolylineClick) + controller.setOnPolygonClickListener(onPolygonClick) + controller.setOnMarkerClickListener(onMarkerClick) + controller.setOnMarkerDragStart(onMarkerDragStart) + controller.setOnMarkerDrag(onMarkerDrag) + controller.setOnMarkerDragEnd(onMarkerDragEnd) + controller.setOnMarkerAnimateStart(onMarkerAnimateStart) + controller.setOnMarkerAnimateEnd(onMarkerAnimateEnd) + controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + state.setController(controller) + + holderRef.value = holder + controllerRef.value = controller - holderRef.value = holder - controllerRef.value = controller + // Post an initial camera update once the MapView is laid out and style is ready + holder.mapView.post { controller.sendInitialCameraUpdate() } + } + }, + scope = scope, + registry = registry, + sdkInitialize = { + MapboxInitSDK(context) true }, - onMapViewInitialized = onMapViewInitialized, + onMapLoaded = onMapLoaded, // Pass content if it needs to be rendered within the overlay providers in MapViewBase, // or handle it here if it's specific to GoogleMapsView structure before calling MapViewBase. // For now, assuming content relates to overlay definitions. content = content, // This might need adjustment based on how overlays are handled + customDisposableEffect = { initState, holderRef -> + + // HERE specific DisposableEffect logic + DisposableEffect(lifecycle) { + val stateId = state.id // from BaseMapViewState + val observer = + object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + holderRef.value?.mapView?.onResume() + } + + override fun onPause(owner: LifecycleOwner) { + // Do not call here to keep the MapView instance + // holderRef.value?.mapView?.onPause() + } + + override fun onDestroy(owner: LifecycleOwner) { + val currentHolder = holderRef.value + if (currentHolder != null) { + val activity = context.findActivity() + if (activity?.isChangingConfigurations == true) { + (currentHolder.mapView.parent as? ViewGroup)?.removeView(currentHolder.mapView) + } else { + // Ensure these calls are safe if mapView might be null or already destroyed + currentHolder.mapView.onDestroy() + } + } + } + } + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + } + } + }, ) } diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerImpl.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerImpl.kt index b243c7a9..ca41c37d 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerImpl.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewControllerImpl.kt @@ -10,6 +10,8 @@ import com.mapbox.maps.ScreenCoordinate import com.mapbox.maps.StyleLoaded import com.mapbox.maps.StyleLoadedCallback import com.mapbox.maps.extension.style.layers.addLayer +import com.mapbox.maps.extension.style.layers.addLayerAbove +import com.mapbox.maps.extension.style.layers.addLayerBelow import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.plugin.animation.MapAnimationOptions import com.mapbox.maps.plugin.animation.flyTo @@ -64,28 +66,10 @@ internal class MapboxMapViewControllerImpl( OnMapClickListener, OnMapLongClickListener, OnMoveListener { + // Track created z-indexed polygon layers to manage add/remove without enumerating style layers + private val polygonZLayers: MutableSet = mutableSetOf() + init { - holder.map.getStyle { style -> - // Circle - style.addSource(circleController.renderer.layer.source) - style.addLayer(circleController.renderer.layer.layer) - - // Polygon - style.addSource(polygonController.polygonOverlay.layer.source) - style.addLayer(polygonController.polygonOverlay.layer.layer) - style.addSource(polygonController.polylineOverlay.layer.source) - style.addLayer(polygonController.polylineOverlay.layer.layer) - - // Polyline - style.addSource(polylineController.renderer.layer.source) - style.addLayer(polylineController.renderer.layer.layer) - - // Marker - style.addSource(markerController.renderer.markerLayer.source) - style.addLayer(markerController.renderer.markerLayer.layer) - style.addSource(markerController.renderer.dragLayer.source) - style.addLayer(markerController.renderer.dragLayer.layer) - } setupListeners() registerController(markerController) registerController(polygonController) @@ -93,6 +77,29 @@ internal class MapboxMapViewControllerImpl( registerController(circleController) } + private fun attachOverlaySourcesAndLayers(style: com.mapbox.maps.Style) { + // Polygon sources only (z-indexed layers added below) + style.addSource(polygonController.polylineOverlay.layer.source) + style.addSource(polygonController.polygonOverlay.layer.source) + + // Circle + style.addSource(circleController.renderer.layer.source) + style.addLayer(circleController.renderer.layer.layer) + + // Polyline (general) + style.addSource(polylineController.renderer.layer.source) + style.addLayer(polylineController.renderer.layer.layer) + + // Add z-indexed polygon layers below general polylines + ensurePolygonZLayers(style) + + // Marker + drag layers + style.addSource(markerController.renderer.markerLayer.source) + style.addLayer(markerController.renderer.markerLayer.layer) + style.addSource(markerController.renderer.dragLayer.source) + style.addLayer(markerController.renderer.dragLayer.layer) + } + fun setupListeners() { holder.map.subscribeCameraChanged(this) holder.map.subscribeStyleLoaded(this) @@ -120,9 +127,15 @@ internal class MapboxMapViewControllerImpl( override suspend fun updatePolyline(state: PolylineState) = polylineController.update(state) - override suspend fun compositionPolygons(data: List) = polygonController.add(data) + override suspend fun compositionPolygons(data: List) { + polygonController.add(data) + holder.map.getStyle { ensurePolygonZLayers(it) } + } - override suspend fun updatePolygon(state: PolygonState) = polygonController.update(state) + override suspend fun updatePolygon(state: PolygonState) { + polygonController.update(state) + holder.map.getStyle { ensurePolygonZLayers(it) } + } override suspend fun compositionCircles(data: List) = circleController.add(data) @@ -155,7 +168,7 @@ internal class MapboxMapViewControllerImpl( override fun hasCircle(state: CircleState): Boolean = this.circleController.circleManager.hasEntity(state.id) private fun getMapCameraPosition(cameraChanged: CameraChanged): MapCameraPositionImpl? { - val options = cameraChanged.toMapCameraPosition() +// val options = cameraChanged.toMapCameraPosition() val camera = holder.map.cameraState.toMapCameraPosition() val mapWidth = holder.mapView.width.toFloat() @@ -309,7 +322,7 @@ internal class MapboxMapViewControllerImpl( } override fun onMove(detector: MoveGestureDetector): Boolean { - markerController.renderer.dragLayer.selected?.let { entity -> + markerController.selectedMarker?.let { entity -> val screenCoordinate = Offset( @@ -398,9 +411,136 @@ internal class MapboxMapViewControllerImpl( mapLoadedCallback?.invoke() mapLoadedCallback = null - holder.map.style?.toMapDesignType()?.let { mapDesignType -> - this@MapboxMapViewControllerImpl.mapDesignType = mapDesignType - mapDesignTypeChangeListener?.invoke(mapDesignType) + holder.map.style?.let { style -> + // When style reloads, our runtime sources/layers/images are dropped. + // Reattach overlays and ensure marker images exist, then redraw. + attachOverlaySourcesAndLayers(style) + markerController.renderer.ensureStyleImages(style) + markerController.renderer.redraw() + + // After style is ready, trigger an initial camera update + sendInitialCameraUpdate() + + style.toMapDesignType().let { mapDesign -> + this@MapboxMapViewControllerImpl.mapDesignType = mapDesign + mapDesignTypeChangeListener?.invoke(mapDesign) + } + } + } + + private fun ensurePolygonZLayers(style: com.mapbox.maps.Style) { + val fillSourceId = polygonController.polygonOverlay.layer.sourceId + val outlineSourceId = polygonController.polylineOverlay.layer.sourceId + val anchorId = polylineController.renderer.layer.layerId + + val zSet = + polygonController.polygonOverlay.polygonManager + .allEntities() + .map { it.state.zIndex } + .toSet() + + // Remove stale z-indexed layers we previously created + val toRemove = polygonZLayers.subtract(zSet) + toRemove.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + try { + style.removeStyleLayer(outlineId) + } catch (_: Exception) { + } + try { + style.removeStyleLayer(fillId) + } catch (_: Exception) { + } + } + + val zList = zSet.toList().sorted() + zList.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + + // Fill layer for this z + if (!style.styleLayerExists(fillId)) { + val layer = + com.mapbox.maps.extension.style.layers.generated.fillLayer(fillId, fillSourceId) { + filter( + com.mapbox.maps.extension.style.expressions.generated.Expression.eq( + com.mapbox.maps.extension.style.expressions.generated.Expression + .get("zIndex"), + com.mapbox.maps.extension.style.expressions.generated.Expression + .literal(z.toDouble()), + ), + ) + fillColor( + com.mapbox.maps.extension.style.expressions.generated.Expression + .get("fillColor"), + ) + } + try { + style.addLayerBelow(layer, anchorId) + } catch (_: Exception) { + style.addLayer(layer) + } + } + + // Outline layer above its fill + if (!style.styleLayerExists(outlineId)) { + val layer = + com.mapbox.maps.extension.style.layers.generated.lineLayer(outlineId, outlineSourceId) { + lineJoin(com.mapbox.maps.extension.style.layers.properties.generated.LineJoin.ROUND) + lineCap(com.mapbox.maps.extension.style.layers.properties.generated.LineCap.ROUND) + filter( + com.mapbox.maps.extension.style.expressions.generated.Expression.eq( + com.mapbox.maps.extension.style.expressions.generated.Expression + .get("zIndex"), + com.mapbox.maps.extension.style.expressions.generated.Expression + .literal(z.toDouble()), + ), + ) + lineColor( + com.mapbox.maps.extension.style.expressions.generated.Expression + .get("strokeColor"), + ) + lineWidth( + com.mapbox.maps.extension.style.expressions.generated.Expression + .get("strokeWidth"), + ) + } + try { + style.addLayerAbove(layer, fillId) + } catch (_: Exception) { + style.addLayer(layer) + } + } + } + // Update tracked set + polygonZLayers.clear() + polygonZLayers.addAll(zSet) + } + + // Trigger an initial camera update after the view and style are ready + fun sendInitialCameraUpdate() { + coroutine.launch { + val mapWidth = holder.mapView.width.toFloat() + val mapHeight = holder.mapView.height.toFloat() + if (mapWidth <= 0 || mapHeight <= 0) return@launch + + val camera = holder.map.cameraState.toMapCameraPosition() + val nearLeft = holder.fromScreenOffsetSync(Offset(0f, mapHeight)) ?: return@launch + val nearRight = holder.fromScreenOffsetSync(Offset(mapWidth, mapHeight)) ?: return@launch + val farLeft = holder.fromScreenOffsetSync(Offset(0f, 0f)) ?: return@launch + val farRight = holder.fromScreenOffsetSync(Offset(mapWidth, 0f)) ?: return@launch + + val bounds = GeoRectBounds() + bounds.extend(nearLeft) + bounds.extend(nearRight) + bounds.extend(farLeft) + bounds.extend(farRight) + + val visibleRegion = VisibleRegion(bounds, nearLeft, nearRight, farLeft, farRight) + val mapCameraPosition = camera.copy(visibleRegion = visibleRegion) + + backCoroutine.launch { notifyMapCameraPosition(mapCameraPosition) } } } } diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewHolderImpl.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewHolderImpl.kt index 4952e9aa..f5fd3d21 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewHolderImpl.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxMapViewHolderImpl.kt @@ -1,8 +1,6 @@ package com.mapconductor.mapbox import androidx.compose.ui.geometry.Offset -import com.mapbox.maps.CameraOptions -import com.mapbox.maps.MapInitOptions import com.mapbox.maps.MapView import com.mapbox.maps.MapboxLifecycleObserver import com.mapbox.maps.MapboxMap @@ -11,16 +9,14 @@ import com.mapbox.maps.plugin.lifecycle.lifecycle import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.MapViewHolder -import android.content.Context typealias MapboxMapViewHolder = MapViewHolder -class MapboxMapViewHolderImpl private constructor( +class MapboxMapViewHolderImpl( override val mapView: MapView, + override val map: MapboxMap, ) : MapViewHolder, MapboxLifecycleObserver { - override lateinit var map: MapboxMap - init { this.mapView.lifecycle.registerLifecycleObserver(this.mapView, this) } @@ -49,40 +45,15 @@ class MapboxMapViewHolderImpl private constructor( ), ) - companion object { - fun create( - context: Context, - mapInitOptions: MapInitOptions, - ): MapViewHolder { - val cameraOptions = - CameraOptions - .Builder() - .center(mapInitOptions.cameraOptions!!.center) - .bearing(mapInitOptions.cameraOptions!!.bearing) - .zoom(mapInitOptions.cameraOptions!!.zoom!! - 1.0) - .pitch(mapInitOptions.cameraOptions!!.pitch) - .build() - - val internalOptions = - MapInitOptions( - context = context, - textureView = true, - styleUri = mapInitOptions.styleUri, - cameraOptions = cameraOptions, - ) - - val mapView = MapView(context, internalOptions) - val holder = MapboxMapViewHolderImpl(mapView) - holder.map = mapView.mapboxMap - return holder - } + override fun onDestroy() { } - override fun onDestroy() = Unit - - override fun onLowMemory() = Unit + override fun onLowMemory() { + } - override fun onStart() = Unit + override fun onStart() { + } - override fun onStop() = Unit + override fun onStop() { + } } diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxPolyUtils.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxPolyUtils.kt index 5f2715e1..95291e49 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxPolyUtils.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxPolyUtils.kt @@ -5,12 +5,14 @@ import androidx.compose.ui.unit.Dp import com.google.gson.JsonObject import com.mapbox.geojson.Feature import com.mapbox.geojson.LineString +import com.mapbox.geojson.Polygon as MBPolygon import com.mapconductor.core.createInterpolatePoints import com.mapconductor.core.createLinearInterpolatePoints import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.features.normalize import com.mapconductor.core.splitByMeridian +import com.mapconductor.mapbox.polygon.MapboxPolygonLayer import com.mapconductor.mapbox.polyline.MapboxPolylineLayer internal fun createMapboxLines( @@ -19,6 +21,7 @@ internal fun createMapboxLines( geodesic: Boolean, strokeColor: Color, strokeWidth: Dp, + zIndex: Int = 0, ): List { val geoPoints: List = when (geodesic) { @@ -35,9 +38,40 @@ internal fun createMapboxLines( JsonObject().apply { addProperty(MapboxPolylineLayer.Prop.STROKE_COLOR, strokeColor.toMapboxColorString()) addProperty(MapboxPolylineLayer.Prop.STROKE_WIDTH, strokeWidth.value) + addProperty("zIndex", zIndex) addProperty("id", id) }, id, ) } } + +internal fun createMapboxPolygons( + id: String, + points: List, + geodesic: Boolean, + fillColor: Color, + zIndex: Int, +): List { + val geoPoints: List = + when (geodesic) { + true -> createInterpolatePoints(points) + false -> createLinearInterpolatePoints(points) + }.map { it.normalize() } + + return splitByMeridian(geoPoints, geodesic).mapIndexed { index, ringPoints -> + val pts = ringPoints.map { GeoPointImpl.from(it).toPoint() } + val closed = if (pts.first() != pts.last()) pts + pts.first() else pts + val fid = "polygon-$id-$index" + + Feature.fromGeometry( + MBPolygon.fromLngLats(listOf(closed)), + JsonObject().apply { + addProperty(MapboxPolygonLayer.Prop.FILL_COLOR, fillColor.toMapboxColorString()) + addProperty("zIndex", zIndex) + addProperty("id", fid) + }, + fid, + ) + } +} diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt index 21dddbbe..f552d607 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/MapboxViewStateImpl.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.map.BaseMapViewSaver -import com.mapconductor.core.map.InitState import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCameraPositionImpl import com.mapconductor.core.map.MapViewState @@ -46,9 +45,6 @@ class MapboxViewStateImpl( internal fun setController(controller: MapboxMapViewController) { this.controller = controller - _mapDesignType?.let { - controller.setMapDesignType(it) - } controller.moveCamera(_cameraPosition.value) } @@ -61,14 +57,6 @@ class MapboxViewStateImpl( durationMs: Long?, listener: MapViewState.MoveCameraCallback?, ) { - if (this.isInitialized.value != InitState.Initialized) { - _cameraPosition.value = - MapCameraPositionImpl( - position = position, - ) - listener?.onComplete() - return - } val currentPosition = this.cameraPosition.value val newPosition = currentPosition.copy( @@ -86,15 +74,13 @@ class MapboxViewStateImpl( listener: MapViewState.MoveCameraCallback?, ) { controller?.let { ctrl -> - if (this.isInitialized.value == InitState.Initialized) { - val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) - if (durationMs == null || durationMs == 0L) { - ctrl.moveCamera(dstCameraPosition, listener) - } else { - ctrl.animateCamera(dstCameraPosition, durationMs, listener) - } - return + val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) + if (durationMs == null || durationMs == 0L) { + ctrl.moveCamera(dstCameraPosition, listener) + } else { + ctrl.animateCamera(dstCameraPosition, durationMs, listener) } + return@let } _cameraPosition.value = cameraPosition listener?.onComplete() diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt index 7d11167b..520af55b 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/circle/MapboxCircleOverlayRenderer.kt @@ -35,10 +35,15 @@ class MapboxCircleOverlayRenderer( override suspend fun createCircle(state: CircleState): MapboxActualCircle? { val centerPoint = GeoPointImpl.from(state.center).toPoint() + val latitudeCorrection = + if (state.geodesic) { + cos(Math.toRadians(centerPoint.latitude())) + } else { + 1.0 + } return Feature.fromGeometry( Point.fromLngLat(centerPoint.longitude(), centerPoint.latitude()), JsonObject().apply { - val latitudeCorrection = cos(Math.toRadians(centerPoint.latitude())) addProperty(MapboxCircleLayer.Prop.LATITUDE_CORRECTION, latitudeCorrection) addProperty(MapboxCircleLayer.Prop.RADIUS, state.radiusMeters) addProperty(MapboxCircleLayer.Prop.FILL_COLOR, state.fillColor.toMapboxColorString()) @@ -56,10 +61,15 @@ class MapboxCircleOverlayRenderer( ): MapboxActualCircle? { val state = current.state val centerPoint = GeoPointImpl.from(state.center).toPoint() + val latitudeCorrection = + if (state.geodesic) { + cos(Math.toRadians(centerPoint.latitude())) + } else { + 1.0 + } return Feature.fromGeometry( Point.fromLngLat(centerPoint.longitude(), centerPoint.latitude()), JsonObject().apply { - val latitudeCorrection = cos(Math.toRadians(centerPoint.latitude())) addProperty(MapboxCircleLayer.Prop.LATITUDE_CORRECTION, latitudeCorrection) addProperty(MapboxCircleLayer.Prop.RADIUS, state.radiusMeters) addProperty(MapboxCircleLayer.Prop.FILL_COLOR, state.fillColor.toMapboxColorString()) diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt index 6e114087..c1fecad2 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerController.kt @@ -27,10 +27,13 @@ class MapboxMarkerController( renderer.dragLayer.updatePosition(GeoPointImpl.from(it.state.position)) // Restore the recomposition for the position property setDraggingState(it.state, false) + // Clear drag layer selection to avoid duplicate icon after drop + renderer.dragLayer.selected = null renderer.drawDragLayer() markerManager.registerEntity(it) renderer.redraw() } + internalSelectedMarker = null return } internalSelectedMarker = value diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt index 455e51b7..ee78aa96 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MapboxMarkerOverlayRenderer.kt @@ -51,6 +51,34 @@ class MapboxMarkerOverlayRenderer( } } + // Ensure default and custom marker images exist on the given style (used after style reload) + fun ensureStyleImages(style: com.mapbox.maps.Style) { + try { + style.addImage(Prop.DEFAULT_MARKER_ID, defaultIcon.bitmap) + } catch (_: Exception) { + // Image may already exist; ignore + } + + // Re-add custom icon images for existing markers + try { + markerManager + .allEntities() + .forEach { entity -> + entity.state.icon?.let { icon -> + val iconKey = icon.hashCode().toString() + // Recreate bitmap from icon definition + val bmp = icon.toBitmapIcon().bitmap + try { + style.addImage(iconKey, bmp) + } catch (_: Exception) { + } + } + } + } catch (_: Exception) { + // Style might be in transition; ignore quietly + } + } + fun redraw() { val entities = markerManager.allEntities() coroutine.launch { @@ -101,13 +129,15 @@ class MapboxMarkerOverlayRenderer( } data.forEach { - val iconKey = - it.state.icon - .hashCode() - .toString() - if (!iconRefCounter.contains(iconKey)) { - style.addImage(iconKey, it.bitmapIcon.bitmap) - iconRefCounter[iconKey] = 0 + it.state.icon?.let { icon -> + val iconKey = + icon + .hashCode() + .toString() + if (!iconRefCounter.contains(iconKey)) { + style.addImage(iconKey, it.bitmapIcon.bitmap) + iconRefCounter[iconKey] = 0 + } } } diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MarkerLayer.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MarkerLayer.kt index 7ce6203a..3af5e24e 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MarkerLayer.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/marker/MarkerLayer.kt @@ -32,10 +32,7 @@ open class MarkerLayer( ) } - val source: GeoJsonSource = - geoJsonSource(sourceId) { - featureCollection(FeatureCollection.fromFeatures(emptyList())) - } + val source: GeoJsonSource = geoJsonSource(sourceId) fun draw(entities: List>) { val visibleEntities = entities.filter { it.visible && it.marker != null } diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt index 5cf8046e..e145de4c 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonConductor.kt @@ -72,7 +72,8 @@ class MapboxPolygonConductor( override var clickListener: ((PolygonEvent) -> Unit)? = null - override fun find(position: GeoPoint): PolygonEntity? = null + override fun find(position: GeoPoint): PolygonEntity? = + polygonOverlay.polygonManager.find(position) as? PolygonEntity override suspend fun clear() { } @@ -97,6 +98,6 @@ private fun PolygonState.toPolylineState(): PolylineState { strokeColor = this.strokeColor, strokeWidth = this.strokeWidth, geodesic = this.geodesic, - extra = this.extra, + extra = this.zIndex, ) } diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonLayer.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonLayer.kt index f1a5829d..e1f77072 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonLayer.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonLayer.kt @@ -13,6 +13,7 @@ class MapboxPolygonLayer( ) { object Prop { const val FILL_COLOR = "fillColor" + const val Z_INDEX = "zIndex" } val source = geoJsonSource(sourceId) @@ -23,10 +24,19 @@ class MapboxPolygonLayer( literal(Prop.FILL_COLOR) }, ) + // Sort rendering within this layer by zIndex (higher draws on top) + fillSortKey( + get { + literal(Prop.Z_INDEX) + }, + ) } fun draw(entities: List>) { - val features = entities.map { it.polygon } + val features = + entities + .sortedBy { it.state.zIndex } + .map { it.polygon } source.featureCollection( FeatureCollection.fromFeatures(features.flatten()), ) diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt index cf866d36..abc7dc0c 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polygon/MapboxPolygonOverlayRenderer.kt @@ -1,10 +1,6 @@ package com.mapconductor.mapbox.polygon -import com.google.gson.JsonObject -import com.mapbox.geojson.Feature -import com.mapbox.geojson.Polygon import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer import com.mapconductor.core.polygon.PolygonEntity import com.mapconductor.core.polygon.PolygonManager @@ -12,8 +8,7 @@ import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.spherical.Spherical import com.mapconductor.mapbox.MapboxActualPolygon import com.mapconductor.mapbox.MapboxMapViewHolder -import com.mapconductor.mapbox.toMapboxColorString -import com.mapconductor.mapbox.toPoint +import com.mapconductor.mapbox.createMapboxPolygons import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -44,30 +39,14 @@ class MapboxPolygonOverlayRenderer( // layer.source.removeGeoJSONSourceFeatures(featureIds) } - override suspend fun createPolygon(state: PolygonState): MapboxActualPolygon? { - val geoPoints: List = - when (state.geodesic) { - true -> createGeodesicPolygonPoints(state.points) - false -> state.points - } - val points = geoPoints.map { GeoPointImpl.from(it).toPoint() } - // Close the polygon by adding the first point at the end if not already closed - val closedPoints = - if (points.first() != points.last()) { - points + points.first() - } else { - points - } - return listOf( - Feature.fromGeometry( - Polygon.fromLngLats(listOf(closedPoints)), - JsonObject().apply { - addProperty(MapboxPolygonLayer.Prop.FILL_COLOR, state.fillColor.toMapboxColorString()) - }, - "polygon-${state.id}", - ), + override suspend fun createPolygon(state: PolygonState): MapboxActualPolygon? = + createMapboxPolygons( + id = state.id, + points = state.points, + geodesic = state.geodesic, + fillColor = state.fillColor, + zIndex = state.zIndex, ) - } override suspend fun updatePolygonProperties( polygon: MapboxActualPolygon, @@ -77,13 +56,10 @@ class MapboxPolygonOverlayRenderer( val finger = current.fingerPrint val prevFinger = prev.fingerPrint - if (finger.points != prevFinger.points || finger.geodesic != prevFinger.geodesic) { - // If points or geodesic changed, recreate the polygon + if (finger != prevFinger) { + // Recreate features when any polygon property changes return createPolygon(current.state) } - - // For other property changes, return the existing polygon - // The layer will handle style updates return prev.polygon } diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineLayer.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineLayer.kt index 721477b0..b3f12d6e 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineLayer.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineLayer.kt @@ -16,6 +16,7 @@ class MapboxPolylineLayer( object Prop { const val STROKE_COLOR = "strokeColor" const val STROKE_WIDTH = "strokeWidth" + const val Z_INDEX = "zIndex" } val source = geoJsonSource(sourceId) @@ -33,6 +34,11 @@ class MapboxPolylineLayer( literal(Prop.STROKE_WIDTH) }, ) + lineSortKey( + get { + literal(Prop.Z_INDEX) + }, + ) } fun draw(entities: List>) { diff --git a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt index e213b5b7..02a2abd3 100644 --- a/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt +++ b/mapconductor-for-mapbox/src/main/java/com/mapconductor/mapbox/polyline/MapboxPolylineOverlayRenderer.kt @@ -25,6 +25,7 @@ class MapboxPolylineOverlayRenderer( geodesic = state.geodesic, strokeColor = state.strokeColor, strokeWidth = state.strokeWidth, + zIndex = (state.extra as? Int) ?: 0, ) override suspend fun updatePolylineProperties( @@ -39,6 +40,7 @@ class MapboxPolylineOverlayRenderer( geodesic = current.state.geodesic, strokeColor = current.state.strokeColor, strokeWidth = current.state.strokeWidth, + zIndex = (current.state.extra as? Int) ?: 0, ) } diff --git a/mapconductor-for-maplibre/.gitignore b/mapconductor-for-maplibre/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/mapconductor-for-maplibre/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/mapconductor-for-maplibre/build.gradle.kts b/mapconductor-for-maplibre/build.gradle.kts new file mode 100644 index 00000000..b2ee55d7 --- /dev/null +++ b/mapconductor-for-maplibre/build.gradle.kts @@ -0,0 +1,171 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + alias(libs.plugins.kotlin.compose) + id("org.jlleitschuh.gradle.ktlint") + id("maven-publish") + id("signing") +} + +ktlint { + android.set(true) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } +} + +android { + namespace = "com.mapconductor.maplibre" + compileSdk = project.property("compileSdk").toString().toInt() + + defaultConfig { + minSdk = project.property("minSdk").toString().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = project.property("kotlinCompilerExtensionVersion").toString() + } + + buildTypes { + debug { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro", + ) + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) + targetCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) + } + kotlinOptions { + jvmTarget = project.property("jvmTarget").toString() + } +} + +dependencies { + + compileOnly(libs.androidx.ui) + compileOnly(libs.androidx.foundation) + compileOnly(libs.androidx.ui.tooling.preview) + implementation(platform(libs.androidx.compose.bom)) // ← bomでバージョン合わせる + // Lifecycle(MapView用) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.common.java8) + + // MapLibre SDK + compileOnly(libs.maplibre.sdk) + compileOnly(libs.maplibre.annotation) + compileOnly(project(":mapconductor-core")) +} + +// Publishing configuration +val libraryGroupId = project.findProperty("libraryGroupId") as String? ?: "com.mapconductor" +val libraryArtifactId = "for-maplibre" +val libraryVersion = project.findProperty("libraryVersion") as String? ?: "1.0.0" + +// Set project version for NMCP plugin +version = libraryVersion +val libraryName = "MapConductor for Mapbox" +val libraryDescription = "Maplibre implementation for MapConductor unified mapping library" + +val javadocJar by tasks.registering(Jar::class) { + archiveClassifier.set("javadoc") +} + +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + + groupId = libraryGroupId + artifactId = libraryArtifactId + version = libraryVersion + + artifact(javadocJar.get()) + + pom { + name.set(libraryName) + description.set(libraryDescription) + url.set( + project.findProperty("libraryUrl") as String? + ?: "https://github.com/MapConductor/android-sdk", + ) + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set(project.findProperty("developerId") as String? ?: "mapconductor") + name.set(project.findProperty("developerName") as String? ?: "MapConductor Team") + email.set(project.findProperty("developerEmail") as String? ?: "dev@mapconductor.com") + } + } + + scm { + connection.set("scm:git:git://github.com/MapConductor/android-sdk.git") + developerConnection + .set("scm:git:ssh://github.com:MapConductor/android-sdk.git") + url.set( + project.findProperty("scmUrl") as String? + ?: "https://github.com/MapConductor/android-sdk.git", + ) + } + } + } + } + + repositories { + maven { + name = "GitHubPackages" + setUrl("https://maven.pkg.github.com/MapConductor/android-sdk") + credentials { + username = + project.findProperty("gpr.user") as String? ?: System.getenv("GPR_USER") + ?: System.getenv("GITHUB_ACTOR") + password = + project.findProperty("gpr.key") as String? ?: System.getenv("GPR_TOKEN") + ?: System.getenv("GITHUB_TOKEN") + } + } + + maven { + name = "OSSRH" + val releasesRepoUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" + val snapshotsRepoUrl = "https://oss.sonatype.org/content/repositories/snapshots/" + setUrl(if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl) + credentials { + username = project.findProperty("ossrh.username") as String? ?: System.getenv("OSSRH_USERNAME") + password = project.findProperty("ossrh.password") as String? ?: System.getenv("OSSRH_PASSWORD") + } + } + } + } + + if (project.hasProperty("signing.keyId")) { + signing { + sign(publishing.publications["release"]) + } + } +} diff --git a/mapconductor-for-maplibre/consumer-rules.pro b/mapconductor-for-maplibre/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/mapconductor-for-maplibre/proguard-rules.pro b/mapconductor-for-maplibre/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/mapconductor-for-maplibre/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/mapconductor-for-maplibre/src/main/AndroidManifest.xml b/mapconductor-for-maplibre/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e1000761 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/GeoPoint.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/GeoPoint.kt new file mode 100644 index 00000000..7d133925 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/GeoPoint.kt @@ -0,0 +1,20 @@ +package com.mapconductor.maplibre + +import com.mapconductor.core.features.GeoPointImpl +import org.maplibre.android.geometry.LatLng +import org.maplibre.geojson.Point + +fun GeoPointImpl.toLatLng(): LatLng = LatLng(this.latitude, this.longitude, this.altitude) + +fun GeoPointImpl.Companion.from(latLng: LatLng) = GeoPointImpl(latLng.latitude, latLng.longitude, latLng.altitude) + +fun LatLng.toGeoPoint() = GeoPointImpl(latitude, longitude, altitude) + +fun GeoPointImpl.toPoint(): Point = Point.fromLngLat(longitude, latitude) + +fun GeoPointImpl.Companion.from(point: Point) = + GeoPointImpl( + latitude = point.latitude(), + longitude = point.longitude(), + altitude = point.altitude(), + ) diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt new file mode 100644 index 00000000..7798416c --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapCameraPosition.kt @@ -0,0 +1,42 @@ +package com.mapconductor.maplibre + +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionImpl +import org.maplibre.android.camera.CameraPosition +import kotlin.math.max + +internal const val MAPLIBRE_CAMERA_ZOOM_ADJUST_VALUE = 1.0 + +fun MapCameraPositionImpl.toCameraPosition(): CameraPosition = + CameraPosition + .Builder() + .target(GeoPointImpl.from(position).toLatLng()) + .zoom(max(zoom - MAPLIBRE_CAMERA_ZOOM_ADJUST_VALUE, 0.0)) + .tilt(tilt) + .bearing(bearing) + // TODO: +// .padding(paddings?.toEdgeInsects()) + .build() + +fun MapCameraPositionImpl.Companion.from(cameraPosition: MapCameraPosition) = + when (cameraPosition) { + is MapCameraPositionImpl -> cameraPosition + else -> + MapCameraPositionImpl( + position = GeoPointImpl.from(cameraPosition.position), + zoom = cameraPosition.zoom, + bearing = cameraPosition.bearing, + tilt = cameraPosition.tilt, + visibleRegion = cameraPosition.visibleRegion, + ) + } + +fun CameraPosition.toMapCameraPosition() = + MapCameraPositionImpl( + position = target?.toGeoPoint() ?: GeoPointImpl.fromLongLat(0.0, 0.0), + zoom = (zoom) + MAPLIBRE_CAMERA_ZOOM_ADJUST_VALUE, + bearing = bearing ?: 0.0, + tilt = tilt ?: 0.0, + visibleRegion = null, + ) diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreDesignType.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreDesignType.kt new file mode 100644 index 00000000..79c78a51 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreDesignType.kt @@ -0,0 +1,22 @@ +package com.mapconductor.maplibre + +import com.mapconductor.core.map.MapDesignType + +interface MapLibreMapDesignType : MapDesignType { + val styleJsonURL: String +} + +data class MapLibreMapDesign( + override val id: String, + override val styleJsonURL: String, +) : MapLibreMapDesignType { + override fun getValue(): String = "mapDesign_id=$id,style=$styleJsonURL" + + companion object { + val DemoTiles = + MapLibreMapDesign( + id = "demo", + styleJsonURL = "https://demotiles.maplibre.org/style.json", + ) + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt new file mode 100644 index 00000000..600cdc5c --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapView.kt @@ -0,0 +1,258 @@ +package com.mapconductor.maplibre + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.mapconductor.core.circle.CircleManagerImpl +import com.mapconductor.core.circle.OnCircleEventHandler +import com.mapconductor.core.map.MapViewBase +import com.mapconductor.core.map.OnMapEventHandler +import com.mapconductor.core.map.OnMapLoadedHandler +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerRenderingStrategy +import com.mapconductor.core.marker.OnMarkerEventHandler +import com.mapconductor.core.polygon.OnPolygonEventHandler +import com.mapconductor.core.polygon.PolygonManagerImpl +import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.PolylineManagerImpl +import com.mapconductor.maplibre.circle.MapLibreCircleController +import com.mapconductor.maplibre.circle.MapLibreCircleLayer +import com.mapconductor.maplibre.circle.MapLibreCircleOverlayRenderer +import com.mapconductor.maplibre.marker.MapLibreMarkerController +import com.mapconductor.maplibre.marker.MapLibreMarkerOverlayRenderer +import com.mapconductor.maplibre.marker.MarkerDragLayer +import com.mapconductor.maplibre.marker.MarkerLayer +import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor +import com.mapconductor.maplibre.polygon.MapLibrePolygonLayer +import com.mapconductor.maplibre.polygon.MapLibrePolygonOverlayRenderer +import com.mapconductor.maplibre.polyline.MapLibrePolylineController +import com.mapconductor.maplibre.polyline.MapLibrePolylineLayer +import com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer +import org.maplibre.android.MapLibre +import org.maplibre.android.maps.MapLibreMapOptions +import org.maplibre.android.maps.MapView +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +fun MapLibreMapView( + state: MapLibreViewStateImpl, + modifier: Modifier = Modifier, + markerRenderingStrategy: MarkerRenderingStrategy? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMarkerClick: OnMarkerEventHandler? = null, + onMarkerDragStart: OnMarkerEventHandler? = null, + onMarkerDrag: OnMarkerEventHandler? = null, + onMarkerDragEnd: OnMarkerEventHandler? = null, + onMarkerAnimateStart: OnMarkerEventHandler? = null, + onMarkerAnimateEnd: OnMarkerEventHandler? = null, + onPolylineClick: OnPolylineEventHandler? = null, + onCircleClick: OnCircleEventHandler? = null, + onPolygonClick: OnPolygonEventHandler? = null, + content: (@Composable MapLibreMapViewScope.() -> Unit)? = null, +) { + val context = LocalContext.current + val scope = remember { MapLibreMapViewScope() } + val registry = remember { scope.buildRegistry() } + + MapViewBase( + state = state, + modifier = modifier, + viewProvider = { + val cameraPosition = + state.cameraPosition.value.toCameraPosition() + val mapInitOptions = + MapLibreMapOptions + .createFromAttributes(context) + .camera(cameraPosition) + .textureMode(true) + // Don't set style here - it will be set in holderProvider + + MapView(context, mapInitOptions) + }, + scope = scope, + registry = registry, + onMapLoaded = onMapLoaded, + holderProvider = { mapView -> + suspendCancellableCoroutine { continuation -> + mapView.getMapAsync { map -> + // Set style and wait for it to load completely + map.setStyle(state.mapDesignType.styleJsonURL) { loadedStyle -> + // Resume only after style is fully loaded + continuation.resume(MapLibreMapViewHolderImpl(mapView, map)) {} + } + } + } + }, + controllerProvider = { holder -> + val markerController = + getMarkerController( + holder = holder, + renderingStrategy = markerRenderingStrategy, + ) + val polylineController = + getPolylineController( + holder = holder, + ) + val polygonController = + getPolygonController( + holder = holder, + ) + val circleController = getCircleController(holder) + + // Defer initial camera update until controller is created and view is laid out + + MapLibreViewControllerImpl( + holder = holder, + markerController = markerController, + polylineController = polylineController, + polygonController = polygonController, + circleController = circleController, + ).also { controller -> + // Store controller reference in holder + (holder as? MapLibreMapViewHolderImpl)?.setController(controller) + controller.setCameraMoveListener(state::onCameraChange) + controller.setMapClickListener(onMapClick) + controller.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + controller.setOnMarkerDragStart(onMarkerDragStart) + controller.setOnMarkerDrag(onMarkerDrag) + controller.setOnMarkerDragEnd(onMarkerDragEnd) + controller.setOnMarkerAnimateEnd(onMarkerAnimateEnd) + controller.setOnMarkerAnimateStart(onMarkerAnimateStart) + controller.setOnMarkerClickListener(onMarkerClick) + controller.setOnPolylineClickListener(onPolylineClick) + controller.setOnCircleClickListener(onCircleClick) + controller.setOnPolygonClickListener(onPolygonClick) + state.setController(controller) + // Post an initial camera update after layout to compute visibleRegion correctly + holder.mapView.post { controller.sendInitialCameraUpdate() } + } + }, + sdkInitialize = { + MapLibre.getInstance(context) + true + }, + // Pass content if it needs to be rendered within the overlay providers in MapViewBase, + // or handle it here if it's specific to GoogleMapsView structure before calling MapViewBase. + // For now, assuming content relates to overlay definitions. + content = content, // This might need adjustment based on how overlays are handled + ) +} + +internal fun getMarkerController( + holder: MapLibreMapViewHolder, + renderingStrategy: MarkerRenderingStrategy? = null, +): MapLibreMarkerController { + val manager = renderingStrategy?.markerManager ?: MarkerManager.defaultManager() + val markerLayer: MarkerLayer = + MarkerLayer( + sourceId = "markers-source", + layerId = "markers-layer", + ) + val dragLayer: MarkerDragLayer = + MarkerDragLayer( + sourceId = "marker-drag-source", + layerId = "marker-drag-layer", + ) + val renderer = + MapLibreMarkerOverlayRenderer( + holder = holder, + markerLayer = markerLayer, + dragLayer = dragLayer, + markerManager = manager, + ) + + val controller = + MapLibreMarkerController( + renderer = renderer, + renderingStrategy = renderingStrategy, + ) + return controller +} + +internal fun getPolylineController(holder: MapLibreMapViewHolder): MapLibrePolylineController { + val polylineLayer: MapLibrePolylineLayer = + MapLibrePolylineLayer( + sourceId = "polyline-source", + layerId = "polyline-layer", + ) + val polylineManager = PolylineManagerImpl() + + val renderer = + MapLibrePolylineOverlayRenderer( + layer = polylineLayer, + polylineManager = polylineManager, + holder = holder, + ) + + val controller = + MapLibrePolylineController( + renderer = renderer, + ) + return controller +} + +internal fun getPolygonController(holder: MapLibreMapViewHolder): MapLibrePolygonConductor { + val polylineLayer = + MapLibrePolylineLayer( + sourceId = "polygon-outline-source", + layerId = "polygon-outline-layer", + ) + val polylineManager = PolylineManagerImpl() + val polylineOverlayRenderer = + MapLibrePolylineOverlayRenderer( + layer = polylineLayer, + polylineManager = polylineManager, + holder = holder, + ) + + val polygonManager = PolygonManagerImpl() + val polygonLayer = + MapLibrePolygonLayer( + sourceId = "polygon-fill-source", + layerId = "polygon-fill-layer", + ) + val polygonOverlayRenderer = + MapLibrePolygonOverlayRenderer( + layer = polygonLayer, + polygonManager = polygonManager, + holder = holder, + ) + + return MapLibrePolygonConductor( + polygonOverlay = polygonOverlayRenderer, + polylineOverlay = polylineOverlayRenderer, + ) +} + +internal fun getCircleController(holder: MapLibreMapViewHolder): MapLibreCircleController { + val circleLayer = + MapLibreCircleLayer( + sourceId = "circle-source", + layerId = "circle-layer", + ) + val circleManager = CircleManagerImpl() + val renderer = + MapLibreCircleOverlayRenderer( + layer = circleLayer, + circleManager = circleManager, + holder = holder, + ) + return MapLibreCircleController( + renderer = renderer, + circleManager = circleManager, + ) +} + +internal fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapViewHolderImpl.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapViewHolderImpl.kt new file mode 100644 index 00000000..f2bdcb22 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapViewHolderImpl.kt @@ -0,0 +1,40 @@ +package com.mapconductor.maplibre + +import androidx.compose.ui.geometry.Offset +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.map.MapViewHolder +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import android.graphics.PointF + +interface MapLibreMapViewHolder : MapViewHolder { + fun getController(): MapLibreViewControllerImpl? +} + +internal class MapLibreMapViewHolderImpl( + override val mapView: MapView, + override val map: MapLibreMap, +) : MapLibreMapViewHolder { + private var controller: MapLibreViewControllerImpl? = null + + fun setController(ctrl: MapLibreViewControllerImpl) { + controller = ctrl + } + + override fun getController(): MapLibreViewControllerImpl? = controller + + override fun toScreenOffset(position: GeoPoint): Offset? { + val pixel = + map.projection.toScreenLocation(GeoPointImpl.from(position).toLatLng()) + return Offset( + x = pixel.x, + y = pixel.y, + ) + } + + override fun fromScreenOffsetSync(offset: Offset): GeoPointImpl? = + map.projection.fromScreenLocation(PointF(offset.x, offset.y)).toGeoPoint() + + override suspend fun fromScreenOffset(offset: Offset): GeoPointImpl? = fromScreenOffsetSync(offset) +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapViewScope.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapViewScope.kt new file mode 100644 index 00000000..a8d2300a --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreMapViewScope.kt @@ -0,0 +1,7 @@ +package com.mapconductor.maplibre + +import com.mapconductor.core.MapViewScope + +class MapLibreMapViewScope : MapViewScope() { + // 他の地図SDKにはない機能は、ここで定義する +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibrePolyUtils.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibrePolyUtils.kt new file mode 100644 index 00000000..1cf1afcb --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibrePolyUtils.kt @@ -0,0 +1,87 @@ +package com.mapconductor.maplibre + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import com.google.gson.JsonObject +import com.mapconductor.core.createInterpolatePoints +import com.mapconductor.core.createLinearInterpolatePoints +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.features.normalize +import com.mapconductor.core.splitByMeridian +import com.mapconductor.maplibre.polygon.MapLibrePolygonLayer +import com.mapconductor.maplibre.polyline.MapLibrePolylineLayer +import org.maplibre.geojson.Feature +import org.maplibre.geojson.LineString +import org.maplibre.geojson.Polygon as GLPolygon + +internal fun createMapLibreLines( + id: String, + points: List, + geodesic: Boolean, + strokeColor: Color, + strokeWidth: Dp, + zIndex: Int = 0, +): List { + val geoPoints: List = + when (geodesic) { + true -> createInterpolatePoints(points) + false -> createLinearInterpolatePoints(points) + }.map { it.normalize() } + + return splitByMeridian(geoPoints, geodesic).mapIndexed { index, linePoints -> + val pts = linePoints.map { GeoPointImpl.from(it).toPoint() } + val fid = "polyline-$id-$index" + + Feature.fromGeometry( + LineString.fromLngLats(pts), + JsonObject().apply { + addProperty(MapLibrePolylineLayer.Prop.STROKE_COLOR, strokeColor.toMapLibreColorString()) + addProperty(MapLibrePolylineLayer.Prop.STROKE_WIDTH, strokeWidth.value) + addProperty("zIndex", zIndex) + addProperty("id", fid) + }, + fid, + ) + } +} + +fun Color.toMapLibreColorString(): String { + val red = (this.red * 255).toInt() + val green = (this.green * 255).toInt() + val blue = (this.blue * 255).toInt() + val alpha = this.alpha + return "rgba($red, $green, $blue, $alpha)" +} + +internal fun createMapLibrePolygons( + id: String, + points: List, + geodesic: Boolean, + fillColor: Color, + zIndex: Int, +): List { + val geoPoints: List = + when (geodesic) { + true -> createInterpolatePoints(points) + false -> createLinearInterpolatePoints(points) + }.map { it.normalize() } + + // Split to avoid antimeridian artifacts and produce multiple polygons if needed + return splitByMeridian(geoPoints, geodesic).mapIndexed { index, ringPoints -> + val pts = ringPoints.map { GeoPointImpl.from(it).toPoint() } + // Ensure closed ring + val closed = if (pts.first() != pts.last()) pts + pts.first() else pts + val fid = "polygon-$id-$index" + + Feature.fromGeometry( + GLPolygon.fromLngLats(listOf(closed)), + JsonObject().apply { + addProperty(MapLibrePolygonLayer.Prop.FILL_COLOR, fillColor.toMapLibreColorString()) + addProperty("zIndex", zIndex) + addProperty("id", fid) + }, + fid, + ) + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreTypeAlias.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreTypeAlias.kt new file mode 100644 index 00000000..b6783fa1 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreTypeAlias.kt @@ -0,0 +1,8 @@ +package com.mapconductor.maplibre + +import org.maplibre.geojson.Feature + +typealias MapLibreActualMarker = Feature +typealias MapLibreActualPolyline = List +typealias MapLibreActualCircle = Feature +typealias MapLibreActualPolygon = List diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt new file mode 100644 index 00000000..89225c72 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewController.kt @@ -0,0 +1,18 @@ +package com.mapconductor.maplibre + +import com.mapconductor.core.circle.CircleCapable +import com.mapconductor.core.controller.MapViewController +import com.mapconductor.core.marker.MarkerCapable +import com.mapconductor.core.polygon.PolygonCapable +import com.mapconductor.core.polyline.PolylineCapable + +interface MapLibreViewController : + MapViewController, + MarkerCapable, + PolylineCapable, + PolygonCapable, + CircleCapable { + fun setMapDesignType(value: MapLibreMapDesignType) + + fun setMapDesignTypeChangeListener(listener: MapLibreDesignTypeChangeHandler) +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerImpl.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerImpl.kt new file mode 100644 index 00000000..02ea7922 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewControllerImpl.kt @@ -0,0 +1,594 @@ +package com.mapconductor.maplibre + +import androidx.compose.ui.geometry.Offset +import com.mapconductor.core.circle.CircleEvent +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.circle.OnCircleEventHandler +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionImpl +import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.map.VisibleRegion +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.marker.OnMarkerEventHandler +import com.mapconductor.core.polygon.OnPolygonEventHandler +import com.mapconductor.core.polygon.PolygonEvent +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.OnPolylineEventHandler +import com.mapconductor.core.polyline.PolylineEvent +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.maplibre.circle.MapLibreCircleController +import com.mapconductor.maplibre.marker.MapLibreMarkerController +import com.mapconductor.maplibre.polygon.MapLibrePolygonConductor +import com.mapconductor.maplibre.polyline.MapLibrePolylineController +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.gestures.MoveGestureDetector +import org.maplibre.android.maps.MapLibreMap +import android.graphics.PointF +import android.view.MotionEvent +import android.view.View +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +typealias MapLibreDesignTypeChangeHandler = (MapLibreMapDesignType) -> Unit + +class MapLibreViewControllerImpl( + override val holder: MapLibreMapViewHolder, + private val markerController: MapLibreMarkerController, + private val polylineController: MapLibrePolylineController, + private val polygonController: MapLibrePolygonConductor, + private val circleController: MapLibreCircleController, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), + val backCoroutine: CoroutineScope = CoroutineScope(Dispatchers.Default), +) : BaseMapViewController(), + MapLibreViewController, + MapLibreMap.OnMapClickListener, + MapLibreMap.OnMapLongClickListener, + MapLibreMap.OnMoveListener, + MapLibreMap.OnCameraMoveListener, + MapLibreMap.OnCameraIdleListener { + // Keep reference to the style instance to avoid getting a new one + private var styleInstance: org.maplibre.android.maps.Style? = null + private var wasScrollEnabledBeforeDrag: Boolean? = null + private var dragTouchInterceptor: View.OnTouchListener? = null + private val polygonZLayers: MutableSet = mutableSetOf() + + private fun setupStyle(style: org.maplibre.android.maps.Style) { + // Store the style instance for future use + styleInstance = style + + // Log existing layers + val topLayerId = style.layers.lastOrNull()?.id + + // Ensure default icon image exists on this style + markerController.renderer.ensureDefaultIcon(style) + + // Polygon sources only (layers will be added per zIndex) + style.addSource(polygonController.polylineOverlay.layer.source) + style.addSource(polygonController.polygonOverlay.layer.source) + + // Circle cts as anchor above polygons + style.addSource(circleController.renderer.layer.source) + style.addLayer(circleController.renderer.layer.layer) + + // Polyline (general) acts as anchor above circles + style.addSource(polylineController.renderer.layer.source) + style.addLayer(polylineController.renderer.layer.layer) + + // Add z-indexed polygon layers below general polylines + ensurePolygonZLayers(style) + + // Marker - add source and layer at the top + style.addSource(markerController.renderer.markerLayer.source) + try { + style.addLayerAbove( + markerController.renderer.markerLayer.layer, + polylineController.renderer.layer.layerId, + ) + } catch (_: Exception) { + // Fallback when anchor layer is not present yet + style.addLayer(markerController.renderer.markerLayer.layer) + } + markerController.renderer.redraw() + + // Drag layer above marker layer + style.addSource(markerController.renderer.dragLayer.source) + try { + style.addLayerAbove( + markerController.renderer.dragLayer.layer, + markerController.renderer.markerLayer.layerId, + ) + } catch (_: Exception) { + style.addLayer(markerController.renderer.dragLayer.layer) + } + markerController.renderer.redraw() + + // Force redraw after adding layers + markerController.renderer.redraw() + polylineController.renderer.redraw() +// polygonController.polygonOverlay.onPostProcess() + } + + init { + // Style should already be loaded by holderProvider + val style = holder.map.style + if (style != null) { + setupStyle(style) + // Trigger initial camera update after style is ready + sendInitialCameraUpdate() + } + + setupListeners() + registerController(markerController) + registerController(polylineController) + registerController(polygonController) + registerController(circleController) + } + + fun setupListeners() { + holder.map.addOnCameraMoveListener(this) + holder.map.addOnCameraIdleListener(this) + + holder.map.removeOnMapClickListener(this) + holder.map.addOnMapClickListener(this) + + holder.map.removeOnMapLongClickListener(this) + holder.map.addOnMapLongClickListener(this) + + holder.map.removeOnMoveListener(this) + holder.map.addOnMoveListener(this) + } + + override suspend fun clearOverlays() { + markerController.clear() + polylineController.clear() + polygonController.clear() + circleController.clear() + } + + override fun moveCamera( + position: MapCameraPositionImpl, + listener: MapViewState.MoveCameraCallback?, + ) { + coroutine.launch { + val cameraUpdate = + CameraUpdateFactory + .newCameraPosition(position.toCameraPosition()) + holder.map.moveCamera(cameraUpdate) + listener?.onComplete() + } + } + + override fun animateCamera( + position: MapCameraPositionImpl, + duration: Long, + listener: MapViewState.MoveCameraCallback?, + ) { + coroutine.launch { + val cameraUpdate = + CameraUpdateFactory + .newCameraPosition(position.toCameraPosition()) + holder.map.animateCamera(cameraUpdate, duration.toInt()) + listener?.onComplete() + } + } + + private var mapDesignType: MapLibreMapDesignType = MapLibreMapDesign.DemoTiles + + private var mapDesignTypeChangeListener: MapLibreDesignTypeChangeHandler? = null + + override fun setMapDesignType(value: MapLibreMapDesignType) { + coroutine.launch { + holder.map.setStyle(value.styleJsonURL) { newStyle -> + android.util.Log.d("MapLibre", "Style changed to ${value.styleJsonURL}") + setupStyle(newStyle) + } + } + } + + // Provide access to the style instance + fun getStyleInstance(): org.maplibre.android.maps.Style? = styleInstance + + override fun setMapDesignTypeChangeListener(listener: MapLibreDesignTypeChangeHandler) { + mapDesignTypeChangeListener = listener + // Don't call listener immediately - it may trigger style reload + // listener(mapDesignType) + } + + override suspend fun compositionMarkers(data: List) = markerController.add(data) + + override suspend fun updateMarker(state: MarkerState) = markerController.update(state) + + override suspend fun compositionPolylines(data: List) = polylineController.add(data) + + override suspend fun updatePolyline(state: PolylineState) = polylineController.update(state) + + override suspend fun compositionPolygons(data: List) { + polygonController.add(data) + getStyleInstance()?.let { ensurePolygonZLayers(it) } + } + + override suspend fun updatePolygon(state: PolygonState) { + polygonController.update(state) + getStyleInstance()?.let { ensurePolygonZLayers(it) } + } + + override suspend fun compositionCircles(data: List) = circleController.add(data) + + override suspend fun updateCircle(state: CircleState) = circleController.update(state) + + override fun setOnMarkerDragStart(listener: OnMarkerEventHandler?) { + markerController.dragStartListener = listener + } + + override fun setOnMarkerDrag(listener: OnMarkerEventHandler?) { + markerController.dragListener = listener + } + + override fun setOnMarkerDragEnd(listener: OnMarkerEventHandler?) { + markerController.dragEndListener = listener + } + + override fun setOnPolylineClickListener(listener: OnPolylineEventHandler?) { + polylineController.clickListener = listener + } + + override fun setOnPolygonClickListener(listener: OnPolygonEventHandler?) { + polygonController.clickListener = listener + } + + override fun setOnCircleClickListener(listener: OnCircleEventHandler?) { + this.circleController.clickListener = listener + } + + override fun setOnMarkerAnimateStart(listener: OnMarkerEventHandler?) { + markerController.renderer.animateStartListener = listener + } + + override fun setOnMarkerAnimateEnd(listener: OnMarkerEventHandler?) { + markerController.renderer.animateEndListener = listener + } + + override fun setOnMarkerClickListener(listener: OnMarkerEventHandler?) { + markerController.clickListener = listener + } + + override fun hasMarker(state: MarkerState): Boolean = this.markerController.markerManager.hasEntity(state.id) + + override fun hasPolyline(state: PolylineState): Boolean = + this.polylineController.polylineManager + .hasEntity(state.id) + + override fun hasPolygon(state: PolygonState): Boolean = + this.polygonController.polygonOverlay.polygonManager + .hasEntity(state.id) + + override fun hasCircle(state: CircleState): Boolean = this.circleController.circleManager.hasEntity(state.id) + + override fun onMapClick(point: LatLng): Boolean { + val touchPosition = point.toGeoPoint() + + markerController.find(touchPosition)?.let { entity -> + markerController.clickListener?.invoke(entity.state) + return true + } + + circleController.find(touchPosition)?.let { entity -> + val event = CircleEvent(state = entity.state, clicked = touchPosition) + circleController.clickListener?.invoke(event) + return true + } + + polylineController.findWithClosestPoint(touchPosition)?.let { hitResult -> + val event = + PolylineEvent( + state = hitResult.entity.state, + clicked = hitResult.closestPoint, + ) + coroutine.launch { + polylineController.clickListener?.invoke(event) + } + return true + } + + polygonController.find(touchPosition)?.let { polygonEntity -> + val event = + PolygonEvent( + state = polygonEntity.state, + clicked = touchPosition, + ) + polygonController.clickListener?.invoke(event) + return true + } + + mapClickCallback?.invoke(touchPosition) + return true + } + + override fun onMapLongClick(point: LatLng): Boolean { + val touchPosition = point.toGeoPoint() + markerController.find(touchPosition)?.let { entity -> + if (entity.state.draggable) { + // Disable map scroll while dragging a marker + try { + val ui = holder.map.uiSettings + wasScrollEnabledBeforeDrag = ui.isScrollGesturesEnabled + ui.isScrollGesturesEnabled = false + } catch (e: Exception) { + android.util.Log.w("MapLibre", "Failed to disable scroll gestures: ${e.message}") + } + markerController.selectedMarker = entity + markerController.markerManager.removeEntity(entity.state.id) + markerController.dragStartListener?.invoke(entity.state) + // Intercept touch to move marker without moving the map + installDragTouchInterceptor() + return true + } + } + + mapLongClickCallback?.invoke(touchPosition) + return true + } + + override fun onMoveBegin(detector: MoveGestureDetector) { + // Do nothing here + } + + override fun onMove(detector: MoveGestureDetector) { + markerController.selectedMarker?.let { entity -> + + val screenCoordinate = + Offset( + detector.focalPoint.x, + detector.focalPoint.y, + ) + + holder.fromScreenOffsetSync(screenCoordinate)?.let { + entity.state.position = it + markerController.renderer.dragLayer.updatePosition(it) + markerController.renderer.drawDragLayer() + } + + markerController.dragListener?.invoke(entity.state) + } + } + + override fun onMoveEnd(detector: MoveGestureDetector) { + markerController.selectedMarker?.let { entity -> + val screenCoordinate = + PointF( + detector.focalPoint.x, + detector.focalPoint.y, + ) + val point = holder.map.projection.fromScreenLocation(screenCoordinate) + markerController.renderer.dragLayer.updatePosition(point.toGeoPoint()) + markerController.selectedMarker = null + markerController.dragEndListener?.invoke(entity.state) + // Re-enable map scroll after dragging finishes + try { + val ui = holder.map.uiSettings + ui.isScrollGesturesEnabled = wasScrollEnabledBeforeDrag == true + } catch (e: Exception) { + android.util.Log.w("MapLibre", "Failed to re-enable scroll gestures: ${e.message}") + } finally { + wasScrollEnabledBeforeDrag = null + } + removeDragTouchInterceptor() + } + } + + private fun installDragTouchInterceptor() { + if (dragTouchInterceptor != null) return + val view = holder.mapView + dragTouchInterceptor = + View.OnTouchListener { _, event -> + val selected = markerController.selectedMarker ?: return@OnTouchListener false + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + val pos = holder.fromScreenOffsetSync(Offset(event.x, event.y)) + if (pos != null) { + selected.state.position = pos + markerController.renderer.dragLayer.updatePosition(pos) + markerController.renderer.drawDragLayer() + markerController.dragListener?.invoke(selected.state) + } + true // consume to prevent map panning + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val point = holder.map.projection.fromScreenLocation(PointF(event.x, event.y)) + markerController.renderer.dragLayer.updatePosition(point.toGeoPoint()) + markerController.selectedMarker = null + markerController.dragEndListener?.invoke(selected.state) + try { + val ui = holder.map.uiSettings + ui.isScrollGesturesEnabled = wasScrollEnabledBeforeDrag == true + } catch (e: Exception) { + android.util.Log.w("MapLibre", "Failed to re-enable scroll gestures: ${e.message}") + } finally { + wasScrollEnabledBeforeDrag = null + } + removeDragTouchInterceptor() + true + } + else -> false + } + } + view.setOnTouchListener(dragTouchInterceptor) + } + + private fun removeDragTouchInterceptor() { + val view = holder.mapView + view.setOnTouchListener(null) + dragTouchInterceptor = null + } + + override fun onCameraMove() { + coroutine.launch { + getMapCameraPosition(holder.map.cameraPosition.toMapCameraPosition())?.let { mapCameraPosition -> + backCoroutine.launch { + notifyMapCameraPosition(mapCameraPosition) + } + } + } + } + + override fun onCameraIdle() { + coroutine.launch { + getMapCameraPosition(holder.map.cameraPosition.toMapCameraPosition())?.let { mapCameraPosition -> + backCoroutine.launch { + notifyMapCameraPosition(mapCameraPosition) + } + } + } + } + + private fun getMapCameraPosition(camera: MapCameraPosition): MapCameraPositionImpl? { + val mapWidth = holder.mapView.width.toFloat() + val mapHeight = holder.mapView.height.toFloat() + val nearLeft = + holder.fromScreenOffsetSync( + Offset(0.0f, mapHeight), + ) ?: return null + val nearRight = + holder.fromScreenOffsetSync( + Offset(mapWidth, mapHeight), + ) ?: return null + val farLeft = + holder.fromScreenOffsetSync( + Offset(0.0f, 0.0f), + ) ?: return null + val farRight = + holder.fromScreenOffsetSync( + Offset(mapWidth, 0.0f), + ) ?: return null + + val bounds = GeoRectBounds() + bounds.extend(nearLeft) + bounds.extend(nearRight) + bounds.extend(farLeft) + bounds.extend(farRight) + val visibleRegion = + VisibleRegion( + bounds = bounds, + nearLeft = nearLeft, + nearRight = nearRight, + farLeft = farLeft, + farRight = farRight, + ) + val mapCameraPosition = + MapCameraPositionImpl.from(camera).copy( + visibleRegion = visibleRegion, + ) + return mapCameraPosition + } + + private fun ensurePolygonZLayers(style: org.maplibre.android.maps.Style) { + val fillSourceId = polygonController.polygonOverlay.layer.sourceId + val outlineSourceId = polygonController.polylineOverlay.layer.sourceId + val anchorId = polylineController.renderer.layer.layerId + + val zSet = + polygonController.polygonOverlay.polygonManager + .allEntities() + .map { it.state.zIndex } + .toSet() + + // Remove stale z-indexed layers we previously created + val toRemove = polygonZLayers.subtract(zSet) + toRemove.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + try { + style.removeLayer(outlineId) + } catch (_: Exception) { + } + try { + style.removeLayer(fillId) + } catch (_: Exception) { + } + } + + val zList = zSet.toList().sorted() + zList.forEach { z -> + val fillId = "polygon-fill-layer-$z" + val outlineId = "polygon-outline-layer-$z" + + if (style.getLayer(fillId) == null) { + val fill = + org.maplibre.android.style.layers.FillLayer(fillId, fillSourceId).apply { + setFilter( + org.maplibre.android.style.expressions.Expression.eq( + org.maplibre.android.style.expressions.Expression + .get("zIndex"), + org.maplibre.android.style.expressions.Expression + .literal(z), + ), + ) + setProperties( + org.maplibre.android.style.layers.PropertyFactory.fillColor( + org.maplibre.android.style.expressions.Expression + .get("fillColor"), + ), + ) + } + try { + style.addLayerBelow(fill, anchorId) + } catch (_: Exception) { + style.addLayer(fill) + } + } + + if (style.getLayer(outlineId) == null) { + val outline = + org.maplibre.android.style.layers.LineLayer(outlineId, outlineSourceId).apply { + setFilter( + org.maplibre.android.style.expressions.Expression.eq( + org.maplibre.android.style.expressions.Expression + .get("zIndex"), + org.maplibre.android.style.expressions.Expression + .literal(z), + ), + ) + setProperties( + org.maplibre.android.style.layers.PropertyFactory + .lineJoin(org.maplibre.android.style.layers.Property.LINE_JOIN_ROUND), + org.maplibre.android.style.layers.PropertyFactory + .lineCap(org.maplibre.android.style.layers.Property.LINE_CAP_ROUND), + org.maplibre.android.style.layers.PropertyFactory.lineColor( + org.maplibre.android.style.expressions.Expression + .get("strokeColor"), + ), + org.maplibre.android.style.layers.PropertyFactory.lineWidth( + org.maplibre.android.style.expressions.Expression + .get("strokeWidth"), + ), + ) + } + try { + style.addLayerAbove(outline, fillId) + } catch (_: Exception) { + style.addLayer(outline) + } + } + } + polygonZLayers.clear() + polygonZLayers.addAll(zSet) + } + + // Trigger an initial camera update after the view and style are ready + fun sendInitialCameraUpdate() { + coroutine.launch { + val mapWidth = holder.mapView.width.toFloat() + val mapHeight = holder.mapView.height.toFloat() + if (mapWidth <= 0 || mapHeight <= 0) return@launch + + val camera = holder.map.cameraPosition.toMapCameraPosition() + getMapCameraPosition(camera)?.let { mapCameraPosition -> + backCoroutine.launch { notifyMapCameraPosition(mapCameraPosition) } + } + } + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt new file mode 100644 index 00000000..65d4d549 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/MapLibreViewStateImpl.kt @@ -0,0 +1,148 @@ +package com.mapconductor.maplibre + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.map.BaseMapViewSaver +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionImpl +import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.map.MapViewStateImpl +import java.util.UUID +import android.os.Bundle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +interface MapLibreViewState : MapViewState + +class MapLibreViewStateImpl( + mapDesignType: MapLibreMapDesignType, + override val id: String, + override val initCameraPosition: MapCameraPositionImpl = MapCameraPositionImpl.Default, +) : MapViewStateImpl(), + MapLibreViewState { + private var controller: MapLibreViewController? = null + + // Camera center position + private val _cameraPosition = MutableStateFlow(initCameraPosition) + override val cameraPosition: StateFlow = _cameraPosition.asStateFlow() + + private var _mapDesignType: MapLibreMapDesignType = mapDesignType + + override var mapDesignType: MapLibreMapDesignType + set(value) { + value?.let { + _mapDesignType = it + this.controller?.setMapDesignType(it) + } + } + get() = _mapDesignType + + internal fun setController(controller: MapLibreViewController) { + this.controller = controller + controller.moveCamera(_cameraPosition.value) + } + + internal fun onMapDesignTypeChange(value: MapLibreMapDesignType) { + _mapDesignType = value + } + + override fun moveCameraTo( + position: GeoPointImpl, + durationMs: Long?, + listener: MapViewState.MoveCameraCallback?, + ) { + val currentPosition = this.cameraPosition.value + val newPosition = + currentPosition.copy( + position = position, + ) + this.moveCameraTo(newPosition, durationMs, listener) + } + + @Suppress("UNCHECKED_CAST") + override fun getMapViewHolder(): MapLibreMapViewHolder? = controller?.holder as? MapLibreMapViewHolder + + override fun moveCameraTo( + cameraPosition: MapCameraPositionImpl, + durationMs: Long?, + listener: MapViewState.MoveCameraCallback?, + ) { + controller?.let { ctrl -> + val dstCameraPosition = MapCameraPositionImpl.from(cameraPosition) + if (durationMs == null || durationMs == 0L) { + ctrl.moveCamera(dstCameraPosition, listener) + } else { + ctrl.animateCamera(dstCameraPosition, durationMs, listener) + } + return@let + } + _cameraPosition.value = cameraPosition + listener?.onComplete() + } + + internal fun onCameraChange(cameraPosition: MapCameraPositionImpl) { + _cameraPosition.value = cameraPosition + } +} + +class MapLibreMapViewSaver : BaseMapViewSaver() { + override fun extractCameraPosition(state: MapLibreViewStateImpl): MapCameraPositionImpl? = + state.cameraPosition.value + + override fun saveMapDesign( + state: MapLibreViewStateImpl, + bundle: Bundle, + ) { + bundle.putString("styleJsonURL", state.mapDesignType?.styleJsonURL ?: "null") + } + + override fun createState( + stateId: String, + mapDesignBundle: Bundle?, + cameraPosition: MapCameraPositionImpl, + ): MapLibreViewStateImpl = + MapLibreViewStateImpl( + id = stateId, + mapDesignType = + MapLibreMapDesign( + id = + mapDesignBundle?.getString("id") + ?: MapLibreMapDesign.DemoTiles.id, + styleJsonURL = + mapDesignBundle?.getString("styleJsonURL") + ?: MapLibreMapDesign.DemoTiles.styleJsonURL, + ), + initCameraPosition = cameraPosition, + ) + + override fun getStateId(state: MapLibreViewStateImpl): String = state.id +} + +@Composable +fun rememberMapLibreMapViewState( + mapDesign: MapLibreMapDesignType = MapLibreMapDesign.DemoTiles, + cameraPosition: MapCameraPosition = MapCameraPositionImpl.Default, +): MapLibreViewStateImpl { + val stateId by rememberSaveable { + val uuid = UUID.randomUUID().toString() + mutableStateOf(uuid) + } + val state = + rememberSaveable( + stateSaver = MapLibreMapViewSaver().createSaver(), + ) { + mutableStateOf( + MapLibreViewStateImpl( + id = stateId, + mapDesignType = mapDesign, + initCameraPosition = MapCameraPositionImpl.Companion.from(cameraPosition), + ), + ) + } + + return state.value +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleController.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleController.kt new file mode 100644 index 00000000..854d8422 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleController.kt @@ -0,0 +1,11 @@ +package com.mapconductor.maplibre.circle + +import com.mapconductor.core.circle.CircleController +import com.mapconductor.core.circle.CircleManager +import com.mapconductor.core.circle.CircleManagerImpl +import com.mapconductor.maplibre.MapLibreActualCircle + +class MapLibreCircleController( + override val renderer: MapLibreCircleOverlayRenderer, + circleManager: CircleManager = CircleManagerImpl(), +) : CircleController(circleManager, renderer) diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt new file mode 100644 index 00000000..87530bec --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleLayer.kt @@ -0,0 +1,98 @@ +package com.mapconductor.maplibre.circle + +import com.mapconductor.core.circle.CircleEntity +import com.mapconductor.core.projection.Earth +import com.mapconductor.maplibre.MapLibreActualCircle +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.circleRadius +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.FeatureCollection + +class MapLibreCircleLayer( + val sourceId: String, + val layerId: String, +) { + object Prop { + const val RADIUS = "radius" + const val LATITUDE_CORRECTION = "latitudeCorrection" + const val FILL_COLOR = "fillColor" + const val STROKE_COLOR = "strokeColor" + const val STROKE_WIDTH = "strokeWidth" + } + + companion object { + private const val TILE_SIZE = 512.0 + } + + private fun radiusExpression(): Expression = + Expression.interpolate( + Expression.exponential(2.0), + Expression.zoom(), + // zoom = 0 + Expression.stop( + 0.0, + Expression.product( + Expression.get(Prop.RADIUS), + Expression.division( + Expression.literal(TILE_SIZE), + Expression.product( + Expression.get(Prop.LATITUDE_CORRECTION), + Expression.literal(Earth.CIRCUMFERENCE_METERS), + ), + ), + ), + ), + // zoom = 22 + Expression.stop( + 22.0, + Expression.product( + Expression.get(Prop.RADIUS), + Expression.division( + Expression.literal(TILE_SIZE), + Expression.product( + Expression.get(Prop.LATITUDE_CORRECTION), + Expression.literal(Earth.CIRCUMFERENCE_METERS), + ), + ), + Expression.literal(4194304.0), // 2^22 + ), + ), + ) + + val source: GeoJsonSource = GeoJsonSource(sourceId) + + val layer: CircleLayer = + CircleLayer(layerId, sourceId).apply { + setProperties( + circleRadius(radiusExpression()), + circleColor(Expression.get(Prop.FILL_COLOR)), + circleStrokeColor(Expression.get(Prop.STROKE_COLOR)), + circleStrokeWidth(Expression.get(Prop.STROKE_WIDTH)), + ) + } + + fun draw( + entities: List>, + style: org.maplibre.android.maps.Style, + ) { + val features = entities.map { it.circle } + val styleSource = + try { + style.getSource(sourceId) + } catch (_: IllegalStateException) { + null + } + if (styleSource is GeoJsonSource) { + try { + styleSource.setGeoJson(FeatureCollection.fromFeatures(features)) + return + } catch (_: IllegalStateException) { + } + } + source.setGeoJson(FeatureCollection.fromFeatures(features)) + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt new file mode 100644 index 00000000..0826c95c --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/circle/MapLibreCircleOverlayRenderer.kt @@ -0,0 +1,74 @@ +package com.mapconductor.maplibre.circle + +import com.google.gson.JsonObject +import com.mapconductor.core.circle.AbstractCircleOverlayRenderer +import com.mapconductor.core.circle.CircleEntity +import com.mapconductor.core.circle.CircleManager +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.maplibre.MapLibreActualCircle +import com.mapconductor.maplibre.MapLibreMapViewHolder +import com.mapconductor.maplibre.toMapLibreColorString +import com.mapconductor.maplibre.toPoint +import org.maplibre.geojson.Feature +import org.maplibre.geojson.Point +import kotlin.math.cos +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MapLibreCircleOverlayRenderer( + val layer: MapLibreCircleLayer, + val circleManager: CircleManager, + override val holder: MapLibreMapViewHolder, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractCircleOverlayRenderer() { + override suspend fun createCircle(state: CircleState): MapLibreActualCircle? { + val center = GeoPointImpl.from(state.center).toPoint() + val latCorr = if (state.geodesic) cos(Math.toRadians(center.latitude())) else 1.0 + return Feature.fromGeometry( + Point.fromLngLat(center.longitude(), center.latitude()), + JsonObject().apply { + addProperty(MapLibreCircleLayer.Prop.LATITUDE_CORRECTION, latCorr) + addProperty(MapLibreCircleLayer.Prop.RADIUS, state.radiusMeters) + addProperty(MapLibreCircleLayer.Prop.FILL_COLOR, state.fillColor.toMapLibreColorString()) + addProperty(MapLibreCircleLayer.Prop.STROKE_COLOR, state.strokeColor.toMapLibreColorString()) + addProperty(MapLibreCircleLayer.Prop.STROKE_WIDTH, state.strokeWidth.value) + }, + "circle-${state.id}", + ) + } + + override suspend fun updateCircleProperties( + circle: MapLibreActualCircle, + current: CircleEntity, + prev: CircleEntity, + ): MapLibreActualCircle? { + val state = current.state + val center = GeoPointImpl.from(state.center).toPoint() + val latCorr = if (state.geodesic) cos(Math.toRadians(center.latitude())) else 1.0 + return Feature.fromGeometry( + Point.fromLngLat(center.longitude(), center.latitude()), + JsonObject().apply { + addProperty(MapLibreCircleLayer.Prop.LATITUDE_CORRECTION, latCorr) + addProperty(MapLibreCircleLayer.Prop.RADIUS, state.radiusMeters) + addProperty(MapLibreCircleLayer.Prop.FILL_COLOR, state.fillColor.toMapLibreColorString()) + addProperty(MapLibreCircleLayer.Prop.STROKE_COLOR, state.strokeColor.toMapLibreColorString()) + addProperty(MapLibreCircleLayer.Prop.STROKE_WIDTH, state.strokeWidth.value) + }, + "circle-${state.id}", + ) + } + + override suspend fun removeCircle(entity: CircleEntity) { + // Remove by redrawing remaining; nothing to do here + } + + override suspend fun onPostProcess() { + val circles = circleManager.allEntities() + val style = holder.getController()?.getStyleInstance() ?: holder.map.style + style?.let { s -> + coroutine.launch { layer.draw(circles, s) } + } + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt new file mode 100644 index 00000000..ace5faf5 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerController.kt @@ -0,0 +1,66 @@ +package com.mapconductor.maplibre.marker + +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.marker.AbstractMarkerController +import com.mapconductor.core.marker.MarkerEntity +import com.mapconductor.core.marker.MarkerRenderingStrategy +import com.mapconductor.core.spherical.Spherical.computeDistanceBetween +import com.mapconductor.maplibre.MapLibreActualMarker +import com.mapconductor.settings.Settings + +class MapLibreMarkerController( + override val renderer: MapLibreMarkerOverlayRenderer, + renderingStrategy: MarkerRenderingStrategy? = null, +) : AbstractMarkerController( + markerManager = renderer.markerManager, + renderer = renderer, + renderingStrategy = renderingStrategy, + ) { + private var internalSelectedMarker: MarkerEntity? = null + + internal var selectedMarker: MarkerEntity? + set(value) { + if (value == null) { + internalSelectedMarker?.let { + renderer.dragLayer.updatePosition(GeoPointImpl.from(it.state.position)) + // Restore the recomposition for the position property + setDraggingState(it.state, false) + // Clear drag layer selection to avoid duplicate icon after drop + renderer.dragLayer.selected = null + renderer.drawDragLayer() + markerManager.registerEntity(it) + renderer.redraw() + } + internalSelectedMarker = null + return + } + internalSelectedMarker = value + markerManager.removeEntity(value.state.id) + // Suppress the recomposition for the position property + setDraggingState(value.state, true) + renderer.dragLayer.selected = value + renderer.dragLayer.updatePosition(GeoPointImpl.from(value.state.position)) + renderer.redraw() + renderer.drawDragLayer() + } + get() = internalSelectedMarker + + override fun find(position: GeoPoint): MarkerEntity? { + return markerManager.findNearest(position)?.let { nearest -> + val zoom = renderer.holder.map.cameraPosition.zoom + val tolerance = + Settings.Default.tapTolerance.value + .toDouble() * ResourceProvider.getDensity() + val meterInMapPixel = renderer.zoomToMetersPerPixel(zoom, 256) + val radius = (tolerance * 0.5) * meterInMapPixel + val distance = computeDistanceBetween(position, nearest.state.position) + return if (distance <= radius) { + nearest + } else { + null + } + } + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt new file mode 100644 index 00000000..b5fab4fe --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MapLibreMarkerOverlayRenderer.kt @@ -0,0 +1,255 @@ +package com.mapconductor.maplibre.marker + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.marker.AbstractMarkerOverlayRenderer +import com.mapconductor.core.marker.BitmapIcon +import com.mapconductor.core.marker.DefaultIcon +import com.mapconductor.core.marker.MarkerEntity +import com.mapconductor.core.marker.MarkerIcon +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRenderer +import com.mapconductor.maplibre.MapLibreActualMarker +import com.mapconductor.maplibre.MapLibreMapViewHolder +import com.mapconductor.maplibre.toPoint +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MapLibreMarkerOverlayRenderer( + holder: MapLibreMapViewHolder, + val markerManager: MarkerManager, + val markerLayer: MarkerLayer, + val dragLayer: MarkerDragLayer, + coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractMarkerOverlayRenderer( + holder = holder, + coroutine = coroutine, + ) { + private val iconRefCounter: MutableMap = mutableMapOf() + private val defaultIcon: BitmapIcon = DefaultIcon().toBitmapIcon() + + object Prop { + const val ICON_ID = "icon_id" + const val DEFAULT_MARKER_ID = "default" + const val SCALE = "scale" + const val ICON_ANCHOR = "icon-offset" + } + + object IconAnchor { + const val CENTER = "center" + const val LEFT = "left" + const val RIGHT = "right" + const val BOTTOM = "bottom" + const val TOP_LEFT = "top-left" + const val TOP_RIGHT = "top-right" + const val BOTTOM_LEFT = "bottom-left" + const val BOTTOM_RIGHT = "bottom-right" + } + + object IconTranslateAnchor { + const val MAP = "map" + const val VIEWPORT = "viewport" + } + + init { + val style = holder.map.style + if (style != null) { + style.addImage(Prop.DEFAULT_MARKER_ID, defaultIcon.bitmap) + } else { + holder.map.getStyle { style -> + style.addImage(Prop.DEFAULT_MARKER_ID, defaultIcon.bitmap) + } + } + } + + // Ensure default icon exists on the given style (used after style reload) + fun ensureDefaultIcon(style: org.maplibre.android.maps.Style) { + try { + if (style.getImage(Prop.DEFAULT_MARKER_ID) == null) { + style.addImage(Prop.DEFAULT_MARKER_ID, defaultIcon.bitmap) + } + } catch (e: Exception) { + android.util.Log.w("MapLibre", "Failed ensuring default icon on style: ${e.message}") + } + } + + override fun setMarkerPosition( + markerEntity: MarkerEntity, + position: GeoPointImpl, + ) { + val entities = markerManager.allEntities() + val feature = + Feature.fromGeometry( + position.toPoint(), + markerEntity.marker?.properties(), + "marker-${markerEntity.state.id}", + ) + markerEntity.marker = feature + val features = + entities.map { + if (it.state.id == markerEntity.state.id) { + feature + } else { + it.marker + } + } + // Execute directly instead of launching a new coroutine + holder.map.style?.let { style -> + val styleSource = style.getSourceAs(markerLayer.sourceId) + styleSource?.setGeoJson(FeatureCollection.fromFeatures(features)) + } + } + + override suspend fun onAdd(data: List): List { + // Get style from controller to use the same instance + val style = + holder.getController()?.getStyleInstance() ?: run { + holder.map.style + } + + if (style == null) { + return emptyList() + } + + withContext(Dispatchers.Main) { + data.forEach { + it.state.icon?.let { icon -> + val iconKey = icon.hashCode().toString() + if (!iconRefCounter.contains(iconKey)) { + style.addImage(iconKey, it.bitmapIcon.bitmap, false) + iconRefCounter[iconKey] = 0 + } + } + } + } + + return data.map { + val featureId = "marker-${it.state.id}" + val position = GeoPointImpl.from(it.state.position).toPoint() + val properties = + JsonObject().apply { + if (it.state.icon != null) { + it.state.icon?.let { icon -> + val iconKey = icon.hashCode().toString() + iconRefCounter[iconKey] = iconRefCounter.getOrDefault(iconKey, 0) + 1 + addProperty(Prop.ICON_ID, iconKey) + // icon offset property + add(Prop.ICON_ANCHOR, createIconOffset(icon)) + } + } else { + addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) + add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) + } + // We don't use the MapLibre SDK's scaling system + // addProperty(Prop.SCALE, 1.0) + } + Feature.fromGeometry(position, properties, featureId) + } + } + + private fun getDefaultIconOffsetProperty(): JsonArray = createIconOffset(defaultIcon) + + private fun createIconOffset(icon: BitmapIcon): JsonArray = + JsonArray().apply { + add(-(icon.size.width * icon.anchor.x) / ResourceProvider.getDensity()) + add(-(icon.size.height * icon.anchor.y) / ResourceProvider.getDensity()) + } + + private fun createIconOffset(icon: MarkerIcon): JsonArray = createIconOffset(icon.toBitmapIcon()) + + override suspend fun onRemove(data: List>) { + coroutine.launch { +// data.forEach { params -> params.marker?.remove() } + } + } + + fun drawDragLayer() { + coroutine.launch { + dragLayer.draw() + } + } + + fun redraw() { + val entities = markerManager.allEntities() + // Get style from controller to use the same instance + val style = holder.getController()?.getStyleInstance() ?: holder.map.style + style?.let { s -> + coroutine.launch { + markerLayer.draw(entities, s) + } + } + } + + override suspend fun onPostProcess() { + // For Mapbox, we need to update the layer after add/remove operations + // but only redraw when there were actual changes + redraw() + } + + override suspend fun onChange( + data: List>, + ): List = + data.map { params -> + val prevFinger = params.prev.fingerPrint + val currFinger = params.current.fingerPrint + val prevProperties = params.prev.marker?.properties() + + val properties = + JsonObject().apply { + // No additional scaling needed - bitmap is created with device density + // and Bitmap.density is set to prevent MapLibre's automatic scaling + // addProperty(Prop.SCALE, 1.0) + if (currFinger.icon == prevFinger.icon) { + addProperty( + Prop.ICON_ID, + prevProperties?.get(Prop.ICON_ID)?.asString ?: Prop.DEFAULT_MARKER_ID, + ) + + add( + Prop.ICON_ANCHOR, + prevProperties?.get(Prop.ICON_ANCHOR) ?: getDefaultIconOffsetProperty(), + ) + } else { + val iconKey = prevFinger.icon.toString() + val cnt = iconRefCounter.getOrDefault(iconKey, 1) - 1 + if (cnt == 0) { + iconRefCounter.remove(iconKey) + coroutine.launch { holder.map.style?.removeImage(iconKey) } + } else { + iconRefCounter[iconKey] = cnt + } + + if (currFinger.icon == null) { + addProperty(Prop.ICON_ID, Prop.DEFAULT_MARKER_ID) + add(Prop.ICON_ANCHOR, getDefaultIconOffsetProperty()) + } else { + params.current.state.icon?.let { icon -> + // icon id + val iconKey = icon.hashCode().toString() + if (iconRefCounter.contains(iconKey)) { + iconRefCounter[iconKey] = (iconRefCounter[iconKey] ?: 0) + 1 + } else { + coroutine.launch { + holder.map.style?.addImage(iconKey, params.bitmapIcon.bitmap, false) + } + iconRefCounter[iconKey] = 1 + } + addProperty(Prop.ICON_ID, iconKey) + add(Prop.ICON_ANCHOR, createIconOffset(icon)) + } + } + } + } + + val position = + GeoPointImpl.from(params.current.state.position).toPoint() + val featureId = "marker-${params.current.state.id}" + Feature.fromGeometry(position, properties, featureId) + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MarkerDragLayer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MarkerDragLayer.kt new file mode 100644 index 00000000..b48300d4 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MarkerDragLayer.kt @@ -0,0 +1,42 @@ +package com.mapconductor.maplibre.marker + +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.marker.MarkerEntity +import com.mapconductor.maplibre.MapLibreActualMarker +import com.mapconductor.maplibre.toPoint +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection + +open class MarkerDragLayer( + sourceId: String, + layerId: String, +) : MarkerLayer(sourceId, layerId) { + var selected: MarkerEntity? = null + + fun updatePosition(geoPoint: GeoPointImpl) { + selected?.let { + it.state.position = geoPoint + } + } + + fun draw() { + val features = + selected?.let { + if (it.marker != null) { + val feature = + Feature.fromGeometry( + GeoPointImpl.from(it.state.position).toPoint(), + it.marker?.properties(), + it.state.id, + ) + it.marker = feature + listOf(feature) + } else { + emptyList() + } + } ?: emptyList() + source.setGeoJson( + FeatureCollection.fromFeatures(features), + ) + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt new file mode 100644 index 00000000..ca36fb8d --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/marker/MarkerLayer.kt @@ -0,0 +1,66 @@ +package com.mapconductor.maplibre.marker + +import com.mapconductor.core.marker.MarkerEntity +import com.mapconductor.maplibre.MapLibreActualMarker +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.layers.PropertyFactory.iconAllowOverlap +import org.maplibre.android.style.layers.PropertyFactory.iconAnchor +import org.maplibre.android.style.layers.PropertyFactory.iconIgnorePlacement +import org.maplibre.android.style.layers.PropertyFactory.iconImage +import org.maplibre.android.style.layers.PropertyFactory.iconOffset +import org.maplibre.android.style.layers.PropertyFactory.iconTranslateAnchor +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection + +open class MarkerLayer( + open val sourceId: String, + open val layerId: String, +) { + val layer = + SymbolLayer(layerId, sourceId).apply { + setProperties( + iconImage(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ID)), + // iconSize(get(MapLibreMarkerOverlayRenderer.Prop.SCALE)), + iconAllowOverlap(true), + iconIgnorePlacement(true), + iconAnchor(MapLibreMarkerOverlayRenderer.IconAnchor.TOP_LEFT), + iconTranslateAnchor(MapLibreMarkerOverlayRenderer.IconTranslateAnchor.MAP), + // Each feature always carries icon-offset in properties; use it directly + iconOffset(get(MapLibreMarkerOverlayRenderer.Prop.ICON_ANCHOR)), + ) + } + + val source: GeoJsonSource = + GeoJsonSource( + sourceId, + FeatureCollection.fromFeatures(emptyList()), + ) + + fun draw( + entities: List>, + style: org.maplibre.android.maps.Style, + ) { + val visibleEntities = entities.filter { it.visible && it.marker != null } + val features = visibleEntities.mapNotNull { it.marker } + val collection = FeatureCollection.fromFeatures(features) + + try { + // Always update the source attached to the current style + var styleSource = style.getSourceAs(sourceId) + if (styleSource == null) { + // Source might not be attached yet (e.g., after style reload). Try to attach ours. + try { + style.addSource(source) + } catch (_: Exception) { + // ignore if already added or style busy + } + styleSource = style.getSourceAs(sourceId) + } + styleSource?.setGeoJson(collection) + } catch (e: Exception) { + android.util.Log.w("MapLibre", "Failed to update marker source: ${e.message}") + } + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt new file mode 100644 index 00000000..14afc322 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonConductor.kt @@ -0,0 +1,102 @@ +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.controller.OverlayController +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.MapCameraPositionImpl +import com.mapconductor.core.polygon.PolygonEntity +import com.mapconductor.core.polygon.PolygonEntityImpl +import com.mapconductor.core.polygon.PolygonEvent +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.polyline.PolylineEntityImpl +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.maplibre.polyline.MapLibrePolylineOverlayRenderer + +class MapLibrePolygonConductor( + val polygonOverlay: MapLibrePolygonOverlayRenderer, + val polylineOverlay: MapLibrePolylineOverlayRenderer, +) : OverlayController< + PolygonState, + PolygonEntity, + PolygonEvent, + > { + override val zIndex: Int = 2 + + override suspend fun add(data: List) { + data.forEach { polygonState -> + + polygonOverlay.createPolygon(polygonState)?.let { polygon -> + val polygonEntity = + PolygonEntityImpl( + polygon = polygon, + state = polygonState, + ) + polygonOverlay.polygonManager.registerEntity(polygonEntity) + } + + val polylineState = polygonState.toPolylineState() + polylineOverlay.createPolyline(polylineState)?.let { polyline -> + val polylineEntity = + PolylineEntityImpl( + polyline = polyline, + state = polylineState, + ) + polylineOverlay.polylineManager.registerEntity(polylineEntity) + } + } + polygonOverlay.onPostProcess() + polylineOverlay.onPostProcess() + } + + override suspend fun update(state: PolygonState) { + polygonOverlay.createPolygon(state)?.let { polygon -> + val polygonEntity = + PolygonEntityImpl( + polygon = polygon, + state = state, + ) + polygonOverlay.polygonManager.registerEntity(polygonEntity) + } + + val polylineState = state.toPolylineState() + polylineOverlay.createPolyline(polylineState)?.let { polyline -> + val polylineEntity = + PolylineEntityImpl( + polyline = polyline, + state = polylineState, + ) + polylineOverlay.polylineManager.registerEntity(polylineEntity) + } + polygonOverlay.onPostProcess() + polylineOverlay.onPostProcess() + } + + override var clickListener: ((PolygonEvent) -> Unit)? = null + + override fun find(position: GeoPoint): PolygonEntity? = + polygonOverlay.polygonManager.find(position) as? PolygonEntity + + override suspend fun clear() {} + + override suspend fun onCameraChanged(mapCameraPosition: MapCameraPositionImpl) {} + + override fun destroy() { + // No native resources to clean up for polygons + } +} + +private fun PolygonState.toPolylineState(): PolylineState { + val closedPoints = + if (points.first() != points.last()) { + points + points.first() + } else { + points + } + return PolylineState( + points = closedPoints, + id = "outline-${this.id}", + strokeColor = this.strokeColor, + strokeWidth = this.strokeWidth, + geodesic = this.geodesic, + extra = this.zIndex, + ) +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt new file mode 100644 index 00000000..eacf3e2a --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonLayer.kt @@ -0,0 +1,61 @@ +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.polygon.PolygonEntity +import com.mapconductor.maplibre.MapLibreActualPolygon +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.PropertyFactory.fillColor +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection + +class MapLibrePolygonLayer( + val sourceId: String, + val layerId: String, +) { + object Prop { + const val FILL_COLOR = "fillColor" + const val Z_INDEX = "zIndex" + } + + val source: GeoJsonSource = + GeoJsonSource( + sourceId, + FeatureCollection.fromFeatures(emptyList()), + ) + + val layer: FillLayer = + FillLayer(layerId, sourceId).apply { + setProperties( + fillColor(get(Prop.FILL_COLOR)), + ) + } + + fun draw( + entities: List>, + style: org.maplibre.android.maps.Style, + ) { + val features: List = + entities + .sortedBy { it.state.zIndex } + .flatMap { it.polygon } + + val styleSource = + try { + style.getSource(sourceId) + } catch (e: IllegalStateException) { + null + } + + if (styleSource is GeoJsonSource) { + try { + styleSource.setGeoJson(FeatureCollection.fromFeatures(features)) + return + } catch (_: IllegalStateException) { + // fall through to fallback + } + } + // Fallback to local source instance if style source is unavailable + source.setGeoJson(FeatureCollection.fromFeatures(features)) + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt new file mode 100644 index 00000000..a4384c4c --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polygon/MapLibrePolygonOverlayRenderer.kt @@ -0,0 +1,104 @@ +package com.mapconductor.maplibre.polygon + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.polygon.AbstractPolygonOverlayRenderer +import com.mapconductor.core.polygon.PolygonEntity +import com.mapconductor.core.polygon.PolygonManager +import com.mapconductor.core.polygon.PolygonState +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.maplibre.MapLibreActualPolygon +import com.mapconductor.maplibre.MapLibreMapViewHolder +import com.mapconductor.maplibre.createMapLibrePolygons +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MapLibrePolygonOverlayRenderer( + val layer: MapLibrePolygonLayer, + val polygonManager: PolygonManager, + override val holder: MapLibreMapViewHolder, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractPolygonOverlayRenderer() { + override suspend fun onRemove(data: List>) { + // Actual removal handled by redrawing remaining polygons in onPostProcess + } + + override suspend fun onPostProcess() { + val polygons = getAllPolygonEntities() + val style = holder.getController()?.getStyleInstance() ?: holder.map.style + style?.let { + coroutine.launch { + this@MapLibrePolygonOverlayRenderer.layer.draw(polygons, it) + } + } + } + + override suspend fun removePolygon(entity: PolygonEntity) { + // No-op; we redraw full collection + } + + override suspend fun createPolygon(state: PolygonState): MapLibreActualPolygon? = + createMapLibrePolygons( + id = state.id, + points = state.points, + geodesic = state.geodesic, + fillColor = state.fillColor, + zIndex = state.zIndex, + ) + + override suspend fun updatePolygonProperties( + polygon: MapLibreActualPolygon, + current: PolygonEntity, + prev: PolygonEntity, + ): MapLibreActualPolygon? { + val finger = current.fingerPrint + val prevFinger = prev.fingerPrint + + if (finger != prevFinger) { + // Recreate features when any polygon property changes + return createPolygon(current.state) + } + return prev.polygon + } + + /** + * Creates geodesic polygon points by interpolating between each consecutive pair of vertices. + */ + private fun createGeodesicPolygonPoints( + points: List, + maxSegmentLength: Double = 1000.0, + ): List { + if (points.size < 3) return points + + val results = mutableListOf() + + for (i in points.indices) { + val currentPoint = points[i] + val nextPoint = points[(i + 1) % points.size] // Wrap around to create closed polygon + + results.add(currentPoint) + + // Calculate distance between current and next point + val distance = Spherical.computeDistanceBetween(currentPoint, nextPoint) + + // Skip interpolation if points are very close + if (distance <= maxSegmentLength) continue + + // Calculate number of interpolation segments needed + val numSegments = (distance / maxSegmentLength).toInt().coerceAtLeast(1) + val step = 1.0 / numSegments + + // Add interpolated points between current and next vertex + var fraction = step + while (fraction < 1.0) { + val interpolatedPoint = Spherical.sphericalInterpolate(currentPoint, nextPoint, fraction) + results.add(interpolatedPoint) + fraction += step + } + } + + return results + } + + private fun getAllPolygonEntities(): List> = polygonManager.allEntities() +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineController.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineController.kt new file mode 100644 index 00000000..f32749ce --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineController.kt @@ -0,0 +1,10 @@ +package com.mapconductor.maplibre.polyline + +import com.mapconductor.core.polyline.PolylineController +import com.mapconductor.core.polyline.PolylineManager +import com.mapconductor.maplibre.MapLibreActualPolyline + +class MapLibrePolylineController( + override val renderer: MapLibrePolylineOverlayRenderer, + polylineManager: PolylineManager = renderer.polylineManager, +) : PolylineController(polylineManager, renderer) diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt new file mode 100644 index 00000000..efa4d644 --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineLayer.kt @@ -0,0 +1,67 @@ +package com.mapconductor.maplibre.polyline + +import com.mapconductor.core.polyline.PolylineEntity +import com.mapconductor.maplibre.MapLibreActualPolyline +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.Property +import org.maplibre.android.style.layers.PropertyFactory.lineCap +import org.maplibre.android.style.layers.PropertyFactory.lineColor +import org.maplibre.android.style.layers.PropertyFactory.lineJoin +import org.maplibre.android.style.layers.PropertyFactory.lineWidth +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection + +class MapLibrePolylineLayer( + val sourceId: String, + val layerId: String, +) { + object Prop { + const val STROKE_COLOR = "strokeColor" + const val STROKE_WIDTH = "strokeWidth" + const val Z_INDEX = "zIndex" + } + + val source: GeoJsonSource = + GeoJsonSource( + sourceId, + FeatureCollection.fromFeatures(emptyList()), + ) + + val layer: LineLayer = + LineLayer(layerId, sourceId).apply { + setProperties( + lineJoin(Property.LINE_JOIN_ROUND), + lineCap(Property.LINE_CAP_ROUND), + lineColor(get(Prop.STROKE_COLOR)), + lineWidth(get(Prop.STROKE_WIDTH)), + ) + } + + fun draw( + entities: List>, + style: org.maplibre.android.maps.Style, + ) { + val features: List = entities.flatMap { it.polyline } + + val styleSource = + try { + style.getSource(sourceId) + } catch (e: IllegalStateException) { + // Style might be in transition + null + } + + if (styleSource is GeoJsonSource) { + try { + styleSource.setGeoJson(FeatureCollection.fromFeatures(features)) + return + } catch (_: IllegalStateException) { + // fall through to fallback + } + } + // Fallback to local source instance if style source is unavailable + source.setGeoJson(FeatureCollection.fromFeatures(features)) + } +} diff --git a/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt new file mode 100644 index 00000000..a457892a --- /dev/null +++ b/mapconductor-for-maplibre/src/main/java/com/mapconductor/maplibre/polyline/MapLibrePolylineOverlayRenderer.kt @@ -0,0 +1,78 @@ +package com.mapconductor.maplibre.polyline + +import com.mapconductor.core.polyline.AbstractPolylineOverlayRenderer +import com.mapconductor.core.polyline.PolylineEntity +import com.mapconductor.core.polyline.PolylineManager +import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.maplibre.MapLibreActualPolyline +import com.mapconductor.maplibre.MapLibreMapViewHolder +import com.mapconductor.maplibre.createMapLibreLines +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MapLibrePolylineOverlayRenderer( + val layer: MapLibrePolylineLayer, + val polylineManager: PolylineManager, + override val holder: MapLibreMapViewHolder, + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : AbstractPolylineOverlayRenderer() { + override suspend fun createPolyline(state: PolylineState): MapLibreActualPolyline? = + createMapLibreLines( + id = state.id, + points = state.points, + geodesic = state.geodesic, + strokeColor = state.strokeColor, + strokeWidth = state.strokeWidth, + zIndex = (state.extra as? Int) ?: 0, + ) + + override suspend fun updatePolylineProperties( + polyline: MapLibreActualPolyline, + current: PolylineEntity, + prev: PolylineEntity, + ): MapLibreActualPolyline? { + // Recreate features to apply updated properties + return createMapLibreLines( + id = current.state.id, + points = current.state.points, + geodesic = current.state.geodesic, + strokeColor = current.state.strokeColor, + strokeWidth = current.state.strokeWidth, + zIndex = (current.state.extra as? Int) ?: 0, + ) + } + + override suspend fun removePolyline(entity: PolylineEntity) { + // Remove features by rewriting source without this entity + // Actual removal is handled in onPostProcess by redrawing all remaining polylines + } + + override suspend fun onPostProcess() { + val polylines = getAllPolylineEntities() + // Use the same style instance from the controller when available + val style = holder.getController()?.getStyleInstance() ?: holder.map.style + style?.let { + coroutine.launch { + layer.draw(polylines, it) + } + } + } + + private fun getAllPolylineEntities(): List> { + // This would need access to the polyline manager + // For now, we'll implement a simple workaround + return polylineManager.allEntities() + } + + fun redraw() { + val entities = polylineManager.allEntities() + // Get style from controller to use the same instance + val style = holder.getController()?.getStyleInstance() ?: holder.map.style + style?.let { + coroutine.launch { + layer.draw(entities, it) + } + } + } +} diff --git a/mapconductor-icons/src/main/java/com/mapconductor/icons/CircleIcon.kt b/mapconductor-icons/src/main/java/com/mapconductor/icons/CircleIcon.kt index 9cc173c7..6b17811d 100644 --- a/mapconductor-icons/src/main/java/com/mapconductor/icons/CircleIcon.kt +++ b/mapconductor-icons/src/main/java/com/mapconductor/icons/CircleIcon.kt @@ -24,6 +24,7 @@ import com.mapconductor.core.marker.BitmapIcon import com.mapconductor.settings.Settings import android.graphics.Canvas import android.graphics.Paint +import android.util.DisplayMetrics class CircleIcon( private val properties: IconProperties, @@ -97,6 +98,10 @@ class CircleIcon( val canvasSize = ResourceProvider.dpToPx(iconSize.value * scale) val bitmap = createBitmap(canvasSize.toInt(), canvasSize.toInt()) + // Set bitmap density to control map provider scaling + ResourceProvider.getBitmapDensity().let { density -> + bitmap.density = (density * DisplayMetrics.DENSITY_DEFAULT).toInt() + } val canvas = Canvas(bitmap) val centerX = canvas.width / 2.0f val centerY = canvas.height / 2.0f diff --git a/mapconductor-icons/src/main/java/com/mapconductor/icons/FlagIcon.kt b/mapconductor-icons/src/main/java/com/mapconductor/icons/FlagIcon.kt index 438b779d..d95676d6 100644 --- a/mapconductor-icons/src/main/java/com/mapconductor/icons/FlagIcon.kt +++ b/mapconductor-icons/src/main/java/com/mapconductor/icons/FlagIcon.kt @@ -26,6 +26,7 @@ import com.mapconductor.settings.Settings import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path +import android.util.DisplayMetrics class FlagIcon( private val properties: IconProperties, @@ -181,6 +182,10 @@ class FlagIcon( val canvasSize = ResourceProvider.dpToPx(iconSize.value * scale) val bitmap = createBitmap(canvasSize.toInt(), canvasSize.toInt()) + // Set bitmap density to control map provider scaling + ResourceProvider.getBitmapDensity().let { density -> + bitmap.density = (density * DisplayMetrics.DENSITY_DEFAULT).toInt() + } val canvas = Canvas(bitmap) val flagPaint = diff --git a/mapconductor-icons/src/main/java/com/mapconductor/icons/RoundInfoBubbleIcon.kt b/mapconductor-icons/src/main/java/com/mapconductor/icons/RoundInfoBubbleIcon.kt index 31d2ecfc..f13a6dc3 100644 --- a/mapconductor-icons/src/main/java/com/mapconductor/icons/RoundInfoBubbleIcon.kt +++ b/mapconductor-icons/src/main/java/com/mapconductor/icons/RoundInfoBubbleIcon.kt @@ -30,6 +30,7 @@ import android.graphics.Paint import android.graphics.Path import android.graphics.RectF import android.graphics.drawable.Drawable +import android.util.DisplayMetrics class RoundInfoBubbleIcon( private val properties: IconProperties, @@ -120,6 +121,10 @@ class RoundInfoBubbleIcon( Bitmap.Config.ARGB_8888, ) val canvas = Canvas(bitmap) + // Set bitmap density to control map provider scaling + ResourceProvider.getBitmapDensity().let { density -> + bitmap.density = (density * DisplayMetrics.DENSITY_DEFAULT).toInt() + } if (this.debug) { Paint() diff --git a/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.cpp b/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.cpp index 78ccfe15..a263a227 100644 --- a/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.cpp +++ b/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.cpp @@ -2,6 +2,7 @@ #include #include #include +#include constexpr double PI = 3.14159265358979323846; constexpr double EARTH_CIRCUMFERENCE = 40075017.0; @@ -29,6 +30,7 @@ void NativeMarkerIndex::addToCell(const std::string& markerId, const std::string void NativeMarkerIndex::registerMarker(const std::string& id, const GeoPoint& position, bool clickable) { + std::unique_lock lock(indexMutex); MarkerPoint marker(id, position, clickable); // Remove from old cell if exists @@ -49,6 +51,7 @@ void NativeMarkerIndex::updateMarker(const std::string& id, const GeoPoint& posi } bool NativeMarkerIndex::removeMarker(const std::string& id) { + std::unique_lock lock(indexMutex); auto markerIt = markers.find(id); if (markerIt == markers.end()) { return false; @@ -65,6 +68,7 @@ bool NativeMarkerIndex::removeMarker(const std::string& id) { } bool NativeMarkerIndex::hasMarker(const std::string& id) const { + std::shared_lock lock(indexMutex); return markers.find(id) != markers.end(); } @@ -103,6 +107,7 @@ HexCell NativeMarkerIndex::findNearest(const GeoPoint& position) const { } std::string NativeMarkerIndex::findNearestMarker(const GeoPoint& position) const { + std::shared_lock lock(indexMutex); // Try optimized ring-by-ring search first std::string result = findNearestOptimized(position); if (!result.empty()) { @@ -157,20 +162,80 @@ std::string NativeMarkerIndex::findNearestOptimized(const GeoPoint& position) co } std::string NativeMarkerIndex::findNearestBruteForce(const GeoPoint& position) const { - std::string bestMarkerId; - double bestDistance = std::numeric_limits::max(); - - for (const auto& pair : markers) { - if (pair.second.clickable) { - double distance = haversineDistance(position, pair.second.position); - if (distance < bestDistance) { - bestDistance = distance; - bestMarkerId = pair.first; + // Build a snapshot to avoid holding iterators into the map across threads + std::vector> items; + items.reserve(markers.size()); + for (const auto& kv : markers) { + if (kv.second.clickable) { + items.emplace_back(kv.first, kv.second.position); + } + } + + if (items.empty()) return std::string(); + + // Decide parallelism based on data size + unsigned hw = std::thread::hardware_concurrency(); + if (hw == 0) hw = 2; + unsigned maxThreads = std::min(hw, 4); // cap + unsigned threadCount = std::min(maxThreads, static_cast(items.size())); + + // Small inputs: single-thread linear scan + if (threadCount <= 1 || items.size() < 1024) { + std::string bestId; + double bestDist = std::numeric_limits::max(); + for (const auto& it : items) { + double d = haversineDistance(position, it.second); + if (d < bestDist) { + bestDist = d; + bestId = it.first; } } + return bestId; } - - return bestMarkerId; + + // Parallel reduce + struct LocalBest { std::string id; double dist; }; + std::vector locals(threadCount, LocalBest{"", std::numeric_limits::max()}); + std::vector threads; + threads.reserve(threadCount); + + size_t n = items.size(); + size_t base = n / threadCount; + size_t rem = n % threadCount; + size_t start = 0; + for (unsigned i = 0; i < threadCount; ++i) { + size_t span = base + (i < rem ? 1 : 0); + size_t s = start; + size_t e = s + span; // [s, e) + start = e; + threads.emplace_back([&, i, s, e]() { + double best = std::numeric_limits::max(); + std::string id; + for (size_t k = s; k < e; ++k) { + const auto& it = items[k]; + double d = haversineDistance(position, it.second); + if (d < best) { + best = d; + id = it.first; + } + } + locals[i].dist = best; + locals[i].id = id; + }); + } + + for (auto& t : threads) t.join(); + + // Reduce global best + std::string bestId; + double bestDist = std::numeric_limits::max(); + for (const auto& lb : locals) { + if (!lb.id.empty() && lb.dist < bestDist) { + bestDist = lb.dist; + bestId = lb.id; + } + } + return bestId; } std::vector NativeMarkerIndex::findWithinRadiusWithDistance(const GeoPoint& center, double radiusMeters) const { @@ -212,46 +277,107 @@ std::vector NativeMarkerIndex::findMarkersInBounds(const GeoRectBou if (bounds.isEmpty()) { return {}; } - - std::vector result; - result.reserve(100); // Pre-allocate reasonable capacity - + + // Hold a shared lock for the duration to block writers while allowing safe reads. + std::shared_lock outerLock(indexMutex); + // More precise search radius calculation GeoPoint center = bounds.center(); double latRadiusMeters = (bounds.maxLat - bounds.minLat) * 111000.0 / 2.0; double lngRadiusMeters = (bounds.maxLng - bounds.minLng) * 111000.0 * std::cos(center.latitude * PI / 180.0) / 2.0; double searchRadiusMeters = std::sqrt(latRadiusMeters * latRadiusMeters + lngRadiusMeters * lngRadiusMeters); - + // Limit search radius to prevent excessive hex cell generation searchRadiusMeters = std::min(searchRadiusMeters, 50000.0); // Max 50km search - + HexCoord centerCoord = geocell->latLngToHexCoord(center, zoom); - - // More efficient hex radius calculation + + // Efficient hex radius calculation int hexRadius = std::max(1, static_cast(std::ceil(searchRadiusMeters / 1000.0))); - hexRadius = std::min(hexRadius, 20); // Limit to 20 hex cells radius to prevent memory explosion - - // Direct iteration instead of generating all cells at once - for (int dq = -hexRadius; dq <= hexRadius; ++dq) { - int minR = std::max(-hexRadius, -dq - hexRadius); - int maxR = std::min(hexRadius, -dq + hexRadius); - - for (int dr = minR; dr <= maxR; ++dr) { - HexCoord coord(centerCoord.q + dq, centerCoord.r + dr, centerCoord.depth); - std::string cellId = geocell->hexToCellId(coord, zoom); - - auto cellIt = cellToMarkers.find(cellId); - if (cellIt != cellToMarkers.end()) { - for (const std::string& markerId : cellIt->second) { - auto markerIt = markers.find(markerId); - if (markerIt != markers.end() && bounds.contains(markerIt->second.position)) { - result.push_back(markerId); + hexRadius = std::min(hexRadius, 20); // bound to prevent explosion + + // Total number of dq rows we will process + const int dqMin = -hexRadius; + const int dqMax = hexRadius; + const int dqCount = dqMax - dqMin + 1; + + // Decide parallelism + unsigned hw = std::thread::hardware_concurrency(); + if (hw == 0) hw = 2; + unsigned maxThreads = std::min(hw, 4); // cap to avoid overhead + unsigned threadCount = std::min(maxThreads, static_cast(dqCount)); + + // If small work, run single-threaded to avoid overhead + if (threadCount <= 1 || dqCount < 6) { + std::vector result; + result.reserve(100); + for (int dq = dqMin; dq <= dqMax; ++dq) { + int minR = std::max(-hexRadius, -dq - hexRadius); + int maxR = std::min(hexRadius, -dq + hexRadius); + for (int dr = minR; dr <= maxR; ++dr) { + HexCoord coord(centerCoord.q + dq, centerCoord.r + dr, centerCoord.depth); + std::string cellId = geocell->hexToCellId(coord, zoom); + auto cellIt = cellToMarkers.find(cellId); + if (cellIt != cellToMarkers.end()) { + for (const std::string& markerId : cellIt->second) { + auto markerIt = markers.find(markerId); + if (markerIt != markers.end() && bounds.contains(markerIt->second.position)) { + result.push_back(markerId); + } } } } } + return result; + } + + // Parallel execution: split dq rows into contiguous segments + std::vector> partials(threadCount); + std::vector threads; + threads.reserve(threadCount); + + auto worker = [&](int dqStart, int dqEnd, std::vector& out) { + out.reserve(64); + for (int dq = dqStart; dq <= dqEnd; ++dq) { + int minR = std::max(-hexRadius, -dq - hexRadius); + int maxR = std::min(hexRadius, -dq + hexRadius); + for (int dr = minR; dr <= maxR; ++dr) { + HexCoord coord(centerCoord.q + dq, centerCoord.r + dr, centerCoord.depth); + std::string cellId = geocell->hexToCellId(coord, zoom); + auto cellIt = cellToMarkers.find(cellId); + if (cellIt != cellToMarkers.end()) { + for (const std::string& markerId : cellIt->second) { + auto markerIt = markers.find(markerId); + if (markerIt != markers.end() && bounds.contains(markerIt->second.position)) { + out.push_back(markerId); + } + } + } + } + } + }; + + int rowsPerThread = dqCount / static_cast(threadCount); + int remainder = dqCount % static_cast(threadCount); + int current = dqMin; + for (unsigned i = 0; i < threadCount; ++i) { + int span = rowsPerThread + (i < static_cast(remainder) ? 1 : 0); + int start = current; + int end = start + span - 1; + current = end + 1; + threads.emplace_back(worker, start, end, std::ref(partials[i])); + } + + for (auto& t : threads) t.join(); + + // Merge results + std::vector result; + size_t total = 0; + for (const auto& v : partials) total += v.size(); + result.reserve(total); + for (auto& v : partials) { + result.insert(result.end(), v.begin(), v.end()); } - return result; } @@ -284,4 +410,4 @@ double NativeMarkerIndex::metersPerPixel(const GeoPoint& position, double zoom, double lat = position.latitude * PI / 180.0; double metersPerTile = EARTH_CIRCUMFERENCE * std::cos(lat) / std::pow(2.0, zoom); return metersPerTile / (tileSize * pixels); -} \ No newline at end of file +} diff --git a/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.h b/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.h index 241243d1..13925d89 100644 --- a/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.h +++ b/mapconductor-marker-native-strategy/src/main/cpp/native_marker_index.h @@ -6,6 +6,7 @@ #include #include #include +#include class NativeMarkerIndex { private: @@ -14,6 +15,7 @@ class NativeMarkerIndex { std::unordered_map> cellToMarkers; std::unordered_map markerToCell; double zoom; + mutable std::shared_mutex indexMutex; void removeFromCell(const std::string& markerId, const std::string& cellId); void addToCell(const std::string& markerId, const std::string& cellId); @@ -39,4 +41,4 @@ class NativeMarkerIndex { size_t markerCount() const; double metersPerPixel(const GeoPoint& position, double zoom, double pixels, int tileSize = 256) const; -}; \ No newline at end of file +}; diff --git a/mapconductor-marker-native-strategy/src/main/cpp/remote_spatial_marker_strategy.cpp b/mapconductor-marker-native-strategy/src/main/cpp/remote_spatial_marker_strategy.cpp index a11e2685..1273eec0 100644 --- a/mapconductor-marker-native-strategy/src/main/cpp/remote_spatial_marker_strategy.cpp +++ b/mapconductor-marker-native-strategy/src/main/cpp/remote_spatial_marker_strategy.cpp @@ -85,22 +85,24 @@ bool RemoteSpatialMarkerStrategy::addMarkers(const std::vector& m auto startTime = std::chrono::high_resolution_clock::now(); try { - std::unique_lock lock(markersMutex); - - for (const auto& markerDTO : markers) { - // Add to our marker collection - allMarkers[markerDTO.id] = markerDTO; - - // Add to spatial index - if (spatialIndex) { + { + // Update logical store under lock + std::unique_lock lock(markersMutex); + for (const auto& markerDTO : markers) { + allMarkers[markerDTO.id] = markerDTO; + } + stats.currentMarkerCount.store(allMarkers.size()); + stats.totalMarkersProcessed += markers.size(); + } + + // Update spatial index outside of markersMutex to avoid lock-order inversion + if (spatialIndex) { + for (const auto& markerDTO : markers) { GeoPoint position(markerDTO.latitude, markerDTO.longitude); spatialIndex->registerMarker(markerDTO.id, position, markerDTO.clickable); } } - stats.currentMarkerCount.store(allMarkers.size()); - stats.totalMarkersProcessed += markers.size(); - auto endTime = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration_cast(endTime - startTime); @@ -115,25 +117,26 @@ bool RemoteSpatialMarkerStrategy::addMarkers(const std::vector& m bool RemoteSpatialMarkerStrategy::updateMarker(const MarkerDataDTO& markerDTO) { try { - std::unique_lock lock(markersMutex); - - auto it = allMarkers.find(markerDTO.id); - if (it != allMarkers.end()) { - // Update marker data - it->second = markerDTO; - - // Update in spatial index - if (spatialIndex) { - GeoPoint position(markerDTO.latitude, markerDTO.longitude); + bool existed = false; + { + std::unique_lock lock(markersMutex); + auto it = allMarkers.find(markerDTO.id); + existed = (it != allMarkers.end()); + allMarkers[markerDTO.id] = markerDTO; // upsert + stats.currentMarkerCount.store(allMarkers.size()); + stats.totalMarkersProcessed++; + } + + // Update spatial index outside markersMutex + if (spatialIndex) { + GeoPoint position(markerDTO.latitude, markerDTO.longitude); + if (existed) { spatialIndex->updateMarker(markerDTO.id, position, markerDTO.clickable); + } else { + spatialIndex->registerMarker(markerDTO.id, position, markerDTO.clickable); } - - stats.totalMarkersProcessed++; - return true; - } else { - LOGE("Marker not found for update: %s", markerDTO.id.c_str()); - return false; } + return true; } catch (const std::exception& e) { LOGE("Failed to update marker %s: %s", markerDTO.id.c_str(), e.what()); return false; @@ -142,29 +145,30 @@ bool RemoteSpatialMarkerStrategy::updateMarker(const MarkerDataDTO& markerDTO) { bool RemoteSpatialMarkerStrategy::removeMarker(const std::string& markerId) { try { - std::unique_lock lock(markersMutex); - - auto it = allMarkers.find(markerId); - if (it != allMarkers.end()) { - // Remove from spatial index - if (spatialIndex) { - spatialIndex->removeMarker(markerId); + bool existed = false; + { + std::unique_lock lock(markersMutex); + auto it = allMarkers.find(markerId); + if (it != allMarkers.end()) { + existed = true; + // Remove from rendered set and logical collection + renderedMarkers.erase(markerId); + allMarkers.erase(it); + stats.currentMarkerCount.store(allMarkers.size()); + stats.renderedMarkerCount.store(renderedMarkers.size()); } - - // Remove from rendered set - renderedMarkers.erase(markerId); - - // Remove from markers collection - allMarkers.erase(it); - - stats.currentMarkerCount.store(allMarkers.size()); - stats.renderedMarkerCount.store(renderedMarkers.size()); - - return true; - } else { + } + + if (!existed) { LOGE("Marker not found for removal: %s", markerId.c_str()); return false; } + + // Update spatial index outside markersMutex + if (spatialIndex) { + spatialIndex->removeMarker(markerId); + } + return true; } catch (const std::exception& e) { LOGE("Failed to remove marker %s: %s", markerId.c_str(), e.what()); return false; @@ -483,4 +487,4 @@ std::unique_ptr createLargeDatasetRemoteStrategy( } } // namespace native -} // namespace mapconductor \ No newline at end of file +} // namespace mapconductor diff --git a/mapconductor-marker-native-strategy/src/main/java/com/mapconductor/marker/nativestrategy/spatial/NativeRemoteSpatialMarkerStrategy.kt b/mapconductor-marker-native-strategy/src/main/java/com/mapconductor/marker/nativestrategy/spatial/NativeRemoteSpatialMarkerStrategy.kt index 29a86ba1..26141505 100644 --- a/mapconductor-marker-native-strategy/src/main/java/com/mapconductor/marker/nativestrategy/spatial/NativeRemoteSpatialMarkerStrategy.kt +++ b/mapconductor-marker-native-strategy/src/main/java/com/mapconductor/marker/nativestrategy/spatial/NativeRemoteSpatialMarkerStrategy.kt @@ -39,13 +39,14 @@ import kotlinx.coroutines.yield */ class NativeRemoteSpatialMarkerStrategy( private val context: Context, - private val expandMargin: Double = 0.3, + private val expandMargin: Double = 0.5, private val addOnlyMode: Boolean = false, semaphore: Semaphore = Semaphore(1), ) : AbstractMarkerRenderingStrategy(semaphore) { companion object { private const val TAG = "RemoteSpatialNative" - private const val BATCH_DELAY_MS = 50L + private const val SERVICE_CONNECTION_TIMEOUT_MS = 5000L + private const val BATCH_DELAY_MS = 100L private const val MAX_BATCH_SIZE = 500 } @@ -62,6 +63,14 @@ class NativeRemoteSpatialMarkerStrategy( private var batchJob: Job? = null private val renderingMutex = kotlinx.coroutines.sync.Mutex() + // Cache last camera and renderer to allow recalculation after batches are sent + @Volatile private var lastCamera: MapCameraPositionImpl? = null + + @Volatile private var lastRenderer: MarkerOverlayRenderer? = null + private val cameraSeq = + java.util.concurrent.atomic + .AtomicLong(0L) + private val serviceConnection = object : ServiceConnection { override fun onServiceConnected( @@ -135,9 +144,7 @@ class NativeRemoteSpatialMarkerStrategy( batchScope.launch { while (true) { delay(BATCH_DELAY_MS) - renderingMutex.withLock { - processPendingUpdates() - } + renderingMutex.withLock { processPendingUpdates() } } } } @@ -156,6 +163,8 @@ class NativeRemoteSpatialMarkerStrategy( } else if (nativeStrategy != null) { batch.forEach { nativeStrategy?.updateMarker(it) } } + // Trigger a recalculation for current camera after new markers are registered + triggerRecalculateAfterBatch() } catch (e: Exception) { Log.e(TAG, "Failed to process batch update", e) batch.forEach { pendingUpdates.offer(it) } @@ -174,80 +183,175 @@ class NativeRemoteSpatialMarkerStrategy( } } + private fun waitForServiceConnection(): Boolean { + synchronized(serviceConnectionLock) { + if (isServiceConnected.get()) return true + try { + serviceConnectionLock.wait(SERVICE_CONNECTION_TIMEOUT_MS) + } catch (_: InterruptedException) { + } + return isServiceConnected.get() + } + } + + private fun buildCameraDto(cameraPosition: MapCameraPositionImpl): NativeCameraPositionDTO? { + val visibleRegion = cameraPosition.visibleRegion ?: return null + return NativeCameraPositionDTO( + latitude = cameraPosition.position.latitude, + longitude = cameraPosition.position.longitude, + zoom = cameraPosition.zoom, + bearing = cameraPosition.bearing, + tilt = cameraPosition.tilt, + boundsMinLat = visibleRegion.bounds.southWest?.latitude ?: 0.0, + boundsMaxLat = visibleRegion.bounds.northEast?.latitude ?: 0.0, + boundsMinLng = visibleRegion.bounds.southWest?.longitude ?: 0.0, + boundsMaxLng = visibleRegion.bounds.northEast?.longitude ?: 0.0, + ) + } + + private fun triggerRecalculateAfterBatch() { + val renderer = lastRenderer ?: return + // Use the latest camera snapshot when executing; do not drop on seq mismatch here + batchScope.launch { + semaphore.withPermit { + try { + val cam = lastCamera ?: return@withPermit + val nativeDto = buildCameraDto(cam) ?: return@withPermit + val result: NativeSpatialResultDTO = + try { + if (!waitForServiceConnection()) return@withPermit + if (remoteService != null) { + remoteService?.processCameraChange(sessionId, nativeDto) + ?: NativeSpatialResultDTO() + } else if (nativeStrategy != null) { + val nativeCamera = + CameraPosition( + latitude = nativeDto.latitude, + longitude = nativeDto.longitude, + zoom = nativeDto.zoom, + bearing = nativeDto.bearing, + tilt = nativeDto.tilt, + visibleBounds = + NativeGeoRectBounds( + south = nativeDto.boundsMinLat, + north = nativeDto.boundsMaxLat, + west = nativeDto.boundsMinLng, + east = nativeDto.boundsMaxLng, + ), + ) + nativeStrategy!!.processCameraChange(nativeCamera) ?: NativeSpatialResultDTO() + } else { + NativeSpatialResultDTO() + } + } catch (e: Exception) { + Log.e(TAG, "Recalc after batch failed", e) + NativeSpatialResultDTO() + } + processRenderingChanges(result, renderer) + } catch (e: Exception) { + Log.e(TAG, "Failed to process recalc after batch", e) + } + } + } + } + override suspend fun onCameraChanged( cameraPosition: MapCameraPositionImpl, renderer: MarkerOverlayRenderer, ) { val visibleRegion = cameraPosition.visibleRegion ?: return + // Cache last known camera and renderer + lastCamera = cameraPosition + lastRenderer = renderer + val seq = cameraSeq.incrementAndGet() + semaphore.withPermit { - renderingMutex.withLock { - try { - val result: NativeSpatialResultDTO? = - when { - remoteService != null && isServiceConnected.get() -> { - try { - val dto = - NativeCameraPositionDTO( - latitude = cameraPosition.position.latitude, - longitude = cameraPosition.position.longitude, - zoom = cameraPosition.zoom, - bearing = cameraPosition.bearing, - tilt = cameraPosition.tilt, - boundsMinLat = visibleRegion.bounds.southWest!!.latitude, - boundsMaxLat = visibleRegion.bounds.northEast!!.latitude, - boundsMinLng = visibleRegion.bounds.southWest!!.longitude, - boundsMaxLng = visibleRegion.bounds.northEast!!.longitude, - ) - remoteService?.processCameraChange(sessionId, dto) - } catch (e: Exception) { - Log.e(TAG, "Remote processCameraChange failed", e) - null - } - } - nativeStrategy != null && isServiceConnected.get() -> { - val nativeCameraPosition = + try { + // Drop stale request if a newer camera arrived while waiting + if (seq != cameraSeq.get()) return@withPermit + + val cameraDto = + NativeCameraPositionDTO( + latitude = cameraPosition.position.latitude, + longitude = cameraPosition.position.longitude, + zoom = cameraPosition.zoom, + bearing = cameraPosition.bearing, + tilt = cameraPosition.tilt, + boundsMinLat = visibleRegion.bounds.southWest?.latitude ?: 0.0, + boundsMaxLat = visibleRegion.bounds.northEast?.latitude ?: 0.0, + boundsMinLng = visibleRegion.bounds.southWest?.longitude ?: 0.0, + boundsMaxLng = visibleRegion.bounds.northEast?.longitude ?: 0.0, + ) + + val result = + if (waitForServiceConnection()) { + try { + if (remoteService != null) { + remoteService?.processCameraChange(sessionId, cameraDto) + ?: NativeSpatialResultDTO() + } else if (nativeStrategy != null) { + val nativeCamera = CameraPosition( - latitude = cameraPosition.position.latitude, - longitude = cameraPosition.position.longitude, - zoom = cameraPosition.zoom, - bearing = cameraPosition.bearing, - tilt = cameraPosition.tilt, + latitude = cameraDto.latitude, + longitude = cameraDto.longitude, + zoom = cameraDto.zoom, + bearing = cameraDto.bearing, + tilt = cameraDto.tilt, visibleBounds = NativeGeoRectBounds( - south = visibleRegion.bounds.southWest!!.latitude, - north = visibleRegion.bounds.northEast!!.latitude, - west = visibleRegion.bounds.southWest!!.longitude, - east = visibleRegion.bounds.northEast!!.longitude, + south = cameraDto.boundsMinLat, + north = cameraDto.boundsMaxLat, + west = cameraDto.boundsMinLng, + east = cameraDto.boundsMaxLng, ), ) - nativeStrategy!!.processCameraChange(nativeCameraPosition) + nativeStrategy!!.processCameraChange(nativeCamera) ?: NativeSpatialResultDTO() + } else { + NativeSpatialResultDTO() } - else -> { - val expandedBounds = expandBounds(visibleRegion.bounds, expandMargin) - val markersInBounds = markerManager.findMarkersInBounds(expandedBounds) - val markerIdsInBounds = markersInBounds.map { it.state.id }.toSet() - val markersToAdd = mutableListOf() - val markersToRemove = mutableListOf() - val currentlyRendered = - markerManager - .allEntities() - .filter { it.isRendered } - .map { it.state.id } - .toSet() - markerIdsInBounds.forEach { id -> - if (!currentlyRendered.contains(id)) markersToAdd.add(id) - } - if (!addOnlyMode) { - currentlyRendered.forEach { id -> - if (!markerIdsInBounds.contains(id)) markersToRemove.add(id) - } - } - NativeSpatialResultDTO(markersToAdd.toTypedArray(), markersToRemove.toTypedArray()) + } catch (e: Exception) { + Log.e(TAG, "Remote/native processCameraChange failed, falling back", e) + NativeSpatialResultDTO() + } + } else { + // Local-only fallback without remote/native backend + val expandedBounds = expandBounds(visibleRegion.bounds, expandMargin) + val markersInBounds = markerManager.findMarkersInBounds(expandedBounds) + val markerIdsInBounds = markersInBounds.map { it.state.id }.toSet() + val markersToAdd = mutableListOf() + val markersToRemove = mutableListOf() + val currentlyRendered = + markerManager + .allEntities() + .filter { it.isRendered } + .map { it.state.id } + .toSet() + markerIdsInBounds.forEach { id -> + if (!currentlyRendered.contains(id)) markersToAdd.add(id) + } + if (!addOnlyMode) { + currentlyRendered.forEach { id -> + if (!markerIdsInBounds.contains(id)) markersToRemove.add(id) } } - result?.let { processRenderingChanges(it, renderer) } - } catch (e: Exception) { - Log.e(TAG, "Failed to process camera change", e) + NativeSpatialResultDTO(markersToAdd.toTypedArray(), markersToRemove.toTypedArray()) + } + + // Only apply if this request is still current + if (seq == cameraSeq.get()) { + processRenderingChanges(result, renderer) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to process camera change", e) + if (e is kotlinx.coroutines.CancellationException) { + // Schedule a follow-up recalculation to avoid missing markers + batchScope.launch { + try { + delay(120) + triggerRecalculateAfterBatch() + } catch (_: Exception) { + } + } } } } @@ -259,16 +363,17 @@ class NativeRemoteSpatialMarkerStrategy( ) { val markersToRemove = mutableListOf>() val markersToAdd = mutableListOf() - val entitiesToUpdate = mutableListOf, ActualMarker?>>() + // Gather removals result.markersToRemove.forEach { markerId -> markerManager.getEntity(markerId)?.let { entity -> if (entity.isRendered) { markersToRemove.add(entity) - entitiesToUpdate.add(entity to null) } } } + + // Gather additions result.markersToAdd.forEach { markerId -> markerManager.getEntity(markerId)?.let { entity -> if (!entity.isRendered) { @@ -282,27 +387,24 @@ class NativeRemoteSpatialMarkerStrategy( } } - if (markersToRemove.isNotEmpty()) { - renderer.onRemove(markersToRemove) - } - + // Add first to avoid visible gaps during fast pans if (markersToAdd.isNotEmpty()) { val actualMarkers = renderer.onAdd(markersToAdd) actualMarkers.forEachIndexed { index, actualMarker -> - if (actualMarker != null) { + actualMarker?.let { val entity = markerManager.getEntity(markersToAdd[index].state.id) entity?.let { e -> - entitiesToUpdate.add(e to actualMarker) + e.marker = actualMarker + e.isRendered = true + e.visible = true } } } } - entitiesToUpdate.forEach { (entity, actualMarker) -> - if (actualMarker != null) { - entity.marker = actualMarker - entity.isRendered = true - } else { + if (markersToRemove.isNotEmpty()) { + renderer.onRemove(markersToRemove) + markersToRemove.forEach { entity -> entity.isRendered = false entity.marker = null } @@ -313,6 +415,44 @@ class NativeRemoteSpatialMarkerStrategy( } } + // Fallback path to complete add outside of Compose composition when it cancels mid-flight + private fun fallbackAddAsync( + params: List, + renderer: MarkerOverlayRenderer, + ) { + if (params.isEmpty()) return + batchScope.launch { + semaphore.withPermit { + try { + val chunkSize = 1000 + var index = 0 + while (index < params.size) { + val end = kotlin.math.min(index + chunkSize, params.size) + val chunk = params.subList(index, end) + val added = renderer.onAdd(chunk) + added.forEachIndexed { i, actualMarker -> + actualMarker?.let { + val state = chunk[i].state + val entity = + MarkerEntityImpl( + state = state, + marker = actualMarker, + isRendered = true, + visible = true, + ) + markerManager.registerEntity(entity) + } + } + index = end + } + renderer.onPostProcess() + } catch (e: Exception) { + Log.e(TAG, "Fallback add failed", e) + } + } + } + } + override suspend fun onAdd( data: List, viewport: GeoRectBounds, @@ -320,79 +460,82 @@ class NativeRemoteSpatialMarkerStrategy( ): Boolean = withContext(Dispatchers.Default) { try { + // Yield after processing each chunk to allow other coroutines + yield() + val markersToRender = mutableListOf() val markersToRegister = mutableListOf>() - if (isServiceConnected.get()) { - val nativeMarkers = - data.map { state -> - NativeMarkerDataDTO( - id = state.id, - latitude = state.position.latitude, - longitude = state.position.longitude, - clickable = state.clickable, + + data.forEach { state -> + val isInViewport = viewport.contains(state.position) + + if (isInViewport) { + markersToRender.add( + object : MarkerOverlayRenderer.AddParams { + override val state = state + override val bitmapIcon = state.icon?.toBitmapIcon() ?: defaultIcon + }, + ) + } else { + val entity = + MarkerEntityImpl( + state = state, + marker = null, + isRendered = false, ) - } - try { - if (remoteService != null) { - remoteService?.addMarkers(sessionId, nativeMarkers) - } else if (nativeStrategy != null) { - nativeStrategy!!.addMarkers(nativeMarkers) - } - } catch (e: Exception) { - Log.e(TAG, "Failed to add markers to backend", e) + markersToRegister.add(entity) } } - val chunks = data.chunked(100) - chunks.forEach { chunk -> - chunk.forEach { state -> - val isInViewport = viewport.contains(state.position) - if (isInViewport) { - markersToRender.add( - object : MarkerOverlayRenderer.AddParams { - override val state = state - override val bitmapIcon = state.icon?.toBitmapIcon() ?: defaultIcon - }, - ) - } else { + + // Register markers without rendering + markersToRegister.forEach { entity -> markerManager.registerEntity(entity) } + + if (markersToRender.isNotEmpty()) { + val actualMarkers = renderer.onAdd(markersToRender) + actualMarkers.forEachIndexed { index, actualMarker -> + actualMarker?.let { val entity = - MarkerEntityImpl(state = state, marker = null, isRendered = false) - markersToRegister.add(entity) + MarkerEntityImpl( + state = markersToRender[index].state, + marker = actualMarker, + isRendered = true, + visible = true, + ) + markerManager.registerEntity(entity) } } - yield() + + renderer.onPostProcess() } - markersToRegister.forEach { entity -> markerManager.registerEntity(entity) } - semaphore.withPermit { - renderingMutex.withLock { - withContext(Dispatchers.Main) { - if (markersToRender.isNotEmpty()) { - val renderChunks = markersToRender.chunked(50) - renderChunks.forEach { renderChunk -> - val actualMarkers = renderer.onAdd(renderChunk) - actualMarkers.forEachIndexed { index, actualMarker -> - actualMarker?.let { - val entity = - MarkerEntityImpl( - state = renderChunk[index].state, - marker = actualMarker, - isRendered = true, - visible = true, - ) - markerManager.registerEntity(entity) - } - } - if (renderChunks.size > 1) delay(1) - } - } - renderer.onPostProcess() - } + // Send marker data to backend (batched updates) + val markerDTOs = + data.map { state -> + NativeMarkerDataDTO( + id = state.id, + latitude = state.position.latitude, + longitude = state.position.longitude, + clickable = state.clickable, + ) } - } + markerDTOs.forEach { addToBatch(it) } true } catch (e: Exception) { - Log.e(TAG, "Failed to add markers", e) - false + return@withContext if (e is kotlinx.coroutines.CancellationException) { + // Reconstruct params for fallback from the input data + val params = + data.map { state -> + object : MarkerOverlayRenderer.AddParams { + override val state: MarkerState = state + override val bitmapIcon = state.icon?.toBitmapIcon() ?: defaultIcon + } + } + fallbackAddAsync(params, renderer) + true + } else { + Log.e(TAG, "Failed to add markers", e) + false + } } } @@ -466,7 +609,7 @@ class NativeRemoteSpatialMarkerStrategy( ): MarkerEntity? = try { renderingMutex.withLock { - if (isServiceConnected.get()) { + if (waitForServiceConnection()) { if (remoteService != null) { val id = remoteService?.findNearestMarker(sessionId, latitude, longitude) id?.let { markerManager.getEntity(it) } @@ -487,7 +630,7 @@ class NativeRemoteSpatialMarkerStrategy( fun getPerformanceStats(): String? = try { - if (isServiceConnected.get()) { + if (waitForServiceConnection()) { if (remoteService != null) { remoteService?.getPerformanceStats(sessionId) } else if (nativeStrategy != null) { diff --git a/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/RemoteSpatialMarkerStrategy.kt b/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/RemoteSpatialMarkerStrategy.kt index 9948da54..e4e9f17e 100644 --- a/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/RemoteSpatialMarkerStrategy.kt +++ b/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/RemoteSpatialMarkerStrategy.kt @@ -10,6 +10,7 @@ import com.mapconductor.core.marker.MarkerOverlayRenderer import com.mapconductor.core.marker.MarkerState import java.util.UUID import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicLong import android.content.ComponentName import android.content.Context import android.content.Intent @@ -35,7 +36,7 @@ import kotlinx.coroutines.yield */ class RemoteSpatialMarkerStrategy( private val context: Context, - private val expandMargin: Double = 0.3, + private val expandMargin: Double = 0.5, private val addOnlyMode: Boolean = false, semaphore: Semaphore = Semaphore(1), ) : AbstractMarkerRenderingStrategy(semaphore) { @@ -115,6 +116,12 @@ class RemoteSpatialMarkerStrategy( startBatchProcessor() } + // Cache last camera and renderer to allow recalculation after batches are sent + @Volatile private var lastCamera: MapCameraPositionImpl? = null + + @Volatile private var lastRenderer: MarkerOverlayRenderer? = null + private val cameraSeq = AtomicLong(0L) + private fun startBatchProcessor() { batchJob = batchScope.launch { @@ -139,6 +146,8 @@ class RemoteSpatialMarkerStrategy( if (batch.isNotEmpty()) { try { spatialService?.updateMarkers(sessionId, batch) + // Trigger a recalculation for current camera after new markers are registered remotely + triggerRecalculateAfterBatch() } catch (e: Exception) { Log.e(TAG, "Failed to process batch update", e) batch.forEach { pendingUpdates.offer(it) } @@ -147,6 +156,85 @@ class RemoteSpatialMarkerStrategy( } } + // Fallback path to complete add outside of Compose composition when it cancels mid-flight + private fun fallbackAddAsync( + params: List, + renderer: MarkerOverlayRenderer, + ) { + if (params.isEmpty()) return + batchScope.launch { + semaphore.withPermit { + try { + // Execute add in chunks to avoid UI stalls + val chunkSize = 1000 + var index = 0 + while (index < params.size) { + val end = kotlin.math.min(index + chunkSize, params.size) + val chunk = params.subList(index, end) + val added = renderer.onAdd(chunk) + added.forEachIndexed { i, actualMarker -> + actualMarker?.let { + val state = chunk[i].state + val entity = + MarkerEntityImpl( + state = state, + marker = actualMarker, + isRendered = true, + visible = true, + ) + markerManager.registerEntity(entity) + } + } + index = end + } + renderer.onPostProcess() + } catch (e: Exception) { + Log.e(TAG, "Fallback add failed", e) + } + } + } + } + + private fun buildCameraDto(cameraPosition: MapCameraPositionImpl): CameraPositionDTO? { + val visibleRegion = cameraPosition.visibleRegion ?: return null + return CameraPositionDTO( + centerLatitude = cameraPosition.position.latitude, + centerLongitude = cameraPosition.position.longitude, + zoom = cameraPosition.zoom, + bearing = cameraPosition.bearing, + tilt = cameraPosition.tilt, + boundsMinLat = visibleRegion.bounds.southWest?.latitude ?: 0.0, + boundsMaxLat = visibleRegion.bounds.northEast?.latitude ?: 0.0, + boundsMinLng = visibleRegion.bounds.southWest?.longitude ?: 0.0, + boundsMaxLng = visibleRegion.bounds.northEast?.longitude ?: 0.0, + ) + } + + private fun triggerRecalculateAfterBatch() { + val renderer = lastRenderer ?: return + // Use the latest camera snapshot when executing; do not drop on seq mismatch + batchScope.launch { + semaphore.withPermit { + try { + val cam = lastCamera ?: return@withPermit + val dto = buildCameraDto(cam) ?: return@withPermit + if (!waitForServiceConnection()) return@withPermit + val result = + try { + spatialService?.calculateChanges(sessionId, dto) + ?: SpatialResultDTO(emptyList(), emptyList(), emptyList()) + } catch (e: Exception) { + Log.e(TAG, "Recalc after batch failed", e) + SpatialResultDTO(emptyList(), emptyList(), emptyList()) + } + processRenderingChanges(result, renderer) + } catch (e: Exception) { + Log.e(TAG, "Failed to process recalc after batch", e) + } + } + } + } + private fun addToBatch(markerDTO: MarkerDataDTO) { pendingUpdates.offer(markerDTO) @@ -181,9 +269,15 @@ class RemoteSpatialMarkerStrategy( renderer: MarkerOverlayRenderer, ) { val visibleRegion = cameraPosition.visibleRegion ?: return + // Cache last known camera and renderer + lastCamera = cameraPosition + lastRenderer = renderer + val seq = cameraSeq.incrementAndGet() semaphore.withPermit { try { + // Drop stale request if a newer camera arrived while waiting + if (seq != cameraSeq.get()) return@withPermit val cameraDto = CameraPositionDTO( centerLatitude = cameraPosition.position.latitude, @@ -210,9 +304,22 @@ class RemoteSpatialMarkerStrategy( SpatialResultDTO(emptyList(), emptyList(), emptyList()) } - processRenderingChanges(result, renderer) + // Only apply if this request is still current + if (seq == cameraSeq.get()) { + processRenderingChanges(result, renderer) + } } catch (e: Exception) { Log.e(TAG, "Failed to process camera change", e) + if (e is kotlinx.coroutines.CancellationException) { + // Schedule a follow-up recalculation to avoid missing markers + batchScope.launch { + try { + delay(120) + triggerRecalculateAfterBatch() + } catch (_: Exception) { + } + } + } } } } @@ -247,15 +354,7 @@ class RemoteSpatialMarkerStrategy( } } - // Execute rendering operations - if (markersToRemove.isNotEmpty()) { - renderer.onRemove(markersToRemove) - markersToRemove.forEach { entity -> - entity.isRendered = false - entity.marker = null - } - } - + // Execute rendering operations - add first to avoid visible gaps during fast pans if (markersToAdd.isNotEmpty()) { val actualMarkers = renderer.onAdd(markersToAdd) actualMarkers.forEachIndexed { index, actualMarker -> @@ -264,11 +363,20 @@ class RemoteSpatialMarkerStrategy( entity?.let { e -> e.marker = actualMarker e.isRendered = true + e.visible = true } } } } + if (markersToRemove.isNotEmpty()) { + renderer.onRemove(markersToRemove) + markersToRemove.forEach { entity -> + entity.isRendered = false + entity.marker = null + } + } + if (markersToRemove.isNotEmpty() || markersToAdd.isNotEmpty()) { renderer.onPostProcess() } @@ -349,8 +457,22 @@ class RemoteSpatialMarkerStrategy( markerDTOs.forEach { addToBatch(it) } true } catch (e: Exception) { - Log.e(TAG, "Failed to add markers", e) - false + return@withContext if (e is kotlinx.coroutines.CancellationException) { + // Add cancelled; schedule fallback add without noisy log + // Reconstruct params for fallback from the input data + val params = + data.map { state -> + object : MarkerOverlayRenderer.AddParams { + override val state: MarkerState = state + override val bitmapIcon = state.icon?.toBitmapIcon() ?: defaultIcon + } + } + fallbackAddAsync(params, renderer) + true + } else { + Log.e(TAG, "Failed to add markers", e) + false + } } } diff --git a/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/SpatialMarkerService.kt b/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/SpatialMarkerService.kt index 087346b8..f5d15b81 100644 --- a/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/SpatialMarkerService.kt +++ b/mapconductor-marker-strategy/src/main/java/com/mapconductor/marker/strategy/spatial/SpatialMarkerService.kt @@ -2,6 +2,8 @@ package com.mapconductor.marker.strategy.spatial import com.mapconductor.core.features.GeoPointImpl import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.geocell.HexGeocell +import com.mapconductor.core.geocell.HexGeocellImpl import com.mapconductor.core.marker.MarkerEntityImpl import com.mapconductor.core.marker.MarkerManager import com.mapconductor.core.marker.MarkerState @@ -10,14 +12,13 @@ import java.util.concurrent.ConcurrentHashMap import android.app.Service import android.content.Intent import android.os.IBinder -import android.os.Process import android.util.Log import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Semaphore /** * Background service that handles marker spatial calculations in a separate process. - * Offloads heavy spatial computations from the main process via Binder (AIDL). + * Uses hex-cell buckets to answer viewport queries efficiently. */ internal class SpatialMarkerService : Service() { companion object { @@ -25,12 +26,15 @@ internal class SpatialMarkerService : Service() { private const val MAX_MARKERS_PER_SESSION = 10000 // Throttling limit } - private data class SpatialSession( + data class SpatialSession( val config: SpatialConfigDTO, - val markerManager: MarkerManager, // Track IDs only on remote side + val markerManager: MarkerManager, // For nearest() API only val markerData: MutableMap = ConcurrentHashMap(), val renderedMarkers: MutableSet = ConcurrentHashMap.newKeySet(), val semaphore: Semaphore = Semaphore(1), + val geocell: HexGeocell = HexGeocellImpl.defaultGeocell(), + val cellBucketsByZoom: MutableMap>> = ConcurrentHashMap(), + val markerCellByZoom: MutableMap> = ConcurrentHashMap(), ) private val sessions = ConcurrentHashMap() @@ -42,11 +46,9 @@ internal class SpatialMarkerService : Service() { config: SpatialConfigDTO, ): Boolean = try { - Log.d(TAG, "Initializing session: $sessionId") val markerManager = MarkerManager.defaultManager() val session = SpatialSession(config, markerManager) sessions[sessionId] = session - Log.d(TAG, "Session $sessionId initialized successfully") true } catch (e: Exception) { Log.e(TAG, "Failed to initialize session $sessionId", e) @@ -63,7 +65,7 @@ internal class SpatialMarkerService : Service() { val limited = if (input.size > MAX_MARKERS_PER_SESSION) { - Log.w(TAG, "Throttling markers from ${input.size} to $MAX_MARKERS_PER_SESSION") + // throttling; omit debug log in release input.take(MAX_MARKERS_PER_SESSION) } else { input @@ -71,6 +73,7 @@ internal class SpatialMarkerService : Service() { limited.forEach { marker -> session.markerData[marker.id] = marker } + // Keep the nearest() support in sync val markerStates = limited.map { dto -> MarkerState( @@ -79,7 +82,6 @@ internal class SpatialMarkerService : Service() { clickable = dto.clickable, ) } - runBlocking { markerStates.forEach { state -> val entity = @@ -92,7 +94,25 @@ internal class SpatialMarkerService : Service() { } } - Log.d(TAG, "Updated ${limited.size} markers in session $sessionId") + // Index new markers into already-built zoom buckets + if (session.markerCellByZoom.isNotEmpty()) { + session.markerCellByZoom.keys.forEach { z -> + val buckets = session.cellBucketsByZoom.getOrPut(z) { ConcurrentHashMap() } + val markerToCell = session.markerCellByZoom.getOrPut(z) { ConcurrentHashMap() } + limited.forEach { dto -> + val cellId = + this@SpatialMarkerService + .latLngToCellId(session.geocell, dto.latitude, dto.longitude, z) + val prev = markerToCell.put(dto.id, cellId) + if (prev != null && prev != cellId) { + buckets[prev]?.remove(dto.id) + } + val set = buckets.getOrPut(cellId) { ConcurrentHashMap.newKeySet() } + set.add(dto.id) + } + } + } + true } catch (e: Exception) { Log.e(TAG, "Failed to update markers in session $sessionId", e) @@ -111,8 +131,11 @@ internal class SpatialMarkerService : Service() { session.markerData.remove(id) session.renderedMarkers.remove(id) session.markerManager.removeEntity(id) + // Remove from all zoom buckets + session.markerCellByZoom.forEach { (z, map) -> + map.remove(id)?.let { cid -> session.cellBucketsByZoom[z]?.get(cid)?.remove(id) } + } } - Log.d(TAG, "Removed ${ids.size} markers from session $sessionId") true } catch (e: Exception) { Log.e(TAG, "Failed to remove markers from session $sessionId", e) @@ -125,33 +148,57 @@ internal class SpatialMarkerService : Service() { camera: CameraPositionDTO, ): SpatialResultDTO { return try { - val session = - sessions[sessionId] - ?: return SpatialResultDTO(emptyList(), emptyList(), emptyList()) + val session = sessions[sessionId] ?: return SpatialResultDTO(emptyList(), emptyList(), emptyList()) val bounds = GeoRectBounds( southWest = GeoPointImpl.fromLatLong(camera.boundsMinLat, camera.boundsMinLng), northEast = GeoPointImpl.fromLatLong(camera.boundsMaxLat, camera.boundsMaxLng), ) - val expandedBounds = expandBounds(bounds, session.config.expandMargin) - val markersInBounds = session.markerManager.findMarkersInBounds(expandedBounds) - val markerIdsInBounds = markersInBounds.map { it.state.id }.toSet() + + val indexZoom = this@SpatialMarkerService.chooseIndexZoom(camera.zoom) + this@SpatialMarkerService.ensureIndexedForZoom(session, indexZoom) + + // Build coverage cells for expanded bounds + val center = + expandedBounds.center ?: GeoPointImpl.fromLatLong(camera.centerLatitude, camera.centerLongitude) + val centerCoord = session.geocell.latLngToHexCoord(center, indexZoom.toDouble()) + val sw = expandedBounds.southWest ?: center + val ne = expandedBounds.northEast ?: center + val se = GeoPointImpl.fromLongLat(ne.longitude, sw.latitude) + val nw = GeoPointImpl.fromLongLat(sw.longitude, ne.latitude) + val swc = session.geocell.latLngToHexCoord(sw, indexZoom.toDouble()) + val nec = session.geocell.latLngToHexCoord(ne, indexZoom.toDouble()) + val sec = session.geocell.latLngToHexCoord(se, indexZoom.toDouble()) + val nwc = session.geocell.latLngToHexCoord(nw, indexZoom.toDouble()) + val radius = + maxOf( + session.geocell.hexDistance(centerCoord, swc), + session.geocell.hexDistance(centerCoord, nec), + session.geocell.hexDistance(centerCoord, sec), + session.geocell.hexDistance(centerCoord, nwc), + ) + + val buckets = session.cellBucketsByZoom[indexZoom] ?: emptyMap() + val idsInBounds = mutableSetOf() + session.geocell.hexRange(centerCoord, radius).forEach { coord -> + val cid = session.geocell.hexToCellId(coord, indexZoom.toDouble()) + buckets[cid]?.let { idsInBounds.addAll(it) } + } val markersToAdd = mutableListOf() val markersToRemove = mutableListOf() val markersToUpdate = mutableListOf() - markerIdsInBounds.forEach { id -> + idsInBounds.forEach { id -> if (!session.renderedMarkers.contains(id)) { markersToAdd.add(id) session.renderedMarkers.add(id) } } - if (!session.config.addOnlyMode) { - val toRemove = session.renderedMarkers.filter { id -> !markerIdsInBounds.contains(id) } + val toRemove = session.renderedMarkers.filter { id -> !idsInBounds.contains(id) } markersToRemove.addAll(toRemove) toRemove.forEach { id -> session.renderedMarkers.remove(id) } } @@ -184,8 +231,14 @@ internal class SpatialMarkerService : Service() { override fun destroySession(sessionId: String): Boolean = try { val session = sessions.remove(sessionId) - session?.markerManager?.destroy() - Log.d(TAG, "Session $sessionId destroyed") + if (session != null) { + session.markerManager.destroy() + session.cellBucketsByZoom.clear() + session.markerCellByZoom.clear() + session.markerData.clear() + session.renderedMarkers.clear() + } + // session destroyed true } catch (e: Exception) { Log.e(TAG, "Failed to destroy session $sessionId", e) @@ -202,6 +255,7 @@ internal class SpatialMarkerService : Service() { "renderedCount" to session.renderedMarkers.size, "addOnlyMode" to session.config.addOnlyMode, "expandMargin" to session.config.expandMargin, + "indexedZooms" to session.cellBucketsByZoom.size, ) stats.entries.joinToString(prefix = "{", postfix = "}", separator = ", ") { "\"${it.key}\": ${if (it.value is String) "\"${it.value}\"" else it.value}" @@ -213,14 +267,10 @@ internal class SpatialMarkerService : Service() { } } - override fun onBind(intent: Intent?): IBinder { - Log.d(TAG, "SpatialMarkerService bound with intent: $intent") - return binder - } + override fun onBind(intent: Intent?): IBinder = binder override fun onCreate() { super.onCreate() - Log.d(TAG, "SpatialMarkerService created in process: ${Process.myPid()}") } override fun onDestroy() { @@ -233,6 +283,41 @@ internal class SpatialMarkerService : Service() { } } sessions.clear() - Log.d(TAG, "SpatialMarkerService destroyed") + } + + private fun chooseIndexZoom(zoom: Double): Int = + when { + zoom < 7 -> 7 + zoom < 9 -> 8 + zoom < 11 -> 10 + zoom < 13 -> 12 + else -> 14 + } + + private fun latLngToCellId( + geocell: HexGeocell, + lat: Double, + lng: Double, + z: Int, + ): String { + val coord = geocell.latLngToHexCoord(GeoPointImpl.fromLatLong(lat, lng), z.toDouble()) + return geocell.hexToCellId(coord, z.toDouble()) + } + + private fun ensureIndexedForZoom( + session: SpatialSession, + z: Int, + ) { + val buckets = session.cellBucketsByZoom.getOrPut(z) { ConcurrentHashMap() } + val markerToCell = session.markerCellByZoom.getOrPut(z) { ConcurrentHashMap() } + if (markerToCell.size == session.markerData.size) return + session.markerData.values.forEach { dto -> + if (!markerToCell.containsKey(dto.id)) { + val cellId = this@SpatialMarkerService.latLngToCellId(session.geocell, dto.latitude, dto.longitude, z) + markerToCell[dto.id] = cellId + val set = buckets.getOrPut(cellId) { ConcurrentHashMap.newKeySet() } + set.add(dto.id) + } + } } } diff --git a/projects.properties b/projects.properties index 653d6072..b50e1e7b 100644 --- a/projects.properties +++ b/projects.properties @@ -1,2 +1,13 @@ -//=In order to share the module names with gradle and KtLinter, define module names are below -modules=example-app,mapconductor-for-here,mapconductor-for-mapbox,mapconductor-for-googlemaps,mapconductor-for-arcgis,mapconductor-core,mapconductor-icons,mapconductor-marker-strategy,mapconductor-marker-native-strategy,mapconductor-bom +# All modules +modules=example-app,\ + simple-map-app,\ + mapconductor-core,\ + mapconductor-icons,\ + mapconductor-marker-strategy,\ + mapconductor-marker-native-strategy,\ + mapconductor-bom,\ + mapconductor-for-here,\ + mapconductor-for-mapbox,\ + mapconductor-for-googlemaps,\ + mapconductor-for-arcgis,\ + mapconductor-for-maplibre diff --git a/settings.gradle.kts b/settings.gradle.kts index d8446881..8ac6041a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,11 +45,15 @@ rootProject.name = "MapConductorSDK" val modulesProp = rootDir .resolve("projects.properties") - .readLines() - .firstOrNull { it.startsWith("modules=") } - ?.removePrefix("modules=") - ?.split(",") - ?.map { it.trim() } - ?: emptyList() + .readText() + .lines() + .joinToString("") + .substringAfter("modules=") + .substringBefore("\n#") + .substringBefore("\nmodules.") + .replace("\\", "") + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } modulesProp.forEach { include(":$it") } diff --git a/simple-map-app/.gitignore b/simple-map-app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/simple-map-app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/simple-map-app/build.gradle.kts b/simple-map-app/build.gradle.kts new file mode 100644 index 00000000..81c202b2 --- /dev/null +++ b/simple-map-app/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + id("org.jlleitschuh.gradle.ktlint") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") version "2.0.1" +} + +ktlint { + android.set(true) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } +} + +android { + namespace = "com.mapconductor.simplemapapp" + compileSdk = 35 + + defaultConfig { + applicationId = "com.mapconductor.simplemapapp" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + buildConfig = true + } +} + +secrets { + propertiesFileName = "secrets.properties" + defaultPropertiesFileName = "local.defaults.properties" +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + debugImplementation(project(":mapconductor-core")) + + // Google Maps SDK + implementation(libs.play.services.maps) + debugImplementation(project(":mapconductor-for-googlemaps")) + + // Mapbox SDK + implementation(libs.mapbox.android) + debugImplementation(project(":mapconductor-for-mapbox")) + + // MapLibre SDK + implementation(libs.maplibre.sdk) + implementation(libs.maplibre.annotation) + debugImplementation(project(":mapconductor-for-maplibre")) + + // arcgis + debugImplementation(project(":mapconductor-for-arcgis")) + implementation(libs.arcgis.maps.kotlin) + implementation(platform(libs.arcgis.maps.kotlin.toolkit.bom)) + implementation(libs.arcgis.maps.kotlin.toolkit.geoview.compose) + implementation(libs.arcgis.maps.kotlin.toolkit.authentication) + + // Here Maps SDK + debugImplementation(project(":mapconductor-for-here")) + implementation( + fileTree( + mapOf( + "dir" to rootDir.resolve("libs").toString(), + "include" to arrayOf("heresdk*.jar", "heresdk*.aar"), + ), + ), + ) +} diff --git a/simple-map-app/proguard-rules.pro b/simple-map-app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/simple-map-app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/simple-map-app/src/main/AndroidManifest.xml b/simple-map-app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a412b4de --- /dev/null +++ b/simple-map-app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/simple-map-app/src/main/java/com/mapconductor/simplemapapp/MainActivity.kt b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/MainActivity.kt new file mode 100644 index 00000000..ef13c7aa --- /dev/null +++ b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/MainActivity.kt @@ -0,0 +1,88 @@ +package com.mapconductor.simplemapapp + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.mapconductor.core.circle.Circle +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPointImpl +import com.mapconductor.core.map.MapCameraPositionImpl +import com.mapconductor.core.marker.Marker +import com.mapconductor.core.marker.MarkerState +import com.mapconductor.maplibre.MapLibreMapView +import com.mapconductor.maplibre.rememberMapLibreMapViewState +import com.mapconductor.simplemapapp.ui.theme.MapConductorSDKTheme +import android.os.Bundle + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MapConductorSDKTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + MapView( + modifier = + Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } + } + } +} + +@Composable +fun MapView(modifier: Modifier = Modifier) { + val state = + rememberMapLibreMapViewState( + cameraPosition = + MapCameraPositionImpl( + position = GeoPointImpl.fromLatLong(21.324513, -157.925074), + zoom = 5.0, + ), + ) + + val markerState = + remember { + MarkerState( + position = GeoPointImpl.fromLatLong(21.324513, -157.925074), + draggable = true, + ) + } + + val circleState = + remember { + CircleState( + id = "demo-circle", + center = markerState.position, + radiusMeters = 5000.0, + strokeColor = Color.Magenta, + strokeWidth = 2.dp, + fillColor = Color.Cyan.copy(alpha = 0.3f), + geodesic = true, + ) + } + + MapLibreMapView( + modifier = modifier, + state = state, + onMarkerDrag = { draggedState -> + circleState.center = draggedState.position + }, + ) { + Marker(markerState) + Circle(circleState) + } +} diff --git a/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Color.kt b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Color.kt new file mode 100644 index 00000000..673e4105 --- /dev/null +++ b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.mapconductor.simplemapapp.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Theme.kt b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Theme.kt new file mode 100644 index 00000000..a7733d14 --- /dev/null +++ b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Theme.kt @@ -0,0 +1,59 @@ +package com.mapconductor.simplemapapp.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import android.os.Build + +private val DarkColorScheme = + darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80, + ) + +private val LightColorScheme = + lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40, + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ + ) + +@Composable +fun MapConductorSDKTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Type.kt b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Type.kt new file mode 100644 index 00000000..b6936445 --- /dev/null +++ b/simple-map-app/src/main/java/com/mapconductor/simplemapapp/ui/theme/Type.kt @@ -0,0 +1,36 @@ +package com.mapconductor.simplemapapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = + Typography( + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ + ) diff --git a/simple-map-app/src/main/res/drawable/ic_launcher_background.xml b/simple-map-app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/simple-map-app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/simple-map-app/src/main/res/drawable/ic_launcher_foreground.xml b/simple-map-app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..7706ab9e --- /dev/null +++ b/simple-map-app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/simple-map-app/src/main/res/mipmap-anydpi/ic_launcher.xml b/simple-map-app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..b3e26b4c --- /dev/null +++ b/simple-map-app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/simple-map-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/simple-map-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..b3e26b4c --- /dev/null +++ b/simple-map-app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/simple-map-app/src/main/res/mipmap-hdpi/ic_launcher.webp b/simple-map-app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/simple-map-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/simple-map-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/simple-map-app/src/main/res/mipmap-mdpi/ic_launcher.webp b/simple-map-app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/simple-map-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/simple-map-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/simple-map-app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/simple-map-app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/simple-map-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/simple-map-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/simple-map-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/simple-map-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/simple-map-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/simple-map-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/simple-map-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/simple-map-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/simple-map-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/simple-map-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/simple-map-app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/simple-map-app/src/main/res/values/colors.xml b/simple-map-app/src/main/res/values/colors.xml new file mode 100644 index 00000000..ca1931bc --- /dev/null +++ b/simple-map-app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/simple-map-app/src/main/res/values/strings.xml b/simple-map-app/src/main/res/values/strings.xml new file mode 100644 index 00000000..fd6cae79 --- /dev/null +++ b/simple-map-app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + simple-map-app + \ No newline at end of file diff --git a/simple-map-app/src/main/res/values/themes.xml b/simple-map-app/src/main/res/values/themes.xml new file mode 100644 index 00000000..306c0ee6 --- /dev/null +++ b/simple-map-app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +