package org.terst.nav 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 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 = 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) } // 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 = 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 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 // 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(Math.toRadians(canvasAngle)).toFloat() val y = viewCenterY + currentRadius * sin(Math.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(Math.toRadians(canvasAngle)).toFloat() val y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat() path.lineTo(x, y) // Continue drawing the path } } canvas.drawPath(path, polarCurvePaint) } private fun drawCurrentPerformance(canvas: Canvas, twa: Double, bsp: Double) { 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(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 } 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(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(Math.toRadians(canvasAngle)).toFloat() y = viewCenterY + currentRadius * sin(Math.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(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(Math.toRadians(canvasAngle)).toFloat() y = viewCenterY + currentRadius * sin(Math.toRadians(canvasAngle)).toFloat() canvas.drawLine(viewCenterX, viewCenterY, x, y, optimalVmgPaint) } } /** * 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 } }