Reimplemented RadarView in Jetpack Compose

Arty Bishop 2023-03-06 18:30:28 +00:00
rodzic 442f13ad07
commit 1a67ca1227
4 zmienionych plików z 129 dodań i 423 usunięć

Wyświetl plik

@ -12,6 +12,6 @@ class ExampleInstrumentedTest {
@Test @Test
fun useAppContext() { fun useAppContext() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.rtbishop.bbctestapp", appContext.packageName) assertEquals("com.rtbishop.look4sat", appContext.packageName)
} }
} }

Wyświetl plik

@ -9,19 +9,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.rtbishop.look4sat.R import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.model.SatRadio import com.rtbishop.look4sat.domain.model.SatRadio
import timber.log.Timber
@Composable @Composable
fun RadarScreen(navController: NavController) { fun RadarScreen(navController: NavController) {
@ -65,19 +62,6 @@ fun RadarScreen(navController: NavController) {
ElevatedCard(modifier = Modifier ElevatedCard(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.weight(1f)) { .weight(1f)) {
// val context = LocalContext.current
// AndroidView({ RadarView(context) }, modifier = Modifier.fillMaxSize()) { radarView ->
// Timber.d("Radar view recomposition")
// radarView.setShowAim(true)
// radarView.setScanning(true)
// radarData.value?.let {
// radarView.setPosition(it.satPos)
// radarView.setPositions(it.satTrack)
// }
// orientation.value?.let {
// radarView.setOrientation(it.first, it.second, it.first)
// }
// }
radarData.value?.let { data -> radarData.value?.let { data ->
orientation.value?.let { triple -> orientation.value?.let { triple ->
RadarViewCompose( RadarViewCompose(

Wyświetl plik

@ -1,265 +1,142 @@
/* @file:OptIn(ExperimentalTextApi::class)
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2022 Arty Bishop (bishop.arty@gmail.com)
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.presentation.radarScreen package com.rtbishop.look4sat.presentation.radarScreen
import android.content.Context import androidx.compose.foundation.Canvas
import android.graphics.* import androidx.compose.foundation.layout.fillMaxSize
import android.view.View import androidx.compose.runtime.Composable
import androidx.core.content.ContextCompat import androidx.compose.runtime.mutableStateOf
import com.rtbishop.look4sat.R import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.domain.predict.PI_2 import com.rtbishop.look4sat.domain.predict.PI_2
import com.rtbishop.look4sat.domain.predict.SatPos import com.rtbishop.look4sat.domain.predict.SatPos
import com.rtbishop.look4sat.domain.predict.TWO_PI import com.rtbishop.look4sat.domain.predict.TWO_PI
import com.rtbishop.look4sat.utility.toRadians import com.rtbishop.look4sat.utility.toRadians
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin import kotlin.math.sin
class RadarView(context: Context) : View(context) { @Composable
fun RadarViewCompose(item: SatPos, items: List<SatPos>, orientation: Triple<Float, Float, Float>) {
private val defaultColor = ContextCompat.getColor(context, R.color.accent) val radarColor = Color(0xFFDCDCDC)
private val scale = resources.displayMetrics.density val primaryColor = Color(0xFFFFE082)
private val strokeSize = scale * 2f val secondaryColor = Color(0xFFDC0000)
private var position: SatPos? = null val strokeWidth = 4f
private var positions: List<SatPos> = emptyList() val measurer = rememberTextMeasurer()
val sweepDegrees = remember { mutableStateOf(0f) }
private var radarColor = ContextCompat.getColor(context, R.color.textMain) val trackCreated = remember { mutableStateOf(false) }
private var radarCircleNum = 3 val trackPath = remember { mutableStateOf(Path()) }
private var radarPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
color = radarColor Canvas(modifier = Modifier.fillMaxSize()) {
style = Paint.Style.STROKE val radius = (size.minDimension / 2f) * 0.95f
strokeWidth = strokeSize if (!trackCreated.value) {
} trackPath.value = createTrackPath(items, radius)
trackEffect.value = createTrackEffect(trackPath.value)
private var shouldShowSweep = true trackCreated.value = true
private var sweepColor = defaultColor
private var sweepDegrees = 0f
private var sweepSpeed = 15.0f
private var sweepPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = sweepColor
}
private var shouldShowBeacons = true
private var beaconColor = defaultColor
private var beaconSize = scale * 7f
private var beaconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = beaconColor
style = Paint.Style.FILL
}
private var radarTextSize = scale * 16f
private var radarTextColor = defaultColor
private var radarTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = radarTextColor
textSize = radarTextSize
}
private var isTrackCreated = false
private val trackPath: Path = Path()
private var trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = strokeSize
}
private val arrowPath = Path()
private var arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.accent)
style = Paint.Style.FILL
strokeWidth = strokeSize
}
private var shouldShowAim = true
private var aimColor = Color.RED
private var aimPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = aimColor
style = Paint.Style.STROKE
strokeWidth = strokeSize
}
private var shouldRotateRadar = true
private var azimuth: Float = 0f
private var pitch: Float = 0f
private var roll: Float = 0f
fun setScanning(isScanning: Boolean) {
shouldShowSweep = isScanning
}
fun setPosition(position: SatPos) {
this.position = position
}
fun setPositions(positions: List<SatPos>) {
this.positions = positions
}
fun setShowAim(showAim: Boolean) {
shouldShowAim = showAim
}
fun setOrientation(azimuth: Float, pitch: Float, roll: Float) {
this.azimuth = azimuth
this.pitch = pitch
this.roll = roll
if (shouldRotateRadar) {
this.rotation = -azimuth
} }
} rotate(-orientation.first) {
drawSweep(center, sweepDegrees.value, radius, primaryColor)
override fun onDraw(canvas: Canvas) { drawRadar(radius, radarColor, strokeWidth, 3)
val radarWidth = width - paddingLeft - paddingRight drawInfo(radius, primaryColor, measurer, 3)
val radarHeight = height - paddingTop - paddingBottom translate(center.x, center.y) {
val radarRadius = min(width, height) * 0.48f drawTrack(trackPath.value, trackEffect.value, secondaryColor, primaryColor)
val cx = paddingLeft + radarWidth / 2f if (item.elevation > 0) drawPosition(item, radius, primaryColor)
val cy = paddingTop + radarHeight / 2f drawAim(orientation.first, orientation.second, radius, strokeWidth, secondaryColor)
if (positions.isNotEmpty() && !isTrackCreated) {
createPassTrajectory(radarRadius)
createPassTrajectoryArrow()
isTrackCreated = true
}
canvas.drawColor(ContextCompat.getColor(context, R.color.cardRegular))
drawRadarCircle(canvas, cx, cy, radarRadius)
drawRadarCross(canvas, cx, cy, radarRadius)
drawRadarText(canvas, cx, radarRadius)
canvas.translate(cx, cy)
if (positions.isNotEmpty()) {
canvas.drawPath(trackPath, trackPaint)
canvas.drawPath(trackPath, arrowPaint)
}
if (shouldShowBeacons) {
drawSatellite(canvas, radarRadius)
}
if (shouldShowAim) {
drawCrosshair(canvas, azimuth, pitch, radarRadius)
}
if (shouldShowSweep) {
canvas.translate(-cx, -cy)
drawRadarSweep(canvas, cx, cy, radarRadius)
sweepDegrees = (sweepDegrees + 360 / sweepSpeed / 60) % 360
}
invalidate()
}
private fun drawRadarCircle(canvas: Canvas, cx: Float, cy: Float, radius: Float) {
for (i in 0 until radarCircleNum) {
canvas.drawCircle(cx, cy, radius - radius / radarCircleNum * i, radarPaint)
}
}
private fun drawRadarCross(canvas: Canvas, cx: Float, cy: Float, radius: Float) {
canvas.drawLine(cx - radius, cy, cx + radius, cy, radarPaint)
canvas.drawLine(cx, cy - radius, cx, cy + radius, radarPaint)
}
private fun drawRadarText(canvas: Canvas, cx: Float, radius: Float) {
for (i in 0 until radarCircleNum) {
val degText = " ${(90 / radarCircleNum) * (radarCircleNum - i)}°"
canvas.drawText(degText, cx, radius - radius / radarCircleNum * i, radarTextPaint)
}
}
private fun drawSatellite(canvas: Canvas, radarRadius: Float) {
position?.let { satPos ->
if (satPos.elevation > 0) {
val satX = sph2CartX(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
val satY = sph2CartY(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
canvas.drawCircle(satX, -satY, beaconSize, beaconPaint)
} }
sweepDegrees.value = (sweepDegrees.value + 360 / 12.0f / 60) % 360
} }
} }
}
private fun drawCrosshair(canvas: Canvas, azimuth: Float, pitch: Float, radarRadius: Float) {
val azimuthRad = azimuth.toDouble().toRadians() private fun DrawScope.drawRadar(radius: Float, color: Color, width: Float, circles: Int) {
val tmpElevation = pitch.toDouble().toRadians() for (i in 0 until circles) {
val elevationRad = if (tmpElevation > 0.0) 0.0 else tmpElevation val circleRadius = radius - radius / circles.toFloat() * i.toFloat()
val crossX = sph2CartX(azimuthRad, -elevationRad, radarRadius.toDouble()) drawCircle(color, circleRadius, style = Stroke(width))
val crossY = sph2CartY(azimuthRad, -elevationRad, radarRadius.toDouble()) }
canvas.drawLine(crossX - radarTextSize, -crossY, crossX + radarTextSize, -crossY, aimPaint) drawLine(color, center.copy(x = center.x - radius), center.copy(x = center.x + radius), width)
canvas.drawLine(crossX, -crossY - radarTextSize, crossX, -crossY + radarTextSize, aimPaint) drawLine(color, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width)
canvas.drawCircle(crossX, -crossY, radarTextSize / 2, aimPaint) }
}
private fun DrawScope.drawInfo(radius: Float, color: Color, measurer: TextMeasurer, circles: Int) {
private fun sph2CartX(azimuth: Double, elevation: Double, r: Double): Float { for (i in 0 until circles) {
val radius = r * (PI_2 - elevation) / PI_2 val textY = (radius - radius / circles * i) - 32f
return (radius * cos(PI_2 - azimuth)).toFloat() val textDeg = " ${(90 / circles) * (circles - i)}°"
} drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp))
}
private fun sph2CartY(azimuth: Double, elevation: Double, r: Double): Float { }
val radius = r * (PI_2 - elevation) / PI_2
return (radius * sin(PI_2 - azimuth)).toFloat() private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) {
} drawPath(path, color, style = Stroke(4f))
drawPath(path, effectColor, style = Stroke(pathEffect = effect))
private fun createPassTrajectory(radarRadius: Float) { }
positions.forEachIndexed { index, satPos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radarRadius.toDouble()) private fun DrawScope.drawPosition(item: SatPos, radius: Float, color: Color) {
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radarRadius.toDouble()) val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble())
if (index == 0) { val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble())
trackPath.moveTo(passX, -passY) drawCircle(color, 18f, center.copy(satX, -satY))
} else { }
trackPath.lineTo(passX, -passY)
} private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) {
} val size = 36f
} val azimRadians = azim.toDouble().toRadians()
val tempElevRadians = elev.toDouble().toRadians()
private fun createPassTrajectoryArrow() { val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians
val radius = beaconSize val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble())
val sides = 3 val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble())
val angle = TWO_PI / sides try {
arrowPath.moveTo((radius * cos(angle)).toFloat(), (radius * sin(angle)).toFloat()) drawLine(color, center.copy(aimX - size, -aimY), center.copy(aimX + size, -aimY), width)
for (i in 1 until sides) { drawLine(color, center.copy(aimX, -aimY - size), center.copy(aimX, -aimY + size), width)
val x = (radius * cos(angle - angle * i)).toFloat() drawCircle(color, size / 2, center.copy(aimX, -aimY), style = Stroke(width))
val y = (radius * sin(angle - angle * i)).toFloat() } catch (exception: Exception) {
arrowPath.lineTo(x, y) // Timber.d(exception)
} }
arrowPath.close() }
val trackLength = PathMeasure(trackPath, false).length
val quarter = trackLength / 4f private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
val center = trackLength / 2f val colors = listOf(Color.Transparent, Color(0x80FFE082), color)
val effect = PathDashPathEffect(arrowPath, center, quarter, PathDashPathEffect.Style.ROTATE) val colorStops = listOf(0.64f, 0.995f, 1f)
arrowPaint.pathEffect = effect val brush = ShaderBrush(SweepGradientShader(center, colors, colorStops))
} rotate(-90 + degrees, center) { drawCircle(brush, radius, style = Fill) }
}
private fun drawRadarSweep(canvas: Canvas, cx: Float, cy: Float, radius: Float) {
val sweepGradient = SweepGradient( private fun createTrackPath(positions: List<SatPos>, radius: Float): Path {
cx, val trackPath = Path()
cy, positions.forEachIndexed { index, satPos ->
intArrayOf( val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble())
Color.TRANSPARENT, val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble())
changeAlpha(sweepColor, 0), if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY)
changeAlpha(sweepColor, 164), }
changeAlpha(sweepColor, 255), return trackPath
changeAlpha(sweepColor, 255) }
),
floatArrayOf(0.0f, 0.55f, 0.996f, 0.999f, 1f) private fun createTrackEffect(trackPath: Path): PathEffect {
) val shape = Path()
sweepPaint.shader = sweepGradient val shapeSides = 3
canvas.rotate(-90 + sweepDegrees, cx, cy) val shapeRadius = 18f
canvas.drawCircle(cx, cy, radius, sweepPaint) val angle = TWO_PI / shapeSides
} shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
for (i in 1 until shapeSides) {
private fun changeAlpha(color: Int, alpha: Int): Int { val x = (shapeRadius * cos(angle - angle * i)).toFloat()
val red = Color.red(color) val y = (shapeRadius * sin(angle - angle * i)).toFloat()
val green = Color.green(color) shape.lineTo(x, y)
val blue = Color.blue(color) }
return Color.argb(alpha, red, green, blue) 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)
}
private fun sph2CartX(azim: Double, elev: Double, r: Double): Float {
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()
} }

Wyświetl plik

@ -1,155 +0,0 @@
@file:OptIn(ExperimentalTextApi::class)
package com.rtbishop.look4sat.presentation.radarScreen
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.domain.predict.PI_2
import com.rtbishop.look4sat.domain.predict.SatPos
import com.rtbishop.look4sat.domain.predict.TWO_PI
import com.rtbishop.look4sat.utility.toRadians
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun RadarViewCompose(item: SatPos, items: List<SatPos>, orientation: Triple<Float, Float, Float>) {
val measurer = rememberTextMeasurer()
val newTrackPath = remember { mutableStateOf(Path()) }
val newArrowPath = remember { mutableStateOf(Path()) }
var isTrackCreated = false
val yellowColor = Color(0xFFFFE082)
val whiteColor = Color(0xCCFFFFFF)
val redColor = Color(0xFFB71C1C)
val strokeWidth = 4f
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = (size.minDimension / 2f) * 0.95f
if (items.isNotEmpty() && !isTrackCreated) {
newTrackPath.value = createPassTrajectory(items, radius)
newArrowPath.value = createPassTrajectoryArrow()
isTrackCreated = true
}
rotate(-orientation.first) {
drawRadarCirle(radius, whiteColor, strokeWidth)
drawRadarCross(radius, whiteColor, strokeWidth)
drawRadarText(radius, yellowColor, measurer)
translate(center.x, center.y) {
if (items.isNotEmpty()) {
val effect = createPathEffect(newTrackPath.value, newArrowPath.value)
drawPath(newTrackPath.value, redColor, style = Stroke(4f))
drawPath(newTrackPath.value, yellowColor, style = Stroke(pathEffect = effect))
}
drawSatellite(item, radius, yellowColor)
drawCrosshair(orientation.first, orientation.second, radius, strokeWidth)
}
}
}
}
private fun DrawScope.drawRadarCirle(radius: Float, color: Color, width: Float, circles: Int = 3) {
for (i in 0 until circles) {
val circleRadius = radius - radius / circles.toFloat() * i.toFloat()
drawCircle(color, circleRadius, style = Stroke(width))
}
}
private fun DrawScope.drawRadarCross(radius: Float, color: Color, width: Float) {
drawLine(color, center, center.copy(x = center.x - radius), strokeWidth = width)
drawLine(color, center, center.copy(x = center.x + radius), strokeWidth = width)
drawLine(color, center, center.copy(y = center.y - radius), strokeWidth = width)
drawLine(color, center, center.copy(y = center.y + radius), strokeWidth = width)
}
private fun DrawScope.drawRadarText(
radius: Float, color: Color, measurer: TextMeasurer, circles: Int = 3
) {
for (i in 0..circles) {
val textY = (radius - radius / circles * i) + 25f
val textDeg = " ${(90 / circles) * (circles - i)}°"
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 16.sp))
}
}
private fun DrawScope.drawSatellite(item: SatPos, radius: Float, color: Color) {
if (item.elevation > 0) {
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))
}
}
private fun DrawScope.drawCrosshair(azimuth: Float, elevation: Float, radius: Float, width: Float) {
val redColor = Color(0xFFFF0000)
val size = 36f
val azimuthRad = azimuth.toDouble().toRadians()
val tmpElevation = elevation.toDouble().toRadians()
val elevationRad = if (tmpElevation > 0.0) 0.0 else tmpElevation
val crossX = sph2CartX(azimuthRad, -elevationRad, radius.toDouble())
val crossY = sph2CartY(azimuthRad, -elevationRad, radius.toDouble())
drawLine(
redColor,
center.copy(crossX - size, -crossY),
center.copy(crossX + size, -crossY),
strokeWidth = width
)
drawLine(
redColor,
center.copy(crossX, -crossY - size),
center.copy(crossX, -crossY + size),
strokeWidth = width
)
drawCircle(redColor, size / 2, center.copy(crossX, -crossY), style = Stroke(width))
}
private fun createPassTrajectory(positions: List<SatPos>, radarRadius: Float): Path {
val trackPath = Path()
positions.forEachIndexed { index, satPos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radarRadius.toDouble())
if (index == 0) {
trackPath.moveTo(passX, -passY)
} else {
trackPath.lineTo(passX, -passY)
}
}
return trackPath
}
private fun createPassTrajectoryArrow(): Path {
val arrowPath = Path()
val radius = 24f
val sides = 3
val angle = TWO_PI / sides
arrowPath.moveTo((radius * cos(angle)).toFloat(), (radius * sin(angle)).toFloat())
for (i in 1 until sides) {
val x = (radius * cos(angle - angle * i)).toFloat()
val y = (radius * sin(angle - angle * i)).toFloat()
arrowPath.lineTo(x, y)
}
arrowPath.close()
return arrowPath
}
private fun createPathEffect(trackPath: Path, arrowPath: Path): PathEffect {
val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length
val quarter = trackLength / 4f
val center = trackLength / 2f
return PathEffect.stampedPathEffect(arrowPath, center, quarter, StampedPathEffectStyle.Rotate)
}
private fun sph2CartX(azimuth: Double, elevation: Double, r: Double): Float {
val radius = r * (PI_2 - elevation) / PI_2
return (radius * cos(PI_2 - azimuth)).toFloat()
}
private fun sph2CartY(azimuth: Double, elevation: Double, r: Double): Float {
val radius = r * (PI_2 - elevation) / PI_2
return (radius * sin(PI_2 - azimuth)).toFloat()
}