Heavily optimized MapScreen.kt and position calculations
This commit is contained in:
1 parent
2edc892dc2
commit
9afe2ab69e
2 files changed
+325
-180
No files matched your search
@@ -24,6 +24,7 @@ import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -40,7 +41,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -80,6 +81,13 @@ import org.osmdroid.views.overlay.FolderOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
|
||||
// Overlay indices
|
||||
private const val OVERLAY_STATION = 0
|
||||
private const val OVERLAY_TRACK = 1
|
||||
private const val OVERLAY_FOOTPRINT = 2
|
||||
private const val OVERLAY_POSITIONS = 3
|
||||
private const val OVERLAY_COUNT = 4
|
||||
|
||||
private val minLat = MapView.getTileSystem().minLatitude
|
||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
@@ -100,98 +108,113 @@ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = "#FFE082".toColorInt()
|
||||
setShadowLayer(3f, 3f, 3f, Color.BLACK)
|
||||
}
|
||||
private val labelRect = Rect()
|
||||
private val iconCache = LruCache<String, Drawable>(128)
|
||||
|
||||
fun NavGraphBuilder.mapDestination() {
|
||||
composable(Screen.Map.route) {
|
||||
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
|
||||
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val mapView = rememberMapViewWithLifecycle()
|
||||
MapScreen(uiState, mapView)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapScreen(uiState: State<MapState>, mapView: MapView) {
|
||||
val onItemClick = { item: OrbitalObject -> uiState.value.sendAction(MapAction.SelectItem(item)) }
|
||||
val selectPrev = { uiState.value.sendAction(MapAction.SelectPrev) }
|
||||
val selectNext = { uiState.value.sendAction(MapAction.SelectNext) }
|
||||
private fun MapScreen(uiState: MapState, mapView: MapView) {
|
||||
val onItemClick = { item: OrbitalObject -> uiState.sendAction(MapAction.SelectItem(item)) }
|
||||
val selectPrev = { uiState.sendAction(MapAction.SelectPrev) }
|
||||
val selectNext = { uiState.sendAction(MapAction.SelectNext) }
|
||||
val rotateMod = Modifier.rotate(180f)
|
||||
val timeString = uiState.value.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.value.mapData?.isTimeAos ?: true
|
||||
LaunchedEffect(uiState.value.track) {
|
||||
val latitude = uiState.value.track?.get(0)?.get(0)?.latitude ?: 0.0
|
||||
val longitude = uiState.value.track?.get(0)?.get(0)?.longitude ?: 0.0
|
||||
mapView.controller.animateTo(GeoPoint(latitude, longitude))
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
|
||||
LaunchedEffect(uiState.track) {
|
||||
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||
}
|
||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (isVerticalLayout()) {
|
||||
val isVertical = isVerticalLayout()
|
||||
if (isVertical) {
|
||||
TopBar {
|
||||
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
||||
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
|
||||
}
|
||||
TopBar { NextPassRow(pass = uiState.value.orbitalPass) }
|
||||
TopBar { NextPassRow(pass = uiState.orbitalPass) }
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
|
||||
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
|
||||
NextPassRow(pass = uiState.value.orbitalPass, modifier = Modifier.weight(1f))
|
||||
NextPassRow(pass = uiState.orbitalPass, modifier = Modifier.weight(1f))
|
||||
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
|
||||
}
|
||||
}
|
||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
AndroidView({ mapView }) { mapView ->
|
||||
uiState.value.stationPosition?.let { setStationPosition(it, mapView) }
|
||||
uiState.value.positions?.let { setPositions(it, mapView, onItemClick) }
|
||||
uiState.value.track?.let { setSatelliteTrack(it, mapView) }
|
||||
uiState.value.footprint?.let { setFootprint(it, mapView) }
|
||||
AndroidView({ mapView }) { view ->
|
||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||
uiState.footprint?.let { setFootprint(it, view) }
|
||||
uiState.positions?.let { setPositions(it, view, onItemClick) }
|
||||
view.invalidate()
|
||||
}
|
||||
uiState.value.mapData?.let { mapData ->
|
||||
if (isVerticalLayout()) MapDataCard(mapData) else MapDataCards(mapData)
|
||||
uiState.mapData?.let { mapData ->
|
||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Map data composables
|
||||
@Composable
|
||||
private fun MapDataCard(data: MapData) {
|
||||
val textColor = MaterialTheme.colorScheme.primary
|
||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
||||
Card(colors = cardColors) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor)
|
||||
Text(text = stringResource(R.string.map_elevation, data.elevation), color = textColor)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.map_altitude, data.altitude))
|
||||
Text(text = stringResource(R.string.map_distance, data.range))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
|
||||
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude), color = textColor)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(text = stringResource(R.string.map_qth, data.qthLoc))
|
||||
Text(text = stringResource(R.string.map_phase, data.phase))
|
||||
}
|
||||
MapDataRow(
|
||||
stringResource(R.string.map_azimuth, data.azimuth) to textColor,
|
||||
stringResource(R.string.map_elevation, data.elevation) to textColor
|
||||
)
|
||||
MapDataRow(
|
||||
stringResource(R.string.map_altitude, data.altitude) to null,
|
||||
stringResource(R.string.map_distance, data.range) to null
|
||||
)
|
||||
MapDataRow(
|
||||
stringResource(R.string.map_latitude, data.osmPos.latitude) to textColor,
|
||||
stringResource(R.string.map_longitude, data.osmPos.longitude) to textColor
|
||||
)
|
||||
MapDataRow(
|
||||
stringResource(R.string.map_qth, data.qthLoc) to null,
|
||||
stringResource(R.string.map_phase, data.phase) to null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapDataRow(
|
||||
left: Pair<String, androidx.compose.ui.graphics.Color?>,
|
||||
right: Pair<String, androidx.compose.ui.graphics.Color?>
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
if (left.second != null) Text(text = left.first, color = left.second!!)
|
||||
else Text(text = left.first)
|
||||
if (right.second != null) Text(text = right.first, color = right.second!!)
|
||||
else Text(text = right.first)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapDataCards(data: MapData) {
|
||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
val paddingMod = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.width(160.dp)
|
||||
val osmText = stringResource(R.string.map_copyright)
|
||||
val textColor = MaterialTheme.colorScheme.primary
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) {
|
||||
@@ -212,7 +235,11 @@ private fun MapDataCards(data: MapData) {
|
||||
Text(text = stringResource(R.string.map_qth, data.qthLoc))
|
||||
}
|
||||
}
|
||||
Text(text = osmText, fontSize = 14.sp, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
Text(
|
||||
text = stringResource(R.string.map_copyright),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) {
|
||||
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
|
||||
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor)
|
||||
@@ -221,113 +248,175 @@ private fun MapDataCards(data: MapData) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Map overlay helpers
|
||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
try {
|
||||
Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
||||
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||
mapView.overlays[0] = this
|
||||
mapView.invalidate()
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
println(exception)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPositions(posMap: Map<OrbitalObject, GeoPos>, mapView: MapView, action: (OrbitalObject) -> Unit) {
|
||||
val markers = FolderOverlay()
|
||||
try {
|
||||
posMap.entries.forEach {
|
||||
Marker(mapView).apply {
|
||||
val overlay = mapView.overlays[OVERLAY_STATION]
|
||||
if (overlay is Marker) {
|
||||
overlay.position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||
} else {
|
||||
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = getCustomTextIcon(it.key.data.name, mapView)
|
||||
try {
|
||||
position = GeoPoint(it.value.latitude, it.value.longitude)
|
||||
} catch (exception: IllegalArgumentException) {
|
||||
println(exception.stackTraceToString())
|
||||
}
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
action(it.key)
|
||||
return@setOnMarkerClickListener true
|
||||
}
|
||||
markers.add(this)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
||||
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||
}
|
||||
}
|
||||
mapView.overlays[3] = markers
|
||||
mapView.invalidate()
|
||||
} catch (exception: Exception) {
|
||||
println(exception)
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCustomTextIcon(textLabel: String, mapView: MapView): Drawable {
|
||||
textPaint.getTextBounds(textLabel, 0, textLabel.length, labelRect)
|
||||
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
||||
private val markerPool = HashMap<String, Marker>()
|
||||
private var lastMapView: MapView? = null
|
||||
|
||||
private fun setPositions(
|
||||
posMap: Map<OrbitalObject, GeoPos>,
|
||||
mapView: MapView,
|
||||
action: (OrbitalObject) -> Unit
|
||||
) {
|
||||
try {
|
||||
// Clear caches when the MapView instance changes (e.g. config change)
|
||||
if (lastMapView !== mapView) {
|
||||
lastMapView = mapView
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
footprintPolyline = null
|
||||
footprintPoints = null
|
||||
}
|
||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||
val folder = mapView.overlays[OVERLAY_POSITIONS] as? FolderOverlay ?: FolderOverlay().also {
|
||||
mapView.overlays[OVERLAY_POSITIONS] = it
|
||||
}
|
||||
folder.items.clear()
|
||||
|
||||
val activeNames = HashSet<String>(posMap.size)
|
||||
posMap.forEach { (satellite, geoPos) ->
|
||||
val name = satellite.data.name
|
||||
activeNames.add(name)
|
||||
val marker = markerPool.getOrPut(name) {
|
||||
Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = getCachedTextIcon(name, mapView)
|
||||
}
|
||||
}
|
||||
// Update position in-place — reuse existing GeoPoint if available
|
||||
val pos = marker.position
|
||||
if (pos != null) {
|
||||
pos.latitude = geoPos.latitude
|
||||
pos.longitude = geoPos.longitude
|
||||
} else {
|
||||
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||
}
|
||||
marker.setOnMarkerClickListener { _, _ ->
|
||||
action(satellite)
|
||||
true
|
||||
}
|
||||
folder.add(marker)
|
||||
}
|
||||
// Evict markers for satellites no longer tracked
|
||||
val iter = markerPool.keys.iterator()
|
||||
while (iter.hasNext()) {
|
||||
if (iter.next() !in activeNames) iter.remove()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||
iconCache[name]?.let { return it }
|
||||
val labelRect = Rect()
|
||||
textPaint.getTextBounds(name, 0, name.length, labelRect)
|
||||
val iconSize = 10f
|
||||
val width = labelRect.width() + iconSize * 2f
|
||||
val height = textPaint.textSize * 3f + iconSize * 2f
|
||||
val bitmap = createBitmap(width.toInt(), height.toInt())
|
||||
Canvas(bitmap).run {
|
||||
drawCircle(width / 2f, height / 2f, iconSize, textPaint)
|
||||
drawText(textLabel, iconSize / 2f, height - iconSize, textPaint)
|
||||
drawText(name, iconSize / 2f, height - iconSize, textPaint)
|
||||
}
|
||||
return bitmap.toDrawable(mapView.context.resources)
|
||||
val drawable = bitmap.toDrawable(mapView.context.resources)
|
||||
iconCache.put(name, drawable)
|
||||
return drawable
|
||||
}
|
||||
|
||||
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
||||
val trackOverlay = FolderOverlay()
|
||||
try {
|
||||
satTrack.forEach { track ->
|
||||
val trackPoints = track.map { GeoPoint(it.latitude, it.longitude) }
|
||||
Polyline().apply {
|
||||
setPoints(trackPoints)
|
||||
setPoints(track.map { GeoPoint(it.latitude, it.longitude) })
|
||||
outlinePaint.set(trackPaint)
|
||||
trackOverlay.add(this)
|
||||
}
|
||||
}
|
||||
mapView.overlays[1] = trackOverlay
|
||||
} catch (exception: Exception) {
|
||||
println(exception)
|
||||
mapView.overlays[OVERLAY_TRACK] = trackOverlay
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable footprint Polyline — created once, points updated in-place each frame */
|
||||
private var footprintPolyline: Polyline? = null
|
||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
||||
|
||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
||||
val footprintPoints = orbitalPos.getRangeCircle().map { GeoPoint(it.latitude, it.longitude) }
|
||||
try {
|
||||
val footprintOverlay = Polyline().apply {
|
||||
outlinePaint.set(footprintPaint)
|
||||
setPoints(footprintPoints)
|
||||
val rangeCircle = orbitalPos.getRangeCircle()
|
||||
// Lazily initialise the reusable point list and polyline
|
||||
var pts = footprintPoints
|
||||
if (pts == null || pts.size != rangeCircle.size) {
|
||||
pts = ArrayList(rangeCircle.size)
|
||||
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
|
||||
footprintPoints = pts
|
||||
} else {
|
||||
// Update coordinates in-place — zero allocations
|
||||
for (i in rangeCircle.indices) {
|
||||
pts[i].latitude = rangeCircle[i].latitude
|
||||
pts[i].longitude = rangeCircle[i].longitude
|
||||
}
|
||||
}
|
||||
mapView.overlays[2] = footprintOverlay
|
||||
} catch (exception: Exception) {
|
||||
println(exception)
|
||||
val polyline = footprintPolyline ?: Polyline().apply {
|
||||
outlinePaint.set(footprintPaint)
|
||||
footprintPolyline = this
|
||||
}
|
||||
polyline.setPoints(pts)
|
||||
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region MapView lifecycle
|
||||
@Composable
|
||||
private fun rememberMapViewWithLifecycle(): MapView {
|
||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||
val context = LocalContext.current
|
||||
val mapView = remember { MapView(context) }.apply {
|
||||
setMultiTouchControls(true)
|
||||
setUseDataConnection(false)
|
||||
setTileSource(tileSource)
|
||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels)
|
||||
maxZoomLevel = 7.0
|
||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||
controller.setZoom(minZoomLevel + 2)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.setColorFilter(getColorFilter())
|
||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||
// add overlays: 0 - GSP, 1 - SatTrack, 2 - SatFootprint, 3 - SatIcons
|
||||
overlays.addAll(Array(4) { FolderOverlay() })
|
||||
val isVertical = isVerticalLayout()
|
||||
val mapView = remember {
|
||||
MapView(context).apply {
|
||||
setMultiTouchControls(true)
|
||||
setUseDataConnection(false)
|
||||
setTileSource(tileSource)
|
||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||
maxZoomLevel = 7.0
|
||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||
controller.setZoom(minZoomLevel + 2)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||
}
|
||||
}
|
||||
// Makes MapView follow the lifecycle of this composable
|
||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
@@ -348,8 +437,7 @@ private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapVie
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getColorFilter(): ColorMatrixColorFilter {
|
||||
private fun createColorFilter(): ColorMatrixColorFilter {
|
||||
val grayScale = ColorMatrix().apply { setSaturation(0f) }
|
||||
val negative = ColorMatrix(
|
||||
floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f)
|
||||
@@ -358,8 +446,8 @@ private fun getColorFilter(): ColorMatrixColorFilter {
|
||||
return ColorMatrixColorFilter(negative)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getMinZoom(screenHeight: Int): Double {
|
||||
if (!isVerticalLayout()) return 3.5
|
||||
private fun getMinZoom(screenHeight: Int, isVertical: Boolean): Double {
|
||||
if (!isVertical) return 3.5
|
||||
return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight)
|
||||
}
|
||||
// endregion
|
||||
@@ -24,6 +24,8 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
@@ -34,7 +36,10 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -120,15 +125,128 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
|
||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||
while (isActive) {
|
||||
dateNow.time = System.currentTimeMillis()
|
||||
getPositions(allSatellites, stationPos, dateNow)
|
||||
getSatFootprint(orbitalObject, stationPos, dateNow)
|
||||
getSatData(orbitalObject, stationPos, dateNow)
|
||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||
delay(updateFreq)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined update: computes all satellite positions in parallel, then derives
|
||||
* the selected satellite's footprint and info panel data from the same batch,
|
||||
* and emits a single state update to avoid redundant recompositions.
|
||||
*/
|
||||
private suspend fun updateMapState(
|
||||
selected: OrbitalObject,
|
||||
orbitalObjects: List<OrbitalObject>,
|
||||
pos: GeoPos,
|
||||
date: Date
|
||||
) {
|
||||
// 1. Compute all positions in parallel, collecting full OrbitalPos for the selected sat
|
||||
val positionsMap = HashMap<OrbitalObject, GeoPos>(orbitalObjects.size)
|
||||
var selectedSatPos: OrbitalPos? = null
|
||||
|
||||
coroutineScope {
|
||||
// Chunk satellites to balance parallelism vs coroutine overhead
|
||||
val chunkSize = maxOf(1, orbitalObjects.size / PARALLEL_CHUNKS)
|
||||
val deferreds = orbitalObjects.chunked(chunkSize).map { chunk ->
|
||||
async {
|
||||
val localPositions = ArrayList<Pair<OrbitalObject, GeoPos>>(chunk.size)
|
||||
var localSelectedPos: OrbitalPos? = null
|
||||
for (satellite in chunk) {
|
||||
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||
localPositions.add(satellite to GeoPos(osmLat, osmLon))
|
||||
if (satellite === selected) {
|
||||
localSelectedPos = satPos
|
||||
}
|
||||
}
|
||||
Pair(localPositions, localSelectedPos)
|
||||
}
|
||||
}
|
||||
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
|
||||
for (pair in localPositions) {
|
||||
positionsMap[pair.first] = pair.second
|
||||
}
|
||||
if (localSelectedPos != null) {
|
||||
selectedSatPos = localSelectedPos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Derive footprint and info data from the already-computed selected position
|
||||
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
||||
val footprint = satPos
|
||||
val mapData = buildMapData(selected, satPos, date)
|
||||
|
||||
// 3. Single atomic state update — one recomposition per cycle
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
positions = positionsMap,
|
||||
footprint = footprint,
|
||||
mapData = mapData.first,
|
||||
orbitalPass = mapData.second
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the map info panel data from an already-computed OrbitalPos.
|
||||
* No additional getPosition() call needed.
|
||||
*/
|
||||
private fun buildMapData(
|
||||
sat: OrbitalObject,
|
||||
satPos: OrbitalPos,
|
||||
date: Date
|
||||
): Pair<MapData, OrbitalPass> {
|
||||
var orbitalPass = defaultPass
|
||||
var aosTime = 0L.toTimerString()
|
||||
var isTimeAos = true
|
||||
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
|
||||
?.let { satPass ->
|
||||
orbitalPass = satPass
|
||||
if (!satPass.isDeepSpace) {
|
||||
aosTime = if (date.time < satPass.aosTime) {
|
||||
val millisBeforeStart = satPass.aosTime.minus(date.time)
|
||||
isTimeAos = true
|
||||
millisBeforeStart.toTimerString()
|
||||
} else {
|
||||
val millisBeforeEnd = satPass.losTime.minus(date.time)
|
||||
isTimeAos = false
|
||||
millisBeforeEnd.toTimerString()
|
||||
}
|
||||
}
|
||||
}
|
||||
val azimuth = satPos.azimuth.toDegrees()
|
||||
val elevation = satPos.elevation.toDegrees()
|
||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||
val osmPos = GeoPos(osmLat, osmLon)
|
||||
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
||||
val velocity = satPos.getOrbitalVelocity()
|
||||
val phase = satPos.phase.toDegrees()
|
||||
val visibility = satPos.eclipsed
|
||||
val satData = MapData(
|
||||
sat.data.catnum,
|
||||
sat.data.name,
|
||||
aosTime,
|
||||
isTimeAos,
|
||||
azimuth,
|
||||
elevation,
|
||||
satPos.distance,
|
||||
satPos.altitude,
|
||||
velocity,
|
||||
qthLoc,
|
||||
osmPos,
|
||||
sat.data.orbitalPeriod,
|
||||
phase,
|
||||
visibility
|
||||
)
|
||||
return satData to orbitalPass
|
||||
}
|
||||
|
||||
private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
||||
val satTracks = mutableListOf<List<GeoPos>>()
|
||||
val currentTrack = mutableListOf<GeoPos>()
|
||||
@@ -158,71 +276,10 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
|
||||
_uiState.update { it.copy(track = satTracks) }
|
||||
}
|
||||
|
||||
private suspend fun getPositions(orbitalObjects: List<OrbitalObject>, pos: GeoPos, date: Date) {
|
||||
val positions = mutableMapOf<OrbitalObject, GeoPos>()
|
||||
orbitalObjects.forEach { satellite ->
|
||||
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||
positions[satellite] = GeoPos(osmLat, osmLon)
|
||||
}
|
||||
_uiState.update { it.copy(positions = positions) }
|
||||
}
|
||||
|
||||
private suspend fun getSatFootprint(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
|
||||
val satPos = satelliteRepo.getPosition(orbitalObject, pos, date.time)
|
||||
_uiState.update { it.copy(footprint = satPos) }
|
||||
}
|
||||
|
||||
private suspend fun getSatData(sat: OrbitalObject, pos: GeoPos, date: Date) {
|
||||
var orbitalPass = defaultPass
|
||||
var aosTime = 0L.toTimerString()
|
||||
var isTimeAos = true
|
||||
allPasses.find { pass -> pass.catNum == sat.data.catnum && pass.progress < 1 }
|
||||
?.let { satPass ->
|
||||
orbitalPass = satPass
|
||||
if (!satPass.isDeepSpace) {
|
||||
aosTime = if (date.time < satPass.aosTime) {
|
||||
val millisBeforeStart = satPass.aosTime.minus(date.time)
|
||||
isTimeAos = true
|
||||
millisBeforeStart.toTimerString()
|
||||
} else {
|
||||
val millisBeforeEnd = satPass.losTime.minus(date.time)
|
||||
isTimeAos = false
|
||||
millisBeforeEnd.toTimerString()
|
||||
}
|
||||
}
|
||||
}
|
||||
val satPos = satelliteRepo.getPosition(sat, pos, date.time)
|
||||
val azimuth = satPos.azimuth.toDegrees()
|
||||
val elevation = satPos.elevation.toDegrees()
|
||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
||||
val osmPos = GeoPos(osmLat, osmLon)
|
||||
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
||||
val velocity = satPos.getOrbitalVelocity()
|
||||
val phase = satPos.phase.toDegrees()
|
||||
val visibility = satPos.eclipsed
|
||||
val satData = MapData(
|
||||
sat.data.catnum,
|
||||
sat.data.name,
|
||||
aosTime,
|
||||
isTimeAos,
|
||||
azimuth,
|
||||
elevation,
|
||||
satPos.distance,
|
||||
satPos.altitude,
|
||||
velocity,
|
||||
qthLoc,
|
||||
osmPos,
|
||||
sat.data.orbitalPeriod,
|
||||
phase,
|
||||
visibility
|
||||
)
|
||||
_uiState.update { it.copy(mapData = satData, orbitalPass = orbitalPass) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Number of parallel chunks for satellite position computation */
|
||||
private const val PARALLEL_CHUNKS = 4
|
||||
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
|
||||
initializer {
|
||||
|
||||
Reference in new issue
Block a user