diff --git a/lib/src/layer/polyline_layer/painter.dart b/lib/src/layer/polyline_layer/painter.dart index 4d14e9228..1afaa8945 100644 --- a/lib/src/layer/polyline_layer/painter.dart +++ b/lib/src/layer/polyline_layer/painter.dart @@ -138,18 +138,32 @@ 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; + if (polyline is MulticolorPolyline) { + 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 +285,190 @@ 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.sourceStartIndex + vertexCount <= 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; + + 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; + if (VisibleSegment.getVisibleSegment( + start, + end, + size, + strokeWidth + polyline.borderStrokeWidth, + ) == + null) { + continue; + } + + final colorIndex = projectedPolyline.sourceStartIndex + i; + + strokePaint.shader = ui.Gradient.linear( + start, + end, + [colors[colorIndex], colors[colorIndex + 1]], + ); + segmentPath.moveTo(start.dx, start.dy); + segmentPath.lineTo(end.dx, end.dy); + canvas.drawPath(segmentPath, strokePaint); + segmentPath.reset(); + } + + 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.sourceStartIndex + 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.sourceStartIndex + 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.sourceStartIndex + index]; + canvas.drawPath(capPath, vertexPaint); + } + + switch (polyline.strokeCap) { + case StrokeCap.butt: + break; + case StrokeCap.round: + if (projectedPolyline.sourceStartIndex == 0) drawRoundCap(0); + if (projectedPolyline.sourceStartIndex + vertexCount == colors.length) { + drawRoundCap(vertexCount - 1); + } + break; + case StrokeCap.square: + if (projectedPolyline.sourceStartIndex == 0) drawSquareCap(0, 1); + if (projectedPolyline.sourceStartIndex + vertexCount == colors.length) { + 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..4d373a46f 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -104,3 +104,101 @@ class Polyline with HitDetectableElement { @override int get hashCode => _hashCode ??= Object.hashAll([...points, renderHashCode]); } + +/// A solid [Polyline] that paints a smooth gradient between vertex colors. +/// +/// 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; + + 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, + 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), + 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, + ); + + bool get _hasTransparentVertices => + _resolvedVertexColors.any((color) => color.a < 1); + + 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(vertexColors, other.vertexColors)); + + @override + int get renderHashCode => _multicolorRenderHashCode ??= Object.hash( + super.renderHashCode, + defaultColor, + 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/polyline_layer.dart b/lib/src/layer/polyline_layer/polyline_layer.dart index a705802b6..7961ebb60 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,7 +208,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 +267,7 @@ class _PolylineLayerState extends State> yield _ProjectedPolyline._( polyline: polyline, points: projectedPolyline.points.sublist(start, i + 1), + sourceStartIndex: projectedPolyline.sourceStartIndex + start, ); // Reset start. @@ -276,8 +283,8 @@ class _PolylineLayerState extends State> ? projectedPolyline : _ProjectedPolyline._( polyline: polyline, - // Special case: the entire polyline is visible points: projectedPolyline.points.sublist(start), + sourceStartIndex: projectedPolyline.sourceStartIndex + start, ); } } diff --git a/lib/src/layer/polyline_layer/projected_polyline.dart b/lib/src/layer/polyline_layer/projected_polyline.dart index bc57c2f8e..734eaccc3 100644 --- a/lib/src/layer/polyline_layer/projected_polyline.dart +++ b/lib/src/layer/polyline_layer/projected_polyline.dart @@ -5,6 +5,11 @@ 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; + /// Bounding box of [points], in projected space (cached) /// /// Computed lazily: culled fragments never use it. @@ -16,6 +21,7 @@ class _ProjectedPolyline with HitDetectableElement { _ProjectedPolyline._({ required this.polyline, required this.points, + this.sourceStartIndex = 0, }); _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/test/layer/polyline_layer_test.dart b/test/layer/polyline_layer_test.dart index 9fdf43f44..173ba66f8 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'; @@ -31,4 +35,279 @@ 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 [], + }); + + final GlobalKey boundaryKey; + final MapController controller; + final List polylines; + + @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: null, + ), + ], + ), + ), + ), + ), + ), + ); +} + +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); + }); +}