From 53af5baa3ab885115bcfa24191cd9f9651177f05 Mon Sep 17 00:00:00 2001 From: Joscha <34318751+josxha@users.noreply.github.com> Date: Sun, 10 Dec 2023 23:30:08 +0100 Subject: [PATCH 1/5] chore: disable CodeCov PR comment & checks (#1773) --- codecov.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/codecov.yml b/codecov.yml index 913a895a9..8a7e33b11 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,11 +1,14 @@ codecov: require_ci_to_pass: true + status: + project: + default: + target: 0% + threshold: 0% ignore: - "**/*.g.dart" - "example" - "benchmark" -comment: - # this posts no PR comment if there are no coverage changes - require_changes: true \ No newline at end of file +comment: false \ No newline at end of file From 2edf1682264fd6a5b1ffb786e0de10e5f0bf4807 Mon Sep 17 00:00:00 2001 From: Joscha <34318751+josxha@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:58:22 +0100 Subject: [PATCH 2/5] chore: add dependabot config (#1774) --- .github/dependabot.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..06abe053f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-minor" + - "version-update:semver-patch" \ No newline at end of file From 757cba8d3a4ad59b9436a6684e6f4df28c6c532e Mon Sep 17 00:00:00 2001 From: Joscha <34318751+josxha@users.noreply.github.com> Date: Tue, 12 Dec 2023 19:12:30 +0100 Subject: [PATCH 3/5] chore: disable CodeCov coverage warnings in PR diff (#1775) --- codecov.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/codecov.yml b/codecov.yml index 8a7e33b11..0ed22dfae 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,14 +1,18 @@ codecov: require_ci_to_pass: true + +coverage: status: project: default: + # the required coverage value target: 0% - threshold: 0% + # the leniency in hitting the target + threshold: 100% + # completely disable coverage warnings and the status check for changes + patch: off ignore: - "**/*.g.dart" - "example" - - "benchmark" - -comment: false \ No newline at end of file + - "benchmark" \ No newline at end of file From e7170da9e7d0fb2e6552ef7c8fb73bf80025c930 Mon Sep 17 00:00:00 2001 From: Joscha <34318751+josxha@users.noreply.github.com> Date: Tue, 12 Dec 2023 20:28:29 +0100 Subject: [PATCH 4/5] feat: add animations to the controller (#1757) * add `TickerProvider` to controller * use central `AnimationController` * implement `moveAndRotateAnimated()` * add fling and stop function * register listeners only a single time * clean up * dont rotate if rotation is 0 * clean up * fix reset animation * clean up * add `rotateAnimatedRaw`, `isAnimating` --------- Co-authored-by: Luka S --- .../map/controller/map_controller_impl.dart | 279 ++++++++++++++++-- lib/src/map/widget.dart | 5 +- 2 files changed, 261 insertions(+), 23 deletions(-) diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 5117be15d..a618ae67a 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -17,13 +17,25 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> late final MapInteractiveViewerState _interactiveViewerState; - MapControllerImpl([MapOptions? options]) + Animation? _moveAnimation; + Animation? _zoomAnimation; + Animation? _rotationAnimation; + Animation? _flingAnimation; + late bool _animationHasGesture; + late Offset _animationOffset; + late Point _flingMapCenterStartPoint; + + MapControllerImpl({MapOptions? options, TickerProvider? vsync}) : super( _MapControllerState( options: options, camera: options == null ? null : MapCamera.initialCamera(options), + animationController: + vsync == null ? null : AnimationController(vsync: vsync), ), - ); + ) { + value.animationController?.addListener(_handleAnimation); + } /// Link the viewer state with the controller. This should be done once when /// the FlutterMapInteractiveViewerState is initialized. @@ -50,6 +62,12 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> 'least once before using the MapController.')); } + AnimationController get _animationController { + return value.animationController ?? + (throw Exception('You need to have the FlutterMap widget rendered at ' + 'least once before using the MapController.')); + } + /// This setter should only be called in this class or within tests. Changes /// to the [_MapControllerState] should be done via methods in this class. @visibleForTesting @@ -179,29 +197,27 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> required MapEventSource source, String? id, }) { - if (newRotation != camera.rotation) { - final newCamera = options.cameraConstraint.constrain( - camera.withRotation(newRotation), - ); - if (newCamera == null) return false; + if (newRotation == camera.rotation) return false; - final oldCamera = camera; + final newCamera = options.cameraConstraint.constrain( + camera.withRotation(newRotation), + ); + if (newCamera == null) return false; - // Update camera then emit events and callbacks - value = value.withMapCamera(newCamera); + final oldCamera = camera; - _emitMapEvent( - MapEventRotate( - id: id, - source: source, - oldCamera: oldCamera, - camera: camera, - ), - ); - return true; - } + // Update camera then emit events and callbacks + value = value.withMapCamera(newCamera); - return false; + _emitMapEvent( + MapEventRotate( + id: id, + source: source, + oldCamera: oldCamera, + camera: camera, + ), + ); + return true; } MoveAndRotateResult rotateAroundPointRaw( @@ -340,9 +356,23 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> value = _MapControllerState( options: newOptions, camera: newCamera, + animationController: value.animationController, ); } + set vsync(TickerProvider tickerProvider) { + if (value.animationController == null) { + value = _MapControllerState( + options: value.options, + camera: value.camera, + animationController: AnimationController(vsync: tickerProvider) + ..addListener(_handleAnimation), + ); + } else { + _animationController.resync(tickerProvider); + } + } + /// To be called when a gesture that causes movement starts. void moveStarted(MapEventSource source) { _emitMapEvent( @@ -508,6 +538,161 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> ); } + void moveAndRotateAnimatedRaw( + LatLng newCenter, + double newZoom, + double newRotation, { + required Offset offset, + required Duration duration, + required Curve curve, + required bool hasGesture, + required MapEventSource source, + }) { + if (newRotation == camera.rotation) { + moveAnimatedRaw( + newCenter, + newZoom, + duration: duration, + curve: curve, + hasGesture: hasGesture, + source: source, + ); + return; + } + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + if (newCenter == camera.center && newZoom == camera.zoom) return; + + // create the new animation + _moveAnimation = LatLngTween(begin: camera.center, end: newCenter) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + _zoomAnimation = Tween(begin: camera.zoom, end: newZoom) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + _rotationAnimation = Tween(begin: camera.rotation, end: newRotation) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + + _animationController.duration = duration; + _animationHasGesture = hasGesture; + _animationOffset = offset; + + // start the animation from its start + _animationController.forward(from: 0); + } + + void rotateAnimatedRaw( + double newRotation, { + required Offset offset, + required Duration duration, + required Curve curve, + required bool hasGesture, + required MapEventSource source, + }) { + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + if (newRotation == camera.rotation) return; + + // create the new animation + _rotationAnimation = Tween(begin: camera.rotation, end: newRotation) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + + _animationController.duration = duration; + _animationHasGesture = hasGesture; + _animationOffset = offset; + + // start the animation from its start + _animationController.forward(from: 0); + } + + void stopAnimationRaw({bool canceled = true}) { + if (isAnimating) _animationController.stop(canceled: canceled); + } + + bool get isAnimating => _animationController.isAnimating; + + void _resetAnimations() { + _moveAnimation = null; + _rotationAnimation = null; + _zoomAnimation = null; + _flingAnimation = null; + } + + void flingAnimatedRaw({ + required double velocity, + required Offset direction, + required Offset begin, + Offset offset = Offset.zero, + double mass = 1, + double stiffness = 1000, + double ratio = 5, + required bool hasGesture, + }) { + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + _animationHasGesture = hasGesture; + _animationOffset = offset; + _flingMapCenterStartPoint = camera.project(camera.center); + + final distance = + (Offset.zero & Size(camera.nonRotatedSize.x, camera.nonRotatedSize.y)) + .shortestSide; + + _flingAnimation = Tween( + begin: begin, + end: begin - direction * distance, + ).animate(_animationController); + + _animationController.value = 0; + _animationController.fling( + velocity: velocity, + springDescription: SpringDescription.withDampingRatio( + mass: mass, + stiffness: stiffness, + ratio: ratio, + ), + ); + } + + void moveAnimatedRaw( + LatLng newCenter, + double newZoom, { + Offset offset = Offset.zero, + required Duration duration, + required Curve curve, + required bool hasGesture, + required MapEventSource source, + }) { + // cancel all ongoing animation + _animationController.stop(); + _resetAnimations(); + + if (newCenter == camera.center && newZoom == camera.zoom) return; + + // create the new animation + _moveAnimation = LatLngTween(begin: camera.center, end: newCenter) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + _zoomAnimation = Tween(begin: camera.zoom, end: newZoom) + .chain(CurveTween(curve: curve)) + .animate(_animationController); + + _animationController.duration = duration; + _animationHasGesture = hasGesture; + _animationOffset = offset; + + // start the animation from its start + _animationController.forward(from: 0); + } + void _emitMapEvent(MapEvent event) { if (event.source == MapEventSource.mapController && event is MapEventMove) { _interactiveViewerState.interruptAnimatedMovement(event); @@ -518,9 +703,58 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _mapEventSink.add(event); } + void _handleAnimation() { + // fling animation + if (_flingAnimation != null) { + final newCenterPoint = _flingMapCenterStartPoint + + _flingAnimation!.value.toPoint().rotate(camera.rotationRad); + moveRaw( + camera.unproject(newCenterPoint), + camera.zoom, + hasGesture: _animationHasGesture, + source: MapEventSource.flingAnimationController, + offset: _animationOffset, + ); + return; + } + + // animated movement + if (_moveAnimation != null) { + if (_rotationAnimation != null) { + moveAndRotateRaw( + _moveAnimation?.value ?? camera.center, + _zoomAnimation?.value ?? camera.zoom, + _rotationAnimation!.value, + hasGesture: _animationHasGesture, + source: MapEventSource.mapController, + offset: _animationOffset, + ); + } else { + moveRaw( + _moveAnimation!.value, + _zoomAnimation?.value ?? camera.zoom, + hasGesture: _animationHasGesture, + source: MapEventSource.mapController, + offset: _animationOffset, + ); + } + return; + } + + // animated rotation + if (_rotationAnimation != null) { + rotateRaw( + _rotationAnimation!.value, + hasGesture: _animationHasGesture, + source: MapEventSource.mapController, + ); + } + } + @override void dispose() { _mapEventStreamController.close(); + value.animationController?.dispose(); super.dispose(); } } @@ -529,14 +763,17 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> class _MapControllerState { final MapCamera? camera; final MapOptions? options; + final AnimationController? animationController; const _MapControllerState({ required this.options, required this.camera, + required this.animationController, }); _MapControllerState withMapCamera(MapCamera camera) => _MapControllerState( options: options, camera: camera, + animationController: animationController, ); } diff --git a/lib/src/map/widget.dart b/lib/src/map/widget.dart index 76d7291fa..958f0cdb9 100644 --- a/lib/src/map/widget.dart +++ b/lib/src/map/widget.dart @@ -51,7 +51,7 @@ class FlutterMap extends StatefulWidget { } class _FlutterMapStateContainer extends State - with AutomaticKeepAliveClientMixin { + with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { bool _initialCameraFitApplied = false; late MapControllerImpl _mapController; @@ -184,9 +184,10 @@ class _FlutterMapStateContainer extends State void _setMapController() { if (_controllerCreatedInternally) { - _mapController = MapControllerImpl(widget.options); + _mapController = MapControllerImpl(options: widget.options, vsync: this); } else { _mapController = widget.mapController! as MapControllerImpl; + _mapController.vsync = this; _mapController.options = widget.options; } } From b94de88662084a1996bd8b21c2986e945eaf131e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 15 Dec 2023 23:41:48 +0100 Subject: [PATCH 5/5] feat: add hit detection to `Polyline`s (#1728) Co-authored-by: Luka S --- example/lib/pages/polyline.dart | 253 +++++++++++++++++++++-------- lib/src/layer/polyline_layer.dart | 255 +++++++++++++++++++++++++----- 2 files changed, 405 insertions(+), 103 deletions(-) diff --git a/example/lib/pages/polyline.dart b/example/lib/pages/polyline.dart index 66aa35fce..92dbc9ff5 100644 --- a/example/lib/pages/polyline.dart +++ b/example/lib/pages/polyline.dart @@ -14,6 +14,96 @@ class PolylinePage extends StatefulWidget { } class _PolylinePageState extends State { + final PolylineHitNotifier hitNotifier = ValueNotifier(null); + + final polylines = { + Polyline( + points: [ + const LatLng(51.5, -0.09), + const LatLng(53.3498, -6.2603), + const LatLng(48.8566, 2.3522), + ], + strokeWidth: 8, + color: const Color(0xFF60399E), + ): ( + title: 'Elizabeth Line', + subtitle: 'Nothing really special here...', + ), + Polyline( + points: [ + const LatLng(48.5, -3.09), + const LatLng(47.3498, -9.2603), + const LatLng(43.8566, -1.3522), + ], + strokeWidth: 16000, + color: Colors.pink, + useStrokeWidthInMeter: true, + ): ( + title: 'Pink Line', + subtitle: 'Fixed radius in meters instead of pixels', + ), + Polyline( + points: [ + const LatLng(55.5, -0.09), + const LatLng(54.3498, -6.2603), + const LatLng(52.8566, 2.3522), + ], + strokeWidth: 4, + gradientColors: [ + const Color(0xffE40203), + const Color(0xffFEED00), + const Color(0xff007E2D), + ], + ): ( + title: 'Traffic Light Line', + subtitle: 'Fancy gradient instead of a solid color', + ), + Polyline( + points: [ + const LatLng(50.5, -0.09), + const LatLng(51.3498, 6.2603), + const LatLng(53.8566, 2.3522), + ], + strokeWidth: 20, + color: Colors.blue.withOpacity(0.6), + borderStrokeWidth: 20, + borderColor: Colors.red.withOpacity(0.4), + ): ( + title: 'BlueRed Line', + subtitle: 'Solid translucent color fill, with different color outline', + ), + Polyline( + points: [ + const LatLng(50.2, -0.08), + const LatLng(51.2498, -10.2603), + const LatLng(54.8566, -9.3522), + ], + strokeWidth: 20, + color: Colors.black.withOpacity(0.2), + borderStrokeWidth: 20, + borderColor: Colors.white30, + ): ( + title: 'BlackWhite Line', + subtitle: 'Solid translucent color fill, with different color outline', + ), + Polyline( + points: [ + const LatLng(49.1, -0.06), + const LatLng(52.15, -1.4), + const LatLng(55.5, 0.8), + ], + strokeWidth: 10, + color: Colors.yellow, + borderStrokeWidth: 10, + borderColor: Colors.blue.withOpacity(0.5), + ): ( + title: 'YellowBlue Line', + subtitle: 'Solid translucent color fill, with different color outline', + ), + }; + + List? hoverLines; + @override Widget build(BuildContext context) { return Scaffold( @@ -26,78 +116,111 @@ class _PolylinePageState extends State { ), children: [ openStreetMapTileLayer, - PolylineLayer( - polylines: [ - Polyline( - points: [ - const LatLng(51.5, -0.09), - const LatLng(53.3498, -6.2603), - const LatLng(48.8566, 2.3522), - ], - strokeWidth: 4, - color: Colors.purple, - ), - Polyline( - points: [ - const LatLng(55.5, -0.09), - const LatLng(54.3498, -6.2603), - const LatLng(52.8566, 2.3522), - ], - strokeWidth: 4, - gradientColors: [ - const Color(0xffE40203), - const Color(0xffFEED00), - const Color(0xff007E2D), - ], - ), - Polyline( - points: [ - const LatLng(50.5, -0.09), - const LatLng(51.3498, 6.2603), - const LatLng(53.8566, 2.3522), - ], - strokeWidth: 20, - color: Colors.blue.withOpacity(0.6), - borderStrokeWidth: 20, - borderColor: Colors.red.withOpacity(0.4), + MouseRegion( + hitTestBehavior: HitTestBehavior.deferToChild, + cursor: SystemMouseCursors.click, + onHover: (_) { + if (hitNotifier.value == null) return; + + final lines = hitNotifier.value!.lines + .where((e) => polylines.containsKey(e)) + .map( + (e) => Polyline( + points: e.points, + strokeWidth: e.strokeWidth + e.borderStrokeWidth, + color: Colors.transparent, + borderStrokeWidth: 15, + borderColor: Colors.green, + useStrokeWidthInMeter: e.useStrokeWidthInMeter, + ), + ) + .toList(); + setState(() => hoverLines = lines); + }, + + /// Clear hovered lines when touched lines modal appears + onExit: (_) => setState(() => hoverLines = null), + child: GestureDetector( + onTap: () => _openTouchedLinesModal( + 'Tapped', + hitNotifier.value!.lines, + hitNotifier.value!.point, ), - Polyline( - points: [ - const LatLng(50.2, -0.08), - const LatLng(51.2498, -10.2603), - const LatLng(54.8566, -9.3522), - ], - strokeWidth: 20, - color: Colors.black.withOpacity(0.2), - borderStrokeWidth: 20, - borderColor: Colors.white30, + onLongPress: () => _openTouchedLinesModal( + 'Long pressed', + hitNotifier.value!.lines, + hitNotifier.value!.point, ), - Polyline( - points: [ - const LatLng(49.1, -0.06), - const LatLng(52.15, -1.4), - const LatLng(55.5, 0.8), - ], - strokeWidth: 10, - color: Colors.yellow, - borderStrokeWidth: 10, - borderColor: Colors.blue.withOpacity(0.5), + onSecondaryTap: () => _openTouchedLinesModal( + 'Secondary tapped', + hitNotifier.value!.lines, + hitNotifier.value!.point, ), - Polyline( - points: [ - const LatLng(48.1, -0.03), - const LatLng(50.5, -7.8), - const LatLng(56.5, 0.4), - ], - strokeWidth: 10, - color: Colors.amber, - borderStrokeWidth: 10, - borderColor: Colors.blue.withOpacity(0.5), + child: PolylineLayer( + hitNotifier: hitNotifier, + polylines: polylines.keys.followedBy(hoverLines ?? []).toList(), ), - ], + ), ), ], ), ); } + + void _openTouchedLinesModal( + String eventType, + List tappedLines, + LatLng coords, + ) { + tappedLines.removeWhere((e) => !polylines.containsKey(e)); + + showModalBottomSheet( + context: context, + builder: (context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tapped Polyline(s)', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + Text( + '$eventType at point: (${coords.latitude.toStringAsFixed(6)}, ${coords.longitude.toStringAsFixed(6)})', + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + final tappedLineData = polylines[tappedLines[index]]!; + return ListTile( + leading: index == 0 + ? const Icon(Icons.vertical_align_top) + : index == tappedLines.length - 1 + ? const Icon(Icons.vertical_align_bottom) + : const SizedBox.shrink(), + title: Text(tappedLineData.title), + subtitle: Text(tappedLineData.subtitle), + dense: true, + ); + }, + itemCount: tappedLines.length, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/src/layer/polyline_layer.dart b/lib/src/layer/polyline_layer.dart index 9e71f60df..af4530711 100644 --- a/lib/src/layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer.dart @@ -1,6 +1,8 @@ import 'dart:core'; +import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/geo/latlng_bounds.dart'; import 'package:flutter_map/src/layer/general/mobile_layer_transformer.dart'; @@ -9,6 +11,27 @@ import 'package:flutter_map/src/misc/offsets.dart'; import 'package:flutter_map/src/misc/point_extensions.dart'; import 'package:latlong2/latlong.dart'; +/// Result from polyline hit detection +/// +/// Emmitted by [PolylineLayer.hitNotifier]'s [ValueNotifier] +/// ([PolylineHitNotifier]). +class PolylineHit { + /// All hit [Polyline]s within the corresponding layer + /// + /// Ordered from first-last, visually top-bottom. + final List lines; + + /// Coordinates of the detected hit + /// + /// Note that this may not lie on a [Polyline]. + final LatLng point; + + const PolylineHit._({required this.lines, required this.point}); +} + +/// Typedef used on [PolylineLayer.hitNotifier] +typedef PolylineHitNotifier = ValueNotifier; + class Polyline { final List points; final double strokeWidth; @@ -41,64 +64,185 @@ class Polyline { this.useStrokeWidthInMeter = false, }); - /// Used to batch draw calls to the canvas. + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Polyline && + listEquals(points, other.points) && + strokeWidth == other.strokeWidth && + color == other.color && + borderStrokeWidth == other.borderStrokeWidth && + borderColor == other.borderColor && + listEquals(gradientColors, other.gradientColors) && + listEquals(colorsStop, other.colorsStop) && + isDotted == other.isDotted && + strokeCap == other.strokeCap && + strokeJoin == other.strokeJoin && + useStrokeWidthInMeter == other.useStrokeWidthInMeter); + + /// Used to batch draw calls to the canvas int get renderHashCode => Object.hash( - strokeWidth, - color, - borderStrokeWidth, - borderColor, - gradientColors, - colorsStop, - isDotted, - strokeCap, - strokeJoin, - useStrokeWidthInMeter); + strokeWidth, + color, + borderStrokeWidth, + borderColor, + gradientColors, + colorsStop, + isDotted, + strokeCap, + strokeJoin, + useStrokeWidthInMeter, + ); + + @override + int get hashCode => Object.hash(points, renderHashCode); } @immutable class PolylineLayer extends StatelessWidget { final List polylines; - final bool polylineCulling; + + /// A notifier to notify when a hit is detected over a/multiple [Polyline]s + /// + /// To listen for hits, wrap the layer in a standard hit detector widget, such + /// as [GestureDetector] and/or [MouseRegion] (and set + /// [HitTestBehavior.deferToChild] if necessary). Then use the latest value + /// (via [ValueNotifier.value]) in the detector's callbacks. It is also + /// possible to listen to the notifier directly. + /// + /// Note that a hover event is included as a hit event. Therefore for + /// performance reasons, it may be advantageous to check the new value's + /// equality against the previous value (excluding the [PolylineHit.point], + /// which will always change), and avoid doing any heavy work if they are the + /// same. + /// + /// See online documentation for more detailed usage instructions. See the + /// example project for an example implementation. + /// + /// Will notify with [PolylineHit]s when any [Polyline]s are hit, otherwise + /// will notify with `null`. + final PolylineHitNotifier? hitNotifier; + + /// The minimum radius of the hittable area around each [Polyline] in logical + /// pixels + /// + /// The entire visible area is always hittable, but if the visible area is + /// smaller than this, then this will be the hittable area. + /// + /// Defaults to 10. + final double minimumHitbox; const PolylineLayer({ super.key, required this.polylines, - this.polylineCulling = false, + this.hitNotifier, + this.minimumHitbox = 10, + // TODO: Remove once PR #1704 is merged + bool polylineCulling = true, }); @override Widget build(BuildContext context) { - final map = MapCamera.of(context); + final camera = MapCamera.of(context); return MobileLayerTransformer( child: CustomPaint( - painter: PolylinePainter( - polylineCulling - ? polylines - .where((p) => p.boundingBox.isOverlapping(map.visibleBounds)) - .toList() - : polylines, - map, + painter: _PolylinePainter( + polylines: polylines + .where((p) => p.boundingBox.isOverlapping(camera.visibleBounds)) + .toList(), + camera: camera, + hitNotifier: hitNotifier, + minimumHitbox: minimumHitbox, ), - size: Size(map.size.x, map.size.y), + size: Size(camera.size.x, camera.size.y), isComplex: true, ), ); } } -class PolylinePainter extends CustomPainter { +class _PolylinePainter extends CustomPainter { final List polylines; - - final MapCamera map; + final MapCamera camera; final LatLngBounds bounds; + final PolylineHitNotifier? hitNotifier; + final double minimumHitbox; - PolylinePainter(this.polylines, this.map) : bounds = map.visibleBounds; + _PolylinePainter({ + required this.polylines, + required this.camera, + required this.hitNotifier, + required this.minimumHitbox, + }) : bounds = camera.visibleBounds; - int get hash => _hash ??= Object.hashAll(polylines); + // Avoids reallocation on every `hitTest`, is cleared every time + final hits = List.empty(growable: true); + int get hash => _hash ??= Object.hashAll(polylines); int? _hash; + @override + bool? hitTest(Offset position) { + if (hitNotifier == null) return null; + + hits.clear(); + + final origin = + camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; + + for (final p in polylines.reversed) { + // TODO: For efficiency we'd ideally filter by bounding box here. However + // we'd need to compute an extended bounding box that accounts account for + // the stroke width. + // if (!p.boundingBox.contains(touch)) { + // continue; + // } + + final offsets = getOffsets(camera, origin, p.points); + final strokeWidth = p.useStrokeWidthInMeter + ? _metersToStrokeWidth( + origin, + p.points.first, + offsets.first, + p.strokeWidth, + ) + : p.strokeWidth; + final hittableDistance = + math.max(strokeWidth / 2 + p.borderStrokeWidth / 2, minimumHitbox); + + for (int i = 0; i < offsets.length - 1; i++) { + final o1 = offsets[i]; + final o2 = offsets[i + 1]; + + final distance = math.sqrt(_distToSegmentSquared( + position.dx, + position.dy, + o1.dx, + o1.dy, + o2.dx, + o2.dy, + )); + + if (distance < hittableDistance) { + hits.add(p); + break; + } + } + } + + if (hits.isEmpty) { + hitNotifier!.value = null; + return false; + } + + hitNotifier!.value = PolylineHit._( + lines: hits, + point: camera.pointToLatLng(math.Point(position.dx, position.dy)), + ); + return true; + } + @override void paint(Canvas canvas, Size size) { final rect = Offset.zero & size; @@ -138,10 +282,11 @@ class PolylinePainter extends CustomPainter { paint = Paint(); } - final origin = map.project(map.center).toOffset() - map.size.toOffset() / 2; + final origin = + camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; for (final polyline in polylines) { - final offsets = getOffsets(map, origin, polyline.points); + final offsets = getOffsets(camera, origin, polyline.points); if (offsets.isEmpty) { continue; } @@ -156,16 +301,12 @@ class PolylinePainter extends CustomPainter { late final double strokeWidth; if (polyline.useStrokeWidthInMeter) { - final firstPoint = polyline.points.first; - final firstOffset = offsets.first; - final r = const Distance().offset( - firstPoint, + strokeWidth = _metersToStrokeWidth( + origin, + polyline.points.first, + offsets.first, polyline.strokeWidth, - 180, ); - final delta = firstOffset - getOffset(map, origin, r); - - strokeWidth = delta.distance; } else { strokeWidth = polyline.strokeWidth; } @@ -277,10 +418,48 @@ class PolylinePainter extends CustomPainter { .toList(); } + double _metersToStrokeWidth( + Offset origin, + LatLng p0, + Offset o0, + double strokeWidthInMeters, + ) { + final r = _distance.offset(p0, strokeWidthInMeters, 180); + final delta = o0 - getOffset(camera, origin, r); + return delta.distance; + } + @override - bool shouldRepaint(PolylinePainter oldDelegate) { + bool shouldRepaint(_PolylinePainter oldDelegate) { return oldDelegate.bounds != bounds || oldDelegate.polylines.length != polylines.length || oldDelegate.hash != hash; } } + +double _distanceSq(double x0, double y0, double x1, double y1) { + final dx = x0 - x1; + final dy = y0 - y1; + return dx * dx + dy * dy; +} + +double _distToSegmentSquared( + double px, + double py, + double x0, + double y0, + double x1, + double y1, +) { + final dx = x1 - x0; + final dy = y1 - y0; + final distanceSq = dx * dx + dy * dy; + if (distanceSq == 0) { + return _distanceSq(px, py, x0, y0); + } + + final t = (((px - x0) * dx + (py - y0) * dy) / distanceSq).clamp(0, 1); + return _distanceSq(px, py, x0 + t * dx, y0 + t * dy); +} + +const _distance = Distance();