Added current moon/sun positions to the MapScreen

This commit is contained in:
Arty Bishop committed 2026-04-26 00:11:08 +01:00
1 parent d3b8e951dd
commit 18f8ab517d
5 files changed
+260 -12

No files matched your search

@@ -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
@@ -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<String, Drawable>(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<GeoPoint>? = 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
@@ -30,7 +30,11 @@ data class MapState(
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null,
val positions: Map<OrbitalObject, GeoPos>? = null
val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0,
val moonLatDeg: Double = 0.0,
val moonLonDeg: Double = 0.0
)
sealed interface MapAction {
@@ -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
)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}