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:
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.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
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.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
@@ -125,7 +127,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
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 context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
@@ -145,6 +147,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
@@ -201,6 +204,10 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
entry<Screen.Map> {
|
entry<Screen.Map> {
|
||||||
MapDestination()
|
MapDestination()
|
||||||
}
|
}
|
||||||
|
entry<Screen.Mutual> {
|
||||||
|
val viewModel = MutualViewModel(container.satelliteRepo)
|
||||||
|
MutualScreen(viewModel = viewModel)
|
||||||
|
}
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
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
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<string name="nav_sat">卫星</string>
|
<string name="nav_sat">卫星</string>
|
||||||
<string name="nav_pass">过境</string>
|
<string name="nav_pass">过境</string>
|
||||||
<string name="nav_radar">雷达</string>
|
<string name="nav_radar">雷达</string>
|
||||||
|
<string name="nav_mutual">对台</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<string name="nav_sat">Satellites</string>
|
<string name="nav_sat">Satellites</string>
|
||||||
<string name="nav_pass">Passes</string>
|
<string name="nav_pass">Passes</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_mutual">Mutual</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.mutual"
|
||||||
|
}
|
||||||
+242
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ include(
|
|||||||
)
|
)
|
||||||
include(
|
include(
|
||||||
":feature:map",
|
":feature:map",
|
||||||
|
":feature:mutual",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
|
|||||||
Reference in new issue
Block a user