fuzzyworld

haunt / commit

Add compass orientation mode to map

5dd0d6717ebcfbe08ad725a637e3eeccc95d7d3e · Pavel Safronov · 2026-05-13 14:00:03 -0700 · parents 0b21814b6998

Add compass orientation mode to map

- Orient button (in map toolbar) toggles live heading-based canvas rotation
- TYPE_ROTATION_VECTOR sensor with exponential smoothing (α=0.25)
- Red N indicator drawn in screen space at the edge, connected to center by a line
- Drag the N circle to calibrate: drag it to the top while facing known north
  sets northOffsetDeg = currentHeading; offset persisted in haunt_prefs
- Sensor registered/unregistered with Activity lifecycle (onResume/onPause)
- Grid, history, points, and icons all rotate with the canvas; axis legend
  and N indicator remain in screen space after canvas.restore()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../java/net/fuzzyworld/haunt/debug/MapView.kt     | 90 +++++++++++++++++++++-
 1 file changed, 89 insertions(+), 1 deletion(-)

diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
index de9b47e..b7599e1 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/MapView.kt
@@ -10,12 +10,16 @@ import android.graphics.Path
 import android.graphics.RectF
 import android.graphics.Typeface
 import android.util.AttributeSet
+import android.view.MotionEvent
 import android.view.View
 import android.view.animation.AccelerateDecelerateInterpolator
 import net.fuzzyworld.haunt.hue.HueLightPoint
+import kotlin.math.atan2
 import kotlin.math.cos
+import kotlin.math.pow
 import kotlin.math.roundToInt
 import kotlin.math.sin
+import kotlin.math.sqrt
 
 class MapView @JvmOverloads constructor(
     context: Context,
@@ -37,6 +41,19 @@ class MapView @JvmOverloads constructor(
     var youColor: Int = Color.HSVToColor(floatArrayOf(210f, 0.75f, 0.90f))
         set(value) { field = value; invalidate() }
 
+    var orientationEnabled: Boolean = false
+        set(value) { field = value; invalidate() }
+
+    var currentHeading: Float = 0f
+        set(value) { field = value; if (orientationEnabled) invalidate() }
+
+    var northOffsetDeg: Float = 0f
+        set(value) { field = value; invalidate() }
+
+    var onNorthOffsetChanged: ((Float) -> Unit)? = null
+
+    private var isDraggingN = false
+
     private var youPulse = 1f
     private val pulseAnimator = ValueAnimator.ofFloat(0.85f, 1.15f).apply {
         duration = 1100
@@ -49,6 +66,35 @@ class MapView @JvmOverloads constructor(
     override fun onAttachedToWindow() { super.onAttachedToWindow(); pulseAnimator.start() }
     override fun onDetachedFromWindow() { super.onDetachedFromWindow(); pulseAnimator.cancel() }
 
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        if (!orientationEnabled) return false
+        val vcx = width / 2f
+        val vcy = height / 2f
+        val edgeR = minOf(width, height) / 2f - 48f
+        val nAngleRad = Math.toRadians((northOffsetDeg - currentHeading).toDouble())
+        val nX = vcx + edgeR * sin(nAngleRad).toFloat()
+        val nY = vcy - edgeR * cos(nAngleRad).toFloat()
+        return when (event.actionMasked) {
+            MotionEvent.ACTION_DOWN -> {
+                val dist = sqrt((event.x - nX).pow(2) + (event.y - nY).pow(2))
+                if (dist < 80f) { isDraggingN = true; invalidate(); true } else false
+            }
+            MotionEvent.ACTION_MOVE -> {
+                if (!isDraggingN) return false
+                val angle = Math.toDegrees(
+                    atan2((event.x - vcx).toDouble(), -(event.y - vcy).toDouble())
+                ).toFloat()
+                northOffsetDeg = ((currentHeading + angle) % 360 + 360) % 360
+                onNorthOffsetChanged?.invoke(northOffsetDeg)
+                true
+            }
+            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+                if (isDraggingN) { isDraggingN = false; invalidate(); true } else false
+            }
+            else -> false
+        }
+    }
+
     private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
     private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
     private val gridPaint = Paint().apply {
@@ -69,6 +115,21 @@ class MapView @JvmOverloads constructor(
         strokeWidth = 3f
         pathEffect = DashPathEffect(floatArrayOf(8f, 6f), 0f)
     }
+    private val nPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        style = Paint.Style.FILL
+        color = Color.parseColor("#CC2244")
+    }
+    private val nLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        style = Paint.Style.STROKE
+        strokeWidth = 2f
+        color = Color.parseColor("#88CC2244")
+    }
+    private val nLabelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = Color.WHITE
+        textSize = 34f
+        textAlign = Paint.Align.CENTER
+        typeface = Typeface.DEFAULT_BOLD
+    }
     private val arcRect = RectF()
     private val starPath = Path()
 
@@ -172,6 +233,23 @@ class MapView @JvmOverloads constructor(
         canvas.drawCircle(cx, cy, r * 1.7f, ringPaint)
     }
 
+    private fun drawNIndicator(canvas: Canvas) {
+        val vcx = width / 2f
+        val vcy = height / 2f
+        val edgeR = minOf(width, height) / 2f - 48f
+        val nAngleRad = Math.toRadians((northOffsetDeg - currentHeading).toDouble())
+        val nX = vcx + edgeR * sin(nAngleRad).toFloat()
+        val nY = vcy - edgeR * cos(nAngleRad).toFloat()
+
+        // Line from center to N
+        canvas.drawLine(vcx, vcy, nX, nY, nLinePaint)
+
+        // N circle
+        nPaint.color = if (isDraggingN) Color.parseColor("#FF8800") else Color.parseColor("#CC2244")
+        canvas.drawCircle(nX, nY, 30f, nPaint)
+        canvas.drawText("N", nX, nY + 12f, nLabelPaint)
+    }
+
     override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
         canvas.drawColor(Color.parseColor("#F2F2F2"))
@@ -222,6 +300,10 @@ class MapView @JvmOverloads constructor(
         val stepX = gridStep(maxX - minX)
         val stepY = gridStep(maxY - minY)
 
+        // Rotate canvas for orientation mode — everything up to restore() rotates with the phone
+        if (orientationEnabled) canvas.save()
+        if (orientationEnabled) canvas.rotate(northOffsetDeg - currentHeading, width / 2f, height / 2f)
+
         // Grid
         val gxFirst = (minX / stepX).toInt()
         val gxLast  = (maxX / stepX).toInt()
@@ -282,7 +364,13 @@ class MapView @JvmOverloads constructor(
             labelPaint.color = Color.BLACK
         }
 
-        // Axis legend — top-right corner
+        // Restore screen-space before drawing overlay elements
+        if (orientationEnabled) {
+            canvas.restore()
+            drawNIndicator(canvas)
+        }
+
+        // Axis legend — top-right corner, always in screen space
         val bssids = allBssids
         dimLabelPaint.textAlign = Paint.Align.RIGHT
         if (bssids.isNotEmpty()) {