add TFL bus stop caching and fetching functionality, enhance map rendering with bus stop markers
This commit is contained in:
parent
31a91a054a
commit
618f3dd3ed
8 changed files with 1198 additions and 131 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:rra_app/pages/map/page.dart';
|
import 'package:rra_app/pages/map/page.dart';
|
||||||
|
import 'package:rra_app/pages/map/tfl/tfl_bus_stop_cache.dart';
|
||||||
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn;
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
@ -9,6 +10,7 @@ Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
await HiveTileCache.init();
|
await HiveTileCache.init();
|
||||||
|
await TflBusStopCache.init();
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,19 @@ final Color kMapWaterColor = Color(0xFF74B8ED);
|
||||||
final Color kMapBuildingFillColor = Color(0xFFFED000);
|
final Color kMapBuildingFillColor = Color(0xFFFED000);
|
||||||
final Color kMapBuildingOutlineColor = Color(0xFFE7B600);
|
final Color kMapBuildingOutlineColor = Color(0xFFE7B600);
|
||||||
|
|
||||||
|
final Color kMapCommercialAreaColor = Color(0xFFF5C87A);
|
||||||
|
final Color kMapCommercialAreaOutlineColor = Color(0xFFE0A84A);
|
||||||
|
|
||||||
|
final Color kMapParkingColor = Color(0xFFDDE3EE);
|
||||||
|
final Color kMapParkingOutlineColor = Color(0xFFC2CBDB);
|
||||||
|
|
||||||
final Color kMapRoadFillColor = Color(0xFFFEFEFF);
|
final Color kMapRoadFillColor = Color(0xFFFEFEFF);
|
||||||
final Color kMapPathFillColour = Color(0xFFD9E0F0);
|
final Color kMapPathFillColour = Color(0xFFD9E0F0);
|
||||||
|
|
||||||
final Color kMapDebugTileBoundaryColor = Color(0xCCFF2D55);
|
final Color kMapDebugTileBoundaryColor = Color(0xCCFF2D55);
|
||||||
|
|
||||||
final Color kMapPlaceLabelColor = Color(0xFF1F2636);
|
final Color kMapPlaceLabelColor = Color(0xFF1F2636);
|
||||||
final Color kMapPlaceLabelHaloColor = Color(0xF7FFFFFF);
|
final Color kMapPlaceLabelHaloColor = Color(0xFFA7BFDB);
|
||||||
|
|
||||||
final Color kMapRoadLabelColor = Color(0xFF56657A);
|
final Color kMapRoadLabelColor = Color(0xFF56657A);
|
||||||
final Color kMapRoadLabelHaloColor = Color(0xF2FFFFFF);
|
final Color kMapRoadLabelHaloColor = Color(0xF2FFFFFF);
|
||||||
|
|
@ -24,3 +30,5 @@ final Color kMapRailUnderlayColor = Color(0xFF8A97A8);
|
||||||
|
|
||||||
final Color kMapPoiLabelColor = Color(0xFF032D51);
|
final Color kMapPoiLabelColor = Color(0xFF032D51);
|
||||||
final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF);
|
final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF);
|
||||||
|
|
||||||
|
const bool kMapDebugShowTileBoundaries = false;
|
||||||
|
|
@ -12,9 +12,13 @@ import 'package:flutter/gestures.dart'
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:latlong2/latlong.dart' show LatLng;
|
import 'package:latlong2/latlong.dart' show LatLng;
|
||||||
import 'package:rra_app/pages/map/routing/osrm_routing_service.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/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_cache.dart';
|
||||||
import 'package:rra_app/pages/map/vector/tile_pyramid_painter.dart';
|
import 'package:rra_app/pages/map/vector/tile_pyramid_painter.dart';
|
||||||
import 'package:rra_app/pages/map/widgets/toolbar.dart';
|
import 'package:rra_app/pages/map/widgets/toolbar.dart';
|
||||||
|
|
@ -80,6 +84,12 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
Timer? _interactionIdleTimer;
|
Timer? _interactionIdleTimer;
|
||||||
MapDebugHit? _debugFeatureHit;
|
MapDebugHit? _debugFeatureHit;
|
||||||
|
|
||||||
|
final TflBusStopService _busStopService = TflBusStopService();
|
||||||
|
List<BusStop> _busStops = const [];
|
||||||
|
Timer? _busStopFetchTimer;
|
||||||
|
// zoom threshold below which we dont bother fetching
|
||||||
|
static const _busStopMinZoom = 18.0;
|
||||||
|
|
||||||
late final TilePyramidPainter _pyramidPainter;
|
late final TilePyramidPainter _pyramidPainter;
|
||||||
|
|
||||||
final List<double> _frameMsWindow = <double>[];
|
final List<double> _frameMsWindow = <double>[];
|
||||||
|
|
@ -110,6 +120,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
TilePyramidCache.clear();
|
TilePyramidCache.clear();
|
||||||
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
|
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
|
||||||
_interactionIdleTimer?.cancel();
|
_interactionIdleTimer?.cancel();
|
||||||
|
_busStopFetchTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,6 +394,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
..devicePixelRatio = _devicePixelRatio
|
..devicePixelRatio = _devicePixelRatio
|
||||||
..interactionActive = _interactionIdleTimer?.isActive ?? false;
|
..interactionActive = _interactionIdleTimer?.isActive ?? false;
|
||||||
_pyramidPainter.markDirty();
|
_pyramidPainter.markDirty();
|
||||||
|
_scheduleBusStopFetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
LatLng _segmentMidpoint(LatLng a, LatLng b) {
|
LatLng _segmentMidpoint(LatLng a, LatLng b) {
|
||||||
|
|
@ -449,6 +461,139 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 _scheduleBusStopFetch() {
|
||||||
|
_busStopFetchTimer?.cancel();
|
||||||
|
if (_zoom < _busStopMinZoom) {
|
||||||
|
if (_busStops.isNotEmpty) setState(() => _busStops = const []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_busStopFetchTimer = Timer(const Duration(milliseconds: 600), () {
|
||||||
|
_fetchBusStops();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchBusStops() async {
|
||||||
|
if (_zoom < _busStopMinZoom || !mounted) return;
|
||||||
|
|
||||||
|
final cacheKey = TflBusStopCache.keyFor(_mapCenter);
|
||||||
|
|
||||||
|
// phase 1: show cached stops immediately
|
||||||
|
final cached = TflBusStopCache.peek(cacheKey);
|
||||||
|
if (cached != null && mounted) {
|
||||||
|
setState(() => _busStops = cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase 2: fetch from TfL and refresh
|
||||||
|
final mpp = TflBusStopService.metersPerPixel(_mapCenter.latitude, _zoom);
|
||||||
|
final halfDiag = (_mapSize.longestSide / 2) * mpp;
|
||||||
|
final radius = halfDiag.clamp(200, 800).toInt();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final stops = await _busStopService.fetchNearby(
|
||||||
|
_mapCenter,
|
||||||
|
radius: radius,
|
||||||
|
);
|
||||||
|
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
|
||||||
|
const minDist = 28.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 - 13,
|
||||||
|
top: screen.dy - 13,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => _inspectBusStop(stop),
|
||||||
|
child: Tooltip(
|
||||||
|
message: stop.name,
|
||||||
|
child: _BusStopMarker(stop: stop),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
List<Widget> _buildPointWidgets() {
|
List<Widget> _buildPointWidgets() {
|
||||||
final widgets = <Widget>[];
|
final widgets = <Widget>[];
|
||||||
for (var i = 0; i < _points.length; i++) {
|
for (var i = 0; i < _points.length; i++) {
|
||||||
|
|
@ -632,6 +777,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
..._buildBusStopWidgets(),
|
||||||
..._buildPointWidgets(),
|
..._buildPointWidgets(),
|
||||||
..._buildInsertHandles(),
|
..._buildInsertHandles(),
|
||||||
],
|
],
|
||||||
|
|
@ -701,6 +847,25 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
frameMs: _displayFrameMs,
|
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),
|
const SizedBox(height: 8),
|
||||||
_DebugInspectPanel(
|
_DebugInspectPanel(
|
||||||
hit: _debugFeatureHit,
|
hit: _debugFeatureHit,
|
||||||
|
|
@ -711,13 +876,13 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
Positioned(
|
||||||
child: Align(
|
bottom: 0,
|
||||||
alignment: Alignment.bottomLeft,
|
left: 0,
|
||||||
child: Padding(
|
right: 0,
|
||||||
padding: const EdgeInsets.all(10),
|
child: _MapFooter(
|
||||||
child: const IgnorePointer(child: _HudCornerNotice()),
|
mapCenter: _mapCenter,
|
||||||
),
|
zoom: _zoom,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -765,30 +930,85 @@ class _RoutePainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HudCornerNotice extends StatelessWidget {
|
class _MapFooter extends StatelessWidget {
|
||||||
const _HudCornerNotice();
|
const _MapFooter({required this.mapCenter, required this.zoom});
|
||||||
|
|
||||||
|
final LatLng mapCenter;
|
||||||
|
final double zoom;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
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(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
color: bg,
|
||||||
borderRadius: BorderRadius.circular(4),
|
border: Border(top: BorderSide(color: borderColor, width: 1)),
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: Row(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
children: [
|
||||||
child: Text(
|
Expanded(child: Row(children: [copyrightBlock()])),
|
||||||
'© 2026 IMBENJI.NET LTD - Roadbound Map Maker\n'
|
Expanded(
|
||||||
'Alpha v2602-7a - This is a public alpha release;\n'
|
child: Row(
|
||||||
'Expect bugs, missing features, and breaking changes.',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: TextStyle(
|
children: [viewportBlock()],
|
||||||
fontSize: 12,
|
|
||||||
height: 1.35,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Color(0xFF525252),
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [versionBlock()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -991,3 +1211,42 @@ class _StatusPanel extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BusStopMarker extends StatelessWidget {
|
||||||
|
const _BusStopMarker({required this.stop});
|
||||||
|
|
||||||
|
final BusStop stop;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFD50000),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 1.5),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x44000000),
|
||||||
|
blurRadius: 3,
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
stop.stopLetter ?? 'B',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: stop.stopLetter != null && stop.stopLetter!.length > 1 ? 9 : 11,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
116
lib/pages/map/tfl/tfl_bus_stop_cache.dart
Normal file
116
lib/pages/map/tfl/tfl_bus_stop_cache.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:hive/hive.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart';
|
||||||
|
|
||||||
|
class TflBusStopCache {
|
||||||
|
TflBusStopCache._();
|
||||||
|
|
||||||
|
static const _boxName = 'tfl_bus_stop_cache_v1';
|
||||||
|
static const _staleAfter = Duration(hours: 12);
|
||||||
|
static const _maxEntries = 512;
|
||||||
|
|
||||||
|
static Future<void> init() async {
|
||||||
|
if (!Hive.isBoxOpen(_boxName)) {
|
||||||
|
await Hive.openBox(_boxName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Box? get _box {
|
||||||
|
try {
|
||||||
|
if (!Hive.isBoxOpen(_boxName)) return null;
|
||||||
|
return Hive.box(_boxName);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rounds to ~1km grid cells so nearby pans reuse the same cache entry
|
||||||
|
static String keyFor(LatLng center) {
|
||||||
|
final lat = (center.latitude * 100).round() / 100;
|
||||||
|
final lon = (center.longitude * 100).round() / 100;
|
||||||
|
return '$lat,$lon';
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<BusStop>? peek(String key) {
|
||||||
|
final box = _box;
|
||||||
|
if (box == null) return null;
|
||||||
|
|
||||||
|
final entry = box.get(key);
|
||||||
|
if (entry is! Map) return null;
|
||||||
|
|
||||||
|
final ts = entry['ts'];
|
||||||
|
if (ts is! int) return null;
|
||||||
|
|
||||||
|
final age = DateTime.now().millisecondsSinceEpoch - ts;
|
||||||
|
if (age > _staleAfter.inMilliseconds) return null;
|
||||||
|
|
||||||
|
final raw = entry['stops'];
|
||||||
|
if (raw is! List) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return _decodeStops(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> store(String key, List<BusStop> stops) async {
|
||||||
|
final box = _box;
|
||||||
|
if (box == null) return;
|
||||||
|
|
||||||
|
await box.put(key, {
|
||||||
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'stops': _encodeStops(stops),
|
||||||
|
});
|
||||||
|
|
||||||
|
await _pruneIfNeeded(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static List<Map<String, dynamic>> _encodeStops(List<BusStop> stops) {
|
||||||
|
return stops.map((s) => {
|
||||||
|
'id': s.id,
|
||||||
|
'name': s.name,
|
||||||
|
'lat': s.position.latitude,
|
||||||
|
'lon': s.position.longitude,
|
||||||
|
if (s.stopLetter != null) 'stopLetter': s.stopLetter,
|
||||||
|
if (s.towards != null) 'towards': s.towards,
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<BusStop> _decodeStops(List raw) {
|
||||||
|
final result = <BusStop>[];
|
||||||
|
for (final item in raw) {
|
||||||
|
if (item is! Map) continue;
|
||||||
|
final lat = (item['lat'] as num?)?.toDouble();
|
||||||
|
final lon = (item['lon'] as num?)?.toDouble();
|
||||||
|
if (lat == null || lon == null) continue;
|
||||||
|
result.add(BusStop(
|
||||||
|
id: item['id'] as String? ?? '',
|
||||||
|
name: item['name'] as String? ?? '',
|
||||||
|
position: LatLng(lat, lon),
|
||||||
|
stopLetter: item['stopLetter'] as String?,
|
||||||
|
towards: item['towards'] as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _pruneIfNeeded(Box box) async {
|
||||||
|
final overflow = box.length - _maxEntries;
|
||||||
|
if (overflow <= 0) return;
|
||||||
|
|
||||||
|
final entries = box.keys.map((k) {
|
||||||
|
final v = box.get(k);
|
||||||
|
final ts = (v is Map && v['ts'] is int) ? v['ts'] as int : 0;
|
||||||
|
return MapEntry(k, ts);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => a.value.compareTo(b.value));
|
||||||
|
|
||||||
|
for (var i = 0; i < overflow; i++) {
|
||||||
|
await box.delete(entries[i].key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/pages/map/tfl/tfl_bus_stop_service.dart
Normal file
102
lib/pages/map/tfl/tfl_bus_stop_service.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
class BusStop {
|
||||||
|
const BusStop({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.position,
|
||||||
|
this.stopLetter,
|
||||||
|
this.towards,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final LatLng position;
|
||||||
|
|
||||||
|
// the letter on the physical bus stop flag e.g. "A", "ZH"
|
||||||
|
final String? stopLetter;
|
||||||
|
|
||||||
|
// may be null for parent stops
|
||||||
|
final String? towards;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TflBusStopService {
|
||||||
|
TflBusStopService();
|
||||||
|
|
||||||
|
static const _baseUrl = 'https://api.tfl.gov.uk';
|
||||||
|
|
||||||
|
// max radius the TfL API accepts is 1600m but in practice
|
||||||
|
// we clamp to 800 so we dont get swamped at low zoom
|
||||||
|
static const _maxRadius = 800;
|
||||||
|
|
||||||
|
Future<List<BusStop>> fetchNearby(LatLng center, {int radius = 600}) async {
|
||||||
|
final r = radius.clamp(0, _maxRadius);
|
||||||
|
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'$_baseUrl/StopPoint'
|
||||||
|
'?stopTypes=NaptanPublicBusCoachTram'
|
||||||
|
'&lat=${center.latitude}'
|
||||||
|
'&lon=${center.longitude}'
|
||||||
|
'&radius=$r'
|
||||||
|
'&modes=bus',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(uri, headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('TfL StopPoint request failed (${response.statusCode})');
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final stopPoints = body['stopPoints'];
|
||||||
|
if (stopPoints is! List) return const [];
|
||||||
|
|
||||||
|
final stops = <BusStop>[];
|
||||||
|
for (final raw in stopPoints) {
|
||||||
|
if (raw is! Map<String, dynamic>) continue;
|
||||||
|
|
||||||
|
final lat = (raw['lat'] as num?)?.toDouble();
|
||||||
|
final lon = (raw['lon'] as num?)?.toDouble();
|
||||||
|
if (lat == null || lon == null) continue;
|
||||||
|
|
||||||
|
final id = raw['id'] as String? ?? '';
|
||||||
|
final name = raw['commonName'] as String? ?? '';
|
||||||
|
final stopLetter = raw['stopLetter'] as String?;
|
||||||
|
|
||||||
|
String? towards;
|
||||||
|
final addInfo = raw['additionalProperties'];
|
||||||
|
if (addInfo is List) {
|
||||||
|
for (final prop in addInfo) {
|
||||||
|
if (prop is Map && prop['key'] == 'Towards') {
|
||||||
|
towards = prop['value'] as String?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stops.add(BusStop(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
position: LatLng(lat, lon),
|
||||||
|
stopLetter: stopLetter,
|
||||||
|
towards: towards,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rough haversine distance in metres, good enough for radius estimation
|
||||||
|
static double metersPerPixel(double lat, double zoom) {
|
||||||
|
const earthCircumference = 40075016.686;
|
||||||
|
final latRad = lat * math.pi / 180.0;
|
||||||
|
return (earthCircumference * math.cos(latRad)) /
|
||||||
|
(256 * math.pow(2, zoom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
|
@ -316,7 +317,7 @@ class _PbfReader {
|
||||||
|
|
||||||
String readString() {
|
String readString() {
|
||||||
final bytes = readBytes();
|
final bytes = readBytes();
|
||||||
return String.fromCharCodes(bytes);
|
return utf8.decode(bytes, allowMalformed: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
double readFloat32() {
|
double readFloat32() {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class TilePyramidCache {
|
||||||
TilePyramidCache._();
|
TilePyramidCache._();
|
||||||
|
|
||||||
static const int _maxEntries = 384;
|
static const int _maxEntries = 384;
|
||||||
static const int _maxApproxBytes = 256 * 1024 * 1024;
|
static const int _maxApproxBytes = 768 * 1024 * 1024;
|
||||||
|
|
||||||
// Cap concurrent renders to avoid saturating the main isolate with
|
// Cap concurrent renders to avoid saturating the main isolate with
|
||||||
// synchronous canvas recording. I/O (network/Hive) is still concurrent
|
// synchronous canvas recording. I/O (network/Hive) is still concurrent
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue