diff --git a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt index c81474d..c178c90 100644 --- a/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt +++ b/app/src/main/java/no/naiv/tilfluktsrom/ui/DirectionArrowView.kt @@ -10,6 +10,8 @@ import android.provider.Settings import android.util.AttributeSet import android.view.View import android.view.accessibility.AccessibilityNodeInfo +import kotlin.math.cos +import kotlin.math.sin import no.naiv.tilfluktsrom.R /** @@ -162,20 +164,35 @@ class DirectionArrowView @JvmOverloads constructor( } /** - * Draw a small north indicator: a tiny triangle and "N" label - * placed on the perimeter of the view, pointing inward toward center. + * Draw a small north indicator: a tiny triangle and "N" label pointing + * outward from the view centre in the direction of north. The radius + * is clamped so the label stays inside the viewport even when north + * points toward the shorter viewport axis (previously the indicator + * would be drawn off-screen when the user was facing roughly east or + * west on a portrait-oriented compass view). */ private fun drawNorthIndicator(canvas: Canvas, cx: Float, cy: Float, arrowSize: Float) { - val radius = arrowSize * 1.35f val tickSize = arrowSize * 0.1f + val textSize = arrowSize * 0.18f + northTextPaint.textSize = textSize - // Scale "N" text relative to the view - northTextPaint.textSize = arrowSize * 0.18f + // Outward reach of the rendered indicator beyond `radius`: the + // triangle's apex sits at tickSize*1.8, the text baseline at + // tickSize*2.2, and the "N" glyph extends roughly textSize above + // its baseline. The larger of these is what must fit inside the + // viewport. + val labelReach = tickSize * 2.2f + textSize + + val radius = clampIndicatorRadius( + cx, cy, width, height, northAngle, + preferredRadius = arrowSize * 1.35f, + labelReach = labelReach, + minRadius = tickSize * 3f + ) canvas.save() canvas.rotate(northAngle, cx, cy) - // Small triangle at the top of the perimeter circle northPath.reset() northPath.moveTo(cx, cy - radius) northPath.lineTo(cx - tickSize, cy - radius - tickSize * 1.8f) @@ -183,7 +200,6 @@ class DirectionArrowView @JvmOverloads constructor( northPath.close() canvas.drawPath(northPath, northPaint) - // "N" label just outside the triangle canvas.drawText("N", cx, cy - radius - tickSize * 2.2f, northTextPaint) canvas.restore() @@ -254,5 +270,46 @@ class DirectionArrowView @JvmOverloads constructor( * Returns a value in {0, 45, 90, 135, 180, 225, 270, 315}. */ internal fun snapToSector(angleDegrees: Float): Float = sectorIndex(angleDegrees) * 45f + + /** + * Return the largest radius from the view centre that still lets an + * indicator — positioned on a circle around the centre and reaching + * [labelReach] further in the outward direction — stay inside the + * axis-aligned viewport `[0, viewWidth] × [0, viewHeight]`, while + * respecting [preferredRadius] as an upper bound. + * + * [angleDegrees] is in screen space: 0° points up, positive is + * clockwise. [minRadius] is a floor used only when the label's + * reach is larger than the available room (a degenerate case we + * shouldn't hit for real viewport sizes). Pure function; exposed + * as `internal` so it can be unit-tested on the JVM. + */ + internal fun clampIndicatorRadius( + cx: Float, + cy: Float, + viewWidth: Int, + viewHeight: Int, + angleDegrees: Float, + preferredRadius: Float, + labelReach: Float, + minRadius: Float + ): Float { + val thetaRad = Math.toRadians(angleDegrees.toDouble()) + val dx = sin(thetaRad).toFloat() + val dy = -cos(thetaRad).toFloat() + val tHoriz = when { + dx > 0f -> (viewWidth - cx) / dx + dx < 0f -> -cx / dx + else -> Float.POSITIVE_INFINITY + } + val tVert = when { + dy > 0f -> (viewHeight - cy) / dy + dy < 0f -> -cy / dy + else -> Float.POSITIVE_INFINITY + } + val distanceToEdge = minOf(tHoriz, tVert) + return minOf(preferredRadius, distanceToEdge - labelReach) + .coerceAtLeast(minRadius) + } } } diff --git a/app/src/test/java/no/naiv/tilfluktsrom/ui/DirectionArrowViewTest.kt b/app/src/test/java/no/naiv/tilfluktsrom/ui/DirectionArrowViewTest.kt index 26a4b78..a7af944 100644 --- a/app/src/test/java/no/naiv/tilfluktsrom/ui/DirectionArrowViewTest.kt +++ b/app/src/test/java/no/naiv/tilfluktsrom/ui/DirectionArrowViewTest.kt @@ -95,4 +95,105 @@ class DirectionArrowViewTest { // Just past full rotation wraps to forward. assertEquals(0f, DirectionArrowView.snapToSector(359.9f), 0.0001f) } + + @Test + fun clampIndicatorRadius_returnsPreferredWhenViewportIsBig() { + // Viewport generous enough to fit the preferred radius + label + // at any angle. + val r = DirectionArrowView.clampIndicatorRadius( + cx = 500f, cy = 500f, viewWidth = 1000, viewHeight = 1000, + angleDegrees = 45f, + preferredRadius = 100f, labelReach = 20f, minRadius = 30f + ) + assertEquals(100f, r, 0.01f) + } + + @Test + fun clampIndicatorRadius_clampsWhenNorthPointsToShortAxis() { + // Portrait-ish compass viewport (1080 × 2400), facing east. + // Horizontal half-width = 540, label reach = 100 → max radius 440. + val r = DirectionArrowView.clampIndicatorRadius( + cx = 540f, cy = 1200f, viewWidth = 1080, viewHeight = 2400, + angleDegrees = 90f, + preferredRadius = 600f, labelReach = 100f, minRadius = 30f + ) + assertEquals(440f, r, 0.01f) + } + + @Test + fun clampIndicatorRadius_usesPreferredWhenVerticalAxisIsLonger() { + // Same portrait viewport, facing north. Vertical half-height is + // 1200, so preferred radius 600 fits comfortably. + val r = DirectionArrowView.clampIndicatorRadius( + cx = 540f, cy = 1200f, viewWidth = 1080, viewHeight = 2400, + angleDegrees = 0f, + preferredRadius = 600f, labelReach = 100f, minRadius = 30f + ) + assertEquals(600f, r, 0.01f) + } + + @Test + fun clampIndicatorRadius_diagonalGetsMoreRoomThanAxial() { + // On a square viewport, the distance to the corner (√2 × half) + // exceeds the axial distance. Used as a regression check that we + // don't accidentally clamp by min(width, height) regardless of + // angle. + val axial = DirectionArrowView.clampIndicatorRadius( + cx = 500f, cy = 500f, viewWidth = 1000, viewHeight = 1000, + angleDegrees = 90f, + preferredRadius = 10_000f, labelReach = 0f, minRadius = 0f + ) + val diagonal = DirectionArrowView.clampIndicatorRadius( + cx = 500f, cy = 500f, viewWidth = 1000, viewHeight = 1000, + angleDegrees = 45f, + preferredRadius = 10_000f, labelReach = 0f, minRadius = 0f + ) + assertEquals(500f, axial, 0.01f) + assertEquals(707.11f, diagonal, 0.1f) + } + + @Test + fun clampIndicatorRadius_enforcesMinRadiusWhenLabelCantFit() { + // Viewport far too small to fit the requested label. Clamp to the + // minimum instead of returning a negative radius. + val r = DirectionArrowView.clampIndicatorRadius( + cx = 50f, cy = 50f, viewWidth = 100, viewHeight = 100, + angleDegrees = 0f, + preferredRadius = 40f, labelReach = 200f, minRadius = 10f + ) + assertEquals(10f, r, 0.01f) + } + + @Test + fun clampIndicatorRadius_stayInsideViewport_sweep() { + // For every 5° step, the rendered indicator (at `radius + labelReach` + // from the centre) must remain inside the viewport rectangle. This + // is the invariant the user reported being violated. + val cx = 540f + val cy = 711f + val w = 1080 + val h = 1422 + val labelReach = 170f + var angle = 0f + while (angle < 360f) { + val radius = DirectionArrowView.clampIndicatorRadius( + cx, cy, w, h, angle, + preferredRadius = 583f, labelReach = labelReach, minRadius = 30f + ) + val outermost = radius + labelReach + val thetaRad = Math.toRadians(angle.toDouble()) + val px = cx + (outermost * Math.sin(thetaRad)).toFloat() + val py = cy - (outermost * Math.cos(thetaRad)).toFloat() + // Allow ~1 px of slack so float precision at the exact edge + // (e.g. 540 * sin(240°) rounding to -5.4e-5 instead of 0) is + // not treated as a clipping regression. + val slack = 1f + val inBounds = px in -slack..(w + slack) && py in -slack..(h + slack) + assertEquals( + "indicator tip at angle $angle° landed at ($px, $py), outside [0,$w]×[0,$h]", + true, inBounds + ) + angle += 5f + } + } }