Rewrite MapTiles component to allow non-square aspect ratios
This commit is contained in:
parent
900e39c680
commit
b9ddb9acf0
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
if (showPositionOnMap) userLocation?.let {
|
||||
UserLocation(
|
||||
it.latitude,
|
||||
it.longitude
|
||||
it.longitude,
|
||||
heading = userHeading,
|
||||
)
|
||||
} else null,
|
||||
} else null
|
||||
},
|
||||
)
|
||||
|
||||
val address = buildAddress(location.street, location.houseNumber)
|
||||
|
||||
@ -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,56 +126,33 @@ 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 =
|
||||
@ -228,254 +193,192 @@ fun MapTiles(
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = .5f)
|
||||
|
||||
val (yLocation, xLocation) = remember(location, zoom) {
|
||||
getDoubleTileCoordinates(
|
||||
getTileCoordinates(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
zoom
|
||||
)
|
||||
}
|
||||
val (yUser, xUser) = remember(userLocation, zoom) {
|
||||
val userOffset = remember(userLocation, zoom) {
|
||||
if (userLocation != null) {
|
||||
getDoubleTileCoordinates(
|
||||
getTileCoordinates(
|
||||
latitude = userLocation.lat,
|
||||
longitude = userLocation.lon,
|
||||
zoom
|
||||
)
|
||||
} else {
|
||||
-1.0 to -1.0
|
||||
Offset.Unspecified
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
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) {
|
||||
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
|
||||
val userTileCoordinates = getTileCoordinates(userLocation.lat, userLocation.lon, zoom)
|
||||
|
||||
if (userLocation.heading != null) {
|
||||
val headingAnim by animateValueAsState(
|
||||
targetValue = userLocation.heading,
|
||||
typeConverter = Float.DegreesConverter
|
||||
)
|
||||
drawCircle(
|
||||
color = userLocationColor,
|
||||
radius = 13.5f * userLocAnimation,
|
||||
center = userIndicatorOffset,
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val locationIndicatorOffset =
|
||||
(Offset(
|
||||
xLocation.toFloat(),
|
||||
yLocation.toFloat()
|
||||
) - start) / sideLength.toFloat() * size.width
|
||||
drawCircle(
|
||||
color = locationBorderColor,
|
||||
radius = poiLocAnimation,
|
||||
center = locationIndicatorOffset,
|
||||
style = Stroke(width = 4f)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
.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 {
|
||||
// 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)
|
||||
}
|
||||
} else {
|
||||
(centerRounded - (tiles / 2))..(centerRounded + (tiles / 2))
|
||||
}
|
||||
}
|
||||
|
||||
return TileCoordinateRange(IntOffset(xStart, yStart), IntOffset(xStop, yStop), zoomLevel)
|
||||
private fun getTileRange(
|
||||
location: Location,
|
||||
userLocation: UserLocation?,
|
||||
tiles: IntSize,
|
||||
maxZoomLevel: Int = ZOOM_MAX
|
||||
): TileCoordinateRange {
|
||||
if (userLocation == null) {
|
||||
val tileCoords = getTileCoordinates(location.latitude, location.longitude, maxZoomLevel)
|
||||
|
||||
val xRange = getTileRange(tileCoords.x, tiles.width)
|
||||
val yRange = getTileRange(tileCoords.y, tiles.height)
|
||||
|
||||
return TileCoordinateRange(
|
||||
IntOffset(xRange.first, yRange.first),
|
||||
IntOffset(xRange.last, yRange.last),
|
||||
maxZoomLevel
|
||||
)
|
||||
}
|
||||
|
||||
for (z in maxZoomLevel downTo ZOOM_MIN) {
|
||||
val tileCoords = getTileCoordinates(location.latitude, location.longitude, z)
|
||||
val userCoords = getTileCoordinates(userLocation.lat, userLocation.lon, z)
|
||||
|
||||
val centerX = (tileCoords.x + userCoords.x) / 2f
|
||||
val centerY = (tileCoords.y + userCoords.y) / 2f
|
||||
|
||||
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 (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)
|
||||
|
||||
}
|
||||
|
||||
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(xRange.first, yRange.first),
|
||||
IntOffset(xRange.last, yRange.last),
|
||||
z
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return TileCoordinateRange(IntOffset(0, 0), IntOffset(0, 0), 0)
|
||||
}
|
||||
|
||||
const val ZOOM_MAX = 19
|
||||
const val ZOOM_MIN = 0
|
||||
|
||||
private fun getEnclosingTiles(
|
||||
location: Location,
|
||||
nTiles: Int,
|
||||
userLocation: UserLocation,
|
||||
previousZoomLevel: MutableIntState,
|
||||
): 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
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
val (locationTileY, locationTileX) = locationY.toInt() to locationX.toInt()
|
||||
val (userTileY, userTileX) = userY.toInt() to userX.toInt()
|
||||
|
||||
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 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()
|
||||
|
||||
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 (topOfCenter) {
|
||||
yStart -= ceil
|
||||
yStop += floor
|
||||
} else {
|
||||
yStart -= floor
|
||||
yStop += ceil
|
||||
}
|
||||
}
|
||||
|
||||
previousZoomLevel.intValue = zoomLevel
|
||||
|
||||
return TileCoordinateRange(
|
||||
IntOffset(xStart, yStart),
|
||||
IntOffset(xStop, yStop),
|
||||
zoomLevel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
throw IllegalStateException("Unreachable (right?) | lat: ${location.latitude} | lon: ${location.longitude} | user: $userLocation | nTiles: $nTiles")
|
||||
}
|
||||
|
||||
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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user