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)
}
val useInsaneUnits = locationSearchSettings.imperialUnits
val imperialUnits = locationSearchSettings.imperialUnits
.stateIn(viewModelScope, SharingStarted.Lazily, false)
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.TravelExplore
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import androidx.compose.ui.unit.times
@ -105,7 +100,24 @@ fun LocationItem(
val userLocation by remember {
viewModel.devicePoseProvider.getLocation()
}.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()
@ -209,16 +221,6 @@ fun LocationItem(
horizontalAlignment = Alignment.CenterHorizontally,
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) {
val directionArrowAngle by animateValueAsState(
@ -234,7 +236,7 @@ fun LocationItem(
if (distance != null) {
Text(
text = distance.metersToLocalizedString(
context, insaneUnits
context, imperialUnits
), style = MaterialTheme.typography.labelSmall
)
}
@ -299,7 +301,6 @@ fun LocationItem(
.padding(top = 16.dp, bottom = 8.dp)
.align(Alignment.CenterHorizontally)
.fillMaxWidth(.9125f)
.aspectRatio(1f)
.border(1.dp, MaterialTheme.colorScheme.outline, shape)
.clip(shape)
.clickable {
@ -307,15 +308,18 @@ fun LocationItem(
},
tileServerUrl = tileServerUrl,
location = location,
initialZoomLevel = zoomLevel,
numberOfTiles = nTiles,
maxZoomLevel = zoomLevel,
tiles = IntSize(3, 2),
applyTheming = applyTheming,
userLocation = if (showPositionOnMap) userLocation?.let {
UserLocation(
it.latitude,
it.longitude
)
} else null,
userLocation = {
if (showPositionOnMap) userLocation?.let {
UserLocation(
it.latitude,
it.longitude,
heading = userHeading,
)
} else null
},
)
val address = buildAddress(location.street, location.houseNumber)

View File

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