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();