neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Put a compass at the top of the board
aa82462 · Gabriel Morell · 2026-09-08 19:54
Message
{commit_body(@commit)}
Files changed
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+7
−0
@@ -236,6 +236,7 @@ private fun StatusHeader(
| 236 | 236 | modifier: Modifier = Modifier |
| 237 | 237 | ) { |
| 238 | 238 | val palette = LocalAppTheme.current |
| 239 | + val heading by rememberHeading() | |
| 239 | 240 | Row( |
| 240 | 241 | modifier = modifier.fillMaxWidth(), |
| 241 | 242 | verticalAlignment = Alignment.Top |
@@ -258,6 +259,12 @@ private fun StatusHeader(
| 258 | 259 | color = palette.dim |
| 259 | 260 | ) |
| 260 | 261 | } |
| 262 | + // Between the two, because it belongs to neither: the cards say a stop | |
| 263 | + // is NE of you and this says which way NE is. Nothing to read without | |
| 264 | + // both, which is why it sits where the eye crosses from one to the | |
| 265 | + // other. | |
| 266 | + Compass(heading, Modifier.padding(horizontal = 12.dp)) | |
| 267 | + | |
| 261 | 268 | // The connection, and the way into it, on the right: both about the |
| 262 | 269 | // link rather than about the data. |
| 263 | 270 | Column(horizontalAlignment = Alignment.End) { |
added
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Compass.kt
+286
−0
@@ -0,0 +1,286 @@
| 1 | +package io.neiam.apolloscrib.ui | |
| 2 | + | |
| 3 | +import android.Manifest | |
| 4 | +import android.content.Context | |
| 5 | +import android.content.pm.PackageManager | |
| 6 | +import android.hardware.GeomagneticField | |
| 7 | +import android.hardware.Sensor | |
| 8 | +import android.hardware.SensorEvent | |
| 9 | +import android.hardware.SensorEventListener | |
| 10 | +import android.hardware.SensorManager | |
| 11 | +import android.location.LocationManager | |
| 12 | +import android.view.Surface | |
| 13 | +import androidx.compose.foundation.Canvas | |
| 14 | +import androidx.compose.foundation.layout.Arrangement | |
| 15 | +import androidx.compose.foundation.layout.Column | |
| 16 | +import androidx.compose.foundation.layout.size | |
| 17 | +import androidx.compose.material3.MaterialTheme | |
| 18 | +import androidx.compose.material3.Text | |
| 19 | +import androidx.compose.runtime.Composable | |
| 20 | +import androidx.compose.runtime.DisposableEffect | |
| 21 | +import androidx.compose.runtime.State | |
| 22 | +import androidx.compose.runtime.getValue | |
| 23 | +import androidx.compose.runtime.mutableStateOf | |
| 24 | +import androidx.compose.runtime.remember | |
| 25 | +import androidx.compose.runtime.setValue | |
| 26 | +import androidx.compose.ui.Alignment | |
| 27 | +import androidx.compose.ui.Modifier | |
| 28 | +import androidx.compose.ui.geometry.Offset | |
| 29 | +import androidx.compose.ui.graphics.Path | |
| 30 | +import androidx.compose.ui.graphics.drawscope.DrawScope | |
| 31 | +import androidx.compose.ui.graphics.drawscope.Stroke | |
| 32 | +import androidx.compose.ui.graphics.drawscope.rotate | |
| 33 | +import androidx.compose.ui.platform.LocalContext | |
| 34 | +import androidx.compose.ui.text.font.FontFamily | |
| 35 | +import androidx.compose.ui.unit.dp | |
| 36 | +import androidx.core.content.ContextCompat | |
| 37 | +import androidx.lifecycle.Lifecycle | |
| 38 | +import androidx.lifecycle.LifecycleEventObserver | |
| 39 | +import androidx.lifecycle.compose.LocalLifecycleOwner | |
| 40 | +import io.neiam.apolloscrib.ui.theme.LocalAppTheme | |
| 41 | +import kotlin.math.cos | |
| 42 | +import kotlin.math.roundToInt | |
| 43 | +import kotlin.math.sin | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * Which way the phone is pointing. | |
| 47 | + * | |
| 48 | + * [degrees] is clockwise from north, 0 until the first reading. [trueNorth] | |
| 49 | + * says which north that is: the cards' bearings are true — they are computed | |
| 50 | + * from coordinates — so a magnetic reading beside them would disagree by the | |
| 51 | + * local declination, which is a whole compass point in New England and rather | |
| 52 | + * more in places. Saying which one is being shown is the difference between a | |
| 53 | + * compass and a decoration. | |
| 54 | + */ | |
| 55 | +data class Heading(val degrees: Float, val trueNorth: Boolean, val available: Boolean) | |
| 56 | + | |
| 57 | +/** | |
| 58 | + * The device's heading, while this composable is on screen and resumed. | |
| 59 | + * | |
| 60 | + * Unregistered when the screen is not resumed rather than only when it leaves | |
| 61 | + * composition: a rotation vector at UI rate is a sensor running, and running | |
| 62 | + * it behind the launcher is battery spent on a reading nobody can see. | |
| 63 | + */ | |
| 64 | +@Composable | |
| 65 | +fun rememberHeading(): State<Heading> { | |
| 66 | + val context = LocalContext.current | |
| 67 | + val lifecycleOwner = LocalLifecycleOwner.current | |
| 68 | + | |
| 69 | + val heading = remember { mutableStateOf(Heading(0f, false, false)) } | |
| 70 | + | |
| 71 | + DisposableEffect(lifecycleOwner) { | |
| 72 | + val sensors = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager | |
| 73 | + val rotation = sensors?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) | |
| 74 | + | |
| 75 | + if (sensors == null || rotation == null) { | |
| 76 | + // A device with no rotation vector: say so once and draw nothing, | |
| 77 | + // rather than a dial frozen at north pretending to be a compass. | |
| 78 | + heading.value = Heading(0f, false, false) | |
| 79 | + return@DisposableEffect onDispose {} | |
| 80 | + } | |
| 81 | + | |
| 82 | + val declination = declinationAt(context) | |
| 83 | + | |
| 84 | + val listener = object : SensorEventListener { | |
| 85 | + private var smoothed: Float? = null | |
| 86 | + | |
| 87 | + override fun onSensorChanged(event: SensorEvent) { | |
| 88 | + val magnetic = azimuthOf(event, context) ?: return | |
| 89 | + val corrected = wrap(magnetic + (declination ?: 0f)) | |
| 90 | + | |
| 91 | + // Smoothed across the shortest way round, so a reading passing | |
| 92 | + // north does not spin the dial the long way from 359 to 1. | |
| 93 | + val previous = smoothed | |
| 94 | + smoothed = if (previous == null) { | |
| 95 | + corrected | |
| 96 | + } else { | |
| 97 | + wrap(previous + shortestTurn(previous, corrected) * SMOOTHING) | |
| 98 | + } | |
| 99 | + | |
| 100 | + heading.value = Heading(smoothed!!, declination != null, true) | |
| 101 | + } | |
| 102 | + | |
| 103 | + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit | |
| 104 | + } | |
| 105 | + | |
| 106 | + val observer = LifecycleEventObserver { _, event -> | |
| 107 | + when (event) { | |
| 108 | + Lifecycle.Event.ON_RESUME -> | |
| 109 | + sensors.registerListener(listener, rotation, SensorManager.SENSOR_DELAY_UI) | |
| 110 | + | |
| 111 | + Lifecycle.Event.ON_PAUSE -> sensors.unregisterListener(listener) | |
| 112 | + else -> Unit | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + lifecycleOwner.lifecycle.addObserver(observer) | |
| 117 | + | |
| 118 | + onDispose { | |
| 119 | + lifecycleOwner.lifecycle.removeObserver(observer) | |
| 120 | + sensors.unregisterListener(listener) | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + return heading | |
| 125 | +} | |
| 126 | + | |
| 127 | +/** How much of each new reading to take. Low enough to settle a shaky hand. */ | |
| 128 | +private const val SMOOTHING = 0.15f | |
| 129 | + | |
| 130 | +private val COMPASS_POINTS = | |
| 131 | + listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", | |
| 132 | + "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW") | |
| 133 | + | |
| 134 | +/** The sixteen-point name for a bearing, as the cards use. */ | |
| 135 | +fun compassPoint(degrees: Float): String = | |
| 136 | + COMPASS_POINTS[((wrap(degrees) / 22.5f).roundToInt()) % 16] | |
| 137 | + | |
| 138 | +private fun wrap(degrees: Float): Float = ((degrees % 360f) + 360f) % 360f | |
| 139 | + | |
| 140 | +// Signed difference from a to b, taking whichever way round is shorter. | |
| 141 | +private fun shortestTurn(from: Float, to: Float): Float { | |
| 142 | + val diff = wrap(to - from) | |
| 143 | + return if (diff > 180f) diff - 360f else diff | |
| 144 | +} | |
| 145 | + | |
| 146 | +/** | |
| 147 | + * The azimuth from a rotation vector, in the orientation the screen is held. | |
| 148 | + * | |
| 149 | + * Remapped by the display's rotation: without it a phone in landscape reads | |
| 150 | + * ninety degrees out, which is the kind of wrong that looks like a broken | |
| 151 | + * sensor rather than a missing correction. | |
| 152 | + */ | |
| 153 | +private fun azimuthOf(event: SensorEvent, context: Context): Float? { | |
| 154 | + val matrix = FloatArray(9) | |
| 155 | + SensorManager.getRotationMatrixFromVector(matrix, event.values) | |
| 156 | + | |
| 157 | + val display = ContextCompat.getDisplayOrDefault(context) | |
| 158 | + val (axisX, axisY) = when (display.rotation) { | |
| 159 | + Surface.ROTATION_90 -> SensorManager.AXIS_Y to SensorManager.AXIS_MINUS_X | |
| 160 | + Surface.ROTATION_180 -> SensorManager.AXIS_MINUS_X to SensorManager.AXIS_MINUS_Y | |
| 161 | + Surface.ROTATION_270 -> SensorManager.AXIS_MINUS_Y to SensorManager.AXIS_X | |
| 162 | + else -> SensorManager.AXIS_X to SensorManager.AXIS_Y | |
| 163 | + } | |
| 164 | + | |
| 165 | + val remapped = FloatArray(9) | |
| 166 | + if (!SensorManager.remapCoordinateSystem(matrix, axisX, axisY, remapped)) return null | |
| 167 | + | |
| 168 | + val orientation = FloatArray(3) | |
| 169 | + SensorManager.getOrientation(remapped, orientation) | |
| 170 | + | |
| 171 | + return wrap(Math.toDegrees(orientation[0].toDouble()).toFloat()) | |
| 172 | +} | |
| 173 | + | |
| 174 | +/** | |
| 175 | + * The local magnetic declination, or null when there is nowhere to compute it. | |
| 176 | + * | |
| 177 | + * Needs a position, and this app only holds one when location reporting has | |
| 178 | + * been switched on. Without it the heading stays magnetic and says so, rather | |
| 179 | + * than silently pointing a compass point off. | |
| 180 | + */ | |
| 181 | +private fun declinationAt(context: Context): Float? { | |
| 182 | + val granted = | |
| 183 | + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == | |
| 184 | + PackageManager.PERMISSION_GRANTED | |
| 185 | + | |
| 186 | + if (!granted) return null | |
| 187 | + | |
| 188 | + val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null | |
| 189 | + | |
| 190 | + val last = runCatching { | |
| 191 | + manager.allProviders | |
| 192 | + .mapNotNull { manager.getLastKnownLocation(it) } | |
| 193 | + .maxByOrNull { it.time } | |
| 194 | + }.getOrNull() ?: return null | |
| 195 | + | |
| 196 | + return GeomagneticField( | |
| 197 | + last.latitude.toFloat(), | |
| 198 | + last.longitude.toFloat(), | |
| 199 | + last.altitude.toFloat(), | |
| 200 | + System.currentTimeMillis() | |
| 201 | + ).declination | |
| 202 | +} | |
| 203 | + | |
| 204 | +/** | |
| 205 | + * A dial, the bearing in degrees, and the point it names. | |
| 206 | + * | |
| 207 | + * The card is what turns, not the needle. A needle swinging against a fixed | |
| 208 | + * dial tells you where north is; a dial turning under a fixed mark tells you | |
| 209 | + * where *you* are pointing, which is the question being asked next to a row of | |
| 210 | + * cards saying a stop is NE of you. | |
| 211 | + */ | |
| 212 | +@Composable | |
| 213 | +fun Compass(heading: Heading, modifier: Modifier = Modifier) { | |
| 214 | + if (!heading.available) return | |
| 215 | + | |
| 216 | + val palette = LocalAppTheme.current | |
| 217 | + | |
| 218 | + Column( | |
| 219 | + modifier = modifier, | |
| 220 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 221 | + verticalArrangement = Arrangement.spacedBy(1.dp) | |
| 222 | + ) { | |
| 223 | + Canvas(Modifier.size(34.dp)) { drawRose(heading.degrees, palette.dim, palette.accent) } | |
| 224 | + | |
| 225 | + Text( | |
| 226 | + "${wrap(heading.degrees).roundToInt()}° ${compassPoint(heading.degrees)}", | |
| 227 | + style = MaterialTheme.typography.labelSmall, | |
| 228 | + fontFamily = FontFamily.Monospace, | |
| 229 | + color = palette.dim | |
| 230 | + ) | |
| 231 | + } | |
| 232 | +} | |
| 233 | + | |
| 234 | +private fun DrawScope.drawRose( | |
| 235 | + degrees: Float, | |
| 236 | + dim: androidx.compose.ui.graphics.Color, | |
| 237 | + accent: androidx.compose.ui.graphics.Color | |
| 238 | +) { | |
| 239 | + val radius = size.minDimension / 2f | |
| 240 | + val centre = Offset(size.minDimension / 2f, size.minDimension / 2f) | |
| 241 | + | |
| 242 | + drawCircle(color = dim.copy(alpha = 0.35f), radius = radius - 1f, style = Stroke(width = 1.5f)) | |
| 243 | + | |
| 244 | + // The mark you read against: fixed at the top, pointing at whatever the | |
| 245 | + // phone is pointing at. | |
| 246 | + drawPath( | |
| 247 | + path = Path().apply { | |
| 248 | + moveTo(centre.x, 1.5f) | |
| 249 | + lineTo(centre.x - 3.5f, 8f) | |
| 250 | + lineTo(centre.x + 3.5f, 8f) | |
| 251 | + close() | |
| 252 | + }, | |
| 253 | + color = accent | |
| 254 | + ) | |
| 255 | + | |
| 256 | + // The card, turning under it. Negative because the world turns the other | |
| 257 | + // way from the phone: face east and north must move to the left. | |
| 258 | + rotate(degrees = -degrees, pivot = centre) { | |
| 259 | + // North, and the other three cardinals as plain ticks. | |
| 260 | + drawLine( | |
| 261 | + color = accent, | |
| 262 | + start = Offset(centre.x, centre.y - radius + 4f), | |
| 263 | + end = Offset(centre.x, centre.y - radius * 0.35f), | |
| 264 | + strokeWidth = 2f | |
| 265 | + ) | |
| 266 | + | |
| 267 | + for (point in 1..3) { | |
| 268 | + val angle = Math.toRadians(point * 90.0) | |
| 269 | + val outer = radius - 4f | |
| 270 | + val inner = radius * 0.7f | |
| 271 | + | |
| 272 | + drawLine( | |
| 273 | + color = dim, | |
| 274 | + start = Offset( | |
| 275 | + centre.x + (sin(angle) * outer).toFloat(), | |
| 276 | + centre.y - (cos(angle) * outer).toFloat() | |
| 277 | + ), | |
| 278 | + end = Offset( | |
| 279 | + centre.x + (sin(angle) * inner).toFloat(), | |
| 280 | + centre.y - (cos(angle) * inner).toFloat() | |
| 281 | + ), | |
| 282 | + strokeWidth = 1f | |
| 283 | + ) | |
| 284 | + } | |
| 285 | + } | |
| 286 | +} |
added
clients/android/app/src/test/java/io/neiam/apolloscrib/ui/CompassTest.kt
+54
−0
@@ -0,0 +1,54 @@
| 1 | +package io.neiam.apolloscrib.ui | |
| 2 | + | |
| 3 | +import org.junit.Assert.assertEquals | |
| 4 | +import org.junit.Test | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The compass names a bearing the same way the publisher does. | |
| 8 | + * | |
| 9 | + * A card says a stop is "NE" and the dial says which way the phone is facing. | |
| 10 | + * If the two disagree about where NE begins, the pair is worse than either | |
| 11 | + * alone -- so this is the same sixteen-point table, checked at the edges. | |
| 12 | + */ | |
| 13 | +class CompassTest { | |
| 14 | + | |
| 15 | + @Test | |
| 16 | + fun `the cardinals sit where they should`() { | |
| 17 | + assertEquals("N", compassPoint(0f)) | |
| 18 | + assertEquals("E", compassPoint(90f)) | |
| 19 | + assertEquals("S", compassPoint(180f)) | |
| 20 | + assertEquals("W", compassPoint(270f)) | |
| 21 | + } | |
| 22 | + | |
| 23 | + @Test | |
| 24 | + fun `the diagonals sit where they should`() { | |
| 25 | + assertEquals("NE", compassPoint(45f)) | |
| 26 | + assertEquals("SE", compassPoint(135f)) | |
| 27 | + assertEquals("SW", compassPoint(225f)) | |
| 28 | + assertEquals("NW", compassPoint(315f)) | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Test | |
| 32 | + fun `a bearing rounds to its nearest point`() { | |
| 33 | + // Each point owns 22.5 degrees, so the boundary between N and NNE is | |
| 34 | + // at 11.25. | |
| 35 | + assertEquals("N", compassPoint(11f)) | |
| 36 | + assertEquals("NNE", compassPoint(12f)) | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + fun `north wraps rather than falling off the end`() { | |
| 41 | + // 349 is past the last point (NNW) and back into north's half. | |
| 42 | + assertEquals("N", compassPoint(349f)) | |
| 43 | + assertEquals("N", compassPoint(360f)) | |
| 44 | + assertEquals("NNW", compassPoint(340f)) | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + fun `a reading outside one turn is still a bearing`() { | |
| 49 | + // Smoothing works in degrees and can drift past the range; naming a | |
| 50 | + // bearing should not care. | |
| 51 | + assertEquals("E", compassPoint(450f)) | |
| 52 | + assertEquals("W", compassPoint(-90f)) | |
| 53 | + } | |
| 54 | +} |
Parents: 2dac4a5