diff --git a/example/lib/pages/polyline.dart b/example/lib/pages/polyline.dart index 92dbc9ff5..9eb11e99a 100644 --- a/example/lib/pages/polyline.dart +++ b/example/lib/pages/polyline.dart @@ -1,9 +1,14 @@ +import 'dart:math'; + +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'; +typedef PolylineHitValue = ({String title, String subtitle}); + class PolylinePage extends StatefulWidget { static const String route = '/polyline'; @@ -14,152 +19,211 @@ class PolylinePage extends StatefulWidget { } class _PolylinePageState extends State { - final PolylineHitNotifier hitNotifier = ValueNotifier(null); + final LayerHitNotifier _hitNotifier = ValueNotifier(null); + List? _prevHitValues; + List>? _hoverLines; - final polylines = { - Polyline( + final _polylinesRaw = >[ + const Polyline( points: [ - const LatLng(51.5, -0.09), - const LatLng(53.3498, -6.2603), - const LatLng(48.8566, 2.3522), + LatLng(51.5, -0.09), + LatLng(53.3498, -6.2603), + LatLng(48.8566, 2.3522), ], strokeWidth: 8, - color: const Color(0xFF60399E), - ): ( - title: 'Elizabeth Line', - subtitle: 'Nothing really special here...', + color: Color(0xFF60399E), + hitValue: ( + title: 'Elizabeth Line', + subtitle: 'Nothing really special here...', + ), ), - Polyline( + const Polyline( points: [ - const LatLng(48.5, -3.09), - const LatLng(47.3498, -9.2603), - const LatLng(43.8566, -1.3522), + LatLng(48.5, -3.09), + LatLng(47.3498, -9.2603), + LatLng(43.8566, -1.3522), ], strokeWidth: 16000, color: Colors.pink, useStrokeWidthInMeter: true, - ): ( - title: 'Pink Line', - subtitle: 'Fixed radius in meters instead of pixels', + hitValue: ( + title: 'Pink Line', + subtitle: 'Fixed radius in meters instead of pixels', + ), ), - Polyline( + const Polyline( points: [ - const LatLng(55.5, -0.09), - const LatLng(54.3498, -6.2603), - const LatLng(52.8566, 2.3522), + LatLng(51.74904, -10.32324), + LatLng(54.3498, -6.2603), + LatLng(52.8566, 2.3522), ], strokeWidth: 4, gradientColors: [ - const Color(0xffE40203), - const Color(0xffFEED00), - const Color(0xff007E2D), + Color(0xffE40203), + Color(0xffFEED00), + Color(0xff007E2D), ], - ): ( - title: 'Traffic Light Line', - subtitle: 'Fancy gradient instead of a solid color', + hitValue: ( + 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), + points: const [ + LatLng(50.5, -0.09), + LatLng(51.3498, 6.2603), + 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', + hitValue: ( + 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), + points: const [ + LatLng(50.2, -0.08), + LatLng(51.2498, -10.2603), + 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', + hitValue: ( + 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), + points: const [ + LatLng(49.1, -0.06), + LatLng(52.15, -1.4), + 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', + hitValue: ( + title: 'YellowBlue Line', + subtitle: 'Solid translucent color fill, with different color outline', + ), ), - }; + ]; + late final _polylines = + Map.fromEntries(_polylinesRaw.map((e) => MapEntry(e.hitValue, e))); - List? hoverLines; + final _randomWalk = [const LatLng(44.861294, 13.845086)]; + + static const double _initialSimplificationTolerance = 0.5; + double simplificationTolerance = _initialSimplificationTolerance; + + @override + void initState() { + super.initState(); + final random = Random(1234); + for (int i = 1; i < 200000; i++) { + final lat = (random.nextDouble() - 0.5) * 0.001; + final lon = (random.nextDouble() - 0.6) * 0.001; + _randomWalk.add( + LatLng( + _randomWalk[i - 1].latitude + lat, + _randomWalk[i - 1].longitude + lon, + ), + ); + } + } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Polylines')), drawer: const MenuDrawer(PolylinePage.route), - body: FlutterMap( - options: const MapOptions( - initialCenter: LatLng(51.5, -0.09), - initialZoom: 5, - ), + body: Stack( children: [ - openStreetMapTileLayer, - 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, + FlutterMap( + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 5, + ), + children: [ + openStreetMapTileLayer, + 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 hoverLines = hitValues.map((v) { + final original = _polylines[v]!; + + return Polyline( + points: original.points, + strokeWidth: + original.strokeWidth + original.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, - ), - onLongPress: () => _openTouchedLinesModal( - 'Long pressed', - hitNotifier.value!.lines, - hitNotifier.value!.point, - ), - onSecondaryTap: () => _openTouchedLinesModal( - 'Secondary tapped', - hitNotifier.value!.lines, - hitNotifier.value!.point, + useStrokeWidthInMeter: original.useStrokeWidthInMeter, + ); + }).toList(); + setState(() => _hoverLines = hoverLines); + }, + onExit: (_) { + _prevHitValues = null; + setState(() => _hoverLines = null); + }, + child: GestureDetector( + onTap: () => _openTouchedLinesModal( + 'Tapped', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.point, + ), + onLongPress: () => _openTouchedLinesModal( + 'Long pressed', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.point, + ), + onSecondaryTap: () => _openTouchedLinesModal( + 'Secondary tapped', + _hitNotifier.value!.hitValues, + _hitNotifier.value!.point, + ), + child: PolylineLayer( + hitNotifier: _hitNotifier, + simplificationTolerance: 0, + polylines: [..._polylinesRaw, ...?_hoverLines], + ), + ), ), - child: PolylineLayer( - hitNotifier: hitNotifier, - polylines: polylines.keys.followedBy(hoverLines ?? []).toList(), + PolylineLayer( + simplificationTolerance: simplificationTolerance, + polylines: [ + Polyline( + points: _randomWalk, + strokeWidth: 3, + color: Colors.deepOrange, + ), + ], ), + ], + ), + Positioned( + left: 16, + top: 16, + right: 16, + child: SimplificationToleranceSlider( + initialTolerance: _initialSimplificationTolerance, + onChangedTolerance: (v) => + setState(() => simplificationTolerance = v), ), ), ], @@ -169,11 +233,9 @@ class _PolylinePageState extends State { void _openTouchedLinesModal( String eventType, - List tappedLines, + List tappedLines, LatLng coords, ) { - tappedLines.removeWhere((e) => !polylines.containsKey(e)); - showModalBottomSheet( context: context, builder: (context) => Padding( @@ -192,7 +254,7 @@ class _PolylinePageState extends State { Expanded( child: ListView.builder( itemBuilder: (context, index) { - final tappedLineData = polylines[tappedLines[index]]!; + final tappedLineData = tappedLines[index]; return ListTile( leading: index == 0 ? const Icon(Icons.vertical_align_top) @@ -224,3 +286,68 @@ class _PolylinePageState extends State { ); } } + +class SimplificationToleranceSlider extends StatefulWidget { + const SimplificationToleranceSlider({ + super.key, + required this.initialTolerance, + required this.onChangedTolerance, + }); + + final double initialTolerance; + final void Function(double) onChangedTolerance; + + @override + State createState() => + _SimplificationToleranceSliderState(); +} + +class _SimplificationToleranceSliderState + extends State { + late double _simplificationTolerance = widget.initialTolerance; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(32), + ), + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 8, top: 4, bottom: 4), + child: Row( + children: [ + const Tooltip( + message: 'Adjust Simplification Tolerance', + child: Row( + children: [ + Icon(Icons.insights), + SizedBox(width: 8), + Icon(Icons.hdr_strong), + ], + ), + ), + Expanded( + child: Slider( + value: _simplificationTolerance, + onChanged: (v) { + if (_simplificationTolerance == 0 && v != 0) { + widget.onChangedTolerance(v); + } + setState(() => _simplificationTolerance = v); + }, + onChangeEnd: widget.onChangedTolerance, + min: 0, + max: 2, + divisions: 100, + label: _simplificationTolerance == 0 + ? 'Disabled' + : _simplificationTolerance.toStringAsFixed(2), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 1351c3793..d1d3ec2db 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -28,12 +28,13 @@ export 'package:flutter_map/src/layer/attribution_layer/rich/source.dart'; 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.dart'; +export 'package:flutter_map/src/layer/general/hit_detection.dart'; export 'package:flutter_map/src/layer/general/mobile_layer_transformer.dart'; export 'package:flutter_map/src/layer/general/translucent_pointer.dart'; export 'package:flutter_map/src/layer/marker_layer.dart'; export 'package:flutter_map/src/layer/overlay_image_layer.dart'; export 'package:flutter_map/src/layer/polygon_layer/polygon_layer.dart'; -export 'package:flutter_map/src/layer/polyline_layer.dart'; +export 'package:flutter_map/src/layer/polyline_layer/polyline_layer.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/general/hit_detection.dart b/lib/src/layer/general/hit_detection.dart new file mode 100644 index 000000000..2e28c211e --- /dev/null +++ b/lib/src/layer/general/hit_detection.dart @@ -0,0 +1,32 @@ +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. + /// + /// Ordered by their corresponding feature, first-to-last, visually + /// top-to-bottom. + final List hitValues; + + /// Coordinates of the detected hit + /// + /// Note that this may not lie on a feature. + final LatLng point; + + @internal + const LayerHitResult({required this.hitValues, 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/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index 414856d7a..fcce81dee 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; @@ -5,8 +6,8 @@ import 'package:flutter_map/src/geo/latlng_bounds.dart'; import 'package:flutter_map/src/layer/general/mobile_layer_transformer.dart'; import 'package:flutter_map/src/layer/polygon_layer/label.dart'; import 'package:flutter_map/src/map/camera/camera.dart'; -import 'package:flutter_map/src/misc/offsets.dart'; import 'package:flutter_map/src/misc/point_extensions.dart'; +import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart' hide Path; // conflict with Path from UI enum PolygonLabelPlacement { @@ -104,40 +105,67 @@ class Polygon { @immutable class PolygonLayer extends StatelessWidget { + /// [Polygon]s to draw final List polygons; - /// screen space culling of polygons based on bounding box + /// Whether to cull polygons and polygon sections that are outside of the + /// viewport + /// + /// Defaults to `true`. final bool polygonCulling; - // Turn on/off per-polygon label drawing on the layer-level. + /// Distance between two mergeable polygon points, in decimal degrees scaled + /// to floored zoom + /// + /// Increasing results in a more jagged, less accurate simplification, with + /// improved performance; and vice versa. + /// + /// Note that this value is internally scaled using the current map zoom, to + /// optimize visual performance in conjunction with improved performance with + /// culling. + /// + /// Defaults to 0.5. Set to 0 to disable simplification. + final double simplificationTolerance; + + /// Whether to draw per-polygon labels + /// + /// Defaults to `true`. final bool polygonLabels; - // Whether to draw labels last and thus over all the polygons. + /// Whether to draw labels last and thus over all the polygons + /// + /// Defaults to `false`. final bool drawLabelsLast; const PolygonLayer({ super.key, required this.polygons, - this.polygonCulling = false, + this.polygonCulling = true, + this.simplificationTolerance = 0.5, this.polygonLabels = true, this.drawLabelsLast = false, }); @override Widget build(BuildContext context) { - final map = MapCamera.of(context); - final size = Size(map.size.x, map.size.y); + final camera = MapCamera.of(context); - final pgons = polygonCulling - ? polygons.where((p) { - return p.boundingBox.isOverlapping(map.visibleBounds); - }).toList() + final culledPolygons = polygonCulling + ? polygons + .where((p) => p.boundingBox.isOverlapping(camera.visibleBounds)) + .toList() : polygons; return MobileLayerTransformer( child: CustomPaint( - painter: PolygonPainter(pgons, map, polygonLabels, drawLabelsLast), - size: size, + painter: PolygonPainter( + polygons: culledPolygons, + camera: camera, + polygonLabels: polygonLabels, + drawLabelsLast: drawLabelsLast, + simplificationTolerance: simplificationTolerance, + ), + size: Size(camera.size.x, camera.size.y), isComplex: true, ), ); @@ -146,14 +174,19 @@ class PolygonLayer extends StatelessWidget { class PolygonPainter extends CustomPainter { final List polygons; - final MapCamera map; + final MapCamera camera; final LatLngBounds bounds; final bool polygonLabels; final bool drawLabelsLast; + final double simplificationTolerance; - PolygonPainter( - this.polygons, this.map, this.polygonLabels, this.drawLabelsLast) - : bounds = map.visibleBounds; + PolygonPainter({ + required this.polygons, + required this.camera, + required this.polygonLabels, + required this.simplificationTolerance, + required this.drawLabelsLast, + }) : bounds = camera.visibleBounds; int get hash { _hash ??= Object.hashAll(polygons); @@ -165,8 +198,30 @@ class PolygonPainter extends CustomPainter { ({Offset min, Offset max}) getBounds(Offset origin, Polygon polygon) { final bbox = polygon.boundingBox; return ( - min: getOffset(map, origin, bbox.southWest), - max: getOffset(map, origin, bbox.northEast), + min: getOffset(origin, bbox.southWest), + max: getOffset(origin, bbox.northEast), + ); + } + + Offset getOffset(Offset origin, LatLng point) { + // Critically create as little garbage as possible. This is called on every frame. + final projected = camera.project(point); + return Offset(projected.x - origin.dx, projected.y - origin.dy); + } + + List getOffsets(Offset origin, List points) { + final renderedPoints = simplificationTolerance != 0 + ? simplify( + points, + simplificationTolerance / pow(2, camera.zoom.floor()), + highestQuality: true, + ) + : points; + + return List.generate( + renderedPoints.length, + (index) => getOffset(origin, renderedPoints[index]), + growable: false, ); } @@ -205,14 +260,14 @@ class PolygonPainter extends CustomPainter { lastHash = null; } - final origin = (map.project(map.center) - map.size / 2).toOffset(); + final origin = (camera.project(camera.center) - camera.size / 2).toOffset(); // Main loop constructing batched fill and border paths from given polygons. for (final polygon in polygons) { if (polygon.points.isEmpty) { continue; } - final offsets = getOffsets(map, origin, polygon.points); + final offsets = getOffsets(origin, polygon.points); // 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 @@ -242,7 +297,7 @@ class PolygonPainter extends CustomPainter { final holeOffsetsList = List>.generate( holePointsList.length, - (i) => getOffsets(map, origin, holePointsList[i]), + (i) => getOffsets(origin, holePointsList[i]), growable: false, ); @@ -266,11 +321,11 @@ class PolygonPainter extends CustomPainter { // The painter will be null if the layouting algorithm determined that // there isn't enough space. final painter = buildLabelTextPainter( - mapSize: map.size, - placementPoint: map.getOffsetFromOrigin(polygon.labelPosition), + mapSize: camera.size, + placementPoint: camera.getOffsetFromOrigin(polygon.labelPosition), bounds: getBounds(origin, polygon), textPainter: polygon.textPainter!, - rotationRad: map.rotationRad, + rotationRad: camera.rotationRad, rotate: polygon.rotateLabel, padding: 20, ); @@ -294,12 +349,12 @@ class PolygonPainter extends CustomPainter { final textPainter = polygon.textPainter; if (textPainter != null) { final painter = buildLabelTextPainter( - mapSize: map.size, + mapSize: camera.size, placementPoint: - map.project(polygon.labelPosition).toOffset() - origin, + camera.project(polygon.labelPosition).toOffset() - origin, bounds: getBounds(origin, polygon), textPainter: textPainter, - rotationRad: map.rotationRad, + rotationRad: camera.rotationRad, rotate: polygon.rotateLabel, padding: 20, ); diff --git a/lib/src/layer/polyline_layer.dart b/lib/src/layer/polyline_layer/painter.dart similarity index 55% rename from lib/src/layer/polyline_layer.dart rename to lib/src/layer/polyline_layer/painter.dart index af4530711..6637c4a84 100644 --- a/lib/src/layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -1,197 +1,47 @@ -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'; -import 'package:flutter_map/src/map/camera/camera.dart'; -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; - final Color color; - final double borderStrokeWidth; - final Color? borderColor; - final List? gradientColors; - final List? colorsStop; - final bool isDotted; - final StrokeCap strokeCap; - final StrokeJoin strokeJoin; - final bool useStrokeWidthInMeter; - - LatLngBounds? _boundingBox; - - LatLngBounds get boundingBox => - _boundingBox ??= LatLngBounds.fromPoints(points); - - Polyline({ - required this.points, - this.strokeWidth = 1.0, - this.color = const Color(0xFF00FF00), - this.borderStrokeWidth = 0.0, - this.borderColor = const Color(0xFFFFFF00), - this.gradientColors, - this.colorsStop, - this.isDotted = false, - this.strokeCap = StrokeCap.round, - this.strokeJoin = StrokeJoin.round, - this.useStrokeWidthInMeter = false, - }); - - @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, - ); +part of 'polyline_layer.dart'; - @override - int get hashCode => Object.hash(points, renderHashCode); -} - -@immutable -class PolylineLayer extends StatelessWidget { - final List polylines; - - /// 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. +class PolylinePainter extends CustomPainter { + final List> polylines; + final MapCamera camera; + final LayerHitNotifier? hitNotifier; final double minimumHitbox; - const PolylineLayer({ - super.key, - required this.polylines, - this.hitNotifier, - this.minimumHitbox = 10, - // TODO: Remove once PR #1704 is merged - bool polylineCulling = true, - }); + final _hits = []; // Avoids repetitive memory reallocation - @override - Widget build(BuildContext context) { - final camera = MapCamera.of(context); - - return MobileLayerTransformer( - child: CustomPaint( - painter: _PolylinePainter( - polylines: polylines - .where((p) => p.boundingBox.isOverlapping(camera.visibleBounds)) - .toList(), - camera: camera, - hitNotifier: hitNotifier, - minimumHitbox: minimumHitbox, - ), - size: Size(camera.size.x, camera.size.y), - isComplex: true, - ), - ); - } -} - -class _PolylinePainter extends CustomPainter { - final List polylines; - final MapCamera camera; - final LatLngBounds bounds; - final PolylineHitNotifier? hitNotifier; - final double minimumHitbox; + int get hash => _hash ??= Object.hashAll(polylines); + int? _hash; - _PolylinePainter({ + PolylinePainter({ required this.polylines, required this.camera, required this.hitNotifier, required this.minimumHitbox, - }) : bounds = camera.visibleBounds; + }); - // Avoids reallocation on every `hitTest`, is cleared every time - final hits = List.empty(growable: true); + List getOffsets(Offset origin, List points) => List.generate( + points.length, + (index) => getOffset(origin, points[index]), + growable: false, + ); - int get hash => _hash ??= Object.hashAll(polylines); - int? _hash; + Offset getOffset(Offset origin, LatLng point) { + // Critically create as little garbage as possible. This is called on every frame. + final projected = camera.project(point); + return Offset(projected.x - origin.dx, projected.y - origin.dy); + } @override bool? hitTest(Offset position) { if (hitNotifier == null) return null; - hits.clear(); + _hits.clear(); final origin = camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; - for (final p in polylines.reversed) { + for (final polyline in polylines.reversed) { + if (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 stroke width. @@ -199,17 +49,19 @@ class _PolylinePainter extends CustomPainter { // continue; // } - final offsets = getOffsets(camera, origin, p.points); - final strokeWidth = p.useStrokeWidthInMeter + final offsets = getOffsets(origin, polyline.points); + final strokeWidth = polyline.useStrokeWidthInMeter ? _metersToStrokeWidth( origin, - p.points.first, + polyline.points.first, offsets.first, - p.strokeWidth, + polyline.strokeWidth, ) - : p.strokeWidth; - final hittableDistance = - math.max(strokeWidth / 2 + p.borderStrokeWidth / 2, minimumHitbox); + : 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]; @@ -225,19 +77,19 @@ class _PolylinePainter extends CustomPainter { )); if (distance < hittableDistance) { - hits.add(p); + _hits.add(polyline.hitValue!); break; } } } - if (hits.isEmpty) { + if (_hits.isEmpty) { hitNotifier!.value = null; return false; } - hitNotifier!.value = PolylineHit._( - lines: hits, + hitNotifier!.value = LayerHitResult( + hitValues: _hits, point: camera.pointToLatLng(math.Point(position.dx, position.dy)), ); return true; @@ -286,7 +138,7 @@ class _PolylinePainter extends CustomPainter { camera.project(camera.center).toOffset() - camera.size.toOffset() / 2; for (final polyline in polylines) { - final offsets = getOffsets(camera, origin, polyline.points); + final offsets = getOffsets(origin, polyline.points); if (offsets.isEmpty) { continue; } @@ -425,16 +277,12 @@ class _PolylinePainter extends CustomPainter { double strokeWidthInMeters, ) { final r = _distance.offset(p0, strokeWidthInMeters, 180); - final delta = o0 - getOffset(camera, origin, r); + final delta = o0 - getOffset(origin, r); return delta.distance; } @override - bool shouldRepaint(_PolylinePainter oldDelegate) { - return oldDelegate.bounds != bounds || - oldDelegate.polylines.length != polylines.length || - oldDelegate.hash != hash; - } + bool shouldRepaint(PolylinePainter oldDelegate) => false; } double _distanceSq(double x0, double y0, double x1, double y1) { diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart new file mode 100644 index 000000000..896285f26 --- /dev/null +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -0,0 +1,89 @@ +part of 'polyline_layer.dart'; + +@immutable +class Polyline { + final List points; + final double strokeWidth; + final Color color; + final double borderStrokeWidth; + final Color? borderColor; + final List? gradientColors; + final List? colorsStop; + final bool isDotted; + final StrokeCap strokeCap; + final StrokeJoin strokeJoin; + 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.==]. + final R? hitValue; + + const Polyline({ + required this.points, + this.strokeWidth = 1.0, + this.color = const Color(0xFF00FF00), + this.borderStrokeWidth = 0.0, + this.borderColor = const Color(0xFFFFFF00), + this.gradientColors, + this.colorsStop, + this.isDotted = false, + this.strokeCap = StrokeCap.round, + this.strokeJoin = StrokeJoin.round, + this.useStrokeWidthInMeter = false, + this.hitValue, + }); + + Polyline copyWithNewPoints(List points) => Polyline( + points: points, + strokeWidth: strokeWidth, + color: color, + borderStrokeWidth: borderStrokeWidth, + borderColor: borderColor, + gradientColors: gradientColors, + colorsStop: colorsStop, + isDotted: isDotted, + strokeCap: strokeCap, + strokeJoin: strokeJoin, + useStrokeWidthInMeter: useStrokeWidthInMeter, + hitValue: hitValue, + ); + + @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 && + hitValue == other.hitValue); + + /// Used to batch draw calls to the canvas + int get renderHashCode => Object.hash( + strokeWidth, + color, + borderStrokeWidth, + borderColor, + gradientColors, + colorsStop, + isDotted, + strokeCap, + strokeJoin, + useStrokeWidthInMeter, + hitValue, + ); + + @override + int get hashCode => Object.hashAll([...points, renderHashCode]); +} diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart new file mode 100644 index 000000000..fddb16a2f --- /dev/null +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -0,0 +1,219 @@ +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/flutter_map.dart'; +import 'package:flutter_map/src/misc/simplify.dart'; +import 'package:latlong2/latlong.dart'; + +part 'painter.dart'; +part 'polyline.dart'; + +@immutable +class PolylineLayer extends StatefulWidget { + /// [Polyline]s to draw + final List> polylines; + + /// Acceptable extent outside of viewport before culling polyline segments + /// + /// May need to be increased if the [Polyline.strokeWidth] + + /// [Polyline.borderStrokeWidth] is large. See online documentation for more + /// information. + /// + /// Defaults to 10. Set to `null` to disable culling. + final double? cullingMargin; + + /// Distance between two mergeable polyline points, in decimal degrees scaled + /// to floored zoom + /// + /// Increasing results in a more jagged, less accurate simplification, with + /// improved performance; and vice versa. + /// + /// Note that this value is internally scaled using the current map zoom, to + /// optimize visual performance in conjunction with improved performance with + /// culling. + /// + /// Defaults to 0.5. Set to 0 to disable simplification. + final double simplificationTolerance; + + /// A notifier to be notified when a hit test occurs on the layer + /// + /// If a notifier is not provided, hit testing is not performed. + /// + /// Notified with a [LayerHitResult] if any polylines are hit, otherwise + /// notified with `null`. + /// + /// See online documentation for more detailed usage instructions. See the + /// example project for an example implementation. + final LayerHitNotifier? 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.cullingMargin = 10, + this.simplificationTolerance = 0.5, + this.hitNotifier, + this.minimumHitbox = 10, + }); + + @override + State> createState() => _PolylineLayerState(); +} + +class _PolylineLayerState extends State> { + final _cachedSimplifiedPolylines = >>{}; + + final _culledPolylines = + >[]; // Avoids repetitive memory reallocation + + @override + void didUpdateWidget(PolylineLayer oldWidget) { + super.didUpdateWidget(oldWidget); + + // IF old yes & new no, clear + // IF old no & new yes, compute + // IF old no & new no, nothing + // IF old yes & new yes & (different tolerance | different lines), both + // otherwise, nothing + if (oldWidget.simplificationTolerance != 0 && + widget.simplificationTolerance != 0 && + (!listEquals(oldWidget.polylines, widget.polylines) || + oldWidget.simplificationTolerance != + widget.simplificationTolerance)) { + _cachedSimplifiedPolylines.clear(); + _computeZoomLevelSimplification(MapCamera.of(context).zoom.floor()); + } else if (oldWidget.simplificationTolerance != 0 && + widget.simplificationTolerance == 0) { + _cachedSimplifiedPolylines.clear(); + } else if (oldWidget.simplificationTolerance == 0 && + widget.simplificationTolerance != 0) { + _computeZoomLevelSimplification(MapCamera.of(context).zoom.floor()); + } + } + + @override + Widget build(BuildContext context) { + final camera = MapCamera.of(context); + + return MobileLayerTransformer( + child: CustomPaint( + painter: PolylinePainter( + polylines: _aggressivelyCullPolylines( + polylines: widget.simplificationTolerance == 0 + ? widget.polylines + : _computeZoomLevelSimplification(camera.zoom.floor()), + camera: camera, + cullingMargin: widget.cullingMargin, + ), + camera: camera, + hitNotifier: widget.hitNotifier, + minimumHitbox: widget.minimumHitbox, + ), + size: Size(camera.size.x, camera.size.y), + ), + ); + } + + List> _computeZoomLevelSimplification(int zoom) => + _cachedSimplifiedPolylines[zoom] ??= widget.polylines + .map( + (polyline) => polyline.copyWithNewPoints( + simplify( + polyline.points, + widget.simplificationTolerance / math.pow(2, zoom), + highestQuality: true, + ), + ), + ) + .toList(); + + List> _aggressivelyCullPolylines({ + required List> polylines, + required MapCamera camera, + required double? cullingMargin, + }) { + if (cullingMargin == null) return polylines; + + _culledPolylines.clear(); + + final bounds = camera.visibleBounds; + final margin = cullingMargin / math.pow(2, camera.zoom.floorToDouble()); + // The min(-90), max(180), ... are used to get around the limits of LatLng + // the value cannot be greater or smaller than that + final boundsAdjusted = LatLngBounds( + LatLng( + math.max(-90, bounds.southWest.latitude - margin), + math.max(-180, bounds.southWest.longitude - margin), + ), + LatLng( + math.min(90, bounds.northEast.latitude + margin), + math.min(180, bounds.northEast.longitude + margin), + ), + ); + + for (final polyline in polylines) { + // Gradient poylines cannot be easily segmented + if (polyline.gradientColors != null) { + _culledPolylines.add(polyline); + continue; + } + // pointer that indicates the start of the visible polyline segment + int start = -1; + bool fullyVisible = true; + for (int i = 0; i < polyline.points.length - 1; i++) { + //current pair + final p1 = polyline.points[i]; + final p2 = polyline.points[i + 1]; + + // segment is visible + if (Bounds( + math.Point( + boundsAdjusted.southWest.longitude, + boundsAdjusted.southWest.latitude, + ), + math.Point( + boundsAdjusted.northEast.longitude, + boundsAdjusted.northEast.latitude, + ), + ).aabbContainsLine( + p1.longitude, p1.latitude, p2.longitude, p2.latitude)) { + // segment is visible + if (start == -1) { + start = i; + } + if (!fullyVisible && i == polyline.points.length - 2) { + final segment = polyline.points.sublist(start, i + 2); + _culledPolylines.add(polyline.copyWithNewPoints(segment)); + } + } else { + fullyVisible = false; + // if we cannot see the segment, then reset start + if (start != -1) { + // partial start + final segment = polyline.points.sublist(start, i + 1); + _culledPolylines.add(polyline.copyWithNewPoints(segment)); + start = -1; + } + if (start != -1) { + start = i; + } + } + } + + if (fullyVisible) _culledPolylines.add(polyline); + } + + return _culledPolylines; + } +} diff --git a/lib/src/misc/bounds.dart b/lib/src/misc/bounds.dart index 5ef5df29d..a593db8a5 100644 --- a/lib/src/misc/bounds.dart +++ b/lib/src/misc/bounds.dart @@ -88,6 +88,32 @@ class Bounds { (b.max.y >= min.y); } + bool aabbContainsLine(num x1, num y1, num x2, num y2) { + // Completely outside. + if ((x1 <= min.x && x2 <= min.x) || + (y1 <= min.y && y2 <= min.y) || + (x1 >= max.x && x2 >= max.x) || + (y1 >= max.y && y2 >= max.y)) { + return false; + } + + final m = (y2 - y1) / (x2 - x1); + + num y = m * (min.x - x1) + y1; + if (y > min.y && y < max.y) return true; + + y = m * (max.x - x1) + y1; + if (y > min.y && y < max.y) return true; + + num x = (min.y - y1) / m + x1; + if (x > min.x && x < max.x) return true; + + x = (max.y - y1) / m + x1; + if (x > min.x && x < max.x) return true; + + return false; + } + /// Calculates the intersection of two Bounds. The return value will be null /// if there is no intersection. The returned bounds may be zero size /// (bottomLeft == topRight). diff --git a/lib/src/misc/simplify.dart b/lib/src/misc/simplify.dart new file mode 100644 index 000000000..ef4574e4e --- /dev/null +++ b/lib/src/misc/simplify.dart @@ -0,0 +1,119 @@ +// implementation based on +// https://github.com/mourner/simplify-js/blob/master/simplify.js + +import 'package:latlong2/latlong.dart'; + +double _getSqDist( + LatLng p1, + LatLng p2, +) { + final double dx = p1.longitude - p2.longitude; + final double dy = p1.latitude - p2.latitude; + return dx * dx + dy * dy; +} + +/// square distance from a point to a segment +double _getSqSegDist( + LatLng p, + LatLng p1, + LatLng p2, +) { + double x = p1.longitude; + double y = p1.latitude; + double dx = p2.longitude - x; + double dy = p2.latitude - y; + if (dx != 0 || dy != 0) { + final double t = + ((p.longitude - x) * dx + (p.latitude - y) * dy) / (dx * dx + dy * dy); + if (t > 1) { + x = p2.longitude; + y = p2.latitude; + } else if (t > 0) { + x += dx * t; + y += dy * t; + } + } + + dx = p.longitude - x; + dy = p.latitude - y; + + return dx * dx + dy * dy; +} + +List simplifyRadialDist( + List points, + double sqTolerance, +) { + LatLng prevPoint = points[0]; + final List newPoints = [prevPoint]; + late LatLng point; + for (int i = 1, len = points.length; i < len; i++) { + point = points[i]; + if (_getSqDist(point, prevPoint) > sqTolerance) { + newPoints.add(point); + prevPoint = point; + } + } + if (prevPoint != point) { + newPoints.add(point); + } + return newPoints; +} + +void _simplifyDPStep( + List points, + int first, + int last, + double sqTolerance, + List simplified, +) { + double maxSqDist = sqTolerance; + late int index; + for (int i = first + 1; i < last; i++) { + final double sqDist = _getSqSegDist(points[i], points[first], points[last]); + + if (sqDist > maxSqDist) { + index = i; + maxSqDist = sqDist; + } + } + if (maxSqDist > sqTolerance) { + if (index - first > 1) { + _simplifyDPStep(points, first, index, sqTolerance, simplified); + } + simplified.add(points[index]); + if (last - index > 1) { + _simplifyDPStep(points, index, last, sqTolerance, simplified); + } + } +} + +// simplification using Ramer-Douglas-Peucker algorithm +List simplifyDouglasPeucker( + List points, + double sqTolerance, +) { + final int last = points.length - 1; + final List simplified = [points[0]]; + _simplifyDPStep(points, 0, last, sqTolerance, simplified); + simplified.add(points[last]); + return simplified; +} + +/// high quality simplification uses the Ramer-Douglas-Peucker algorithm +/// otherwise it just merges close points +List simplify( + List points, + double tolerance, { + bool highestQuality = false, +}) { + if (points.length <= 2) return points; + + List nextPoints = points; + final double sqTolerance = tolerance * tolerance; + nextPoints = + highestQuality ? points : simplifyRadialDist(nextPoints, sqTolerance); + nextPoints = simplifyDouglasPeucker(nextPoints, sqTolerance); + + return nextPoints; +} diff --git a/test/full_coverage_test.dart b/test/full_coverage_test.dart index 0f567f3ff..934c834e1 100644 --- a/test/full_coverage_test.dart +++ b/test/full_coverage_test.dart @@ -19,7 +19,7 @@ import 'package:flutter_map/src/layer/marker_layer.dart'; import 'package:flutter_map/src/layer/overlay_image_layer.dart'; import 'package:flutter_map/src/layer/polygon_layer/label.dart'; import 'package:flutter_map/src/layer/polygon_layer/polygon_layer.dart'; -import 'package:flutter_map/src/layer/polyline_layer.dart'; +import 'package:flutter_map/src/layer/polyline_layer/polyline_layer.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'; diff --git a/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index e2290ae70..566bb9fab 100644 --- a/test/layer/polyline_layer_test.dart +++ b/test/layer/polyline_layer_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_map/src/layer/polyline_layer.dart'; +import 'package:flutter_map/src/layer/polyline_layer/polyline_layer.dart'; import 'package:flutter_map/src/map/widget.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; diff --git a/test/test_utils/test_app.dart b/test/test_utils/test_app.dart index e0ea2cc21..f4cf83993 100644 --- a/test/test_utils/test_app.dart +++ b/test/test_utils/test_app.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/src/layer/circle_layer.dart'; import 'package:flutter_map/src/layer/marker_layer.dart'; import 'package:flutter_map/src/layer/polygon_layer/polygon_layer.dart'; -import 'package:flutter_map/src/layer/polyline_layer.dart'; +import 'package:flutter_map/src/layer/polyline_layer/polyline_layer.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_layer.dart'; import 'package:flutter_map/src/map/controller/map_controller.dart'; import 'package:flutter_map/src/map/options/options.dart'; diff --git a/windowsApplicationInstallerSetup.iss b/windowsApplicationInstallerSetup.iss index 654ab3fff..e84dd6624 100644 --- a/windowsApplicationInstallerSetup.iss +++ b/windowsApplicationInstallerSetup.iss @@ -62,7 +62,7 @@ Name: "ukrainian"; MessagesFile: "compiler:Languages\Ukrainian.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked -; Specify all files within 'build/windows/runner/Release' except 'example.exe' +; Specify all files within 'build/windows/x64/runner/Release' except 'example.exe' [Files] Source: "example\build\windows\x64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion Source: "example\build\windows\x64\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion