From 430f45085d34576261d98ab6e558c3d7d8eaf8ec Mon Sep 17 00:00:00 2001 From: Paul Meems Date: Sat, 31 Oct 2026 18:21:45 +0100 Subject: [PATCH] Wire ActiveX navigation to MapView Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 13 +- docs/ARCHITECTURE.md | 36 +- docs/TESTING.md | 10 +- samples/CSharp/ActiveXTestHost/MainForm.cs | 238 ++++++++++ src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl | 42 +- src/MapWinGIS.ActiveX/MapWinGISControl.cpp | 481 +++++++++++++++++++- src/MapWinGIS.ActiveX/MapWinGISControl.h | 49 +- 7 files changed, 826 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index c195cf0..133cc37 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,18 @@ Early. The core currently includes GDAL setup and OGR-backed vector dataset and layer access, including feature and field reading and updates. Available formats depend on the GDAL drivers enabled in the build; tests cover shapefiles and GeoPackage. The ActiveX layer supports COM activation, vector loading, -polygon rendering, and asynchronous generic GDAL algorithms with progress and -cancellation events. +polygon rendering, basic map navigation, 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. +do not depend on a window. ActiveX exposes map/layer zoom operations and +supports cursor-anchored mouse-wheel zoom and left-button drag-to-pan. -Vector loads add layers rather than replace them. Call `ClearLayers` to -release the loaded datasets before switching or refreshing them. +Vector loads add layers rather than replace them. The first layer fits the +initial view; later loads preserve it. Call `ZoomToMap` to include new layers +in the visible extent, or `ClearLayers` to release loaded datasets before +switching or refreshing them. ## Build and test diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7052ea7..07a5ae2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -325,8 +325,10 @@ intentional compatibility gaps and the rationale when interfaces are introduced. ## Current limits and next work The control exposes a small v6 COM surface: Core initialization, vector -dataset loading, layer-loaded state, feature count, renderer preference, -active backend and status, plus asynchronous generic GDAL operations with +dataset loading, layer-loaded state, feature count, layer count and stable IDs, +layer removal, clear and visibility, map/layer zoom, and center zoom in/out. +Mouse-wheel zoom and drag-to-pan use the same `MapView`. The control also +supports renderer preferences and asynchronous generic GDAL operations with progress, cancellation, and result reporting. The GDAL API has a new interface and does not claim MapWinGIS 5 `IGdalUtils` binary compatibility. At most sixteen operations may be active or awaiting result cleanup. Call @@ -348,12 +350,19 @@ polygonal. An empty polygon layer loads but draws nothing. The control reads and validates the candidate layer before adding it, so a rejected load cannot change the live viewport or schedule a fit. `LayerLoaded` reports whether the map has any layer, and `FeatureCount` is -the cached total over all loaded layers. The control updates the total after -a successful load and resets it on `ClearLayers`. Property reads do not scan -datasets or rebuild the scene. Until the control exposes navigation, every load -fits the view to the whole map. Raster layers and other geometry types are -not rendered. There is no reprojection, so coordinates remain in the source -coordinate system and layers are assumed to share one. +the cached total over all loaded layers. The control updates the total after a +successful load or removal and resets it on `ClearLayers`; property reads do +not scan datasets or rebuild the scene. The first layer fits the initial view; +later loads preserve the current view, and callers can use `ZoomToMap` to +include new layers in the visible extent. `IMapWinGISControl4` exposes layer +count and stable layer IDs, removal, visibility, `ZoomToMap`, `ZoomToLayer`, +`ZoomIn`, and `ZoomOut`; it inherits `ClearLayers` from `IMapWinGISControl3`. +Layer reordering is not exposed because Core has no reorder operation. +Mouse-wheel zoom is anchored at the cursor. Left-button dragging pans the +view, with windowless mouse capture released on button-up, capture loss, +cancellation, or control destruction. Raster layers and other geometry types +are not rendered. There is no reprojection, so coordinates remain in the +source coordinate system and layers are assumed to share one. Core prepares and retains the complete supported polygon scene in memory, reading feature geometries sequentially without WKT serialization and @@ -370,10 +379,9 @@ 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. -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. +Core and ActiveX navigation use the shared `MapView`; the COM API exposes +programmatic zoom operations and the control handles mouse-wheel zoom and +drag-to-pan. 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 5349ccf..e3cbb41 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -35,10 +35,12 @@ pwsh -File samples\CSharp\ActiveXTestHost\Run-ActiveXTestHost.ps1 -Configuration The launcher makes no COM registration changes. Automated mode creates a visible window and runs the UI message loop, checks the `CoreInitialized` -automation property, loads a generated two-polygon GeoJSON dataset through the -control, resizes the host, samples rendered polygon and background pixels for -both the GDI preference and the default (OpenGL-first) path, recreates the -control, reloads the layer, and writes individual outcomes to +automation property, loads generated GeoJSON layers through the control, +checks the COM zoom methods and stable layer IDs, sends cursor-anchored wheel +zoom and captured drag-to-pan input, resizes the host, samples rendered +polygon and background pixels for both the GDI preference and the default +(OpenGL-first) path, recreates the control, reloads the layer, and writes +individual outcomes to `bin\Debug\x64\ActiveXTestHost-results.json`. Pixels are sampled from the host window's own composed content (`PrintWindow` with `PW_RENDERFULLCONTENT`), not from the screen, so another window on top or a lost foreground race does not diff --git a/samples/CSharp/ActiveXTestHost/MainForm.cs b/samples/CSharp/ActiveXTestHost/MainForm.cs index 2a5c537..a18e2e8 100644 --- a/samples/CSharp/ActiveXTestHost/MainForm.cs +++ b/samples/CSharp/ActiveXTestHost/MainForm.cs @@ -34,6 +34,7 @@ internal sealed class MainForm : Form private readonly string _testDatasetDirectory = Path.Combine(Path.GetTempPath(), $"MapWinGIS.ActiveXTestHost-{Guid.NewGuid():N}"); private string TestDatasetPath => Path.Combine(_testDatasetDirectory, "sample.geojson"); + private string TestSecondDatasetPath => Path.Combine(_testDatasetDirectory, "second.geojson"); private string TestRasterPath => Path.Combine(_testDatasetDirectory, "sample.asc"); private string TestRasterOutputPath => Path.Combine(_testDatasetDirectory, "sample.tif"); private ActiveXHostControl? _host; @@ -152,6 +153,8 @@ private async Task RunAutomatedAsync() _statusLabel.Text = $"Active: {ProgId}; CoreInitialized={initialized}"; }); RecordCheck("Load vector layer", LoadTestLayer); + RecordCheck("Programmatic map navigation", VerifyProgrammaticNavigation); + await RecordCheckAsync("Mouse-wheel zoom and drag-to-pan", VerifyMouseNavigationAsync); await RecordCheckAsync("GDAL COM event sinks", VerifyGdalEventSinksAsync); RecordCheck("CPU backend preference and status", VerifyCpuBackendSelection); @@ -223,6 +226,12 @@ private void VerifyPrerequisites() "The generated vector test dataset is missing.", TestDatasetPath); } + if (!File.Exists(TestSecondDatasetPath)) + { + throw new FileNotFoundException( + "The generated second vector test dataset is missing.", + TestSecondDatasetPath); + } } private void CreateTestDataset() @@ -255,6 +264,33 @@ private void CreateTestDataset() ] } """); + File.WriteAllText( + TestSecondDatasetPath, + """ + { + "type": "FeatureCollection", + "name": "second", + "crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, + "features": [ + { + "type": "Feature", + "properties": { "id": 3, "name": "west" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[100, 0], [101.5, 0], [101.5, 2], [100, 2], [100, 0]]] + } + }, + { + "type": "Feature", + "properties": { "id": 4, "name": "east" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[102.5, 0], [104, 0], [104, 2], [102.5, 2], [102.5, 0]]] + } + } + ] + } + """); var raster = new StringBuilder(); raster.AppendLine("ncols 512"); @@ -334,6 +370,68 @@ private void CreateHostedControl() Application.DoEvents(); } + private async Task WaitForRepaintAsync() + { + RepaintHost(); + await Task.Delay(150); + Application.DoEvents(); + } + + private void SendMouseWheel(Point clientPosition, short delta) + { + if (_host is null) + { + throw new InvalidOperationException("The ActiveX control has not been created."); + } + + Point screenPosition = _host.PointToScreen(clientPosition); + IntPtr wParam = new(unchecked((int)((ushort)delta << 16))); + SendMessage( + _host.Handle, + MouseWheelMessage, + wParam, + PackPosition(screenPosition.X, screenPosition.Y)); + } + + private void SendMouseMessage(uint message, nint buttonState, Point clientPosition) + { + if (_host is null) + { + throw new InvalidOperationException("The ActiveX control has not been created."); + } + + SendMessage( + _host.Handle, + message, + buttonState, + PackPosition(clientPosition.X, clientPosition.Y)); + } + + private static void VerifyImagesDiffer(Bitmap before, Bitmap after, string operation) + { + if (before.Width != after.Width || before.Height != after.Height) + { + throw new InvalidOperationException($"The host image size changed during {operation}."); + } + + int changedPixels = 0; + for (int y = 0; y < before.Height; y++) + { + for (int x = 0; x < before.Width; x++) + { + if (before.GetPixel(x, y).ToArgb() != after.GetPixel(x, y).ToArgb()) + { + changedPixels++; + } + } + } + if (changedPixels < 100) + { + throw new InvalidOperationException( + $"The {operation} did not change the rendered map enough to verify navigation."); + } + } + private bool ReadCoreInitialized() { if (_host is null) @@ -364,6 +462,136 @@ private void LoadTestLayer() VerifyCoreInitialized("vector dataset load"); } + private void VerifyProgrammaticNavigation() + { + if (_host?.AutomationObject is not object automationObject) + { + throw new InvalidOperationException("The ActiveX automation object is unavailable."); + } + + dynamic automation = automationObject; + automation.LoadVectorDataset(TestSecondDatasetPath); + if ((int)automation.LayerCount != 2 || (long)automation.FeatureCount != 4) + { + throw new InvalidOperationException( + "Loading another dataset did not add its layer while preserving the map."); + } + + long firstLayerId = (long)automation.GetLayerId(0); + long secondLayerId = (long)automation.GetLayerId(1); + if (firstLayerId <= 0 || secondLayerId <= 0 || firstLayerId == secondLayerId) + { + throw new InvalidOperationException("The map did not expose distinct stable layer IDs."); + } + + if (!(bool)automation.ZoomToMap() || + !(bool)automation.ZoomToLayer(secondLayerId) || + (bool)automation.ZoomToLayer(secondLayerId + 1000) || + !(bool)automation.ZoomToLayer(firstLayerId) || + !(bool)automation.ZoomIn() || + !(bool)automation.ZoomOut()) + { + throw new InvalidOperationException( + "The control's zoom-to-map, zoom-to-layer, zoom-in, or zoom-out operation failed."); + } + + if (!(bool)automation.ZoomToMap() || + !(bool)automation.SetLayerVisible(secondLayerId, false) || + !(bool)automation.ZoomToMap() || + !(bool)automation.SetLayerVisible(secondLayerId, true) || + !(bool)automation.ZoomToMap() || + !(bool)automation.ZoomToLayer(firstLayerId) || + !(bool)automation.RemoveLayer(secondLayerId) || + (int)automation.LayerCount != 1 || + (long)automation.FeatureCount != 2 || + (long)automation.GetLayerId(0) != firstLayerId) + { + throw new InvalidOperationException( + "The control's layer visibility or removal operation failed."); + } + + automation.ClearLayers(); + if ((int)automation.LayerCount != 0 || + (long)automation.FeatureCount != 0 || + (bool)automation.ZoomToMap()) + { + throw new InvalidOperationException("The control did not clear its layer collection."); + } + + automation.LoadVectorDataset(TestDatasetPath); + automation.LoadVectorDataset(TestSecondDatasetPath); + if ((int)automation.LayerCount != 2 || !(bool)automation.ZoomToMap()) + { + throw new InvalidOperationException( + "The control did not restore its layers after clearing the map."); + } + } + + private async Task VerifyMouseNavigationAsync() + { + if (_host?.AutomationObject is not object automationObject) + { + throw new InvalidOperationException("The ActiveX automation object is unavailable."); + } + + dynamic automation = automationObject; + long firstLayerId = (long)automation.GetLayerId(0); + + automation.ZoomToMap(); + if (!(bool)automation.ZoomToLayer(firstLayerId)) + { + throw new InvalidOperationException("Could not reset the view before testing mouse input."); + } + RepaintHost(); + using (Bitmap beforeWheel = CaptureWindowContent()) + { + SendMouseWheel(new Point(_host.Width / 5, _host.Height / 2), 240); + await WaitForRepaintAsync(); + using Bitmap afterWheel = CaptureWindowContent(); + VerifyImagesDiffer(beforeWheel, afterWheel, "centered mouse-wheel zoom"); + } + + automation.ZoomToMap(); + if (!(bool)automation.ZoomToLayer(firstLayerId)) + { + throw new InvalidOperationException("Could not reset the view before testing edge zoom."); + } + RepaintHost(); + using (Bitmap beforeEdgeWheel = CaptureWindowContent()) + { + SendMouseWheel(new Point(_host.Width * 4 / 5, _host.Height / 2), -120); + await WaitForRepaintAsync(); + using Bitmap afterEdgeWheel = CaptureWindowContent(); + VerifyImagesDiffer(beforeEdgeWheel, afterEdgeWheel, "near-edge mouse-wheel zoom"); + } + + automation.ZoomToMap(); + if (!(bool)automation.ZoomToLayer(firstLayerId)) + { + throw new InvalidOperationException("Could not reset the view before testing drag-to-pan."); + } + RepaintHost(); + int dragDistance = Math.Max(40, _host.Width / 8); + Point start = new(_host.Width / 2, _host.Height / 2); + Point end = new(start.X + dragDistance, start.Y); + SendMouseMessage(LeftButtonDown, 1, start); + SendMouseMessage(MouseMoveMessage, 1, end); + SendMouseMessage(LeftButtonUp, 0, new Point(-8, -8)); + await WaitForRepaintAsync(); + + using Bitmap afterPan = CaptureWindowContent(); + VerifyPaintPixel( + afterPan, + _host.PointToScreen(new Point(_host.Width / 5 + dragDistance, _host.Height / 2)), + ColorToColorRef(MapFillColor), + "panned polygon"); + if (!(bool)automation.ZoomToLayer(firstLayerId)) + { + throw new InvalidOperationException("Could not restore the view after testing drag-to-pan."); + } + RepaintHost(); + } + private void VerifyCoreInitialized(string activation) { if (!ReadCoreInitialized()) @@ -745,6 +973,13 @@ protected override void Dispose(bool disposing) private static uint ColorToColorRef(Color color) => (uint)(color.R | (color.G << 8) | (color.B << 16)); + private static IntPtr PackPosition(int x, int y) => + new(unchecked((int)((ushort)(short)x | ((uint)(ushort)(short)y << 16)))); + + private const uint MouseWheelMessage = 0x020A; + private const uint LeftButtonDown = 0x0201; + private const uint MouseMoveMessage = 0x0200; + private const uint LeftButtonUp = 0x0202; private const uint PrintWindowRenderFullContent = 0x00000002; [StructLayout(LayoutKind.Sequential)] @@ -767,6 +1002,9 @@ private struct NativeRect [DllImport("user32.dll")] private static extern uint GetSysColor(int index); + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr window, uint message, nint wParam, nint lParam); + private sealed record CheckResult(string Check, bool Passed, string Details); private sealed record TestReport(bool Automated, int ExitCode, IReadOnlyList Checks); } diff --git a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl index 4493e4e..dc5038f 100644 --- a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl +++ b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.idl @@ -70,6 +70,43 @@ library MapWinGISActiveXLib HRESULT ClearLayers(); }; + [ + object, + dual, + oleautomation, + uuid(EA0484AC-2A78-462C-AAE1-B0D6F11E4D7A), + pointer_default(unique) + ] + interface IMapWinGISControl4 : IMapWinGISControl3 + { + [propget, id(9)] + HRESULT LayerCount([out, retval] LONG* count); + + [id(10)] + HRESULT GetLayerId([in] LONG index, [out, retval] hyper* layerId); + + [id(11)] + HRESULT ZoomToMap([out, retval] VARIANT_BOOL* changed); + + [id(12)] + HRESULT ZoomToLayer([in] hyper layerId, [out, retval] VARIANT_BOOL* changed); + + [id(13)] + HRESULT ZoomIn([out, retval] VARIANT_BOOL* changed); + + [id(14)] + HRESULT ZoomOut([out, retval] VARIANT_BOOL* changed); + + [id(15)] + HRESULT RemoveLayer([in] hyper layerId, [out, retval] VARIANT_BOOL* changed); + + [id(16)] + HRESULT SetLayerVisible( + [in] hyper layerId, + [in] VARIANT_BOOL visible, + [out, retval] VARIANT_BOOL* changed); + }; + [ object, dual, @@ -124,7 +161,10 @@ library MapWinGISActiveXLib ] coclass MapWinGISControl { - [default] interface IMapWinGISControl3; + [default] interface IMapWinGISControl4; + interface IMapWinGISControl3; + interface IMapWinGISControl2; + interface IMapWinGISControl; interface IMapWinGISControlGdal; [default, source] dispinterface IMapWinGISControlEvents; }; diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index 6e3a5ad..97e2366 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp @@ -12,8 +12,10 @@ #include "MapWinGISControl.h" #include +#include #include +#include #include #include #include @@ -48,6 +50,18 @@ namespace errorCode); } + [[nodiscard]] HRESULT SetNavigationErrorInfo( + const std::string& message, + const HRESULT errorCode) + { + const CA2W wideMessage(message.c_str(), CP_UTF8); + return AtlReportError( + CLSID_MapWinGISControl, + wideMessage, + IID_IMapWinGISControl4, + errorCode); + } + [[nodiscard]] const wchar_t* BackendName( const std::optional backend) noexcept { @@ -292,6 +306,229 @@ STDMETHODIMP CMapWinGISControl::get_CoreInitialized(VARIANT_BOOL* initialized) return S_OK; } +STDMETHODIMP CMapWinGISControl::get_LayerCount(LONG* count) +{ + if (count == nullptr) + { + return E_POINTER; + } + + *count = _view.GetMap().LayerCount(); + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::GetLayerId( + const LONG index, + LONGLONG* layerId) +{ + if (layerId == nullptr) + { + return E_POINTER; + } + *layerId = 0; + + const mwg::Map& map = _view.GetMap(); + if (index < 0 || index >= map.LayerCount()) + { + return E_INVALIDARG; + } + + const std::uint64_t value = map.Layer(index).Id().value; + if (value > static_cast(std::numeric_limits::max())) + { + return E_FAIL; + } + *layerId = static_cast(value); + return S_OK; +} + +STDMETHODIMP CMapWinGISControl::ZoomToMap(VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + + try + { + if (_view.FitToMap()) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_FAIL); + } +} + +STDMETHODIMP CMapWinGISControl::ZoomToLayer( + const LONGLONG layerId, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + if (layerId <= 0) + { + return E_INVALIDARG; + } + + try + { + if (_view.ZoomToLayer( + mwg::LayerId{static_cast(layerId)})) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_FAIL); + } +} + +STDMETHODIMP CMapWinGISControl::ZoomIn(VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + + try + { + if (_view.ZoomIn()) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_FAIL); + } +} + +STDMETHODIMP CMapWinGISControl::ZoomOut(VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + + try + { + if (_view.ZoomOut()) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_FAIL); + } +} + +STDMETHODIMP CMapWinGISControl::RemoveLayer( + const LONGLONG layerId, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + if (layerId <= 0) + { + return E_INVALIDARG; + } + + try + { + const mwg::LayerId id{static_cast(layerId)}; + const mwg::MapLayer* layer = _view.GetMap().FindLayer(id); + if (layer == nullptr) + { + return S_OK; + } + + const LONGLONG featureCount = static_cast( + layer->Dataset().Layer(layer->SourceLayerIndex()).FeatureCount()); + if (featureCount < 0 || featureCount > _featureCount) + { + return SetNavigationErrorInfo("Layer feature count is out of range", E_FAIL); + } + if (_view.RemoveLayer(id)) + { + _featureCount -= featureCount; + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + return SetNavigationErrorInfo(exception.what(), E_FAIL); + } +} + +STDMETHODIMP CMapWinGISControl::SetLayerVisible( + const LONGLONG layerId, + const VARIANT_BOOL visible, + VARIANT_BOOL* changed) +{ + if (changed == nullptr) + { + return E_POINTER; + } + *changed = VARIANT_FALSE; + if (layerId <= 0) + { + return E_INVALIDARG; + } + + if (_view.SetLayerVisible( + mwg::LayerId{static_cast(layerId)}, + visible != VARIANT_FALSE)) + { + *changed = VARIANT_TRUE; + FireViewChange(); + } + return S_OK; +} + STDMETHODIMP CMapWinGISControl::LoadVectorDataset(BSTR path) { if (path == nullptr) @@ -340,18 +577,7 @@ STDMETHODIMP CMapWinGISControl::LoadVectorDataset(BSTR path) throw mwg::CoreError("Map feature count is out of range"); } - const mwg::LayerId id = _view.AddLayer(std::move(dataset)); - try - { - // Until the control exposes navigation, every load shows the - // whole map so that each loaded layer is visible. - (void)_view.FitToMap(); - } - catch (...) - { - (void)_view.RemoveLayer(id); - throw; - } + (void)_view.AddLayer(std::move(dataset)); _featureCount += count; FireViewChange(); @@ -474,6 +700,87 @@ bool CMapWinGISControl::ResizeView(const LONG width, const LONG height) noexcept } } +bool CMapWinGISControl::GetPointerPosition( + const LPARAM position, + const bool screenCoordinates, + mwg::ScreenPoint& point) const noexcept +{ + LONG x = GET_X_LPARAM(position); + LONG y = GET_Y_LPARAM(position); + if (screenCoordinates) + { + HWND window = m_hWndCD; + if (m_bWndLess) + { + if (m_spInPlaceSite == nullptr || + FAILED(m_spInPlaceSite->GetWindow(&window))) + { + return false; + } + } + if (window == nullptr) + { + return false; + } + + POINT positionInClient{x, y}; + if (!::ScreenToClient(window, &positionInClient)) + { + return false; + } + x = positionInClient.x; + y = positionInClient.y; + } + + if (m_bWndLess) + { + x -= m_rcPos.left; + y -= m_rcPos.top; + } + point = {static_cast(x), static_cast(y)}; + return true; +} + +HRESULT CMapWinGISControl::SetPointerCapture(const bool capture) noexcept +{ + if (m_bWndLess) + { + if (m_spInPlaceSite == nullptr) + { + return E_UNEXPECTED; + } + return m_spInPlaceSite->SetCapture(capture ? TRUE : FALSE); + } + + if (m_hWndCD == nullptr) + { + return E_UNEXPECTED; + } + if (capture) + { + ::SetCapture(m_hWndCD); + return ::GetCapture() == m_hWndCD ? S_OK : E_FAIL; + } + if (::GetCapture() == m_hWndCD && !::ReleaseCapture()) + { + return HRESULT_FROM_WIN32(::GetLastError()); + } + return S_OK; +} + +void CMapWinGISControl::EndPan(const bool releaseCapture) noexcept +{ + if (!_isPanning) + { + return; + } + _isPanning = false; + if (releaseCapture && FAILED(SetPointerCapture(false))) + { + ::OutputDebugStringA("MapWinGIS could not release pointer capture after pan.\n"); + } +} + LRESULT CMapWinGISControl::OnSize( UINT, WPARAM, @@ -489,6 +796,155 @@ LRESULT CMapWinGISControl::OnSize( return 0; } +LRESULT CMapWinGISControl::OnMouseWheel( + UINT, + const WPARAM wParam, + const LPARAM lParam, + BOOL& handled) +{ + if (_view.GetMap().LayerCount() == 0 || !_view.GetViewport().IsValid()) + { + handled = FALSE; + return 0; + } + + const int delta = GET_WHEEL_DELTA_WPARAM(wParam); + mwg::ScreenPoint anchor; + if (delta == 0 || !GetPointerPosition(lParam, true, anchor)) + { + handled = FALSE; + return 0; + } + + const double factor = std::pow( + mwg::MapView::kDefaultZoomFactor, + static_cast(delta) / WHEEL_DELTA); + try + { + if (_view.ZoomAt(anchor, factor)) + { + FireViewChange(); + } + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + } + handled = TRUE; + return 0; +} + +LRESULT CMapWinGISControl::OnLButtonDown( + UINT, + WPARAM, + const LPARAM lParam, + BOOL& handled) +{ + mwg::ScreenPoint position; + if (_view.GetMap().LayerCount() == 0 || + !_view.GetViewport().IsValid() || + !GetPointerPosition(lParam, false, position)) + { + handled = FALSE; + return 0; + } + + const HRESULT captureResult = SetPointerCapture(true); + if (FAILED(captureResult)) + { + ::OutputDebugStringA("MapWinGIS could not capture the pointer for pan.\n"); + handled = FALSE; + return 0; + } + + _isPanning = true; + _lastPanPosition = position; + handled = TRUE; + return 0; +} + +LRESULT CMapWinGISControl::OnMouseMove( + UINT, + WPARAM, + const LPARAM lParam, + BOOL& handled) +{ + if (!_isPanning) + { + handled = FALSE; + return 0; + } + + mwg::ScreenPoint position; + if (!GetPointerPosition(lParam, false, position)) + { + handled = TRUE; + return 0; + } + + const std::optional resolution = _view.GetViewport().Resolution(); + if (resolution.has_value()) + { + try + { + const double deltaX = (_lastPanPosition.x - position.x) * *resolution; + const double deltaY = (position.y - _lastPanPosition.y) * *resolution; + if (_view.Pan(deltaX, deltaY)) + { + FireViewChange(); + } + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + } + } + + _lastPanPosition = position; + handled = TRUE; + return 0; +} + +LRESULT CMapWinGISControl::OnLButtonUp( + UINT, + WPARAM, + LPARAM, + BOOL& handled) +{ + if (_isPanning) + { + EndPan(true); + handled = TRUE; + } + else + { + handled = FALSE; + } + return 0; +} + +LRESULT CMapWinGISControl::OnCaptureChanged( + UINT, + WPARAM, + LPARAM, + BOOL& handled) +{ + EndPan(false); + handled = FALSE; + return 0; +} + +LRESULT CMapWinGISControl::OnCancelMode( + UINT, + WPARAM, + LPARAM, + BOOL& handled) +{ + EndPan(true); + handled = FALSE; + return 0; +} + LRESULT CMapWinGISControl::OnGdalDispatch( UINT, WPARAM, @@ -504,6 +960,7 @@ LRESULT CMapWinGISControl::OnDestroy( LPARAM, BOOL& handled) { + EndPan(true); _gdalEvents.OnDestroy(); handled = FALSE; return 0; diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.h b/src/MapWinGIS.ActiveX/MapWinGISControl.h index 0b75bba..aafef27 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -4,10 +4,10 @@ * @file MapWinGISControl.h * @brief Declares the COM-creatable MapWinGIS ActiveX control. * - * Provides automation for vector loading, GDAL operations, rendering - * preferences, and painting the map's polygon layers. The control owns one - * Core MapView, the only map and view state; it forwards host events to it - * and draws its render scene through its viewport. + * Provides automation for vector loading and map navigation, GDAL operations, + * rendering preferences, and painting the map's polygon layers. The control + * owns one Core MapView, the only map and view state; it forwards host events + * to it and draws its render scene through its viewport. */ #pragma once @@ -38,8 +38,8 @@ class ATL_NO_VTABLE CMapWinGISControl : public ATL::IOleInPlaceObjectWindowlessImpl, public ATL::IPersistStreamInitImpl, public ATL::IDispatchImpl< - IMapWinGISControl3, - &IID_IMapWinGISControl3, + IMapWinGISControl4, + &IID_IMapWinGISControl4, &LIBID_MapWinGISActiveXLib, 1, 0>, @@ -58,12 +58,13 @@ class ATL_NO_VTABLE CMapWinGISControl : DECLARE_PROTECT_FINAL_CONSTRUCT() BEGIN_COM_MAP(CMapWinGISControl) + COM_INTERFACE_ENTRY(IMapWinGISControl4) COM_INTERFACE_ENTRY(IMapWinGISControl3) COM_INTERFACE_ENTRY(IMapWinGISControl2) COM_INTERFACE_ENTRY(IMapWinGISControl) COM_INTERFACE_ENTRY(IMapWinGISControlGdal) COM_INTERFACE_ENTRY(IConnectionPointContainer) - COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl3) + COM_INTERFACE_ENTRY2(IDispatch, IMapWinGISControl4) COM_INTERFACE_ENTRY(IViewObjectEx) COM_INTERFACE_ENTRY(IViewObject2) COM_INTERFACE_ENTRY(IViewObject) @@ -86,12 +87,29 @@ class ATL_NO_VTABLE CMapWinGISControl : BEGIN_MSG_MAP(CMapWinGISControl) MESSAGE_HANDLER(WM_SIZE, OnSize) + MESSAGE_HANDLER(WM_MOUSEWHEEL, OnMouseWheel) + MESSAGE_HANDLER(WM_LBUTTONDOWN, OnLButtonDown) + MESSAGE_HANDLER(WM_MOUSEMOVE, OnMouseMove) + MESSAGE_HANDLER(WM_LBUTTONUP, OnLButtonUp) + MESSAGE_HANDLER(WM_CAPTURECHANGED, OnCaptureChanged) + MESSAGE_HANDLER(WM_CANCELMODE, OnCancelMode) MESSAGE_HANDLER(WM_DESTROY, OnDestroy) CHAIN_MSG_MAP(ATL::CComControl) END_MSG_MAP() DECLARE_VIEW_STATUS(VIEWSTATUS_SOLIDBKGND | VIEWSTATUS_OPAQUE) + STDMETHOD(get_LayerCount)(LONG* count) override; + STDMETHOD(GetLayerId)(LONG index, LONGLONG* layerId) override; + STDMETHOD(ZoomToMap)(VARIANT_BOOL* changed) override; + STDMETHOD(ZoomToLayer)(LONGLONG layerId, VARIANT_BOOL* changed) override; + STDMETHOD(ZoomIn)(VARIANT_BOOL* changed) override; + STDMETHOD(ZoomOut)(VARIANT_BOOL* changed) override; + STDMETHOD(RemoveLayer)(LONGLONG layerId, VARIANT_BOOL* changed) override; + STDMETHOD(SetLayerVisible)( + LONGLONG layerId, + VARIANT_BOOL visible, + VARIANT_BOOL* changed) override; STDMETHOD(get_CoreInitialized)(VARIANT_BOOL* initialized) override; STDMETHOD(LoadVectorDataset)(BSTR path) override; STDMETHOD(ClearLayers)() override; @@ -120,6 +138,12 @@ class ATL_NO_VTABLE CMapWinGISControl : STDMETHOD(SetObjectRects)(LPCRECT position, LPCRECT clip) override; LRESULT OnSize(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnMouseWheel(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnLButtonDown(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnMouseMove(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnLButtonUp(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnCaptureChanged(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnCancelMode(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnGdalDispatch(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnDestroy(UINT, WPARAM, LPARAM, BOOL& handled); static void DispatchGdalNotifications(void* control) noexcept; @@ -130,11 +154,22 @@ class ATL_NO_VTABLE CMapWinGISControl : /// Resizes the view to a host size, clamped to the supported range, and /// returns true when the frame must be redrawn. bool ResizeView(LONG width, LONG height) noexcept; + /// Converts a host pointer position to viewport coordinates. + bool GetPointerPosition( + LPARAM position, + bool screenCoordinates, + mwg::ScreenPoint& point) const noexcept; + /// Acquires or releases capture through the active window or windowless site. + HRESULT SetPointerCapture(bool capture) noexcept; + /// Ends a drag and optionally releases capture still owned by this control. + void EndPan(bool releaseCapture) noexcept; mwg::MapView _view; LONGLONG _featureCount = 0; mwg::activex::VectorLayerRenderer _renderer; mwg::activex::GdalEventSource _gdalEvents; + mwg::ScreenPoint _lastPanPosition{}; + bool _isPanning = false; }; OBJECT_ENTRY_AUTO(CLSID_MapWinGISControl, CMapWinGISControl)