From 7b39b26254b2a091277fa7ecc52104c94e1f2059 Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Wed, 1 Jan 2025 23:55:45 +0100 Subject: [PATCH] feat: replicate `Marker`s across all worlds (#2000) Co-authored-by: Luka S --- example/lib/main.dart | 2 + example/lib/pages/multi_worlds.dart | 83 ++++++++++++++++++++ example/lib/widgets/drawer/menu_drawer.dart | 6 ++ lib/src/layer/marker_layer/marker_layer.dart | 83 ++++++++++++++------ lib/src/map/camera/camera.dart | 10 +++ 5 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 example/lib/pages/multi_worlds.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index fe38155ec..4eb4d17f2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -15,6 +15,7 @@ import 'package:flutter_map_example/pages/many_markers.dart'; import 'package:flutter_map_example/pages/map_controller.dart'; import 'package:flutter_map_example/pages/map_inside_listview.dart'; import 'package:flutter_map_example/pages/markers.dart'; +import 'package:flutter_map_example/pages/multi_worlds.dart'; import 'package:flutter_map_example/pages/overlay_image.dart'; import 'package:flutter_map_example/pages/plugin_zoombuttons.dart'; import 'package:flutter_map_example/pages/polygon.dart'; @@ -66,6 +67,7 @@ class MyApp extends StatelessWidget { CirclePage.route: (context) => const CirclePage(), OverlayImagePage.route: (context) => const OverlayImagePage(), PolygonPage.route: (context) => const PolygonPage(), + MultiWorldsPage.route: (context) => const MultiWorldsPage(), PolygonPerfStressPage.route: (context) => const PolygonPerfStressPage(), SlidingMapPage.route: (_) => const SlidingMapPage(), WMSLayerPage.route: (context) => const WMSLayerPage(), diff --git a/example/lib/pages/multi_worlds.dart b/example/lib/pages/multi_worlds.dart new file mode 100644 index 000000000..a82171bb1 --- /dev/null +++ b/example/lib/pages/multi_worlds.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.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'; + +/// Example dedicated to replicated worlds and related objects (e.g. Markers). +class MultiWorldsPage extends StatefulWidget { + static const String route = '/multi_worlds'; + + const MultiWorldsPage({super.key}); + + @override + State createState() => _MultiWorldsPageState(); +} + +class _MultiWorldsPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Multi-worlds')), + drawer: const MenuDrawer(MultiWorldsPage.route), + body: Stack( + children: [ + FlutterMap( + options: const MapOptions( + initialCenter: LatLng(51.5, -0.09), + initialZoom: 0, + initialRotation: 0, + ), + children: [ + openStreetMapTileLayer, + MarkerLayer( + markers: [ + Marker( + point: const LatLng(48.856666, 2.351944), + alignment: Alignment.topCenter, + child: GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Paris'), + duration: Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: const Icon(Icons.location_on_rounded), + ), + ), + Marker( + point: const LatLng(34.05, -118.25), + child: GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Los Angeles'), + duration: Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: const Icon(Icons.location_city), + ), + ), + Marker( + point: const LatLng(35.689444, 139.691666), + child: GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tokyo'), + duration: Duration(seconds: 1), + showCloseIcon: true, + ), + ), + child: const Icon(Icons.backpack_outlined), + ), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index 7e85f08f3..5d130c072 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -16,6 +16,7 @@ import 'package:flutter_map_example/pages/many_markers.dart'; import 'package:flutter_map_example/pages/map_controller.dart'; import 'package:flutter_map_example/pages/map_inside_listview.dart'; import 'package:flutter_map_example/pages/markers.dart'; +import 'package:flutter_map_example/pages/multi_worlds.dart'; import 'package:flutter_map_example/pages/overlay_image.dart'; import 'package:flutter_map_example/pages/plugin_zoombuttons.dart'; import 'package:flutter_map_example/pages/polygon.dart'; @@ -109,6 +110,11 @@ class MenuDrawer extends StatelessWidget { routeName: ScaleBarPage.route, currentRoute: currentRoute, ), + MenuItemWidget( + caption: 'Multi-world and layers', + routeName: MultiWorldsPage.route, + currentRoute: currentRoute, + ), const Divider(), MenuItemWidget( caption: 'Map Controller', diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index 2d6c7c369..fe4693d46 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -41,13 +41,14 @@ class MarkerLayer extends StatelessWidget { @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 this can probably just be done with calls to Size, Offset, and Rect + // 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; @@ -56,33 +57,65 @@ class MarkerLayer extends StatelessWidget { // Perform projection final pxPoint = map.projectAtZoom(m.point); - // Cull if out of bounds - if (!map.pixelBounds.overlaps( - Rect.fromPoints( - Offset(pxPoint.dx + left, pxPoint.dy - bottom), - Offset(pxPoint.dx - right, pxPoint.dy + top), - ), - )) { - continue; + 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, + ); } - // Apply map camera to marker position - final pos = pxPoint - map.pixelOrigin; + // 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 - yield Positioned( - key: m.key, - width: m.width, - height: m.height, - left: pos.dx - right, - top: pos.dy - bottom, - child: (m.rotate ?? rotate) - ? Transform.rotate( - angle: -map.rotationRad, - alignment: (m.alignment ?? alignment) * -1, - child: m.child, - ) - : m.child, - ); + // 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(), diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index bef6bc65c..dcde25d59 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -239,6 +239,16 @@ class MapCamera { LatLng unprojectAtZoom(Offset point, [double? zoom]) => crs.offsetToLatLng(point, zoom ?? this.zoom); + /// Returns the width of the world at the current zoom, or 0 if irrelevant. + double getWorldWidthAtZoom() { + if (!crs.replicatesWorldLongitude) { + return 0; + } + final offset0 = projectAtZoom(const LatLng(0, 0)); + final offset180 = projectAtZoom(const LatLng(0, 180)); + return 2 * (offset180.dx - offset0.dx).abs(); + } + /// Calculates the scale for a zoom from [fromZoom] to [toZoom] using this /// camera\s [crs]. double getZoomScale(double toZoom, double fromZoom) =>