Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 27 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
10 changes: 10 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions src/MapWinGIS.Core/include/mapwingis/map/MapView.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ 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
{
public:
/// 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();

Expand Down Expand Up @@ -74,17 +82,58 @@ 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
/// free on every side; see Viewport::FitExtent. 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. 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
Expand All @@ -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> _impl;
};
Expand Down
42 changes: 42 additions & 0 deletions src/MapWinGIS.Core/include/mapwingis/map/Viewport.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -179,6 +217,10 @@ namespace mwg
[[nodiscard]] std::optional<MapPoint> 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;
Expand Down
110 changes: 94 additions & 16 deletions src/MapWinGIS.Core/src/map/MapView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,65 @@
*/
#include "mapwingis/map/MapView.h"

#include <cmath>
#include <optional>
#include <utility>

#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<LayerId> 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<double> _pendingFitPadding;
std::optional<PendingFit> _pendingFit;
};

MapView::MapView()
Expand Down Expand Up @@ -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()
Expand Down
Loading