diff --git a/demo/maps/src/jvmMain/kotlin/AngleConversion.kt b/demo/maps/src/jvmMain/kotlin/AngleConversion.kt index a7ec5e5..d6c972a 100644 --- a/demo/maps/src/jvmMain/kotlin/AngleConversion.kt +++ b/demo/maps/src/jvmMain/kotlin/AngleConversion.kt @@ -2,3 +2,5 @@ import kotlin.math.PI fun Double.toDegrees() = this * 180 / PI +fun Double.toRadians() = this * PI / 180 + diff --git a/demo/maps/src/jvmMain/kotlin/Main.kt b/demo/maps/src/jvmMain/kotlin/Main.kt index 9660683..e4f7955 100644 --- a/demo/maps/src/jvmMain/kotlin/Main.kt +++ b/demo/maps/src/jvmMain/kotlin/Main.kt @@ -10,9 +10,7 @@ import androidx.compose.ui.graphics.PointMode import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import center.sciprog.maps.compose.* -import center.sciprog.maps.coordinates.Distance -import center.sciprog.maps.coordinates.GeodeticMapCoordinates -import center.sciprog.maps.coordinates.MapViewPoint +import center.sciprog.maps.coordinates.* import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import kotlinx.coroutines.delay @@ -52,24 +50,11 @@ fun App() { val pointOne = 55.568548 to 37.568604 var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) } val pointThree = 60.929444 to 37.518434 - MapView( - mapTileProvider = mapTileProvider, - initialViewPoint = viewPoint, - config = MapViewConfig( - inferViewBoxFromFeatures = true, - onViewChange = { centerCoordinates = focus }, - onDrag = { start, end -> - if (start.focus.latitude.toDegrees() in (pointTwo.first - 0.05)..(pointTwo.first + 0.05) && - start.focus.longitude.toDegrees() in (pointTwo.second - 0.05)..(pointTwo.second + 0.05) - ) { - pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to - pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees() - false// returning false, because when we are dragging circle we don't want to drag map - } else true - } - ) - ) { + val state = MapViewState( + mapTileProvider = mapTileProvider, + initialViewPoint = { viewPoint }, + ) { image(pointOne, Icons.Filled.Home) points( @@ -89,7 +74,19 @@ fun App() { centerCoordinates = pointTwo, ) - draw(position = pointThree) { + draw( + position = pointThree, + getBoundingBox = { + GmcBox.withCenter( + center = GeodeticMapCoordinates.ofDegrees( + pointThree.first, + pointThree.second + ), + height = Distance(0.001), + width = Distance(0.001) + ) + } + ) { drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red) drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red) } @@ -118,6 +115,29 @@ fun App() { } } } + + val config = MapViewConfig( + onViewChange = { centerCoordinates = focus }, + onDrag = { start, end -> + val markerRadius = 5f + val startPosition = with(state) { start.focus.toOffset(this@MapViewConfig) } + val markerLocation = with(state) { + GeodeticMapCoordinates.ofDegrees(pointTwo.first, pointTwo.second).toOffset(this@MapViewConfig) + } + if (startPosition.x in (markerLocation.x - markerRadius)..(markerLocation.x + markerRadius) && + startPosition.y in (markerLocation.y - markerRadius)..(markerLocation.y + markerRadius) + ) { + pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to + pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees() + false// returning false, because when we are dragging circle we don't want to drag map + } else true + } + ) + + MapView( + mapViewState = state, + mapViewConfig = config + ) } } diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt index 202e30b..cb7a077 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeature.kt @@ -41,12 +41,10 @@ public class MapFeatureSelector( public class MapDrawFeature( public val position: GeodeticMapCoordinates, override val zoomRange: IntRange = defaultZoomRange, + private val computeBoundingBox: (zoom: Int) -> GmcBox, public val drawFeature: DrawScope.() -> Unit, ) : MapFeature { - override fun getBoundingBox(zoom: Int): GmcBox { - //TODO add box computation - return GmcBox(position, position) - } + override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom) } public class MapPointsFeature( diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt index e916f12..14ca946 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapFeatureBuilder.kt @@ -72,8 +72,9 @@ public fun MapFeatureBuilder.draw( position: Pair, zoomRange: IntRange = defaultZoomRange, id: FeatureId? = null, + getBoundingBox: (Int) -> GmcBox, drawFeature: DrawScope.() -> Unit, -): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature)) +): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, getBoundingBox, drawFeature)) public fun MapFeatureBuilder.line( aCoordinates: Pair, @@ -143,4 +144,14 @@ public fun MapFeatureBuilder.group( val map = MapFeatureBuilderImpl(emptyMap()).apply(builder).build() val feature = MapFeatureGroup(map, zoomRange) return addFeature(id, feature) -} \ No newline at end of file +} + +public fun MapFeatureBuilder.featureSelector( + id: FeatureId? = null, + onSelect: MapFeatureBuilder.(zoom: Int) -> MapFeature +): FeatureId = addFeature( + id = id, + feature = MapFeatureSelector( + selector = { onSelect(this, it) } + ) +) \ No newline at end of file diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt similarity index 84% rename from maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt rename to maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt index fef40fb..9e4a187 100644 --- a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapTextFeature.kt @@ -1,10 +1,15 @@ package center.sciprog.maps.compose import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.NativeCanvas import center.sciprog.maps.coordinates.GeodeticMapCoordinates import center.sciprog.maps.coordinates.GmcBox -import org.jetbrains.skia.Font +public expect class Font constructor() { + public var size: Float +} + +public expect fun NativeCanvas.drawString(text: String, x: Float, y: Float, font: Font, color: Color) public class MapTextFeature( public val position: GeodeticMapCoordinates, diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt index 0dc4fd4..e97169d 100644 --- a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapView.kt @@ -3,6 +3,7 @@ package center.sciprog.maps.compose import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import center.sciprog.maps.coordinates.* import kotlin.math.PI @@ -17,9 +18,8 @@ import kotlin.math.min */ public data class MapViewConfig( val zoomSpeed: Double = 1.0 / 3.0, - val inferViewBoxFromFeatures: Boolean = false, val onClick: MapViewPoint.() -> Unit = {}, - val onDrag: (start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true }, + val onDrag: Density.(start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true }, val onViewChange: MapViewPoint.() -> Unit = {}, val onSelect: (GmcBox) -> Unit = {}, val zoomOnSelect: Boolean = true, @@ -28,11 +28,9 @@ public data class MapViewConfig( @Composable public expect fun MapView( - mapTileProvider: MapTileProvider, - computeViewPoint: (canvasSize: DpSize) -> MapViewPoint, - features: Map, - config: MapViewConfig = MapViewConfig(), - modifier: Modifier = Modifier.fillMaxSize(), + modifier: Modifier = Modifier, + mapViewState: MapViewState, + mapViewConfig: MapViewConfig, ) @Composable @@ -47,23 +45,26 @@ public fun MapView( val featuresBuilder = MapFeatureBuilderImpl(features) featuresBuilder.buildFeatures() MapView( - mapTileProvider, - { initialViewPoint }, - featuresBuilder.build(), - config, - modifier + mapViewState = MapViewState( + mapTileProvider = mapTileProvider, + initialViewPoint = { initialViewPoint }, + features = featuresBuilder.build(), + ), + mapViewConfig = config, + modifier = modifier ) } -internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint = { canvasSize -> - val zoom = log2( - min( - canvasSize.width.value / width, - canvasSize.height.value / height - ) * PI / mapTileProvider.tileSize - ) - MapViewPoint(center, zoom) -} +internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint = + { canvasSize -> + val zoom = log2( + min( + canvasSize.width.value / width, + canvasSize.height.value / height + ) * PI / mapTileProvider.tileSize + ) + MapViewPoint(center, zoom) + } @Composable public fun MapView( @@ -77,10 +78,12 @@ public fun MapView( val featuresBuilder = MapFeatureBuilderImpl(features) featuresBuilder.buildFeatures() MapView( - mapTileProvider, - box.computeViewPoint(mapTileProvider), - featuresBuilder.build(), - config, - modifier + mapViewState = MapViewState( + mapTileProvider = mapTileProvider, + features = featuresBuilder.build(), + initialViewPoint = box.computeViewPoint(mapTileProvider), + ), + modifier = modifier, + mapViewConfig = config, ) } \ No newline at end of file diff --git a/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapViewState.kt b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapViewState.kt new file mode 100644 index 0000000..d5622a4 --- /dev/null +++ b/maps-kt-compose/src/commonMain/kotlin/center/sciprog/maps/compose/MapViewState.kt @@ -0,0 +1,181 @@ +package center.sciprog.maps.compose + +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import center.sciprog.maps.coordinates.* +import mu.KotlinLogging +import kotlin.math.* + +@Composable +public fun MapViewState( + initialViewPoint: (canvasSize: DpSize) -> MapViewPoint, + mapTileProvider: MapTileProvider, + features: Map = emptyMap(), + inferViewBoxFromFeatures: Boolean = false, + buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {}, +): MapViewState { + val featuresBuilder = MapFeatureBuilderImpl(features) + featuresBuilder.buildFeatures() + return MapViewState( + initialViewPoint = initialViewPoint, + mapTileProvider = mapTileProvider, + features = featuresBuilder.build(), + inferViewBoxFromFeatures = inferViewBoxFromFeatures + ) +} + +public class MapViewState( + public val initialViewPoint: (canvasSize: DpSize) -> MapViewPoint, + public val mapTileProvider: MapTileProvider, + public val features: Map = emptyMap(), + inferViewBoxFromFeatures: Boolean = false, +) { + public var canvasSize: DpSize by mutableStateOf(DpSize(512.dp, 512.dp)) + public var viewPointInternal: MapViewPoint? by mutableStateOf(null) + public val viewPoint: MapViewPoint by derivedStateOf { + viewPointInternal ?: if (inferViewBoxFromFeatures) { + features.values.computeBoundingBox(1)?.let { box -> + val zoom = log2( + min( + canvasSize.width.value / box.width, + canvasSize.height.value / box.height + ) * PI / mapTileProvider.tileSize + ) + MapViewPoint(box.center, zoom) + } ?: initialViewPoint(canvasSize) + } else { + initialViewPoint(canvasSize) + } + } + public val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() } + + public val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) } + + public val mapTiles: SnapshotStateList = mutableStateListOf() + + public val centerCoordinates: WebMercatorCoordinates by derivedStateOf { + WebMercatorProjection.toMercator( + viewPoint.focus, + zoom + ) + } + + public fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates( + zoom, + (x - canvasSize.width / 2).value / tileScale + centerCoordinates.x, + (y - canvasSize.height / 2).value / tileScale + centerCoordinates.y, + ) + + /* + * Convert screen independent offset to GMC, adjusting for fractional zoom + */ + public fun DpOffset.toGeodetic(): GeodeticMapCoordinates = + with(this@MapViewState) { WebMercatorProjection.toGeodetic(toMercator()) } + + // Selection rectangle. If null - no selection + public var selectRect: Rect? by mutableStateOf(null) + + public fun WebMercatorCoordinates.toOffset(density: Density): Offset = + with(density) { + with(this@MapViewState) { + Offset( + (canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(), + (canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx() + ) + } + } + + //Convert GMC to offset in pixels (not DP), adjusting for zoom + public fun GeodeticMapCoordinates.toOffset(density: Density): Offset = + WebMercatorProjection.toMercator(this, zoom).toOffset(density) + + private val logger = KotlinLogging.logger("MapViewState") + + public fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) { + when (feature) { + is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom)) + is MapCircleFeature -> drawCircle( + feature.color, + feature.size, + center = feature.center.toOffset(this@drawFeature) + ) + is MapRectangleFeature -> drawRect( + feature.color, + topLeft = feature.center.toOffset(this@drawFeature) - Offset( + feature.size.width.toPx() / 2, + feature.size.height.toPx() / 2 + ), + size = feature.size.toSize() + ) + is MapLineFeature -> drawLine( + feature.color, + feature.a.toOffset(this@drawFeature), + feature.b.toOffset(this@drawFeature) + ) + is MapArcFeature -> { + val topLeft = feature.oval.topLeft.toOffset(this@drawFeature) + val bottomRight = feature.oval.bottomRight.toOffset(this@drawFeature) + + val path = Path().apply { + addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle) + } + + drawPath(path, color = feature.color, style = Stroke()) + + } + is MapBitmapImageFeature -> drawImage( + image = feature.image, + topLeft = feature.position.toOffset(this@drawFeature) + ) + is MapVectorImageFeature -> { + val offset = feature.position.toOffset(this@drawFeature) + val size = feature.size.toSize() + translate(offset.x - size.width / 2, offset.y - size.height / 2) { + with(feature.painter) { + draw(size) + } + } + } + is MapTextFeature -> drawIntoCanvas { canvas -> + val offset = feature.position.toOffset(this@drawFeature) + canvas.nativeCanvas.drawString( + feature.text, + offset.x + 5, + offset.y - 5, + Font().apply(feature.fontConfig), + feature.color + ) + } + is MapDrawFeature -> { + val offset = feature.position.toOffset(this) + translate(offset.x, offset.y) { + feature.drawFeature(this) + } + } + is MapFeatureGroup -> { + feature.children.values.forEach { + drawFeature(zoom, it) + } + } + else -> { + logger.error { "Unrecognized feature type: ${feature::class}" } + } + } + } +} + + + + diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeatureJvm.kt b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeatureJvm.kt new file mode 100644 index 0000000..b303332 --- /dev/null +++ b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapTextFeatureJvm.kt @@ -0,0 +1,23 @@ +package center.sciprog.maps.compose + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.NativeCanvas +import androidx.compose.ui.graphics.toArgb +import org.jetbrains.skia.Paint + +public actual typealias Font = org.jetbrains.skia.Font + +public actual fun NativeCanvas.drawString( + text: String, + x: Float, + y: Float, + font: Font, + color: Color +) { + drawString(text, x, y, font, color.toPaint()) +} + +private fun Color.toPaint(): Paint = Paint().apply { + isAntiAlias = true + color = toArgb() +} \ No newline at end of file diff --git a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt index 27f9496..74dc4f5 100644 --- a/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt +++ b/maps-kt-compose/src/jvmMain/kotlin/center/sciprog/maps/compose/MapViewJvm.kt @@ -17,15 +17,10 @@ import center.sciprog.maps.coordinates.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import mu.KotlinLogging -import org.jetbrains.skia.Font -import org.jetbrains.skia.Paint import kotlin.math.* -private fun Color.toPaint(): Paint = Paint().apply { - isAntiAlias = true - color = toArgb() -} + private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last) @@ -48,292 +43,171 @@ private val logger = KotlinLogging.logger("MapView") @Composable public actual fun MapView( - mapTileProvider: MapTileProvider, - computeViewPoint: (canvasSize: DpSize) -> MapViewPoint, - features: Map, - config: MapViewConfig, modifier: Modifier, + mapViewState: MapViewState, + mapViewConfig: MapViewConfig, ) { - var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) } + with(mapViewState) { + @OptIn(ExperimentalComposeUiApi::class) + val canvasModifier = modifier.pointerInput(Unit) { + forEachGesture { + awaitPointerEventScope { + fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) - var viewPointInternal: MapViewPoint? by remember { - mutableStateOf(null) - } - - if (config.resetViewPoint) { - viewPointInternal = null - } - - val viewPoint: MapViewPoint by derivedStateOf { - viewPointInternal ?: if (config.inferViewBoxFromFeatures) { - features.values.computeBoundingBox(1)?.let { box -> - val zoom = log2( - min( - canvasSize.width.value / box.width, - canvasSize.height.value / box.height - ) * PI / mapTileProvider.tileSize - ) - MapViewPoint(box.center, zoom) - } ?: computeViewPoint(canvasSize) - } else { - computeViewPoint(canvasSize) - } - } - - val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() } - - val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) } - - val mapTiles = remember { mutableStateListOf() } - - val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) } - - fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates( - zoom, - (x - canvasSize.width / 2).value / tileScale + centerCoordinates.x, - (y - canvasSize.height / 2).value / tileScale + centerCoordinates.y, - ) - - /* - * Convert screen independent offset to GMC, adjusting for fractional zoom - */ - fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator()) - - // Selection rectangle. If null - no selection - var selectRect by remember { mutableStateOf(null) } - - @OptIn(ExperimentalComposeUiApi::class) - val canvasModifier = modifier.pointerInput(Unit) { - forEachGesture { - awaitPointerEventScope { - fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp()) - - val event: PointerEvent = awaitPointerEvent() - event.changes.forEach { change -> - if (event.buttons.isPrimaryPressed) { - //Evaluating selection frame - if (event.keyboardModifiers.isShiftPressed) { - selectRect = Rect(change.position, change.position) - drag(change.id) { dragChange -> - selectRect?.let { rect -> - val offset = dragChange.position - selectRect = Rect( - min(offset.x, rect.left), - min(offset.y, rect.top), - max(offset.x, rect.right), - max(offset.y, rect.bottom) - ) + val event: PointerEvent = awaitPointerEvent() + event.changes.forEach { change -> + if (event.buttons.isPrimaryPressed) { + //Evaluating selection frame + if (event.keyboardModifiers.isShiftPressed) { + selectRect = Rect(change.position, change.position) + drag(change.id) { dragChange -> + selectRect?.let { rect -> + val offset = dragChange.position + selectRect = Rect( + min(offset.x, rect.left), + min(offset.y, rect.top), + max(offset.x, rect.right), + max(offset.y, rect.bottom) + ) + } } - } - selectRect?.let { rect -> - //Use selection override if it is defined - val gmcBox = GmcBox( - rect.topLeft.toDpOffset().toGeodetic(), - rect.bottomRight.toDpOffset().toGeodetic() - ) - config.onSelect(gmcBox) - if (config.zoomOnSelect) { - val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize) + selectRect?.let { rect -> + //Use selection override if it is defined + val gmcBox = GmcBox( + rect.topLeft.toDpOffset().toGeodetic(), + rect.bottomRight.toDpOffset().toGeodetic() + ) - config.onViewChange(newViewPoint) + mapViewConfig.onSelect(gmcBox) + if (mapViewConfig.zoomOnSelect) { + val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize) + + mapViewConfig.onViewChange(newViewPoint) + viewPointInternal = newViewPoint + } + selectRect = null + } + } else { + val dragStart = change.position + val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) + mapViewConfig.onClick( + MapViewPoint( + dpPos.toGeodetic() , + viewPoint.zoom + ) + ) + drag(change.id) { dragChange -> + val dragAmount = dragChange.position - dragChange.previousPosition + val dpStart = + DpOffset( + dragChange.previousPosition.x.toDp(), + dragChange.previousPosition.y.toDp() + ) + val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp()) + if (!mapViewConfig.onDrag( + this, + MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom), + MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom) + ) + ) return@drag + val newViewPoint = viewPoint.move( + -dragAmount.x.toDp().value / tileScale, + +dragAmount.y.toDp().value / tileScale + ) + mapViewConfig.onViewChange(newViewPoint) viewPointInternal = newViewPoint } - selectRect = null } - } else { - val dragStart = change.position - val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp()) - config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom)) - drag(change.id) { dragChange -> - val dragAmount = dragChange.position - dragChange.previousPosition - val dpStart = - DpOffset(dragChange.previousPosition.x.toDp(), dragChange.previousPosition.y.toDp()) - val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp()) - if (!config.onDrag( - MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom), - MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom) - ) - ) return@drag - val newViewPoint = viewPoint.move( - -dragAmount.x.toDp().value / tileScale, - +dragAmount.y.toDp().value / tileScale - ) - config.onViewChange(newViewPoint) - viewPointInternal = newViewPoint + } + } + } + } + }.onPointerEvent(PointerEventType.Scroll) { + val change = it.changes.first() + val (xPos, yPos) = change.position + //compute invariant point of translation + val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic() + val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * mapViewConfig.zoomSpeed, invariant) + mapViewConfig.onViewChange(newViewPoint) + viewPointInternal = newViewPoint + }.fillMaxSize() + + + // Load tiles asynchronously + LaunchedEffect(viewPoint, canvasSize) { + with(mapTileProvider) { + val indexRange = 0 until 2.0.pow(zoom).toInt() + + val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale + val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale + val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange) + + val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) + val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) + val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) + + mapTiles.clear() + + for (j in verticalIndices) { + for (i in horizontalIndices) { + val id = TileId(zoom, i, j) + //start all + val deferred = loadTileAsync(id) + //wait asynchronously for it to finish + launch { + try { + mapTiles += deferred.await() + } catch (ex: Exception) { + if (ex !is CancellationException) { + //displaying the error is maps responsibility + logger.error(ex) { "Failed to load tile with id=$id" } + } } } } } } } - }.onPointerEvent(PointerEventType.Scroll) { - val change = it.changes.first() - val (xPos, yPos) = change.position - //compute invariant point of translation - val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic() - val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant) - config.onViewChange(newViewPoint) - viewPointInternal = newViewPoint - }.fillMaxSize() - // Load tiles asynchronously - LaunchedEffect(viewPoint, canvasSize) { - with(mapTileProvider) { - val indexRange = 0 until 2.0.pow(zoom).toInt() - - val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale - val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale - val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange) - - val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale) - val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale) - val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange) - - mapTiles.clear() - - for (j in verticalIndices) { - for (i in horizontalIndices) { - val id = TileId(zoom, i, j) - //start all - val deferred = loadTileAsync(id) - //wait asynchronously for it to finish - launch { - try { - mapTiles += deferred.await() - } catch (ex: Exception) { - if (ex !is CancellationException) { - //displaying the error is maps responsibility - logger.error(ex) { "Failed to load tile with id=$id" } - } - } - } + Canvas(canvasModifier) { + if (mapViewState.canvasSize != size.toDpSize()) { + mapViewState.canvasSize = size.toDpSize() + logger.debug { "Recalculate canvas. Size: $size" } + } + clipRect { + val tileSize = IntSize( + ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(), + ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt() + ) + mapTiles.forEach { (id, image) -> + //converting back from tile index to screen offset + val offset = IntOffset( + (canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(), + (canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx() + ) + drawImage( + image = image, + dstOffset = offset, + dstSize = tileSize + ) } + features.values.filter { zoom in it.zoomRange }.forEach { feature -> + drawFeature(zoom, feature) + } + } + selectRect?.let { rect -> + drawRect( + color = Color.Blue, + topLeft = rect.topLeft, + size = rect.size, + alpha = 0.5f, + style = Stroke( + width = 2f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + ) } } } - - - Canvas(canvasModifier) { - fun WebMercatorCoordinates.toOffset(): Offset = Offset( - (canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(), - (canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx() - ) - - //Convert GMC to offset in pixels (not DP), adjusting for zoom - fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, zoom).toOffset() - - - fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) { - when (feature) { - is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom)) - is MapCircleFeature -> drawCircle( - feature.color, - feature.size, - center = feature.center.toOffset() - ) - is MapRectangleFeature -> drawRect( - feature.color, - topLeft = feature.center.toOffset() - Offset( - feature.size.width.toPx() / 2, - feature.size.height.toPx() / 2 - ), - size = feature.size.toSize() - ) - is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset()) - is MapArcFeature -> { - val topLeft = feature.oval.topLeft.toOffset() - val bottomRight = feature.oval.bottomRight.toOffset() - - val path = Path().apply { - addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle) - } - - drawPath(path, color = feature.color, style = Stroke()) - - } - is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset()) - is MapVectorImageFeature -> { - val offset = feature.position.toOffset() - val size = feature.size.toSize() - translate(offset.x - size.width / 2, offset.y - size.height / 2) { - with(feature.painter) { - draw(size) - } - } - } - is MapTextFeature -> drawIntoCanvas { canvas -> - val offset = feature.position.toOffset() - canvas.nativeCanvas.drawString( - feature.text, - offset.x + 5, - offset.y - 5, - Font().apply(feature.fontConfig), - feature.color.toPaint() - ) - } - is MapDrawFeature -> { - val offset = feature.position.toOffset() - translate(offset.x, offset.y) { - feature.drawFeature(this) - } - } - is MapFeatureGroup -> { - feature.children.values.forEach { - drawFeature(zoom, it) - } - } - is MapPointsFeature -> { - val points = feature.points.map { it.toOffset() } - drawPoints( - points = points, - color = feature.color, - strokeWidth = feature.stroke, - pointMode = feature.pointMode - ) - } - else -> { - logger.error { "Unrecognized feature type: ${feature::class}" } - } - } - } - - if (canvasSize != size.toDpSize()) { - canvasSize = size.toDpSize() - logger.debug { "Recalculate canvas. Size: $size" } - } - clipRect { - val tileSize = IntSize( - ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(), - ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt() - ) - mapTiles.forEach { (id, image) -> - //converting back from tile index to screen offset - val offset = IntOffset( - (canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(), - (canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx() - ) - drawImage( - image = image, - dstOffset = offset, - dstSize = tileSize - ) - } - features.values.filter { zoom in it.zoomRange }.forEach { feature -> - drawFeature(zoom, feature) - } - } - selectRect?.let { rect -> - drawRect( - color = Color.Blue, - topLeft = rect.topLeft, - size = rect.size, - alpha = 0.5f, - style = Stroke( - width = 2f, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) - ) - ) - } - } -} +} \ No newline at end of file