fix: default station A grid, find all mutual passes, color toArgb()

- Pre-fill station A grid from settingsRepo.stationPosition
- Find ALL mutual passes per satellite (not just first)
- Fix Color.hashCode() -> Color.toArgb() for native canvas paint
- Use 2min gap between pass searches to avoid duplicates
This commit is contained in:
atsunatsu committed 2026-08-02 21:44:31 +08:00
1 parent 65f61a5147
commit 0bae306312
3 files changed
+76 -39

No files matched your search

@@ -205,7 +205,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
MapDestination() MapDestination()
} }
entry<Screen.Mutual> { entry<Screen.Mutual> {
val viewModel = MutualViewModel(container.satelliteRepo) val viewModel = MutualViewModel(container.satelliteRepo, container.settingsRepo)
MutualScreen(viewModel = viewModel) MutualScreen(viewModel = viewModel)
} }
entry<Screen.Settings> { entry<Screen.Settings> {
@@ -36,7 +36,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
@@ -66,6 +66,11 @@ fun ElevationCurveChart(
val gridColor = MaterialTheme.colorScheme.outlineVariant val gridColor = MaterialTheme.colorScheme.outlineVariant
val textColor = MaterialTheme.colorScheme.onSurfaceVariant val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val onSurfaceColor = MaterialTheme.colorScheme.onSurface val onSurfaceColor = MaterialTheme.colorScheme.onSurface
val colorAArgb = colorA.toArgb()
val colorBArgb = colorB.toArgb()
val textColorArgb = textColor.toArgb()
val onSurfaceArgb = onSurfaceColor.toArgb()
val gridColorArgb = gridColor.toArgb()
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
var dragProgress by remember { mutableFloatStateOf(0.5f) } var dragProgress by remember { mutableFloatStateOf(0.5f) }
@@ -113,7 +118,7 @@ fun ElevationCurveChart(
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
"$elev°", 2f, y + 4f, "$elev°", 2f, y + 4f,
android.graphics.Paint().apply { android.graphics.Paint().apply {
color = textColor.hashCode() color = textColorArgb
textSize = 24f textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT textAlign = android.graphics.Paint.Align.LEFT
} }
@@ -128,7 +133,7 @@ fun ElevationCurveChart(
drawContext.canvas.nativeCanvas.drawText( drawContext.canvas.nativeCanvas.drawText(
timeFormat.format(Date(t)), x - 20f, chartHeight - 2f, timeFormat.format(Date(t)), x - 20f, chartHeight - 2f,
android.graphics.Paint().apply { android.graphics.Paint().apply {
color = textColor.hashCode() color = textColorArgb
textSize = 22f textSize = 22f
textAlign = android.graphics.Paint.Align.LEFT textAlign = android.graphics.Paint.Align.LEFT
} }
@@ -176,7 +181,7 @@ fun ElevationCurveChart(
timeFormat.format(Date(dragTime)), timeFormat.format(Date(dragTime)),
dragX - 24f, plotBottom + 16f, dragX - 24f, plotBottom + 16f,
android.graphics.Paint().apply { android.graphics.Paint().apply {
color = onSurfaceColor.hashCode() color = onSurfaceArgb
textSize = 24f textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT textAlign = android.graphics.Paint.Align.LEFT
} }
@@ -185,7 +190,7 @@ fun ElevationCurveChart(
"A:${dragElevA}°", "A:${dragElevA}°",
dragX + 6f, plotTop + 16f, dragX + 6f, plotTop + 16f,
android.graphics.Paint().apply { android.graphics.Paint().apply {
color = colorA.hashCode() color = colorAArgb
textSize = 24f textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT textAlign = android.graphics.Paint.Align.LEFT
} }
@@ -194,14 +199,14 @@ fun ElevationCurveChart(
"B:${dragElevB}°", "B:${dragElevB}°",
dragX + 6f, plotTop + 42f, dragX + 6f, plotTop + 42f,
android.graphics.Paint().apply { android.graphics.Paint().apply {
color = colorB.hashCode() color = colorBArgb
textSize = 24f textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT textAlign = android.graphics.Paint.Align.LEFT
} }
) )
} }
// Legend (outside Canvas, in Composable context) // Legend
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
@@ -24,6 +24,7 @@ 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.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.roundToInt import kotlin.math.roundToInt
data class MutualUiState( data class MutualUiState(
@@ -51,12 +53,20 @@ data class MutualUiState(
) )
class MutualViewModel( class MutualViewModel(
private val satelliteRepo: ISatelliteRepo private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(MutualUiState()) private val _uiState = MutableStateFlow(MutualUiState())
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow() val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
init {
// Pre-fill station A with the user's current station position (as grid)
val pos = settingsRepo.stationPosition.value
val grid = latLonToGrid(pos.latitude, pos.longitude)
_uiState.update { it.copy(stationAGrid = grid) }
}
fun onStationALat(value: String) = _uiState.update { it.copy(stationALat = value) } fun onStationALat(value: String) = _uiState.update { it.copy(stationALat = value) }
fun onStationALon(value: String) = _uiState.update { it.copy(stationALon = value) } fun onStationALon(value: String) = _uiState.update { it.copy(stationALon = value) }
fun onStationAGrid(value: String) = _uiState.update { it.copy(stationAGrid = value) } fun onStationAGrid(value: String) = _uiState.update { it.copy(stationAGrid = value) }
@@ -110,27 +120,51 @@ class MutualViewModel(
/** Resolve a position from lat/lon text or grid square text. */ /** Resolve a position from lat/lon text or grid square text. */
private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? { private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? {
// Try grid square first
val trimmed = gridText.trim().uppercase() val trimmed = gridText.trim().uppercase()
if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) { if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) {
return gridToLatLon(trimmed) return gridToLatLon(trimmed)
} }
// Fall back to lat/lon
val lat = latText.toDoubleOrNull() val lat = latText.toDoubleOrNull()
val lon = lonText.toDoubleOrNull() val lon = lonText.toDoubleOrNull()
return if (lat != null && lon != null) GeoPos(lat, lon) else null return if (lat != null && lon != null) GeoPos(lat, lon) else null
} }
/** Convert lat/lon to Maidenhead grid (6-char). */
private fun latLonToGrid(lat: Double, lon: Double): String {
var adjLon = (lon + 180.0) % 360.0
var adjLat = (lat + 90.0) % 180.0
val fieldLon = (adjLon / 20.0).toInt()
val fieldLat = (adjLat / 10.0).toInt()
adjLon -= fieldLon * 20.0
adjLat -= fieldLat * 10.0
val squareLon = (adjLon / 2.0).toInt()
val squareLat = (adjLat / 1.0).toInt()
adjLon -= squareLon * 2.0
adjLat -= squareLat * 1.0
val subLon = (adjLon * 60.0 / 5.0).toInt()
val subLat = (adjLat * 60.0 / 2.5).toInt()
return buildString {
append('A' + fieldLon)
append('A' + fieldLat)
append('0' + squareLon)
append('0' + squareLat)
append('A' + subLon)
append('A' + subLat)
}
}
/** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */ /** Convert Maidenhead grid (4, 6, or 8 chars) to lat/lon center of the square. */
private fun gridToLatLon(grid: String): GeoPos? { private fun gridToLatLon(grid: String): GeoPos? {
val g = grid.uppercase() val g = grid.uppercase()
if (g.length < 4) return null if (g.length < 4) return null
// First pair: letters A-R
val lonField = (g[0] - 'A').toDouble() * 20.0 val lonField = (g[0] - 'A').toDouble() * 20.0
val latField = (g[1] - 'A').toDouble() * 10.0 val latField = (g[1] - 'A').toDouble() * 10.0
if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null if (lonField < 0 || lonField > 340 || latField < 0 || latField > 170) return null
// Second pair: digits 0-9
val lonSquare = (g[2] - '0').toDouble() * 2.0 val lonSquare = (g[2] - '0').toDouble() * 2.0
val latSquare = (g[3] - '0').toDouble() * 1.0 val latSquare = (g[3] - '0').toDouble() * 1.0
if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null
@@ -139,30 +173,27 @@ class MutualViewModel(
var lat = latField + latSquare var lat = latField + latSquare
if (g.length >= 6) { if (g.length >= 6) {
// Third pair: letters A-X val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0
val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0 // 5 minutes val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0
val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0 // 2.5 minutes
if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null
lon += lonSub lon += lonSub
lat += latSub lat += latSub
if (g.length >= 8) { if (g.length >= 8) {
// Fourth pair: digits 0-9 val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0
val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0 // 30 seconds val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0
val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0 // 15 seconds
if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null if (lonExt < 0 || lonExt > 270.0 / 3600.0 || latExt < 0 || latExt > 135.0 / 3600.0) return null
lon += lonExt + 15.0 / 3600.0 // center of the 30" square lon += lonExt + 15.0 / 3600.0
lat += latExt + 7.5 / 3600.0 // center of the 15" square lat += latExt + 7.5 / 3600.0
} else { } else {
lon += 2.5 / 60.0 // center of the 5' square lon += 2.5 / 60.0
lat += 1.25 / 60.0 // center of the 2.5' square lat += 1.25 / 60.0
} }
} else { } else {
lon += 1.0 // center of the 2° square lon += 1.0
lat += 0.5 // center of the 1° square lat += 0.5
} }
// Convert to signed lat/lon
lon = (lon + 180.0) % 360.0 - 180.0 lon = (lon + 180.0) % 360.0 - 180.0
lat = (lat + 90.0) % 180.0 - 90.0 lat = (lat + 90.0) % 180.0 - 90.0
return GeoPos(lat, lon) return GeoPos(lat, lon)
@@ -176,13 +207,17 @@ class MutualViewModel(
): List<MutualPass> { ): List<MutualPass> {
val results = mutableListOf<MutualPass>() val results = mutableListOf<MutualPass>()
val endTime = time + hours * 60L * 60L * 1000L val endTime = time + hours * 60L * 60L * 1000L
val sampleInterval = 10_000L // 10 seconds between samples val sampleInterval = 10_000L
for (sat in satellites) { for (sat in satellites) {
if (sat.data.meanmo < 1e-8) continue if (sat.data.meanmo < 1e-8) continue
val t = findNextMutualPass(sat, posA, posB, minElevADeg, minElevBDeg, time, endTime) var searchStart = time
if (t != null) { // Find ALL mutual passes for this satellite
while (true) {
val t = findNextMutualPass(sat, posA, posB, minElevADeg, minElevBDeg, searchStart, endTime)
if (t == null) break
val (start, end) = t val (start, end) = t
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>() val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
var maxElevA = 0.0 var maxElevA = 0.0
var maxElevB = 0.0 var maxElevB = 0.0
@@ -210,20 +245,17 @@ class MutualViewModel(
) )
) )
} }
// Advance past this pass and keep searching
searchStart = end + 120_000L
} }
} }
return results return results
} }
/** Get elevation in degrees. */
private fun elevationDeg(sat: OrbitalObject, pos: GeoPos, time: Long): Double { private fun elevationDeg(sat: OrbitalObject, pos: GeoPos, time: Long): Double {
return sat.getElevation(pos, time) * 180.0 / PI return sat.getElevation(pos, time) * 180.0 / PI
} }
/**
* Find the first common time window where a satellite is above both stations'
* minimum elevations. Uses 60-second coarse stepping.
*/
private fun findNextMutualPass( private fun findNextMutualPass(
sat: OrbitalObject, sat: OrbitalObject,
posA: GeoPos, posB: GeoPos, posA: GeoPos, posB: GeoPos,
@@ -231,15 +263,13 @@ class MutualViewModel(
startTime: Long, endTime: Long startTime: Long, endTime: Long
): Pair<Long, Long>? { ): Pair<Long, Long>? {
var t = startTime var t = startTime
val step = 60_000L // 60s coarse search step val step = 60_000L
while (t < endTime) { while (t < endTime) {
val elevA = elevationDeg(sat, posA, t) val elevA = elevationDeg(sat, posA, t)
val elevB = elevationDeg(sat, posB, t) val elevB = elevationDeg(sat, posB, t)
if (elevA > minElevADeg && elevB > minElevBDeg) { if (elevA > minElevADeg && elevB > minElevBDeg) {
// Both above horizon — find the common pass window
// Rewind to find AOS (either station drops below)
var aos = t var aos = t
var rew = t var rew = t
while (rew > startTime - 600_000L) { while (rew > startTime - 600_000L) {
@@ -252,7 +282,6 @@ class MutualViewModel(
rew -= step rew -= step
} }
// Fast-forward to find LOS (either station drops below)
var los = t var los = t
var fwd = t var fwd = t
while (fwd < endTime + 600_000L) { while (fwd < endTime + 600_000L) {
@@ -278,7 +307,10 @@ class MutualViewModel(
fun factory(container: IMainContainer) = object : ViewModelProvider.Factory { fun factory(container: IMainContainer) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
MutualViewModel(satelliteRepo = container.satelliteRepo) as T MutualViewModel(
satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo
) as T
} }
} }
} }