add TFL bus stop caching and fetching functionality, enhance map rendering with bus stop markers

This commit is contained in:
ImBenji 2026-03-31 14:14:45 +01:00
parent 31a91a054a
commit 618f3dd3ed
8 changed files with 1198 additions and 131 deletions

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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:shadcn_flutter/shadcn_flutter.dart' as shadcn;
import 'package:hive_flutter/hive_flutter.dart';
@ -9,6 +10,7 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
await HiveTileCache.init();
await TflBusStopCache.init();
runApp(const MyApp());
}

View file

@ -8,13 +8,19 @@ final Color kMapWaterColor = Color(0xFF74B8ED);
final Color kMapBuildingFillColor = Color(0xFFFED000);
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 kMapPathFillColour = Color(0xFFD9E0F0);
final Color kMapDebugTileBoundaryColor = Color(0xCCFF2D55);
final Color kMapPlaceLabelColor = Color(0xFF1F2636);
final Color kMapPlaceLabelHaloColor = Color(0xF7FFFFFF);
final Color kMapPlaceLabelHaloColor = Color(0xFFA7BFDB);
final Color kMapRoadLabelColor = Color(0xFF56657A);
final Color kMapRoadLabelHaloColor = Color(0xF2FFFFFF);
@ -24,3 +30,5 @@ final Color kMapRailUnderlayColor = Color(0xFF8A97A8);
final Color kMapPoiLabelColor = Color(0xFF032D51);
final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF);
const bool kMapDebugShowTileBoundaries = false;

View file

@ -12,9 +12,13 @@ import 'package:flutter/gestures.dart'
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/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';
@ -80,6 +84,12 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
Timer? _interactionIdleTimer;
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;
final List<double> _frameMsWindow = <double>[];
@ -110,6 +120,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
TilePyramidCache.clear();
SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings);
_interactionIdleTimer?.cancel();
_busStopFetchTimer?.cancel();
super.dispose();
}
@ -383,6 +394,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
..devicePixelRatio = _devicePixelRatio
..interactionActive = _interactionIdleTimer?.isActive ?? false;
_pyramidPainter.markDirty();
_scheduleBusStopFetch();
}
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() {
final widgets = <Widget>[];
for (var i = 0; i < _points.length; i++) {
@ -632,6 +777,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
),
),
),
..._buildBusStopWidgets(),
..._buildPointWidgets(),
..._buildInsertHandles(),
],
@ -701,6 +847,25 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
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,
@ -711,13 +876,13 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
),
),
),
SafeArea(
child: Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.all(10),
child: const IgnorePointer(child: _HudCornerNotice()),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: _MapFooter(
mapCenter: _mapCenter,
zoom: _zoom,
),
),
],
@ -765,30 +930,85 @@ class _RoutePainter extends CustomPainter {
}
}
class _HudCornerNotice extends StatelessWidget {
const _HudCornerNotice();
class _MapFooter extends StatelessWidget {
const _MapFooter({required this.mapCenter, required this.zoom});
final LatLng mapCenter;
final double zoom;
@override
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(
color: Colors.white.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
color: bg,
border: Border(top: BorderSide(color: borderColor, width: 1)),
),
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',
child: Row(
children: [
Expanded(child: Row(children: [copyrightBlock()])),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [viewportBlock()],
),
),
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,
),
),
),
),
);
}
}

View 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);
}
}
}

View 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));
}
}

View file

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui';
@ -316,7 +317,7 @@ class _PbfReader {
String readString() {
final bytes = readBytes();
return String.fromCharCodes(bytes);
return utf8.decode(bytes, allowMalformed: true);
}
double readFloat32() {

View file

@ -28,7 +28,7 @@ class TilePyramidCache {
TilePyramidCache._();
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
// synchronous canvas recording. I/O (network/Hive) is still concurrent

File diff suppressed because it is too large Load diff