Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions lib/src/map/camera/camera_constraint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ abstract class CameraConstraint {
///
/// To instead constrain the center coordinate of the camera to these bounds,
/// use [CameraConstraint.containCenter].
///
/// The edges are those of the map at pitch 0, at every pitch, as in
/// MapLibre GL JS. When the map is pitched, ground beyond [bounds] can be
/// visible toward the top of the map.
const factory CameraConstraint.contain({
required LatLngBounds bounds,
}) = ContainCamera._;
Expand All @@ -42,6 +46,10 @@ abstract class CameraConstraint {
///
/// Defaults to 90 & -90, to prevent the background color from appearing
/// at the 'top' and 'bottom' of the typical map.
///
/// The edges are those of the map at pitch 0, at every pitch, as in
/// MapLibre GL JS. When the map is pitched, ground beyond [a] and [b] can be
/// visible toward the top of the map.
const factory CameraConstraint.containLatitude([
double a,
double b,
Expand Down Expand Up @@ -123,7 +131,11 @@ class ContainCamera extends CameraConstraint {
final nePixel = camera.projectAtZoom(bounds.northEast, testZoom);
final swPixel = camera.projectAtZoom(bounds.southWest, testZoom);

final halfSize = camera.size / 2;
// Not [MapCamera.size], which is the visible ground's bounding box when
// pitched: the constraint ignores pitch, as MapLibre GL JS's does.
final halfSize =
MapCamera.calculateRotatedSize(camera.rotation, camera.nonRotatedSize) /
2;

// Find the limits for the map center which would keep the camera within the
// [latLngBounds].
Expand Down Expand Up @@ -186,7 +198,11 @@ class ContainCameraLatitude extends CameraConstraint {
final Offset aPixel = camera.projectAtZoom(LatLng(a, 0), testZoom);
final Offset bPixel = camera.projectAtZoom(LatLng(b, 0), testZoom);

final Size halfSize = camera.size / 2;
// Not [MapCamera.size], which is the visible ground's bounding box when
// pitched: the constraint ignores pitch, as MapLibre GL JS's does.
final Size halfSize =
MapCamera.calculateRotatedSize(camera.rotation, camera.nonRotatedSize) /
2;

// Find the limits for the map center which would keep the camera within the
// [north] and [south] bounds.
Expand Down
172 changes: 171 additions & 1 deletion lib/src/map/camera/camera_fit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ class FitBounds extends CameraFit {
/// Returns a new [MapCamera] which fits this classes configuration.
@override
MapCamera fit(MapCamera camera) {
if (camera.pitch == 0) return _fitUnpitched(camera);
return _fitPitched(
camera,
[
bounds.northWest,
bounds.northEast,
bounds.southEast,
bounds.southWest,
],
padding,
start: _fitUnpitched(camera.withPitch(0)).withPitch(camera.pitch),
limitZoom: (zoom) => _limitZoom(
camera,
zoom,
minZoom: minZoom,
maxZoom: maxZoom,
forceIntegerZoomLevel: forceIntegerZoomLevel,
),
);
}

MapCamera _fitUnpitched(MapCamera camera) {
final paddingTL = Offset(padding.left, padding.top);
final paddingBR = Offset(padding.right, padding.bottom);

Expand Down Expand Up @@ -217,7 +239,49 @@ class FitInsideBounds extends CameraFit {
});

@override
MapCamera fit(MapCamera camera) {
MapCamera fit(MapCamera camera) =>
camera.pitch == 0 ? _fitUnpitched(camera) : _fitPitched(camera);

/// The ground under the padded viewport, as offsets from the center in
/// projected pixels, has the same shape at every zoom, so the zoom follows
/// from the ratio of its bounding box to the projected bounds.
MapCamera _fitPitched(MapCamera camera) {
final viewport = padding.deflateRect(Offset.zero & camera.nonRotatedSize);
final ground = [
viewport.topLeft,
viewport.topRight,
viewport.bottomRight,
viewport.bottomLeft,
].map(camera.groundOffsetFromCenter);
if (ground.contains(null)) return camera;
final visible = RectExtension.containing(ground.nonNulls.toList());

final projectedBounds = Rect.fromPoints(
camera.projectAtZoom(bounds.southEast),
camera.projectAtZoom(bounds.northWest),
);
final scale = math.max(
visible.width / projectedBounds.width,
visible.height / projectedBounds.height,
);
var newZoom = camera.zoom + math.log(scale) / math.ln2;
if (forceIntegerZoomLevel) newZoom = newZoom.ceilToDouble();
newZoom = newZoom.clamp(
math.max(camera.minZoom ?? 0, minZoom),
math.min(camera.maxZoom ?? double.infinity, maxZoom ?? double.infinity),
);

final boundsCenter = Rect.fromPoints(
camera.projectAtZoom(bounds.southEast, newZoom),
camera.projectAtZoom(bounds.northWest, newZoom),
).center;
return camera.withPosition(
center: camera.unprojectAtZoom(boundsCenter - visible.center, newZoom),
zoom: newZoom,
);
}

MapCamera _fitUnpitched(MapCamera camera) {
final paddingTL = Offset(padding.left, padding.top);
final paddingBR = Offset(padding.right, padding.bottom);
final paddingTotalXY = paddingTL + paddingBR;
Expand Down Expand Up @@ -427,6 +491,23 @@ class FitCoordinates extends CameraFit {
/// Returns a new [MapCamera] which fits this classes configuration.
@override
MapCamera fit(MapCamera camera) {
if (camera.pitch == 0) return _fitUnpitched(camera);
return _fitPitched(
camera,
coordinates,
padding,
start: _fitUnpitched(camera.withPitch(0)).withPitch(camera.pitch),
limitZoom: (zoom) => _limitZoom(
camera,
zoom,
minZoom: minZoom,
maxZoom: maxZoom,
forceIntegerZoomLevel: forceIntegerZoomLevel,
),
);
}

MapCamera _fitUnpitched(MapCamera camera) {
final paddingTL = Offset(padding.left, padding.top);
final paddingBR = Offset(padding.right, padding.bottom);

Expand Down Expand Up @@ -496,3 +577,92 @@ class FitCoordinates extends CameraFit {
return newZoom.clamp(min, max);
}
}

/// The zoom limits of a fit, applied to [zoom].
double _limitZoom(
MapCamera camera,
double zoom, {
required double minZoom,
required double? maxZoom,
required bool forceIntegerZoomLevel,
}) {
final limited = forceIntegerZoomLevel ? zoom.floorToDouble() : zoom;
return limited.clamp(
math.max(camera.minZoom ?? 0, minZoom),
math.min(camera.maxZoom ?? double.infinity, maxZoom ?? double.infinity),
);
}

/// The camera, with the rotation and pitch of [start], at which [points] lie
/// inside the viewport reduced by [padding], at the largest zoom that
/// [limitZoom] allows.
///
/// Starts from [start] and repeats one step until it converges: move the
/// center so that the middle of the points' screen extent is at the middle of
/// the padded viewport, and change the zoom by the ratio of the two extents.
MapCamera _fitPitched(
MapCamera camera,
List<LatLng> points,
EdgeInsets padding, {
required MapCamera start,
required double Function(double zoom) limitZoom,
}) {
final target = padding.deflateRect(Offset.zero & camera.nonRotatedSize);
if (target.width <= 0 || target.height <= 0 || points.isEmpty) return start;

var fitted = start;
var zoomChange = double.infinity;
for (var i = 0; i < 20 && zoomChange.abs() >= 1e-4; i++) {
final extent = _screenExtent(fitted, points);
if (extent == null) {
zoomChange = -1;
} else {
fitted = _centered(fitted, extent.center, target.center);
final scale = math.min(
target.width / extent.width,
target.height / extent.height,
);
zoomChange = scale.isFinite ? math.log(scale) / math.ln2 : 0;
}
fitted = fitted.withPosition(zoom: fitted.zoom + zoomChange);
}

fitted = fitted.withPosition(zoom: limitZoom(fitted.zoom));
for (var i = 0; i < 20; i++) {
final extent = _screenExtent(fitted, points);
if (extent == null || (extent.center - target.center).distance < 0.1) {
break;
}
fitted = _centered(fitted, extent.center, target.center);
}
return fitted;
}

/// The bounding rectangle of the screen positions of [points], or null if a
/// point is behind the camera.
Rect? _screenExtent(MapCamera camera, List<LatLng> points) {
final transform = camera.pixelToScreenTransform;
final positions = <Offset>[];
for (final point in points) {
final projected = camera.projectAtZoom(point);
final w = transform.entry(3, 0) * projected.dx +
transform.entry(3, 1) * projected.dy +
transform.entry(3, 3);
if (w <= 0) return null;
positions.add(MatrixUtils.transformPoint(transform, projected));
}
return RectExtension.containing(positions);
}

/// [camera] with its center moved so that the ground under the screen
/// position [from] is at [to].
MapCamera _centered(MapCamera camera, Offset from, Offset to) {
final fromGround = camera.groundOffsetFromCenter(from);
final toGround = camera.groundOffsetFromCenter(to);
if (fromGround == null || toGround == null) return camera;
return camera.withPosition(
center: camera.unprojectAtZoom(
camera.projectAtZoom(camera.center) + fromGround - toGround,
),
);
}
6 changes: 4 additions & 2 deletions lib/src/map/options/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ class MapOptions {
/// * a layer that computes screen positions itself, instead of drawing at
/// `projectAtZoom(coordinate) - pixelOrigin`, must use
/// [MapCamera.latLngToScreenOffset] and [MapCamera.screenOffsetToLatLng]
/// * [CameraFit] and [CameraConstraint] compute as if the map were not
/// pitched
/// * the built-in [CameraFit]s fit at the current pitch and rotation
/// * [CameraConstraint.contain] and [CameraConstraint.containLatitude]
/// ignore pitch, so ground beyond their bounds can be visible toward the
/// top of the map
/// * the tile layer loads up to 7.5 times as many tiles at 60° as when
/// unpitched
///
Expand Down
Loading
Loading