Rewrite MapTiles component to allow non-square aspect ratios

This commit is contained in:
MM20 2024-05-05 01:17:07 +02:00
parent 900e39c680
commit b9ddb9acf0
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
4 changed files with 259 additions and 342 deletions

View File

@ -0,0 +1,9 @@
package de.mm20.launcher2.ui.ktx
inline operator fun IntRange.inc(): IntRange {
return start + 1..endInclusive + 1
}
inline operator fun IntRange.dec(): IntRange {
return start - 1..endInclusive - 1
}

View File

@ -223,7 +223,7 @@ class SearchableItemVM : ListItemViewModel(), KoinComponent {
permissionsManager.requestPermission(activity, PermissionGroup.AppShortcuts) permissionsManager.requestPermission(activity, PermissionGroup.AppShortcuts)
} }
val useInsaneUnits = locationSearchSettings.imperialUnits val imperialUnits = locationSearchSettings.imperialUnits
.stateIn(viewModelScope, SharingStarted.Lazily, false) .stateIn(viewModelScope, SharingStarted.Lazily, false)
val showMap = locationSearchSettings.showMap val showMap = locationSearchSettings.showMap

View File

@ -33,20 +33,14 @@ import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline import androidx.compose.material.icons.rounded.StarOutline
import androidx.compose.material.icons.rounded.TravelExplore import androidx.compose.material.icons.rounded.TravelExplore
import androidx.compose.material.icons.twotone.CloudOff import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -59,6 +53,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect import androidx.compose.ui.unit.roundToIntRect
import androidx.compose.ui.unit.times import androidx.compose.ui.unit.times
@ -105,7 +100,24 @@ fun LocationItem(
val userLocation by remember { val userLocation by remember {
viewModel.devicePoseProvider.getLocation() viewModel.devicePoseProvider.getLocation()
}.collectAsStateWithLifecycle(viewModel.devicePoseProvider.lastLocation) }.collectAsStateWithLifecycle(viewModel.devicePoseProvider.lastLocation)
val insaneUnits by viewModel.useInsaneUnits.collectAsState()
val targetHeading by remember(userLocation, location) {
if (userLocation != null) {
viewModel.devicePoseProvider.getHeadingToDegrees(
userLocation!!.bearingTo(
location.toAndroidLocation()
)
)
} else emptyFlow()
}.collectAsStateWithLifecycle(null)
val userHeading by remember {
if (userLocation != null) {
viewModel.devicePoseProvider.getAzimuthDegrees()
} else emptyFlow()
}.collectAsStateWithLifecycle(null)
val imperialUnits by viewModel.imperialUnits.collectAsState()
val isUpToDate by viewModel.isUpToDate.collectAsState() val isUpToDate by viewModel.isUpToDate.collectAsState()
@ -209,16 +221,6 @@ fun LocationItem(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround, verticalArrangement = Arrangement.SpaceAround,
) { ) {
val targetHeading by remember(userLocation, location) {
if (userLocation != null)
viewModel.devicePoseProvider.getHeadingToDegrees(
userLocation!!.bearingTo(
location.toAndroidLocation()
)
)
else
emptyFlow()
}.collectAsStateWithLifecycle(null)
if (targetHeading != null) { if (targetHeading != null) {
val directionArrowAngle by animateValueAsState( val directionArrowAngle by animateValueAsState(
@ -234,7 +236,7 @@ fun LocationItem(
if (distance != null) { if (distance != null) {
Text( Text(
text = distance.metersToLocalizedString( text = distance.metersToLocalizedString(
context, insaneUnits context, imperialUnits
), style = MaterialTheme.typography.labelSmall ), style = MaterialTheme.typography.labelSmall
) )
} }
@ -299,7 +301,6 @@ fun LocationItem(
.padding(top = 16.dp, bottom = 8.dp) .padding(top = 16.dp, bottom = 8.dp)
.align(Alignment.CenterHorizontally) .align(Alignment.CenterHorizontally)
.fillMaxWidth(.9125f) .fillMaxWidth(.9125f)
.aspectRatio(1f)
.border(1.dp, MaterialTheme.colorScheme.outline, shape) .border(1.dp, MaterialTheme.colorScheme.outline, shape)
.clip(shape) .clip(shape)
.clickable { .clickable {
@ -307,15 +308,18 @@ fun LocationItem(
}, },
tileServerUrl = tileServerUrl, tileServerUrl = tileServerUrl,
location = location, location = location,
initialZoomLevel = zoomLevel, maxZoomLevel = zoomLevel,
numberOfTiles = nTiles, tiles = IntSize(3, 2),
applyTheming = applyTheming, applyTheming = applyTheming,
userLocation = if (showPositionOnMap) userLocation?.let { userLocation = {
UserLocation( if (showPositionOnMap) userLocation?.let {
it.latitude, UserLocation(
it.longitude it.latitude,
) it.longitude,
} else null, heading = userHeading,
)
} else null
},
) )
val address = buildAddress(location.street, location.houseNumber) val address = buildAddress(location.street, location.houseNumber)

View File

@ -4,53 +4,55 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.compose.animation.core.EaseInOutCirc import androidx.compose.animation.core.EaseInOutCirc
import androidx.compose.animation.core.EaseInOutSine import androidx.compose.animation.core.EaseInOutSine
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateOffsetAsState import androidx.compose.animation.core.animateValueAsState
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Navigation
import androidx.compose.material.icons.rounded.Place
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.minus import androidx.compose.ui.unit.times
import coil.ImageLoader import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import coil.request.CachePolicy import coil.request.CachePolicy
@ -63,6 +65,7 @@ import de.mm20.launcher2.search.OpeningHours
import de.mm20.launcher2.search.OpeningSchedule import de.mm20.launcher2.search.OpeningSchedule
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchableSerializer import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.ui.ktx.DegreesConverter
import de.mm20.launcher2.ui.ktx.contrast import de.mm20.launcher2.ui.ktx.contrast
import de.mm20.launcher2.ui.ktx.hue import de.mm20.launcher2.ui.ktx.hue
import de.mm20.launcher2.ui.ktx.hueRotate import de.mm20.launcher2.ui.ktx.hueRotate
@ -74,54 +77,39 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import kotlin.math.asinh import kotlin.math.PI
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.max import kotlin.math.ln
import kotlin.math.min import kotlin.math.pow
import kotlin.math.sqrt
import kotlin.math.tan import kotlin.math.tan
data class UserLocation(val lat: Double, val lon: Double) data class UserLocation(val lat: Double, val lon: Double, val heading: Float? = null)
@Composable @Composable
fun MapTiles( fun MapTiles(
tileServerUrl: String, tileServerUrl: String,
location: Location, location: Location,
initialZoomLevel: Int, userLocation: () -> UserLocation?,
numberOfTiles: Int, maxZoomLevel: Int,
userLocation: UserLocation?, tiles: IntSize,
applyTheming: Boolean, applyTheming: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// https://wiki.openstreetmap.org/wiki/Attribution_guidelines/2021-06-04_draft#Attribution_text // https://wiki.openstreetmap.org/wiki/Attribution_guidelines/2021-06-04_draft#Attribution_text
osmAttribution: String? = "© OpenStreetMap", osmAttribution: String? = "© OpenStreetMap",
) { ) {
val context = LocalContext.current
val tintColor = MaterialTheme.colorScheme.surface val tintColor = MaterialTheme.colorScheme.surface
val darkMode = LocalDarkTheme.current val darkMode = LocalDarkTheme.current
val previousZoomLevel = remember { mutableIntStateOf(-1) } val userLocation = userLocation()
val (start, stop, zoom) = remember(userLocation) {
userLocation val (start, stop, zoom) = remember(location, userLocation) {
?.runCatching { getTileRange(location, userLocation, tiles, maxZoomLevel)
getEnclosingTiles(
location,
numberOfTiles,
this,
previousZoomLevel
)
}
?.onFailure {
Log.e("MapTiles", "Enclosing calculation failed", it)
}
?.getOrNull()
?: getTilesAround(location, initialZoomLevel, numberOfTiles)
} }
val sideLength = stop.x - start.x + 1 val sideLength = stop.x - start.x + 1
val imageStates = remember { (0 until numberOfTiles).map { false }.toMutableStateList() }
val colorMatrix = remember(applyTheming, darkMode, tintColor) { val colorMatrix = remember(applyTheming, darkMode, tintColor) {
// darkreader css for openstreetmap tiles // darkreader css for openstreetmap tiles
// invert(93.7%) hue-rotate(180deg) contrast(90.6%) // invert(93.7%) hue-rotate(180deg) contrast(90.6%)
@ -138,344 +126,259 @@ fun MapTiles(
} else null } else null
} }
Box( BoxWithConstraints(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(modifier = Modifier.matchParentSize()) { Column(modifier = Modifier.fillMaxWidth()) {
for (y in start.y..stop.y) { for (y in start.y..stop.y) {
Row( Row(
modifier = Modifier modifier = Modifier
.weight(1f / sideLength) .fillMaxWidth()
.fillMaxSize()
) { ) {
for (x in start.x..stop.x) { for (x in start.x..stop.x) {
AsyncImage( AsyncImage(
modifier = Modifier modifier = Modifier
.weight(1f / sideLength) .weight(1f / sideLength)
.fillMaxSize(), .aspectRatio(1f)
.background(MaterialTheme.colorScheme.secondaryContainer),
imageLoader = MapTileLoader.loader, imageLoader = MapTileLoader.loader,
model = MapTileLoader.getTileRequest(tileServerUrl, x, y, zoom), model = MapTileLoader.getTileRequest(tileServerUrl, x, y, zoom),
contentDescription = null, contentDescription = null,
colorFilter = colorMatrix?.let { ColorFilter.colorMatrix(it) }, colorFilter = colorMatrix?.let { ColorFilter.colorMatrix(it) },
filterQuality = FilterQuality.High, filterQuality = FilterQuality.High,
onState = {
val stateIndex =
(y - start.y) * (stop.y - start.y + 1) + (x - start.x)
when (it) {
is AsyncImagePainter.State.Loading -> imageStates[stateIndex] =
false
is AsyncImagePainter.State.Success -> imageStates[stateIndex] =
true
is AsyncImagePainter.State.Error -> {
imageStates[stateIndex] = false
Log.e(
"MapTiles",
"Error loading tile: $x, $y @$zoom",
it.result.throwable
)
}
else -> {}
}
}
) )
} }
} }
} }
} }
if (imageStates.all { it }) { val locationBorderColor =
val locationBorderColor = if (applyTheming) MaterialTheme.colorScheme.error else Color(0xFFEFA521) // orange-ish
if (applyTheming) MaterialTheme.colorScheme.error else Color(0xFFEFA521) // orange-ish val userLocationColor =
val userLocationColor = if (applyTheming) MaterialTheme.colorScheme.onErrorContainer else Color(0xFF35A82C) // darkish green
if (applyTheming) MaterialTheme.colorScheme.onErrorContainer else Color(0xFF35A82C) // darkish green val userLocationBorderColor =
val userLocationBorderColor = if (applyTheming) {
if (applyTheming) { MaterialTheme.colorScheme.errorContainer
MaterialTheme.colorScheme.errorContainer } else if (darkMode) {
} else if (darkMode) { Color(0xFF777777)
Color(0xFF777777) } else {
} else { Color(0xFFE5E5E5)
Color(0xFFE5E5E5) }
}
val infiniteTransition = rememberInfiniteTransition("infiniteTransition") val infiniteTransition = rememberInfiniteTransition("infiniteTransition")
val userLocAnimation by infiniteTransition.animateFloat( val userLocAnimation by infiniteTransition.animateFloat(
initialValue = 0.8f, initialValue = 0.8f,
targetValue = 1f, targetValue = 1f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(2000, easing = EaseInOutSine), animation = tween(2000, easing = EaseInOutSine),
repeatMode = RepeatMode.Reverse repeatMode = RepeatMode.Reverse
), ),
label = "userLocAnimation" label = "userLocAnimation"
)
val poiLocAnimation by infiniteTransition.animateFloat(
initialValue = 30f,
targetValue = 20f,
animationSpec = infiniteRepeatable(
animation = tween(750, easing = EaseInOutCirc),
repeatMode = RepeatMode.Reverse
),
label = "poiLocAnimation"
)
val textMeasurer = rememberTextMeasurer()
val osmAttributionTextStyle = MaterialTheme.typography.labelSmall
val osmAttributionTextColor = MaterialTheme.colorScheme.onSurface
val osmAttributionSurface =
MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = .5f)
val (yLocation, xLocation) = remember(location, zoom) {
getTileCoordinates(
latitude = location.latitude,
longitude = location.longitude,
zoom
) )
val poiLocAnimation by infiniteTransition.animateFloat( }
initialValue = 30f, val userOffset = remember(userLocation, zoom) {
targetValue = 20f, if (userLocation != null) {
animationSpec = infiniteRepeatable( getTileCoordinates(
animation = tween(750, easing = EaseInOutCirc), latitude = userLocation.lat,
repeatMode = RepeatMode.Reverse longitude = userLocation.lon,
),
label = "poiLocAnimation"
)
val textMeasurer = rememberTextMeasurer()
val osmAttributionTextStyle = MaterialTheme.typography.labelSmall
val osmAttributionTextColor = MaterialTheme.colorScheme.onSurface
val osmAttributionSurface =
MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = .5f)
val (yLocation, xLocation) = remember(location, zoom) {
getDoubleTileCoordinates(
latitude = location.latitude,
longitude = location.longitude,
zoom zoom
) )
} else {
Offset.Unspecified
} }
val (yUser, xUser) = remember(userLocation, zoom) { }
if (userLocation != null) { val tileSize = minWidth / tiles.width.toFloat()
getDoubleTileCoordinates( val locationTileCoordinates =
latitude = userLocation.lat, getTileCoordinates(location.latitude, location.longitude, zoom)
longitude = userLocation.lon,
zoom Box(
) modifier = Modifier
} else { .align(Alignment.TopStart)
-1.0 to -1.0 .width(12.dp)
} .height(4.dp)
} .absoluteOffset(
val animatedUserIndicatorOffset by animateOffsetAsState( x = (locationTileCoordinates.x - start.x) * tileSize - 6.dp,
targetValue = (Offset( y = (locationTileCoordinates.y - start.y) * tileSize - 2.dp,
xUser.toFloat(),
yUser.toFloat()
) - start) / sideLength.toFloat(),
animationSpec = tween(
1000,
250
) )
) .shadow(1.dp, CircleShape)
)
Icon(
Canvas(modifier = Modifier.matchParentSize()) { imageVector = Icons.Rounded.Place,
assert(size.width == size.height) contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
if (userLocation != null) { modifier = Modifier
if (start.y < yUser && yUser < stop.y + 1 && .align(Alignment.TopStart)
start.x < xUser && xUser < stop.x + 1 .size(24.dp)
) { .absoluteOffset(
val userIndicatorOffset = animatedUserIndicatorOffset * size.width x = (locationTileCoordinates.x - start.x) * tileSize - 12.dp,
drawCircle( y = (locationTileCoordinates.y - start.y) * tileSize - 22.dp,
color = userLocationBorderColor, )
radius = 18.5f * userLocAnimation, )
center = userIndicatorOffset, if (userLocation != null) {
alpha = (userLocAnimation - 0.8f) * 5f val userTileCoordinates = getTileCoordinates(userLocation.lat, userLocation.lon, zoom)
)
drawCircle( if (userLocation.heading != null) {
color = userLocationColor, val headingAnim by animateValueAsState(
radius = 13.5f * userLocAnimation, targetValue = userLocation.heading,
center = userIndicatorOffset, typeConverter = Float.DegreesConverter
) )
}
} Icon(
imageVector = Icons.Rounded.Navigation,
val locationIndicatorOffset = contentDescription = null,
(Offset( tint = MaterialTheme.colorScheme.tertiary,
xLocation.toFloat(), modifier = Modifier
yLocation.toFloat() .align(Alignment.TopStart)
) - start) / sideLength.toFloat() * size.width .size(16.dp)
drawCircle( .absoluteOffset(
color = locationBorderColor, x = (userTileCoordinates.x - start.x) * tileSize - 8.dp,
radius = poiLocAnimation, y = (userTileCoordinates.y - start.y) * tileSize - 8.dp,
center = locationIndicatorOffset, )
style = Stroke(width = 4f) .rotate(headingAnim)
.absoluteOffset(y = -8.dp)
) )
if (osmAttribution != null) {
val measureResult = textMeasurer.measure(
osmAttribution,
maxLines = 1,
style = osmAttributionTextStyle
)
val osmLabelPadding = 6f
val textOffset = Offset(
x = size.width - measureResult.size.width - osmLabelPadding,
y = size.height - measureResult.size.height - osmLabelPadding
)
drawRoundRect(
color = osmAttributionSurface,
topLeft = textOffset - Offset(osmLabelPadding, 0f),
size = Size(
width = measureResult.size.width + 2 * osmLabelPadding,
height = measureResult.size.height + osmLabelPadding
),
cornerRadius = CornerRadius(8f, 8f)
)
drawText(
measureResult,
color = osmAttributionTextColor,
topLeft = textOffset
)
}
} }
} else {
val loadingColor = MaterialTheme.colorScheme.secondary Box(
CircularProgressIndicator( modifier = Modifier
modifier = Modifier.fillMaxSize(.15f), .align(Alignment.TopStart)
color = loadingColor, .size(16.dp)
strokeCap = StrokeCap.Round, .absoluteOffset(
x = (userTileCoordinates.x - start.x) * tileSize - 8.dp,
y = (userTileCoordinates.y - start.y) * tileSize - 8.dp,
)
.background(MaterialTheme.colorScheme.tertiary, CircleShape)
.border(2.dp, MaterialTheme.colorScheme.onTertiary, CircleShape)
.shadow(1.dp, CircleShape)
) )
} }
} }
} }
private fun getDoubleTileCoordinates( /**
* Returns the tile coordinates for a given location at a given zoom level (not rounded).
*/
private fun getTileCoordinates(
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
zoomLevel: Int zoomLevel: Int
): Pair<Double, Double> { ): Offset {
// https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Mathematics // https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Mathematics
val latRadians = Math.toRadians(latitude) val x = ((longitude + 180) / 360) * 2.0.pow(zoomLevel)
val xCoordinate = (longitude + 180.0) / 360.0 * (1 shl zoomLevel) val latRad = Math.toRadians(latitude)
val yCoordinate = (1.0 - asinh(tan(latRadians)) / Math.PI) * (1 shl (zoomLevel - 1)) val y = (1.0 - ((ln(tan(latRad) + (1.0 / cos(latRad)))) / PI)) * (2.0.pow(zoomLevel - 1))
return yCoordinate to xCoordinate return Offset(x.toFloat(), y.toFloat())
} }
data class TileCoordinateRange(val start: IntOffset, val stop: IntOffset, val zoomLevel: Int) data class TileCoordinateRange(val start: IntOffset, val stop: IntOffset, val zoomLevel: Int)
private fun getTilesAround( private fun getTileRange(
location: Location, center: Float,
zoomLevel: Int, tiles: Int,
nTiles: Int ): IntRange {
): TileCoordinateRange { val centerRounded = center.toInt()
if (sqrt(nTiles.toDouble()) % 1.0 != 0.0) return if (tiles % 2 == 0) {
throw IllegalArgumentException("nTiles must be a square number") if (center % 1 >= 0.5f) {
(centerRounded - (tiles / 2) + 1)..(centerRounded + (tiles / 2))
val sideLen = sqrt(nTiles.toDouble()).toInt() } else {
val sideLenHalf = sideLen / 2 (centerRounded - (tiles / 2))..<centerRounded + (tiles / 2)
}
val (yCoordinate, xCoordinate) = getDoubleTileCoordinates(
location.latitude,
location.longitude,
zoomLevel
)
val xTile = xCoordinate.toInt()
val yTile = yCoordinate.toInt()
val yStart: Int
val yStop: Int
val xStart: Int
val xStop: Int
if (sideLen % 2 == 1) {
// center tile is defined
yStart = yTile - sideLenHalf
yStop = yTile + sideLenHalf
xStart = xTile - sideLenHalf
xStop = xTile + sideLenHalf
} else { } else {
// center tile is not defined; take adjacent tiles closest to coordinate of interest (centerRounded - (tiles / 2))..(centerRounded + (tiles / 2))
val leftOfCenter = (xCoordinate % 1.0) < 0.5
val topOfCenter = (yCoordinate % 1.0) < 0.5
yStart = if (topOfCenter) yTile - sideLenHalf else yTile - sideLenHalf + 1
yStop = if (topOfCenter) yTile + sideLenHalf - 1 else yTile + sideLenHalf
xStart = if (leftOfCenter) xTile - sideLenHalf else xTile - sideLenHalf + 1
xStop = if (leftOfCenter) xTile + sideLenHalf - 1 else xTile + sideLenHalf
} }
return TileCoordinateRange(IntOffset(xStart, yStart), IntOffset(xStop, yStop), zoomLevel)
} }
const val ZOOM_MAX = 19 private fun getTileRange(
const val ZOOM_MIN = 0
private fun getEnclosingTiles(
location: Location, location: Location,
nTiles: Int, userLocation: UserLocation?,
userLocation: UserLocation, tiles: IntSize,
previousZoomLevel: MutableIntState, maxZoomLevel: Int = ZOOM_MAX
): TileCoordinateRange { ): TileCoordinateRange {
if (sqrt(nTiles.toDouble()) % 1.0 != 0.0) if (userLocation == null) {
throw IllegalArgumentException("nTiles must be a square number") val tileCoords = getTileCoordinates(location.latitude, location.longitude, maxZoomLevel)
val sideLen = sqrt(nTiles.toDouble()).toInt() val xRange = getTileRange(tileCoords.x, tiles.width)
val sideLenHalf = sideLen / 2 val yRange = getTileRange(tileCoords.y, tiles.height)
// start at previous zoom (+1) to do less calculations, because if return TileCoordinateRange(
// - user comes closer to location: IntOffset(xRange.first, yRange.first),
// we might be able to increase the zoom level by one IntOffset(xRange.last, yRange.last),
// - user moves further away from location: maxZoomLevel
// we still iterate down to minimum zoom and there is no need to start with ZOOM_MAX for that
for (zoomLevel in previousZoomLevel
.intValue
.let { if (it == -1) ZOOM_MAX else min(it + 1, ZOOM_MAX) } downTo ZOOM_MIN
) {
val (locationY, locationX) = getDoubleTileCoordinates(
location.latitude,
location.longitude,
zoomLevel
)
val (userY, userX) = getDoubleTileCoordinates(
userLocation.lat,
userLocation.lon,
zoomLevel
) )
}
val (locationTileY, locationTileX) = locationY.toInt() to locationX.toInt() for (z in maxZoomLevel downTo ZOOM_MIN) {
val (userTileY, userTileX) = userY.toInt() to userX.toInt() val tileCoords = getTileCoordinates(location.latitude, location.longitude, z)
val userCoords = getTileCoordinates(userLocation.lat, userLocation.lon, z)
if (locationTileY - sideLenHalf <= userTileY && userTileY <= locationTileY + sideLenHalf && val centerX = (tileCoords.x + userCoords.x) / 2f
locationTileX - sideLenHalf <= userTileX && userTileX <= locationTileX + sideLenHalf val centerY = (tileCoords.y + userCoords.y) / 2f
) {
var xStart = min(locationTileX, userTileX)
var yStart = min(locationTileY, userTileY)
var xStop = max(locationTileX, userTileX)
var yStop = max(locationTileY, userTileY)
val xRem = sideLen - xStop + xStart - 1 val minX = floor(minOf(tileCoords.x, userCoords.x)).toInt()
if (0 < xRem) { val maxX = ceil(maxOf(tileCoords.x, userCoords.x)).toInt()
val leftOfCenter = (locationX % 1.0) < 0.5 val minY = floor(minOf(tileCoords.y, userCoords.y)).toInt()
val ceil = ceil(xRem / 2.0).toInt() val maxY = ceil(maxOf(tileCoords.y, userCoords.y)).toInt()
val floor = floor(xRem / 2.0).toInt()
if (leftOfCenter) { if (maxX - minX <= tiles.width && maxY - minY <= tiles.height) {
xStart -= ceil val diffX = tiles.width - (maxX - minX)
xStop += floor val diffY = tiles.height - (maxY - minY)
} else {
xStart -= floor val xRange = if (diffX % 2 == 0) {
xStop += ceil (minX - diffX / 2)..<(maxX + diffX / 2)
} } else if (centerX % 1 >= 0.5f) {
} (minX - diffX / 2)..<(maxX + diffX / 2 + 1)
val yRem = sideLen - yStop + yStart - 1 } else {
if (0 < yRem) { (minX - diffX / 2 - 1)..<(maxX + diffX / 2)
val topOfCenter = (locationY % 1.0) < 0.5
val ceil = ceil(yRem / 2.0).toInt()
val floor = floor(yRem / 2.0).toInt()
if (topOfCenter) {
yStart -= ceil
yStop += floor
} else {
yStart -= floor
yStop += ceil
}
} }
previousZoomLevel.intValue = zoomLevel val yRange = if (diffY % 2 == 0) {
(minY - diffY / 2)..<(maxY + diffY / 2)
} else if (centerY % 1 >= 0.5f) {
(minY - diffY / 2)..<(maxY + diffY / 2 + 1)
} else {
(minY - diffY / 2 - 1)..<(maxY + diffY / 2)
}
return TileCoordinateRange( return TileCoordinateRange(
IntOffset(xStart, yStart), IntOffset(xRange.first, yRange.first),
IntOffset(xStop, yStop), IntOffset(xRange.last, yRange.last),
zoomLevel z
) )
} }
} }
throw IllegalStateException("Unreachable (right?) | lat: ${location.latitude} | lon: ${location.longitude} | user: $userLocation | nTiles: $nTiles") return TileCoordinateRange(IntOffset(0, 0), IntOffset(0, 0), 0)
} }
const val ZOOM_MAX = 19
const val ZOOM_MIN = 0
private object MapTileLoader : KoinComponent { private object MapTileLoader : KoinComponent {
private val context: Context by inject() private val context: Context by inject()
@ -485,6 +388,7 @@ private object MapTileLoader : KoinComponent {
0 0
)?.versionName ?: "dev" )?.versionName ?: "dev"
}" }"
fun getTileRequest(tileServerUrl: String, x: Int, y: Int, zoom: Int): ImageRequest { fun getTileRequest(tileServerUrl: String, x: Int, y: Int, zoom: Int): ImageRequest {
return ImageRequest.Builder(context) return ImageRequest.Builder(context)
.data("$tileServerUrl/$zoom/$x/$y.png") .data("$tileServerUrl/$zoom/$x/$y.png")
@ -538,10 +442,10 @@ private fun MapTilesPreview() {
.clip(borderShape), .clip(borderShape),
tileServerUrl = "http://tile.openstreetmap.org", tileServerUrl = "http://tile.openstreetmap.org",
location = MockLocation, location = MockLocation,
initialZoomLevel = 19, maxZoomLevel = 19,
numberOfTiles = 9, tiles = IntSize(3, 3),
applyTheming = false, applyTheming = false,
userLocation = UserLocation(52.51623, 13.4048) userLocation = { UserLocation(52.51623, 13.4048) }
) )
} }