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