import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:rra_app/pages/map/vector/mapbox_vector_tile_cache.dart'; import 'package:rra_app/pages/map/vector/mvt_parser.dart'; import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart'; // How many ancestor levels to walk when looking for a fallback image. const int _kMaxAncestorLookup = 4; const double _kTileResolutionScale = 2.0; class TilePyramidPainter extends ChangeNotifier implements CustomPainter { TilePyramidPainter({ required this.tileUrlTemplate, required this.tileSize, required this.minTileZoom, required this.maxTileZoom, required this.tileOverscan, }); final String tileUrlTemplate; final double tileSize; final int minTileZoom; final int maxTileZoom; final int tileOverscan; // Viewport state — updated by the map page each frame. double mapWidth = 0; double mapHeight = 0; double zoom = 0; double centerWorldX = 0; double centerWorldY = 0; int activeTileZoom = 0; int? fallbackTileZoom; double devicePixelRatio = 1; bool interactionActive = false; // Colour filters applied when drawing each tile (contrast + saturation combined). static const double _contrast = 1.20; static const double _saturation = 1.12; late final ui.ColorFilter _tileColorFilter = ui.ColorFilter.matrix( _multiplyMatrices( _contrastMatrix(_contrast), _saturationMatrix(_saturation), ), ); void markDirty() => notifyListeners(); String _tileUrl(int z, int x, int y) => tileUrlTemplate .replaceAll('{z}', '$z') .replaceAll('{x}', '$x') .replaceAll('{y}', '$y'); @override void paint(Canvas canvas, Size size) { if (mapWidth == 0 || mapHeight == 0) return; final fallbackZ = fallbackTileZoom; if (fallbackZ != null && fallbackZ != activeTileZoom) { _drawTileLayer(canvas, fallbackZ, queueTiles: false); } _drawTileLayer(canvas, activeTileZoom, queueTiles: true); } void _drawTileLayer(Canvas canvas, int z, {required bool queueTiles}) { final zDelta = z - activeTileZoom; final factor = math.pow(2, zoom - z).toDouble(); final worldScale = math.pow(2, zDelta).toDouble(); final centerX = centerWorldX * worldScale; final centerY = centerWorldY * worldScale; final maxIndex = 1 << z; final minWX = centerX - (mapWidth / 2) / factor; final minWY = centerY - (mapHeight / 2) / factor; final maxWX = centerX + (mapWidth / 2) / factor; final maxWY = centerY + (mapHeight / 2) / factor; final minTX = (minWX / tileSize).floor() - tileOverscan; final maxTX = (maxWX / tileSize).floor() + tileOverscan; final minTY = (minWY / tileSize).floor() - tileOverscan; final maxTY = (maxWY / tileSize).floor() + tileOverscan; if (queueTiles) { final allowed = _allowedQueuedTileHashes( z, minTX, maxTX, minTY, maxTY, maxIndex, ); TilePyramidCache.setQueueFilter( (qz, qx, qy) => allowed.contains(Object.hash(qz, qx, qy)), ); } for (var tx = minTX; tx <= maxTX; tx++) { for (var ty = minTY; ty <= maxTY; ty++) { if (ty < 0 || ty >= maxIndex) continue; final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex; final left = (tx * tileSize - minWX) * factor; final top = (ty * tileSize - minWY) * factor; final displaySize = tileSize * factor; final dst = Rect.fromLTWH(left, top, displaySize, displaySize); _drawTile(canvas, z, wrappedX, ty, dst); } } // Queue rasterisation for visible tiles when permitted. if (queueTiles) { _queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex); // Pre-warm tiles just outside the viewport so panning feels instant. if (!interactionActive) { const guard = 2; _queueVisibleTiles( z, minTX - guard, maxTX + guard, minTY - guard, maxTY + guard, maxIndex, skipIfAlreadyQueued: true, visMinTX: minTX, visMaxTX: maxTX, visMinTY: minTY, visMaxTY: maxTY, ); } } } Set _allowedQueuedTileHashes( int z, int minTX, int maxTX, int minTY, int maxTY, int maxIndex, ) { final hashes = {}; for (var tx = minTX; tx <= maxTX; tx++) { for (var ty = minTY; ty <= maxTY; ty++) { if (ty < 0 || ty >= maxIndex) continue; final wx = ((tx % maxIndex) + maxIndex) % maxIndex; hashes.add(Object.hash(z, wx, ty)); } } if (!interactionActive) { const guard = 2; final guardMinTX = minTX - guard; final guardMaxTX = maxTX + guard; final guardMinTY = minTY - guard; final guardMaxTY = maxTY + guard; for (var tx = guardMinTX; tx <= guardMaxTX; tx++) { for (var ty = guardMinTY; ty <= guardMaxTY; ty++) { if (ty < 0 || ty >= maxIndex) continue; if (tx >= minTX && tx <= maxTX && ty >= minTY && ty <= maxTY) { continue; } final wx = ((tx % maxIndex) + maxIndex) % maxIndex; hashes.add(Object.hash(z, wx, ty)); } } } return hashes; } void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) { final zoomBucket = z; // Try native level first. final native = TilePyramidCache.peek(z, x, y); if (native != null && native.zoomBucket == zoomBucket) { _blitImage( canvas, native.image, Rect.fromLTWH( 0, 0, native.image.width.toDouble(), native.image.height.toDouble(), ), dst, ); return; } // Walk up the pyramid for an ancestor fallback. for (int 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 ancestor = TilePyramidCache.peek(az, ancestorX, ancestorY); if (ancestor != null) { // Compute which sub-region of the ancestor image corresponds to this tile. final fracX = (x - ancestorX * subdivision).toDouble() / subdivision; final fracY = (y - ancestorY * subdivision).toDouble() / subdivision; final srcW = ancestor.image.width / subdivision; final srcH = ancestor.image.height / subdivision; final src = Rect.fromLTWH( fracX * ancestor.image.width, fracY * ancestor.image.height, srcW, srcH, ); _blitImage(canvas, ancestor.image, src, dst); break; } } // Queue native image if not already cached/in-flight. if ((native == null || native.zoomBucket != zoomBucket) && !TilePyramidCache.isInFlight(z, x, y) && !interactionActive) { _queueTile(z, x, y, zoomBucket); } } void _blitImage(Canvas canvas, ui.Image image, Rect src, Rect dst) { final paint = Paint() ..filterQuality = FilterQuality.high ..colorFilter = _tileColorFilter; canvas.drawImageRect(image, src, dst, paint); } void _queueTile(int z, int x, int y, int zoomBucket) { final url = _tileUrl(z, x, y); final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale) .round(); final currentZoom = zoom; TilePyramidCache.enqueue( z, x, y, zoomBucket, () async { final tile = await MapboxVectorTileCache.getOrFetch(url); if (tile == null || tile.layers.isEmpty) return null; return _rasterize(tile, zoom: currentZoom, rasterPx: rasterPx); }, () { // Image ready — ask Flutter to redraw. SchedulerBinding.instance.scheduleFrame(); notifyListeners(); }, ); } void _queueVisibleTiles( int z, int minTX, int maxTX, int minTY, int maxTY, int maxIndex, { bool skipIfAlreadyQueued = false, int visMinTX = 0, int visMaxTX = 0, int visMinTY = 0, int visMaxTY = 0, }) { for (var tx = minTX; tx <= maxTX; tx++) { for (var ty = minTY; ty <= maxTY; ty++) { if (ty < 0 || ty >= maxIndex) continue; // When pre-warming the guard ring, skip tiles in the core visible area // (they're already handled by the first call). if (skipIfAlreadyQueued && tx >= visMinTX && tx <= visMaxTX && ty >= visMinTY && ty <= visMaxTY) { continue; } final wx = ((tx % maxIndex) + maxIndex) % maxIndex; final cached = TilePyramidCache.peek(z, wx, ty); if (cached != null && cached.zoomBucket == z) continue; if (TilePyramidCache.isInFlight(z, wx, ty)) continue; _queueTile(z, wx, ty, z); } } } // ------------------------------------------------------------------------- // Rasterisation for the cached tile images. // ------------------------------------------------------------------------- void _drawTileContents( Canvas canvas, Size sz, MvtTile tile, { required double zoom, }) { final paint = Paint()..isAntiAlias = true; paint.color = const Color(0xFFE6EAF1); canvas.drawRect(Offset.zero & sz, paint); _drawPolygons( canvas, sz, tile, const {'landuse'}, const Color(0xFFCCDCBF), zoom: zoom, ); _drawPolygons( canvas, sz, tile, const {'park'}, const Color(0xFF97D585), zoom: zoom, ); _drawPolygons( canvas, sz, tile, const {'water'}, const Color(0xFF68B5EC), zoom: zoom, ); _drawPolygons( canvas, sz, tile, const {'building'}, const Color(0xFFCCD3DF), zoom: zoom, strokeColor: const Color(0xFF8E9CB4), strokeBaseWidth: 0.56, ); _drawRoads(canvas, sz, tile, zoom: zoom); _drawLabels(canvas, sz, tile, zoom: zoom); } Future _rasterize( MvtTile tile, { required double zoom, required int rasterPx, }) async { if (rasterPx <= 0) return null; final recorder = ui.PictureRecorder(); final sz = Size(rasterPx.toDouble(), rasterPx.toDouble()); _drawTileContents(Canvas(recorder), sz, tile, zoom: zoom); final pic = recorder.endRecording(); try { return await pic.toImage(rasterPx, rasterPx); } finally { pic.dispose(); } } void _drawPolygons( Canvas canvas, Size size, MvtTile tile, Set layerNames, Color color, { required double zoom, Color? strokeColor, double strokeBaseWidth = 0.0, }) { final fill = Paint() ..color = color ..style = PaintingStyle.fill ..isAntiAlias = true; final stroke = strokeColor == null ? null : (Paint() ..color = strokeColor ..style = PaintingStyle.stroke ..strokeWidth = (strokeBaseWidth + (zoom - 15.0).clamp(0.0, 8.0) * 0.06).clamp( 0.44, 1.18, ) ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round ..isAntiAlias = true); for (final entry in tile.layers.entries) { if (!layerNames.contains(entry.key)) continue; final layer = entry.value; final scale = size.width / layer.extent; for (final feature in layer.features) { if (feature.type != MvtGeometryType.polygon) continue; for (final ring in feature.geometry) { if (ring.length < 3) continue; final path = Path() ..moveTo(ring.first.dx * scale, ring.first.dy * scale); for (var i = 1; i < ring.length; i++) { path.lineTo(ring[i].dx * scale, ring[i].dy * scale); } path.close(); canvas.drawPath(path, fill); if (stroke != null) canvas.drawPath(path, stroke); } } } } void _drawRoads( Canvas canvas, Size size, MvtTile tile, { required double zoom, }) { final roadLayer = tile.layers['road']; if (roadLayer == null) return; final scale = size.width / roadLayer.extent; for (final feature in roadLayer.features) { if (feature.type != MvtGeometryType.lineString) continue; final roadClass = (feature.properties['class'] ?? feature.properties['type'] ?? '') .toString() .toLowerCase(); final isPathLike = roadClass.contains('path') || roadClass.contains('footway') || roadClass.contains('steps') || roadClass.contains('cycleway') || roadClass.contains('track') || roadClass.contains('bridleway'); if (isPathLike && zoom < 15) continue; final isMotorway = roadClass.contains('motorway'); final isTrunkPrimary = roadClass.contains('trunk') || roadClass.contains('primary'); final isSecondary = roadClass.contains('secondary') || roadClass.contains('tertiary'); final isLink = roadClass.contains('link'); var baseWidth = 0.92; var growth = 0.24; var casingColor = const Color(0xFF9BA7BE); var fillColor = const Color(0xFFD8DFEC); if (isMotorway) { baseWidth = 1.60; growth = 0.42; casingColor = const Color(0xFF5D6F92); fillColor = const Color(0xFF98AFCF); } else if (isTrunkPrimary) { baseWidth = 1.35; growth = 0.34; casingColor = const Color(0xFF7080A0); fillColor = const Color(0xFFAFC0D8); } else if (isSecondary) { baseWidth = 1.06; growth = 0.28; casingColor = const Color(0xFF8694B0); fillColor = const Color(0xFFC5D1E3); } if (isLink) baseWidth *= 0.82; final zoomBoost = (zoom - 11).clamp(0.0, 10.0); final width = (baseWidth + zoomBoost * growth).clamp(0.60, 7.20); final casing = Paint() ..color = casingColor ..style = PaintingStyle.stroke ..strokeWidth = width + (isMotorway ? 1.15 : 0.95) ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..isAntiAlias = true; final road = Paint() ..color = fillColor ..style = PaintingStyle.stroke ..strokeWidth = width ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round ..isAntiAlias = true; for (final line in feature.geometry) { if (line.length < 2) continue; final path = Path() ..moveTo(line.first.dx * scale, line.first.dy * scale); for (var i = 1; i < line.length; i++) { path.lineTo(line[i].dx * scale, line[i].dy * scale); } canvas.drawPath(path, casing); canvas.drawPath(path, road); } } } void _drawLabels( Canvas canvas, Size size, MvtTile tile, { required double zoom, }) { if (zoom < 12) return; final occupiedRects = []; final seenTexts = {}; void drawFromLayer( String layerName, Color color, double fontSize, { bool pointsOnly = true, FontWeight fontWeight = FontWeight.w500, required double minZoom, int maxLabels = 9999, double minSpacing = 10, Set? allowedClasses, }) { if (zoom < minZoom) return; final layer = tile.layers[layerName]; if (layer == null) return; final scale = size.width / layer.extent; final candidates = <_LabelCandidate>[]; for (final feature in layer.features) { if (pointsOnly && feature.type != MvtGeometryType.point) continue; final featureClass = (feature.properties['class'] ?? feature.properties['type'] ?? '') .toString() .toLowerCase(); if (allowedClasses != null && allowedClasses.isNotEmpty && !allowedClasses.contains(featureClass)) { continue; } final text = (feature.properties['name_en'] ?? feature.properties['name'] ?? '') .toString() .trim(); if (text.isEmpty || text.length > 26) continue; final anchor = feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty ? feature.geometry.first.first : null; if (anchor == null) continue; final scalerank = _toRank(feature.properties['scalerank']) ?? 99; final localrank = _toRank(feature.properties['localrank']) ?? 99; candidates.add( _LabelCandidate( text: text, anchor: anchor, rank: scalerank * 100 + localrank, ), ); } candidates.sort((a, b) { final r = a.rank.compareTo(b.rank); return r != 0 ? r : a.text.length.compareTo(b.text.length); }); var drawn = 0; for (final c in candidates) { if (drawn >= maxLabels) break; if (seenTexts.contains(c.text.toLowerCase())) continue; final tp = TextPainter( text: TextSpan( text: c.text, style: TextStyle( color: color, fontSize: fontSize, fontWeight: fontWeight, shadows: const [ Shadow(color: Color(0xF7FFFFFF), blurRadius: 1.4), ], height: 1.0, ), ), textDirection: TextDirection.ltr, maxLines: 1, ellipsis: '', )..layout(); final x = c.anchor.dx * scale - tp.width / 2; final y = c.anchor.dy * scale - tp.height / 2; if (x < -tp.width || y < -tp.height || x > size.width || y > size.height) { continue; } final labelRect = Rect.fromLTWH( x, y, tp.width, tp.height, ).inflate(minSpacing); if (occupiedRects.any((r) => r.overlaps(labelRect))) continue; tp.paint(canvas, Offset(x, y)); occupiedRects.add(labelRect); seenTexts.add(c.text.toLowerCase()); drawn++; } } drawFromLayer( 'place_label', const Color(0xFF1F2636), 11.2, fontWeight: FontWeight.w600, minZoom: 12.5, maxLabels: zoom >= 15 ? 5 : 2, minSpacing: zoom >= 15 ? 24 : 30, allowedClasses: const { 'city', 'town', 'suburb', 'neighbourhood', 'borough', }, ); } int? _toRank(Object? value) { if (value is int) return value; if (value is double) return value.toInt(); if (value is String) return int.tryParse(value); return null; } // ------------------------------------------------------------------------- // ColorFilter matrices // ------------------------------------------------------------------------- static List _contrastMatrix(double contrast) { final c = contrast.clamp(0.0, 3.0); final t = 128.0 * (1.0 - c); return [c, 0, 0, 0, t, 0, c, 0, 0, t, 0, 0, c, 0, t, 0, 0, 0, 1, 0]; } static List _saturationMatrix(double saturation) { final s = saturation.clamp(0.0, 2.0); const rw = 0.213; const gw = 0.715; const bw = 0.072; final a = 1 - s; return [ a * rw + s, a * gw, a * bw, 0, 0, a * rw, a * gw + s, a * bw, 0, 0, a * rw, a * gw, a * bw + s, 0, 0, 0, 0, 0, 1, 0, ]; } // 4x5 colour matrix multiply: result = a * b (both are row-major 4x5). static List _multiplyMatrices(List a, List b) { final out = List.filled(20, 0); for (int row = 0; row < 4; row++) { for (int col = 0; col < 5; col++) { double v = 0; for (int k = 0; k < 4; k++) { v += a[row * 5 + k] * b[k * 5 + col]; } // translation column — just add when col == 4 if (col == 4) v += a[row * 5 + 4]; out[row * 5 + col] = v; } } return out; } // ------------------------------------------------------------------------- // CustomPainter boilerplate // ------------------------------------------------------------------------- @override bool shouldRepaint(covariant TilePyramidPainter oldDelegate) => true; @override bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false; @override SemanticsBuilderCallback? get semanticsBuilder => null; @override bool hitTest(Offset position) => false; } class _LabelCandidate { const _LabelCandidate({ required this.text, required this.anchor, required this.rank, }); final String text; final Offset anchor; final int rank; }