diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 000000000..b14b7f937 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,54 @@ +# Multicolor outline performance + +Run from the package root: + +```sh +flutter test --no-pub benchmark/multicolor_outline_benchmark_test.dart --concurrency=1 +``` + +This synthetic, tile-free benchmark compares ordinary polylines, uniform-color +multicolor polylines, gradients, and gradients with a separate uniform outline. +It exercises pan and zoom with 5,000 and 20,000 vertices. Each case warms up for +15 frames and reports the median and minimum of five 30-frame samples. + +These are widget-test CPU build/paint timings, not GPU frame times or an FPS +prediction. Run before and after changes on the same machine, without concurrent +benchmarks. Both kinds of polyline now use the existing projection and floored-zoom +simplification cache. Multicolor lines additionally preserve changes exceeding +0.02 per RGBA channel, so they may retain more vertices than ordinary lines. +Setting the layer's `simplificationTolerance` to zero disables simplification. + +The opaque uniform-color fast path uses the standard batched path painter while +retaining multicolor projection and culling. +Nonuniform and translucent lines retain the segment-gradient renderer. Immutable +vertex colors allow uniformity and transparency checks to be cached per line. + +On the development machine, compared with cc3dc7f, the 5,000-point outlined route +decreased from 21.34 to 12.80 ms per pan frame and 22.00 to 12.78 ms per zoom frame. +The uniform outline alone decreased from about 10 ms to 2 ms. The gradient-only +case remained around 11 ms: this optimization does not eliminate gradient cost. + +A subsequent segment optimization uses `Canvas.drawLine` instead of repeatedly +building and resetting a two-point path. The same 5,000-point benchmark measured +11.19 to 10.02 ms for gradient pan and 11.21 to 10.22 ms for gradient zoom; +outlined routes decreased from 13.03 to 12.02 ms and 13.05 to 11.91 ms respectively. +This preserves per-segment gradients, visibility checks, and explicit joins/caps; +it does not quantize colors or discard samples. Results are local CPU measurements +and should be rechecked on the target device and renderer. + +Color-aware simplification stores explicit original sample indices, also sliced +when viewport culling splits a line. A separate outline should supply its +foreground's `vertexColors` as `simplificationColors`, selecting the same vertices +without changing the outline's paint. Separate polylines remain separate; no gaps +are joined. No predictive prewarming or raster cache is added. + +The 5,000-point synthetic benchmark with warm zoom caches measured approximately +0.88 ms gradient pan, 0.83 ms gradient zoom, 0.98 ms outlined pan, and 0.85 ms +outlined zoom after simplification. These are dataset-dependent: noisy geometry +or colors deliberately retain more points. Warm timings exclude initial cache +construction and are not claims about first-open latency or GPU frame rate. + +Pixel tests compare uniform multicolor and ordinary simplification across caps, +borders, duplicate vertices, and offscreen endpoints. Additional +tests cover original color indices after simplification and culling, brief color +and alpha peaks, duplicate coordinates, zoom changes, and zero-tolerance mode. diff --git a/benchmark/multicolor_outline_benchmark_test.dart b/benchmark/multicolor_outline_benchmark_test.dart new file mode 100644 index 000000000..af4cf8fa0 --- /dev/null +++ b/benchmark/multicolor_outline_benchmark_test.dart @@ -0,0 +1,74 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +// CPU build/paint timings in the widget-test runner, not GPU frame timings. +// Run the identical file before/after with --concurrency=1 on the same machine. +void main() { + for (final count in [5000, 20000]) { + for (final mode in ['plain', 'solid-multicolor', 'gradient', 'outlined']) { + for (final zooming in [false, true]) { + testWidgets('$mode $count ${zooming ? 'zoom' : 'pan'} benchmark', + (tester) async { + final points = [ + for (var i = 0; i < count; i++) + LatLng(43 + 0.008 * math.sin(i / count * math.pi * 8), + -79 + 0.012 * math.cos(i / count * math.pi * 6)), + ]; + final colors = [ + for (var i = 0; i < count; i++) + Color.lerp(Colors.blue, Colors.red, (i % 100) / 99)!, + ]; + final lines = [ + if (mode == 'plain') + Polyline(points: points, color: Colors.white, strokeWidth: 7), + if (mode == 'solid-multicolor' || mode == 'outlined') + MulticolorPolyline( + points: points, + defaultColor: Colors.white, + simplificationColors: mode == 'outlined' ? colors : null, + strokeWidth: 7), + if (mode == 'gradient' || mode == 'outlined') + MulticolorPolyline( + points: points, vertexColors: colors, strokeWidth: 4.5), + ]; + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: FlutterMap( + mapController: controller, + options: const MapOptions( + initialCenter: LatLng(43, -79), initialZoom: 14), + children: [PolylineLayer(polylines: lines)], + ))); + Future frame(int i) async { + controller.move( + LatLng(43 + (i % 7) * 0.00003, -79 + (i % 11) * 0.00003), + zooming ? 13.5 + (i % 20) * 0.05 : 14); + await tester.pump(); + } + + for (var i = 0; i < 15; i++) { + await frame(i); + } + final samples = []; + for (var repeat = 0; repeat < 5; repeat++) { + final timer = Stopwatch()..start(); + for (var i = 0; i < 30; i++) { + await frame(i); + } + samples.add(timer.elapsedMicroseconds / 30); + } + samples.sort(); + debugPrint('RESULT $mode points=$count ${zooming ? 'zoom' : 'pan'} ' + 'median_us=${samples[2].round()} min_us=${samples.first.round()}'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + } + } + } +} diff --git a/example/lib/pages/polyline.dart b/example/lib/pages/polyline.dart index 9e0395e23..d26387b07 100644 --- a/example/lib/pages/polyline.dart +++ b/example/lib/pages/polyline.dart @@ -7,6 +7,24 @@ import 'package:latlong2/latlong.dart'; typedef HitValue = ({String title, String subtitle}); +const _gpsMetricPoints = [ + LatLng(50.8, -4.2), + LatLng(51.2, -3), + LatLng(51, -1.8), + LatLng(51.5, -0.4), + LatLng(52, 0.8), + LatLng(52.4, 2), +]; + +// GPS metrics contain one value per recorded coordinate. +const _gpsSpeedKph = [8, 14, 27, 21, 35, 18]; + +Color _speedColor(double speedKph) => Color.lerp( + const Color(0xFF1565C0), + const Color(0xFFF4511E), + ((speedKph - 5) / 30).clamp(0.0, 1.0), + )!; + class PolylinePage extends StatefulWidget { static const String route = '/polyline'; @@ -22,6 +40,17 @@ class _PolylinePageState extends State { List>? _hoverLines; final _polylinesRaw = >[ + MulticolorPolyline( + points: _gpsMetricPoints, + vertexColors: _gpsSpeedKph.map(_speedColor).toList(growable: false), + strokeWidth: 8, + borderStrokeWidth: 2, + borderColor: Colors.white, + hitValue: ( + title: 'GPS Speed Line', + subtitle: 'Color encodes speed at each recorded GPS point', + ), + ), Polyline( points: const [ LatLng(40, 150), diff --git a/lib/src/layer/polyline_layer/multicolor_polyline.dart b/lib/src/layer/polyline_layer/multicolor_polyline.dart new file mode 100644 index 000000000..a950ac0a3 --- /dev/null +++ b/lib/src/layer/polyline_layer/multicolor_polyline.dart @@ -0,0 +1,124 @@ +part of 'polyline_layer.dart'; + +/// A solid [Polyline] that paints a smooth gradient between vertex colors. +/// +/// Map metrics recorded alongside GPS coordinates, such as speed, elevation, +/// heart rate, or accuracy, to one color per coordinate. +/// +/// Layer simplification retains source indices and limits sampled color error +/// to 0.02 per RGBA channel. Set [PolylineLayer.simplificationTolerance] to zero +/// to render every recorded sample. +/// +/// Consecutive segments use round joins. Line endings use [strokeCap]. +class MulticolorPolyline extends Polyline { + /// The color applied at each vertex in [points]. + /// + /// When `null` or empty, [defaultColor] is used instead. + final List? vertexColors; + + /// The fallback color used when no [vertexColors] are provided. + final Color defaultColor; + + /// Optional colors used only to select simplification vertices. + /// + /// Supply the foreground's vertex colors for a separate solid outline so + /// both strokes retain identical geometry. Defaults to [vertexColors]. + final List? simplificationColors; + + int? _multicolorRenderHashCode; + int? _multicolorHashCode; + List? _resolvedColors; + + /// Create a multicolor polyline that interpolates between the supplied + /// [vertexColors]. + // Super parameters cannot initialize `color`, which is derived from + // `vertexColors` and `defaultColor`. + // ignore: use_super_parameters + MulticolorPolyline({ + required List points, + List? vertexColors, + List? simplificationColors, + this.defaultColor = const Color(0xFF00FF00), + double strokeWidth = 1.0, + double borderStrokeWidth = 0.0, + Color borderColor = const Color(0xFFFFFF00), + StrokeCap strokeCap = StrokeCap.round, + bool useStrokeWidthInMeter = false, + R? hitValue, + }) : vertexColors = _validateVertexColors(points, vertexColors), + simplificationColors = + _validateVertexColors(points, simplificationColors), + super( + points: points, + strokeWidth: strokeWidth, + pattern: const StrokePattern.solid(), + color: (vertexColors != null && vertexColors.isNotEmpty) + ? vertexColors.first + : defaultColor, + borderStrokeWidth: borderStrokeWidth, + borderColor: borderColor, + gradientColors: null, + colorsStop: null, + strokeCap: strokeCap, + strokeJoin: StrokeJoin.round, + useStrokeWidthInMeter: useStrokeWidthInMeter, + hitValue: hitValue, + ); + + late final bool _hasTransparentVertices = vertexColors == null + ? defaultColor.a < 1 + : vertexColors!.any((color) => color.a < 1); + + late final bool _isOpaqueSolid = color.a == 1 && + (vertexColors == null || + vertexColors!.every((vertex) => vertex == color)); + + List get _resolvedVertexColors => _resolvedColors ??= + vertexColors ?? List.filled(points.length, defaultColor); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MulticolorPolyline && + super == other && + defaultColor == other.defaultColor && + listEquals(simplificationColors, other.simplificationColors) && + listEquals(vertexColors, other.vertexColors)); + + @override + int get renderHashCode => _multicolorRenderHashCode ??= Object.hash( + super.renderHashCode, + defaultColor, + simplificationColors == null + ? null + : Object.hashAll(simplificationColors!), + vertexColors == null ? null : Object.hashAll(vertexColors!)); + + @override + int get hashCode => _multicolorHashCode ??= Object.hash( + super.hashCode, + defaultColor, + vertexColors == null ? null : Object.hashAll(vertexColors!)); +} + +List? _validateVertexColors( + List points, + List? vertexColors, +) { + if (points.length < 2) { + throw ArgumentError.value( + points, + 'points', + 'MulticolorPolyline requires at least two points', + ); + } + if (vertexColors == null || vertexColors.isEmpty) return null; + if (points.length != vertexColors.length) { + throw ArgumentError.value( + vertexColors, + 'vertexColors', + 'length must match points length', + ); + } + return List.unmodifiable(vertexColors); +} diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index 4d14e9228..7db336b1b 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -138,18 +138,34 @@ class _PolylinePainter extends CustomPainter shift: shift, ); - late final double strokeWidth; - if (polyline.useStrokeWidthInMeter) { - strokeWidth = metersToScreenPixels( - projectedPolyline.polyline.points.first, - polyline.strokeWidth, - ); - } else { - strokeWidth = polyline.strokeWidth; + final strokeWidth = polyline.useStrokeWidthInMeter + ? metersToScreenPixels( + projectedPolyline.polyline.points.first, + polyline.strokeWidth, + ) + : polyline.strokeWidth; + + if (!areOffsetsVisible( + offsets, + strokeWidth + polyline.borderStrokeWidth, + )) { + return WorldWorkControl.invisible; } - if (!areOffsetsVisible(offsets, strokeWidth)) { - return WorldWorkControl.invisible; + // Solid multicolor strokes use the same selected geometry with the + // batched path renderer instead of per-segment shaders. + if (polyline is MulticolorPolyline && !polyline._isOpaqueSolid) { + drawPaths(); + lastHash = null; + needsLayerSaving = false; + _drawMulticolorPolyline( + canvas: canvas, + size: size, + projectedPolyline: projectedPolyline, + offsets: offsets, + strokeWidth: strokeWidth, + ); + return WorldWorkControl.visible; } final hash = polyline.renderHashCode; @@ -271,6 +287,187 @@ class _PolylinePainter extends CustomPainter drawPaths(); } + void _drawMulticolorPolyline({ + required Canvas canvas, + required Size size, + required _ProjectedPolyline projectedPolyline, + required List offsets, + required double strokeWidth, + }) { + final polyline = projectedPolyline.polyline as MulticolorPolyline; + + final colors = polyline._resolvedVertexColors; + final vertexCount = offsets.length; + if (vertexCount < 2) { + return; + } + assert( + projectedPolyline.sourceIndexAt(vertexCount - 1) < colors.length, + 'Projected points must retain their source color indices', + ); + + final hasBorder = polyline.borderStrokeWidth > 0.0; + final requiresLayerSaving = polyline._hasTransparentVertices; + final strokeBlendMode = + requiresLayerSaving ? BlendMode.src : BlendMode.srcOver; + + if (hasBorder) { + final borderPath = ui.Path(); + final filterPath = ui.Path(); + final paths = [borderPath]; + if (requiresLayerSaving) { + paths.add(filterPath); + } + + final SolidPixelHiker borderHiker = SolidPixelHiker( + offsets: offsets, + closePath: false, + canvasSize: size, + strokeWidth: strokeWidth + polyline.borderStrokeWidth, + ); + borderHiker.addAllVisibleSegments(paths); + + final borderPaint = Paint() + ..color = polyline.borderColor + ..strokeWidth = strokeWidth + polyline.borderStrokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = PaintingStyle.stroke + ..blendMode = BlendMode.srcOver; + + final filterPaint = Paint() + ..color = polyline.borderColor.withAlpha(255) + ..strokeWidth = strokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = PaintingStyle.stroke + ..blendMode = BlendMode.dstOut; + + if (requiresLayerSaving) { + canvas.saveLayer(viewportRect, Paint()); + } + + canvas.drawPath(borderPath, borderPaint); + + if (requiresLayerSaving) { + canvas.drawPath(filterPath, filterPaint); + canvas.restore(); + } + } + + if (requiresLayerSaving) { + canvas.saveLayer(viewportRect, Paint()); + } + + final strokePaint = Paint() + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.butt + ..style = PaintingStyle.stroke + ..blendMode = strokeBlendMode; + + for (int i = 0; i < vertexCount - 1; i++) { + final start = offsets[i]; + final end = offsets[i + 1]; + if (start == end) continue; + if (VisibleSegment.getVisibleSegment( + start, + end, + size, + strokeWidth + polyline.borderStrokeWidth, + ) == + null) { + continue; + } + + final colorIndex = projectedPolyline.sourceIndexAt(i); + + strokePaint.shader = ui.Gradient.linear( + start, + end, + [colors[colorIndex], colors[projectedPolyline.sourceIndexAt(i + 1)]], + ); + canvas.drawLine(start, end, strokePaint); + } + + strokePaint.shader = null; + + final vertexPaint = Paint() + ..style = PaintingStyle.fill + ..blendMode = strokeBlendMode; + final radius = strokeWidth / 2; + + for (int i = 0; i < vertexCount; i++) { + final sourceIndex = projectedPolyline.sourceIndexAt(i); + if (sourceIndex == 0 || sourceIndex == colors.length - 1) continue; + if (!VisibleSegment.isVisible(offsets[i], size, strokeWidth)) continue; + vertexPaint.color = colors[sourceIndex]; + canvas.drawCircle(offsets[i], radius, vertexPaint); + } + + void drawRoundCap(int index) { + if (!VisibleSegment.isVisible(offsets[index], size, strokeWidth)) return; + vertexPaint.color = colors[projectedPolyline.sourceIndexAt(index)]; + canvas.drawCircle(offsets[index], radius, vertexPaint); + } + + void drawSquareCap(int index, int step) { + final endpoint = offsets[index]; + if (!VisibleSegment.isVisible(endpoint, size, strokeWidth)) return; + + var neighborIndex = index + step; + while (neighborIndex >= 0 && + neighborIndex < vertexCount && + offsets[neighborIndex] == endpoint) { + neighborIndex += step; + } + if (neighborIndex < 0 || neighborIndex >= vertexCount) return; + + final outward = endpoint - offsets[neighborIndex]; + final distance = outward.distance; + if (distance == 0) return; + + final direction = outward / distance; + final normal = Offset(-direction.dy, direction.dx) * radius; + final outer = endpoint + direction * radius; + final capPath = ui.Path() + ..addPolygon( + [ + endpoint + normal, + outer + normal, + outer - normal, + endpoint - normal, + ], + true, + ); + + vertexPaint.color = colors[projectedPolyline.sourceIndexAt(index)]; + canvas.drawPath(capPath, vertexPaint); + } + + switch (polyline.strokeCap) { + case StrokeCap.butt: + break; + case StrokeCap.round: + if (projectedPolyline.sourceIndexAt(0) == 0) drawRoundCap(0); + if (projectedPolyline.sourceIndexAt(vertexCount - 1) == + colors.length - 1) { + drawRoundCap(vertexCount - 1); + } + break; + case StrokeCap.square: + if (projectedPolyline.sourceIndexAt(0) == 0) drawSquareCap(0, 1); + if (projectedPolyline.sourceIndexAt(vertexCount - 1) == + colors.length - 1) { + drawSquareCap(vertexCount - 1, -1); + } + break; + } + + if (requiresLayerSaving) { + canvas.restore(); + } + } + ui.Gradient _paintGradient(Polyline polyline, List offsets) => ui.Gradient.linear(offsets.first, offsets.last, polyline.gradientColors!, _getColorsStop(polyline)); diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart index b5712da66..1be93e6e7 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -68,6 +68,9 @@ class Polyline with HitDetectableElement { bool operator ==(Object other) => identical(this, other) || (other is Polyline && + // Subclasses can add rendering state; compare matching types so + // equality stays symmetric between base and specialized polylines. + runtimeType == other.runtimeType && strokeWidth == other.strokeWidth && color == other.color && borderStrokeWidth == other.borderStrokeWidth && diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index a705802b6..d94732550 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -13,9 +13,11 @@ import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:flutter_map/src/misc/offsets.dart'; import 'package:flutter_map/src/misc/simplify.dart'; +import 'package:flutter_map/src/misc/simplify_colored.dart'; import 'package:latlong2/latlong.dart'; part 'painter.dart'; +part 'multicolor_polyline.dart'; part 'polyline.dart'; part 'projected_polyline.dart'; @@ -93,15 +95,35 @@ class _PolylineLayerState extends State> _ProjectedPolyline simplifyProjectedElement({ required _ProjectedPolyline projectedElement, required double tolerance, - }) => - _ProjectedPolyline._( - polyline: projectedElement.polyline, - points: simplifyPoints( - points: projectedElement.points, - tolerance: tolerance, - highQuality: true, - ), + }) { + final polyline = projectedElement.polyline; + if (polyline is MulticolorPolyline) { + final indices = simplifyColoredPoints( + points: projectedElement.points, + colors: polyline.simplificationColors ?? polyline.vertexColors, + tolerance: tolerance, ); + if (indices.length == projectedElement.points.length) { + return projectedElement; + } + return _ProjectedPolyline._( + polyline: polyline, + points: [for (final index in indices) projectedElement.points[index]], + sourceIndices: [ + for (final index in indices) projectedElement.sourceIndexAt(index), + ], + ); + } + + return _ProjectedPolyline._( + polyline: polyline, + points: simplifyPoints( + points: projectedElement.points, + tolerance: tolerance, + highQuality: true, + ), + ); + } @override List> get elements => widget.polylines; @@ -202,7 +224,7 @@ class _PolylineLayerState extends State> // First check, bullet-proof, focusing on latitudes. if (!isOverlappingLatitude()) continue; - // Gradient polylines cannot be easily segmented + // Global gradients cannot be segmented without changing their shader. if (polyline.gradientColors != null) { yield projectedPolyline; continue; @@ -261,6 +283,9 @@ class _PolylineLayerState extends State> yield _ProjectedPolyline._( polyline: polyline, points: projectedPolyline.points.sublist(start, i + 1), + sourceStartIndex: projectedPolyline.sourceStartIndex + start, + sourceIndices: + projectedPolyline.sourceIndices?.sublist(start, i + 1), ); // Reset start. @@ -276,8 +301,9 @@ class _PolylineLayerState extends State> ? projectedPolyline : _ProjectedPolyline._( polyline: polyline, - // Special case: the entire polyline is visible points: projectedPolyline.points.sublist(start), + sourceStartIndex: projectedPolyline.sourceStartIndex + start, + sourceIndices: projectedPolyline.sourceIndices?.sublist(start), ); } } diff --git a/lib/src/layer/polyline_layer/projected_polyline.dart b/lib/src/layer/polyline_layer/projected_polyline.dart index bc57c2f8e..3eb0f2a31 100644 --- a/lib/src/layer/polyline_layer/projected_polyline.dart +++ b/lib/src/layer/polyline_layer/projected_polyline.dart @@ -5,6 +5,16 @@ class _ProjectedPolyline with HitDetectableElement { final Polyline polyline; final List points; + /// Index of the first projected point in the source polyline's points. + /// + /// Used to preserve per-vertex metadata when culling creates fragments. + final int sourceStartIndex; + + final List? sourceIndices; + + int sourceIndexAt(int index) => + sourceIndices?[index] ?? sourceStartIndex + index; + /// Bounding box of [points], in projected space (cached) /// /// Computed lazily: culled fragments never use it. @@ -16,6 +26,8 @@ class _ProjectedPolyline with HitDetectableElement { _ProjectedPolyline._({ required this.polyline, required this.points, + this.sourceStartIndex = 0, + this.sourceIndices, }); _ProjectedPolyline._fromPolyline( diff --git a/lib/src/misc/extensions.dart b/lib/src/misc/extensions.dart index b6284efe1..e25d2350c 100644 --- a/lib/src/misc/extensions.dart +++ b/lib/src/misc/extensions.dart @@ -60,6 +60,10 @@ extension RectExtension on Rect { /// Checks if the line between the two coordinates is contained within the /// [Rect]. bool aabbContainsLine(double x1, double y1, double x2, double y2) { + if (contains(Offset(x1, y1)) || contains(Offset(x2, y2))) { + return true; + } + // Completely outside. if ((x1 <= left && x2 <= left) || (y1 <= top && y2 <= top) || diff --git a/lib/src/misc/simplify_colored.dart b/lib/src/misc/simplify_colored.dart new file mode 100644 index 000000000..49bcc0f47 --- /dev/null +++ b/lib/src/misc/simplify_colored.dart @@ -0,0 +1,70 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_map/src/misc/simplify.dart'; + +/// Source indices retained by geometry- and color-aware Douglas-Peucker. +/// +/// Colors are compared with the gradient at the projected position on each +/// candidate segment. The maximum permitted error is 0.02 per RGBA channel. +/// Indices preserve metadata through simplification, including repeated points. +List simplifyColoredPoints({ + required List points, + required List? colors, + required double tolerance, +}) { + assert(colors == null || colors.length == points.length, + 'Colors must correspond to every source point'); + if (points.length <= 4 || tolerance <= 0) { + return List.generate(points.length, (index) => index); + } + final keep = List.filled(points.length, false); + keep.first = keep.last = true; + final pending = <(int, int)>[(0, points.length - 1)]; + final squaredTolerance = tolerance * tolerance; + while (pending.isNotEmpty) { + final (first, last) = pending.removeLast(); + final start = points[first]; + final end = points[last]; + final delta = end - start; + final lengthSquared = delta.distanceSquared; + var greatestError = 1.0; + var selected = -1; + for (var index = first + 1; index < last; index++) { + final point = points[index]; + var error = + getSqSegDist(point.dx, point.dy, start.dx, start.dy, end.dx, end.dy) / + squaredTolerance; + if (colors != null) { + final fraction = lengthSquared == 0 + ? (index - first) / (last - first) + : (((point.dx - start.dx) * delta.dx + + (point.dy - start.dy) * delta.dy) / + lengthSquared) + .clamp(0.0, 1.0); + final expected = Color.lerp(colors[first], colors[last], fraction)!; + final actual = colors[index]; + final colorError = [ + (actual.r - expected.r).abs(), + (actual.g - expected.g).abs(), + (actual.b - expected.b).abs(), + (actual.a - expected.a).abs(), + ].reduce(math.max); + error = math.max(error, colorError / 0.02); + } + if (error > greatestError) { + greatestError = error; + selected = index; + } + } + if (selected != -1) { + keep[selected] = true; + if (selected - first > 1) pending.add((first, selected)); + if (last - selected > 1) pending.add((selected, last)); + } + } + return [ + for (var i = 0; i < keep.length; i++) + if (keep[i]) i + ]; +} diff --git a/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index 9fdf43f44..8ac0a08e6 100644 --- a/test/layer/polyline_layer_test.dart +++ b/test/layer/polyline_layer_test.dart @@ -1,4 +1,8 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; @@ -6,6 +10,194 @@ import 'package:latlong2/latlong.dart'; import '../test_utils/test_app.dart'; void main() { + test('polyline equality is symmetric across rendering subtypes', () { + const points = [LatLng(0, 0), LatLng(0, 1)]; + final plain = Polyline(points: points); + final colored = MulticolorPolyline(points: points); + expect(plain == colored, isFalse); + expect(colored == plain, isFalse); + final equalColored = MulticolorPolyline(points: points); + expect(colored, equals(equalColored)); + expect(colored.hashCode, equalColored.hashCode); + }); + + testWidgets('simplified colors survive separate culled fragments', + (tester) async { + final controller = MapController(); + final boundaryKey = GlobalKey(); + await tester.pumpWidget( + _RenderingTestApp(boundaryKey: boundaryKey, controller: controller)); + final points = [ + for (final offset in const [ + Offset(-400, 50), + Offset(-300, 50), + Offset(0, 50), + Offset(100, 50), + Offset(300, 50), + Offset(400, 50), + Offset(400, 100), + Offset(400, 150), + Offset(300, 150), + Offset(200, 150), + Offset(100, 150), + Offset(0, 150), + Offset(-400, 150), + ]) + controller.camera.screenOffsetToLatLng(offset), + ]; + final colors = [ + for (var i = 0; i < points.length; i++) + Color.lerp(Colors.red, Colors.blue, i / (points.length - 1))!, + ]; + final lines = [ + MulticolorPolyline(points: points, vertexColors: colors, strokeWidth: 12) + ]; + final initialZoom = controller.camera.zoom; + for (final zoomDelta in [0.0, 0.5, 0.75, 0.0]) { + final zoom = initialZoom + zoomDelta; + controller.move(controller.camera.center, zoom); + await tester.pumpWidget(_RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + polylines: lines, + simplificationTolerance: 0, + cullingMargin: 10, + )); + final expected = await _capturePixels(tester, boundaryKey); + await tester.pumpWidget(_RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + polylines: lines, + simplificationTolerance: 2, + cullingMargin: 10, + )); + final actual = await _capturePixels(tester, boundaryKey); + var visibleSamples = 0; + for (var y = 10; y < 190; y += 10) { + final reference = expected.colorAt(100, y); + if (reference.g < 0.5) visibleSamples++; + final result = actual.colorAt(100, y); + expect(result.r, closeTo(reference.r, 0.04)); + expect(result.g, closeTo(reference.g, 0.04)); + expect(result.b, closeTo(reference.b, 0.04)); + } + expect(visibleSamples, greaterThan(0)); + } + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + for (final cap in StrokeCap.values) { + for (final vertical in [false, true]) { + testWidgets('gradient segments preserve samples $cap vertical=$vertical', + (tester) async { + final controller = MapController(); + final boundaryKey = GlobalKey(); + Offset position(double distance) => + vertical ? Offset(100, distance) : Offset(distance, 100); + await tester.pumpWidget(_RenderingTestApp( + boundaryKey: boundaryKey, controller: controller)); + await tester.pumpWidget(_RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + polylines: [ + MulticolorPolyline( + points: [ + for (final distance in [40.0, 100.0, 100.0, 160.0]) + controller.camera.screenOffsetToLatLng(position(distance)), + ], + vertexColors: const [ + Colors.red, + Colors.green, + Colors.green, + Colors.blue, + ], + strokeWidth: 20, + strokeCap: cap, + ), + ], + )); + final pixels = await _capturePixels(tester, boundaryKey); + Color sample(int distance) => vertical + ? pixels.colorAt(100, distance) + : pixels.colorAt(distance, 100); + for (final (distance, expected) in [ + (70, Color.lerp(Colors.red, Colors.green, 0.5)!), + (100, Colors.green), + (130, Color.lerp(Colors.green, Colors.blue, 0.5)!), + (35, cap == StrokeCap.butt ? Colors.white : Colors.red), + (165, cap == StrokeCap.butt ? Colors.white : Colors.blue), + ]) { + final actual = sample(distance); + expect(actual.r, closeTo(expected.r, 0.03)); + expect(actual.g, closeTo(expected.g, 0.03)); + expect(actual.b, closeTo(expected.b, 0.03)); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + controller.dispose(); + }); + } + } + for (final cap in StrokeCap.values) { + for (final explicitColors in [false, true]) { + testWidgets( + 'solid multicolor matches plain simplification $cap explicit=$explicitColors', + (tester) async { + final controller = MapController(); + final boundaryKey = GlobalKey(); + await tester.pumpWidget(_RenderingTestApp( + boundaryKey: boundaryKey, controller: controller)); + final points = [ + for (final point in const [ + Offset(-30, 50), + Offset(40, 70), + Offset(40, 70), + Offset(60, 64), + Offset(90, 130), + Offset(170, 90), + Offset(230, 120) + ]) + controller.camera.screenOffsetToLatLng(point), + ]; + await tester.pumpWidget(_RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + simplificationTolerance: 50, + polylines: [ + Polyline( + points: points, + color: Colors.red, + strokeWidth: 8, + strokeCap: cap, + borderStrokeWidth: 3, + borderColor: Colors.black) + ], + )); + final expected = await _capturePixels(tester, boundaryKey); + await tester.pumpWidget(_RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + simplificationTolerance: 50, + polylines: [ + MulticolorPolyline( + points: points, + defaultColor: Colors.red, + vertexColors: explicitColors + ? List.filled(points.length, Colors.red) + : null, + strokeWidth: 8, + strokeCap: cap, + borderStrokeWidth: 3, + borderColor: Colors.black) + ], + )); + final actual = await _capturePixels(tester, boundaryKey); + expect(actual.bytes.buffer.asUint8List(), + orderedEquals(expected.bytes.buffer.asUint8List())); + expect(tester.takeException(), isNull); + }); + } + } testWidgets('test polyline layer', (tester) async { final polylines = [ for (int i = 0; i < 10; i++) @@ -31,4 +223,284 @@ void main() { of: find.byType(PolylineLayer), matching: find.byType(CustomPaint)), findsOneWidget); }); + + testWidgets('multicolor polyline renders without errors', (tester) async { + final polylines = [ + MulticolorPolyline( + points: const [ + LatLng(50.5, -0.09), + LatLng(51.3498, -6.2603), + LatLng(53.8566, 2.3522), + ], + vertexColors: const [ + Colors.red, + Colors.orange, + Colors.blue, + ], + strokeWidth: 6, + ), + ]; + + await tester.pumpWidget(TestApp(polylines: polylines)); + + expect(find.byType(FlutterMap), findsOneWidget); + expect(find.byType(PolylineLayer), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('multicolor polyline falls back to defaultColor', (tester) async { + final polylines = [ + MulticolorPolyline( + points: const [ + LatLng(52.5, -0.09), + LatLng(53.3498, -6.2603), + LatLng(55.8566, 2.3522), + ], + defaultColor: Colors.purple, + strokeWidth: 5, + ), + ]; + + await tester.pumpWidget(TestApp(polylines: polylines)); + + expect(find.byType(FlutterMap), findsOneWidget); + expect(find.byType(PolylineLayer), findsOneWidget); + expect(tester.takeException(), isNull); + + final polyline = polylines.first as MulticolorPolyline; + expect(polyline.vertexColors, isNull); + expect(polyline.color, equals(Colors.purple)); + }); + + test('multicolor polyline validates point and color counts', () { + expect( + () => MulticolorPolyline(points: const [LatLng(0, 0)]), + throwsArgumentError, + ); + expect( + () => MulticolorPolyline( + points: const [LatLng(0, 0), LatLng(0, 1)], + vertexColors: const [Colors.red], + ), + throwsArgumentError, + ); + }); + + testWidgets('multicolor polyline interpolates vertex colors', (tester) async { + final controller = MapController(); + final boundaryKey = GlobalKey(); + + await tester.pumpWidget( + _RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + ), + ); + + final camera = controller.camera; + final points = [ + camera.screenOffsetToLatLng(const Offset(40, 100)), + camera.screenOffsetToLatLng(const Offset(100, 100)), + camera.screenOffsetToLatLng(const Offset(160, 100)), + ]; + + await tester.pumpWidget( + _RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + polylines: [ + MulticolorPolyline( + points: points, + vertexColors: const [ + Color(0xFFFF0000), + Color(0xFF00FF00), + Color(0xFF0000FF), + ], + strokeWidth: 20, + strokeCap: StrokeCap.butt, + ), + ], + ), + ); + + final pixels = await _capturePixels(tester, boundaryKey); + final firstSegmentMiddle = pixels.colorAt(70, 100); + final vertex = pixels.colorAt(100, 100); + + expect(firstSegmentMiddle.r, inInclusiveRange(0.4, 0.65)); + expect(firstSegmentMiddle.g, inInclusiveRange(0.4, 0.65)); + expect(firstSegmentMiddle.b, lessThan(0.08)); + expect(vertex.r, lessThan(0.08)); + expect(vertex.g, greaterThan(0.94)); + expect(vertex.b, lessThan(0.08)); + }); + + testWidgets('transparent joins do not accumulate opacity', (tester) async { + final controller = MapController(); + final boundaryKey = GlobalKey(); + + await tester.pumpWidget( + _RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + ), + ); + + final camera = controller.camera; + final points = [ + camera.screenOffsetToLatLng(const Offset(40, 100)), + camera.screenOffsetToLatLng(const Offset(100, 100)), + camera.screenOffsetToLatLng(const Offset(160, 60)), + ]; + const translucentGreen = Color(0x8000FF00); + + await tester.pumpWidget( + _RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + polylines: [ + MulticolorPolyline( + points: points, + vertexColors: const [ + translucentGreen, + translucentGreen, + translucentGreen, + ], + strokeWidth: 20, + ), + ], + ), + ); + + final pixels = await _capturePixels(tester, boundaryKey); + final vertex = pixels.colorAt(100, 100); + + expect(vertex.r, inInclusiveRange(0.47, 0.53)); + expect(vertex.g, greaterThan(0.96)); + expect(vertex.b, inInclusiveRange(0.47, 0.53)); + }); + + testWidgets('stroke margin keeps an off-screen centerline visible', + (tester) async { + final controller = MapController(); + final boundaryKey = GlobalKey(); + + await tester.pumpWidget( + _RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + ), + ); + + final camera = controller.camera; + final points = [ + camera.screenOffsetToLatLng(const Offset(40, -5)), + camera.screenOffsetToLatLng(const Offset(160, -5)), + ]; + + await tester.pumpWidget( + _RenderingTestApp( + boundaryKey: boundaryKey, + controller: controller, + polylines: [ + Polyline( + points: points, + color: const Color(0xFFFF0000), + strokeWidth: 20, + strokeCap: StrokeCap.butt, + ), + ], + ), + ); + + final pixels = await _capturePixels(tester, boundaryKey); + final visibleStroke = pixels.colorAt(100, 1); + + expect(visibleStroke.r, greaterThan(0.94)); + expect(visibleStroke.g, lessThan(0.08)); + expect(visibleStroke.b, lessThan(0.08)); + }); +} + +class _RenderingTestApp extends StatelessWidget { + const _RenderingTestApp({ + required this.boundaryKey, + required this.controller, + this.polylines = const [], + this.simplificationTolerance = 0.4, + this.cullingMargin, + }); + + final GlobalKey boundaryKey; + final MapController controller; + final List polylines; + final double simplificationTolerance; + final double? cullingMargin; + + @override + Widget build(BuildContext context) => MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: boundaryKey, + child: SizedBox.square( + dimension: 200, + child: FlutterMap( + mapController: controller, + options: const MapOptions( + initialCenter: LatLng(0, 0), + initialZoom: 8, + backgroundColor: Colors.white, + interactionOptions: InteractionOptions( + flags: InteractiveFlag.none, + ), + ), + children: [ + if (polylines.isNotEmpty) + PolylineLayer( + polylines: polylines, + cullingMargin: cullingMargin, + simplificationTolerance: simplificationTolerance, + ), + ], + ), + ), + ), + ), + ), + ); +} + +class _CapturedPixels { + const _CapturedPixels(this.bytes, this.width); + + final ByteData bytes; + final int width; + + Color colorAt(int x, int y) { + final offset = (x + y * width) * 4; + return Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ); + } +} + +Future<_CapturedPixels> _capturePixels( + WidgetTester tester, + GlobalKey boundaryKey, +) async { + final boundary = + boundaryKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(boundary.toImage); + if (image == null) throw StateError('Failed to capture map image'); + final bytes = await tester.runAsync( + () => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ); + if (bytes == null) throw StateError('Failed to read map image pixels'); + final pixels = _CapturedPixels(bytes, image.width); + image.dispose(); + return pixels; } diff --git a/test/misc/rect_extension_test.dart b/test/misc/rect_extension_test.dart new file mode 100644 index 000000000..5fee2196b --- /dev/null +++ b/test/misc/rect_extension_test.dart @@ -0,0 +1,12 @@ +import 'dart:ui'; + +import 'package:flutter_map/src/misc/extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('recognizes a line segment wholly inside a viewport', () { + const viewport = Rect.fromLTWH(0, 0, 100, 100); + + expect(viewport.aabbContainsLine(20, 20, 80, 80), isTrue); + }); +} diff --git a/test/misc/simplify_colored_test.dart b/test/misc/simplify_colored_test.dart new file mode 100644 index 000000000..3e9ecc87b --- /dev/null +++ b/test/misc/simplify_colored_test.dart @@ -0,0 +1,73 @@ +import 'dart:ui'; + +import 'package:flutter_map/src/misc/simplify.dart'; +import 'package:flutter_map/src/misc/simplify_colored.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final straight = [for (var i = 0; i < 101; i++) Offset(i.toDouble(), 0)]; + test('smooth gradient retains endpoints and original indices', () { + expect( + simplifyColoredPoints( + points: straight, + colors: [ + for (var i = 0; i < 101; i++) + Color.lerp( + const Color(0xffff0000), const Color(0xff0000ff), i / 100)!, + ], + tolerance: 1, + ), + [0, 100], + ); + }); + test('uniform color uses the same geometry as ordinary simplification', () { + final points = [ + for (var i = 0; i < 100; i++) Offset(i.toDouble(), (i % 17).toDouble()), + ]; + for (final tolerance in [0.3, 1.0, 10.0, 50.0]) { + final indices = simplifyColoredPoints( + points: points, + colors: List.filled(points.length, const Color(0xff00ff00)), + tolerance: tolerance, + ); + expect( + [for (final index in indices) points[index]], + simplifyPoints(points: points, tolerance: tolerance, highQuality: true), + ); + } + }); + test('retains brief color and opacity peaks on straight routes', () { + for (final peak in [const Color(0xffff0000), const Color(0x000000ff)]) { + final colors = List.filled(straight.length, const Color(0xff0000ff)); + colors[50] = peak; + expect( + simplifyColoredPoints(points: straight, colors: colors, tolerance: 100), + [0, 49, 50, 51, 100], + ); + } + }); + test('zero tolerance retains all samples', () { + expect( + simplifyColoredPoints(points: straight, colors: null, tolerance: 0), + List.generate(straight.length, (i) => i), + ); + }); + test('duplicate coordinates preserve meaningful color transitions', () { + final points = List.filled(6, Offset.zero); + final indices = simplifyColoredPoints( + points: points, + colors: const [ + Color(0xff0000ff), + Color(0xff0000ff), + Color(0xffff0000), + Color(0xff0000ff), + Color(0xff0000ff), + Color(0xff0000ff), + ], + tolerance: 1, + ); + expect(indices, contains(2)); + expect(indices.first, 0); + expect(indices.last, 5); + }); +}