diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 75c2e16..11237e5 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -138,6 +138,42 @@ state; hosts and renderers read from it.
The map does not reproject; layers in different coordinate reference
systems do not align until coordinate transformations are implemented.
+## Viewport
+
+`mwg::Viewport` (`mapwingis/map/Viewport.h`) is the view of map coordinates on
+a pixel area. It converts between map and screen coordinates and fits an
+extent, typically `Map::GetExtent()`, into the display area. It does not
+reference a `Map` and holds no windowing, COM, or rendering state. It is a
+small copyable value type, like `Extent`, so renderers and navigation code can
+take snapshots.
+
+- **Coordinates.** Map X increases right and map Y increases up. Screen X
+ increases right and screen Y increases down, in pixels from the top-left
+ corner. Screen coordinates are continuous `double` values: pixel `(i, j)`
+ covers `[i, i + 1) x [j, j + 1)`. `ToPixel` rounds down to the containing
+ pixel; round only there.
+- **State.** A size in pixels plus, once set, a view: the map point at the
+ viewport center and one resolution (map units per pixel) for both axes. The
+ visible extent is derived from these, so it always has the viewport's
+ aspect ratio and geometry is never distorted. Resizing keeps the center and
+ resolution.
+- **Validity.** A viewport is valid when both dimensions are positive and it
+ has a view. On an invalid viewport, transformations return `std::nullopt`
+ and `VisibleExtent` returns an empty `Extent`. A zero-sized viewport keeps
+ its view. Non-finite inputs or results give `std::nullopt`; negative sizes,
+ non-finite views, and invalid padding throw `InvalidArgument`.
+- **Fitting.** `FitExtent` centers the extent and picks the smallest
+ resolution that shows it on both axes, so the other axis expands. Padding
+ is kept free on every side. Extent edges map to the centers of the
+ outermost pixels inside the padding, so pixel rounding never pushes
+ boundary geometry outside the viewport. A zero-span axis does not constrain
+ the resolution; a single point keeps the current resolution. Invalid
+ extents and zero-sized viewports return `false` without changing the view.
+- **Limits.** Sizes are limited to `kMaxDimension` pixels. Centers are clamped
+ 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.
+
## ActiveX and rendering architecture
The first ActiveX and rendering vertical slice is implemented across the
diff --git a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj
index 2f859bd..6ff648d 100644
--- a/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj
+++ b/src/MapWinGIS.Core/MapWinGIS.Core.vcxproj
@@ -61,6 +61,7 @@
+
@@ -79,6 +80,7 @@
+
diff --git a/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h
new file mode 100644
index 0000000..b6bbc42
--- /dev/null
+++ b/src/MapWinGIS.Core/include/mapwingis/map/Viewport.h
@@ -0,0 +1,188 @@
+// SPDX-License-Identifier: MPL-2.0
+
+/**
+ * @file Viewport.h
+ * @brief Declares the viewport: the view of map coordinates on a pixel area.
+ *
+ * The viewport converts between map coordinates and screen coordinates and
+ * fits a map extent into the available display area. It holds no windowing,
+ * COM, or rendering-backend state, and it does not reference a Map: callers
+ * fit the viewport to Map::GetExtent and keep the two side by side.
+ *
+ * Coordinate conventions:
+ * - Map coordinates: X increases to the right, Y increases upwards, in the
+ * units of the layers' coordinate system.
+ * - Screen coordinates: X increases to the right, Y increases downwards, in
+ * pixels, with the origin at the top-left corner of the viewport. Screen
+ * coordinates are continuous: pixel (i, j) covers [i, i + 1) x [j, j + 1),
+ * so its center is at (i + 0.5, j + 0.5) and the viewport covers
+ * [0, Width()] x [0, Height()].
+ */
+#pragma once
+
+#include
+
+#include "mapwingis/core/Api.h"
+#include "mapwingis/core/Extent.h"
+
+namespace mwg
+{
+ /// A position in map coordinates (X right, Y up).
+ struct MapPoint
+ {
+ double x = 0.0;
+ double y = 0.0;
+ };
+
+ /// A position in continuous screen coordinates (X right, Y down, pixels).
+ struct ScreenPoint
+ {
+ double x = 0.0;
+ double y = 0.0;
+ };
+
+ /// The integer pixel that contains a screen position.
+ struct PixelPoint
+ {
+ int x = 0;
+ int y = 0;
+ };
+
+ /// Pixel that contains a screen position: each coordinate is rounded
+ /// down, so pixel (i, j) holds every position in [i, i + 1) x [j, j + 1).
+ /// Rounding happens only here; keep screen coordinates in double until
+ /// pixels are needed. Returns std::nullopt when a coordinate is not
+ /// finite or the pixel is outside the int range.
+ [[nodiscard]] MWG_CORE_API std::optional ToPixel(const ScreenPoint& point) noexcept;
+
+ /// The view of map coordinates on a rectangular pixel area.
+ ///
+ /// State: a size in pixels and, once set, a view given by the map
+ /// coordinate shown at the viewport center and the resolution in map
+ /// units per pixel. The same resolution applies to both axes, so the view
+ /// never distorts geometry. The visible extent follows from these values
+ /// and always has the viewport's aspect ratio.
+ ///
+ /// Validity: a default-constructed viewport has size 0 x 0 and no view.
+ /// A viewport is valid when both dimensions are positive and it has a
+ /// view. Transformations and VisibleExtent need a valid viewport; on an
+ /// invalid one they return std::nullopt or an empty Extent. A zero-sized
+ /// viewport (for example, a minimized window) keeps its view, so the view
+ /// returns when the viewport gets a size again.
+ ///
+ /// Limits: the center is clamped to +/-kMaxCoordinate on each axis and
+ /// the resolution to [MinResolution(center), kMaxResolution]. The lower
+ /// resolution bound keeps every pixel many representable doubles wide,
+ /// so screen-to-map conversions stay stable at any supported location.
+ ///
+ /// Viewport is a small copyable value type, like Extent, so render
+ /// scenes and navigation code can take snapshots of it.
+ /// Not thread-safe.
+ class MWG_CORE_API Viewport
+ {
+ public:
+ /// Largest supported width or height, in pixels.
+ static constexpr int kMaxDimension = 1 << 16;
+
+ /// Largest supported absolute center coordinate, in map units.
+ static constexpr double kMaxCoordinate = 1.0e12;
+
+ /// Largest supported resolution, in map units per pixel.
+ static constexpr double kMaxResolution = 1.0e9;
+
+ /// Smallest supported resolution relative to the center's magnitude;
+ /// see MinResolution.
+ static constexpr double kMinRelativeResolution = 1.0e-10;
+
+ /// Resolution used to fit a single point when there is no view yet.
+ static constexpr double kDefaultPointResolution = 1.0;
+
+ /// Smallest supported resolution for a center: kMinRelativeResolution
+ /// times the larger of 1 and the center's largest absolute coordinate.
+ [[nodiscard]] static double MinResolution(const MapPoint& center) noexcept;
+
+ /// Size 0 x 0 and no view.
+ Viewport() noexcept = default;
+
+ /// A viewport of this size without a view.
+ /// \throws InvalidArgument when a dimension is negative or larger than
+ /// kMaxDimension.
+ Viewport(int width, int height);
+
+ [[nodiscard]] int Width() const noexcept { return _width; }
+ [[nodiscard]] int Height() const noexcept { return _height; }
+
+ /// True when the view has been set by SetView or FitExtent.
+ [[nodiscard]] bool HasView() const noexcept { return _hasView; }
+
+ /// True when both dimensions are positive and the viewport has a view.
+ [[nodiscard]] bool IsValid() const noexcept
+ {
+ return _hasView && _width > 0 && _height > 0;
+ }
+
+ /// Map coordinate at the viewport center, or std::nullopt without a view.
+ [[nodiscard]] std::optional Center() const noexcept;
+
+ /// Map units per pixel, or std::nullopt without a view.
+ [[nodiscard]] std::optional Resolution() const noexcept;
+
+ /// Changes the size and keeps the center and resolution, so the map
+ /// stays centered and keeps its scale when the display area resizes.
+ /// \throws InvalidArgument when a dimension is negative or larger than
+ /// kMaxDimension.
+ void SetSize(int width, int height);
+
+ /// Sets the view. The center and resolution are clamped to the limits
+ /// described above.
+ /// \throws InvalidArgument when a center coordinate is not finite or
+ /// the resolution is not a finite positive number.
+ void SetView(const MapPoint& center, double resolution);
+
+ /// Removes the view; the size is kept.
+ void ClearView() noexcept;
+
+ /// Fits extent into the viewport, keeping paddingPixels free on every
+ /// side, and returns true. The extent is centered, and the resolution
+ /// is the smallest that shows the whole extent on both axes, so the
+ /// visible extent grows on the other axis to keep the aspect ratio.
+ ///
+ /// The extent edges map to the centers of the outermost pixels inside
+ /// the padding, so rounding to pixels never puts boundary geometry
+ /// outside the viewport. When the padding leaves less than half a
+ /// pixel between those centers, the extent spans half a pixel around
+ /// the viewport center.
+ ///
+ /// Degenerate extents: an axis with zero span does not constrain the
+ /// resolution. A single point is centered and keeps the current
+ /// resolution, or kDefaultPointResolution without a view. Results are
+ /// clamped to the limits; an extent larger than the limits allow is
+ /// then not fully visible.
+ ///
+ /// Returns false, and changes nothing, when the extent is not valid
+ /// (see Extent::IsValid) or a dimension is zero.
+ /// \throws InvalidArgument when paddingPixels is negative or not finite.
+ bool FitExtent(const Extent& extent, double paddingPixels = 0.0);
+
+ /// Map extent covered by the whole viewport, or an empty Extent when
+ /// the viewport is not valid.
+ [[nodiscard]] Extent VisibleExtent() const noexcept;
+
+ /// Screen position of a map point. Returns std::nullopt when the
+ /// viewport is not valid, a coordinate is not finite, or the result
+ /// is not finite. Points outside the visible extent give positions
+ /// outside the viewport.
+ [[nodiscard]] std::optional MapToScreen(const MapPoint& point) const noexcept;
+
+ /// Map position of a screen point; the inverse of MapToScreen.
+ /// Returns std::nullopt under the same conditions.
+ [[nodiscard]] std::optional ScreenToMap(const ScreenPoint& point) const noexcept;
+
+ private:
+ int _width = 0;
+ int _height = 0;
+ bool _hasView = false;
+ MapPoint _center{};
+ double _resolution = 0.0;
+ };
+}
diff --git a/src/MapWinGIS.Core/src/map/Viewport.cpp b/src/MapWinGIS.Core/src/map/Viewport.cpp
new file mode 100644
index 0000000..8d2fc07
--- /dev/null
+++ b/src/MapWinGIS.Core/src/map/Viewport.cpp
@@ -0,0 +1,220 @@
+// SPDX-License-Identifier: MPL-2.0
+
+/**
+ * @file Viewport.cpp
+ * @brief Implements viewport transformations and extent fitting.
+ */
+#include "mapwingis/map/Viewport.h"
+
+#include
+#include
+#include
+#include
+
+#include "mapwingis/core/Error.h"
+
+namespace mwg
+{
+ namespace
+ {
+ void ValidateSize(const int width, const int height)
+ {
+ if (width < 0 || height < 0 || width > Viewport::kMaxDimension ||
+ height > Viewport::kMaxDimension)
+ {
+ throw InvalidArgument(
+ "Viewport size out of range: " + std::to_string(width) + " x " +
+ std::to_string(height));
+ }
+ }
+
+ [[nodiscard]] MapPoint ClampCenter(const MapPoint& center) noexcept
+ {
+ return {
+ std::clamp(center.x, -Viewport::kMaxCoordinate, Viewport::kMaxCoordinate),
+ std::clamp(center.y, -Viewport::kMaxCoordinate, Viewport::kMaxCoordinate)};
+ }
+
+ [[nodiscard]] double ClampResolution(const double resolution, const MapPoint& center) noexcept
+ {
+ return std::clamp(resolution, Viewport::MinResolution(center), Viewport::kMaxResolution);
+ }
+
+ // 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
+ // even a one-pixel axis keeps both edges inside the pixel.
+ [[nodiscard]] double AvailablePixels(const int dimension, const double padding) noexcept
+ {
+ return std::max(static_cast(dimension) - 1.0 - 2.0 * padding, 0.5);
+ }
+
+ [[nodiscard]] bool IsFinite(const double x, const double y) noexcept
+ {
+ return std::isfinite(x) && std::isfinite(y);
+ }
+
+ [[nodiscard]] bool IsInIntRange(const double value) noexcept
+ {
+ return value >= static_cast(std::numeric_limits::min()) &&
+ value <= static_cast(std::numeric_limits::max());
+ }
+ }
+
+ std::optional ToPixel(const ScreenPoint& point) noexcept
+ {
+ if (!IsFinite(point.x, point.y))
+ {
+ return std::nullopt;
+ }
+
+ const double x = std::floor(point.x);
+ const double y = std::floor(point.y);
+ if (!IsInIntRange(x) || !IsInIntRange(y))
+ {
+ return std::nullopt;
+ }
+ return PixelPoint{static_cast(x), static_cast(y)};
+ }
+
+ double Viewport::MinResolution(const MapPoint& center) noexcept
+ {
+ const double magnitude = std::max({1.0, std::abs(center.x), std::abs(center.y)});
+ return kMinRelativeResolution * magnitude;
+ }
+
+ Viewport::Viewport(const int width, const int height)
+ {
+ SetSize(width, height);
+ }
+
+ std::optional Viewport::Center() const noexcept
+ {
+ if (!_hasView)
+ {
+ return std::nullopt;
+ }
+ return _center;
+ }
+
+ std::optional Viewport::Resolution() const noexcept
+ {
+ if (!_hasView)
+ {
+ return std::nullopt;
+ }
+ return _resolution;
+ }
+
+ void Viewport::SetSize(const int width, const int height)
+ {
+ ValidateSize(width, height);
+ _width = width;
+ _height = height;
+ }
+
+ void Viewport::SetView(const MapPoint& center, const double resolution)
+ {
+ if (!IsFinite(center.x, center.y))
+ {
+ throw InvalidArgument("Viewport center must be finite");
+ }
+ if (!std::isfinite(resolution) || resolution <= 0.0)
+ {
+ throw InvalidArgument("Viewport resolution must be a finite positive number");
+ }
+
+ _center = ClampCenter(center);
+ _resolution = ClampResolution(resolution, _center);
+ _hasView = true;
+ }
+
+ void Viewport::ClearView() noexcept
+ {
+ _hasView = false;
+ _center = {};
+ _resolution = 0.0;
+ }
+
+ bool Viewport::FitExtent(const Extent& extent, const double paddingPixels)
+ {
+ if (!std::isfinite(paddingPixels) || paddingPixels < 0.0)
+ {
+ throw InvalidArgument("Viewport padding must be a finite non-negative number");
+ }
+ if (!extent.IsValid() || _width <= 0 || _height <= 0)
+ {
+ return false;
+ }
+
+ // Halving before adding keeps the center finite for extents near the
+ // double range.
+ const MapPoint center = ClampCenter(
+ {extent.xMin / 2.0 + extent.xMax / 2.0, extent.yMin / 2.0 + extent.yMax / 2.0});
+
+ double resolution = std::max(
+ extent.Width() / AvailablePixels(_width, paddingPixels),
+ extent.Height() / AvailablePixels(_height, paddingPixels));
+ if (!std::isfinite(resolution))
+ {
+ resolution = kMaxResolution;
+ }
+ else if (resolution <= 0.0)
+ {
+ resolution = _hasView ? _resolution : kDefaultPointResolution;
+ }
+
+ _center = center;
+ _resolution = ClampResolution(resolution, center);
+ _hasView = true;
+ return true;
+ }
+
+ Extent Viewport::VisibleExtent() const noexcept
+ {
+ if (!IsValid())
+ {
+ return {};
+ }
+
+ const double halfWidth = static_cast(_width) * _resolution / 2.0;
+ const double halfHeight = static_cast(_height) * _resolution / 2.0;
+ return {
+ _center.x - halfWidth,
+ _center.y - halfHeight,
+ _center.x + halfWidth,
+ _center.y + halfHeight};
+ }
+
+ std::optional Viewport::MapToScreen(const MapPoint& point) const noexcept
+ {
+ if (!IsValid() || !IsFinite(point.x, point.y))
+ {
+ return std::nullopt;
+ }
+
+ const double x = static_cast(_width) / 2.0 + (point.x - _center.x) / _resolution;
+ const double y = static_cast(_height) / 2.0 - (point.y - _center.y) / _resolution;
+ if (!IsFinite(x, y))
+ {
+ return std::nullopt;
+ }
+ return ScreenPoint{x, y};
+ }
+
+ std::optional Viewport::ScreenToMap(const ScreenPoint& point) const noexcept
+ {
+ if (!IsValid() || !IsFinite(point.x, point.y))
+ {
+ return std::nullopt;
+ }
+
+ const double x = _center.x + (point.x - static_cast(_width) / 2.0) * _resolution;
+ const double y = _center.y - (point.y - static_cast(_height) / 2.0) * _resolution;
+ if (!IsFinite(x, y))
+ {
+ return std::nullopt;
+ }
+ return MapPoint{x, y};
+ }
+}
diff --git a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj
index 8135b50..5a561c6 100644
--- a/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj
+++ b/tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj
@@ -65,6 +65,7 @@
+
diff --git a/tests/MapWinGIS.Core.Tests/ViewportTests.cpp b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp
new file mode 100644
index 0000000..0eb24ef
--- /dev/null
+++ b/tests/MapWinGIS.Core.Tests/ViewportTests.cpp
@@ -0,0 +1,539 @@
+// SPDX-License-Identifier: MPL-2.0
+
+/**
+ * @file ViewportTests.cpp
+ * @brief Tests viewport coordinate transformations and extent fitting.
+ *
+ * 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.
+ */
+#include
+
+#include
+#include
+#include
+#include
+
+#include "mapwingis/core/Error.h"
+#include "mapwingis/map/Viewport.h"
+
+using namespace mwg;
+
+namespace
+{
+ constexpr double kNaN = std::numeric_limits::quiet_NaN();
+ constexpr double kInfinity = std::numeric_limits::infinity();
+ constexpr double kTolerance = 1e-9;
+
+ void ExpectScreen(const std::optional& actual, const double x, const double y)
+ {
+ ASSERT_TRUE(actual.has_value());
+ EXPECT_NEAR(actual->x, x, kTolerance);
+ EXPECT_NEAR(actual->y, y, kTolerance);
+ }
+
+ void ExpectMap(const std::optional& actual, const double x, const double y,
+ const double tolerance = kTolerance)
+ {
+ ASSERT_TRUE(actual.has_value());
+ EXPECT_NEAR(actual->x, x, tolerance);
+ EXPECT_NEAR(actual->y, y, tolerance);
+ }
+
+ void ExpectExtent(const Extent& actual, const Extent& expected)
+ {
+ EXPECT_NEAR(actual.xMin, expected.xMin, kTolerance);
+ EXPECT_NEAR(actual.yMin, expected.yMin, kTolerance);
+ EXPECT_NEAR(actual.xMax, expected.xMax, kTolerance);
+ EXPECT_NEAR(actual.yMax, expected.yMax, kTolerance);
+ }
+
+ /// True when the extent lies within the visible extent.
+ bool IsVisible(const Viewport& viewport, const Extent& extent)
+ {
+ const Extent visible = viewport.VisibleExtent();
+ return visible.xMin <= extent.xMin && visible.yMin <= extent.yMin &&
+ visible.xMax >= extent.xMax && visible.yMax >= extent.yMax;
+ }
+
+ /// Viewport of 101 x 51 pixels fitted to (0, 0)-(100, 50): one map unit
+ /// per pixel, with the extent edges on the outermost pixel centers.
+ Viewport MakeFittedViewport()
+ {
+ Viewport viewport(101, 51);
+ EXPECT_TRUE(viewport.FitExtent({0, 0, 100, 50}));
+ return viewport;
+ }
+}
+
+TEST(Viewport, DefaultViewportHasNoSizeAndNoView)
+{
+ const Viewport viewport;
+
+ EXPECT_EQ(viewport.Width(), 0);
+ EXPECT_EQ(viewport.Height(), 0);
+ EXPECT_FALSE(viewport.HasView());
+ EXPECT_FALSE(viewport.IsValid());
+ EXPECT_FALSE(viewport.Center().has_value());
+ EXPECT_FALSE(viewport.Resolution().has_value());
+ EXPECT_TRUE(viewport.VisibleExtent().IsEmpty());
+ EXPECT_FALSE(viewport.MapToScreen({0, 0}).has_value());
+ EXPECT_FALSE(viewport.ScreenToMap({0, 0}).has_value());
+}
+
+TEST(Viewport, SizedViewportWithoutViewIsNotValid)
+{
+ const Viewport viewport(640, 480);
+
+ EXPECT_EQ(viewport.Width(), 640);
+ EXPECT_EQ(viewport.Height(), 480);
+ EXPECT_FALSE(viewport.IsValid());
+ EXPECT_FALSE(viewport.MapToScreen({0, 0}).has_value());
+}
+
+TEST(Viewport, RejectsNegativeAndOversizedDimensions)
+{
+ EXPECT_THROW(Viewport(-1, 10), InvalidArgument);
+ EXPECT_THROW(Viewport(10, -1), InvalidArgument);
+ EXPECT_THROW(Viewport(Viewport::kMaxDimension + 1, 10), InvalidArgument);
+
+ Viewport viewport(10, 10);
+ EXPECT_THROW(viewport.SetSize(10, Viewport::kMaxDimension + 1), InvalidArgument);
+ EXPECT_EQ(viewport.Width(), 10);
+ EXPECT_EQ(viewport.Height(), 10);
+ EXPECT_NO_THROW(viewport.SetSize(Viewport::kMaxDimension, Viewport::kMaxDimension));
+}
+
+TEST(Viewport, MapsExtentCornersToOutermostPixelCenters)
+{
+ const Viewport viewport = MakeFittedViewport();
+
+ ExpectScreen(viewport.MapToScreen({0, 50}), 0.5, 0.5); // top-left
+ ExpectScreen(viewport.MapToScreen({100, 50}), 100.5, 0.5); // top-right
+ ExpectScreen(viewport.MapToScreen({0, 0}), 0.5, 50.5); // bottom-left
+ ExpectScreen(viewport.MapToScreen({100, 0}), 100.5, 50.5); // bottom-right
+}
+
+TEST(Viewport, MapsMapCenterToViewportCenter)
+{
+ const Viewport viewport = MakeFittedViewport();
+
+ ExpectMap(viewport.Center(), 50, 25);
+ ExpectScreen(viewport.MapToScreen({50, 25}), 50.5, 25.5);
+ ExpectMap(viewport.ScreenToMap({50.5, 25.5}), 50, 25);
+}
+
+TEST(Viewport, ScreenYIncreasesDownwardsWhileMapYIncreasesUpwards)
+{
+ const Viewport viewport = MakeFittedViewport();
+
+ const std::optional lower = viewport.MapToScreen({50, 10});
+ const std::optional higher = viewport.MapToScreen({50, 40});
+ ASSERT_TRUE(lower.has_value());
+ ASSERT_TRUE(higher.has_value());
+ EXPECT_GT(lower->y, higher->y);
+
+ const std::optional left = viewport.MapToScreen({10, 25});
+ const std::optional right = viewport.MapToScreen({90, 25});
+ ASSERT_TRUE(left.has_value());
+ ASSERT_TRUE(right.has_value());
+ EXPECT_LT(left->x, right->x);
+}
+
+TEST(Viewport, MapsViewportCornersToVisibleExtentCorners)
+{
+ const Viewport viewport = MakeFittedViewport();
+
+ ExpectExtent(viewport.VisibleExtent(), {-0.5, -0.5, 100.5, 50.5});
+ ExpectMap(viewport.ScreenToMap({0, 0}), -0.5, 50.5);
+ ExpectMap(viewport.ScreenToMap({101, 51}), 100.5, -0.5);
+}
+
+TEST(Viewport, KeepsFractionalScreenCoordinates)
+{
+ const Viewport viewport = MakeFittedViewport();
+
+ ExpectScreen(viewport.MapToScreen({10.25, 20.75}), 10.75, 29.75);
+}
+
+TEST(Viewport, RoundTripsMapToScreenToMap)
+{
+ const Extent extents[] = {
+ {0, 0, 100, 50},
+ {-180, -90, 180, 90},
+ {500'000, 6'000'000, 501'000, 6'000'010},
+ {-20'037'508.34, -20'037'508.34, 20'037'508.34, 20'037'508.34},
+ {0.001, 0.002, 0.003, 0.004},
+ };
+ const int sizes[][2] = {{1, 1}, {101, 51}, {800, 600}, {37, 1200}};
+
+ for (const Extent& extent : extents)
+ {
+ for (const auto& size : sizes)
+ {
+ Viewport viewport(size[0], size[1]);
+ ASSERT_TRUE(viewport.FitExtent(extent, 5.0));
+ const double tolerance = 1e-9 * std::max(1.0, std::abs(extent.xMax) + std::abs(extent.yMax));
+
+ const MapPoint points[] = {
+ {extent.xMin, extent.yMin},
+ {extent.xMax, extent.yMax},
+ {extent.CenterX(), extent.CenterY()},
+ {extent.xMin + extent.Width() / 3.0, extent.yMax - extent.Height() / 7.0},
+ {extent.xMax + extent.Width(), extent.yMin - extent.Height()},
+ };
+ for (const MapPoint& point : points)
+ {
+ const std::optional screen = viewport.MapToScreen(point);
+ ASSERT_TRUE(screen.has_value());
+ ExpectMap(viewport.ScreenToMap(*screen), point.x, point.y, tolerance);
+ }
+ }
+ }
+}
+
+TEST(Viewport, RoundTripsScreenToMapToScreen)
+{
+ const Viewport viewport = MakeFittedViewport();
+
+ for (const ScreenPoint point : {ScreenPoint{0, 0}, ScreenPoint{17.3, 42.9}, ScreenPoint{-20, 300}})
+ {
+ const std::optional map = viewport.ScreenToMap(point);
+ ASSERT_TRUE(map.has_value());
+ ExpectScreen(viewport.MapToScreen(*map), point.x, point.y);
+ }
+}
+
+TEST(Viewport, WideViewportExpandsVisibleExtentHorizontally)
+{
+ Viewport viewport(201, 51);
+ ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50}));
+
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0);
+ ExpectExtent(viewport.VisibleExtent(), {-50.5, -0.5, 150.5, 50.5});
+ ExpectScreen(viewport.MapToScreen({0, 0}), 50.5, 50.5);
+ ExpectScreen(viewport.MapToScreen({100, 50}), 150.5, 0.5);
+}
+
+TEST(Viewport, TallViewportExpandsVisibleExtentVertically)
+{
+ Viewport viewport(101, 201);
+ ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50}));
+
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0);
+ ExpectExtent(viewport.VisibleExtent(), {-0.5, -75.5, 100.5, 125.5});
+ ExpectScreen(viewport.MapToScreen({0, 0}), 0.5, 125.5);
+ ExpectScreen(viewport.MapToScreen({100, 50}), 100.5, 75.5);
+}
+
+TEST(Viewport, WideExtentIsLimitedByViewportWidth)
+{
+ Viewport viewport(101, 101);
+ ASSERT_TRUE(viewport.FitExtent({0, 0, 400, 10}));
+
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 4.0);
+ ExpectScreen(viewport.MapToScreen({0, 0}), 0.5, 51.75);
+ ExpectScreen(viewport.MapToScreen({400, 10}), 100.5, 49.25);
+}
+
+TEST(Viewport, TallExtentIsLimitedByViewportHeight)
+{
+ Viewport viewport(101, 101);
+ ASSERT_TRUE(viewport.FitExtent({0, 0, 10, 400}));
+
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 4.0);
+ ExpectScreen(viewport.MapToScreen({0, 0}), 49.25, 100.5);
+ ExpectScreen(viewport.MapToScreen({10, 400}), 51.75, 0.5);
+}
+
+TEST(Viewport, PreservesAspectRatioForAllFits)
+{
+ const Extent extents[] = {{0, 0, 100, 50}, {0, 0, 10, 400}, {0, 0, 400, 10}};
+ const int sizes[][2] = {{300, 100}, {100, 300}, {256, 256}};
+
+ for (const Extent& extent : extents)
+ {
+ for (const auto& size : sizes)
+ {
+ Viewport viewport(size[0], size[1]);
+ ASSERT_TRUE(viewport.FitExtent(extent, 8.0));
+
+ // A map square stays a screen square.
+ const std::optional origin = viewport.MapToScreen({0, 0});
+ const std::optional corner = viewport.MapToScreen({5, 5});
+ ASSERT_TRUE(origin.has_value());
+ ASSERT_TRUE(corner.has_value());
+ EXPECT_NEAR(corner->x - origin->x, origin->y - corner->y, kTolerance);
+
+ const Extent visible = viewport.VisibleExtent();
+ EXPECT_NEAR(visible.Width() / visible.Height(),
+ static_cast(size[0]) / size[1], kTolerance);
+ EXPECT_TRUE(IsVisible(viewport, extent));
+ }
+ }
+}
+
+TEST(Viewport, KeepsPaddingFreeOnEverySide)
+{
+ Viewport viewport(121, 71);
+ ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50}, 10.0));
+
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0);
+ ExpectScreen(viewport.MapToScreen({0, 50}), 10.5, 10.5);
+ ExpectScreen(viewport.MapToScreen({100, 0}), 110.5, 60.5);
+}
+
+TEST(Viewport, CentersExtentOnTheAxisWithSpareRoom)
+{
+ Viewport viewport(221, 71);
+ ASSERT_TRUE(viewport.FitExtent({0, 0, 100, 50}, 10.0));
+
+ // Height constrains: 50 units over 50 available pixels. The width has
+ // more than the padding to spare.
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0);
+ ExpectScreen(viewport.MapToScreen({0, 50}), 60.5, 10.5);
+ ExpectScreen(viewport.MapToScreen({100, 0}), 160.5, 60.5);
+}
+
+TEST(Viewport, PaddingLargerThanViewportLeavesHalfAPixel)
+{
+ Viewport viewport(11, 11);
+ ASSERT_TRUE(viewport.FitExtent({0, 0, 10, 10}, 100.0));
+
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 20.0);
+ ExpectMap(viewport.Center(), 5, 5);
+}
+
+TEST(Viewport, RejectsNegativeAndNonFinitePadding)
+{
+ Viewport viewport(100, 100);
+
+ EXPECT_THROW((void)viewport.FitExtent({0, 0, 1, 1}, -1.0), InvalidArgument);
+ EXPECT_THROW((void)viewport.FitExtent({0, 0, 1, 1}, kNaN), InvalidArgument);
+ EXPECT_THROW((void)viewport.FitExtent({0, 0, 1, 1}, kInfinity), InvalidArgument);
+ EXPECT_FALSE(viewport.HasView());
+}
+
+TEST(Viewport, FittedBoundaryGeometryRoundsToPixelsInsideViewport)
+{
+ const int sizes[][2] = {{1, 1}, {2, 2}, {7, 3}, {640, 480}, {1000, 17}};
+ const Extent extent{-123.456, 7.89, 987.654, 321.0};
+
+ for (const auto& size : sizes)
+ {
+ Viewport viewport(size[0], size[1]);
+ ASSERT_TRUE(viewport.FitExtent(extent));
+
+ for (const MapPoint corner : {MapPoint{extent.xMin, extent.yMin}, MapPoint{extent.xMin, extent.yMax},
+ MapPoint{extent.xMax, extent.yMin}, MapPoint{extent.xMax, extent.yMax}})
+ {
+ const std::optional screen = viewport.MapToScreen(corner);
+ ASSERT_TRUE(screen.has_value());
+ const std::optional pixel = ToPixel(*screen);
+ ASSERT_TRUE(pixel.has_value());
+ EXPECT_GE(pixel->x, 0);
+ EXPECT_GE(pixel->y, 0);
+ EXPECT_LT(pixel->x, size[0]);
+ EXPECT_LT(pixel->y, size[1]);
+ }
+ }
+}
+
+TEST(Viewport, ZeroSizedViewportCannotFitOrTransform)
+{
+ Viewport viewport(0, 100);
+
+ EXPECT_FALSE(viewport.FitExtent({0, 0, 10, 10}));
+ EXPECT_FALSE(viewport.HasView());
+
+ viewport.SetSize(100, 0);
+ EXPECT_FALSE(viewport.FitExtent({0, 0, 10, 10}));
+ EXPECT_FALSE(viewport.HasView());
+}
+
+TEST(Viewport, ZeroSizedViewportKeepsItsView)
+{
+ Viewport viewport = MakeFittedViewport();
+
+ viewport.SetSize(0, 0);
+ EXPECT_TRUE(viewport.HasView());
+ EXPECT_FALSE(viewport.IsValid());
+ EXPECT_TRUE(viewport.VisibleExtent().IsEmpty());
+ EXPECT_FALSE(viewport.MapToScreen({50, 25}).has_value());
+
+ viewport.SetSize(101, 51);
+ EXPECT_TRUE(viewport.IsValid());
+ ExpectScreen(viewport.MapToScreen({0, 50}), 0.5, 0.5);
+}
+
+TEST(Viewport, ResizeKeepsCenterAndResolution)
+{
+ Viewport viewport = MakeFittedViewport();
+
+ viewport.SetSize(301, 11);
+
+ ExpectMap(viewport.Center(), 50, 25);
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0);
+ ExpectScreen(viewport.MapToScreen({50, 25}), 150.5, 5.5);
+}
+
+TEST(Viewport, FitsHorizontalAndVerticalLines)
+{
+ Viewport viewport(101, 101);
+
+ ASSERT_TRUE(viewport.FitExtent({0, 5, 100, 5}));
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0);
+ ExpectMap(viewport.Center(), 50, 5);
+
+ ASSERT_TRUE(viewport.FitExtent({5, 0, 5, 200}));
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 2.0);
+ ExpectMap(viewport.Center(), 5, 100);
+}
+
+TEST(Viewport, FitsSinglePointWithDefaultOrCurrentResolution)
+{
+ Viewport viewport(100, 100);
+
+ ASSERT_TRUE(viewport.FitExtent({3, 4, 3, 4}));
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kDefaultPointResolution);
+ ExpectMap(viewport.Center(), 3, 4);
+
+ viewport.SetView({0, 0}, 0.25);
+ ASSERT_TRUE(viewport.FitExtent({-7, 8, -7, 8}));
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 0.25);
+ ExpectMap(viewport.Center(), -7, 8);
+ ExpectScreen(viewport.MapToScreen({-7, 8}), 50, 50);
+}
+
+TEST(Viewport, RejectsInvalidExtentsWithoutChangingTheView)
+{
+ Viewport viewport = MakeFittedViewport();
+
+ EXPECT_FALSE(viewport.FitExtent(Extent{}));
+ EXPECT_FALSE(viewport.FitExtent({10, 0, 0, 10}));
+ EXPECT_FALSE(viewport.FitExtent({kNaN, 0, 10, 10}));
+ EXPECT_FALSE(viewport.FitExtent({0, 0, kInfinity, 10}));
+ EXPECT_FALSE(viewport.FitExtent({-kInfinity, 0, 10, 10}));
+
+ ExpectMap(viewport.Center(), 50, 25);
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), 1.0);
+}
+
+TEST(Viewport, TransformationsRejectNonFiniteInputs)
+{
+ const Viewport viewport = MakeFittedViewport();
+
+ EXPECT_FALSE(viewport.MapToScreen({kNaN, 0}).has_value());
+ EXPECT_FALSE(viewport.MapToScreen({0, kInfinity}).has_value());
+ EXPECT_FALSE(viewport.MapToScreen({-kInfinity, 0}).has_value());
+ EXPECT_FALSE(viewport.ScreenToMap({kNaN, 0}).has_value());
+ EXPECT_FALSE(viewport.ScreenToMap({0, -kInfinity}).has_value());
+}
+
+TEST(Viewport, TransformationsRejectResultsThatOverflow)
+{
+ Viewport viewport(100, 100);
+ viewport.SetView({0, 0}, Viewport::MinResolution({0, 0}));
+ EXPECT_FALSE(viewport.MapToScreen({std::numeric_limits::max(), 0}).has_value());
+
+ viewport.SetView({0, 0}, Viewport::kMaxResolution);
+ EXPECT_FALSE(viewport.ScreenToMap({std::numeric_limits::max(), 0}).has_value());
+}
+
+TEST(Viewport, SetViewRejectsNonFiniteAndNonPositiveValues)
+{
+ Viewport viewport(100, 100);
+
+ EXPECT_THROW(viewport.SetView({kNaN, 0}, 1.0), InvalidArgument);
+ EXPECT_THROW(viewport.SetView({0, kInfinity}, 1.0), InvalidArgument);
+ EXPECT_THROW(viewport.SetView({0, 0}, 0.0), InvalidArgument);
+ EXPECT_THROW(viewport.SetView({0, 0}, -1.0), InvalidArgument);
+ EXPECT_THROW(viewport.SetView({0, 0}, kNaN), InvalidArgument);
+ EXPECT_THROW(viewport.SetView({0, 0}, kInfinity), InvalidArgument);
+ EXPECT_FALSE(viewport.HasView());
+}
+
+TEST(Viewport, SetViewClampsToLimits)
+{
+ Viewport viewport(100, 100);
+
+ viewport.SetView({1.0e20, -1.0e20}, 1.0e-30);
+ ExpectMap(viewport.Center(), Viewport::kMaxCoordinate, -Viewport::kMaxCoordinate);
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(),
+ Viewport::kMinRelativeResolution * Viewport::kMaxCoordinate);
+
+ viewport.SetView({0, 0}, 1.0e-30);
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMinRelativeResolution);
+
+ viewport.SetView({0, 0}, 1.0e30);
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMaxResolution);
+}
+
+TEST(Viewport, FitClampsHugeExtentsToLimits)
+{
+ Viewport viewport(100, 100);
+ const double huge = std::numeric_limits::max();
+
+ ASSERT_TRUE(viewport.FitExtent({-huge, -huge, huge, huge}));
+ ExpectMap(viewport.Center(), 0, 0);
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::kMaxResolution);
+
+ ASSERT_TRUE(viewport.FitExtent({1.0e15, 1.0e15, 1.0e15 + 1.0, 1.0e15 + 1.0}));
+ ExpectMap(viewport.Center(), Viewport::kMaxCoordinate, Viewport::kMaxCoordinate);
+ EXPECT_GE(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center()));
+}
+
+TEST(Viewport, FitClampsTinyExtentsToMinimumResolution)
+{
+ Viewport viewport(100, 100);
+
+ ASSERT_TRUE(viewport.FitExtent({1000, 1000, 1000 + 1.0e-12, 1000 + 1.0e-12}));
+ EXPECT_DOUBLE_EQ(*viewport.Resolution(), Viewport::MinResolution(*viewport.Center()));
+ EXPECT_NEAR(*viewport.Resolution(), 1.0e-7, 1.0e-12);
+ EXPECT_TRUE(IsVisible(viewport, {1000, 1000, 1000 + 1.0e-12, 1000 + 1.0e-12}));
+}
+
+TEST(Viewport, ClearViewKeepsSize)
+{
+ Viewport viewport = MakeFittedViewport();
+
+ viewport.ClearView();
+
+ EXPECT_FALSE(viewport.HasView());
+ EXPECT_FALSE(viewport.IsValid());
+ EXPECT_EQ(viewport.Width(), 101);
+ EXPECT_EQ(viewport.Height(), 51);
+}
+
+TEST(Viewport, CopiesAreIndependentSnapshots)
+{
+ Viewport viewport = MakeFittedViewport();
+ const Viewport snapshot = viewport;
+
+ viewport.SetView({0, 0}, 2.0);
+
+ ExpectMap(snapshot.Center(), 50, 25);
+ EXPECT_DOUBLE_EQ(*snapshot.Resolution(), 1.0);
+}
+
+TEST(ToPixel, RoundsDownToTheContainingPixel)
+{
+ const std::optional inside = ToPixel({10.99, 0.0});
+ ASSERT_TRUE(inside.has_value());
+ EXPECT_EQ(inside->x, 10);
+ EXPECT_EQ(inside->y, 0);
+
+ const std::optional negative = ToPixel({-0.5, -1.0});
+ ASSERT_TRUE(negative.has_value());
+ EXPECT_EQ(negative->x, -1);
+ EXPECT_EQ(negative->y, -1);
+}
+
+TEST(ToPixel, RejectsNonFiniteAndOutOfRangeCoordinates)
+{
+ EXPECT_FALSE(ToPixel({kNaN, 0}).has_value());
+ EXPECT_FALSE(ToPixel({0, kInfinity}).has_value());
+ EXPECT_FALSE(ToPixel({1.0e20, 0}).has_value());
+ EXPECT_FALSE(ToPixel({0, -1.0e20}).has_value());
+}