From 18f8ab517d3b188ea1cb5a57e6b89b6e630e26c7 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Sun, 26 Apr 2026 00:07:44 +0100 Subject: [PATCH] Added current moon/sun positions to the MapScreen --- .../core/domain/predict/OrbitalPos.kt | 6 +- .../look4sat/feature/map/MapScreen.kt | 97 ++++++++++- .../rtbishop/look4sat/feature/map/MapState.kt | 6 +- .../look4sat/feature/map/MapViewModel.kt | 11 +- .../look4sat/feature/map/NightOverlay.kt | 152 ++++++++++++++++++ 5 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 feature/map/src/main/java/com/rtbishop/look4sat/feature/map/NightOverlay.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt index eeea96dc..fc9440a4 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPos.kt @@ -63,10 +63,8 @@ data class OrbitalPos( val sinBeta = sin(beta) for (azimuth in 0..720) { val rads = azimuth * DEG2RAD - val sinRads = sin(rads) - val cosRads = cos(rads) - val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cosRads) - val lon = longitude + atan2(sinRads * sinBeta * cosLat, cosBeta - sinLat * sin(lat)) + val lat = asin(sinLat * cosBeta + cosLat * sinBeta * cos(rads)) + val lon = longitude + atan2(sin(rads) * sinBeta * cosLat, cosBeta - sinLat * sin(lat)) rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG)) } return rangeCirclePoints 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 a3cf8b1b..bb86ecb5 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 @@ -83,7 +83,10 @@ 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 const val OVERLAY_TERMINATOR = 4 +private const val OVERLAY_SUN = 5 +private const val OVERLAY_MOON = 6 +private const val OVERLAY_COUNT = 7 private val minLat = MapView.getTileSystem().minLatitude private val maxLat = MapView.getTileSystem().maxLatitude @@ -106,6 +109,14 @@ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { setShadowLayer(3f, 3f, 3f, Color.BLACK) } private val iconCache = LruCache(128) +private val sunIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + colorFilter = + android.graphics.PorterDuffColorFilter("#FFE082".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN) +} +private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + colorFilter = + android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN) +} @Composable fun MapDestination() { @@ -152,6 +163,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: uiState.track?.let { setSatelliteTrack(it, view) } uiState.footprint?.let { setFootprint(it, view) } uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } } + setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view) + setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view) + setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view) view.invalidate() } uiState.mapData?.let { mapData -> @@ -280,8 +294,6 @@ private fun setPositions( 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. @@ -365,14 +377,12 @@ private var footprintPoints: ArrayList? = null private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { try { val rangeCircle = orbitalPos.getRangeCircle() - // Lazily initialize the reusable point list and polyline var pts = footprintPoints if (pts == null || pts.size != rangeCircle.size) { pts = ArrayList(rangeCircle.size) for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude)) footprintPoints = pts } else { - // Update coordinates in-place — zero allocations for (i in rangeCircle.indices) { pts[i].latitude = rangeCircle[i].latitude pts[i].longitude = rangeCircle[i].longitude @@ -388,6 +398,83 @@ private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { println(e) } } + +/** + * Update the NightOverlay with the current sub-solar position. + * The overlay is created once and kept in OVERLAY_TERMINATOR; only its + * sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it. + */ +private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) { + try { + val overlay = mapView.overlays[OVERLAY_TERMINATOR] + if (overlay is NightOverlay) { + overlay.sunLatDeg = sunLatDeg + overlay.sunLonDeg = sunLonDeg + } else { + mapView.overlays[OVERLAY_TERMINATOR] = NightOverlay().apply { + this.sunLatDeg = sunLatDeg + this.sunLonDeg = sunLonDeg + } + } + } catch (e: Exception) { + println(e) + } +} + +/** Place an ic_sun icon marker at the sub-solar point. */ +private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) { + try { + val overlay = mapView.overlays[OVERLAY_SUN] + val sunPos = GeoPoint(sunLatDeg, sunLonDeg) + if (overlay is Marker) { + overlay.position = sunPos + } else { + val iconSize = 48 + val bmp = createBitmap(iconSize, iconSize) + ContextCompat.getDrawable(mapView.context, R.drawable.ic_sun)?.apply { + setBounds(0, 0, iconSize, iconSize) + colorFilter = sunIconPaint.colorFilter + draw(Canvas(bmp)) + } + mapView.overlays[OVERLAY_SUN] = Marker(mapView).apply { + setInfoWindow(null) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = bmp.toDrawable(mapView.context.resources) + position = sunPos + } + } + } catch (e: Exception) { + println(e) + } +} + +/** Place an ic_moon icon marker at the sub-lunar point. */ +private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: MapView) { + try { + val overlay = mapView.overlays[OVERLAY_MOON] + val moonPos = GeoPoint(moonLatDeg, moonLonDeg) + if (overlay is Marker) { + overlay.position = moonPos + } else { + val iconSize = 48 + val bmp = createBitmap(iconSize, iconSize) + val c = Canvas(bmp) + ContextCompat.getDrawable(mapView.context, R.drawable.ic_moon)?.apply { + setBounds(0, 0, iconSize, iconSize) + colorFilter = moonIconPaint.colorFilter + draw(c) + } + mapView.overlays[OVERLAY_MOON] = Marker(mapView).apply { + setInfoWindow(null) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = bmp.toDrawable(mapView.context.resources) + position = moonPos + } + } + } catch (e: Exception) { + println(e) + } +} // endregion // region MapView lifecycle 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 bc45e788..e1274618 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 @@ -30,7 +30,11 @@ data class MapState( val orbitalPass: OrbitalPass, val track: List>? = null, val footprint: OrbitalPos? = null, - val positions: Map? = null + val positions: Map? = null, + val sunLatDeg: Double = 0.0, + val sunLonDeg: Double = 0.0, + val moonLatDeg: Double = 0.0, + val moonLonDeg: Double = 0.0 ) sealed interface MapAction { 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 b67b6b6a..a0903a6e 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 @@ -22,6 +22,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import com.rtbishop.look4sat.core.domain.predict.CelestialComputer import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalPass @@ -177,10 +178,12 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin } } - // 2. Derive footprint and info data from the already-computed selected position + // 2. Derive footprint, info data, sun and moon position from already-computed state val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time) val footprint = satPos val mapData = buildMapData(selected, satPos, date) + val sunPos = CelestialComputer.getSunPosition(stationPos, date.time) + val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time) // 3. Single atomic state update — one recomposition per cycle _uiState.update { @@ -188,7 +191,11 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin positions = positionsMap, footprint = footprint, mapData = mapData.first, - orbitalPass = mapData.second + orbitalPass = mapData.second, + sunLatDeg = sunPos.latitude, + sunLonDeg = sunPos.longitude, + moonLatDeg = moonPos.declination, // sub-lunar latitude = declination + moonLonDeg = if (moonPos.gha <= 180.0) -moonPos.gha else 360.0 - moonPos.gha ) } } diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/NightOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/NightOverlay.kt new file mode 100644 index 00000000..81f2f544 --- /dev/null +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/NightOverlay.kt @@ -0,0 +1,152 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.feature.map + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Overlay +import kotlin.math.cos +import kotlin.math.sin + +/** + * Custom osmdroid overlay that shades the night side of the globe. + * + * Works entirely in screen-pixel space: for each vertical strip on screen it + * asks osmdroid for the geographic coordinate, then tests whether that point is + * in the night half-sphere relative to the sub-solar point. Because the + * computation happens during draw() the result is always correct regardless + * of zoom level or map scroll position — no polygon winding issues possible. + * + * A point (latRad, lonRad) is in night when the angle to the sub-solar point + * 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 + * + * Performance: we sample one column per [stepPx] pixels (default 4) and draw + * filled vertical rectangles. On a 1080-wide screen this means ~270 trig + * evaluations per row, which is imperceptible. + */ +class NightOverlay : Overlay() { + + /** Sub-solar latitude in degrees. */ + var sunLatDeg: Double = 0.0 + + /** Sub-solar longitude in degrees. */ + var sunLonDeg: Double = 0.0 + + private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.argb(75, 0, 0, 0) + } + + private val rect = RectF() + + override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { + if (shadow) return + + val proj = mapView.projection + val sunLatRad = Math.toRadians(sunLatDeg) + val sunLonRad = Math.toRadians(sunLonDeg) + val sinSunLat = sin(sunLatRad) + val cosSunLat = cos(sunLatRad) + + val w = mapView.width + val h = mapView.height + val stepPx = 4 // sample every N pixels — balance quality vs CPU + + // We scan column by column. For each column we determine the longitude, + // then find the latitude range that is in night and shade it. + // Since longitude is constant along a vertical strip and the day/night + // boundary at a given longitude is at most two latitudes, we can do a + // scan-line fill efficiently. + + var x = 0 + while (x < w) { + // Get the geographic coordinate at the top and bottom of this column. + 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 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 = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff + val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff + val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff + + when { + dotTop < 0 && dotBot < 0 -> { + // Entire column is night — shade from top to bottom + rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat()) + canvas.drawRect(rect, nightPaint) + } + + dotTop >= 0 && dotBot >= 0 -> { + // Entire column is day — nothing to draw + } + + else -> { + // Terminator crosses this column — find the crossing pixel by binary search + val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff) + if (dotTop < 0) { + // Night at top, day at bottom + rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat()) + canvas.drawRect(rect, nightPaint) + } else { + // Day at top, night at bottom + rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat()) + canvas.drawRect(rect, nightPaint) + } + } + } + x += stepPx + } + } + + /** + * Binary-search for the pixel row where the day/night boundary crosses column [x]. + * [yTop] is in day, [yBot] is in night (or vice versa). + */ + private fun findCrossingY( + proj: org.osmdroid.views.Projection, + x: Int, + yTop: Int, + yBot: Int, + sinSunLat: Double, + cosSunLat: Double, + cosLonDiff: Double + ): Int { + var lo = yTop + var hi = yBot + while (hi - lo > 1) { + val mid = (lo + hi) / 2 + val geo = proj.fromPixels(x, mid) ?: return mid + val latRad = Math.toRadians(geo.latitude) + val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff + if (dot < 0) hi = mid else lo = mid + } + return (lo + hi) / 2 + } +}