From 41f3208f1e65a7da81717769611054e83b91cac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:43:32 +0200 Subject: [PATCH 1/3] fix(wasserfoerderung): add wheel/pinch zoom and overzoom to Karte mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: "I cannot zoom into the map data" (initially proposed introducing Mapsui). Investigated first: MapCanvasControl had no scroll-wheel or pinch zoom at all -- only two tiny +/- buttons -- and WasserfoerderungViewModel's MinZoom/MaxZoom were fixed constants (3/19) unrelated to what the configured region's region.mbtiles actually contains. The real, published Fürstenfeldbruck pack only renders z11-15; MapDrawing silently skipped any missing tile, so clicking + past z15 or - past z11 showed a blank canvas with zero feedback. Mapsui would fix this too, but at real cost (this app's first third-party dependency, blocked on Android today since it needs Avalonia >=11.3.1 and Android is pinned to 11.2.2, and a much bigger rewrite than the bug needs) -- fixed the actual root cause instead. - IMapTileSource.GetMaxZoom() (MbTilesFileSource: SELECT MAX(zoom_level)). - WasserfoerderungViewModel: per-region MinZoom (from the same GetTileBounds() the center fix already computes -- the region's own lowest rendered zoom), a new ChangeMapViewCommand(MapViewChange) command applying a wheel/pinch-driven view change, clamped to Min/MaxZoom. - MapCanvasControl: OnPointerWheelChanged and a PinchGestureRecognizer handler, both zooming while keeping the gesture's focal point geographically stationary, routed through the new ViewChangedCommand (matching the control's existing PointClickedCommand/UndoRequestedCommand pattern rather than two-way property binding). - MapDrawing.DrawTiles: when the exact tile is missing past the source's actual max zoom, draws a cropped ancestor tile from the max zoom instead ("overzoom" -- standard map-app behavior past native detail). Verified against the real published Fürstenfeldbruck pack: scrolling in 6 levels past z15 shows a legible overzoomed view (not blank); scrolling out clamps cleanly at z11 (the pack's real minimum), never blank. Pinch-to-zoom's scale->zoom-delta math is unit-tested directly (Avalonia.Headless has no touch/gesture simulation API to drive the full gesture pipeline). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2 Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../Controls/MapCanvasControl.cs | 99 ++++++++++++++++++- .../Controls/MapDrawing.cs | 37 ++++++- .../Views/WasserfoerderungView.axaml | 3 +- .../Services/MapViewChange.cs | 9 ++ .../ViewModels/IncidentWorkspaceViewModel.cs | 13 ++- .../ViewModels/WasserfoerderungViewModel.cs | 34 +++++-- .../Wasserfoerderung/IMapTileSource.cs | 7 ++ .../Wasserfoerderung/MbTilesFileSource.cs | 10 ++ .../MapCanvasControlTests.cs | 92 ++++++++++++++++- .../MapDrawingTests.cs | 62 ++++++++++++ .../RouteOverviewRendererTests.cs | 1 + .../IncidentWorkspaceViewModelTests.cs | 52 ++++++++++ .../WasserfoerderungViewModelTests.cs | 32 ++++++ .../MbTilesFileSourceTests.cs | 34 +++++++ 14 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 src/LageBuch.AppLogic/Services/MapViewChange.cs create mode 100644 tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs diff --git a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs index 1c0df3e..aa765f5 100644 --- a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs +++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs @@ -41,13 +41,30 @@ public sealed class MapCanvasControl : Control public static readonly StyledProperty UndoRequestedCommandProperty = AvaloniaProperty.Register(nameof(UndoRequestedCommand)); + public static readonly StyledProperty ViewChangedCommandProperty = + AvaloniaProperty.Register(nameof(ViewChangedCommand)); + static MapCanvasControl() { AffectsRender( TileSourceProperty, CenterLatitudeProperty, CenterLongitudeProperty, ZoomProperty, RoutePointsProperty); } - public MapCanvasControl() => Focusable = true; + // Cumulative pinch scale since the gesture started (Avalonia.Input.Gestures.PinchEvent reports + // Scale relative to gesture start, not incrementally) -- reset in OnPinchEnded. + private double _pinchStartScale = 1.0; + private int _pinchStartZoom; + + public MapCanvasControl() + { + Focusable = true; + // Avalonia.Input.Gestures (which raises PinchEvent/PinchEndedEvent) is internal; the + // documented public way to opt a control into pinch recognition is registering a + // recognizer here, then handling the routed events declared on InputElement itself. + GestureRecognizers.Add(new PinchGestureRecognizer()); + AddHandler(PinchEvent, OnPinch); + AddHandler(PinchEndedEvent, OnPinchEnded); + } public IMapTileSource? TileSource { @@ -93,6 +110,18 @@ public ICommand? UndoRequestedCommand set => SetValue(UndoRequestedCommandProperty, value); } + /// + /// Invoked with a from a wheel or pinch zoom (#150 follow-up). + /// A command rather than two-way binding CenterLatitude/CenterLongitude/Zoom, matching this + /// control's existing pattern for control->VM communication (, + /// ) instead of changing those three properties' binding mode. + /// + public ICommand? ViewChangedCommand + { + get => GetValue(ViewChangedCommandProperty); + set => SetValue(ViewChangedCommandProperty, value); + } + public override void Render(DrawingContext context) { base.Render(context); @@ -127,13 +156,73 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (!current.Properties.IsLeftButtonPressed) return; - var (centerX, centerY) = WebMercator.ToWorldPixel(new GeoPoint(CenterLatitude, CenterLongitude), Zoom); - var worldX = current.Position.X + centerX - Bounds.Width / 2; - var worldY = current.Position.Y + centerY - Bounds.Height / 2; - var geoPoint = WebMercator.ToGeo(worldX, worldY, Zoom); + var geoPoint = ScreenToGeo(current.Position); if (PointClickedCommand?.CanExecute(geoPoint) == true) PointClickedCommand.Execute(geoPoint); e.Handled = true; } + + private GeoPoint ScreenToGeo(Point screenPoint) + { + var (centerX, centerY) = WebMercator.ToWorldPixel(new GeoPoint(CenterLatitude, CenterLongitude), Zoom); + var worldX = screenPoint.X + centerX - Bounds.Width / 2; + var worldY = screenPoint.Y + centerY - Bounds.Height / 2; + return WebMercator.ToGeo(worldX, worldY, Zoom); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + + var newZoom = Zoom + (e.Delta.Y > 0 ? 1 : -1); + ZoomAt(e.GetPosition(this), newZoom); + e.Handled = true; + } + + // PinchEventArgs.Scale is cumulative relative to the gesture's start, not incremental between + // events, so the target zoom is derived fresh each time from the gesture's starting zoom. + private void OnPinch(object? sender, PinchEventArgs e) + { + if (_pinchStartZoom == 0) + { + _pinchStartScale = e.Scale; + _pinchStartZoom = Zoom; + } + + var relativeScale = e.Scale / _pinchStartScale; + var newZoom = _pinchStartZoom + PinchScaleToZoomDelta(relativeScale); + var origin = new Point(e.ScaleOrigin.X * Bounds.Width, e.ScaleOrigin.Y * Bounds.Height); + ZoomAt(origin, newZoom); + e.Handled = true; + } + + private void OnPinchEnded(object? sender, PinchEndedEventArgs e) + { + _pinchStartZoom = 0; + _pinchStartScale = 1.0; + } + + /// Each zoom level doubles resolution, so a relative pinch scale of 2x is +1 zoom + /// level — public/static for direct unit testing since Avalonia.Headless cannot synthesize + /// pinch/touch input to drive this through a full gesture pipeline (#150 follow-up). + public static int PinchScaleToZoomDelta(double relativeScale) => + (int)Math.Round(Math.Log2(Math.Max(relativeScale, 0.01))); + + /// Zooms to while keeping the geo point currently under + /// stationary on screen (#150 follow-up) — the standard + /// "zoom to cursor"/"zoom to pinch centroid" map UX. + private void ZoomAt(Point screenPoint, int newZoom) + { + var anchorGeo = ScreenToGeo(screenPoint); + + var (anchorWorldX, anchorWorldY) = WebMercator.ToWorldPixel(anchorGeo, newZoom); + var newCenterX = anchorWorldX - (screenPoint.X - Bounds.Width / 2); + var newCenterY = anchorWorldY - (screenPoint.Y - Bounds.Height / 2); + var newCenter = WebMercator.ToGeo(newCenterX, newCenterY, newZoom); + var change = new MapViewChange(newCenter.Latitude, newCenter.Longitude, newZoom); + + if (ViewChangedCommand?.CanExecute(change) == true) + ViewChangedCommand.Execute(change); + } } diff --git a/src/LageBuch.App.Shared/Controls/MapDrawing.cs b/src/LageBuch.App.Shared/Controls/MapDrawing.cs index 933e9d7..36146a1 100644 --- a/src/LageBuch.App.Shared/Controls/MapDrawing.cs +++ b/src/LageBuch.App.Shared/Controls/MapDrawing.cs @@ -39,25 +39,58 @@ private static void DrawTiles( var (firstTileX, firstTileY) = WebMercator.ToTileIndex(centerX - width / 2, centerY - height / 2); var (lastTileX, lastTileY) = WebMercator.ToTileIndex(centerX + width / 2, centerY + height / 2); + var sourceMaxZoom = tileSource.GetMaxZoom(); for (var tx = firstTileX; tx <= lastTileX; tx++) { for (var ty = firstTileY; ty <= lastTileY; ty++) { var bytes = tileSource.GetTile(zoom, tx, ty); + var sourceRect = (Rect?)null; + if (bytes is null) - continue; + { + if (ComputeOverzoomTile(zoom, tx, ty, sourceMaxZoom) is not { } overzoom) + continue; + bytes = tileSource.GetTile(overzoom.Zoom, overzoom.X, overzoom.Y); + if (bytes is null) + continue; + sourceRect = overzoom.SourceRect; + } using var stream = new MemoryStream(bytes); using var bitmap = new Bitmap(stream); var screenX = tx * WebMercator.TileSizePixels - centerX + width / 2; var screenY = ty * WebMercator.TileSizePixels - centerY + height / 2; var destRect = new Rect(screenX, screenY, WebMercator.TileSizePixels, WebMercator.TileSizePixels); - context.DrawImage(bitmap, new Rect(bitmap.Size), destRect); + context.DrawImage(bitmap, sourceRect ?? new Rect(bitmap.Size), destRect); } } } + /// + /// When the exact tile at (, , ) + /// isn't available and is past the source's actual max detail + /// (), computes the ancestor tile at that max zoom and the + /// source sub-rect within it covering this tile's area — "overzoom": an increasingly blurry + /// but still-oriented view past the region's native detail, instead of drawing nothing + /// (#150 follow-up). Null when zoom is already within range, or the source's max is unknown + /// (an empty tile source). + /// + public static (int Zoom, int X, int Y, Rect SourceRect)? ComputeOverzoomTile(int zoom, int x, int y, int? sourceMaxZoom) + { + if (sourceMaxZoom is not { } maxZoom || zoom <= maxZoom) + return null; + + var levels = zoom - maxZoom; + var ancestorX = x >> levels; + var ancestorY = y >> levels; + var subSize = (double)WebMercator.TileSizePixels / (1 << levels); + var subX = (x - (ancestorX << levels)) * subSize; + var subY = (y - (ancestorY << levels)) * subSize; + return (maxZoom, ancestorX, ancestorY, new Rect(subX, subY, subSize, subSize)); + } + private static void DrawRoute( DrawingContext context, IReadOnlyList? routePoints, int zoom, double centerX, double centerY, double width, double height) diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml index 675cd64..622bd61 100644 --- a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml +++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml @@ -125,7 +125,8 @@ Zoom="{Binding MapZoom}" RoutePoints="{Binding DrawnRoutePoints}" PointClickedCommand="{Binding AddRoutePointCommand}" - UndoRequestedCommand="{Binding UndoLastRoutePointCommand}" /> + UndoRequestedCommand="{Binding UndoLastRoutePointCommand}" + ViewChangedCommand="{Binding ChangeMapViewCommand}" /> diff --git a/src/LageBuch.AppLogic/Services/MapViewChange.cs b/src/LageBuch.AppLogic/Services/MapViewChange.cs new file mode 100644 index 0000000..87fa3e3 --- /dev/null +++ b/src/LageBuch.AppLogic/Services/MapViewChange.cs @@ -0,0 +1,9 @@ +namespace LageBuch.AppLogic.Services; + +/// +/// A wheel/pinch-driven map view change from MapCanvasControl (#150 follow-up) — the +/// control->VM counterpart of GeoPoint for WasserfoerderungViewModel.ChangeMapViewCommand, +/// living alongside (not in a specific ViewModel or the Domain layer) +/// since both the App.Shared control and the AppLogic view model need to reference it. +/// +public sealed record MapViewChange(double CenterLatitude, double CenterLongitude, int Zoom); diff --git a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs index 8a31310..db34f86 100644 --- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs @@ -242,7 +242,8 @@ private void BuildChildren() var (elevationSampler, tileSource) = BuildWasserfoerderungMapSources(); var initialMapView = InitialMapViewFrom(tileSource); Wasserfoerderung = new WasserfoerderungViewModel( - _session, OnChanged, elevationSampler, tileSource, initialMapView?.Center, initialMapView?.Zoom); + _session, OnChanged, elevationSampler, tileSource, + initialMapView?.Center, initialMapView?.Zoom, initialMinZoom: initialMapView?.Zoom); Reminder?.Dispose(); // The ILS reminder is autonomous, time-driven host-side logging (§ IsRemote) — a joined @@ -287,10 +288,12 @@ private void BuildChildren() } /// - /// Derives the Karte mode's initial view from the tiles the configured region actually has, - /// rather than an unrelated fixed fallback (#150 follow-up) — a real per-Landkreis pack has no - /// tiles anywhere near 's hardcoded German default, so - /// without this the map opened blank until the operator happened to pan to the right area. + /// Derives the Karte mode's initial view — and, doubling as the region's lowest usable zoom + /// (initialMinZoom), the floor past which zooming out has nothing to show — from the + /// tiles the configured region actually has, rather than an unrelated fixed fallback (#150 + /// follow-up). A real per-Landkreis pack has no tiles anywhere near + /// 's hardcoded German default and only ever renders a + /// narrow zoom band, so without this the map opened blank (or could be zoomed out to blank). /// private static (GeoPoint Center, int Zoom)? InitialMapViewFrom(IMapTileSource? tileSource) { diff --git a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs index a277fc7..d8801ea 100644 --- a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LageBuch.AppLogic.Services; using LageBuch.Domain.Wasserfoerderung; using LageBuch.Documents; using LageBuch.Persistence.Wasserfoerderung; @@ -26,7 +27,7 @@ public sealed partial class WasserfoerderungViewModel : ObservableObject, IDispo public WasserfoerderungViewModel( IIncidentSession session, Action onChanged, IElevationSampler? elevationSampler = null, IMapTileSource? tileSource = null, - GeoPoint? initialMapCenter = null, int? initialMapZoom = null) + GeoPoint? initialMapCenter = null, int? initialMapZoom = null, int? initialMinZoom = null) { _session = session; _onChanged = onChanged; @@ -38,13 +39,16 @@ public WasserfoerderungViewModel( DrawnRoutePoints.CollectionChanged += (_, _) => UndoLastRoutePointCommand.NotifyCanExecuteChanged(); DrawnRoutePoints.CollectionChanged += (_, _) => FinishRouteCommand.NotifyCanExecuteChanged(); + if (initialMinZoom is { } minZoom) + _minZoom = minZoom; + if (initialMapCenter is { } center) { _mapCenterLatitude = center.Latitude; _mapCenterLongitude = center.Longitude; } if (initialMapZoom is { } zoom) - _mapZoom = Math.Clamp(zoom, MinZoom, MaxZoom); + _mapZoom = Math.Clamp(zoom, _minZoom, MaxZoom); _session.Changed += Sync; Sync(); @@ -67,12 +71,13 @@ public WasserfoerderungViewModel( [ObservableProperty] private bool _isMapMode; - // The constructor overrides these from the configured region's actual tile bounds - // (IncidentWorkspaceViewModel, #150 follow-up) whenever one is available; a region with no - // tiles at all (or no Einsatzgebiet configured) falls back to this fixed, reasonable German - // default and the operator pans from there. Bounds keep zooming out from going past a - // whole-continent view or in past building-level detail. - private const int MinZoom = 3; + // The constructor overrides MinZoom from the configured region's actual tile bounds + // (IncidentWorkspaceViewModel, #150 follow-up) whenever one is available — going lower than + // the region's own lowest rendered zoom has nothing to show. A region with no tiles at all + // (or no Einsatzgebiet configured) falls back to this fixed default. MaxZoom stays a generous + // constant regardless: zooming past a region's native detail is handled by MapDrawing's + // overzoom fallback (an increasingly blurry but still-oriented view), not blocked here. + private readonly int _minZoom = 3; private const int MaxZoom = 19; [ObservableProperty] @@ -88,7 +93,18 @@ public WasserfoerderungViewModel( private void ZoomIn() => MapZoom = Math.Min(MaxZoom, MapZoom + 1); [RelayCommand] - private void ZoomOut() => MapZoom = Math.Max(MinZoom, MapZoom - 1); + private void ZoomOut() => MapZoom = Math.Max(_minZoom, MapZoom - 1); + + /// Applies a wheel/pinch-driven view change from the map canvas (#150 follow-up) — + /// the control->VM counterpart of /, + /// used instead of two-way property binding on CenterLatitude/CenterLongitude/Zoom. + [RelayCommand] + private void ChangeMapView(MapViewChange change) + { + MapCenterLatitude = change.CenterLatitude; + MapCenterLongitude = change.CenterLongitude; + MapZoom = Math.Clamp(change.Zoom, _minZoom, MaxZoom); + } [RelayCommand] private void AddRoutePoint(GeoPoint point) => DrawnRoutePoints.Add(point); diff --git a/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs index 8b6d5ff..138aa35 100644 --- a/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs +++ b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs @@ -12,4 +12,11 @@ public interface IMapTileSource /// tiles actually present, instead of an unrelated fixed fallback (#150 follow-up). /// (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds(); + + /// + /// The highest zoom level this source has any tiles at, or null when it has none — lets a + /// caller detect when a requested zoom is past the region's native detail and fall back to + /// overzooming an ancestor tile instead of drawing nothing (#150 follow-up). + /// + int? GetMaxZoom(); } diff --git a/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs index d8b4e40..4f4735a 100644 --- a/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs +++ b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs @@ -49,4 +49,14 @@ public sealed class MbTilesFileSource(string mbtilesFilePath) : IMapTileSource var maxRow = (1 << zoom) - 1; return (zoom, minX, maxX, maxRow - maxTmsRow, maxRow - minTmsRow); } + + public int? GetMaxZoom() + { + using var cn = SqliteConnectionFactory.OpenReadOnly(mbtilesFilePath); + using var cmd = cn.CreateCommand(); + cmd.CommandText = "SELECT MAX(zoom_level) FROM tiles;"; + + var result = cmd.ExecuteScalar(); + return result is long max ? (int)max : null; + } } diff --git a/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs index 7521849..2a4b5e3 100644 --- a/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs +++ b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs @@ -7,6 +7,7 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.Input; using LageBuch.App.Shared.Controls; +using LageBuch.AppLogic.Services; using LageBuch.Domain.Wasserfoerderung; using LageBuch.Persistence.Wasserfoerderung; @@ -19,6 +20,7 @@ private sealed class FakeTileSource : IMapTileSource { public byte[]? GetTile(int zoom, int x, int y) => SolidTilePng.Bytes; public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds() => null; + public int? GetMaxZoom() => null; } // A real, decodable 4x4 solid-color PNG (built once via Avalonia's own encoder) so @@ -39,7 +41,8 @@ private static byte[] Build() } private static (Window Window, MapCanvasControl Control) ShowControl( - IReadOnlyList? routePoints = null, RelayCommand? onPointClicked = null, RelayCommand? onUndo = null) + IReadOnlyList? routePoints = null, RelayCommand? onPointClicked = null, + RelayCommand? onUndo = null, RelayCommand? onViewChanged = null) { var control = new MapCanvasControl { @@ -52,6 +55,7 @@ private static (Window Window, MapCanvasControl Control) ShowControl( RoutePoints = routePoints, PointClickedCommand = onPointClicked, UndoRequestedCommand = onUndo, + ViewChangedCommand = onViewChanged, }; var window = new Window { Content = control, Width = 400, Height = 300 }; window.Show(); @@ -69,6 +73,92 @@ public void Renders_tiles_and_a_route_without_throwing() Assert.NotNull(frame); } + // #150 follow-up: zooming past a tile source's actual max detail must still draw something + // (an overzoomed ancestor tile), not silently skip the tile. + private sealed class MaxZoomSpyTileSource : IMapTileSource + { + public readonly List<(int Zoom, int X, int Y)> Requested = new(); + + public byte[]? GetTile(int zoom, int x, int y) + { + Requested.Add((zoom, x, y)); + return zoom <= 15 ? SolidTilePng.Bytes : null; + } + + public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds() => null; + public int? GetMaxZoom() => 15; + } + + [AvaloniaFact] + public void Zooming_past_the_sources_max_detail_falls_back_to_the_overzoomed_ancestor_tile() + { + var spy = new MaxZoomSpyTileSource(); + var control = new MapCanvasControl + { + Width = 256, + Height = 256, + TileSource = spy, + CenterLatitude = 48.0, + CenterLongitude = 11.0, + Zoom = 17, + }; + var window = new Window { Content = control, Width = 256, Height = 256 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + using var frame = window.CaptureRenderedFrame(); + + Assert.NotNull(frame); + Assert.Contains(spy.Requested, r => r.Zoom == 17); // tried the exact tile first + Assert.Contains(spy.Requested, r => r.Zoom == 15); // fell back to the max-zoom ancestor + } + + // #150 follow-up: scroll-wheel zoom was entirely missing before this fix -- only two tiny + // +/- buttons existed. Scrolling up over the control's exact center must zoom in by one level + // while keeping that same geo point (the center) stationary, i.e. center unchanged. + [AvaloniaFact] + public void Scrolling_up_zooms_in_by_one_level_keeping_the_cursors_geo_point_stationary() + { + MapViewChange? change = null; + var command = new RelayCommand(c => change = c); + var (window, control) = ShowControl(onViewChanged: command); + + var center = control.TranslatePoint(new Point(200, 150), window)!.Value; + window.MouseWheel(center, new Vector(0, 1)); + + Assert.NotNull(change); + Assert.Equal(16, change!.Zoom); + Assert.Equal(48.0, change.CenterLatitude, 3); + Assert.Equal(11.0, change.CenterLongitude, 3); + } + + [AvaloniaFact] + public void Scrolling_down_zooms_out_by_one_level() + { + MapViewChange? change = null; + var command = new RelayCommand(c => change = c); + var (window, control) = ShowControl(onViewChanged: command); + + var center = control.TranslatePoint(new Point(200, 150), window)!.Value; + window.MouseWheel(center, new Vector(0, -1)); + + Assert.NotNull(change); + Assert.Equal(14, change!.Zoom); + } + + // #150 follow-up: pinch-to-zoom (primarily for Android touch) can't be driven through + // Avalonia.Headless (no touch/gesture simulation API exists there), so its scale->zoom-delta + // math is unit-tested directly instead. + [Theory] + [InlineData(1.0, 0)] // no pinch movement -> no zoom change + [InlineData(2.0, 1)] // pinch-out to double size -> zoom in one level + [InlineData(4.0, 2)] // quadruple size -> zoom in two levels + [InlineData(0.5, -1)] // pinch-in to half size -> zoom out one level + [InlineData(1.4, 0)] // small movement rounds back to no change + [InlineData(1.6, 1)] // past the rounding midpoint commits to the next level + public void PinchScaleToZoomDelta_rounds_to_the_nearest_zoom_level(double relativeScale, int expectedDelta) + => Assert.Equal(expectedDelta, MapCanvasControl.PinchScaleToZoomDelta(relativeScale)); + [AvaloniaFact] public void Left_click_at_the_controls_center_invokes_PointClicked_with_the_center_geo_point() { diff --git a/tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs b/tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs new file mode 100644 index 0000000..874bac0 --- /dev/null +++ b/tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs @@ -0,0 +1,62 @@ +using Avalonia; +using LageBuch.App.Shared.Controls; + +namespace LageBuch.Acceptance.Tests; + +// #150 follow-up: overzoom -- when a requested zoom is past the configured region's actual max +// rendered detail, MapDrawing falls back to the nearest ancestor tile it does have instead of +// drawing nothing, matching every other map app's "blurry but oriented" behavior past native zoom. +public class MapDrawingTests +{ + [Fact] + public void ComputeOverzoomTile_returns_null_when_zoom_is_within_the_sources_range() + { + Assert.Null(MapDrawing.ComputeOverzoomTile(zoom: 14, x: 100, y: 200, sourceMaxZoom: 15)); + Assert.Null(MapDrawing.ComputeOverzoomTile(zoom: 15, x: 100, y: 200, sourceMaxZoom: 15)); + } + + [Fact] + public void ComputeOverzoomTile_returns_null_when_the_source_has_no_known_max_zoom() + { + Assert.Null(MapDrawing.ComputeOverzoomTile(zoom: 20, x: 100, y: 200, sourceMaxZoom: null)); + } + + [Fact] + public void ComputeOverzoomTile_finds_the_direct_parent_one_level_up() + { + // Tile (x=100,y=200) at z16 is the top-left quadrant of its z15 parent (x=50,y=100). + var overzoom = MapDrawing.ComputeOverzoomTile(zoom: 16, x: 100, y: 200, sourceMaxZoom: 15); + + Assert.NotNull(overzoom); + Assert.Equal(15, overzoom!.Value.Zoom); + Assert.Equal(50, overzoom.Value.X); + Assert.Equal(100, overzoom.Value.Y); + Assert.Equal(new Rect(0, 0, 128, 128), overzoom.Value.SourceRect); + } + + [Fact] + public void ComputeOverzoomTile_picks_the_correct_quadrant_for_an_odd_tile_index() + { + // Tile (x=101,y=201) at z16 is the bottom-right quadrant of the same z15 parent (x=50,y=100). + var overzoom = MapDrawing.ComputeOverzoomTile(zoom: 16, x: 101, y: 201, sourceMaxZoom: 15); + + Assert.NotNull(overzoom); + Assert.Equal(15, overzoom!.Value.Zoom); + Assert.Equal(50, overzoom.Value.X); + Assert.Equal(100, overzoom.Value.Y); + Assert.Equal(new Rect(128, 128, 128, 128), overzoom.Value.SourceRect); + } + + [Fact] + public void ComputeOverzoomTile_climbs_multiple_levels_when_zoomed_in_further() + { + // Two levels up (z17 -> z15): a quarter-size crop of the ancestor tile. + var overzoom = MapDrawing.ComputeOverzoomTile(zoom: 17, x: 400, y: 800, sourceMaxZoom: 15); + + Assert.NotNull(overzoom); + Assert.Equal(15, overzoom!.Value.Zoom); + Assert.Equal(100, overzoom.Value.X); + Assert.Equal(200, overzoom.Value.Y); + Assert.Equal(new Rect(0, 0, 64, 64), overzoom.Value.SourceRect); + } +} diff --git a/tests/LageBuch.Acceptance.Tests/RouteOverviewRendererTests.cs b/tests/LageBuch.Acceptance.Tests/RouteOverviewRendererTests.cs index 0097fae..0e54d6f 100644 --- a/tests/LageBuch.Acceptance.Tests/RouteOverviewRendererTests.cs +++ b/tests/LageBuch.Acceptance.Tests/RouteOverviewRendererTests.cs @@ -13,6 +13,7 @@ private sealed class EmptyTileSource : IMapTileSource { public byte[]? GetTile(int zoom, int x, int y) => null; public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds() => null; + public int? GetMaxZoom() => null; } [AvaloniaFact] diff --git a/tests/LageBuch.AppLogic.Tests/IncidentWorkspaceViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/IncidentWorkspaceViewModelTests.cs index ca247e3..c0f4949 100644 --- a/tests/LageBuch.AppLogic.Tests/IncidentWorkspaceViewModelTests.cs +++ b/tests/LageBuch.AppLogic.Tests/IncidentWorkspaceViewModelTests.cs @@ -126,6 +126,58 @@ public void Wasserfoerderung_map_opens_centered_on_the_configured_regions_tiles( } } + // #150 follow-up: zooming out must stop at the configured region's own lowest rendered zoom + // (there's nothing to show below it), not the unrelated fixed constant (3) — that constant + // would let the operator zoom out to a blank map for any per-Landkreis pack, which only ever + // renders a narrow zoom band. + [Fact] + public void Wasserfoerderung_map_cannot_zoom_out_past_the_configured_regions_lowest_zoom() + { + var regionDir = Path.Combine(Path.GetTempPath(), $"region-{Guid.NewGuid():N}"); + Directory.CreateDirectory(regionDir); + try + { + using (var writer = new BinaryWriter(File.Create(Path.Combine(regionDir, "region.dem")))) + { + writer.Write("FWDM"u8.ToArray()); + writer.Write(1); + writer.Write(48.2); + writer.Write(11.4); + writer.Write(0.01); + writer.Write(10); + writer.Write(10); + for (var i = 0; i < 100; i++) + writer.Write((short)500); + } + + using (var cn = LageBuch.Persistence.Sqlite.SqliteConnectionFactory.OpenReadWrite(Path.Combine(regionDir, "region.mbtiles"))) + using (var cmd = cn.CreateCommand()) + { + cmd.CommandText = + "CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);"; + cmd.ExecuteNonQuery(); + cmd.CommandText = "INSERT INTO tiles VALUES (11, 1085, 1339, X'00');"; + cmd.ExecuteNonQuery(); + } + + var clock = new FixedClock(T0); + var session = LocalIncidentSession.StartNew(new FakeStore(), clock, new SessionOperator("Müller"), + "/x.fwincident", Array.Empty<(string, bool)>(), Array.Empty<(string, bool)>()); + var masterData = Md() with { Einsatzgebiet = new Einsatzgebiet("Testgebiet", regionDir) }; + var vm = new IncidentWorkspaceViewModel(session, clock, new FakeTicker(), masterData, + new FakeDialogs(), new FakeAlarmService(), new NoopIncidentHostController()); + + for (var i = 0; i < 20; i++) + vm.Wasserfoerderung.ZoomOutCommand.Execute(null); + + Assert.Equal(11, vm.Wasserfoerderung.MapZoom); + } + finally + { + Directory.Delete(regionDir, recursive: true); + } + } + // --- Network sharing (no Tailscale required) -------------------------------------------- [Fact] diff --git a/tests/LageBuch.AppLogic.Tests/WasserfoerderungViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/WasserfoerderungViewModelTests.cs index e32f15b..4fd4eee 100644 --- a/tests/LageBuch.AppLogic.Tests/WasserfoerderungViewModelTests.cs +++ b/tests/LageBuch.AppLogic.Tests/WasserfoerderungViewModelTests.cs @@ -1,3 +1,4 @@ +using LageBuch.AppLogic.Services; using LageBuch.AppLogic.ViewModels; using LageBuch.Domain; using LageBuch.Domain.Wasserfoerderung; @@ -30,6 +31,7 @@ private sealed class FakeTileSource : IMapTileSource { public byte[]? GetTile(int zoom, int x, int y) => null; public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds() => null; + public int? GetMaxZoom() => null; } private static (LocalIncidentSession Session, FakeStore Store) NewSession() @@ -85,6 +87,36 @@ public void Map_falls_back_to_the_hardcoded_default_when_no_initial_view_is_give Assert.Equal(14, vm.MapZoom); } + // #150 follow-up: wheel/pinch zoom on the map canvas routes through this command instead of + // two-way property binding (matching PointClickedCommand/UndoRequestedCommand's existing + // control->VM pattern), so it must apply the new view exactly as given. + [Fact] + public void ChangeMapView_applies_the_given_center_and_zoom() + { + var (session, _) = NewSession(); + var vm = new WasserfoerderungViewModel(session, () => { }, new FakeElevationSampler(), new FakeTileSource()); + + vm.ChangeMapViewCommand.Execute(new MapViewChange(48.2, 11.3, 13)); + + Assert.Equal(48.2, vm.MapCenterLatitude); + Assert.Equal(11.3, vm.MapCenterLongitude); + Assert.Equal(13, vm.MapZoom); + } + + [Fact] + public void ChangeMapView_clamps_zoom_to_the_configured_min_and_max() + { + var (session, _) = NewSession(); + var vm = new WasserfoerderungViewModel(session, () => { }, new FakeElevationSampler(), new FakeTileSource(), + initialMinZoom: 11); + + vm.ChangeMapViewCommand.Execute(new MapViewChange(48.2, 11.3, 5)); + Assert.Equal(11, vm.MapZoom); + + vm.ChangeMapViewCommand.Execute(new MapViewChange(48.2, 11.3, 25)); + Assert.Equal(19, vm.MapZoom); + } + [Fact] public void AddRoutePoint_appends_and_UndoLastRoutePoint_removes_the_last_one() { diff --git a/tests/LageBuch.Persistence.Tests/MbTilesFileSourceTests.cs b/tests/LageBuch.Persistence.Tests/MbTilesFileSourceTests.cs index 7afc399..210641c 100644 --- a/tests/LageBuch.Persistence.Tests/MbTilesFileSourceTests.cs +++ b/tests/LageBuch.Persistence.Tests/MbTilesFileSourceTests.cs @@ -109,4 +109,38 @@ public void GetTileBounds_flips_stored_tms_rows_back_to_xyz_and_uses_the_lowest_ Assert.Equal(2, bounds.Value.MinY); Assert.Equal(6, bounds.Value.MaxY); } + + [Fact] + public void GetMaxZoom_returns_null_when_the_file_has_no_tiles() + { + var emptyPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mbtiles"); + using (var cn = SqliteConnectionFactory.OpenReadWrite(emptyPath)) + using (var create = cn.CreateCommand()) + { + create.CommandText = + "CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB);"; + create.ExecuteNonQuery(); + } + try + { + var source = new MbTilesFileSource(emptyPath); + + Assert.Null(source.GetMaxZoom()); + } + finally + { + File.Delete(emptyPath); + } + } + + [Fact] + public void GetMaxZoom_returns_the_highest_zoom_level_present() + { + using (var cn = SqliteConnectionFactory.OpenReadWrite(_path)) + InsertTile(cn, zoom: 7, column: 0, tmsRow: 0, data: new byte[] { 0x11 }); + + var source = new MbTilesFileSource(_path); + + Assert.Equal(7, source.GetMaxZoom()); + } } From 9d4c41b09059c4b6cae2fd3b9c599f12b4548b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:44:20 +0200 Subject: [PATCH 2/3] fix(wasserfoerderung): require Ctrl for wheel-zoom so plain scroll reaches the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (systematic-debugging): the wheel-zoom just added in this same branch unconditionally captured every wheel event over MapCanvasControl (e.Handled = true, no modifier check). The map sits inside the Wasserförderung tab's own ScrollViewer (WasserfoerderungView.axaml) at 360px tall — on a laptop where the window is short enough that the tab needs to scroll, the cursor is very likely to land on the map while the operator scrolls the page. Every such scroll got swallowed as a zoom instead of reaching the ScrollViewer, and a laptop trackpad's rapid wheel-delta stream during a single scroll swipe could zoom dozens of levels in an instant — "renders it unreadable," exactly as reported. Fix: gate wheel-zoom on Ctrl (KeyModifiers.Control), matching how most embedded maps resolve this exact conflict (Leaflet, Google Maps embeds, etc.). Plain scroll no longer sets e.Handled, so it bubbles to the ScrollViewer as normal; Ctrl+scroll still zooms exactly as before, keeping the cursor's geo point stationary. Pinch-to-zoom is unaffected — a pinch gesture doesn't conflict with page-scroll the way a plain wheel notch does. Verified against the real published Fürstenfeldbruck pack: plain scroll over the map no longer changes zoom; Ctrl+scroll still zooms in by one level as designed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2 Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../Controls/MapCanvasControl.cs | 9 ++++++ .../MapCanvasControlTests.cs | 32 +++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs index aa765f5..44a0711 100644 --- a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs +++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs @@ -175,6 +175,15 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { base.OnPointerWheelChanged(e); + // The map sits inside the Wasserförderung tab's own scrollable page (see + // WasserfoerderungView.axaml) -- a plain scroll must fall through to that ScrollViewer + // instead of zooming here, or the page becomes unscrollable wherever the cursor happens to + // land on the map, and a laptop trackpad's rapid wheel-delta stream during a scroll swipe + // zooms wildly, rendering the map unreadable (#150 follow-up). Ctrl+scroll to zoom matches + // the same convention most embedded maps use for exactly this reason. + if ((e.KeyModifiers & KeyModifiers.Control) == 0) + return; + var newZoom = Zoom + (e.Delta.Y > 0 ? 1 : -1); ZoomAt(e.GetPosition(this), newZoom); e.Handled = true; diff --git a/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs index 2a4b5e3..e466b48 100644 --- a/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs +++ b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs @@ -114,17 +114,17 @@ public void Zooming_past_the_sources_max_detail_falls_back_to_the_overzoomed_anc } // #150 follow-up: scroll-wheel zoom was entirely missing before this fix -- only two tiny - // +/- buttons existed. Scrolling up over the control's exact center must zoom in by one level - // while keeping that same geo point (the center) stationary, i.e. center unchanged. + // +/- buttons existed. Ctrl+scrolling up over the control's exact center must zoom in by one + // level while keeping that same geo point (the center) stationary, i.e. center unchanged. [AvaloniaFact] - public void Scrolling_up_zooms_in_by_one_level_keeping_the_cursors_geo_point_stationary() + public void CtrlScrolling_up_zooms_in_by_one_level_keeping_the_cursors_geo_point_stationary() { MapViewChange? change = null; var command = new RelayCommand(c => change = c); var (window, control) = ShowControl(onViewChanged: command); var center = control.TranslatePoint(new Point(200, 150), window)!.Value; - window.MouseWheel(center, new Vector(0, 1)); + window.MouseWheel(center, new Vector(0, 1), RawInputModifiers.Control); Assert.NotNull(change); Assert.Equal(16, change!.Zoom); @@ -133,19 +133,39 @@ public void Scrolling_up_zooms_in_by_one_level_keeping_the_cursors_geo_point_sta } [AvaloniaFact] - public void Scrolling_down_zooms_out_by_one_level() + public void CtrlScrolling_down_zooms_out_by_one_level() { MapViewChange? change = null; var command = new RelayCommand(c => change = c); var (window, control) = ShowControl(onViewChanged: command); var center = control.TranslatePoint(new Point(200, 150), window)!.Value; - window.MouseWheel(center, new Vector(0, -1)); + window.MouseWheel(center, new Vector(0, -1), RawInputModifiers.Control); Assert.NotNull(change); Assert.Equal(14, change!.Zoom); } + // #150 follow-up regression fix: the map sits inside the tab's outer ScrollViewer (see + // WasserfoerderungView.axaml) -- plain scroll (no Ctrl) must NOT be swallowed as a zoom, or + // the whole page becomes unscrollable wherever the cursor happens to be over the map, and a + // laptop trackpad's rapid wheel-delta stream during a scroll swipe zooms wildly, rendering the + // map unreadable. + [AvaloniaFact] + public void Plain_scrolling_without_ctrl_does_not_zoom() + { + MapViewChange? change = null; + var command = new RelayCommand(c => change = c); + var (window, control) = ShowControl(onViewChanged: command); + + var center = control.TranslatePoint(new Point(200, 150), window)!.Value; + window.MouseWheel(center, new Vector(0, 1)); + window.MouseWheel(center, new Vector(0, -1)); + + Assert.Null(change); + Assert.Equal(15, control.Zoom); + } + // #150 follow-up: pinch-to-zoom (primarily for Android touch) can't be driven through // Avalonia.Headless (no touch/gesture simulation API exists there), so its scale->zoom-delta // math is unit-tested directly instead. From 3e8ed75c0ae7c28682957a8405f734c31f52f23d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:17:13 +0200 Subject: [PATCH 3/3] fix(wasserfoerderung): add Ctrl+drag pan and a reset-view control to Karte mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (systematic-debugging): cursor-anchored wheel/pinch zoom shifts the map's center as a side effect (correct, by design), but MapCanvasControl had no drag-to-pan at all and WasserfoerderungViewModel never kept the region's initial center/zoom anywhere. Once the view drifted away from the configured region (trivial after a few zooms near an edge), there was no way back at all -- matching the report exactly. Considered Mapsui again; same conclusion as before (first third-party dependency, blocked on Android's Avalonia pin, much bigger than this gap needs) -- added the missing controls to the existing hand-rolled map instead. - WasserfoerderungViewModel: stores the view it opened with and exposes a new ResetMapViewCommand ("ZENTRIEREN") that returns to it. - MapCanvasControl: Ctrl+left-drag pans (moving the center opposite the drag direction, standard map UX), routed through the same ViewChangedCommand as wheel/pinch zoom. Reuses the Ctrl convention already established for zoom, so it never risks being confused with the primary plain-left-click-to-draw-a-route-point gesture. - Added a small "Strg + Scrollen: Zoom / Strg + Ziehen: Verschieben" hint next to the new button, since both interactions are otherwise undiscoverable. Also fixed a real regression these additions exposed: the extra button and hint line made the tab tall enough to trigger the outer ScrollViewer's (Fluent overlay-style) vertical scrollbar at window sizes that previously fit without scrolling. That scrollbar renders on top of -- not narrowing -- the content, so it silently swallowed clicks on the map's own right edge. Fixed by reserving a right margin matching the scrollbar's width, so real content never sits flush against that edge regardless of what triggers scrolling in the future. Verified against the real published Fürstenfeldbruck pack: drifting the view to Berlin, then Ctrl+drag panning and clicking ZENTRIEREN, both bring it back to Fürstenfeldbruck exactly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2 Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../Controls/MapCanvasControl.cs | 54 ++++++++++++++++++- .../Views/WasserfoerderungView.axaml | 9 +++- .../ViewModels/WasserfoerderungViewModel.cs | 23 ++++++++ .../MapCanvasControlTests.cs | 48 +++++++++++++++++ .../WasserfoerderungTabRenderTests.cs | 9 ++++ .../WasserfoerderungViewModelTests.cs | 34 ++++++++++++ 6 files changed, 174 insertions(+), 3 deletions(-) diff --git a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs index 44a0711..f831397 100644 --- a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs +++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs @@ -16,7 +16,9 @@ namespace LageBuch.App.Shared.Controls; /// ), right-click undoes the last one (via /// ); finishing the route is a separate, explicit action in the /// view (not a click gesture here), to avoid a fast double left-click accidentally placing two -/// points and then finishing. +/// points and then finishing. Ctrl+left-drag pans instead of adding a point (#150 follow-up) — +/// reusing the same Ctrl convention as wheel/pinch zoom, so the primary click-to-draw gesture is +/// never at risk of being misread as a pan. /// public sealed class MapCanvasControl : Control { @@ -55,6 +57,11 @@ static MapCanvasControl() private double _pinchStartScale = 1.0; private int _pinchStartZoom; + // Ctrl+drag panning state (#150 follow-up) -- null when not currently panning. + private Point? _panStartScreenPoint; + private double _panStartCenterLatitude; + private double _panStartCenterLongitude; + public MapCanvasControl() { Focusable = true; @@ -156,6 +163,15 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (!current.Properties.IsLeftButtonPressed) return; + if ((e.KeyModifiers & KeyModifiers.Control) != 0) + { + _panStartScreenPoint = current.Position; + _panStartCenterLatitude = CenterLatitude; + _panStartCenterLongitude = CenterLongitude; + e.Handled = true; + return; + } + var geoPoint = ScreenToGeo(current.Position); if (PointClickedCommand?.CanExecute(geoPoint) == true) @@ -163,6 +179,42 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) e.Handled = true; } + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (_panStartScreenPoint is not { } start) + return; + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _panStartScreenPoint = null; + return; + } + + var current = e.GetPosition(this); + var (startWorldX, startWorldY) = WebMercator.ToWorldPixel( + new GeoPoint(_panStartCenterLatitude, _panStartCenterLongitude), Zoom); + // Dragging reveals content on the opposite side, so the center moves against the drag. + var newWorldX = startWorldX - (current.X - start.X); + var newWorldY = startWorldY - (current.Y - start.Y); + var newCenter = WebMercator.ToGeo(newWorldX, newWorldY, Zoom); + var change = new MapViewChange(newCenter.Latitude, newCenter.Longitude, Zoom); + + if (ViewChangedCommand?.CanExecute(change) == true) + ViewChangedCommand.Execute(change); + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (_panStartScreenPoint is null) + return; + _panStartScreenPoint = null; + e.Handled = true; + } + private GeoPoint ScreenToGeo(Point screenPoint) { var (centerX, centerY) = WebMercator.ToWorldPixel(new GeoPoint(CenterLatitude, CenterLongitude), Zoom); diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml index 622bd61..49c9cce 100644 --- a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml +++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml @@ -23,9 +23,11 @@ + top of the header buttons). Right margin matches Fluent's overlay scrollbar width (#150 + follow-up regression: once content needs scrolling, that scrollbar renders on top of, not + narrowing, the content, silently swallowing clicks on the map's own right edge). --> - +