diff --git a/app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt b/app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt index 0fe87e1d..6cb98387 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,6 +42,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import ru.spbu.depnav.R import ru.spbu.depnav.ui.theme.DepNavTheme +import ru.spbu.depnav.ui.theme.ON_MAP_SURFACE_ALPHA private const val MIN_FLOOR = 1 @@ -54,7 +56,8 @@ fun FloorSwitch( ) { Surface( modifier = modifier, - shape = CircleShape + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = ON_MAP_SURFACE_ALPHA) ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { IconButton( diff --git a/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt b/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt index 85b9f230..e15aee0d 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt @@ -42,7 +42,9 @@ import androidx.compose.material.icons.rounded.Menu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable @@ -55,6 +57,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import ru.spbu.depnav.R import ru.spbu.depnav.ui.theme.DEFAULT_PADDING +import ru.spbu.depnav.ui.theme.ON_MAP_SURFACE_ALPHA import ru.spbu.depnav.ui.viewmodel.SearchResults // These are basically copied from SearchBar implementation @@ -107,6 +110,9 @@ fun MapSearchBar( val focusManager = LocalFocusManager.current + val containerColorAlpha = + ON_MAP_SURFACE_ALPHA + (1 - ON_MAP_SURFACE_ALPHA) * activationAnimationProgress + SearchBar( query = query, onQueryChange = onQueryChange, @@ -141,7 +147,12 @@ fun MapSearchBar( onClearClick = { onQueryChange("") }, modifier = Modifier.padding(end = innerEndPadding) ) - } + }, + colors = SearchBarDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = containerColorAlpha + ) + ) ) { val keyboard = LocalSoftwareKeyboardController.current diff --git a/app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt b/app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt index ce10c087..6ffe61e0 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt @@ -18,7 +18,6 @@ package ru.spbu.depnav.ui.component -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -29,7 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import ru.spbu.depnav.R -private val SIZE = 30.dp +val PIN_SIZE = 30.dp /** Pin for highlighting map markers. */ @Composable @@ -38,8 +37,7 @@ fun Pin(modifier: Modifier = Modifier) { painter = painterResource(R.drawable.pin), contentDescription = stringResource(R.string.label_selected_place), modifier = Modifier - .size(SIZE) - .offset(y = -SIZE / 2) + .size(PIN_SIZE) .then(modifier), tint = MaterialTheme.colorScheme.primary ) diff --git a/app/src/main/java/ru/spbu/depnav/ui/component/PinPointer.kt b/app/src/main/java/ru/spbu/depnav/ui/component/PinPointer.kt new file mode 100644 index 00000000..833b7b93 --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/ui/component/PinPointer.kt @@ -0,0 +1,177 @@ +/** + * DepNav -- department navigator. + * Copyright (C) 2024 Timofei Pushkin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package ru.spbu.depnav.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.rotate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import ovh.plrapps.mapcompose.api.VisibleArea +import ovh.plrapps.mapcompose.api.fullSize +import ovh.plrapps.mapcompose.api.getLayoutSizeFlow +import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.utils.AngleDegree +import ovh.plrapps.mapcompose.utils.Point +import ru.spbu.depnav.data.model.Marker +import ru.spbu.depnav.utils.map.LineSegment +import ru.spbu.depnav.utils.map.bottom +import ru.spbu.depnav.utils.map.centroid +import ru.spbu.depnav.utils.map.contains +import ru.spbu.depnav.utils.map.left +import ru.spbu.depnav.utils.map.rectangularVisibleArea +import ru.spbu.depnav.utils.map.right +import ru.spbu.depnav.utils.map.rotation +import ru.spbu.depnav.utils.map.top + +/** + * When the pin is outside of the map area visible on the screen, shows a pointer towards the pin. + * + * It is intended to be placed exactly over the map's composable. + */ +@Composable +fun PinPointer(mapState: MapState, pin: Marker?) { + var mapLayoutSizeFlow by remember { mutableStateOf?>(null) } + LaunchedEffect(mapState) { mapLayoutSizeFlow = mapState.getLayoutSizeFlow() } + val mapLayoutSize by mapLayoutSizeFlow?.collectAsStateWithLifecycle(IntSize.Zero) ?: return + if (mapLayoutSize == IntSize.Zero) { + return + } + + val pinSize = with(LocalDensity.current) { PIN_SIZE.roundToPx() } + + val pinPoint = pin?.run { Point(x * mapState.fullSize.width, y * mapState.fullSize.height) } + val visibleArea = mapState.rectangularVisibleArea(mapLayoutSize) // Area visible on the screen + + val currentPointerPose = + if ( + pinPoint != null && + !mapState.rectangularVisibleArea( // Area on which the pin is visible on the screen + mapLayoutSize, + leftPadding = pinSize / 2, + rightPadding = pinSize / 2, + bottomPadding = pinSize + ).contains(pinPoint) + ) { + calculatePointerPose(visibleArea, pinPoint) + } else { + null // There is no pin or it is visible on the screen + } + + // Have to remember the latest non-null pointer pose to continue showing it while the exit + // animation is still in progress + var lastPointerPose by remember { mutableStateOf(PinPointerPose.Empty) } + if (currentPointerPose != null) { + lastPointerPose = currentPointerPose + } + + AnimatedVisibility( + visible = currentPointerPose != null, + modifier = Modifier.absoluteOffset { lastPointerPose.coordinates(mapLayoutSize, pinSize) }, + enter = fadeIn() + slideIn { lastPointerPose.slideAnimationOffset(it) } + scaleIn(), + exit = fadeOut() + slideOut { lastPointerPose.slideAnimationOffset(it) } + scaleOut() + ) { + // Cannot use mapState.rotation since it has a different pivot + Pin(modifier = Modifier.rotate(lastPointerPose.direction - visibleArea.rotation())) + } +} + +private data class PinPointerPose( + val side: Side, + val sideFraction: Float, + val direction: AngleDegree +) { + enum class Side { LEFT, RIGHT, TOP, BOTTOM } + + companion object { + val Empty = PinPointerPose(Side.TOP, 0f, 0f) + } + + fun coordinates(boxSize: IntSize, pinSize: Int): IntOffset { + return when (side) { + Side.LEFT -> IntOffset( + x = 0, + y = (boxSize.height * sideFraction - pinSize / 2f) + .toInt() + .coerceIn(0, boxSize.height - pinSize) + ) + Side.RIGHT -> IntOffset( + x = (boxSize.width - pinSize).coerceAtLeast(0), + y = (boxSize.height * sideFraction - pinSize / 2f) + .toInt() + .coerceIn(0, boxSize.height - pinSize) + ) + Side.TOP -> IntOffset( + x = (boxSize.width * sideFraction - pinSize / 2f) + .toInt() + .coerceIn(0, boxSize.width - pinSize), + y = 0 + ) + Side.BOTTOM -> IntOffset( + x = (boxSize.width * sideFraction - pinSize / 2f) + .toInt() + .coerceIn(0, boxSize.width - pinSize), + y = (boxSize.height - pinSize).coerceAtLeast(0) + ) + } + } + + fun slideAnimationOffset(pinSize: IntSize) = when (side) { + Side.LEFT -> IntOffset(x = -pinSize.width, y = 0) + Side.RIGHT -> IntOffset(x = pinSize.width, y = 0) + Side.TOP -> IntOffset(x = 0, y = -pinSize.height) + Side.BOTTOM -> IntOffset(x = 0, y = pinSize.height) + } +} + +private fun calculatePointerPose(visibleArea: VisibleArea, pin: Point): PinPointerPose { + val centroidPinSegment = LineSegment(visibleArea.centroid(), pin) + val direction = centroidPinSegment.slope() - 90 + + visibleArea.top().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction -> + return PinPointerPose(PinPointerPose.Side.TOP, fraction, direction) + } + visibleArea.right().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction -> + return PinPointerPose(PinPointerPose.Side.RIGHT, fraction, direction) + } + visibleArea.bottom().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction -> + return PinPointerPose(PinPointerPose.Side.BOTTOM, fraction, direction) + } + visibleArea.left().fractionOfIntersectionWith(centroidPinSegment)?.let { fraction -> + return PinPointerPose(PinPointerPose.Side.LEFT, fraction, direction) + } + + throw IllegalArgumentException("Pin lies inside the visible area") +} diff --git a/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt b/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt index c5405e0c..117dbbb4 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt @@ -79,10 +79,12 @@ import ru.spbu.depnav.ui.component.MainMenuSheet import ru.spbu.depnav.ui.component.MapSearchBar import ru.spbu.depnav.ui.component.MarkerInfoLines import ru.spbu.depnav.ui.component.MarkerView +import ru.spbu.depnav.ui.component.PinPointer import ru.spbu.depnav.ui.component.ZoomInHint import ru.spbu.depnav.ui.dialog.MapLegendDialog import ru.spbu.depnav.ui.dialog.SettingsDialog import ru.spbu.depnav.ui.theme.DEFAULT_PADDING +import ru.spbu.depnav.ui.theme.ON_MAP_SURFACE_ALPHA import ru.spbu.depnav.ui.viewmodel.MapUiState import ru.spbu.depnav.ui.viewmodel.MapViewModel import ru.spbu.depnav.ui.viewmodel.SearchResults @@ -178,6 +180,8 @@ private fun OnMapUi( ) { CompositionLocalProvider(LocalAbsoluteTonalElevation provides 4.dp) { Box(modifier = Modifier.fillMaxSize()) { + PinPointer(mapUiState.mapState, mapUiState.pinnedMarker?.marker) + AnimatedSearchBar( visible = mapUiState.showOnMapUi, mapTitle = mapUiState.mapTitle, @@ -298,7 +302,8 @@ private fun BoxScope.AnimatedBottom(pinnedMarker: MarkerWithText?, showZoomInHin shape = MaterialTheme.shapes.large.copy( bottomStart = CornerSize(0), bottomEnd = CornerSize(0) - ) + ), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = ON_MAP_SURFACE_ALPHA) ) { // Have to remember the latest pinned marker to continue showing it while the exit // animation is still in progress diff --git a/app/src/main/java/ru/spbu/depnav/ui/theme/Theme.kt b/app/src/main/java/ru/spbu/depnav/ui/theme/Theme.kt index 562ac8b8..d95ec879 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/theme/Theme.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/theme/Theme.kt @@ -99,6 +99,9 @@ val DEFAULT_PADDING = 16.dp /** Alpha value applied to disabled elements. */ const val DISABLED_ALPHA = 0.38f +/** Alpha value applied to surfaces comprising on-map UI. */ +const val ON_MAP_SURFACE_ALPHA = 0.9f + /** Theme of the application. */ @Composable fun DepNavTheme( diff --git a/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt b/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt index 5afa6ae6..6048cda4 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt @@ -277,7 +277,6 @@ class MapViewModel @Inject constructor( y = marker.y, zIndex = 1f, clickable = false, - relativeOffset = Offset(-0.5f, -0.5f), clipShape = null ) { Pin() } } diff --git a/app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt b/app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt new file mode 100644 index 00000000..ec0dd1eb --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt @@ -0,0 +1,77 @@ +/** + * DepNav -- department navigator. + * Copyright (C) 2024 Timofei Pushkin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package ru.spbu.depnav.utils.map + +import ovh.plrapps.mapcompose.utils.AngleDegree +import ovh.plrapps.mapcompose.utils.Point +import kotlin.math.atan2 + +/** + * A segment of a line lying between two points. + */ +data class LineSegment(val p1: Point, val p2: Point) { + /** + * Returns the slope of this line in degrees in the range from -180 to 180 with 0 representing + * a horizontal line. Positive values correspond to clockwise rotation while negative values + * correspond to counterclockwise rotation. + */ + fun slope(): AngleDegree = Math.toDegrees(atan2(y = p2.y - p1.y, x = p2.x - p1.x)).toFloat() + + /** + * Returns true if projection of the provided point on the line of this segment lies within the + * segment, or false otherwise. + */ + fun containsProjectionOf(p: Point) = fractionOfProjectionOf(p) in 0f..1f + + /** + * Returns the fraction from the start of this segment to its intersection point with the other + * segment if such point exists, or null otherwise. + */ + fun fractionOfIntersectionWith(l: LineSegment): Float? { + // See https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line_segment + val denominator = (p1.x - p2.x) * (l.p1.y - l.p2.y) - (p1.y - p2.y) * (l.p1.x - l.p2.x) + + val numerator1 = (p1.x - l.p1.x) * (l.p1.y - l.p2.y) - (p1.y - l.p1.y) * (l.p1.x - l.p2.x) + val t1 = numerator1 / denominator + if (t1 < 0 || t1 > 1) { + return null + } + + val numerator2 = (p1.x - p2.x) * (p1.y - l.p1.y) - (p1.y - p2.y) * (p1.x - l.p1.x) + val t2 = -(numerator2 / denominator) + if (t2 < 0 || t2 > 1) { + return null + } + + return t1.toFloat() + } + + private fun fractionOfProjectionOf(p: Point): Float { + val vecP1ToP2 = Point(p2.x - p1.x, p2.y - p1.y) + val vecP1ToP = Point(p.x - p1.x, p.y - p1.y) + + val squaredLength = vecP1ToP2.x * vecP1ToP2.x + vecP1ToP2.y * vecP1ToP2.y + if (squaredLength == 0.0) { + return 0f + } + val dotProduct = vecP1ToP.x * vecP1ToP2.x + vecP1ToP.y * vecP1ToP2.y + + return (dotProduct / squaredLength).toFloat() + } +} diff --git a/app/src/main/java/ru/spbu/depnav/utils/map/VisibleArea.kt b/app/src/main/java/ru/spbu/depnav/utils/map/VisibleArea.kt new file mode 100644 index 00000000..26563955 --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/utils/map/VisibleArea.kt @@ -0,0 +1,135 @@ +/** + * DepNav -- department navigator. + * Copyright (C) 2024 Timofei Pushkin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package ru.spbu.depnav.utils.map + +import androidx.compose.ui.unit.IntSize +import ovh.plrapps.mapcompose.api.VisibleArea +import ovh.plrapps.mapcompose.api.centroidX +import ovh.plrapps.mapcompose.api.centroidY +import ovh.plrapps.mapcompose.api.fullSize +import ovh.plrapps.mapcompose.api.rotation +import ovh.plrapps.mapcompose.api.scale +import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.utils.Point +import ovh.plrapps.mapcompose.utils.rotateCenteredX +import ovh.plrapps.mapcompose.utils.rotateCenteredY +import ovh.plrapps.mapcompose.utils.toRad + +/** + * Like [ovh.plrapps.mapcompose.api.visibleArea] but scaled to on-map pixel coordinates which makes + * it a rectangle. + * + * When the map is not a square its normalized coordinates become non-uniform when compared to the + * screen's pixel coordinates. This causes the visible area, calculated as in + * [ovh.plrapps.mapcompose.api.visibleArea], to become a non-rectangular parallelogram when the map + * is rotated. Defining it in map's pixel coordinates fixes the problem. + * + * @param layoutSize size of map's composable in pixels + * @param leftPadding padding in pixels to add to map's layout size on its left + * @param topPadding padding in pixels to add to map's layout size on its top + * @param rightPadding padding in pixels to add to map's layout size on its right + * @param bottomPadding padding in pixels to add to map's layout size on its bottom + */ +fun MapState.rectangularVisibleArea( + layoutSize: IntSize, + leftPadding: Int = 0, + topPadding: Int = 0, + rightPadding: Int = 0, + bottomPadding: Int = 0 +): VisibleArea { + val leftX = centroidX - (layoutSize.width / 2 + leftPadding) / (fullSize.width * scale) + val topY = centroidY - (layoutSize.height / 2 + topPadding) / (fullSize.height * scale) + val rightX = centroidX + (layoutSize.width / 2 + rightPadding) / (fullSize.width * scale) + val bottomY = centroidY + (layoutSize.height / 2 + bottomPadding) / (fullSize.height * scale) + + val xAxisScale = fullSize.height / fullSize.width.toDouble() + val scaledCenterX = centroidX / xAxisScale + val scaledLeftX = leftX / xAxisScale + val scaledRightX = rightX / xAxisScale + + val p1x = rotateCenteredX( + scaledLeftX, topY, scaledCenterX, centroidY, -rotation.toRad() + ) * /* xAxisScale * fullSize.width = */ fullSize.height + val p1y = rotateCenteredY( + scaledLeftX, topY, scaledCenterX, centroidY, -rotation.toRad() + ) * fullSize.height + + val p2x = rotateCenteredX( + scaledRightX, topY, scaledCenterX, centroidY, -rotation.toRad() + ) * fullSize.height + val p2y = rotateCenteredY( + scaledRightX, topY, scaledCenterX, centroidY, -rotation.toRad() + ) * fullSize.height + + val p3x = rotateCenteredX( + scaledRightX, bottomY, scaledCenterX, centroidY, -rotation.toRad() + ) * fullSize.height + val p3y = rotateCenteredY( + scaledRightX, bottomY, scaledCenterX, centroidY, -rotation.toRad() + ) * fullSize.height + + val p4x = rotateCenteredX( + scaledLeftX, bottomY, scaledCenterX, centroidY, -rotation.toRad() + ) * fullSize.height + val p4y = rotateCenteredY( + scaledLeftX, bottomY, scaledCenterX, centroidY, -rotation.toRad() + ) * fullSize.height + + return VisibleArea(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y) +} + +/** + * Centroid of the area. + */ +fun VisibleArea.centroid() = Point((p1x + p3x) / 2, (p1y + p3y) / 2) + +/** + * Top border of the area. + */ +fun VisibleArea.top() = LineSegment(Point(p1x, p1y), Point(p2x, p2y)) + +/** + * Bottom border of the area. + */ +fun VisibleArea.bottom() = LineSegment(Point(p4x, p4y), Point(p3x, p3y)) + +/** + * Left border of the area. + */ +fun VisibleArea.left() = LineSegment(Point(p1x, p1y), Point(p4x, p4y)) + +/** + * Right border of the area. + */ +fun VisibleArea.right() = LineSegment(Point(p2x, p2y), Point(p3x, p3y)) + +/** + * Returns true if the provided point lies inside this area, or false otherwise. + * + * In theory, it should always return the same result as [ovh.plrapps.mapcompose.utils.contains] but + * in practice this version seems to be more accurate. + */ +fun VisibleArea.contains(p: Point) = + top().containsProjectionOf(p) && left().containsProjectionOf(p) + +/** + * Returns the rotation of this area in degrees in the range from -180 to 180. Positive values + * correspond to clockwise rotation while negative values correspond to counterclockwise rotation. + */ +fun VisibleArea.rotation() = top().slope()