mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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:
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> {
|
||||||
|
|||||||
+12
-7
@@ -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
|
||||||
|
|||||||
+63
-31
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user