summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/org/terst
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-13 23:04:02 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-13 23:04:12 +0000
commit7f89b6d4d0bc4996c0f1802f81abcc23ce47c221 (patch)
tree2725b968ef4a15dee09f51ce676b5481fba35705 /android-app/app/src/main/kotlin/org/terst
parent3c4e18b94db15fc0d012e12aa3be0d0557f6ad3c (diff)
refactor: update package name to org.terst.nav and setup CI/CD with Firebase App Distribution
Diffstat (limited to 'android-app/app/src/main/kotlin/org/terst')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt108
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt254
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt673
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt229
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt403
6 files changed, 1689 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt
new file mode 100644
index 0000000..d4423db
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/AnchorAlarmManager.kt
@@ -0,0 +1,108 @@
+package org.terst.nav
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.media.AudioAttributes
+import android.media.RingtoneManager
+import android.net.Uri
+import android.os.Build
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager // For API 31+
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+
+class AnchorAlarmManager(private val context: Context) {
+
+ private val CHANNEL_ID = "anchor_alarm_channel"
+ private val NOTIFICATION_ID = 1001
+
+ private var isAlarming: Boolean = false
+ private var ringtone: android.media.Ringtone? = null
+
+ init {
+ createNotificationChannel()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val name = "Anchor Alarm"
+ val descriptionText = "Notifications for anchor drag events"
+ val importance = NotificationManager.IMPORTANCE_HIGH
+ val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
+ description = descriptionText
+ }
+ val notificationManager: NotificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun getVibrator(): Vibrator? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
+ vibratorManager.defaultVibrator
+ } else {
+ context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+ }
+ }
+
+ fun startAlarm() {
+ if (isAlarming) return
+
+ isAlarming = true
+ // Play sound
+ try {
+ val alarmUri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
+ ringtone = RingtoneManager.getRingtone(context, alarmUri)
+ ringtone?.audioAttributes = AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_ALARM)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
+ .build()
+ ringtone?.play()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+
+ // Vibrate
+ val vibrator = getVibrator()
+ if (vibrator?.hasVibrator() == true) {
+ val pattern = longArrayOf(0, 1000, 1000) // Start immediately, vibrate for 1s, pause for 1s
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(VibrationEffect.createWaveform(pattern, 0)) // Repeat indefinitely
+ } else {
+ vibrator.vibrate(pattern, 0) // Repeat indefinitely
+ }
+ }
+
+ // Show persistent notification
+ showNotification("Anchor Drag Detected!", "Your boat is outside the watch circle.")
+ }
+
+ fun stopAlarm() {
+ if (!isAlarming) return
+
+ isAlarming = false
+ ringtone?.stop()
+ getVibrator()?.cancel()
+ NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
+ }
+
+ private fun showNotification(title: String, message: String) {
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_dialog_alert) // Replace with a proper icon
+ .setContentTitle(title)
+ .setContentText(message)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setOngoing(true) // Makes the notification persistent
+ .setAutoCancel(false) // Does not disappear when tapped
+ .setDefaults(NotificationCompat.DEFAULT_ALL) // Use default sound, vibrate, light (though we manually control sound/vibration)
+
+ with(NotificationManagerCompat.from(context)) {
+ notify(NOTIFICATION_ID, builder.build())
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
new file mode 100644
index 0000000..03e6a2f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
@@ -0,0 +1,22 @@
+package org.terst.nav
+
+import android.location.Location
+
+data class AnchorWatchState(
+ val anchorLocation: Location? = null,
+ val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
+ val setTimeMillis: Long = 0L,
+ val isActive: Boolean = false
+) {
+ companion object {
+ const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters
+ }
+
+ fun isDragging(currentLocation: Location): Boolean {
+ anchorLocation ?: return false // Cannot drag if anchor not set
+ if (!isActive) return false // Not active, so not dragging
+
+ val distance = anchorLocation.distanceTo(currentLocation)
+ return distance > watchCircleRadiusMeters
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
new file mode 100644
index 0000000..4b59139
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -0,0 +1,254 @@
+package org.terst.nav
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.location.Location
+import android.os.IBinder
+import android.os.Looper
+import androidx.core.app.NotificationCompat
+import com.google.android.gms.location.*
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import android.util.Log
+import kotlinx.coroutines.tasks.await
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+data class GpsData(
+ val latitude: Double,
+ val longitude: Double,
+ val speedOverGround: Float, // m/s
+ val courseOverGround: Float // degrees
+) {
+ fun toLocation(): Location {
+ val location = Location("GpsData")
+ location.latitude = latitude
+ location.longitude = longitude
+ location.speed = speedOverGround
+ location.bearing = courseOverGround
+ return location
+ }
+}
+
+class LocationService : Service() {
+
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var locationCallback: LocationCallback
+ private lateinit var anchorAlarmManager: AnchorAlarmManager
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
+ private val NOTIFICATION_ID = 123
+
+ private var isAlarmTriggered = false // To prevent repeated alarm triggering
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d("LocationService", "Service created")
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
+ createNotificationChannel()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(locationResult: LocationResult) {
+ locationResult.lastLocation?.let { location ->
+ val gpsData = GpsData(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedOverGround = location.speed,
+ courseOverGround = location.bearing
+ )
+ serviceScope.launch {
+ _locationFlow.emit(gpsData) // Emit to shared flow
+ }
+
+
+ // Check for anchor drag if anchor watch is active
+ _anchorWatchState.update { currentState ->
+ if (currentState.isActive && currentState.anchorLocation != null) {
+ val isDragging = currentState.isDragging(location)
+ if (isDragging) {
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (!isAlarmTriggered) {
+ anchorAlarmManager.startAlarm()
+ isAlarmTriggered = true
+ }
+ } else {
+ Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+ }
+ } else {
+ // If anchor watch is not active, ensure alarm is stopped
+ if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+ }
+ currentState
+ }
+ }
+ }
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START_FOREGROUND_SERVICE -> {
+ Log.d("LocationService", "Starting foreground service")
+ startForeground(NOTIFICATION_ID, createNotification())
+ startLocationUpdatesInternal()
+ }
+ ACTION_STOP_FOREGROUND_SERVICE -> {
+ Log.d("LocationService", "Stopping foreground service")
+ stopLocationUpdatesInternal()
+ stopSelf()
+ }
+ ACTION_START_ANCHOR_WATCH -> {
+ Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH")
+ val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
+ serviceScope.launch { startAnchorWatch(radius) }
+ }
+ ACTION_STOP_ANCHOR_WATCH -> {
+ Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
+ stopAnchorWatch()
+ }
+ ACTION_UPDATE_WATCH_RADIUS -> {
+ Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS")
+ val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
+ updateWatchCircleRadius(radius)
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null // Not a bound service
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Log.d("LocationService", "Service destroyed")
+ stopLocationUpdatesInternal()
+ anchorAlarmManager.stopAlarm()
+ _anchorWatchState.value = AnchorWatchState(isActive = false)
+ isAlarmTriggered = false // Reset alarm trigger state
+ serviceScope.cancel() // Cancel the coroutine scope
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun startLocationUpdatesInternal() {
+ Log.d("LocationService", "Requesting location updates")
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
+ .setMinUpdateIntervalMillis(500)
+ .build()
+ fusedLocationClient.requestLocationUpdates(
+ locationRequest,
+ locationCallback,
+ Looper.getMainLooper()
+ )
+ }
+
+ private fun stopLocationUpdatesInternal() {
+ Log.d("LocationService", "Removing location updates")
+ fusedLocationClient.removeLocationUpdates(locationCallback)
+ }
+
+ private fun createNotificationChannel() {
+ val serviceChannel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ "Location Service Channel",
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService(NotificationManager::class.java) as NotificationManager
+ manager.createNotificationChannel(serviceChannel)
+ }
+
+ private fun createNotification(): Notification {
+ val notificationIntent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ notificationIntent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Sailing Companion")
+ .setContentText("Tracking your location in the background...")
+ .setSmallIcon(R.drawable.ic_anchor)
+ .setContentIntent(pendingIntent)
+ .build()
+ }
+
+ /**
+ * Starts the anchor watch with the current location as the anchor point.
+ * @param radiusMeters The watch circle radius in meters.
+ */
+ @SuppressLint("MissingPermission")
+ suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) {
+ val lastLocation = fusedLocationClient.lastLocation.await()
+ lastLocation?.let { location ->
+ _anchorWatchState.update { AnchorWatchState(
+ anchorLocation = location,
+ watchCircleRadiusMeters = radiusMeters,
+ setTimeMillis = System.currentTimeMillis(),
+ isActive = true
+ ) }
+ Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m")
+ } ?: run {
+ Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.")
+ // Handle error, e.g., show a toast to the user
+ }
+ }
+
+ /**
+ * Stops the anchor watch.
+ */
+ fun stopAnchorWatch() {
+ _anchorWatchState.update { AnchorWatchState(isActive = false) }
+ Log.i("AnchorWatch", "Anchor watch stopped.")
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
+ }
+
+ /**
+ * Updates the watch circle radius.
+ */
+ fun updateWatchCircleRadius(radiusMeters: Double) {
+ _anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) }
+ Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
+ }
+
+ companion object {
+ const val ACTION_START_FOREGROUND_SERVICE = "ACTION_START_FOREGROUND_SERVICE"
+ const val ACTION_STOP_FOREGROUND_SERVICE = "ACTION_STOP_FOREGROUND_SERVICE"
+ const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH"
+ const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
+ const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
+ const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
+
+ // Publicly accessible flows
+ val locationFlow: SharedFlow<GpsData>
+ get() = _locationFlow
+ val anchorWatchState: StateFlow<AnchorWatchState>
+ get() = _anchorWatchState
+
+ private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
+ private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
new file mode 100644
index 0000000..ccdf32f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -0,0 +1,673 @@
+package org.terst.nav
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
+import android.location.Location
+import android.media.MediaPlayer
+import android.os.Build
+import android.os.Bundle
+import android.content.Intent
+import android.util.Log
+import android.view.View
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import org.maplibre.android.MapLibre
+import org.maplibre.android.maps.MapView
+import org.maplibre.android.maps.MapLibreMap
+import org.maplibre.android.maps.Style
+import org.maplibre.android.style.layers.CircleLayer
+import org.maplibre.android.style.layers.PropertyFactory
+import org.maplibre.android.style.layers.SymbolLayer
+import org.maplibre.android.style.sources.GeoJsonSource
+import com.mapbox.geojson.Feature
+import com.mapbox.geojson.FeatureCollection
+import com.mapbox.geojson.Point
+import com.mapbox.geojson.Polygon
+import com.mapbox.geojson.LineString
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+//import kotlinx.coroutines.tasks.await // Removed as we're no longer directly accessing FusedLocationProviderClient
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+import kotlin.math.atan2
+import kotlin.math.toDegrees
+import kotlin.math.toRadians
+
+data class MobWaypoint(
+ val latitude: Double,
+ val longitude: Double,
+ val timestamp: Long // System.currentTimeMillis()
+)
+
+class MainActivity : AppCompatActivity() {
+
+ private var mapView: MapView? = null
+ private lateinit var instrumentDisplayContainer: ConstraintLayout
+ private lateinit var fabToggleInstruments: FloatingActionButton
+ private lateinit var fabMob: FloatingActionButton
+
+ // MapLibreMap instance
+ private var maplibreMap: MapLibreMap? = null
+
+ // MapLibre Layers and Sources for Anchor Watch
+ private val ANCHOR_POINT_SOURCE_ID = "anchor-point-source"
+ private val ANCHOR_CIRCLE_SOURCE_ID = "anchor-circle-source"
+ private val ANCHOR_POINT_LAYER_ID = "anchor-point-layer"
+ private val ANCHOR_CIRCLE_LAYER_ID = "anchor-circle-layer"
+ private val ANCHOR_ICON_ID = "anchor-icon"
+
+ private var anchorPointSource: GeoJsonSource? = null
+ private var anchorCircleSource: GeoJsonSource? = null
+
+ // MOB UI elements
+ private lateinit var mobNavigationContainer: ConstraintLayout
+ private lateinit var mobValueDistance: TextView
+ private lateinit var mobValueElapsedTime: TextView
+ private lateinit var mobRecoveredButton: Button
+
+ // Removed direct locationService instance
+ // private lateinit var locationService: LocationService
+
+ // MOB State
+ private var mobActivated: Boolean = false
+ private var activeMobWaypoint: MobWaypoint? = null
+
+ // Media player for MOB alarm
+ private var mobMediaPlayer: MediaPlayer? = null
+
+ // Instrument TextViews
+ private lateinit var valueAws: TextView
+ private lateinit var valueTws: TextView
+ private lateinit var valueHdg: TextView
+ private lateinit var valueCog: TextView
+ private lateinit var valueBsp: TextView
+ private lateinit var valueSog: TextView
+ private lateinit var valueVmg: TextView
+ private lateinit var valueDepth: TextView
+ // Removed valuePolarPct as it's now handled by the PolarDiagramView
+ private lateinit var polarDiagramView: PolarDiagramView // Reference to the custom view
+
+ // Anchor Watch UI elements
+ private lateinit var fabAnchor: FloatingActionButton
+ private lateinit var anchorConfigContainer: ConstraintLayout
+ private lateinit var anchorStatusText: TextView
+ private lateinit var anchorRadiusText: TextView
+ private lateinit var buttonDecreaseRadius: Button
+ private lateinit var buttonIncreaseRadius: Button
+ private lateinit var buttonSetAnchor: Button
+ private lateinit var buttonStopAnchor: Button
+
+ private var currentWatchCircleRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
+
+ // Register the permissions callback, which handles the user's response to the
+ // system permissions dialog.
+ private val requestPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ val fineLocationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true
+ val coarseLocationGranted = permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
+ val backgroundLocationGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions[Manifest.permission.ACCESS_BACKGROUND_LOCATION] == true
+ } else true // Not needed below Android 10
+
+ if (fineLocationGranted && coarseLocationGranted && backgroundLocationGranted) {
+ // Permissions granted, start location service and observe updates
+ Toast.makeText(this, "Location permissions granted", Toast.LENGTH_SHORT).show()
+ startLocationService()
+ observeLocationUpdates() // Start observing location updates
+ observeAnchorWatchState() // Start observing anchor watch state
+ } else {
+ // Permissions denied, handle the case (e.g., show a message to the user)
+ Toast.makeText(this, "Location permissions denied", Toast.LENGTH_LONG).show()
+ Log.e("MainActivity", "Location permissions denied by user.")
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // MapLibre access token only needed for Mapbox styles, but good practice to initialize
+ MapLibre.getInstance(this)
+ setContentView(R.layout.activity_main)
+
+ val permissionsToRequest = mutableListOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissionsToRequest.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
+ }
+
+ // Check and request location permissions
+ val allPermissionsGranted = permissionsToRequest.all {
+ ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED
+ }
+
+ if (!allPermissionsGranted) {
+ requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
+ } else {
+ // Permissions already granted, start location service
+ startLocationService()
+ observeLocationUpdates() // Start observing location updates
+ observeAnchorWatchState() // Start observing anchor watch state
+ }
+
+ mapView = findViewById(R.id.mapView)
+ mapView?.onCreate(savedInstanceState)
+ mapView?.getMapAsync { maplibreMap ->
+ this.maplibreMap = maplibreMap // Assign to class member
+ maplibreMap.setStyle(Style.Builder().fromUri("https://tiles.openseamap.org/seamark/osm-bright/style.json")) { style ->
+ setupAnchorMapLayers(style)
+ }
+ }
+
+ instrumentDisplayContainer = findViewById(R.id.instrument_display_container)
+ fabToggleInstruments = findViewById(R.id.fab_toggle_instruments)
+ fabMob = findViewById(R.id.fab_mob)
+
+ // Initialize MOB UI elements
+ mobNavigationContainer = findViewById(R.id.mob_navigation_container)
+ mobValueDistance = findViewById(R.id.mob_value_distance)
+ mobValueElapsedTime = findViewById(R.id.mob_value_elapsed_time)
+ mobRecoveredButton = findViewById(R.id.mob_recovered_button)
+
+ // Initialize instrument TextViews
+ valueAws = findViewById(R.id.value_aws)
+ valueTws = findViewById(R.id.value_tws)
+ valueHdg = findViewById(R.id.value_hdg)
+ valueCog = findViewById(R.id.value_cog)
+ valueBsp = findViewById(R.id.value_bsp)
+ valueSog = findViewById(R.id.value_sog)
+ valueVmg = findViewById(R.id.value_vmg)
+ valueDepth = findViewById(R.id.value_depth)
+ // Removed initialization for valuePolarPct
+
+ // Initialize PolarDiagramView
+ polarDiagramView = findViewById(R.id.polar_diagram_view)
+
+ // Set up mock polar data
+ val mockPolarTable = createMockPolarTable()
+ polarDiagramView.setPolarTable(mockPolarTable)
+
+ // Simulate real-time updates for the polar diagram
+ lifecycleScope.launch {
+ var simulatedTws = 8.0
+ var simulatedTwa = 40.0
+ var simulatedBsp = mockPolarTable.interpolateBsp(simulatedTws, simulatedTwa)
+
+ while (true) {
+ // Update instrument display with current simulated values
+ updateInstrumentDisplay(
+ aws = "%.1f".format(Locale.getDefault(), simulatedTws * 1.1), // AWS usually higher than TWS
+ tws = "%.1f".format(Locale.getDefault(), simulatedTws),
+ hdg = "---", // No mock for HDG
+ cog = "---", // No mock for COG
+ bsp = "%.1f".format(Locale.getDefault(), simulatedBsp),
+ sog = "%.1f".format(Locale.getDefault(), simulatedBsp * 0.95), // SOG usually slightly less than BSP
+ vmg = "%.1f".format(Locale.getDefault(), mockPolarTable.curves.firstOrNull { it.twS == simulatedTws }?.calculateVmg(simulatedTwa, simulatedBsp) ?: 0.0),
+ depth = getString(R.string.placeholder_depth_value),
+ polarPct = "%.0f%%".format(Locale.getDefault(), mockPolarTable.calculatePolarPercentage(simulatedTws, simulatedTwa, simulatedBsp))
+ )
+ polarDiagramView.setCurrentPerformance(simulatedTws, simulatedTwa, simulatedBsp)
+
+ // Slowly change TWA to simulate sailing
+ simulatedTwa += 0.5 // Change by 0.5 degrees
+ if (simulatedTwa > 170) simulatedTwa = 40.0 // Reset or change direction
+ simulatedBsp = mockPolarTable.interpolateBsp(simulatedTws, simulatedTwa)
+
+ kotlinx.coroutines.delay(1000) // Update every second
+ }
+ }
+
+ // Initialize Anchor Watch UI elements
+ fabAnchor = findViewById(R.id.fab_anchor)
+ anchorConfigContainer = findViewById(R.id.anchor_config_container)
+ anchorStatusText = findViewById(R.id.anchor_status_text)
+ anchorRadiusText = findViewById(R.id.anchor_radius_text)
+ buttonDecreaseRadius = findViewById(R.id.button_decrease_radius)
+ buttonIncreaseRadius = findViewById(R.id.button_increase_radius)
+ buttonSetAnchor = findViewById(R.id.button_set_anchor)
+ buttonStopAnchor = findViewById(R.id.button_stop_anchor)
+
+ // Set initial placeholder values
+ updateInstrumentDisplay(
+ aws = getString(R.string.placeholder_aws_value),
+ tws = getString(R.string.placeholder_tws_value),
+ hdg = getString(R.string.placeholder_hdg_value),
+ cog = getString(R.string.placeholder_cog_value),
+ bsp = getString(R.string.placeholder_bsp_value),
+ sog = getString(R.string.placeholder_sog_value),
+ vmg = getString(R.string.placeholder_vmg_value),
+ depth = getString(R.string.placeholder_depth_value),
+ polarPct = getString(R.string.placeholder_polar_value)
+ )
+
+ fabToggleInstruments.setOnClickListener {
+ if (instrumentDisplayContainer.visibility == View.VISIBLE) {
+ instrumentDisplayContainer.visibility = View.GONE
+ mapView?.visibility = View.VISIBLE
+ } else {
+ instrumentDisplayContainer.visibility = View.VISIBLE
+ mapView?.visibility = View.GONE
+ }
+ }
+
+ fabMob.setOnClickListener {
+ activateMob()
+ }
+
+ fabAnchor.setOnClickListener {
+ if (anchorConfigContainer.visibility == View.VISIBLE) {
+ anchorConfigContainer.visibility = View.GONE
+ } else {
+ anchorConfigContainer.visibility = View.VISIBLE
+ // Ensure anchor radius display is updated when shown
+ anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
+ }
+ }
+
+ buttonDecreaseRadius.setOnClickListener {
+ currentWatchCircleRadius = (currentWatchCircleRadius - 5).coerceAtLeast(10.0) // Minimum 10m
+ anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_UPDATE_WATCH_RADIUS
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
+ }
+ startService(intent)
+ }
+
+ buttonIncreaseRadius.setOnClickListener {
+ currentWatchCircleRadius = (currentWatchCircleRadius + 5).coerceAtMost(200.0) // Maximum 200m
+ anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_UPDATE_WATCH_RADIUS
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
+ }
+ startService(intent)
+ }
+
+ buttonSetAnchor.setOnClickListener {
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_START_ANCHOR_WATCH
+ putExtra(LocationService.EXTRA_WATCH_RADIUS, currentWatchCircleRadius)
+ }
+ startService(intent)
+ Toast.makeText(this@MainActivity, "Anchor watch set!", Toast.LENGTH_SHORT).show()
+ }
+
+ buttonStopAnchor.setOnClickListener {
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_STOP_ANCHOR_WATCH
+ }
+ startService(intent)
+ Toast.makeText(this@MainActivity, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
+ }
+
+ mobRecoveredButton.setOnClickListener {
+ recoverMob()
+ }
+ }
+
+ private fun startLocationService() {
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_START_FOREGROUND_SERVICE
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent)
+ } else {
+ startService(intent)
+ }
+ }
+
+ private fun stopLocationService() {
+ val intent = Intent(this, LocationService::class.java).apply {
+ action = LocationService.ACTION_STOP_FOREGROUND_SERVICE
+ }
+ stopService(intent)
+ }
+
+ private fun createMockPolarTable(): PolarTable {
+ // Example polar data for a hypothetical boat
+ // TWS 6 knots
+ val polar6k = PolarCurve(
+ twS = 6.0,
+ points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 3.0),
+ PolarPoint(tWa = 45.0, bSp = 4.0),
+ PolarPoint(tWa = 60.0, bSp = 4.5),
+ PolarPoint(tWa = 90.0, bSp = 4.8),
+ PolarPoint(tWa = 120.0, bSp = 4.0),
+ PolarPoint(tWa = 150.0, bSp = 3.0),
+ PolarPoint(tWa = 180.0, bSp = 2.0)
+ )
+ )
+
+ // TWS 8 knots
+ val polar8k = PolarCurve(
+ twS = 8.0,
+ points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 4.0),
+ PolarPoint(tWa = 45.0, bSp = 5.0),
+ PolarPoint(tWa = 60.0, bSp = 5.5),
+ PolarPoint(tWa = 90.0, bSp = 5.8),
+ PolarPoint(tWa = 120.0, bSp = 5.0),
+ PolarPoint(tWa = 150.0, bSp = 4.0),
+ PolarPoint(tWa = 180.0, bSp = 2.5)
+ )
+ )
+
+ // TWS 10 knots
+ val polar10k = PolarCurve(
+ twS = 10.0,
+ points = listOf(
+ PolarPoint(tWa = 30.0, bSp = 5.0),
+ PolarPoint(tWa = 45.0, bSp = 6.0),
+ PolarPoint(tWa = 60.0, bSp = 6.5),
+ PolarPoint(tWa = 90.0, bSp = 6.8),
+ PolarPoint(tWa = 120.0, bSp = 6.0),
+ PolarPoint(tWa = 150.0, bSp = 4.5),
+ PolarPoint(tWa = 180.0, bSp = 3.0)
+ )
+ )
+
+ return PolarTable(curves = listOf(polar6k, polar8k, polar10k))
+ }
+
+
+ private fun setupAnchorMapLayers(style: Style) {
+ // Add anchor icon
+ style.addImage(ANCHOR_ICON_ID, BitmapFactory.decodeResource(resources, R.drawable.ic_anchor))
+
+ // Create sources
+ anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID, FeatureCollection.fromFeatures(emptyList()))
+ anchorCircleSource = GeoJsonSource(ANCHOR_CIRCLE_SOURCE_ID, FeatureCollection.fromFeatures(emptyList()))
+ style.addSource(anchorPointSource!!)
+ style.addSource(anchorCircleSource!!)
+
+ // Create layers
+ val anchorPointLayer = SymbolLayer(ANCHOR_POINT_LAYER_ID, ANCHOR_POINT_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.iconImage(ANCHOR_ICON_ID),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconIgnorePlacement(true)
+ )
+ }
+ val anchorCircleLayer = CircleLayer(ANCHOR_CIRCLE_LAYER_ID, ANCHOR_CIRCLE_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.circleRadius(PropertyFactory.zoom().toExpression()), // Radius will be handled dynamically or by GeoJSON property
+ PropertyFactory.circleColor(ContextCompat.getColor(this@MainActivity, R.color.anchor_button_background)),
+ PropertyFactory.circleOpacity(0.3f),
+ PropertyFactory.circleStrokeWidth(2.0f),
+ PropertyFactory.circleStrokeColor(ContextCompat.getColor(this@MainActivity, R.color.anchor_button_background))
+ )
+ }
+
+ style.addLayer(anchorCircleLayer)
+ style.addLayer(anchorPointLayer)
+ }
+
+ private fun updateAnchorMapLayers(state: AnchorWatchState) {
+ maplibreMap?.getStyle { style ->
+ if (state.isActive && state.anchorLocation != null) {
+ // Update anchor point
+ val anchorPoint = Point.fromLngLat(state.anchorLocation.longitude, state.anchorLocation.latitude)
+ anchorPointSource?.setGeoJson(Feature.fromGeometry(anchorPoint))
+
+ // Update watch circle
+ val watchCirclePolygon = createWatchCirclePolygon(anchorPoint, state.watchCircleRadiusMeters)
+ anchorCircleSource?.setGeoJson(Feature.fromGeometry(watchCirclePolygon))
+
+ // Set layer visibility to visible
+ style.getLayer(ANCHOR_POINT_LAYER_ID)?.setProperties(PropertyFactory.visibility(PropertyFactory.visibility(PropertyFactory.VISIBLE)))
+ style.getLayer(ANCHOR_CIRCLE_LAYER_ID)?.setProperties(PropertyFactory.visibility(PropertyFactory.visibility(PropertyFactory.VISIBLE)))
+ } else {
+ // Clear sources and hide layers
+ anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ style.getLayer(ANCHOR_POINT_LAYER_ID)?.setProperties(PropertyFactory.visibility(PropertyFactory.visibility(PropertyFactory.NONE)))
+ style.getLayer(ANCHOR_CIRCLE_LAYER_ID)?.setProperties(PropertyFactory.visibility(PropertyFactory.visibility(PropertyFactory.NONE)))
+ }
+ }
+ }
+
+ // Helper function to create a GeoJSON Polygon for a circle
+ private fun createWatchCirclePolygon(center: Point, radiusMeters: Double, steps: Int = 64): Polygon {
+ val coordinates = mutableListOf<Point>()
+ val earthRadius = 6371000.0 // Earth's radius in meters
+
+ for (i in 0..steps) {
+ val angle = 2 * Math.PI * i / steps
+ val lat = center.latitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * cos(angle)
+ val lon = center.longitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * sin(angle) / cos(toRadians(center.latitude()))
+ coordinates.add(Point.fromLngLats(lon, lat))
+ }
+ return Polygon.fromLngLats(listOf(coordinates))
+ }
+
+ private fun observeLocationUpdates() {
+ lifecycleScope.launch {
+ // Observe from the static locationFlow in LocationService
+ LocationService.locationFlow.distinctUntilChanged().collect { gpsData ->
+ if (mobActivated && activeMobWaypoint != null) {
+ val mobLocation = Location("").apply {
+ latitude = activeMobWaypoint!!.latitude
+ longitude = activeMobWaypoint!!.longitude
+ }
+ val currentPosition = Location("").apply {
+ latitude = gpsData.latitude
+ longitude = gpsData.longitude
+ }
+
+ val distance = currentPosition.distanceTo(mobLocation) // distance in meters
+ val elapsedTime = System.currentTimeMillis() - activeMobWaypoint!!.timestamp
+
+ withContext(Dispatchers.Main) {
+ mobValueDistance.text = String.format(Locale.getDefault(), "%.1f m", distance)
+ mobValueElapsedTime.text = formatElapsedTime(elapsedTime)
+ // TODO: Update bearing arrow (requires custom view or rotation logic)
+ }
+ }
+ }
+ }
+ }
+
+ private fun observeAnchorWatchState() {
+ lifecycleScope.launch {
+ // Observe from the static anchorWatchState in LocationService
+ LocationService.anchorWatchState.collect { state ->
+ withContext(Dispatchers.Main) {
+ updateAnchorMapLayers(state) // Update map layers
+ if (state.isActive && state.anchorLocation != null) {
+ currentWatchCircleRadius = state.watchCircleRadiusMeters
+ anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
+
+ // Get the current location from the static flow
+ val currentLocation = LocationService.locationFlow.firstOrNull()?.toLocation()
+ if (currentLocation != null) {
+ val distance = state.anchorLocation.distanceTo(currentLocation)
+ val distanceDiff = distance - state.watchCircleRadiusMeters
+ if (distanceDiff > 0) {
+ anchorStatusText.text = String.format(
+ Locale.getDefault(),
+ getString(R.string.anchor_active_dragging_format),
+ state.anchorLocation.latitude,
+ state.anchorLocation.longitude,
+ state.watchCircleRadiusMeters,
+ distance,
+ distanceDiff
+ )
+ anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_alarm))
+ } else {
+ anchorStatusText.text = String.format(
+ Locale.getDefault(),
+ getString(R.string.anchor_active_format),
+ state.anchorLocation.latitude,
+ state.anchorLocation.longitude,
+ state.watchCircleRadiusMeters,
+ distance,
+ -distanceDiff // distance FROM limit
+ )
+ anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
+ }
+ } else {
+ anchorStatusText.text = "Anchor watch active (waiting for location...)"
+ anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
+ }
+ } else {
+ anchorStatusText.text = getString(R.string.anchor_inactive)
+ anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
+ }
+ }
+ }
+ }
+ }
+
+ private fun activateMob() {
+ // Get last known location from the static flow
+ lifecycleScope.launch {
+ val lastGpsData: GpsData? = LocationService.locationFlow.firstOrNull()
+ if (lastGpsData != null) {
+ activeMobWaypoint = MobWaypoint(
+ latitude = lastGpsData.latitude,
+ longitude = lastGpsData.longitude,
+ timestamp = System.currentTimeMillis()
+ )
+ mobActivated = true
+ Log.d("MainActivity", "MOB Activated! Location: ${activeMobWaypoint!!.latitude}, ${activeMobWaypoint!!.longitude} at ${activeMobWaypoint!!.timestamp}")
+ Toast.makeText(this@MainActivity, "MOB Activated!", Toast.LENGTH_SHORT).show()
+
+ // Switch display to MOB navigation view
+ mapView?.visibility = View.GONE
+ instrumentDisplayContainer.visibility = View.GONE
+ fabToggleInstruments.visibility = View.GONE
+ fabMob.visibility = View.GONE
+ anchorConfigContainer.visibility = View.GONE // Hide anchor config
+ fabAnchor.visibility = View.GONE // Hide anchor FAB
+ mobNavigationContainer.visibility = View.VISIBLE
+
+
+ // Sound continuous alarm
+ mobMediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm).apply {
+ isLooping = true
+ start()
+ }
+
+ // Log event to logbook
+ logMobEvent(activeMobWaypoint!!)
+ } else {
+ Toast.makeText(this@MainActivity, "Could not get current location for MOB", Toast.LENGTH_SHORT).show()
+ Log.e("MainActivity", "Last known location is null, cannot activate MOB.")
+ }
+ }
+ }
+
+ private fun recoverMob() {
+ mobActivated = false
+ activeMobWaypoint = null
+ stopMobAlarm()
+
+ mobNavigationContainer.visibility = View.GONE
+ mapView?.visibility = View.VISIBLE
+ // instrumentDisplayContainer visibility is controlled by fabToggleInstruments, so leave as is
+ fabToggleInstruments.visibility = View.VISIBLE
+ fabMob.visibility = View.VISIBLE
+ fabAnchor.visibility = View.VISIBLE // Show anchor FAB
+ anchorConfigContainer.visibility = View.GONE // Hide anchor config
+
+ Toast.makeText(this, "MOB Recovery initiated.", Toast.LENGTH_SHORT).show()
+ Log.d("MainActivity", "MOB Recovery initiated.")
+ }
+
+ private fun stopMobAlarm() {
+ mobMediaPlayer?.stop()
+ mobMediaPlayer?.release()
+ mobMediaPlayer = null
+ Log.d("MainActivity", "MOB Alarm stopped and released.")
+ }
+
+ private fun logMobEvent(mobWaypoint: MobWaypoint) {
+ Log.i("Logbook", "MOB Event: Lat ${mobWaypoint.latitude}, Lon ${mobWaypoint.longitude}, Time ${mobWaypoint.timestamp}")
+ // TODO: Integrate with actual logbook system for persistence
+ }
+
+
+ private fun formatElapsedTime(milliseconds: Long): String {
+ val hours = TimeUnit.MILLISECONDS.toHours(milliseconds)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(milliseconds) % 60
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(milliseconds) % 60
+ return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds)
+ }
+
+ private fun updateInstrumentDisplay(
+ aws: String,
+ tws: String,
+ hdg: String,
+ cog: String,
+ bsp: String,
+ sog: String,
+ vmg: String,
+ depth: String,
+ polarPct: String
+ ) {
+ valueAws.text = aws
+ valueTws.text = tws
+ valueHdg.text = hdg
+ valueCog.text = cog
+ valueBsp.text = bsp
+ valueSog.text = sog
+ valueVmg.text = vmg
+ valueDepth.text = depth
+ valuePolarPct.text = polarPct
+ }
+
+ override fun onStart() {
+ super.onStart()
+ mapView?.onStart()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ mapView?.onResume()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ mapView?.onPause()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ mapView?.onStop()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ mapView?.onSaveInstanceState(outState)
+ }
+
+ override fun onLowMemory() {
+ super.onLowMemory()
+ mapView?.onLowMemory()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ mapView?.onDestroy()
+ mobMediaPlayer?.release() // Ensure media player is released on destroy
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt
new file mode 100644
index 0000000..9624607
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt
@@ -0,0 +1,229 @@
+package org.terst.nav
+
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.toRadians
+
+// Represents a single point on a polar curve: True Wind Angle and target Boat Speed
+data class PolarPoint(val tWa: Double, val bSp: Double)
+
+// Represents a polar curve for a specific True Wind Speed
+data class PolarCurve(val twS: Double, val points: List<PolarPoint>) {
+ init {
+ // Ensure points are sorted by TWA for correct interpolation
+ require(points.sortedBy { it.tWa } == points) {
+ "PolarPoints in a PolarCurve must be sorted by TWA."
+ }
+ }
+
+ /**
+ * Interpolates the target Boat Speed (BSP) for a given True Wind Angle (TWA)
+ * within this specific polar curve (constant TWS).
+ * Uses linear interpolation.
+ *
+ * @param tWa The True Wind Angle in degrees.
+ * @return The interpolated Boat Speed (BSP) in knots, or 0.0 if outside the defined TWA range.
+ */
+ fun interpolateBspForTwa(tWa: Double): Double {
+ if (points.isEmpty()) return 0.0
+ if (tWa < points.first().tWa || tWa > points.last().tWa) {
+ // Extrapolate linearly if outside of range to avoid returning 0.0,
+ // or clamp to nearest value. For now, clamp to nearest.
+ return when {
+ tWa < points.first().tWa -> points.first().bSp
+ tWa > points.last().tWa -> points.last().bSp
+ else -> 0.0 // Should not happen with above checks
+ }
+ }
+
+ // Find the two points that bracket the given TWA
+ val p2 = points.firstOrNull { it.tWa >= tWa } ?: return 0.0
+ val p1 = points.lastOrNull { it.tWa < tWa } ?: return p2.bSp // If tWa is less than first point, return first point's BSP
+
+ if (p1.tWa == p2.tWa) return p1.bSp // Should only happen if tWa exactly matches a point or only one point exists
+
+ // Linear interpolation: BSP = BSP1 + (TWA - TWA1) * (BSP2 - BSP1) / (TWA2 - TWA1)
+ return p1.bSp + (tWa - p1.tWa) * (p2.bSp - p1.bSp) / (p2.tWa - p1.tWa)
+ }
+
+ /**
+ * Calculates the Velocity Made Good (VMG) for a given TWA and BSP.
+ * VMG = BSP * cos(TWA) when TWA is relative to the wind (0=upwind, 180=downwind).
+ * In this context, TWA is the angle off the wind, so abs(TWA - 180) for downwind, abs(TWA) for upwind
+ * For optimal VMG calculations, we consider the angle to the wind direction.
+ * We'll use the absolute TWA for simplicity assuming the diagram shows absolute TWA off the wind axis.
+ */
+ fun calculateVmg(tWa: Double, bSp: Double): Double {
+ // TWA is in degrees, convert to radians.
+ // VMG is the component of speed in the direction of the wind (or directly opposite).
+ // For upwind, smaller TWA means more directly into the wind, so VMG = BSP * cos(TWA)
+ // For downwind, TWA closer to 180 means more directly downwind, so VMG = BSP * cos(180 - TWA)
+ // Given that TWA in polars is usually 0-180 degrees (one side of the boat),
+ // we can simplify by taking the cosine of the angle to 0 or 180.
+ // For upwind VMG, we want to maximize BSP * cos(TWA).
+ // For downwind VMG, we want to maximize BSP * cos(abs(TWA - 180)).
+ val angleToWind = if (tWa <= 90) tWa else (180 - tWa)
+ return bSp * cos(toRadians(angleToWind))
+ }
+
+ /**
+ * Finds the TWA that yields the maximum upwind VMG for this polar curve.
+ */
+ fun findOptimalUpwindTwa(): Double {
+ if (points.isEmpty()) return 0.0
+ var maxVmg = -Double.MAX_VALUE
+ var optimalTwa = 0.0
+
+ // Iterate through small angle increments for better precision
+ // Consider angles typically used for upwind sailing (e.g., 20 to 50 degrees)
+ for (twaDeg in 20..50) { // Typical upwind range
+ val bsp = interpolateBspForTwa(twaDeg.toDouble())
+ val vmg = calculateVmg(twaDeg.toDouble(), bsp)
+ if (vmg > maxVmg) {
+ maxVmg = vmg
+ optimalTwa = twaDeg.toDouble()
+ }
+ }
+ return optimalTwa
+ }
+
+ /**
+ * Finds the TWA that yields the maximum downwind VMG for this polar curve.
+ */
+ fun findOptimalDownwindTwa(): Double {
+ if (points.isEmpty()) return 0.0
+ var maxVmg = -Double.MAX_VALUE
+ var optimalTwa = 0.0
+
+ // Iterate through small angle increments for better precision
+ // Consider angles typically used for downwind sailing (e.g., 130 to 170 degrees)
+ for (twaDeg in 130..170) { // Typical downwind range
+ val bsp = interpolateBspForTwa(twaDeg.toDouble())
+ val vmg = calculateVmg(twaDeg.toDouble(), bsp)
+ if (vmg > maxVmg) {
+ maxVmg = vmg
+ optimalTwa = twaDeg.toDouble()
+ }
+ }
+ return optimalTwa
+ }
+}
+
+// Represents the complete polar table for a boat, containing multiple PolarCurves for different TWS
+data class PolarTable(val curves: List<PolarCurve>) {
+ init {
+ // Ensure curves are sorted by TWS for correct interpolation
+ require(curves.sortedBy { it.twS } == curves) {
+ "PolarCurves in a PolarTable must be sorted by TWS."
+ }
+ }
+
+ /**
+ * Interpolates the target Boat Speed (BSP) for a given True Wind Speed (TWS)
+ * and True Wind Angle (TWA) using bi-linear interpolation.
+ *
+ * @param twS The True Wind Speed in knots.
+ * @param tWa The True Wind Angle in degrees.
+ * @return The interpolated Boat Speed (BSP) in knots, or 0.0 if outside defined ranges.
+ */
+ fun interpolateBsp(twS: Double, tWa: Double): Double {
+ if (curves.isEmpty()) return 0.0
+
+ val twsCurves = curves.filter { curve ->
+ curve.points.any { it.tWa >= tWa } && curve.points.any { it.tWa <= tWa }
+ }
+
+ if (twsCurves.isEmpty()) return 0.0
+
+ // Find the two curves that bracket the given TWS
+ val curve2 = twsCurves.firstOrNull { it.twS >= twS }
+ val curve1 = twsCurves.lastOrNull { it.twS < twS }
+
+ return when {
+ curve1 == null && curve2 != null -> curve2.interpolateBspForTwa(tWa) // Below first TWS, use first curve
+ curve1 != null && curve2 == null -> curve1.interpolateBspForTwa(tWa) // Above last TWS, use last curve
+ curve1 != null && curve2 != null && curve1.twS == curve2.twS -> curve1.interpolateBspForTwa(tWa) // Exact TWS match or only one curve available
+ curve1 != null && curve2 != null -> {
+ // Bi-linear interpolation
+ val bsp1 = curve1.interpolateBspForTwa(tWa)
+ val bsp2 = curve2.interpolateBspForTwa(tWa)
+
+ // BSP = BSP1 + (TWS - TWS1) * (BSP2 - BSP1) / (TWS2 - TWS1)
+ bsp1 + (twS - curve1.twS) * (bsp2 - bsp1) / (curve2.twS - curve1.twS)
+ }
+ else -> 0.0 // No suitable curves found
+ }
+ }
+
+ /**
+ * Calculates the "Polar Percentage" for current boat performance.
+ * This is (current_BSP / target_BSP) * 100.
+ *
+ * @param currentTwS Current True Wind Speed.
+ * @param currentTwa Current True Wind Angle.
+ * @param currentBsp Current Boat Speed.
+ * @return Polar percentage, or 0.0 if target BSP cannot be determined.
+ */
+ fun calculatePolarPercentage(currentTwS: Double, currentTwa: Double, currentBsp: Double): Double {
+ val targetBsp = interpolateBsp(currentTwS, currentTwa)
+ return if (targetBsp > 0.1) { // Avoid division by zero or near-zero target
+ (currentBsp / targetBsp) * 100.0
+ } else {
+ 0.0
+ }
+ }
+
+ /**
+ * Finds the TWA that yields the maximum upwind VMG for a given TWS.
+ */
+ fun findOptimalUpwindTwa(twS: Double): Double {
+ val twsCurves = curves.filter { curve ->
+ curve.points.isNotEmpty()
+ }
+ if (twsCurves.isEmpty()) return 0.0
+
+ val curve2 = twsCurves.firstOrNull { it.twS >= twS }
+ val curve1 = twsCurves.lastOrNull { it.twS < twS }
+
+ return when {
+ curve1 == null && curve2 != null -> curve2.findOptimalUpwindTwa()
+ curve1 != null && curve2 == null -> curve1.findOptimalUpwindTwa()
+ curve1 != null && curve2 != null && curve1.twS == curve2.twS -> curve1.findOptimalUpwindTwa()
+ curve1 != null && curve2 != null -> {
+ // Interpolate optimal TWA
+ val optTwa1 = curve1.findOptimalUpwindTwa()
+ val optTwa2 = curve2.findOptimalUpwindTwa()
+ optTwa1 + (twS - curve1.twS) * (optTwa2 - optTwa1) / (curve2.twS - curve1.twS)
+ }
+ else -> 0.0
+ }
+ }
+
+ /**
+ * Finds the TWA that yields the maximum downwind VMG for a given TWS.
+ */
+ fun findOptimalDownwindTwa(twS: Double): Double {
+ val twsCurves = curves.filter { curve ->
+ curve.points.isNotEmpty()
+ }
+ if (twsCurves.isEmpty()) return 0.0
+
+ val curve2 = twsCurves.firstOrNull { it.twS >= twS }
+ val curve1 = twsCurves.lastOrNull { it.twS < twS }
+
+ return when {
+ curve1 == null && curve2 != null -> curve2.findOptimalDownwindTwa()
+ curve1 != null && curve2 == null -> curve1.findOptimalDownwindTwa()
+ curve1 != null && curve2 != null && curve1.twS == curve2.twS -> curve1.findOptimalDownwindTwa()
+ curve1 != null && curve2 != null -> {
+ // Interpolate optimal TWA
+ val optTwa1 = curve1.findOptimalDownwindTwa()
+ val optTwa2 = curve2.findOptimalDownwindTwa()
+ optTwa1 + (twS - curve1.twS) * (optTwa2 - optTwa1) / (curve2.twS - curve1.twS)
+ }
+ else -> 0.0
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt
new file mode 100644
index 0000000..a794ed5
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt
@@ -0,0 +1,403 @@
+package org.terst.nav
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.util.AttributeSet
+import android.view.View
+import kotlin.math.cos
+import kotlin.math.min
+import kotlin.math.sin
+import kotlin.math.toRadians
+
+class PolarDiagramView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : View(context, attrs, defStyleAttr) {
+
+ private val gridPaint = Paint().apply {
+ color = Color.parseColor("#404040") // Dark gray for grid lines
+ style = Paint.Style.STROKE
+ strokeWidth = 1f
+ isAntiAlias = true
+ }
+
+ private val textPaint = Paint().apply {
+ color = Color.WHITE
+ textSize = 24f
+ isAntiAlias = true
+ textAlign = Paint.Align.CENTER
+ }
+
+ private val polarCurvePaint = Paint().apply {
+ color = Color.CYAN // Bright color for the polar curve
+ style = Paint.Style.STROKE
+ strokeWidth = 3f
+ isAntiAlias = true
+ }
+
+ private val currentPerformancePaint = Paint().apply {
+ color = Color.RED // Red dot for current performance
+ style = Paint.Style.FILL
+ isAntiAlias = true
+ }
+
+ private val noSailZonePaint = Paint().apply {
+ color = Color.parseColor("#80FF0000") // Semi-transparent red for no-sail zone
+ style = Paint.Style.FILL
+ isAntiAlias = true
+ }
+
+ private val optimalVmgPaint = Paint().apply {
+ color = Color.GREEN // Green for optimal VMG angles
+ style = Paint.Style.STROKE
+ strokeWidth = 4f
+ isAntiAlias = true
+ }
+
+ private var viewCenterX: Float = 0f
+ private var viewCenterY: Float = 0f
+ private var radius: Float = 0f
+
+ // Data for rendering
+ private var polarTable: PolarTable? = null
+ private var currentTws: Double = 0.0
+ private var currentTwa: Double = 0.0
+ private var currentBsp: Double = 0.0
+
+ // Configuration for the diagram
+ private val maxSpeedKnots = 10.0 // Max speed for the outermost circle in knots
+ private val speedCircleInterval = 2.0 // Interval between speed circles in knots
+ private val twaInterval = 30 // Interval between TWA radial lines in degrees
+ private val noSailZoneAngle = 20.0 // Angle +/- from 0 degrees for no-sail zone
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ viewCenterX = w / 2f
+ viewCenterY = h / 2f
+ radius = min(w, h) / 2f * 0.9f // Use 90% of the minimum dimension for radius
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ // Draw basic diagram elements
+ drawGrid(canvas)
+ drawTwaLabels(canvas)
+ drawNoSailZone(canvas)
+
+ // Draw polar curve if data is available
+ polarTable?.let {
+ drawPolarCurve(canvas, it, currentTws)
+ drawOptimalVmgAngles(canvas, it, currentTws) // Draw optimal VMG angles
+ }
+
+ // Draw current performance if data is available and not zero
+ if (currentTws > 0 && currentTwa > 0 && currentBsp > 0) {
+ drawCurrentPerformance(canvas, currentTwa, currentBsp)
+ }
+ }
+
+ private fun drawGrid(canvas: Canvas) {
+ // Draw TWA radial lines (0 to 360 degrees)
+ for (i in 0 until 360 step twaInterval) {
+ val angleRad = toRadians(i.toDouble())
+ val x = viewCenterX + radius * cos(angleRad).toFloat()
+ val y = viewCenterY + radius * sin(angleRad).toFloat()
+ canvas.drawLine(viewCenterX, viewCenterY, x, y, gridPaint)
+ }
+
+ // Draw speed circles
+ for (i in 0..maxSpeedKnots.toInt() step speedCircleInterval.toInt()) {
+ val currentRadius = (i / maxSpeedKnots * radius).toFloat()
+ canvas.drawCircle(viewCenterX, viewCenterY, currentRadius, gridPaint)
+ }
+ }
+
+ private fun drawTwaLabels(canvas: Canvas) {
+ // Draw TWA labels around the perimeter
+ for (i in 0 until 360 step twaInterval) {
+ val displayAngleRad = toRadians(i.toDouble())
+ // Position the text slightly outside the outermost circle
+ val textX = viewCenterX + (radius + 40) * cos(displayAngleRad).toFloat()
+ // Adjust textY to account for text height, so it's centered vertically on the arc
+ val textY = viewCenterY + (radius + 40) * sin(displayAngleRad).toFloat() + (textPaint.textSize / 3)
+
+ // Map canvas angle (0=right, 90=down) to polar diagram angle (0=up, 90=right)
+ // Example: canvas 270 is polar 0, canvas 0 is polar 90, canvas 90 is polar 180, canvas 180 is polar 270
+ val polarAngle = ( (i + 90) % 360 )
+ canvas.drawText(polarAngle.toString(), textX, textY, textPaint)
+ }
+
+ // Draw speed labels on the horizontal axis
+ for (i in 0..maxSpeedKnots.toInt() step speedCircleInterval.toInt()) {
+ if (i > 0) {
+ val currentRadius = (i / maxSpeedKnots * radius).toFloat()
+ // Left side
+ canvas.drawText(i.toString(), viewCenterX - currentRadius - 10, viewCenterY + (textPaint.textSize / 3), textPaint)
+ // Right side
+ canvas.drawText(i.toString(), viewCenterX + currentRadius + 10, viewCenterY + (textPaint.textSize / 3), textPaint)
+ }
+ }
+ }
+
+ private fun drawNoSailZone(canvas: Canvas) {
+ // The no-sail zone is typically symmetric around the wind direction (0 TWA, which is 'up' on our diagram)
+ // In canvas coordinates, 'up' is -90 degrees or 270 degrees.
+ // So the arc will be centered around 270 degrees.
+ val startAngle = (270 - noSailZoneAngle).toFloat()
+ val sweepAngle = (2 * noSailZoneAngle).toFloat()
+
+ val oval = RectF(viewCenterX - radius, viewCenterY - radius, viewCenterX + radius, viewCenterY + radius)
+ canvas.drawArc(oval, startAngle, sweepAngle, true, noSailZonePaint)
+ }
+
+
+ private fun drawPolarCurve(canvas: Canvas, polarTable: PolarTable, tws: Double) {
+ val path = android.graphics.Path()
+ var firstPoint = true
+
+ // Iterate TWA from 0 to 180 for one side, and then mirror it for the other side.
+ // TWA 0 is upwind (canvas 270 deg)
+ // TWA 90 is beam (canvas 0/360 or 180 deg)
+ // TWA 180 is downwind (canvas 90 deg)
+
+ // Generate points for 0 to 180 TWA (starboard side)
+ for (twa in 0..180) {
+ val bsp = polarTable.interpolateBsp(tws, twa.toDouble())
+ if (bsp > 0) {
+ // Map TWA to canvas angle for the starboard side (0 TWA at 270, 90 TWA at 0, 180 TWA at 90)
+ val canvasAngle = (270 + twa).toDouble() % 360
+ val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
+ val x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ val y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+
+ if (firstPoint) {
+ path.moveTo(x, y)
+ firstPoint = false
+ } else {
+ path.lineTo(x, y)
+ }
+ }
+ }
+
+ // Generate points for 0 to -180 TWA (port side) by mirroring
+ // Start from 180 back to 0 to connect the curve
+ for (twa in 180 downTo 0) {
+ val bsp = polarTable.interpolateBsp(tws, twa.toDouble())
+ if (bsp > 0) {
+ // Map negative TWA to canvas angle for the port side (0 TWA at 270, -90 TWA at 180, -180 TWA at 90)
+ val canvasAngle = (270 - twa).toDouble() // This maps TWA 0 to 270, TWA 90 to 180, TWA 180 to 90
+ val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
+ val x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ val y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+
+ path.lineTo(x, y) // Continue drawing the path
+ }
+ }
+ canvas.drawPath(path, polarCurvePaint)
+ }
+
+
+ private fun drawCurrentPerformance(canvas: Canvas, twa: Double, bsp: Double) {
+ // Map TWA to canvas angle.
+ // Assuming TWA is provided as 0-180 (absolute angle off wind).
+ // If actual TWA (e.g., -30, 30) is passed, adjust accordingly.
+ // For drawing, we need a full 0-360 angle to represent actual boat heading relative to wind.
+ // Let's assume positive TWA is starboard and negative TWA is port.
+ val canvasAngle = if (twa >= 0) {
+ (270 + twa).toDouble() % 360 // Starboard side
+ } else {
+ (270 + twa).toDouble() // Port side (e.g., -30 TWA is 240 canvas angle)
+ }
+
+ val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
+ val x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ val y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+
+ canvas.drawCircle(x, y, 10f, currentPerformancePaint) // Draw a small circle for current performance
+ }
+
+ private fun drawOptimalVmgAngles(canvas: Canvas, polarTable: PolarTable, tws: Double) {
+ // Find optimal upwind TWA
+ val optimalUpwindTwa = polarTable.findOptimalUpwindTwa(tws)
+ if (optimalUpwindTwa > 0) {
+ // Draw a line indicating the optimal upwind TWA (both port and starboard)
+ val upwindBsp = polarTable.interpolateBsp(tws, optimalUpwindTwa)
+ val currentRadius = (upwindBsp / maxSpeedKnots * radius).toFloat() * 1.05f // Slightly longer
+
+ // Starboard side
+ var canvasAngle = (270 + optimalUpwindTwa).toDouble() % 360
+ var x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ var y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+ canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
+
+ // Port side
+ canvasAngle = (270 - optimalUpwindTwa).toDouble() // Use negative TWA for port side
+ x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+ canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
+ }
+
+ // Find optimal downwind TWA
+ val optimalDownwindTwa = polarTable.findOptimalDownwindTwa(tws)
+ if (optimalDownwindTwa > 0) {
+ // Draw a line indicating the optimal downwind TWA (both port and starboard)
+ val downwindBsp = polarTable.interpolateBsp(tws, optimalDownwindTwa)
+ val currentRadius = (downwindBsp / maxSpeedKnots * radius).toFloat() * 1.05f // Slightly longer
+
+ // Starboard side
+ var canvasAngle = (270 + optimalDownwindTwa).toDouble() % 360
+ var x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ var y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+ canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
+
+ // Port side
+ canvasAngle = (270 - optimalDownwindTwa).toDouble() // Use negative TWA for port side
+ x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+ canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint)
+ }
+ }
+
+ private fun drawGrid(canvas: Canvas) {
+ // Draw TWA radial lines (0 to 360 degrees)
+ for (i in 0 until 360 step twaInterval) {
+ val angleRad = toRadians(i.toDouble())
+ val x = viewCenterX + radius * cos(angleRad).toFloat()
+ val y = viewCenterY + radius * sin(angleRad).toFloat()
+ canvas.drawLine(viewCenterX, viewCenterY, x, y, gridPaint)
+ }
+
+ // Draw speed circles
+ for (i in 0..maxSpeedKnots.toInt() step speedCircleInterval.toInt()) {
+ val currentRadius = (i / maxSpeedKnots * radius).toFloat()
+ canvas.drawCircle(viewCenterX, viewCenterY, currentRadius, gridPaint)
+ }
+ }
+
+ private fun drawTwaLabels(canvas: Canvas) {
+ // Draw TWA labels around the perimeter
+ for (i in 0 until 360 step twaInterval) {
+ val displayAngleRad = toRadians(i.toDouble())
+ // Position the text slightly outside the outermost circle
+ val textX = viewCenterX + (radius + 40) * cos(displayAngleRad).toFloat()
+ // Adjust textY to account for text height, so it's centered vertically on the arc
+ val textY = viewCenterY + (radius + 40) * sin(displayAngleRad).toFloat() + (textPaint.textSize / 3)
+
+ // Map canvas angle (0=right, 90=down) to polar diagram angle (0=up, 90=right)
+ // Example: canvas 270 is polar 0, canvas 0 is polar 90, canvas 90 is polar 180, canvas 180 is polar 270
+ val polarAngle = ( (i + 90) % 360 )
+ canvas.drawText(polarAngle.toString(), textX, textY, textPaint)
+ }
+
+ // Draw speed labels on the horizontal axis
+ for (i in 0..maxSpeedKnots.toInt() step speedCircleInterval.toInt()) {
+ if (i > 0) {
+ val currentRadius = (i / maxSpeedKnots * radius).toFloat()
+ // Left side
+ canvas.drawText(i.toString(), viewCenterX - currentRadius - 10, viewCenterY + (textPaint.textSize / 3), textPaint)
+ // Right side
+ canvas.drawText(i.toString(), viewCenterX + currentRadius + 10, viewCenterY + (textPaint.textSize / 3), textPaint)
+ }
+ }
+ }
+
+ private fun drawNoSailZone(canvas: Canvas) {
+ // The no-sail zone is typically symmetric around the wind direction (0 TWA, which is 'up' on our diagram)
+ // In canvas coordinates, 'up' is -90 degrees or 270 degrees.
+ // So the arc will be centered around 270 degrees.
+ val startAngle = (270 - noSailZoneAngle).toFloat()
+ val sweepAngle = (2 * noSailZoneAngle).toFloat()
+
+ val oval = RectF(viewCenterX - radius, viewCenterY - radius, viewCenterX + radius, viewCenterY + radius)
+ canvas.drawArc(oval, startAngle, sweepAngle, true, noSailZonePaint)
+ }
+
+
+ private fun drawPolarCurve(canvas: Canvas, polarTable: PolarTable, tws: Double) {
+ val path = android.graphics.Path()
+ var firstPoint = true
+
+ // Iterate TWA from 0 to 180 for one side, and then mirror it for the other side.
+ // TWA 0 is upwind (canvas 270 deg)
+ // TWA 90 is beam (canvas 0/360 or 180 deg)
+ // TWA 180 is downwind (canvas 90 deg)
+
+ // Generate points for 0 to 180 TWA (starboard side)
+ for (twa in 0..180) {
+ val bsp = polarTable.interpolateBsp(tws, twa.toDouble())
+ if (bsp > 0) {
+ // Map TWA to canvas angle for the starboard side (0 TWA at 270, 90 TWA at 0, 180 TWA at 90)
+ val canvasAngle = (270 + twa).toDouble() % 360
+ val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
+ val x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ val y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+
+ if (firstPoint) {
+ path.moveTo(x, y)
+ firstPoint = false
+ } else {
+ path.lineTo(x, y)
+ }
+ }
+ }
+
+ // Generate points for 0 to -180 TWA (port side) by mirroring
+ // Start from 180 back to 0 to connect the curve
+ for (twa in 180 downTo 0) {
+ val bsp = polarTable.interpolateBsp(tws, twa.toDouble())
+ if (bsp > 0) {
+ // Map negative TWA to canvas angle for the port side (0 TWA at 270, -90 TWA at 180, -180 TWA at 90)
+ val canvasAngle = (270 - twa).toDouble() // This maps TWA 0 to 270, TWA 90 to 180, TWA 180 to 90
+ val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
+ val x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ val y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+
+ path.lineTo(x, y) // Continue drawing the path
+ }
+ }
+ canvas.drawPath(path, polarCurvePaint)
+ }
+
+
+ private fun drawCurrentPerformance(canvas: Canvas, twa: Double, bsp: Double) {
+ // Map TWA to canvas angle.
+ // Assuming TWA is provided as 0-180 (absolute angle off wind).
+ // If actual TWA (e.g., -30, 30) is passed, adjust accordingly.
+ // For drawing, we need a full 0-360 angle to represent actual boat heading relative to wind.
+ // Let's assume positive TWA is starboard and negative TWA is port.
+ val canvasAngle = if (twa >= 0) {
+ (270 + twa).toDouble() % 360 // Starboard side
+ } else {
+ (270 + twa).toDouble() // Port side (e.g., -30 TWA is 240 canvas angle)
+ }
+
+ val currentRadius = (bsp / maxSpeedKnots * radius).toFloat()
+ val x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat()
+ val y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat()
+
+ canvas.drawCircle(x, y, 10f, currentPerformancePaint) // Draw a small circle for current performance
+ }
+
+ /**
+ * Sets the polar table data for the view.
+ */
+ fun setPolarTable(table: PolarTable) {
+ this.polarTable = table
+ invalidate() // Redraw the view
+ }
+
+ /**
+ * Sets the current true wind speed, true wind angle, and boat speed.
+ */
+ fun setCurrentPerformance(tws: Double, twa: Double, bsp: Double) {
+ this.currentTws = tws
+ this.currentTwa = twa
+ this.currentBsp = bsp
+ invalidate() // Redraw the view
+ }
+}