diff options
Diffstat (limited to 'android-app/app')
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> |
