mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
v4.4.7 - Data import overhaul, AMSAT reports, Map tweaks
This commit is contained in:
1 parent
9699d142fa
commit
d471f02370
6 files changed
+176
-49
No files matched your search
@@ -56,13 +56,14 @@
|
|||||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
|
||||||
\n* Tweaked Radar track on reposition by atsunatsu (#235)
|
\n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||||
\n* Tweaked linear transponder calc by atsunatsu (#236)
|
\nSources now have priority - the top ones define satellite names and transceiver data.
|
||||||
\n* Added saving per-sat doppler offset by atsunatsu (#237)
|
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
|
||||||
\n* Added custom freq offset setting to network reporting
|
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||||
\n* Added ability to customize data sources via import
|
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||||
\n* Fixed star chart in README by PingouinFerreux (#240)
|
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||||
|
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- AMSAT Status screen -->
|
<!-- AMSAT Status screen -->
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
* Added AMSAT status tracking page by MCKero6423 (#234)
|
* Tweaked sun calculations, added tests by atsunatsu (#241)
|
||||||
* Tweaked Radar track on reposition by atsunatsu (#235)
|
* Overhauled sources import, added HTTP status by wty2019wty (#242)
|
||||||
* Tweaked linear transponder calc by atsunatsu (#236)
|
* Localized AMSAT status screen for Chinese by atsunatsu (#245)
|
||||||
* Added saving per-sat doppler offset by atsunatsu (#237)
|
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
|
||||||
* Added custom freq offset setting to network reporting
|
* Added AMSAT satellite status reports submission by atsunatsu (#248)
|
||||||
* Added ability to customize data sources via import
|
|
||||||
* Fixed star chart in README by PingouinFerreux (#240)
|
|
||||||
@@ -21,6 +21,7 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Overlay
|
import org.osmdroid.views.overlay.Overlay
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
@@ -39,9 +40,11 @@ import kotlin.math.sin
|
|||||||
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
||||||
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
||||||
*
|
*
|
||||||
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
* Performance: we sample one column per stepPx pixels (default 4) and draw
|
||||||
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
||||||
* evaluations per row, which is imperceptible.
|
* evaluations per row, which is imperceptible. draw() is called on every
|
||||||
|
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
|
||||||
|
* single GeoPoint instance.
|
||||||
*/
|
*/
|
||||||
class MapNightOverlay : Overlay() {
|
class MapNightOverlay : Overlay() {
|
||||||
|
|
||||||
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
private val rect = RectF()
|
private val rect = RectF()
|
||||||
|
|
||||||
|
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
|
||||||
|
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||||
if (shadow) return
|
if (shadow) return
|
||||||
|
|
||||||
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
|
|||||||
val h = mapView.height
|
val h = mapView.height
|
||||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
||||||
|
|
||||||
|
// The map is never rotated, so latitude depends only on y and longitude only on x.
|
||||||
|
// Resolve the top/bottom latitudes once instead of once per column.
|
||||||
|
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
|
||||||
|
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
|
||||||
|
val sinLatTop = sin(latTopRad)
|
||||||
|
val cosLatTop = cos(latTopRad)
|
||||||
|
val sinLatBot = sin(latBotRad)
|
||||||
|
val cosLatBot = cos(latBotRad)
|
||||||
|
|
||||||
// We scan column by column. For each column we determine the longitude,
|
// We scan column by column. For each column we determine the longitude,
|
||||||
// then find the latitude range that is in night and shade it.
|
// then find the latitude range that is in night and shade it.
|
||||||
// Since longitude is constant along a vertical strip and the day/night
|
// Since longitude is constant along a vertical strip and the day/night
|
||||||
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
|
|||||||
|
|
||||||
var x = 0
|
var x = 0
|
||||||
while (x < w) {
|
while (x < w) {
|
||||||
// Get the geographic coordinate at the top and bottom of this column.
|
val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
|
||||||
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
|
||||||
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
|
||||||
|
|
||||||
val lonRad = Math.toRadians(geoTop.longitude)
|
val lonRad = Math.toRadians(geoTop.longitude)
|
||||||
val cosLonDiff = cos(lonRad - sunLonRad)
|
val cosLonDiff = cos(lonRad - sunLonRad)
|
||||||
|
|
||||||
// Top pixel geographic lat
|
|
||||||
val latTopRad = Math.toRadians(geoTop.latitude)
|
|
||||||
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
|
||||||
val latBotRad = Math.toRadians(geoBot.latitude)
|
|
||||||
|
|
||||||
// dot(sunVec, pointVec) < 0 → night
|
// dot(sunVec, pointVec) < 0 → night
|
||||||
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
||||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
|
||||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
|
||||||
|
|
||||||
when {
|
when {
|
||||||
dotTop < 0 && dotBot < 0 -> {
|
dotTop < 0 && dotBot < 0 -> {
|
||||||
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
|
|||||||
var hi = yBot
|
var hi = yBot
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
val mid = (lo + hi) / 2
|
val mid = (lo + hi) / 2
|
||||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
|
||||||
val latRad = Math.toRadians(geo.latitude)
|
val latRad = Math.toRadians(geo.latitude)
|
||||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||||
if (dot < 0) hi = mid else lo = mid
|
if (dot < 0) hi = mid else lo = mid
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
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.OrbitalPos
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||||
@@ -71,6 +70,10 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
|||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import org.osmdroid.events.DelayedMapListener
|
||||||
|
import org.osmdroid.events.MapListener
|
||||||
|
import org.osmdroid.events.ScrollEvent
|
||||||
|
import org.osmdroid.events.ZoomEvent
|
||||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.CustomZoomButtonsController
|
import org.osmdroid.views.CustomZoomButtonsController
|
||||||
@@ -126,6 +129,21 @@ fun MapDestination() {
|
|||||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val mapView = rememberMapViewWithLifecycle()
|
val mapView = rememberMapViewWithLifecycle()
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
DisposableEffect(lifecycle) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
|
||||||
|
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycle.removeObserver(observer)
|
||||||
|
viewModel.onAction(MapAction.SetVisible(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
MapScreen(uiState, viewModel::onAction, mapView)
|
MapScreen(uiState, viewModel::onAction, mapView)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,19 +301,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
|||||||
private val markerPool = HashMap<String, Marker>()
|
private val markerPool = HashMap<String, Marker>()
|
||||||
private var lastMapView: MapView? = null
|
private var lastMapView: MapView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this many satellites inside the viewport labels are dropped in favor of a single
|
||||||
|
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
|
||||||
|
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
|
||||||
|
*/
|
||||||
|
private const val LABEL_LIMIT = 128
|
||||||
|
|
||||||
|
/** Degrees of space around the viewport so markers don't pop in at the edges */
|
||||||
|
private const val VIEWPORT_MARGIN = 8.0
|
||||||
|
|
||||||
|
/** Debounce for viewport-driven marker refreshes, in milliseconds */
|
||||||
|
private const val MAP_LISTENER_DELAY = 128L
|
||||||
|
|
||||||
|
/** Shared dot icon used when too many satellites are visible to label them */
|
||||||
|
private var dotIcon: Drawable? = null
|
||||||
|
|
||||||
|
/** Scratch list reused every frame to avoid per-tick allocation */
|
||||||
|
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
|
||||||
|
|
||||||
|
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
|
||||||
|
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
|
||||||
|
private var lastAction: ((OrbitalObject) -> Unit)? = null
|
||||||
|
|
||||||
private fun setPositions(
|
private fun setPositions(
|
||||||
posMap: Map<OrbitalObject, GeoPos>,
|
posMap: Map<OrbitalObject, GeoPos>,
|
||||||
mapView: MapView,
|
mapView: MapView,
|
||||||
action: (OrbitalObject) -> Unit
|
action: (OrbitalObject) -> Unit
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
lastPositions = posMap
|
||||||
|
lastAction = action
|
||||||
// Clear caches when the MapView instance changes (e.g. config change)
|
// Clear caches when the MapView instance changes (e.g. config change)
|
||||||
if (lastMapView !== mapView) {
|
if (lastMapView !== mapView) {
|
||||||
lastMapView = mapView
|
lastMapView = mapView
|
||||||
markerPool.clear()
|
markerPool.clear()
|
||||||
iconCache.evictAll()
|
iconCache.evictAll()
|
||||||
|
dotIcon = null
|
||||||
footprintPolyline = null
|
footprintPolyline = null
|
||||||
footprintPoints = null
|
footprintPoints = null
|
||||||
|
mapView.addMapListener(DelayedMapListener(object : MapListener {
|
||||||
|
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
|
||||||
|
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
|
||||||
|
}, MAP_LISTENER_DELAY))
|
||||||
}
|
}
|
||||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||||
@@ -304,17 +352,48 @@ private fun setPositions(
|
|||||||
}
|
}
|
||||||
folder.items.clear()
|
folder.items.clear()
|
||||||
|
|
||||||
val activeNames = HashSet<String>(posMap.size)
|
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
|
||||||
posMap.forEach { (satellite, geoPos) ->
|
// exactly when marker labels are shown and drawing is most expensive.
|
||||||
|
visibleSats.clear()
|
||||||
|
if (mapView.width > 0 && mapView.height > 0) {
|
||||||
|
val box = mapView.boundingBox
|
||||||
|
val latNorth = box.latNorth + VIEWPORT_MARGIN
|
||||||
|
val latSouth = box.latSouth - VIEWPORT_MARGIN
|
||||||
|
val lonWest = box.lonWest - VIEWPORT_MARGIN
|
||||||
|
val lonEast = box.lonEast + VIEWPORT_MARGIN
|
||||||
|
val wrapsDateLine = box.lonWest > box.lonEast
|
||||||
|
for ((satellite, geoPos) in posMap) {
|
||||||
|
val lat = geoPos.latitude
|
||||||
|
if (lat !in latSouth..latNorth) continue
|
||||||
|
val lon = geoPos.longitude
|
||||||
|
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
|
||||||
|
else lon in lonWest..lonEast
|
||||||
|
if (isLonVisible) visibleSats.add(satellite to geoPos)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (entry in posMap) visibleSats.add(entry.key to entry.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showLabels = visibleSats.size <= LABEL_LIMIT
|
||||||
|
val activeNames = HashSet<String>(visibleSats.size)
|
||||||
|
for ((satellite, geoPos) in visibleSats) {
|
||||||
val name = satellite.data.name
|
val name = satellite.data.name
|
||||||
activeNames.add(name)
|
activeNames.add(name)
|
||||||
val marker = markerPool.getOrPut(name) {
|
val marker = markerPool.getOrPut(name) {
|
||||||
Marker(mapView).apply {
|
Marker(mapView).apply {
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
icon = getCachedTextIcon(name, mapView)
|
// Resolve the satellite via relatedObject so the listener is allocated
|
||||||
|
// once per marker instead of once per satellite per tick
|
||||||
|
setOnMarkerClickListener { clicked, _ ->
|
||||||
|
(clicked.relatedObject as? OrbitalObject)?.let(action)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
marker.relatedObject = satellite
|
||||||
|
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
|
||||||
|
if (marker.icon !== icon) marker.icon = icon
|
||||||
// Update position in-place — reuse existing GeoPoint if available
|
// Update position in-place — reuse existing GeoPoint if available
|
||||||
val pos = marker.position
|
val pos = marker.position
|
||||||
if (pos != null) {
|
if (pos != null) {
|
||||||
@@ -323,22 +402,32 @@ private fun setPositions(
|
|||||||
} else {
|
} else {
|
||||||
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||||
}
|
}
|
||||||
marker.setOnMarkerClickListener { _, _ ->
|
|
||||||
action(satellite)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
folder.add(marker)
|
folder.add(marker)
|
||||||
}
|
}
|
||||||
// Evict markers for satellites no longer tracked
|
// Evict markers that are no longer tracked or no longer visible
|
||||||
val iter = markerPool.keys.iterator()
|
markerPool.keys.retainAll(activeNames)
|
||||||
while (iter.hasNext()) {
|
visibleSats.clear()
|
||||||
if (iter.next() !in activeNames) iter.remove()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println(e)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Re-applies the last known positions against the new viewport after a pan or zoom */
|
||||||
|
private fun refreshPositions(mapView: MapView): Boolean {
|
||||||
|
val posMap = lastPositions ?: return false
|
||||||
|
val action = lastAction ?: return false
|
||||||
|
setPositions(posMap, mapView, action)
|
||||||
|
mapView.invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
|
||||||
|
val size = 20
|
||||||
|
val bitmap = createBitmap(size, size)
|
||||||
|
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
|
||||||
|
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
|
||||||
|
}
|
||||||
|
|
||||||
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||||
iconCache[name]?.let { return it }
|
iconCache[name]?.let { return it }
|
||||||
val labelRect = Rect()
|
val labelRect = Rect()
|
||||||
@@ -376,9 +465,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
|||||||
private var footprintPolyline: Polyline? = null
|
private var footprintPolyline: Polyline? = null
|
||||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
private var footprintPoints: ArrayList<GeoPoint>? = null
|
||||||
|
|
||||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
val rangeCircle = orbitalPos.getRangeCircle()
|
|
||||||
var pts = footprintPoints
|
var pts = footprintPoints
|
||||||
if (pts == null || pts.size != rangeCircle.size) {
|
if (pts == null || pts.size != rangeCircle.size) {
|
||||||
pts = ArrayList(rangeCircle.size)
|
pts = ArrayList(rangeCircle.size)
|
||||||
@@ -508,9 +596,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
|||||||
lifecycle.addObserver(lifecycleObserver)
|
lifecycle.addObserver(lifecycleObserver)
|
||||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||||
}
|
}
|
||||||
|
// The overlay caches below are file-level (shared across MapView instances), so they must be
|
||||||
|
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
|
||||||
|
DisposableEffect(mapView) {
|
||||||
|
onDispose {
|
||||||
|
clearMapCaches()
|
||||||
|
mapView.onDetach()
|
||||||
|
}
|
||||||
|
}
|
||||||
return mapView
|
return mapView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearMapCaches() {
|
||||||
|
markerPool.clear()
|
||||||
|
iconCache.evictAll()
|
||||||
|
dotIcon = null
|
||||||
|
footprintPolyline = null
|
||||||
|
footprintPoints = null
|
||||||
|
lastPositions = null
|
||||||
|
lastAction = null
|
||||||
|
lastMapView = null
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||||
LifecycleEventObserver { _, event ->
|
LifecycleEventObserver { _, event ->
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.feature.map
|
|||||||
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.OrbitalPass
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
|
||||||
|
|
||||||
data class MapState(
|
data class MapState(
|
||||||
val mapData: MapData? = null,
|
val mapData: MapData? = null,
|
||||||
@@ -29,7 +28,7 @@ data class MapState(
|
|||||||
val stationPosition: GeoPos? = null,
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>? = null,
|
val track: List<List<GeoPos>>? = null,
|
||||||
val footprint: OrbitalPos? = null,
|
val footprint: List<GeoPos>? = null,
|
||||||
val positions: Map<OrbitalObject, GeoPos>? = null,
|
val positions: Map<OrbitalObject, GeoPos>? = null,
|
||||||
val sunLatDeg: Double = 0.0,
|
val sunLatDeg: Double = 0.0,
|
||||||
val sunLonDeg: Double = 0.0,
|
val sunLonDeg: Double = 0.0,
|
||||||
@@ -42,6 +41,7 @@ sealed interface MapAction {
|
|||||||
data object SelectNext : MapAction
|
data object SelectNext : MapAction
|
||||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||||
|
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MapData(
|
data class MapData(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class MapViewModel(
|
class MapViewModel(
|
||||||
private val satelliteRepo: ISatelliteRepo,
|
private val satelliteRepo: ISatelliteRepo,
|
||||||
@@ -69,6 +72,9 @@ class MapViewModel(
|
|||||||
private var dataUpdateJob: Job? = null
|
private var dataUpdateJob: Job? = null
|
||||||
private var dataUpdateRate = 1000L
|
private var dataUpdateRate = 1000L
|
||||||
private var selectedOrbitalObject: OrbitalObject? = null
|
private var selectedOrbitalObject: OrbitalObject? = null
|
||||||
|
|
||||||
|
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
|
||||||
|
private val isScreenVisible = MutableStateFlow(true)
|
||||||
val uiState: StateFlow<MapState> = _uiState
|
val uiState: StateFlow<MapState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -87,6 +93,7 @@ class MapViewModel(
|
|||||||
MapAction.SelectNext -> scrollSelection(false)
|
MapAction.SelectNext -> scrollSelection(false)
|
||||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||||
|
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +131,11 @@ class MapViewModel(
|
|||||||
selectedOrbitalObject = orbitalObject
|
selectedOrbitalObject = orbitalObject
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataUpdateJob?.cancelAndJoin()
|
dataUpdateJob?.cancelAndJoin()
|
||||||
dataUpdateJob = launch {
|
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
|
||||||
|
// satelliteRepo call internally hops to Default and resumes back on the caller's
|
||||||
|
// dispatcher. On Main that posts one continuation per satellite per tick, which
|
||||||
|
// floods the looper and ANRs for users tracking thousands of objects.
|
||||||
|
dataUpdateJob = launch(Dispatchers.Default) {
|
||||||
val dateNow = Date()
|
val dateNow = Date()
|
||||||
getStationPosition()
|
getStationPosition()
|
||||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||||
@@ -135,9 +146,11 @@ class MapViewModel(
|
|||||||
else -> updateFreq
|
else -> updateFreq
|
||||||
}
|
}
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
// Suspends while the map is off-screen instead of predicting into the void
|
||||||
|
isScreenVisible.first { it }
|
||||||
dateNow.time = System.currentTimeMillis()
|
dateNow.time = System.currentTimeMillis()
|
||||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||||
delay(effectiveRate)
|
delay(effectiveRate.milliseconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +200,8 @@ class MapViewModel(
|
|||||||
|
|
||||||
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
// 2. Derive footprint, info data, sun and moon position from already-computed state
|
||||||
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
|
||||||
val footprint = satPos
|
// Range circle is 721 trig-heavy points — keep it on this background dispatcher
|
||||||
|
val footprint = satPos.getRangeCircle()
|
||||||
val mapData = buildMapData(selected, satPos, date)
|
val mapData = buildMapData(selected, satPos, date)
|
||||||
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
|
||||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
|
||||||
@@ -294,7 +308,7 @@ class MapViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Number of parallel chunks for satellite position computation */
|
/** Number of parallel chunks for satellite position computation */
|
||||||
private const val PARALLEL_CHUNKS = 4
|
private val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
|
||||||
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
fun factory(container: IMainContainer) = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
|
|||||||
Reference in new issue
Block a user