diff --git a/example/lib/pages/multi_worlds.dart b/example/lib/pages/multi_worlds.dart index 8ef79549e..af3c0af55 100644 --- a/example/lib/pages/multi_worlds.dart +++ b/example/lib/pages/multi_worlds.dart @@ -127,6 +127,87 @@ class _MultiWorldsPageState extends State { ..._customMarkers, ], ), + GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_hitNotifier.value!.hitValues.join(', ')), + duration: const Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: PolygonLayer( + hitNotifier: _hitNotifier, + simplificationTolerance: 0, + useAltRendering: true, + drawLabelsLast: false, + polygons: [ + Polygon( + label: 'Aloha!', + labelStyle: + const TextStyle(color: Colors.green, fontSize: 40), + labelPlacement: + PolygonLabelPlacement.centroidWithMultiWorld, + rotateLabel: false, + points: const [ + LatLng(40, 149), + LatLng(45, 159), + LatLng(50, 169), + LatLng(55, 179), + LatLng(50, -170), + LatLng(45, -160), + LatLng(40, -150), + LatLng(35, -160), + LatLng(30, -170), + LatLng(25, -180), + LatLng(30, 169), + LatLng(35, 159), + ], + holePointsList: const [ + [ + LatLng(45, 175), + LatLng(45, -175), + LatLng(35, -175), + LatLng(35, 175), + ], + ], + color: const Color(0xFFFF0000), + hitValue: 'Red Line, Across the universe...', + ), + ], + ), + ), + GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_hitNotifier.value!.hitValues.join(', ')), + duration: const Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: PolylineLayer( + hitNotifier: _hitNotifier, + simplificationTolerance: 0, + polylines: [ + Polyline( + points: const [ + LatLng(-40, 150), + LatLng(-45, 160), + LatLng(-50, 170), + LatLng(-55, 180), + LatLng(-50, -170), + LatLng(-45, -160), + LatLng(-40, -150), + LatLng(-45, -140), + LatLng(-50, -130), + ], + useStrokeWidthInMeter: true, + strokeWidth: 500000, + color: const Color(0xFF0000FF), + hitValue: 'Blue Line', + ), + ], + ), + ), ], ), ], diff --git a/lib/src/layer/circle_layer/circle_layer.dart b/lib/src/layer/circle_layer/circle_layer.dart index 8090fa582..72fdbdb58 100644 --- a/lib/src/layer/circle_layer/circle_layer.dart +++ b/lib/src/layer/circle_layer/circle_layer.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/shared/feature_layer_utils.dart'; import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; import 'package:latlong2/latlong.dart' hide Path; diff --git a/lib/src/layer/circle_layer/painter.dart b/lib/src/layer/circle_layer/painter.dart index 5600ef370..8fd6b2058 100644 --- a/lib/src/layer/circle_layer/painter.dart +++ b/lib/src/layer/circle_layer/painter.dart @@ -1,79 +1,56 @@ part of 'circle_layer.dart'; -/// The [CustomPainter] used to draw [CircleMarker] for the [CircleLayer]. -base class CirclePainter - extends HitDetectablePainter> { +/// The [CustomPainter] used to draw [CircleMarker]s for the [CircleLayer]. +class CirclePainter extends CustomPainter + with HitDetectablePainter>, FeatureLayerUtils { /// Reference to the list of [CircleMarker]s of the [CircleLayer]. final List> circles; + @override + final MapCamera camera; + + @override + final LayerHitNotifier? hitNotifier; + /// Create a [CirclePainter] instance by providing the required /// reference objects. CirclePainter({ required this.circles, - required super.camera, - required super.hitNotifier, + required this.camera, + required this.hitNotifier, }); - static const _distance = Distance(); - @override bool elementHitTest( CircleMarker element, { required Offset point, required LatLng coordinate, }) { - final worldWidth = _getWorldWidth(); final radius = _getRadiusInPixel(element, withBorder: true); final initialCenter = _getOffset(element.point); - /// Returns null if invisible, true if hit, false if not hit. - bool? checkIfHit(double shift) { + WorldWorkControl checkIfHit(double shift) { final center = initialCenter + Offset(shift, 0); - if (!_isVisible( - screenRect: _screenRect, - center: center, - radiusInPixel: radius, - )) { - return null; + if (!_isVisible(center: center, radiusInPixel: radius)) { + return WorldWorkControl.invisible; } return pow(point.dx - center.dx, 2) + pow(point.dy - center.dy, 2) <= - radius * radius; + radius * radius + ? WorldWorkControl.hit + : WorldWorkControl.visible; } - if (checkIfHit(0) ?? false) { - return true; - } - - // Repeat over all worlds (<--||-->) until culling determines that - // that element is out of view, and therefore all further elements in - // that direction will also be - if (worldWidth == 0) return false; - for (double shift = -worldWidth;; shift -= worldWidth) { - final isHit = checkIfHit(shift); - if (isHit == null) break; - if (isHit) return true; - } - for (double shift = worldWidth;; shift += worldWidth) { - final isHit = checkIfHit(shift); - if (isHit == null) break; - if (isHit) return true; - } - - return false; + return workAcrossWorlds(checkIfHit); } @override Iterable> get elements => circles; - late Rect _screenRect; - @override void paint(Canvas canvas, Size size) { - _screenRect = Offset.zero & size; - canvas.clipRect(_screenRect); - - final worldWidth = _getWorldWidth(); + super.paint(canvas, size); + canvas.clipRect(viewportRect); // Let's calculate all the points grouped by color and radius final points = >>{}; @@ -84,17 +61,15 @@ base class CirclePainter final radiusWithBorder = _getRadiusInPixel(circle, withBorder: true); final initialCenter = _getOffset(circle.point); - bool checkIfVisible(double shift) { - bool result = false; + /// Draws on a "single-world" + WorldWorkControl drawIfVisible(double shift) { + WorldWorkControl result = WorldWorkControl.invisible; final center = initialCenter + Offset(shift, 0); bool isVisible(double radius) { - if (_isVisible( - screenRect: _screenRect, - center: center, - radiusInPixel: radius, - )) { - return result = true; + if (_isVisible(center: center, radiusInPixel: radius)) { + result = WorldWorkControl.visible; + return true; } return false; } @@ -123,23 +98,11 @@ base class CirclePainter .add(center); } } + return result; } - checkIfVisible(0); - - // Repeat over all worlds (<--||-->) until culling determines that - // that element is out of view, and therefore all further elements in - // that direction will also be - if (worldWidth == 0) continue; - for (double shift = -worldWidth;; shift -= worldWidth) { - final isVisible = checkIfVisible(shift); - if (!isVisible) break; - } - for (double shift = worldWidth;; shift += worldWidth) { - final isVisible = checkIfVisible(shift); - if (!isVisible) break; - } + workAcrossWorlds(drawIfVisible); } // Now that all the points are grouped, let's draw them @@ -203,21 +166,14 @@ base class CirclePainter double _getRadiusInPixel(CircleMarker circle, {required bool withBorder}) => (withBorder ? circle.borderStrokeWidth / 2 : 0) + (circle.useRadiusInMeter - ? (_getOffset(circle.point) - - _getOffset( - _distance.offset(circle.point, circle.radius, 180))) - .distance + ? metersToScreenPixels(circle.point, circle.radius) : circle.radius); /// Returns true if a centered circle with this radius is on the screen. bool _isVisible({ - required Rect screenRect, required Offset center, required double radiusInPixel, }) => - screenRect.overlaps( - Rect.fromCircle(center: center, radius: radiusInPixel), - ); - - double _getWorldWidth() => camera.getWorldWidthAtZoom(); + viewportRect + .overlaps(Rect.fromCircle(center: center, radius: radiusInPixel)); } diff --git a/lib/src/layer/polygon_layer/label.dart b/lib/src/layer/polygon_layer/label.dart index 39878c2ab..ee0ba04c7 100644 --- a/lib/src/layer/polygon_layer/label.dart +++ b/lib/src/layer/polygon_layer/label.dart @@ -60,6 +60,8 @@ LatLng _computeLabelPosition( ) { return switch (labelPlacement) { PolygonLabelPlacement.centroid => _computeCentroid(points), + PolygonLabelPlacement.centroidWithMultiWorld => + _computeCentroidWithMultiWorld(points), PolygonLabelPlacement.polylabel => _computePolylabel(points), }; } @@ -72,6 +74,29 @@ LatLng _computeCentroid(List points) { ); } +/// Calculate the centroid of a given list of [LatLng] points with multiple worlds. +LatLng _computeCentroidWithMultiWorld(List points) { + if (points.isEmpty) return _computeCentroid(points); + const halfWorld = 180; + int count = 0; + double sum = 0; + late double lastLng; + for (final LatLng point in points) { + double lng = point.longitude; + count++; + if (count > 1) { + if (lng - lastLng > halfWorld) { + lng -= 2 * halfWorld; + } else if (lng - lastLng < -halfWorld) { + lng += 2 * halfWorld; + } + } + lastLng = lng; + sum += lastLng; + } + return LatLng(points.map((e) => e.latitude).average, sum / count); +} + /// Use the Maxbox Polylabel algorithm to calculate the [LatLng] position for /// a given list of points. LatLng _computePolylabel(List points) { @@ -93,3 +118,20 @@ LatLng _computePolylabel(List points) { labelPosition.point.x.toDouble(), ); } + +/// Defines the algorithm used to calculate the position of the [Polygon] label. +/// +/// > [!IMPORTANT] +/// > If your project allows users to browse across multiple worlds, and your +/// > polygons may be over the anti-meridan boundary, [centroidWithMultiWorld] +/// > must be used - other algorithms will produce unexpected results. +enum PolygonLabelPlacement { + /// Use the centroid of the [Polygon] outline as position for the label. + centroid, + + /// Use the centroid in a multi-world as position for the label. + centroidWithMultiWorld, + + /// Use the Mapbox Polylabel algorithm as position for the label. + polylabel, +} diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index 4f261acfd..bbfbea9a0 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -1,10 +1,10 @@ part of 'polygon_layer.dart'; -/// The [_PolygonPainter] class is used to render [Polygon]s for -/// the [PolygonLayer]. -base class _PolygonPainter - extends HitDetectablePainter> - with HitTestRequiresCameraOrigin { +/// The [CustomPainter] used to draw [Polygon]s for the [PolygonLayer]. +// TODO: We should consider exposing this publicly, as with [CirclePainter] - +// but the projected objects are private at the moment. +class _PolygonPainter extends CustomPainter + with HitDetectablePainter>, FeatureLayerUtils { /// Reference to the list of [_ProjectedPolygon]s final List<_ProjectedPolygon> polygons; @@ -35,15 +35,21 @@ base class _PolygonPainter /// See [PolygonLayer.debugAltRenderer] final bool debugAltRenderer; + @override + final MapCamera camera; + + @override + final LayerHitNotifier? hitNotifier; + /// Create a new [_PolygonPainter] instance. _PolygonPainter({ required this.polygons, required this.triangles, - required super.camera, required this.polygonLabels, required this.drawLabelsLast, required this.debugAltRenderer, - required super.hitNotifier, + required this.camera, + required this.hitNotifier, }) : bounds = camera.visibleBounds; @override @@ -59,39 +65,51 @@ base class _PolygonPainter // continue; // } - final projectedCoords = getOffsetsXY( - camera: camera, - origin: hitTestCameraOrigin, - points: projectedPolygon.points, - ); - if (projectedCoords.first != projectedCoords.last) { - projectedCoords.add(projectedCoords.first); - } + WorldWorkControl checkIfHit(double shift) { + final projectedCoords = getOffsetsXY( + camera: camera, + origin: origin, + points: projectedPolygon.points, + shift: shift, + ); + if (!areOffsetsVisible(projectedCoords)) { + return WorldWorkControl.invisible; + } - final isValidPolygon = projectedCoords.length >= 3; - final isInPolygon = - isValidPolygon && isPointInPolygon(point, projectedCoords); + if (projectedCoords.first != projectedCoords.last) { + projectedCoords.add(projectedCoords.first); + } - final isInHole = projectedPolygon.holePoints.any( - (points) { - final projectedHoleCoords = getOffsetsXY( - camera: camera, - origin: hitTestCameraOrigin, - points: points, - ); - if (projectedHoleCoords.first != projectedHoleCoords.last) { - projectedHoleCoords.add(projectedHoleCoords.first); - } + final isValidPolygon = projectedCoords.length >= 3; + final isInPolygon = + isValidPolygon && isPointInPolygon(point, projectedCoords); - final isValidHolePolygon = projectedHoleCoords.length >= 3; - return isValidHolePolygon && - isPointInPolygon(point, projectedHoleCoords); - }, - ); + final isInHole = projectedPolygon.holePoints.any( + (points) { + final projectedHoleCoords = getOffsetsXY( + camera: camera, + origin: origin, + points: points, + shift: shift, + ); + if (projectedHoleCoords.first != projectedHoleCoords.last) { + projectedHoleCoords.add(projectedHoleCoords.first); + } + + final isValidHolePolygon = projectedHoleCoords.length >= 3; + return isValidHolePolygon && + isPointInPolygon(point, projectedHoleCoords); + }, + ); + + // Second check handles case where polygon outline intersects a hole, + // ensuring that the hit matches with the visual representation + return (isInPolygon && !isInHole) || (!isInPolygon && isInHole) + ? WorldWorkControl.hit + : WorldWorkControl.visible; + } - // Second check handles case where polygon outline intersects a hole, - // ensuring that the hit matches with the visual representation - return (isInPolygon && !isInHole) || (!isInPolygon && isInHole); + return workAcrossWorlds(checkIfHit); } @override @@ -100,6 +118,7 @@ base class _PolygonPainter @override void paint(Canvas canvas, Size size) { const checkOpacity = true; // for debugging purposes only, should be true + super.paint(canvas, size); final trianglePoints = []; @@ -190,8 +209,34 @@ base class _PolygonPainter lastHash = null; } - final origin = - camera.projectAtZoom(camera.center) - camera.size.center(Offset.zero); + /// Draws labels on a "single-world" + WorldWorkControl drawLabelIfVisible( + double shift, + _ProjectedPolygon projectedPolygon, + ) { + final polygon = projectedPolygon.polygon; + final painter = _buildLabelTextPainter( + mapSize: camera.size, + placementPoint: getOffset( + camera, + origin, + polygon.labelPosition, + shift: shift, + ), + bounds: _getBounds(origin, polygon), + textPainter: polygon.textPainter!, + rotationRad: camera.rotationRad, + rotate: polygon.rotateLabel, + padding: 20, + ); + if (painter == null) return WorldWorkControl.invisible; + + // Flush the batch before painting to preserve stacking. + drawPaths(); + + painter(canvas); + return WorldWorkControl.visible; + } // Main loop constructing batched fill and border paths from given polygons. for (int i = 0; i <= polygons.length - 1; i++) { @@ -201,126 +246,138 @@ base class _PolygonPainter final polygonTriangles = triangles?[i]; - final fillOffsets = getOffsetsXY( - camera: camera, - origin: origin, - points: projectedPolygon.points, - holePoints: - polygonTriangles != null ? projectedPolygon.holePoints : null, - ); - - if (debugAltRenderer) { - const offsetsLabelStyle = TextStyle( - color: Color(0xFF000000), - fontSize: 16, + /// Draws on a "single-world" + WorldWorkControl drawIfVisible(double shift) { + final fillOffsets = getOffsetsXY( + camera: camera, + origin: origin, + points: projectedPolygon.points, + holePoints: + polygonTriangles != null ? projectedPolygon.holePoints : null, + shift: shift, ); + if (!areOffsetsVisible(fillOffsets)) return WorldWorkControl.invisible; - for (int i = 0; i < fillOffsets.length; i++) { - TextPainter( - text: TextSpan( - text: i.toString(), - style: offsetsLabelStyle, - ), - textDirection: TextDirection.ltr, - ) - ..layout(maxWidth: 100) - ..paint(canvas, fillOffsets[i]); - } - } + if (debugAltRenderer) { + const offsetsLabelStyle = TextStyle( + color: Color(0xFF000000), + fontSize: 16, + ); - // The hash is based on the polygons visual properties. If the hash from - // the current and the previous polygon no longer match, we need to flush - // the batch previous polygons. - // We also need to flush if the opacity is not 1 or 0, so that they get - // mixed properly. Otherwise, holes get cut, or colors aren't mixed, - // depending on the holes handler. - final hash = polygon.renderHashCode; - final opacity = polygon.color?.a ?? 0; - if (lastHash != hash || (checkOpacity && opacity > 0 && opacity < 1)) { - drawPaths(); - } - lastPolygon = polygon; - lastHash = hash; - - // First add fills and borders to path. - if (polygon.color != null) { - if (polygonTriangles != null) { - final len = polygonTriangles.length; - for (int i = 0; i < len; ++i) { - trianglePoints.add(fillOffsets[polygonTriangles[i]]); + for (int i = 0; i < fillOffsets.length; i++) { + TextPainter( + text: TextSpan( + text: i.toString(), + style: offsetsLabelStyle, + ), + textDirection: TextDirection.ltr, + ) + ..layout(maxWidth: 100) + ..paint(canvas, fillOffsets[i]); } - } else { - filledPath.addPolygon(fillOffsets, true); } - } - if (polygon.borderStrokeWidth > 0.0) { - _addBorderToPath( - borderPath, - polygon, - getOffsetsXY( - camera: camera, - origin: origin, - points: projectedPolygon.points, - ), - size, - canvas, - _getBorderPaint(polygon), - polygon.borderStrokeWidth, - ); - } + // The hash is based on the polygons visual properties. If the hash from + // the current and the previous polygon no longer match, we need to flush + // the batch previous polygons. + // We also need to flush if the opacity is not 1 or 0, so that they get + // mixed properly. Otherwise, holes get cut, or colors aren't mixed, + // depending on the holes handler. + final hash = polygon.renderHashCode; + final opacity = polygon.color?.a ?? 0; + if (lastHash != hash || (checkOpacity && opacity > 0 && opacity < 1)) { + drawPaths(); + } + lastPolygon = polygon; + lastHash = hash; + + // First add fills and borders to path. + if (polygon.color != null) { + if (polygonTriangles != null) { + final len = polygonTriangles.length; + for (int i = 0; i < len; ++i) { + trianglePoints.add(fillOffsets[polygonTriangles[i]]); + } + } else { + filledPath.addPolygon(fillOffsets, true); + } + } - // Afterwards deal with more complicated holes. - // Improper handling of opacity and fill methods may result in normal - // polygons cutting holes into other polygons, when they should be mixing: - // https://github.com/fleaflet/flutter_map/issues/1898. - final holePointsList = polygon.holePointsList; - if (holePointsList != null && holePointsList.isNotEmpty) { - // See `Path.combine` comments below - // Avoids failing to cut holes if the winding directions of the holes - // and the normal points are the same - filledPath.fillType = PathFillType.evenOdd; - - for (final singleHolePoints in projectedPolygon.holePoints) { - final holeOffsets = getOffsetsXY( - camera: camera, - origin: origin, - points: singleHolePoints, + if (polygon.borderStrokeWidth > 0.0) { + _addBorderToPath( + borderPath, + polygon, + getOffsetsXY( + camera: camera, + origin: origin, + points: projectedPolygon.points, + shift: shift, + ), + size, + canvas, + _getBorderPaint(polygon), + polygon.borderStrokeWidth, ); - filledPath.addPolygon(holeOffsets, true); - - // TODO: Potentially more efficient and may change the need to do - // opacity checking - needs testing. However, - // https://github.com/flutter/flutter/issues/44572 prevents this. - // Also need to verify if `xor` or `difference` is preferred. - /*filledPath = Path.combine( - PathOperation.xor, - filledPath, - Path()..addPolygon(holeOffsets, true), - );*/ } - if (!polygon.disableHolesBorder && polygon.borderStrokeWidth > 0.0) { - final borderPaint = _getBorderPaint(polygon); + // Afterwards deal with more complicated holes. + // Improper handling of opacity and fill methods may result in normal + // polygons cutting holes into other polygons, when they should be mixing: + // https://github.com/fleaflet/flutter_map/issues/1898. + final holePointsList = polygon.holePointsList; + if (holePointsList != null && holePointsList.isNotEmpty) { + // See `Path.combine` comments below + // Avoids failing to cut holes if the winding directions of the holes + // and the normal points are the same + filledPath.fillType = PathFillType.evenOdd; + for (final singleHolePoints in projectedPolygon.holePoints) { final holeOffsets = getOffsetsXY( camera: camera, origin: origin, points: singleHolePoints, + shift: shift, ); - _addBorderToPath( - borderPath, - polygon, - holeOffsets, - size, - canvas, - borderPaint, - polygon.borderStrokeWidth, - ); + filledPath.addPolygon(holeOffsets, true); + + // TODO: Potentially more efficient and may change the need to do + // opacity checking - needs testing. Also need to verify if `xor` or + // `difference` is preferred. + // No longer blocked by lack of HTML support in Flutter 3.29 + /*filledPath = Path.combine( + PathOperation.xor, + filledPath, + Path()..addPolygon(holeOffsets, true), + );*/ + } + + if (!polygon.disableHolesBorder && polygon.borderStrokeWidth > 0.0) { + final borderPaint = _getBorderPaint(polygon); + for (final singleHolePoints in projectedPolygon.holePoints) { + final holeOffsets = getOffsetsXY( + camera: camera, + origin: origin, + points: singleHolePoints, + shift: shift, + ); + _addBorderToPath( + borderPath, + polygon, + holeOffsets, + size, + canvas, + borderPaint, + polygon.borderStrokeWidth, + ); + } } } + + return WorldWorkControl.visible; } + workAcrossWorlds(drawIfVisible); + if (!drawLabelsLast && polygonLabels && polygon.textPainter != null) { // Labels are expensive because: // * they themselves cannot easily be pulled into our batched path @@ -331,22 +388,9 @@ base class _PolygonPainter // The painter will be null if the layOuting algorithm determined that // there isn't enough space. - final painter = _buildLabelTextPainter( - mapSize: camera.size, - placementPoint: getOffset(camera, origin, polygon.labelPosition), - bounds: _getBounds(origin, polygon), - textPainter: polygon.textPainter!, - rotationRad: camera.rotationRad, - rotate: polygon.rotateLabel, - padding: 20, + workAcrossWorlds( + (double shift) => drawLabelIfVisible(shift, projectedPolygon), ); - - if (painter != null) { - // Flush the batch before painting to preserve stacking. - drawPaths(); - - painter(canvas); - } } } @@ -357,21 +401,12 @@ base class _PolygonPainter if (projectedPolygon.points.isEmpty) { continue; } - final polygon = projectedPolygon.polygon; - final textPainter = polygon.textPainter; - if (textPainter != null) { - final painter = _buildLabelTextPainter( - mapSize: camera.size, - placementPoint: getOffset(camera, origin, polygon.labelPosition), - bounds: _getBounds(origin, polygon), - textPainter: textPainter, - rotationRad: camera.rotationRad, - rotate: polygon.rotateLabel, - padding: 20, - ); - - painter?.call(canvas); + if (projectedPolygon.polygon.textPainter == null) { + continue; } + workAcrossWorlds( + (double shift) => drawLabelIfVisible(shift, projectedPolygon), + ); } } } diff --git a/lib/src/layer/polygon_layer/polygon.dart b/lib/src/layer/polygon_layer/polygon.dart index 54cf5b15b..ecedd7ac4 100644 --- a/lib/src/layer/polygon_layer/polygon.dart +++ b/lib/src/layer/polygon_layer/polygon.dart @@ -1,16 +1,7 @@ part of 'polygon_layer.dart'; -/// Defines the algorithm used to calculate the position of the [Polygon] label. -enum PolygonLabelPlacement { - /// Use the centroid of the [Polygon] outline as position for the label. - centroid, - - /// Use the Mapbox Polylabel algorithm as position for the label. - polylabel, -} - /// [Polygon] class, to be used for the [PolygonLayer]. -class Polygon { +class Polygon with HitDetectableElement { /// The points for the outline of the [Polygon]. final List points; @@ -66,6 +57,12 @@ class Polygon { /// [PolygonLabelPlacement.polylabel] can be expensive for some polygons. If /// there is a large lag spike, try using [PolygonLabelPlacement.centroid]. /// + /// > [!IMPORTANT] + /// > If your project allows users to browse across multiple worlds, and your + /// > polygons may be over the anti-meridan boundary, + /// > [PolygonLabelPlacement.centroidWithMultiWorld] must be used - other + /// > algorithms will produce unexpected results. + /// /// Labels will not be drawn if there is not enough space. final PolygonLabelPlacement labelPlacement; @@ -73,7 +70,7 @@ class Polygon { /// it remains upright final bool rotateLabel; - /// {@macro fm.hde.hitValue} + @override final R? hitValue; /// Designates whether the given polygon points follow a clock or diff --git a/lib/src/layer/polygon_layer/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index 34b289029..6cf7172b5 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -6,6 +6,7 @@ import 'package:dart_earcut/dart_earcut.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/shared/feature_layer_utils.dart'; import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; import 'package:flutter_map/src/layer/shared/layer_projection_simplification/state.dart'; import 'package:flutter_map/src/layer/shared/layer_projection_simplification/widget.dart'; @@ -120,7 +121,7 @@ class _PolygonLayerState extends State> ); @override - Iterable> getElements(PolygonLayer widget) => widget.polygons; + List> get elements => widget.polygons; @override Widget build(BuildContext context) { diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index c78023bb2..661b5afe9 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -1,20 +1,25 @@ part of 'polyline_layer.dart'; -/// [CustomPainter] for [Polyline]s. -base class _PolylinePainter - extends HitDetectablePainter> - with HitTestRequiresCameraOrigin { - /// Reference to the list of [Polyline]s. +/// The [CustomPainter] used to draw [Polyline]s for the [PolylineLayer]. +// TODO: We should consider exposing this publicly, as with [CirclePainter] - +// but the projected objects are private at the moment. +class _PolylinePainter extends CustomPainter + with HitDetectablePainter>, FeatureLayerUtils { final List<_ProjectedPolyline> polylines; - final double minimumHitbox; + @override + final MapCamera camera; + + @override + final LayerHitNotifier? hitNotifier; + /// Create a new [_PolylinePainter] instance _PolylinePainter({ required this.polylines, required this.minimumHitbox, - required super.camera, - required super.hitNotifier, + required this.camera, + required this.hitNotifier, }); @override @@ -34,35 +39,42 @@ base class _PolylinePainter // continue; // } - final offsets = getOffsetsXY( - camera: camera, - origin: hitTestCameraOrigin, - points: projectedPolyline.points, - ); - final strokeWidth = polyline.useStrokeWidthInMeter - ? _metersToStrokeWidth( - hitTestCameraOrigin, - _unproject(projectedPolyline.points.first), - offsets.first, - polyline.strokeWidth, - ) - : polyline.strokeWidth; - final hittableDistance = math.max( - strokeWidth / 2 + polyline.borderStrokeWidth / 2, - minimumHitbox, - ); + WorldWorkControl checkIfHit(double shift) { + final offsets = getOffsetsXY( + camera: camera, + origin: origin, + points: projectedPolyline.points, + shift: shift, + ); + if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible; + + final strokeWidth = polyline.useStrokeWidthInMeter + ? metersToScreenPixels( + projectedPolyline.polyline.points.first, + polyline.strokeWidth, + ) + : polyline.strokeWidth; + final hittableDistance = math.max( + strokeWidth / 2 + polyline.borderStrokeWidth / 2, + minimumHitbox, + ); + + for (int i = 0; i < offsets.length - 1; i++) { + final o1 = offsets[i]; + final o2 = offsets[i + 1]; - for (int i = 0; i < offsets.length - 1; i++) { - final o1 = offsets[i]; - final o2 = offsets[i + 1]; + final distanceSq = + getSqSegDist(point.dx, point.dy, o1.dx, o1.dy, o2.dx, o2.dy); - final distanceSq = - getSqSegDist(point.dx, point.dy, o1.dx, o1.dy, o2.dx, o2.dy); + if (distanceSq <= hittableDistance * hittableDistance) { + return WorldWorkControl.hit; + } + } - if (distanceSq <= hittableDistance * hittableDistance) return true; + return WorldWorkControl.visible; } - return false; + return workAcrossWorlds(checkIfHit); } @override @@ -70,7 +82,7 @@ base class _PolylinePainter @override void paint(Canvas canvas, Size size) { - final rect = Offset.zero & size; + super.paint(canvas, size); var path = ui.Path(); var borderPath = ui.Path(); @@ -86,7 +98,7 @@ base class _PolylinePainter final hasBorder = borderPaint != null && filterPaint != null; if (hasBorder) { if (needsLayerSaving) { - canvas.saveLayer(rect, Paint()); + canvas.saveLayer(viewportRect, Paint()); } canvas.drawPath(borderPath, borderPaint!); @@ -107,142 +119,147 @@ base class _PolylinePainter paint = Paint(); } - final origin = - camera.projectAtZoom(camera.center) - camera.size.center(Offset.zero); - for (final projectedPolyline in polylines) { final polyline = projectedPolyline.polyline; - final offsets = getOffsetsXY( - camera: camera, - origin: origin, - points: projectedPolyline.points, - ); - if (offsets.isEmpty) { + if (polyline.points.isEmpty) { continue; } - final hash = polyline.renderHashCode; - if (needsLayerSaving || (lastHash != null && lastHash != hash)) { - drawPaths(); - } - lastHash = hash; - needsLayerSaving = polyline.color.a < 1 || - (polyline.gradientColors?.any((c) => c.a < 1) ?? false); - - // strokeWidth, or strokeWidth + borderWidth if relevant. - late double largestStrokeWidth; - - late final double strokeWidth; - if (polyline.useStrokeWidthInMeter) { - strokeWidth = _metersToStrokeWidth( - origin, - _unproject(projectedPolyline.points.first), - offsets.first, - polyline.strokeWidth, + /// Draws on a "single-world" + WorldWorkControl drawIfVisible(double shift) { + final offsets = getOffsetsXY( + camera: camera, + origin: origin, + points: projectedPolyline.points, + shift: shift, ); - } else { - strokeWidth = polyline.strokeWidth; - } - largestStrokeWidth = strokeWidth; - - final isSolid = polyline.pattern == const StrokePattern.solid(); - final isDashed = polyline.pattern.segments != null; - final isDotted = polyline.pattern.spacingFactor != null; - - paint = Paint() - ..strokeWidth = strokeWidth - ..strokeCap = polyline.strokeCap - ..strokeJoin = polyline.strokeJoin - ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke - ..blendMode = BlendMode.srcOver; - - if (polyline.gradientColors == null) { - paint.color = polyline.color; - } else { - polyline.gradientColors!.isNotEmpty - ? paint.shader = _paintGradient(polyline, offsets) - : paint.color = polyline.color; - } + if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible; - if (polyline.borderStrokeWidth > 0.0) { - // Outlined lines are drawn by drawing a thicker path underneath, then - // stenciling the middle (in case the line fill is transparent), and - // finally drawing the line fill. - largestStrokeWidth = strokeWidth + polyline.borderStrokeWidth; - borderPaint = Paint() - ..color = polyline.borderColor - ..strokeWidth = strokeWidth + polyline.borderStrokeWidth - ..strokeCap = polyline.strokeCap - ..strokeJoin = polyline.strokeJoin - ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke - ..blendMode = BlendMode.srcOver; + final hash = polyline.renderHashCode; + if (needsLayerSaving || (lastHash != null && lastHash != hash)) { + drawPaths(); + } + lastHash = hash; + needsLayerSaving = polyline.color.a < 1 || + (polyline.gradientColors?.any((c) => c.a < 1) ?? false); + + // strokeWidth, or strokeWidth + borderWidth if relevant. + late double largestStrokeWidth; - filterPaint = Paint() - ..color = polyline.borderColor.withAlpha(255) + late final double strokeWidth; + if (polyline.useStrokeWidthInMeter) { + strokeWidth = metersToScreenPixels( + projectedPolyline.polyline.points.first, + polyline.strokeWidth, + ); + } else { + strokeWidth = polyline.strokeWidth; + } + largestStrokeWidth = strokeWidth; + + final isSolid = polyline.pattern == const StrokePattern.solid(); + final isDashed = polyline.pattern.segments != null; + final isDotted = polyline.pattern.spacingFactor != null; + + paint = Paint() ..strokeWidth = strokeWidth ..strokeCap = polyline.strokeCap ..strokeJoin = polyline.strokeJoin ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke - ..blendMode = BlendMode.dstOut; - } + ..blendMode = BlendMode.srcOver; - final radius = paint.strokeWidth / 2; - final borderRadius = (borderPaint?.strokeWidth ?? 0) / 2; + if (polyline.gradientColors == null) { + paint.color = polyline.color; + } else { + polyline.gradientColors!.isNotEmpty + ? paint.shader = _paintGradient(polyline, offsets) + : paint.color = polyline.color; + } - final List paths = []; - if (borderPaint != null && filterPaint != null) { - paths.add(borderPath); - paths.add(filterPath); - } - paths.add(path); - if (isSolid) { - final SolidPixelHiker hiker = SolidPixelHiker( - offsets: offsets, - closePath: false, - canvasSize: size, - strokeWidth: largestStrokeWidth, - ); - hiker.addAllVisibleSegments(paths); - } else if (isDotted) { - final DottedPixelHiker hiker = DottedPixelHiker( - offsets: offsets, - stepLength: strokeWidth * polyline.pattern.spacingFactor!, - patternFit: polyline.pattern.patternFit!, - closePath: false, - canvasSize: size, - strokeWidth: largestStrokeWidth, - ); + if (polyline.borderStrokeWidth > 0.0) { + // Outlined lines are drawn by drawing a thicker path underneath, then + // stenciling the middle (in case the line fill is transparent), and + // finally drawing the line fill. + largestStrokeWidth = strokeWidth + polyline.borderStrokeWidth; + borderPaint = Paint() + ..color = polyline.borderColor + ..strokeWidth = strokeWidth + polyline.borderStrokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke + ..blendMode = BlendMode.srcOver; + + filterPaint = Paint() + ..color = polyline.borderColor.withAlpha(255) + ..strokeWidth = strokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = isDotted ? PaintingStyle.fill : PaintingStyle.stroke + ..blendMode = BlendMode.dstOut; + } + + final radius = paint.strokeWidth / 2; + final borderRadius = (borderPaint?.strokeWidth ?? 0) / 2; - final List radii = []; + final List paths = []; if (borderPaint != null && filterPaint != null) { - radii.add(borderRadius); - radii.add(radius); + paths.add(borderPath); + paths.add(filterPath); } - radii.add(radius); - - for (final visibleDot in hiker.getAllVisibleDots()) { - for (int i = 0; i < paths.length; i++) { - paths[i] - .addOval(Rect.fromCircle(center: visibleDot, radius: radii[i])); + paths.add(path); + if (isSolid) { + final SolidPixelHiker hiker = SolidPixelHiker( + offsets: offsets, + closePath: false, + canvasSize: size, + strokeWidth: largestStrokeWidth, + ); + hiker.addAllVisibleSegments(paths); + } else if (isDotted) { + final DottedPixelHiker hiker = DottedPixelHiker( + offsets: offsets, + stepLength: strokeWidth * polyline.pattern.spacingFactor!, + patternFit: polyline.pattern.patternFit!, + closePath: false, + canvasSize: size, + strokeWidth: largestStrokeWidth, + ); + + final List radii = []; + if (borderPaint != null && filterPaint != null) { + radii.add(borderRadius); + radii.add(radius); } - } - } else if (isDashed) { - final DashedPixelHiker hiker = DashedPixelHiker( - offsets: offsets, - segmentValues: polyline.pattern.segments!, - patternFit: polyline.pattern.patternFit!, - closePath: false, - canvasSize: size, - strokeWidth: largestStrokeWidth, - ); + radii.add(radius); - for (final visibleSegment in hiker.getAllVisibleSegments()) { - for (final path in paths) { - path.moveTo(visibleSegment.begin.dx, visibleSegment.begin.dy); - path.lineTo(visibleSegment.end.dx, visibleSegment.end.dy); + for (final visibleDot in hiker.getAllVisibleDots()) { + for (int i = 0; i < paths.length; i++) { + paths[i].addOval( + Rect.fromCircle(center: visibleDot, radius: radii[i])); + } + } + } else if (isDashed) { + final DashedPixelHiker hiker = DashedPixelHiker( + offsets: offsets, + segmentValues: polyline.pattern.segments!, + patternFit: polyline.pattern.patternFit!, + closePath: false, + canvasSize: size, + strokeWidth: largestStrokeWidth, + ); + + for (final visibleSegment in hiker.getAllVisibleSegments()) { + for (final path in paths) { + path.moveTo(visibleSegment.begin.dx, visibleSegment.begin.dy); + path.lineTo(visibleSegment.end.dx, visibleSegment.end.dy); + } } } + + return WorldWorkControl.visible; } + + workAcrossWorlds(drawIfVisible); } drawPaths(); @@ -267,26 +284,6 @@ base class _PolylinePainter .toList(); } - double _metersToStrokeWidth( - Offset origin, - LatLng p0, - Offset o0, - double strokeWidthInMeters, - ) { - final r = _distance.offset(p0, strokeWidthInMeters, 180); - var delta = o0 - getOffset(camera, origin, r); - final worldSize = camera.crs.scale(camera.zoom); - if (delta.dx < 0) { - delta = delta.translate(worldSize, 0); - } else if (delta.dx >= worldSize) { - delta = delta.translate(-worldSize, 0); - } - return delta.distance; - } - - LatLng _unproject(Offset p0) => - camera.crs.projection.unprojectXY(p0.dx, p0.dy); - @override bool shouldRepaint(_PolylinePainter oldDelegate) => polylines != oldDelegate.polylines || @@ -294,5 +291,3 @@ base class _PolylinePainter hitNotifier != oldDelegate.hitNotifier || minimumHitbox != oldDelegate.minimumHitbox; } - -const _distance = Distance(); diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart index 0ab84ccfc..b5712da66 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -1,7 +1,7 @@ part of 'polyline_layer.dart'; /// [Polyline] (aka. LineString) class, to be used for the [PolylineLayer]. -class Polyline { +class Polyline with HitDetectableElement { /// The list of coordinates for the [Polyline]. final List points; @@ -39,7 +39,7 @@ class Polyline { /// Set to true if the width of the stroke should have meters as unit. final bool useStrokeWidthInMeter; - /// {@macro fm.hde.hitValue} + @override final R? hitValue; LatLngBounds? _boundingBox; diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index f905afca1..f46b981b4 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/shared/feature_layer_utils.dart'; import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; import 'package:flutter_map/src/layer/shared/layer_projection_simplification/state.dart'; import 'package:flutter_map/src/layer/shared/layer_projection_simplification/widget.dart'; @@ -86,8 +87,7 @@ class _PolylineLayerState extends State> ); @override - Iterable> getElements(PolylineLayer widget) => - widget.polylines; + List> get elements => widget.polylines; @override Widget build(BuildContext context) { diff --git a/lib/src/layer/shared/feature_layer_utils.dart b/lib/src/layer/shared/feature_layer_utils.dart new file mode 100644 index 000000000..789e9969f --- /dev/null +++ b/lib/src/layer/shared/feature_layer_utils.dart @@ -0,0 +1,121 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; + +/// Provides utilities for 'feature layers' implemented with canvas painters and +/// hit testers, especially those which have multi-world support +@internal +mixin FeatureLayerUtils on CustomPainter { + abstract final MapCamera camera; + static const _distance = Distance(); + + /// The rectangle of the canvas on its last paint + /// + /// Must not be retrieved before [paint] has been called. + Rect get viewportRect => _viewportRect; + late Rect _viewportRect; + + @mustCallSuper + @mustBeOverridden + @override + void paint(Canvas canvas, Size size) { + _viewportRect = Offset.zero & size; + } + + /// Determine whether the specified offsets are visible within the viewport + /// + /// Always returns `false` if the specified list is empty. + bool areOffsetsVisible(Iterable offsets) { + if (offsets.isEmpty) { + return false; + } + double minX; + double maxX; + double minY; + double maxY; + minX = maxX = offsets.first.dx; + minY = maxY = offsets.first.dy; + for (final Offset offset in offsets) { + if (viewportRect.contains(offset)) return true; + if (minX > offset.dx) minX = offset.dx; + if (minY > offset.dy) minY = offset.dy; + if (maxX < offset.dx) maxX = offset.dx; + if (maxY < offset.dy) maxY = offset.dy; + } + return viewportRect.overlaps(Rect.fromLTRB(minX, minY, maxX, maxY)); + } + + /// Perform the callback in all world copies (until stopped) + /// + /// See [WorldWorkControl] for information about the callback return types. + /// Returns `true` if any result is [WorldWorkControl.hit]. + /// + /// Internally, the worker is invoked in the 'negative' worlds (worlds to the + /// left of the 'primary' world) until repetition is stopped, then in the + /// 'positive' worlds: <--||-->. + bool workAcrossWorlds(WorldWorkControl Function(double shift) work) { + if (work(0) == WorldWorkControl.hit) return true; + + if (worldWidth == 0) return false; + + negativeWorldsLoop: + for (double shift = -worldWidth;; shift -= worldWidth) { + switch (work(shift)) { + case WorldWorkControl.hit: + return true; + case WorldWorkControl.invisible: + break negativeWorldsLoop; + case WorldWorkControl.visible: + } + } + + for (double shift = worldWidth;; shift += worldWidth) { + switch (work(shift)) { + case WorldWorkControl.hit: + return true; + case WorldWorkControl.invisible: + return false; + case WorldWorkControl.visible: + } + } + } + + /// Returns the origin of the camera. + Offset get origin => + camera.projectAtZoom(camera.center) - camera.size.center(Offset.zero); + + /// Returns the world size in pixels. + /// + /// Equivalent to [MapCamera.getWorldWidthAtZoom]. + double get worldWidth => camera.getWorldWidthAtZoom(); + + /// Converts a distance in meters to the equivalent distance in screen pixels, + /// at the geographic coordinates specified. + double metersToScreenPixels(LatLng point, double meters) => + (camera.getOffsetFromOrigin(point) - + camera.getOffsetFromOrigin(_distance.offset(point, meters, 180))) + .distance; +} + +/// Return type for the callback argument of +/// [FeatureLayerUtils.workAcrossWorlds], which indicates how to control the +/// iteration across worlds & how to return from the method +/// +/// The callback must return [hit] or [invisible] in some case to prevent an +/// infinite loop. +@internal +enum WorldWorkControl { + /// Immediately stop iteration across all further worlds, and return `true` + /// + /// This is useful for hit testing for efficiency purposes, where hitting any + /// one element is enough to determine a hit testing result. + hit, + + /// Keep iterating across worlds in the current direction + visible, + + /// Stop iterating across worlds in the current direction; if both directions + /// have been completed without a [hit], returns `false` + invisible, +} diff --git a/lib/src/layer/shared/layer_interactivity/internal_hit_detectable.dart b/lib/src/layer/shared/layer_interactivity/internal_hit_detectable.dart index b59135d7a..85e2e89c9 100644 --- a/lib/src/layer/shared/layer_interactivity/internal_hit_detectable.dart +++ b/lib/src/layer/shared/layer_interactivity/internal_hit_detectable.dart @@ -1,11 +1,11 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/shared/feature_layer_utils.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; @internal mixin HitDetectableElement { - /// {@template fm.hde.hitValue} /// Value to notify layer's `hitNotifier` with (such as /// [PolygonLayer.hitNotifier]) /// @@ -14,17 +14,14 @@ mixin HitDetectableElement { /// /// The object should have a valid & useful equality, as it may be used /// by FM internals. - /// {@endtemplate} R? get hitValue; } @internal -abstract base class HitDetectablePainter> extends CustomPainter { - HitDetectablePainter({required this.camera, required this.hitNotifier}); - - final MapCamera camera; - final LayerHitNotifier? hitNotifier; +mixin HitDetectablePainter> + on CustomPainter { + abstract final MapCamera camera; + abstract final LayerHitNotifier? hitNotifier; /// Elements that should be possibly be hit tested by [elementHitTest] /// ([hitTest]) @@ -45,9 +42,8 @@ abstract base class HitDetectablePainter> on HitDetectablePainter { - /// Calculated [MapCamera] origin, using the following formula: - /// - /// ```dart - /// camera.project(camera.center) - camera.size.center(Offset.zero) - /// ``` - /// - /// Only initialised after [hitTest] is invoked. Recalculated every time - /// [hitTest] is invoked. - late Offset hitTestCameraOrigin; - - @override - bool? hitTest(Offset position) { - hitTestCameraOrigin = - camera.projectAtZoom(camera.center) - camera.size.center(Offset.zero); - return super.hitTest(position); - } -} diff --git a/lib/src/layer/shared/layer_projection_simplification/state.dart b/lib/src/layer/shared/layer_projection_simplification/state.dart index b65cb0cd6..524815ce2 100644 --- a/lib/src/layer/shared/layer_projection_simplification/state.dart +++ b/lib/src/layer/shared/layer_projection_simplification/state.dart @@ -34,7 +34,7 @@ mixin ProjectionSimplificationManagement< /// Return the individual elements given the /// [ProjectionSimplificationManagementSupportedWidget] - Iterable getElements(W widget); + List get elements; /// An iterable of simplified [ProjectedElement]s, which is always ready /// after the [build] method has been invoked, and should then be used in the @@ -64,8 +64,6 @@ mixin ProjectionSimplificationManagement< Widget build(BuildContext context) { final camera = MapCamera.of(context); - final elements = getElements(widget); - final projected = _cachedProjectedElements ??= List.generate( elements.length, (i) => projectElement( diff --git a/lib/src/misc/offsets.dart b/lib/src/misc/offsets.dart index 372891b6c..fed76d672 100644 --- a/lib/src/misc/offsets.dart +++ b/lib/src/misc/offsets.dart @@ -5,13 +5,19 @@ import 'package:flutter_map/src/geo/crs.dart'; import 'package:latlong2/latlong.dart'; /// Calculate the [Offset] for the [LatLng] point. -Offset getOffset(MapCamera camera, Offset origin, LatLng point) { +Offset getOffset( + MapCamera camera, + Offset origin, + LatLng point, { + double shift = 0, +}) { final crs = camera.crs; final zoomScale = crs.scale(camera.zoom); final (x, y) = crs.latLngToXY(point, zoomScale); - return Offset(x - origin.dx, y - origin.dy); + return Offset(x - origin.dx + shift, y - origin.dy); } +// TODO not sure if still relevant /// Calculate the [Offset]s for the list of [LatLng] points. List getOffsets(MapCamera camera, Offset origin, List points) { // Critically create as little garbage as possible. This is called on every frame. @@ -46,6 +52,7 @@ List getOffsetsXY({ required Offset origin, required List points, List>? holePoints, + double shift = 0, }) { // Critically create as little garbage as possible. This is called on every frame. final crs = camera.crs; @@ -97,7 +104,7 @@ List getOffsetsXY({ for (int i = 0; i < len; ++i) { final p = realPoints.elementAt(i); final (x, y) = crs.transform(p.dx + addedWorldWidth, p.dy, zoomScale); - v[i] = Offset(x + ox, y + oy); + v[i] = Offset(x + ox + shift, y + oy); } return v; } @@ -106,7 +113,7 @@ List getOffsetsXY({ for (int i = 0; i < len; ++i) { final p = realPoints.elementAt(i); final (x, y) = crs.transform(p.dx + addedWorldWidth, p.dy, zoomScale); - v[i] = Offset(x + ox, y + oy); + v[i] = Offset(x + ox + shift, y + oy); } return v; }