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
5 changes: 4 additions & 1 deletion example/lib/pages/pitch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ class _PitchPageState extends State<PitchPage> {
const Text(
'Markers and labels face the viewer; tiles, polygons and '
'polylines lie on the ground. Far ground uses coarser '
'tiles.',
'tiles. Above a pitch of about 71°, the ground ends at a '
'far limit, and the background color shows above it.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
Expand Down Expand Up @@ -86,6 +87,7 @@ class _PitchPageState extends State<PitchPage> {
initialZoom: 14,
initialPitch: _pitch,
maxPitch: MapOptions.pitchLimit,
backgroundColor: const Color(0xFFCFE3F3),
onMapEvent: (event) {
if (event.camera.pitch != _pitch ||
event.camera.rotation != _rotation) {
Expand All @@ -112,6 +114,7 @@ class _PitchPageState extends State<PitchPage> {
borderColor: Colors.purple,
borderStrokeWidth: 3,
label: 'Polygon',
rotateLabel: true,
labelStyle: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
Expand Down
43 changes: 11 additions & 32 deletions lib/src/gestures/map_interactive_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -774,13 +774,10 @@ class MapInteractiveViewerState extends State<MapInteractiveViewer>
widget.controller.moveStarted(MapEventSource.dragStart);
}

final groundShift = _groundShift(
_lastFocalLocal,
details.localFocalPoint,
widget.controller.dragUpdated(
MapEventSource.onDrag,
_groundShift(_lastFocalLocal, details.localFocalPoint),
);
if (groundShift == null) return;

widget.controller.dragUpdated(MapEventSource.onDrag, groundShift);
}
}

Expand Down Expand Up @@ -889,8 +886,7 @@ class MapInteractiveViewerState extends State<MapInteractiveViewer>
final oldFocalPt =
_camera.projectAtZoom(_focalStartLatLng, zoomAfterPinchZoom);
final zoomDifference = oldFocalPt - newFocalPt;
final moveDifference =
_groundShift(_focalStartLocal, _lastFocalLocal) ?? Offset.zero;
final moveDifference = _groundShift(_focalStartLocal, _lastFocalLocal);

final newCenterPt = oldCenterPt + zoomDifference + moveDifference;
return _camera.unprojectAtZoom(newCenterPt, zoomAfterPinchZoom);
Expand Down Expand Up @@ -1011,8 +1007,7 @@ class MapInteractiveViewerState extends State<MapInteractiveViewer>
if (_camera.pitch != 0) {
_flingCenterStart = _camera.center;
_flingGroundStep =
_groundShift(_lastFocalLocal + direction, _lastFocalLocal) ??
_rotateOffset(direction);
_groundShift(_lastFocalLocal + direction, _lastFocalLocal);
_flingDirection = direction;
_flingOffset = flingOffset;
}
Expand Down Expand Up @@ -1622,26 +1617,10 @@ class MapInteractiveViewerState extends State<MapInteractiveViewer>
}

/// The offset, in projected pixels, by which to move the center so that the
/// ground under the screen position [from] moves under [to], or null if
/// either is above the horizon.
Offset? _groundShift(Offset from, Offset to) {
final fromGround = _camera.groundOffsetFromCenter(from);
final toGround = _camera.groundOffsetFromCenter(to);
if (fromGround == null || toGround == null) return null;
return fromGround - toGround;
}

Offset _rotateOffset(Offset offset) {
final radians = _camera.rotationRad;
if (radians != 0.0) {
final cos = math.cos(radians);
final sin = math.sin(radians);
final nx = (cos * offset.dx) + (sin * offset.dy);
final ny = (cos * offset.dy) - (sin * offset.dx);

return Offset(nx, ny);
}

return offset;
}
/// ground under the screen position [from] moves under [to]. A position
/// above the far limit of the visible ground acts as the position on the far
/// limit directly below it, so that the ground moves by a bounded distance.
Offset _groundShift(Offset from, Offset to) =>
_camera.clampedGroundOffsetFromCenter(from) -
_camera.clampedGroundOffsetFromCenter(to);
}
7 changes: 7 additions & 0 deletions lib/src/layer/marker_layer/marker_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,13 @@ class _MarkerLayerState extends State<MarkerLayer> {

final screen = MatrixUtils.transformPoint(pixelToScreen, shifted);

// Beyond the far limit, which also ends the loop over worlds
// when the horizon is in view: world copies far away converge
// to it, inside the viewport
if (camera.pitch != 0 && screen.dy < camera.farLimit) {
return null;
}

// Without rotation, the marker's corners are within the larger
// of its sizes from its point
if (camera.pitch != 0 &&
Expand Down
5 changes: 4 additions & 1 deletion lib/src/layer/polygon_layer/label/label_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,15 @@ class _PolygonLabelPainter<R extends Object> extends CustomPainter
0) {
return WorldWorkControl.invisible;
}
final screen = MatrixUtils.transformPoint(canvasToScreen, point);
// Beyond the far limit; see the marker layer
if (screen.dy < camera.farLimit) return WorldWorkControl.invisible;

// In screen space, a label that turns with the map is rotated and an
// upright label is not: the reverse of the unpitched painter.
final painter = _buildLabelTextPainter(
mapSize: size,
placementPoint: MatrixUtils.transformPoint(canvasToScreen, point),
placementPoint: screen,
bounds: bounds,
textPainter: textPainter,
rotationRad: -camera.rotationRad,
Expand Down
25 changes: 24 additions & 1 deletion lib/src/layer/shared/mobile_layer_transformer.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_map/src/map/camera/camera.dart';

Expand Down Expand Up @@ -29,6 +30,12 @@ class MobileLayerTransformer extends StatelessWidget {
// The child's top-left corner is at [MapCamera.pixelOrigin], which is not
// centered on the map's center when pitched.
final origin = camera.pixelOrigin;
// Clipped to the visible ground, because a point behind the camera would
// be drawn mirrored, and nothing is drawn beyond the far limit.
final fromOrigin = camera.projectAtZoom(camera.center) - origin;
final ground = [
for (final corner in camera.visibleGroundFromCenter) fromOrigin + corner,
];
return OverflowBox(
alignment: Alignment.topLeft,
minWidth: camera.size.width,
Expand All @@ -38,8 +45,24 @@ class MobileLayerTransformer extends StatelessWidget {
child: Transform(
transform: camera.pixelToScreenTransform
..multiply(Matrix4.translationValues(origin.dx, origin.dy, 0)),
child: child,
child: ClipPath(
clipper: _PolygonClipper(ground),
child: child,
),
),
);
}
}

class _PolygonClipper extends CustomClipper<Path> {
final List<Offset> points;

const _PolygonClipper(this.points);

@override
Path getClip(Size size) => Path()..addPolygon(points, true);

@override
bool shouldReclip(_PolygonClipper oldClipper) =>
!listEquals(oldClipper.points, points);
}
14 changes: 3 additions & 11 deletions lib/src/layer/tile_layer/tile_cover_calculator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ class _Cover {
final int minNativeZoom;
final double viewingZoom;

/// The ground under the corners of the viewport, from the center, in pixels
/// at [viewingZoom].
/// The ground under the corners of [MapCamera.groundViewport], from the
/// center, in pixels at [viewingZoom].
final List<Offset> _corners;
final Offset _pixelCenter;
final int _tileZoom;
Expand All @@ -74,15 +74,7 @@ class _Cover {
camera.projectAtZoom(center, tileZoom.toDouble()).floor(),
_corners = camera.nonRotatedSize.isEmpty
? const [Offset.zero]
: [
Offset.zero,
Offset(camera.nonRotatedSize.width, 0),
Offset(
camera.nonRotatedSize.width, camera.nonRotatedSize.height),
Offset(0, camera.nonRotatedSize.height),
]
.map((corner) => camera.groundOffsetFromCenter(corner)!)
.toList();
: camera.visibleGroundFromCenter;

int _zoomAtScale(double scale) => (viewingZoom + math.log(scale) / math.ln2)
.round()
Expand Down
117 changes: 77 additions & 40 deletions lib/src/map/camera/camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -428,16 +428,19 @@ class MapCamera {

/// Calculate the [LatLng] coordinates for a [offset].
///
/// When the map is pitched and [offset] is above the horizon, there is no
/// coordinate at [offset]; this returns a distant coordinate in its
/// direction instead. [maybeScreenOffsetToLatLng] returns null there.
/// When the map is pitched and [offset] is above the far limit of the
/// visible ground, there is no visible ground at [offset]; this returns the
/// coordinate on the far limit directly below [offset] instead.
/// [maybeScreenOffsetToLatLng] returns null there.
LatLng screenOffsetToLatLng(Offset offset) =>
unprojectAtZoom(_screenToPixel(offset, aboveHorizon: _distantGround)!);
unprojectAtZoom(_screenToPixel(offset, clampToFarLimit: true)!);

/// Calculate the [LatLng] coordinates for a [offset], or null when the map
/// is pitched and [offset] is above the horizon.
/// is pitched and [offset] is above the far limit of the visible ground.
/// The far limit is where the view ray is 89.25 degrees from straight down,
/// just below the horizon.
LatLng? maybeScreenOffsetToLatLng(Offset offset) {
final pixel = _screenToPixel(offset, aboveHorizon: null);
final pixel = _screenToPixel(offset, clampToFarLimit: false);
return pixel == null ? null : unprojectAtZoom(pixel);
}

Expand All @@ -450,17 +453,7 @@ class MapCamera {
/// [nonRotatedSize], [rotation] and [pitch], because [zoom] is the scale at
/// [center].
Rect get _visibleGroundFromCenter => _footprintFromCenter ??= () {
final projectedCenter = projectAtZoom(center);
final corners = [
Offset.zero,
Offset(nonRotatedSize.width, 0),
Offset(nonRotatedSize.width, nonRotatedSize.height),
Offset(0, nonRotatedSize.height),
]
.map((corner) =>
_screenToPixel(corner, aboveHorizon: _distantGround)! -
projectedCenter)
.toList();
final corners = visibleGroundFromCenter;
return Rect.fromLTRB(
corners.map((c) => c.dx).reduce(math.min),
corners.map((c) => c.dy).reduce(math.min),
Expand All @@ -469,22 +462,62 @@ class MapCamera {
);
}();

/// The smallest divisor of [_screenToPixel] that [screenOffsetToLatLng]
/// uses above the horizon, where the divisor is not positive.
static const _distantGround = 1e-3;
/// The angle from straight down of the view ray at the far limit of the
/// visible ground, as in MapLibre Native.
static const _farLimitAngle = 89.25 * degrees2Radians;

/// The y position in the map view, before rotation, of the far limit of the
/// visible ground: ground points whose view ray is [_farLimitAngle] from
/// straight down. It is above the map view, and may be negative infinity,
/// unless [pitch] is above about 70.8 degrees.
@internal
double get farLimit => pitch == 0
? double.negativeInfinity
: nonRotatedSize.height / 2 -
_cameraDistance * math.tan(_farLimitAngle - pitchRad);

/// The part of the map view, before rotation, that shows ground: the map
/// view, with its top moved down to [farLimit] when [farLimit] is in it.
@internal
Rect get groundViewport => Rect.fromLTRB(
0,
math.max(0, farLimit),
nonRotatedSize.width,
nonRotatedSize.height,
);

/// The ground under the corners of [groundViewport], clockwise from the
/// top-left, as offsets in projected pixels at [zoom] from [center].
@internal
List<Offset> get visibleGroundFromCenter {
final viewport = groundViewport;
return [
viewport.topLeft,
viewport.topRight,
viewport.bottomRight,
viewport.bottomLeft,
].map(clampedGroundOffsetFromCenter).toList();
}

/// The projected pixel at [zoom] of the ground point under the screen
/// position [offset]. See [_groundFromCenter].
Offset? _screenToPixel(Offset offset, {required double? aboveHorizon}) {
final fromCenter = _groundFromCenter(offset, aboveHorizon: aboveHorizon);
Offset? _screenToPixel(Offset offset, {required bool clampToFarLimit}) {
final fromCenter =
_groundFromCenter(offset, clampToFarLimit: clampToFarLimit);
return fromCenter == null ? null : projectAtZoom(center) + fromCenter;
}

/// The offset, in projected pixels at [zoom], from [center] to the ground
/// point under the screen position [offset], or null above the horizon.
/// point under the screen position [offset], or null above [farLimit].
@internal
Offset? groundOffsetFromCenter(Offset offset) =>
_groundFromCenter(offset, aboveHorizon: null);
_groundFromCenter(offset, clampToFarLimit: false);

/// As [groundOffsetFromCenter], but above [farLimit] uses the position on
/// [farLimit] directly below [offset].
@internal
Offset clampedGroundOffsetFromCenter(Offset offset) =>
_groundFromCenter(offset, clampToFarLimit: true)!;

/// The size on screen of one projected pixel at the ground point [offset]
/// from [center], where [offset] is in projected pixels at [zoom]. It is 1 at
Expand All @@ -499,20 +532,23 @@ class MapCamera {

/// The offset, in projected pixels at [zoom], from [center] to the ground
/// point under the screen position [offset], found by casting the view ray
/// to the ground. Above the horizon, uses [aboveHorizon] as the divisor, or
/// returns null if it is null.
/// to the ground. Above [farLimit], uses the position on [farLimit] directly
/// below [offset] if [clampToFarLimit], or returns null otherwise.
///
/// The result doesn't depend on [center] or [zoom], because [zoom] is the
/// scale at [center].
Offset? _groundFromCenter(Offset offset, {required double? aboveHorizon}) {
final fromCenter = offset - nonRotatedSize.center(Offset.zero);
Offset? _groundFromCenter(Offset offset, {required bool clampToFarLimit}) {
final farLimit = this.farLimit;
final belowFarLimit = offset.dy >= farLimit
? offset
: clampToFarLimit
? Offset(offset.dx, farLimit)
: null;
if (belowFarLimit == null) return null;
final fromCenter = belowFarLimit - nonRotatedSize.center(Offset.zero);
final sinPitch = math.sin(pitchRad);
var divisor =
final divisor =
math.cos(pitchRad) + fromCenter.dy * sinPitch / _cameraDistance;
if (divisor <= 0) {
if (aboveHorizon == null) return null;
divisor = aboveHorizon;
}
final y = fromCenter.dy / divisor;
final x = fromCenter.dx * (1 - y * sinPitch / _cameraDistance);
return Offset(x, y).rotate(rotationRad);
Expand Down Expand Up @@ -554,11 +590,12 @@ class MapCamera {
/// zoom level. If [zoom] is not provided the current zoom level of the
/// [MapCamera] gets used.
///
/// When the map is pitched and [offset] is above the horizon, this returns a
/// distant coordinate in its direction.
/// When the map is pitched and [offset] is above the far limit of the
/// visible ground, this returns the coordinate on the far limit directly
/// below [offset].
LatLng offsetToCrs(Offset offset, [double? zoom]) {
final focalStartPt = projectAtZoom(center, zoom ?? this.zoom);
final point = _groundFromCenter(offset, aboveHorizon: _distantGround)!;
final point = _groundFromCenter(offset, clampToFarLimit: true)!;

final newCenterPt = focalStartPt + point;
final worldWidth = getWorldWidthAtZoom(zoom ?? this.zoom);
Expand All @@ -577,11 +614,11 @@ class MapCamera {
/// Calculate the center point which would keep the same point of the map
/// visible at the given [cursorPos] with the zoom set to [zoom].
///
/// When the map is pitched and [cursorPos] is above the horizon, this
/// returns [center].
/// When the map is pitched and [cursorPos] is above the far limit of the
/// visible ground, this keeps the point on the far limit directly below
/// [cursorPos] instead.
LatLng focusedZoomCenter(Offset cursorPos, double zoom) {
final offset = _groundFromCenter(cursorPos, aboveHorizon: null);
if (offset == null) return center;
final offset = clampedGroundOffsetFromCenter(cursorPos);
// Match new center coordinate to mouse cursor position
final scale = getZoomScale(zoom, this.zoom);
final newOffset = offset * (1.0 - 1.0 / scale);
Expand Down
Loading
Loading