diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dcfd99ef..3faa5860 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -51,7 +51,9 @@ "Bash(xcodebuild -workspace MapConductorSampleApp.xcworkspace -scheme MapConductorSampleApp -destination 'generic/platform=iOS Simulator' build)", "Bash(git -C /Users/masashi/android-sdk/android-geojson-layer log --oneline bfa194c07a..e75c70bae)", "Bash(git -C /Users/masashi/android-sdk/android-geojson-layer log --oneline e75c70bae..bfa194c07a)", - "Bash(git *)" + "Bash(git *)", + "WebFetch(domain:repo1.maven.org)", + "WebFetch(domain:central.sonatype.com)" ], "deny": [], "ask": [] diff --git a/README.es-419.md b/README.es-419.md index 32da9af5..b64d2364 100644 --- a/README.es-419.md +++ b/README.es-419.md @@ -166,6 +166,8 @@ Este ejemplo usa MapLibre Maps, pero los objetos del mapa están escritos usando ## Cambiar de proveedor de mapas +![](docs/src/assets/top-page/unified-map-view.png) + Una de las ideas principales detrás de MapConductor es que tus superposiciones de mapa no deberían tener que reescribirse cuando cambias de proveedor de mapas. Por ejemplo: diff --git a/README.ja.md b/README.ja.md index a1fc0d65..9a2bcaa8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -166,6 +166,8 @@ fun SimpleMapScreen(modifier: Modifier) { ## マッププロバイダーの切り替え +![](docs/src/assets/top-page/unified-map-view.png) + MapConductorの主な考え方の1つは、マッププロバイダーを変更してもマップオーバーレイを書き直す必要がないということです。 例: diff --git a/README.md b/README.md index 7c16f8b5..3474793b 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,8 @@ This example uses MapLibre Maps, but the map objects are written using MapConduc ## Switching Map Providers +![](docs/src/assets/top-page/unified-map-view.png) + One of the main ideas behind MapConductor is that your map overlays should not have to be rewritten when you change map providers. For example: diff --git a/android-for-arcgis b/android-for-arcgis index 9d3d6be2..d8c96663 160000 --- a/android-for-arcgis +++ b/android-for-arcgis @@ -1 +1 @@ -Subproject commit 9d3d6be2985903827b2f035c04096116418a7170 +Subproject commit d8c96663d08721f08eb377e5ba47a07c3a9c1c70 diff --git a/android-for-googlemaps b/android-for-googlemaps index 4d13211e..e7a27302 160000 --- a/android-for-googlemaps +++ b/android-for-googlemaps @@ -1 +1 @@ -Subproject commit 4d13211e12622bd6faedf1cdf84c001349e81070 +Subproject commit e7a2730207835d6ba98defe900283bdb10b8d2d8 diff --git a/android-for-here b/android-for-here index 9b612224..ea78b543 160000 --- a/android-for-here +++ b/android-for-here @@ -1 +1 @@ -Subproject commit 9b612224144dd425d1f85e43ab36f7533b616eed +Subproject commit ea78b543f078b32f53f78882d94942fda23ce687 diff --git a/android-for-mapbox b/android-for-mapbox index 4da7f784..b38d7ebc 160000 --- a/android-for-mapbox +++ b/android-for-mapbox @@ -1 +1 @@ -Subproject commit 4da7f7842758062422f1b8f85a93c5c5e5b17fec +Subproject commit b38d7ebccf3006ca74ae60832a6adef312b9be23 diff --git a/android-for-maplibre b/android-for-maplibre index 54fc47c4..a7e31f56 160000 --- a/android-for-maplibre +++ b/android-for-maplibre @@ -1 +1 @@ -Subproject commit 54fc47c4c9d8127dc6e2d3e962f9cb6a4988e2c1 +Subproject commit a7e31f5682735231490c7637c605ae91e251bf49 diff --git a/android-geojson-layer b/android-geojson-layer index 55158170..377049ee 160000 --- a/android-geojson-layer +++ b/android-geojson-layer @@ -1 +1 @@ -Subproject commit 55158170d5128b0c4e943fb226d934dbd0bb3269 +Subproject commit 377049eeed59038e36ee589ae0be3c09c99e7703 diff --git a/android-heatmap b/android-heatmap index f04f128f..1670c634 160000 --- a/android-heatmap +++ b/android-heatmap @@ -1 +1 @@ -Subproject commit f04f128f6a7a73b47cc3e6a2f04b23b888b5983b +Subproject commit 1670c634bdb0dc4f9e95f95c3a6a75ff90bfda65 diff --git a/android-icons b/android-icons index b15c5ac9..e35a3bd0 160000 --- a/android-icons +++ b/android-icons @@ -1 +1 @@ -Subproject commit b15c5ac948fca3c65936c5816126f237df866e85 +Subproject commit e35a3bd0b3d1a6a310af08b76865903a5e6377ab diff --git a/android-marker-clustering b/android-marker-clustering index b10fdacb..e90554c1 160000 --- a/android-marker-clustering +++ b/android-marker-clustering @@ -1 +1 @@ -Subproject commit b10fdacb70aadde6f8237caf24ee09b2021fd254 +Subproject commit e90554c19103641e22cf3b31480a88e600393509 diff --git a/android-sdk-core b/android-sdk-core index e4fb21d0..01460875 160000 --- a/android-sdk-core +++ b/android-sdk-core @@ -1 +1 @@ -Subproject commit e4fb21d03a55977c805dc5d62679d6e09e6e6f4c +Subproject commit 0146087581d6b6ef921b58d79a290aa7031fdf47 diff --git a/docs/src/assets/top-page/unified-map-view.png b/docs/src/assets/top-page/unified-map-view.png new file mode 100644 index 00000000..2c5a5423 Binary files /dev/null and b/docs/src/assets/top-page/unified-map-view.png differ diff --git a/docs/src/components/index/Features.astro b/docs/src/components/index/Features.astro index f7820b9b..a60a8f24 100644 --- a/docs/src/components/index/Features.astro +++ b/docs/src/components/index/Features.astro @@ -85,7 +85,7 @@ const { {descriptionForUnifiedAPI} - Marker + Mapview {descriptionForMarker} diff --git a/example-app/src/main/assets/cluster_red.png b/example-app/src/main/assets/cluster_red.png new file mode 100644 index 00000000..7fd5c60d Binary files /dev/null and b/example-app/src/main/assets/cluster_red.png differ diff --git a/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapComponent.kt b/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapComponent.kt index 3d7bdbb4..97a9eee5 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapComponent.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapComponent.kt @@ -1,12 +1,30 @@ package com.mapconductor.example.pages.circle +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.text.BasicText 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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp import com.mapconductor.core.circle.Circle import com.mapconductor.core.circle.CircleState import com.mapconductor.core.map.MapViewStateInterface +import com.mapconductor.core.map.OnCameraMoveHandler import com.mapconductor.core.marker.Marker import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.polyline.Polyline +import com.mapconductor.core.polyline.PolylineState import com.mapconductor.example.MapViewContainer @Composable @@ -15,21 +33,67 @@ fun CircleMapComponent( circleState: CircleState, centerMarker: MarkerState, edgeMarker: MarkerState, + labelPosition: IntOffset?, modifier: Modifier = Modifier, + onMapCameraMove: OnCameraMoveHandler = { }, ) { - mapViewState?.let { it -> + var labelSize by remember { mutableStateOf(IntSize.Zero) } + + mapViewState?.let { state -> MapViewContainer( modifier = modifier, - state = it, + state = state, + onCameraMove = onMapCameraMove, ) { // Circle Circle(circleState) + // Radius line polygon from center (C) to edge (E) + // Stable id prevents multiple polygons accumulating during rapid drag + Polyline( + PolylineState( + points = listOf(centerMarker.position, edgeMarker.position), + id = "circle-radius-line", + strokeColor = Color.White, + strokeWidth = 3.dp, + ), + ) + // Center marker (not draggable) Marker(centerMarker) // Edge marker (draggable) Marker(edgeMarker) + + Box(modifier = Modifier.fillMaxSize()) { + labelPosition?.let { pos -> + Box( + modifier = Modifier + .onGloballyPositioned { labelSize = it.size } + .offset { + IntOffset( + pos.x - labelSize.width / 2, + pos.y - labelSize.height / 2 + ) + }, + ) { + val labelText = "${circleState.radiusMeters.toInt()} m" + // White outline + BasicText( + text = labelText, + style = TextStyle( + color = Color.White, + drawStyle = Stroke(width = 6f), + ), + ) + // Black fill + BasicText( + text = labelText, + style = TextStyle(color = Color.Red), + ) + } + } + } } } } diff --git a/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapPage.kt b/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapPage.kt index 4b0e884d..e74c6a58 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapPage.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/circle/CircleMapPage.kt @@ -28,12 +28,15 @@ fun CircleMapPage(onToggleSidebar: () -> Unit = {}) { onMapViewStateChanged = viewModel::onMapViewChanged, ) { paddingValues -> val mapViewState = viewModel.mapViewState.collectAsState() + val labelPosition = viewModel.labelPosition.collectAsState() CircleMapComponent( mapViewState = mapViewState.value, circleState = viewModel.circleState, centerMarker = viewModel.centerMarker, edgeMarker = viewModel.edgeMarker, + labelPosition = labelPosition.value, + onMapCameraMove = viewModel::onMapCameraMove, ) MessageCard( diff --git a/example-app/src/main/java/com/mapconductor/example/pages/circle/CirclePageViewModel.kt b/example-app/src/main/java/com/mapconductor/example/pages/circle/CirclePageViewModel.kt index fae697e7..29eb1d7b 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/circle/CirclePageViewModel.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/circle/CirclePageViewModel.kt @@ -6,8 +6,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.arcgismaps.mapping.view.Camera +import com.arcgismaps.mapping.view.Offset import com.mapconductor.core.circle.CircleEvent import com.mapconductor.core.circle.CircleState import com.mapconductor.core.features.GeoPoint @@ -15,18 +19,26 @@ import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapViewStateInterface import com.mapconductor.core.marker.DefaultMarkerIcon import com.mapconductor.core.marker.MarkerState +import com.mapconductor.core.spherical.Spherical import com.mapconductor.core.spherical.WGS84Geodesic.computeDistanceBetween import com.mapconductor.core.spherical.calculatePositionAtDistance import com.mapconductor.example.toast.ToastMessage +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch interface CirclePageViewModelInterface { val initCameraPosition: MapCameraPosition val mapViewState: StateFlow?> val messages: StateFlow> + val labelPosition: StateFlow + val circleCenter: GeoPoint val radiusMeters: Double val centerMarker: MarkerState @@ -37,9 +49,7 @@ interface CirclePageViewModelInterface { fun onMapViewChanged(state: MapViewStateInterface<*>) - fun onMarkerClick(clicked: MarkerState) - - fun onMapClick(clicked: GeoPoint) + fun onMapCameraMove(cameraPosition: MapCameraPosition) fun onCircleClick(event: CircleEvent) @@ -50,11 +60,17 @@ interface CirclePageViewModelInterface { fun removeToast(toastMessage: ToastMessage) } -class CirclePageViewModel : +class CirclePageViewModel( + private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main), +) : ViewModel(), CirclePageViewModelInterface { private val _messages: MutableStateFlow> = MutableStateFlow(emptyList()) override val messages: StateFlow> = _messages.asStateFlow() + + private val _labelPosition: MutableStateFlow = MutableStateFlow(null) + override val labelPosition: StateFlow = _labelPosition.asStateFlow() + private val colors: List = listOf( Color.Blue, @@ -96,33 +112,46 @@ class CirclePageViewModel : draggable = false, ) - private val _edgeMarker: MutableState = - mutableStateOf( - MarkerState( - id = "edge_marker", - position = - calculatePositionAtDistance( - center = circleCenter, - distanceMeters = 1000.0, - bearingDegrees = 90.0, // East - ), - icon = - DefaultMarkerIcon( - fillColor = Color.Green, - strokeColor = Color.White, - label = "E", - ), - draggable = true, - onDragStart = this::onMarkerMove, - onDrag = this::onMarkerMove, - onDragEnd = this::onMarkerMove, + override val edgeMarker: MarkerState = MarkerState( + id = "edge_marker", + position = + calculatePositionAtDistance( + center = circleCenter, + distanceMeters = 1000.0, + bearingDegrees = 90.0, // East ), - ) - override val edgeMarker: MarkerState - get() = _edgeMarker.value + icon = + DefaultMarkerIcon( + fillColor = Color.Green, + strokeColor = Color.White, + label = "E", + ), + draggable = true, + onDragStart = this::onMarkerMove, + onDrag = this::onMarkerMove, + onDragEnd = this::onMarkerMove, + ) override val radiusMeters by derivedStateOf { - computeDistanceBetween(circleCenter, _edgeMarker.value.position) + computeDistanceBetween(circleCenter, edgeMarker.position) + } + + private fun calculateLabelPosition() { + mainScope.launch { + mapViewState.value?.getMapViewHolder()?.let { holder -> + val midPoint = Spherical.linearInterpolate( + from = centerMarker.position, + to = edgeMarker.position, + fraction = 0.5, + ) + holder.toScreenOffset(midPoint)?.let { + _labelPosition.value = IntOffset( + x = it.x.roundToInt(), + y = it.y.roundToInt(), + ) + } + } + } } override val circleState: CircleState @@ -147,12 +176,8 @@ class CirclePageViewModel : this._mapViewState.value = state } - override fun onMarkerClick(clicked: MarkerState) { - showToast("${clicked.icon?.let { (it as? DefaultMarkerIcon)?.label } ?: "Marker"} clicked") - } - - override fun onMapClick(clicked: GeoPoint) { - showToast("Map clicked at: ${clicked.toUrlValue()}") + override fun onMapCameraMove(cameraPosition: MapCameraPosition) { + this.calculateLabelPosition() } override fun onCircleClick(event: CircleEvent) { @@ -161,7 +186,8 @@ class CirclePageViewModel : } override fun onMarkerMove(dragged: MarkerState) { - _edgeMarker.value.position = dragged.position + edgeMarker.position = dragged.position + this.calculateLabelPosition() } override fun showToast(text: String) { diff --git a/example-app/src/main/java/com/mapconductor/example/pages/marker/postofficecluster/MarkerClusterMapPage.kt b/example-app/src/main/java/com/mapconductor/example/pages/marker/postofficecluster/MarkerClusterMapPage.kt index 354c7e5f..c3921746 100644 --- a/example-app/src/main/java/com/mapconductor/example/pages/marker/postofficecluster/MarkerClusterMapPage.kt +++ b/example-app/src/main/java/com/mapconductor/example/pages/marker/postofficecluster/MarkerClusterMapPage.kt @@ -1,5 +1,12 @@ -package com.mapconductor.example.pages.marker.postofficecluster +package com.mapconductor.example.pages.marker.postofficecluster +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import androidx.collection.LruCache import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -7,13 +14,18 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import com.mapconductor.arcgis.ArcGISActualMarker import com.mapconductor.arcgis.map.ArcGISMapViewStateInterface +import com.mapconductor.core.marker.ColorDefaultIcon import com.mapconductor.core.marker.ImageIcon +import com.mapconductor.core.marker.MarkerIconInterface import com.mapconductor.example.ui.DefaultMapViewItems import com.mapconductor.example.ui.DemoMapPageScaffold import com.mapconductor.googlemaps.GoogleMapActualMarker @@ -27,6 +39,7 @@ import com.mapconductor.maplibre.MapLibreViewStateInterface import com.mapconductor.marker.clustering.MarkerClusterGroupState import com.mapconductor.postoffice.PostOfficeDataLoader import com.mapconductor.utils.LoadingDialog +import androidx.core.graphics.drawable.toDrawable @Composable fun MarkerClusterMapPage( @@ -35,25 +48,43 @@ fun MarkerClusterMapPage( ) { val context = LocalContext.current val dataLoader = remember { PostOfficeDataLoader(context) } + val clusterIconProvider: (Int) -> MarkerIconInterface = remember(context) { + val bitmap = runCatching { + context.assets.open("cluster_red.png").use { BitmapFactory.decodeStream(it) } + }.getOrNull() + val cache = ClusterIconLruCache(maxSize = 128) + val provider: (Int) -> MarkerIconInterface = { count -> + bitmap?.let { image -> + cache.getOrCreate(clusterCountLabel(count)) { label -> + ImageIcon( + image = drawClusterIcon(background = image, label = label).toDrawable(context.resources), + anchor = Offset(0.5f, 0.5f), + ) + } + } ?: ColorDefaultIcon(label = clusterCountLabel(count)) + } + provider + } val googleClusterState = remember { MarkerClusterGroupState( + clusterIconProvider = clusterIconProvider, enableZoomAnimation = true, enablePanAnimation = true, - debugHullPolygons = false, ) } val mapboxClusterState = remember { MarkerClusterGroupState( + clusterIconProvider = clusterIconProvider, enableZoomAnimation = true, enablePanAnimation = true, - debugHullPolygons = false, ) } val hereClusterState = remember { MarkerClusterGroupState( + clusterIconProvider = clusterIconProvider, enableZoomAnimation = true, enablePanAnimation = true, debugHullPolygons = false, @@ -62,14 +93,16 @@ fun MarkerClusterMapPage( val arcgisClusterState = remember { MarkerClusterGroupState( + clusterIconProvider = clusterIconProvider, enableZoomAnimation = true, enablePanAnimation = true, - debugHullPolygons = false, + debugHullPolygons = true, ) } val maplibreClusterState = remember { MarkerClusterGroupState( + clusterIconProvider = clusterIconProvider, enableZoomAnimation = true, enablePanAnimation = true, debugHullPolygons = false, @@ -173,3 +206,50 @@ fun MarkerClusterMapPage( } } } + +private const val CLUSTER_LABEL_TEXT_SIZE_PX = 170f +private val CLUSTER_LABEL_RECT = Rect(15, 20, 497, 184) + +private fun clusterCountLabel(count: Int): String = + when { + count > 1_000 -> "1k+" + count > 200 -> "200+" + count > 100 -> "100+" + else -> count.toString() + } + +private fun drawClusterIcon( + background: Bitmap, + label: String, +): Bitmap { + val bitmap = background.copy(Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(bitmap) + val textPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.White.toArgb() + textSize = CLUSTER_LABEL_TEXT_SIZE_PX + textAlign = Paint.Align.CENTER + isSubpixelText = true + typeface = android.graphics.Typeface.DEFAULT_BOLD + } + + val fontMetrics = textPaint.fontMetrics + val baseline = CLUSTER_LABEL_RECT.centerY() - (fontMetrics.ascent + fontMetrics.descent) / 2f + canvas.drawText(label, CLUSTER_LABEL_RECT.centerX().toFloat(), baseline, textPaint) + return bitmap +} + +private class ClusterIconLruCache( + maxSize: Int, +) { + private val cache = LruCache(maxSize) + + @Synchronized + fun getOrCreate( + key: String, + create: (String) -> MarkerIconInterface, + ): MarkerIconInterface { + cache.get(key)?.let { return it } + return create(key).also { cache.put(key, it) } + } +} diff --git a/images/ESRI_Logo.svg b/images/ESRI_Logo.svg deleted file mode 100644 index abf95b05..00000000 --- a/images/ESRI_Logo.svg +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/images/google-maps.svg b/images/google-maps.svg deleted file mode 100644 index ca9ed644..00000000 --- a/images/google-maps.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - diff --git a/images/here.svg b/images/here.svg deleted file mode 100644 index b97ed476..00000000 --- a/images/here.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/images/logo.png b/images/logo.png deleted file mode 100644 index 45349202..00000000 Binary files a/images/logo.png and /dev/null differ diff --git a/images/logo.svg b/images/logo.svg deleted file mode 100644 index f0e847f9..00000000 --- a/images/logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/images/mapbox.svg b/images/mapbox.svg deleted file mode 100644 index 7362745e..00000000 --- a/images/mapbox.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/mapconductor-bom/gradle.properties b/mapconductor-bom/gradle.properties index b3f2adcb..ee389ca9 100644 --- a/mapconductor-bom/gradle.properties +++ b/mapconductor-bom/gradle.properties @@ -1 +1 @@ -libraryVersion=1.1.7 +libraryVersion=1.1.8