From bd42e643ea6cd9a218c99f9b7ec7f1f70a0ab131 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 3 Aug 2026 00:21:20 +0800 Subject: [PATCH] feat: dual-station radar track plot (satlover/satmatch style) - MutualPass now carries TrackSample (azimuth/elevation for both stations) - New MutualRadarView: polar plot with elevation rings (30/60/90), cardinal spokes, solid A-track vs dashed B-track, AOS/LOS markers - Shown in expanded mutual pass card and radar page mutual card --- .../core/domain/repository/IMainContainer.kt | 9 + .../look4sat/feature/mutual/MutualPass.kt | 16 +- .../feature/mutual/MutualRadarView.kt | 192 ++++++++++++++++++ .../look4sat/feature/mutual/MutualScreen.kt | 24 +++ .../feature/mutual/MutualViewModel.kt | 19 +- .../look4sat/feature/radar/RadarScreen.kt | 20 ++ 6 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualRadarView.kt diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index bdb454ef..017ace38 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -46,6 +46,7 @@ interface IMainContainer { data class MutualPassData( val samples: List>> = emptyList(), + val trackSamples: List = emptyList(), val startTime: Long = 0L, val endTime: Long = 0L, val maxElev: Double = 10.0, @@ -53,6 +54,14 @@ data class MutualPassData( val labelB: String = "友台" ) +/** Minimal track sample for cross-module sharing (angles in degrees). */ +data class TrackSampleData( + val azimuthA: Double, + val elevationA: Double, + val azimuthB: Double, + val elevationB: Double +) + interface IContainerProvider { fun getMainContainer(): IMainContainer } 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 index 514b8318..3463c251 100644 --- 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 @@ -27,6 +27,7 @@ package com.rtbishop.look4sat.feature.mutual * @param maxElevationA Peak elevation for station A * @param maxElevationB Peak elevation for station B * @param elevationSamples List of (time, (elevationA, elevationB)) pairs + * @param trackSamples List of radar-track samples (azimuth/elevation for both stations, degrees) */ data class MutualPass( val catNum: Int, @@ -35,5 +36,18 @@ data class MutualPass( val endTime: Long, val maxElevationA: Double, val maxElevationB: Double, - val elevationSamples: List>> + val elevationSamples: List>>, + val trackSamples: List = emptyList() +) + +/** + * One radar-track sample: satellite position as seen from both stations. + * All angles are in degrees. + */ +data class TrackSample( + val time: Long, + val azimuthA: Double, + val elevationA: Double, + val azimuthB: Double, + val elevationB: Double ) \ No newline at end of file diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualRadarView.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualRadarView.kt new file mode 100644 index 00000000..a093c195 --- /dev/null +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualRadarView.kt @@ -0,0 +1,192 @@ +/* + * 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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.PathEffect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +private const val CIRCLES = 3 // 30°, 60°, 90° rings +private const val STROKE_WIDTH = 2.5f + +/** + * Polar radar chart showing the satellite track as seen from both stations + * on one plot. Station A is drawn solid, station B dashed. + */ +@Composable +fun MutualRadarView( + trackSamples: List, + labelA: String = "站点A", + labelB: String = "站点B", + modifier: Modifier = Modifier +) { + if (trackSamples.isEmpty()) return + + val colorA = MaterialTheme.colorScheme.primary + val colorB = MaterialTheme.colorScheme.tertiary + val gridColor = MaterialTheme.colorScheme.outlineVariant + val textColor = MaterialTheme.colorScheme.onSurfaceVariant + val measurer = rememberTextMeasurer() + + Column(modifier = modifier.fillMaxWidth()) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(4.dp) + ) { + val radius = size.minDimension / 2f * 0.92f + val center = Offset(size.width / 2f, size.height / 2f) + + // Grid: elevation rings + azimuth spokes + cardinal labels + drawRadarGrid(center, radius, gridColor, textColor, measurer) + + // Track paths (only the above-horizon arc) + val pathA = Path() + val pathB = Path() + var firstVisibleA = true + var firstVisibleB = true + trackSamples.forEach { sample -> + if (sample.elevationA > 0f) { + val p = sph2Cart(center, sample.azimuthA, sample.elevationA, radius) + if (firstVisibleA) { + pathA.moveTo(p.x, p.y) + firstVisibleA = false + } else { + pathA.lineTo(p.x, p.y) + } + } + if (sample.elevationB > 0f) { + val p = sph2Cart(center, sample.azimuthB, sample.elevationB, radius) + if (firstVisibleB) { + pathB.moveTo(p.x, p.y) + firstVisibleB = false + } else { + pathB.lineTo(p.x, p.y) + } + } + } + + // Solid line for station A, dashed for station B + drawPath(pathA, colorA, style = Stroke(STROKE_WIDTH)) + drawPath( + pathB, colorB, + style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(14f, 10f))) + ) + + // AOS / LOS markers on both tracks + val first = trackSamples.first() + val last = trackSamples.last() + val aosA = sph2Cart(center, first.azimuthA, first.elevationA, radius) + val aosB = sph2Cart(center, first.azimuthB, first.elevationB, radius) + val losA = sph2Cart(center, last.azimuthA, last.elevationA, radius) + val losB = sph2Cart(center, last.azimuthB, last.elevationB, radius) + // AOS: hollow circle, LOS: filled circle + if (first.elevationA > 0f) drawCircle(colorA, 7f, aosA, style = Stroke(2.5f)) + if (first.elevationB > 0f) drawCircle(colorB, 7f, aosB, style = Stroke(2.5f)) + if (last.elevationA > 0f) drawCircle(colorA, 5f, losA) + if (last.elevationB > 0f) drawCircle(colorB, 5f, losB) + + // Center dot + drawCircle(gridColor, 4f, center) + } + + // Legend + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Text("● $labelA", color = colorA, fontSize = 12.sp) + Text("- - $labelB", color = colorB, fontSize = 12.sp) + } + } +} + +private fun DrawScope.drawRadarGrid( + center: Offset, + radius: Float, + color: Color, + textColor: Color, + measurer: TextMeasurer +) { + // Elevation rings: 30°, 60°, 90°(center) — outer edge is horizon (0°) + val step = radius / CIRCLES + for (i in 0 until CIRCLES) { + drawCircle(color, radius - step * i, center, style = Stroke(1.5f)) + } + // Cardinal spokes + drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), 1.5f) + drawLine(color, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), 1.5f) + // Diagonal spokes (lighter) + val diag = radius * 0.7071f + drawLine( + color.copy(alpha = 0.5f), + Offset(center.x - diag, center.y - diag), Offset(center.x + diag, center.y + diag), 1f + ) + drawLine( + color.copy(alpha = 0.5f), + Offset(center.x - diag, center.y + diag), Offset(center.x + diag, center.y - diag), 1f + ) + + // Elevation ring labels + val style = TextStyle(color = textColor, fontSize = 11.sp) + for (i in 0 until CIRCLES) { + val deg = 30 * (CIRCLES - i) + val y = (center.y - (radius - step * i)) - 24f + drawText(measurer, "$deg°", Offset(center.x + 6f, y), style = style) + } + // Cardinal labels + drawText(measurer, "N", Offset(center.x - 8f, center.y - radius - 20f), style = style) + drawText(measurer, "E", Offset(center.x + radius + 4f, center.y - 10f), style = style) + drawText(measurer, "S", Offset(center.x - 6f, center.y + radius + 2f), style = style) + drawText(measurer, "W", Offset(center.x - radius - 24f, center.y - 10f), style = style) +} + +/** Convert azimuth (deg, 0=N, clockwise) and elevation (deg) to canvas offset. */ +private fun sph2Cart(center: Offset, azimDeg: Double, elevDeg: Double, r: Float): Offset { + val azimRad = azimDeg * PI / 180.0 + val elevRad = elevDeg * PI / 180.0 + val radius = r * (PI / 2 - elevRad) / (PI / 2) + return Offset( + x = center.x + (radius * cos(PI / 2 - azimRad)).toFloat(), + y = center.y - (radius * sin(PI / 2 - azimRad)).toFloat() + ) +} 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 index 430252e6..620fecad 100644 --- 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 @@ -58,6 +58,7 @@ 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.domain.repository.TrackSampleData import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.ScreenColumn @@ -326,6 +327,14 @@ private fun MutualContent( itemsIndexed(state.mutualPasses) { index, pass -> val mutualData = MutualPassData( samples = pass.elevationSamples, + trackSamples = pass.trackSamples.map { + TrackSampleData( + azimuthA = it.azimuthA, + elevationA = it.elevationA, + azimuthB = it.azimuthB, + elevationB = it.elevationB + ) + }, startTime = pass.startTime, endTime = pass.endTime, maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0), @@ -399,6 +408,21 @@ private fun MutualPassCard( ) { Column { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + // Dual-station radar track (polar plot) + if (pass.trackSamples.isNotEmpty()) { + Text( + text = "双方轨迹(雷达图)", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + MutualRadarView( + trackSamples = pass.trackSamples, + labelA = "你", + labelB = "友台" + ) + Spacer(Modifier.height(8.dp)) + } if (pass.elevationSamples.isNotEmpty()) { ElevationCurveChart( samples = pass.elevationSamples, 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 index b1377e40..1c3c2948 100644 --- 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 @@ -224,16 +224,28 @@ class MutualViewModel( val refinedLos = refineEdge(sat, posA, posB, minElevADeg, minElevBDeg, los, step = 1_000L, goingUp = false) val samples = mutableListOf>>() + val tracks = mutableListOf() var maxElevA = 0.0 var maxElevB = 0.0 var tSample = refinedAos while (tSample <= refinedLos) { - val elevA = elevationDeg(sat, posA, tSample) - val elevB = elevationDeg(sat, posB, tSample) + val fullA = sat.getFullPosition(posA, tSample) + val fullB = sat.getFullPosition(posB, tSample) + val elevA = fullA.elevation * 180.0 / PI + val elevB = fullB.elevation * 180.0 / PI if (elevA > maxElevA) maxElevA = elevA if (elevB > maxElevB) maxElevB = elevB samples.add(tSample to (elevA to elevB)) + tracks.add( + TrackSample( + time = tSample, + azimuthA = fullA.azimuth * 180.0 / PI, + elevationA = elevA, + azimuthB = fullB.azimuth * 180.0 / PI, + elevationB = elevB + ) + ) tSample += sampleInterval } @@ -246,7 +258,8 @@ class MutualViewModel( endTime = refinedLos, maxElevationA = (maxElevA * 10).roundToInt() / 10.0, maxElevationB = (maxElevB * 10).roundToInt() / 10.0, - elevationSamples = samples + elevationSamples = samples, + trackSamples = tracks ) ) } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 26234417..d4cfb65a 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -32,8 +32,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -72,6 +74,8 @@ 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 com.rtbishop.look4sat.feature.mutual.MutualRadarView +import com.rtbishop.look4sat.feature.mutual.TrackSample import kotlinx.coroutines.launch private enum class RadarPage(val title: String) { @@ -319,6 +323,22 @@ private fun RadarLabel( private fun MutualElevationCard(mutualData: MutualPassData, modifier: Modifier = Modifier) { ElevatedCard(modifier = modifier) { Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { + if (mutualData.trackSamples.isNotEmpty()) { + MutualRadarView( + trackSamples = mutualData.trackSamples.map { + TrackSample( + time = 0L, + azimuthA = it.azimuthA, + elevationA = it.elevationA, + azimuthB = it.azimuthB, + elevationB = it.elevationB + ) + }, + labelA = mutualData.labelA, + labelB = mutualData.labelB + ) + Spacer(Modifier.height(4.dp)) + } if (mutualData.samples.isNotEmpty()) { ElevationCurveChart( samples = mutualData.samples,