From 0bae3063128e9f79202d79dd098d743ebac89432 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sun, 2 Aug 2026 21:44:31 +0800 Subject: [PATCH] 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 --- .../java/com/rtbishop/look4sat/MainScreen.kt | 2 +- .../feature/mutual/ElevationCurveChart.kt | 19 ++-- .../feature/mutual/MutualViewModel.kt | 94 +++++++++++++------ 3 files changed, 76 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 4a605afd..d82a3360 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -205,7 +205,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { MapDestination() } entry { - val viewModel = MutualViewModel(container.satelliteRepo) + val viewModel = MutualViewModel(container.satelliteRepo, container.settingsRepo) MutualScreen(viewModel = viewModel) } entry { diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt index fb133e6c..b0057323 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt @@ -36,7 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas @@ -66,6 +66,11 @@ fun ElevationCurveChart( val gridColor = MaterialTheme.colorScheme.outlineVariant val textColor = MaterialTheme.colorScheme.onSurfaceVariant 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()) } var dragProgress by remember { mutableFloatStateOf(0.5f) } @@ -113,7 +118,7 @@ fun ElevationCurveChart( drawContext.canvas.nativeCanvas.drawText( "$elev°", 2f, y + 4f, android.graphics.Paint().apply { - color = textColor.hashCode() + color = textColorArgb textSize = 24f textAlign = android.graphics.Paint.Align.LEFT } @@ -128,7 +133,7 @@ fun ElevationCurveChart( drawContext.canvas.nativeCanvas.drawText( timeFormat.format(Date(t)), x - 20f, chartHeight - 2f, android.graphics.Paint().apply { - color = textColor.hashCode() + color = textColorArgb textSize = 22f textAlign = android.graphics.Paint.Align.LEFT } @@ -176,7 +181,7 @@ fun ElevationCurveChart( timeFormat.format(Date(dragTime)), dragX - 24f, plotBottom + 16f, android.graphics.Paint().apply { - color = onSurfaceColor.hashCode() + color = onSurfaceArgb textSize = 24f textAlign = android.graphics.Paint.Align.LEFT } @@ -185,7 +190,7 @@ fun ElevationCurveChart( "A:${dragElevA}°", dragX + 6f, plotTop + 16f, android.graphics.Paint().apply { - color = colorA.hashCode() + color = colorAArgb textSize = 24f textAlign = android.graphics.Paint.Align.LEFT } @@ -194,14 +199,14 @@ fun ElevationCurveChart( "B:${dragElevB}°", dragX + 6f, plotTop + 42f, android.graphics.Paint().apply { - color = colorB.hashCode() + color = colorBArgb textSize = 24f textAlign = android.graphics.Paint.Align.LEFT } ) } - // Legend (outside Canvas, in Composable context) + // Legend Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceEvenly diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt index 5a193353..6a0afa3d 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -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.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo +import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.PI +import kotlin.math.floor import kotlin.math.roundToInt data class MutualUiState( @@ -51,12 +53,20 @@ data class MutualUiState( ) class MutualViewModel( - private val satelliteRepo: ISatelliteRepo + private val satelliteRepo: ISatelliteRepo, + private val settingsRepo: ISettingsRepo ) : ViewModel() { private val _uiState = MutableStateFlow(MutualUiState()) val uiState: StateFlow = _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 onStationALon(value: String) = _uiState.update { it.copy(stationALon = 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. */ private fun resolvePosition(latText: String, lonText: String, gridText: String): GeoPos? { - // Try grid square first val trimmed = gridText.trim().uppercase() if (trimmed.length in listOf(4, 6, 8) && trimmed.all { it.isLetterOrDigit() }) { return gridToLatLon(trimmed) } - // Fall back to lat/lon val lat = latText.toDoubleOrNull() val lon = lonText.toDoubleOrNull() 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. */ private fun gridToLatLon(grid: String): GeoPos? { val g = grid.uppercase() if (g.length < 4) return null - // First pair: letters A-R val lonField = (g[0] - 'A').toDouble() * 20.0 val latField = (g[1] - 'A').toDouble() * 10.0 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 latSquare = (g[3] - '0').toDouble() * 1.0 if (lonSquare < 0 || lonSquare > 18 || latSquare < 0 || latSquare > 9) return null @@ -139,30 +173,27 @@ class MutualViewModel( var lat = latField + latSquare if (g.length >= 6) { - // Third pair: letters A-X - val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0 // 5 minutes - val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0 // 2.5 minutes + val lonSub = (g[4] - 'A').toDouble() * 5.0 / 60.0 + val latSub = (g[5] - 'A').toDouble() * 2.5 / 60.0 if (lonSub < 0 || lonSub > 115.0 / 60.0 || latSub < 0 || latSub > 57.5 / 60.0) return null lon += lonSub lat += latSub if (g.length >= 8) { - // Fourth pair: digits 0-9 - val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0 // 30 seconds - val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0 // 15 seconds + val lonExt = (g[6] - '0').toDouble() * 30.0 / 3600.0 + val latExt = (g[7] - '0').toDouble() * 15.0 / 3600.0 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 - lat += latExt + 7.5 / 3600.0 // center of the 15" square + lon += lonExt + 15.0 / 3600.0 + lat += latExt + 7.5 / 3600.0 } else { - lon += 2.5 / 60.0 // center of the 5' square - lat += 1.25 / 60.0 // center of the 2.5' square + lon += 2.5 / 60.0 + lat += 1.25 / 60.0 } } else { - lon += 1.0 // center of the 2° square - lat += 0.5 // center of the 1° square + lon += 1.0 + lat += 0.5 } - // Convert to signed lat/lon lon = (lon + 180.0) % 360.0 - 180.0 lat = (lat + 90.0) % 180.0 - 90.0 return GeoPos(lat, lon) @@ -176,13 +207,17 @@ class MutualViewModel( ): List { val results = mutableListOf() val endTime = time + hours * 60L * 60L * 1000L - val sampleInterval = 10_000L // 10 seconds between samples + val sampleInterval = 10_000L for (sat in satellites) { if (sat.data.meanmo < 1e-8) continue - val t = findNextMutualPass(sat, posA, posB, minElevADeg, minElevBDeg, time, endTime) - if (t != null) { + var searchStart = time + // 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 samples = mutableListOf>>() var maxElevA = 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 } - /** Get elevation in degrees. */ private fun elevationDeg(sat: OrbitalObject, pos: GeoPos, time: Long): Double { 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( sat: OrbitalObject, posA: GeoPos, posB: GeoPos, @@ -231,15 +263,13 @@ class MutualViewModel( startTime: Long, endTime: Long ): Pair? { var t = startTime - val step = 60_000L // 60s coarse search step + val step = 60_000L while (t < endTime) { val elevA = elevationDeg(sat, posA, t) val elevB = elevationDeg(sat, posB, t) 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 rew = t while (rew > startTime - 600_000L) { @@ -252,7 +282,6 @@ class MutualViewModel( rew -= step } - // Fast-forward to find LOS (either station drops below) var los = t var fwd = t while (fwd < endTime + 600_000L) { @@ -278,7 +307,10 @@ class MutualViewModel( fun factory(container: IMainContainer) = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - MutualViewModel(satelliteRepo = container.satelliteRepo) as T + MutualViewModel( + satelliteRepo = container.satelliteRepo, + settingsRepo = container.settingsRepo + ) as T } } } \ No newline at end of file