Files
Roadbound-Map-Utility/lib/pages/map/page.dart

696 lines
22 KiB
Dart

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/tiles/hive_tile_cache.dart';
import 'package:rra_app/pages/map/tiles/hive_tile_image.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<MapPage> createState() => _MapPageState();
}
class _MapPageState extends State<MapPage> with SingleTickerProviderStateMixin {
static const _london = LatLng(51.5074, -0.1278);
static const _routeColor = Color(0xFFE02425);
static const _tileSize = 256.0;
static const _tileUrlTemplate =
'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiaW1iZW5qaTAzIiwiYSI6ImNtN2Rqdmw5MDA0bzEyaXM3YjE1emkzOXAifQ.cYyCPQE7OvZx0hzKX2hEiQ';
final GlobalKey _mapKey = GlobalKey();
final OsrmRoutingService _routingService = const OsrmRoutingService();
final List<LatLng> _points = <LatLng>[];
List<LatLng> _resolvedRoute = <LatLng>[];
LatLng _mapCenter = _london;
double _zoom = 12;
double _targetZoom = 12;
Offset _zoomFocal = Offset.zero;
Size _mapSize = Size.zero;
late final Ticker _zoomTicker;
double _lastPinchScale = 1.0;
bool _isAddPointArmed = false;
bool _isResolvingRoute = false;
String? _routeError;
int _routeRequestId = 0;
int? _draggingInsertPointIndex;
String _lastPrefetchSignature = '';
@override
void initState() {
super.initState();
_zoomTicker = createTicker(_onZoomTick);
}
@override
void dispose() {
_zoomTicker.dispose();
super.dispose();
}
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 = <LatLng>[];
_routeError = null;
_isResolvingRoute = false;
_draggingInsertPointIndex = null;
});
}
Future<void> _resolveRoute() async {
if (_points.length < 2) {
setState(() {
_resolvedRoute = <LatLng>[];
_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.';
});
}
}
Color _pointColor(int index) {
if (_points.length == 1) return const Color(0xFF0F9D58);
if (index == 0) return const Color(0xFF0F9D58);
if (index == _points.length - 1) return const Color(0xFFE11D48);
return const Color(0xFF2563EB);
}
math.Point<double> _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<double>(x, y);
}
LatLng _worldToLatLng(math.Point<double> 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);
}
int get _tileZoom => _zoom.floor().clamp(2, 18);
double get _tileZoomFactor => math.pow(2, _zoom - _tileZoom).toDouble();
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<double>(
centerWorld.x + (screenPoint.dx - _mapSize.width / 2),
centerWorld.y + (screenPoint.dy - _mapSize.height / 2),
);
return _worldToLatLng(world, _zoom);
}
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 _panMap(Offset delta, {bool notify = true}) {
if (_mapSize == Size.zero) return;
final centerWorld = _latLngToWorld(_mapCenter, _zoom);
final nextCenter = math.Point<double>(centerWorld.x - delta.dx, centerWorld.y - delta.dy);
void apply() {
_mapCenter = _worldToLatLng(nextCenter, _zoom);
}
if (notify) {
setState(apply);
} else {
apply();
}
}
void _setZoomAroundFocal(double requestedZoom, Offset focal) {
if (_mapSize == Size.zero) return;
final nextZoom = requestedZoom.clamp(2.0, 18.0);
if ((nextZoom - _zoom).abs() < 0.0001) return;
final focalLatLng = _screenToLatLng(focal);
final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom);
final centerWorldNext = math.Point<double>(
focalWorldNext.x - (focal.dx - _mapSize.width / 2),
focalWorldNext.y - (focal.dy - _mapSize.height / 2),
);
_zoom = nextZoom;
_mapCenter = _worldToLatLng(centerWorldNext, nextZoom);
}
void _onZoomTick(Duration _) {
if (!mounted) return;
setState(() {
const t = 0.2;
final newZoom = _zoom + (_targetZoom - _zoom) * t;
final done = (_targetZoom - newZoom).abs() < 0.001;
final actual = done ? _targetZoom : newZoom;
_setZoomAroundFocal(actual, _zoomFocal);
if (done) _zoomTicker.stop();
});
}
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);
}
void _updateInsertPointDrag(Offset globalPosition) {
final index = _draggingInsertPointIndex;
if (index == null || index < 0 || index >= _points.length) return;
final local = _globalToMapLocal(globalPosition);
if (local == null) return;
final point = _screenToLatLng(local);
setState(() {
_points[index] = point;
_resolvedRoute = _points.toList();
});
}
void _finishInsertPointDrag() {
if (_draggingInsertPointIndex == null) return;
setState(() {
_draggingInsertPointIndex = null;
});
_resolveRoute();
}
List<Widget> _buildTileWidgets() {
if (_mapSize == Size.zero) return const <Widget>[];
final tileZoom = _tileZoom;
final factor = _tileZoomFactor;
final centerWorld = _latLngToWorld(_mapCenter, tileZoom.toDouble());
final minWorldX = centerWorld.x - (_mapSize.width / 2) / factor;
final maxWorldX = centerWorld.x + (_mapSize.width / 2) / factor;
final minWorldY = centerWorld.y - (_mapSize.height / 2) / factor;
final maxWorldY = centerWorld.y + (_mapSize.height / 2) / factor;
final minTileX = (minWorldX / _tileSize).floor();
final maxTileX = (maxWorldX / _tileSize).floor();
final minTileY = (minWorldY / _tileSize).floor();
final maxTileY = (maxWorldY / _tileSize).floor();
final maxIndex = math.pow(2, tileZoom).toInt();
final tiles = <Widget>[];
final prefetchUrls = <String>{};
for (var tileX = minTileX; tileX <= maxTileX; tileX++) {
final wrappedX = ((tileX % maxIndex) + maxIndex) % maxIndex;
for (var tileY = minTileY; tileY <= maxTileY; tileY++) {
if (tileY < 0 || tileY >= maxIndex) continue;
final left = (tileX * _tileSize - minWorldX) * factor;
final top = (tileY * _tileSize - minWorldY) * factor;
final url = _tileUrlTemplate
.replaceAll('{z}', '$tileZoom')
.replaceAll('{x}', '$wrappedX')
.replaceAll('{y}', '$tileY');
tiles.add(
Positioned(
key: ValueKey<String>(url),
left: left,
top: top,
width: _tileSize * factor,
height: _tileSize * factor,
child: HiveTileImage(url: url),
),
);
if (tileZoom < 18) {
final childZoom = tileZoom + 1;
final childMaxIndex = math.pow(2, childZoom).toInt();
final childXBase = wrappedX * 2;
final childYBase = tileY * 2;
for (var dx = 0; dx < 2; dx++) {
for (var dy = 0; dy < 2; dy++) {
final childX = ((childXBase + dx) % childMaxIndex + childMaxIndex) %
childMaxIndex;
final childY = childYBase + dy;
if (childY < 0 || childY >= childMaxIndex) continue;
prefetchUrls.add(
_tileUrlTemplate
.replaceAll('{z}', '$childZoom')
.replaceAll('{x}', '$childX')
.replaceAll('{y}', '$childY'),
);
}
}
}
}
}
final prefetchList = prefetchUrls.toList()..sort();
final signature = prefetchList.join('|');
if (signature != _lastPrefetchSignature) {
_lastPrefetchSignature = signature;
HiveTileCache.prefetchUrls(prefetchList);
}
return tiles;
}
List<Widget> _buildPointWidgets() {
final widgets = <Widget>[];
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: _pointColor(i),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: const Icon(Icons.circle, size: 10, color: Colors.white),
),
),
),
);
}
return widgets;
}
List<Widget> _buildInsertHandles() {
if (_points.length < 2) return const <Widget>[];
final handles = <Widget>[];
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 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;
return Listener(
onPointerSignal: (event) {
if (event is PointerScaleEvent) {
setState(() {
_setZoomAroundFocal(_zoom + math.log(event.scale) / math.ln2, event.localPosition);
_targetZoom = _zoom;
});
} else if (event is PointerScrollEvent) {
if (event.kind == PointerDeviceKind.trackpad) {
_panMap(event.scrollDelta);
} else {
final zoomStep = math.log(1.1) / math.ln2;
_zoomFocal = event.localPosition;
_targetZoom = (_targetZoom +
(event.scrollDelta.dy < 0 ? zoomStep : -zoomStep))
.clamp(2.0, 18.0);
if (!_zoomTicker.isTicking) _zoomTicker.start();
}
}
},
onPointerPanZoomStart: (PointerPanZoomStartEvent event) {
_lastPinchScale = 1.0;
},
onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) {
setState(() {
if ((_lastPinchScale - event.scale).abs() > 0.25) {
_lastPinchScale = event.scale;
}
final scaleChange = event.scale / _lastPinchScale;
_lastPinchScale = event.scale;
final dz = math.log(scaleChange) / math.ln2;
_setZoomAroundFocal(_zoom + dz, event.localPosition);
_targetZoom = _zoom;
if (event.panDelta != Offset.zero) {
_panMap(event.panDelta, notify: false);
}
});
},
onPointerPanZoomEnd: (PointerPanZoomEndEvent event) {},
child: GestureDetector(
key: _mapKey,
behavior: HitTestBehavior.opaque,
onPanUpdate: (details) {
if (_draggingInsertPointIndex != null) return;
_panMap(details.delta);
},
onTapUp: (details) {
if (!_isAddPointArmed || _draggingInsertPointIndex != null) return;
_addPoint(_screenToLatLng(details.localPosition));
},
child: ClipRect(
child: Stack(
children: [
..._buildTileWidgets(),
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,
),
),
],
),
),
),
),
const SafeArea(
child: Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: EdgeInsets.all(10),
child: IgnorePointer(child: _HudCornerNotice()),
),
),
),
],
),
);
}
}
class _RoutePainter extends CustomPainter {
const _RoutePainter({required this.points, required this.routeColor});
final List<Offset> 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 _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),
),
],
),
);
}
}