diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index f19de9c46..7410f9e9c 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -1,7 +1,7 @@ name: "Branch & PR" on: push: - branches: [ "!master" ] + branches: ["!master"] pull_request: workflow_dispatch: @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - sdk: [ "3.27.0", "" ] + sdk: ["3.27.0", ""] steps: - name: Checkout Repository uses: actions/checkout@v7 @@ -72,7 +72,7 @@ jobs: strategy: fail-fast: false matrix: - sdk: [ "3.27.0", "" ] + sdk: ["3.27.0", ""] defaults: run: working-directory: ./example @@ -91,7 +91,7 @@ jobs: flutter-version: ${{ matrix.sdk }} cache: true - name: Build Android Application - run: flutter build apk --dart-define=COMMIT_SHA=${{ github.sha }} --dart-define=flutter.flutter_map.unblockOSM="${{ secrets.UNBLOCK_OSM }}" + run: flutter build apk --dart-define=COMMIT_SHA=${{ github.sha }} - name: Archive Artifact if: ${{ matrix.sdk == '' }} uses: actions/upload-artifact@v7 @@ -106,7 +106,7 @@ jobs: strategy: fail-fast: false matrix: - sdk: [ "3.27.0", "" ] + sdk: ["3.41.0", ""] # Can't use 3.27 on github because windows-latest is incompatible now defaults: run: working-directory: ./example @@ -119,7 +119,7 @@ jobs: flutter-version: ${{ matrix.sdk }} cache: true - name: Build Windows Application - run: flutter build windows --dart-define=COMMIT_SHA=${{ github.sha }} --dart-define=flutter.flutter_map.unblockOSM="${{ secrets.UNBLOCK_OSM }}" + run: flutter build windows --dart-define=COMMIT_SHA=${{ github.sha }} - name: Install Inno Setup if: ${{ matrix.sdk == '' }} run: choco install innosetup --yes --no-progress @@ -141,7 +141,7 @@ jobs: strategy: fail-fast: false matrix: - sdk: [ "3.27.0", "" ] + sdk: ["3.27.0", ""] defaults: run: working-directory: ./example @@ -154,7 +154,7 @@ jobs: flutter-version: ${{ matrix.sdk }} cache: true - name: Build Web Application - run: flutter build web --wasm --dart-define=COMMIT_SHA=${{ github.sha }} --dart-define=flutter.flutter_map.unblockOSM="${{ secrets.UNBLOCK_OSM }}" + run: flutter build web --wasm --dart-define=COMMIT_SHA=${{ github.sha }} - name: Archive Artifact uses: actions/upload-artifact@v7 if: ${{ matrix.sdk == '' }} diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 107d588ae..51f31b949 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -31,7 +31,7 @@ jobs: build-android: name: "Build Android Example App" runs-on: ubuntu-latest - needs: [ run-tests ] + needs: [run-tests] if: github.repository == 'fleaflet/flutter_map' defaults: run: @@ -44,14 +44,14 @@ jobs: with: distribution: "temurin" java-version: "21" - cache: 'gradle' + cache: "gradle" - name: Setup Flutter Environment uses: subosito/flutter-action@v2 with: channel: "stable" cache: true - name: Build Android Application - run: flutter build apk --dart-define=COMMIT_SHA=${{ github.sha }} --dart-define=flutter.flutter_map.unblockOSM="${{ secrets.UNBLOCK_OSM }}" + run: flutter build apk --dart-define=COMMIT_SHA=${{ github.sha }} - name: Archive Artifact uses: actions/upload-artifact@v7 with: @@ -62,7 +62,7 @@ jobs: build-windows: name: "Build Windows Example App" runs-on: windows-latest - needs: [ run-tests ] + needs: [run-tests] if: github.repository == 'fleaflet/flutter_map' defaults: run: @@ -75,7 +75,7 @@ jobs: with: cache: true - name: Build Windows Application - run: flutter build windows --dart-define=COMMIT_SHA=${{ github.sha }} --dart-define=flutter.flutter_map.unblockOSM="${{ secrets.UNBLOCK_OSM }}" + run: flutter build windows --dart-define=COMMIT_SHA=${{ github.sha }} - name: Install Inno Setup run: choco install innosetup --yes --no-progress - name: Create Windows Application Installer @@ -91,7 +91,7 @@ jobs: build-web: name: "Build & Deploy Web Example App" runs-on: ubuntu-latest - needs: [ run-tests ] + needs: [run-tests] if: github.repository == 'fleaflet/flutter_map' defaults: run: @@ -105,7 +105,7 @@ jobs: channel: "stable" cache: true - name: Build Web Application - run: flutter build web --wasm --dart-define=COMMIT_SHA=${{ github.sha }} --dart-define=flutter.flutter_map.unblockOSM="${{ secrets.UNBLOCK_OSM }}" + run: flutter build web --wasm --dart-define=COMMIT_SHA=${{ github.sha }} - name: Archive Artifact uses: actions/upload-artifact@v7 with: @@ -118,4 +118,4 @@ jobs: repoToken: "${{ secrets.GITHUB_TOKEN }}" firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_FLEAFLET }}" channelId: live - projectId: fleaflet-firebase \ No newline at end of file + projectId: fleaflet-firebase diff --git a/example/lib/pages/many_circles.dart b/example/lib/pages/many_circles.dart index 6d03da5f3..11e42c53a 100644 --- a/example/lib/pages/many_circles.dart +++ b/example/lib/pages/many_circles.dart @@ -99,6 +99,7 @@ class _ManyCirclesPageState extends State { right: 16, child: RepaintBoundary( child: Column( + spacing: 12, children: [ NumberOfItemsSlider( number: displayedCirclesCount, @@ -106,7 +107,6 @@ class _ManyCirclesPageState extends State { maxNumber: _maxCirclesCount, itemDescription: 'Circle', ), - const SizedBox(height: 12), UnconstrainedBox( child: Container( decoration: BoxDecoration( @@ -117,71 +117,70 @@ class _ManyCirclesPageState extends State { vertical: 4, horizontal: 16, ), - child: Row( - children: [ - const Tooltip( - message: 'Use Borders', - child: Icon(Icons.circle_outlined), - ), - const SizedBox(width: 8), - Switch.adaptive( - value: useBorders, - onChanged: (v) { - allCircles = allCircles - .map( - (c) => CircleMarker( - point: c.point, - radius: c.radius, - color: c.color, - useRadiusInMeter: c.useRadiusInMeter, - borderColor: c.borderColor, - borderStrokeWidth: v ? 5 : 0, - ), - ) - .toList(growable: false); - useBorders = v; - setState(() {}); - }, - ), - const SizedBox(width: 16), - const Tooltip( - message: 'Use Radius In Meters', - child: Icon(Icons.straighten), - ), - const SizedBox(width: 8), - Switch.adaptive( - value: useRadiusInMeters, - onChanged: (v) { - allCircles = allCircles - .map( - (c) => CircleMarker( - point: c.point, - radius: v ? 25000 : 5, - color: c.color, - useRadiusInMeter: v, - borderColor: c.borderColor, - borderStrokeWidth: c.borderStrokeWidth, - ), - ) - .toList(growable: false); - useRadiusInMeters = v; - setState(() {}); - }, - ), - const SizedBox(width: 16), - const Tooltip( - message: 'Optimise Meters Radius', - child: Icon(Icons.speed_rounded), - ), - const SizedBox(width: 8), - Switch.adaptive( - value: optimizeRadiusInMeters, - onChanged: useRadiusInMeters - ? (v) => - setState(() => optimizeRadiusInMeters = v) - : null, - ), - ], + child: IntrinsicHeight( + child: Row( + spacing: 8, + children: [ + const Tooltip( + message: 'Use Borders', + child: Icon(Icons.circle_outlined), + ), + Switch.adaptive( + value: useBorders, + onChanged: (v) { + allCircles = allCircles + .map( + (c) => CircleMarker( + point: c.point, + radius: c.radius, + color: c.color, + useRadiusInMeter: c.useRadiusInMeter, + borderColor: c.borderColor, + borderStrokeWidth: v ? 5 : 0, + ), + ) + .toList(growable: false); + useBorders = v; + setState(() {}); + }, + ), + const VerticalDivider(), + const Tooltip( + message: 'Use Radius In Meters', + child: Icon(Icons.straighten), + ), + Switch.adaptive( + value: useRadiusInMeters, + onChanged: (v) { + allCircles = allCircles + .map( + (c) => CircleMarker( + point: c.point, + radius: v ? 25000 : 5, + color: c.color, + useRadiusInMeter: v, + borderColor: c.borderColor, + borderStrokeWidth: c.borderStrokeWidth, + ), + ) + .toList(growable: false); + useRadiusInMeters = v; + setState(() {}); + }, + ), + const Tooltip( + message: 'Optimise Meters Radius', + child: Icon(Icons.speed_rounded), + ), + Switch.adaptive( + value: optimizeRadiusInMeters, + onChanged: useRadiusInMeters + ? (v) => + setState(() => optimizeRadiusInMeters = v) + : null, + ), + ], + ), ), ), ), diff --git a/example/lib/pages/many_markers.dart b/example/lib/pages/many_markers.dart index f8060e785..ec839e23b 100644 --- a/example/lib/pages/many_markers.dart +++ b/example/lib/pages/many_markers.dart @@ -30,7 +30,7 @@ class ManyMarkersPage extends StatefulWidget { class ManyMarkersPageState extends State { final randomGenerator = Random(10); - late final allMarkers = List.generate( + late List allMarkers = List.generate( _maxMarkersCount, (_) { final angle = randomGenerator.nextDouble() * 2 * pi; @@ -46,28 +46,14 @@ class ManyMarkersPageState extends State { return Marker( point: position, - width: 30, - height: 30, - child: 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), - ), + child: Icon( + Icons.location_pin, + size: 30, + color: Color.fromARGB( + 255, + randomGenerator.nextInt(256), + randomGenerator.nextInt(256), + randomGenerator.nextInt(256), ), ), ); @@ -75,6 +61,10 @@ class ManyMarkersPageState extends State { ); int displayedMarkersCount = _maxMarkersCount ~/ 10; + bool useIcons = true; + bool useSizeInMeters = false; + bool optimizeSizeInMeters = true; + @override void initState() { super.initState(); @@ -103,6 +93,7 @@ class ManyMarkersPageState extends State { markers: allMarkers .take(displayedMarkersCount) .toList(growable: false), + optimizeDimensionsInMeters: optimizeSizeInMeters, ), ], ), @@ -110,11 +101,128 @@ class ManyMarkersPageState extends State { left: 16, top: 16, right: 16, - child: NumberOfItemsSlider( - number: displayedMarkersCount, - onChanged: (v) => setState(() => displayedMarkersCount = v), - maxNumber: _maxMarkersCount, - itemDescription: 'Marker', + child: Column( + spacing: 12, + children: [ + NumberOfItemsSlider( + number: displayedMarkersCount, + onChanged: (v) => setState(() => displayedMarkersCount = v), + maxNumber: _maxMarkersCount, + itemDescription: 'Marker', + ), + UnconstrainedBox( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(32), + ), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 16, + ), + child: IntrinsicHeight( + child: Row( + spacing: 8, + children: [ + const Tooltip( + message: 'Use Icons', + child: Icon(Icons.location_on), + ), + Switch.adaptive( + value: useIcons, + onChanged: (v) { + if (v) { + allMarkers = allMarkers.map( + (c) { + return Marker( + point: c.point, + child: Icon( + Icons.location_pin, + size: 30, + color: Color.fromARGB( + 255, + randomGenerator.nextInt(256), + randomGenerator.nextInt(256), + randomGenerator.nextInt(256), + ), + ), + ); + }, + ).toList(growable: false); + } else { + allMarkers = allMarkers.map( + (c) { + return Marker( + point: c.point, + useDimensionsInMeters: useSizeInMeters + ? const BoxConstraints() + : null, + height: useSizeInMeters ? 1000 : 30, + width: useSizeInMeters ? 1000 : 30, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(), + ), + ), + ), + ); + }, + ).toList(growable: false); + } + useIcons = v; + setState(() {}); + }, + ), + const VerticalDivider(), + const Tooltip( + message: 'Use Radius In Meters', + child: Icon(Icons.straighten), + ), + Switch.adaptive( + value: useSizeInMeters, + onChanged: useIcons + ? null + : (v) { + allMarkers = allMarkers.map( + (c) { + return Marker( + point: c.point, + useDimensionsInMeters: + v ? const BoxConstraints() : null, + height: v ? 1000 : 30, + width: v ? 1000 : 30, + child: SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(), + ), + ), + ), + ); + }, + ).toList(growable: false); + useSizeInMeters = v; + setState(() {}); + }, + ), + const Tooltip( + message: 'Optimise Meters Radius', + child: Icon(Icons.speed_rounded), + ), + Switch.adaptive( + value: optimizeSizeInMeters, + onChanged: useSizeInMeters && !useIcons + ? (v) => + setState(() => optimizeSizeInMeters = v) + : null, + ), + ], + ), + ), + ), + ), + ], ), ), if (!kIsWeb) diff --git a/example/lib/pages/markers.dart b/example/lib/pages/markers.dart index 2f652096b..67e295bff 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -16,6 +16,7 @@ class MarkerPage extends StatefulWidget { class _MarkerPageState extends State { Alignment selectedAlignment = Alignment.topCenter; bool counterRotate = false; + bool constrainMeterMarkers = false; static const alignments = { 315: Alignment.topLeft, @@ -59,57 +60,117 @@ class _MarkerPageState extends State { children: [ Padding( padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, + child: Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 20, + spacing: 20, children: [ - SizedBox.square( - dimension: 130, - child: GridView.builder( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 5, - crossAxisSpacing: 5, + Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + RotatedBox( + quarterTurns: 3, + child: FittedBox( + child: Text( + 'ALIGNMENT OF PINS', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .labelMedium! + .copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w400, + ), + ), + ), ), - itemCount: 9, - itemBuilder: (_, index) { - final deg = alignments.keys.elementAt(index); - final align = alignments.values.elementAt(index); - - return IconButton.outlined( - onPressed: () => - setState(() => selectedAlignment = align), - icon: Transform.rotate( - angle: deg == null ? 0 : deg * pi / 180, - child: Icon( - deg == null ? Icons.circle : Icons.arrow_upward, - color: selectedAlignment == align - ? Colors.green - : null, - size: deg == null ? 16 : null, - ), + SizedBox.square( + dimension: 130, + child: GridView.builder( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 5, + crossAxisSpacing: 5, ), - ); - }, - ), + itemCount: 9, + itemBuilder: (_, index) { + final deg = alignments.keys.elementAt(index); + final align = alignments.values.elementAt(index); + + return IconButton.outlined( + onPressed: () => + setState(() => selectedAlignment = align), + icon: Transform.rotate( + angle: deg == null ? 0 : deg * pi / 180, + child: Icon( + deg == null ? Icons.circle : Icons.arrow_upward, + color: selectedAlignment == align + ? Colors.green + : null, + size: deg == null ? 16 : null, + ), + ), + ); + }, + ), + ), + ], ), - const SizedBox(width: 16), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Tap the map to add markers!'), - const SizedBox(height: 10), - Row( + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onInverseSurface, + borderRadius: BorderRadius.circular(16), + ), + child: const Padding( + padding: EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, children: [ - const Text('Counter-rotation'), - const SizedBox(width: 10), - Switch.adaptive( - value: counterRotate, - onChanged: (v) => setState(() => counterRotate = v), + Text( + 'Tap/click map to\nadd more pins', + textAlign: TextAlign.center, ), + Icon(Icons.add_location, size: 32) ], ), - ], + ), + ), + IntrinsicWidth( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 12, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Flexible(child: Text('Counter rotate to map')), + Switch.adaptive( + value: counterRotate, + onChanged: (v) => setState(() => counterRotate = v), + ), + ], + ), + Row( + spacing: 12, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Flexible( + child: Text('Constraints on meter marker')), + Switch.adaptive( + value: constrainMeterMarkers, + onChanged: (v) => + setState(() => constrainMeterMarkers = v), + ), + ], + ), + ], + ), ), ], ), @@ -128,9 +189,9 @@ class _MarkerPageState extends State { openStreetMapTileLayer, MarkerLayer( rotate: counterRotate, - markers: const [ - Marker( - point: LatLng(47.18664724067855, -1.5436768515939427), + markers: [ + const Marker( + point: LatLng(47.18664, -1.54367), width: 64, height: 64, alignment: Alignment.centerLeft, @@ -142,8 +203,8 @@ class _MarkerPageState extends State { ), ), ), - Marker( - point: LatLng(47.18664724067855, -1.5436768515939427), + const Marker( + point: LatLng(47.18664, -1.54367), width: 64, height: 64, alignment: Alignment.centerRight, @@ -155,11 +216,80 @@ class _MarkerPageState extends State { ), ), ), - Marker( - point: LatLng(47.18664724067855, -1.5436768515939427), + const Marker( + point: LatLng(47.18664, -1.54367), rotate: false, child: ColoredBox(color: Colors.black), ), + Marker( + point: const LatLng( + 0, + -0.12835, + ), + height: 500000, + width: 300000, + useDimensionsInMeters: constrainMeterMarkers + ? const BoxConstraints(maxHeight: 200, maxWidth: 200) + : const BoxConstraints(), + child: _MeterMarkerChild( + label: '500x300km\n(' + '${constrainMeterMarkers ? '0-200px²' : 'constraints off'})', + ), + ), + Marker( + point: const LatLng( + 51.51868, + -0.12835, + ), + height: 1000, + width: 1000, + useDimensionsInMeters: constrainMeterMarkers + ? const BoxConstraints( + minHeight: 30, + minWidth: 30, + maxHeight: 1000, + maxWidth: 1000, + ) + : const BoxConstraints(), + child: _MeterMarkerChild( + label: '\n\n1km²\n(' + '${constrainMeterMarkers ? '30px²-1000px²' : 'constraints off'})', + ), + ), + const Marker( + point: LatLng( + 71.51868, + -0.12835, + ), + height: 500000, + width: 300000, + useDimensionsInMeters: BoxConstraints(), + child: _MeterMarkerChild( + label: '500x300km\n(no constraints)', + ), + ), + ], + ), + CircleLayer( + circles: [ + CircleMarker( + point: const LatLng( + 0, + -0.12835, + ), + radius: 150000, + useRadiusInMeter: true, + color: Colors.black.withValues(alpha: 0.2), + ), + CircleMarker( + point: const LatLng( + 71.51868, + -0.12835, + ), + radius: 150000, + useRadiusInMeter: true, + color: Colors.black.withValues(alpha: 0.2), + ), ], ), MarkerLayer( @@ -175,3 +305,27 @@ class _MarkerPageState extends State { ); } } + +class _MeterMarkerChild extends StatelessWidget { + const _MeterMarkerChild({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: LayoutBuilder( + builder: (context, constraints) => DecoratedBox( + decoration: BoxDecoration(border: Border.all()), + child: Padding( + padding: const EdgeInsets.all(6), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Center(child: Text(label, textAlign: TextAlign.center)), + ), + ), + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 50adcec9a..4cd9a4455 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -124,7 +124,7 @@ packages: path: ".." relative: true source: path - version: "8.3.0" + version: "8.3.1" flutter_test: dependency: "direct dev" description: flutter diff --git a/lib/src/layer/marker_layer/marker.dart b/lib/src/layer/marker_layer/marker.dart index 764770342..8d8d3f011 100644 --- a/lib/src/layer/marker_layer/marker.dart +++ b/lib/src/layer/marker_layer/marker.dart @@ -11,24 +11,49 @@ class Marker { /// This key will get passed through to the created marker widget. final Key? key; - /// Coordinates of the marker + /// Coordinates of the marker. /// /// This will be the center of the marker, assuming that [alignment] is /// [Alignment.center] (default). final LatLng point; - /// Widget tree of the marker, sized by [width] & [height] + /// Widget tree of the marker, sized by [width] & [height]. /// /// The [Marker] itself is not a widget. final Widget child; - /// Width of [child] + /// Width dimension of child, in pixels (unless [useDimensionsInMeters] is + /// set). final double width; - /// Height of [child] + /// Height dimension of child, in pixels (unless [useDimensionsInMeters] is + /// set). final double height; - /// Alignment of the marker relative to the normal center at [point] + /// Whether to treat [width] and [height] as meters, with optional pixel size + /// constraints. + /// + /// If `null` (as default), [width] and [height] are specified in pixels. + /// + /// If set to [BoxConstraints], [width] and [height] are specified in meters. + /// These [BoxConstraints] are in pixels, and constrain the screen size of the + /// child. + /// + /// Set an empty [BoxConstraints] to display the child in meters without + /// constraints (true size): + /// + /// ```dart + /// useDimensionsInMeters: const BoxConstraints(), + /// ``` + /// + /// Any constraints set must have finite minimum dimensions. + /// + /// When using meters, the child can use [SizedBox.expand] to expand itself to + /// the available size. [LayoutBuilder] can be used to obtain the calculated + /// screen size, if necessary. + final BoxConstraints? useDimensionsInMeters; + + /// Alignment of the marker relative to the normal center at [point]. /// /// For example, [Alignment.topCenter] will mean the entire marker widget is /// located above the [point]. @@ -39,7 +64,7 @@ class Marker { final Alignment? alignment; /// Whether to counter rotate this marker to the map's rotation, to keep a - /// fixed orientation + /// fixed orientation. /// /// When `true`, this marker will always appear upright and vertical from the /// user's perspective. Defaults to `false` if also unset by [MarkerLayer]. @@ -59,11 +84,13 @@ class Marker { required this.child, this.width = 30, this.height = 30, + this.useDimensionsInMeters, this.alignment, this.rotate, }); - /// Returns the alignment of a [width]x[height] rectangle by [left]x[top] pixels. + /// Returns the alignment of a [width]x[height] rectangle by [left]x[top] + /// pixels. static Alignment computePixelAlignment({ required final double width, required final double height, diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index 0e18c700b..76d640642 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -30,12 +32,34 @@ class MarkerLayer extends StatefulWidget { /// markers. Use a widget inside [Marker.child] to perform this. final bool rotate; + /// Whether to use a single meters to pixels conversion ratio for all markers + /// with [Marker.useDimensionsInMeters] enabled. + /// + /// > [!IMPORTANT] + /// > This reduces the accuracy of the dimensions of markers. Depending on the + /// > location of the markers, this may or may not be significant. + /// + /// Where all markers within this layer are geographically (particularly + /// latitudinally) close, the difference in the ratio between pixels and + /// meters between markers is likely to be small. Calculating this conversion + /// ratio is expensive, and is usually done for every marker to ensure + /// accuracy, as the ratio depends on the latitude. Setting this `true` means + /// the ratio is calculated based off the first marker only, then reused for + /// all other markers within this layer. + /// + /// This should not be used where markers are geographically spread out - it + /// is best suited, for example, for markers located within a single city. + /// + /// Defaults to `false`. + final bool optimizeDimensionsInMeters; + /// Create a new [MarkerLayer] to use inside of [FlutterMap.children]. const MarkerLayer({ super.key, required this.markers, this.alignment = Alignment.center, this.rotate = false, + this.optimizeDimensionsInMeters = false, }); @override @@ -43,6 +67,8 @@ class MarkerLayer extends StatefulWidget { } class _MarkerLayerState extends State { + static const _distance = Distance(); + /// Projected (zoom-independent) coordinates of every [Marker.point], in the /// same order as the markers list /// @@ -52,8 +78,12 @@ class _MarkerLayerState extends State { /// projected -> screen transformation per marker, instead of a full /// re-projection. List? _projectedPoints; + Map? _projectedMeterSizes; Crs? _projectionCrs; + // Cached number of pixels per meter. + double? _pixelsPerMeter; + @override void didUpdateWidget(MarkerLayer oldWidget) { super.didUpdateWidget(oldWidget); @@ -62,6 +92,7 @@ class _MarkerLayerState extends State { // new widget instance re-projects, so in-place mutations of the markers // list keep working as they did when projection was performed per-frame. _projectedPoints = null; + _projectedMeterSizes = null; } List _projectPoints(Crs crs) { @@ -81,49 +112,113 @@ class _MarkerLayerState extends State { ); } + Map _projectMeterSizes(Crs crs) { + final projection = crs.projection; + return HashMap.fromEntries( + () sync* { + for (int i = 0; i < widget.markers.length; i++) { + final m = widget.markers[i]; + if (m.useDimensionsInMeters == null) continue; + + final w = + projection.project(_distance.offset(m.point, m.width / 2, 180)); + late final h = + projection.project(_distance.offset(m.point, m.height / 2, 180)); + yield MapEntry(i, (w, m.width == m.height ? w : h)); + + // If we're optimizing, we'll never use any more values we generate + if (widget.optimizeDimensionsInMeters) break; + } + }(), + ); + } + + /// Use generated [_projectedMeterSizes] to convert a marker's size in meters + /// to its screen size + Size _metersToScreenPixels( + MapCamera camera, + Marker m, + Offset pxPoint, + int i, + double zoomScale, + ) { + final p = _projectedMeterSizes![i]!; + final (wpx, wpy) = camera.crs.transform(p.$1.dx, p.$1.dy, zoomScale); + final width = 2 * (pxPoint - Offset(wpx, wpy)).distance; + if (m.width == m.height) return Size.square(width); + final (hpx, hpy) = camera.crs.transform(p.$2.dx, p.$2.dy, zoomScale); + return Size(width, 2 * (pxPoint - Offset(hpx, hpy)).distance); + } + @override Widget build(BuildContext context) { - final map = MapCamera.of(context); - final crs = map.crs; + final camera = MapCamera.of(context); - if (_projectedPoints == null || _projectionCrs != crs) { - _projectionCrs = crs; - _projectedPoints = _projectPoints(crs); + if (_projectedPoints == null || + _projectedMeterSizes == null || + _projectionCrs != camera.crs) { + _projectionCrs = camera.crs; + _projectedPoints = _projectPoints(camera.crs); + _projectedMeterSizes = _projectMeterSizes(camera.crs); } - final projectedPoints = _projectedPoints!; + _pixelsPerMeter = null; - final worldWidth = map.getWorldWidthAtZoom(); - final zoomScale = crs.scale(map.zoom); - final pixelBounds = map.pixelBounds; - final pixelOrigin = map.pixelOrigin; - final markers = widget.markers; + final worldWidth = camera.getWorldWidthAtZoom(); + final zoomScale = camera.crs.scale(camera.zoom); return MobileLayerTransformer( child: Stack( children: () sync* { - for (var i = 0; i < markers.length; i++) { - final m = markers[i]; - - // Resolve real alignment - // TODO: maybe just using Size, Offset, and Rect? - final left = - 0.5 * m.width * ((m.alignment ?? widget.alignment).x + 1); - final top = - 0.5 * m.height * ((m.alignment ?? widget.alignment).y + 1); - final right = m.width - left; - final bottom = m.height - top; + for (int i = 0; i < widget.markers.length; i++) { + final m = widget.markers[i]; // Scale the cached projection to the current zoom - final projected = projectedPoints[i]; + final projected = _projectedPoints![i]; final (px, py) = - crs.transform(projected.dx, projected.dy, zoomScale); + camera.crs.transform(projected.dx, projected.dy, zoomScale); final pxPoint = Offset(px, py); + // Resolve real size and alignment + final double width; + final double height; + if (m.useDimensionsInMeters case final constraints?) { + if (!constraints.minWidth.isFinite || + !constraints.minHeight.isFinite) { + throw RangeError( + '`Marker.useDimensionsInMeters` must have finite minimums', + ); + } + if (!widget.optimizeDimensionsInMeters) { + final size = + _metersToScreenPixels(camera, m, pxPoint, i, zoomScale); + width = constraints.constrainWidth(size.width); + height = constraints.constrainHeight(size.height); + } else { + // If optimizing, use the cached ratio if available, or + // calculate it (using the first marker in the layer) + _pixelsPerMeter ??= + _metersToScreenPixels(camera, m, pxPoint, i, zoomScale) + .width / + m.width; + width = constraints.constrainWidth(_pixelsPerMeter! * m.width); + height = + constraints.constrainHeight(_pixelsPerMeter! * m.height); + } + } else { + width = m.width; + height = m.height; + } + final alignment = m.alignment ?? widget.alignment; + final left = 0.5 * width * (alignment.x + 1); + final top = 0.5 * height * (alignment.y + 1); + final right = width - left; + final bottom = height - top; + Positioned? getPositioned(double worldShift) { final shiftedX = pxPoint.dx + worldShift; // Cull if out of bounds - if (!pixelBounds.overlaps( + if (!camera.pixelBounds.overlaps( Rect.fromPoints( Offset(shiftedX + left, pxPoint.dy - bottom), Offset(shiftedX - right, pxPoint.dy + top), @@ -135,18 +230,18 @@ class _MarkerLayerState extends State { // Shift original coordinate along worlds, then move into relative // to origin space final shiftedLocalPoint = - Offset(shiftedX, pxPoint.dy) - pixelOrigin; + Offset(shiftedX, pxPoint.dy) - camera.pixelOrigin; return Positioned( key: m.key, - width: m.width, - height: m.height, + width: width, + height: height, left: shiftedLocalPoint.dx - right, top: shiftedLocalPoint.dy - bottom, child: (m.rotate ?? widget.rotate) ? Transform.rotate( - angle: -map.rotationRad, - alignment: (m.alignment ?? widget.alignment) * -1, + angle: -camera.rotationRad, + alignment: Alignment(-alignment.x, -alignment.y), child: m.child, ) : m.child,