2283 lines
71 KiB
Dart
2283 lines
71 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:rra_app/pages/map/constants.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';
|
|
|
|
const int _kMaxAncestorLookup = 4;
|
|
const double _kTileResolutionScale = 2.0;
|
|
const double _kLineReferenceZoom = 14.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;
|
|
|
|
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;
|
|
|
|
// 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();
|
|
|
|
String _tileUrl(int z, int x, int y) => tileUrlTemplate
|
|
.replaceAll('{z}', '$z')
|
|
.replaceAll('{x}', '$x')
|
|
.replaceAll('{y}', '$y');
|
|
|
|
Future<MapDebugHit?> inspectFeatureAt(Offset localPosition) async {
|
|
if (mapWidth <= 0 || mapHeight <= 0) return null;
|
|
|
|
final z = activeTileZoom;
|
|
final factor = math.pow(2, zoom - z).toDouble();
|
|
final maxIndex = 1 << z;
|
|
final minWX = centerWorldX - (mapWidth / 2) / factor;
|
|
final minWY = centerWorldY - (mapHeight / 2) / factor;
|
|
final worldX = minWX + localPosition.dx / factor;
|
|
final worldY = minWY + localPosition.dy / factor;
|
|
|
|
final tileX = (worldX / tileSize).floor();
|
|
final tileY = (worldY / tileSize).floor();
|
|
if (tileY < 0 || tileY >= maxIndex) return null;
|
|
|
|
final wrappedX = ((tileX % maxIndex) + maxIndex) % maxIndex;
|
|
final tile = await MapboxVectorTileCache.getOrFetch(
|
|
_tileUrl(z, wrappedX, tileY),
|
|
);
|
|
if (tile == null) return null;
|
|
|
|
final displayTileSize = tileSize * factor;
|
|
for (final layerName in _inspectLayerOrder) {
|
|
final layer = tile.layers[layerName];
|
|
if (layer == null) continue;
|
|
|
|
final localExtentPoint = Offset(
|
|
(worldX - tileX * tileSize) / tileSize * layer.extent,
|
|
(worldY - tileY * tileSize) / tileSize * layer.extent,
|
|
);
|
|
|
|
final hit = _inspectLayerHit(
|
|
layer,
|
|
layerName: layerName,
|
|
localPoint: localExtentPoint,
|
|
pixelTolerance: 10.0,
|
|
displayTileSize: displayTileSize,
|
|
tileZ: z,
|
|
tileX: wrappedX,
|
|
tileY: tileY,
|
|
);
|
|
if (hit != null) return hit;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
@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);
|
|
_drawAllRoadLabels(canvas);
|
|
_drawAllPlaceLabels(canvas);
|
|
}
|
|
|
|
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 dst = Rect.fromLTWH(
|
|
left,
|
|
top,
|
|
tileSize * factor,
|
|
tileSize * factor,
|
|
);
|
|
|
|
_drawTile(canvas, z, wrappedX, ty, dst);
|
|
}
|
|
}
|
|
|
|
if (queueTiles) {
|
|
_queueVisibleTiles(z, minTX, maxTX, minTY, maxTY, maxIndex);
|
|
|
|
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<int> _allowedQueuedTileHashes(
|
|
int z,
|
|
int minTX,
|
|
int maxTX,
|
|
int minTY,
|
|
int maxTY,
|
|
int maxIndex,
|
|
) {
|
|
final hashes = <int>{};
|
|
|
|
void addWindow(int fromTX, int toTX, int fromTY, int toTY) {
|
|
for (var tx = fromTX; tx <= toTX; tx++) {
|
|
for (var ty = fromTY; ty <= toTY; ty++) {
|
|
if (ty < 0 || ty >= maxIndex) continue;
|
|
final wrappedX = ((tx % maxIndex) + maxIndex) % maxIndex;
|
|
hashes.add(Object.hash(z, wrappedX, ty));
|
|
}
|
|
}
|
|
}
|
|
|
|
addWindow(minTX, maxTX, minTY, maxTY);
|
|
|
|
if (!interactionActive) {
|
|
const guard = 2;
|
|
addWindow(minTX - guard, maxTX + guard, minTY - guard, maxTY + guard);
|
|
}
|
|
|
|
return hashes;
|
|
}
|
|
|
|
void _drawTile(Canvas canvas, int z, int x, int y, Rect dst) {
|
|
final native = TilePyramidCache.peek(z, x, y);
|
|
if (native != null) {
|
|
_blitImage(
|
|
canvas,
|
|
native.image,
|
|
Rect.fromLTWH(
|
|
0,
|
|
0,
|
|
native.image.width.toDouble(),
|
|
native.image.height.toDouble(),
|
|
),
|
|
dst,
|
|
);
|
|
return;
|
|
}
|
|
|
|
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 ancestor = TilePyramidCache.peek(az, ancestorX, ancestorY);
|
|
if (ancestor == null) continue;
|
|
|
|
final fracX = (x - ancestorX * subdivision).toDouble() / subdivision;
|
|
final fracY = (y - ancestorY * subdivision).toDouble() / subdivision;
|
|
final src = Rect.fromLTWH(
|
|
fracX * ancestor.image.width,
|
|
fracY * ancestor.image.height,
|
|
ancestor.image.width / subdivision,
|
|
ancestor.image.height / subdivision,
|
|
);
|
|
|
|
_blitImage(canvas, ancestor.image, src, dst);
|
|
break;
|
|
}
|
|
|
|
if (!TilePyramidCache.isInFlight(z, x, y)) {
|
|
_queueTile(z, x, y);
|
|
}
|
|
}
|
|
|
|
void _blitImage(Canvas canvas, ui.Image image, Rect src, Rect dst) {
|
|
final paint = Paint()..filterQuality = FilterQuality.high;
|
|
canvas.drawImageRect(image, src, dst, paint);
|
|
}
|
|
|
|
void _queueTile(int z, int x, int y) {
|
|
final url = _tileUrl(z, x, y);
|
|
final rasterPx = (tileSize * devicePixelRatio * _kTileResolutionScale)
|
|
.round();
|
|
|
|
TilePyramidCache.enqueue(
|
|
z,
|
|
x,
|
|
y,
|
|
() async {
|
|
final tile = await MapboxVectorTileCache.getOrFetch(url);
|
|
if (tile == null || tile.layers.isEmpty) return null;
|
|
return _rasterize(tile, tileZoom: z.toDouble(), rasterPx: rasterPx);
|
|
},
|
|
() {
|
|
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;
|
|
if (skipIfAlreadyQueued &&
|
|
tx >= visMinTX &&
|
|
tx <= visMaxTX &&
|
|
ty >= visMinTY &&
|
|
ty <= visMaxTY) {
|
|
continue;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
MapDebugHit? _inspectLayerHit(
|
|
MvtLayer layer, {
|
|
required String layerName,
|
|
required Offset localPoint,
|
|
required double pixelTolerance,
|
|
required double displayTileSize,
|
|
required int tileZ,
|
|
required int tileX,
|
|
required int tileY,
|
|
}) {
|
|
final tolerance = layer.extent / displayTileSize * pixelTolerance;
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.lineString) continue;
|
|
final distance = _minDistanceToGeometry(localPoint, feature.geometry);
|
|
if (distance <= tolerance) {
|
|
return MapDebugHit(
|
|
layerName: layerName,
|
|
geometryType: feature.type,
|
|
properties: feature.properties,
|
|
tileZ: tileZ,
|
|
tileX: tileX,
|
|
tileY: tileY,
|
|
distance: distance,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.polygon) continue;
|
|
if (_polygonContains(localPoint, feature.geometry)) {
|
|
return MapDebugHit(
|
|
layerName: layerName,
|
|
geometryType: feature.type,
|
|
properties: feature.properties,
|
|
tileZ: tileZ,
|
|
tileX: tileX,
|
|
tileY: tileY,
|
|
distance: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.point) continue;
|
|
final distance = _minDistanceToPoints(localPoint, feature.geometry);
|
|
if (distance <= tolerance) {
|
|
return MapDebugHit(
|
|
layerName: layerName,
|
|
geometryType: feature.type,
|
|
properties: feature.properties,
|
|
tileZ: tileZ,
|
|
tileX: tileX,
|
|
tileY: tileY,
|
|
distance: distance,
|
|
);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<ui.Image?> _rasterize(
|
|
MvtTile tile, {
|
|
required double tileZoom,
|
|
required int rasterPx,
|
|
}) async {
|
|
if (rasterPx <= 0) return null;
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
final size = Size.square(rasterPx.toDouble());
|
|
final canvas = Canvas(recorder);
|
|
|
|
_paintTile(canvas, size, tile, tileZoom: tileZoom);
|
|
|
|
final picture = recorder.endRecording();
|
|
try {
|
|
return await picture.toImage(rasterPx, rasterPx);
|
|
} finally {
|
|
picture.dispose();
|
|
}
|
|
}
|
|
|
|
void _paintTile(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double tileZoom,
|
|
}) {
|
|
canvas.drawRect(
|
|
Offset.zero & size,
|
|
Paint()
|
|
..color = kMapBackgroundColor
|
|
..isAntiAlias = true,
|
|
);
|
|
|
|
final commands = <_DrawCommand>[];
|
|
|
|
// landuse retail
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'landuse'},
|
|
fillColor: kMapLanduseColor,
|
|
allowedTypes: const <String>{'retail'},
|
|
tileZoom: tileZoom,
|
|
baseZ: 20.0,
|
|
);
|
|
|
|
// landuse park/meadow/grass
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'landuse'},
|
|
fillColor: kMapGrassColor,
|
|
allowedTypes: const <String>{'park', 'meadow', 'garden', 'wood', 'sports_centre', 'recreation_ground'},
|
|
allowedClasses: const <String>{'grass', 'cemetery', 'agriculture'},
|
|
tileZoom: tileZoom,
|
|
baseZ: 25.0,
|
|
);
|
|
|
|
// park layer
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'park'},
|
|
fillColor: kMapGrassColor,
|
|
tileZoom: tileZoom,
|
|
baseZ: 25.0,
|
|
);
|
|
|
|
// water — above grass so rivers/lakes cut through parks
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'water'},
|
|
fillColor: kMapWaterColor,
|
|
tileZoom: tileZoom,
|
|
baseZ: 30.0,
|
|
);
|
|
|
|
// airport/aerodrome
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'landuse'},
|
|
fillColor: kMapParkingColor,
|
|
strokeColor: kMapParkingOutlineColor,
|
|
strokeWorldUnits: 2.0,
|
|
tileZoom: tileZoom,
|
|
allowedClasses: const <String>{'airport'},
|
|
baseZ: 10.0,
|
|
);
|
|
|
|
// school grounds
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'landuse'},
|
|
fillColor: kMapPathFillColour,
|
|
tileZoom: tileZoom,
|
|
allowedClasses: const <String>{'school'},
|
|
baseZ: 35.0,
|
|
);
|
|
|
|
// parking areas
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'landuse'},
|
|
fillColor: kMapParkingColor,
|
|
strokeColor: kMapParkingOutlineColor,
|
|
strokeWorldUnits: 2.0,
|
|
tileZoom: tileZoom,
|
|
allowedTypes: const <String>{'parking'},
|
|
allowedClasses: const <String>{'parking'},
|
|
baseZ: 35.0,
|
|
);
|
|
|
|
// commercial areas (landuse)
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'landuse'},
|
|
fillColor: kMapCommercialAreaColor,
|
|
strokeColor: kMapCommercialAreaOutlineColor,
|
|
strokeWorldUnits: 2.0,
|
|
tileZoom: tileZoom,
|
|
allowedTypes: const <String>{'commercial_area'},
|
|
allowedClasses: const <String>{'commercial_area'},
|
|
baseZ: 35.0,
|
|
);
|
|
|
|
// buildings
|
|
_collectPolygonCommands(
|
|
commands,
|
|
size,
|
|
tile,
|
|
layerNames: const <String>{'building'},
|
|
fillColor: kMapBuildingFillColor,
|
|
strokeColor: kMapBuildingOutlineColor,
|
|
strokeWorldUnits: 3.5,
|
|
strokeCap: StrokeCap.round,
|
|
strokeJoin: StrokeJoin.miter,
|
|
strokeAntiAlias: false,
|
|
allowedTypes: const <String>{'retail', 'shelter', 'stadium', 'school', 'university', 'college', 'hospital', 'church', 'civic', 'train_station'},
|
|
excludeExtruded: true,
|
|
allowExtrudedTypes: const <String>{'stadium', 'school', 'university', 'college', 'hospital', 'church', 'civic', 'train_station'},
|
|
minHeight: 2.5,
|
|
tileZoom: tileZoom,
|
|
baseZ: 200.0,
|
|
);
|
|
|
|
_collectRoadCommands(commands, size, tile, tileZoom: tileZoom);
|
|
|
|
commands.sort((a, b) => a.z.compareTo(b.z));
|
|
for (final cmd in commands) {
|
|
cmd.draw(canvas);
|
|
}
|
|
|
|
if (kMapDebugShowTileBoundaries) _drawRasterTileBoundary(canvas, size);
|
|
}
|
|
|
|
static String _epKey(Offset p) => '${p.dx.round()},${p.dy.round()}';
|
|
|
|
double _featureLayerProp(MvtFeature feature) {
|
|
return (_toDouble(feature.properties['layer']) ?? 0.0).clamp(-5.0, 5.0);
|
|
}
|
|
|
|
void _collectPolygonCommands(
|
|
List<_DrawCommand> commands,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required Set<String> layerNames,
|
|
required Color fillColor,
|
|
Color? strokeColor,
|
|
double strokeWorldUnits = 0.0,
|
|
StrokeCap strokeCap = StrokeCap.round,
|
|
StrokeJoin strokeJoin = StrokeJoin.miter,
|
|
bool strokeAntiAlias = true,
|
|
Set<String>? allowedTypes,
|
|
Set<String>? allowedClasses,
|
|
bool excludeExtruded = false,
|
|
Set<String>? allowExtrudedTypes,
|
|
double? minHeight,
|
|
required double tileZoom,
|
|
required double baseZ,
|
|
}) {
|
|
for (final entry in tile.layers.entries) {
|
|
if (!layerNames.contains(entry.key)) continue;
|
|
final layer = entry.value;
|
|
final scale = size.width / layer.extent;
|
|
|
|
Paint? stroke;
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.polygon) continue;
|
|
|
|
final featureType = (feature.properties['type'] ?? '').toString().toLowerCase();
|
|
final featureClass = (feature.properties['class'] ?? '').toString().toLowerCase();
|
|
final extrude = _isTruthy(feature.properties['extrude']);
|
|
final height = _toDouble(feature.properties['height']);
|
|
final minFeatureHeight = feature.properties['min_height'];
|
|
|
|
if (allowedTypes != null &&
|
|
allowedTypes.isNotEmpty &&
|
|
(allowedClasses == null || allowedClasses.isEmpty) &&
|
|
!allowedTypes.contains(featureType)) {
|
|
continue;
|
|
}
|
|
if (allowedClasses != null &&
|
|
allowedClasses.isNotEmpty &&
|
|
(allowedTypes == null || allowedTypes.isEmpty) &&
|
|
!allowedClasses.contains(featureClass)) {
|
|
continue;
|
|
}
|
|
if (allowedTypes != null &&
|
|
allowedTypes.isNotEmpty &&
|
|
allowedClasses != null &&
|
|
allowedClasses.isNotEmpty &&
|
|
!allowedTypes.contains(featureType) &&
|
|
!allowedClasses.contains(featureClass)) {
|
|
continue;
|
|
}
|
|
if (excludeExtruded &&
|
|
extrude &&
|
|
(allowExtrudedTypes == null || !allowExtrudedTypes.contains(featureType))) {
|
|
continue;
|
|
}
|
|
if (minHeight != null && height != null && height < minHeight && minFeatureHeight == null) {
|
|
continue;
|
|
}
|
|
|
|
final z = baseZ + _featureLayerProp(feature) * 0.1;
|
|
|
|
stroke ??= strokeColor == null
|
|
? null
|
|
: (Paint()
|
|
..color = strokeColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = _worldStrokePx(
|
|
strokeWorldUnits,
|
|
extent: layer.extent,
|
|
rasterSize: size.width,
|
|
tileZoom: tileZoom,
|
|
)
|
|
..strokeCap = strokeCap
|
|
..strokeJoin = strokeJoin
|
|
..isAntiAlias = strokeAntiAlias);
|
|
|
|
final fill = Paint()
|
|
..color = fillColor
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
|
|
final strokeSnap = stroke;
|
|
final rings = feature.geometry;
|
|
|
|
commands.add(_DrawCommand(
|
|
z: z,
|
|
draw: (c) {
|
|
for (final ring in rings) {
|
|
if (ring.length < 3) continue;
|
|
final path = _scaledPath(ring, scale, close: true);
|
|
c.drawPath(path, fill);
|
|
if (strokeSnap != null) c.drawPath(path, strokeSnap);
|
|
}
|
|
},
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
void _collectRoadCommands(
|
|
List<_DrawCommand> commands,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double tileZoom,
|
|
}) {
|
|
final roadLayer = tile.layers['road'];
|
|
if (roadLayer == null) return;
|
|
|
|
final scale = size.width / roadLayer.extent;
|
|
|
|
// collect all styled line features upfront for the underlay punch-out pass
|
|
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 style = _roadStyleFor(feature, tileZoom: tileZoom);
|
|
if (style == null) continue;
|
|
styledLines.add((
|
|
feature: feature,
|
|
style: style,
|
|
layerProp: _featureLayerProp(feature),
|
|
sortKey: _toDouble(feature.properties['sort_key']) ?? 0.0,
|
|
roadClass: (feature.properties['class'] ?? '').toString(),
|
|
));
|
|
}
|
|
|
|
// build junction set: endpoints shared by 2+ polylines across the whole tile
|
|
// only junction endpoints get round caps; terminal (dead-end) endpoints stay flat
|
|
final epCount = <String, int>{};
|
|
for (final f in styledLines) {
|
|
for (final line in f.feature.geometry) {
|
|
if (line.length < 2) continue;
|
|
final sk = _epKey(line.first);
|
|
final ek = _epKey(line.last);
|
|
epCount[sk] = (epCount[sk] ?? 0) + 1;
|
|
epCount[ek] = (epCount[ek] ?? 0) + 1;
|
|
}
|
|
}
|
|
final junctions = <String>{
|
|
for (final e in epCount.entries)
|
|
if (e.value > 1) e.key,
|
|
};
|
|
|
|
// group by (layerProp, class) for correct z placement, but the punch-out erase
|
|
// uses all features of the same class across all layerProps
|
|
final byGroup = <(double, String), List<({MvtFeature feature, _RoadStyle style, double layerProp, double sortKey})>>{};
|
|
for (final f in styledLines) {
|
|
(byGroup[(f.layerProp, f.roadClass)] ??= []).add((feature: f.feature, style: f.style, layerProp: f.layerProp, sortKey: f.sortKey));
|
|
}
|
|
|
|
// pre-build erase geometry per class — butt caps, circles at junctions only
|
|
final eraseByClass = <String, List<({List<List<Offset>> lines, Paint paint, List<Offset> junctionPts, double radius, double terminalInset})>>{};
|
|
for (final f in styledLines) {
|
|
if (f.style.outlineOnly || f.style.fillColor == kMapPathFillColour) continue;
|
|
final uR = _worldStrokePx(f.style.underlayWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom);
|
|
final cR = _worldStrokePx(f.style.coreWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom);
|
|
final erasePaint = Paint()
|
|
..color = const Color(0xFFFFFFFF)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = cR
|
|
..strokeCap = StrokeCap.butt
|
|
..strokeJoin = StrokeJoin.round
|
|
..blendMode = BlendMode.dstOut
|
|
..isAntiAlias = true;
|
|
|
|
final junctionPts = <Offset>[];
|
|
for (final line in f.feature.geometry) {
|
|
if (line.length < 2) continue;
|
|
if (junctions.contains(_epKey(line.first))) junctionPts.add(line.first);
|
|
if (junctions.contains(_epKey(line.last))) junctionPts.add(line.last);
|
|
}
|
|
|
|
final terminalInset = (uR - cR) / 2 / scale;
|
|
(eraseByClass[f.roadClass] ??= []).add((lines: f.feature.geometry, paint: erasePaint, junctionPts: junctionPts, radius: cR / 2, terminalInset: terminalInset));
|
|
}
|
|
|
|
for (final entry in byGroup.entries) {
|
|
final lp = entry.key.$1;
|
|
final features = entry.value;
|
|
final roadClass = entry.key.$2;
|
|
|
|
final underlayDrawData = <({List<List<Offset>> lines, Paint paint, List<Offset> junctionPts, double radius})>[];
|
|
final coreDrawData = <({List<List<Offset>> lines, Paint paint, List<Offset> junctionPts, double radius, double z, double terminalInset})>[];
|
|
|
|
for (final f in features) {
|
|
final junctionPts = <Offset>[];
|
|
for (final line in f.feature.geometry) {
|
|
if (line.length < 2) continue;
|
|
if (junctions.contains(_epKey(line.first))) junctionPts.add(line.first);
|
|
if (junctions.contains(_epKey(line.last))) junctionPts.add(line.last);
|
|
}
|
|
|
|
final uR = _worldStrokePx(f.style.underlayWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom);
|
|
final underlayPaint = Paint()
|
|
..color = f.style.outlineOnly ? f.style.fillColor : f.style.underlayColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = uR
|
|
..strokeCap = StrokeCap.butt
|
|
..strokeJoin = StrokeJoin.round
|
|
..isAntiAlias = true;
|
|
|
|
underlayDrawData.add((lines: f.feature.geometry, paint: underlayPaint, junctionPts: junctionPts, radius: uR / 2));
|
|
|
|
final skipOverlay = f.style.outlineOnly || f.style.fillColor == kMapPathFillColour;
|
|
if (!skipOverlay) {
|
|
final railBias = roadClass.contains('rail') ? -10.0 : 0.0;
|
|
final coreZ = 50.0 + f.layerProp * 30.0 + f.sortKey * 0.001 + 1.0 + railBias;
|
|
|
|
final cR = _worldStrokePx(f.style.coreWorldUnits, extent: roadLayer.extent, rasterSize: size.width, tileZoom: tileZoom);
|
|
final corePaint = Paint()
|
|
..color = f.style.fillColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = cR
|
|
..strokeCap = StrokeCap.butt
|
|
..strokeJoin = StrokeJoin.round
|
|
..isAntiAlias = true;
|
|
|
|
// inset amount in extent coords so the underlay shows as a flat border at terminal ends
|
|
final terminalInset = (uR - cR) / 2 / scale;
|
|
|
|
coreDrawData.add((lines: f.feature.geometry, paint: corePaint, junctionPts: junctionPts, radius: cR / 2, z: coreZ, terminalInset: terminalInset));
|
|
}
|
|
}
|
|
|
|
// erase with all same-class cores (across all layerProps)
|
|
final classErase = eraseByClass[roadClass] ?? [];
|
|
|
|
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());
|
|
|
|
for (final d in underlayDrawData) {
|
|
for (final line in d.lines) {
|
|
if (line.length < 2) continue;
|
|
c.drawPath(_scaledPath(line, scale), d.paint);
|
|
}
|
|
final circlePaint = Paint()
|
|
..color = d.paint.color
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
for (final pt in d.junctionPts) {
|
|
c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), d.radius, circlePaint);
|
|
}
|
|
}
|
|
|
|
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();
|
|
},
|
|
));
|
|
|
|
for (final d in coreDrawData) {
|
|
final lines = d.lines;
|
|
final paint = d.paint;
|
|
final jPts = d.junctionPts;
|
|
final r = d.radius;
|
|
final inset = d.terminalInset;
|
|
commands.add(_DrawCommand(
|
|
z: d.z,
|
|
draw: (c) {
|
|
for (final line in 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 ? inset : 0.0,
|
|
endIsTerminal ? inset : 0.0,
|
|
);
|
|
if (trimmed == null || trimmed.length < 2) continue;
|
|
c.drawPath(_scaledPath(trimmed, scale), paint);
|
|
}
|
|
final circlePaint = Paint()
|
|
..color = paint.color
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
for (final pt in jPts) {
|
|
c.drawCircle(Offset(pt.dx * scale, pt.dy * scale), r, circlePaint);
|
|
}
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
for (final feature in roadLayer.features) {
|
|
final style = _roadStyleFor(feature, tileZoom: tileZoom);
|
|
if (style == null) continue;
|
|
|
|
final layerProp = _featureLayerProp(feature);
|
|
final sortKey = _toDouble(feature.properties['sort_key']) ?? 0.0;
|
|
|
|
if (feature.type != MvtGeometryType.polygon) continue;
|
|
|
|
final z = 22.0 + layerProp * 30.0;
|
|
final fill = Paint()
|
|
..color = style.fillColor
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
final outline = Paint()
|
|
..color = style.underlayColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = _worldStrokePx(
|
|
math.max(style.coreWorldUnits * 0.22, 3.0),
|
|
extent: roadLayer.extent,
|
|
rasterSize: size.width,
|
|
tileZoom: tileZoom,
|
|
)
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round
|
|
..isAntiAlias = true;
|
|
|
|
final rings = feature.geometry;
|
|
commands.add(_DrawCommand(
|
|
z: z,
|
|
draw: (c) {
|
|
for (final ring in rings) {
|
|
if (ring.length < 3) continue;
|
|
final path = _scaledPath(ring, scale, close: true);
|
|
c.drawPath(path, fill);
|
|
c.drawPath(path, outline);
|
|
}
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
void _drawRasterTileBoundary(Canvas canvas, Size size) {
|
|
canvas.drawRect(
|
|
Offset.zero & size,
|
|
Paint()
|
|
..color = kMapDebugTileBoundaryColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 2.0
|
|
..isAntiAlias = false,
|
|
);
|
|
}
|
|
|
|
void _drawPolygonLayer(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required Set<String> layerNames,
|
|
required Color fillColor,
|
|
Color? strokeColor,
|
|
double strokeWorldUnits = 0.0,
|
|
StrokeCap strokeCap = StrokeCap.round,
|
|
StrokeJoin strokeJoin = StrokeJoin.miter,
|
|
bool strokeAntiAlias = true,
|
|
Set<String>? allowedTypes,
|
|
Set<String>? allowedClasses,
|
|
bool excludeExtruded = false,
|
|
Set<String>? allowExtrudedTypes,
|
|
double? minHeight,
|
|
required double tileZoom,
|
|
}) {
|
|
final fill = Paint()
|
|
..color = fillColor
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
|
|
Paint? stroke;
|
|
|
|
for (final entry in tile.layers.entries) {
|
|
if (!layerNames.contains(entry.key)) continue;
|
|
final layer = entry.value;
|
|
final scale = size.width / layer.extent;
|
|
stroke ??= strokeColor == null
|
|
? null
|
|
: (Paint()
|
|
..color = strokeColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = _worldStrokePx(
|
|
strokeWorldUnits,
|
|
extent: layer.extent,
|
|
rasterSize: size.width,
|
|
tileZoom: tileZoom,
|
|
)
|
|
..strokeCap = strokeCap
|
|
..strokeJoin = strokeJoin
|
|
..isAntiAlias = strokeAntiAlias);
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.polygon) continue;
|
|
final featureType = (feature.properties['type'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
final featureClass = (feature.properties['class'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
final extrude = _isTruthy(feature.properties['extrude']);
|
|
final height = _toDouble(feature.properties['height']);
|
|
final minFeatureHeight = feature.properties['min_height'];
|
|
if (allowedTypes != null &&
|
|
allowedTypes.isNotEmpty &&
|
|
(allowedClasses == null || allowedClasses.isEmpty) &&
|
|
!allowedTypes.contains(featureType)) {
|
|
continue;
|
|
}
|
|
if (allowedClasses != null &&
|
|
allowedClasses.isNotEmpty &&
|
|
(allowedTypes == null || allowedTypes.isEmpty) &&
|
|
!allowedClasses.contains(featureClass)) {
|
|
continue;
|
|
}
|
|
if (allowedTypes != null &&
|
|
allowedTypes.isNotEmpty &&
|
|
allowedClasses != null &&
|
|
allowedClasses.isNotEmpty &&
|
|
!allowedTypes.contains(featureType) &&
|
|
!allowedClasses.contains(featureClass)) {
|
|
continue;
|
|
}
|
|
if (excludeExtruded &&
|
|
extrude &&
|
|
(allowExtrudedTypes == null ||
|
|
!allowExtrudedTypes.contains(featureType))) {
|
|
continue;
|
|
}
|
|
if (minHeight != null &&
|
|
height != null &&
|
|
height < minHeight &&
|
|
minFeatureHeight == null) {
|
|
continue;
|
|
}
|
|
for (final ring in feature.geometry) {
|
|
if (ring.length < 3) continue;
|
|
final path = _scaledPath(ring, scale, close: true);
|
|
canvas.drawPath(path, fill);
|
|
if (stroke != null) canvas.drawPath(path, stroke);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void _drawRoadLayer(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double tileZoom,
|
|
}) {
|
|
final roadLayer = tile.layers['road'];
|
|
if (roadLayer == null) return;
|
|
|
|
final scale = size.width / roadLayer.extent;
|
|
final styledLines = <_StyledRoad>[];
|
|
final styledPolygons = <_StyledRoad>[];
|
|
|
|
for (final feature in roadLayer.features) {
|
|
final style = _roadStyleFor(feature, tileZoom: tileZoom);
|
|
if (style == null) continue;
|
|
if (feature.type == MvtGeometryType.lineString) {
|
|
styledLines.add(_StyledRoad(feature: feature, style: style));
|
|
} else if (feature.type == MvtGeometryType.polygon) {
|
|
styledPolygons.add(_StyledRoad(feature: feature, style: style));
|
|
}
|
|
}
|
|
|
|
styledPolygons.sort(
|
|
(a, b) => a.style.sortOrder.compareTo(b.style.sortOrder),
|
|
);
|
|
styledLines.sort((a, b) => a.style.sortOrder.compareTo(b.style.sortOrder));
|
|
|
|
for (final road in styledPolygons) {
|
|
final fill = Paint()
|
|
..color = road.style.fillColor
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
final outline = Paint()
|
|
..color = road.style.underlayColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = _worldStrokePx(
|
|
math.max(road.style.coreWorldUnits * 0.22, 3.0),
|
|
extent: roadLayer.extent,
|
|
rasterSize: size.width,
|
|
tileZoom: tileZoom,
|
|
)
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.miter
|
|
..isAntiAlias = true;
|
|
|
|
for (final ring in road.feature.geometry) {
|
|
if (ring.length < 3) continue;
|
|
final path = _scaledPath(ring, scale, close: true);
|
|
canvas.drawPath(path, fill);
|
|
canvas.drawPath(path, outline);
|
|
}
|
|
}
|
|
|
|
for (final road in styledLines) {
|
|
final outlineOnly = road.style.outlineOnly;
|
|
final underlay = Paint()
|
|
..color = outlineOnly ? road.style.fillColor : road.style.underlayColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = _worldStrokePx(
|
|
road.style.underlayWorldUnits,
|
|
extent: roadLayer.extent,
|
|
rasterSize: size.width,
|
|
tileZoom: tileZoom,
|
|
)
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.miter
|
|
..isAntiAlias = true;
|
|
|
|
for (final line in road.feature.geometry) {
|
|
if (line.length < 2) continue;
|
|
final path = _scaledPath(line, scale);
|
|
canvas.drawPath(path, underlay);
|
|
}
|
|
}
|
|
|
|
for (final road in styledLines) {
|
|
if (road.style.outlineOnly) continue;
|
|
final core = Paint()
|
|
..color = road.style.fillColor
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = _worldStrokePx(
|
|
road.style.coreWorldUnits,
|
|
extent: roadLayer.extent,
|
|
rasterSize: size.width,
|
|
tileZoom: tileZoom,
|
|
)
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.miter
|
|
..isAntiAlias = true;
|
|
|
|
for (final line in road.feature.geometry) {
|
|
if (line.length < 2) continue;
|
|
final path = _scaledPath(line, scale);
|
|
canvas.drawPath(path, core);
|
|
}
|
|
}
|
|
}
|
|
|
|
_RoadStyle? _roadStyleFor(MvtFeature feature, {required double tileZoom}) {
|
|
final roadClass = (feature.properties['class'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
final rawRoadType = feature.properties['type'];
|
|
final roadType = (rawRoadType ?? '').toString().toLowerCase();
|
|
final surface = (feature.properties['surface'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
final structure =
|
|
(feature.properties['structure'] ?? feature.properties['brunnel'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
|
|
final isRailLike =
|
|
roadClass.contains('rail') ||
|
|
roadType.contains('rail') ||
|
|
roadClass.contains('subway') ||
|
|
roadType.contains('subway') ||
|
|
roadClass.contains('metro') ||
|
|
roadType.contains('metro') ||
|
|
roadClass.contains('train') ||
|
|
roadType.contains('train');
|
|
final isPlatform =
|
|
roadClass.contains('platform') || roadType.contains('platform');
|
|
final isService = roadClass == 'service' && roadType == 'service';
|
|
final isServiceSubtype =
|
|
roadClass == 'service' && roadType.startsWith('service:');
|
|
final isUnderground =
|
|
structure.contains('tunnel') || structure.contains('underground');
|
|
if (isPlatform && feature.type == MvtGeometryType.lineString) return null;
|
|
if (isServiceSubtype) {
|
|
return _RoadStyle(
|
|
sortOrder: 0,
|
|
coreWorldUnits: 7.4,
|
|
underlayWorldUnits: 11.6,
|
|
fillColor: kMapPathFillColour,
|
|
underlayColor: kMapBackgroundColor,
|
|
outlineOnly: true,
|
|
);
|
|
}
|
|
if (isService) {
|
|
return _RoadStyle(
|
|
sortOrder: 0,
|
|
coreWorldUnits: 7.4,
|
|
underlayWorldUnits: 11.6,
|
|
fillColor: kMapPathFillColour,
|
|
underlayColor: kMapBackgroundColor,
|
|
outlineOnly: true,
|
|
);
|
|
}
|
|
if (roadClass == 'aerialway' || roadClass.startsWith('aerialway')) {
|
|
const allowedAerialwayTypes = <String>{'aerialway:gondola', 'aerialway:cable_car', 'aerialway:funicular'};
|
|
if (!allowedAerialwayTypes.contains(roadType)) return null;
|
|
return _RoadStyle(
|
|
sortOrder: 0,
|
|
coreWorldUnits: 3.5,
|
|
underlayWorldUnits: 5.5,
|
|
fillColor: kMapRailFillColor,
|
|
underlayColor: kMapRailUnderlayColor,
|
|
outlineOnly: false,
|
|
);
|
|
}
|
|
if (roadClass == 'ferry' || roadType == 'ferry') return null;
|
|
if (roadClass == 'golf' || roadType == 'hole') return null;
|
|
if (roadType == 'minor_rail') return null;
|
|
final layerPropVal = (_toDouble(feature.properties['layer']) ?? 0.0);
|
|
if (isRailLike && isUnderground) return null;
|
|
if (isRailLike) {
|
|
if (isUnderground) {
|
|
return _RoadStyle(
|
|
sortOrder: 0,
|
|
coreWorldUnits: 3.5,
|
|
underlayWorldUnits: 5.5,
|
|
fillColor: kMapRailUnderlayColor,
|
|
underlayColor: kMapBackgroundColor,
|
|
outlineOnly: false,
|
|
);
|
|
}
|
|
return _RoadStyle(
|
|
sortOrder: 0,
|
|
coreWorldUnits: 3.5,
|
|
underlayWorldUnits: 5.5,
|
|
fillColor: kMapRailFillColor,
|
|
underlayColor: kMapRailUnderlayColor,
|
|
outlineOnly: false,
|
|
);
|
|
}
|
|
|
|
final isPedestrian =
|
|
roadClass.contains('pedestrian') || roadType.contains('pedestrian');
|
|
final isFootway =
|
|
roadClass.contains('footway') || roadType.contains('footway');
|
|
final isPathClass = roadClass.contains('path');
|
|
final isPathLike =
|
|
isPathClass ||
|
|
roadType.contains('path') ||
|
|
isFootway ||
|
|
roadClass.contains('steps') ||
|
|
roadType.contains('steps') ||
|
|
roadClass.contains('cycleway') ||
|
|
roadType.contains('cycleway') ||
|
|
roadClass.contains('track') ||
|
|
roadType.contains('track') ||
|
|
roadClass.contains('bridleway') ||
|
|
roadType.contains('bridleway') ||
|
|
roadType.contains('trail');
|
|
final isFootwayPath = isFootway && isPathClass;
|
|
final isPavedPathLike = surface.contains('paved') && isPathLike;
|
|
if (isFootwayPath) return null;
|
|
if (isPavedPathLike) return null;
|
|
if (isFootway) {
|
|
return _RoadStyle(
|
|
sortOrder: 0,
|
|
coreWorldUnits: 7.4,
|
|
underlayWorldUnits: 11.6,
|
|
fillColor: kMapPathFillColour,
|
|
underlayColor: kMapBackgroundColor,
|
|
outlineOnly: true,
|
|
);
|
|
}
|
|
if (isPathLike) return null;
|
|
|
|
final isMotorway =
|
|
roadClass.contains('motorway') || roadType.contains('motorway');
|
|
final isTrunkPrimary =
|
|
roadClass.contains('trunk') ||
|
|
roadType.contains('trunk') ||
|
|
roadClass.contains('primary') ||
|
|
roadType.contains('primary');
|
|
final isSecondary =
|
|
roadClass.contains('secondary') ||
|
|
roadType.contains('secondary') ||
|
|
roadClass.contains('tertiary') ||
|
|
roadType.contains('tertiary');
|
|
final isLink = roadClass.contains('link') || roadType.contains('link');
|
|
|
|
var style = _RoadStyle(
|
|
sortOrder: 1,
|
|
coreWorldUnits: 13.4,
|
|
underlayWorldUnits: 20.4,
|
|
fillColor: kMapRoadFillColor,
|
|
underlayColor: kMapPathFillColour,
|
|
outlineOnly: false,
|
|
);
|
|
|
|
if (isSecondary) {
|
|
style = _RoadStyle(
|
|
sortOrder: 2,
|
|
coreWorldUnits: 16.4,
|
|
underlayWorldUnits: 24.8,
|
|
fillColor: kMapRoadFillColor,
|
|
underlayColor: kMapPathFillColour,
|
|
outlineOnly: false,
|
|
);
|
|
}
|
|
|
|
if (isTrunkPrimary) {
|
|
style = _RoadStyle(
|
|
sortOrder: 3,
|
|
coreWorldUnits: 20.9,
|
|
underlayWorldUnits: 31.0,
|
|
fillColor: kMapRoadFillColor,
|
|
underlayColor: kMapPathFillColour,
|
|
outlineOnly: false,
|
|
);
|
|
}
|
|
|
|
if (isMotorway) {
|
|
style = _RoadStyle(
|
|
sortOrder: 4,
|
|
coreWorldUnits: 25.3,
|
|
underlayWorldUnits: 37.0,
|
|
fillColor: kMapRoadFillColor,
|
|
underlayColor: kMapPathFillColour,
|
|
outlineOnly: false,
|
|
);
|
|
}
|
|
|
|
if (isPedestrian) {
|
|
style = _RoadStyle(
|
|
sortOrder: style.sortOrder,
|
|
coreWorldUnits: style.coreWorldUnits,
|
|
underlayWorldUnits: style.underlayWorldUnits,
|
|
fillColor: kMapPathFillColour,
|
|
underlayColor: style.underlayColor,
|
|
outlineOnly: false,
|
|
);
|
|
}
|
|
|
|
final isOneway = _isTruthy(feature.properties['oneway']);
|
|
if (isTrunkPrimary && isOneway && !isMotorway) {
|
|
style = _RoadStyle(
|
|
sortOrder: style.sortOrder,
|
|
coreWorldUnits: style.coreWorldUnits * 0.75,
|
|
underlayWorldUnits: style.underlayWorldUnits * 0.75,
|
|
fillColor: style.fillColor,
|
|
underlayColor: style.underlayColor,
|
|
outlineOnly: style.outlineOnly,
|
|
);
|
|
}
|
|
|
|
if (!isLink) return style;
|
|
|
|
return _RoadStyle(
|
|
sortOrder: style.sortOrder,
|
|
coreWorldUnits: style.coreWorldUnits * 0.82,
|
|
underlayWorldUnits: style.underlayWorldUnits * 0.82,
|
|
fillColor: style.fillColor,
|
|
underlayColor: style.underlayColor,
|
|
outlineOnly: style.outlineOnly,
|
|
);
|
|
}
|
|
|
|
static const Set<String> _areaPoiClasses = <String>{
|
|
'park',
|
|
'nature',
|
|
'cemetery',
|
|
'wood',
|
|
'forest',
|
|
'golf',
|
|
'recreation_ground',
|
|
'stadium',
|
|
'pitch',
|
|
'allotments',
|
|
'supermarket',
|
|
};
|
|
|
|
void _drawAllPlaceLabels(Canvas canvas) {
|
|
final z = activeTileZoom;
|
|
final factor = math.pow(2, zoom - z).toDouble();
|
|
final maxIndex = 1 << z;
|
|
final minWX = centerWorldX - (mapWidth / 2) / factor;
|
|
final minWY = centerWorldY - (mapHeight / 2) / factor;
|
|
final maxWX = centerWorldX + (mapWidth / 2) / factor;
|
|
final maxWY = centerWorldY + (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;
|
|
|
|
final occupiedRects = <Rect>[];
|
|
final placedByName = <String, List<Offset>>{};
|
|
final screenBounds = Rect.fromLTWH(0, 0, mapWidth, mapHeight);
|
|
const minSameNameSpacing = 300.0;
|
|
|
|
final candidates = <({String text, String featureClass, Offset screenPt, int rank})>[];
|
|
|
|
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 layer = tile.layers['place_label'];
|
|
if (layer == null) continue;
|
|
|
|
final tileLeft = (tx * tileSize - minWX) * factor;
|
|
final tileTop = (ty * tileSize - minWY) * factor;
|
|
final scale = (tileSize * factor) / layer.extent;
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.point) continue;
|
|
|
|
final featureClass =
|
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
if (!_allowedPlaceClasses.contains(featureClass)) continue;
|
|
|
|
final capital = (_toDouble(feature.properties['capital']) ?? 0).toInt();
|
|
final isCapital = capital >= 2;
|
|
|
|
final symbolrank = (_toDouble(feature.properties['symbolrank']) ?? 99).toInt();
|
|
if (symbolrank > 15 && zoom <= 14.0) continue;
|
|
|
|
if (!isCapital) {
|
|
if (zoom < 12.5 && featureClass == 'settlement_subdivision') continue;
|
|
if (zoom < 12.5 && featureClass == 'settlement') continue;
|
|
}
|
|
|
|
final rawText = _sanitizeLabel(
|
|
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
|
|
.toString()
|
|
.trim());
|
|
if (rawText.isEmpty || rawText.length > 30) continue;
|
|
final text = rawText;
|
|
|
|
final anchor =
|
|
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
|
|
? feature.geometry.first.first
|
|
: null;
|
|
if (anchor == null) continue;
|
|
|
|
final screenPt = Offset(
|
|
tileLeft + anchor.dx * scale,
|
|
tileTop + anchor.dy * scale,
|
|
);
|
|
|
|
candidates.add((
|
|
text: text,
|
|
featureClass: featureClass,
|
|
screenPt: screenPt,
|
|
rank: symbolrank,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
candidates.sort((a, b) => a.rank.compareTo(b.rank));
|
|
|
|
for (final c in candidates) {
|
|
if (!screenBounds.contains(c.screenPt)) continue;
|
|
|
|
final key = c.text.toLowerCase();
|
|
final existing = placedByName[key];
|
|
if (existing != null) {
|
|
final tooClose = existing.any((p) {
|
|
final dx = p.dx - c.screenPt.dx;
|
|
final dy = p.dy - c.screenPt.dy;
|
|
return math.sqrt(dx * dx + dy * dy) < minSameNameSpacing;
|
|
});
|
|
if (tooClose) continue;
|
|
}
|
|
|
|
final isSettlement = c.featureClass == 'settlement';
|
|
final fontSize = isSettlement ? 22.0 : 15.0;
|
|
final fontWeight = isSettlement ? FontWeight.w800 : FontWeight.w800;
|
|
|
|
final painter = TextPainter(
|
|
text: TextSpan(
|
|
text: c.text.toUpperCase(),
|
|
style: GoogleFonts.inter(
|
|
color: kMapPlaceLabelColor,
|
|
fontSize: fontSize,
|
|
fontWeight: fontWeight,
|
|
letterSpacing: isSettlement ? 3.2 : 2.0,
|
|
height: 1.0,
|
|
shadows: <Shadow>[
|
|
Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 6),
|
|
],
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
maxLines: 1,
|
|
ellipsis: '',
|
|
)..layout();
|
|
|
|
final bounds = Rect.fromCenter(
|
|
center: c.screenPt,
|
|
width: painter.width + 16,
|
|
height: painter.height + 12,
|
|
);
|
|
if (occupiedRects.any((r) => r.overlaps(bounds))) continue;
|
|
|
|
painter.paint(canvas, Offset(c.screenPt.dx - painter.width / 2, c.screenPt.dy - painter.height / 2));
|
|
occupiedRects.add(bounds);
|
|
(placedByName[key] ??= <Offset>[]).add(c.screenPt);
|
|
}
|
|
}
|
|
|
|
void _drawPoiLabels(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double tileZoom,
|
|
}) {
|
|
if (tileZoom < 14) return;
|
|
|
|
final layer = tile.layers['poi_label'];
|
|
if (layer == null) return;
|
|
|
|
final scale = size.width / layer.extent;
|
|
final occupiedRects = <Rect>[];
|
|
final seenTexts = <String>{};
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.point) continue;
|
|
|
|
final featureClass =
|
|
(feature.properties['class'] ??
|
|
feature.properties['type'] ??
|
|
feature.properties['maki'] ??
|
|
'')
|
|
.toString()
|
|
.toLowerCase();
|
|
|
|
if (!_areaPoiClasses.contains(featureClass)) continue;
|
|
|
|
final rawText = _sanitizeLabel(
|
|
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
|
|
.toString()
|
|
.trim());
|
|
if (rawText.isEmpty || rawText.length > 30) continue;
|
|
if (!seenTexts.add(rawText.toLowerCase())) continue;
|
|
|
|
final symbolrank = feature.properties['symbolrank'];
|
|
final sizerank = feature.properties['sizerank'];
|
|
final text = '$rawText [sr:$symbolrank sz:$sizerank]';
|
|
|
|
final anchor =
|
|
feature.geometry.isNotEmpty && feature.geometry.first.isNotEmpty
|
|
? feature.geometry.first.first
|
|
: null;
|
|
if (anchor == null) continue;
|
|
|
|
final painter = TextPainter(
|
|
text: TextSpan(
|
|
text: text,
|
|
style: GoogleFonts.inter(
|
|
color: kMapPoiLabelColor,
|
|
fontSize: 10.0,
|
|
fontWeight: FontWeight.w500,
|
|
fontStyle: FontStyle.italic,
|
|
height: 1.0,
|
|
shadows: <Shadow>[
|
|
Shadow(color: kMapPoiLabelHaloColor, blurRadius: 4),
|
|
],
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
maxLines: 1,
|
|
ellipsis: '',
|
|
)..layout();
|
|
|
|
final x = anchor.dx * scale - painter.width / 2;
|
|
final y = anchor.dy * scale - painter.height / 2;
|
|
|
|
if (x < -painter.width ||
|
|
y < -painter.height ||
|
|
x > size.width ||
|
|
y > size.height) {
|
|
continue;
|
|
}
|
|
|
|
final bounds = Rect.fromLTWH(x, y, painter.width, painter.height).inflate(20);
|
|
if (occupiedRects.any((r) => r.overlaps(bounds))) continue;
|
|
|
|
painter.paint(canvas, Offset(x, y));
|
|
occupiedRects.add(bounds);
|
|
}
|
|
}
|
|
|
|
void _drawPlaceLabels(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double tileZoom,
|
|
}) {
|
|
if (tileZoom < 12) return;
|
|
|
|
final layer = tile.layers['place_label'];
|
|
if (layer == null) return;
|
|
|
|
final candidates = <_LabelCandidate>[];
|
|
final occupiedRects = <Rect>[];
|
|
final seenTexts = <String>{};
|
|
final scale = size.width / layer.extent;
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.point) continue;
|
|
final featureClass =
|
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
|
|
// temp: log all place_label classes to figure out whats in the data
|
|
if (!_allowedPlaceClasses.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,
|
|
featureClass: featureClass,
|
|
),
|
|
);
|
|
}
|
|
|
|
candidates.sort((a, b) {
|
|
final rankCompare = a.rank.compareTo(b.rank);
|
|
return rankCompare != 0
|
|
? rankCompare
|
|
: a.text.length.compareTo(b.text.length);
|
|
});
|
|
|
|
final maxLabels = tileZoom >= 15 ? 5 : 2;
|
|
final minSpacing = tileZoom >= 15 ? 24.0 : 30.0;
|
|
|
|
var drawn = 0;
|
|
for (final candidate in candidates) {
|
|
if (drawn >= maxLabels) break;
|
|
if (seenTexts.contains(candidate.text.toLowerCase())) continue;
|
|
|
|
final isSettlement = candidate.featureClass == 'settlement';
|
|
final fontSize = isSettlement ? 22.0 : 15.0;
|
|
final fontWeight = isSettlement ? FontWeight.w800 : FontWeight.w800;
|
|
|
|
final painter = TextPainter(
|
|
text: TextSpan(
|
|
text: candidate.text.toUpperCase(),
|
|
style: GoogleFonts.inter(
|
|
color: kMapPlaceLabelColor,
|
|
fontSize: fontSize,
|
|
fontWeight: fontWeight,
|
|
letterSpacing: isSettlement ? 3.2 : 2.0,
|
|
shadows: <Shadow>[
|
|
Shadow(color: kMapPlaceLabelHaloColor, blurRadius: 6),
|
|
],
|
|
height: 1.0,
|
|
),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
maxLines: 1,
|
|
ellipsis: '',
|
|
)..layout();
|
|
|
|
final x = candidate.anchor.dx * scale - painter.width / 2;
|
|
final y = candidate.anchor.dy * scale - painter.height / 2;
|
|
|
|
if (x < -painter.width ||
|
|
y < -painter.height ||
|
|
x > size.width ||
|
|
y > size.height) {
|
|
continue;
|
|
}
|
|
|
|
final bounds = Rect.fromLTWH(
|
|
x,
|
|
y,
|
|
painter.width,
|
|
painter.height,
|
|
).inflate(minSpacing);
|
|
if (occupiedRects.any((rect) => rect.overlaps(bounds))) continue;
|
|
|
|
painter.paint(canvas, Offset(x, y));
|
|
occupiedRects.add(bounds);
|
|
seenTexts.add(candidate.text.toLowerCase());
|
|
drawn++;
|
|
}
|
|
}
|
|
|
|
// draws road labels in screen space across all visible tiles so they
|
|
// dont get clipped at tile boundaries
|
|
void _drawAllRoadLabels(Canvas canvas) {
|
|
final z = activeTileZoom;
|
|
if (zoom < 15.0) return;
|
|
final majorRoadsOnly = zoom < 16.5;
|
|
|
|
final factor = math.pow(2, zoom - z).toDouble();
|
|
final maxIndex = 1 << z;
|
|
final minWX = centerWorldX - (mapWidth / 2) / factor;
|
|
final minWY = centerWorldY - (mapHeight / 2) / factor;
|
|
final maxWX = centerWorldX + (mapWidth / 2) / factor;
|
|
final maxWY = centerWorldY + (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;
|
|
|
|
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>[];
|
|
|
|
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 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;
|
|
|
|
for (final feature in layer.features) {
|
|
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 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 painter = 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();
|
|
|
|
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;
|
|
}
|
|
}
|
|
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,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
|
|
occupiedRects.add(bounds);
|
|
(placedByName[key] ??= <Offset>[]).add(c.screenCenter);
|
|
}
|
|
}
|
|
|
|
void _drawRoadLabels(
|
|
Canvas canvas,
|
|
Size size,
|
|
MvtTile tile, {
|
|
required double tileZoom,
|
|
}) {
|
|
if (tileZoom < 14) return;
|
|
|
|
final layer = tile.layers['road'];
|
|
if (layer == null) return;
|
|
|
|
final scale = size.width / layer.extent;
|
|
final seenTexts = <String>{};
|
|
final occupiedRects = <Rect>[];
|
|
final tileBounds = Offset.zero & size;
|
|
const tileEdgeMargin = 12.0;
|
|
final labelSafeBounds = Rect.fromLTWH(
|
|
tileEdgeMargin,
|
|
tileEdgeMargin,
|
|
math.max(0, size.width - tileEdgeMargin * 2),
|
|
math.max(0, size.height - tileEdgeMargin * 2),
|
|
);
|
|
|
|
for (final feature in layer.features) {
|
|
if (feature.type != MvtGeometryType.lineString) continue;
|
|
|
|
final roadClass =
|
|
(feature.properties['class'] ?? feature.properties['type'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
if (_shouldSkipRoadLabelClass(roadClass)) continue;
|
|
final style = _roadStyleFor(feature, tileZoom: tileZoom);
|
|
if (style == null) continue;
|
|
|
|
final text = _sanitizeLabel(
|
|
(feature.properties['name_en'] ?? feature.properties['name'] ?? '')
|
|
.toString()
|
|
.trim());
|
|
if (text.isEmpty || text.length > 32) continue;
|
|
if (!seenTexts.add(text.toLowerCase())) continue;
|
|
|
|
final minFontZoom2 = _isMajorRoadClass(roadClass) ? 19.0 : zoom;
|
|
final fontRasterSize = size.width * math.pow(2, math.max(zoom, minFontZoom2) - tileZoom);
|
|
final roadCorePx = _worldStrokePx(
|
|
style.coreWorldUnits,
|
|
extent: layer.extent,
|
|
rasterSize: fontRasterSize,
|
|
tileZoom: tileZoom,
|
|
);
|
|
final fontSize = (roadCorePx * 1.05).clamp(8.5, 24.0);
|
|
|
|
final painter = 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();
|
|
|
|
final placement = _bestRoadLabelPlacement(
|
|
feature.geometry,
|
|
scale: scale,
|
|
labelWidth: painter.width,
|
|
);
|
|
if (placement == null) continue;
|
|
|
|
final bounds = Rect.fromCenter(
|
|
center: placement.center,
|
|
width: painter.width + 12,
|
|
height: painter.height + 10,
|
|
);
|
|
if (!tileBounds.contains(bounds.topLeft) ||
|
|
!tileBounds.contains(bounds.bottomRight)) {
|
|
continue;
|
|
}
|
|
if (!labelSafeBounds.contains(placement.center)) continue;
|
|
if (occupiedRects.any((rect) => rect.overlaps(bounds))) continue;
|
|
|
|
canvas.save();
|
|
canvas.translate(placement.center.dx, placement.center.dy);
|
|
canvas.rotate(placement.angle);
|
|
painter.paint(canvas, Offset(-painter.width / 2, -painter.height / 2));
|
|
canvas.restore();
|
|
|
|
occupiedRects.add(bounds);
|
|
}
|
|
}
|
|
|
|
double _worldStrokePx(
|
|
double worldUnits, {
|
|
required int extent,
|
|
required double rasterSize,
|
|
required double tileZoom,
|
|
}) {
|
|
final zoomScale = math.pow(2.0, tileZoom - _kLineReferenceZoom).toDouble();
|
|
return worldUnits * zoomScale * (rasterSize / extent);
|
|
}
|
|
|
|
bool _polygonContains(Offset point, List<List<Offset>> geometry) {
|
|
for (final ring in geometry) {
|
|
if (ring.length < 3) continue;
|
|
final path = Path()..moveTo(ring.first.dx, ring.first.dy);
|
|
for (var i = 1; i < ring.length; i++) {
|
|
path.lineTo(ring[i].dx, ring[i].dy);
|
|
}
|
|
path.close();
|
|
if (path.contains(point)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
double _minDistanceToGeometry(Offset point, List<List<Offset>> geometry) {
|
|
var best = double.infinity;
|
|
for (final line in geometry) {
|
|
for (var i = 1; i < line.length; i++) {
|
|
final distance = _distanceToSegment(point, line[i - 1], line[i]);
|
|
if (distance < best) best = distance;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
double _minDistanceToPoints(Offset point, List<List<Offset>> geometry) {
|
|
var best = double.infinity;
|
|
for (final group in geometry) {
|
|
for (final candidate in group) {
|
|
final dx = candidate.dx - point.dx;
|
|
final dy = candidate.dy - point.dy;
|
|
final distance = math.sqrt(dx * dx + dy * dy);
|
|
if (distance < best) best = distance;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
double _distanceToSegment(Offset point, Offset a, Offset b) {
|
|
final dx = b.dx - a.dx;
|
|
final dy = b.dy - a.dy;
|
|
final lengthSquared = dx * dx + dy * dy;
|
|
if (lengthSquared <= 0.0001) {
|
|
final px = point.dx - a.dx;
|
|
final py = point.dy - a.dy;
|
|
return math.sqrt(px * px + py * py);
|
|
}
|
|
|
|
final t =
|
|
(((point.dx - a.dx) * dx) + ((point.dy - a.dy) * dy)) / lengthSquared;
|
|
final clamped = t.clamp(0.0, 1.0);
|
|
final projX = a.dx + dx * clamped;
|
|
final projY = a.dy + dy * clamped;
|
|
final distX = point.dx - projX;
|
|
final distY = point.dy - projY;
|
|
return math.sqrt(distX * distX + distY * distY);
|
|
}
|
|
|
|
Path _scaledPath(List<Offset> points, double scale, {bool close = false}) {
|
|
final path = Path()
|
|
..moveTo(points.first.dx * scale, points.first.dy * scale);
|
|
for (var i = 1; i < points.length; i++) {
|
|
path.lineTo(points[i].dx * scale, points[i].dy * scale);
|
|
}
|
|
if (close) path.close();
|
|
return path;
|
|
}
|
|
|
|
_RoadLabelPlacement? _bestRoadLabelPlacement(
|
|
List<List<Offset>> geometry, {
|
|
required double scale,
|
|
required double labelWidth,
|
|
}) {
|
|
_RoadLabelPlacement? best;
|
|
|
|
for (final line in geometry) {
|
|
if (line.length < 2) continue;
|
|
final scaled = <Offset>[
|
|
for (final point in line) Offset(point.dx * scale, point.dy * scale),
|
|
];
|
|
final length = _polylineLength(scaled);
|
|
if (length < math.max(labelWidth * 1.35, 48.0)) continue;
|
|
|
|
final placement = _placementAlongPolyline(scaled, distance: length / 2);
|
|
if (placement == null) continue;
|
|
if (best == null || length > best.pathLength) {
|
|
best = _RoadLabelPlacement(
|
|
center: placement.center,
|
|
angle: placement.angle,
|
|
pathLength: length,
|
|
);
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
_RoadLabelPlacement? _placementAlongPolyline(
|
|
List<Offset> points, {
|
|
required double distance,
|
|
}) {
|
|
var traversed = 0.0;
|
|
|
|
for (var i = 1; i < points.length; i++) {
|
|
final a = points[i - 1];
|
|
final b = points[i];
|
|
final dx = b.dx - a.dx;
|
|
final dy = b.dy - a.dy;
|
|
final segmentLength = math.sqrt(dx * dx + dy * dy);
|
|
if (segmentLength <= 0.001) continue;
|
|
|
|
if (traversed + segmentLength >= distance) {
|
|
final t = (distance - traversed) / segmentLength;
|
|
final center = Offset(a.dx + dx * t, a.dy + dy * t);
|
|
var angle = math.atan2(dy, dx);
|
|
if (angle > math.pi / 2 || angle < -math.pi / 2) {
|
|
angle += math.pi;
|
|
}
|
|
return _RoadLabelPlacement(center: center, angle: angle, pathLength: 0);
|
|
}
|
|
|
|
traversed += segmentLength;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Trim a polyline by [startDist] from the start and [endDist] from the end.
|
|
// Returns null if the remaining length is zero or negative.
|
|
List<Offset>? _trimPolyline(List<Offset> pts, double startDist, double endDist) {
|
|
if (startDist <= 0 && endDist <= 0) return pts;
|
|
|
|
List<Offset> trimStart(List<Offset> p, double dist) {
|
|
var rem = dist;
|
|
for (var i = 1; i < p.length; i++) {
|
|
final dx = p[i].dx - p[i - 1].dx;
|
|
final dy = p[i].dy - p[i - 1].dy;
|
|
final len = math.sqrt(dx * dx + dy * dy);
|
|
if (rem <= len) {
|
|
final t = rem / len;
|
|
final newFirst = Offset(p[i - 1].dx + dx * t, p[i - 1].dy + dy * t);
|
|
return [newFirst, ...p.sublist(i)];
|
|
}
|
|
rem -= len;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
var result = startDist > 0 ? trimStart(pts, startDist) : pts;
|
|
if (result.length < 2) return null;
|
|
|
|
if (endDist > 0) {
|
|
result = trimStart(result.reversed.toList(), endDist).reversed.toList();
|
|
}
|
|
|
|
return result.length >= 2 ? result : null;
|
|
}
|
|
|
|
double _polylineLength(List<Offset> points) {
|
|
var length = 0.0;
|
|
for (var i = 1; i < points.length; i++) {
|
|
final dx = points[i].dx - points[i - 1].dx;
|
|
final dy = points[i].dy - points[i - 1].dy;
|
|
length += math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
return length;
|
|
}
|
|
|
|
bool _isMajorRoadClass(String roadClass) {
|
|
return roadClass.contains('motorway') ||
|
|
roadClass.contains('trunk') ||
|
|
roadClass.contains('primary') ||
|
|
roadClass.contains('secondary');
|
|
}
|
|
|
|
bool _shouldSkipRoadLabelClass(String roadClass) {
|
|
return roadClass.contains('path') ||
|
|
roadClass.contains('footway') ||
|
|
roadClass.contains('steps') ||
|
|
roadClass.contains('cycleway') ||
|
|
roadClass.contains('track') ||
|
|
roadClass.contains('bridleway') ||
|
|
roadClass.contains('rail') ||
|
|
roadClass.contains('subway') ||
|
|
roadClass.contains('metro') ||
|
|
roadClass.contains('train');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
double? _toDouble(Object? value) {
|
|
if (value is num) return value.toDouble();
|
|
if (value is String) return double.tryParse(value.trim());
|
|
return null;
|
|
}
|
|
|
|
// strips non-printable and non-latin characters from label text
|
|
String _sanitizeLabel(String text) =>
|
|
text.replaceAll(RegExp(r'[^\x20-\x7E\u00C0-\u024F\u2018-\u201D]'), '').trim();
|
|
|
|
bool _isTruthy(Object? value) {
|
|
if (value is bool) return value;
|
|
if (value is num) return value != 0;
|
|
if (value is String) {
|
|
final normalized = value.trim().toLowerCase();
|
|
return normalized == 'true' || normalized == '1' || normalized == 'yes';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static const Set<String> _allowedPlaceClasses = <String>{
|
|
'city',
|
|
'town',
|
|
'settlement',
|
|
'settlement_subdivision',
|
|
};
|
|
|
|
static const List<String> _inspectLayerOrder = <String>[
|
|
'road',
|
|
'building',
|
|
'water',
|
|
'park',
|
|
'landuse',
|
|
'road_label',
|
|
'place_label',
|
|
];
|
|
|
|
@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 _RoadStyle {
|
|
const _RoadStyle({
|
|
required this.sortOrder,
|
|
required this.coreWorldUnits,
|
|
required this.underlayWorldUnits,
|
|
required this.fillColor,
|
|
required this.underlayColor,
|
|
required this.outlineOnly,
|
|
});
|
|
|
|
final int sortOrder;
|
|
final double coreWorldUnits;
|
|
final double underlayWorldUnits;
|
|
final Color fillColor;
|
|
final Color underlayColor;
|
|
final bool outlineOnly;
|
|
}
|
|
|
|
class _StyledRoad {
|
|
const _StyledRoad({required this.feature, required this.style});
|
|
|
|
final MvtFeature feature;
|
|
final _RoadStyle style;
|
|
}
|
|
|
|
class MapDebugHit {
|
|
const MapDebugHit({
|
|
required this.layerName,
|
|
required this.geometryType,
|
|
required this.properties,
|
|
required this.tileZ,
|
|
required this.tileX,
|
|
required this.tileY,
|
|
required this.distance,
|
|
});
|
|
|
|
final String layerName;
|
|
final MvtGeometryType geometryType;
|
|
final Map<String, Object?> properties;
|
|
final int tileZ;
|
|
final int tileX;
|
|
final int tileY;
|
|
final double distance;
|
|
}
|
|
|
|
class _RoadLabelPlacement {
|
|
const _RoadLabelPlacement({
|
|
required this.center,
|
|
required this.angle,
|
|
required this.pathLength,
|
|
});
|
|
|
|
final Offset center;
|
|
final double angle;
|
|
final double pathLength;
|
|
}
|
|
|
|
class _RoadLabelCandidate {
|
|
const _RoadLabelCandidate({
|
|
required this.text,
|
|
required this.screenCenter,
|
|
required this.angle,
|
|
required this.pathLength,
|
|
required this.painter,
|
|
});
|
|
|
|
final String text;
|
|
final Offset screenCenter;
|
|
final double angle;
|
|
final double pathLength;
|
|
final TextPainter painter;
|
|
}
|
|
|
|
class _LabelCandidate {
|
|
const _LabelCandidate({
|
|
required this.text,
|
|
required this.anchor,
|
|
required this.rank,
|
|
required this.featureClass,
|
|
});
|
|
|
|
final String text;
|
|
final Offset anchor;
|
|
final int rank;
|
|
final String featureClass;
|
|
}
|
|
|
|
class _DrawCommand {
|
|
const _DrawCommand({required this.z, required this.draw});
|
|
final double z;
|
|
final void Function(Canvas) draw;
|
|
}
|