Starry Night
Stijn De Raeve-Zenner
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
// region Public
/**
* A contact plotted on the radar.
*
* @param bearing degrees clockwise from straight up (0 = north, 90 = east).
* @param distance 0f at the centre, 1f at the outer ring.
*/
@Immutable
data class RadarContact(
val id: String,
val label: String,
val bearing: Float,
val distance: Float,
)
/**
* A sweeping scanner radar.
*
* The beam rotates continuously; every contact lights up the moment the beam crosses its bearing,
* throws off an expanding ping ring, then decays back into the dark until the next pass.
* Tapping a contact reports it back through [onContactClick].
*
*/
@Composable
fun ScannerRadar(
contacts: List<RadarContact>,
modifier: Modifier = Modifier,
selectedId: String? = null,
onContactClick: ((RadarContact) -> Unit)? = null,
accent: Color = Color(0xFF00E5A0),
background: Color = Color(0xFF04120D),
sweepDurationMillis: Int = 3400,
ringCount: Int = 4,
showLabels: Boolean = true,
labelStyle: TextStyle = TextStyle(
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
letterSpacing = 0.6.sp
),
) {
val textMeasurer = rememberTextMeasurer()
val sweep: State<Float> = rememberInfiniteTransition(label = "radar").animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(tween(sweepDurationMillis, easing = LinearEasing)),
label = "sweep",
)
Canvas(
modifier = modifier
.aspectRatio(1f)
.pointerInput(contacts, onContactClick) {
if (onContactClick == null) return@pointerInput
detectTapGestures { tap ->
val centre = Offset(size.width / 2f, size.height / 2f)
val radius = minOf(size.width, size.height) / 2f * OUTER_RING_FRACTION
val slop = 22.dp.toPx()
contacts
.map { it to (contactOffset(centre, radius, it) - tap).getDistance() }
.filter { it.second <= slop }
.minByOrNull { it.second }
?.let { onContactClick(it.first) }
}
},
) {
val centre = center
val radius = size.minDimension / 2f * OUTER_RING_FRACTION
val beam = sweep.value
drawDish(centre, radius, background, accent, ringCount)
drawBeam(centre, radius, beam, accent)
contacts.forEach { contact ->
val since = ((beam - contact.bearing) % 360f + 360f) % 360f
val glow = (1f - (since / CONTACT_FADE_DEGREES)).coerceAtLeast(0f)
val position = contactOffset(centre, radius, contact)
drawContact(
position = position,
accent = accent,
glow = glow,
since = since,
selected = contact.id == selectedId,
)
if (showLabels) {
drawContactLabel(textMeasurer, contact.label, position, accent, glow, labelStyle)
}
}
drawHub(centre, accent)
}
}
// endregion
// region Private
private const val OUTER_RING_FRACTION = 0.86f
private const val BEAM_TRAIL_DEGREES = 92f
private const val CONTACT_FADE_DEGREES = 300f
private const val PING_DEGREES = 70f
private fun contactOffset(centre: Offset, radius: Float, contact: RadarContact): Offset {
val radians = ((contact.bearing - 90f) * PI / 180f).toFloat()
val distance = contact.distance.coerceIn(0f, 1f) * radius
return Offset(centre.x + cos(radians) * distance, centre.y + sin(radians) * distance)
}
private fun DrawScope.drawDish(
centre: Offset,
radius: Float,
background: Color,
accent: Color,
ringCount: Int,
) {
drawCircle(
brush = Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.09f), background),
center = centre,
radius = radius,
),
radius = radius,
center = centre,
)
repeat(ringCount) { index ->
val ringRadius = radius * (index + 1) / ringCount
drawCircle(
color = accent.copy(alpha = if (index == ringCount - 1) 0.34f else 0.14f),
radius = ringRadius,
center = centre,
style = Stroke(width = 1.dp.toPx()),
)
}
// Crosshair
drawLine(
accent.copy(alpha = 0.12f),
Offset(centre.x - radius, centre.y),
Offset(centre.x + radius, centre.y)
)
drawLine(
accent.copy(alpha = 0.12f),
Offset(centre.x, centre.y - radius),
Offset(centre.x, centre.y + radius)
)
// Bearing ticks every 30 degrees, longer on the cardinals
for (degrees in 0 until 360 step 30) {
val radians = ((degrees - 90) * PI / 180f).toFloat()
val tick = if (degrees % 90 == 0) 9.dp.toPx() else 5.dp.toPx()
val outer = Offset(centre.x + cos(radians) * radius, centre.y + sin(radians) * radius)
val inner = Offset(
centre.x + cos(radians) * (radius - tick),
centre.y + sin(radians) * (radius - tick)
)
drawLine(accent.copy(alpha = 0.3f), inner, outer, strokeWidth = 1.dp.toPx())
}
}
private fun DrawScope.drawBeam(centre: Offset, radius: Float, beam: Float, accent: Color) {
val trail = BEAM_TRAIL_DEGREES / 360f
val brush = Brush.sweepGradient(
0f to Color.Transparent,
trail * 0.55f to accent.copy(alpha = 0.08f),
trail to accent.copy(alpha = 0.40f),
1f to Color.Transparent,
center = centre,
)
rotate(degrees = beam - 90f - BEAM_TRAIL_DEGREES, pivot = centre) {
drawArc(
brush = brush,
startAngle = 0f,
sweepAngle = BEAM_TRAIL_DEGREES,
useCenter = true,
topLeft = Offset(centre.x - radius, centre.y - radius),
size = Size(radius * 2f, radius * 2f),
)
}
val leadingEdge = ((beam - 90f) * PI / 180f).toFloat()
val tip = Offset(centre.x + cos(leadingEdge) * radius, centre.y + sin(leadingEdge) * radius)
drawLine(
brush = Brush.linearGradient(
listOf(accent.copy(alpha = 0.1f), accent),
start = centre,
end = tip
),
start = centre,
end = tip,
strokeWidth = 1.5.dp.toPx(),
)
}
private fun DrawScope.drawContact(
position: Offset,
accent: Color,
glow: Float,
since: Float,
selected: Boolean,
) {
val dotRadius = 3.5.dp.toPx()
if (since < PING_DEGREES) {
val progress = since / PING_DEGREES
drawCircle(
color = accent.copy(alpha = (1f - progress) * 0.55f),
radius = dotRadius + progress * 18.dp.toPx(),
center = position,
style = Stroke(width = 1.dp.toPx()),
)
}
if (glow > 0f) {
drawCircle(
brush = Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.45f * glow), Color.Transparent),
center = position,
radius = 14.dp.toPx(),
),
radius = 14.dp.toPx(),
center = position,
)
}
drawCircle(
color = accent.copy(alpha = 0.25f + 0.75f * glow),
radius = dotRadius,
center = position
)
if (selected) {
drawCircle(
color = accent.copy(alpha = 0.8f),
radius = dotRadius + 6.dp.toPx(),
center = position,
style = Stroke(width = 1.5.dp.toPx()),
)
}
}
private fun DrawScope.drawContactLabel(
textMeasurer: TextMeasurer,
label: String,
position: Offset,
accent: Color,
glow: Float,
style: TextStyle,
) {
val layout = textMeasurer.measure(label, style)
drawText(
textLayoutResult = layout,
color = accent.copy(alpha = 0.2f + 0.8f * glow),
topLeft = Offset(position.x - layout.size.width / 2f, position.y + 9.dp.toPx()),
)
}
private fun DrawScope.drawHub(centre: Offset, accent: Color) {
drawCircle(
brush = Brush.radialGradient(
colors = listOf(accent.copy(alpha = 0.35f), Color.Transparent),
center = centre,
radius = 16.dp.toPx(),
),
radius = 16.dp.toPx(),
center = centre,
)
drawCircle(color = accent, radius = 2.5.dp.toPx(), center = centre)
}
// endregion
@Preview(
name = "Scanner radar",
showBackground = true,
backgroundColor = 0xFF04120D,
)
@Composable
private fun ScannerRadarPreview() {
ScannerRadar(
contacts = listOf(
RadarContact("north", "SENTINEL-7", bearing = 12f, distance = 0.78f),
RadarContact("east", "ECHO-9", bearing = 82f, distance = 0.53f),
RadarContact("south-east", "VANTA-03", bearing = 138f, distance = 0.84f),
RadarContact("south", "NODE-42", bearing = 191f, distance = 0.35f),
RadarContact("south-west", "GHOST-1", bearing = 238f, distance = 0.69f),
RadarContact("west", "ORBIT-6", bearing = 294f, distance = 0.47f),
RadarContact("north-west", "NOVA-12", bearing = 326f, distance = 0.88f),
),
modifier = Modifier.size(320.dp),
selectedId = "east",
)
}