Heavily optimized MapScreen.kt and position calculations

This commit is contained in:
Arty Bishop committed 2026-04-03 16:45:02 +01:00
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.Paint
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.collection.LruCache
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -40,7 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Marker
import org.osmdroid.views.overlay.Polyline 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 minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude private val maxLat = MapView.getTileSystem().maxLatitude
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 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() color = "#FFE082".toColorInt()
setShadowLayer(3f, 3f, 3f, Color.BLACK) setShadowLayer(3f, 3f, 3f, Color.BLACK)
} }
private val labelRect = Rect() private val iconCache = LruCache<String, Drawable>(128)
fun NavGraphBuilder.mapDestination() { fun NavGraphBuilder.mapDestination() {
composable(Screen.Map.route) { composable(Screen.Map.route) {
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory) val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, mapView) MapScreen(uiState, mapView)
} }
} }
@Composable @Composable
private fun MapScreen(uiState: State<MapState>, mapView: MapView) { private fun MapScreen(uiState: MapState, mapView: MapView) {
val onItemClick = { item: OrbitalObject -> uiState.value.sendAction(MapAction.SelectItem(item)) } val onItemClick = { item: OrbitalObject -> uiState.sendAction(MapAction.SelectItem(item)) }
val selectPrev = { uiState.value.sendAction(MapAction.SelectPrev) } val selectPrev = { uiState.sendAction(MapAction.SelectPrev) }
val selectNext = { uiState.value.sendAction(MapAction.SelectNext) } val selectNext = { uiState.sendAction(MapAction.SelectNext) }
val rotateMod = Modifier.rotate(180f) val rotateMod = Modifier.rotate(180f)
val timeString = uiState.value.mapData?.aosTime ?: "00:00:00" val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.value.mapData?.isTimeAos ?: true val isTimeAos = uiState.mapData?.isTimeAos ?: true
LaunchedEffect(uiState.value.track) {
val latitude = uiState.value.track?.get(0)?.get(0)?.latitude ?: 0.0 LaunchedEffect(uiState.track) {
val longitude = uiState.value.track?.get(0)?.get(0)?.longitude ?: 0.0 val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(latitude, longitude)) mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
} }
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) { val isVertical = isVerticalLayout()
if (isVertical) {
TopBar { TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod) IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos) TimerRow(timeString = timeString, isTimeAos = isTimeAos)
IconCard(action = selectNext, resId = R.drawable.ic_arrow) IconCard(action = selectNext, resId = R.drawable.ic_arrow)
} }
TopBar { NextPassRow(pass = uiState.value.orbitalPass) } TopBar { NextPassRow(pass = uiState.orbitalPass) }
} else { } else {
TopBar { TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod) IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos) 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) IconCard(action = selectNext, resId = R.drawable.ic_arrow)
} }
} }
ElevatedCard(modifier = Modifier.weight(1f)) { ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) { Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { mapView -> AndroidView({ mapView }) { view ->
uiState.value.stationPosition?.let { setStationPosition(it, mapView) } uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.value.positions?.let { setPositions(it, mapView, onItemClick) } uiState.track?.let { setSatelliteTrack(it, view) }
uiState.value.track?.let { setSatelliteTrack(it, mapView) } uiState.footprint?.let { setFootprint(it, view) }
uiState.value.footprint?.let { setFootprint(it, mapView) } uiState.positions?.let { setPositions(it, view, onItemClick) }
view.invalidate()
} }
uiState.value.mapData?.let { mapData -> uiState.mapData?.let { mapData ->
if (isVerticalLayout()) MapDataCard(mapData) else MapDataCards(mapData) if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
} }
} }
} }
} }
} }
// region Map data composables
@Composable @Composable
private fun MapDataCard(data: MapData) { private fun MapDataCard(data: MapData) {
val textColor = MaterialTheme.colorScheme.primary val textColor = MaterialTheme.colorScheme.primary
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest) 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) Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
Card(colors = cardColors) { Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_azimuth, data.azimuth), color = textColor) stringResource(R.string.map_azimuth, data.azimuth) to textColor,
Text(text = stringResource(R.string.map_elevation, data.elevation), color = textColor) stringResource(R.string.map_elevation, data.elevation) to textColor
} )
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_altitude, data.altitude)) stringResource(R.string.map_altitude, data.altitude) to null,
Text(text = stringResource(R.string.map_distance, data.range)) stringResource(R.string.map_distance, data.range) to null
} )
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor) stringResource(R.string.map_latitude, data.osmPos.latitude) to textColor,
Text(text = stringResource(R.string.map_longitude, data.osmPos.longitude), color = textColor) stringResource(R.string.map_longitude, data.osmPos.longitude) to textColor
} )
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { MapDataRow(
Text(text = stringResource(R.string.map_qth, data.qthLoc)) stringResource(R.string.map_qth, data.qthLoc) to null,
Text(text = stringResource(R.string.map_phase, data.phase)) 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 @Composable
private fun MapDataCards(data: MapData) { private fun MapDataCards(data: MapData) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest) val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier val paddingMod = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
.width(160.dp) .width(160.dp)
val osmText = stringResource(R.string.map_copyright)
val textColor = MaterialTheme.colorScheme.primary val textColor = MaterialTheme.colorScheme.primary
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopStart)) { 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 = 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)) { Card(colors = cardColors, modifier = Modifier.align(Alignment.BottomEnd)) {
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) { Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
Text(text = stringResource(R.string.map_latitude, data.osmPos.latitude), color = textColor) 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) { private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try { try {
Marker(mapView).apply { val overlay = mapView.overlays[OVERLAY_STATION]
setInfoWindow(null) if (overlay is Marker) {
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) overlay.position = GeoPoint(stationPos.latitude, stationPos.longitude)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position) } else {
position = GeoPoint(stationPos.latitude, stationPos.longitude) mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
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 {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = getCustomTextIcon(it.key.data.name, mapView) icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
try { position = GeoPoint(stationPos.latitude, stationPos.longitude)
position = GeoPoint(it.value.latitude, it.value.longitude)
} catch (exception: IllegalArgumentException) {
println(exception.stackTraceToString())
}
setOnMarkerClickListener { _, _ ->
action(it.key)
return@setOnMarkerClickListener true
}
markers.add(this)
} }
} }
mapView.overlays[3] = markers } catch (e: Exception) {
mapView.invalidate() println(e)
} catch (exception: Exception) {
println(exception)
} }
} }
private fun getCustomTextIcon(textLabel: String, mapView: MapView): Drawable { /** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
textPaint.getTextBounds(textLabel, 0, textLabel.length, labelRect) 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 iconSize = 10f
val width = labelRect.width() + iconSize * 2f val width = labelRect.width() + iconSize * 2f
val height = textPaint.textSize * 3f + iconSize * 2f val height = textPaint.textSize * 3f + iconSize * 2f
val bitmap = createBitmap(width.toInt(), height.toInt()) val bitmap = createBitmap(width.toInt(), height.toInt())
Canvas(bitmap).run { Canvas(bitmap).run {
drawCircle(width / 2f, height / 2f, iconSize, textPaint) 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) { private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
val trackOverlay = FolderOverlay() val trackOverlay = FolderOverlay()
try { try {
satTrack.forEach { track -> satTrack.forEach { track ->
val trackPoints = track.map { GeoPoint(it.latitude, it.longitude) }
Polyline().apply { Polyline().apply {
setPoints(trackPoints) setPoints(track.map { GeoPoint(it.latitude, it.longitude) })
outlinePaint.set(trackPaint) outlinePaint.set(trackPaint)
trackOverlay.add(this) trackOverlay.add(this)
} }
} }
mapView.overlays[1] = trackOverlay mapView.overlays[OVERLAY_TRACK] = trackOverlay
} catch (exception: Exception) { } catch (e: Exception) {
println(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) { private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
val footprintPoints = orbitalPos.getRangeCircle().map { GeoPoint(it.latitude, it.longitude) }
try { try {
val footprintOverlay = Polyline().apply { val rangeCircle = orbitalPos.getRangeCircle()
outlinePaint.set(footprintPaint) // Lazily initialise the reusable point list and polyline
setPoints(footprintPoints) 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 val polyline = footprintPolyline ?: Polyline().apply {
} catch (exception: Exception) { outlinePaint.set(footprintPaint)
println(exception) footprintPolyline = this
}
polyline.setPoints(pts)
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
} catch (e: Exception) {
println(e)
} }
} }
// endregion
// region MapView lifecycle
@Composable @Composable
private fun rememberMapViewWithLifecycle(): MapView { private fun rememberMapViewWithLifecycle(): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>()) val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current val context = LocalContext.current
val mapView = remember { MapView(context) }.apply { val isVertical = isVerticalLayout()
setMultiTouchControls(true) val mapView = remember {
setUseDataConnection(false) MapView(context).apply {
setTileSource(tileSource) setMultiTouchControls(true)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels) setUseDataConnection(false)
maxZoomLevel = 7.0 setTileSource(tileSource)
controller.setCenter(GeoPoint(48.8575, 6.3514)) minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
controller.setZoom(minZoomLevel + 2) maxZoomLevel = 7.0
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) controller.setCenter(GeoPoint(48.8575, 6.3514))
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT controller.setZoom(minZoomLevel + 2)
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.setColorFilter(getColorFilter()) overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
setScrollableAreaLimitLatitude(maxLat, minLat, 0) overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
// add overlays: 0 - GSP, 1 - SatTrack, 2 - SatFootprint, 3 - SatIcons overlayManager.tilesOverlay.setColorFilter(createColorFilter())
overlays.addAll(Array(4) { FolderOverlay() }) setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
}
} }
// Makes MapView follow the lifecycle of this composable
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView) val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) { DisposableEffect(lifecycle) {
@@ -348,8 +437,7 @@ private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapVie
} }
} }
@Composable private fun createColorFilter(): ColorMatrixColorFilter {
private fun getColorFilter(): ColorMatrixColorFilter {
val grayScale = ColorMatrix().apply { setSaturation(0f) } val grayScale = ColorMatrix().apply { setSaturation(0f) }
val negative = ColorMatrix( val negative = ColorMatrix(
floatArrayOf(-1f, 0f, 0f, 0f, 260f, 0f, -1f, 0f, 0f, 260f, 0f, 0f, -1f, 0f, 260f, 0f, 0f, 0f, 1f, 0f) 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) return ColorMatrixColorFilter(negative)
} }
@Composable private fun getMinZoom(screenHeight: Int, isVertical: Boolean): Double {
private fun getMinZoom(screenHeight: Int): Double { if (!isVertical) return 3.5
if (!isVerticalLayout()) return 3.5
return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight) return MapView.getTileSystem().getLatitudeZoom(maxLat, minLat, screenHeight)
} }
// endregion
@@ -24,6 +24,8 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject 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.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo 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.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -120,15 +125,128 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
while (isActive) { while (isActive) {
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
getPositions(allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
getSatFootprint(orbitalObject, stationPos, dateNow)
getSatData(orbitalObject, stationPos, dateNow)
delay(updateFreq) 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) { private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) {
val satTracks = mutableListOf<List<GeoPos>>() val satTracks = mutableListOf<List<GeoPos>>()
val currentTrack = mutableListOf<GeoPos>() val currentTrack = mutableListOf<GeoPos>()
@@ -158,71 +276,10 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, settingsRepo: ISet
_uiState.update { it.copy(track = satTracks) } _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 { companion object {
/** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY val applicationKey = ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY
initializer { initializer {