feat: add mutual pass query with dual-station elevation curve

Port satlover.de dual-station pass matching feature:
- New feature/mutual module with mutual pass data model
- MutualViewModel: compute overlapping passes for two stations
- ElevationCurveChart: Canvas-based dual elevation curve with drag
- MutualScreen: input form + results with expandable cards
- Navigation: add Mutual tab to bottom navigation bar
- i18n: add Chinese/English strings for new feature
This commit is contained in:
atsunatsu committed 2026-08-02 21:22:42 +08:00
1 parent 8aedd38b51
commit 1064dc739e
12 files changed
+899 -1

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.mutual"
}
@@ -0,0 +1,242 @@
/*
* 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.mutual
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
/**
* Draggable dual-station elevation curve chart.
*/
@Composable
fun ElevationCurveChart(
samples: List<Pair<Long, Pair<Double, Double>>>,
startTime: Long,
endTime: Long,
maxElev: Double,
modifier: Modifier = Modifier
) {
if (samples.isEmpty()) return
val colorA = MaterialTheme.colorScheme.primary
val colorB = MaterialTheme.colorScheme.tertiary
val gridColor = MaterialTheme.colorScheme.outlineVariant
val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
var dragProgress by remember { mutableFloatStateOf(0.5f) }
Column(modifier = modifier.fillMaxWidth()) {
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.pointerInput(Unit) {
detectTapGestures { offset ->
dragProgress = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
}
}
.pointerInput(Unit) {
detectDragGestures { _, dragAmount ->
dragProgress = (dragProgress + dragAmount.x / size.width.toFloat()).coerceIn(0f, 1f)
}
}
) {
val chartWidth = size.width
val chartHeight = size.height
val padding = 40f
val plotLeft = padding
val plotRight = chartWidth - 10f
val plotTop = 10f
val plotBottom = chartHeight - padding
val plotWidth = plotRight - plotLeft
val plotHeight = plotBottom - plotTop
if (plotWidth <= 0f || plotHeight <= 0f) return@Canvas
val minTime = startTime.toFloat()
val maxTime = endTime.toFloat()
val timeRange = maxTime - minTime
if (timeRange <= 0f) return@Canvas
// Grid lines
val gridSteps = 4
for (i in 0..gridSteps) {
val y = plotBottom - (plotHeight * i / gridSteps)
drawLine(gridColor, Offset(plotLeft, y), Offset(plotRight, y), strokeWidth = 1f)
val elev = (maxElev * i / gridSteps).roundToInt()
drawContext.canvas.nativeCanvas.drawText(
"$elev°", 2f, y + 4f,
android.graphics.Paint().apply {
color = textColor.hashCode()
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
}
// Time axis
val timeSteps = 4
for (i in 0..timeSteps) {
val x = plotLeft + (plotWidth * i / timeSteps)
val t = startTime + ((endTime - startTime) * i / timeSteps)
drawContext.canvas.nativeCanvas.drawText(
timeFormat.format(Date(t)), x - 20f, chartHeight - 2f,
android.graphics.Paint().apply {
color = textColor.hashCode()
textSize = 22f
textAlign = android.graphics.Paint.Align.LEFT
}
)
}
// Elevation curves
val pathA = Path()
val pathB = Path()
var first = true
for ((time, elev) in samples) {
val x = plotLeft + ((time.toFloat() - minTime) / timeRange) * plotWidth
val yA = plotBottom - ((elev.first.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
val yB = plotBottom - ((elev.second.toFloat() / maxElev.toFloat()) * plotHeight).coerceIn(0f, plotHeight)
if (first) {
pathA.moveTo(x, yA)
pathB.moveTo(x, yB)
first = false
} else {
pathA.lineTo(x, yA)
pathB.lineTo(x, yB)
}
}
drawPath(pathA, colorA, style = Stroke(width = 2.5f))
drawPath(pathB, colorB, style = Stroke(width = 2.5f))
// Drag indicator
val dragX = plotLeft + dragProgress * plotWidth
val dragTime = startTime + (dragProgress * (endTime - startTime)).toLong()
drawLine(
onSurfaceColor,
Offset(dragX, plotTop), Offset(dragX, plotBottom),
strokeWidth = 2f
)
val dragElev = getElevationAtTime(samples, dragTime)
val dragElevA = (dragElev?.first?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
val dragElevB = (dragElev?.second?.let { (it * 10).roundToInt() / 10.0 } ?: 0.0)
drawContext.canvas.nativeCanvas.drawText(
timeFormat.format(Date(dragTime)),
dragX - 24f, plotBottom + 16f,
android.graphics.Paint().apply {
color = onSurfaceColor.hashCode()
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
drawContext.canvas.nativeCanvas.drawText(
"A:${dragElevA}°",
dragX + 6f, plotTop + 16f,
android.graphics.Paint().apply {
color = colorA.hashCode()
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
drawContext.canvas.nativeCanvas.drawText(
"B:${dragElevB}°",
dragX + 6f, plotTop + 42f,
android.graphics.Paint().apply {
color = colorB.hashCode()
textSize = 24f
textAlign = android.graphics.Paint.Align.LEFT
}
)
}
// Legend (outside Canvas, in Composable context)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text("● 站点A", color = colorA, fontSize = 12.sp)
Text("● 站点B", color = colorB, fontSize = 12.sp)
}
}
}
private fun getElevationAtTime(
samples: List<Pair<Long, Pair<Double, Double>>>,
time: Long
): Pair<Double, Double>? {
if (samples.isEmpty()) return null
if (samples.size == 1) return samples[0].second
if (time <= samples[0].first) return samples[0].second
if (time >= samples.last().first) return samples.last().second
var lo = 0
var hi = samples.size - 1
while (hi - lo > 1) {
val mid = (lo + hi) / 2
if (samples[mid].first <= time) lo = mid
else hi = mid
}
val t0 = samples[lo].first
val t1 = samples[hi].first
val frac = if (t1 > t0) (time - t0).toFloat() / (t1 - t0).toFloat() else 0f
val e0 = samples[lo].second
val e1 = samples[hi].second
return Pair(
e0.first + (e1.first - e0.first) * frac,
e0.second + (e1.second - e0.second) * frac
)
}
@@ -0,0 +1,39 @@
/*
* 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.mutual
/**
* A mutual pass where a satellite is visible from two stations simultaneously.
*
* @param catNum Satellite catalog number
* @param name Satellite name
* @param startTime Common AOS time (millis)
* @param endTime Common LOS time (millis)
* @param maxElevationA Peak elevation for station A
* @param maxElevationB Peak elevation for station B
* @param elevationSamples List of (time, (elevationA, elevationB)) pairs
*/
data class MutualPass(
val catNum: Int,
val name: String,
val startTime: Long,
val endTime: Long,
val maxElevationA: Double,
val maxElevationB: Double,
val elevationSamples: List<Pair<Long, Pair<Double, Double>>>
)
@@ -0,0 +1,269 @@
/*
* 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.mutual
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun MutualScreen(
viewModel: MutualViewModel,
modifier: Modifier = Modifier
) {
val state by viewModel.uiState.collectAsState()
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Title
item {
Text(
text = "对台过境查询",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = "查看双方同时过境的卫星,然后相约 QSO 吧!",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Error message
val errorMsg = state.errorMessage
if (errorMsg != null) {
item {
Text(
text = errorMsg,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
// Station A input
item {
Text("你的位置", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = state.stationALat,
onValueChange = viewModel::onStationALat,
label = { Text("纬度") },
placeholder = { Text("例如: 39.9042") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.stationALon,
onValueChange = viewModel::onStationALon,
label = { Text("经度") },
placeholder = { Text("例如: 116.4074") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Text("最小仰角:${state.stationAMinElev.toInt()}°", style = MaterialTheme.typography.bodySmall)
Slider(
value = state.stationAMinElev.toFloat(),
onValueChange = { viewModel.onStationAMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17,
modifier = Modifier.fillMaxWidth()
)
}
// Station B input
item {
Text("友台位置", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = state.stationBLat,
onValueChange = viewModel::onStationBLat,
label = { Text("纬度") },
placeholder = { Text("例如: 34.0522") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.stationBLon,
onValueChange = viewModel::onStationBLon,
label = { Text("经度") },
placeholder = { Text("例如: -118.2437") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Text("最小仰角:${state.stationBMinElev.toInt()}°", style = MaterialTheme.typography.bodySmall)
Slider(
value = state.stationBMinElev.toFloat(),
onValueChange = { viewModel.onStationBMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17,
modifier = Modifier.fillMaxWidth()
)
}
// Time range
item {
Text("时间范围", style = MaterialTheme.typography.titleSmall)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(6, 12, 24, 48, 72).forEach { hours ->
FilterChip(
selected = state.hoursAhead == hours,
onClick = { viewModel.onHoursAhead(hours) },
label = { Text("${hours}h") }
)
}
}
}
// Query button
item {
Button(
onClick = { viewModel.queryMutualPasses() },
enabled = !state.isCalculating,
modifier = Modifier.fillMaxWidth()
) {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp).width(20.dp),
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isCalculating) "计算中..." else "查询过境")
}
}
// Results
if (state.mutualPasses.isNotEmpty()) {
item {
Text(
text = "找到 ${state.mutualPasses.size} 个共同过境",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
itemsIndexed(state.mutualPasses) { index, pass ->
MutualPassCard(
pass = pass,
isExpanded = state.selectedPassIndex == index,
timeFormat = timeFormat,
onToggle = { viewModel.onSelectPass(if (state.selectedPassIndex == index) -1 else index) }
)
}
}
}
}
@Composable
private fun MutualPassCard(
pass: MutualPass,
isExpanded: Boolean,
timeFormat: SimpleDateFormat,
onToggle: () -> Unit
) {
Card(
onClick = onToggle,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
}
Text(
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "你: ${pass.maxElevationA}° 友台: ${pass.maxElevationB}°",
style = MaterialTheme.typography.bodySmall
)
if (isExpanded && pass.elevationSamples.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
ElevationCurveChart(
samples = pass.elevationSamples,
startTime = pass.startTime,
endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0)
)
}
}
}
}
@@ -0,0 +1,214 @@
/*
* 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.mutual
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
data class MutualUiState(
val stationALat: String = "",
val stationALon: String = "",
val stationAMinElev: Double = 10.0,
val stationBLat: String = "",
val stationBLon: String = "",
val stationBMinElev: Double = 10.0,
val hoursAhead: Int = 24,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val selectedPassIndex: Int = -1,
val errorMessage: String? = null
)
class MutualViewModel(
private val satelliteRepo: ISatelliteRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(MutualUiState())
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
fun onStationALat(value: String) = _uiState.update { it.copy(stationALat = value) }
fun onStationALon(value: String) = _uiState.update { it.copy(stationALon = value) }
fun onStationAMinElev(value: Double) = _uiState.update { it.copy(stationAMinElev = value) }
fun onStationBLat(value: String) = _uiState.update { it.copy(stationBLat = value) }
fun onStationBLon(value: String) = _uiState.update { it.copy(stationBLon = value) }
fun onStationBMinElev(value: Double) = _uiState.update { it.copy(stationBMinElev = value) }
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
fun queryMutualPasses() {
val state = _uiState.value
val latA = state.stationALat.toDoubleOrNull()
val lonA = state.stationALon.toDoubleOrNull()
val latB = state.stationBLat.toDoubleOrNull()
val lonB = state.stationBLon.toDoubleOrNull()
if (latA == null || lonA == null || latB == null || lonB == null) {
_uiState.update { it.copy(errorMessage = "请输入有效的位置坐标") }
return
}
val satellites = satelliteRepo.satellites.value
if (satellites.isEmpty()) {
_uiState.update { it.copy(errorMessage = "没有卫星数据,请先在卫星列表中选择卫星") }
return
}
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) }
viewModelScope.launch {
val time = System.currentTimeMillis()
val posA = GeoPos(latA, lonA)
val posB = GeoPos(latB, lonB)
val minElevA = state.stationAMinElev
val minElevB = state.stationBMinElev
val hours = state.hoursAhead
val results = withContext(Dispatchers.Default) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
}
_uiState.update {
it.copy(
mutualPasses = results.sortedBy { mp -> mp.startTime },
isCalculating = false,
errorMessage = if (results.isEmpty()) "未找到共同过境" else null
)
}
}
}
private fun findMutualPasses(
satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos,
minElevA: Double, minElevB: Double,
time: Long, hours: Int
): List<MutualPass> {
val results = mutableListOf<MutualPass>()
val endTime = time + hours * 60L * 60L * 1000L
val sampleInterval = 10_000L // 10 seconds between samples
for (sat in satellites) {
if (sat.data.meanmo < 1e-8) continue
val t = findNextMutualPass(sat, posA, posB, minElevA, minElevB, time, endTime)
if (t != null) {
val (start, end) = t
val samples = mutableListOf<Pair<Long, Pair<Double, Double>>>()
var maxElevA = 0.0
var maxElevB = 0.0
var tSample = start
while (tSample <= end) {
val elevA = sat.getElevation(posA, tSample)
val elevB = sat.getElevation(posB, tSample)
if (elevA > maxElevA) maxElevA = elevA
if (elevB > maxElevB) maxElevB = elevB
samples.add(tSample to (elevA to elevB))
tSample += sampleInterval
}
results.add(
MutualPass(
catNum = sat.data.catnum,
name = sat.data.name,
startTime = start,
endTime = end,
maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
elevationSamples = samples
)
)
}
}
return results
}
/**
* 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,
minElevA: Double, minElevB: Double,
startTime: Long, endTime: Long
): Pair<Long, Long>? {
var t = startTime
val step = 60_000L // 60s coarse search step
while (t < endTime) {
val elevA = sat.getElevation(posA, t)
val elevB = sat.getElevation(posB, t)
if (elevA > minElevA && elevB > minElevB) {
// Both above horizon — find the common pass window
// Rewind to find AOS (both stations)
var aos = t
var rew = t
while (rew > startTime - 600_000L) {
val eA = sat.getElevation(posA, rew)
val eB = sat.getElevation(posB, rew)
if (eA < minElevA || eB < minElevB) {
aos = rew + step
break
}
rew -= step
}
// Fast-forward to find LOS (either station drops below)
var los = t
var fwd = t
while (fwd < endTime + 600_000L) {
val eA = sat.getElevation(posA, fwd)
val eB = sat.getElevation(posB, fwd)
if (eA < minElevA || eB < minElevB) {
los = fwd
break
}
fwd += step
}
if (los > aos) return Pair(aos, los)
}
t += step
}
return null
}
fun clearError() = _uiState.update { it.copy(errorMessage = null) }
companion object {
fun factory(container: IMainContainer) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
MutualViewModel(satelliteRepo = container.satelliteRepo) as T
}
}
}