diff --git a/example/lib/pages/circle.dart b/example/lib/pages/circle.dart index ed9363b22..df5e10fa2 100644 --- a/example/lib/pages/circle.dart +++ b/example/lib/pages/circle.dart @@ -1,19 +1,70 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; -class CirclePage extends StatelessWidget { +typedef HitValue = ({String title, String subtitle}); + +class CirclePage extends StatefulWidget { static const String route = '/circle'; const CirclePage({super.key}); + @override + State createState() => _CirclePageState(); +} + +class _CirclePageState extends State { + final LayerHitNotifier _hitNotifier = ValueNotifier(null); + List? _prevHitValues; + List>? _hoverCircles; + + final _circlesRaw = >[ + CircleMarker( + point: const LatLng(51.5, -0.09), + color: Colors.white.withOpacity(0.7), + borderColor: Colors.black, + borderStrokeWidth: 2, + useRadiusInMeter: false, + radius: 100, + hitValue: (title: 'White', subtitle: 'Radius in logical pixels'), + ), + CircleMarker( + point: const LatLng(51.5, -0.09), + color: Colors.black.withOpacity(0.7), + borderColor: Colors.black, + borderStrokeWidth: 2, + useRadiusInMeter: false, + radius: 50, + hitValue: ( + title: 'Black', + subtitle: 'Radius in logical pixels, should be above white.', + ), + ), + CircleMarker( + point: const LatLng(51.4937, -0.6638), + // Dorney Lake is ~2km long + color: Colors.green.withOpacity(0.9), + borderColor: Colors.black, + borderStrokeWidth: 2, + useRadiusInMeter: true, + radius: 1000, // 1000 meters + hitValue: ( + title: 'Green', + subtitle: 'Radius in meters, calibrated over ~2km rowing lake' + ), + ), + ]; + late final _circles = + Map.fromEntries(_circlesRaw.map((e) => MapEntry(e.hitValue, e))); + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Circle')), - drawer: const MenuDrawer(route), + appBar: AppBar(title: const Text('Circles')), + drawer: const MenuDrawer(CirclePage.route), body: FlutterMap( options: const MapOptions( initialCenter: LatLng(51.5, -0.09), @@ -21,29 +72,116 @@ class CirclePage extends StatelessWidget { ), children: [ openStreetMapTileLayer, - CircleLayer( - circles: [ - CircleMarker( - point: const LatLng(51.5, -0.09), - color: Colors.blue.withOpacity(0.7), - borderColor: Colors.black, - borderStrokeWidth: 2, - useRadiusInMeter: true, - radius: 2000, // 2000 meters + MouseRegion( + hitTestBehavior: HitTestBehavior.deferToChild, + cursor: SystemMouseCursors.click, + onHover: (_) { + final hitValues = _hitNotifier.value?.hitValues.toList(); + if (hitValues == null) return; + + if (listEquals(hitValues, _prevHitValues)) return; + _prevHitValues = hitValues; + + final hoverCircles = hitValues.map((v) { + final original = _circles[v]!; + + return CircleMarker( + point: original.point, + radius: original.radius + 6.5, + useRadiusInMeter: original.useRadiusInMeter, + color: Colors.transparent, + borderStrokeWidth: 15, + borderColor: Colors.green, + ); + }).toList(); + setState(() => _hoverCircles = hoverCircles); + }, + onExit: (_) { + _prevHitValues = null; + setState(() => _hoverCircles = null); + }, + child: GestureDetector( + onTap: () => _openTouchedCirclesModal( + 'Tapped', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.coordinate, + ), + onLongPress: () => _openTouchedCirclesModal( + 'Long pressed', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.coordinate, + ), + onSecondaryTap: () => _openTouchedCirclesModal( + 'Secondary tapped', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.coordinate, ), - CircleMarker( - point: const LatLng(51.4937, -0.6638), - // Dorney Lake is ~2km long - color: Colors.green.withOpacity(0.9), - borderColor: Colors.black, - borderStrokeWidth: 2, - useRadiusInMeter: true, - radius: 1000, // 1000 meters + child: CircleLayer( + hitNotifier: _hitNotifier, + circles: [ + ..._circlesRaw, + ...?_hoverCircles, + ], ), - ], + ), ), ], ), ); } + + void _openTouchedCirclesModal( + String eventType, + List tappedCircles, + LatLng coords, + ) { + showModalBottomSheet( + context: context, + builder: (context) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tapped Circle(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 = tappedCircles[index]; + return ListTile( + leading: index == 0 + ? const Icon(Icons.vertical_align_top) + : index == tappedCircles.length - 1 + ? const Icon(Icons.vertical_align_bottom) + : const SizedBox.shrink(), + title: Text(tappedLineData.title), + subtitle: Text(tappedLineData.subtitle), + dense: true, + ); + }, + itemCount: tappedCircles.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/flutter_map.dart b/lib/flutter_map.dart index 497b1b52f..8e7d1dd3f 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -29,14 +29,15 @@ export 'package:flutter_map/src/layer/attribution_layer/rich/widget.dart'; export 'package:flutter_map/src/layer/attribution_layer/simple.dart'; export 'package:flutter_map/src/layer/circle_layer/circle_layer.dart'; export 'package:flutter_map/src/layer/marker_layer/marker_layer.dart'; -export 'package:flutter_map/src/layer/misc/hit_detection.dart'; -export 'package:flutter_map/src/layer/misc/line_patterns/stroke_pattern.dart'; -export 'package:flutter_map/src/layer/misc/mobile_layer_transformer.dart'; -export 'package:flutter_map/src/layer/misc/translucent_pointer.dart'; export 'package:flutter_map/src/layer/overlay_image_layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer/polyline_layer.dart'; export 'package:flutter_map/src/layer/scalebar/scalebar.dart'; +export 'package:flutter_map/src/layer/shared/layer_interactivity/layer_hit_notifier.dart'; +export 'package:flutter_map/src/layer/shared/layer_interactivity/layer_hit_result.dart'; +export 'package:flutter_map/src/layer/shared/line_patterns/stroke_pattern.dart'; +export 'package:flutter_map/src/layer/shared/mobile_layer_transformer.dart'; +export 'package:flutter_map/src/layer/shared/translucent_pointer.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_builder.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; export 'package:flutter_map/src/layer/tile_layer/tile_display.dart'; diff --git a/lib/src/layer/circle_layer/circle_layer.dart b/lib/src/layer/circle_layer/circle_layer.dart index 40b1c57e5..98e2235ec 100644 --- a/lib/src/layer/circle_layer/circle_layer.dart +++ b/lib/src/layer/circle_layer/circle_layer.dart @@ -1,7 +1,9 @@ +import 'dart:math'; import 'dart:ui'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; import 'package:latlong2/latlong.dart' hide Path; part 'circle_marker.dart'; @@ -9,19 +11,31 @@ part 'painter.dart'; /// A layer that displays a list of [CircleMarker] on the map @immutable -class CircleLayer extends StatelessWidget { +class CircleLayer extends StatelessWidget { /// The list of [CircleMarker]s. - final List circles; + final List> circles; - /// Create a new [CircleLayer] as a child for flutter map - const CircleLayer({super.key, required this.circles}); + /// {@macro fm.lhn.layerHitNotifier.usage} + final LayerHitNotifier? hitNotifier; + + /// Create a new [CircleLayer] as a child for [FlutterMap] + const CircleLayer({ + super.key, + required this.circles, + this.hitNotifier, + }); @override Widget build(BuildContext context) { final camera = MapCamera.of(context); + return MobileLayerTransformer( child: CustomPaint( - painter: CirclePainter(circles, camera), + painter: CirclePainter( + circles: circles, + camera: camera, + hitNotifier: hitNotifier, + ), size: Size(camera.size.x, camera.size.y), isComplex: true, ), diff --git a/lib/src/layer/circle_layer/circle_marker.dart b/lib/src/layer/circle_layer/circle_marker.dart index 7694125b0..23f37ba5f 100644 --- a/lib/src/layer/circle_layer/circle_marker.dart +++ b/lib/src/layer/circle_layer/circle_marker.dart @@ -3,7 +3,7 @@ part of 'circle_layer.dart'; /// Immutable marker options for [CircleMarker]. Circle markers are a more /// simple and performant way to draw markers as the regular [Marker] @immutable -class CircleMarker { +base class CircleMarker extends HitDetectableElement { /// An optional [Key] for the [CircleMarker]. /// This key is not used internally. final Key? key; @@ -36,5 +36,6 @@ class CircleMarker { this.color = const Color(0xFF00FF00), this.borderStrokeWidth = 0.0, this.borderColor = const Color(0xFFFFFF00), + super.hitValue, }); } diff --git a/lib/src/layer/circle_layer/painter.dart b/lib/src/layer/circle_layer/painter.dart index 7ac552721..e0e0ab684 100644 --- a/lib/src/layer/circle_layer/painter.dart +++ b/lib/src/layer/circle_layer/painter.dart @@ -2,20 +2,46 @@ part of 'circle_layer.dart'; /// The [CustomPainter] used to draw [CircleMarker] for the [CircleLayer]. @immutable -class CirclePainter extends CustomPainter { +base class CirclePainter + extends HitDetectablePainter> { /// Reference to the list of [CircleMarker]s of the [CircleLayer]. - final List circles; - - /// Reference to the [MapCamera]. - final MapCamera camera; + final List> circles; /// Create a [CirclePainter] instance by providing the required /// reference objects. - const CirclePainter(this.circles, this.camera); + CirclePainter({ + required this.circles, + required super.camera, + required super.hitNotifier, + }); + + static const _distance = Distance(); + + @override + bool elementHitTest( + CircleMarker element, { + required Point point, + required LatLng coordinate, + }) { + final circle = element; // Should be optimized out by compiler, avoids lint + + final center = camera.getOffsetFromOrigin(circle.point); + final radius = circle.useRadiusInMeter + ? (center - + camera.getOffsetFromOrigin( + _distance.offset(circle.point, circle.radius, 180))) + .distance + : circle.radius; + + return pow(point.x - center.dx, 2) + pow(point.y - center.dy, 2) <= + radius * radius; + } + + @override + Iterable> get elements => circles; @override void paint(Canvas canvas, Size size) { - const distance = Distance(); final rect = Offset.zero & size; canvas.clipRect(rect); @@ -24,16 +50,16 @@ class CirclePainter extends CustomPainter { final pointsFilledBorder = >>{}; final pointsBorder = >>>{}; for (final circle in circles) { - final offset = camera.getOffsetFromOrigin(circle.point); - double radius = circle.radius; - if (circle.useRadiusInMeter) { - final r = distance.offset(circle.point, circle.radius, 180); - final delta = offset - camera.getOffsetFromOrigin(r); - radius = delta.distance; - } + final center = camera.getOffsetFromOrigin(circle.point); + final radius = circle.useRadiusInMeter + ? (center - + camera.getOffsetFromOrigin( + _distance.offset(circle.point, circle.radius, 180))) + .distance + : circle.radius; points[circle.color] ??= {}; points[circle.color]![radius] ??= []; - points[circle.color]![radius]!.add(offset); + points[circle.color]![radius]!.add(center); if (circle.borderStrokeWidth > 0) { // Check if color have some transparency or not @@ -41,18 +67,18 @@ class CirclePainter extends CustomPainter { if (circle.color.alpha == 0xFF) { double radiusBorder = circle.radius + circle.borderStrokeWidth; if (circle.useRadiusInMeter) { - final rBorder = distance.offset(circle.point, radiusBorder, 180); - final deltaBorder = offset - camera.getOffsetFromOrigin(rBorder); + final rBorder = _distance.offset(circle.point, radiusBorder, 180); + final deltaBorder = center - camera.getOffsetFromOrigin(rBorder); radiusBorder = deltaBorder.distance; } pointsFilledBorder[circle.borderColor] ??= {}; pointsFilledBorder[circle.borderColor]![radiusBorder] ??= []; - pointsFilledBorder[circle.borderColor]![radiusBorder]!.add(offset); + pointsFilledBorder[circle.borderColor]![radiusBorder]!.add(center); } else { double realRadius = circle.radius; if (circle.useRadiusInMeter) { - final rBorder = distance.offset(circle.point, realRadius, 180); - final deltaBorder = offset - camera.getOffsetFromOrigin(rBorder); + final rBorder = _distance.offset(circle.point, realRadius, 180); + final deltaBorder = center - camera.getOffsetFromOrigin(rBorder); realRadius = deltaBorder.distance; } pointsBorder[circle.borderColor] ??= {}; @@ -61,7 +87,7 @@ class CirclePainter extends CustomPainter { realRadius] ??= []; pointsBorder[circle.borderColor]![circle.borderStrokeWidth]![ realRadius]! - .add(offset); + .add(center); } } } diff --git a/lib/src/layer/misc/hit_detection.dart b/lib/src/layer/misc/hit_detection.dart deleted file mode 100644 index 4bdf5a742..000000000 --- a/lib/src/layer/misc/hit_detection.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/widgets.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:meta/meta.dart'; - -/// Result emmitted by hit notifiers (see [LayerHitNotifier]) when a hit is -/// detected on a feature within the respective layer -/// -/// Not emitted if the hit was not over a feature. -@immutable -class LayerHitResult { - /// `hitValue`s from all features hit (which have `hitValue`s defined) - /// - /// If a feature is hit but has no `hitValue` defined, it will not be included. - /// May be empty. - /// - /// Ordered by their corresponding feature, first-to-last, visually - /// top-to-bottom. - final List hitValues; - - /// Geographical coordinates of the detected hit - /// - /// Note that this may not lie on a feature. - /// - /// See [point] for the screen point which was hit. - final LatLng coordinate; - - /// Screen point of the detected hit - /// - /// See [coordinate] for the geographical coordinate which was hit. - final Point point; - - /// Construct a new [LayerHitResult] by providing the values. - @internal - const LayerHitResult({ - required this.hitValues, - required this.coordinate, - required this.point, - }); -} - -/// A [ValueNotifier] that notifies: -/// -/// * a [LayerHitResult] when a hit is detected on a feature in a layer -/// * `null` when a hit is detected on the layer but not on a feature -typedef LayerHitNotifier = ValueNotifier?>; diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index 880daae99..315fe0402 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -2,7 +2,9 @@ part of 'polygon_layer.dart'; /// The [_PolygonPainter] class is used to render [Polygon]s for /// the [PolygonLayer]. -class _PolygonPainter extends CustomPainter { +base class _PolygonPainter + extends HitDetectablePainter> + with HitTestRequiresCameraOrigin { /// Reference to the list of [_ProjectedPolygon]s final List<_ProjectedPolygon> polygons; @@ -11,9 +13,6 @@ class _PolygonPainter extends CustomPainter { /// Expected to be in same/corresponding order as [polygons]. final List?>? triangles; - /// Reference to the [MapCamera]. - final MapCamera camera; - /// Reference to the bounding box of the [Polygon]. final LatLngBounds bounds; @@ -23,93 +22,68 @@ class _PolygonPainter extends CustomPainter { /// Whether to draw labels last and thus over all the polygons final bool drawLabelsLast; - /// See [PolylineLayer.hitNotifier] - final LayerHitNotifier? hitNotifier; - - final _hits = []; // Avoids repetitive memory reallocation - /// Create a new [_PolygonPainter] instance. _PolygonPainter({ required this.polygons, required this.triangles, - required this.camera, + required super.camera, required this.polygonLabels, required this.drawLabelsLast, - required this.hitNotifier, + required super.hitNotifier, }) : bounds = camera.visibleBounds; @override - bool? hitTest(Offset position) { - _hits.clear(); - bool hasHit = false; - - final origin = - camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; - final point = position.toPoint(); - final coordinate = camera.pointToLatLng(point); - - for (final projectedPolygon in polygons.reversed) { - final polygon = projectedPolygon.polygon; - if ((hasHit && polygon.hitValue == null) || - !polygon.boundingBox.contains(coordinate)) { - continue; - } - - final projectedCoords = getOffsetsXY( - camera: camera, - origin: origin, - points: projectedPolygon.points, - ).toList(); - - if (projectedCoords.first != projectedCoords.last) { - projectedCoords.add(projectedCoords.first); - } - - final hasHoles = projectedPolygon.holePoints.isNotEmpty; - late final List> projectedHoleCoords; - if (hasHoles) { - projectedHoleCoords = projectedPolygon.holePoints - .map( - (points) => getOffsetsXY( - camera: camera, - origin: origin, - points: points, - ).toList(), - ) - .toList(); - - if (projectedHoleCoords.firstOrNull != projectedHoleCoords.lastOrNull) { - projectedHoleCoords.add(projectedHoleCoords.first); - } - } + bool elementHitTest( + _ProjectedPolygon projectedPolygon, { + required math.Point point, + required LatLng coordinate, + }) { + final polygon = projectedPolygon.polygon; + + if (!polygon.boundingBox.contains(coordinate)) return false; + + final projectedCoords = getOffsetsXY( + camera: camera, + origin: hitTestCameraOrigin, + points: projectedPolygon.points, + ).toList(); + + if (projectedCoords.first != projectedCoords.last) { + projectedCoords.add(projectedCoords.first); + } - final isInPolygon = _isPointInPolygon(position, projectedCoords); - late final isInHole = hasHoles && - projectedHoleCoords - .map((c) => _isPointInPolygon(position, c)) - .any((e) => e); - - // Second check handles case where polygon outline intersects a hole, - // ensuring that the hit matches with the visual representation - if ((isInPolygon && !isInHole) || (!isInPolygon && isInHole)) { - if (polygon.hitValue != null) _hits.add(polygon.hitValue!); - hasHit = true; + final hasHoles = projectedPolygon.holePoints.isNotEmpty; + late final List> projectedHoleCoords; + if (hasHoles) { + projectedHoleCoords = projectedPolygon.holePoints + .map( + (points) => getOffsetsXY( + camera: camera, + origin: hitTestCameraOrigin, + points: points, + ).toList(), + ) + .toList(); + + if (projectedHoleCoords.firstOrNull != projectedHoleCoords.lastOrNull) { + projectedHoleCoords.add(projectedHoleCoords.first); } } - if (!hasHit) { - hitNotifier?.value = null; - return false; - } + final isInPolygon = _isPointInPolygon(point, projectedCoords); + final isInHole = hasHoles && + projectedHoleCoords + .map((c) => _isPointInPolygon(point, c)) + .any((e) => e); - hitNotifier?.value = LayerHitResult( - hitValues: _hits, - coordinate: coordinate, - point: point, - ); - return true; + // Second check handles case where polygon outline intersects a hole, + // ensuring that the hit matches with the visual representation + return (isInPolygon && !isInHole) || (!isInPolygon && isInHole); } + @override + Iterable<_ProjectedPolygon> get elements => polygons; + @override void paint(Canvas canvas, Size size) { final trianglePoints = []; @@ -390,15 +364,15 @@ class _PolygonPainter extends CustomPainter { /// Checks whether point [p] is within the specified closed [polygon] /// /// Uses the even-odd algorithm. - static bool _isPointInPolygon(Offset p, List polygon) { + static bool _isPointInPolygon(math.Point p, List polygon) { bool isInPolygon = false; for (int i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - if ((((polygon[i].dy <= p.dy) && (p.dy < polygon[j].dy)) || - ((polygon[j].dy <= p.dy) && (p.dy < polygon[i].dy))) && - (p.dx < + if ((((polygon[i].dy <= p.y) && (p.y < polygon[j].dy)) || + ((polygon[j].dy <= p.y) && (p.y < polygon[i].dy))) && + (p.x < (polygon[j].dx - polygon[i].dx) * - (p.dy - polygon[i].dy) / + (p.y - polygon[i].dy) / (polygon[j].dy - polygon[i].dy) + polygon[i].dx)) isInPolygon = !isInPolygon; } diff --git a/lib/src/layer/polygon_layer/polygon.dart b/lib/src/layer/polygon_layer/polygon.dart index af279278e..2aaf08e53 100644 --- a/lib/src/layer/polygon_layer/polygon.dart +++ b/lib/src/layer/polygon_layer/polygon.dart @@ -84,12 +84,7 @@ class Polygon { /// it remains upright final bool rotateLabel; - /// Value notified in [PolygonLayer.hitNotifier] - /// - /// Polylines without a defined [hitValue] are still hit tested, but are not - /// notified about. - /// - /// Should implement an equality operator to avoid breaking [Polygon.==]. + /// {@macro fm.hde.hitValue} 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 918ee9156..ce20d0c99 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -6,7 +6,8 @@ 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/misc/line_patterns/pixel_hiker.dart'; +import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; +import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart'; import 'package:flutter_map/src/misc/offsets.dart'; import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart' hide Path; @@ -66,15 +67,7 @@ class PolygonLayer extends StatefulWidget { /// Defaults to `false`. final bool drawLabelsLast; - /// A notifier to be notified when a hit test occurs on the layer - /// - /// Notified with a [LayerHitResult] if any polylines are hit, otherwise - /// notified with `null`. - /// - /// Hit testing still occurs even if this is `null`. - /// - /// See online documentation for more detailed usage instructions. See the - /// example project for an example implementation. + /// {@macro fm.lhn.layerHitNotifier.usage} final LayerHitNotifier? hitNotifier; /// Create a new [PolygonLayer] for the [FlutterMap] widget. diff --git a/lib/src/layer/polygon_layer/projected_polygon.dart b/lib/src/layer/polygon_layer/projected_polygon.dart index 1a462344a..c5f0c000a 100644 --- a/lib/src/layer/polygon_layer/projected_polygon.dart +++ b/lib/src/layer/polygon_layer/projected_polygon.dart @@ -1,11 +1,14 @@ part of 'polygon_layer.dart'; @immutable -class _ProjectedPolygon { +base class _ProjectedPolygon extends HitDetectableElement { final Polygon polygon; final List points; final List> holePoints; + @override + R? get hitValue => polygon.hitValue; + const _ProjectedPolygon._({ required this.polygon, required this.points, diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index db4c9ca52..3b4e25a67 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -1,91 +1,71 @@ part of 'polyline_layer.dart'; /// [CustomPainter] for [Polyline]s. -class _PolylinePainter extends CustomPainter { +base class _PolylinePainter + extends HitDetectablePainter> + with HitTestRequiresCameraOrigin { /// Reference to the list of [Polyline]s. final List<_ProjectedPolyline> polylines; - /// Reference to the [MapCamera]. - final MapCamera camera; - final LayerHitNotifier? hitNotifier; final double minimumHitbox; - final _hits = []; // Avoids repetitive memory reallocation - /// Create a new [_PolylinePainter] instance _PolylinePainter({ required this.polylines, - required this.camera, - required this.hitNotifier, required this.minimumHitbox, + required super.camera, + required super.hitNotifier, }); @override - bool? hitTest(Offset position) { - _hits.clear(); - bool hasHit = false; - - final origin = - camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; - - for (final projectedPolyline in polylines.reversed) { - final polyline = projectedPolyline.polyline; - if (hasHit && polyline.hitValue == null) continue; - - // 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 `borderStrokeWidth` & the `minimumHitbox` - // if (!polyline.boundingBox.contains(touch)) { - // continue; - // } - - final offsets = getOffsetsXY( - camera: camera, - origin: origin, - points: projectedPolyline.points, - ); - final strokeWidth = polyline.useStrokeWidthInMeter - ? _metersToStrokeWidth( - origin, - _unproject(projectedPolyline.points.first), - offsets.first, - polyline.strokeWidth, - ) - : polyline.strokeWidth; - final hittableDistance = math.max( - strokeWidth / 2 + polyline.borderStrokeWidth / 2, - minimumHitbox, - ); + bool elementHitTest( + _ProjectedPolyline projectedPolyline, { + required math.Point point, + required LatLng coordinate, + }) { + final polyline = projectedPolyline.polyline; + + // 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 `borderStrokeWidth` & the `minimumHitbox` + // if (!polyline.boundingBox.contains(touch)) { + // 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, + ); - 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(position.dx, position.dy, o1.dx, o1.dy, o2.dx, o2.dy); + final distanceSq = + getSqSegDist(point.x, point.y, o1.dx, o1.dy, o2.dx, o2.dy); - if (distanceSq <= hittableDistance * hittableDistance) { - if (polyline.hitValue != null) _hits.add(polyline.hitValue!); - hasHit = true; - break; - } - } + if (distanceSq <= hittableDistance * hittableDistance) return true; } - if (!hasHit) { - hitNotifier?.value = null; - return false; - } - - final point = position.toPoint(); - hitNotifier?.value = LayerHitResult( - hitValues: _hits, - coordinate: camera.pointToLatLng(point), - point: point, - ); - return true; + return false; } + @override + Iterable<_ProjectedPolyline> get elements => polylines; + @override void paint(Canvas canvas, Size size) { final rect = Offset.zero & size; diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart index 0ddd5af31..72dc2f4f6 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -39,12 +39,7 @@ class Polyline { /// Set to true if the width of the stroke should have meters as unit. final bool useStrokeWidthInMeter; - /// Value notified in [PolylineLayer.hitNotifier] - /// - /// Polylines without a defined [hitValue] are still hit tested, but are not - /// notified about. - /// - /// Should implement an equality operator to avoid breaking [Polyline.==]. + /// {@macro fm.hde.hitValue} final R? hitValue; /// Create a new [Polyline] used for the [PolylineLayer]. diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index 77cb60339..68acb0bb4 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -5,7 +5,8 @@ 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/misc/line_patterns/pixel_hiker.dart'; +import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; +import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart'; import 'package:flutter_map/src/misc/offsets.dart'; import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart'; @@ -39,15 +40,7 @@ class PolylineLayer extends StatefulWidget { /// Defaults to 0.4. Set to 0 to disable simplification. final double simplificationTolerance; - /// A notifier to be notified when a hit test occurs on the layer - /// - /// Notified with a [LayerHitResult] if any polylines are hit, otherwise - /// notified with `null`. - /// - /// Hit testing still occurs even if this is `null`. - /// - /// See online documentation for more detailed usage instructions. See the - /// example project for an example implementation. + /// {@macro fm.lhn.layerHitNotifier.usage} final LayerHitNotifier? hitNotifier; /// The minimum radius of the hittable area around each [Polyline] in logical diff --git a/lib/src/layer/polyline_layer/projected_polyline.dart b/lib/src/layer/polyline_layer/projected_polyline.dart index a9f2facca..8c0b6c424 100644 --- a/lib/src/layer/polyline_layer/projected_polyline.dart +++ b/lib/src/layer/polyline_layer/projected_polyline.dart @@ -1,10 +1,14 @@ part of 'polyline_layer.dart'; @immutable -class _ProjectedPolyline { +base class _ProjectedPolyline + extends HitDetectableElement { final Polyline polyline; final List points; + @override + R? get hitValue => polyline.hitValue; + const _ProjectedPolyline._({ required this.polyline, required this.points, diff --git a/lib/src/layer/shared/layer_interactivity/internal_hit_detectable.dart b/lib/src/layer/shared/layer_interactivity/internal_hit_detectable.dart new file mode 100644 index 000000000..1a6ef6c2c --- /dev/null +++ b/lib/src/layer/shared/layer_interactivity/internal_hit_detectable.dart @@ -0,0 +1,116 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; + +@internal +abstract base class HitDetectableElement { + const HitDetectableElement({this.hitValue}); + + /// {@template fm.hde.hitValue} + /// Value to notify layer's `hitNotifier` with (such as + /// [PolygonLayer.hitNotifier]) + /// + /// Elements without a defined [hitValue] are still hit tested, but are not + /// notified about. + /// + /// The object should have a valid & useful equality, as it may be used + /// by FM internals. + /// {@endtemplate} + final R? hitValue; +} + +@internal +abstract base class HitDetectablePainter> extends CustomPainter { + HitDetectablePainter({required this.camera, required this.hitNotifier}); + + final MapCamera camera; + final LayerHitNotifier? hitNotifier; + + /// Elements that should be possibly be hit tested by [elementHitTest] + /// ([hitTest]) + /// + /// See [elementHitTest] for more information. + Iterable get elements; + + /// Method invoked by [hitTest] for every element (each of [elements] in + /// reverse order) that requires testing + /// + /// Not all elements will require testing. For example, testing is skipped if + /// a hit has already been found on another element, and the + /// [HitDetectableElement.hitValue] is `null` on this element. + /// + /// [point] ([OffsetToPointExtension.toPoint]) and [coordinate] + /// ([MapCamera.pointToLatLng]) are provided for simplicity. + /// + /// Avoid performing calculations that are not dependent on [element]. Instead, + /// override [hitTest], store the necessary calculation results in + /// (`late` non-`null`able) members, and call `super.hitTest(position)` at the + /// end. To calculate the camera origin in this way, instead mix in + /// [HitTestRequiresCameraOrigin], which makes the origin available through + /// the `hitTestCameraOrigin` member. + /// + /// Should return whether an element has been hit. + bool elementHitTest( + E element, { + required Point point, + required LatLng coordinate, + }); + + final _hits = []; // Avoids repetitive memory reallocation + + @override + @mustCallSuper + bool? hitTest(Offset position) { + _hits.clear(); + bool hasHit = false; + + final point = position.toPoint(); + final coordinate = camera.pointToLatLng(point); + + for (int i = elements.length - 1; i >= 0; i--) { + final element = elements.elementAt(i); + if (hasHit && element.hitValue == null) continue; + if (elementHitTest(element, point: point, coordinate: coordinate)) { + if (element.hitValue != null) _hits.add(element.hitValue!); + hasHit = true; + } + } + + if (!hasHit) { + hitNotifier?.value = null; + return false; + } + + hitNotifier?.value = LayerHitResult( + hitValues: _hits, + coordinate: coordinate, + point: point, + ); + return true; + } +} + +@internal +base mixin HitTestRequiresCameraOrigin> on HitDetectablePainter { + /// Calculated [MapCamera] origin, using the following formula: + /// + /// ```dart + /// camera.project(camera.center).toOffset() - camera.size.toOffset() / 2 + /// ``` + /// + /// Only initialised after [hitTest] is invoked. Recalculated every time + /// [hitTest] is invoked. + late Offset hitTestCameraOrigin; + + @override + bool? hitTest(Offset position) { + hitTestCameraOrigin = + camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; + return super.hitTest(position); + } +} diff --git a/lib/src/layer/shared/layer_interactivity/layer_hit_notifier.dart b/lib/src/layer/shared/layer_interactivity/layer_hit_notifier.dart new file mode 100644 index 000000000..076d118bc --- /dev/null +++ b/lib/src/layer/shared/layer_interactivity/layer_hit_notifier.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; + +import 'package:flutter_map/src/layer/shared/layer_interactivity/layer_hit_result.dart'; + +/// A [ValueNotifier] that notifies: +/// +/// * a [LayerHitResult] when a hit is detected on an element in a layer +/// * `null` when a hit is detected on the layer but not on an element +/// +/// Should be initialised using the following pattern: +/// ```dart +/// final LayerHitNotifier hitNotifier = ValueNotifier(null); +/// ``` +typedef LayerHitNotifier = ValueNotifier?>; + +/// {@template fm.lhn.layerHitNotifier.usage} +/// A notifier to be notified when a hit test occurs on the layer +/// +/// Notified with a [LayerHitResult] if any elements are hit, otherwise +/// notified with `null`. +/// +/// Hit testing still occurs even if this is `null`. +/// +/// See online documentation for more detailed usage instructions. See the +/// example project for an example implementation. +/// {@endtemplate} +// ignore: unused_element, constant_identifier_names +const _doc_fmLHNLayerHitNotiferUsage = null; diff --git a/lib/src/layer/shared/layer_interactivity/layer_hit_result.dart b/lib/src/layer/shared/layer_interactivity/layer_hit_result.dart new file mode 100644 index 000000000..535cac558 --- /dev/null +++ b/lib/src/layer/shared/layer_interactivity/layer_hit_result.dart @@ -0,0 +1,41 @@ +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; + +/// Result emitted by hit notifiers (see [LayerHitNotifier]) when a hit is +/// detected on an element (such as a [Polyline]) within the respective layer +/// +/// Not emitted if the hit was not over an element. +@immutable +class LayerHitResult { + /// `hitValue`s from all elements hit (which have `hitValue`s defined) + /// + /// If an element is hit but has no `hitValue` defined, it will not be + /// included. May be empty. + /// + /// Ordered by their corresponding element, first-to-last, visually + /// top-to-bottom. + final List hitValues; + + /// Geographical coordinates of the detected hit + /// + /// Note that this may not lie on an element. + /// + /// See [point] for the screen point which was hit. + final LatLng coordinate; + + /// Screen point of the detected hit + /// + /// See [coordinate] for the geographical coordinate which was hit. + final Point point; + + /// Construct a new [LayerHitResult] + @internal + const LayerHitResult({ + required this.hitValues, + required this.coordinate, + required this.point, + }); +} diff --git a/lib/src/layer/misc/line_patterns/pixel_hiker.dart b/lib/src/layer/shared/line_patterns/pixel_hiker.dart similarity index 100% rename from lib/src/layer/misc/line_patterns/pixel_hiker.dart rename to lib/src/layer/shared/line_patterns/pixel_hiker.dart diff --git a/lib/src/layer/misc/line_patterns/stroke_pattern.dart b/lib/src/layer/shared/line_patterns/stroke_pattern.dart similarity index 100% rename from lib/src/layer/misc/line_patterns/stroke_pattern.dart rename to lib/src/layer/shared/line_patterns/stroke_pattern.dart diff --git a/lib/src/layer/misc/line_patterns/visible_segment.dart b/lib/src/layer/shared/line_patterns/visible_segment.dart similarity index 100% rename from lib/src/layer/misc/line_patterns/visible_segment.dart rename to lib/src/layer/shared/line_patterns/visible_segment.dart diff --git a/lib/src/layer/misc/mobile_layer_transformer.dart b/lib/src/layer/shared/mobile_layer_transformer.dart similarity index 100% rename from lib/src/layer/misc/mobile_layer_transformer.dart rename to lib/src/layer/shared/mobile_layer_transformer.dart diff --git a/lib/src/layer/misc/translucent_pointer.dart b/lib/src/layer/shared/translucent_pointer.dart similarity index 100% rename from lib/src/layer/misc/translucent_pointer.dart rename to lib/src/layer/shared/translucent_pointer.dart diff --git a/test/full_coverage_test.dart b/test/full_coverage_test.dart index 41f3a3405..3e1f687bd 100644 --- a/test/full_coverage_test.dart +++ b/test/full_coverage_test.dart @@ -14,12 +14,12 @@ import 'package:flutter_map/src/layer/attribution_layer/rich/widget.dart'; import 'package:flutter_map/src/layer/attribution_layer/simple.dart'; import 'package:flutter_map/src/layer/circle_layer/circle_layer.dart'; import 'package:flutter_map/src/layer/marker_layer/marker_layer.dart'; -import 'package:flutter_map/src/layer/misc/hit_detection.dart'; -import 'package:flutter_map/src/layer/misc/mobile_layer_transformer.dart'; -import 'package:flutter_map/src/layer/misc/translucent_pointer.dart'; import 'package:flutter_map/src/layer/overlay_image_layer/overlay_image_layer.dart'; import 'package:flutter_map/src/layer/polygon_layer/polygon_layer.dart'; import 'package:flutter_map/src/layer/polyline_layer/polyline_layer.dart'; +import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; +import 'package:flutter_map/src/layer/shared/mobile_layer_transformer.dart'; +import 'package:flutter_map/src/layer/shared/translucent_pointer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart';