mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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.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 {
|
||||||
|
|||||||
Reference in new issue
Block a user