From a96942779a18152d98a77d5a02b175e1ff5a4aea Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Mon, 9 Jun 2025 13:33:41 +0100 Subject: [PATCH 1/3] Experimenting with `MultiChildRenderObjectWidget` to improve rendering of markers --- example/lib/pages/many_markers.dart | 5 +- example/lib/pages/markers.dart | 76 +++--- lib/src/layer/marker_layer/marker_layer.dart | 240 ++++++++++++++++++- lib/src/map/camera/camera.dart | 3 +- 4 files changed, 293 insertions(+), 31 deletions(-) diff --git a/example/lib/pages/many_markers.dart b/example/lib/pages/many_markers.dart index 8b3f54545..9894c0e70 100644 --- a/example/lib/pages/many_markers.dart +++ b/example/lib/pages/many_markers.dart @@ -45,7 +45,10 @@ class ManyMarkersPageState extends State { point: LatLng(doubleInRange(r, 37, 55), doubleInRange(r, -9, 30)), height: 12, width: 12, - child: ColoredBox(color: Colors.blue[900]!), + child: SizedBox.square( + dimension: 12, + child: ColoredBox(color: Colors.blue[900]!), + ), ), ); } diff --git a/example/lib/pages/markers.dart b/example/lib/pages/markers.dart index 2f652096b..0220a301f 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -36,17 +36,30 @@ class _MarkerPageState extends State { Marker buildPin(LatLng point) => Marker( point: point, - width: 60, - height: 60, - child: GestureDetector( - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Tapped existing marker'), - duration: Duration(seconds: 1), - showCloseIcon: true, - ), - ), - child: const Icon(Icons.location_pin, size: 60, color: Colors.black), + //width: 60, + //height: 60, + child: Builder( + builder: (context) { + final e = MapCamera.of(context); + print('sdsd'); + return DecoratedBox( + decoration: BoxDecoration(border: Border.all()), + child: GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tapped existing marker'), + duration: Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: const Icon( + Icons.location_pin, + size: 60, + color: Colors.black, + ), + ), + ); + }, ), ); @@ -128,37 +141,46 @@ class _MarkerPageState extends State { openStreetMapTileLayer, MarkerLayer( rotate: counterRotate, - markers: const [ + markers: [ Marker( point: LatLng(47.18664724067855, -1.5436768515939427), - width: 64, - height: 64, + //width: 64, + //height: 64, alignment: Alignment.centerLeft, - child: ColoredBox( - color: Colors.lightBlue, - child: Align( - alignment: Alignment.centerRight, - child: Text('-->'), + child: SizedBox.square( + dimension: 64, + child: ColoredBox( + color: Colors.lightBlue, + child: Align( + alignment: Alignment.centerRight, + child: Text('-->'), + ), ), ), ), Marker( point: LatLng(47.18664724067855, -1.5436768515939427), - width: 64, - height: 64, + //width: 64, + //height: 64, alignment: Alignment.centerRight, - child: ColoredBox( - color: Colors.pink, - child: Align( - alignment: Alignment.centerLeft, - child: Text('<--'), + child: SizedBox.square( + dimension: 64, + child: ColoredBox( + color: Colors.pink, + child: Align( + alignment: Alignment.centerLeft, + child: Text('<--'), + ), ), ), ), Marker( point: LatLng(47.18664724067855, -1.5436768515939427), rotate: false, - child: ColoredBox(color: Colors.black), + child: SizedBox.square( + dimension: 24, + child: ColoredBox(color: Colors.black), + ), ), ], ), diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index fe4693d46..5b421aa79 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -1,10 +1,247 @@ +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; part 'marker.dart'; -/// A [Marker] layer for [FlutterMap]. +class MarkerLayer extends MultiChildRenderObjectWidget { + /// The list of [Marker]s. + final List markers; + + /// Alignment of each marker relative to its normal center at [Marker.point] + /// + /// For example, [Alignment.topCenter] will mean the entire marker widget is + /// located above the [Marker.point]. + /// + /// The center of rotation (anchor) will be opposite this. + /// + /// Defaults to [Alignment.center]. Overriden by [Marker.alignment] if set. + final Alignment alignment; + + /// Whether to counter rotate markers to the map's rotation, to keep a fixed + /// orientation + /// + /// When `true`, markers will always appear upright and vertical from the + /// user's perspective. Defaults to `false`. Overriden by [Marker.rotate]. + /// + /// Note that this is not used to apply a custom rotation in degrees to the + /// markers. Use a widget inside [Marker.child] to perform this. + final bool rotate; + + /// Create a new [MarkerLayer] to use inside of [FlutterMap.children]. + const MarkerLayer({ + super.key, + required this.markers, + this.alignment = Alignment.center, + this.rotate = false, + }); + + @override + List get children => + markers.map((m) => _MarkerWidget(marker: m)).toList(growable: false); + + @override + RenderObject createRenderObject(BuildContext context) => + _MarkerLayerRenderBox( + camera: MapCamera.of(context), + alignment: alignment, + rotate: rotate, + ); + + @override + void updateRenderObject( + BuildContext context, + // ignore: library_private_types_in_public_api + covariant _MarkerLayerRenderBox renderObject, + ) { + final latestCamera = MapCamera.of(context); + if (latestCamera != renderObject.camera) { + renderObject.camera = latestCamera; + } + + if (alignment != renderObject.alignment) { + renderObject.alignment = alignment; + } + + if (rotate != renderObject.rotate) { + renderObject.rotate = rotate; + } + } +} + +class _MarkerWidget extends ParentDataWidget<_MarkerParentData> { + _MarkerWidget({required this.marker}) : super(child: marker.child); + + final Marker marker; + + @override + Key? get key => marker.key ?? ObjectKey(marker); + + @override + void applyParentData(RenderObject renderObject) { + final parentData = renderObject.parentData! as _MarkerParentData; + bool needsLayout = false; + + if (parentData.point != marker.point) { + parentData.point = marker.point; + needsLayout = true; + } + if (parentData.alignment != marker.alignment) { + parentData.alignment = marker.alignment; + needsLayout = true; + } + if (parentData.rotate != marker.rotate) { + parentData.rotate = marker.rotate; + needsLayout = true; + } + + if (needsLayout) renderObject.parent?.markNeedsLayout(); + } + + @override + Type get debugTypicalAncestorWidgetClass => MarkerLayer; +} + +class _MarkerParentData extends ParentData + with ContainerParentDataMixin { + LatLng? point; + Alignment? alignment; + bool? rotate; +} + +class _MarkerLayerRenderBox extends RenderBox + with ContainerRenderObjectMixin { + _MarkerLayerRenderBox({ + required MapCamera camera, + required Alignment alignment, + required bool rotate, + }) : _camera = camera, + _alignment = alignment, + _rotate = rotate; + + MapCamera get camera => _camera; + MapCamera _camera; + set camera(MapCamera value) { + if (value == _camera) return; + _camera = value; + markNeedsPaint(); + } + + Alignment get alignment => _alignment; + Alignment _alignment; + set alignment(Alignment value) { + if (value == _alignment) return; + _alignment = value; + markNeedsPaint(); + } + + bool get rotate => _rotate; + bool _rotate; + set rotate(bool value) { + if (value == _rotate) return; + _rotate = value; + markNeedsPaint(); + } + + @override + void setupParentData(RenderObject child) { + if (child.parentData is! _MarkerParentData) { + child.parentData = _MarkerParentData(); + } + } + + @override + void performLayout() { + size = constraints.biggest; + + var child = firstChild; + while (child != null) { + // If the child expands, it is constrained to the maximum non rotated size + // TODO: This is probably unwanted, find a way to force children to define + // their size + child.layout(constraints.loosen(), parentUsesSize: true); + child = (child.parentData! as ContainerParentDataMixin) + .nextSibling; + } + } + + @override + void paint(PaintingContext context, Offset offset) { + var child = firstChild; + while (child != null) { + final markerData = child.parentData! as _MarkerParentData; + + final markerOffset = camera.latLngToScreenOffset(markerData.point!); + // We need to apply this, but if we're rotating, we want to do that first + final alignmentOffset = + ((markerData.alignment ?? alignment) * -1).alongSize(child.size); + + if (markerData.rotate ?? rotate) { + context.pushTransform( + needsCompositing, + offset + markerOffset, + Matrix4.identity()..rotateZ(camera.rotationRad), + (context, transformOffset) => + context.paintChild(child!, transformOffset - alignmentOffset), + ); + } else { + final childOffset = offset + markerOffset - alignmentOffset; + + bool paintIfVisible(double worldShift) { + final shiftedX = childOffset.dx + worldShift; + + // Cull if out of bounds + // TODO: Verify + // TODO: Copy to transformed logic also + if (size.width <= shiftedX || shiftedX + child!.size.width <= 0) { + return false; + } + if (size.height <= childOffset.dy || + childOffset.dy + child.size.height <= 0) { + return false; + } + + context.paintChild(child, Offset(shiftedX, childOffset.dy)); + return true; + } + + // Create marker in main world, unless culled + final main = paintIfVisible(0); + if (!main) { + child = markerData.nextSibling; + continue; + } + // It is unsafe to assume that if the main one is culled, it will + // also be culled in all other worlds, so we must continue + + // TODO: optimization - find a way to skip these tests in some + // obvious situations. Imagine we're in a map smaller than the + // world, and west lower than east - in that case we probably don't + // need to check eastern and western. + + final worldWidth = camera.getWorldWidthAtZoom(); + + // Repeat over all worlds (<--||-->) until culling determines that + // that marker is out of view, and therefore all further markers in + // that direction will also be + if (worldWidth == 0) continue; + for (double shift = -worldWidth;; shift -= worldWidth) { + final additional = paintIfVisible(shift); + if (!additional) break; + } + for (double shift = worldWidth;; shift += worldWidth) { + final additional = paintIfVisible(shift); + if (!additional) break; + } + } + + child = markerData.nextSibling; + } + } +} + +/*/// A [Marker] layer for [FlutterMap]. @immutable class MarkerLayer extends StatelessWidget { /// The list of [Marker]s. @@ -123,3 +360,4 @@ class MarkerLayer extends StatelessWidget { ); } } +*/ diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index 4f54cc36d..9fcd82549 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -310,9 +310,8 @@ class MapCamera { var point = crs.latLngToOffset(latLng, zoom); - final mapCenter = crs.latLngToOffset(center, zoom); - if (rotation != 0.0) { + final mapCenter = crs.latLngToOffset(center, zoom); point = rotatePoint(mapCenter, point, counterRotation: false); } From 5035f7a604342a95fd8fb1dd0fdb305e9f1c1e2d Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 22 Jul 2025 21:56:41 +0100 Subject: [PATCH 2/3] TODO & debugging --- example/lib/pages/many_markers.dart | 41 +++++++++++--------- lib/src/layer/marker_layer/marker_layer.dart | 2 + 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/example/lib/pages/many_markers.dart b/example/lib/pages/many_markers.dart index f8060e785..ae509dfb1 100644 --- a/example/lib/pages/many_markers.dart +++ b/example/lib/pages/many_markers.dart @@ -48,28 +48,31 @@ class ManyMarkersPageState extends State { point: position, width: 30, height: 30, - child: GestureDetector( - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Tapped existing marker (${position.latitude}, ' - '${position.longitude})', + child: Builder(builder: (context) { + print('built $position'); + return GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Tapped existing marker (${position.latitude}, ' + '${position.longitude})', + ), + duration: const Duration(seconds: 1), + showCloseIcon: true, ), - duration: const Duration(seconds: 1), - showCloseIcon: true, ), - ), - child: Icon( - Icons.location_pin, - size: 30, - color: Color.fromARGB( - 255, - randomGenerator.nextInt(256), - randomGenerator.nextInt(256), - randomGenerator.nextInt(256), + child: Icon( + Icons.location_pin, + size: 30, + color: Color.fromARGB( + 255, + randomGenerator.nextInt(256), + randomGenerator.nextInt(256), + randomGenerator.nextInt(256), + ), ), - ), - ), + ); + }), ); }, ); diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index 5b421aa79..e29568030 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -75,6 +75,8 @@ class _MarkerWidget extends ParentDataWidget<_MarkerParentData> { final Marker marker; + // TODO: I think?? this means the `Marker` must be constructed `const` or have + // a key set? @override Key? get key => marker.key ?? ObjectKey(marker); From d05ed7a2a4f1a04883b1a4170967fc58c0bc3e15 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Wed, 23 Jul 2025 13:39:15 +0100 Subject: [PATCH 3/3] Implement initial hit testing for `MarkerLayer` Refactoring of `MarkerLayer` internals Added `TestMarker` widget to example app --- example/lib/misc/test_marker.dart | 63 ++++ example/lib/pages/many_markers.dart | 39 +-- example/lib/pages/markers.dart | 36 +- lib/src/layer/marker_layer/marker_layer.dart | 344 +++++++++---------- 4 files changed, 247 insertions(+), 235 deletions(-) create mode 100644 example/lib/misc/test_marker.dart diff --git a/example/lib/misc/test_marker.dart b/example/lib/misc/test_marker.dart new file mode 100644 index 000000000..61b496194 --- /dev/null +++ b/example/lib/misc/test_marker.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +final _randomGenerator = Random(10); + +class TestMarker extends StatefulWidget { + const TestMarker({super.key, required this.point}); + + final LatLng point; + + @override + State createState() => _TestMarkerState(); +} + +class _TestMarkerState extends State { + Timer? _tapSelected; + bool _hoverSelected = false; + + bool get _isSelected => _tapSelected != null || _hoverSelected; + + late final _unselectedColor = Color.fromARGB( + 255, + _randomGenerator.nextInt(256), + _randomGenerator.nextInt(256), + _randomGenerator.nextInt(256), + ); + + @override + Widget build(BuildContext context) { + print('built ${widget.point}'); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: _isSelected ? Colors.green : Colors.black.withAlpha(51), + width: _isSelected ? 3 : 1, + ), + ), + child: MouseRegion( + onEnter: (_) => setState(() => _hoverSelected = true), + onExit: (_) => setState(() => _hoverSelected = false), + child: GestureDetector( + onTap: () => setState(() { + _tapSelected?.cancel(); + _tapSelected = Timer( + const Duration(seconds: 1), + () { + if (mounted) setState(() => _tapSelected = null); + }, + ); + }), + child: Icon( + Icons.location_pin, + size: 40, + color: _isSelected ? Colors.green : _unselectedColor, + ), + ), + ), + ); + } +} diff --git a/example/lib/pages/many_markers.dart b/example/lib/pages/many_markers.dart index ae509dfb1..3cfd9ff6b 100644 --- a/example/lib/pages/many_markers.dart +++ b/example/lib/pages/many_markers.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/test_marker.dart'; import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:flutter_map_example/widgets/number_of_items_slider.dart'; @@ -39,40 +40,19 @@ class ManyMarkersPageState extends State { distance * sin(angle) * (0.7 + randomGenerator.nextDouble() * 0.6); final lngOffset = distance * cos(angle) * (0.7 + randomGenerator.nextDouble() * 0.6); - final position = LatLng( + final point = LatLng( _londonOrigin.latitude + latOffset, _londonOrigin.longitude + lngOffset, ); return Marker( - point: position, - width: 30, - height: 30, - child: Builder(builder: (context) { - print('built $position'); - return GestureDetector( - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Tapped existing marker (${position.latitude}, ' - '${position.longitude})', - ), - duration: const Duration(seconds: 1), - showCloseIcon: true, - ), - ), - child: Icon( - Icons.location_pin, - size: 30, - color: Color.fromARGB( - 255, - randomGenerator.nextInt(256), - randomGenerator.nextInt(256), - randomGenerator.nextInt(256), - ), - ), - ); - }), + point: point, + // TODO: Part of what's being tested is removing these w/h. But we can't + // perform culling before build without - but maybe that's not too + // necessary now? + // width: 30, + // height: 30, + child: TestMarker(point: point), ); }, ); @@ -103,6 +83,7 @@ class ManyMarkersPageState extends State { children: [ openStreetMapTileLayer, MarkerLayer( + alignment: Alignment.topCenter, markers: allMarkers .take(displayedMarkersCount) .toList(growable: false), diff --git a/example/lib/pages/markers.dart b/example/lib/pages/markers.dart index 0220a301f..6dff6d701 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/test_marker.dart'; import 'package:flutter_map_example/misc/tile_providers.dart'; import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; import 'package:latlong2/latlong.dart'; @@ -30,37 +31,18 @@ class _MarkerPageState extends State { }; late final customMarkers = [ - buildPin(const LatLng(51.51868093513547, -0.12835376940892318)), - buildPin(const LatLng(53.33360293799854, -6.284001062079881)), + buildPin(const LatLng(51.5186809, -0.1283537)), + buildPin(const LatLng(53.3336029, -6.2840010)), ]; Marker buildPin(LatLng point) => Marker( point: point, - //width: 60, - //height: 60, - child: Builder( - builder: (context) { - final e = MapCamera.of(context); - print('sdsd'); - return DecoratedBox( - decoration: BoxDecoration(border: Border.all()), - child: GestureDetector( - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Tapped existing marker'), - duration: Duration(seconds: 1), - showCloseIcon: true, - ), - ), - child: const Icon( - Icons.location_pin, - size: 60, - color: Colors.black, - ), - ), - ); - }, - ), + // TODO: Part of what's being tested is removing these w/h. But we can't + // perform culling before build without - but maybe that's not too + // necessary now? + // width: 60, + // height: 60, + child: TestMarker(point: point), ); @override diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index e29568030..c130674a1 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -1,10 +1,14 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/shared/feature_layer_utils.dart'; import 'package:latlong2/latlong.dart'; part 'marker.dart'; +/// A layer for [FlutterMap] which displays custom widgets at specified +/// coordinates ([Marker]s). +@immutable class MarkerLayer extends MultiChildRenderObjectWidget { /// The list of [Marker]s. final List markers; @@ -37,6 +41,7 @@ class MarkerLayer extends MultiChildRenderObjectWidget { this.rotate = false, }); + // TODO: Consider whether culling before build is still necessary @override List get children => markers.map((m) => _MarkerWidget(marker: m)).toList(growable: false); @@ -75,7 +80,7 @@ class _MarkerWidget extends ParentDataWidget<_MarkerParentData> { final Marker marker; - // TODO: I think?? this means the `Marker` must be constructed `const` or have + // TODO: I think? this means the `Marker` must be constructed `const` or have // a key set? @override Key? get key => marker.key ?? ObjectKey(marker); @@ -112,6 +117,7 @@ class _MarkerParentData extends ParentData bool? rotate; } +/// See also [RenderStack] & [RenderTransform] class _MarkerLayerRenderBox extends RenderBox with ContainerRenderObjectMixin { _MarkerLayerRenderBox({ @@ -159,207 +165,187 @@ class _MarkerLayerRenderBox extends RenderBox var child = firstChild; while (child != null) { - // If the child expands, it is constrained to the maximum non rotated size - // TODO: This is probably unwanted, find a way to force children to define - // their size - child.layout(constraints.loosen(), parentUsesSize: true); - child = (child.parentData! as ContainerParentDataMixin) - .nextSibling; + child.layout(const BoxConstraints(), parentUsesSize: true); + child = (child.parentData! as _MarkerParentData).nextSibling; } } +// TODO: This is in `RenderTransform`, but I can't figure out what it +// necessarily does or whether we can take advantage of it +/* + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.multiply(_effectiveTransform); + } +*/ + + ({Offset markerOffset, Offset alignmentOffset}) _getChildOffsets( + _MarkerParentData childParentData, + Size childSize, + ) => + ( + markerOffset: camera.latLngToScreenOffset(childParentData.point!), + alignmentOffset: + ((childParentData.alignment ?? alignment) * -1).alongSize(childSize) + ); + + bool _isChildInvisible(Offset shiftedChildOffset, Size childSize) => + size.width <= shiftedChildOffset.dx || + shiftedChildOffset.dx + childSize.width <= 0 || + size.height <= shiftedChildOffset.dy || + shiftedChildOffset.dy + childSize.height <= 0; + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + var child = lastChild; + while (child != null) { + final childParentData = child.parentData! as _MarkerParentData; + + final (:markerOffset, :alignmentOffset) = + _getChildOffsets(childParentData, child.size); + + if (childParentData.rotate ?? rotate + ? + // TODO: Repeat across worlds + result.addWithPaintTransform( + transform: Matrix4.identity() + ..leftTranslateByDouble(markerOffset.dx, markerOffset.dy, 0, 1) + ..rotateZ(camera.rotationRad), + position: position, + hitTest: (result, transformed) { + return child! + .hitTest(result, position: transformed + alignmentOffset); + }, + ) + : + // TODO: Fix issue of not rotating across worlds + result.addWithPaintOffset( + offset: markerOffset - alignmentOffset, + position: position, + hitTest: (result, transformed) => _workAcrossWorlds( + camera, + (shift) { + final childOffset = + Offset(transformed.dx + shift, transformed.dy); + + if (_isChildInvisible(childOffset, child!.size)) { + return WorldWorkControl.invisible; + } + + return child.hitTest(result, position: childOffset) + ? WorldWorkControl.hit + : WorldWorkControl.visible; + }, + ), + )) { + return true; + } + + child = childParentData.previousSibling; + } + return false; + } + @override void paint(PaintingContext context, Offset offset) { var child = firstChild; while (child != null) { - final markerData = child.parentData! as _MarkerParentData; + final childParentData = child.parentData! as _MarkerParentData; - final markerOffset = camera.latLngToScreenOffset(markerData.point!); - // We need to apply this, but if we're rotating, we want to do that first - final alignmentOffset = - ((markerData.alignment ?? alignment) * -1).alongSize(child.size); + final (:markerOffset, :alignmentOffset) = + _getChildOffsets(childParentData, child.size); - if (markerData.rotate ?? rotate) { - context.pushTransform( + if (childParentData.rotate ?? rotate) { + // TODO: Repeat across worlds + layer = context.pushTransform( needsCompositing, offset + markerOffset, Matrix4.identity()..rotateZ(camera.rotationRad), - (context, transformOffset) => - context.paintChild(child!, transformOffset - alignmentOffset), + (context, offset) => + context.paintChild(child!, offset - alignmentOffset), + oldLayer: layer is TransformLayer ? layer as TransformLayer? : null, ); } else { - final childOffset = offset + markerOffset - alignmentOffset; - - bool paintIfVisible(double worldShift) { - final shiftedX = childOffset.dx + worldShift; - - // Cull if out of bounds - // TODO: Verify - // TODO: Copy to transformed logic also - if (size.width <= shiftedX || shiftedX + child!.size.width <= 0) { - return false; - } - if (size.height <= childOffset.dy || - childOffset.dy + child.size.height <= 0) { - return false; - } - - context.paintChild(child, Offset(shiftedX, childOffset.dy)); - return true; - } - - // Create marker in main world, unless culled - final main = paintIfVisible(0); - if (!main) { - child = markerData.nextSibling; - continue; - } - // It is unsafe to assume that if the main one is culled, it will - // also be culled in all other worlds, so we must continue - - // TODO: optimization - find a way to skip these tests in some - // obvious situations. Imagine we're in a map smaller than the - // world, and west lower than east - in that case we probably don't - // need to check eastern and western. - - final worldWidth = camera.getWorldWidthAtZoom(); - - // Repeat over all worlds (<--||-->) until culling determines that - // that marker is out of view, and therefore all further markers in - // that direction will also be - if (worldWidth == 0) continue; - for (double shift = -worldWidth;; shift -= worldWidth) { - final additional = paintIfVisible(shift); - if (!additional) break; - } - for (double shift = worldWidth;; shift += worldWidth) { - final additional = paintIfVisible(shift); - if (!additional) break; - } + // TODO: Fix issue of not rotating across worlds + final unshiftedChildOffset = offset + markerOffset - alignmentOffset; + + _workAcrossWorlds( + camera, + (shift) { + final childOffset = Offset( + unshiftedChildOffset.dx + shift, + unshiftedChildOffset.dy, + ); + + if (_isChildInvisible(childOffset, child!.size)) { + return WorldWorkControl.invisible; + } + + context.paintChild(child, childOffset); + return WorldWorkControl.visible; + }, + ); } - child = markerData.nextSibling; + child = childParentData.nextSibling; } } } -/*/// A [Marker] layer for [FlutterMap]. -@immutable -class MarkerLayer extends StatelessWidget { - /// The list of [Marker]s. - final List markers; - - /// Alignment of each marker relative to its normal center at [Marker.point] - /// - /// For example, [Alignment.topCenter] will mean the entire marker widget is - /// located above the [Marker.point]. - /// - /// The center of rotation (anchor) will be opposite this. - /// - /// Defaults to [Alignment.center]. Overriden by [Marker.alignment] if set. - final Alignment alignment; +/// Perform the callback in all world copies (until stopped) +/// +/// See [WorldWorkControl] for information about the callback return types. +/// Returns `true` if any result is [WorldWorkControl.hit]. +/// +/// Internally, the worker is invoked in the 'negative' worlds (worlds to the +/// left of the 'primary' world) until repetition is stopped, then in the +/// 'positive' worlds: <--||-->. +// TODO: Remove duplication - consider how to refactor `FeatureLayerUtils` +bool _workAcrossWorlds( + MapCamera camera, + WorldWorkControl Function(double shift) work, +) { + // Protection in case of unexpected infinite loop if `work` never returns + // `invisible`. e.g. https://github.com/fleaflet/flutter_map/issues/2052. + //! This can produce false positives - but it's better than a crash. + const maxShiftsCount = 30; + int shiftsCount = 0; + + final worldWidth = camera.getWorldWidthAtZoom(); + + void protectInfiniteLoop() { + if (++shiftsCount > maxShiftsCount) { + throw AssertionError( + 'Infinite loop going beyond $maxShiftsCount for world width $worldWidth', + ); + } + } - /// Whether to counter rotate markers to the map's rotation, to keep a fixed - /// orientation - /// - /// When `true`, markers will always appear upright and vertical from the - /// user's perspective. Defaults to `false`. Overriden by [Marker.rotate]. - /// - /// Note that this is not used to apply a custom rotation in degrees to the - /// markers. Use a widget inside [Marker.child] to perform this. - final bool rotate; + protectInfiniteLoop(); + if (work(0) == WorldWorkControl.hit) return true; - /// Create a new [MarkerLayer] to use inside of [FlutterMap.children]. - const MarkerLayer({ - super.key, - required this.markers, - this.alignment = Alignment.center, - this.rotate = false, - }); + if (worldWidth == 0) return false; - @override - Widget build(BuildContext context) { - final map = MapCamera.of(context); - final worldWidth = map.getWorldWidthAtZoom(); - - return MobileLayerTransformer( - child: Stack( - children: (List markers) sync* { - for (final m in markers) { - // Resolve real alignment - // TODO: maybe just using Size, Offset, and Rect? - final left = 0.5 * m.width * ((m.alignment ?? alignment).x + 1); - final top = 0.5 * m.height * ((m.alignment ?? alignment).y + 1); - final right = m.width - left; - final bottom = m.height - top; - - // Perform projection - final pxPoint = map.projectAtZoom(m.point); - - Positioned? getPositioned(double worldShift) { - final shiftedX = pxPoint.dx + worldShift; - - // Cull if out of bounds - if (!map.pixelBounds.overlaps( - Rect.fromPoints( - Offset(shiftedX + left, pxPoint.dy - bottom), - Offset(shiftedX - right, pxPoint.dy + top), - ), - )) { - return null; - } - - // Shift original coordinate along worlds, then move into relative - // to origin space - final shiftedLocalPoint = - Offset(shiftedX, pxPoint.dy) - map.pixelOrigin; - - return Positioned( - key: m.key, - width: m.width, - height: m.height, - left: shiftedLocalPoint.dx - right, - top: shiftedLocalPoint.dy - bottom, - child: (m.rotate ?? rotate) - ? Transform.rotate( - angle: -map.rotationRad, - alignment: (m.alignment ?? alignment) * -1, - child: m.child, - ) - : m.child, - ); - } + negativeWorldsLoop: + for (double shift = -worldWidth;; shift -= worldWidth) { + protectInfiniteLoop(); + switch (work(shift)) { + case WorldWorkControl.hit: + return true; + case WorldWorkControl.invisible: + break negativeWorldsLoop; + case WorldWorkControl.visible: + } + } - // Create marker in main world, unless culled - final main = getPositioned(0); - if (main != null) yield main; - // It is unsafe to assume that if the main one is culled, it will - // also be culled in all other worlds, so we must continue - - // TODO: optimization - find a way to skip these tests in some - // obvious situations. Imagine we're in a map smaller than the - // world, and west lower than east - in that case we probably don't - // need to check eastern and western. - - // Repeat over all worlds (<--||-->) until culling determines that - // that marker is out of view, and therefore all further markers in - // that direction will also be - if (worldWidth == 0) continue; - for (double shift = -worldWidth;; shift -= worldWidth) { - final additional = getPositioned(shift); - if (additional == null) break; - yield additional; - } - for (double shift = worldWidth;; shift += worldWidth) { - final additional = getPositioned(shift); - if (additional == null) break; - yield additional; - } - } - }(markers) - .toList(), - ), - ); + for (double shift = worldWidth;; shift += worldWidth) { + protectInfiniteLoop(); + switch (work(shift)) { + case WorldWorkControl.hit: + return true; + case WorldWorkControl.invisible: + return false; + case WorldWorkControl.visible: + } } } -*/