diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt index 118a089d..6c429a2b 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.Date +import kotlin.math.abs class MapViewModel( private val satelliteRepo: ISatelliteRepo, @@ -266,30 +267,10 @@ class MapViewModel( } private suspend fun getSatTrack(orbitalObject: OrbitalObject, pos: GeoPos, date: Date) { - val satTracks = mutableListOf>() - val currentTrack = mutableListOf() val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong()) - var oldLongitude = 0.0 - satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos -> - val currentPosition = satPos.toMapGeoPos() - if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) { - // adding left terminal position - currentTrack.add(GeoPos(currentPosition.latitude, -180.0)) - val finishedTrack = mutableListOf().apply { addAll(currentTrack) } - satTracks.add(finishedTrack) - currentTrack.clear() - } else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) { - // adding right terminal position - currentTrack.add(GeoPos(currentPosition.latitude, 180.0)) - val finishedTrack = mutableListOf().apply { addAll(currentTrack) } - satTracks.add(finishedTrack) - currentTrack.clear() - } - oldLongitude = currentPosition.longitude - currentTrack.add(currentPosition) - } - satTracks.add(currentTrack) - _uiState.update { it.copy(track = satTracks) } + val track = satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time) + .map { it.toMapGeoPos() } + _uiState.update { it.copy(track = splitAtAntimeridian(track)) } } companion object { @@ -306,3 +287,47 @@ class MapViewModel( } } } + +/** + * Splits a ground track into polylines that never span the antimeridian. + * + * Each crossing closes the current polyline on the edge it leaves through and + * opens the next one on the opposite edge, both at the interpolated crossing + * latitude. Without the entry point a new segment started inland (e.g. at -178) + * and the drawn track broke visibly at 180 degrees; reusing the next sample's + * latitude for the edge made the closing leg jump north or south. + */ +internal fun splitAtAntimeridian(track: List): List> { + val segments = mutableListOf>() + val current = mutableListOf() + var previous: GeoPos? = null + track.forEach { position -> + val last = previous + if (last != null && abs(position.longitude - last.longitude) > 180.0) { + val exitEdge = if (last.longitude > 0.0) 180.0 else -180.0 + val edgeLatitude = crossingLatitude(last, position, exitEdge) + current.add(GeoPos(edgeLatitude, exitEdge)) + segments.add(current.toList()) + current.clear() + current.add(GeoPos(edgeLatitude, -exitEdge)) + } + previous = position + current.add(position) + } + segments.add(current.toList()) + return segments +} + +/** Latitude where the leg between [from] and [to] crosses [edgeLongitude]. */ +internal fun crossingLatitude(from: GeoPos, to: GeoPos, edgeLongitude: Double): Double { + // Unwrap the destination so the leg is continuous, then interpolate. + val unwrappedTo = when { + from.longitude > 0.0 && to.longitude < 0.0 -> to.longitude + 360.0 + from.longitude < 0.0 && to.longitude > 0.0 -> to.longitude - 360.0 + else -> to.longitude + } + val span = unwrappedTo - from.longitude + if (abs(span) < 1e-12) return from.latitude + val fraction = (edgeLongitude - from.longitude) / span + return from.latitude + (to.latitude - from.latitude) * fraction +} diff --git a/feature/map/src/test/java/com/rtbishop/look4sat/feature/map/AntimeridianSplitTest.kt b/feature/map/src/test/java/com/rtbishop/look4sat/feature/map/AntimeridianSplitTest.kt new file mode 100644 index 00000000..ba27ffb8 --- /dev/null +++ b/feature/map/src/test/java/com/rtbishop/look4sat/feature/map/AntimeridianSplitTest.kt @@ -0,0 +1,110 @@ +package com.rtbishop.look4sat.feature.map + +import com.rtbishop.look4sat.core.domain.predict.GeoPos +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs + +/** + * A ground track must be cut into polylines that never span the antimeridian, + * and the cut must be seamless: the closing point of one segment and the opening + * point of the next sit on opposite edges at the same interpolated latitude. + * + * Regression: the previous implementation only appended the outgoing edge point, + * so every following segment began inland (e.g. at -178) and the drawn track + * broke at 180 degrees. It also reused the next sample's latitude for the edge, + * which made the closing leg jump. + */ +class AntimeridianSplitTest { + + private fun pos(lat: Double, lon: Double) = GeoPos(lat, lon) + + private fun assertNoSegmentSpansTheEdge(segments: List>) { + segments.forEachIndexed { index, segment -> + segment.zipWithNext { a, b -> + assertTrue( + "segment $index still spans the antimeridian: $a -> $b", + abs(b.longitude - a.longitude) <= 180.0 + ) + } + } + } + + private fun assertSeamsAreOnOppositeEdges(segments: List>) { + segments.zipWithNext { previous, next -> + val exit = previous.last() + val entry = next.first() + assertEquals("exit longitude", 180.0, abs(exit.longitude), 1e-9) + assertEquals("entry longitude", 180.0, abs(entry.longitude), 1e-9) + assertEquals("edges must be opposite", 0.0, exit.longitude + entry.longitude, 1e-9) + assertEquals("latitude must be continuous", exit.latitude, entry.latitude, 1e-9) + } + } + + @Test + fun `eastward crossing is cut seamlessly`() { + val segments = splitAtAntimeridian( + listOf( + pos(10.0, 165.0), pos(12.0, 172.0), pos(14.0, 179.0), + pos(16.0, -178.0), pos(18.0, -171.0) + ) + ) + + assertEquals(2, segments.size) + assertEquals(180.0, segments[0].last().longitude, 1e-9) + assertEquals(-180.0, segments[1].first().longitude, 1e-9) + assertNoSegmentSpansTheEdge(segments) + assertSeamsAreOnOppositeEdges(segments) + } + + @Test + fun `westward crossing is cut seamlessly`() { + val segments = splitAtAntimeridian( + listOf( + pos(10.0, -165.0), pos(12.0, -172.0), pos(14.0, -179.0), + pos(16.0, 178.0), pos(18.0, 171.0) + ) + ) + + assertEquals(2, segments.size) + assertEquals(-180.0, segments[0].last().longitude, 1e-9) + assertEquals(180.0, segments[1].first().longitude, 1e-9) + assertNoSegmentSpansTheEdge(segments) + assertSeamsAreOnOppositeEdges(segments) + } + + @Test + fun `edge latitude is interpolated rather than copied`() { + // 179 -> -178 spans three degrees of longitude and two of latitude, so + // the edge sits one degree past the first sample, not at the second one. + val segments = splitAtAntimeridian(listOf(pos(14.0, 179.0), pos(16.0, -178.0))) + + assertEquals(14.0 + 2.0 / 3.0, segments[0].last().latitude, 1e-9) + } + + @Test + fun `track that stays near the edge is not split`() { + val segments = splitAtAntimeridian(listOf(pos(10.0, 175.0), pos(12.0, 178.0))) + + assertEquals(1, segments.size) + assertEquals(2, segments[0].size) + } + + @Test + fun `repeated crossings all produce seams`() { + val segments = splitAtAntimeridian( + listOf(pos(0.0, 170.0), pos(5.0, -175.0), pos(10.0, 175.0), pos(15.0, -170.0)) + ) + + assertEquals(4, segments.size) + assertNoSegmentSpansTheEdge(segments) + assertSeamsAreOnOppositeEdges(segments) + } + + @Test + fun `empty and single point tracks are handled`() { + assertEquals(listOf(emptyList()), splitAtAntimeridian(emptyList())) + assertEquals(1, splitAtAntimeridian(listOf(pos(1.0, 2.0))).size) + } +}