add backend server setup with environment configuration, tile caching, and bus stop fetching functionality
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export 'src/cache/tile_cache.dart';
|
||||
export 'src/router.dart';
|
||||
+75
@@ -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',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user