Files
dmc/app/lib/services/terrain/terrain_service.dart
T
Constantin LeueandClaude Sonnet 5 b176ad294e 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>
2026-07-28 18:38:23 +02:00

121 lines
4.4 KiB
Dart

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,
),
],
);
}
}