Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.9 KiB
T04 — Geo utilities
Phase 2 · Depends on T01 · Status Done
Goal
Pure geographic maths with no Android dependencies: distance between fixes, polyline
simplification for rendering, and bounding boxes for map auto-fit. Done when
geo/Geo.kt exists, imports nothing from android.*, and is covered by JVM unit tests
that run without a device.
Context
This is the pattern Telemetry.kt already establishes and the reason its logic is
cheaply testable — the conversion and sanitisation functions there are covered by
TelemetryTest with no emulator involved. Geo follows the same rule.
Three consumers depend on this task: T05 (statistics), T14 (path rendering), T15 (export). It is on nobody's blocking path from the UI side, so it can be built in parallel with the whole data-layer track.
Design
object Geo {
const val EARTH_RADIUS_M = 6_371_008.8 // IUGG mean radius
fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double
/** Douglas–Peucker. Always preserves first and last points. */
fun simplify(points: List<LatLon>, epsilonMeters: Double): List<LatLon>
fun bounds(points: List<LatLon>): Bounds? // null for empty input
}
data class LatLon(val lat: Double, val lon: Double)
data class Bounds(val minLat: Double, val minLon: Double,
val maxLat: Double, val maxLon: Double)
Haversine, not Vincenty. Haversine assumes a sphere and is accurate to roughly 0.5% — a few metres per kilometre. For motorcycle ride distances that is far below GPS noise, and Vincenty's iterative solution would be false precision at real cost.
Douglas–Peucker with a metre epsilon, not a degree epsilon. A degree of longitude is ~111 km at the equator and ~0 at the poles, so a degree-based tolerance behaves differently depending where you ride. Perpendicular distance is computed with a local equirectangular approximation — valid over the short spans between consecutive fixes and far cheaper than a full geodesic.
Recursion depth. A naive recursive Douglas–Peucker on 20,000+ points can blow the stack in a pathological case. Implement iteratively with an explicit work stack.
Implementation
- Create
geo/Geo.ktwithLatLon,Bounds, and the three functions. - Haversine in double precision throughout — float loses metres over a long ride.
- Iterative Douglas–Peucker with an explicit stack.
bounds()returns null on empty rather than a degenerate zero box, so callers must handle "no path" explicitly instead of silently centring on Null Island.- Add an extension to map
TrackPoint→LatLonso callers stay tidy.
Acceptance criteria
- No
android.*imports ingeo/ - Haversine matches known reference distances within 0.5%
simplify()always returns first and last points unchangedsimplify()with epsilon 0 returns the input unchangedsimplify()on 25,000 synthetic points completes quickly and does not stack-overflowbounds()returns null for an empty list
Tests
JVM unit tests (app/src/test/…), no device needed:
- Known pairs: Calgary→Edmonton ≈ 280.9 km (great-circle, not road); a 1° latitude step ≈ 111.2 km; identical points → 0
- Antimeridian and equator crossings behave sanely
- Douglas–Peucker: a straight line collapses to two points; a zigzag above epsilon is preserved; endpoints always survive
- 25,000-point performance and stack-safety check
- Bounds across a mixed-sign coordinate set
Risks / gotchas
- Float vs double.
TrackPoint.latitude/longitudeare alreadyDouble; keep them that way through every calculation.speedKmhisFloat, which is fine — it is display data, not accumulated. - Epsilon tuning is a T14 concern, not this one. Expose it as a parameter and let the render layer choose.
Out of scope
Anything that consumes these functions — statistics (T05), rendering (T14), export (T15).