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:
parent
0efb599b8c
commit
1303a74d25
2 changed files with 166 additions and 8 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue