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

@@ -0,0 +1,113 @@
# Mutual Pass & Elevation Curve Feature
> **For Hermes:** Implementation plan for dual-station mutual pass query and draggable elevation curve.
**Goal:** Port satlover.de's "对台过境查询" (dual-station mutual pass query) and draggable dual elevation curve into Look4Sat.
**Architecture:**
- New feature module `feature/mutual/` following existing feature plugin pattern
- Mutual pass logic: compute passes for two stations, find overlapping time windows
- Elevation curve: Canvas-based composable with drag gesture
**Tech Stack:** Kotlin, Jetpack Compose, Canvas, predict4java (existing)
---
## Module Structure
### New files to create:
```
feature/mutual/
├── build.gradle.kts
└── src/main/java/com/rtbishop/look4sat/feature/mutual/
├── MutualPass.kt # data class for a mutual pass
├── MutualViewModel.kt # ViewModel for dual-station logic
├── MutualScreen.kt # Main screen composable
├── MutualInputSection.kt # Location input section
├── MutualResultList.kt # Mutual pass results list
└── ElevationCurveChart.kt # Draggable dual elevation curve
```
### Existing files to modify:
- `settings.gradle.kts` — add `:feature:mutual` include
- `app/build.gradle.kts` — add `implementation(project(":feature:mutual"))`
- `app/.../MainActivity.kt` or navigation — add mutual pass route
- `core/presentation/.../strings.xml` — add string resources (Chinese + English)
---
## Task 1: Create feature/mutual module
**Files:**
- Create: `feature/mutual/build.gradle.kts`
- Modify: `settings.gradle.kts` — add `include(":feature:mutual")`
- Modify: `app/build.gradle.kts` — add `implementation(project(":feature:mutual"))`
## Task 2: Add MutualPass data model
**Files:**
- Create: `feature/mutual/src/main/java/.../MutualPass.kt`
**Data class:**
```kotlin
data class MutualPass(
val catNum: Int,
val name: String,
val startTime: Long, // common AOS time
val endTime: Long, // common LOS time
val maxElevationA: Double,
val maxElevationB: Double,
val elevationSamples: List<Pair<Long, Pair<Double, Double>>> // time -> (elevA, elevB)
)
```
## Task 3: MutualViewModel
**Files:**
- Create: `feature/mutual/src/main/java/.../MutualViewModel.kt`
**Logic:**
- Input: station A position, station B position, time range, selected satellites
- For each satellite: compute passes for A and B, find overlapping windows
- For each overlap: sample elevation every N seconds, store in MutualPass
- Return sorted list of mutual passes
## Task 4: ElevationCurveChart composable
**Files:**
- Create: `feature/mutual/src/main/java/.../ElevationCurveChart.kt`
**Features:**
- Canvas draw: background grid, elevation labels, two curves (station A color, station B color)
- Drag gesture: draggable vertical line + time label
- Time axis: show time labels at regular intervals
## Task 5: MutualScreen UI
**Files:**
- Create: `feature/mutual/src/main/java/.../MutualInputSection.kt`
- Create: `feature/mutual/src/main/java/.../MutualResultList.kt`
- Create: `feature/mutual/src/main/java/.../MutualScreen.kt`
**Layout:**
- Top: two station location inputs (lat/lon + min elevation)
- Middle: time range selector + satellite selector
- Bottom: results list with elevation curve for each mutual pass
## Task 6: Navigation + Strings
**Files:**
- Modify: `app/.../MainActivity.kt` or navigation setup
- Modify: `core/presentation/.../values/strings.xml` + `values-zh/strings.xml`
---
## Verification
1. Build: `./gradlew :app:assembleDebug` → BUILD SUCCESSFUL
2. Navigate to mutual pass screen from main navigation
3. Input two locations, select satellites, query → see mutual passes
4. Tap a mutual pass → see elevation curve with drag
5. Test with different time zones and UTC
@@ -78,6 +78,8 @@ import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
import com.rtbishop.look4sat.feature.map.MapDestination
import com.rtbishop.look4sat.feature.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
import com.rtbishop.look4sat.feature.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
@@ -125,7 +127,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -145,6 +147,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -201,6 +204,10 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Mutual> {
val viewModel = MutualViewModel(container.satelliteRepo)
MutualScreen(viewModel = viewModel)
}
entry<Screen.Settings> {
SettingsDestination()
}
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:satellites"))
@@ -35,6 +35,9 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable
data object Mutual : Screen(R.drawable.ic_radar, R.string.nav_mutual)
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
}
@@ -9,6 +9,7 @@
<string name="nav_sat">卫星</string>
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_mutual">对台</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
@@ -10,6 +10,7 @@
<string name="nav_sat">Satellites</string>
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Mutual</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
+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
}
}
}
+1
View File
@@ -22,6 +22,7 @@ include(
)
include(
":feature:map",
":feature:mutual",
":feature:passes",
":feature:radar",
":feature:satellites",