diff --git a/example/lib/pages/polyline.dart b/example/lib/pages/polyline.dart index 41f2e65b6..32fa7314f 100644 --- a/example/lib/pages/polyline.dart +++ b/example/lib/pages/polyline.dart @@ -31,7 +31,7 @@ class _PolylinePageState extends State { strokeWidth: 8, color: const Color(0xFF60399E), hitValue: ( - title: 'Elizabeth Line', + title: 'Purple Line', subtitle: 'Nothing really special here...', ), ), @@ -77,22 +77,7 @@ class _PolylinePageState extends State { borderStrokeWidth: 20, borderColor: Colors.red.withOpacity(0.4), hitValue: ( - title: 'BlueRed Line', - subtitle: 'Solid translucent color fill, with different color outline', - ), - ), - Polyline( - 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, - hitValue: ( - title: 'BlackWhite Line', + title: 'Bordered Line', subtitle: 'Solid translucent color fill, with different color outline', ), ), @@ -107,7 +92,7 @@ class _PolylinePageState extends State { borderStrokeWidth: 10, borderColor: Colors.blue.withOpacity(0.5), hitValue: ( - title: 'YellowBlue Line', + title: 'BorderedLine 2', subtitle: 'Solid translucent color fill, with different color outline', ), ), @@ -118,15 +103,48 @@ class _PolylinePageState extends State { LatLng(35.566530, 5.584283), ], strokeWidth: 10, - color: Colors.blueAccent, - isDotted: true, - segmentSpacingFactor: 3, + color: Colors.orange, + pattern: const PolylinePattern.dotted( + spacingFactor: 3, + ), borderStrokeWidth: 8, borderColor: Colors.blue.withOpacity(0.5), hitValue: ( - title: 'Blue Dotted Line with Custom Spacing', - subtitle: - 'Dotted line with segment spacing controlled by `segmentSpacingFactor`', + title: 'Orange line', + subtitle: 'Dotted pattern', + ), + ), + // Paris-Nice TGV + Polyline( + points: const [ + // Paris + LatLng(48.8567, 2.3519), + // Lyon + LatLng(45.7256, 5.0811), + // Avignon + LatLng(43.95, 4.8169), + // Aix-en-Provence + LatLng(43.5311, 5.4539), + // Marseille + LatLng(43.2964, 5.37), + // Toulon + LatLng(43.1222, 5.93), + // Cannes + LatLng(43.5514, 7.0128), + // Antibes + LatLng(43.5808, 7.1239), + // Nice + LatLng(43.6958, 7.2714), + ], + strokeWidth: 6, + color: Colors.green[900]!, + pattern: PolylinePattern.dashed( + segments: const [50, 20, 30, 20], + ), + borderStrokeWidth: 6, + hitValue: ( + title: 'Green Line', + subtitle: 'Dashed line', ), ), ]; diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index 2203a7c3f..ba88e78a8 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -159,7 +159,9 @@ class _PolylinePainter extends CustomPainter { strokeWidth = polyline.strokeWidth; } - final isDotted = polyline.isDotted; + final isDashed = polyline.pattern.segments != null; + final isDotted = polyline.pattern.spacingFactor != null; + paint = Paint() ..strokeWidth = strokeWidth ..strokeCap = polyline.strokeCap @@ -200,12 +202,20 @@ class _PolylinePainter extends CustomPainter { final borderRadius = (borderPaint?.strokeWidth ?? 0) / 2; if (isDotted) { - final spacing = strokeWidth * polyline.segmentSpacingFactor; + final spacing = strokeWidth * polyline.pattern.spacingFactor!; + if (borderPaint != null && filterPaint != null) { + _paintDottedLine( + borderPath, offsets, borderRadius, spacing, polyline.pattern); + _paintDottedLine( + filterPath, offsets, radius, spacing, polyline.pattern); + } + _paintDottedLine(path, offsets, radius, spacing, polyline.pattern); + } else if (isDashed) { if (borderPaint != null && filterPaint != null) { - _paintDottedLine(borderPath, offsets, borderRadius, spacing); - _paintDottedLine(filterPath, offsets, radius, spacing); + _paintDashedLine(borderPath, offsets, polyline.pattern); + _paintDashedLine(filterPath, offsets, polyline.pattern); } - _paintDottedLine(path, offsets, radius, spacing); + _paintDashedLine(path, offsets, polyline.pattern); } else { if (borderPaint != null && filterPaint != null) { _paintLine(borderPath, offsets); @@ -219,25 +229,127 @@ class _PolylinePainter extends CustomPainter { } void _paintDottedLine( - ui.Path path, List offsets, double radius, double stepLength) { - var startDistance = 0.0; - for (var i = 0; i < offsets.length - 1; i++) { - final o0 = offsets[i]; - final o1 = offsets[i + 1]; - final totalDistance = (o0 - o1).distance; - var distance = startDistance; - while (distance < totalDistance) { - final f1 = distance / totalDistance; - final f0 = 1.0 - f1; - final offset = Offset(o0.dx * f0 + o1.dx * f1, o0.dy * f0 + o1.dy * f1); - path.addOval(Rect.fromCircle(center: offset, radius: radius)); - distance += stepLength; + ui.Path path, + List offsets, + double radius, + double stepLength, + PolylinePattern pattern, + ) { + final PatternFit patternFit = pattern.patternFit!; + + if (offsets.isEmpty) return; + + if (offsets.length == 1) { + path.addOval(Rect.fromCircle(center: offsets.last, radius: radius)); + return; + } + + int offsetIndex = 0; + + Offset offset0 = offsets[offsetIndex++]; + Offset offset1 = offsets[offsetIndex++]; + + final _PixelHiker hiker = _PixelHiker.dotted( + offsets: offsets, + stepLength: stepLength, + patternFit: patternFit, + ); + path.addOval(Rect.fromCircle(center: offsets.first, radius: radius)); + while (true) { + final Offset newOffset = hiker.getIntermediateOffset(offset0, offset1); + + if (hiker.goToNextOffsetIfNeeded()) { + if (offsetIndex >= offsets.length) { + if (patternFit != PatternFit.none) { + path.addOval(Rect.fromCircle(center: newOffset, radius: radius)); + } + return; + } + offset0 = offset1; + offset1 = offsets[offsetIndex++]; + } else { + offset0 = newOffset; + } + + if (hiker.goToNextSegmentIfNeeded()) { + path.addOval(Rect.fromCircle(center: newOffset, radius: radius)); } - startDistance = distance < totalDistance - ? stepLength - (totalDistance - distance) - : distance - totalDistance; } - path.addOval(Rect.fromCircle(center: offsets.last, radius: radius)); + } + + void _paintDashedLine( + ui.Path path, + List offsets, + PolylinePattern pattern, + ) { + final List segmentValues = pattern.segments!; + final PatternFit patternFit = pattern.patternFit!; + + if (offsets.length < 2 || + segmentValues.length < 2 || + segmentValues.length.isOdd) { + return; + } + + int offsetIndex = 0; + + Offset offset0 = offsets[offsetIndex++]; + Offset offset1 = offsets[offsetIndex++]; + + Offset? latestMoveTo; + + void moveTo(final Offset offset) { + latestMoveTo = offset; + } + + void lineTo(final Offset offset) { + if (latestMoveTo != null) { + path.moveTo(latestMoveTo!.dx, latestMoveTo!.dy); + latestMoveTo = null; + } + path.lineTo(offset.dx, offset.dy); + } + + final _PixelHiker hiker = _PixelHiker.dashed( + offsets: offsets, + segmentValues: segmentValues, + patternFit: patternFit, + ); + moveTo(offset0); + while (true) { + final Offset newOffset = hiker.getIntermediateOffset(offset0, offset1); + + if (hiker.segmentIndex.isOdd) { + if (hiker.isLastSegment && patternFit == PatternFit.extendFinalDash) { + lineTo(newOffset); + } else { + moveTo(newOffset); + } + } else { + lineTo(newOffset); + } + + if (hiker.goToNextOffsetIfNeeded()) { + // was it the last point? + if (offsetIndex >= offsets.length) { + if (hiker.segmentIndex.isOdd) { + // Were we on a "space-dash"? + if (patternFit == PatternFit.appendDot) { + // Add a dot at the new point. + moveTo(newOffset); + lineTo(newOffset); + } + } + return; + } + offset0 = offset1; + offset1 = offsets[offsetIndex++]; + } else { + offset0 = newOffset; + } + + hiker.goToNextSegmentIfNeeded(); + } } void _paintLine(ui.Path path, List offsets) { @@ -289,3 +401,146 @@ class _PolylinePainter extends CustomPainter { } const _distance = Distance(); + +class _PixelHiker { + final double _polylinePixelDistance; + final List _segmentValues; + + /// Factor to be used on offset distances. + late final double _factor; + + double _distanceSoFar = 0; + int _segmentIndex = 0; + + _PixelHiker.dotted({ + required List offsets, + required double stepLength, + required PatternFit patternFit, + }) : _polylinePixelDistance = _getPolylinePixelDistance(offsets), + _segmentValues = [stepLength] { + _factor = _getDottedFactor(patternFit); + _setRemaining(_segmentValues[_segmentIndex]); + } + + _PixelHiker.dashed({ + required List offsets, + required List segmentValues, + required PatternFit patternFit, + }) : _polylinePixelDistance = _getPolylinePixelDistance(offsets), + _segmentValues = segmentValues { + _factor = _getDashedFactor(patternFit); + _setRemaining(_segmentValues[_segmentIndex]); + } + + /// Segment pixel length remaining. + late double _remaining; + void _setRemaining(double value) { + _remaining = value; + _distanceSoFar += value; + } + + int get segmentIndex => _segmentIndex; + + bool get isLastSegment => _polylinePixelDistance - _distanceSoFar < 0; + bool _doneWithCurrentOffset = false; + + bool goToNextOffsetIfNeeded() { + if (_doneWithCurrentOffset) { + _doneWithCurrentOffset = false; + return true; + } + return false; + } + + bool goToNextSegmentIfNeeded() { + if (_remaining == 0) { + _segmentIndex++; + _setRemaining(_segmentValues[_segmentIndex % _segmentValues.length]); + return true; + } + return false; + } + + /// Returns the offset on segment [A,B] that matches the remaining distance. + Offset getIntermediateOffset(final Offset offsetA, final Offset offsetB) { + final segmentDistance = _factor * (offsetA - offsetB).distance; + if (_remaining >= segmentDistance) { + _remaining -= segmentDistance; + _doneWithCurrentOffset = true; + return offsetB; + } + final fB = _remaining / segmentDistance; + final fA = 1.0 - fB; + _setRemaining(0); + return Offset( + offsetA.dx * fA + offsetB.dx * fB, + offsetA.dy * fA + offsetB.dy * fB, + ); + } + + static double _getPolylinePixelDistance(List offsets) { + double result = 0; + if (offsets.length < 2) { + return result; + } + for (int i = 1; i < offsets.length; i++) { + final Offset offsetA = offsets[i - 1]; + final Offset offsetB = offsets[i]; + result += (offsetA - offsetB).distance; + } + return result; + } + + double _getDottedFactor(PatternFit patternFit) { + if (patternFit != PatternFit.scaleDown && + patternFit != PatternFit.scaleUp) { + return 1; + } + + if (_polylinePixelDistance == 0) { + return 0; + } + + final double stepLength = _segmentValues.first; + final double factor = _polylinePixelDistance / stepLength; + + if (patternFit == PatternFit.scaleDown) { + return (factor.ceil() * stepLength + stepLength) / _polylinePixelDistance; + } + return (factor.floor() * stepLength + stepLength) / _polylinePixelDistance; + } + + /// Returns the factor for offset distances so that the dash pattern fits. + /// + /// The idea is that we need to be able to display the dash pattern completely + /// n times (at least once), plus once the initial dash segment. That's the + /// way we deal with the "ending" side-effect. + double _getDashedFactor(PatternFit patternFit) { + if (patternFit != PatternFit.scaleDown && + patternFit != PatternFit.scaleUp) { + return 1; + } + + if (_polylinePixelDistance == 0) { + return 0; + } + + double getTotalSegmentDistance(List segmentValues) { + double result = 0; + for (final double value in segmentValues) { + result += value; + } + return result; + } + + final double totalDashDistance = getTotalSegmentDistance(_segmentValues); + final double firstDashDistance = _segmentValues.first; + final double factor = _polylinePixelDistance / totalDashDistance; + if (patternFit == PatternFit.scaleDown) { + return (factor.ceil() * totalDashDistance + firstDashDistance) / + _polylinePixelDistance; + } + return (factor.floor() * totalDashDistance + firstDashDistance) / + _polylinePixelDistance; + } +} diff --git a/lib/src/layer/polyline_layer/pattern.dart b/lib/src/layer/polyline_layer/pattern.dart new file mode 100644 index 000000000..5dd910de9 --- /dev/null +++ b/lib/src/layer/polyline_layer/pattern.dart @@ -0,0 +1,134 @@ +part of 'polyline_layer.dart'; + +/// Determines whether a [Polyline] should be solid, dotted, or dashed, and the +/// exact characteristics of each +@immutable +class PolylinePattern { + /// Solid/unbroken + const PolylinePattern.solid() + : spacingFactor = null, + segments = null, + patternFit = null; + + /// Circular dots, spaced with [spacingFactor] + /// + /// See [spacingFactor] and [PatternFit] for more information about parameters. + /// [spacingFactor] defaults to 1.5, and [patternFit] defaults to + /// [PatternFit.scaleUp]. + const PolylinePattern.dotted({ + double this.spacingFactor = 1.5, + PatternFit this.patternFit = PatternFit.scaleUp, + }) : segments = null; + + /// Elongated dashes, with length and spacing set by [segments] + /// + /// Dashes may not be linear: they may pass through different [Polyline.points] + /// without regard to their relative bearing/direction. + /// + /// See [segments] and [PatternFit] for more information about parameters. + /// [patternFit] defaults to [PatternFit.scaleUp]. + const PolylinePattern.dashed({ + required List this.segments, + PatternFit this.patternFit = PatternFit.scaleUp, + }) : assert( + segments.length >= 2, + '`segments` must contain at least two items', + ), + assert( + // ignore: use_is_even_rather_than_modulo + segments.length % 2 == 0, + '`segments` must have an even length', + ), + spacingFactor = null; + + /// The multiplier used to calculate the spacing between dots in a dotted + /// polyline, with respect to [Polyline.strokeWidth] + /// + /// A value of 1.0 will result in spacing equal to the `strokeWidth`. + /// Increasing the value increases the spacing with the same scaling. + /// + /// May also be scaled by the use of [PatternFit.scaleUp]. + /// + /// Defaults to 1.5. + final double? spacingFactor; + + /// A list of even length with a minimum of 2, in the form of + /// `[a₁, b₁, (a₂, b₂, ...)]`, where `a` should be the length of segments in + /// 'units', and `b` the length of the space after each segment in units. Both + /// values must be strictly positive. + /// + /// 'Units' refers to pixels, unless the pattern has been scaled due to the + /// use of [PatternFit.scaleUp]. + /// + /// If more than two items are specified, then each segments will + /// alternate/iterate through the values. + /// + /// For example, `[50, 10]` will cause: + /// * a segment of length 50px + /// * followed by a space of 10px + /// * followed by a segment of length 50px + /// * followed by a space of 10px + /// * etc... + /// + /// For example, `[50, 10, 10, 10]` will cause: + /// * a segment of length 50px + /// * followed by a space of 10px + /// * followed by a segment of length 10px + /// * followed by a space of 10px + /// * followed by a segment of length of 50px + /// * followed by a space of 10px + /// * etc... + final List? segments; + + /// Determines how a non-solid [PolylinePattern] should be fit to a [Polyline] + /// when their lengths are not equal or multiples + /// + /// Defaults to [PatternFit.scaleUp]. + final PatternFit? patternFit; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PolylinePattern && + spacingFactor == other.spacingFactor && + patternFit == other.patternFit && + ((segments == null && other.segments == null) || + listEquals(segments, other.segments))); + + @override + int get hashCode => Object.hash(spacingFactor, segments, patternFit); +} + +/// Determines how a non-solid [PolylinePattern] should be fit to a [Polyline] +/// when their lengths are not equal or multiples +/// +/// [PolylinePattern.solid]s do not require fitting. +enum PatternFit { + /// Don't apply any specific fit to the pattern - repeat exactly as specified, + /// and stop when the last point is reached + /// + /// Not recommended. May leave a gap between the final segment and the last + /// point, making it unclear where the line ends. + none, + + /// Scale the pattern to ensure it fits an integer number of times into the + /// polyline (smaller version regarding rounding, cf. [scaleUp]) + scaleDown, + + /// Scale the pattern to ensure it fits an integer number of times into the + /// polyline (bigger version regarding rounding, cf. [scaleDown]) + scaleUp, + + /// Uses the pattern exactly, truncating the final dash if it does not fit, or + /// adding a single dot at the last point if the final dash does not reach the + /// last point (there is a gap at that location) + appendDot, + + /// (Only valid for [PolylinePattern.dashed], equal to [appendDot] for + /// [PolylinePattern.dotted]) + /// + /// Uses the pattern exactly, truncating the final dash if it does not fit, or + /// extending the final dash to the last point if it would not normally reach + /// that point (there is a gap at that location). + extendFinalDash; +} diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart index d56c491d7..81d15a311 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -8,14 +8,11 @@ class Polyline { /// The width of the stroke final double strokeWidth; - /// The multiplier used to calculate the spacing between segments in a dotted/ - /// dashed polyline + /// Determines whether this should be solid, dotted, or dashed, and the exact + /// characteristics of each /// - /// A value of 1.0 will result in spacing equal to the `strokeWidth`. - /// Increasing the value increases the spacing with respect to `strokeWidth`. - /// - /// Defaults to 1.5. - final double segmentSpacingFactor; + /// Defaults to being a solid/unbroken line ([PolylinePattern.solid]). + final PolylinePattern pattern; /// The color of the line stroke. final Color color; @@ -33,9 +30,6 @@ class Polyline { /// The stops for the gradient colors. final List? colorsStop; - /// Set to true if the line stroke should be rendered as a dotted line. - final bool isDotted; - /// Styles to use for line endings. final StrokeCap strokeCap; @@ -57,17 +51,16 @@ class Polyline { Polyline({ required this.points, this.strokeWidth = 1.0, + this.pattern = const PolylinePattern.solid(), 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, - this.segmentSpacingFactor = 1.5, }); @override @@ -78,8 +71,7 @@ class Polyline { color == other.color && borderStrokeWidth == other.borderStrokeWidth && borderColor == other.borderColor && - isDotted == other.isDotted && - segmentSpacingFactor == other.segmentSpacingFactor && + pattern == other.pattern && strokeCap == other.strokeCap && strokeJoin == other.strokeJoin && useStrokeWidthInMeter == other.useStrokeWidthInMeter && @@ -100,8 +92,7 @@ class Polyline { borderColor, gradientColors, colorsStop, - isDotted, - segmentSpacingFactor, + pattern, strokeCap, strokeJoin, useStrokeWidthInMeter, diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index a373ffbb9..20372a317 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -10,6 +10,7 @@ import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart'; part 'painter.dart'; +part 'pattern.dart'; part 'polyline.dart'; part 'projected_polyline.dart';