add backend server setup with environment configuration, tile caching, and bus stop fetching functionality
This commit is contained in:
parent
618f3dd3ed
commit
49fc04591b
27 changed files with 1536 additions and 374 deletions
2
backend/.env
Normal file
2
backend/.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q
|
||||||
|
PORT=8080
|
||||||
2
backend/.env.example
Normal file
2
backend/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
MAPBOX_ACCESS_TOKEN=pk.your_token_here
|
||||||
|
PORT=8080
|
||||||
52
backend/bin/server.dart
Normal file
52
backend/bin/server.dart
Normal file
|
|
@ -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}');
|
||||||
|
}
|
||||||
2
backend/lib/rra_backend.dart
Normal file
2
backend/lib/rra_backend.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export 'src/cache/tile_cache.dart';
|
||||||
|
export 'src/router.dart';
|
||||||
75
backend/lib/src/cache/tile_cache.dart
vendored
Normal file
75
backend/lib/src/cache/tile_cache.dart
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
|
|
||||||
|
// disk cache for raw .pbf tile bytes
|
||||||
|
// stale after 14 days, max 50k tiles before oldest get pruned
|
||||||
|
class TileCache {
|
||||||
|
TileCache._();
|
||||||
|
|
||||||
|
static const _staleMs = 14 * 24 * 60 * 60 * 1000;
|
||||||
|
static const _maxTiles = 50000;
|
||||||
|
|
||||||
|
static late final Database _db;
|
||||||
|
|
||||||
|
static Future<void> init({String? path}) async {
|
||||||
|
final dbPath = path ?? '${Directory.current.path}/tile_cache.db';
|
||||||
|
_db = sqlite3.open(dbPath);
|
||||||
|
|
||||||
|
_db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS tiles (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
bytes BLOB NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
_db.execute('CREATE INDEX IF NOT EXISTS idx_tiles_ts ON tiles(ts)');
|
||||||
|
|
||||||
|
// WAL mode — much better for concurrent reads
|
||||||
|
_db.execute('PRAGMA journal_mode=WAL');
|
||||||
|
_db.execute('PRAGMA synchronous=NORMAL');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uint8List? get(String key) {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final result = _db.select(
|
||||||
|
'SELECT bytes, ts FROM tiles WHERE key = ?',
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isEmpty) return null;
|
||||||
|
|
||||||
|
final row = result.first;
|
||||||
|
final ts = row['ts'] as int;
|
||||||
|
if (now - ts > _staleMs) {
|
||||||
|
_db.execute('DELETE FROM tiles WHERE key = ?', [key]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row['bytes'] as Uint8List;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void put(String key, Uint8List bytes) {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
_db.execute(
|
||||||
|
'INSERT OR REPLACE INTO tiles (key, ts, bytes) VALUES (?, ?, ?)',
|
||||||
|
[key, now, bytes],
|
||||||
|
);
|
||||||
|
_pruneIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _pruneIfNeeded() {
|
||||||
|
final count = (_db.select('SELECT COUNT(*) as c FROM tiles').first['c'] as int);
|
||||||
|
if (count <= _maxTiles) return;
|
||||||
|
|
||||||
|
final excess = count - _maxTiles;
|
||||||
|
|
||||||
|
_db.execute('''
|
||||||
|
DELETE FROM tiles WHERE key IN (
|
||||||
|
SELECT key FROM tiles ORDER BY ts ASC LIMIT ?
|
||||||
|
)
|
||||||
|
''', [excess]);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
backend/lib/src/handlers/bus_stops_handler.dart
Normal file
116
backend/lib/src/handlers/bus_stops_handler.dart
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/lib/src/handlers/route_handler.dart
Normal file
60
backend/lib/src/handlers/route_handler.dart
Normal file
|
|
@ -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'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/lib/src/handlers/tile_handler.dart
Normal file
60
backend/lib/src/handlers/tile_handler.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
import '../cache/tile_cache.dart';
|
||||||
|
|
||||||
|
class TileHandler {
|
||||||
|
TileHandler({required this.mapboxToken});
|
||||||
|
|
||||||
|
final String mapboxToken;
|
||||||
|
|
||||||
|
static const _upstreamTemplate =
|
||||||
|
'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token={token}';
|
||||||
|
|
||||||
|
Future<Response> handle(Request req) async {
|
||||||
|
final z = req.params['z']!;
|
||||||
|
final x = req.params['x']!;
|
||||||
|
final y = req.params['y']!;
|
||||||
|
|
||||||
|
final cacheKey = 'tile/$z/$x/$y';
|
||||||
|
|
||||||
|
final cached = TileCache.get(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
return _tileResponse(cached, fromCache: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final url = _upstreamTemplate
|
||||||
|
.replaceAll('{z}', z)
|
||||||
|
.replaceAll('{x}', x)
|
||||||
|
.replaceAll('{y}', y)
|
||||||
|
.replaceAll('{token}', mapboxToken);
|
||||||
|
|
||||||
|
final upstream = await http.get(Uri.parse(url));
|
||||||
|
|
||||||
|
if (upstream.statusCode != 200) {
|
||||||
|
return Response(upstream.statusCode, body: 'upstream error');
|
||||||
|
}
|
||||||
|
|
||||||
|
final bytes = upstream.bodyBytes;
|
||||||
|
TileCache.put(cacheKey, bytes);
|
||||||
|
|
||||||
|
return _tileResponse(bytes, fromCache: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Response _tileResponse(Uint8List bytes, {required bool fromCache}) {
|
||||||
|
final compressed = gzip.encode(bytes);
|
||||||
|
return Response.ok(
|
||||||
|
compressed,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-protobuf',
|
||||||
|
'Content-Encoding': 'gzip',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
'X-Cache': fromCache ? 'HIT' : 'MISS',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/lib/src/router.dart
Normal file
24
backend/lib/src/router.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
import 'handlers/bus_stops_handler.dart';
|
||||||
|
import 'handlers/route_handler.dart';
|
||||||
|
import 'handlers/tile_handler.dart';
|
||||||
|
|
||||||
|
Router buildRouter(String mapboxToken) {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
final tileHandler = TileHandler(mapboxToken: mapboxToken);
|
||||||
|
final busStopsHandler = BusStopsHandler();
|
||||||
|
final routeHandler = RouteHandler();
|
||||||
|
|
||||||
|
// GET /tiles/{z}/{x}/{y}
|
||||||
|
router.get('/tiles/<z>/<x>/<y>', tileHandler.handle);
|
||||||
|
|
||||||
|
// GET /stops?lat=51.5&lon=-0.1&radius=600
|
||||||
|
router.get('/stops', busStopsHandler.handle);
|
||||||
|
|
||||||
|
// GET /route?waypoints=lon,lat;lon,lat&profile=driving
|
||||||
|
router.get('/route', routeHandler.handle);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
173
backend/pubspec.lock
Normal file
173
backend/pubspec.lock
Normal file
|
|
@ -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"
|
||||||
17
backend/pubspec.yaml
Normal file
17
backend/pubspec.yaml
Normal file
|
|
@ -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
|
||||||
|
|
@ -20,7 +20,5 @@
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>13.0</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
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 */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
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 */; };
|
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 */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
|
@ -40,14 +43,19 @@
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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 = "<group>"; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
2D2EF8518DC68D268BB75F78 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
6A08BC70BBE3CBD0627A4BE1 /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
|
26DCCBCFF47641A189F46375 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
0FA9A9998E29E2A691A2A3BB /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9E2B72AB7FC986FC7292C27B /* Pods_Runner.framework */,
|
||||||
|
81A5C3940BC8570BABCA562A /* Pods_RunnerTests.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -79,6 +110,7 @@
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
|
|
@ -94,6 +126,8 @@
|
||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
B4CAA32E841ED9413BF9A155 /* Pods */,
|
||||||
|
0FA9A9998E29E2A691A2A3BB /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|
@ -121,6 +155,20 @@
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
|
@ -128,8 +176,10 @@
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
25C458846219BE655061800B /* [CP] Check Pods Manifest.lock */,
|
||||||
331C807D294A63A400263BE5 /* Sources */,
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
331C807F294A63A400263BE5 /* Resources */,
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
2D2EF8518DC68D268BB75F78 /* Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
|
@ -145,6 +195,7 @@
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
296FAA439AE10256186EEEC8 /* [CP] Check Pods Manifest.lock */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
|
|
@ -157,6 +208,9 @@
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
|
|
@ -190,6 +244,9 @@
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
|
|
@ -222,6 +279,50 @@
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase 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 */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
|
|
@ -379,6 +480,7 @@
|
||||||
};
|
};
|
||||||
331C8088294A63A400263BE5 /* Debug */ = {
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 4D757F63BD16F17354CAFDAE /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
|
@ -396,6 +498,7 @@
|
||||||
};
|
};
|
||||||
331C8089294A63A400263BE5 /* Release */ = {
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = B0768FD4CFE5F6B5F38F8296 /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
|
@ -411,6 +514,7 @@
|
||||||
};
|
};
|
||||||
331C808A294A63A400263BE5 /* Profile */ = {
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 01BC4FEF809E87D61F7EC1FC /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
|
@ -614,6 +718,20 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,24 @@
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|
|
||||||
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
|
|
@ -4,4 +4,7 @@
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
|
|
@ -24,6 +26,29 @@
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>FlutterSceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
|
|
@ -41,9 +66,5 @@
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const String kBackendBaseUrl = 'http://192.168.0.25:8080';
|
||||||
|
|
||||||
final Color kMapBackgroundColor = Color(0xFFA7BFDB);
|
final Color kMapBackgroundColor = Color(0xFFA7BFDB);
|
||||||
|
|
||||||
final Color kMapLanduseColor = Color(0xFFE500FE);
|
final Color kMapLanduseColor = Color(0xFFE500FE);
|
||||||
final Color kMapGrassColor = Color(0xFFA2CE83);
|
final Color kMapGrassColor = Color(0xFF9EC582);
|
||||||
final Color kMapWaterColor = Color(0xFF74B8ED);
|
final Color kMapWaterColor = Color(0xFF74B8ED);
|
||||||
final Color kMapBuildingFillColor = Color(0xFFFED000);
|
final Color kMapBuildingFillColor = Color(0xFFFED000);
|
||||||
final Color kMapBuildingOutlineColor = Color(0xFFE7B600);
|
final Color kMapBuildingOutlineColor = Color(0xFFE7B600);
|
||||||
|
|
@ -31,4 +33,7 @@ final Color kMapRailUnderlayColor = Color(0xFF8A97A8);
|
||||||
final Color kMapPoiLabelColor = Color(0xFF032D51);
|
final Color kMapPoiLabelColor = Color(0xFF032D51);
|
||||||
final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF);
|
final Color kMapPoiLabelHaloColor = Color(0xF2FFFFFF);
|
||||||
|
|
||||||
const bool kMapDebugShowTileBoundaries = false;
|
const bool kMapDebugShowTileBoundaries = false;
|
||||||
|
const bool kMapDebugShowPlaceSymbolRank = false;
|
||||||
|
|
||||||
|
const bool kMapUseSymbolRankAsZoomGate = true;
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:flutter/scheduler.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:latlong2/latlong.dart' show LatLng;
|
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/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_cache.dart';
|
||||||
import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart';
|
import 'package:rra_app/pages/map/tfl/tfl_bus_stop_service.dart';
|
||||||
|
|
@ -48,13 +49,13 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
static const _interactionIdleDelay = Duration(milliseconds: 280);
|
static const _interactionIdleDelay = Duration(milliseconds: 280);
|
||||||
static const _fpsWindowSize = 90;
|
static const _fpsWindowSize = 90;
|
||||||
static const _fpsUpdateInterval = Duration(milliseconds: 240);
|
static const _fpsUpdateInterval = Duration(milliseconds: 240);
|
||||||
static const _tileUrlTemplate =
|
static const _tileUrlTemplate = '$kBackendBaseUrl/tiles/{z}/{x}/{y}';
|
||||||
'https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoiaW1iZW5qaW5ldCIsImEiOiJjbW01azQ1bTcwODJ5MnBzOG9tMTUxdGRoIn0.z22taz_5I_8D5GuDlser_Q';
|
|
||||||
|
|
||||||
final GlobalKey _mapKey = GlobalKey();
|
final GlobalKey _mapKey = GlobalKey();
|
||||||
final OsrmRoutingService _routingService = const OsrmRoutingService();
|
final OsrmRoutingService _routingService = const OsrmRoutingService();
|
||||||
|
|
||||||
final List<LatLng> _points = <LatLng>[];
|
final List<LatLng> _points = <LatLng>[];
|
||||||
|
final List<String?> _pointLabels = <String?>[];
|
||||||
List<LatLng> _resolvedRoute = <LatLng>[];
|
List<LatLng> _resolvedRoute = <LatLng>[];
|
||||||
|
|
||||||
LatLng _mapCenter = _london;
|
LatLng _mapCenter = _london;
|
||||||
|
|
@ -68,6 +69,8 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
|
|
||||||
late final Ticker _zoomTicker;
|
late final Ticker _zoomTicker;
|
||||||
double _lastPinchScale = 1.0;
|
double _lastPinchScale = 1.0;
|
||||||
|
double _lastScaleValue = 1.0;
|
||||||
|
Offset _lastScaleFocal = Offset.zero;
|
||||||
Offset _pendingPanDelta = Offset.zero;
|
Offset _pendingPanDelta = Offset.zero;
|
||||||
bool _panFrameScheduled = false;
|
bool _panFrameScheduled = false;
|
||||||
Offset _pendingPanZoomDelta = Offset.zero;
|
Offset _pendingPanZoomDelta = Offset.zero;
|
||||||
|
|
@ -87,8 +90,9 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
final TflBusStopService _busStopService = TflBusStopService();
|
final TflBusStopService _busStopService = TflBusStopService();
|
||||||
List<BusStop> _busStops = const [];
|
List<BusStop> _busStops = const [];
|
||||||
Timer? _busStopFetchTimer;
|
Timer? _busStopFetchTimer;
|
||||||
// zoom threshold below which we dont bother fetching
|
LatLng? _lastFetchedCenter;
|
||||||
static const _busStopMinZoom = 18.0;
|
double? _lastFetchedZoom;
|
||||||
|
static const _busStopMinZoom = 14.0;
|
||||||
|
|
||||||
late final TilePyramidPainter _pyramidPainter;
|
late final TilePyramidPainter _pyramidPainter;
|
||||||
|
|
||||||
|
|
@ -163,9 +167,10 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addPoint(LatLng point) {
|
void _addPoint(LatLng point, {String? label}) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_points.add(point);
|
_points.add(point);
|
||||||
|
_pointLabels.add(label);
|
||||||
});
|
});
|
||||||
_resolveRoute();
|
_resolveRoute();
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +179,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
if (_points.isEmpty) return;
|
if (_points.isEmpty) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_points.removeLast();
|
_points.removeLast();
|
||||||
|
_pointLabels.removeLast();
|
||||||
});
|
});
|
||||||
_resolveRoute();
|
_resolveRoute();
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +188,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
if (_points.isEmpty) return;
|
if (_points.isEmpty) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_points.clear();
|
_points.clear();
|
||||||
|
_pointLabels.clear();
|
||||||
_resolvedRoute = <LatLng>[];
|
_resolvedRoute = <LatLng>[];
|
||||||
_routeError = null;
|
_routeError = null;
|
||||||
_isResolvingRoute = false;
|
_isResolvingRoute = false;
|
||||||
|
|
@ -394,7 +401,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
..devicePixelRatio = _devicePixelRatio
|
..devicePixelRatio = _devicePixelRatio
|
||||||
..interactionActive = _interactionIdleTimer?.isActive ?? false;
|
..interactionActive = _interactionIdleTimer?.isActive ?? false;
|
||||||
_pyramidPainter.markDirty();
|
_pyramidPainter.markDirty();
|
||||||
_scheduleBusStopFetch();
|
_maybeScheduleBusStopFetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
LatLng _segmentMidpoint(LatLng a, LatLng b) {
|
LatLng _segmentMidpoint(LatLng a, LatLng b) {
|
||||||
|
|
@ -413,6 +420,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
final insertIndex = segmentIndex + 1;
|
final insertIndex = segmentIndex + 1;
|
||||||
setState(() {
|
setState(() {
|
||||||
_points.insert(insertIndex, midpoint);
|
_points.insert(insertIndex, midpoint);
|
||||||
|
_pointLabels.insert(insertIndex, null);
|
||||||
_draggingInsertPointIndex = insertIndex;
|
_draggingInsertPointIndex = insertIndex;
|
||||||
_isAddPointArmed = false;
|
_isAddPointArmed = false;
|
||||||
_resolvedRoute = _points.toList();
|
_resolvedRoute = _points.toList();
|
||||||
|
|
@ -461,6 +469,11 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _addBusStopToRoute(BusStop stop) {
|
||||||
|
setState(() => _isAddPointArmed = false);
|
||||||
|
_addPoint(stop.position, label: stop.name);
|
||||||
|
}
|
||||||
|
|
||||||
void _inspectBusStop(BusStop stop) {
|
void _inspectBusStop(BusStop stop) {
|
||||||
final props = <String, Object?>{
|
final props = <String, Object?>{
|
||||||
'name': stop.name,
|
'name': stop.name,
|
||||||
|
|
@ -485,14 +498,31 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleBusStopFetch() {
|
void _maybeScheduleBusStopFetch() {
|
||||||
_busStopFetchTimer?.cancel();
|
|
||||||
if (_zoom < _busStopMinZoom) {
|
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;
|
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();
|
_fetchBusStops();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -500,27 +530,24 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
Future<void> _fetchBusStops() async {
|
Future<void> _fetchBusStops() async {
|
||||||
if (_zoom < _busStopMinZoom || !mounted) return;
|
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 mpp = TflBusStopService.metersPerPixel(_mapCenter.latitude, _zoom);
|
||||||
final halfDiag = (_mapSize.longestSide / 2) * mpp;
|
final halfWidthM = (_mapSize.width / 2) * mpp;
|
||||||
final radius = halfDiag.clamp(200, 800).toInt();
|
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 {
|
try {
|
||||||
final stops = await _busStopService.fetchNearby(
|
await for (final stops in _busStopService.fetchInRect(sw, ne)) {
|
||||||
_mapCenter,
|
if (!mounted) return;
|
||||||
radius: radius,
|
setState(() => _busStops = stops);
|
||||||
);
|
await TflBusStopCache.store(cacheKey, stops);
|
||||||
if (!mounted) return;
|
}
|
||||||
setState(() => _busStops = stops);
|
|
||||||
await TflBusStopCache.store(cacheKey, stops);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// silently swallow
|
// silently swallow
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +570,7 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
|
|
||||||
// repulsion pass — push overlapping markers apart in screen space
|
// repulsion pass — push overlapping markers apart in screen space
|
||||||
// only affects rendering positions, not the underlying LatLng data
|
// 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;
|
const iterations = 6;
|
||||||
|
|
||||||
for (var iter = 0; iter < iterations; iter++) {
|
for (var iter = 0; iter < iterations; iter++) {
|
||||||
|
|
@ -577,14 +604,20 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
final screen = positions[i];
|
final screen = positions[i];
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Positioned(
|
Positioned(
|
||||||
left: screen.dx - 13,
|
left: screen.dx - (_zoom < 17.5 ? 8 : 15),
|
||||||
top: screen.dy - 13,
|
top: screen.dy - (_zoom < 17.5 ? 8 : 15),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => _inspectBusStop(stop),
|
onTap: () => _isAddPointArmed
|
||||||
child: Tooltip(
|
? _addBusStopToRoute(stop)
|
||||||
message: stop.name,
|
: _inspectBusStop(stop),
|
||||||
child: _BusStopMarker(stop: 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<MapPage> with TickerProviderStateMixin {
|
||||||
|
|
||||||
final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points;
|
final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points;
|
||||||
final routeScreenPoints = routePoints.map(_latLngToScreen).toList();
|
final routeScreenPoints = routePoints.map(_latLngToScreen).toList();
|
||||||
|
_pyramidPainter.routeScreenPoints = routeScreenPoints;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
|
|
@ -744,9 +778,34 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: _mapKey,
|
key: _mapKey,
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onPanUpdate: (details) {
|
onScaleStart: (details) {
|
||||||
|
_lastScaleValue = 1.0;
|
||||||
|
_lastScaleFocal = details.localFocalPoint;
|
||||||
|
_noteInteraction();
|
||||||
|
},
|
||||||
|
onScaleUpdate: (details) {
|
||||||
if (_draggingInsertPointIndex != null) return;
|
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) {
|
onTapUp: (details) {
|
||||||
if (_draggingInsertPointIndex != null) return;
|
if (_draggingInsertPointIndex != null) return;
|
||||||
|
|
@ -780,6 +839,13 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
..._buildBusStopWidgets(),
|
..._buildBusStopWidgets(),
|
||||||
..._buildPointWidgets(),
|
..._buildPointWidgets(),
|
||||||
..._buildInsertHandles(),
|
..._buildInsertHandles(),
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: TileLabelPainter(_pyramidPainter),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -876,6 +942,14 @@ class _MapPageState extends State<MapPage> with TickerProviderStateMixin {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 34,
|
||||||
|
left: 10,
|
||||||
|
child: _RoutePanel(
|
||||||
|
points: _points,
|
||||||
|
labels: _pointLabels,
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -908,14 +982,14 @@ class _RoutePainter extends CustomPainter {
|
||||||
|
|
||||||
final white = Paint()
|
final white = Paint()
|
||||||
..color = Colors.white
|
..color = Colors.white
|
||||||
..strokeWidth = 9
|
..strokeWidth = 14
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeJoin = StrokeJoin.round
|
..strokeJoin = StrokeJoin.round
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
||||||
final route = Paint()
|
final route = Paint()
|
||||||
..color = routeColor
|
..color = routeColor
|
||||||
..strokeWidth = 5
|
..strokeWidth = 9
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeJoin = StrokeJoin.round
|
..strokeJoin = StrokeJoin.round
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
|
|
@ -1212,21 +1286,170 @@ class _StatusPanel extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BusStopMarker extends StatelessWidget {
|
class _RoutePanel extends StatelessWidget {
|
||||||
const _BusStopMarker({required this.stop});
|
const _RoutePanel({required this.points, required this.labels});
|
||||||
|
|
||||||
final BusStop stop;
|
final List<LatLng> points;
|
||||||
|
final List<String?> labels;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return SizedBox(
|
||||||
width: 26,
|
width: size,
|
||||||
height: 26,
|
height: size,
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFD50000),
|
color: const Color(0xFFD50000),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 1.5),
|
border: Border.all(color: Colors.white, width: borderWidth),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Color(0x44000000),
|
color: Color(0x44000000),
|
||||||
|
|
@ -1235,17 +1458,23 @@ class _BusStopMarker extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Center(
|
child: compact
|
||||||
child: Text(
|
? null
|
||||||
stop.stopLetter ?? 'B',
|
: Center(
|
||||||
style: TextStyle(
|
child: Builder(builder: (context) {
|
||||||
fontSize: stop.stopLetter != null && stop.stopLetter!.length > 1 ? 9 : 11,
|
final raw = stop.stopLetter ?? 'B';
|
||||||
fontWeight: FontWeight.w900,
|
final letter = raw.replaceAll(RegExp(r'^-+>'), '').trim();
|
||||||
color: Colors.white,
|
return Text(
|
||||||
height: 1,
|
letter.isEmpty ? 'B' : letter,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
fontSize: letter.length > 1 ? 9 : 11,
|
||||||
),
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:rra_app/pages/map/constants.dart';
|
||||||
|
|
||||||
class OsrmRoutingService {
|
class OsrmRoutingService {
|
||||||
const OsrmRoutingService({
|
const OsrmRoutingService({
|
||||||
this.baseUrl = 'https://router.project-osrm.org',
|
this.baseUrl = kBackendBaseUrl,
|
||||||
this.profile = 'driving',
|
this.profile = 'driving',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -21,8 +22,7 @@ class OsrmRoutingService {
|
||||||
.map((point) => '${point.longitude},${point.latitude}')
|
.map((point) => '${point.longitude},${point.latitude}')
|
||||||
.join(';');
|
.join(';');
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
'$baseUrl/route/v1/$profile/$coordinates'
|
'$baseUrl/route?waypoints=$coordinates&profile=$profile',
|
||||||
'?overview=full&geometries=geojson',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final response = await http.get(uri);
|
final response = await http.get(uri);
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,10 @@ class TflBusStopCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rounds to ~1km grid cells so nearby pans reuse the same cache entry
|
// rounds bbox corners to ~1km grid so nearby pans reuse the same entry
|
||||||
static String keyFor(LatLng center) {
|
static String keyFor(LatLng sw, LatLng ne) {
|
||||||
final lat = (center.latitude * 100).round() / 100;
|
double r(double v) => (v * 100).round() / 100;
|
||||||
final lon = (center.longitude * 100).round() / 100;
|
return '${r(sw.latitude)},${r(sw.longitude)},${r(ne.latitude)},${r(ne.longitude)}';
|
||||||
return '$lat,$lon';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<BusStop>? peek(String key) {
|
static List<BusStop>? peek(String key) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:rra_app/pages/map/constants.dart';
|
||||||
|
|
||||||
class BusStop {
|
class BusStop {
|
||||||
const BusStop({
|
const BusStop({
|
||||||
|
|
@ -17,82 +19,94 @@ class BusStop {
|
||||||
final String name;
|
final String name;
|
||||||
final LatLng position;
|
final LatLng position;
|
||||||
|
|
||||||
// the letter on the physical bus stop flag e.g. "A", "ZH"
|
|
||||||
final String? stopLetter;
|
final String? stopLetter;
|
||||||
|
|
||||||
// may be null for parent stops
|
|
||||||
final String? towards;
|
final String? towards;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TflBusStopService {
|
class TflBusStopService {
|
||||||
TflBusStopService();
|
TflBusStopService();
|
||||||
|
|
||||||
static const _baseUrl = 'https://api.tfl.gov.uk';
|
// streams bus stops — emits once from cache, then again from TfL if fresher
|
||||||
|
Stream<List<BusStop>> fetchInRect(LatLng sw, LatLng ne) async* {
|
||||||
// max radius the TfL API accepts is 1600m but in practice
|
|
||||||
// we clamp to 800 so we dont get swamped at low zoom
|
|
||||||
static const _maxRadius = 800;
|
|
||||||
|
|
||||||
Future<List<BusStop>> fetchNearby(LatLng center, {int radius = 600}) async {
|
|
||||||
final r = radius.clamp(0, _maxRadius);
|
|
||||||
|
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
'$_baseUrl/StopPoint'
|
'$kBackendBaseUrl/stops'
|
||||||
'?stopTypes=NaptanPublicBusCoachTram'
|
'?minLat=${sw.latitude}'
|
||||||
'&lat=${center.latitude}'
|
'&minLon=${sw.longitude}'
|
||||||
'&lon=${center.longitude}'
|
'&maxLat=${ne.latitude}'
|
||||||
'&radius=$r'
|
'&maxLon=${ne.longitude}',
|
||||||
'&modes=bus',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final response = await http.get(uri, headers: {
|
final client = http.Client();
|
||||||
'Accept': 'application/json',
|
try {
|
||||||
});
|
final request = http.Request('GET', uri);
|
||||||
|
final streamed = await client.send(request);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
final buffer = StringBuffer();
|
||||||
throw Exception('TfL StopPoint request failed (${response.statusCode})');
|
|
||||||
}
|
|
||||||
|
|
||||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
await for (final chunk in streamed.stream.transform(utf8.decoder)) {
|
||||||
final stopPoints = body['stopPoints'];
|
buffer.write(chunk);
|
||||||
if (stopPoints is! List) return const [];
|
final text = buffer.toString();
|
||||||
|
|
||||||
final stops = <BusStop>[];
|
// SSE events are delimited by \n\n
|
||||||
for (final raw in stopPoints) {
|
int idx;
|
||||||
if (raw is! Map<String, dynamic>) continue;
|
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();
|
if (event.startsWith('data: ')) {
|
||||||
final lon = (raw['lon'] as num?)?.toDouble();
|
final json = event.substring(6);
|
||||||
if (lat == null || lon == null) continue;
|
final stops = _parseStops(json);
|
||||||
|
if (stops != null) yield stops;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buffer.clear();
|
||||||
|
buffer.write(remaining);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
stops.add(BusStop(
|
client.close();
|
||||||
id: id,
|
}
|
||||||
name: name,
|
}
|
||||||
position: LatLng(lat, lon),
|
|
||||||
stopLetter: stopLetter,
|
static List<BusStop>? _parseStops(String json) {
|
||||||
towards: towards,
|
try {
|
||||||
));
|
final body = jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
final stopPoints = body['stopPoints'];
|
||||||
|
if (stopPoints is! List) return null;
|
||||||
|
|
||||||
|
final stops = <BusStop>[];
|
||||||
|
for (final raw in stopPoints) {
|
||||||
|
if (raw is! Map<String, dynamic>) continue;
|
||||||
|
final lat = (raw['lat'] as num?)?.toDouble();
|
||||||
|
final lon = (raw['lon'] as num?)?.toDouble();
|
||||||
|
if (lat == null || lon == null) continue;
|
||||||
|
|
||||||
|
String? towards;
|
||||||
|
final addInfo = raw['additionalProperties'];
|
||||||
|
if (addInfo is List) {
|
||||||
|
for (final prop in addInfo) {
|
||||||
|
if (prop is Map && prop['key'] == 'Towards') {
|
||||||
|
towards = prop['value'] as String?;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stops.add(BusStop(
|
||||||
|
id: raw['id'] as String? ?? '',
|
||||||
|
name: raw['commonName'] as String? ?? '',
|
||||||
|
position: LatLng(lat, lon),
|
||||||
|
stopLetter: raw['stopLetter'] as String?,
|
||||||
|
towards: towards,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return stops;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return stops;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rough haversine distance in metres, good enough for radius estimation
|
|
||||||
static double metersPerPixel(double lat, double zoom) {
|
static double metersPerPixel(double lat, double zoom) {
|
||||||
const earthCircumference = 40075016.686;
|
const earthCircumference = 40075016.686;
|
||||||
final latRad = lat * math.pi / 180.0;
|
final latRad = lat * math.pi / 180.0;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
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/tiles/hive_tile_cache.dart';
|
||||||
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
||||||
|
|
||||||
|
// top-level so compute() can find it
|
||||||
|
MvtTile? _parseMvtBackground(({Uint8List bytes, Set<String> layerNames}) args) {
|
||||||
|
try {
|
||||||
|
return const MvtParser().parse(args.bytes, layerNames: args.layerNames);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MapboxVectorTileCache {
|
class MapboxVectorTileCache {
|
||||||
MapboxVectorTileCache._();
|
MapboxVectorTileCache._();
|
||||||
|
|
||||||
static final MvtParser _parser = const MvtParser();
|
|
||||||
static const Set<String> _styleLayerWhitelist = <String>{
|
static const Set<String> _styleLayerWhitelist = <String>{
|
||||||
'water',
|
'water',
|
||||||
'landuse',
|
'landuse',
|
||||||
|
|
@ -18,12 +28,16 @@ class MapboxVectorTileCache {
|
||||||
'place_label',
|
'place_label',
|
||||||
'poi_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<String, MvtTile> _cache =
|
static final LinkedHashMap<String, MvtTile> _cache =
|
||||||
LinkedHashMap<String, MvtTile>();
|
LinkedHashMap<String, MvtTile>();
|
||||||
static final Map<String, Future<MvtTile?>> _inFlight =
|
static final Map<String, Future<MvtTile?>> _inFlight =
|
||||||
<String, Future<MvtTile?>>{};
|
<String, Future<MvtTile?>>{};
|
||||||
|
|
||||||
|
static bool isFetching(String url) => _inFlight.containsKey(url);
|
||||||
|
|
||||||
static MvtTile? peek(String url) {
|
static MvtTile? peek(String url) {
|
||||||
final cached = _cache.remove(url);
|
final cached = _cache.remove(url);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
|
|
@ -48,21 +62,25 @@ class MapboxVectorTileCache {
|
||||||
static Future<MvtTile?> _fetch(String url) async {
|
static Future<MvtTile?> _fetch(String url) async {
|
||||||
final bytes = await HiveTileCache.getOrFetch(url);
|
final bytes = await HiveTileCache.getOrFetch(url);
|
||||||
if (bytes == null || bytes.isEmpty) return null;
|
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;
|
if (tile == null) return null;
|
||||||
|
|
||||||
_cache[url] = tile;
|
_cache[url] = tile;
|
||||||
_trim();
|
_trim();
|
||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
static MvtTile? _parse(Uint8List bytes) {
|
|
||||||
try {
|
|
||||||
return _parser.parse(bytes, layerNames: _styleLayerWhitelist);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void _trim() {
|
static void _trim() {
|
||||||
while (_cache.length > _maxEntries) {
|
while (_cache.length > _maxEntries) {
|
||||||
_cache.remove(_cache.keys.first);
|
_cache.remove(_cache.keys.first);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class _TileCoord {
|
class _TileCoord {
|
||||||
const _TileCoord(this.z, this.x, this.y);
|
const _TileCoord(this.z, this.x, this.y);
|
||||||
|
|
||||||
|
|
@ -27,13 +30,18 @@ class CachedTile {
|
||||||
class TilePyramidCache {
|
class TilePyramidCache {
|
||||||
TilePyramidCache._();
|
TilePyramidCache._();
|
||||||
|
|
||||||
static const int _maxEntries = 384;
|
static final bool _isMobile =
|
||||||
static const int _maxApproxBytes = 768 * 1024 * 1024;
|
!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
|
// Cap concurrent renders to avoid saturating the main isolate with
|
||||||
// synchronous canvas recording. I/O (network/Hive) is still concurrent
|
// synchronous canvas recording. I/O (network/Hive) is still concurrent
|
||||||
// up to this limit.
|
// up to this limit.
|
||||||
static const int _maxConcurrent = 4;
|
static final int _maxConcurrent = kIsWeb ? 2 : (_isMobile ? 2 : 4);
|
||||||
|
|
||||||
static final LinkedHashMap<_TileCoord, CachedTile> _cache =
|
static final LinkedHashMap<_TileCoord, CachedTile> _cache =
|
||||||
LinkedHashMap<_TileCoord, CachedTile>();
|
LinkedHashMap<_TileCoord, CachedTile>();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:google_fonts/google_fonts.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';
|
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
|
||||||
|
|
||||||
const int _kMaxAncestorLookup = 4;
|
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;
|
const double _kLineReferenceZoom = 14.0;
|
||||||
|
|
||||||
class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
|
|
@ -37,12 +39,26 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
int? fallbackTileZoom;
|
int? fallbackTileZoom;
|
||||||
double devicePixelRatio = 1;
|
double devicePixelRatio = 1;
|
||||||
bool interactionActive = false;
|
bool interactionActive = false;
|
||||||
|
List<Offset> routeScreenPoints = const [];
|
||||||
|
|
||||||
// cache of tile-local road label placements keyed by "$url:$featureIndex"
|
// cache of tile-local road label placements keyed by "$url:$featureIndex"
|
||||||
// placement is stored in tile extent coordinates so it never changes on pan
|
// placement is stored in tile extent coordinates so it never changes on pan
|
||||||
final Map<String, _RoadLabelPlacement?> _roadLabelPlacementCache = {};
|
final Map<String, _RoadLabelPlacement?> _roadLabelPlacementCache = {};
|
||||||
|
|
||||||
void markDirty() => notifyListeners();
|
// laid-out TextPainters keyed by "text|fontSize" — layout is expensive
|
||||||
|
final Map<String, TextPainter> _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
|
String _tileUrl(int z, int x, int y) => tileUrlTemplate
|
||||||
.replaceAll('{z}', '$z')
|
.replaceAll('{z}', '$z')
|
||||||
|
|
@ -106,6 +122,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
_drawTileLayer(canvas, activeTileZoom, queueTiles: true);
|
_drawTileLayer(canvas, activeTileZoom, queueTiles: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void paintLabels(Canvas canvas) {
|
||||||
|
if (mapWidth == 0 || mapHeight == 0) return;
|
||||||
_drawAllRoadLabels(canvas);
|
_drawAllRoadLabels(canvas);
|
||||||
_drawAllPlaceLabels(canvas);
|
_drawAllPlaceLabels(canvas);
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +148,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
||||||
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
||||||
|
|
||||||
if (queueTiles) {
|
if (queueTiles && !kIsWeb) {
|
||||||
final allowed = _allowedQueuedTileHashes(
|
final allowed = _allowedQueuedTileHashes(
|
||||||
z,
|
z,
|
||||||
minTX,
|
minTX,
|
||||||
|
|
@ -160,7 +180,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queueTiles) {
|
if (queueTiles && !kIsWeb) {
|
||||||
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
|
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
|
||||||
|
|
||||||
if (!interactionActive) {
|
if (!interactionActive) {
|
||||||
|
|
@ -213,6 +233,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
|
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);
|
final native = TilePyramidCache.peek(z, x, y);
|
||||||
if (native != null) {
|
if (native != null) {
|
||||||
_blitImage(
|
_blitImage(
|
||||||
|
|
@ -251,9 +277,54 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
_blitImage(canvas, ancestor.image, src, dst);
|
_blitImage(canvas, ancestor.image, src, dst);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!TilePyramidCache.isInFlight(z, x, y)) {
|
void _drawTileWeb(Canvas canvas, int z, int x, int y, Rect dst) {
|
||||||
_queueTile(z, x, y);
|
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) {
|
void _queueTile(int z, int x, int y) {
|
||||||
final url = _tileUrl(z, x, 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(
|
TilePyramidCache.enqueue(
|
||||||
z,
|
z,
|
||||||
|
|
@ -296,6 +371,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
int visMinTY = 0,
|
int visMinTY = 0,
|
||||||
int visMaxTY = 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 tx = minTX; tx <= maxTX; tx++) {
|
||||||
for (var ty = minTY; ty <= maxTY; ty++) {
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||||
if (ty < 0 || ty >= maxIndex) continue;
|
if (ty < 0 || ty >= maxIndex) continue;
|
||||||
|
|
@ -310,9 +391,17 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||||
if (TilePyramidCache.peek(z, wrappedX, ty) != null) continue;
|
if (TilePyramidCache.peek(z, wrappedX, ty) != null) continue;
|
||||||
if (TilePyramidCache.isInFlight(z, wrappedX, ty)) 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(
|
MapDebugHit? _inspectLayerHit(
|
||||||
|
|
@ -386,7 +475,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
|
|
||||||
final recorder = ui.PictureRecorder();
|
final recorder = ui.PictureRecorder();
|
||||||
final size = Size.square(rasterPx.toDouble());
|
final size = Size.square(rasterPx.toDouble());
|
||||||
final canvas = Canvas(recorder);
|
final canvas = Canvas(recorder, Offset.zero & size);
|
||||||
|
|
||||||
_paintTile(canvas, size, tile, tileZoom: tileZoom);
|
_paintTile(canvas, size, tile, tileZoom: tileZoom);
|
||||||
|
|
||||||
|
|
@ -449,7 +538,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
baseZ: 25.0,
|
baseZ: 25.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// water — above grass so rivers/lakes cut through parks
|
// water
|
||||||
_collectPolygonCommands(
|
_collectPolygonCommands(
|
||||||
commands,
|
commands,
|
||||||
size,
|
size,
|
||||||
|
|
@ -483,6 +572,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
fillColor: kMapPathFillColour,
|
fillColor: kMapPathFillColour,
|
||||||
tileZoom: tileZoom,
|
tileZoom: tileZoom,
|
||||||
allowedClasses: const <String>{'school'},
|
allowedClasses: const <String>{'school'},
|
||||||
|
allowedTypes: const <String>{'school', 'college', 'university'},
|
||||||
baseZ: 35.0,
|
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})>[];
|
final styledLines = <({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey, String roadClass})>[];
|
||||||
for (final feature in roadLayer.features) {
|
for (final feature in roadLayer.features) {
|
||||||
if (feature.type != MvtGeometryType.lineString) continue;
|
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);
|
final style = _roadStyleFor(feature, tileZoom: tileZoom);
|
||||||
if (style == null) continue;
|
if (style == null) continue;
|
||||||
styledLines.add((
|
styledLines.add((
|
||||||
|
|
@ -679,7 +771,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
style: style,
|
style: style,
|
||||||
layerProp: _featureLayerProp(feature),
|
layerProp: _featureLayerProp(feature),
|
||||||
sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0,
|
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)
|
// erase with all same-class cores (across all layerProps)
|
||||||
final classErase = eraseByClass[roadClass] ?? [];
|
final classErase = eraseByClass[roadClass] ?? [];
|
||||||
|
final needsErase = classErase.isNotEmpty;
|
||||||
|
|
||||||
final railBias = roadClass.contains('rail') ? -10.0 : 0.0;
|
final railBias = roadClass.contains('rail') ? -10.0 : 0.0;
|
||||||
final underlayZ = 50.0 + lp * 30.0 + railBias;
|
final underlayZ = 50.0 + lp * 30.0 + railBias;
|
||||||
commands.add(_DrawCommand(
|
commands.add(_DrawCommand(
|
||||||
z: underlayZ,
|
z: underlayZ,
|
||||||
draw: (c) {
|
draw: (c) {
|
||||||
c.saveLayer(Offset.zero & size, Paint());
|
if (needsErase) c.saveLayer(Offset.zero & size, Paint());
|
||||||
|
|
||||||
for (final d in underlayDrawData) {
|
for (final d in underlayDrawData) {
|
||||||
for (final line in d.lines) {
|
for (final line in d.lines) {
|
||||||
|
|
@ -805,30 +898,31 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final d in classErase) {
|
if (needsErase) {
|
||||||
for (final line in d.lines) {
|
for (final d in classErase) {
|
||||||
if (line.length < 2) continue;
|
for (final line in d.lines) {
|
||||||
final startIsTerminal = !junctions.contains(_epKey(line.first));
|
if (line.length < 2) continue;
|
||||||
final endIsTerminal = !junctions.contains(_epKey(line.last));
|
final startIsTerminal = !junctions.contains(_epKey(line.first));
|
||||||
final trimmed = _trimPolyline(
|
final endIsTerminal = !junctions.contains(_epKey(line.last));
|
||||||
line,
|
final trimmed = _trimPolyline(
|
||||||
startIsTerminal ? d.terminalInset : 0.0,
|
line,
|
||||||
endIsTerminal ? d.terminalInset : 0.0,
|
startIsTerminal ? d.terminalInset : 0.0,
|
||||||
);
|
endIsTerminal ? d.terminalInset : 0.0,
|
||||||
if (trimmed == null || trimmed.length < 2) continue;
|
);
|
||||||
c.drawPath(_scaledPath(trimmed, scale), d.paint);
|
if (trimmed == null || trimmed.length < 2) continue;
|
||||||
}
|
c.drawPath(_scaledPath(trimmed, scale), d.paint);
|
||||||
final circlePaint = Paint()
|
}
|
||||||
..color = d.paint.color
|
final circlePaint = Paint()
|
||||||
..style = PaintingStyle.fill
|
..color = d.paint.color
|
||||||
..blendMode = BlendMode.dstOut
|
..style = PaintingStyle.fill
|
||||||
..isAntiAlias = true;
|
..blendMode = BlendMode.dstOut
|
||||||
for (final pt in d.junctionPts) {
|
..isAntiAlias = true;
|
||||||
c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint);
|
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 isCapital = capital >= 2;
|
||||||
|
|
||||||
final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt();
|
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_subdivision') continue;
|
||||||
if (zoom < 12.5 && featureClass == 'settlement') continue;
|
if (zoom < 12.5 && featureClass == 'settlement') continue;
|
||||||
}
|
}
|
||||||
|
|
@ -1404,7 +1499,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
.toString()
|
.toString()
|
||||||
.trim());
|
.trim());
|
||||||
if (rawText.isEmpty || rawText.length > 30) continue;
|
if (rawText.isEmpty || rawText.length > 30) continue;
|
||||||
final text = rawText;
|
final text = kMapDebugShowPlaceSymbolRank ? '$rawText [$symbolrank]' : rawText;
|
||||||
|
|
||||||
final anchor =
|
final anchor =
|
||||||
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
|
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
|
||||||
|
|
@ -1693,147 +1788,193 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
||||||
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
||||||
|
|
||||||
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
|
|
||||||
const minSameNameSpacing = 500.0;
|
const minSameNameSpacing = 500.0;
|
||||||
|
|
||||||
// collect all candidates first so we can sort by path length and always
|
// rebuild world-space label list only when tiles or zoom changed
|
||||||
// pick the longest visible segment per name — stable across panning
|
// pan only changes screen positions, which we recompute cheaply from worldX/Y
|
||||||
final candidates = <_RoadLabelCandidate>[];
|
if (_tileVersion != _roadLabelCacheVersion || zoom != _roadLabelCacheZoom) {
|
||||||
|
final worldLabels = <_WorldRoadLabel>[];
|
||||||
|
|
||||||
for (var tx = minTX; tx <= maxTX; tx++) {
|
for (var tx = minTX; tx <= maxTX; tx++) {
|
||||||
for (var ty = minTY; ty <= maxTY; ty++) {
|
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||||
if (ty < 0 || ty >= maxIndex) continue;
|
if (ty < 0 || ty >= maxIndex) continue;
|
||||||
|
|
||||||
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||||
final url = _tileUrl(z, wrappedX, ty);
|
final url = _tileUrl(z, wrappedX, ty);
|
||||||
final tile = MapboxVectorTileCache.peek(url);
|
final tile = MapboxVectorTileCache.peek(url);
|
||||||
if (tile == null) continue;
|
if (tile == null) continue;
|
||||||
|
|
||||||
final layer = tile.layers['road'];
|
final layer = tile.layers['road'];
|
||||||
if (layer == null) continue;
|
if (layer == null) continue;
|
||||||
|
|
||||||
final tileLeft = (tx * tileSize - minWX) * factor;
|
final scale = (tileSize * factor) / layer.extent;
|
||||||
final tileTop = (ty * tileSize - minWY) * factor;
|
|
||||||
final scale = (tileSize * factor) / layer.extent;
|
|
||||||
|
|
||||||
for (final feature in layer.features) {
|
for (var fi = 0; fi < layer.features.length; fi++) {
|
||||||
if (feature.type != MvtGeometryType.lineString) continue;
|
final feature = layer.features[fi];
|
||||||
|
if (feature.type != MvtGeometryType.lineString) continue;
|
||||||
|
|
||||||
final roadClass =
|
final roadClass =
|
||||||
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
||||||
.toString()
|
.toString()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (_shouldSkipRoadLabelClass(roadClass)) continue;
|
if (_shouldSkipRoadLabelClass(roadClass)) continue;
|
||||||
if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue;
|
if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue;
|
||||||
final style = _roadStyleFor(feature, tileZoom: z.toDouble());
|
final style = _roadStyleFor(feature, tileZoom: z.toDouble());
|
||||||
if (style == null) continue;
|
if (style == null) continue;
|
||||||
|
|
||||||
final text = _sanitizeLabel(
|
final text = _sanitizeLabel(
|
||||||
(feature.properties['name_en'] ??
|
(feature.properties['name_en'] ??
|
||||||
feature.properties['name'] ??
|
feature.properties['name'] ??
|
||||||
'')
|
'')
|
||||||
.toString()
|
.toString()
|
||||||
.trim());
|
.trim());
|
||||||
if (text.isEmpty || text.length > 32) continue;
|
if (text.isEmpty || text.length > 32) continue;
|
||||||
|
|
||||||
final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom;
|
final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom;
|
||||||
final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble();
|
final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble();
|
||||||
final roadCorePx = _worldStrokePx(
|
final roadCorePx = _worldStrokePx(
|
||||||
style.coreWorldUnits,
|
style.coreWorldUnits,
|
||||||
extent: layer.extent,
|
extent: layer.extent,
|
||||||
rasterSize: tileSize * fontFactor,
|
rasterSize: tileSize * fontFactor,
|
||||||
tileZoom: z.toDouble(),
|
tileZoom: z.toDouble(),
|
||||||
);
|
);
|
||||||
final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0);
|
final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0);
|
||||||
|
|
||||||
final painter = TextPainter(
|
final painterKey = '$text|${fontSize.toStringAsFixed(1)}';
|
||||||
text: TextSpan(
|
final painter = _textPainterCache[painterKey] ?? () {
|
||||||
|
final p = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: kMapRoadLabelColor,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
shadows: <Shadow>[
|
||||||
|
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,
|
text: text,
|
||||||
style: GoogleFonts.inter(
|
painter: painter,
|
||||||
color: kMapRoadLabelColor,
|
));
|
||||||
fontSize: fontSize,
|
}
|
||||||
fontWeight: FontWeight.w600,
|
}
|
||||||
shadows: <Shadow>[
|
}
|
||||||
Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2),
|
|
||||||
],
|
// longest segment always wins regardless of tile iteration order
|
||||||
height: 1.0,
|
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 = <Rect>[];
|
||||||
|
final placedByName = <String, List<Offset>>{};
|
||||||
|
|
||||||
|
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,
|
textDirection: TextDirection.ltr,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
ellipsis: '',
|
ellipsis: '',
|
||||||
)..layout();
|
)..layout();
|
||||||
|
if (_textPainterCache.length >= _maxTextPainterCacheSize) {
|
||||||
final featureIndex = layer.features.indexOf(feature);
|
_textPainterCache.remove(_textPainterCache.keys.first);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
final localPlacement = _roadLabelPlacementCache[cacheKey];
|
_textPainterCache[strokeKey] = p;
|
||||||
if (localPlacement == null) continue;
|
return p;
|
||||||
|
}();
|
||||||
final screenCenter = Offset(
|
strokePainter.paint(canvas, Offset(-strokePainter.width / 2, -strokePainter.height / 2));
|
||||||
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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
w.painter.paint(canvas, Offset(-w.painter.width / 2, -w.painter.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 = <Rect>[];
|
|
||||||
final placedByName = <String, List<Offset>>{};
|
|
||||||
|
|
||||||
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));
|
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
|
|
||||||
occupiedRects.add(bounds);
|
occupiedRects.add(bounds);
|
||||||
(placedByName[key] ??= <Offset>[]).add(c.screenCenter);
|
(placedByName[key] ??= <Offset>[]).add(screenCenter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2112,6 +2253,36 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
return length;
|
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) {
|
bool _isMajorRoadClass(String roadClass) {
|
||||||
return roadClass.contains('motorway') ||
|
return roadClass.contains('motorway') ||
|
||||||
roadClass.contains('trunk') ||
|
roadClass.contains('trunk') ||
|
||||||
|
|
@ -2189,6 +2360,33 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||||
bool hitTest(Offset position) => false;
|
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 {
|
class _RoadStyle {
|
||||||
const _RoadStyle({
|
const _RoadStyle({
|
||||||
required this.sortOrder,
|
required this.sortOrder,
|
||||||
|
|
@ -2276,6 +2474,26 @@ class _LabelCandidate {
|
||||||
final String featureClass;
|
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 {
|
class _DrawCommand {
|
||||||
const _DrawCommand({required this.z, required this.draw});
|
const _DrawCommand({required this.z, required this.draw});
|
||||||
final double z;
|
final double z;
|
||||||
|
|
|
||||||
72
pubspec.lock
72
pubspec.lock
|
|
@ -97,14 +97,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
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:
|
data_widget:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -163,14 +155,6 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -301,22 +285,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
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:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -349,14 +317,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -445,22 +405,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
quiver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -554,14 +498,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
unicode:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: unicode
|
|
||||||
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.3.1"
|
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -586,14 +522,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ environment:
|
||||||
|
|
||||||
# Dependencies specify other packages that your package needs in order to work.
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
# To automatically upgrade your package dependencies to the latest versions
|
# 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
|
# 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
|
# the latest version available on pub.dev. To see which dependencies have newer
|
||||||
# versions available, run `flutter pub outdated`.
|
# versions available, run `flutter pub outdated`.
|
||||||
|
|
@ -34,7 +34,6 @@ dependencies:
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_map: ^7.0.2
|
|
||||||
hive: ^2.2.3
|
hive: ^2.2.3
|
||||||
hive_flutter: ^1.1.0
|
hive_flutter: ^1.1.0
|
||||||
latlong2: ^0.9.1
|
latlong2: ^0.9.1
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue