Skip to content

Commit

Permalink
feat: add multi-world support to CircleMarkers (#2018)
Browse files Browse the repository at this point in the history
  • Loading branch information
monsieurtanuki authored Jan 29, 2025
1 parent 48832da commit 433c660
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 59 deletions.
18 changes: 12 additions & 6 deletions example/lib/pages/circle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ class _CirclePageState extends State<CirclePage> {
List<HitValue>? _prevHitValues;
List<CircleMarker<HitValue>>? _hoverCircles;

static const double _initialBorderStrokeWidth = 2;
static const double _hoverBorderStrokeWidth = 15;

final _circlesRaw = <CircleMarker<HitValue>>[
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'),
Expand All @@ -35,7 +38,7 @@ class _CirclePageState extends State<CirclePage> {
point: const LatLng(51.5, -0.09),
color: Colors.black.withAlpha(178),
borderColor: Colors.black,
borderStrokeWidth: 2,
borderStrokeWidth: _initialBorderStrokeWidth,
useRadiusInMeter: false,
radius: 50,
hitValue: (
Expand All @@ -48,9 +51,10 @@ class _CirclePageState extends State<CirclePage> {
// 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'
Expand Down Expand Up @@ -87,10 +91,12 @@ class _CirclePageState extends State<CirclePage> {

return CircleMarker<HitValue>(
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();
Expand Down
33 changes: 33 additions & 0 deletions example/lib/pages/multi_worlds.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class MultiWorldsPage extends StatefulWidget {
}

class _MultiWorldsPageState extends State<MultiWorldsPage> {
final LayerHitNotifier<String> _hitNotifier = ValueNotifier(null);

@override
Widget build(BuildContext context) {
return Scaffold(
Expand All @@ -30,6 +32,37 @@ class _MultiWorldsPageState extends State<MultiWorldsPage> {
),
children: [
openStreetMapTileLayer,
GestureDetector(
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_hitNotifier.value!.hitValues.join(', ')),
duration: const Duration(seconds: 1),
showCloseIcon: true,
),
),
child: CircleLayer<String>(
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(
Expand Down
181 changes: 128 additions & 53 deletions lib/src/layer/circle_layer/painter.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
part of 'circle_layer.dart';

/// The [CustomPainter] used to draw [CircleMarker] for the [CircleLayer].
@immutable
base class CirclePainter<R extends Object>
extends HitDetectablePainter<R, CircleMarker<R>> {
/// Reference to the list of [CircleMarker]s of the [CircleLayer].
Expand All @@ -23,76 +22,129 @@ base class CirclePainter<R extends Object>
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<CircleMarker<R>> 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 = <Color, Map<double, List<Offset>>>{};
final pointsFilledBorder = <Color, Map<double, List<Offset>>>{};
final pointsBorder = <Color, Map<double, Map<double, List<Offset>>>>{};
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;
Expand All @@ -108,7 +160,7 @@ base class CirclePainter<R extends Object>
}
}

// 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;
Expand All @@ -122,7 +174,7 @@ base class CirclePainter<R extends Object>
}
}

// And then the circle
// And then the disk
for (final color in points.keys) {
final paint = paintPoint..color = color;
final pointsByRadius = points[color]!;
Expand All @@ -145,4 +197,27 @@ base class CirclePainter<R extends Object>
@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();
}

0 comments on commit 433c660

Please sign in to comment.