Fiks klipping av N-indikator i kompassvisning

Nordindikatoren («N»-merket) ble plassert på en sirkel med radius
min(w,h) * 0,54 fra senter. I stående kompassvisning er dette
bredere enn viewporten, så indikatoren havnet utenfor skjermen når
brukeren vendte omtrent 90° eller 270°.

Ny hjelpefunksjon clampIndicatorRadius finner avstanden fra
sentrum til nærmeste viewport-kant i nordretningen, og klamper
radiusen slik at hele «N»-etiketten holder seg innenfor rammen
uansett rotasjonsvinkel. Seks nye unittester dekker
grensetilfeller, inkludert en sweep over alle 360° som fanget opp
en float-presisjonsartefakt ved 240°.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-18 19:13:27 +02:00
commit 1303a74d25
2 changed files with 166 additions and 8 deletions

View file

@ -10,6 +10,8 @@ import android.provider.Settings
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityNodeInfo
import kotlin.math.cos
import kotlin.math.sin
import no.naiv.tilfluktsrom.R import no.naiv.tilfluktsrom.R
/** /**
@ -162,20 +164,35 @@ class DirectionArrowView @JvmOverloads constructor(
} }
/** /**
* Draw a small north indicator: a tiny triangle and "N" label * Draw a small north indicator: a tiny triangle and "N" label pointing
* placed on the perimeter of the view, pointing inward toward center. * 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) { private fun drawNorthIndicator(canvas: Canvas, cx: Float, cy: Float, arrowSize: Float) {
val radius = arrowSize * 1.35f
val tickSize = arrowSize * 0.1f val tickSize = arrowSize * 0.1f
val textSize = arrowSize * 0.18f
northTextPaint.textSize = textSize
// Scale "N" text relative to the view // Outward reach of the rendered indicator beyond `radius`: the
northTextPaint.textSize = arrowSize * 0.18f // 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.save()
canvas.rotate(northAngle, cx, cy) canvas.rotate(northAngle, cx, cy)
// Small triangle at the top of the perimeter circle
northPath.reset() northPath.reset()
northPath.moveTo(cx, cy - radius) northPath.moveTo(cx, cy - radius)
northPath.lineTo(cx - tickSize, cy - radius - tickSize * 1.8f) northPath.lineTo(cx - tickSize, cy - radius - tickSize * 1.8f)
@ -183,7 +200,6 @@ class DirectionArrowView @JvmOverloads constructor(
northPath.close() northPath.close()
canvas.drawPath(northPath, northPaint) canvas.drawPath(northPath, northPaint)
// "N" label just outside the triangle
canvas.drawText("N", cx, cy - radius - tickSize * 2.2f, northTextPaint) canvas.drawText("N", cx, cy - radius - tickSize * 2.2f, northTextPaint)
canvas.restore() canvas.restore()
@ -254,5 +270,46 @@ class DirectionArrowView @JvmOverloads constructor(
* Returns a value in {0, 45, 90, 135, 180, 225, 270, 315}. */ * Returns a value in {0, 45, 90, 135, 180, 225, 270, 315}. */
internal fun snapToSector(angleDegrees: Float): Float = internal fun snapToSector(angleDegrees: Float): Float =
sectorIndex(angleDegrees) * 45f 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)
}
} }
} }

View file

@ -95,4 +95,105 @@ class DirectionArrowViewTest {
// Just past full rotation wraps to forward. // Just past full rotation wraps to forward.
assertEquals(0f, DirectionArrowView.snapToSector(359.9f), 0.0001f) 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
}
}
} }