diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..0c2831a --- /dev/null +++ b/backend/.env @@ -0,0 +1,2 @@ +MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q +PORT=8080 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1e40732 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,2 @@ +MAPBOX_ACCESS_TOKEN=pk.your_token_here +PORT=8080 \ No newline at end of file diff --git a/backend/bin/server.dart b/backend/bin/server.dart new file mode 100644 index 0000000..ea9e81e --- /dev/null +++ b/backend/bin/server.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:dotenv/dotenv.dart'; +import 'package:shelf/shelf.dart' show Middleware, Response; +import 'package:rra_backend/rra_backend.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; + +Middleware _logger() => (handler) => (request) async { + final sw = Stopwatch()..start(); + final response = await handler(request); + sw.stop(); + final ms = sw.elapsedMilliseconds; + print('${request.method} ${response.statusCode} ${ms}ms ${request.url.path}'); + return response; +}; + +Middleware _cors() => (handler) => (request) async { + if (request.method == 'OPTIONS') { + return Response.ok('', headers: _corsHeaders); + } + final response = await handler(request); + return response.change(headers: _corsHeaders); +}; + +const _corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Origin, Content-Type', +}; + +void main() async { + final env = DotEnv(includePlatformEnvironment: true)..load(); + + final mapboxToken = env['MAPBOX_ACCESS_TOKEN']; + if (mapboxToken == null || mapboxToken.isEmpty) { + stderr.writeln('ERROR: MAPBOX_ACCESS_TOKEN not set'); + exit(1); + } + + final port = int.tryParse(env['PORT'] ?? '8080') ?? 8080; + + await TileCache.init(); + + final handler = const Pipeline() + .addMiddleware(_logger()) + .addMiddleware(_cors()) + .addHandler(buildRouter(mapboxToken).call); + + final server = await io.serve(handler, InternetAddress.anyIPv4, port); + print('rra_backend listening on :${server.port}'); +} diff --git a/backend/lib/rra_backend.dart b/backend/lib/rra_backend.dart new file mode 100644 index 0000000..6782111 --- /dev/null +++ b/backend/lib/rra_backend.dart @@ -0,0 +1,2 @@ +export 'src/cache/tile_cache.dart'; +export 'src/router.dart'; diff --git a/backend/lib/src/cache/tile_cache.dart b/backend/lib/src/cache/tile_cache.dart new file mode 100644 index 0000000..da7b1ab --- /dev/null +++ b/backend/lib/src/cache/tile_cache.dart @@ -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 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]); + } +} diff --git a/backend/lib/src/handlers/bus_stops_handler.dart b/backend/lib/src/handlers/bus_stops_handler.dart new file mode 100644 index 0000000..92dee65 --- /dev/null +++ b/backend/lib/src/handlers/bus_stops_handler.dart @@ -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 _cache = {}; + +const _ttlMs = 5 * 60 * 1000; + +class _BusStopEntry { + _BusStopEntry(this.body, this.ts); + final String body; + final int ts; +} + +class BusStopsHandler { + + Future 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>(); + + () 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 _sseBytes(String json) { + return utf8.encode('data: $json\n\n'); + } + + static Future _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)); + } +} diff --git a/backend/lib/src/handlers/route_handler.dart b/backend/lib/src/handlers/route_handler.dart new file mode 100644 index 0000000..435f3b7 --- /dev/null +++ b/backend/lib/src/handlers/route_handler.dart @@ -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 _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 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'}, + ); + } +} diff --git a/backend/lib/src/handlers/tile_handler.dart b/backend/lib/src/handlers/tile_handler.dart new file mode 100644 index 0000000..c1504b1 --- /dev/null +++ b/backend/lib/src/handlers/tile_handler.dart @@ -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 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', + }, + ); + } +} diff --git a/backend/lib/src/router.dart b/backend/lib/src/router.dart new file mode 100644 index 0000000..51d7f54 --- /dev/null +++ b/backend/lib/src/router.dart @@ -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///', 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; +} diff --git a/backend/pubspec.lock b/backend/pubspec.lock new file mode 100644 index 0000000..1cad632 --- /dev/null +++ b/backend/pubspec.lock @@ -0,0 +1,173 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + dotenv: + dependency: "direct main" + description: + name: dotenv + sha256: "379e64b6fc82d3df29461d349a1796ecd2c436c480d4653f3af6872eccbc90e1" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + lints: + dependency: "direct dev" + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.7.0 <4.0.0" diff --git a/backend/pubspec.yaml b/backend/pubspec.yaml new file mode 100644 index 0000000..ddc5901 --- /dev/null +++ b/backend/pubspec.yaml @@ -0,0 +1,17 @@ +name: rra_backend +description: Backend proxy/cache server for rra_app +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + shelf: ^1.4.0 + shelf_router: ^1.1.0 + http: ^1.2.0 + sqlite3: ^2.4.0 + dotenv: ^4.0.0 + +dev_dependencies: + lints: ^3.0.0 \ No newline at end of file diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5cbbbde..33ac586 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,9 +8,12 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 26DCCBCFF47641A189F46375 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9E2B72AB7FC986FC7292C27B /* Pods_Runner.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 6A08BC70BBE3CBD0627A4BE1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81A5C3940BC8570BABCA562A /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -40,14 +43,19 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 01BC4FEF809E87D61F7EC1FC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 39AB1A8747EDBAA39C75148E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4D757F63BD16F17354CAFDAE /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 81A5C3940BC8570BABCA562A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,19 +63,42 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9E2B72AB7FC986FC7292C27B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AD097AB0DE1E1D72CD972EE1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + B0768FD4CFE5F6B5F38F8296 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + C69AED9D09BFAE561F2EA023 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 2D2EF8518DC68D268BB75F78 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6A08BC70BBE3CBD0627A4BE1 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 26DCCBCFF47641A189F46375 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0FA9A9998E29E2A691A2A3BB /* Frameworks */ = { + isa = PBXGroup; + children = ( + 9E2B72AB7FC986FC7292C27B /* Pods_Runner.framework */, + 81A5C3940BC8570BABCA562A /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -79,6 +110,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -94,6 +126,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + B4CAA32E841ED9413BF9A155 /* Pods */, + 0FA9A9998E29E2A691A2A3BB /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +155,20 @@ path = Runner; sourceTree = ""; }; + B4CAA32E841ED9413BF9A155 /* Pods */ = { + isa = PBXGroup; + children = ( + 39AB1A8747EDBAA39C75148E /* Pods-Runner.debug.xcconfig */, + AD097AB0DE1E1D72CD972EE1 /* Pods-Runner.release.xcconfig */, + C69AED9D09BFAE561F2EA023 /* Pods-Runner.profile.xcconfig */, + 4D757F63BD16F17354CAFDAE /* Pods-RunnerTests.debug.xcconfig */, + B0768FD4CFE5F6B5F38F8296 /* Pods-RunnerTests.release.xcconfig */, + 01BC4FEF809E87D61F7EC1FC /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +176,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 25C458846219BE655061800B /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 2D2EF8518DC68D268BB75F78 /* Frameworks */, ); buildRules = ( ); @@ -145,6 +195,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 296FAA439AE10256186EEEC8 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -157,6 +208,9 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -190,6 +244,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -222,6 +279,50 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 25C458846219BE655061800B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 296FAA439AE10256186EEEC8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -379,6 +480,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 4D757F63BD16F17354CAFDAE /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -396,6 +498,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B0768FD4CFE5F6B5F38F8296 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -411,6 +514,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 01BC4FEF809E87D61F7EC1FC /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -614,6 +718,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 43584b3..8bc0e8d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/lib/pages/map/constants.dart b/lib/pages/map/constants.dart index c9e986d..d670291 100644 --- a/lib/pages/map/constants.dart +++ b/lib/pages/map/constants.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +const String kBackendBaseUrl = 'http://192.168.0.25:8080'; + final Color kMapBackgroundColor = Color(0xFFA7BFDB); final Color kMapLanduseColor = Color(0xFFE500FE); -final Color kMapGrassColor = Color(0xFFA2CE83); +final Color kMapGrassColor = Color(0xFF9EC582); final Color kMapWaterColor = Color(0xFF74B8ED); final Color kMapBuildingFillColor = Color(0xFFFED000); final Color kMapBuildingOutlineColor = Color(0xFFE7B600); @@ -31,4 +33,7 @@ final Color kMapRailUnderlayColor = Color(0xFF8A97A8); final Color kMapPoiLabelColor = Color(0xFF032D51); final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF); -const bool kMapDebugShowTileBoundaries = false; \ No newline at end of file +const bool kMapDebugShowTileBoundaries = false; +const bool kMapDebugShowPlaceSymbolRank = false; + +const bool kMapUseSymbolRankAsZoomGate = true; \ No newline at end of file diff --git a/lib/pages/map/page.dart b/lib/pages/map/page.dart index a66e569..3b03d02 100644 --- a/lib/pages/map/page.dart +++ b/lib/pages/map/page.dart @@ -14,6 +14,7 @@ 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/constants.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'; @@ -48,13 +49,13 @@ class _MapPageState extends State with TickerProviderStateMixin { static const _interactionIdleDelay = Duration(milliseconds: 280); static const _fpsWindowSize = 90; static const _fpsUpdateInterval = Duration(milliseconds: 240); - static const _tileUrlTemplate = - 'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q'; + static const _tileUrlTemplate = '$kBackendBaseUrl/tiles/{z}/{x}/{y}'; final GlobalKey _mapKey = GlobalKey(); final OsrmRoutingService _routingService = const OsrmRoutingService(); final List _points = []; + final List _pointLabels = []; List _resolvedRoute = []; LatLng _mapCenter = _london; @@ -68,6 +69,8 @@ class _MapPageState extends State with TickerProviderStateMixin { late final Ticker _zoomTicker; double _lastPinchScale = 1.0; + double _lastScaleValue = 1.0; + Offset _lastScaleFocal = Offset.zero; Offset _pendingPanDelta = Offset.zero; bool _panFrameScheduled = false; Offset _pendingPanZoomDelta = Offset.zero; @@ -87,8 +90,9 @@ class _MapPageState extends State with TickerProviderStateMixin { final TflBusStopService _busStopService = TflBusStopService(); List _busStops = const []; Timer? _busStopFetchTimer; - // zoom threshold below which we dont bother fetching - static const _busStopMinZoom = 18.0; + LatLng? _lastFetchedCenter; + double? _lastFetchedZoom; + static const _busStopMinZoom = 14.0; late final TilePyramidPainter _pyramidPainter; @@ -163,9 +167,10 @@ class _MapPageState extends State with TickerProviderStateMixin { }); } - void _addPoint(LatLng point) { + void _addPoint(LatLng point, {String? label}) { setState(() { _points.add(point); + _pointLabels.add(label); }); _resolveRoute(); } @@ -174,6 +179,7 @@ class _MapPageState extends State with TickerProviderStateMixin { if (_points.isEmpty) return; setState(() { _points.removeLast(); + _pointLabels.removeLast(); }); _resolveRoute(); } @@ -182,6 +188,7 @@ class _MapPageState extends State with TickerProviderStateMixin { if (_points.isEmpty) return; setState(() { _points.clear(); + _pointLabels.clear(); _resolvedRoute = []; _routeError = null; _isResolvingRoute = false; @@ -394,7 +401,7 @@ class _MapPageState extends State with TickerProviderStateMixin { ..devicePixelRatio = _devicePixelRatio ..interactionActive = _interactionIdleTimer?.isActive ?? false; _pyramidPainter.markDirty(); - _scheduleBusStopFetch(); + _maybeScheduleBusStopFetch(); } LatLng _segmentMidpoint(LatLng a, LatLng b) { @@ -413,6 +420,7 @@ class _MapPageState extends State with TickerProviderStateMixin { final insertIndex = segmentIndex + 1; setState(() { _points.insert(insertIndex, midpoint); + _pointLabels.insert(insertIndex, null); _draggingInsertPointIndex = insertIndex; _isAddPointArmed = false; _resolvedRoute = _points.toList(); @@ -461,6 +469,11 @@ class _MapPageState extends State with TickerProviderStateMixin { }); } + void _addBusStopToRoute(BusStop stop) { + setState(() => _isAddPointArmed = false); + _addPoint(stop.position, label: stop.name); + } + void _inspectBusStop(BusStop stop) { final props = { 'name': stop.name, @@ -485,14 +498,31 @@ class _MapPageState extends State with TickerProviderStateMixin { }); } - void _scheduleBusStopFetch() { - _busStopFetchTimer?.cancel(); + void _maybeScheduleBusStopFetch() { if (_zoom < _busStopMinZoom) { - if (_busStops.isNotEmpty) setState(() => _busStops = const []); + if (_busStops.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _busStops = const []); + }); + } + _lastFetchedCenter = null; + _lastFetchedZoom = null; return; } - _busStopFetchTimer = Timer(const Duration(milliseconds: 600), () { + // dont re-debounce if the viewport hasnt moved meaningfully + final zoomChanged = _lastFetchedZoom == null || + (_zoom - _lastFetchedZoom!).abs() > 0.5; + final centerMoved = _lastFetchedCenter == null || + (_mapCenter.latitude - _lastFetchedCenter!.latitude).abs() > 0.002 || + (_mapCenter.longitude - _lastFetchedCenter!.longitude).abs() > 0.002; + + if (!zoomChanged && !centerMoved) return; + + _busStopFetchTimer?.cancel(); + _busStopFetchTimer = Timer(const Duration(milliseconds: 400), () { + _lastFetchedCenter = _mapCenter; + _lastFetchedZoom = _zoom; _fetchBusStops(); }); } @@ -500,27 +530,24 @@ class _MapPageState extends State with TickerProviderStateMixin { Future _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(); + final halfWidthM = (_mapSize.width / 2) * mpp; + final halfHeightM = (_mapSize.height / 2) * mpp; + + const metersPerDegLat = 111320.0; + final latOffset = halfHeightM / metersPerDegLat; + final lonOffset = halfWidthM / (metersPerDegLat * math.cos(_mapCenter.latitude * math.pi / 180)); + + final sw = LatLng(_mapCenter.latitude - latOffset, _mapCenter.longitude - lonOffset); + final ne = LatLng(_mapCenter.latitude + latOffset, _mapCenter.longitude + lonOffset); + final cacheKey = TflBusStopCache.keyFor(sw, ne); try { - final stops = await _busStopService.fetchNearby( - _mapCenter, - radius: radius, - ); - if (!mounted) return; - setState(() => _busStops = stops); - await TflBusStopCache.store(cacheKey, stops); + await for (final stops in _busStopService.fetchInRect(sw, ne)) { + if (!mounted) return; + setState(() => _busStops = stops); + await TflBusStopCache.store(cacheKey, stops); + } } catch (_) { // silently swallow } @@ -543,7 +570,7 @@ class _MapPageState extends State with TickerProviderStateMixin { // repulsion pass — push overlapping markers apart in screen space // only affects rendering positions, not the underlying LatLng data - const minDist = 28.0; + final minDist = _zoom < 17.5 ? 18.0 : 32.0; const iterations = 6; for (var iter = 0; iter < iterations; iter++) { @@ -577,14 +604,20 @@ class _MapPageState extends State with TickerProviderStateMixin { final screen = positions[i]; widgets.add( Positioned( - left: screen.dx - 13, - top: screen.dy - 13, + left: screen.dx - (_zoom < 17.5 ? 8 : 15), + top: screen.dy - (_zoom < 17.5 ? 8 : 15), child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _inspectBusStop(stop), - child: Tooltip( - message: stop.name, - child: _BusStopMarker(stop: stop), + onTap: () => _isAddPointArmed + ? _addBusStopToRoute(stop) + : _inspectBusStop(stop), + child: shadcn.Tooltip( + tooltip: (context) => shadcn.TooltipContainer( + child: Text(stop.name), + ), + alignment: Alignment.centerRight, + anchorAlignment: Alignment.centerLeft, + child: _BusStopMarker(stop: stop, compact: _zoom < 17.5), ), ), ), @@ -681,6 +714,7 @@ class _MapPageState extends State with TickerProviderStateMixin { final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points; final routeScreenPoints = routePoints.map(_latLngToScreen).toList(); + _pyramidPainter.routeScreenPoints = routeScreenPoints; return Scaffold( body: Stack( @@ -744,9 +778,34 @@ class _MapPageState extends State with TickerProviderStateMixin { child: GestureDetector( key: _mapKey, behavior: HitTestBehavior.opaque, - onPanUpdate: (details) { + onScaleStart: (details) { + _lastScaleValue = 1.0; + _lastScaleFocal = details.localFocalPoint; + _noteInteraction(); + }, + onScaleUpdate: (details) { if (_draggingInsertPointIndex != null) return; - _queuePan(details.delta); + + final panDelta = details.localFocalPoint - _lastScaleFocal; + _lastScaleFocal = details.localFocalPoint; + + final scaleChange = details.scale / _lastScaleValue; + _lastScaleValue = details.scale; + + if ((scaleChange - 1.0).abs() > 0.001) { + _noteInteraction(); + setState(() { + _setZoomAroundFocal( + _zoom + math.log(scaleChange) / math.ln2, + details.localFocalPoint, + ); + _targetZoom = _zoom; + }); + } + + if (panDelta != Offset.zero) { + _queuePan(panDelta); + } }, onTapUp: (details) { if (_draggingInsertPointIndex != null) return; @@ -780,6 +839,13 @@ class _MapPageState extends State with TickerProviderStateMixin { ..._buildBusStopWidgets(), ..._buildPointWidgets(), ..._buildInsertHandles(), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: TileLabelPainter(_pyramidPainter), + ), + ), + ), ], ), ), @@ -876,6 +942,14 @@ class _MapPageState extends State with TickerProviderStateMixin { ), ), ), + Positioned( + bottom: 34, + left: 10, + child: _RoutePanel( + points: _points, + labels: _pointLabels, + ), + ), Positioned( bottom: 0, left: 0, @@ -908,14 +982,14 @@ class _RoutePainter extends CustomPainter { final white = Paint() ..color = Colors.white - ..strokeWidth = 9 + ..strokeWidth = 14 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; final route = Paint() ..color = routeColor - ..strokeWidth = 5 + ..strokeWidth = 9 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; @@ -1212,21 +1286,170 @@ class _StatusPanel extends StatelessWidget { } } -class _BusStopMarker extends StatelessWidget { - const _BusStopMarker({required this.stop}); +class _RoutePanel extends StatelessWidget { + const _RoutePanel({required this.points, required this.labels}); - final BusStop stop; + final List points; + final List labels; @override Widget build(BuildContext context) { + if (points.isEmpty) return const SizedBox.shrink(); + + const stopColor = Color(0xFFD50000); + const waypointColor = Color(0xFF2563EB); + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260, maxHeight: 320), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0x22000000)), + boxShadow: const [ + BoxShadow( + color: Color(0x22000000), + blurRadius: 8, + spreadRadius: 1, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Route', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: Color(0xFF111827), + ), + ), + const SizedBox(height: 8), + + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < points.length; i++) ...[ + if (i > 0) + Padding( + padding: const EdgeInsets.only(left: 9), + child: Container( + width: 2, + height: 10, + color: const Color(0xFFE02425), + ), + ), + _RoutePanelStop( + index: i, + total: points.length, + label: labels[i], + position: points[i], + stopColor: stopColor, + waypointColor: waypointColor, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + + +class _RoutePanelStop extends StatelessWidget { + const _RoutePanelStop({ + required this.index, + required this.total, + required this.label, + required this.position, + required this.stopColor, + required this.waypointColor, + }); + + final int index; + final int total; + final String? label; + final LatLng position; + final Color stopColor; + final Color waypointColor; + + @override + Widget build(BuildContext context) { + final isBusStop = label != null; + final isFirst = index == 0; + final isLast = index == total - 1; + + final dotColor = isFirst + ? const Color(0xFF0F9D58) + : (isLast ? const Color(0xFFE11D48) : waypointColor); + + final displayLabel = label ?? '${position.latitude.toStringAsFixed(4)}, ${position.longitude.toStringAsFixed(4)}'; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: isBusStop ? stopColor : dotColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: isBusStop + ? const Icon(Icons.directions_bus, size: 10, color: Colors.white) + : null, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + displayLabel, + style: TextStyle( + fontSize: 12, + fontWeight: isBusStop ? FontWeight.w600 : FontWeight.w400, + color: const Color(0xFF1F2937), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + + +class _BusStopMarker extends StatelessWidget { + const _BusStopMarker({required this.stop, this.compact = false}); + + final BusStop stop; + final bool compact; + + @override + Widget build(BuildContext context) { + final size = compact ? 16.0 : 30.0; + final borderWidth = compact ? 2.0 : 3.0; + return SizedBox( - width: 26, - height: 26, + width: size, + height: size, child: DecoratedBox( decoration: BoxDecoration( color: const Color(0xFFD50000), shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.5), + border: Border.all(color: Colors.white, width: borderWidth), boxShadow: const [ BoxShadow( color: Color(0x44000000), @@ -1235,17 +1458,23 @@ class _BusStopMarker extends StatelessWidget { ), ], ), - 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, - ), - ), - ), + child: compact + ? null + : Center( + child: Builder(builder: (context) { + final raw = stop.stopLetter ?? 'B'; + final letter = raw.replaceAll(RegExp(r'^-+>'), '').trim(); + return Text( + letter.isEmpty ? 'B' : letter, + style: TextStyle( + fontSize: letter.length > 1 ? 9 : 11, + fontWeight: FontWeight.w900, + color: Colors.white, + height: 1, + ), + ); + }), + ), ), ); } diff --git a/lib/pages/map/routing/osrm_routing_service.dart b/lib/pages/map/routing/osrm_routing_service.dart index 4c5f61c..6f8c4ba 100644 --- a/lib/pages/map/routing/osrm_routing_service.dart +++ b/lib/pages/map/routing/osrm_routing_service.dart @@ -2,10 +2,11 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; +import 'package:rra_app/pages/map/constants.dart'; class OsrmRoutingService { const OsrmRoutingService({ - this.baseUrl = 'https://router.project-osrm.org', + this.baseUrl = kBackendBaseUrl, this.profile = 'driving', }); @@ -21,8 +22,7 @@ class OsrmRoutingService { .map((point) => '${point.longitude},${point.latitude}') .join(';'); final uri = Uri.parse( - '$baseUrl/route/v1/$profile/$coordinates' - '?overview=full&geometries=geojson', + '$baseUrl/route?waypoints=$coordinates&profile=$profile', ); final response = await http.get(uri); diff --git a/lib/pages/map/tfl/tfl_bus_stop_cache.dart b/lib/pages/map/tfl/tfl_bus_stop_cache.dart index d316ab5..cdf3391 100644 --- a/lib/pages/map/tfl/tfl_bus_stop_cache.dart +++ b/lib/pages/map/tfl/tfl_bus_stop_cache.dart @@ -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? peek(String key) { diff --git a/lib/pages/map/tfl/tfl_bus_stop_service.dart b/lib/pages/map/tfl/tfl_bus_stop_service.dart index 6e2fd02..2f7ad09 100644 --- a/lib/pages/map/tfl/tfl_bus_stop_service.dart +++ b/lib/pages/map/tfl/tfl_bus_stop_service.dart @@ -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> 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> 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; - 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 = []; - for (final raw in stopPoints) { - if (raw is! Map) 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? _parseStops(String json) { + try { + final body = jsonDecode(json) as Map; + final stopPoints = body['stopPoints']; + if (stopPoints is! List) return null; + + final stops = []; + for (final raw in stopPoints) { + if (raw is! Map) 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; diff --git a/lib/pages/map/vector/mapbox_vector_tile_cache.dart b/lib/pages/map/vector/mapbox_vector_tile_cache.dart index 2dba67c..69a6a58 100644 --- a/lib/pages/map/vector/mapbox_vector_tile_cache.dart +++ b/lib/pages/map/vector/mapbox_vector_tile_cache.dart @@ -1,13 +1,23 @@ import 'dart:collection'; +import 'dart:io'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart'; import 'package:rra_app/pages/map/vector/mvt_parser.dart'; +// top-level so compute() can find it +MvtTile? _parseMvtBackground(({Uint8List bytes, Set layerNames}) args) { + try { + return const MvtParser().parse(args.bytes, layerNames: args.layerNames); + } catch (_) { + return null; + } +} + class MapboxVectorTileCache { MapboxVectorTileCache._(); - static final MvtParser _parser = const MvtParser(); static const Set _styleLayerWhitelist = { 'water', 'landuse', @@ -18,12 +28,16 @@ class MapboxVectorTileCache { 'place_label', 'poi_label', }; - static const int _maxEntries = 220; + static final bool _isMobile = + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + static final int _maxEntries = _isMobile ? 60 : 220; static final LinkedHashMap _cache = LinkedHashMap(); static final Map> _inFlight = >{}; + static bool isFetching(String url) => _inFlight.containsKey(url); + static MvtTile? peek(String url) { final cached = _cache.remove(url); if (cached != null) { @@ -48,21 +62,25 @@ class MapboxVectorTileCache { static Future _fetch(String url) async { final bytes = await HiveTileCache.getOrFetch(url); if (bytes == null || bytes.isEmpty) return null; - final tile = _parse(bytes); + + // dart:ui Offset doesn't survive structured-clone on web workers, + // so on web we parse synchronously on the main thread + MvtTile? tile; + if (kIsWeb) { + tile = _parseMvtBackground((bytes: bytes, layerNames: _styleLayerWhitelist)); + } else { + tile = await compute( + _parseMvtBackground, + (bytes: bytes, layerNames: _styleLayerWhitelist), + ); + } if (tile == null) return null; + _cache[url] = tile; _trim(); return tile; } - static MvtTile? _parse(Uint8List bytes) { - try { - return _parser.parse(bytes, layerNames: _styleLayerWhitelist); - } catch (_) { - return null; - } - } - static void _trim() { while (_cache.length > _maxEntries) { _cache.remove(_cache.keys.first); diff --git a/lib/pages/map/vector/tile_pyramid_cache.dart b/lib/pages/map/vector/tile_pyramid_cache.dart index 0a4703c..f23b312 100644 --- a/lib/pages/map/vector/tile_pyramid_cache.dart +++ b/lib/pages/map/vector/tile_pyramid_cache.dart @@ -1,6 +1,9 @@ import 'dart:collection'; +import 'dart:io'; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; + class _TileCoord { const _TileCoord(this.z, this.x, this.y); @@ -27,13 +30,18 @@ class CachedTile { class TilePyramidCache { TilePyramidCache._(); - static const int _maxEntries = 384; - static const int _maxApproxBytes = 768 * 1024 * 1024; + static final bool _isMobile = + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + static final int _maxEntries = kIsWeb ? 64 : (_isMobile ? 96 : 384); + static final int _maxApproxBytes = kIsWeb + ? 64 * 1024 * 1024 + : (_isMobile ? 80 * 1024 * 1024 : 768 * 1024 * 1024); // Cap concurrent renders to avoid saturating the main isolate with // synchronous canvas recording. I/O (network/Hive) is still concurrent // up to this limit. - static const int _maxConcurrent = 4; + static final int _maxConcurrent = kIsWeb ? 2 : (_isMobile ? 2 : 4); static final LinkedHashMap<_TileCoord, CachedTile> _cache = LinkedHashMap<_TileCoord, CachedTile>(); diff --git a/lib/pages/map/vector/tile_pyramid_painter.dart b/lib/pages/map/vector/tile_pyramid_painter.dart index af8d51c..f823500 100644 --- a/lib/pages/map/vector/tile_pyramid_painter.dart +++ b/lib/pages/map/vector/tile_pyramid_painter.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -10,7 +11,8 @@ import 'package:rra_app/pages/map/vector/mvt_parser.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart'; const int _kMaxAncestorLookup = 4; -const double _kTileResolutionScale = 2.0; +// web WASM heap is limited — use 1x resolution to keep memory under control +final double _kTileResolutionScale = kIsWeb ? 1.0 : 2.0; const double _kLineReferenceZoom = 14.0; class TilePyramidPainter extends ChangeNotifier implements CustomPainter { @@ -37,12 +39,26 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { int? fallbackTileZoom; double devicePixelRatio = 1; bool interactionActive = false; + List routeScreenPoints = const []; // cache of tile-local road label placements keyed by "$url:$featureIndex" // placement is stored in tile extent coordinates so it never changes on pan final Map _roadLabelPlacementCache = {}; - void markDirty() => notifyListeners(); + // laid-out TextPainters keyed by "text|fontSize" — layout is expensive + final Map _textPainterCache = {}; + static const int _maxTextPainterCacheSize = 600; + + // world-space road label cache — rebuilt only when tiles change or zoom changes + List<_WorldRoadLabel>? _roadLabelWorld; + int _tileVersion = 0; + int _roadLabelCacheVersion = -1; + double _roadLabelCacheZoom = -1.0; + + void markDirty() { + _tileVersion++; + notifyListeners(); + } String _tileUrl(int z, int x, int y) => tileUrlTemplate .replaceAll('{z}', '$z') @@ -106,6 +122,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } _drawTileLayer(canvas, activeTileZoom, queueTiles: true); + } + + void paintLabels(Canvas canvas) { + if (mapWidth == 0 || mapHeight == 0) return; _drawAllRoadLabels(canvas); _drawAllPlaceLabels(canvas); } @@ -128,7 +148,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final minTY = (minWY / tileSize).floor() - tileOverscan; final maxTY = (maxWY / tileSize).floor() + tileOverscan; - if (queueTiles) { + if (queueTiles && !kIsWeb) { final allowed = _allowedQueuedTileHashes( z, minTX, @@ -160,7 +180,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } } - if (queueTiles) { + if (queueTiles && !kIsWeb) { _queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex); if (!interactionActive) { @@ -213,6 +233,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) { + // on web, skip the rasterization cache entirely and paint directly + if (kIsWeb) { + _drawTileWeb(canvas, z, x, y, dst); + return; + } + final native = TilePyramidCache.peek(z, x, y); if (native != null) { _blitImage( @@ -251,9 +277,54 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { _blitImage(canvas, ancestor.image, src, dst); break; } + } - if (!TilePyramidCache.isInFlight(z, x, y)) { - _queueTile(z, x, y); + void _drawTileWeb(Canvas canvas, int z, int x, int y, Rect dst) { + final url = _tileUrl(z, x, y); + final tile = MapboxVectorTileCache.peek(url); + + if (tile != null) { + canvas.save(); + canvas.clipRect(dst); + canvas.translate(dst.left, dst.top); + canvas.scale(dst.width / tileSize, dst.height / tileSize); + _paintTile(canvas, Size.square(tileSize), tile, tileZoom: z.toDouble()); + canvas.restore(); + return; + } + + // fetch if not cached yet + if (!MapboxVectorTileCache.isFetching(url)) { + MapboxVectorTileCache.getOrFetch(url).then((_) => markDirty()); + } + + // show ancestor tile scaled up while loading + final maxIndex = 1 << z; + for (var dz = 1; dz <= _kMaxAncestorLookup; dz++) { + final az = z - dz; + if (az < minTileZoom) break; + + final subdivision = 1 << dz; + final ancestorX = x >> dz; + final ancestorY = y >> dz; + final ancestorUrl = _tileUrl(az, ((ancestorX % (maxIndex >> dz)) + (maxIndex >> dz)) % (maxIndex >> dz), ancestorY); + final ancestor = MapboxVectorTileCache.peek(ancestorUrl); + if (ancestor == null) continue; + + final fracX = (x - ancestorX * subdivision).toDouble() / subdivision; + final fracY = (y - ancestorY * subdivision).toDouble() / subdivision; + final srcLeft = fracX * tileSize; + final srcTop = fracY * tileSize; + final srcSize = tileSize / subdivision; + + canvas.save(); + canvas.clipRect(dst); + canvas.translate(dst.left, dst.top); + canvas.scale(dst.width / srcSize, dst.height / srcSize); + canvas.translate(-srcLeft, -srcTop); + _paintTile(canvas, Size.square(tileSize), ancestor, tileZoom: az.toDouble()); + canvas.restore(); + break; } } @@ -264,8 +335,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { void _queueTile(int z, int x, int y) { final url = _tileUrl(z, x, y); - final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale) - .round(); + + // cap render resolution so high-DPI mobile doesnt blow the image cache + // (256 * 3dpr * 2x scale = 1536px = 9MB per tile, way too big) + // 512px = 1MB per tile, fits ~80 tiles in the mobile budget + final dprCapped = devicePixelRatio.clamp(1.0, 2.0); + final rasterPx = (tileSize * dprCapped * _kTileResolutionScale).round(); TilePyramidCache.enqueue( z, @@ -296,6 +371,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { int visMinTY = 0, int visMaxTY = 0, }) { + // centerWorldX/Y are already in pixel coords at activeTileZoom scale + final centerTX = centerWorldX / tileSize; + final centerTY = centerWorldY / tileSize; + + final pending = <(int tx, int ty, int wrappedX, double dist)>[]; + for (var tx = minTX; tx <= maxTX; tx++) { for (var ty = minTY; ty <= maxTY; ty++) { if (ty < 0 || ty >= maxIndex) continue; @@ -310,9 +391,17 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; if (TilePyramidCache.peek(z, wrappedX, ty) != null) continue; if (TilePyramidCache.isInFlight(z, wrappedX, ty)) continue; - _queueTile(z, wrappedX, ty); + + final dx = tx + 0.5 - centerTX; + final dy = ty + 0.5 - centerTY; + pending.add((tx, ty, wrappedX, dx * dx + dy * dy)); } } + + pending.sort((a, b) => a.$4.compareTo(b.$4)); + for (final t in pending) { + _queueTile(z, t.$3, t.$2); + } } MapDebugHit? _inspectLayerHit( @@ -386,7 +475,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final recorder = ui.PictureRecorder(); final size = Size.square(rasterPx.toDouble()); - final canvas = Canvas(recorder); + final canvas = Canvas(recorder, Offset.zero & size); _paintTile(canvas, size, tile, tileZoom: tileZoom); @@ -449,7 +538,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { baseZ: 25.0, ); - // water — above grass so rivers/lakes cut through parks + // water _collectPolygonCommands( commands, size, @@ -483,6 +572,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { fillColor: kMapPathFillColour, tileZoom: tileZoom, allowedClasses: const {'school'}, + allowedTypes: const {'school', 'college', 'university'}, baseZ: 35.0, ); @@ -672,6 +762,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final styledLines = <({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey, String roadClass})>[]; for (final feature in roadLayer.features) { if (feature.type != MvtGeometryType.lineString) continue; + final roadClass = (feature.properties['class'] ?? '').toString().toLowerCase(); + if (tileZoom < 13 && !_isMajorRoadClass(roadClass) && !roadClass.contains('street') && !roadClass.contains('rail') && roadClass != 'aerialway') continue; final style = _roadStyleFor(feature, tileZoom: tileZoom); if (style == null) continue; styledLines.add(( @@ -679,7 +771,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { style: style, layerProp: _featureLayerProp(feature), sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0, - roadClass: (feature.properties['class'] ?? '').toString(), + roadClass: roadClass, )); } @@ -783,13 +875,14 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { // erase with all same-class cores (across all layerProps) final classErase = eraseByClass[roadClass] ?? []; + final needsErase = classErase.isNotEmpty; final railBias = roadClass.contains('rail') ? -10.0 : 0.0; final underlayZ = 50.0 + lp * 30.0 + railBias; commands.add(_DrawCommand( z: underlayZ, draw: (c) { - c.saveLayer(Offset.zero & size, Paint()); + if (needsErase) c.saveLayer(Offset.zero & size, Paint()); for (final d in underlayDrawData) { for (final line in d.lines) { @@ -805,30 +898,31 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { } } - for (final d in classErase) { - for (final line in d.lines) { - if (line.length < 2) continue; - final startIsTerminal = !junctions.contains(_epKey(line.first)); - final endIsTerminal = !junctions.contains(_epKey(line.last)); - final trimmed = _trimPolyline( - line, - startIsTerminal ? d.terminalInset : 0.0, - endIsTerminal ? d.terminalInset : 0.0, - ); - if (trimmed == null || trimmed.length < 2) continue; - c.drawPath(_scaledPath(trimmed, scale), d.paint); - } - final circlePaint = Paint() - ..color = d.paint.color - ..style = PaintingStyle.fill - ..blendMode = BlendMode.dstOut - ..isAntiAlias = true; - for (final pt in d.junctionPts) { - c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint); + if (needsErase) { + for (final d in classErase) { + for (final line in d.lines) { + if (line.length < 2) continue; + final startIsTerminal = !junctions.contains(_epKey(line.first)); + final endIsTerminal = !junctions.contains(_epKey(line.last)); + final trimmed = _trimPolyline( + line, + startIsTerminal ? d.terminalInset : 0.0, + endIsTerminal ? d.terminalInset : 0.0, + ); + if (trimmed == null || trimmed.length < 2) continue; + c.drawPath(_scaledPath(trimmed, scale), d.paint); + } + final circlePaint = Paint() + ..color = d.paint.color + ..style = PaintingStyle.fill + ..blendMode = BlendMode.dstOut + ..isAntiAlias = true; + for (final pt in d.junctionPts) { + c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint); + } } + c.restore(); } - - c.restore(); }, )); @@ -1392,9 +1486,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final isCapital = capital >= 2; final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt(); - if (symbolrank > 15 && zoom <= 14.0) continue; + if (kMapUseSymbolRankAsZoomGate && zoom < symbolrank) continue; - if (!isCapital) { + final isMajorCity = symbolrank < 13; + if (!isCapital && !isMajorCity) { if (zoom < 12.5 && featureClass == 'settlement_subdivision') continue; if (zoom < 12.5 && featureClass == 'settlement') continue; } @@ -1404,7 +1499,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { .toString() .trim()); if (rawText.isEmpty || rawText.length > 30) continue; - final text = rawText; + final text = kMapDebugShowPlaceSymbolRank ? '$rawText [$symbolrank]' : rawText; final anchor = feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty @@ -1693,147 +1788,193 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { final minTY = (minWY / tileSize).floor() - tileOverscan; final maxTY = (maxWY / tileSize).floor() + tileOverscan; - final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight); const minSameNameSpacing = 500.0; - // collect all candidates first so we can sort by path length and always - // pick the longest visible segment per name — stable across panning - final candidates = <_RoadLabelCandidate>[]; + // rebuild world-space label list only when tiles or zoom changed + // pan only changes screen positions, which we recompute cheaply from worldX/Y + if (_tileVersion != _roadLabelCacheVersion || zoom != _roadLabelCacheZoom) { + final worldLabels = <_WorldRoadLabel>[]; - for (var tx = minTX; tx <= maxTX; tx++) { - for (var ty = minTY; ty <= maxTY; ty++) { - if (ty < 0 || ty >= maxIndex) continue; + for (var tx = minTX; tx <= maxTX; tx++) { + for (var ty = minTY; ty <= maxTY; ty++) { + if (ty < 0 || ty >= maxIndex) continue; - final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; - final url = _tileUrl(z, wrappedX, ty); - final tile = MapboxVectorTileCache.peek(url); - if (tile == null) continue; + final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; + final url = _tileUrl(z, wrappedX, ty); + final tile = MapboxVectorTileCache.peek(url); + if (tile == null) continue; - final layer = tile.layers['road']; - if (layer == null) continue; + final layer = tile.layers['road']; + if (layer == null) continue; - final tileLeft = (tx * tileSize - minWX) * factor; - final tileTop = (ty * tileSize - minWY) * factor; - final scale = (tileSize * factor) / layer.extent; + final scale = (tileSize * factor) / layer.extent; - for (final feature in layer.features) { - if (feature.type != MvtGeometryType.lineString) continue; + for (var fi = 0; fi < layer.features.length; fi++) { + final feature = layer.features[fi]; + if (feature.type != MvtGeometryType.lineString) continue; - final roadClass = - (feature.properties['class'] ?? feature.properties['type'] ?? '') - .toString() - .toLowerCase(); - if (_shouldSkipRoadLabelClass(roadClass)) continue; - if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue; - final style = _roadStyleFor(feature, tileZoom: z.toDouble()); - if (style == null) continue; + final roadClass = + (feature.properties['class'] ?? feature.properties['type'] ?? '') + .toString() + .toLowerCase(); + if (_shouldSkipRoadLabelClass(roadClass)) continue; + if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue; + final style = _roadStyleFor(feature, tileZoom: z.toDouble()); + if (style == null) continue; - final text = _sanitizeLabel( - (feature.properties['name_en'] ?? - feature.properties['name'] ?? - '') - .toString() - .trim()); - if (text.isEmpty || text.length > 32) continue; + final text = _sanitizeLabel( + (feature.properties['name_en'] ?? + feature.properties['name'] ?? + '') + .toString() + .trim()); + if (text.isEmpty || text.length > 32) continue; - final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom; - final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble(); - final roadCorePx = _worldStrokePx( - style.coreWorldUnits, - extent: layer.extent, - rasterSize: tileSize * fontFactor, - tileZoom: z.toDouble(), - ); - final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0); + final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom; + final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble(); + final roadCorePx = _worldStrokePx( + style.coreWorldUnits, + extent: layer.extent, + rasterSize: tileSize * fontFactor, + tileZoom: z.toDouble(), + ); + final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0); - final painter = TextPainter( - text: TextSpan( + final painterKey = '$text|${fontSize.toStringAsFixed(1)}'; + final painter = _textPainterCache[painterKey] ?? () { + final p = TextPainter( + text: TextSpan( + text: text, + style: GoogleFonts.inter( + color: kMapRoadLabelColor, + fontSize: fontSize, + fontWeight: FontWeight.w600, + shadows: [ + Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2), + ], + height: 1.0, + ), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '', + )..layout(); + if (_textPainterCache.length >= _maxTextPainterCacheSize) { + _textPainterCache.remove(_textPainterCache.keys.first); + } + _textPainterCache[painterKey] = p; + return p; + }(); + + final cacheKey = '$url:$fi'; + if (!_roadLabelPlacementCache.containsKey(cacheKey)) { + final placement = _bestRoadLabelPlacement( + feature.geometry, + scale: 1.0, + labelWidth: painter.width / scale, + ); + if (placement != null) { + _roadLabelPlacementCache[cacheKey] = placement; + } + } + final localPlacement = _roadLabelPlacementCache[cacheKey]; + if (localPlacement == null) continue; + + // store in world space so pan is free + final worldX = tx * tileSize + localPlacement.center.dx * tileSize / layer.extent; + final worldY = ty * tileSize + localPlacement.center.dy * tileSize / layer.extent; + + worldLabels.add(_WorldRoadLabel( + worldX: worldX, + worldY: worldY, + angle: localPlacement.angle, + pathLength: localPlacement.pathLength, text: text, - style: GoogleFonts.inter( - color: kMapRoadLabelColor, - fontSize: fontSize, - fontWeight: FontWeight.w600, - shadows: [ - Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2), - ], - height: 1.0, + painter: painter, + )); + } + } + } + + // longest segment always wins regardless of tile iteration order + worldLabels.sort((a, b) => b.pathLength.compareTo(a.pathLength)); + _roadLabelWorld = worldLabels; + _roadLabelCacheVersion = _tileVersion; + _roadLabelCacheZoom = zoom; + } + + final worldLabels = _roadLabelWorld; + if (worldLabels == null || worldLabels.isEmpty) return; + + // reproject world → screen using current pan/zoom (cheap) + final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight); + final occupiedRects = []; + final placedByName = >{}; + + for (final w in worldLabels) { + final screenCenter = Offset( + (w.worldX - minWX) * factor, + (w.worldY - minWY) * factor, + ); + if (!screenBounds.contains(screenCenter)) continue; + + final bounds = Rect.fromCenter( + center: screenCenter, + width: w.painter.width + 12, + height: w.painter.height + 10, + ); + if (occupiedRects.any((r) => r.overlaps(bounds))) continue; + + final key = w.text.toLowerCase(); + final existing = placedByName[key]; + if (existing != null) { + final tooClose = existing.any((p) { + final dx = p.dx - screenCenter.dx; + final dy = p.dy - screenCenter.dy; + return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing; + }); + if (tooClose) continue; + } + + final overRoute = _labelOverRoute(bounds); + + canvas.save(); + canvas.translate(screenCenter.dx, screenCenter.dy); + canvas.rotate(w.angle); + if (overRoute) { + final strokeKey = '${w.text}|route_stroke'; + final strokePainter = _textPainterCache[strokeKey] ?? () { + final baseStyle = w.painter.text!.style!; + final p = TextPainter( + text: TextSpan( + text: w.text, + style: baseStyle.copyWith( + color: null, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeJoin = StrokeJoin.round + ..color = kMapRoadLabelHaloColor, + shadows: null, ), ), textDirection: TextDirection.ltr, maxLines: 1, ellipsis: '', )..layout(); - - final featureIndex = layer.features.indexOf(feature); - final cacheKey = '$url:$featureIndex'; - if (!_roadLabelPlacementCache.containsKey(cacheKey)) { - final placement = _bestRoadLabelPlacement( - feature.geometry, - scale: 1.0, - labelWidth: painter.width / scale, - ); - // only cache successful placements — null means segment too short, - // which can change at different zoom/scale so we shouldn't lock it in - if (placement != null) { - _roadLabelPlacementCache[cacheKey] = placement; - } + if (_textPainterCache.length >= _maxTextPainterCacheSize) { + _textPainterCache.remove(_textPainterCache.keys.first); } - final localPlacement = _roadLabelPlacementCache[cacheKey]; - if (localPlacement == null) continue; - - final screenCenter = Offset( - tileLeft + localPlacement.center.dx * scale, - tileTop + localPlacement.center.dy * scale, - ); - - if (!screenBounds.contains(screenCenter)) continue; - - candidates.add(_RoadLabelCandidate( - text: text, - screenCenter: screenCenter, - angle: localPlacement.angle, - pathLength: localPlacement.pathLength, - painter: painter, - )); - } + _textPainterCache[strokeKey] = p; + return p; + }(); + strokePainter.paint(canvas, Offset(-strokePainter.width / 2, -strokePainter.height / 2)); } - } - - // longest segment wins — so the winner is always the same regardless of - // which order tiles were iterated - candidates.sort((a, b) => b.pathLength.compareTo(a.pathLength)); - - final occupiedRects = []; - final placedByName = >{}; - - for (final c in candidates) { - final bounds = Rect.fromCenter( - center: c.screenCenter, - width: c.painter.width + 12, - height: c.painter.height + 10, - ); - - if (occupiedRects.any((r) => r.overlaps(bounds))) continue; - - final key = c.text.toLowerCase(); - final existing = placedByName[key]; - if (existing != null) { - final tooClose = existing.any((p) { - final dx = p.dx - c.screenCenter.dx; - final dy = p.dy - c.screenCenter.dy; - return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing; - }); - if (tooClose) continue; - } - - canvas.save(); - canvas.translate(c.screenCenter.dx, c.screenCenter.dy); - canvas.rotate(c.angle); - c.painter.paint(canvas, Offset(-c.painter.width / 2, -c.painter.height / 2)); + w.painter.paint(canvas, Offset(-w.painter.width / 2, -w.painter.height / 2)); canvas.restore(); occupiedRects.add(bounds); - (placedByName[key] ??= []).add(c.screenCenter); + (placedByName[key] ??= []).add(screenCenter); } } @@ -2112,6 +2253,36 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { return length; } + bool _labelOverRoute(Rect labelBounds) { + final pts = routeScreenPoints; + if (pts.length < 2) return false; + final inflated = labelBounds.inflate(4); + for (var i = 0; i < pts.length - 1; i++) { + if (_segmentIntersectsRect(pts[i], pts[i + 1], inflated)) return true; + } + return false; + } + + bool _segmentIntersectsRect(Offset a, Offset b, Rect r) { + // quick AABB check first + final minX = math.min(a.dx, b.dx); + final maxX = math.max(a.dx, b.dx); + final minY = math.min(a.dy, b.dy); + final maxY = math.max(a.dy, b.dy); + if (maxX < r.left || minX > r.right || maxY < r.top || minY > r.bottom) return false; + // check if any rect corner is on opposite sides of the segment + final dx = b.dx - a.dx; + final dy = b.dy - a.dy; + double sign(Offset p) => dx * (p.dy - a.dy) - dy * (p.dx - a.dx); + final s1 = sign(r.topLeft); + final s2 = sign(r.topRight); + final s3 = sign(r.bottomLeft); + final s4 = sign(r.bottomRight); + final mn = math.min(math.min(s1, s2), math.min(s3, s4)); + final mx = math.max(math.max(s1, s2), math.max(s3, s4)); + return mn <= 0 && mx >= 0; + } + bool _isMajorRoadClass(String roadClass) { return roadClass.contains('motorway') || roadClass.contains('trunk') || @@ -2189,6 +2360,33 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter { bool hitTest(Offset position) => false; } +class TileLabelPainter implements CustomPainter { + TileLabelPainter(this._painter); + + final TilePyramidPainter _painter; + + @override + void paint(Canvas canvas, Size size) => _painter.paintLabels(canvas); + + @override + bool shouldRepaint(covariant TileLabelPainter old) => true; + + @override + bool shouldRebuildSemantics(covariant CustomPainter old) => false; + + @override + SemanticsBuilderCallback? get semanticsBuilder => null; + + @override + bool hitTest(Offset position) => false; + + @override + void addListener(ui.VoidCallback listener) {} + + @override + void removeListener(ui.VoidCallback listener) {} +} + class _RoadStyle { const _RoadStyle({ required this.sortOrder, @@ -2276,6 +2474,26 @@ class _LabelCandidate { final String featureClass; } +// road label stored in world-space so screen position can be recomputed on pan +// without re-iterating tile features +class _WorldRoadLabel { + const _WorldRoadLabel({ + required this.worldX, + required this.worldY, + required this.angle, + required this.pathLength, + required this.text, + required this.painter, + }); + + final double worldX; + final double worldY; + final double angle; + final double pathLength; + final String text; + final TextPainter painter; +} + class _DrawCommand { const _DrawCommand({required this.z, required this.draw}); final double z; diff --git a/pubspec.lock b/pubspec.lock index 4610941..5076815 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,14 +97,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" - dart_earcut: - dependency: transitive - description: - name: dart_earcut - sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b - url: "https://pub.dev" - source: hosted - version: "1.2.0" data_widget: dependency: transitive description: @@ -163,14 +155,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_map: - dependency: "direct main" - description: - name: flutter_map - sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" - url: "https://pub.dev" - source: hosted - version: "7.0.2" flutter_test: dependency: "direct dev" description: flutter @@ -301,22 +285,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - lists: - dependency: transitive - description: - name: lists - sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - logger: - dependency: transitive - description: - name: logger - sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 - url: "https://pub.dev" - source: hosted - version: "2.6.2" logging: dependency: transitive description: @@ -349,14 +317,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" - mgrs_dart: - dependency: transitive - description: - name: mgrs_dart - sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 - url: "https://pub.dev" - source: hosted - version: "2.0.0" path: dependency: transitive description: @@ -445,22 +405,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - polylabel: - dependency: transitive - description: - name: polylabel - sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - proj4dart: - dependency: transitive - description: - name: proj4dart - sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e - url: "https://pub.dev" - source: hosted - version: "2.1.0" quiver: dependency: transitive description: @@ -554,14 +498,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - unicode: - dependency: transitive - description: - name: unicode - sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" - url: "https://pub.dev" - source: hosted - version: "0.3.1" vector_math: dependency: transitive description: @@ -586,14 +522,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - wkt_parser: - dependency: transitive - description: - name: wkt_parser - sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" - url: "https://pub.dev" - source: hosted - version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5090597..b4f6291 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ environment: # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, +# consider running `flutter pub upgrade --major-versions`. Alternatively,Still # dependencies can be manually updated by changing the version numbers below to # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. @@ -34,7 +34,6 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - flutter_map: ^7.0.2 hive: ^2.2.3 hive_flutter: ^1.1.0 latlong2: ^0.9.1