add backend server setup with environment configuration, tile caching, and bus stop fetching functionality
This commit is contained in:
@@ -1,13 +1,23 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
||||
import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
||||
|
||||
// top-level so compute() can find it
|
||||
MvtTile? _parseMvtBackground(({Uint8List bytes, Set<String> layerNames}) args) {
|
||||
try {
|
||||
return const MvtParser().parse(args.bytes, layerNames: args.layerNames);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MapboxVectorTileCache {
|
||||
MapboxVectorTileCache._();
|
||||
|
||||
static final MvtParser _parser = const MvtParser();
|
||||
static const Set<String> _styleLayerWhitelist = <String>{
|
||||
'water',
|
||||
'landuse',
|
||||
@@ -18,12 +28,16 @@ class MapboxVectorTileCache {
|
||||
'place_label',
|
||||
'poi_label',
|
||||
};
|
||||
static const int _maxEntries = 220;
|
||||
static final bool _isMobile =
|
||||
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||
static final int _maxEntries = _isMobile ? 60 : 220;
|
||||
static final LinkedHashMap<String, MvtTile> _cache =
|
||||
LinkedHashMap<String, MvtTile>();
|
||||
static final Map<String, Future<MvtTile?>> _inFlight =
|
||||
<String, Future<MvtTile?>>{};
|
||||
|
||||
static bool isFetching(String url) => _inFlight.containsKey(url);
|
||||
|
||||
static MvtTile? peek(String url) {
|
||||
final cached = _cache.remove(url);
|
||||
if (cached != null) {
|
||||
@@ -48,21 +62,25 @@ class MapboxVectorTileCache {
|
||||
static Future<MvtTile?> _fetch(String url) async {
|
||||
final bytes = await HiveTileCache.getOrFetch(url);
|
||||
if (bytes == null || bytes.isEmpty) return null;
|
||||
final tile = _parse(bytes);
|
||||
|
||||
// dart:ui Offset doesn't survive structured-clone on web workers,
|
||||
// so on web we parse synchronously on the main thread
|
||||
MvtTile? tile;
|
||||
if (kIsWeb) {
|
||||
tile = _parseMvtBackground((bytes: bytes, layerNames: _styleLayerWhitelist));
|
||||
} else {
|
||||
tile = await compute(
|
||||
_parseMvtBackground,
|
||||
(bytes: bytes, layerNames: _styleLayerWhitelist),
|
||||
);
|
||||
}
|
||||
if (tile == null) return null;
|
||||
|
||||
_cache[url] = tile;
|
||||
_trim();
|
||||
return tile;
|
||||
}
|
||||
|
||||
static MvtTile? _parse(Uint8List bytes) {
|
||||
try {
|
||||
return _parser.parse(bytes, layerNames: _styleLayerWhitelist);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void _trim() {
|
||||
while (_cache.length > _maxEntries) {
|
||||
_cache.remove(_cache.keys.first);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class _TileCoord {
|
||||
const _TileCoord(this.z, this.x, this.y);
|
||||
|
||||
@@ -27,13 +30,18 @@ class CachedTile {
|
||||
class TilePyramidCache {
|
||||
TilePyramidCache._();
|
||||
|
||||
static const int _maxEntries = 384;
|
||||
static const int _maxApproxBytes = 768 * 1024 * 1024;
|
||||
static final bool _isMobile =
|
||||
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
static final int _maxEntries = kIsWeb ? 64 : (_isMobile ? 96 : 384);
|
||||
static final int _maxApproxBytes = kIsWeb
|
||||
? 64 * 1024 * 1024
|
||||
: (_isMobile ? 80 * 1024 * 1024 : 768 * 1024 * 1024);
|
||||
|
||||
// Cap concurrent renders to avoid saturating the main isolate with
|
||||
// synchronous canvas recording. I/O (network/Hive) is still concurrent
|
||||
// up to this limit.
|
||||
static const int _maxConcurrent = 4;
|
||||
static final int _maxConcurrent = kIsWeb ? 2 : (_isMobile ? 2 : 4);
|
||||
|
||||
static final LinkedHashMap<_TileCoord, CachedTile> _cache =
|
||||
LinkedHashMap<_TileCoord, CachedTile>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@@ -10,7 +11,8 @@ import 'package:rra_app/pages/map/vector/mvt_parser.dart';
|
||||
import 'package:rra_app/pages/map/vector/tile_pyramid_cache.dart';
|
||||
|
||||
const int _kMaxAncestorLookup = 4;
|
||||
const double _kTileResolutionScale = 2.0;
|
||||
// web WASM heap is limited — use 1x resolution to keep memory under control
|
||||
final double _kTileResolutionScale = kIsWeb ? 1.0 : 2.0;
|
||||
const double _kLineReferenceZoom = 14.0;
|
||||
|
||||
class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
@@ -37,12 +39,26 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
int? fallbackTileZoom;
|
||||
double devicePixelRatio = 1;
|
||||
bool interactionActive = false;
|
||||
List<Offset> routeScreenPoints = const [];
|
||||
|
||||
// cache of tile-local road label placements keyed by "$url:$featureIndex"
|
||||
// placement is stored in tile extent coordinates so it never changes on pan
|
||||
final Map<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
|
||||
.replaceAll('{z}', '$z')
|
||||
@@ -106,6 +122,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
}
|
||||
|
||||
_drawTileLayer(canvas, activeTileZoom, queueTiles: true);
|
||||
}
|
||||
|
||||
void paintLabels(Canvas canvas) {
|
||||
if (mapWidth == 0 || mapHeight == 0) return;
|
||||
_drawAllRoadLabels(canvas);
|
||||
_drawAllPlaceLabels(canvas);
|
||||
}
|
||||
@@ -128,7 +148,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
||||
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
||||
|
||||
if (queueTiles) {
|
||||
if (queueTiles && !kIsWeb) {
|
||||
final allowed = _allowedQueuedTileHashes(
|
||||
z,
|
||||
minTX,
|
||||
@@ -160,7 +180,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
if (queueTiles) {
|
||||
if (queueTiles && !kIsWeb) {
|
||||
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
|
||||
|
||||
if (!interactionActive) {
|
||||
@@ -213,6 +233,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
}
|
||||
|
||||
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
|
||||
// on web, skip the rasterization cache entirely and paint directly
|
||||
if (kIsWeb) {
|
||||
_drawTileWeb(canvas, z, x, y, dst);
|
||||
return;
|
||||
}
|
||||
|
||||
final native = TilePyramidCache.peek(z, x, y);
|
||||
if (native != null) {
|
||||
_blitImage(
|
||||
@@ -251,9 +277,54 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
_blitImage(canvas, ancestor.image, src, dst);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TilePyramidCache.isInFlight(z, x, y)) {
|
||||
_queueTile(z, x, y);
|
||||
void _drawTileWeb(Canvas canvas, int z, int x, int y, Rect dst) {
|
||||
final url = _tileUrl(z, x, y);
|
||||
final tile = MapboxVectorTileCache.peek(url);
|
||||
|
||||
if (tile != null) {
|
||||
canvas.save();
|
||||
canvas.clipRect(dst);
|
||||
canvas.translate(dst.left, dst.top);
|
||||
canvas.scale(dst.width / tileSize, dst.height / tileSize);
|
||||
_paintTile(canvas, Size.square(tileSize), tile, tileZoom: z.toDouble());
|
||||
canvas.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
// fetch if not cached yet
|
||||
if (!MapboxVectorTileCache.isFetching(url)) {
|
||||
MapboxVectorTileCache.getOrFetch(url).then((_) => markDirty());
|
||||
}
|
||||
|
||||
// show ancestor tile scaled up while loading
|
||||
final maxIndex = 1 << z;
|
||||
for (var dz = 1; dz <= _kMaxAncestorLookup; dz++) {
|
||||
final az = z - dz;
|
||||
if (az < minTileZoom) break;
|
||||
|
||||
final subdivision = 1 << dz;
|
||||
final ancestorX = x >> dz;
|
||||
final ancestorY = y >> dz;
|
||||
final ancestorUrl = _tileUrl(az, ((ancestorX % (maxIndex >> dz)) + (maxIndex >> dz)) % (maxIndex >> dz), ancestorY);
|
||||
final ancestor = MapboxVectorTileCache.peek(ancestorUrl);
|
||||
if (ancestor == null) continue;
|
||||
|
||||
final fracX = (x - ancestorX * subdivision).toDouble() / subdivision;
|
||||
final fracY = (y - ancestorY * subdivision).toDouble() / subdivision;
|
||||
final srcLeft = fracX * tileSize;
|
||||
final srcTop = fracY * tileSize;
|
||||
final srcSize = tileSize / subdivision;
|
||||
|
||||
canvas.save();
|
||||
canvas.clipRect(dst);
|
||||
canvas.translate(dst.left, dst.top);
|
||||
canvas.scale(dst.width / srcSize, dst.height / srcSize);
|
||||
canvas.translate(-srcLeft, -srcTop);
|
||||
_paintTile(canvas, Size.square(tileSize), ancestor, tileZoom: az.toDouble());
|
||||
canvas.restore();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,8 +335,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
|
||||
void _queueTile(int z, int x, int y) {
|
||||
final url = _tileUrl(z, x, y);
|
||||
final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale)
|
||||
.round();
|
||||
|
||||
// cap render resolution so high-DPI mobile doesnt blow the image cache
|
||||
// (256 * 3dpr * 2x scale = 1536px = 9MB per tile, way too big)
|
||||
// 512px = 1MB per tile, fits ~80 tiles in the mobile budget
|
||||
final dprCapped = devicePixelRatio.clamp(1.0, 2.0);
|
||||
final rasterPx = (tileSize * dprCapped * _kTileResolutionScale).round();
|
||||
|
||||
TilePyramidCache.enqueue(
|
||||
z,
|
||||
@@ -296,6 +371,12 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
int visMinTY = 0,
|
||||
int visMaxTY = 0,
|
||||
}) {
|
||||
// centerWorldX/Y are already in pixel coords at activeTileZoom scale
|
||||
final centerTX = centerWorldX / tileSize;
|
||||
final centerTY = centerWorldY / tileSize;
|
||||
|
||||
final pending = <(int tx, int ty, int wrappedX, double dist)>[];
|
||||
|
||||
for (var tx = minTX; tx <= maxTX; tx++) {
|
||||
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||
if (ty < 0 || ty >= maxIndex) continue;
|
||||
@@ -310,9 +391,17 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||
if (TilePyramidCache.peek(z, wrappedX, ty) != null) continue;
|
||||
if (TilePyramidCache.isInFlight(z, wrappedX, ty)) continue;
|
||||
_queueTile(z, wrappedX, ty);
|
||||
|
||||
final dx = tx + 0.5 - centerTX;
|
||||
final dy = ty + 0.5 - centerTY;
|
||||
pending.add((tx, ty, wrappedX, dx * dx + dy * dy));
|
||||
}
|
||||
}
|
||||
|
||||
pending.sort((a, b) => a.$4.compareTo(b.$4));
|
||||
for (final t in pending) {
|
||||
_queueTile(z, t.$3, t.$2);
|
||||
}
|
||||
}
|
||||
|
||||
MapDebugHit? _inspectLayerHit(
|
||||
@@ -386,7 +475,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final size = Size.square(rasterPx.toDouble());
|
||||
final canvas = Canvas(recorder);
|
||||
final canvas = Canvas(recorder, Offset.zero & size);
|
||||
|
||||
_paintTile(canvas, size, tile, tileZoom: tileZoom);
|
||||
|
||||
@@ -449,7 +538,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
baseZ: 25.0,
|
||||
);
|
||||
|
||||
// water — above grass so rivers/lakes cut through parks
|
||||
// water
|
||||
_collectPolygonCommands(
|
||||
commands,
|
||||
size,
|
||||
@@ -483,6 +572,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
fillColor: kMapPathFillColour,
|
||||
tileZoom: tileZoom,
|
||||
allowedClasses: const <String>{'school'},
|
||||
allowedTypes: const <String>{'school', 'college', 'university'},
|
||||
baseZ: 35.0,
|
||||
);
|
||||
|
||||
@@ -672,6 +762,8 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
final styledLines = <({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey, String roadClass})>[];
|
||||
for (final feature in roadLayer.features) {
|
||||
if (feature.type != MvtGeometryType.lineString) continue;
|
||||
final roadClass = (feature.properties['class'] ?? '').toString().toLowerCase();
|
||||
if (tileZoom < 13 && !_isMajorRoadClass(roadClass) && !roadClass.contains('street') && !roadClass.contains('rail') && roadClass != 'aerialway') continue;
|
||||
final style = _roadStyleFor(feature, tileZoom: tileZoom);
|
||||
if (style == null) continue;
|
||||
styledLines.add((
|
||||
@@ -679,7 +771,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
style: style,
|
||||
layerProp: _featureLayerProp(feature),
|
||||
sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0,
|
||||
roadClass: (feature.properties['class'] ?? '').toString(),
|
||||
roadClass: roadClass,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -783,13 +875,14 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
|
||||
// erase with all same-class cores (across all layerProps)
|
||||
final classErase = eraseByClass[roadClass] ?? [];
|
||||
final needsErase = classErase.isNotEmpty;
|
||||
|
||||
final railBias = roadClass.contains('rail') ? -10.0 : 0.0;
|
||||
final underlayZ = 50.0 + lp * 30.0 + railBias;
|
||||
commands.add(_DrawCommand(
|
||||
z: underlayZ,
|
||||
draw: (c) {
|
||||
c.saveLayer(Offset.zero & size, Paint());
|
||||
if (needsErase) c.saveLayer(Offset.zero & size, Paint());
|
||||
|
||||
for (final d in underlayDrawData) {
|
||||
for (final line in d.lines) {
|
||||
@@ -805,30 +898,31 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
for (final d in classErase) {
|
||||
for (final line in d.lines) {
|
||||
if (line.length < 2) continue;
|
||||
final startIsTerminal = !junctions.contains(_epKey(line.first));
|
||||
final endIsTerminal = !junctions.contains(_epKey(line.last));
|
||||
final trimmed = _trimPolyline(
|
||||
line,
|
||||
startIsTerminal ? d.terminalInset : 0.0,
|
||||
endIsTerminal ? d.terminalInset : 0.0,
|
||||
);
|
||||
if (trimmed == null || trimmed.length < 2) continue;
|
||||
c.drawPath(_scaledPath(trimmed, scale), d.paint);
|
||||
}
|
||||
final circlePaint = Paint()
|
||||
..color = d.paint.color
|
||||
..style = PaintingStyle.fill
|
||||
..blendMode = BlendMode.dstOut
|
||||
..isAntiAlias = true;
|
||||
for (final pt in d.junctionPts) {
|
||||
c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint);
|
||||
if (needsErase) {
|
||||
for (final d in classErase) {
|
||||
for (final line in d.lines) {
|
||||
if (line.length < 2) continue;
|
||||
final startIsTerminal = !junctions.contains(_epKey(line.first));
|
||||
final endIsTerminal = !junctions.contains(_epKey(line.last));
|
||||
final trimmed = _trimPolyline(
|
||||
line,
|
||||
startIsTerminal ? d.terminalInset : 0.0,
|
||||
endIsTerminal ? d.terminalInset : 0.0,
|
||||
);
|
||||
if (trimmed == null || trimmed.length < 2) continue;
|
||||
c.drawPath(_scaledPath(trimmed, scale), d.paint);
|
||||
}
|
||||
final circlePaint = Paint()
|
||||
..color = d.paint.color
|
||||
..style = PaintingStyle.fill
|
||||
..blendMode = BlendMode.dstOut
|
||||
..isAntiAlias = true;
|
||||
for (final pt in d.junctionPts) {
|
||||
c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint);
|
||||
}
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
c.restore();
|
||||
},
|
||||
));
|
||||
|
||||
@@ -1392,9 +1486,10 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
final isCapital = capital >= 2;
|
||||
|
||||
final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt();
|
||||
if (symbolrank > 15 && zoom <= 14.0) continue;
|
||||
if (kMapUseSymbolRankAsZoomGate && zoom < symbolrank) continue;
|
||||
|
||||
if (!isCapital) {
|
||||
final isMajorCity = symbolrank < 13;
|
||||
if (!isCapital && !isMajorCity) {
|
||||
if (zoom < 12.5 && featureClass == 'settlement_subdivision') continue;
|
||||
if (zoom < 12.5 && featureClass == 'settlement') continue;
|
||||
}
|
||||
@@ -1404,7 +1499,7 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
.toString()
|
||||
.trim());
|
||||
if (rawText.isEmpty || rawText.length > 30) continue;
|
||||
final text = rawText;
|
||||
final text = kMapDebugShowPlaceSymbolRank ? '$rawText [$symbolrank]' : rawText;
|
||||
|
||||
final anchor =
|
||||
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
|
||||
@@ -1693,147 +1788,193 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
final minTY = (minWY / tileSize).floor() - tileOverscan;
|
||||
final maxTY = (maxWY / tileSize).floor() + tileOverscan;
|
||||
|
||||
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
|
||||
const minSameNameSpacing = 500.0;
|
||||
|
||||
// collect all candidates first so we can sort by path length and always
|
||||
// pick the longest visible segment per name — stable across panning
|
||||
final candidates = <_RoadLabelCandidate>[];
|
||||
// rebuild world-space label list only when tiles or zoom changed
|
||||
// pan only changes screen positions, which we recompute cheaply from worldX/Y
|
||||
if (_tileVersion != _roadLabelCacheVersion || zoom != _roadLabelCacheZoom) {
|
||||
final worldLabels = <_WorldRoadLabel>[];
|
||||
|
||||
for (var tx = minTX; tx <= maxTX; tx++) {
|
||||
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||
if (ty < 0 || ty >= maxIndex) continue;
|
||||
for (var tx = minTX; tx <= maxTX; tx++) {
|
||||
for (var ty = minTY; ty <= maxTY; ty++) {
|
||||
if (ty < 0 || ty >= maxIndex) continue;
|
||||
|
||||
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||
final url = _tileUrl(z, wrappedX, ty);
|
||||
final tile = MapboxVectorTileCache.peek(url);
|
||||
if (tile == null) continue;
|
||||
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
||||
final url = _tileUrl(z, wrappedX, ty);
|
||||
final tile = MapboxVectorTileCache.peek(url);
|
||||
if (tile == null) continue;
|
||||
|
||||
final layer = tile.layers['road'];
|
||||
if (layer == null) continue;
|
||||
final layer = tile.layers['road'];
|
||||
if (layer == null) continue;
|
||||
|
||||
final tileLeft = (tx * tileSize - minWX) * factor;
|
||||
final tileTop = (ty * tileSize - minWY) * factor;
|
||||
final scale = (tileSize * factor) / layer.extent;
|
||||
final scale = (tileSize * factor) / layer.extent;
|
||||
|
||||
for (final feature in layer.features) {
|
||||
if (feature.type != MvtGeometryType.lineString) continue;
|
||||
for (var fi = 0; fi < layer.features.length; fi++) {
|
||||
final feature = layer.features[fi];
|
||||
if (feature.type != MvtGeometryType.lineString) continue;
|
||||
|
||||
final roadClass =
|
||||
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
if (_shouldSkipRoadLabelClass(roadClass)) continue;
|
||||
if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue;
|
||||
final style = _roadStyleFor(feature, tileZoom: z.toDouble());
|
||||
if (style == null) continue;
|
||||
final roadClass =
|
||||
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
if (_shouldSkipRoadLabelClass(roadClass)) continue;
|
||||
if (majorRoadsOnly && !_isMajorRoadClass(roadClass)) continue;
|
||||
final style = _roadStyleFor(feature, tileZoom: z.toDouble());
|
||||
if (style == null) continue;
|
||||
|
||||
final text = _sanitizeLabel(
|
||||
(feature.properties['name_en'] ??
|
||||
feature.properties['name'] ??
|
||||
'')
|
||||
.toString()
|
||||
.trim());
|
||||
if (text.isEmpty || text.length > 32) continue;
|
||||
final text = _sanitizeLabel(
|
||||
(feature.properties['name_en'] ??
|
||||
feature.properties['name'] ??
|
||||
'')
|
||||
.toString()
|
||||
.trim());
|
||||
if (text.isEmpty || text.length > 32) continue;
|
||||
|
||||
final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom;
|
||||
final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble();
|
||||
final roadCorePx = _worldStrokePx(
|
||||
style.coreWorldUnits,
|
||||
extent: layer.extent,
|
||||
rasterSize: tileSize * fontFactor,
|
||||
tileZoom: z.toDouble(),
|
||||
);
|
||||
final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0);
|
||||
final minFontZoom = _isMajorRoadClass(roadClass) ? 19.0 : zoom;
|
||||
final fontFactor = math.pow(2, math.max(zoom, minFontZoom) - z).toDouble();
|
||||
final roadCorePx = _worldStrokePx(
|
||||
style.coreWorldUnits,
|
||||
extent: layer.extent,
|
||||
rasterSize: tileSize * fontFactor,
|
||||
tileZoom: z.toDouble(),
|
||||
);
|
||||
final fontSize = (roadCorePx * 0.80).clamp(8.0, 18.0);
|
||||
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
final painterKey = '$text|${fontSize.toStringAsFixed(1)}';
|
||||
final painter = _textPainterCache[painterKey] ?? () {
|
||||
final p = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: GoogleFonts.inter(
|
||||
color: kMapRoadLabelColor,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: <Shadow>[
|
||||
Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2),
|
||||
],
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
ellipsis: '',
|
||||
)..layout();
|
||||
if (_textPainterCache.length >= _maxTextPainterCacheSize) {
|
||||
_textPainterCache.remove(_textPainterCache.keys.first);
|
||||
}
|
||||
_textPainterCache[painterKey] = p;
|
||||
return p;
|
||||
}();
|
||||
|
||||
final cacheKey = '$url:$fi';
|
||||
if (!_roadLabelPlacementCache.containsKey(cacheKey)) {
|
||||
final placement = _bestRoadLabelPlacement(
|
||||
feature.geometry,
|
||||
scale: 1.0,
|
||||
labelWidth: painter.width / scale,
|
||||
);
|
||||
if (placement != null) {
|
||||
_roadLabelPlacementCache[cacheKey] = placement;
|
||||
}
|
||||
}
|
||||
final localPlacement = _roadLabelPlacementCache[cacheKey];
|
||||
if (localPlacement == null) continue;
|
||||
|
||||
// store in world space so pan is free
|
||||
final worldX = tx * tileSize + localPlacement.center.dx * tileSize / layer.extent;
|
||||
final worldY = ty * tileSize + localPlacement.center.dy * tileSize / layer.extent;
|
||||
|
||||
worldLabels.add(_WorldRoadLabel(
|
||||
worldX: worldX,
|
||||
worldY: worldY,
|
||||
angle: localPlacement.angle,
|
||||
pathLength: localPlacement.pathLength,
|
||||
text: text,
|
||||
style: GoogleFonts.inter(
|
||||
color: kMapRoadLabelColor,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: <Shadow>[
|
||||
Shadow(color: kMapRoadLabelHaloColor, blurRadius: 2.2),
|
||||
],
|
||||
height: 1.0,
|
||||
painter: painter,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// longest segment always wins regardless of tile iteration order
|
||||
worldLabels.sort((a, b) => b.pathLength.compareTo(a.pathLength));
|
||||
_roadLabelWorld = worldLabels;
|
||||
_roadLabelCacheVersion = _tileVersion;
|
||||
_roadLabelCacheZoom = zoom;
|
||||
}
|
||||
|
||||
final worldLabels = _roadLabelWorld;
|
||||
if (worldLabels == null || worldLabels.isEmpty) return;
|
||||
|
||||
// reproject world → screen using current pan/zoom (cheap)
|
||||
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
|
||||
final occupiedRects = <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,
|
||||
maxLines: 1,
|
||||
ellipsis: '',
|
||||
)..layout();
|
||||
|
||||
final featureIndex = layer.features.indexOf(feature);
|
||||
final cacheKey = '$url:$featureIndex';
|
||||
if (!_roadLabelPlacementCache.containsKey(cacheKey)) {
|
||||
final placement = _bestRoadLabelPlacement(
|
||||
feature.geometry,
|
||||
scale: 1.0,
|
||||
labelWidth: painter.width / scale,
|
||||
);
|
||||
// only cache successful placements — null means segment too short,
|
||||
// which can change at different zoom/scale so we shouldn't lock it in
|
||||
if (placement != null) {
|
||||
_roadLabelPlacementCache[cacheKey] = placement;
|
||||
}
|
||||
if (_textPainterCache.length >= _maxTextPainterCacheSize) {
|
||||
_textPainterCache.remove(_textPainterCache.keys.first);
|
||||
}
|
||||
final localPlacement = _roadLabelPlacementCache[cacheKey];
|
||||
if (localPlacement == null) continue;
|
||||
|
||||
final screenCenter = Offset(
|
||||
tileLeft + localPlacement.center.dx * scale,
|
||||
tileTop + localPlacement.center.dy * scale,
|
||||
);
|
||||
|
||||
if (!screenBounds.contains(screenCenter)) continue;
|
||||
|
||||
candidates.add(_RoadLabelCandidate(
|
||||
text: text,
|
||||
screenCenter: screenCenter,
|
||||
angle: localPlacement.angle,
|
||||
pathLength: localPlacement.pathLength,
|
||||
painter: painter,
|
||||
));
|
||||
}
|
||||
_textPainterCache[strokeKey] = p;
|
||||
return p;
|
||||
}();
|
||||
strokePainter.paint(canvas, Offset(-strokePainter.width / 2, -strokePainter.height / 2));
|
||||
}
|
||||
}
|
||||
|
||||
// longest segment wins — so the winner is always the same regardless of
|
||||
// which order tiles were iterated
|
||||
candidates.sort((a, b) => b.pathLength.compareTo(a.pathLength));
|
||||
|
||||
final occupiedRects = <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));
|
||||
w.painter.paint(canvas, Offset(-w.painter.width / 2, -w.painter.height / 2));
|
||||
canvas.restore();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
bool _labelOverRoute(Rect labelBounds) {
|
||||
final pts = routeScreenPoints;
|
||||
if (pts.length < 2) return false;
|
||||
final inflated = labelBounds.inflate(4);
|
||||
for (var i = 0; i < pts.length - 1; i++) {
|
||||
if (_segmentIntersectsRect(pts[i], pts[i + 1], inflated)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _segmentIntersectsRect(Offset a, Offset b, Rect r) {
|
||||
// quick AABB check first
|
||||
final minX = math.min(a.dx, b.dx);
|
||||
final maxX = math.max(a.dx, b.dx);
|
||||
final minY = math.min(a.dy, b.dy);
|
||||
final maxY = math.max(a.dy, b.dy);
|
||||
if (maxX < r.left || minX > r.right || maxY < r.top || minY > r.bottom) return false;
|
||||
// check if any rect corner is on opposite sides of the segment
|
||||
final dx = b.dx - a.dx;
|
||||
final dy = b.dy - a.dy;
|
||||
double sign(Offset p) => dx * (p.dy - a.dy) - dy * (p.dx - a.dx);
|
||||
final s1 = sign(r.topLeft);
|
||||
final s2 = sign(r.topRight);
|
||||
final s3 = sign(r.bottomLeft);
|
||||
final s4 = sign(r.bottomRight);
|
||||
final mn = math.min(math.min(s1, s2), math.min(s3, s4));
|
||||
final mx = math.max(math.max(s1, s2), math.max(s3, s4));
|
||||
return mn <= 0 && mx >= 0;
|
||||
}
|
||||
|
||||
bool _isMajorRoadClass(String roadClass) {
|
||||
return roadClass.contains('motorway') ||
|
||||
roadClass.contains('trunk') ||
|
||||
@@ -2189,6 +2360,33 @@ class TilePyramidPainter extends ChangeNotifier implements CustomPainter {
|
||||
bool hitTest(Offset position) => false;
|
||||
}
|
||||
|
||||
class TileLabelPainter implements CustomPainter {
|
||||
TileLabelPainter(this._painter);
|
||||
|
||||
final TilePyramidPainter _painter;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) => _painter.paintLabels(canvas);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant TileLabelPainter old) => true;
|
||||
|
||||
@override
|
||||
bool shouldRebuildSemantics(covariant CustomPainter old) => false;
|
||||
|
||||
@override
|
||||
SemanticsBuilderCallback? get semanticsBuilder => null;
|
||||
|
||||
@override
|
||||
bool hitTest(Offset position) => false;
|
||||
|
||||
@override
|
||||
void addListener(ui.VoidCallback listener) {}
|
||||
|
||||
@override
|
||||
void removeListener(ui.VoidCallback listener) {}
|
||||
}
|
||||
|
||||
class _RoadStyle {
|
||||
const _RoadStyle({
|
||||
required this.sortOrder,
|
||||
@@ -2276,6 +2474,26 @@ class _LabelCandidate {
|
||||
final String featureClass;
|
||||
}
|
||||
|
||||
// road label stored in world-space so screen position can be recomputed on pan
|
||||
// without re-iterating tile features
|
||||
class _WorldRoadLabel {
|
||||
const _WorldRoadLabel({
|
||||
required this.worldX,
|
||||
required this.worldY,
|
||||
required this.angle,
|
||||
required this.pathLength,
|
||||
required this.text,
|
||||
required this.painter,
|
||||
});
|
||||
|
||||
final double worldX;
|
||||
final double worldY;
|
||||
final double angle;
|
||||
final double pathLength;
|
||||
final String text;
|
||||
final TextPainter painter;
|
||||
}
|
||||
|
||||
class _DrawCommand {
|
||||
const _DrawCommand({required this.z, required this.draw});
|
||||
final double z;
|
||||
|
||||
Reference in New Issue
Block a user