Add fillet flight path geometry and Alt/Speed wheels
- RouteGeometry (lib/domain/mission/route_geometry.dart): physically grounded flight path - straight segments + tangential arcs at each course change, sized from the drone's minimum turn radius (doc 3.7/4.6), replacing a naive spline that would suggest unrealistic turn radii. Marks a vertex "bad" (turn angle >= 160°, tangent length exceeding 90% of either adjacent leg, or exceeding the waypoint's catch radius) and a leg "bad" when the required climb/descent rate exceeds the drone's max climb/descent rate. Pure Dart, no Flutter dependency, so it stays usable if the mission domain is ever split into its own package (4.22). Wind-based turn-radius correction from the prototype isn't ported yet - the wind system (doc 3.8) doesn't exist in the Flutter app. - MissionMap now draws each RouteSegment as its own Polyline, colored red when bad instead of a single plain white line; waypoint markers turn red too when their vertex is bad. - ValueWheel: vertical drag-to-adjust tape control (HTML prototype's #altWheel/#spdWheel), custom-painted tick marks, blue center indicator, gradient fade at top/bottom. Wired into PlanScreen on both screen edges with Alt/Speed readouts. - curAlt/curSpeed now live in PlanScreen state instead of fixed constants: dropping a new waypoint uses whatever the wheels are currently set to; entering editing mode on an existing waypoint loads its values into the wheels; adjusting a wheel while editing writes live into that waypoint (provider gained setAltitude/setSpeed to match the existing moveWaypoint/toggleAction pattern). Added dedicated unit tests for the geometry (empty list, straight line, feasible 90° turn producing a line-arc-line segment sequence, an infeasible near-180° turn, and an infeasible descent rate) rather than relying on eyeballing it on the emulator - this is exactly the kind of ported-math correctness that's hard to verify visually but easy to get subtly wrong. All 11 tests (previous 6 + these 5) and flutter analyze pass. Also manually verified the wheels on the Pixel_10a emulator: drag changes the value and the on-screen readout in real time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6dcb765b80
commit
b93bbab23b
@@ -0,0 +1,221 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../waypoint/flat_waypoint_list.dart';
|
||||
|
||||
/// Ein Streckenabschnitt der Flugpfad-Darstellung: entweder ein
|
||||
/// Geradenstueck zwischen zwei Tangentenpunkten oder ein abgetasteter
|
||||
/// Kreisbogen (Fillet) an einer Kursaenderung (Architektur-Doku 3.7/4.6).
|
||||
class RouteSegment {
|
||||
const RouteSegment({required this.points, required this.bad});
|
||||
|
||||
final List<LatLng> points;
|
||||
final bool bad;
|
||||
}
|
||||
|
||||
class RouteGeometry {
|
||||
const RouteGeometry({required this.segments, required this.vertexBad});
|
||||
|
||||
final List<RouteSegment> segments;
|
||||
|
||||
/// Pro Wegpunkt: true, wenn die Kursaenderung an diesem Punkt mit dem
|
||||
/// minimalen Kurvenradius der Drohne nicht fliegbar ist (Tangentenlaenge
|
||||
/// ueberschreitet Fangradius oder angrenzendes Geradenstueck, oder der
|
||||
/// Kurswinkel ist zu scharf).
|
||||
final List<bool> vertexBad;
|
||||
|
||||
static const empty = RouteGeometry(segments: [], vertexBad: []);
|
||||
}
|
||||
|
||||
class _V {
|
||||
const _V(this.x, this.y);
|
||||
final double x;
|
||||
final double y;
|
||||
|
||||
_V operator -(_V other) => _V(x - other.x, y - other.y);
|
||||
_V operator +(_V other) => _V(x + other.x, y + other.y);
|
||||
_V scaled(double f) => _V(x * f, y * f);
|
||||
double get length => math.sqrt(x * x + y * y);
|
||||
double dot(_V other) => x * other.x + y * other.y;
|
||||
double cross(_V other) => x * other.y - y * other.x;
|
||||
}
|
||||
|
||||
class _MetricPoint {
|
||||
const _MetricPoint(this.pos, this.alt, this.speed);
|
||||
final _V pos;
|
||||
final double alt;
|
||||
final double speed;
|
||||
}
|
||||
|
||||
class _VertexArc {
|
||||
const _VertexArc({
|
||||
required this.t1,
|
||||
required this.t2,
|
||||
required this.center,
|
||||
required this.radius,
|
||||
required this.angleStart,
|
||||
required this.angleEnd,
|
||||
});
|
||||
|
||||
final _V t1;
|
||||
final _V t2;
|
||||
final _V center;
|
||||
final double radius;
|
||||
final double angleStart;
|
||||
final double angleEnd;
|
||||
}
|
||||
|
||||
const _maxTurnAngle = 160 * math.pi / 180;
|
||||
|
||||
/// Baut den physikalisch fundierten Flugpfad: Geradenstuecke + tangentiale
|
||||
/// Kreisboegen an jeder Kursaenderung (Doku 3.7), abgeleitet aus dem
|
||||
/// minimalen Kurvenradius der Drohne. Ersetzt einen naiven Spline durch
|
||||
/// alle Punkte (Doku 4.6) - ein Spline wuerde unrealistische Kurvenradien
|
||||
/// suggerieren und ein falsches Sicherheitsgefuehl bei engen Kurven geben.
|
||||
///
|
||||
/// Windkorrektur des effektiven Kurvenradius (HTML-Demonstrator:
|
||||
/// vertexGroundSpeed-Anpassung) ist noch nicht portiert, da das Wind-System
|
||||
/// (Doku 3.8) in der Flutter-App noch nicht existiert.
|
||||
RouteGeometry buildRouteGeometry(
|
||||
List<Waypoint> waypoints, {
|
||||
required double minTurnRadius,
|
||||
required double maxClimbRate,
|
||||
required double maxDescentRate,
|
||||
}) {
|
||||
final n = waypoints.length;
|
||||
if (n == 0) return RouteGeometry.empty;
|
||||
|
||||
final ref = LatLng(waypoints.first.lat, waypoints.first.lon);
|
||||
final pts = [for (final w in waypoints) _toMeters(w, ref)];
|
||||
|
||||
final vertexBad = List<bool>.filled(n, false);
|
||||
final arcs = List<_VertexArc?>.filled(n, null);
|
||||
|
||||
for (var i = 1; i < n - 1; i++) {
|
||||
final pPrev = pts[i - 1];
|
||||
final pCur = pts[i];
|
||||
final pNext = pts[i + 1];
|
||||
|
||||
final inVec = pCur.pos - pPrev.pos;
|
||||
final outVec = pNext.pos - pCur.pos;
|
||||
final inLen = inVec.length;
|
||||
final outLen = outVec.length;
|
||||
final vIn = inLen > 1e-6 ? inVec.scaled(1 / inLen) : const _V(1, 0);
|
||||
final vOut = outLen > 1e-6 ? outVec.scaled(1 / outLen) : const _V(1, 0);
|
||||
|
||||
final dot = vIn.dot(vOut).clamp(-1.0, 1.0);
|
||||
final turnAngle = math.acos(dot);
|
||||
if (turnAngle <= 0.02) continue;
|
||||
|
||||
final cappedAngle = math.min(turnAngle, _maxTurnAngle);
|
||||
final t = minTurnRadius * math.tan(cappedAngle / 2);
|
||||
|
||||
var bad = false;
|
||||
if (turnAngle >= _maxTurnAngle) bad = true;
|
||||
if (t > inLen * 0.9 || t > outLen * 0.9) bad = true;
|
||||
if (t > waypoints[i].catchRadiusM) bad = true;
|
||||
|
||||
final turnLeft = vIn.cross(vOut) > 0;
|
||||
final normalIn =
|
||||
turnLeft ? _V(-vIn.y, vIn.x) : _V(vIn.y, -vIn.x);
|
||||
|
||||
final t1 = pCur.pos - vIn.scaled(t);
|
||||
final t2 = pCur.pos + vOut.scaled(t);
|
||||
final center = t1 + normalIn.scaled(minTurnRadius);
|
||||
final angleStart = math.atan2(t1.y - center.y, t1.x - center.x);
|
||||
final angleEnd = angleStart + (turnLeft ? cappedAngle : -cappedAngle);
|
||||
|
||||
vertexBad[i] = bad;
|
||||
arcs[i] = _VertexArc(
|
||||
t1: t1,
|
||||
t2: t2,
|
||||
center: center,
|
||||
radius: minTurnRadius,
|
||||
angleStart: angleStart,
|
||||
angleEnd: angleEnd,
|
||||
);
|
||||
}
|
||||
|
||||
final legClimbBad = List<bool>.filled(math.max(n - 1, 0), false);
|
||||
for (var i = 0; i < n - 1; i++) {
|
||||
final a = pts[i];
|
||||
final b = pts[i + 1];
|
||||
final horiz = (b.pos - a.pos).length;
|
||||
legClimbBad[i] = _isClimbBad(
|
||||
a.alt,
|
||||
b.alt,
|
||||
horiz,
|
||||
b.speed,
|
||||
maxClimbRate: maxClimbRate,
|
||||
maxDescentRate: maxDescentRate,
|
||||
);
|
||||
}
|
||||
|
||||
final segments = <RouteSegment>[];
|
||||
for (var leg = 0; leg < n - 1; leg++) {
|
||||
final startArc = arcs[leg];
|
||||
final endArc = arcs[leg + 1];
|
||||
final startPt = (leg == 0) ? pts[0].pos : (startArc?.t2 ?? pts[leg].pos);
|
||||
final endPt =
|
||||
(leg == n - 2) ? pts[n - 1].pos : (endArc?.t1 ?? pts[leg + 1].pos);
|
||||
|
||||
segments.add(RouteSegment(
|
||||
points: [_fromMeters(startPt, ref), _fromMeters(endPt, ref)],
|
||||
bad: legClimbBad[leg],
|
||||
));
|
||||
|
||||
if (leg + 1 <= n - 2 && endArc != null) {
|
||||
const steps = 14;
|
||||
final arcPoints = [
|
||||
for (var k = 0; k <= steps; k++)
|
||||
_fromMeters(
|
||||
_pointOnCircle(
|
||||
endArc.center,
|
||||
endArc.radius,
|
||||
endArc.angleStart +
|
||||
(endArc.angleEnd - endArc.angleStart) * k / steps,
|
||||
),
|
||||
ref,
|
||||
),
|
||||
];
|
||||
segments.add(RouteSegment(points: arcPoints, bad: vertexBad[leg + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
return RouteGeometry(segments: segments, vertexBad: vertexBad);
|
||||
}
|
||||
|
||||
_V _pointOnCircle(_V center, double radius, double angle) =>
|
||||
_V(center.x + radius * math.cos(angle), center.y + radius * math.sin(angle));
|
||||
|
||||
bool _isClimbBad(
|
||||
double altA,
|
||||
double altB,
|
||||
double horizDist,
|
||||
double speed, {
|
||||
required double maxClimbRate,
|
||||
required double maxDescentRate,
|
||||
}) {
|
||||
final vert = altB - altA;
|
||||
if (horizDist < 0.001) return vert.abs() > 0.001;
|
||||
final rate = vert / (horizDist / speed);
|
||||
if (rate > 0) return rate > maxClimbRate;
|
||||
return rate.abs() > maxDescentRate;
|
||||
}
|
||||
|
||||
// Equirektangulare Naeherung relativ zum ersten Wegpunkt - ausreichend fuer
|
||||
// missionstypische Distanzen (HTML-Demonstrator: toMeters()/fromMeters()).
|
||||
_MetricPoint _toMeters(Waypoint w, LatLng ref) {
|
||||
final dLat = (w.lat - ref.latitude) * 110540;
|
||||
final dLon =
|
||||
(w.lon - ref.longitude) * 111320 * math.cos(ref.latitude * math.pi / 180);
|
||||
return _MetricPoint(_V(dLon, dLat), w.altitudeM, w.speedMs);
|
||||
}
|
||||
|
||||
LatLng _fromMeters(_V m, LatLng ref) {
|
||||
return LatLng(
|
||||
ref.latitude + m.y / 110540,
|
||||
ref.longitude + m.x / (111320 * math.cos(ref.latitude * math.pi / 180)),
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,24 @@ class CurrentMissionNotifier extends Notifier<List<Waypoint>> {
|
||||
];
|
||||
}
|
||||
|
||||
/// Aktualisiert die Zielhoehe eines Wegpunkts (Alt-Drehrad im
|
||||
/// Editier-Modus schreibt live in den Zielpunkt).
|
||||
void setAltitude(int index, double altitudeM) {
|
||||
state = [
|
||||
for (var i = 0; i < state.length; i++)
|
||||
if (i == index) state[i].copyWith(altitudeM: altitudeM) else state[i],
|
||||
];
|
||||
}
|
||||
|
||||
/// Aktualisiert die Zielgeschwindigkeit eines Wegpunkts (Speed-Drehrad im
|
||||
/// Editier-Modus schreibt live in den Zielpunkt).
|
||||
void setSpeed(int index, double speedMs) {
|
||||
state = [
|
||||
for (var i = 0; i < state.length; i++)
|
||||
if (i == index) state[i].copyWith(speedMs: speedMs) else state[i],
|
||||
];
|
||||
}
|
||||
|
||||
/// Schaltet die Execute-Aktion eines Wegpunkts um (Halo-Radialmenue:
|
||||
/// erneutes Antippen derselben Aktion setzt sie zurueck auf "none").
|
||||
void toggleAction(int index, WaypointAction action) {
|
||||
|
||||
@@ -6,11 +6,13 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../../domain/mission/route_geometry.dart';
|
||||
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
||||
import '../../providers/current_mission_provider.dart';
|
||||
import '../../widgets/halo_menu.dart';
|
||||
import '../../widgets/mission_map.dart';
|
||||
import '../../widgets/reticle_button.dart';
|
||||
import '../../widgets/value_wheel.dart';
|
||||
|
||||
/// Reticle-Zustand (HTML-Demonstrator: Variable `mode`).
|
||||
/// idle: kein Wegpunkt in der Naehe, Tap legt einen neuen an.
|
||||
@@ -39,10 +41,26 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
|
||||
// TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die
|
||||
// Profilverwaltung existiert. Werte entsprechen dem Default-Profil
|
||||
// T1 Ranger im HTML-Demonstrator (curAlt=60, cruiseSpeed=15, catchRadius=60).
|
||||
static const _defaultAltitudeM = 60.0;
|
||||
static const _defaultSpeedMs = 15.0;
|
||||
// T1 Ranger im HTML-Demonstrator (DEFAULT_DRONE).
|
||||
static const _altMin = -500.0;
|
||||
static const _altMax = 2000.0;
|
||||
static const _altStep = 10.0;
|
||||
static const _altPxPerStep = 14.0;
|
||||
static const _altMajorEvery = 5;
|
||||
|
||||
static const _speedMin = 13.0;
|
||||
static const _speedMax = 25.0;
|
||||
static const _speedStep = 1.0;
|
||||
static const _speedPxPerStep = 14.0;
|
||||
static const _speedMajorEvery = 5;
|
||||
|
||||
static const _defaultCatchRadiusM = 60.0;
|
||||
static const _minTurnRadius = 35.0;
|
||||
static const _maxClimbRate = 5.0;
|
||||
static const _maxDescentRate = 6.0;
|
||||
|
||||
double _curAlt = 60;
|
||||
double _curSpeed = 15;
|
||||
|
||||
// Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser).
|
||||
static const _reticleRadiusPx = 55 / 2;
|
||||
@@ -138,8 +156,8 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
Waypoint(
|
||||
lat: center.latitude,
|
||||
lon: center.longitude,
|
||||
altitudeM: _defaultAltitudeM,
|
||||
speedMs: _defaultSpeedMs,
|
||||
altitudeM: _curAlt,
|
||||
speedMs: _curSpeed,
|
||||
catchRadiusM: _defaultCatchRadiusM,
|
||||
),
|
||||
);
|
||||
@@ -148,7 +166,15 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
_mode = _ReticleMode.snapped;
|
||||
});
|
||||
case _ReticleMode.snapped:
|
||||
setState(() => _mode = _ReticleMode.editing);
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
if (_targetIndex >= 0 && _targetIndex < waypoints.length) {
|
||||
final target = waypoints[_targetIndex];
|
||||
setState(() {
|
||||
_mode = _ReticleMode.editing;
|
||||
_curAlt = target.altitudeM;
|
||||
_curSpeed = target.speedMs;
|
||||
});
|
||||
}
|
||||
case _ReticleMode.editing:
|
||||
setState(() => _mode = _ReticleMode.snapped);
|
||||
}
|
||||
@@ -174,13 +200,30 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onAltChanged(double value) {
|
||||
setState(() => _curAlt = value);
|
||||
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
|
||||
ref.read(currentMissionProvider.notifier).setAltitude(_targetIndex, value);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSpeedChanged(double value) {
|
||||
setState(() => _curSpeed = value);
|
||||
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
|
||||
ref.read(currentMissionProvider.notifier).setSpeed(_targetIndex, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waypoints = ref.watch(currentMissionProvider);
|
||||
|
||||
final routePoints = [
|
||||
for (final wp in waypoints) LatLng(wp.lat, wp.lon),
|
||||
];
|
||||
final routeGeometry = buildRouteGeometry(
|
||||
waypoints,
|
||||
minTurnRadius: _minTurnRadius,
|
||||
maxClimbRate: _maxClimbRate,
|
||||
maxDescentRate: _maxDescentRate,
|
||||
);
|
||||
|
||||
List<LatLng>? rubberBand;
|
||||
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
|
||||
@@ -196,16 +239,26 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
? waypoints[_targetIndex]
|
||||
: null;
|
||||
|
||||
final wheelBottomInset = math.max(
|
||||
MediaQuery.of(context).size.height * 0.06,
|
||||
124.0,
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
MissionMap(
|
||||
mapController: _mapController,
|
||||
onMapReady: () => setState(() => _mapReady = true),
|
||||
routeLine: routePoints,
|
||||
routeSegments: routeGeometry.segments,
|
||||
rubberBandLine: rubberBand,
|
||||
waypointMarkers: [
|
||||
for (var i = 0; i < waypoints.length; i++)
|
||||
_markerFor(waypoints[i], isTarget: i == _targetIndex),
|
||||
_markerFor(
|
||||
waypoints[i],
|
||||
isTarget: i == _targetIndex,
|
||||
isBad: i < routeGeometry.vertexBad.length &&
|
||||
routeGeometry.vertexBad[i],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_mode == _ReticleMode.editing && targetWaypoint != null)
|
||||
@@ -221,22 +274,84 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
alignment: Alignment.center,
|
||||
child: ReticleButton(icon: _reticleIcon, onTap: _onReticleTap),
|
||||
),
|
||||
Positioned(
|
||||
top: 56,
|
||||
bottom: wheelBottomInset,
|
||||
left: 14,
|
||||
child: ValueWheel(
|
||||
value: _curAlt,
|
||||
min: _altMin,
|
||||
max: _altMax,
|
||||
step: _altStep,
|
||||
pxPerStep: _altPxPerStep,
|
||||
majorEvery: _altMajorEvery,
|
||||
onChanged: _onAltChanged,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 56,
|
||||
bottom: wheelBottomInset,
|
||||
left: 70,
|
||||
child: Center(child: _readout('Alt', _curAlt, 'm')),
|
||||
),
|
||||
Positioned(
|
||||
top: 56,
|
||||
bottom: wheelBottomInset,
|
||||
right: 14,
|
||||
child: ValueWheel(
|
||||
value: _curSpeed,
|
||||
min: _speedMin,
|
||||
max: _speedMax,
|
||||
step: _speedStep,
|
||||
pxPerStep: _speedPxPerStep,
|
||||
majorEvery: _speedMajorEvery,
|
||||
onChanged: _onSpeedChanged,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 56,
|
||||
bottom: wheelBottomInset,
|
||||
right: 70,
|
||||
child: Center(child: _readout('Speed', _curSpeed, 'm/s')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _readout(String label, double value, String unit) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'$label: ${value.round()} $unit',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData get _reticleIcon => switch (_mode) {
|
||||
_ReticleMode.idle => Icons.add,
|
||||
_ReticleMode.snapped => Icons.edit,
|
||||
_ReticleMode.editing => Icons.check,
|
||||
};
|
||||
|
||||
CircleMarker _markerFor(Waypoint wp, {required bool isTarget}) {
|
||||
CircleMarker _markerFor(
|
||||
Waypoint wp, {
|
||||
required bool isTarget,
|
||||
required bool isBad,
|
||||
}) {
|
||||
final highlighted = isTarget && _mode != _ReticleMode.idle;
|
||||
return CircleMarker(
|
||||
point: LatLng(wp.lat, wp.lon),
|
||||
radius: highlighted ? 9 : 6,
|
||||
color: const Color(0xFF2B2B28),
|
||||
color: isBad ? const Color(0xFFE2574A) : const Color(0xFF2B2B28),
|
||||
borderStrokeWidth: highlighted ? 3 : 1.5,
|
||||
borderColor: Colors.white,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../domain/mission/route_geometry.dart';
|
||||
|
||||
/// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator:
|
||||
/// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles.
|
||||
class MissionMap extends StatelessWidget {
|
||||
@@ -10,7 +12,7 @@ class MissionMap extends StatelessWidget {
|
||||
super.key,
|
||||
this.mapController,
|
||||
this.waypointMarkers = const [],
|
||||
this.routeLine,
|
||||
this.routeSegments = const [],
|
||||
this.rubberBandLine,
|
||||
this.onMapReady,
|
||||
});
|
||||
@@ -20,10 +22,9 @@ class MissionMap extends StatelessWidget {
|
||||
/// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7).
|
||||
final List<CircleMarker> waypointMarkers;
|
||||
|
||||
/// Verbindungslinie zwischen den Wegpunkten. Bewusst eine gerade Strecke -
|
||||
/// die physikalisch fundierte Geraden+Kreisbogen-Geometrie (Doku 3.7/4.6)
|
||||
/// ist ein separater, noch offener Schritt.
|
||||
final List<LatLng>? routeLine;
|
||||
/// Geraden + Kreisbogen-Segmente des Flugpfads (Doku 3.7/4.6); rot
|
||||
/// markierte Segmente ueberschreiten Steig-/Sinkrate oder Kurvenradius.
|
||||
final List<RouteSegment> routeSegments;
|
||||
|
||||
/// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen
|
||||
/// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde.
|
||||
@@ -55,8 +56,12 @@ class MissionMap extends StatelessWidget {
|
||||
),
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
if (routeLine != null && routeLine!.length > 1)
|
||||
Polyline(points: routeLine!, strokeWidth: 3, color: Colors.white),
|
||||
for (final segment in routeSegments)
|
||||
Polyline(
|
||||
points: segment.points,
|
||||
strokeWidth: 3,
|
||||
color: segment.bad ? const Color(0xFFE2574A) : Colors.white,
|
||||
),
|
||||
if (rubberBandLine != null)
|
||||
Polyline(
|
||||
points: rubberBandLine!,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Vertikales Drehrad zum Einstellen eines Werts per Swipe (HTML-
|
||||
/// Demonstrator: .wheel/#altWheel/#spdWheel + setupWheel()). Der Tick an
|
||||
/// der Bildschirmmitte (blauer Indikator) zeigt den aktuellen Wert; nach
|
||||
/// oben ziehen erhoeht den Wert.
|
||||
class ValueWheel extends StatefulWidget {
|
||||
const ValueWheel({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.step,
|
||||
required this.pxPerStep,
|
||||
required this.majorEvery,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final double step;
|
||||
final double pxPerStep;
|
||||
final int majorEvery;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
@override
|
||||
State<ValueWheel> createState() => _ValueWheelState();
|
||||
}
|
||||
|
||||
class _ValueWheelState extends State<ValueWheel> {
|
||||
double? _dragStartY;
|
||||
double _dragStartValue = 0;
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
_dragStartY = details.globalPosition.dy;
|
||||
_dragStartValue = widget.value;
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
final startY = _dragStartY;
|
||||
if (startY == null) return;
|
||||
final deltaY = startY - details.globalPosition.dy;
|
||||
final deltaSteps = deltaY / widget.pxPerStep;
|
||||
final raw = _dragStartValue + deltaSteps * widget.step;
|
||||
var stepped = (raw / widget.step).round() * widget.step;
|
||||
stepped = stepped.clamp(widget.min, widget.max);
|
||||
widget.onChanged(stepped);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: (_) => _dragStartY = null,
|
||||
child: Container(
|
||||
width: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.9),
|
||||
const Color(0xFF333333).withValues(alpha: 0.9),
|
||||
Colors.black.withValues(alpha: 0.9),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (rect) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [0, 0.1, 0.9, 1],
|
||||
).createShader(rect),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _TapePainter(
|
||||
value: widget.value,
|
||||
min: widget.min,
|
||||
max: widget.max,
|
||||
step: widget.step,
|
||||
pxPerStep: widget.pxPerStep,
|
||||
majorEvery: widget.majorEvery,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(height: 2, color: const Color(0xFF4D9DFF)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TapePainter extends CustomPainter {
|
||||
const _TapePainter({
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.step,
|
||||
required this.pxPerStep,
|
||||
required this.majorEvery,
|
||||
});
|
||||
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final double step;
|
||||
final double pxPerStep;
|
||||
final int majorEvery;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final centerY = size.height / 2;
|
||||
final centerX = size.width / 2;
|
||||
final majorPaint = Paint()..color = Colors.white.withValues(alpha: 0.85);
|
||||
final minorPaint = Paint()..color = Colors.white.withValues(alpha: 0.32);
|
||||
|
||||
final nearestGridIndex = ((value - min) / step).round();
|
||||
final visibleHalfCount = (size.height / 2 / pxPerStep).ceil() + 1;
|
||||
|
||||
for (var i = -visibleHalfCount; i <= visibleHalfCount; i++) {
|
||||
final gridIndex = nearestGridIndex + i;
|
||||
final tickValue = min + gridIndex * step;
|
||||
if (tickValue < min || tickValue > max) continue;
|
||||
|
||||
final y = centerY + (tickValue - value) / step * pxPerStep;
|
||||
final isMajor = gridIndex % majorEvery == 0;
|
||||
final halfWidth = size.width * (isMajor ? 0.35 : 0.19);
|
||||
final thickness = isMajor ? 2.0 : 1.0;
|
||||
|
||||
canvas.drawRect(
|
||||
Rect.fromCenter(
|
||||
center: Offset(centerX, y),
|
||||
width: halfWidth * 2,
|
||||
height: thickness,
|
||||
),
|
||||
isMajor ? majorPaint : minorPaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _TapePainter oldDelegate) =>
|
||||
oldDelegate.value != value ||
|
||||
oldDelegate.min != min ||
|
||||
oldDelegate.max != max ||
|
||||
oldDelegate.step != step;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:dmc_app/domain/mission/route_geometry.dart';
|
||||
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Waypoint _wp(double lat, double lon, {double alt = 60, double speed = 15}) =>
|
||||
Waypoint(lat: lat, lon: lon, altitudeM: alt, speedMs: speed, catchRadiusM: 60);
|
||||
|
||||
void main() {
|
||||
group('buildRouteGeometry', () {
|
||||
const params = (minTurnRadius: 35.0, maxClimbRate: 5.0, maxDescentRate: 6.0);
|
||||
|
||||
test('leere Wegpunktliste liefert leere Geometrie', () {
|
||||
final geometry = buildRouteGeometry(
|
||||
const [],
|
||||
minTurnRadius: params.minTurnRadius,
|
||||
maxClimbRate: params.maxClimbRate,
|
||||
maxDescentRate: params.maxDescentRate,
|
||||
);
|
||||
|
||||
expect(geometry.segments, isEmpty);
|
||||
expect(geometry.vertexBad, isEmpty);
|
||||
});
|
||||
|
||||
test('zwei Wegpunkte ergeben eine einzelne Geradenverbindung', () {
|
||||
final geometry = buildRouteGeometry(
|
||||
[_wp(0, 0), _wp(0, 0.01)],
|
||||
minTurnRadius: params.minTurnRadius,
|
||||
maxClimbRate: params.maxClimbRate,
|
||||
maxDescentRate: params.maxDescentRate,
|
||||
);
|
||||
|
||||
expect(geometry.segments, hasLength(1));
|
||||
expect(geometry.segments.single.bad, isFalse);
|
||||
expect(geometry.vertexBad, [false, false]);
|
||||
});
|
||||
|
||||
test('sanfte 90-Grad-Kurve mit ausreichend Platz ist fliegbar', () {
|
||||
final geometry = buildRouteGeometry(
|
||||
[_wp(0, 0), _wp(0, 0.01), _wp(0.01, 0.01)],
|
||||
minTurnRadius: params.minTurnRadius,
|
||||
maxClimbRate: params.maxClimbRate,
|
||||
maxDescentRate: params.maxDescentRate,
|
||||
);
|
||||
|
||||
expect(geometry.vertexBad[1], isFalse);
|
||||
// Gerade -> Bogen -> Gerade.
|
||||
expect(geometry.segments, hasLength(3));
|
||||
expect(geometry.segments[1].points.length, greaterThan(2));
|
||||
});
|
||||
|
||||
test('scharfe Kehrtwende ueberschreitet den maximalen Kurswinkel', () {
|
||||
final geometry = buildRouteGeometry(
|
||||
[_wp(0, 0), _wp(0, 0.01), _wp(0, 0.001)],
|
||||
minTurnRadius: params.minTurnRadius,
|
||||
maxClimbRate: params.maxClimbRate,
|
||||
maxDescentRate: params.maxDescentRate,
|
||||
);
|
||||
|
||||
expect(geometry.vertexBad[1], isTrue);
|
||||
});
|
||||
|
||||
test('zu steiler Sinkflug markiert das Geradenstueck als bad', () {
|
||||
// ~1113 m horizontal bei 15 m/s ~ 74s Flugzeit; 500 m Sinken darin
|
||||
// ergibt eine Sinkrate weit ueber der maxDescentRate von 6 m/s.
|
||||
final geometry = buildRouteGeometry(
|
||||
[_wp(0, 0, alt: 500), _wp(0, 0.01, alt: 0)],
|
||||
minTurnRadius: params.minTurnRadius,
|
||||
maxClimbRate: params.maxClimbRate,
|
||||
maxDescentRate: params.maxDescentRate,
|
||||
);
|
||||
|
||||
expect(geometry.segments.single.bad, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user