diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 05:25:04 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-04-06 05:25:04 +0000 |
| commit | 8004e7e05a68a2409ad0fdfc067936f9e2329067 (patch) | |
| tree | e924c953934dee08b36defb6c288ae57c063295b /android-app/app/src/main/kotlin/org | |
| parent | 6ea6bb39678dcc7c0e7b787286eba0f425c346d9 (diff) | |
feat(ui): add DirectionArrowView and WaveView custom views
DirectionArrowView: rotating notched-chevron compass indicator in
SKY (grey) and OCEAN (blue) palettes, with bearing normalization.
WaveView: animated swell + wind-chop canvas divider — sky/sea
gradient fills, shimmer line, whitecap highlights; self-animates
via postInvalidateOnAnimation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/main/kotlin/org')
| -rw-r--r-- | android-app/app/src/main/kotlin/org/terst/nav/ui/DirectionArrowView.kt | 69 | ||||
| -rw-r--r-- | android-app/app/src/main/kotlin/org/terst/nav/ui/WaveView.kt | 150 |
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 + } +} |
