Scaffold Flutter port: toolchain, project, and Geo
T00 — toolchain green on both platforms. flutter doctor reports no issues. Pinned Flutter to Temurin 21 (AGP rejects the default JDK 25, same constraint as the native build) and installed Android cmdline-tools with an explicit --sdk_root, the trap already documented in the native DEVELOPMENT.md. No caches were deleted: the 16 GiB disk reading that drove the cleanup plan re-measured at 36 GiB before anything was removed. T01 — flutter create for android+ios. applicationId is com.rippr.port, not com.rippr, so the native app stays installable alongside it during the port; T27 switches it at cutover. T02 — geo/geo.dart ported from com.rippr.geo.Geo with all 23 tests, tolerances and comments carried over unchanged. Kotlin's object namespace became top-level functions; LatLon stays our own type so the pure layer never depends on flutter_map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
187
lib/src/geo/geo.dart
Normal file
187
lib/src/geo/geo.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
/// Geographic maths with no Flutter or platform dependencies, so it is testable
|
||||
/// on the plain Dart VM.
|
||||
///
|
||||
/// Ported from `com.rippr.geo.Geo` (native Android v2.0.1). The structure is kept
|
||||
/// deliberately faithful to the Kotlin original so the two can be diffed and so the
|
||||
/// cross-language parity harness can assert identical output.
|
||||
///
|
||||
/// Haversine rather than Vincenty throughout: assuming a sphere costs about 0.5% — a
|
||||
/// few metres per kilometre — which is well below GPS noise. Vincenty's iterative
|
||||
/// solution would be false precision at real cost.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// A bare latitude/longitude pair.
|
||||
///
|
||||
/// Deliberately *not* `latlong2`'s `LatLng`: the pure layer must not depend on the
|
||||
/// map package. Conversion happens at the rendering boundary only.
|
||||
class LatLon {
|
||||
const LatLon(this.lat, this.lon);
|
||||
|
||||
final double lat;
|
||||
final double lon;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is LatLon && other.lat == lat && other.lon == lon;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(lat, lon);
|
||||
|
||||
@override
|
||||
String toString() => 'LatLon($lat, $lon)';
|
||||
}
|
||||
|
||||
class Bounds {
|
||||
const Bounds(this.minLat, this.minLon, this.maxLat, this.maxLon);
|
||||
|
||||
final double minLat;
|
||||
final double minLon;
|
||||
final double maxLat;
|
||||
final double maxLon;
|
||||
|
||||
double get centerLat => (minLat + maxLat) / 2;
|
||||
double get centerLon => (minLon + maxLon) / 2;
|
||||
|
||||
/// True when every point sits at effectively one spot — a parked "ride".
|
||||
bool get isDegenerate =>
|
||||
(maxLat - minLat).abs() < 1e-9 && (maxLon - minLon).abs() < 1e-9;
|
||||
|
||||
@override
|
||||
String toString() => 'Bounds($minLat, $minLon, $maxLat, $maxLon)';
|
||||
}
|
||||
|
||||
/// IUGG mean Earth radius.
|
||||
const double earthRadiusM = 6371008.8;
|
||||
|
||||
const double _degToRad = math.pi / 180.0;
|
||||
|
||||
double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||
final dLat = (lat2 - lat1) * _degToRad;
|
||||
final dLon = (lon2 - lon1) * _degToRad;
|
||||
final sinLat = math.sin(dLat / 2);
|
||||
final sinLon = math.sin(dLon / 2);
|
||||
final a = sinLat * sinLat +
|
||||
math.cos(lat1 * _degToRad) * math.cos(lat2 * _degToRad) * sinLon * sinLon;
|
||||
// asin(sqrt(a)) rather than atan2 — better conditioned for the very short hops
|
||||
// between consecutive GPS fixes.
|
||||
return 2 * earthRadiusM * math.asin(math.min(1.0, math.sqrt(a)));
|
||||
}
|
||||
|
||||
double haversineBetween(LatLon a, LatLon b) =>
|
||||
haversineMeters(a.lat, a.lon, b.lat, b.lon);
|
||||
|
||||
/// Douglas–Peucker simplification, tolerance in **metres**.
|
||||
///
|
||||
/// A degree-based tolerance would behave differently depending where you ride — a
|
||||
/// degree of longitude is ~111 km at the equator and ~0 at the poles.
|
||||
///
|
||||
/// Iterative with an explicit stack: a 20,000-point ride can blow the call stack in
|
||||
/// the pathological case where recursion never balances.
|
||||
///
|
||||
/// This is a **render-only** transform. It must never reach storage or export.
|
||||
List<LatLon> simplify(List<LatLon> points, double epsilonMeters) {
|
||||
if (points.length <= 2 || epsilonMeters <= 0.0) return points;
|
||||
|
||||
final lastIndex = points.length - 1;
|
||||
final keep = List<bool>.filled(points.length, false);
|
||||
keep[0] = true;
|
||||
keep[lastIndex] = true;
|
||||
|
||||
final stack = <List<int>>[
|
||||
[0, lastIndex],
|
||||
];
|
||||
|
||||
while (stack.isNotEmpty) {
|
||||
final pair = stack.removeLast();
|
||||
final first = pair[0];
|
||||
final last = pair[1];
|
||||
if (last <= first + 1) continue;
|
||||
|
||||
var maxDist = 0.0;
|
||||
var index = first;
|
||||
for (var i = first + 1; i < last; i++) {
|
||||
final d = perpendicularDistanceMeters(points[i], points[first], points[last]);
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDist > epsilonMeters) {
|
||||
keep[index] = true;
|
||||
stack.add([first, index]);
|
||||
stack.add([index, last]);
|
||||
}
|
||||
}
|
||||
|
||||
final out = <LatLon>[];
|
||||
for (var i = 0; i < points.length; i++) {
|
||||
if (keep[i]) out.add(points[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Distance from [p] to the segment [a]–[b], via a local equirectangular projection.
|
||||
///
|
||||
/// Valid over the short spans between consecutive fixes and far cheaper than a
|
||||
/// geodesic solution.
|
||||
double perpendicularDistanceMeters(LatLon p, LatLon a, LatLon b) {
|
||||
final latRef = (a.lat + b.lat) / 2 * _degToRad;
|
||||
final mPerDegLat = earthRadiusM * _degToRad;
|
||||
final mPerDegLon = mPerDegLat * math.cos(latRef);
|
||||
|
||||
final ax = a.lon * mPerDegLon;
|
||||
final ay = a.lat * mPerDegLat;
|
||||
final bx = b.lon * mPerDegLon;
|
||||
final by = b.lat * mPerDegLat;
|
||||
final px = p.lon * mPerDegLon;
|
||||
final py = p.lat * mPerDegLat;
|
||||
|
||||
final dx = bx - ax;
|
||||
final dy = by - ay;
|
||||
final lengthSq = dx * dx + dy * dy;
|
||||
if (lengthSq == 0.0) {
|
||||
// Degenerate segment: fall back to point distance.
|
||||
final ex = px - ax;
|
||||
final ey = py - ay;
|
||||
return math.sqrt(ex * ex + ey * ey);
|
||||
}
|
||||
|
||||
// Clamped projection, so a point beyond either end measures to the endpoint
|
||||
// rather than to the infinite line.
|
||||
final t = (((px - ax) * dx + (py - ay) * dy) / lengthSq).clamp(0.0, 1.0);
|
||||
final cx = ax + t * dx;
|
||||
final cy = ay + t * dy;
|
||||
final ex = px - cx;
|
||||
final ey = py - cy;
|
||||
return math.sqrt(ex * ex + ey * ey);
|
||||
}
|
||||
|
||||
/// Null for an empty list — callers must handle "no path" rather than centring on
|
||||
/// Null Island.
|
||||
Bounds? bounds(List<LatLon> points) {
|
||||
if (points.isEmpty) return null;
|
||||
var minLat = points[0].lat;
|
||||
var maxLat = points[0].lat;
|
||||
var minLon = points[0].lon;
|
||||
var maxLon = points[0].lon;
|
||||
for (final p in points) {
|
||||
minLat = math.min(minLat, p.lat);
|
||||
maxLat = math.max(maxLat, p.lat);
|
||||
minLon = math.min(minLon, p.lon);
|
||||
maxLon = math.max(maxLon, p.lon);
|
||||
}
|
||||
return Bounds(minLat, minLon, maxLat, maxLon);
|
||||
}
|
||||
|
||||
/// Total length of a polyline. Callers must not span a pause with this.
|
||||
double pathLengthMeters(List<LatLon> points) {
|
||||
if (points.length < 2) return 0.0;
|
||||
var total = 0.0;
|
||||
for (var i = 1; i < points.length; i++) {
|
||||
total += haversineBetween(points[i - 1], points[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
Reference in New Issue
Block a user