From df26c7b72db056b947584cc605e72f64e25e195b Mon Sep 17 00:00:00 2001 From: TimPushkin Date: Mon, 3 Jun 2024 16:53:47 +0300 Subject: [PATCH 1/3] Make on-map UI semi-transparent --- .../java/ru/spbu/depnav/ui/component/FloorSwitch.kt | 5 ++++- .../ru/spbu/depnav/ui/component/MapSearchBar.kt | 13 ++++++++++++- .../main/java/ru/spbu/depnav/ui/screen/MapScreen.kt | 4 +++- app/src/main/java/ru/spbu/depnav/ui/theme/Theme.kt | 3 +++ 4 files changed, 22 insertions(+), 3 deletions(-) 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/screen/MapScreen.kt b/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt index c5405e0c..8a20f4dd 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 @@ -83,6 +83,7 @@ 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 @@ -298,7 +299,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( From d85f6f93c3dbc1f851cbbf0e27125a84b2dea96e Mon Sep 17 00:00:00 2001 From: TimPushkin Date: Mon, 3 Jun 2024 14:54:19 +0300 Subject: [PATCH 2/3] Implement perpendicular pin pointer --- .../java/ru/spbu/depnav/ui/component/Pin.kt | 6 +- .../ru/spbu/depnav/ui/component/PinPointer.kt | 203 ++++++++++++++++++ .../ru/spbu/depnav/ui/screen/MapScreen.kt | 3 + .../spbu/depnav/ui/viewmodel/MapViewModel.kt | 1 - .../ru/spbu/depnav/utils/map/LineSegment.kt | 60 ++++++ .../ru/spbu/depnav/utils/map/VisibleArea.kt | 120 +++++++++++ 6 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/ru/spbu/depnav/ui/component/PinPointer.kt create mode 100644 app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt create mode 100644 app/src/main/java/ru/spbu/depnav/utils/map/VisibleArea.kt 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..2f03245c --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/ui/component/PinPointer.kt @@ -0,0 +1,203 @@ +/** + * 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.contains +import ru.spbu.depnav.utils.map.left +import ru.spbu.depnav.utils.map.rectangularVisibleArea +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 const val EPSILON = 1e-5f + +private fun calculatePointerPose(visibleArea: VisibleArea, pin: Point): PinPointerPose { + val topBorder = visibleArea.top() + val leftBorder = visibleArea.left() + + val horizontalFraction = topBorder.fractionOfClosestPointTo(pin) + val verticalFraction = leftBorder.fractionOfClosestPointTo(pin) + + return when { + // Corners + horizontalFraction < EPSILON && verticalFraction < EPSILON -> { + val direction = LineSegment(topBorder.p1, pin).slope() - 90 + PinPointerPose(PinPointerPose.Side.TOP, 0f, direction) + } + horizontalFraction > 1f - EPSILON && verticalFraction < EPSILON -> { + val direction = LineSegment(topBorder.p2, pin).slope() - 90 + PinPointerPose(PinPointerPose.Side.TOP, 1f, direction) + } + horizontalFraction < EPSILON && verticalFraction > 1f - EPSILON -> { + val direction = LineSegment(leftBorder.p2, pin).slope() - 90 + PinPointerPose(PinPointerPose.Side.BOTTOM, 0f, direction) + } + horizontalFraction > 1f - EPSILON && verticalFraction > 1f - EPSILON -> { + val direction = LineSegment(with(visibleArea) { Point(p3x, p3y) }, pin).slope() - 90 + PinPointerPose(PinPointerPose.Side.BOTTOM, 1f, direction) + } + // Sides + horizontalFraction < EPSILON -> { + val direction = (topBorder.slope() - 180) - 90 + PinPointerPose(PinPointerPose.Side.LEFT, verticalFraction, direction) + } + horizontalFraction > 1f - EPSILON -> { + val direction = topBorder.slope() - 90 + PinPointerPose(PinPointerPose.Side.RIGHT, verticalFraction, direction) + } + verticalFraction < EPSILON -> { + val direction = (leftBorder.slope() - 180) - 90 + PinPointerPose(PinPointerPose.Side.TOP, horizontalFraction, direction) + } + verticalFraction > 1f - EPSILON -> { + val direction = leftBorder.slope() - 90 + PinPointerPose(PinPointerPose.Side.BOTTOM, horizontalFraction, direction) + } + // Pin is inside the area + else -> 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 8a20f4dd..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,6 +79,7 @@ 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 @@ -179,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, 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..fce5b49f --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt @@ -0,0 +1,60 @@ +/** + * 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 point that is the closest to the + * specified point. + */ + fun fractionOfClosestPointTo(p: Point) = fractionOfProjectionOf(p).coerceIn(0f, 1f) + + 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..8c4a93fa --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/utils/map/VisibleArea.kt @@ -0,0 +1,120 @@ +/** + * 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) +} + +/** + * Top border of the area. + */ +fun VisibleArea.top() = LineSegment(Point(p1x, p1y), Point(p2x, p2y)) + +/** + * Left border of the area. + */ +fun VisibleArea.left() = LineSegment(Point(p1x, p1y), Point(p4x, p4y)) + +/** + * 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() From c761b2edbfab943bcf217f7e131541ac3e0f900d Mon Sep 17 00:00:00 2001 From: TimPushkin Date: Sat, 8 Jun 2024 21:46:25 +0300 Subject: [PATCH 3/3] Make pin pointer rotated --- .../ru/spbu/depnav/ui/component/PinPointer.kt | 62 ++++++------------- .../ru/spbu/depnav/utils/map/LineSegment.kt | 23 ++++++- .../ru/spbu/depnav/utils/map/VisibleArea.kt | 15 +++++ 3 files changed, 53 insertions(+), 47 deletions(-) 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 index 2f03245c..833b7b93 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/component/PinPointer.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/PinPointer.kt @@ -47,9 +47,12 @@ 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 @@ -153,51 +156,22 @@ private data class PinPointerPose( } } -private const val EPSILON = 1e-5f - private fun calculatePointerPose(visibleArea: VisibleArea, pin: Point): PinPointerPose { - val topBorder = visibleArea.top() - val leftBorder = visibleArea.left() - - val horizontalFraction = topBorder.fractionOfClosestPointTo(pin) - val verticalFraction = leftBorder.fractionOfClosestPointTo(pin) + val centroidPinSegment = LineSegment(visibleArea.centroid(), pin) + val direction = centroidPinSegment.slope() - 90 - return when { - // Corners - horizontalFraction < EPSILON && verticalFraction < EPSILON -> { - val direction = LineSegment(topBorder.p1, pin).slope() - 90 - PinPointerPose(PinPointerPose.Side.TOP, 0f, direction) - } - horizontalFraction > 1f - EPSILON && verticalFraction < EPSILON -> { - val direction = LineSegment(topBorder.p2, pin).slope() - 90 - PinPointerPose(PinPointerPose.Side.TOP, 1f, direction) - } - horizontalFraction < EPSILON && verticalFraction > 1f - EPSILON -> { - val direction = LineSegment(leftBorder.p2, pin).slope() - 90 - PinPointerPose(PinPointerPose.Side.BOTTOM, 0f, direction) - } - horizontalFraction > 1f - EPSILON && verticalFraction > 1f - EPSILON -> { - val direction = LineSegment(with(visibleArea) { Point(p3x, p3y) }, pin).slope() - 90 - PinPointerPose(PinPointerPose.Side.BOTTOM, 1f, direction) - } - // Sides - horizontalFraction < EPSILON -> { - val direction = (topBorder.slope() - 180) - 90 - PinPointerPose(PinPointerPose.Side.LEFT, verticalFraction, direction) - } - horizontalFraction > 1f - EPSILON -> { - val direction = topBorder.slope() - 90 - PinPointerPose(PinPointerPose.Side.RIGHT, verticalFraction, direction) - } - verticalFraction < EPSILON -> { - val direction = (leftBorder.slope() - 180) - 90 - PinPointerPose(PinPointerPose.Side.TOP, horizontalFraction, direction) - } - verticalFraction > 1f - EPSILON -> { - val direction = leftBorder.slope() - 90 - PinPointerPose(PinPointerPose.Side.BOTTOM, horizontalFraction, direction) - } - // Pin is inside the area - else -> throw IllegalArgumentException("Pin lies inside the visible area") + 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/utils/map/LineSegment.kt b/app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt index fce5b49f..ec0dd1eb 100644 --- a/app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt +++ b/app/src/main/java/ru/spbu/depnav/utils/map/LineSegment.kt @@ -40,10 +40,27 @@ data class LineSegment(val p1: Point, val p2: Point) { fun containsProjectionOf(p: Point) = fractionOfProjectionOf(p) in 0f..1f /** - * Returns the fraction from the start of this segment to its point that is the closest to the - * specified point. + * 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 fractionOfClosestPointTo(p: Point) = fractionOfProjectionOf(p).coerceIn(0f, 1f) + 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) 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 index 8c4a93fa..26563955 100644 --- a/app/src/main/java/ru/spbu/depnav/utils/map/VisibleArea.kt +++ b/app/src/main/java/ru/spbu/depnav/utils/map/VisibleArea.kt @@ -94,16 +94,31 @@ fun MapState.rectangularVisibleArea( 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. *