aves_mio0.31/lib/utils/gpx_activity_parser.dart
2026-07-18 13:39:22 +02:00

297 lines
8 KiB
Dart

// lib/utils/gpx_activity_parser.dart
import 'package:aves/model/activity/activity_track.dart';
import 'package:gpx/gpx.dart';
import 'package:latlong2/latlong.dart';
import 'gps_smoothing.dart';
class GpxActivityParser {
static final Distance _distance = Distance();
static ActivityTrack parse(String xml, {required String fileName}) {
final gpx = GpxReader().fromString(xml);
final points = <_GpxPoint>[];
final displayTracks = <List<LatLng>>{};
for (final trk in gpx.trks) {
for (final seg in trk.trksegs) {
final segmentPoints = <_GpxPoint>[];
for (final p in seg.trkpts) {
final lat = p.lat;
final lon = p.lon;
if (lat == null || lon == null) continue;
if (!lat.isFinite || !lon.isFinite) continue;
if (lat.abs() > 90 || lon.abs() > 180) continue;
final point = _GpxPoint(
latLng: LatLng(lat, lon),
elevationMeters: p.ele,
time: p.time?.toUtc(),
);
points.add(point);
segmentPoints.add(point);
}
if (segmentPoints.isNotEmpty) {
final latLngs = segmentPoints.map((p) => p.latLng).toList();
final smoothed = GpsSmoothing.douglasPeucker(latLngs, 5.0);
displayTracks.add(smoothed);
}
}
}
points.sort(_compareByTimeKeepingNullsLast);
final computedDistanceMeters = _computeDistanceFromPoints(points);
final computedElevationGainMeters = _computeElevationLikeCommercialApps(points);
final computedDuration = _computeDuration(points);
final computedStartTime = _computeStartTime(points);
final routeName = _readRouteName(gpx, fileName);
return ActivityTrack(
name: fileName,
format: 'GPX',
tracks: displayTracks,
routeName: routeName,
pointCount: points.length,
distanceMeters: computedDistanceMeters,
elevationGainMeters: computedElevationGainMeters,
fileDistanceMeters: null,
fileElevationGainMeters: null,
computedDistanceMeters: computedDistanceMeters,
computedElevationGainMeters: computedElevationGainMeters,
duration: computedDuration,
startTime: computedStartTime,
sport: null,
diagnostics: [
'computedDistanceMeters=$computedDistanceMeters',
'computedElevationGainMeters=$computedElevationGainMeters',
'elevationPointCount=${points.where((p) => p.elevationMeters != null).length}',
'elevationMin=${_minElevation(points)}',
'elevationMax=${_maxElevation(points)}',
'originalPoints=${points.length}',
'displayPoints=${displayTracks.expand((segment) => segment).length}',
'routeName=$routeName',
].join('\n'),
);
}
static String? _readRouteName(Gpx gpx, String fileName) {
// Nome GPX standard: primo <trk><name> valido.
for (final trk in gpx.trks) {
final name = trk.name?.trim();
if (name != null && name.isNotEmpty) return name;
}
// Alcuni GPX usano <rte><name> invece di <trk><name>.
for (final rte in gpx.rtes) {
final name = rte.name?.trim();
if (name != null && name.isNotEmpty) return name;
}
// Fallback: nome file senza estensione.
final cleaned = fileName.trim();
if (cleaned.isEmpty) return null;
final dotIndex = cleaned.lastIndexOf('.');
if (dotIndex > 0) return cleaned.substring(0, dotIndex);
return cleaned;
}
static double? _computeDistanceFromPoints(List<_GpxPoint> points) {
if (points.length < 2) return null;
double dist = 0;
for (int i = 1; i < points.length; i++) {
final prev = points[i - 1];
final curr = points[i];
final seg = _distance.as(
LengthUnit.Meter,
prev.latLng,
curr.latLng,
);
if (!seg.isFinite || seg <= 0) continue;
final dt = prev.time != null && curr.time != null
? curr.time!.difference(prev.time!).inSeconds.abs()
: null;
if (dt == null || dt == 0) {
// Filtro prudente anti-spike quando manca il timestamp.
if (seg > 500) continue;
dist += seg;
continue;
}
if (seg < 0.3) continue;
final speedMps = seg / dt;
if (speedMps > 30) continue;
dist += seg;
}
return dist > 0 ? dist : null;
}
/// Fallback stile app commerciali:
/// - rimozione quote impossibili;
/// - smoothing mediano;
/// - rimozione spike verticali;
/// - somma delle salite valle-picco sopra soglia.
///
/// Non fa quota finale - quota iniziale, quindi i giri circolari non si annullano.
static double? _computeElevationLikeCommercialApps(List<_GpxPoint> points) {
final elevations = points
.map((p) => p.elevationMeters)
.whereType<double>()
.where((e) => e.isFinite && e > -500 && e < 9000)
.toList();
if (elevations.length < 2) return null;
final smoothed = _medianSmooth(elevations, radius: 2);
const minClimbMeters = 2.0;
const reverseThresholdMeters = 2.0;
const maxVerticalJumpMeters = 80.0;
double gain = 0;
double valley = smoothed.first;
double peak = smoothed.first;
double previous = smoothed.first;
for (int i = 1; i < smoothed.length; i++) {
final current = smoothed[i];
final delta = current - previous;
if (delta.abs() > maxVerticalJumpMeters) {
previous = current;
valley = current;
peak = current;
continue;
}
if (current > peak) {
peak = current;
}
if (current < valley) {
valley = current;
peak = current;
}
if (peak - current >= reverseThresholdMeters) {
final climb = peak - valley;
if (climb >= minClimbMeters) {
gain += climb;
}
valley = current;
peak = current;
}
previous = current;
}
final finalClimb = peak - valley;
if (finalClimb >= minClimbMeters) {
gain += finalClimb;
}
return gain > 0 ? gain : null;
}
static List<double> _medianSmooth(List<double> values, {required int radius}) {
if (values.length < 3 || radius <= 0) return List<double>.from(values);
final result = <double>[];
for (int i = 0; i < values.length; i++) {
final start = i - radius < 0 ? 0 : i - radius;
final end = i + radius >= values.length ? values.length - 1 : i + radius;
final window = <double>[];
for (int j = start; j <= end; j++) {
window.add(values[j]);
}
window.sort();
result.add(window[window.length ~/ 2]);
}
return result;
}
static Duration? _computeDuration(List<_GpxPoint> points) {
final times = points.map((p) => p.time).nonNulls.toList();
if (times.length < 2) return null;
times.sort();
final duration = times.last.difference(times.first);
return duration.isNegative ? null : duration;
}
static DateTime? _computeStartTime(List<_GpxPoint> points) {
final times = points.map((p) => p.time).nonNulls.toList();
if (times.isEmpty) return null;
times.sort();
return times.first;
}
static double? _minElevation(List<_GpxPoint> points) {
final values = points
.map((p) => p.elevationMeters)
.whereType<double>()
.where((e) => e.isFinite)
.toList();
if (values.isEmpty) return null;
values.sort();
return values.first;
}
static double? _maxElevation(List<_GpxPoint> points) {
final values = points
.map((p) => p.elevationMeters)
.whereType<double>()
.where((e) => e.isFinite)
.toList();
if (values.isEmpty) return null;
values.sort();
return values.last;
}
static int _compareByTimeKeepingNullsLast(_GpxPoint a, _GpxPoint b) {
final at = a.time;
final bt = b.time;
if (at == null && bt == null) return 0;
if (at == null) return 1;
if (bt == null) return -1;
return at.compareTo(bt);
}
}
class _GpxPoint {
final LatLng latLng;
final double? elevationMeters;
final DateTime? time;
const _GpxPoint({
required this.latLng,
this.elevationMeters,
this.time,
});
}