TEST(Transform, Antimeridian) { Transform transform; transform.resize({ 1000, 1000 }); transform.jumpTo(CameraOptions().withCenter(LatLng()).withZoom(1.0)); // San Francisco const LatLng coordinateSanFrancisco { 37.7833, -122.4167 }; ScreenCoordinate pixelSF = transform.latLngToScreenCoordinate(coordinateSanFrancisco); ASSERT_DOUBLE_EQ(151.79249437176432, pixelSF.x); ASSERT_DOUBLE_EQ(383.76720782527661, pixelSF.y); transform.jumpTo(CameraOptions().withCenter(LatLng { 0.0, -181.0 })); ScreenCoordinate pixelSFLongest = transform.latLngToScreenCoordinate(coordinateSanFrancisco); ASSERT_DOUBLE_EQ(-357.36306616412816, pixelSFLongest.x); ASSERT_DOUBLE_EQ(pixelSF.y, pixelSFLongest.y); LatLng unwrappedSF = coordinateSanFrancisco.wrapped(); unwrappedSF.unwrapForShortestPath(transform.getLatLng()); ScreenCoordinate pixelSFShortest = transform.latLngToScreenCoordinate(unwrappedSF); ASSERT_DOUBLE_EQ(666.63694385219173, pixelSFShortest.x); ASSERT_DOUBLE_EQ(pixelSF.y, pixelSFShortest.y); transform.jumpTo(CameraOptions().withCenter(LatLng { 0.0, 179.0 })); pixelSFShortest = transform.latLngToScreenCoordinate(coordinateSanFrancisco); ASSERT_DOUBLE_EQ(pixelSFLongest.x, pixelSFShortest.x); ASSERT_DOUBLE_EQ(pixelSFLongest.y, pixelSFShortest.y); // Waikiri const LatLng coordinateWaikiri{ -16.9310, 179.9787 }; transform.jumpTo(CameraOptions().withCenter(coordinateWaikiri).withZoom(10.0)); ScreenCoordinate pixelWaikiri = transform.latLngToScreenCoordinate(coordinateWaikiri); ASSERT_DOUBLE_EQ(500, pixelWaikiri.x); ASSERT_DOUBLE_EQ(500, pixelWaikiri.y); transform.jumpTo(CameraOptions().withCenter(LatLng { coordinateWaikiri.latitude(), 180.0213 })); ScreenCoordinate pixelWaikiriLongest = transform.latLngToScreenCoordinate(coordinateWaikiri); ASSERT_DOUBLE_EQ(524725.96438108233, pixelWaikiriLongest.x); ASSERT_DOUBLE_EQ(pixelWaikiri.y, pixelWaikiriLongest.y); LatLng unwrappedWaikiri = coordinateWaikiri.wrapped(); unwrappedWaikiri.unwrapForShortestPath(transform.getLatLng()); ScreenCoordinate pixelWaikiriShortest = transform.latLngToScreenCoordinate(unwrappedWaikiri); ASSERT_DOUBLE_EQ(437.95925272648344, pixelWaikiriShortest.x); ASSERT_DOUBLE_EQ(pixelWaikiri.y, pixelWaikiriShortest.y); LatLng coordinateFromPixel = transform.screenCoordinateToLatLng(pixelWaikiriLongest); ASSERT_NEAR(coordinateWaikiri.latitude(), coordinateFromPixel.latitude(), 1e-4); ASSERT_NEAR(coordinateWaikiri.longitude(), coordinateFromPixel.longitude(), 1e-4); transform.jumpTo(CameraOptions().withCenter(LatLng { coordinateWaikiri.latitude(), 180.0213 })); pixelWaikiriShortest = transform.latLngToScreenCoordinate(coordinateWaikiri); ASSERT_DOUBLE_EQ(pixelWaikiriLongest.x, pixelWaikiriShortest.x); ASSERT_DOUBLE_EQ(pixelWaikiriLongest.y, pixelWaikiriShortest.y); coordinateFromPixel = transform.screenCoordinateToLatLng(pixelWaikiriShortest); ASSERT_NEAR(coordinateWaikiri.latitude(), coordinateFromPixel.latitude(), 1e-4); ASSERT_NEAR(coordinateWaikiri.longitude(), coordinateFromPixel.longitude(), 1e-4); }
ScreenCoordinate Transform::latLngToScreenCoordinate(const LatLng& latLng) const { // If the center and point longitudes are not in the same side of the // antimeridian, we unwrap the point longitude so it would be seen if // e.g. the next antimeridian side is visible. LatLng unwrappedLatLng = latLng.wrapped(); unwrappedLatLng.unwrapForShortestPath(getLatLng()); ScreenCoordinate point = state.latLngToScreenCoordinate(unwrappedLatLng); point.y = state.height - point.y; return point; }
void MapSnapshotter::Impl::snapshot(ActorRef<MapSnapshotter::Callback> callback) { map.renderStill([this, callback = std::move(callback)] (std::exception_ptr error) mutable { // Create lambda that captures the current transform state // and can be used to translate for geographic to screen // coordinates assert (frontend.getTransformState()); PointForFn pointForFn { [=, center=map.getLatLng(), transformState = *frontend.getTransformState()] (const LatLng& latLng) { LatLng unwrappedLatLng = latLng.wrapped(); unwrappedLatLng.unwrapForShortestPath(center); Transform transform { transformState }; return transform.latLngToScreenCoordinate(unwrappedLatLng); }}; // Create lambda that captures the current transform state // and can be used to translate for geographic to screen // coordinates assert (frontend.getTransformState()); LatLngForFn latLngForFn { [=, transformState = *frontend.getTransformState()] (const ScreenCoordinate& screenCoordinate) { Transform transform { transformState }; return transform.screenCoordinateToLatLng(screenCoordinate); }}; // Collect all source attributions std::vector<std::string> attributions; for (auto source : map.getStyle().getSources()) { auto attribution = source->getAttribution(); if (attribution) { attributions.push_back(*attribution); } } // Invoke callback callback.invoke( &MapSnapshotter::Callback::operator(), error, error ? PremultipliedImage() : frontend.readStillImage(), std::move(attributions), std::move(pointForFn), std::move(latLngForFn) ); }); }
/** * Change any combination of center, zoom, bearing, and pitch, with a smooth animation * between old and new values. The map will retain the current values for any options * not included in `options`. */ void Transform::easeTo(const CameraOptions& camera, const AnimationOptions& animation) { const LatLng unwrappedLatLng = camera.center.value_or(getLatLng()); const LatLng latLng = unwrappedLatLng.wrapped(); double zoom = camera.zoom.value_or(getZoom()); double angle = camera.angle.value_or(getAngle()); double pitch = camera.pitch.value_or(getPitch()); if (!latLng || std::isnan(zoom)) { return; } // Determine endpoints. EdgeInsets padding; if (camera.padding) padding = *camera.padding; LatLng startLatLng = getLatLng(padding); // If gesture in progress, we transfer the world rounds from the end // longitude into start, so we can guarantee the "scroll effect" of rounding // the world while assuring the end longitude remains wrapped. if (isGestureInProgress()) startLatLng.longitude -= unwrappedLatLng.longitude - latLng.longitude; // Find the shortest path otherwise. else startLatLng.unwrapForShortestPath(latLng); const ScreenCoordinate startPoint = { state.lngX(startLatLng.longitude), state.latY(startLatLng.latitude), }; const ScreenCoordinate endPoint = { state.lngX(latLng.longitude), state.latY(latLng.latitude), }; ScreenCoordinate center = getScreenCoordinate(padding); center.y = state.height - center.y; // Constrain camera options. zoom = util::clamp(zoom, state.getMinZoom(), state.getMaxZoom()); const double scale = state.zoomScale(zoom); pitch = util::clamp(pitch, 0., util::PITCH_MAX); Update update = state.getZoom() == zoom ? Update::Repaint : Update::RecalculateStyle; // Minimize rotation by taking the shorter path around the circle. angle = _normalizeAngle(angle, state.angle); state.angle = _normalizeAngle(state.angle, angle); Duration duration = animation.duration ? *animation.duration : Duration::zero(); const double startWorldSize = state.worldSize(); state.Bc = startWorldSize / util::DEGREES_MAX; state.Cc = startWorldSize / util::M2PI; const double startScale = state.scale; const double startAngle = state.angle; const double startPitch = state.pitch; state.panning = latLng != startLatLng; state.scaling = scale != startScale; state.rotating = angle != startAngle; startTransition(camera, animation, [=](double t) { ScreenCoordinate framePoint = util::interpolate(startPoint, endPoint, t); LatLng frameLatLng = { state.yLat(framePoint.y, startWorldSize), state.xLng(framePoint.x, startWorldSize) }; double frameScale = util::interpolate(startScale, scale, t); state.setLatLngZoom(frameLatLng, state.scaleZoom(frameScale)); if (angle != startAngle) { state.angle = util::wrap(util::interpolate(startAngle, angle, t), -M_PI, M_PI); } if (pitch != startPitch) { state.pitch = util::interpolate(startPitch, pitch, t); } if (padding) { state.moveLatLng(frameLatLng, center); } return update; }, duration); }
/** This method implements an “optimal path” animation, as detailed in: Van Wijk, Jarke J.; Nuij, Wim A. A. “Smooth and efficient zooming and panning.” INFOVIS ’03. pp. 15–22. <https://www.win.tue.nl/~vanwijk/zoompan.pdf#page=5>. Where applicable, local variable documentation begins with the associated variable or function in van Wijk (2003). */ void Transform::flyTo(const CameraOptions &camera, const AnimationOptions &animation) { const LatLng latLng = camera.center.value_or(getLatLng()).wrapped(); double zoom = camera.zoom.value_or(getZoom()); double angle = camera.angle.value_or(getAngle()); double pitch = camera.pitch.value_or(getPitch()); if (!latLng || std::isnan(zoom)) { return; } // Determine endpoints. EdgeInsets padding; if (camera.padding) padding = *camera.padding; LatLng startLatLng = getLatLng(padding).wrapped(); startLatLng.unwrapForShortestPath(latLng); const ScreenCoordinate startPoint = { state.lngX(startLatLng.longitude), state.latY(startLatLng.latitude), }; const ScreenCoordinate endPoint = { state.lngX(latLng.longitude), state.latY(latLng.latitude), }; ScreenCoordinate center = getScreenCoordinate(padding); center.y = state.height - center.y; // Constrain camera options. zoom = util::clamp(zoom, state.getMinZoom(), state.getMaxZoom()); pitch = util::clamp(pitch, 0., util::PITCH_MAX); // Minimize rotation by taking the shorter path around the circle. angle = _normalizeAngle(angle, state.angle); state.angle = _normalizeAngle(state.angle, angle); const double startZoom = state.scaleZoom(state.scale); const double startAngle = state.angle; const double startPitch = state.pitch; /// w₀: Initial visible span, measured in pixels at the initial scale. /// Known henceforth as a <i>screenful</i>. double w0 = padding ? std::max(state.width, state.height) : std::max(state.width - padding.left - padding.right, state.height - padding.top - padding.bottom); /// w₁: Final visible span, measured in pixels with respect to the initial /// scale. double w1 = w0 / state.zoomScale(zoom - startZoom); /// Length of the flight path as projected onto the ground plane, measured /// in pixels from the world image origin at the initial scale. double u1 = ::hypot((endPoint - startPoint).x, (endPoint - startPoint).y); /** ρ: The relative amount of zooming that takes place along the flight path. A high value maximizes zooming for an exaggerated animation, while a low value minimizes zooming for something closer to easeTo(). 1.42 is the average value selected by participants in the user study in van Wijk (2003). A value of 6<sup>¼</sup> would be equivalent to the root mean squared average velocity, V<sub>RMS</sub>. A value of 1 would produce a circular motion. */ double rho = 1.42; if (animation.minZoom) { double minZoom = util::min(*animation.minZoom, startZoom, zoom); minZoom = util::clamp(minZoom, state.getMinZoom(), state.getMaxZoom()); /// w<sub>m</sub>: Maximum visible span, measured in pixels with respect /// to the initial scale. double wMax = w0 / state.zoomScale(minZoom - startZoom); rho = std::sqrt(wMax / u1 * 2); } /// ρ² double rho2 = rho * rho; /** rᵢ: Returns the zoom-out factor at one end of the animation. @param i 0 for the ascent or 1 for the descent. */ auto r = [=](double i) { /// bᵢ double b = (w1 * w1 - w0 * w0 + (i ? -1 : 1) * rho2 * rho2 * u1 * u1) / (2 * (i ? w1 : w0) * rho2 * u1); return std::log(std::sqrt(b * b + 1) - b); }; // When u₀ = u₁, the optimal path doesn’t require both ascent and descent. bool isClose = std::abs(u1) < 0.000001; // Perform a more or less instantaneous transition if the path is too short. if (isClose && std::abs(w0 - w1) < 0.000001) { easeTo(camera, animation); return; } /// r₀: Zoom-out factor during ascent. double r0 = r(0); /** w(s): Returns the visible span on the ground, measured in pixels with respect to the initial scale. Assumes an angular field of view of 2 arctan ½ ≈ 53°. */ auto w = [=](double s) { return (isClose ? std::exp((w1 < w0 ? -1 : 1) * rho * s) : (std::cosh(r0) / std::cosh(r0 + rho * s))); }; /// u(s): Returns the distance along the flight path as projected onto the /// ground plane, measured in pixels from the world image origin at the /// initial scale. auto u = [=](double s) { return (isClose ? 0. : (w0 * (std::cosh(r0) * std::tanh(r0 + rho * s) - std::sinh(r0)) / rho2 / u1)); }; /// S: Total length of the flight path, measured in ρ-screenfuls. double S = (isClose ? (std::abs(std::log(w1 / w0)) / rho) : ((r(1) - r0) / rho)); Duration duration; if (animation.duration) { duration = *animation.duration; } else { /// V: Average velocity, measured in ρ-screenfuls per second. double velocity = 1.2; if (animation.velocity) { velocity = *animation.velocity / rho; } duration = std::chrono::duration_cast<Duration>(std::chrono::duration<double>(S / velocity)); } if (duration == Duration::zero()) { // Perform an instantaneous transition. jumpTo(camera); return; } const double startWorldSize = state.worldSize(); state.Bc = startWorldSize / util::DEGREES_MAX; state.Cc = startWorldSize / util::M2PI; state.panning = true; state.scaling = true; state.rotating = angle != startAngle; startTransition(camera, animation, [=](double k) { /// s: The distance traveled along the flight path, measured in /// ρ-screenfuls. double s = k * S; double us = u(s); // Calculate the current point and zoom level along the flight path. ScreenCoordinate framePoint = util::interpolate(startPoint, endPoint, us); double frameZoom = startZoom + state.scaleZoom(1 / w(s)); // Convert to geographic coordinates and set the new viewpoint. LatLng frameLatLng = { state.yLat(framePoint.y, startWorldSize), state.xLng(framePoint.x, startWorldSize), }; state.setLatLngZoom(frameLatLng, frameZoom); if (angle != startAngle) { state.angle = util::wrap(util::interpolate(startAngle, angle, k), -M_PI, M_PI); } if (pitch != startPitch) { state.pitch = util::interpolate(startPitch, pitch, k); } if (padding) { state.moveLatLng(frameLatLng, center); } return Update::RecalculateStyle; }, duration); }