Add terrain/elevation data fetching (Doku 3.9)
Rundet das Altitude-Tab um ein reales Gelaendeprofil ab, damit sichtbar wird, ob die geplante Flughoehe ausreichend Bodenabstand haelt: - domain/mission/terrain_math.dart: reine Sampling-/Geometrie-Funktionen (routeKeyForTerrain fuer Caching, distanzbasiertes Sampling alle 30m, lineare Sollhoehen-Interpolation, terrainDangerRanges fuer Abschnitte mit < 10m Bodenabstand) - unabhaengig testbar ohne Netzwerk/UI. - services/terrain/terrain_service.dart: laedt AWS-Terrarium-PNG-Kacheln (elevation-tiles-prod, zoom 13) und dekodiert sie ueber dart:ui (instantiateImageCodec/toByteData), ohne zusaetzliches Bildpaket. Elevation = R*256 + G + B/256 - 32768 pro Pixel; Kacheln werden pro Route nur einmal geladen (nach Kachel gruppierte Sample-Punkte). - ui/providers/terrain_provider.dart: cached das geladene Profil ueber routeKeyForTerrain - reine Werteaenderungen (Hoehe/Speed) loesen keinen erneuten Kachel-Download aus, nur eine tatsaechliche Ortsverschiebung. - ui/widgets/full_value_chart.dart: _paintDangerBands/_paintTerrain zeichnen rote Gefahrenbaender bzw. die braune Gelaende-Flaeche, in der gleichen Reihenfolge wie im HTML-Demonstrator (dangerHtml, grid, terrainHtml, dann Soll-Kurve obenauf). - ui/widgets/waypoint_list_panel.dart: triggert ensureFor() beim Wechsel auf den Altitude-Tab sowie bei Routenaenderungen waehrend dieser aktiv ist (ref.listenManual auf currentMissionProvider). Getestet: 13 neue Unit-Tests fuer die reine Geometrie/Sampling-Logik, 3 Service-Tests mit einer zur Testzeit synthetisch erzeugten PNG (dart:ui-Encoding, keine Testasset-Datei noetig). Alle 80 Tests sowie flutter analyze bestehen. Manuell auf dem Pixel_10a-Emulator verifiziert: Altitude-Tab zeigt das reale Amsterdam-Gelaendeprofil (nahe Meereshoehe) korrekt als Flaeche unter der Sollhoehen-Linie. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5505bb3741
commit
b176ad294e
@@ -0,0 +1,120 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../domain/mission/terrain_math.dart';
|
||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||
|
||||
/// Hoehendaten ueber AWS-Terrarium-Kacheln (Doku 3.9): PNG-kodierte
|
||||
/// Rasterkacheln im selben z/x/y-Schema wie die OSM-Basiskarte, jeder
|
||||
/// Pixel kodiert eine absolute Hoehe in den RGB-Kanaelen (Terrarium-Format:
|
||||
/// `elevation = R*256 + G + B/256 - 32768`).
|
||||
class TerrainService {
|
||||
TerrainService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
static const _tileUrlTemplate =
|
||||
'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png';
|
||||
static const _zoom = 13;
|
||||
static const _tileSize = 256;
|
||||
|
||||
/// Kachelkoordinate plus Pixelposition innerhalb der Kachel fuer einen
|
||||
/// Punkt - analog lngLatToTilePixel() im HTML-Demonstrator.
|
||||
({int tx, int ty, int px, int py}) _tilePixelFor(double lat, double lon) {
|
||||
final n = math.pow(2, _zoom).toDouble();
|
||||
final x = (lon + 180) / 360 * n;
|
||||
final latRad = lat * math.pi / 180;
|
||||
final y =
|
||||
(1 - math.log(math.tan(latRad) + 1 / math.cos(latRad)) / math.pi) /
|
||||
2 *
|
||||
n;
|
||||
final tx = x.floor();
|
||||
final ty = y.floor();
|
||||
final px = ((x - tx) * _tileSize).floor().clamp(0, _tileSize - 1);
|
||||
final py = ((y - ty) * _tileSize).floor().clamp(0, _tileSize - 1);
|
||||
return (tx: tx, ty: ty, px: px, py: py);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _fetchTileRgba(int tx, int ty) async {
|
||||
final url = _tileUrlTemplate
|
||||
.replaceAll('{z}', '$_zoom')
|
||||
.replaceAll('{x}', '$tx')
|
||||
.replaceAll('{y}', '$ty');
|
||||
try {
|
||||
final response = await _client.get(Uri.parse(url));
|
||||
if (response.statusCode != 200) return null;
|
||||
final codec = await ui.instantiateImageCodec(response.bodyBytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final byteData =
|
||||
await frame.image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
return byteData?.buffer.asUint8List();
|
||||
} catch (_) {
|
||||
// CORS-Aequivalent/Kachel fehlt/Decodierfehler: Punkt bleibt null,
|
||||
// kein Absturz (analog dem try/catch um ctx.getImageData() im
|
||||
// HTML-Demonstrator).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
double? _elevationAt(Uint8List rgba, int px, int py) {
|
||||
final offset = (py * _tileSize + px) * 4;
|
||||
if (offset + 2 >= rgba.length) return null;
|
||||
final r = rgba[offset];
|
||||
final g = rgba[offset + 1];
|
||||
final b = rgba[offset + 2];
|
||||
return (r * 256 + g + b / 256) - 32768;
|
||||
}
|
||||
|
||||
/// Laedt das Gelaendeprofil fuer die aktuelle Route (Doku 3.9): sampelt
|
||||
/// distanzbasiert, gruppiert die Sample-Punkte nach Kachel (jede Kachel
|
||||
/// wird nur einmal geladen) und macht die Hoehe relativ zum ersten
|
||||
/// gueltigen Sample. Null, wenn keine einzige Kachel geladen werden
|
||||
/// konnte oder die Route weniger als zwei Wegpunkte hat.
|
||||
Future<TerrainProfile?> fetchProfile(List<Waypoint> waypoints) async {
|
||||
if (waypoints.length < 2) return null;
|
||||
|
||||
final numSamples = sampleCountFor(waypoints);
|
||||
final samples = sampleRouteByDistance(waypoints, numSamples);
|
||||
if (samples.isEmpty) return null;
|
||||
|
||||
final tileGroups = <String, List<int>>{};
|
||||
final tileCoords = <String, (int, int)>{};
|
||||
final pixelForIndex = <int, (int, int)>{};
|
||||
for (var i = 0; i < samples.length; i++) {
|
||||
final t = _tilePixelFor(samples[i].point.latitude, samples[i].point.longitude);
|
||||
final key = '${t.tx}_${t.ty}';
|
||||
tileGroups.putIfAbsent(key, () => []).add(i);
|
||||
tileCoords[key] = (t.tx, t.ty);
|
||||
pixelForIndex[i] = (t.px, t.py);
|
||||
}
|
||||
|
||||
final elevations = List<double?>.filled(samples.length, null);
|
||||
await Future.wait(tileGroups.entries.map((entry) async {
|
||||
final (tx, ty) = tileCoords[entry.key]!;
|
||||
final rgba = await _fetchTileRgba(tx, ty);
|
||||
if (rgba == null) return;
|
||||
for (final idx in entry.value) {
|
||||
final (px, py) = pixelForIndex[idx]!;
|
||||
elevations[idx] = _elevationAt(rgba, px, py);
|
||||
}
|
||||
}));
|
||||
|
||||
final validIdx = elevations.indexWhere((e) => e != null);
|
||||
if (validIdx == -1) return null;
|
||||
final home = elevations[validIdx]!;
|
||||
|
||||
return TerrainProfile(
|
||||
routeKey: routeKeyForTerrain(waypoints),
|
||||
points: [
|
||||
for (var i = 0; i < samples.length; i++)
|
||||
TerrainSamplePoint(
|
||||
distanceM: samples[i].distanceM,
|
||||
elevationRelM: elevations[i] != null ? elevations[i]! - home : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user