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>
121 lines
4.4 KiB
Dart
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,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|