summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-13 20:16:13 +0000
committerClaudomator Agent <agent@claudomator>2026-03-13 20:16:13 +0000
commitcc8e0c0221e2730b05b85f7577e0f7ebe93c43e2 (patch)
tree0062b17dedaf3116c66a30785389d7be3f476ce6 /android-app
parent92bbfd909d621a0dcdfbbd25164cb0431c0b449d (diff)
Implement polar performance diagram visualization
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt320
-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
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml27
4 files changed, 958 insertions, 21 deletions
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
index 5a91a7a..f1f8c4d 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/MainActivity.kt
@@ -2,11 +2,14 @@ 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
@@ -17,7 +20,17 @@ 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
@@ -26,6 +39,13 @@ 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,
@@ -40,6 +60,19 @@ class MainActivity : AppCompatActivity() {
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
@@ -64,7 +97,20 @@ class MainActivity : AppCompatActivity() {
private lateinit var valueSog: TextView
private lateinit var valueVmg: TextView
private lateinit var valueDepth: TextView
- private lateinit var valuePolarPct: 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.
@@ -76,6 +122,7 @@ class MainActivity : AppCompatActivity() {
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()
@@ -94,18 +141,22 @@ class MainActivity : AppCompatActivity() {
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
requestPermissionLauncher.launch(arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
- Manifest.permission.ACCESS_COARSE_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 ->
- maplibreMap.setStyle(Style.Builder().fromUri("https://tiles.openseamap.org/seamark/osm-bright/style.json"))
+ 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)
@@ -127,7 +178,54 @@ class MainActivity : AppCompatActivity() {
valueSog = findViewById(R.id.value_sog)
valueVmg = findViewById(R.id.value_vmg)
valueDepth = findViewById(R.id.value_depth)
- valuePolarPct = findViewById(R.id.value_polar_pct)
+ // 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(
@@ -156,11 +254,173 @@ class MainActivity : AppCompatActivity() {
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 ->
@@ -187,6 +447,51 @@ class MainActivity : AppCompatActivity() {
}
}
+ 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 {
@@ -207,8 +512,11 @@ class MainActivity : AppCompatActivity() {
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
@@ -242,6 +550,8 @@ class MainActivity : AppCompatActivity() {
// 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.")
@@ -324,4 +634,4 @@ class MainActivity : AppCompatActivity() {
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
new file mode 100644
index 0000000..395b80f
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/PolarData.kt
@@ -0,0 +1,229 @@
+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
new file mode 100644
index 0000000..36e7071
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/PolarDiagramView.kt
@@ -0,0 +1,403 @@
+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
+ }
+}
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index 3df0645..4f38772 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -209,23 +209,18 @@
app:layout_constraintEnd_toStartOf="@+id/guideline_vertical_66"
app:layout_constraintHorizontal_bias="0.5" />
- <!-- Polar % Instrument -->
- <TextView
- android:id="@+id/label_polar_pct"
- style="@style/InstrumentLabel"
- android:text="@string/instrument_label_polar_pct"
- app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
- app:layout_constraintTop_toTopOf="@+id/guideline_horizontal_50"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="0.5" />
- <TextView
- android:id="@+id/value_polar_pct"
- style="@style/InstrumentPrimaryValue"
- tools:text="---"
- app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66"
- app:layout_constraintTop_toBottomOf="@+id/label_polar_pct"
+ <!-- Polar Diagram View -->
+ <com.example.androidapp.PolarDiagramView
+ android:id="@+id/polar_diagram_view"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:layout_margin="16dp"
+ app:layout_constraintDimensionRatio="1:1"
+ app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="0.5" />
+ app:layout_constraintTop_toBottomOf="@+id/label_vmg"
+ app:layout_constraintBottom_toBottomOf="parent"
+ />
</androidx.constraintlayout.widget.ConstraintLayout>