From 433c660cb9a1a57daa6f2549f225a0371c21a9e3 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Wed, 29 Jan 2025 19:24:55 +0100 Subject: [PATCH] feat: add multi-world support to `CircleMarker`s (#2018) --- example/lib/pages/circle.dart | 18 ++- example/lib/pages/multi_worlds.dart | 33 +++++ lib/src/layer/circle_layer/painter.dart | 181 +++++++++++++++++------- 3 files changed, 173 insertions(+), 59 deletions(-) diff --git a/example/lib/pages/circle.dart b/example/lib/pages/circle.dart index ee967cb78..2d3d3797d 100644 --- a/example/lib/pages/circle.dart +++ b/example/lib/pages/circle.dart @@ -21,12 +21,15 @@ class _CirclePageState extends State { List? _prevHitValues; List>? _hoverCircles; + static const double _initialBorderStrokeWidth = 2; + static const double _hoverBorderStrokeWidth = 15; + final _circlesRaw = >[ CircleMarker( point: const LatLng(51.5, -0.09), color: Colors.white.withAlpha(178), borderColor: Colors.black, - borderStrokeWidth: 2, + borderStrokeWidth: _initialBorderStrokeWidth, useRadiusInMeter: false, radius: 100, hitValue: (title: 'White', subtitle: 'Radius in logical pixels'), @@ -35,7 +38,7 @@ class _CirclePageState extends State { point: const LatLng(51.5, -0.09), color: Colors.black.withAlpha(178), borderColor: Colors.black, - borderStrokeWidth: 2, + borderStrokeWidth: _initialBorderStrokeWidth, useRadiusInMeter: false, radius: 50, hitValue: ( @@ -48,9 +51,10 @@ class _CirclePageState extends State { // Dorney Lake is ~2km long color: Colors.green.withAlpha(229), borderColor: Colors.black, - borderStrokeWidth: 2, + borderStrokeWidth: _initialBorderStrokeWidth, useRadiusInMeter: true, - radius: 1000, // 1000 meters + radius: 1000, + // 1000 meters hitValue: ( title: 'Green', subtitle: 'Radius in meters, calibrated over ~2km rowing lake' @@ -87,10 +91,12 @@ class _CirclePageState extends State { return CircleMarker( point: original.point, - radius: original.radius + 6.5, + radius: original.radius + + _initialBorderStrokeWidth / 2 + + _hoverBorderStrokeWidth / 2, useRadiusInMeter: original.useRadiusInMeter, color: Colors.transparent, - borderStrokeWidth: 15, + borderStrokeWidth: _hoverBorderStrokeWidth, borderColor: Colors.green, ); }).toList(); diff --git a/example/lib/pages/multi_worlds.dart b/example/lib/pages/multi_worlds.dart index a82171bb1..3d40cc13f 100644 --- a/example/lib/pages/multi_worlds.dart +++ b/example/lib/pages/multi_worlds.dart @@ -15,6 +15,8 @@ class MultiWorldsPage extends StatefulWidget { } class _MultiWorldsPageState extends State { + final LayerHitNotifier _hitNotifier = ValueNotifier(null); + @override Widget build(BuildContext context) { return Scaffold( @@ -30,6 +32,37 @@ class _MultiWorldsPageState extends State { ), children: [ openStreetMapTileLayer, + GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_hitNotifier.value!.hitValues.join(', ')), + duration: const Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: CircleLayer( + circles: [ + const CircleMarker( + point: LatLng(-27.466667, 153.033333), + radius: 1000000, + color: Color.from(alpha: .8, red: 1, green: 1, blue: 0), + borderColor: Colors.green, + borderStrokeWidth: 2, + hitValue: 'Brisbane', + useRadiusInMeter: true, + ), + const CircleMarker( + point: LatLng(45.466667, 9.166667), + radius: 10, + color: Colors.green, + borderColor: Colors.red, + borderStrokeWidth: 2, + hitValue: 'Milan', + ), + ], + hitNotifier: _hitNotifier, + ), + ), MarkerLayer( markers: [ Marker( diff --git a/lib/src/layer/circle_layer/painter.dart b/lib/src/layer/circle_layer/painter.dart index 10d593e59..5600ef370 100644 --- a/lib/src/layer/circle_layer/painter.dart +++ b/lib/src/layer/circle_layer/painter.dart @@ -1,7 +1,6 @@ part of 'circle_layer.dart'; /// The [CustomPainter] used to draw [CircleMarker] for the [CircleLayer]. -@immutable base class CirclePainter extends HitDetectablePainter> { /// Reference to the list of [CircleMarker]s of the [CircleLayer]. @@ -23,76 +22,129 @@ base class CirclePainter required Offset point, required LatLng coordinate, }) { - final circle = element; // Should be optimized out by compiler, avoids lint - - final center = camera.getOffsetFromOrigin(circle.point); - final radius = circle.useRadiusInMeter - ? (center - - camera.getOffsetFromOrigin( - _distance.offset(circle.point, circle.radius, 180))) - .distance - : circle.radius; - - return pow(point.dx - center.dx, 2) + pow(point.dy - center.dy, 2) <= - radius * radius; + final worldWidth = _getWorldWidth(); + final radius = _getRadiusInPixel(element, withBorder: true); + final initialCenter = _getOffset(element.point); + + /// Returns null if invisible, true if hit, false if not hit. + bool? checkIfHit(double shift) { + final center = initialCenter + Offset(shift, 0); + if (!_isVisible( + screenRect: _screenRect, + center: center, + radiusInPixel: radius, + )) { + return null; + } + + return pow(point.dx - center.dx, 2) + pow(point.dy - center.dy, 2) <= + radius * radius; + } + + if (checkIfHit(0) ?? false) { + return true; + } + + // Repeat over all worlds (<--||-->) until culling determines that + // that element is out of view, and therefore all further elements in + // that direction will also be + if (worldWidth == 0) return false; + for (double shift = -worldWidth;; shift -= worldWidth) { + final isHit = checkIfHit(shift); + if (isHit == null) break; + if (isHit) return true; + } + for (double shift = worldWidth;; shift += worldWidth) { + final isHit = checkIfHit(shift); + if (isHit == null) break; + if (isHit) return true; + } + + return false; } @override Iterable> get elements => circles; + late Rect _screenRect; + @override void paint(Canvas canvas, Size size) { - final rect = Offset.zero & size; - canvas.clipRect(rect); + _screenRect = Offset.zero & size; + canvas.clipRect(_screenRect); + + final worldWidth = _getWorldWidth(); // Let's calculate all the points grouped by color and radius final points = >>{}; final pointsFilledBorder = >>{}; final pointsBorder = >>>{}; for (final circle in circles) { - final center = camera.getOffsetFromOrigin(circle.point); - final radius = circle.useRadiusInMeter - ? (center - - camera.getOffsetFromOrigin( - _distance.offset(circle.point, circle.radius, 180))) - .distance - : circle.radius; - points[circle.color] ??= {}; - points[circle.color]![radius] ??= []; - points[circle.color]![radius]!.add(center); - - if (circle.borderStrokeWidth > 0) { - // Check if color have some transparency or not - // As drawPoints is more efficient than drawCircle - if (circle.color.a == 1) { - double radiusBorder = circle.radius + circle.borderStrokeWidth; - if (circle.useRadiusInMeter) { - final rBorder = _distance.offset(circle.point, radiusBorder, 180); - final deltaBorder = center - camera.getOffsetFromOrigin(rBorder); - radiusBorder = deltaBorder.distance; + final radiusWithoutBorder = _getRadiusInPixel(circle, withBorder: false); + final radiusWithBorder = _getRadiusInPixel(circle, withBorder: true); + final initialCenter = _getOffset(circle.point); + + bool checkIfVisible(double shift) { + bool result = false; + final center = initialCenter + Offset(shift, 0); + + bool isVisible(double radius) { + if (_isVisible( + screenRect: _screenRect, + center: center, + radiusInPixel: radius, + )) { + return result = true; } - pointsFilledBorder[circle.borderColor] ??= {}; - pointsFilledBorder[circle.borderColor]![radiusBorder] ??= []; - pointsFilledBorder[circle.borderColor]![radiusBorder]!.add(center); - } else { - double realRadius = circle.radius; - if (circle.useRadiusInMeter) { - final rBorder = _distance.offset(circle.point, realRadius, 180); - final deltaBorder = center - camera.getOffsetFromOrigin(rBorder); - realRadius = deltaBorder.distance; + return false; + } + + if (isVisible(radiusWithoutBorder)) { + points[circle.color] ??= {}; + points[circle.color]![radiusWithoutBorder] ??= []; + points[circle.color]![radiusWithoutBorder]!.add(center); + } + + if (circle.borderStrokeWidth > 0 && isVisible(radiusWithBorder)) { + // Check if color have some transparency or not + // As drawPoints is more efficient than drawCircle + if (circle.color.a == 1) { + pointsFilledBorder[circle.borderColor] ??= {}; + pointsFilledBorder[circle.borderColor]![radiusWithBorder] ??= []; + pointsFilledBorder[circle.borderColor]![radiusWithBorder]! + .add(center); + } else { + pointsBorder[circle.borderColor] ??= {}; + pointsBorder[circle.borderColor]![circle.borderStrokeWidth] ??= {}; + pointsBorder[circle.borderColor]![circle.borderStrokeWidth]![ + radiusWithoutBorder] ??= []; + pointsBorder[circle.borderColor]![circle.borderStrokeWidth]![ + radiusWithoutBorder]! + .add(center); } - pointsBorder[circle.borderColor] ??= {}; - pointsBorder[circle.borderColor]![circle.borderStrokeWidth] ??= {}; - pointsBorder[circle.borderColor]![circle.borderStrokeWidth]![ - realRadius] ??= []; - pointsBorder[circle.borderColor]![circle.borderStrokeWidth]![ - realRadius]! - .add(center); } + return result; + } + + checkIfVisible(0); + + // Repeat over all worlds (<--||-->) until culling determines that + // that element is out of view, and therefore all further elements in + // that direction will also be + if (worldWidth == 0) continue; + for (double shift = -worldWidth;; shift -= worldWidth) { + final isVisible = checkIfVisible(shift); + if (!isVisible) break; + } + for (double shift = worldWidth;; shift += worldWidth) { + final isVisible = checkIfVisible(shift); + if (!isVisible) break; } } // Now that all the points are grouped, let's draw them + + // First, the border when with non opaque disk final paintBorder = Paint()..style = PaintingStyle.stroke; for (final color in pointsBorder.keys) { final paint = paintBorder..color = color; @@ -108,7 +160,7 @@ base class CirclePainter } } - // Then the filled border in order to be under the circle + // Then the filled border in order to be under the disk final paintPoint = Paint() ..isAntiAlias = false ..strokeCap = StrokeCap.round; @@ -122,7 +174,7 @@ base class CirclePainter } } - // And then the circle + // And then the disk for (final color in points.keys) { final paint = paintPoint..color = color; final pointsByRadius = points[color]!; @@ -145,4 +197,27 @@ base class CirclePainter @override bool shouldRepaint(CirclePainter oldDelegate) => circles != oldDelegate.circles || camera != oldDelegate.camera; + + Offset _getOffset(LatLng pos) => camera.getOffsetFromOrigin(pos); + + double _getRadiusInPixel(CircleMarker circle, {required bool withBorder}) => + (withBorder ? circle.borderStrokeWidth / 2 : 0) + + (circle.useRadiusInMeter + ? (_getOffset(circle.point) - + _getOffset( + _distance.offset(circle.point, circle.radius, 180))) + .distance + : circle.radius); + + /// Returns true if a centered circle with this radius is on the screen. + bool _isVisible({ + required Rect screenRect, + required Offset center, + required double radiusInPixel, + }) => + screenRect.overlaps( + Rect.fromCircle(center: center, radius: radiusInPixel), + ); + + double _getWorldWidth() => camera.getWorldWidthAtZoom(); }