fuzzyworld

haunt / commit

Color picker + fix purple clustering

4f3a4f2359c0dae4aca42a19b699b6c1d70fc628 · Pavel Safronov · 2026-05-12 22:18:04 -0700 · parents 54ee6e7f0d01

Color picker + fix purple clustering

randomColor() now uses golden-angle hue stepping (137.5° per color)
instead of Math.random(), guaranteeing maximum visual separation between
successive auto-generated colors

Tap any color swatch in the list to pick from 12 distinct preset colors
(full hue spectrum, built as oval chips in a 4-column grid dialog)
Current color shown with a black outline in the picker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 .../main/java/net/fuzzyworld/haunt/MainActivity.kt |  1 +
 .../net/fuzzyworld/haunt/debug/DebugViewModel.kt   | 11 +++-
 .../net/fuzzyworld/haunt/debug/PointsAdapter.kt    | 61 ++++++++++++++++++++++
 3 files changed, 71 insertions(+), 2 deletions(-)

diff --git a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
index f031591..df5a0db 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/MainActivity.kt
@@ -25,6 +25,7 @@ class MainActivity : AppCompatActivity() {
     private val adapter = PointsAdapter(
         onRename = { id, name -> vm.renamePoint(id, name) },
         onDelete = { id -> vm.deletePoint(id) },
+        onRecolor = { id, color -> vm.recolorPoint(id, color) },
     )
 
     private val requestPermission = registerForActivityResult(
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
index 2cf3fc5..680f00f 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/DebugViewModel.kt
@@ -102,8 +102,15 @@ class DebugViewModel(application: Application) : AndroidViewModel(application) {
         store.save(_points.value)
     }
 
+    fun recolorPoint(id: String, color: Int) {
+        _points.value = _points.value.map { if (it.id == id) it.copy(color = color) else it }
+        store.save(_points.value)
+    }
+
+    // Golden-angle stepping guarantees maximum hue separation between successive colors
+    private var nextHue = (0..360).random().toFloat()
     private fun randomColor(): Int {
-        val hue = (Math.random() * 360).toFloat()
-        return Color.HSVToColor(floatArrayOf(hue, 0.70f, 0.80f))
+        nextHue = (nextHue + 137.5f) % 360f
+        return Color.HSVToColor(floatArrayOf(nextHue, 0.72f, 0.82f))
     }
 }
diff --git a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
index a9cf5dd..bbaaba4 100644
--- a/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
+++ b/app/src/main/java/net/fuzzyworld/haunt/debug/PointsAdapter.kt
@@ -1,13 +1,34 @@
 package net.fuzzyworld.haunt.debug
 
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
 import android.view.LayoutInflater
+import android.view.View
 import android.view.ViewGroup
+import android.widget.GridLayout
+import androidx.appcompat.app.AlertDialog
 import androidx.recyclerview.widget.RecyclerView
 import net.fuzzyworld.haunt.databinding.ItemDebugPointBinding
 
+private val PRESET_COLORS = listOf(
+    Color.HSVToColor(floatArrayOf(0f,   0.75f, 0.90f)), // red
+    Color.HSVToColor(floatArrayOf(25f,  0.85f, 0.90f)), // orange
+    Color.HSVToColor(floatArrayOf(50f,  0.80f, 0.85f)), // yellow
+    Color.HSVToColor(floatArrayOf(90f,  0.70f, 0.80f)), // lime
+    Color.HSVToColor(floatArrayOf(130f, 0.70f, 0.75f)), // green
+    Color.HSVToColor(floatArrayOf(165f, 0.70f, 0.78f)), // teal
+    Color.HSVToColor(floatArrayOf(195f, 0.75f, 0.85f)), // cyan
+    Color.HSVToColor(floatArrayOf(215f, 0.75f, 0.90f)), // sky
+    Color.HSVToColor(floatArrayOf(240f, 0.70f, 0.90f)), // blue
+    Color.HSVToColor(floatArrayOf(270f, 0.70f, 0.85f)), // purple
+    Color.HSVToColor(floatArrayOf(310f, 0.70f, 0.85f)), // magenta
+    Color.HSVToColor(floatArrayOf(340f, 0.75f, 0.90f)), // pink
+)
+
 class PointsAdapter(
     private val onRename: (id: String, newName: String) -> Unit,
     private val onDelete: (id: String) -> Unit,
+    private val onRecolor: (id: String, color: Int) -> Unit,
 ) : RecyclerView.Adapter<PointsAdapter.ViewHolder>() {
 
     private var points: List<DebugPoint> = emptyList()
@@ -36,6 +57,7 @@ class PointsAdapter(
                 if (!hasFocus) onRename(point.id, b.nameEdit.text.toString().trim().ifEmpty { point.name })
             }
             b.deleteButton.setOnClickListener { onDelete(point.id) }
+            b.colorSwatch.setOnClickListener { showColorPicker(point) }
 
             val x = axisBssids.getOrNull(0)?.let { point.readings[it] }
             val y = axisBssids.getOrNull(1)?.let { point.readings[it] }
@@ -45,5 +67,44 @@ class PointsAdapter(
                 else -> ""
             }
         }
+
+        private fun showColorPicker(point: DebugPoint) {
+            val ctx = b.root.context
+            val dp = ctx.resources.displayMetrics.density
+            val chipSize = (48 * dp).toInt()
+            val chipMargin = (8 * dp).toInt()
+
+            val grid = GridLayout(ctx).apply {
+                columnCount = 4
+                setPadding(chipMargin, chipMargin, chipMargin, chipMargin)
+            }
+
+            var dialog: AlertDialog? = null
+            PRESET_COLORS.forEach { color ->
+                val chip = View(ctx).apply {
+                    background = GradientDrawable().apply {
+                        shape = GradientDrawable.OVAL
+                        setColor(color)
+                        if (color == point.color) setStroke((3 * dp).toInt(), Color.BLACK)
+                    }
+                    setOnClickListener {
+                        onRecolor(point.id, color)
+                        dialog?.dismiss()
+                    }
+                }
+                val lp = GridLayout.LayoutParams().apply {
+                    width = chipSize; height = chipSize
+                    setMargins(chipMargin, chipMargin, chipMargin, chipMargin)
+                }
+                grid.addView(chip, lp)
+            }
+
+            dialog = AlertDialog.Builder(ctx)
+                .setTitle("Pick a color")
+                .setView(grid)
+                .setNegativeButton("Cancel", null)
+                .create()
+            dialog.show()
+        }
     }
 }