| 1 | import 'dart:async'; |
| 2 | import 'dart:math'; |
| 3 | import 'package:openapi/openapi.dart'; |
| 4 | import 'package:flutter/material.dart'; |
| 5 | import 'package:flutter_map/flutter_map.dart'; |
| 6 | import 'package:flutter_map_animations/flutter_map_animations.dart'; |
| 7 | import 'package:latlong2/latlong.dart'; |
| 8 | import 'package:aero/constants/map.dart'; |
| 9 | |
| 10 | MapBounds getVisibleMapBounds(AnimatedMapController |
| 11 | final mapState = mapController.mapController. |
| 12 | final center = mapState.center; |
| 13 | final zoom = mapState.zoom; |
| 14 | |
| 15 | // Calculate the visible bounds based on zoom level |
| 16 | // At zoom level 1, the world is roughly 180 degrees wide |
| 17 | // Each zoom level doubles the detail (halves the visible area) |
| 18 | final degreesPerPixel = 360 / |
| 19 | final screenWidth = 400; |
| 20 | final screenHeight = 400; |
| 21 | |
| 22 | final latSpan = (screenHeight * degreesPerPixel) / |
| 23 | final lngSpan = (screenWidth * degreesPerPixel) / |
| 24 | |
| 25 | // Calculate bounds |
| 26 | double minLat = center.latitude - |
| 27 | double maxLat = center.latitude + |
| 28 | double minLng = center.longitude - |
| 29 | double maxLng = center.longitude + |
| 30 | |
| 31 | // Apply reasonable bounds to prevent queries that are too large |
| 32 | // Maximum span: 30 degrees (roughly 3000km at equator) |
| 33 | const maxSpan = 30.0; |
| 34 | |
| 35 | if (maxLat - minLat > maxSpan) { |
| 36 | final centerLat = (maxLat + minLat) / |
| 37 | minLat = centerLat - maxSpan / 2; |
| 38 | maxLat = centerLat + maxSpan / 2; |
| 39 | } |
| 40 | |
| 41 | if (maxLng - minLng > maxSpan) { |
| 42 | final centerLng = (maxLng + minLng) / |
| 43 | minLng = centerLng - maxSpan / 2; |
| 44 | maxLng = centerLng + maxSpan / 2; |
| 45 | } |
| 46 | |
| 47 | // Ensure bounds are within world limits |
| 48 | minLat = minLat.clamp(-90.0 |
| 49 | maxLat = maxLat.clamp(-90.0 |
| 50 | minLng = minLng.clamp(-180.0 |
| 51 | maxLng = maxLng.clamp(-180.0 |
| 52 | |
| 53 | return MapBounds( |
| 54 | minLat: minLat, |
| 55 | maxLat: maxLat, |
| 56 | minLng: minLng, |
| 57 | maxLng: maxLng, |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | class MapBounds { |
| 62 | final double minLat; |
| 63 | final double maxLat; |
| 64 | final double minLng; |
| 65 | final double maxLng; |
| 66 | |
| 67 | const MapBounds({ |
| 68 | required this.minLat, |
| 69 | required this.maxLat, |
| 70 | required this.minLng, |
| 71 | required this.maxLng, |
| 72 | }); |
| 73 | |
| 74 | @override |
| 75 | String toString() { |
| 76 | return 'MapBounds(minLat: $minLat, maxLat: |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | class FlightMap extends StatefulWidget |
| 81 | final AnimatedMapController mapController; |
| 82 | final AirportEntity? departureAirport; |
| 83 | final AirportEntity? arrivalAirport; |
| 84 | final List<LatLng |
| 85 | final List<LatLng |
| 86 | final double? currentHeading; |
| 87 | final Function(FlightsControllerGetFlightsInBoundsV1200ResponseFlightsInner |
| 88 | onAircraftSelected; |
| 89 | final bool isFlightInAir; |
| 90 | final List<AirportEntity |
| 91 | final void Function |
| 92 | double minLat, |
| 93 | double maxLat, |
| 94 | double minLng, |
| 95 | double maxLng, |
| 96 | )? onBoundsChanged; |
| 97 | final void Function |
| 98 | |
| 99 | const FlightMap({ |
| 100 | super.key, |
| 101 | required this.mapController, |
| 102 | this.arrivalAirport, |
| 103 | this.departureAirport, |
| 104 | required this.coordinates, |
| 105 | this.flightTrackPoints = const |
| 106 | this.currentHeading, |
| 107 | this.onAircraftSelected, |
| 108 | this.isFlightInAir = false, |
| 109 | this.airports = const |
| 110 | this.onBoundsChanged, |
| 111 | this.onAirportTap, |
| 112 | }); |
| 113 | |
| 114 | @override |
| 115 | State<FlightMap> createState |
| 116 | } |
| 117 | |
| 118 | class _FlightMapState extends State |
| 119 | Timer? _boundsDebounce; |
| 120 | |
| 121 | @override |
| 122 | void initState() { |
| 123 | super.initState(); |
| 124 | } |
| 125 | |
| 126 | @override |
| 127 | void dispose() { |
| 128 | _boundsDebounce?.cancel(); |
| 129 | super.dispose(); |
| 130 | } |
| 131 | |
| 132 | /// Debounced map-movement handler. Once the user has zoomed in past the |
| 133 | /// threshold, panning triggers a backend query for airports in the new bounds. |
| 134 | void _handlePositionChanged(MapCamera |
| 135 | if (widget.onBoundsChanged == null) return |
| 136 | _boundsDebounce?.cancel(); |
| 137 | _boundsDebounce = Timer(const |
| 138 | if (camera.zoom < kAirportQueryMinZoom) return |
| 139 | final bounds = getVisibleMapBounds(widget |
| 140 | widget.onBoundsChanged!( |
| 141 | bounds.minLat, |
| 142 | bounds.maxLat, |
| 143 | bounds.minLng, |
| 144 | bounds.maxLng, |
| 145 | ); |
| 146 | }); |
| 147 | } |
| 148 | |
| 149 | @override |
| 150 | Widget build(BuildContext |
| 151 | return Stack( |
| 152 | children: [ |
| 153 | FlutterMap( |
| 154 | mapController: widget.mapController.mapController, |
| 155 | options: MapOptions( |
| 156 | initialCenter: |
| 157 | const LatLng(28.086815545374254, |
| 158 | initialZoom: 5, |
| 159 | onPositionChanged: _handlePositionChanged, |
| 160 | cameraConstraint: CameraConstraint.containLatitude()) |
| 161 | children: [ |
| 162 | openStreetMapTileLayer, |
| 163 | if (widget.airports.isNotEmpty) |
| 164 | MarkerLayer( |
| 165 | markers: widget.airports |
| 166 | .map( |
| 167 | (airport) => Marker( |
| 168 | point: LatLng( |
| 169 | double.parse(airport.lat) |
| 170 | double.parse(airport.long) |
| 171 | ), |
| 172 | width: 28, |
| 173 | height: 28, |
| 174 | child: GestureDetector( |
| 175 | behavior: HitTestBehavior.opaque, |
| 176 | onTap: () => widget.onAirportTap?.call |
| 177 | child: Center( |
| 178 | child: Container( |
| 179 | width: 14, |
| 180 | height: 14, |
| 181 | decoration: BoxDecoration( |
| 182 | color: Colors.blueGrey.shade700 |
| 183 | shape: BoxShape.circle, |
| 184 | border: |
| 185 | Border.all(color: |
| 186 | boxShadow: [ |
| 187 | BoxShadow( |
| 188 | color: Colors.black.withOpacity |
| 189 | blurRadius: 2, |
| 190 | ), |
| 191 | ], |
| 192 | ), |
| 193 | ), |
| 194 | ), |
| 195 | ), |
| 196 | ), |
| 197 | ) |
| 198 | .toList(), |
| 199 | ), |
| 200 | MarkerLayer( |
| 201 | markers: [ |
| 202 | if (widget.arrivalAirport != null) |
| 203 | Marker( |
| 204 | point: LatLng(double. |
| 205 | double.parse(widget.arrivalAirport |
| 206 | width: 18, |
| 207 | height: 18, |
| 208 | child: Container( |
| 209 | decoration: BoxDecoration( |
| 210 | color: Colors.redAccent, |
| 211 | shape: BoxShape.circle, |
| 212 | border: Border.all(color |
| 213 | boxShadow: [ |
| 214 | BoxShadow( |
| 215 | color: Colors.black.withOpacity |
| 216 | blurRadius: 4, |
| 217 | ), |
| 218 | ], |
| 219 | ), |
| 220 | ), |
| 221 | ), |
| 222 | if (widget.departureAirport != null) |
| 223 | Marker( |
| 224 | point: LatLng(double. |
| 225 | double.parse(widget.departureAirport |
| 226 | width: 18, |
| 227 | height: 18, |
| 228 | child: Container( |
| 229 | decoration: BoxDecoration( |
| 230 | color: Colors.green, |
| 231 | shape: BoxShape.circle, |
| 232 | border: Border.all(color |
| 233 | boxShadow: [ |
| 234 | BoxShadow( |
| 235 | color: Colors.black.withOpacity |
| 236 | blurRadius: 4, |
| 237 | ), |
| 238 | ], |
| 239 | ), |
| 240 | ), |
| 241 | ), |
| 242 | ], |
| 243 | ), |
| 244 | if (widget.arrivalAirport != null && |
| 245 | widget.departureAirport != null && |
| 246 | widget.flightTrackPoints.isEmpty) |
| 247 | PolylineLayer( |
| 248 | polylines: [ |
| 249 | Polyline( |
| 250 | points: [ |
| 251 | LatLng( |
| 252 | double.parse(widget.arrivalAirport |
| 253 | double.parse(widget.arrivalAirport |
| 254 | ), |
| 255 | LatLng( |
| 256 | double.parse(widget.departureAirport |
| 257 | double.parse(widget.departureAirport |
| 258 | ), |
| 259 | ], |
| 260 | color: Colors.blueAccent, |
| 261 | strokeWidth: 4, |
| 262 | borderStrokeWidth: 2, |
| 263 | borderColor: Colors.white70, |
| 264 | ), |
| 265 | ], |
| 266 | ), |
| 267 | if (widget.flightTrackPoints.isEmpty && |
| 268 | widget.coordinates.isNotEmpty) |
| 269 | PolylineLayer( |
| 270 | polylines: [ |
| 271 | Polyline( |
| 272 | points: widget.coordinates.map((e) |
| 273 | color: Colors.blueAccent, |
| 274 | strokeWidth: 4, |
| 275 | borderStrokeWidth: 2, |
| 276 | borderColor: Colors.white70, |
| 277 | pattern: StrokePattern.dashed(segments |
| 278 | ], |
| 279 | ), |
| 280 | if (widget.flightTrackPoints.isNotEmpty) |
| 281 | PolylineLayer( |
| 282 | drawInSingleWorld: false, |
| 283 | polylines: [ |
| 284 | Polyline( |
| 285 | points: widget.flightTrackPoints, |
| 286 | color: Colors.green, |
| 287 | strokeWidth: 3, |
| 288 | borderStrokeWidth: 1, |
| 289 | borderColor: Colors.white70, |
| 290 | strokeCap: StrokeCap.round, |
| 291 | strokeJoin: StrokeJoin.round, |
| 292 | ), |
| 293 | ], |
| 294 | ), |
| 295 | // Show moving aircraft marker only when airborne |
| 296 | if (widget.flightTrackPoints.isNotEmpty && widget. |
| 297 | MarkerLayer( |
| 298 | markers: [ |
| 299 | Marker( |
| 300 | point: widget.flightTrackPoints.last, |
| 301 | width: 30, |
| 302 | height: 30, |
| 303 | child: Transform.rotate( |
| 304 | angle: widget.currentHeading != null |
| 305 | ? (widget.currentHeading! * pi / |
| 306 | : 0, |
| 307 | child: Container( |
| 308 | decoration: BoxDecoration( |
| 309 | color: Colors.green, |
| 310 | shape: BoxShape.circle, |
| 311 | border: Border.all( |
| 312 | color: Colors.white, |
| 313 | width: 2, |
| 314 | ), |
| 315 | ), |
| 316 | child: Icon( |
| 317 | Icons.flight, |
| 318 | color: Colors.white, |
| 319 | size: 16, |
| 320 | ), |
| 321 | ), |
| 322 | ), |
| 323 | ), |
| 324 | ], |
| 325 | ), |
| 326 | // When not airborne, show static origin/destination markers from track points |
| 327 | if (widget.flightTrackPoints.isNotEmpty && ! |
| 328 | MarkerLayer( |
| 329 | markers: [ |
| 330 | Marker( |
| 331 | point: widget.flightTrackPoints.first, |
| 332 | width: 18, |
| 333 | height: 18, |
| 334 | child: Container( |
| 335 | decoration: BoxDecoration( |
| 336 | color: Colors.green, |
| 337 | shape: BoxShape.circle, |
| 338 | border: Border.all(color |
| 339 | boxShadow: [ |
| 340 | BoxShadow( |
| 341 | color: Colors.black.withOpacity |
| 342 | blurRadius: 4, |
| 343 | ), |
| 344 | ], |
| 345 | ), |
| 346 | ), |
| 347 | ), |
| 348 | Marker( |
| 349 | point: widget.flightTrackPoints.last, |
| 350 | width: 18, |
| 351 | height: 18, |
| 352 | child: Container( |
| 353 | decoration: BoxDecoration( |
| 354 | color: Colors.redAccent, |
| 355 | shape: BoxShape.circle, |
| 356 | border: Border.all(color |
| 357 | boxShadow: [ |
| 358 | BoxShadow( |
| 359 | color: Colors.black.withOpacity |
| 360 | blurRadius: 4, |
| 361 | ), |
| 362 | ], |
| 363 | ), |
| 364 | ), |
| 365 | ), |
| 366 | ], |
| 367 | ), |
| 368 | ], |
| 369 | ), |
| 370 | ], |
| 371 | ); |
| 372 | } |
| 373 | } |