summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt69
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt150
2 files changed, 219 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
new file mode 100644
index 0000000..fa68b63
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt
@@ -0,0 +1,69 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
+import android.util.AttributeSet
+import android.view.View
+
+/** Normalises a bearing in degrees to [0, 360). */
+fun normalizeBearing(deg: Float): Float = ((deg % 360f) + 360f) % 360f
+
+/**
+ * Small circular direction indicator — notched chevron pointing in [bearing] degrees
+ * (0 = north/up, clockwise). Two palettes: SKY (grey) and OCEAN (blue).
+ */
+class DirectionArrowView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ enum class ArrowStyle { SKY, OCEAN }
+
+ var bearing: Float = 0f
+ set(value) { field = normalizeBearing(value); invalidate() }
+
+ var arrowStyle: ArrowStyle = ArrowStyle.SKY
+ set(value) {
+ field = value
+ circlePaint.color = circleColor()
+ arrowPaint.color = arrowColor()
+ invalidate()
+ }
+
+ private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE; strokeWidth = 1.5f; color = circleColor()
+ }
+ private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL; color = arrowColor()
+ }
+ private val arrowPath = Path()
+
+ private fun circleColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#3A3640")
+ ArrowStyle.OCEAN -> Color.parseColor("#1E4A6E")
+ }
+ private fun arrowColor() = when (arrowStyle) {
+ ArrowStyle.SKY -> Color.parseColor("#9A94A0")
+ ArrowStyle.OCEAN -> Color.parseColor("#6FC3E8")
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ val cx = width / 2f; val cy = height / 2f
+ val r = (minOf(width, height) / 2f) - circlePaint.strokeWidth
+ canvas.drawCircle(cx, cy, r, circlePaint)
+ val tipY = cy - r * 0.72f; val baseY = cy + r * 0.50f
+ val notchY = cy + r * 0.22f; val halfW = r * 0.42f
+ arrowPath.reset()
+ arrowPath.moveTo(cx, tipY)
+ arrowPath.lineTo(cx - halfW, baseY)
+ arrowPath.lineTo(cx, notchY)
+ arrowPath.lineTo(cx + halfW, baseY)
+ arrowPath.close()
+ canvas.save(); canvas.rotate(bearing, cx, cy)
+ canvas.drawPath(arrowPath, arrowPaint); canvas.restore()
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
new file mode 100644
index 0000000..30aca06
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt
@@ -0,0 +1,150 @@
+package org.terst.nav.ui
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.Shader
+import android.os.SystemClock
+import android.util.AttributeSet
+import android.view.View
+import kotlin.math.sin
+
+/**
+ * Draws an animated ocean-horizon scene used as the divider between the
+ * instrument section and the forecast section.
+ *
+ * The primary wave is driven by swell height and period; a secondary
+ * high-frequency wave represents wind chop. Whitecaps appear at wind-wave
+ * crests. The view self-animates via [postInvalidateOnAnimation].
+ *
+ * Set [swellHeightFt], [swellPeriodSec], and [windWaveHeightFt] to update
+ * the wave state when new forecast data arrives.
+ */
+class WaveView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ var swellHeightFt: Float = 3f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ var swellPeriodSec: Float = 10f
+ set(value) { field = value.coerceAtLeast(1f); invalidate() }
+
+ var windWaveHeightFt: Float = 1.5f
+ set(value) { field = value.coerceAtLeast(0f); invalidate() }
+
+ private var startTimeMs = -1L
+
+ private val wavePath = Path()
+
+ private val skyPaint = Paint()
+ private val seaPaint = Paint()
+
+ private val shimmerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ color = Color.argb(77, 111, 195, 232)
+ }
+
+ private val whitecapPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeWidth = 1.5f
+ strokeCap = Paint.Cap.ROUND
+ color = Color.argb(128, 255, 255, 255)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ startTimeMs = SystemClock.elapsedRealtime()
+ postInvalidateOnAnimation()
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ skyPaint.shader = LinearGradient(
+ 0f, 0f, 0f, h * 0.6f,
+ Color.parseColor("#1C1B1F"),
+ Color.parseColor("#162433"),
+ Shader.TileMode.CLAMP
+ )
+ seaPaint.shader = LinearGradient(
+ 0f, h * 0.4f, 0f, h.toFloat(),
+ Color.parseColor("#0B3050"),
+ Color.parseColor("#0D2137"),
+ Shader.TileMode.CLAMP
+ )
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ if (startTimeMs < 0) startTimeMs = SystemClock.elapsedRealtime()
+ val t = (SystemClock.elapsedRealtime() - startTimeMs) / 1000.0 // seconds
+
+ val w = width.toFloat()
+ val h = height.toFloat()
+
+ // Amplitudes scale with wave data, capped relative to view height
+ val swellAmp = (h * (swellHeightFt / 28f)).coerceIn(h * 0.04f, h * 0.22f)
+ val windAmp = (h * (windWaveHeightFt / 28f)).coerceIn(h * 0.02f, h * 0.10f)
+ val swellWlen = w * (swellPeriodSec / 14f)
+ val windWlen = swellWlen * 0.35f
+ val midY = h * 0.52f
+
+ fun waveY(x: Float): Float =
+ (midY
+ + sin((x / swellWlen) * TWO_PI - t * 0.8) * swellAmp
+ + sin((x / windWlen) * TWO_PI - t * 1.8 + 1.2) * windAmp).toFloat()
+
+ // ── Sky fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, 0f)
+ wavePath.lineTo(0f, waveY(0f))
+ var x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, 0f)
+ wavePath.close()
+ canvas.drawPath(wavePath, skyPaint)
+
+ // ── Sea fill ──────────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, h)
+ wavePath.lineTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ wavePath.lineTo(w, h)
+ wavePath.close()
+ canvas.drawPath(wavePath, seaPaint)
+
+ // ── Shimmer line ──────────────────────────────────────────────
+ wavePath.reset()
+ wavePath.moveTo(0f, waveY(0f))
+ x = 2f
+ while (x <= w) { wavePath.lineTo(x, waveY(x)); x += 2f }
+ canvas.drawPath(wavePath, shimmerPaint)
+
+ // ── Whitecaps at wind-wave crests ─────────────────────────────
+ val capPath = Path()
+ x = windWlen * 0.5f
+ while (x <= w) {
+ val wv = sin((x / windWlen) * TWO_PI - t * 1.8 + 1.2) * windAmp
+ val wn = sin(((x + 3) / windWlen) * TWO_PI - t * 1.8 + 1.2) * windAmp
+ if (wv > windAmp * 0.55 && wv >= wn) {
+ val y = waveY(x)
+ capPath.reset()
+ capPath.moveTo(x - 7f, y + 1f)
+ capPath.quadTo(x, y - 2.5f, x + 8f, y + 1f)
+ canvas.drawPath(capPath, whitecapPaint)
+ }
+ x += windWlen * 0.9f
+ }
+
+ postInvalidateOnAnimation()
+ }
+
+ companion object {
+ private const val TWO_PI = Math.PI * 2.0
+ }
+}