import 'dart:async'; import 'dart:math' as math; import 'package:flutter/gestures.dart' show PointerDeviceKind, PointerPanZoomEndEvent, PointerPanZoomStartEvent, PointerPanZoomUpdateEvent, PointerScaleEvent, PointerScrollEvent; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:rra_app/pages/map/routing/osrm_routing_service.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_painter.dart'; import 'package:rra_app/pages/map/widgets/toolbar.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; class MapPage extends StatefulWidget { static final GoRoute route = GoRoute( path: '/', builder: (context, state) => const MapPage(), ); const MapPage({super.key}); @override State createState() => _MapPageState(); } class _MapPageState extends State with TickerProviderStateMixin { static const _london = LatLng(51.5074, -0.1278); static const _routeColor = Color(0xFFE02425); static const _tileSize = 256.0; static const _minZoom = 2.0; static const _maxZoom = 24.0; static const _minTileZoom = 2; static const _maxTileZoom = 22; static const _tileOverscan = 1; static const _interactionIdleDelay = Duration(milliseconds: 280); static const _fpsWindowSize = 90; static const _fpsUpdateInterval = Duration(milliseconds: 240); static const _tileUrlTemplate = 'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q'; final GlobalKey _mapKey = GlobalKey(); final OsrmRoutingService _routingService = const OsrmRoutingService(); final List _points = []; List _resolvedRoute = []; LatLng _mapCenter = _london; double _zoom = 12; double _targetZoom = 12; int _activeTileZoom = 12; int? _fallbackTileZoom; Offset _zoomFocal = Offset.zero; Size _mapSize = Size.zero; double _devicePixelRatio = 2.0; late final Ticker _zoomTicker; double _lastPinchScale = 1.0; Offset _pendingPanDelta = Offset.zero; bool _panFrameScheduled = false; Offset _pendingPanZoomDelta = Offset.zero; double _pendingPanZoomZoomDelta = 0.0; Offset _pendingPanZoomFocal = Offset.zero; bool _panZoomFrameScheduled = false; bool _isAddPointArmed = false; bool _isResolvingRoute = false; String? _routeError; int _routeRequestId = 0; int? _draggingInsertPointIndex; Timer? _interactionIdleTimer; late final TilePyramidPainter _pyramidPainter; final List _frameMsWindow = []; late final Stopwatch _fpsUpdateStopwatch; double _displayFps = 0; double _displayFrameMs = 0; @override void initState() { super.initState(); _zoomTicker = createTicker(_onZoomTick); _pyramidPainter = TilePyramidPainter( tileUrlTemplate: _tileUrlTemplate, tileSize: _tileSize, minTileZoom: _minTileZoom, maxTileZoom: _maxTileZoom, tileOverscan: _tileOverscan, ); _fpsUpdateStopwatch = Stopwatch()..start(); SchedulerBinding.instance.addTimingsCallback(_onFrameTimings); } @override void dispose() { _zoomTicker.dispose(); _pyramidPainter.dispose(); TilePyramidCache.clear(); SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); _interactionIdleTimer?.cancel(); super.dispose(); } void _onFrameTimings(List timings) { for (final timing in timings) { _frameMsWindow.add(timing.totalSpan.inMicroseconds / 1000.0); } if (_frameMsWindow.length > _fpsWindowSize) { _frameMsWindow.removeRange(0, _frameMsWindow.length - _fpsWindowSize); } if (_fpsUpdateStopwatch.elapsed < _fpsUpdateInterval || !mounted) return; _fpsUpdateStopwatch.reset(); if (_frameMsWindow.isEmpty) return; var total = 0.0; for (final ms in _frameMsWindow) { total += ms; } final avgMs = total / _frameMsWindow.length; final fps = avgMs <= 0 ? 0.0 : 1000.0 / avgMs; if ((fps - _displayFps).abs() < 0.2 && (avgMs - _displayFrameMs).abs() < 0.2) { return; } setState(() { _displayFps = fps; _displayFrameMs = avgMs; }); } void _addPoint(LatLng point) { setState(() { _points.add(point); }); _resolveRoute(); } void _undoPoint() { if (_points.isEmpty) return; setState(() { _points.removeLast(); }); _resolveRoute(); } void _clearPoints() { if (_points.isEmpty) return; setState(() { _points.clear(); _resolvedRoute = []; _routeError = null; _isResolvingRoute = false; _draggingInsertPointIndex = null; }); } Future _resolveRoute() async { if (_points.length < 2) { setState(() { _resolvedRoute = []; _routeError = null; _isResolvingRoute = false; }); return; } final requestId = ++_routeRequestId; setState(() { _isResolvingRoute = true; _routeError = null; }); try { final route = await _routingService.fetchRoute(_points); if (!mounted || requestId != _routeRequestId) return; setState(() { _resolvedRoute = route; _isResolvingRoute = false; _routeError = null; }); } catch (_) { if (!mounted || requestId != _routeRequestId) return; setState(() { _resolvedRoute = _points.toList(); _isResolvingRoute = false; _routeError = 'Routing unavailable. Showing straight-line fallback.'; }); } } math.Point _latLngToWorld(LatLng latLng, double zoom) { final scale = _tileSize * math.pow(2, zoom).toDouble(); final x = (latLng.longitude + 180.0) / 360.0 * scale; final sinLat = math.sin(latLng.latitude * math.pi / 180.0); final y = (0.5 - math.log((1 + sinLat) / (1 - sinLat)) / (4 * math.pi)) * scale; return math.Point(x, y); } LatLng _worldToLatLng(math.Point world, double zoom) { final scale = _tileSize * math.pow(2, zoom).toDouble(); final lon = world.x / scale * 360.0 - 180.0; final n = math.pi - 2.0 * math.pi * world.y / scale; final lat = 180.0 / math.pi * math.atan(0.5 * (math.exp(n) - math.exp(-n))); return LatLng(lat, lon); } Offset _latLngToScreen(LatLng latLng) { final centerWorld = _latLngToWorld(_mapCenter, _zoom); final pointWorld = _latLngToWorld(latLng, _zoom); return Offset( pointWorld.x - centerWorld.x + _mapSize.width / 2, pointWorld.y - centerWorld.y + _mapSize.height / 2, ); } LatLng _screenToLatLng(Offset screenPoint) { final centerWorld = _latLngToWorld(_mapCenter, _zoom); final world = math.Point( centerWorld.x + (screenPoint.dx - _mapSize.width / 2), centerWorld.y + (screenPoint.dy - _mapSize.height / 2), ); return _worldToLatLng(world, _zoom); } math.Point _clampWorldY(math.Point world, double zoom) { final scale = _tileSize * math.pow(2, zoom).toDouble(); final maxY = math.max(0.0, scale - 1); return math.Point(world.x, world.y.clamp(0.0, maxY).toDouble()); } void _panMap(Offset delta, {bool notify = true}) { if (_mapSize == Size.zero) return; final centerWorld = _latLngToWorld(_mapCenter, _zoom); final nextCenter = _clampWorldY( math.Point(centerWorld.x - delta.dx, centerWorld.y - delta.dy), _zoom, ); void apply() => _mapCenter = _worldToLatLng(nextCenter, _zoom); if (notify) { setState(apply); } else { apply(); } } int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) { final ratio = devicePixelRatio.clamp(1.0, 4.0).toDouble(); final adjusted = zoomValue + (math.log(ratio / 2.0) / math.ln2); return adjusted.floor().clamp(_minTileZoom, _maxTileZoom); } void _setZoomAroundFocal(double requestedZoom, Offset focal) { if (_mapSize == Size.zero) return; final nextZoom = requestedZoom.clamp(_minZoom, _maxZoom); if ((nextZoom - _zoom).abs() < 0.0001) return; final previousTileZoom = _activeTileZoom; final focalLatLng = _screenToLatLng(focal); final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom); final centerWorldNext = _clampWorldY( math.Point( focalWorldNext.x - (focal.dx - _mapSize.width / 2), focalWorldNext.y - (focal.dy - _mapSize.height / 2), ), nextZoom, ); _zoom = nextZoom; _mapCenter = _worldToLatLng(centerWorldNext, nextZoom); _activeTileZoom = _idealTileZoom( _zoom, devicePixelRatio: _devicePixelRatio, ); if (_activeTileZoom != previousTileZoom) { _fallbackTileZoom = previousTileZoom; } } void _onZoomTick(Duration _) { if (!mounted) return; setState(() { const t = 0.2; final newZoom = _zoom + (_targetZoom - _zoom) * t; final done = (_targetZoom - newZoom).abs() < 0.001; _setZoomAroundFocal(done ? _targetZoom : newZoom, _zoomFocal); if (done) _zoomTicker.stop(); }); } void _queuePan(Offset delta) { if (delta == Offset.zero) return; _noteInteraction(); _pendingPanDelta += delta; if (_panFrameScheduled) return; _panFrameScheduled = true; SchedulerBinding.instance.scheduleFrameCallback((_) { _panFrameScheduled = false; if (!mounted) return; final pending = _pendingPanDelta; _pendingPanDelta = Offset.zero; if (pending == Offset.zero) return; setState(() => _panMap(pending, notify: false)); }); } void _queuePanZoomUpdate({ required Offset panDelta, required double zoomDelta, required Offset focal, }) { if (panDelta == Offset.zero && zoomDelta.abs() < 0.0001) return; _noteInteraction(); _pendingPanZoomDelta += panDelta; _pendingPanZoomZoomDelta += zoomDelta; _pendingPanZoomFocal = focal; if (_panZoomFrameScheduled) return; _panZoomFrameScheduled = true; SchedulerBinding.instance.scheduleFrameCallback((_) { _panZoomFrameScheduled = false; if (!mounted) return; final pendingPan = _pendingPanZoomDelta; final pendingZoom = _pendingPanZoomZoomDelta; final pendingFocal = _pendingPanZoomFocal; _pendingPanZoomDelta = Offset.zero; _pendingPanZoomZoomDelta = 0.0; if (pendingPan == Offset.zero && pendingZoom.abs() < 0.0001) return; setState(() { if (pendingZoom.abs() >= 0.0001) { _setZoomAroundFocal(_zoom + pendingZoom, pendingFocal); _targetZoom = _zoom; } if (pendingPan != Offset.zero) { _panMap(pendingPan, notify: false); } }); }); } void _noteInteraction() { _interactionIdleTimer?.cancel(); _interactionIdleTimer = Timer(_interactionIdleDelay, () {}); } void _updatePainterViewport() { final cw = _latLngToWorld(_mapCenter, _activeTileZoom.toDouble()); _pyramidPainter ..mapWidth = _mapSize.width ..mapHeight = _mapSize.height ..zoom = _zoom ..centerWorldX = cw.x ..centerWorldY = cw.y ..activeTileZoom = _activeTileZoom ..fallbackTileZoom = _fallbackTileZoom ..devicePixelRatio = _devicePixelRatio ..interactionActive = _interactionIdleTimer?.isActive ?? false; _pyramidPainter.markDirty(); } LatLng _segmentMidpoint(LatLng a, LatLng b) { return LatLng( (a.latitude + b.latitude) / 2, (a.longitude + b.longitude) / 2, ); } void _startInsertPointDrag(int segmentIndex, Offset globalPosition) { if (segmentIndex < 0 || segmentIndex >= _points.length - 1) return; final midpoint = _segmentMidpoint( _points[segmentIndex], _points[segmentIndex + 1], ); final insertIndex = segmentIndex + 1; setState(() { _points.insert(insertIndex, midpoint); _draggingInsertPointIndex = insertIndex; _isAddPointArmed = false; _resolvedRoute = _points.toList(); _isResolvingRoute = false; _routeError = null; }); _updateInsertPointDrag(globalPosition); } Offset? _globalToMapLocal(Offset globalPosition) { final context = _mapKey.currentContext; if (context == null) return null; final box = context.findRenderObject(); if (box is! RenderBox) return null; return box.globalToLocal(globalPosition); } void _updateInsertPointDrag(Offset globalPosition) { final index = _draggingInsertPointIndex; if (index == null || index < 0 || index >= _points.length) return; final local = _globalToMapLocal(globalPosition); if (local == null) return; setState(() { _points[index] = _screenToLatLng(local); _resolvedRoute = _points.toList(); }); } void _finishInsertPointDrag() { if (_draggingInsertPointIndex == null) return; setState(() => _draggingInsertPointIndex = null); _resolveRoute(); } List _buildPointWidgets() { final widgets = []; for (var i = 0; i < _points.length; i++) { final screen = _latLngToScreen(_points[i]); widgets.add( Positioned( left: screen.dx - 17, top: screen.dy - 17, child: IgnorePointer( child: Container( width: 34, height: 34, decoration: BoxDecoration( color: i == 0 ? const Color(0xFF0F9D58) : (i == _points.length - 1 ? const Color(0xFFE11D48) : const Color(0xFF2563EB)), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), child: const Icon(Icons.circle, size: 10, color: Colors.white), ), ), ), ); } return widgets; } List _buildInsertHandles() { if (_points.length < 2) return const []; final handles = []; for (var i = 0; i < _points.length - 1; i++) { final midpoint = _segmentMidpoint(_points[i], _points[i + 1]); final screen = _latLngToScreen(midpoint); handles.add( Positioned( left: screen.dx - 22, top: screen.dy - 22, child: GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: (details) => _startInsertPointDrag(i, details.globalPosition), onPanUpdate: (details) => _updateInsertPointDrag(details.globalPosition), onPanEnd: (_) => _finishInsertPointDrag(), onPanCancel: _finishInsertPointDrag, child: SizedBox( width: 44, height: 44, child: Center( child: Container( width: 30, height: 30, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.95), shape: BoxShape.circle, border: Border.all( color: const Color(0xFF111827), width: 1.5, ), ), child: const Icon( Icons.open_with, size: 14, color: Color(0xFF111827), ), ), ), ), ), ), ); } return handles; } @override Widget build(BuildContext context) { final dpr = MediaQuery.devicePixelRatioOf(context); if ((dpr - _devicePixelRatio).abs() > 0.01) { _devicePixelRatio = dpr; } final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points; final routeScreenPoints = routePoints.map(_latLngToScreen).toList(); return Scaffold( body: Stack( children: [ Positioned.fill( child: LayoutBuilder( builder: (context, constraints) { _mapSize = constraints.biggest; _updatePainterViewport(); return Listener( onPointerSignal: (event) { if (event is PointerScaleEvent) { _noteInteraction(); setState(() { _setZoomAroundFocal( _zoom + math.log(event.scale) / math.ln2, event.localPosition, ); _targetZoom = _zoom; }); } else if (event is PointerScrollEvent) { if (event.kind == PointerDeviceKind.trackpad) { _queuePan(event.scrollDelta); } else { _noteInteraction(); final zoomStep = math.log(1.1) / math.ln2; _zoomFocal = event.localPosition; _targetZoom = (_targetZoom + (event.scrollDelta.dy < 0 ? zoomStep : -zoomStep)) .clamp(_minZoom, _maxZoom); if (!_zoomTicker.isTicking) _zoomTicker.start(); } } }, onPointerPanZoomStart: (PointerPanZoomStartEvent event) { _lastPinchScale = 1.0; _pendingPanZoomDelta = Offset.zero; _pendingPanZoomZoomDelta = 0.0; _pendingPanZoomFocal = event.localPosition; _noteInteraction(); }, onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) { final scaleChange = event.scale / _lastPinchScale; _lastPinchScale = event.scale; final dz = (scaleChange - 1.0).abs() > 0.001 ? math.log(scaleChange) / math.ln2 : 0.0; _queuePanZoomUpdate( panDelta: event.panDelta, zoomDelta: dz, focal: event.localPosition, ); }, onPointerPanZoomEnd: (PointerPanZoomEndEvent event) { _noteInteraction(); }, child: GestureDetector( key: _mapKey, behavior: HitTestBehavior.opaque, onPanUpdate: (details) { if (_draggingInsertPointIndex != null) return; _queuePan(details.delta); }, onTapUp: (details) { if (!_isAddPointArmed || _draggingInsertPointIndex != null) { return; } _addPoint(_screenToLatLng(details.localPosition)); }, child: ClipRect( child: Stack( children: [ const Positioned.fill( child: ColoredBox(color: Color(0xFFECECEC)), ), Positioned.fill( child: RepaintBoundary( child: CustomPaint(painter: _pyramidPainter), ), ), Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _RoutePainter( points: routeScreenPoints, routeColor: _routeColor, ), ), ), ), ..._buildPointWidgets(), ..._buildInsertHandles(), ], ), ), ), ); }, ), ), SafeArea( child: Align( alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.all(10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ MapToolbar( isAddPointArmed: _isAddPointArmed, pointCount: _points.length, onAddPointPressed: () { setState(() { _isAddPointArmed = !_isAddPointArmed; }); }, onUndoPressed: _undoPoint, onClearPressed: _clearPoints, ), const SizedBox(width: 8), shadcn.OutlinedContainer( boxShadow: const [ BoxShadow( color: Color(0x26000000), blurRadius: 4, spreadRadius: 2, ), ], padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), child: _StatusPanel( isAddPointArmed: _isAddPointArmed, isResolvingRoute: _isResolvingRoute, routeError: _routeError, hasRoute: _points.length > 1 && _routeError == null, ), ), ], ), ), ), ), SafeArea( child: Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.all(10), child: IgnorePointer( child: _PerformanceHud( fps: _displayFps, frameMs: _displayFrameMs, ), ), ), ), ), SafeArea( child: Align( alignment: Alignment.bottomLeft, child: Padding( padding: const EdgeInsets.all(10), child: const IgnorePointer(child: _HudCornerNotice()), ), ), ), ], ), ); } } class _RoutePainter extends CustomPainter { const _RoutePainter({required this.points, required this.routeColor}); final List points; final Color routeColor; @override void paint(Canvas canvas, Size size) { if (points.length < 2) return; final path = Path()..moveTo(points.first.dx, points.first.dy); for (var i = 1; i < points.length; i++) { path.lineTo(points[i].dx, points[i].dy); } final white = Paint() ..color = Colors.white ..strokeWidth = 9 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; final route = Paint() ..color = routeColor ..strokeWidth = 5 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; canvas.drawPath(path, white); canvas.drawPath(path, route); } @override bool shouldRepaint(covariant _RoutePainter oldDelegate) { return oldDelegate.points != points || oldDelegate.routeColor != routeColor; } } class _HudCornerNotice extends StatelessWidget { const _HudCornerNotice(); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(4), ), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Text( '© 2026 IMBENJI.NET LTD - Roadbound Map Maker\n' 'Alpha v2602-7a - This is a public alpha release;\n' 'Expect bugs, missing features, and breaking changes.', style: TextStyle( fontSize: 12, height: 1.35, fontWeight: FontWeight.w700, color: Color(0xFF525252), fontFamily: 'monospace', ), ), ), ); } } class _PerformanceHud extends StatelessWidget { const _PerformanceHud({required this.fps, required this.frameMs}); final double fps; final double frameMs; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(10), boxShadow: const [ BoxShadow(color: Color(0x26000000), blurRadius: 4, spreadRadius: 2), ], ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '${fps.toStringAsFixed(1)} FPS', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF111827), ), ), Text( '${frameMs.toStringAsFixed(1)} ms', style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF4B5563), ), ), ], ), ), ); } } class _StatusPanel extends StatelessWidget { const _StatusPanel({ required this.isAddPointArmed, required this.isResolvingRoute, required this.routeError, required this.hasRoute, }); final bool isAddPointArmed; final bool isResolvingRoute; final String? routeError; final bool hasRoute; @override Widget build(BuildContext context) { return SizedBox( width: 220, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ shadcn.Icon(shadcn.LucideIcons.mapPin, size: 14), SizedBox(width: 8), Expanded( child: Text( 'London Route Builder', overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 8), if (isResolvingRoute) const Row( children: [ SizedBox( width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 8), Expanded( child: Text( 'Resolving route...', overflow: TextOverflow.ellipsis, ), ), ], ) else if (hasRoute) const Text( 'Route resolved with OSRM', style: TextStyle(fontSize: 12), ), if (routeError != null) ...[ const SizedBox(height: 4), Text( routeError!, style: const TextStyle(fontSize: 12, color: Color(0xFFB91C1C)), ), ], const SizedBox(height: 4), Text( isAddPointArmed ? 'Tap anywhere on the map to add a point' : 'Drag midpoint handles to insert points', style: const TextStyle(fontSize: 12), ), ], ), ); } }