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)
|
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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user