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/markers.dart b/example/lib/pages/markers.dart index 2f652096b..54b6702a2 100644 --- a/example/lib/pages/markers.dart +++ b/example/lib/pages/markers.dart @@ -128,9 +128,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 +142,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 +155,30 @@ 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( + 51.51868, + -0.12835, + ), + height: 1000, + width: 1000, + useDimensionsInMeters: const BoxConstraints( + minHeight: 30, + minWidth: 30, + ), + child: SizedBox.expand( + child: LayoutBuilder( + builder: (context, constraints) => DecoratedBox( + decoration: BoxDecoration(border: Border.all()), + ), + ), + ), + ), ], ), MarkerLayer( diff --git a/lib/src/layer/marker_layer/marker.dart b/lib/src/layer/marker_layer/marker.dart index 764770342..2f27696c5 100644 --- a/lib/src/layer/marker_layer/marker.dart +++ b/lib/src/layer/marker_layer/marker.dart @@ -11,24 +11,45 @@ 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 of child, in pixels (unless [useDimensionsInMeters] is set). final double width; - /// Height of [child] + /// Height 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. + /// They will constrain size of the marker on the screen in pixels. The + /// constraints must have finite minimum dimensions. + /// + /// Set an empty [BoxConstraints] to display the marker as its true + /// geographical size (without constraints): + /// + /// ```dart + /// useDimensionsInMeters: const BoxConstraints(), + /// ``` + /// + /// When using geographical sizing, the child can use [SizedBox.expand] to + /// expand itself to the available size. [LayoutBuilder] can be used to obtain + /// its 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 +60,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 +80,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..33be25fc2 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -30,12 +30,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 +65,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 /// @@ -54,6 +78,9 @@ class _MarkerLayerState extends State { List? _projectedPoints; Crs? _projectionCrs; + // Cached number of pixels per meter. + double? _pixelsPerMeter; + @override void didUpdateWidget(MarkerLayer oldWidget) { super.didUpdateWidget(oldWidget); @@ -81,6 +108,50 @@ class _MarkerLayerState extends State { ); } + (double, double) _getDimensionsInPixels(Marker marker) { + final constraints = marker.useDimensionsInMeters; + if (constraints == null) return (marker.width, marker.height); + + final camera = MapCamera.of(context); + + (double, double) metersToScreenPixels() { + final baseOffset = camera.getOffsetFromOrigin(marker.point); + return ( + (baseOffset - + camera.getOffsetFromOrigin( + _distance.offset(marker.point, marker.width / 2, 180))) + .distance * + 2, + (baseOffset - + camera.getOffsetFromOrigin( + _distance.offset(marker.point, marker.height / 2, 180))) + .distance * + 2 + ); + } + + double width; + double height; + if (!widget.optimizeDimensionsInMeters) { + // If not optimizing, then we need to calculate this for every marker... + (width, height) = metersToScreenPixels(); + } else { + // ...otherwise we use the cached ratio if available, or calculate it + // (using the first marker in the layer, given how this method is called) + _pixelsPerMeter ??= metersToScreenPixels().$1 / marker.width; + width = _pixelsPerMeter! * marker.width; + height = _pixelsPerMeter! * marker.height; + } + + if (!constraints.minWidth.isFinite || !constraints.minHeight.isFinite) { + throw RangeError('`Marker.useSizeInMeters` must have finite minimums'); + } + return ( + constraints.constrainWidth(width), + constraints.constrainHeight(height) + ); + } + @override Widget build(BuildContext context) { final map = MapCamera.of(context); @@ -94,24 +165,12 @@ class _MarkerLayerState extends State { final worldWidth = map.getWorldWidthAtZoom(); final zoomScale = crs.scale(map.zoom); - final pixelBounds = map.pixelBounds; - final pixelOrigin = map.pixelOrigin; - final markers = widget.markers; 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 (var i = 0; i < widget.markers.length; i++) { + final m = widget.markers[i]; // Scale the cached projection to the current zoom final projected = projectedPoints[i]; @@ -119,11 +178,24 @@ class _MarkerLayerState extends State { crs.transform(projected.dx, projected.dy, zoomScale); final pxPoint = Offset(px, py); + // Get marker dimensions + final double width; + final double height; + (width, height) = _getDimensionsInPixels(m); + + // Resolve real alignment + final left = + 0.5 * width * ((m.alignment ?? widget.alignment).x + 1); + final top = + 0.5 * height * ((m.alignment ?? widget.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 (!map.pixelBounds.overlaps( Rect.fromPoints( Offset(shiftedX + left, pxPoint.dy - bottom), Offset(shiftedX - right, pxPoint.dy + top), @@ -135,12 +207,12 @@ 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) - map.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)