diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index 8ec13abd..96af2a1b 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -90,6 +90,10 @@ import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.layoutPadding import org.osmdroid.tileprovider.tilesource.XYTileSource +import org.osmdroid.events.DelayedMapListener +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView @@ -145,6 +149,21 @@ fun MapDestination() { val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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) } @@ -731,6 +750,27 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) { } } +/** 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>() + +/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */ +private var lastPositions: Map? = null +private var lastAction: ((OrbitalObject) -> Unit)? = null + /** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */ private val markerPool = HashMap() private var lastMapView: MapView? = null @@ -741,13 +781,20 @@ private fun setPositions( action: (OrbitalObject) -> Unit ) { try { + lastPositions = posMap + lastAction = action // Clear caches when the MapView instance changes (e.g. config change) if (lastMapView !== mapView) { lastMapView = mapView markerPool.clear() iconCache.evictAll() + dotIcon = null footprintPolyline = 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 // causes osmdroid to detach shared Marker objects, making them invisible. @@ -756,17 +803,48 @@ private fun setPositions( } folder.items.clear() - val activeNames = HashSet(posMap.size) - posMap.forEach { (satellite, geoPos) -> + // Cull satellites outside the viewport: only meaningful once zoomed in, but that is + // 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(visibleSats.size) + for ((satellite, geoPos) in visibleSats) { 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) + // 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 val pos = marker.position if (pos != null) { @@ -775,22 +853,34 @@ private fun setPositions( } 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() - } + // Evict markers that are no longer tracked or no longer visible + markerPool.keys.retainAll(activeNames) + visibleSats.clear() } catch (e: Exception) { println(e) } } +/** Re-applies the last known positions against the new viewport after a pan or zoom */ +private fun refreshPositions(mapView: MapView): Boolean { + // Grid mode disables the satellite layer — don't re-populate markers off-screen + if (mapView.overlays.getOrNull(OVERLAY_POSITIONS)?.isEnabled == false) return true + 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 { iconCache[name]?.let { return it } val labelRect = Rect() @@ -966,9 +1056,23 @@ private fun rememberMapViewWithLifecycle(): MapView { lifecycle.addObserver(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() } + } return mapView } +private fun clearMapCaches() { + markerPool.clear() + iconCache.evictAll() + dotIcon = null + lastPositions = null + lastAction = null + lastMapView = null +} + @Composable private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) { LifecycleEventObserver { _, event -> diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt index 79cbb924..13907be6 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt @@ -48,6 +48,7 @@ sealed interface MapAction { data class SelectItem(val item: OrbitalObject) : MapAction data class SelectDefaultItem(val catnum: Int) : MapAction data class ToggleGridMode(val value: Boolean) : MapAction + data class SetVisible(val isVisible: Boolean) : MapAction } data class MapData( diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt index 8582dec3..e00bdd5b 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt @@ -37,6 +37,7 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.presentation.getDefaultPass import kotlinx.coroutines.Job +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancelAndJoin @@ -45,6 +46,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -69,6 +71,9 @@ class MapViewModel( private var dataUpdateJob: Job? = null private var dataUpdateRate = 1000L 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 = _uiState init { @@ -100,6 +105,7 @@ class MapViewModel( is MapAction.SelectItem -> selectSatellite(action.item) is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum) is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) } + is MapAction.SetVisible -> isScreenVisible.value = action.isVisible } } @@ -137,7 +143,11 @@ class MapViewModel( selectedOrbitalObject = orbitalObject viewModelScope.launch { 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() getStationPosition() getSatTrack(orbitalObject, stationPos, dateNow) @@ -148,6 +158,8 @@ class MapViewModel( else -> updateFreq } while (isActive) { + // Suspends while the map is off-screen instead of predicting into the void + isScreenVisible.first { it } dateNow.time = System.currentTimeMillis() updateMapState(orbitalObject, allSatellites, stationPos, dateNow) delay(effectiveRate)