From 1064dc739e70498d433ac92876901bd449fd5fe8 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sun, 2 Aug 2026 21:22:42 +0800 Subject: [PATCH] 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 --- ...8-02_210000-mutual-pass-elevation-curve.md | 113 ++++++++ .../java/com/rtbishop/look4sat/MainScreen.kt | 9 +- .../look4sat/convention/ApplicationPlugin.kt | 1 + .../look4sat/core/presentation/Navigation.kt | 3 + .../src/main/res/values-zh/strings.xml | 1 + .../src/main/res/values/strings.xml | 1 + feature/mutual/build.gradle.kts | 7 + .../feature/mutual/ElevationCurveChart.kt | 242 ++++++++++++++++ .../look4sat/feature/mutual/MutualPass.kt | 39 +++ .../look4sat/feature/mutual/MutualScreen.kt | 269 ++++++++++++++++++ .../feature/mutual/MutualViewModel.kt | 214 ++++++++++++++ settings.gradle.kts | 1 + 12 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 .hermes/plans/2026-08-02_210000-mutual-pass-elevation-curve.md create mode 100644 feature/mutual/build.gradle.kts create mode 100644 feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt create mode 100644 feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualPass.kt create mode 100644 feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt create mode 100644 feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt diff --git a/.hermes/plans/2026-08-02_210000-mutual-pass-elevation-curve.md b/.hermes/plans/2026-08-02_210000-mutual-pass-elevation-curve.md new file mode 100644 index 00000000..338010f5 --- /dev/null +++ b/.hermes/plans/2026-08-02_210000-mutual-pass-elevation-curve.md @@ -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>> // 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 \ No newline at end of file diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 0efa0fe2..4a605afd 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -78,6 +78,8 @@ import com.rtbishop.look4sat.core.presentation.Screen import com.rtbishop.look4sat.core.presentation.hasEnoughHeight import com.rtbishop.look4sat.core.presentation.hasEnoughWidth import com.rtbishop.look4sat.feature.map.MapDestination +import com.rtbishop.look4sat.feature.mutual.MutualScreen +import com.rtbishop.look4sat.feature.mutual.MutualViewModel import com.rtbishop.look4sat.feature.passes.PassesDestination import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination @@ -125,7 +127,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { val currentKey = backStack.lastOrNull() val navigateBack: () -> Unit = { backStack.removeLastOrNull() } val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350)) - val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings) + val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Map, Screen.Settings) val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() @@ -145,6 +147,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { is Screen.Satellites -> screen is Screen.Satellites is Screen.Passes -> screen is Screen.Passes is Screen.Radar -> screen is Screen.Radar + is Screen.Mutual -> screen is Screen.Mutual is Screen.Map -> screen is Screen.Map is Screen.Settings -> screen is Screen.Settings else -> false @@ -201,6 +204,10 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { entry { MapDestination() } + entry { + val viewModel = MutualViewModel(container.satelliteRepo) + MutualScreen(viewModel = viewModel) + } entry { SettingsDestination() } diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index 1b33372f..db09bc5f 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin { implementation(project(":core:domain")) implementation(project(":core:presentation")) implementation(project(":feature:map")) + implementation(project(":feature:mutual")) implementation(project(":feature:passes")) implementation(project(":feature:radar")) implementation(project(":feature:satellites")) diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index b0ff0c1b..32669d59 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -35,6 +35,9 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey { @Serializable data object Map : Screen(R.drawable.ic_map, R.string.nav_map) + @Serializable + data object Mutual : Screen(R.drawable.ic_radar, R.string.nav_mutual) + @Serializable data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs) } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 570b77ac..50436bde 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -9,6 +9,7 @@ 卫星 过境 雷达 + 对台 地图 设置 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 9a25c2c1..b91911ba 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Satellites Passes Radar + Mutual Map Settings diff --git a/feature/mutual/build.gradle.kts b/feature/mutual/build.gradle.kts new file mode 100644 index 00000000..7ef105f9 --- /dev/null +++ b/feature/mutual/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.convention.featurePlugin) +} + +android { + namespace = "com.rtbishop.look4sat.feature.mutual" +} \ No newline at end of file diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt new file mode 100644 index 00000000..fb133e6c --- /dev/null +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt @@ -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 . + */ +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>>, + 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>>, + time: Long +): Pair? { + 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 + ) +} \ No newline at end of file diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualPass.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualPass.kt new file mode 100644 index 00000000..514b8318 --- /dev/null +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualPass.kt @@ -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 . + */ +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>> +) \ No newline at end of file diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt new file mode 100644 index 00000000..8080a527 --- /dev/null +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt @@ -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 . + */ +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) + ) + } + } + } +} \ No newline at end of file diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt new file mode 100644 index 00000000..1bc5485c --- /dev/null +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -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 . + */ +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 = 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 = _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, + posA: GeoPos, posB: GeoPos, + minElevA: Double, minElevB: Double, + time: Long, hours: Int + ): List { + val results = mutableListOf() + 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>>() + 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? { + 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 create(modelClass: Class): T = + MutualViewModel(satelliteRepo = container.satelliteRepo) as T + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 667e5214..45c81f6b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,7 @@ include( ) include( ":feature:map", + ":feature:mutual", ":feature:passes", ":feature:radar", ":feature:satellites",