add backend server setup with environment configuration, tile caching, and bus stop fetching functionality

This commit is contained in:
ImBenji
2026-03-31 16:37:34 +01:00
parent 618f3dd3ed
commit 49fc04591b
27 changed files with 1536 additions and 374 deletions
+4 -5
View File
@@ -26,11 +26,10 @@ class TflBusStopCache {
}
}
// 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';
// rounds bbox corners to ~1km grid so nearby pans reuse the same entry
static String keyFor(LatLng sw, LatLng ne) {
double r(double v) => (v * 100).round() / 100;
return '${r(sw.latitude)},${r(sw.longitude)},${r(ne.latitude)},${r(ne.longitude)}';
}
static List<BusStop>? peek(String key) {
+70 -56
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'package:rra_app/pages/map/constants.dart';
class BusStop {
const BusStop({
@@ -17,82 +19,94 @@ class BusStop {
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);
// streams bus stops — emits once from cache, then again from TfL if fresher
Stream<List<BusStop>> fetchInRect(LatLng sw, LatLng ne) async* {
final uri = Uri.parse(
'$_baseUrl/StopPoint'
'?stopTypes=NaptanPublicBusCoachTram'
'&lat=${center.latitude}'
'&lon=${center.longitude}'
'&radius=$r'
'&modes=bus',
'$kBackendBaseUrl/stops'
'?minLat=${sw.latitude}'
'&minLon=${sw.longitude}'
'&maxLat=${ne.latitude}'
'&maxLon=${ne.longitude}',
);
final response = await http.get(uri, headers: {
'Accept': 'application/json',
});
final client = http.Client();
try {
final request = http.Request('GET', uri);
final streamed = await client.send(request);
if (response.statusCode != 200) {
throw Exception('TfL StopPoint request failed (${response.statusCode})');
}
final buffer = StringBuffer();
final body = jsonDecode(response.body) as Map<String, dynamic>;
final stopPoints = body['stopPoints'];
if (stopPoints is! List) return const [];
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
buffer.write(chunk);
final text = buffer.toString();
final stops = <BusStop>[];
for (final raw in stopPoints) {
if (raw is! Map<String, dynamic>) continue;
// SSE events are delimited by \n\n
int idx;
var remaining = text;
while ((idx = remaining.indexOf('\n\n')) != -1) {
final event = remaining.substring(0, idx).trim();
remaining = remaining.substring(idx + 2);
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;
if (event.startsWith('data: ')) {
final json = event.substring(6);
final stops = _parseStops(json);
if (stops != null) yield stops;
}
}
buffer.clear();
buffer.write(remaining);
}
stops.add(BusStop(
id: id,
name: name,
position: LatLng(lat, lon),
stopLetter: stopLetter,
towards: towards,
));
} finally {
client.close();
}
}
static List<BusStop>? _parseStops(String json) {
try {
final body = jsonDecode(json) as Map<String, dynamic>;
final stopPoints = body['stopPoints'];
if (stopPoints is! List) return null;
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;
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: raw['id'] as String? ?? '',
name: raw['commonName'] as String? ?? '',
position: LatLng(lat, lon),
stopLetter: raw['stopLetter'] as String?,
towards: towards,
));
}
return stops;
} catch (_) {
return null;
}
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;