diff --git a/README.md b/README.md index 627542a..c195cf0 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ and GeoPackage. The ActiveX layer supports COM activation, vector loading, polygon rendering, and asynchronous generic GDAL algorithms with progress and cancellation events. +The core also supports a shared map viewport with pan, center zoom, anchored +zoom, and fitting to the visible map or a named layer. These navigation calls +do not depend on a window. ActiveX mouse navigation remains future work. + Vector loads add layers rather than replace them. Call `ClearLayers` to release the loaded datasets before switching or refreshing them. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58c215c..7052ea7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -175,6 +175,12 @@ take snapshots. to `±kMaxCoordinate` and resolutions to `[MinResolution(center), kMaxResolution]`, where the minimum is relative to the center's magnitude so that pixels stay many representable doubles wide. +- **Navigation.** `Pan` moves the center in map units. `Zoom` divides the + resolution by its factor, and `ZoomOut` multiplies it directly. `ZoomAt` + keeps the map coordinate under a screen anchor unless the limits intervene. + Moving the center can raise `MinResolution` and change the resolution + again, so the anchored coordinate can move even without center clamping. + `ZoomAt` needs a valid viewport. Center zoom and pan only need a view. ## Map view and multi-layer rendering @@ -206,6 +212,21 @@ has no extent or transform of its own. requested while the viewport has no size, for example before the host window exists or while it is minimized, is done by the first `Resize` that gives it a size. + If reading the pending extent throws, `Resize` propagates the error after + applying the size and discarding the pending fit. The center and resolution + stay unchanged. The host must request a redraw after this error. +- **Navigation.** `FitToMap` (zoom to map: visible layers only, with + padding), `ZoomToLayer` (by `LayerId`, also for hidden layers), `ZoomIn` + and `ZoomOut` (center zoom by a factor above 1, default + `kDefaultZoomFactor`), `ZoomAt`, and `Pan` change the shared viewport and + return whether a redraw is needed. Empty maps, missing layers, and layers + without a valid extent return `false` and keep the view. The latest request + wins: a new fit replaces a pending one, and a navigation step that changes + the view cancels it. A missing layer or an invalid extent also cancels a + pending fit. Navigation that returns `false` keeps it, including `ZoomAt` + on a zero-sized viewport and center zoom at a resolution limit. + None of this depends on COM, mouse messages, or a + renderer; Phase 5 maps input onto these calls. ## ActiveX and rendering architecture @@ -349,7 +370,10 @@ Windows CPU fallback; Core has no independent platform-neutral renderer. The C# host's pixel assertions, activation, and native tests pass for Debug and Release on Win32 and x64 in an interactive desktop session. -The next milestone is navigation on the shared `MapView`: zoom to map and -layer, zoom in and out, and pan. Measure scene preparation with representative -datasets before adding caching, paging, or broader OpenGL geometry support. +Core navigation on the shared `MapView` (zoom to map and layer, zoom in and +out, anchored zoom, and pan) is implemented but not yet exposed through COM or +mouse input; that is the next milestone. Deep zoom can push a layer past the +GDI coordinate range until clipping exists. Measure scene preparation with +representative datasets before adding caching, paging, or broader OpenGL +geometry support. OpenCL and full MapWinGIS 5 API parity remain deferred. diff --git a/docs/TESTING.md b/docs/TESTING.md index 9906686..5349ccf 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -207,6 +207,16 @@ unchanged-geometry cache. Total memory includes the OpenGL driver and context. Map view tests fit point and zero-span extents, and tiny targets with default padding. Map scene tests isolate standard geometry-read exceptions, reuse the resulting scene, and let allocation failures propagate. +- Navigation is tested without input events: `ViewportTests.cpp` covers the + pan, center-zoom, and anchored-zoom calculations and their limits, and + `MapViewTests.cpp` covers zoom to map and layer, zoom in and out, empty and + missing-layer cases, and pending-fit handling on `MapView`. Pending-fit + tests cover replacement in both directions between map and layer fits, + cancellation by pan and center zoom, and cancellation by missing or empty + layers. They also cover unchanged navigation at a resolution limit and + `ZoomAt` on a zero-sized viewport, which keep the pending fit. + Viewport tests cover anchor movement when the new center raises the + minimum resolution, and direct zoom-out multiplication without a reciprocal. - Cover for each feature: happy path, empty/missing input, out-of-range indexes, null values, non-ASCII paths, write-then-reopen round-trips, and error cases. diff --git a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h index 0213745..edf4e62 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/MapView.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/MapView.h @@ -38,6 +38,11 @@ namespace mwg /// the viewport a size, so data loaded before a host window has a size is /// fitted once the window appears. /// + /// Navigation: FitToMap, ZoomToLayer, ZoomIn, ZoomOut, ZoomAt, and Pan + /// change the viewport; they depend on no window, input, or renderer. + /// A later fit replaces a pending fit, and a navigation operation that + /// changes the view cancels it, so the latest request wins. + /// /// Not thread-safe; see Map. class MWG_CORE_API MapView { @@ -45,6 +50,9 @@ namespace mwg /// Padding, in pixels, used by FitToMap when none is given. static constexpr double kDefaultPadding = 12.0; + /// Zoom factor used by ZoomIn and ZoomOut when none is given. + static constexpr double kDefaultZoomFactor = 2.0; + MapView(); ~MapView(); @@ -74,7 +82,11 @@ namespace mwg /// the map neither moves nor changes scale; see Viewport::SetSize. /// Completes a pending fit once both dimensions are positive. /// Returns true when the size or view changed. + /// If reading the pending fit extent throws, the new size stays + /// applied, the center and resolution stay unchanged, and the + /// pending fit is discarded. The host must redraw after this error. /// \throws InvalidArgument as Viewport::SetSize does. + /// \throws CoreError when the pending fit extent cannot be read. bool Resize(int width, int height); /// Fits the viewport to the visible map extent with paddingPixels @@ -82,9 +94,46 @@ namespace mwg /// view was fitted. When the viewport has no size yet, the fit is /// kept pending for Resize and false is returned. A map without a /// valid visible extent returns false and leaves the view unchanged. + /// A map without a valid visible extent also cancels a pending fit. /// \throws InvalidArgument when paddingPixels is negative or not finite. + /// \throws CoreError when a visible layer extent cannot be read. bool FitToMap(double paddingPixels = kDefaultPadding); + /// Fits the viewport to the extent of one layer with paddingPixels + /// free on every side; see Viewport::FitExtent. The layer is fitted + /// even when it is hidden, because the caller named it explicitly. + /// Returns true when the view was fitted. When the viewport has no + /// size yet, the fit is kept pending for Resize and false is + /// returned. Returns false and leaves the view unchanged when the map + /// has no such layer or the layer has no valid extent. + /// These cases also cancel a pending fit. + /// \throws InvalidArgument when paddingPixels is negative or not finite. + /// \throws CoreError when the layer extent cannot be read. + bool ZoomToLayer(LayerId id, double paddingPixels = kDefaultPadding); + + /// Zooms in around the viewport center: the resolution is divided by + /// factor; see Viewport::Zoom. Returns true when the view changed; + /// false without a view or at the resolution limit. + /// \throws InvalidArgument when factor is not finite or not above 1. + bool ZoomIn(double factor = kDefaultZoomFactor); + + /// Zooms out around the viewport center: the resolution is + /// multiplied by factor; see Viewport::ZoomOut. Returns true when the + /// view changed; false without a view or at the resolution limit. + /// \throws InvalidArgument when factor is not finite or not above 1. + bool ZoomOut(double factor = kDefaultZoomFactor); + + /// Zooms around a screen position, keeping the map coordinate under + /// it in place; see Viewport::ZoomAt. A factor above 1 zooms in and + /// below 1 zooms out. Returns true when the view changed. + /// \throws InvalidArgument as Viewport::ZoomAt does. + bool ZoomAt(const ScreenPoint& anchor, double factor); + + /// Moves the view by a map-coordinate delta; see Viewport::Pan. + /// Returns true when the view changed. + /// \throws InvalidArgument as Viewport::Pan does. + bool Pan(double deltaX, double deltaY); + /// The render scene of the current map, rebuilt first when the map /// changed since the last call; see rendering::CreateMapRenderScene. /// The reference stays valid for the lifetime of the MapView; a later @@ -97,6 +146,9 @@ namespace mwg bool InvalidateScene() noexcept; private: + /// Clears a pending fit when changed is true and returns changed. + bool ViewChanged(bool changed) noexcept; + class Impl; std::unique_ptr _impl; }; diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h index b6bbc42..0299636 100644 --- a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h +++ b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h @@ -164,6 +164,44 @@ namespace mwg /// \throws InvalidArgument when paddingPixels is negative or not finite. bool FitExtent(const Extent& extent, double paddingPixels = 0.0); + /// Moves the view by a map-coordinate delta: the visible extent + /// shifts by (deltaX, deltaY), so positive deltaX shows content + /// further right (east) and positive deltaY content further up + /// (north). The center is clamped to the limits. Returns true when + /// the view changed; false without a view or for a zero delta. + /// The size is not needed, so a zero-sized viewport can be panned. + /// \throws InvalidArgument when a delta is not finite. + bool Pan(double deltaX, double deltaY); + + /// Zooms around the viewport center: the resolution is divided by + /// factor, so a factor above 1 zooms in and below 1 zooms out. The + /// center is kept and the resolution is clamped to the limits. + /// Returns true when the view changed; false without a view or when + /// the resolution is already at the limit in that direction. + /// \throws InvalidArgument when factor is not a finite positive number. + bool Zoom(double factor); + + /// Zooms around the viewport center by multiplying the resolution + /// by factor. A factor above 1 zooms out and below 1 zooms in. + /// Keeps the center and clamps the resolution to the limits. + /// Returns true when the view changed; false without a view or at + /// the resolution limit in that direction. + /// \throws InvalidArgument when factor is not a finite positive number. + bool ZoomOut(double factor); + + /// Zooms around a screen position: the resolution is divided by + /// factor as in Zoom, and the map coordinate under anchor stays + /// under anchor. The anchor may lie outside the viewport. When the + /// limits clamp the result, the resolution or center is clamped and + /// the anchored coordinate may move. Moving the center can increase + /// MinResolution, which clamps the resolution again and can also + /// move the anchored coordinate. Returns true when the view changed; + /// false when the viewport is not valid or the view is already at + /// the limit. + /// \throws InvalidArgument when factor is not a finite positive number + /// or an anchor coordinate is not finite. + bool ZoomAt(const ScreenPoint& anchor, double factor); + /// Map extent covered by the whole viewport, or an empty Extent when /// the viewport is not valid. [[nodiscard]] Extent VisibleExtent() const noexcept; @@ -179,6 +217,10 @@ namespace mwg [[nodiscard]] std::optional ScreenToMap(const ScreenPoint& point) const noexcept; private: + /// Applies a clamped view and returns true when it differs from the + /// current one. + bool ApplyView(const MapPoint& center, double resolution) noexcept; + int _width = 0; int _height = 0; bool _hasView = false; diff --git a/src/MapWinGIS.Core/src/map/MapView.cpp b/src/MapWinGIS.Core/src/map/MapView.cpp index 593a563..f36b8d6 100644 --- a/src/MapWinGIS.Core/src/map/MapView.cpp +++ b/src/MapWinGIS.Core/src/map/MapView.cpp @@ -6,22 +6,65 @@ */ #include "mapwingis/map/MapView.h" +#include #include #include +#include "mapwingis/core/Error.h" #include "mapwingis/vector/VectorDataset.h" namespace mwg { + namespace + { + void ValidateStepFactor(const double factor) + { + if (!std::isfinite(factor) || factor <= 1.0) + { + throw InvalidArgument("Zoom factor must be a finite number above 1"); + } + } + } + class MapView::Impl { public: + /// A fit that waits for the viewport to get a size. + struct PendingFit + { + /// The layer to fit, or std::nullopt to fit the whole map. + std::optional layer; + double padding = 0.0; + }; + + /// Fits extent and returns true, or keeps the fit pending when only + /// the viewport size prevents it. + bool Fit(const Extent& extent, const PendingFit& request) + { + if (_viewport.FitExtent(extent, request.padding)) + { + _pendingFit.reset(); + return true; + } + + // FitExtent fails for an invalid extent or a zero-sized viewport; + // only the second can succeed later without another request. + if (extent.IsValid()) + { + _pendingFit = request; + } + else + { + _pendingFit.reset(); + } + return false; + } + Map _map; Viewport _viewport; rendering::MapRenderScene _scene; bool _sceneStale = false; - /// Padding of a fit that waits for the viewport to get a size. - std::optional _pendingFitPadding; + std::optional _pendingFit; }; MapView::MapView() @@ -101,33 +144,68 @@ namespace mwg viewport.SetSize(width, height); bool fitted = false; - if (_impl->_pendingFitPadding.has_value() && width > 0 && height > 0) + if (_impl->_pendingFit.has_value() && width > 0 && height > 0) { - fitted = FitToMap(*_impl->_pendingFitPadding); + // Cleared first, so a layer removed in the meantime does not + // leave the fit pending forever. + const Impl::PendingFit pending = *_impl->_pendingFit; + _impl->_pendingFit.reset(); + fitted = pending.layer.has_value() + ? ZoomToLayer(*pending.layer, pending.padding) + : FitToMap(pending.padding); } return sizeChanged || fitted; } bool MapView::FitToMap(const double paddingPixels) { - const Extent extent = _impl->_map.GetExtent(); - if (_impl->_viewport.FitExtent(extent, paddingPixels)) + return _impl->Fit(_impl->_map.GetExtent(), {std::nullopt, paddingPixels}); + } + + bool MapView::ZoomToLayer(const LayerId id, const double paddingPixels) + { + if (!std::isfinite(paddingPixels) || paddingPixels < 0.0) { - _impl->_pendingFitPadding.reset(); - return true; + throw InvalidArgument("Viewport padding must be a finite non-negative number"); } - - // Valid extents, including points and lines, fit any positive size. - // Padding that cannot fit is reduced by Viewport::FitExtent. - if (extent.IsValid()) + const MapLayer* layer = _impl->_map.FindLayer(id); + if (layer == nullptr) { - _impl->_pendingFitPadding = paddingPixels; + _impl->_pendingFit.reset(); + return false; } - else + return _impl->Fit(layer->GetExtent(), {id, paddingPixels}); + } + + bool MapView::ZoomIn(const double factor) + { + ValidateStepFactor(factor); + return ViewChanged(_impl->_viewport.Zoom(factor)); + } + + bool MapView::ZoomOut(const double factor) + { + ValidateStepFactor(factor); + return ViewChanged(_impl->_viewport.ZoomOut(factor)); + } + + bool MapView::ZoomAt(const ScreenPoint& anchor, const double factor) + { + return ViewChanged(_impl->_viewport.ZoomAt(anchor, factor)); + } + + bool MapView::Pan(const double deltaX, const double deltaY) + { + return ViewChanged(_impl->_viewport.Pan(deltaX, deltaY)); + } + + bool MapView::ViewChanged(const bool changed) noexcept + { + if (changed) { - _impl->_pendingFitPadding.reset(); + _impl->_pendingFit.reset(); } - return false; + return changed; } const rendering::MapRenderScene& MapView::Scene() diff --git a/src/MapWinGIS.Core/src/map/Viewport.cpp b/src/MapWinGIS.Core/src/map/Viewport.cpp index 8d2fc07..50e7594 100644 --- a/src/MapWinGIS.Core/src/map/Viewport.cpp +++ b/src/MapWinGIS.Core/src/map/Viewport.cpp @@ -40,6 +40,14 @@ namespace mwg return std::clamp(resolution, Viewport::MinResolution(center), Viewport::kMaxResolution); } + void ValidateZoomFactor(const double factor) + { + if (!std::isfinite(factor) || factor <= 0.0) + { + throw InvalidArgument("Viewport zoom factor must be a finite positive number"); + } + } + // Pixels available for the extent on one axis. The extent edges map // to the centers of the outermost pixels inside the padding, which // removes one pixel from the span. At least half a pixel remains, so @@ -170,6 +178,88 @@ namespace mwg return true; } + bool Viewport::Pan(const double deltaX, const double deltaY) + { + if (!IsFinite(deltaX, deltaY)) + { + throw InvalidArgument("Viewport pan delta must be finite"); + } + if (!_hasView) + { + return false; + } + // The center is at most kMaxCoordinate, so a finite delta can only + // overflow to infinity, which the clamp maps back to the limit. + return ApplyView({_center.x + deltaX, _center.y + deltaY}, _resolution); + } + + bool Viewport::Zoom(const double factor) + { + ValidateZoomFactor(factor); + if (!_hasView) + { + return false; + } + return ApplyView(_center, _resolution / factor); + } + + bool Viewport::ZoomOut(const double factor) + { + ValidateZoomFactor(factor); + if (!_hasView) + { + return false; + } + return ApplyView(_center, _resolution * factor); + } + + bool Viewport::ZoomAt(const ScreenPoint& anchor, const double factor) + { + ValidateZoomFactor(factor); + if (!IsFinite(anchor.x, anchor.y)) + { + throw InvalidArgument("Viewport zoom anchor must be finite"); + } + const std::optional anchored = ScreenToMap(anchor); + if (!anchored.has_value()) + { + return false; + } + + // Resolve the resolution first, then place the center so that the + // anchored map point lands on the anchor at the new resolution. + const double resolution = ClampResolution(_resolution / factor, _center); + if (resolution == _resolution) + { + // At the limit: recomputing the center would only add rounding. + return false; + } + const double offsetX = anchor.x - static_cast(_width) / 2.0; + const double offsetY = anchor.y - static_cast(_height) / 2.0; + const MapPoint center{ + anchored->x - offsetX * resolution, + anchored->y + offsetY * resolution}; + if (!IsFinite(center.x, center.y)) + { + return false; + } + return ApplyView(center, resolution); + } + + bool Viewport::ApplyView(const MapPoint& center, const double resolution) noexcept + { + const MapPoint clampedCenter = ClampCenter(center); + const double clampedResolution = ClampResolution(resolution, clampedCenter); + if (clampedCenter.x == _center.x && clampedCenter.y == _center.y && + clampedResolution == _resolution) + { + return false; + } + _center = clampedCenter; + _resolution = clampedResolution; + return true; + } + Extent Viewport::VisibleExtent() const noexcept { if (!IsValid()) diff --git a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp index 0348139..71647e4 100644 --- a/tests/MapWinGIS.Core.Tests/MapViewTests.cpp +++ b/tests/MapWinGIS.Core.Tests/MapViewTests.cpp @@ -2,14 +2,17 @@ /** * @file MapViewTests.cpp - * @brief Tests the map view: shared viewport, scene invalidation, and resizing. + * @brief Tests the map view: shared viewport, scene invalidation, resizing, + * and programmatic navigation. * * Verifies that layers with different extents are shown through one * viewport, that map changes rebuild the render scene and report a redraw, - * and that resizing keeps the view stable without a window or ActiveX. + * that resizing keeps the view stable, and that zoom-to-map, zoom-to-layer, + * zoom in and out, anchored zoom, and pan work without a window or ActiveX. */ #include +#include #include #include #include @@ -270,6 +273,388 @@ TEST_F(MapViewTest, ClearingLayersKeepsTheView) EXPECT_FALSE(_view.FitToMap()); } +TEST_F(MapViewTest, ZoomToMapWithOneLayerCentersAndFitsIt) +{ + ASSERT_TRUE(_view.Resize(201, 101)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + ASSERT_TRUE(_view.ZoomIn(8.0)); + ASSERT_TRUE(_view.Pan(100.0, 100.0)); + + ASSERT_TRUE(_view.FitToMap(0.0)); + + const Viewport& viewport = _view.GetViewport(); + EXPECT_NEAR(viewport.Center()->x, 5.0, kTolerance); + EXPECT_NEAR(viewport.Center()->y, 2.5, kTolerance); + // 10 x 5 map units over 200 x 100 pixel-center spans: both axes fit exactly. + EXPECT_NEAR(*viewport.Resolution(), 0.05, kTolerance); +} + +TEST_F(MapViewTest, ZoomToMapWithMultipleLayersShowsAllOfThem) +{ + ASSERT_TRUE(_view.Resize(300, 300)); + const Extent first{0.0, 0.0, 10.0, 10.0}; + const Extent second{-50.0, 20.0, -40.0, 30.0}; + const Extent third{200.0, -100.0, 210.0, -90.0}; + _view.AddLayer(OpenRectangles({first})); + _view.AddLayer(OpenRectangles({second})); + _view.AddLayer(OpenRectangles({third})); + + ASSERT_TRUE(_view.FitToMap()); + + const Viewport& viewport = _view.GetViewport(); + for (const Extent& extent : {first, second, third}) + { + EXPECT_TRUE(IsVisible(viewport, extent)); + } + EXPECT_NEAR(viewport.Center()->x, 80.0, kTolerance); + EXPECT_NEAR(viewport.Center()->y, -35.0, kTolerance); +} + +TEST_F(MapViewTest, ZoomToMapOnAnEmptyMapKeepsTheView) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + EXPECT_FALSE(_view.FitToMap()); + EXPECT_FALSE(_view.GetViewport().HasView()); + + const LayerId id = _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + ASSERT_TRUE(_view.SetLayerVisible(id, false)); + const MapPoint center = *_view.GetViewport().Center(); + + // Only hidden layers: no visible extent, so the view stays. + EXPECT_FALSE(_view.FitToMap()); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); +} + +TEST_F(MapViewTest, ZoomToLayerFitsLargeAndSmallLayers) +{ + ASSERT_TRUE(_view.Resize(400, 200)); + const Extent large{-1000.0, -500.0, 1000.0, 500.0}; + const Extent small{10.0, 10.0, 10.5, 10.25}; + const LayerId largeId = _view.AddLayer(OpenRectangles({large})); + const LayerId smallId = _view.AddLayer(OpenRectangles({small})); + + ASSERT_TRUE(_view.ZoomToLayer(smallId, 0.0)); + const Viewport& viewport = _view.GetViewport(); + EXPECT_NEAR(viewport.Center()->x, 10.25, kTolerance); + EXPECT_NEAR(viewport.Center()->y, 10.125, kTolerance); + EXPECT_TRUE(IsVisible(viewport, small)); + EXPECT_FALSE(IsVisible(viewport, large)); + const double smallResolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.ZoomToLayer(largeId, 0.0)); + EXPECT_NEAR(viewport.Center()->x, 0.0, kTolerance); + EXPECT_NEAR(viewport.Center()->y, 0.0, kTolerance); + EXPECT_TRUE(IsVisible(viewport, large)); + EXPECT_NEAR(*viewport.Resolution() / smallResolution, 4000.0, 1e-6); +} + +TEST_F(MapViewTest, ZoomToLayerAppliesPaddingAndFitsHiddenLayers) +{ + ASSERT_TRUE(_view.Resize(201, 201)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId hidden = _view.AddLayer(OpenRectangles({{100.0, 100.0, 120.0, 120.0}})); + ASSERT_TRUE(_view.SetLayerVisible(hidden, false)); + + ASSERT_TRUE(_view.ZoomToLayer(hidden, 50.0)); + + const Viewport& viewport = _view.GetViewport(); + EXPECT_NEAR(viewport.Center()->x, 110.0, kTolerance); + // 20 map units over 200 - 2 * 50 pixels between the outer pixel centers. + EXPECT_NEAR(*viewport.Resolution(), 0.2, kTolerance); + EXPECT_THROW((void)_view.ZoomToLayer(hidden, -1.0), InvalidArgument); +} + +TEST_F(MapViewTest, ZoomToMissingOrEmptyLayerKeepsTheView) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + const LayerId id = _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const LayerId empty = _view.AddLayer(OpenRectangles({})); + const MapPoint center = *_view.GetViewport().Center(); + const double resolution = *_view.GetViewport().Resolution(); + + EXPECT_FALSE(_view.ZoomToLayer(empty)); + ASSERT_TRUE(_view.RemoveLayer(id)); + EXPECT_FALSE(_view.ZoomToLayer(id)); + EXPECT_FALSE(_view.ZoomToLayer(LayerId{})); + + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->y, center.y); + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), resolution); +} + +TEST_F(MapViewTest, ZoomToLayerBeforeTheViewportHasASizeHappensOnFirstResize) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 100.0, 100.0}})); + const LayerId target = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + + EXPECT_FALSE(_view.FitToMap(20.0)); + EXPECT_FALSE(_view.ZoomToLayer(target, 0.0)); + ASSERT_TRUE(_view.Resize(101, 101)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 50.5, kTolerance); + EXPECT_NEAR(*_view.GetViewport().Resolution(), 0.01, kTolerance); +} + +TEST_F(MapViewTest, FitToMapReplacesAPendingZoomToLayer) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 100.0, 100.0}})); + const LayerId target = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + EXPECT_FALSE(_view.ZoomToLayer(target, 20.0)); + EXPECT_FALSE(_view.FitToMap(0.0)); + + ASSERT_TRUE(_view.Resize(101, 101)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 50.0, kTolerance); + EXPECT_NEAR(_view.GetViewport().Center()->y, 50.0, kTolerance); + EXPECT_NEAR(*_view.GetViewport().Resolution(), 1.0, kTolerance); +} + +TEST_F(MapViewTest, ZoomToMissingLayerCancelsAPendingMapFit) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId removed = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + ASSERT_TRUE(_view.RemoveLayer(removed)); + EXPECT_FALSE(_view.FitToMap()); + EXPECT_FALSE(_view.ZoomToLayer(removed)); + + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_FALSE(_view.GetViewport().HasView()); + EXPECT_FALSE(_view.Resize(200, 100)); +} + +TEST_F(MapViewTest, ZoomToEmptyLayerCancelsAPendingMapFit) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId empty = _view.AddLayer(OpenRectangles({})); + EXPECT_FALSE(_view.FitToMap()); + EXPECT_FALSE(_view.ZoomToLayer(empty)); + + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_FALSE(_view.GetViewport().HasView()); +} + +TEST_F(MapViewTest, PendingZoomToRemovedLayerIsDropped) +{ + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 10.0}})); + const LayerId target = _view.AddLayer(OpenRectangles({{50.0, 50.0, 51.0, 51.0}})); + EXPECT_FALSE(_view.ZoomToLayer(target)); + ASSERT_TRUE(_view.RemoveLayer(target)); + + ASSERT_TRUE(_view.Resize(200, 100)); + + // The removed layer cannot be fitted, and no fit stays pending. + EXPECT_FALSE(_view.GetViewport().HasView()); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_TRUE(_view.Resize(200, 100)); + EXPECT_FALSE(_view.GetViewport().HasView()); +} + +TEST_F(MapViewTest, ZoomInAndOutUseTheFactorAndKeepTheCenter) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const Viewport& viewport = _view.GetViewport(); + const MapPoint center = *viewport.Center(); + const double resolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.ZoomIn()); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / MapView::kDefaultZoomFactor); + ASSERT_TRUE(_view.ZoomIn(4.0)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / 8.0); + ASSERT_TRUE(_view.ZoomOut(1.5)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / 8.0 * 1.5); + ASSERT_TRUE(_view.ZoomOut()); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution / 8.0 * 1.5 * 2.0); + + EXPECT_DOUBLE_EQ(viewport.Center()->x, center.x); + EXPECT_DOUBLE_EQ(viewport.Center()->y, center.y); +} + +TEST_F(MapViewTest, ZoomInAndOutRejectFactorsThatDoNotZoom) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const double resolution = *_view.GetViewport().Resolution(); + + for (const double factor : {1.0, 0.5, 0.0, -2.0, std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity()}) + { + EXPECT_THROW((void)_view.ZoomIn(factor), InvalidArgument) << factor; + EXPECT_THROW((void)_view.ZoomOut(factor), InvalidArgument) << factor; + } + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), resolution); +} + +TEST_F(MapViewTest, ZoomOutMultipliesWithoutTakingAReciprocal) +{ + ASSERT_TRUE(_view.Resize(101, 101)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 30.0, 30.0}})); + ASSERT_TRUE(_view.FitToMap(0.0)); + const double resolution = *_view.GetViewport().Resolution(); + + ASSERT_TRUE(_view.ZoomOut(3.0)); + + EXPECT_EQ(*_view.GetViewport().Resolution(), resolution * 3.0); +} + +TEST_F(MapViewTest, ZoomInStopsAtTheLimitAndReportsNoRedraw) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + + int steps = 0; + while (_view.ZoomIn(1.0e6)) + { + ASSERT_LT(++steps, 100); + } + EXPECT_DOUBLE_EQ(*_view.GetViewport().Resolution(), + Viewport::MinResolution(*_view.GetViewport().Center())); + EXPECT_FALSE(_view.ZoomIn()); + EXPECT_TRUE(_view.ZoomOut()); +} + +TEST_F(MapViewTest, NavigationWithoutAViewReportsNoChange) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + EXPECT_FALSE(_view.ZoomIn()); + EXPECT_FALSE(_view.ZoomOut()); + EXPECT_FALSE(_view.ZoomAt({10.0, 10.0}, 2.0)); + EXPECT_FALSE(_view.Pan(1.0, 1.0)); + EXPECT_FALSE(_view.GetViewport().HasView()); +} + +TEST_F(MapViewTest, ZoomAtKeepsTheMapCoordinateUnderTheCursor) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const Viewport& viewport = _view.GetViewport(); + const ScreenPoint cursor{37.5, 81.25}; + const MapPoint before = *viewport.ScreenToMap(cursor); + const double resolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.ZoomAt(cursor, 2.0)); + EXPECT_NEAR(*viewport.Resolution(), resolution / 2.0, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->x, before.x, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->y, before.y, kTolerance); + + ASSERT_TRUE(_view.ZoomAt(cursor, 0.25)); + EXPECT_NEAR(*viewport.Resolution(), resolution * 2.0, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->x, before.x, kTolerance); + EXPECT_NEAR(viewport.ScreenToMap(cursor)->y, before.y, kTolerance); +} + +TEST_F(MapViewTest, PanMovesTheViewByTheMapDelta) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + const Viewport& viewport = _view.GetViewport(); + const Extent before = viewport.VisibleExtent(); + const ScreenPoint origin = *viewport.MapToScreen({0.0, 0.0}); + const double resolution = *viewport.Resolution(); + + ASSERT_TRUE(_view.Pan(-3.0, 2.0)); + + const Extent after = viewport.VisibleExtent(); + EXPECT_NEAR(after.xMin, before.xMin - 3.0, kTolerance); + EXPECT_NEAR(after.yMax, before.yMax + 2.0, kTolerance); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), resolution); + // Panning west moves geometry right; panning north moves it down. + const ScreenPoint moved = *viewport.MapToScreen({0.0, 0.0}); + EXPECT_NEAR(moved.x - origin.x, 3.0 / resolution, 1e-6); + EXPECT_NEAR(moved.y - origin.y, 2.0 / resolution, 1e-6); + EXPECT_FALSE(_view.Pan(0.0, 0.0)); + EXPECT_THROW((void)_view.Pan(std::numeric_limits::quiet_NaN(), 0.0), InvalidArgument); +} + +TEST_F(MapViewTest, NavigationCancelsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap()); + + ASSERT_TRUE(_view.Pan(1.0, 0.0)); + const MapPoint center = *_view.GetViewport().Center(); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_DOUBLE_EQ(_view.GetViewport().Center()->x, center.x); + EXPECT_FALSE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); +} + +TEST_F(MapViewTest, ZoomInCancelsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap()); + + ASSERT_TRUE(_view.ZoomIn()); + const Viewport before = _view.GetViewport(); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_EQ(_view.GetViewport().Center()->x, before.Center()->x); + EXPECT_EQ(_view.GetViewport().Center()->y, before.Center()->y); + EXPECT_EQ(_view.GetViewport().Resolution(), before.Resolution()); +} + +TEST_F(MapViewTest, ZoomOutCancelsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap()); + + ASSERT_TRUE(_view.ZoomOut()); + const Viewport before = _view.GetViewport(); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_EQ(_view.GetViewport().Center()->x, before.Center()->x); + EXPECT_EQ(_view.GetViewport().Center()->y, before.Center()->y); + EXPECT_EQ(_view.GetViewport().Resolution(), before.Resolution()); +} + +TEST_F(MapViewTest, ZoomAtOnAZeroSizedViewportKeepsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap(0.0)); + const Viewport before = _view.GetViewport(); + + EXPECT_FALSE(_view.ZoomAt({37.5, 81.25}, 2.0)); + EXPECT_EQ(_view.GetViewport().Center()->x, before.Center()->x); + EXPECT_EQ(_view.GetViewport().Center()->y, before.Center()->y); + EXPECT_EQ(_view.GetViewport().Resolution(), before.Resolution()); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 55.0, kTolerance); + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); +} + +TEST_F(MapViewTest, CenterZoomAtAResolutionLimitKeepsAPendingFit) +{ + ASSERT_TRUE(_view.Resize(200, 100)); + _view.AddLayer(OpenRectangles({{0.0, 0.0, 10.0, 5.0}})); + _view.AddLayer(OpenRectangles({{100.0, 0.0, 110.0, 5.0}})); + ASSERT_TRUE(_view.ZoomOut(1.0e300)); + ASSERT_TRUE(_view.Resize(0, 0)); + EXPECT_FALSE(_view.FitToMap(0.0)); + + EXPECT_FALSE(_view.ZoomOut()); + EXPECT_EQ(*_view.GetViewport().Resolution(), Viewport::kMaxResolution); + ASSERT_TRUE(_view.Resize(200, 100)); + + EXPECT_NEAR(_view.GetViewport().Center()->x, 55.0, kTolerance); + EXPECT_TRUE(IsVisible(_view.GetViewport(), {0.0, 0.0, 110.0, 5.0})); + EXPECT_LT(*_view.GetViewport().Resolution(), Viewport::kMaxResolution); +} + TEST_F(MapViewTest, InvalidLayerIndexLeavesTheMapUnchanged) { EXPECT_THROW((void)_view.AddLayer(OpenRectangles({{0.0, 0.0, 1.0, 1.0}}), 1), InvalidArgument); diff --git a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp index 0eb24ef..361e31e 100644 --- a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp +++ b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp @@ -2,11 +2,12 @@ /** * @file ViewportTests.cpp - * @brief Tests viewport coordinate transformations and extent fitting. + * @brief Tests viewport coordinate transformations, extent fitting, and + * pan and zoom calculations. * * Verifies map-to-screen and screen-to-map conversions, aspect-preserving - * extent fitting with padding, limits, and the handling of zero-sized - * viewports, degenerate extents, and non-finite inputs. + * extent fitting with padding, pan, center and anchored zoom, limits, and the + * handling of zero-sized viewports, degenerate extents, and non-finite inputs. */ #include @@ -530,6 +531,205 @@ TEST(ToPixel, RoundsDownToTheContainingPixel) EXPECT_EQ(negative->y, -1); } +TEST(Viewport, PanShiftsTheVisibleExtentByTheMapDelta) +{ + Viewport viewport = MakeFittedViewport(); + const Extent before = viewport.VisibleExtent(); + + ASSERT_TRUE(viewport.Pan(10.0, -5.0)); + + ExpectMap(viewport.Center(), 60, 20); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); + ExpectExtent(viewport.VisibleExtent(), + {before.xMin + 10.0, before.yMin - 5.0, before.xMax + 10.0, before.yMax - 5.0}); + // Content moves the opposite way on screen: a pan east moves geometry + // left, a pan south moves geometry up. + ExpectScreen(viewport.MapToScreen({50, 25}), 40.5, 20.5); +} + +TEST(Viewport, PanWithoutViewOrWithZeroDeltaChangesNothing) +{ + Viewport noView(100, 100); + EXPECT_FALSE(noView.Pan(1.0, 1.0)); + EXPECT_FALSE(noView.HasView()); + + Viewport viewport = MakeFittedViewport(); + EXPECT_FALSE(viewport.Pan(0.0, 0.0)); + ExpectMap(viewport.Center(), 50, 25); +} + +TEST(Viewport, PanWorksOnAZeroSizedViewport) +{ + Viewport viewport = MakeFittedViewport(); + viewport.SetSize(0, 0); + + ASSERT_TRUE(viewport.Pan(-50.0, 0.0)); + + ExpectMap(viewport.Center(), 0, 25); +} + +TEST(Viewport, PanRejectsNonFiniteDeltasAndClampsTheCenter) +{ + Viewport viewport = MakeFittedViewport(); + EXPECT_THROW((void)viewport.Pan(kNaN, 0.0), InvalidArgument); + EXPECT_THROW((void)viewport.Pan(0.0, kInfinity), InvalidArgument); + ExpectMap(viewport.Center(), 50, 25); + + ASSERT_TRUE(viewport.Pan(std::numeric_limits::max(), -std::numeric_limits::max())); + EXPECT_DOUBLE_EQ(viewport.Center()->x, Viewport::kMaxCoordinate); + EXPECT_DOUBLE_EQ(viewport.Center()->y, -Viewport::kMaxCoordinate); + EXPECT_FALSE(viewport.Pan(1.0e6, -1.0e6)); +} + +TEST(Viewport, ZoomDividesTheResolutionAndKeepsTheCenter) +{ + Viewport viewport = MakeFittedViewport(); + + ASSERT_TRUE(viewport.Zoom(2.0)); + ExpectMap(viewport.Center(), 50, 25); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 0.5); + EXPECT_NEAR(viewport.VisibleExtent().Width(), 50.5, kTolerance); + + ASSERT_TRUE(viewport.Zoom(0.25)); + ExpectMap(viewport.Center(), 50, 25); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 2.0); +} + +TEST(Viewport, ZoomInThenOutRestoresTheView) +{ + Viewport viewport = MakeFittedViewport(); + const Extent before = viewport.VisibleExtent(); + + ASSERT_TRUE(viewport.Zoom(4.0)); + ASSERT_TRUE(viewport.Zoom(0.25)); + + ExpectExtent(viewport.VisibleExtent(), before); +} + +TEST(Viewport, ZoomRejectsInvalidFactors) +{ + Viewport viewport = MakeFittedViewport(); + for (const double factor : {0.0, -2.0, kNaN, kInfinity}) + { + EXPECT_THROW((void)viewport.Zoom(factor), InvalidArgument) << factor; + EXPECT_THROW((void)viewport.ZoomOut(factor), InvalidArgument) << factor; + EXPECT_THROW((void)viewport.ZoomAt({0, 0}, factor), InvalidArgument) << factor; + } + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); +} + +TEST(Viewport, ZoomStopsAtTheResolutionLimits) +{ + Viewport viewport = MakeFittedViewport(); + const MapPoint center = *viewport.Center(); + + ASSERT_TRUE(viewport.Zoom(1.0e300)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::MinResolution(center)); + EXPECT_FALSE(viewport.Zoom(2.0)); + + ASSERT_TRUE(viewport.Zoom(1.0e-300)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMaxResolution); + EXPECT_FALSE(viewport.Zoom(0.5)); + ExpectMap(viewport.Center(), center.x, center.y); +} + +TEST(Viewport, ZoomWithoutViewChangesNothing) +{ + Viewport viewport(100, 100); + EXPECT_FALSE(viewport.Zoom(2.0)); + EXPECT_FALSE(viewport.ZoomOut(2.0)); + EXPECT_FALSE(viewport.ZoomAt({10, 10}, 2.0)); + EXPECT_FALSE(viewport.HasView()); +} + +TEST(Viewport, ZoomOutMultipliesTheResolutionAndClampsOverflow) +{ + Viewport viewport(100, 100); + viewport.SetView({5.0, 10.0}, 0.3); + + ASSERT_TRUE(viewport.ZoomOut(3.0)); + EXPECT_EQ(*viewport.Resolution(), 0.3 * 3.0); + ExpectMap(viewport.Center(), 5.0, 10.0); + + ASSERT_TRUE(viewport.ZoomOut(0.5)); + EXPECT_EQ(*viewport.Resolution(), 0.3 * 3.0 * 0.5); + ASSERT_TRUE(viewport.ZoomOut(std::numeric_limits::max())); + EXPECT_EQ(*viewport.Resolution(), Viewport::kMaxResolution); + EXPECT_FALSE(viewport.ZoomOut(2.0)); + + ASSERT_TRUE(viewport.ZoomOut(1.0e-300)); + EXPECT_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); + EXPECT_FALSE(viewport.ZoomOut(0.5)); +} + +TEST(Viewport, ZoomAtKeepsTheMapCoordinateUnderTheAnchor) +{ + const ScreenPoint anchors[] = {{0.0, 0.0}, {100.25, 3.75}, {50.5, 25.5}, {-30.0, 200.0}}; + for (const ScreenPoint& anchor : anchors) + { + for (const double factor : {2.0, 0.5, 3.3}) + { + Viewport viewport = MakeFittedViewport(); + const std::optional before = viewport.ScreenToMap(anchor); + ASSERT_TRUE(before.has_value()); + + ASSERT_TRUE(viewport.ZoomAt(anchor, factor)); + + EXPECT_NEAR(*viewport.Resolution(), 1.0 / factor, kTolerance); + ExpectMap(viewport.ScreenToMap(anchor), before->x, before->y); + ExpectScreen(viewport.MapToScreen(*before), anchor.x, anchor.y); + } + } +} + +TEST(Viewport, ZoomAtTheCenterMatchesCenterZoom) +{ + Viewport anchored = MakeFittedViewport(); + Viewport centered = MakeFittedViewport(); + + ASSERT_TRUE(anchored.ZoomAt({50.5, 25.5}, 2.0)); + ASSERT_TRUE(centered.Zoom(2.0)); + + ExpectExtent(anchored.VisibleExtent(), centered.VisibleExtent()); +} + +TEST(Viewport, ZoomAtNeedsAValidViewportAndAFiniteAnchor) +{ + Viewport viewport = MakeFittedViewport(); + EXPECT_THROW((void)viewport.ZoomAt({kNaN, 0}, 2.0), InvalidArgument); + EXPECT_THROW((void)viewport.ZoomAt({0, -kInfinity}, 2.0), InvalidArgument); + + viewport.SetSize(0, 10); + EXPECT_FALSE(viewport.ZoomAt({0, 0}, 2.0)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0); +} + +TEST(Viewport, ZoomAtStopsAtTheResolutionLimit) +{ + Viewport viewport = MakeFittedViewport(); + ASSERT_TRUE(viewport.ZoomAt({0, 0}, 1.0e300)); + EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); + EXPECT_FALSE(viewport.ZoomAt({0, 0}, 2.0)); +} + +TEST(Viewport, ZoomAtCanMoveTheAnchorWhenTheNewCenterRaisesTheMinimumResolution) +{ + Viewport viewport(Viewport::kMaxDimension, 100); + viewport.SetView({Viewport::kMaxCoordinate - 1.0e8, 0.0}, 200.0); + const ScreenPoint anchor{static_cast(Viewport::kMaxDimension), 50.0}; + const MapPoint before = *viewport.ScreenToMap(anchor); + const double oldMinimum = Viewport::MinResolution(*viewport.Center()); + + ASSERT_TRUE(viewport.ZoomAt(anchor, 3.0)); + + EXPECT_LT(viewport.Center()->x, Viewport::kMaxCoordinate); + EXPECT_GT(*viewport.Resolution(), oldMinimum); + EXPECT_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center())); + const MapPoint after = *viewport.ScreenToMap(anchor); + EXPECT_GT(after.x - before.x, 1.0); + EXPECT_EQ(after.y, before.y); +} + TEST(ToPixel, RejectsNonFiniteAndOutOfRangeCoordinates) { EXPECT_FALSE(ToPixel({kNaN, 0}).has_value());