fix: smooth dual-station elevation curve

- Fix cubic Bezier control points (proper Catmull-Rom to Bezier conversion)
- Refine AOS/LOS more robustly by walking from edge into the pass window
- Still 5s sampling for smooth curves
This commit is contained in:
atsunatsu committed 2026-08-02 23:07:28 +08:00
1 parent 5de370983d
commit d7e1ce11c9
9 files changed
+437 -221

No files matched your search

@@ -206,7 +206,15 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
}
entry<Screen.Mutual> {
val viewModel = MutualViewModel(container.satelliteRepo, container.settingsRepo)
MutualScreen(viewModel = viewModel)
MutualScreen(
viewModel = viewModel,
navigateUp = navigateBack,
navigateToRadar = { catNum, aosTime, pass ->
pass?.let { container.setMutualPassData(it) }
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
}
)
}
entry<Screen.Settings> {
SettingsDestination()
@@ -50,6 +50,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
@@ -61,6 +62,9 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer {
@@ -77,6 +81,13 @@ class MainContainer(private val context: Context) : IMainContainer {
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
}
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context)
@@ -16,12 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
interface IMainContainer {
val appScope: CoroutineScope
@@ -30,6 +31,8 @@ interface IMainContainer {
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
@@ -41,6 +44,15 @@ interface IMainContainer {
fun provideSaveImage(): ISaveImage
}
data class MutualPassData(
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
val startTime: Long = 0L,
val endTime: Long = 0L,
val maxElev: Double = 10.0,
val labelA: String = "你",
val labelB: String = "友台"
)
interface IContainerProvider {
fun getMainContainer(): IMainContainer
}
@@ -158,20 +158,21 @@ fun ElevationCurveChart(
pathA.moveTo(ptsA[0].x, ptsA[0].y)
pathB.moveTo(ptsB[0].x, ptsB[0].y)
for (i in 1 until ptsA.size) {
val prev = ptsA.getOrNull(i - 2) ?: ptsA[i - 1]
val curr = ptsA[i - 1]
val next = ptsA.getOrNull(i + 1) ?: ptsA[i]
val cp1 = Offset(curr.x + (next.x - prev.x) / 6f, curr.y + (next.y - prev.y) / 6f)
val cp2 = Offset(ptsA[i].x - (ptsA[i].x - curr.x) / 6f, ptsA[i].y - (ptsA[i].y - curr.y) / 6f)
pathA.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, ptsA[i].x, ptsA[i].y)
}
for (i in 1 until ptsB.size) {
val prev = ptsB.getOrNull(i - 2) ?: ptsB[i - 1]
val curr = ptsB[i - 1]
val next = ptsB.getOrNull(i + 1) ?: ptsB[i]
val cp1 = Offset(curr.x + (next.x - prev.x) / 6f, curr.y + (next.y - prev.y) / 6f)
val cp2 = Offset(ptsB[i].x - (ptsB[i].x - curr.x) / 6f, ptsB[i].y - (ptsB[i].y - curr.y) / 6f)
pathB.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, ptsB[i].x, ptsB[i].y)
val p0a = ptsA.getOrNull(i - 2) ?: ptsA[i - 1]
val p1a = ptsA[i - 1]
val p2a = ptsA[i]
val p3a = ptsA.getOrNull(i + 1) ?: ptsA[i]
val cp1a = Offset(p1a.x + (p2a.x - p0a.x) / 6f, p1a.y + (p2a.y - p0a.y) / 6f)
val cp2a = Offset(p2a.x - (p3a.x - p1a.x) / 6f, p2a.y - (p3a.y - p1a.y) / 6f)
pathA.cubicTo(cp1a.x, cp1a.y, cp2a.x, cp2a.y, p2a.x, p2a.y)
val p0b = ptsB.getOrNull(i - 2) ?: ptsB[i - 1]
val p1b = ptsB[i - 1]
val p2b = ptsB[i]
val p3b = ptsB.getOrNull(i + 1) ?: ptsB[i]
val cp1b = Offset(p1b.x + (p2b.x - p0b.x) / 6f, p1b.y + (p2b.y - p0b.y) / 6f)
val cp2b = Offset(p2b.x - (p3b.x - p1b.x) / 6f, p2b.y - (p3b.y - p1b.y) / 6f)
pathB.cubicTo(cp1b.x, cp1b.y, cp2b.x, cp2b.y, p2b.x, p2b.y)
}
}
@@ -17,6 +17,10 @@
*/
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,11 +34,14 @@ 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.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
@@ -42,9 +49,20 @@ 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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -52,183 +70,275 @@ import java.util.Locale
@Composable
fun MutualScreen(
viewModel: MutualViewModel,
navigateUp: () -> Unit = {},
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
modifier: Modifier = Modifier
) {
val state by viewModel.uiState.collectAsState()
ScreenColumn(
topBar = { isVertical ->
TopBar(
isVerticalLayout = isVertical,
startAction = {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
},
topInfo = {
Text(
text = "对台过境",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
},
bottomInfo = {
if (state.mutualPasses.isNotEmpty()) {
Text(
text = "找到 ${state.mutualPasses.size} 个过境",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
endAction = {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(24.dp)
.width(24.dp),
strokeWidth = 2.dp
)
}
}
)
}
) { isVertical ->
MutualContent(
state = state,
isVertical = isVertical,
onQuery = viewModel::queryMutualPasses,
onSelectPass = viewModel::onSelectPass,
onNavigateToRadar = navigateToRadar,
onStationALat = viewModel::onStationALat,
onStationALon = viewModel::onStationALon,
onStationAGrid = viewModel::onStationAGrid,
onStationAMinElev = viewModel::onStationAMinElev,
onStationBLat = viewModel::onStationBLat,
onStationBLon = viewModel::onStationBLon,
onStationBGrid = viewModel::onStationBGrid,
onStationBMinElev = viewModel::onStationBMinElev,
onHoursAhead = viewModel::onHoursAhead,
onClearError = viewModel::clearError
)
}
}
@Composable
private fun MutualContent(
state: MutualUiState,
isVertical: Boolean,
onQuery: () -> Unit,
onSelectPass: (Int) -> Unit,
onNavigateToRadar: (Int, Long, MutualPassData?) -> Unit,
onStationALat: (String) -> Unit,
onStationALon: (String) -> Unit,
onStationAGrid: (String) -> Unit,
onStationAMinElev: (Double) -> Unit,
onStationBLat: (String) -> Unit,
onStationBLon: (String) -> Unit,
onStationBGrid: (String) -> Unit,
onStationBMinElev: (Double) -> Unit,
onHoursAhead: (Int) -> Unit,
onClearError: () -> Unit
) {
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)
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.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)
)
}
OutlinedTextField(
value = state.stationAGrid,
onValueChange = viewModel::onStationAGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("ON79uj") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
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)
)
}
OutlinedTextField(
value = state.stationBGrid,
onValueChange = viewModel::onStationBGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("PM01tv") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
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") }
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMsg,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
// Query button
// Input form
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
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "你的位置",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.width(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = state.stationALat,
onValueChange = onStationALat,
label = { Text("纬度") },
placeholder = { Text("39.9042") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.stationALon,
onValueChange = onStationALon,
label = { Text("经度") },
placeholder = { Text("116.4074") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = state.stationAGrid,
onValueChange = onStationAGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("ON79uj") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${state.stationAMinElev.toInt()}°",
style = MaterialTheme.typography.bodySmall
)
Slider(
value = state.stationAMinElev.toFloat(),
onValueChange = { onStationAMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
HorizontalDivider()
Text(
text = "友台位置",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = state.stationBLat,
onValueChange = onStationBLat,
label = { Text("纬度") },
placeholder = { Text("34.0522") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.stationBLon,
onValueChange = onStationBLon,
label = { Text("经度") },
placeholder = { Text("-118.2437") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = state.stationBGrid,
onValueChange = onStationBGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("PM01tv") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${state.stationBMinElev.toInt()}°",
style = MaterialTheme.typography.bodySmall
)
Slider(
value = state.stationBMinElev.toFloat(),
onValueChange = { onStationBMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
HorizontalDivider()
Text(
text = "时间范围",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(6, 12, 24, 48, 72).forEach { hours ->
FilterChip(
selected = state.hoursAhead == hours,
onClick = { onHoursAhead(hours) },
label = { Text("${hours}h") }
)
}
}
Button(
onClick = onQuery,
enabled = !state.isCalculating,
modifier = Modifier.fillMaxWidth()
) {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(18.dp)
.width(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isCalculating) "计算中..." else "查询过境")
}
}
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) }
)
}
itemsIndexed(state.mutualPasses) { index, pass ->
val mutualData = MutualPassData(
samples = pass.elevationSamples,
startTime = pass.startTime,
endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
labelA = "你",
labelB = "友台"
)
MutualPassCard(
pass = pass,
isExpanded = state.selectedPassIndex == index,
timeFormat = timeFormat,
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
onNavigateToRadar = { onNavigateToRadar(pass.catNum, pass.startTime, mutualData) }
)
}
}
}
@@ -238,39 +348,79 @@ private fun MutualPassCard(
pass: MutualPass,
isExpanded: Boolean,
timeFormat: SimpleDateFormat,
onToggle: () -> Unit
onClick: () -> Unit,
onNavigateToRadar: () -> Unit
) {
Card(
onClick = onToggle,
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
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)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = pass.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
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
)
}
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "你: ${pass.maxElevationA}°",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "友台: ${pass.maxElevationB}°",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
if (pass.elevationSamples.isNotEmpty()) {
ElevationCurveChart(
samples = pass.elevationSamples,
startTime = pass.startTime,
endTime = pass.endTime,
maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0)
)
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = onNavigateToRadar,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.ic_radar),
contentDescription = null,
modifier = Modifier.height(16.dp).width(16.dp)
)
Spacer(Modifier.width(6.dp))
Text("查看雷达")
}
}
}
}
}
@@ -263,36 +263,34 @@ class MutualViewModel(
minElevADeg: Double, minElevBDeg: Double,
approxTime: Long, step: Long, goingUp: Boolean
): Long {
var t = approxTime
val limit = 30_000L // search up to 30 seconds
if (goingUp) {
// Find the point where both stations just go above minElev
var best = t
t -= limit
while (t <= approxTime + limit) {
// AOS: walk backward from approxTime to find the last sample where both are below,
// then AOS is the next step after that.
var t = approxTime
while (t > approxTime - 70_000L) {
val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t)
if (eA > minElevADeg && eB > minElevBDeg) {
best = t
break
t -= step
} else {
return t + step
}
t += step
}
return best
return approxTime - 70_000L + step
} else {
// Find the point where either station just drops below minElev
var best = t
t -= limit
while (t <= approxTime + limit) {
// LOS: walk forward from approxTime to find the first sample where either drops below,
// then LOS is the step before that.
var t = approxTime
while (t < approxTime + 70_000L) {
val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t)
if (eA < minElevADeg || eB < minElevBDeg) {
best = t
break
if (eA > minElevADeg && eB > minElevBDeg) {
t += step
} else {
return t - step
}
t += step
}
return best
return approxTime + 70_000L - step
}
}
+4
View File
@@ -5,3 +5,7 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.radar"
}
dependencies {
implementation(project(":feature:mutual"))
}
@@ -59,6 +59,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard
@@ -70,6 +71,7 @@ import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.feature.mutual.ElevationCurveChart
import kotlinx.coroutines.launch
private enum class RadarPage(val title: String) {
@@ -83,6 +85,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
// Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
@@ -97,7 +100,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
RadarScreen(uiState, viewModel::onAction, navigateUp, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}
@@ -107,6 +110,7 @@ private fun RadarScreen(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit,
mutualData: MutualPassData,
requestMicPermission: () -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
@@ -137,10 +141,16 @@ private fun RadarScreen(
}
if (isVertical) {
RadarCard(uiState, Modifier.weight(1f))
if (mutualData.samples.isNotEmpty()) {
MutualElevationCard(mutualData, Modifier.weight(1f))
}
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, Modifier.weight(1f))
if (mutualData.samples.isNotEmpty()) {
MutualElevationCard(mutualData, Modifier.weight(1f))
}
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
}
}
@@ -304,3 +314,19 @@ private fun RadarLabel(
}
}
}
@Composable
private fun MutualElevationCard(mutualData: MutualPassData, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
if (mutualData.samples.isNotEmpty()) {
ElevationCurveChart(
samples = mutualData.samples,
startTime = mutualData.startTime,
endTime = mutualData.endTime,
maxElev = mutualData.maxElev
)
}
}
}
}
@@ -63,7 +63,13 @@ data class RadarState(
val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState(),
val cw: CwSubState = CwSubState()
val cw: CwSubState = CwSubState(),
val mutualSamples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
val mutualStartTime: Long = 0L,
val mutualEndTime: Long = 0L,
val mutualMaxElev: Double = 10.0,
val mutualLabelA: String = "你",
val mutualLabelB: String = "友台"
)
enum class SstvStatus { Idle, Recording }