Put a compass at the top of the board

aa82462 · Gabriel Morell · 2026-09-08 19:54

19 files +347 -0
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