From 279594c42555e97304531e0628fd5fdbd09b9cbb Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Sat, 28 Mar 2026 20:04:03 +0000 Subject: [PATCH] Simplified RadarScreen/View, added sensors smoothing --- .../core/data/repository/SensorsRepo.kt | 107 +++-- .../look4sat/feature/radar/RadarScreen.kt | 406 ++++++++---------- .../look4sat/feature/radar/RadarView.kt | 205 +++------ 3 files changed, 311 insertions(+), 407 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SensorsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SensorsRepo.kt index a0fbbb00..3553a2ca 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SensorsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SensorsRepo.kt @@ -35,78 +35,109 @@ class SensorsRepo( private val sensorManager: SensorManager, private val sensor: Sensor?, private val windowManager: WindowManager -) : - SensorEventListener, ISensorsRepo { +) : SensorEventListener, ISensorsRepo { private val _orientation = MutableStateFlow(Pair(0f, 0f)) private val rotationMatrix = FloatArray(9) + private val tempMatrix = FloatArray(9) private val orientationValues = FloatArray(3) - private var sensorAccuracy = SensorManager.SENSOR_STATUS_UNRELIABLE + private var smoothAzimuth = 0f + private var smoothPitch = 0f + private var hasInitialReading = false + + companion object { + private const val SMOOTHING_FACTOR = 0.15f + } override val orientation: StateFlow> = _orientation override fun getMagDeclination(geoPos: GeoPos, time: Long): Float { - val latitude = geoPos.latitude.toFloat() - val longitude = geoPos.longitude.toFloat() - return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination + return GeomagneticField( + geoPos.latitude.toFloat(), + geoPos.longitude.toFloat(), + geoPos.altitude.toFloat(), + time + ).declination } override fun enableSensor() { + hasInitialReading = false sensor?.let { sensorManager.registerListener(this, it, 8000) } } override fun disableSensor() = sensorManager.unregisterListener(this) - override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { - sensorAccuracy = accuracy - } + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit - override fun onSensorChanged(event: SensorEvent) = when { - sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {} - event.sensor == sensor -> updateOrientation(event.values) - else -> {} + override fun onSensorChanged(event: SensorEvent) { + if (event.sensor == sensor) updateOrientation(event.values) } private fun getDisplayRotation(): Int { return try { @Suppress("DEPRECATION") windowManager.defaultDisplay.rotation - } catch (e: Exception) { + } catch (_: Exception) { Surface.ROTATION_0 } } - private fun transformRotationMatrix(rotationMatrix: FloatArray, rotation: Int) { - val tempMatrix = FloatArray(9) - when (rotation) { - Surface.ROTATION_0 -> { - SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_X, SensorManager.AXIS_Y, tempMatrix) - System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9) - } - Surface.ROTATION_90 -> { - SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix) - SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix) - } - Surface.ROTATION_180 -> { - SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix) - System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9) - } - Surface.ROTATION_270 -> { - SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix) - SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix) - } + private fun remapForRotation(rotation: Int) { + val remapped = when (rotation) { + Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem( + rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix + ) + + Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem( + rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix + ) + + Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem( + rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix + ) + + else -> false } + if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9) } private fun updateOrientation(rotationVector: FloatArray) { SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector) - transformRotationMatrix(rotationMatrix, getDisplayRotation()) + remapForRotation(getDisplayRotation()) SensorManager.getOrientation(rotationMatrix, orientationValues) val azimuth = (orientationValues[0] * RAD2DEG).toFloat() - val pitch = (orientationValues[1] * RAD2DEG).toFloat() // roll [2] + val pitch = (orientationValues[1] * RAD2DEG).toFloat() val magneticAzimuth = (azimuth + 360f) % 360f - val roundedAzimuth = round(magneticAzimuth * 10) / 10 - val roundedPitch = round(pitch * 10) / 10 - _orientation.value = Pair(roundedAzimuth, roundedPitch) + + if (!hasInitialReading) { + smoothAzimuth = magneticAzimuth + smoothPitch = pitch + hasInitialReading = true + } else { + smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth) + smoothPitch = lowPass(smoothPitch, pitch) + } + + _orientation.value = Pair( + round(smoothAzimuth * 10) / 10, + round(smoothPitch * 10) / 10 + ) + } + + /** Standard exponential low-pass filter. */ + private fun lowPass(previous: Float, current: Float): Float { + return previous + SMOOTHING_FACTOR * (current - previous) + } + + /** + * Low-pass filter that accounts for the 0°/360° wraparound. + * Always takes the shortest angular path between the two values. + */ + private fun lowPassAngle(previous: Float, current: Float): Float { + var delta = current - previous + // Normalise delta into the range (-180, 180] + while (delta > 180f) delta -= 360f + while (delta <= -180f) delta += 360f + return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f } } 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 640f36f0..c7487d25 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 @@ -42,6 +42,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -62,6 +63,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.rtbishop.look4sat.core.domain.model.SatRadio +import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.IconCard @@ -84,14 +86,13 @@ fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) { ) composable(radarRoute, radarArgs) { val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory) - val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val uiState by viewModel.uiState.collectAsStateWithLifecycle() RadarScreen(uiState, navigateUp) } } @Composable private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) { -// BluetoothCIV.init(LocalContext.current) val addToCalendar: () -> Unit = { uiState.currentPass?.let { pass -> uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime)) @@ -102,9 +103,11 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) { Column( modifier = Modifier .layoutPadding() - .keepScreenOn(), verticalArrangement = Arrangement.spacedBy(6.dp) + .keepScreenOn(), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - if (isVerticalLayout()) { + val isVertical = isVerticalLayout() + if (isVertical) { TopBar { IconCard(action = navigateUp, resId = R.drawable.ic_back) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos) @@ -119,196 +122,123 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) { IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) } } - if(isVerticalLayout()) { - ElevatedCard(modifier = Modifier.weight(1f)) { - Box(contentAlignment = Alignment.Center) { - if (uiState.orbitalPos == null) { - ElevatedCard(modifier = Modifier.fillMaxSize()) { - EmptyListCard(message = "") - } - } - uiState.orbitalPos?.let { position -> - RadarViewCompose( - item = position, - items = uiState.satTrack, - azimElev = uiState.orientationValues, - shouldShowSweep = uiState.shouldShowSweep, - shouldUseCompass = uiState.shouldUseCompass, - modifier = Modifier.align(Alignment.Center) - ) - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 6.dp, vertical = 4.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - RadarTextTop( - position.azimuth, - stringResource(R.string.radar_az_text), - true - ) - RadarTextTop( - position.elevation, - stringResource(R.string.radar_el_text), - false - ) - } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - RadarTextBottom( - position.altitude, - stringResource(R.string.radar_alt_text), - true - ) - RadarTextBottom( - position.distance, - stringResource(R.string.radar_dist_text), - false - ) - } - } - } - } - } - ElevatedCard(modifier = Modifier.weight(1f)) { - if (uiState.transmitters.isEmpty()) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier.padding(32.dp) - ) { - Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp) - Text( - text = stringResource(R.string.empty_list_message), - fontSize = 21.sp, - textAlign = TextAlign.Center - ) - Text( - text = stringResource(R.string.radar_no_data), - fontSize = 18.sp, - textAlign = TextAlign.Center - ) - } - } - } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - TransmittersList( - transmitters = uiState.transmitters, - selectedUuid = uiState.selectedTransmitterUuid, - onSelect = { uuid -> - if (uiState.selectedTransmitterUuid != null) { - uiState.sendAction(RadarAction.SelectTransmitter(uuid)) - } - } - ) - if (uiState.orbitalPos?.eclipsed == true) { - EclipsedIndicator() - } - } - } - } + if (isVertical) { + RadarCard(uiState, Modifier.weight(1f)) + TransmittersCard(uiState, Modifier.weight(1f)) } else { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - ElevatedCard(modifier = Modifier.weight(1f)) { - Box(contentAlignment = Alignment.Center) { - if (uiState.orbitalPos == null) { - ElevatedCard(modifier = Modifier.fillMaxSize()) { - EmptyListCard(message = "") - } - } - uiState.orbitalPos?.let { position -> - RadarViewCompose( - item = position, - items = uiState.satTrack, - azimElev = uiState.orientationValues, - shouldShowSweep = uiState.shouldShowSweep, - shouldUseCompass = uiState.shouldUseCompass, - modifier = Modifier.align(Alignment.Center) - ) - Column( - verticalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 6.dp, vertical = 4.dp) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - RadarTextTop( - position.azimuth, - stringResource(R.string.radar_az_text), - true - ) - RadarTextTop( - position.elevation, - stringResource(R.string.radar_el_text), - false - ) - } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - RadarTextBottom( - position.altitude, - stringResource(R.string.radar_alt_text), - true - ) - RadarTextBottom( - position.distance, - stringResource(R.string.radar_dist_text), - false - ) - } - } - } - } + RadarCard(uiState, Modifier.weight(1f)) + TransmittersCard(uiState, Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { + ElevatedCard(modifier = modifier) { + Box(contentAlignment = Alignment.Center) { + val position = uiState.orbitalPos + if (position == null) { + ElevatedCard(modifier = Modifier.fillMaxSize()) { + EmptyListCard(message = "") } - ElevatedCard(modifier = Modifier.weight(1f)) { - if (uiState.transmitters.isEmpty()) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier.padding(32.dp) - ) { - Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp) - Text( - text = stringResource(R.string.empty_list_message), - fontSize = 21.sp, - textAlign = TextAlign.Center - ) - Text( - text = stringResource(R.string.radar_no_data), - fontSize = 18.sp, - textAlign = TextAlign.Center - ) - } - } - } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - TransmittersList( - transmitters = uiState.transmitters, - selectedUuid = uiState.selectedTransmitterUuid, - onSelect = { uuid -> - if (uiState.selectedTransmitterUuid != null) { - uiState.sendAction(RadarAction.SelectTransmitter(uuid)) - } - } - ) - if (uiState.orbitalPos?.eclipsed == true) { - EclipsedIndicator() - } + } else { + RadarViewCompose( + item = position, + items = uiState.satTrack, + azimElev = uiState.orientationValues, + shouldShowSweep = uiState.shouldShowSweep, + shouldUseCompass = uiState.shouldUseCompass, + modifier = Modifier.align(Alignment.Center) + ) + PositionOverlay(position) + } + } + } +} + +@Composable +private fun PositionOverlay(position: OrbitalPos) { + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 6.dp, vertical = 4.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + RadarLabel( + value = stringResource(R.string.radar_az_value, position.azimuth.toDegrees()), + label = stringResource(R.string.radar_az_text), + alignment = Alignment.Start, + labelFirst = false + ) + RadarLabel( + value = stringResource(R.string.radar_az_value, position.elevation.toDegrees()), + label = stringResource(R.string.radar_el_text), + alignment = Alignment.End, + labelFirst = false + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + RadarLabel( + value = stringResource(R.string.radar_alt_value, position.altitude), + label = stringResource(R.string.radar_alt_text), + alignment = Alignment.Start, + labelFirst = true + ) + RadarLabel( + value = stringResource(R.string.radar_alt_value, position.distance), + label = stringResource(R.string.radar_dist_text), + alignment = Alignment.End, + labelFirst = true + ) + } + } +} + +@Composable +private fun RadarLabel( + value: String, + label: String, + alignment: Alignment.Horizontal, + labelFirst: Boolean +) { + Column(horizontalAlignment = alignment) { + if (labelFirst) { + Text(text = label, fontSize = 15.sp) + Text(text = value, fontSize = 18.sp) + } else { + Text(text = value, fontSize = 18.sp) + Text(text = label, fontSize = 15.sp) + } + } +} + +@Composable +private fun TransmittersCard(uiState: RadarState, modifier: Modifier = Modifier) { + ElevatedCard(modifier = modifier) { + if (uiState.transmitters.isEmpty()) { + EmptyTransmittersContent() + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TransmittersList( + transmitters = uiState.transmitters, + selectedUuid = uiState.selectedTransmitterUuid, + onSelect = { uuid -> + if (uiState.selectedTransmitterUuid != null) { + uiState.sendAction(RadarAction.SelectTransmitter(uuid)) } } + ) + if (uiState.orbitalPos?.eclipsed == true) { + EclipsedIndicator() } } } @@ -316,22 +246,25 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) { } @Composable -private fun RadarTextTop(value: Double, text: String, isLeft: Boolean) { - val alignment = if (isLeft) Alignment.Start else Alignment.End - val degValue = stringResource(R.string.radar_az_value, value.toDegrees()) - Column(horizontalAlignment = alignment) { - Text(text = degValue, fontSize = 18.sp) - Text(text = text, fontSize = 15.sp) - } -} - -@Composable -private fun RadarTextBottom(value: Double, text: String, isLeft: Boolean) { - val alignment = if (isLeft) Alignment.Start else Alignment.End - val degValue = stringResource(R.string.radar_alt_value, value) - Column(horizontalAlignment = alignment) { - Text(text = text, fontSize = 15.sp) - Text(text = degValue, fontSize = 18.sp) +private fun EmptyTransmittersContent() { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(32.dp) + ) { + Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp) + Text( + text = stringResource(R.string.empty_list_message), + fontSize = 21.sp, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.radar_no_data), + fontSize = 18.sp, + textAlign = TextAlign.Center + ) + } } } @@ -342,12 +275,13 @@ private fun TransmittersList( onSelect: (String) -> Unit ) { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(items = transmitters, key = { item -> item.uuid }) { radio -> + items(items = transmitters, key = { it.uuid }) { radio -> TransmitterItem( - radio, - (selectedUuid != null), - (radio.uuid == selectedUuid) - ) { onSelect(radio.uuid) } + radio = radio, + isClickable = selectedUuid != null, + isSelected = radio.uuid == selectedUuid, + onClick = { onSelect(radio.uuid) } + ) } } } @@ -366,23 +300,20 @@ private fun TransmitterItemPreview() { private fun EclipsedIndicator() { val bgColor = MaterialTheme.colorScheme.primary val bgShape = MaterialTheme.shapes.medium - val infiniteTransition = rememberInfiniteTransition() - val textAlpha = infiniteTransition.animateFloat( + val infiniteTransition = rememberInfiniteTransition(label = "eclipsed") + val textAlpha by infiniteTransition.animateFloat( initialValue = 1.0f, targetValue = 0.0f, animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1000, - delayMillis = 25, - easing = LinearEasing - ), + animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing), repeatMode = RepeatMode.Reverse - ) + ), + label = "eclipsedAlpha" ) Box( modifier = Modifier .fillMaxSize() - .alpha(textAlpha.value) + .alpha(textAlpha) .border(width = 2.dp, color = bgColor, shape = bgShape), contentAlignment = Alignment.Center ) { @@ -447,45 +378,52 @@ private fun TransmitterItem( } @Composable -private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean, modifier: Modifier = Modifier) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) { - val rotateMod = Modifier.rotate(if (isDownlink) 90f else -90f) - val weightMod = Modifier.weight(1f) - val desc = if (isDownlink) stringResource(R.string.radar_downlink) else stringResource(R.string.radar_uplink) +private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + val desc = if (isDownlink) stringResource(R.string.radar_downlink) + else stringResource(R.string.radar_uplink) Text( text = if (isDownlink) "D:" else "U:", textAlign = TextAlign.Center, fontSize = 18.sp, color = MaterialTheme.colorScheme.onSurface, - modifier = modifier - .size(width = 24.dp, height = 24.dp) + modifier = Modifier + .size(24.dp) .semantics { contentDescription = desc } ) - FrequencyText(if (isDownlink) radio.downlinkLow else radio.uplinkLow, weightMod) + FrequencyText( + frequency = if (isDownlink) radio.downlinkLow else radio.uplinkLow, + modifier = Modifier.weight(1f) + ) Text( text = "-", textAlign = TextAlign.Center, fontSize = 21.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = modifier + color = MaterialTheme.colorScheme.primary + ) + FrequencyText( + frequency = if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, + modifier = Modifier.weight(1f) ) - FrequencyText(if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, weightMod) Icon( painter = painterResource(id = R.drawable.ic_arrow), tint = MaterialTheme.colorScheme.onSurface, contentDescription = null, - modifier = rotateMod.size(width = 24.dp, height = 24.dp) + modifier = Modifier + .rotate(if (isDownlink) 90f else -90f) + .size(24.dp) ) } } @Composable private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { - val noLinkText = stringResource(R.string.radar_no_link) - val freqValue = frequency?.let { it / 1000000f } + val text = frequency?.let { + stringResource(id = R.string.radar_link_low, it / 1000000f) + } ?: stringResource(R.string.radar_no_link) Text( - text = freqValue?.let { stringResource(id = R.string.radar_link_low, it) } ?: noLinkText, + text = text, textAlign = TextAlign.Center, fontSize = 21.sp, fontWeight = FontWeight.Bold, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt index 472ce936..114fc20d 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarView.kt @@ -25,9 +25,11 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.aspectRatio import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf 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 @@ -53,6 +55,10 @@ import com.rtbishop.look4sat.core.domain.utility.toRadians import kotlin.math.cos import kotlin.math.sin +private const val CIRCLES = 3 +private const val STROKE_WIDTH = 6f +private const val SWEEP_INCREMENT = 360f / 12f / 60f + @Composable fun RadarViewCompose( item: OrbitalPos, @@ -62,149 +68,81 @@ fun RadarViewCompose( shouldUseCompass: Boolean, modifier: Modifier = Modifier ) { -// val context = LocalContext.current -// val view = LocalView.current val radarColor = MaterialTheme.colorScheme.secondary val trackColor = MaterialTheme.colorScheme.primary val aimColor = MaterialTheme.colorScheme.error - val strokeWidth = 6f val animTransition = rememberInfiniteTransition(label = "animScale") - val animSpec = infiniteRepeatable(tween(1000)) - val animScale = animTransition.animateFloat(16f, 64f, animSpec, label = "animScale") -// val aimThreshold = 0.05f + val animScale by animTransition.animateFloat( + initialValue = 16f, + targetValue = 64f, + animationSpec = infiniteRepeatable(tween(1000)), + label = "animScale" + ) val measurer = rememberTextMeasurer() - val sweepDegrees = remember { mutableFloatStateOf(0f) } - val trackLastRadius = remember { mutableStateOf(0f) } - val trackPath = remember { mutableStateOf(Path()) } - val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } -// val soundPool = remember { mutableStateOf(null) } -// val beepSoundId = remember { mutableIntStateOf(0) } -// val aimTargetDifference = remember { mutableFloatStateOf(0f) } - -// LaunchedEffect(item.azimuth, item.elevation, azimElev.first, azimElev.second) { -// val aimAzimuthRadians = azimElev.first.toDouble().toRadians() -// val aimElevationRadians = abs(min(azimElev.second, 0f)).toDouble().toRadians() -// // radius of 0.5 makes the aimTargetDifference range 0.0 to 1.0 -// val radius = 0.5 -// val aimX = sph2CartX(aimAzimuthRadians, aimElevationRadians, radius) -// val aimY = sph2CartY(aimAzimuthRadians, aimElevationRadians, radius) -// val satX = sph2CartX(item.azimuth, item.elevation, radius) -// val satY = sph2CartY(item.azimuth, item.elevation, radius) -// aimTargetDifference.floatValue = sqrt((satX - aimX).pow(2) + (satY - aimY).pow(2)) -// val minPlaybackRate = 0.5f -// val maxPlaybackRate = 2.0f -// val playbackRate = -// maxPlaybackRate - (aimTargetDifference.floatValue / (maxPlaybackRate - minPlaybackRate)) -// soundPool.value?.setRate(beepSoundId.intValue, playbackRate) -// } -// -// LaunchedEffect(aimTargetDifference.floatValue < aimThreshold) { -// if (aimTargetDifference.floatValue < aimThreshold) { -// view.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY) -// soundPool.value?.pause(beepSoundId.intValue) -// } else { -// soundPool.value?.resume(beepSoundId.intValue) -// } -// } -// -// LaunchedEffect(item.elevation > 0) { -// if(item.elevation > 0) { -// soundPool.value?.resume(beepSoundId.intValue) -// } else { -// soundPool.value?.pause(beepSoundId.intValue) -// } -// } -// -// DisposableEffect(Unit) { -// val audioAttributes = AudioAttributes.Builder() -// .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) -// .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) -// .build() -// soundPool.value = SoundPool.Builder() -// .setMaxStreams(1) -// .setAudioAttributes(audioAttributes) -// .build() -// beepSoundId.intValue = soundPool.value?.load(context, R.raw.beep, 1) ?: 0 -// soundPool.value?.setOnLoadCompleteListener { soundPool, _, status -> -// if (status == 0) { -// soundPool.play(beepSoundId.intValue, 0.5f, 0.5f, 0, -1, 1f) -// } -// } -// onDispose { -// soundPool.value?.release() -// } -// } + var sweepDegrees by remember { mutableFloatStateOf(0f) } + var cachedRadius by remember { mutableFloatStateOf(0f) } + var trackPath by remember { mutableStateOf(Path()) } + var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } Canvas(modifier = modifier.aspectRatio(1f)) { - val radius = (size.minDimension / 2f) * 0.95f - if(radius != trackLastRadius.value) { - trackPath.value = createTrackPath(items, radius) - trackEffect.value = createTrackEffect(trackPath.value) - trackLastRadius.value = radius + val radius = size.minDimension / 2f * 0.95f + if (radius != cachedRadius) { + trackPath = createTrackPath(items, radius) + trackEffect = createTrackEffect(trackPath) + cachedRadius = radius } rotate(if (shouldUseCompass) -azimElev.first else 0f) { - if (shouldShowSweep) { - drawSweep(center, sweepDegrees.floatValue, radius, trackColor) - } - drawRadar(radius, radarColor, strokeWidth, 3) - drawInfo(radius, trackColor, measurer, 3) + if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, trackColor) + drawRadar(radius, radarColor) + drawElevationLabels(radius, trackColor, measurer) translate(center.x, center.y) { - drawTrack(trackPath.value, trackEffect.value, aimColor, trackColor) - if (item.elevation > 0) { - drawPosition(item, radius, animScale.value, trackColor) - } - if (shouldUseCompass) { - drawAim(azimElev.first, azimElev.second, radius, strokeWidth, aimColor) - } + drawTrack(trackPath, trackEffect, aimColor, trackColor) + if (item.elevation > 0) drawPosition(item, radius, animScale, trackColor) + if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor) } - sweepDegrees.floatValue = (sweepDegrees.floatValue + 360 / 12.0f / 60) % 360 + sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f } } } -private fun DrawScope.drawRadar(radius: Float, color: Color, width: Float, circles: Int) { - for (i in 0 until circles) { - val circleRadius = radius - radius / circles.toFloat() * i.toFloat() - drawCircle(color, circleRadius, style = Stroke(width)) +private fun DrawScope.drawRadar(radius: Float, color: Color) { + val step = radius / CIRCLES + for (i in 0 until CIRCLES) { + drawCircle(color, radius - step * i, style = Stroke(STROKE_WIDTH)) } - drawLine(color, center.copy(x = center.x - radius), center.copy(x = center.x + radius), width) - drawLine(color, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width) + drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), STROKE_WIDTH) + drawLine(color, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), STROKE_WIDTH) } -private fun DrawScope.drawInfo(radius: Float, color: Color, measurer: TextMeasurer, circles: Int) { - for (i in 0 until circles) { - val textY = (radius - radius / circles * i) - 32f - val textDeg = " ${(90 / circles) * (circles - i)}°" - drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp)) +private fun DrawScope.drawElevationLabels(radius: Float, color: Color, measurer: TextMeasurer) { + val step = radius / CIRCLES + val degStep = 90 / CIRCLES + val style = TextStyle(color, 15.sp) + for (i in 0 until CIRCLES) { + val textY = (radius - step * i) - 32f + drawText(measurer, " ${degStep * (CIRCLES - i)}°", Offset(center.x, textY), style = style) } } private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) { - drawPath(path, color, style = Stroke(6f)) + drawPath(path, color, style = Stroke(STROKE_WIDTH)) drawPath(path, effectColor, style = Stroke(pathEffect = effect)) } private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) { - val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble()) - val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble()) - drawCircle(color, 16f, center.copy(satX, -satY)) - drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, center.copy(satX, -satY)) + val pos = sph2Cart(item.azimuth, item.elevation, radius.toDouble()) + drawCircle(color, 16f, pos) + drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, pos) } -private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) { +private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Color) { val size = 36f - val azimRadians = azim.toDouble().toRadians() - val tempElevRadians = elev.toDouble().toRadians() - val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians - val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble()) - val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble()) - try { - drawLine(color, center.copy(aimX - size, -aimY), center.copy(aimX + size, -aimY), width) - drawLine(color, center.copy(aimX, -aimY - size), center.copy(aimX, -aimY + size), width) - drawCircle(color, size / 2, center.copy(aimX, -aimY), style = Stroke(width)) - } catch (exception: Exception) { - println(exception) - } + val azimRad = azim.toDouble().toRadians() + val elevRad = elev.toDouble().toRadians().coerceAtMost(0.0) + val pos = sph2Cart(azimRad, -elevRad, radius.toDouble()) + drawLine(color, Offset(pos.x - size, pos.y), Offset(pos.x + size, pos.y), STROKE_WIDTH) + drawLine(color, Offset(pos.x, pos.y - size), Offset(pos.x, pos.y + size), STROKE_WIDTH) + drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH)) } private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) { @@ -216,37 +154,34 @@ private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, c private fun createTrackPath(positions: List, radius: Float): Path { val trackPath = Path() - positions.forEachIndexed { index, satPos -> - val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble()) - val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble()) - if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY) + positions.forEachIndexed { index, pos -> + val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble()) + if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y) } return trackPath } private fun createTrackEffect(trackPath: Path): PathEffect { - val shape = Path() val shapeRadius = 24f val angle = 120.0.toRadians() - shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat()) - for (i in 1 until 3) { - val x = (shapeRadius * cos(angle - angle * i)).toFloat() - val y = (shapeRadius * sin(angle - angle * i)).toFloat() - shape.lineTo(x, y) + val shape = Path().apply { + moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat()) + for (i in 1 until 3) { + lineTo( + x = (shapeRadius * cos(angle - angle * i)).toFloat(), + y = (shapeRadius * sin(angle - angle * i)).toFloat() + ) + } + close() } - shape.close() val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length - val advance = trackLength / 2f - val phase = trackLength / 4f - return PathEffect.stampedPathEffect(shape, advance, phase, StampedPathEffectStyle.Rotate) + return PathEffect.stampedPathEffect(shape, trackLength / 2f, trackLength / 4f, StampedPathEffectStyle.Rotate) } -private fun sph2CartX(azim: Double, elev: Double, r: Double): Float { +private fun sph2Cart(azim: Double, elev: Double, r: Double): Offset { val radius = r * (PI_2 - elev) / PI_2 - return (radius * cos(PI_2 - azim)).toFloat() -} - -private fun sph2CartY(azim: Double, elev: Double, r: Double): Float { - val radius = r * (PI_2 - elev) / PI_2 - return (radius * sin(PI_2 - azim)).toFloat() + return Offset( + x = (radius * cos(PI_2 - azim)).toFloat(), + y = -(radius * sin(PI_2 - azim)).toFloat() + ) }