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
+2
View File
@@ -0,0 +1,2 @@
export 'src/cache/tile_cache.dart';
export 'src/router.dart';
+75
View File
@@ -0,0 +1,75 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:sqlite3/sqlite3.dart';
// disk cache for raw .pbf tile bytes
// stale after 14 days, max 50k tiles before oldest get pruned
class TileCache {
TileCache._();
static const _staleMs = 14 * 24 * 60 * 60 * 1000;
static const _maxTiles = 50000;
static late final Database _db;
static Future<void> init({String? path}) async {
final dbPath = path ?? '${Directory.current.path}/tile_cache.db';
_db = sqlite3.open(dbPath);
_db.execute('''
CREATE TABLE IF NOT EXISTS tiles (
key TEXT PRIMARY KEY,
ts INTEGER NOT NULL,
bytes BLOB NOT NULL
)
''');
_db.execute('CREATE INDEX IF NOT EXISTS idx_tiles_ts ON tiles(ts)');
// WAL mode — much better for concurrent reads
_db.execute('PRAGMA journal_mode=WAL');
_db.execute('PRAGMA synchronous=NORMAL');
}
static Uint8List? get(String key) {
final now = DateTime.now().millisecondsSinceEpoch;
final result = _db.select(
'SELECT bytes, ts FROM tiles WHERE key = ?',
[key],
);
if (result.isEmpty) return null;
final row = result.first;
final ts = row['ts'] as int;
if (now - ts > _staleMs) {
_db.execute('DELETE FROM tiles WHERE key = ?', [key]);
return null;
}
return row['bytes'] as Uint8List;
}
static void put(String key, Uint8List bytes) {
final now = DateTime.now().millisecondsSinceEpoch;
_db.execute(
'INSERT OR REPLACE INTO tiles (key, ts, bytes) VALUES (?, ?, ?)',
[key, now, bytes],
);
_pruneIfNeeded();
}
static void _pruneIfNeeded() {
final count = (_db.select('SELECT COUNT(*) as c FROM tiles').first['c'] as int);
if (count <= _maxTiles) return;
final excess = count - _maxTiles;
_db.execute('''
DELETE FROM tiles WHERE key IN (
SELECT key FROM tiles ORDER BY ts ASC LIMIT ?
)
''', [excess]);
}
}
@@ -0,0 +1,116 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
final Map<String, _BusStopEntry> _cache = {};
const _ttlMs = 5 * 60 * 1000;
class _BusStopEntry {
_BusStopEntry(this.body, this.ts);
final String body;
final int ts;
}
class BusStopsHandler {
Future<Response> handle(Request req) async {
final params = req.url.queryParameters;
final minLat = double.tryParse(params['minLat'] ?? '');
final minLon = double.tryParse(params['minLon'] ?? '');
final maxLat = double.tryParse(params['maxLat'] ?? '');
final maxLon = double.tryParse(params['maxLon'] ?? '');
if (minLat == null || minLon == null || maxLat == null || maxLon == null) {
return Response.badRequest(body: 'minLat, minLon, maxLat, maxLon required');
}
String r2(double v) => ((v * 100).round() / 100).toString();
final key = '${r2(minLat)},${r2(minLon)},${r2(maxLat)},${r2(maxLon)}';
final now = DateTime.now().millisecondsSinceEpoch;
final cached = _cache[key];
final isStale = cached == null || now - cached.ts >= _ttlMs;
final controller = StreamController<List<int>>();
() async {
// emit cache immediately if we have it
if (cached != null) {
controller.add(_sseBytes(cached.body));
}
// always fetch fresh from TfL if stale
if (isStale) {
final fresh = await _fetchFromTfl(minLat, minLon, maxLat, maxLon);
if (fresh != null) {
_cache[key] = _BusStopEntry(fresh, DateTime.now().millisecondsSinceEpoch);
if (_cache.length > 2000) {
_cache.removeWhere((_, v) =>
DateTime.now().millisecondsSinceEpoch - v.ts > _ttlMs);
}
// only emit if different from what we already sent
if (fresh != cached?.body) {
controller.add(_sseBytes(fresh));
}
}
}
await controller.close();
}();
return Response.ok(
controller.stream,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
);
}
static List<int> _sseBytes(String json) {
return utf8.encode('data: $json\n\n');
}
static Future<String?> _fetchFromTfl(
double minLat,
double minLon,
double maxLat,
double maxLon,
) async {
final centerLat = (minLat + maxLat) / 2;
final centerLon = (minLon + maxLon) / 2;
final radius = _haversineM(minLat, minLon, maxLat, maxLon) / 2;
final r = radius.clamp(200, 1600).toInt();
final uri = Uri.parse(
'https://api.tfl.gov.uk/StopPoint'
'?stopTypes=NaptanPublicBusCoachTram'
'&lat=$centerLat&lon=$centerLon&radius=$r&modes=bus',
);
try {
final upstream = await http.get(uri, headers: {'Accept': 'application/json'});
if (upstream.statusCode != 200) return null;
return upstream.body;
} catch (_) {
return null;
}
}
static double _haversineM(double lat1, double lon1, double lat2, double lon2) {
const r = 6371000.0;
final dLat = (lat2 - lat1) * math.pi / 180;
final dLon = (lon2 - lon1) * math.pi / 180;
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(lat1 * math.pi / 180) *
math.cos(lat2 * math.pi / 180) *
math.sin(dLon / 2) *
math.sin(dLon / 2);
return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a));
}
}
@@ -0,0 +1,60 @@
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
// cache routes by waypoints+profile — routes dont change often
final Map<String, _RouteEntry> _routeCache = {};
const _routeTtlMs = 60 * 60 * 1000; // 1 hour
class _RouteEntry {
_RouteEntry(this.body, this.ts);
final String body;
final int ts;
}
class RouteHandler {
static const _osrm = 'https://router.project-osrm.org';
Future<Response> handle(Request req) async {
final params = req.url.queryParameters;
final waypoints = params['waypoints'];
final profile = params['profile'] ?? 'driving';
if (waypoints == null || waypoints.isEmpty) {
return Response.badRequest(body: 'waypoints required');
}
final cacheKey = '$profile|$waypoints';
final now = DateTime.now().millisecondsSinceEpoch;
final cached = _routeCache[cacheKey];
if (cached != null && now - cached.ts < _routeTtlMs) {
return Response.ok(
cached.body,
headers: {'Content-Type': 'application/json', 'X-Cache': 'HIT'},
);
}
final uri = Uri.parse(
'$_osrm/route/v1/$profile/$waypoints'
'?overview=full&geometries=geojson',
);
final upstream = await http.get(uri);
if (upstream.statusCode != 200) {
return Response(upstream.statusCode, body: 'OSRM error');
}
_routeCache[cacheKey] = _RouteEntry(upstream.body, now);
if (_routeCache.length > 500) {
_routeCache.removeWhere((_, v) => now - v.ts > _routeTtlMs);
}
return Response.ok(
upstream.body,
headers: {'Content-Type': 'application/json', 'X-Cache': 'MISS'},
);
}
}
@@ -0,0 +1,60 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../cache/tile_cache.dart';
class TileHandler {
TileHandler({required this.mapboxToken});
final String mapboxToken;
static const _upstreamTemplate =
'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token={token}';
Future<Response> handle(Request req) async {
final z = req.params['z']!;
final x = req.params['x']!;
final y = req.params['y']!;
final cacheKey = 'tile/$z/$x/$y';
final cached = TileCache.get(cacheKey);
if (cached != null) {
return _tileResponse(cached, fromCache: true);
}
final url = _upstreamTemplate
.replaceAll('{z}', z)
.replaceAll('{x}', x)
.replaceAll('{y}', y)
.replaceAll('{token}', mapboxToken);
final upstream = await http.get(Uri.parse(url));
if (upstream.statusCode != 200) {
return Response(upstream.statusCode, body: 'upstream error');
}
final bytes = upstream.bodyBytes;
TileCache.put(cacheKey, bytes);
return _tileResponse(bytes, fromCache: false);
}
static Response _tileResponse(Uint8List bytes, {required bool fromCache}) {
final compressed = gzip.encode(bytes);
return Response.ok(
compressed,
headers: {
'Content-Type': 'application/x-protobuf',
'Content-Encoding': 'gzip',
'Cache-Control': 'public, max-age=86400',
'X-Cache': fromCache ? 'HIT' : 'MISS',
},
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:shelf_router/shelf_router.dart';
import 'handlers/bus_stops_handler.dart';
import 'handlers/route_handler.dart';
import 'handlers/tile_handler.dart';
Router buildRouter(String mapboxToken) {
final router = Router();
final tileHandler = TileHandler(mapboxToken: mapboxToken);
final busStopsHandler = BusStopsHandler();
final routeHandler = RouteHandler();
// GET /tiles/{z}/{x}/{y}
router.get('/tiles/<z>/<x>/<y>', tileHandler.handle);
// GET /stops?lat=51.5&lon=-0.1&radius=600
router.get('/stops', busStopsHandler.handle);
// GET /route?waypoints=lon,lat;lon,lat&profile=driving
router.get('/route', routeHandler.handle);
return router;
}