summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/com/example
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main/kotlin/com/example')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/AnchorAlarmManager.kt108
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/AnchorWatchData.kt22
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/LocationService.kt142
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt637
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt229
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/PolarDiagramView.kt403
6 files changed, 0 insertions, 1541 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/AnchorAlarmManager.kt b/android-app/app/src/main/kotlin/com/example/androidapp/AnchorAlarmManager.kt
deleted file mode 100644
index 4b31719..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/AnchorAlarmManager.kt
+++ /dev/null
@@ -1,108 +0,0 @@
-package com.example.androidapp
-
-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/com/example/androidapp/AnchorWatchData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/AnchorWatchData.kt
deleted file mode 100644
index c7c13fd..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/AnchorWatchData.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.example.androidapp
-
-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/com/example/androidapp/LocationService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/LocationService.kt
deleted file mode 100644
index ca73397..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/LocationService.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package com.example.androidapp
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.location.Location
-import android.os.Looper
-import com.google.android.gms.location.*
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.flow.update
-import android.util.Log // Import Log for logging
-import kotlinx.coroutines.tasks.await // Import await for Task conversion
-
-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(private val context: Context) {
-
- private val fusedLocationClient: FusedLocationProviderClient =
- LocationServices.getFusedLocationProviderClient(context)
-
- // StateFlow to hold the current anchor watch state
- private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
- val anchorWatchState: StateFlow<AnchorWatchState> = _anchorWatchState
-
- // Anchor alarm manager
- private val anchorAlarmManager = AnchorAlarmManager(context)
- private var isAlarmTriggered = false // To prevent repeated alarm triggering
-
- @SuppressLint("MissingPermission") // Permissions handled by the calling component (Activity/Fragment)
- fun getLocationUpdates(): Flow<GpsData> = callbackFlow {
- val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
- .setMinUpdateIntervalMillis(500)
- .build()
-
- val 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
- )
- trySend(gpsData)
-
- // 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 // Return the current state (no change unless we explicitly want to update something here)
- }
- }
- }
- }
-
- fusedLocationClient.requestLocationUpdates(
- locationRequest,
- locationCallback,
- Looper.getMainLooper()
- )
-
- awaitClose {
- fusedLocationClient.removeLocationUpdates(locationCallback)
- }
- }
-
- /**
- * 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() // Using await() from kotlinx.coroutines.tasks
- lastLocation?.let { location ->
- _anchorWatchState.value = 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.value = AnchorWatchState(isActive = false)
- Log.i("AnchorWatch", "Anchor watch stopped.")
- anchorAlarmManager.stopAlarm() // Ensure alarm is stopped when anchor watch is explicitly stopped
- 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.")
- }
-}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt b/android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt
deleted file mode 100644
index f1f8c4d..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt
+++ /dev/null
@@ -1,637 +0,0 @@
-package com.example.androidapp
-
-import android.Manifest
-import android.content.pm.PackageManager
-import android.graphics.BitmapFactory
-import android.location.Location
-import android.media.MediaPlayer
-import android.os.Bundle
-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.launch
-import kotlinx.coroutines.withContext
-import java.util.Locale
-import java.util.concurrent.TimeUnit
-import kotlinx.coroutines.tasks.await
-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
-
- 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 ->
- if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true &&
- permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) {
- // Permissions granted, initialize location service and start updates
- Toast.makeText(this, "Location permissions granted", Toast.LENGTH_SHORT).show()
- locationService = LocationService(this)
- 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)
-
- // Check and request location permissions
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ||
- ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
- requestPermissionLauncher.launch(arrayOf(
- Manifest.permission.ACCESS_FINE_LOCATION,
- Manifest.PERMISSION_ACCESS_COARSE_LOCATION
- ))
- } else {
- // Permissions already granted, initialize location service
- locationService = LocationService(this)
- 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)
- if (::locationService.isInitialized) {
- locationService.updateWatchCircleRadius(currentWatchCircleRadius)
- }
- }
-
- buttonIncreaseRadius.setOnClickListener {
- currentWatchCircleRadius = (currentWatchCircleRadius + 5).coerceAtMost(200.0) // Maximum 200m
- anchorRadiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentWatchCircleRadius)
- if (::locationService.isInitialized) {
- locationService.updateWatchCircleRadius(currentWatchCircleRadius)
- }
- }
-
- buttonSetAnchor.setOnClickListener {
- if (::locationService.isInitialized) {
- lifecycleScope.launch {
- locationService.startAnchorWatch(currentWatchCircleRadius)
- Toast.makeText(this@MainActivity, "Anchor watch set!", Toast.LENGTH_SHORT).show()
- }
- } else {
- Toast.makeText(this, "Location service not initialized. Grant permissions first.", Toast.LENGTH_LONG).show()
- }
- }
-
- buttonStopAnchor.setOnClickListener {
- if (::locationService.isInitialized) {
- locationService.stopAnchorWatch()
- Toast.makeText(this@MainActivity, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
- }
- }
-
- mobRecoveredButton.setOnClickListener {
- recoverMob()
- }
- }
-
- 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.fromLngLat(lon, lat))
- }
- return Polygon.fromLngLats(listOf(coordinates))
- }
-
- private fun observeLocationUpdates() {
- lifecycleScope.launch {
- locationService.getLocationUpdates().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 {
- 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)
-
- locationService.fusedLocationClient.lastLocation.await()?.let { currentLocation ->
- 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 = getString(R.string.anchor_inactive)
- anchorStatusText.setTextColor(ContextCompat.getColor(this@MainActivity, R.color.instrument_text_normal))
- }
- }
- }
- }
- }
-
- private fun activateMob() {
- if (::locationService.isInitialized) {
- CoroutineScope(Dispatchers.Main).launch {
- try {
- val lastLocation: Location? = locationService.fusedLocationClient.lastLocation.await()
- if (lastLocation != null) {
- activeMobWaypoint = MobWaypoint(
- latitude = lastLocation.latitude,
- longitude = lastLocation.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.")
- }
- } catch (e: Exception) {
- Toast.makeText(this@MainActivity, "Error getting location for MOB: ${e.message}", Toast.LENGTH_LONG).show()
- Log.e("MainActivity", "Error getting location for MOB", e)
- }
- }
- } else {
- Toast.makeText(this, "Location service not initialized. Grant permissions first.", Toast.LENGTH_LONG).show()
- Log.e("MainActivity", "Location service not initialized when trying to 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
- }
-} \ No newline at end of file
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt
deleted file mode 100644
index 395b80f..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt
+++ /dev/null
@@ -1,229 +0,0 @@
-package com.example.androidapp
-
-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/com/example/androidapp/PolarDiagramView.kt b/android-app/app/src/main/kotlin/com/example/androidapp/PolarDiagramView.kt
deleted file mode 100644
index 36e7071..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/PolarDiagramView.kt
+++ /dev/null
@@ -1,403 +0,0 @@
-package com.example.androidapp
-
-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
- }
-}