diff options
Diffstat (limited to 'android-app/app/src/main/kotlin/com/example')
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 - } -} |
