import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart' show kDebugMode; 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:google_fonts/google_fonts.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:rra_app/pages/map/constants.dart'; import 'package:rra_app/pages/map/routing/osrm_routing_service.dart'; import 'package:rra_app/pages/map/tfl/tfl_bus_stop_cache.dart'; import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart'; import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart'; import 'package:rra_app/pages/map/vector/mvt_parser.dart' show MvtGeometryType; 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 = '$kBackendBaseUrl/tiles/{z}/{x}/{y}'; final GlobalKey _mapKey = GlobalKey(); final OsrmRoutingService _routingService = const OsrmRoutingService(); final List _points = []; final List _pointLabels = []; 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; double _lastScaleValue = 1.0; Offset _lastScaleFocal = Offset.zero; 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; bool _isInspectingFeature = false; String? _routeError; int _routeRequestId = 0; int? _draggingInsertPointIndex; Timer? _interactionIdleTimer; MapDebugHit? _debugFeatureHit; final TflBusStopService _busStopService = TflBusStopService(); List _busStops = const []; Timer? _busStopFetchTimer; LatLng? _lastFetchedCenter; double? _lastFetchedZoom; static const _busStopMinZoom = 14.0; 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(); MapboxVectorTileCache.clear(); TilePyramidCache.clear(); SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); _interactionIdleTimer?.cancel(); _busStopFetchTimer?.cancel(); super.dispose(); } @override void reassemble() { super.reassemble(); MapboxVectorTileCache.clear(); TilePyramidCache.clear(); _pyramidPainter.markDirty(); if (!mounted) return; setState(() { _fallbackTileZoom = null; }); } 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, {String? label}) { setState(() { _points.add(point); _pointLabels.add(label); }); _resolveRoute(); } void _undoPoint() { if (_points.isEmpty) return; setState(() { _points.removeLast(); _pointLabels.removeLast(); }); _resolveRoute(); } void _clearPoints() { if (_points.isEmpty) return; setState(() { _points.clear(); _pointLabels.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) { // Raster tiles are already rendered at device-pixel-ratio-aware // resolutions, so boosting source tile zoom by DPR double-counts detail // and explodes the number of tiles mobile has to keep visible at once. // We still snap by logical zoom so the next tile level starts loading as // you cross the midpoint, instead of waiting for the next whole integer. final snapped = zoomValue.round(); return snapped.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); 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()); final interactionActive = _interactionIdleTimer?.isActive ?? false; final viewportChanged = _pyramidPainter.mapWidth != _mapSize.width || _pyramidPainter.mapHeight != _mapSize.height || _pyramidPainter.zoom != _zoom || _pyramidPainter.centerWorldX != cw.x || _pyramidPainter.centerWorldY != cw.y || _pyramidPainter.activeTileZoom != _activeTileZoom || _pyramidPainter.fallbackTileZoom != _fallbackTileZoom || _pyramidPainter.devicePixelRatio != _devicePixelRatio || _pyramidPainter.interactionActive != interactionActive; _pyramidPainter ..mapWidth = _mapSize.width ..mapHeight = _mapSize.height ..zoom = _zoom ..centerWorldX = cw.x ..centerWorldY = cw.y ..activeTileZoom = _activeTileZoom ..fallbackTileZoom = _fallbackTileZoom ..devicePixelRatio = _devicePixelRatio ..interactionActive = interactionActive; if (viewportChanged) { _pyramidPainter.markDirty(); } _maybeScheduleBusStopFetch(); } 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); _pointLabels.insert(insertIndex, null); _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(); } Future _inspectFeatureAt(Offset localPosition) async { setState(() { _isInspectingFeature = true; }); final hit = await _pyramidPainter.inspectFeatureAt(localPosition); if (!mounted) return; setState(() { _debugFeatureHit = hit; _isInspectingFeature = false; }); } void _addBusStopToRoute(BusStop stop) { setState(() => _isAddPointArmed = false); _addPoint(stop.position, label: stop.name); } void _inspectBusStop(BusStop stop) { final props = { 'name': stop.name, if (stop.stopLetter != null) 'stop_letter': stop.stopLetter, if (stop.towards != null) 'towards': stop.towards, 'naptan_id': stop.id, 'lat': stop.position.latitude.toStringAsFixed(6), 'lon': stop.position.longitude.toStringAsFixed(6), }; setState(() { _debugFeatureHit = MapDebugHit( layerName: 'bus_stop', geometryType: MvtGeometryType.point, properties: props, tileZ: 0, tileX: 0, tileY: 0, distance: 0, ); _isInspectingFeature = false; }); } void _maybeScheduleBusStopFetch() { if (_zoom < _busStopMinZoom) { if (_busStops.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _busStops = const []); }); } _lastFetchedCenter = null; _lastFetchedZoom = null; return; } // dont re-debounce if the viewport hasnt moved meaningfully final zoomChanged = _lastFetchedZoom == null || (_zoom - _lastFetchedZoom!).abs() > 0.5; final centerMoved = _lastFetchedCenter == null || (_mapCenter.latitude - _lastFetchedCenter!.latitude).abs() > 0.002 || (_mapCenter.longitude - _lastFetchedCenter!.longitude).abs() > 0.002; if (!zoomChanged && !centerMoved) return; _busStopFetchTimer?.cancel(); _busStopFetchTimer = Timer(const Duration(milliseconds: 400), () { _lastFetchedCenter = _mapCenter; _lastFetchedZoom = _zoom; _fetchBusStops(); }); } Future _fetchBusStops() async { if (_zoom < _busStopMinZoom || !mounted) return; final mpp = TflBusStopService.metersPerPixel(_mapCenter.latitude, _zoom); final halfWidthM = (_mapSize.width / 2) * mpp; final halfHeightM = (_mapSize.height / 2) * mpp; const metersPerDegLat = 111320.0; final latOffset = halfHeightM / metersPerDegLat; final lonOffset = halfWidthM / (metersPerDegLat * math.cos(_mapCenter.latitude * math.pi / 180)); final sw = LatLng(_mapCenter.latitude - latOffset, _mapCenter.longitude - lonOffset); final ne = LatLng(_mapCenter.latitude + latOffset, _mapCenter.longitude + lonOffset); final cacheKey = TflBusStopCache.keyFor(sw, ne); try { await for (final stops in _busStopService.fetchInRect(sw, ne)) { if (!mounted) return; setState(() => _busStops = stops); await TflBusStopCache.store(cacheKey, stops); } } catch (_) { // silently swallow } } List _buildBusStopWidgets() { if (_zoom < _busStopMinZoom || _busStops.isEmpty) return const []; // first pass: collect visible stops + their raw screen positions final visible = []; final positions = []; for (final stop in _busStops) { final screen = _latLngToScreen(stop.position); if (screen.dx < -20 || screen.dx > _mapSize.width + 20) continue; if (screen.dy < -20 || screen.dy > _mapSize.height + 20) continue; visible.add(stop); positions.add(screen); } // repulsion pass — push overlapping markers apart in screen space // only affects rendering positions, not the underlying LatLng data final minDist = _zoom < 17.5 ? 18.0 : 32.0; const iterations = 6; for (var iter = 0; iter < iterations; iter++) { for (var i = 0; i < positions.length; i++) { for (var j = i + 1; j < positions.length; j++) { final dx = positions[j].dx - positions[i].dx; final dy = positions[j].dy - positions[i].dy; final dist = math.sqrt(dx * dx + dy * dy); if (dist >= minDist || dist < 0.001) continue; final overlap = (minDist - dist) / 2; final nx = dx / dist; final ny = dy / dist; positions[i] = Offset( positions[i].dx - nx * overlap, positions[i].dy - ny * overlap, ); positions[j] = Offset( positions[j].dx + nx * overlap, positions[j].dy + ny * overlap, ); } } } final widgets = []; for (var i = 0; i < visible.length; i++) { final stop = visible[i]; final screen = positions[i]; widgets.add( Positioned( left: screen.dx - (_zoom < 17.5 ? 8 : 15), top: screen.dy - (_zoom < 17.5 ? 8 : 15), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _isAddPointArmed ? _addBusStopToRoute(stop) : _inspectBusStop(stop), child: shadcn.Tooltip( tooltip: (context) => shadcn.TooltipContainer( child: Text(stop.name), ), alignment: Alignment.centerRight, anchorAlignment: Alignment.centerLeft, child: _BusStopMarker(stop: stop, compact: _zoom < 17.5), ), ), ), ); } return widgets; } 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(); _pyramidPainter.routeScreenPoints = routeScreenPoints; 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, onScaleStart: (details) { _lastScaleValue = 1.0; _lastScaleFocal = details.localFocalPoint; _noteInteraction(); }, onScaleUpdate: (details) { if (_draggingInsertPointIndex != null) return; final panDelta = details.localFocalPoint - _lastScaleFocal; _lastScaleFocal = details.localFocalPoint; final scaleChange = details.scale / _lastScaleValue; _lastScaleValue = details.scale; if ((scaleChange - 1.0).abs() > 0.001) { _noteInteraction(); setState(() { _setZoomAroundFocal( _zoom + math.log(scaleChange) / math.ln2, details.localFocalPoint, ); _targetZoom = _zoom; }); } if (panDelta != Offset.zero) { _queuePan(panDelta); } }, onTapUp: (details) { if (_draggingInsertPointIndex != null) return; if (_isAddPointArmed) { _addPoint(_screenToLatLng(details.localPosition)); return; } _inspectFeatureAt(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, ), ), ), ), ..._buildBusStopWidgets(), ..._buildPointWidgets(), ..._buildInsertHandles(), Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: TileLabelPainter(_pyramidPainter), ), ), ), ], ), ), ), ); }, ), ), 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: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ IgnorePointer( child: _PerformanceHud( fps: _displayFps, frameMs: _displayFrameMs, tileDebugSummary: kDebugMode ? 'z$_activeTileZoom' '${_fallbackTileZoom != null ? ' f$_fallbackTileZoom' : ''} ' 'c${TilePyramidCache.debugCacheCount} ' 'q${TilePyramidCache.debugQueueCount} ' 'i${TilePyramidCache.debugInFlightCount} ' '${TilePyramidCache.debugApproxMegabytes.toStringAsFixed(0)}MB' : null, ), ), const SizedBox(height: 6), GestureDetector( onTap: () { TilePyramidCache.clear(); MapboxVectorTileCache.clear(); _pyramidPainter.markDirty(); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.55), borderRadius: BorderRadius.circular(6), ), child: const Text( 'Clear Cache', style: TextStyle(color: Colors.white, fontSize: 11), ), ), ), const SizedBox(height: 8), _DebugInspectPanel( hit: _debugFeatureHit, isInspecting: _isInspectingFeature, ), ], ), ), ), ), Positioned( bottom: 34, left: 10, child: _RoutePanel( points: _points, labels: _pointLabels, ), ), Positioned( bottom: 0, left: 0, right: 0, child: _MapFooter( mapCenter: _mapCenter, zoom: _zoom, ), ), ], ), ); } } 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 = 14 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; final route = Paint() ..color = routeColor ..strokeWidth = 9 ..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 _MapFooter extends StatelessWidget { const _MapFooter({required this.mapCenter, required this.zoom}); final LatLng mapCenter; final double zoom; @override Widget build(BuildContext context) { final borderColor = const Color(0xFFE5E7EB); final bg = Colors.white; final muted = const Color(0xFF6B7280); TextStyle footerStyle() => GoogleFonts.ibmPlexMono( fontSize: 11, height: 1, fontWeight: FontWeight.w600, color: muted, ); Widget txt(String s) => Transform.translate( offset: const Offset(0, -1), child: Text(s, style: footerStyle()), ); Widget divider() => const Padding( padding: EdgeInsets.symmetric(horizontal: 5), child: SizedBox( height: 12, child: VerticalDivider(width: 1, color: Color(0xFFD1D5DB)), ), ); final latStr = mapCenter.latitude.toStringAsFixed(4); final lngStr = mapCenter.longitude.toStringAsFixed(4); Widget viewportBlock() { return Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ txt("$latStr, $lngStr"), divider(), txt("Zoom: ${zoom.toStringAsFixed(1)}"), ], ); } Widget copyrightBlock() { return txt("© 2026 IMBENJI.NET LTD - Roadbound"); } Widget versionBlock() { return txt("Alpha v2602-7a"); } return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), width: double.infinity, decoration: BoxDecoration( color: bg, border: Border(top: BorderSide(color: borderColor, width: 1)), ), child: Row( children: [ Expanded(child: Row(children: [copyrightBlock()])), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [viewportBlock()], ), ), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [versionBlock()], ), ), ], ), ); } } class _DebugInspectPanel extends StatelessWidget { const _DebugInspectPanel({required this.hit, required this.isInspecting}); final MapDebugHit? hit; final bool isInspecting; @override Widget build(BuildContext context) { if (!isInspecting && hit == null) return const SizedBox.shrink(); final rows = [ if (hit != null) 'layer: ${hit!.layerName}', if (hit != null) 'type: ${hit!.geometryType.name}', if (hit != null) 'tile: z${hit!.tileZ}/${hit!.tileX}/${hit!.tileY}', if (hit != null) 'distance: ${hit!.distance.toStringAsFixed(1)}', if (hit != null) ...hit!.properties.entries.map( (entry) => '${entry.key}: ${entry.value}', ), ]; return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 320), child: DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.94), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0x22000000)), boxShadow: const [ BoxShadow( color: Color(0x22000000), blurRadius: 10, spreadRadius: 1, ), ], ), child: Padding( padding: const EdgeInsets.all(12), child: DefaultTextStyle( style: const TextStyle( fontSize: 12, height: 1.35, color: Color(0xFF1F2937), fontWeight: FontWeight.w600, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isInspecting ? 'Inspecting map...' : 'Map Inspect', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w800, color: Color(0xFF111827), ), ), if (!isInspecting && hit == null) const Padding( padding: EdgeInsets.only(top: 6), child: Text('Click the map to inspect a feature.'), ), if (rows.isNotEmpty) const SizedBox(height: 6), for (final row in rows) Padding( padding: const EdgeInsets.only(top: 2), child: Text(row), ), ], ), ), ), ), ); } } class _PerformanceHud extends StatelessWidget { const _PerformanceHud({ required this.fps, required this.frameMs, this.tileDebugSummary, }); final double fps; final double frameMs; final String? tileDebugSummary; @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), ), ), if (tileDebugSummary != null) Text( tileDebugSummary!, style: const TextStyle( fontSize: 10, fontWeight: FontWeight.w600, color: Color(0xFF6B7280), ), ), ], ), ), ); } } 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), ), ], ), ); } } class _RoutePanel extends StatelessWidget { const _RoutePanel({required this.points, required this.labels}); final List points; final List labels; @override Widget build(BuildContext context) { if (points.isEmpty) return const SizedBox.shrink(); const stopColor = Color(0xFFD50000); const waypointColor = Color(0xFF2563EB); return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 260, maxHeight: 320), child: DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.95), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0x22000000)), boxShadow: const [ BoxShadow( color: Color(0x22000000), blurRadius: 8, spreadRadius: 1, ), ], ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Route', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w800, color: Color(0xFF111827), ), ), const SizedBox(height: 8), Flexible( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var i = 0; i < points.length; i++) ...[ if (i > 0) Padding( padding: const EdgeInsets.only(left: 9), child: Container( width: 2, height: 10, color: const Color(0xFFE02425), ), ), _RoutePanelStop( index: i, total: points.length, label: labels[i], position: points[i], stopColor: stopColor, waypointColor: waypointColor, ), ], ], ), ), ), ], ), ), ), ); } } class _RoutePanelStop extends StatelessWidget { const _RoutePanelStop({ required this.index, required this.total, required this.label, required this.position, required this.stopColor, required this.waypointColor, }); final int index; final int total; final String? label; final LatLng position; final Color stopColor; final Color waypointColor; @override Widget build(BuildContext context) { final isBusStop = label != null; final isFirst = index == 0; final isLast = index == total - 1; final dotColor = isFirst ? const Color(0xFF0F9D58) : (isLast ? const Color(0xFFE11D48) : waypointColor); final displayLabel = label ?? '${position.latitude.toStringAsFixed(4)}, ${position.longitude.toStringAsFixed(4)}'; return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: 20, height: 20, decoration: BoxDecoration( color: isBusStop ? stopColor : dotColor, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), child: isBusStop ? const Icon(Icons.directions_bus, size: 10, color: Colors.white) : null, ), const SizedBox(width: 8), Expanded( child: Text( displayLabel, style: TextStyle( fontSize: 12, fontWeight: isBusStop ? FontWeight.w600 : FontWeight.w400, color: const Color(0xFF1F2937), ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ); } } class _BusStopMarker extends StatelessWidget { const _BusStopMarker({required this.stop, this.compact = false}); final BusStop stop; final bool compact; @override Widget build(BuildContext context) { final size = compact ? 16.0 : 30.0; final borderWidth = compact ? 2.0 : 3.0; return SizedBox( width: size, height: size, child: DecoratedBox( decoration: BoxDecoration( color: const Color(0xFFD50000), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: borderWidth), boxShadow: const [ BoxShadow( color: Color(0x44000000), blurRadius: 3, offset: Offset(0, 1), ), ], ), child: compact ? null : Center( child: Builder(builder: (context) { final raw = stop.stopLetter ?? 'B'; final letter = raw.replaceAll(RegExp(r'^-+>'), '').trim(); return Text( letter.isEmpty ? 'B' : letter, style: TextStyle( fontSize: letter.length > 1 ? 9 : 11, fontWeight: FontWeight.w900, color: Colors.white, height: 1, ), ); }), ), ), ); } }