From d81e85bf22dcb9a04adc0e97d8209d6afa39e97e Mon Sep 17 00:00:00 2001 From: Richard Young Date: Wed, 1 Oct 2025 21:20:29 -0400 Subject: [PATCH 1/2] multicolorpolyline --- lib/src/layer/polyline_layer/painter.dart | 109 ++++++++++++++++++ lib/src/layer/polyline_layer/polyline.dart | 62 ++++++++++ .../layer/polyline_layer/polyline_layer.dart | 29 +++-- test/layer/polyline_layer_test.dart | 24 ++++ 4 files changed, 213 insertions(+), 11 deletions(-) diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index 4656f83a4..f9da74055 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -135,6 +135,19 @@ class _PolylinePainter extends CustomPainter ); if (!areOffsetsVisible(offsets)) return WorldWorkControl.invisible; + if (polyline is MulticolorPolyline) { + drawPaths(); + lastHash = null; + needsLayerSaving = false; + _drawMulticolorPolyline( + canvas: canvas, + size: size, + projectedPolyline: projectedPolyline, + offsets: offsets, + ); + return WorldWorkControl.visible; + } + final hash = polyline.renderHashCode; if (needsLayerSaving || (lastHash != null && lastHash != hash)) { drawPaths(); @@ -265,6 +278,102 @@ class _PolylinePainter extends CustomPainter drawPaths(); } + void _drawMulticolorPolyline({ + required Canvas canvas, + required Size size, + required _ProjectedPolyline projectedPolyline, + required List offsets, + }) { + final polyline = projectedPolyline.polyline as MulticolorPolyline; + + final colors = polyline.vertexColors; + final vertexCount = math.min(offsets.length, colors.length); + if (vertexCount < 2) { + return; + } + + final strokeWidth = polyline.useStrokeWidthInMeter + ? metersToScreenPixels( + projectedPolyline.polyline.points.first, + polyline.strokeWidth, + ) + : polyline.strokeWidth; + + final hasBorder = polyline.borderStrokeWidth > 0.0; + final requiresLayerSaving = polyline.hasTransparentVertices; + + 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(); + } + } + + final strokePaint = Paint() + ..strokeWidth = strokeWidth + ..strokeCap = polyline.strokeCap + ..strokeJoin = polyline.strokeJoin + ..style = PaintingStyle.stroke + ..blendMode = BlendMode.srcOver; + + final segmentPath = ui.Path(); + + for (int i = 0; i < vertexCount - 1; i++) { + final start = offsets[i]; + final end = offsets[i + 1]; + if (start == end) continue; + + strokePaint.shader = ui.Gradient.linear( + start, + end, + [colors[i], colors[i + 1]], + ); + segmentPath.moveTo(start.dx, start.dy); + segmentPath.lineTo(end.dx, end.dy); + canvas.drawPath(segmentPath, strokePaint); + segmentPath.reset(); + } + + strokePaint.shader = null; + } + 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..10421e6ac 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -104,3 +104,65 @@ class Polyline with HitDetectableElement { @override int get hashCode => _hashCode ??= Object.hashAll([...points, renderHashCode]); } + +/// A [Polyline] variant that can display a different color at each vertex and +/// paints a smooth gradient between consecutive vertices. +class MulticolorPolyline extends Polyline { + /// The color applied at each vertex in [points]. + /// + /// The list length must match the number of [points]. + final List vertexColors; + + int? _multicolorRenderHashCode; + + /// Create a multicolor polyline that interpolates between the supplied + /// [vertexColors]. + MulticolorPolyline({ + required List points, + required this.vertexColors, + double strokeWidth = 1.0, + StrokePattern pattern = const StrokePattern.solid(), + double borderStrokeWidth = 0.0, + Color borderColor = const Color(0xFFFFFF00), + StrokeCap strokeCap = StrokeCap.round, + StrokeJoin strokeJoin = StrokeJoin.round, + bool useStrokeWidthInMeter = false, + R? hitValue, + }) : assert(points.length == vertexColors.length, + 'vertexColors length must match points length'), + assert(points.length >= 2, + 'MulticolorPolyline requires at least two points'), + assert( + pattern == const StrokePattern.solid(), + 'MulticolorPolyline currently supports only solid stroke patterns.', + ), + super( + points: points, + strokeWidth: strokeWidth, + pattern: pattern, + color: vertexColors.first, + borderStrokeWidth: borderStrokeWidth, + borderColor: borderColor, + gradientColors: null, + colorsStop: null, + strokeCap: strokeCap, + strokeJoin: strokeJoin, + useStrokeWidthInMeter: useStrokeWidthInMeter, + hitValue: hitValue, + ); + + /// Returns `true` when any vertex color is translucent. + bool get hasTransparentVertices => + vertexColors.any((color) => color.alpha < 0xFF); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MulticolorPolyline && + super == other && + listEquals(vertexColors, other.vertexColors)); + + @override + int get renderHashCode => _multicolorRenderHashCode ??= + Object.hash(super.renderHashCode, Object.hashAll(vertexColors)); +} diff --git a/lib/src/layer/polyline_layer/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index 6da20ffc5..da15010cc 100644 --- a/lib/src/layer/polyline_layer/polyline_layer.dart +++ b/lib/src/layer/polyline_layer/polyline_layer.dart @@ -93,15 +93,21 @@ 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) { + return projectedElement; + } + + return _ProjectedPolyline._( + polyline: polyline, + points: simplifyPoints( + points: projectedElement.points, + tolerance: tolerance, + highQuality: true, + ), + ); + } @override List> get elements => widget.polylines; @@ -202,8 +208,9 @@ class _PolylineLayerState extends State> // First check, bullet-proof, focusing on latitudes. if (!isOverlappingLatitude()) continue; - // Gradient polylines cannot be easily segmented - if (polyline.gradientColors != null) { + // Gradient or multicolor polylines cannot be easily segmented + if (polyline.gradientColors != null || + polyline is MulticolorPolyline) { yield projectedPolyline; continue; } diff --git a/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index 9fdf43f44..64a85e037 100644 --- a/test/layer/polyline_layer_test.dart +++ b/test/layer/polyline_layer_test.dart @@ -31,4 +31,28 @@ 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); + }); } From 672694eb4ab26b02f3d0e3279a8f97b58ed5e689 Mon Sep 17 00:00:00 2001 From: Richard Young Date: Thu, 2 Oct 2025 01:36:22 -0400 Subject: [PATCH 2/2] default color --- lib/src/layer/polyline_layer/painter.dart | 2 +- lib/src/layer/polyline_layer/polyline.dart | 54 +++++++++++++++++----- test/layer/polyline_layer_test.dart | 25 ++++++++++ 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index f9da74055..fa8005b08 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -286,7 +286,7 @@ class _PolylinePainter extends CustomPainter }) { final polyline = projectedPolyline.polyline as MulticolorPolyline; - final colors = polyline.vertexColors; + final colors = polyline.resolvedVertexColors; final vertexCount = math.min(offsets.length, colors.length); if (vertexCount < 2) { return; diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart index 10421e6ac..d899be516 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -110,16 +110,21 @@ class Polyline with HitDetectableElement { class MulticolorPolyline extends Polyline { /// The color applied at each vertex in [points]. /// - /// The list length must match the number of [points]. - final List vertexColors; + /// When `null` or empty, [defaultColor] is used instead. + final List? vertexColors; + + /// The fallback color used when no [vertexColors] are provided. + final Color defaultColor; int? _multicolorRenderHashCode; + List? _resolvedColors; /// Create a multicolor polyline that interpolates between the supplied /// [vertexColors]. MulticolorPolyline({ required List points, - required this.vertexColors, + List? vertexColors, + this.defaultColor = const Color(0xFF00FF00), double strokeWidth = 1.0, StrokePattern pattern = const StrokePattern.solid(), double borderStrokeWidth = 0.0, @@ -128,19 +133,28 @@ class MulticolorPolyline extends Polyline { StrokeJoin strokeJoin = StrokeJoin.round, bool useStrokeWidthInMeter = false, R? hitValue, - }) : assert(points.length == vertexColors.length, - 'vertexColors length must match points length'), + }) : assert( + vertexColors == null || + vertexColors.isEmpty || + points.length == vertexColors.length, + 'vertexColors length must match points length', + ), assert(points.length >= 2, 'MulticolorPolyline requires at least two points'), assert( pattern == const StrokePattern.solid(), 'MulticolorPolyline currently supports only solid stroke patterns.', ), + vertexColors = vertexColors != null && vertexColors.isNotEmpty + ? vertexColors + : null, super( points: points, strokeWidth: strokeWidth, pattern: pattern, - color: vertexColors.first, + color: (vertexColors != null && vertexColors.isNotEmpty) + ? vertexColors.first + : defaultColor, borderStrokeWidth: borderStrokeWidth, borderColor: borderColor, gradientColors: null, @@ -151,18 +165,36 @@ class MulticolorPolyline extends Polyline { hitValue: hitValue, ); - /// Returns `true` when any vertex color is translucent. + /// Returns `true` when any effective vertex color is translucent. bool get hasTransparentVertices => - vertexColors.any((color) => color.alpha < 0xFF); + resolvedVertexColors.any((color) => color.alpha < 0xFF); + + /// Returns `true` when at least two distinct vertex colors were provided. + bool get hasGradientStops => + vertexColors != null && vertexColors!.length >= 2; + + /// Returns the colors used for painting, falling back to [defaultColor] when + /// custom [vertexColors] are not provided. + List get resolvedVertexColors => _resolvedColors ??= vertexColors ?? + List.filled(points.length, defaultColor, growable: false); @override bool operator ==(Object other) => identical(this, other) || (other is MulticolorPolyline && super == other && - listEquals(vertexColors, other.vertexColors)); + defaultColor == other.defaultColor && + _listEqualsNullable(vertexColors, other.vertexColors)); @override - int get renderHashCode => _multicolorRenderHashCode ??= - Object.hash(super.renderHashCode, Object.hashAll(vertexColors)); + int get renderHashCode => _multicolorRenderHashCode ??= Object.hash( + super.renderHashCode, + defaultColor, + vertexColors == null ? null : Object.hashAll(vertexColors!)); +} + +bool _listEqualsNullable(List? a, List? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return a == null && b == null; + return listEquals(a, b); } diff --git a/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index 64a85e037..d36393b82 100644 --- a/test/layer/polyline_layer_test.dart +++ b/test/layer/polyline_layer_test.dart @@ -55,4 +55,29 @@ void main() { 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)); + expect(polyline.resolvedVertexColors, everyElement(equals(Colors.purple))); + }); }