1481 lines
46 KiB
Dart
1481 lines
46 KiB
Dart
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: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<MapPage> createState() => _MapPageState();
|
|
}
|
|
|
|
class _MapPageState extends State<MapPage> 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<LatLng> _points = <LatLng>[];
|
|
final List<String?> _pointLabels = <String?>[];
|
|
List<LatLng> _resolvedRoute = <LatLng>[];
|
|
|
|
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<BusStop> _busStops = const [];
|
|
Timer? _busStopFetchTimer;
|
|
LatLng? _lastFetchedCenter;
|
|
double? _lastFetchedZoom;
|
|
static const _busStopMinZoom = 14.0;
|
|
|
|
late final TilePyramidPainter _pyramidPainter;
|
|
|
|
final List<double> _frameMsWindow = <double>[];
|
|
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<FrameTiming> 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 = <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.';
|
|
});
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
math.Point<double> _clampWorldY(math.Point<double> world, double zoom) {
|
|
final scale = _tileSize * math.pow(2, zoom).toDouble();
|
|
final maxY = math.max(0.0, scale - 1);
|
|
return math.Point<double>(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<double>(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();
|
|
// Bias toward higher-detail tiles on high-DPI screens so the visible
|
|
// raster layer matches the viewport density more closely.
|
|
final adjusted = zoomValue + (math.log(ratio) / math.ln2);
|
|
return adjusted.round().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<double>(
|
|
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();
|
|
_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<void> _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 = <String, Object?>{
|
|
'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<void> _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<Widget> _buildBusStopWidgets() {
|
|
if (_zoom < _busStopMinZoom || _busStops.isEmpty) return const [];
|
|
|
|
// first pass: collect visible stops + their raw screen positions
|
|
final visible = <BusStop>[];
|
|
final positions = <Offset>[];
|
|
|
|
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 = <Widget>[];
|
|
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<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: 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<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 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,
|
|
),
|
|
),
|
|
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<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 = 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 = <String>[
|
|
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});
|
|
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RoutePanel extends StatelessWidget {
|
|
const _RoutePanel({required this.points, required this.labels});
|
|
|
|
final List<LatLng> points;
|
|
final List<String?> 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,
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|