diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-13 23:47:20 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-13 23:47:20 +0000 |
| commit | dde00c0f8e4571b6253a8c65598e1de7d905e742 (patch) | |
| tree | 2c896f997d4489e0df04a4057dbac51da2198bda /android-app/app/src/main | |
| parent | dea0c6d1e873eac30f9087e80026cf5127428fbf (diff) | |
fix: resolve MapLibre 11.x migration issues and layout/resource errors
Diffstat (limited to 'android-app/app/src/main')
9 files changed, 184 insertions, 323 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index ccdf32f..a32fb18 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -28,11 +28,11 @@ 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 org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.Point +import org.maplibre.geojson.Polygon +import org.maplibre.geojson.LineString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged @@ -42,13 +42,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Locale import java.util.concurrent.TimeUnit -//import kotlinx.coroutines.tasks.await // Removed as we're no longer directly accessing FusedLocationProviderClient import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt import kotlin.math.atan2 -import kotlin.math.toDegrees -import kotlin.math.toRadians data class MobWaypoint( val latitude: Double, @@ -82,9 +79,6 @@ class MainActivity : AppCompatActivity() { private lateinit var mobValueElapsedTime: TextView private lateinit var mobRecoveredButton: Button - // Removed direct locationService instance - // private lateinit var locationService: LocationService - // MOB State private var mobActivated: Boolean = false private var activeMobWaypoint: MobWaypoint? = null @@ -101,7 +95,7 @@ class MainActivity : AppCompatActivity() { 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 valuePolarPct: TextView private lateinit var polarDiagramView: PolarDiagramView // Reference to the custom view // Anchor Watch UI elements @@ -167,7 +161,7 @@ class MainActivity : AppCompatActivity() { observeAnchorWatchState() // Start observing anchor watch state } - mapView = findViewById(R.id.mapView) + mapView = findViewById<MapView>(R.id.mapView) mapView?.onCreate(savedInstanceState) mapView?.getMapAsync { maplibreMap -> this.maplibreMap = maplibreMap // Assign to class member @@ -195,7 +189,7 @@ class MainActivity : AppCompatActivity() { valueSog = findViewById(R.id.value_sog) valueVmg = findViewById(R.id.value_vmg) valueDepth = findViewById(R.id.value_depth) - // Removed initialization for valuePolarPct + valuePolarPct = findViewById(R.id.value_polar_pct) // Initialize PolarDiagramView polarDiagramView = findViewById(R.id.polar_diagram_view) @@ -394,8 +388,12 @@ class MainActivity : AppCompatActivity() { 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())) + anchorPointSource = GeoJsonSource(ANCHOR_POINT_SOURCE_ID) + anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>())) + + anchorCircleSource = GeoJsonSource(ANCHOR_CIRCLE_SOURCE_ID) + anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>())) + style.addSource(anchorPointSource!!) style.addSource(anchorCircleSource!!) @@ -409,7 +407,6 @@ class MainActivity : AppCompatActivity() { } 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), @@ -433,14 +430,14 @@ class MainActivity : AppCompatActivity() { 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))) + style.getLayer(ANCHOR_POINT_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.VISIBLE)) + style.getLayer(ANCHOR_CIRCLE_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.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))) + anchorPointSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>())) + anchorCircleSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList<Feature>())) + style.getLayer(ANCHOR_POINT_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.NONE)) + style.getLayer(ANCHOR_CIRCLE_LAYER_ID)?.setProperties(PropertyFactory.visibility(org.maplibre.android.style.layers.Property.NONE)) } } } @@ -452,9 +449,9 @@ class MainActivity : AppCompatActivity() { for (i in 0..steps) { val angle = 2 * Math.PI * i / steps - val lat = center.latitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * cos(angle) - val lon = center.longitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * sin(angle) / cos(toRadians(center.latitude())) - coordinates.add(Point.fromLngLats(lon, lat)) + val lat = center.latitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * Math.cos(angle) + val lon = center.longitude() + (radiusMeters / earthRadius) * (180 / Math.PI) * Math.sin(angle) / Math.cos(Math.toRadians(center.latitude())) + coordinates.add(Point.fromLngLat(lon, lat)) } return Polygon.fromLngLats(listOf(coordinates)) } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt index 9624607..88a8d0d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/PolarData.kt @@ -4,7 +4,6 @@ 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) @@ -12,78 +11,56 @@ 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) { + require(points.isNotEmpty()) { "PolarCurve must have at least one point." } + require(points.all { it.tWa in 0.0..180.0 }) { + "TWA in PolarCurve must be between 0 and 180 degrees." + } + require(points.zipWithNext().all { it.first.tWa < it.second.tWa }) { "PolarPoints in a PolarCurve must be sorted by TWA." } } /** - * Interpolates the target Boat Speed (BSP) for a given True Wind Angle (TWA) + * Interpolates the target boat speed 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 + fun interpolateBsp(twa: Double): Double { + val absoluteTwa = abs(twa) + if (absoluteTwa < points.first().tWa) return points.first().bSp + if (absoluteTwa > points.last().tWa) return points.last().bSp + + for (i in 0 until points.size - 1) { + val p1 = points[i] + val p2 = points[i + 1] + if (absoluteTwa >= p1.tWa && absoluteTwa <= p2.tWa) { + val ratio = (absoluteTwa - p1.tWa) / (p2.tWa - p1.tWa) + return p1.bSp + ratio * (p2.bSp - p1.bSp) } } - - // 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) + return 0.0 } /** * 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. + * VMG = BSP * cos(TWA) */ - 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)) + fun calculateVmg(twa: Double, bsp: Double): Double { + return bsp * cos(Math.toRadians(twa)) } /** * 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) + // Search through TWA 0 to 90 + for (twa in 0..90) { + val bsp = interpolateBsp(twa.toDouble()) + val vmg = calculateVmg(twa.toDouble(), bsp) if (vmg > maxVmg) { maxVmg = vmg - optimalTwa = twaDeg.toDouble() + optimalTwa = twa.toDouble() } } return optimalTwa @@ -93,18 +70,17 @@ data class PolarCurve(val twS: Double, val points: List<PolarPoint>) { * 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) { + var maxVmg = -Double.MAX_VALUE // We want the most negative VMG for downwind + var optimalTwa = 180.0 + // Search through TWA 90 to 180 + // For downwind, VMG is negative (moving away from wind) + // We look for the minimum value (largest absolute negative) + for (twa in 90..180) { + val bsp = interpolateBsp(twa.toDouble()) + val vmg = calculateVmg(twa.toDouble(), bsp) + if (vmg < maxVmg) { maxVmg = vmg - optimalTwa = twaDeg.toDouble() + optimalTwa = twa.toDouble() } } return optimalTwa @@ -114,116 +90,79 @@ data class PolarCurve(val twS: Double, val points: List<PolarPoint>) { // 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) { + require(curves.isNotEmpty()) { "PolarTable must have at least one curve." } + require(curves.zipWithNext().all { it.first.twS < it.second.twS }) { "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. + * Interpolates the target boat speed for a given True Wind Speed (TWS) and True Wind Angle (TWA). */ - 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) + fun interpolateBsp(tws: Double, twa: Double): Double { + if (tws <= curves.first().twS) return curves.first().interpolateBsp(twa) + if (tws >= curves.last().twS) return curves.last().interpolateBsp(twa) + + for (i in 0 until curves.size - 1) { + val c1 = curves[i] + val c2 = curves[i + 1] + if (tws >= c1.twS && tws <= c2.twS) { + val ratio = (tws - c1.twS) / (c2.twS - c1.twS) + val bsp1 = c1.interpolateBsp(twa) + val bsp2 = c2.interpolateBsp(twa) + return bsp1 + ratio * (bsp2 - bsp1) } - else -> 0.0 // No suitable curves found } + return 0.0 } /** - * 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. + * Finds the optimal upwind TWA for a given TWS by interpolating between curves. */ - 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 + fun findOptimalUpwindTwa(tws: Double): Double { + if (tws <= curves.first().twS) return curves.first().findOptimalUpwindTwa() + if (tws >= curves.last().twS) return curves.last().findOptimalUpwindTwa() + + for (i in 0 until curves.size - 1) { + val c1 = curves[i] + val c2 = curves[i + 1] + if (tws >= c1.twS && tws <= c2.twS) { + val ratio = (tws - c1.twS) / (c2.twS - c1.twS) + return c1.findOptimalUpwindTwa() + ratio * (c2.findOptimalUpwindTwa() - c1.findOptimalUpwindTwa()) + } } + return 0.0 } /** - * Finds the TWA that yields the maximum upwind VMG for a given TWS. + * Finds the optimal downwind TWA for a given TWS by interpolating between curves. */ - 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) + fun findOptimalDownwindTwa(tws: Double): Double { + if (tws <= curves.first().twS) return curves.first().findOptimalDownwindTwa() + if (tws >= curves.last().twS) return curves.last().findOptimalDownwindTwa() + + for (i in 0 until curves.size - 1) { + val c1 = curves[i] + val c2 = curves[i + 1] + if (tws >= c1.twS && tws <= c2.twS) { + val ratio = (tws - c1.twS) / (c2.twS - c1.twS) + return c1.findOptimalDownwindTwa() + ratio * (c2.findOptimalDownwindTwa() - c1.findOptimalDownwindTwa()) } - else -> 0.0 } + return 0.0 } /** - * Finds the TWA that yields the maximum downwind VMG for a given TWS. + * Calculates the "Polar Percentage" for current boat performance. + * Polar % = (Actual BSP / Target BSP) * 100 + * @return Polar percentage, or 0.0 if target BSP cannot be determined. */ - 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 + fun calculatePolarPercentage(currentTwS: Double, currentTwa: Double, currentBsp: Double): Double { + val targetBsp = interpolateBsp(currentTwS, currentTwa) + return if (targetBsp > 0) { + (currentBsp / targetBsp) * 100.0 + } else { + 0.0 } } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt b/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt index a794ed5..4a678cc 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/PolarDiagramView.kt @@ -10,7 +10,6 @@ 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, @@ -104,7 +103,7 @@ class PolarDiagramView @JvmOverloads constructor( 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 angleRad = Math.toRadians(i.toDouble()) val x = viewCenterX + radius * cos(angleRad).toFloat() val y = viewCenterY + radius * sin(angleRad).toFloat() canvas.drawLine(viewCenterX, viewCenterY, x, y, gridPaint) @@ -120,7 +119,7 @@ class PolarDiagramView @JvmOverloads constructor( private fun drawTwaLabels(canvas: Canvas) { // Draw TWA labels around the perimeter for (i in 0 until 360 step twaInterval) { - val displayAngleRad = toRadians(i.toDouble()) + val displayAngleRad = Math.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 @@ -155,16 +154,10 @@ class PolarDiagramView @JvmOverloads constructor( 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()) @@ -172,8 +165,8 @@ class PolarDiagramView @JvmOverloads constructor( // 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() + val x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat() + val y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat() if (firstPoint) { path.moveTo(x, y) @@ -192,8 +185,8 @@ class PolarDiagramView @JvmOverloads constructor( // 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() + val x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat() + val y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat() path.lineTo(x, y) // Continue drawing the path } @@ -201,13 +194,7 @@ class PolarDiagramView @JvmOverloads constructor( 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 { @@ -215,8 +202,8 @@ class PolarDiagramView @JvmOverloads constructor( } val currentRadius = (bsp / maxSpeedKnots * radius).toFloat() - val x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat() - val y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat() + val x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat() + val y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat() canvas.drawCircle(x, y, 10f, currentPerformancePaint) // Draw a small circle for current performance } @@ -231,14 +218,14 @@ class PolarDiagramView @JvmOverloads constructor( // Starboard side var canvasAngle = (270 + optimalUpwindTwa).toDouble() % 360 - var x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat() - var y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat() + var x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat() + var y = viewCenterY + currentRadius * sin(Math.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() + x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat() + y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat() canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint) } @@ -251,138 +238,18 @@ class PolarDiagramView @JvmOverloads constructor( // Starboard side var canvasAngle = (270 + optimalDownwindTwa).toDouble() % 360 - var x = viewCenterX + currentRadius * cos(toRadians(canvasAngle)).toFloat() - var y = viewCenterY + currentRadius * sin(toRadians(canvasAngle)).toFloat() + var x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat() + var y = viewCenterY + currentRadius * sin(Math.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() + x = viewCenterX + currentRadius * cos(Math.toRadians(canvasAngle)).toFloat() + y = viewCenterY + currentRadius * sin(Math.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. */ 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 02a94cc..746fe32 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -209,6 +209,24 @@ 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" + android:text="@string/placeholder_polar_value" + app:layout_constraintStart_toStartOf="@+id/guideline_vertical_66" + app:layout_constraintTop_toBottomOf="@+id/label_polar_pct" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintHorizontal_bias="0.5" /> + <!-- Polar Diagram View --> <org.terst.nav.PolarDiagramView android:id="@+id/polar_diagram_view" @@ -388,7 +406,7 @@ android:id="@+id/mob_value_distance" style="@style/InstrumentPrimaryValue" android:layout_width="wrap_content" - android://layout_height="wrap_content" + android:layout_height="wrap_content" tools:text="125 m" android:textSize="80sp" app:layout_constraintBottom_toTopOf="@+id/mob_label_elapsed_time" diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..52d5417 --- /dev/null +++ b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@android:color/white" /> + <foreground android:drawable="@drawable/ic_anchor" /> +</adaptive-icon> diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..52d5417 --- /dev/null +++ b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@android:color/white" /> + <foreground android:drawable="@drawable/ic_anchor" /> +</adaptive-icon> diff --git a/android-app/app/src/main/res/raw/mob_alarm.mp3 b/android-app/app/src/main/res/raw/mob_alarm.mp3 new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/android-app/app/src/main/res/raw/mob_alarm.mp3 @@ -0,0 +1 @@ + diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml index 3dce53c..7ccb28f 100644 --- a/android-app/app/src/main/res/values/colors.xml +++ b/android-app/app/src/main/res/values/colors.xml @@ -14,4 +14,13 @@ <color name="instrument_background">#E61E1E1E</color> <!-- Slightly transparent dark grey --> <color name="mob_button_background">#FFD70000</color> <!-- High-contrast red for MOB button --> <color name="anchor_button_background">#3F51B5</color> + + <!-- Night Vision Mode Colors --> + <color name="night_red_primary">#FFFF0000</color> <!-- Bright red for primary elements --> + <color name="night_red_variant">#FFBB0000</color> <!-- Slightly darker red for variants --> + <color name="night_on_red">#FF000000</color> <!-- Black text on red background --> + <color name="night_background">#FF000000</color> <!-- Pure black background --> + <color name="night_on_background">#FFFF0000</color> <!-- Bright red text on black background --> + <color name="night_surface">#FF110000</color> <!-- Very dark red surface color --> + <color name="night_on_surface">#FFFF0000</color> <!-- Red text on dark red surface --> </resources>
\ No newline at end of file diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml index c23c0ab..52028de 100644 --- a/android-app/app/src/main/res/values/themes.xml +++ b/android-app/app/src/main/res/values/themes.xml @@ -14,6 +14,26 @@ <!-- Customize your theme here. --> </style> + <!-- Night Vision Theme --> + <style name="Theme.AndroidApp.NightVision" parent="Theme.MaterialComponents.NoActionBar"> + <!-- Primary brand color. --> + <item name="colorPrimary">@color/night_red_primary</item> + <item name="colorPrimaryVariant">@color/night_red_variant</item> + <item name="colorOnPrimary">@color/night_on_red</item> + <!-- Secondary brand color. --> + <item name="colorSecondary">@color/night_red_primary</item> + <item name="colorSecondaryVariant">@color/night_red_variant</item> + <item name="colorOnSecondary">@color/night_on_red</item> + <!-- Background color --> + <item name="android:colorBackground">@color/night_background</item> + <!-- Surface color --> + <item name="colorSurface">@color/night_surface</item> + <item name="colorOnSurface">@color/night_on_surface</item> + <!-- Status bar color. --> + <item name="android:statusBarColor" tools:targetApi="l">@color/night_background</item> + <!-- Customize your theme here. --> + </style> + <!-- Instrument Display Styles --> <style name="InstrumentLabel" parent="Widget.AppCompat.TextView"> <item name="android:layout_width">0dp</item> |
