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()