diff --git a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
index 1c0df3e..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
{
@@ -41,13 +43,35 @@ 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;
+
+ // 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;
+ // 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 +117,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 +163,127 @@ 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);
+ 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)
PointClickedCommand.Execute(geoPoint);
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);
+ 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);
+
+ // 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;
+ }
+
+ // 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..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). -->
-
+
+
+
@@ -125,7 +130,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..c8db13e 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;
@@ -23,10 +24,18 @@ public sealed partial class WasserfoerderungViewModel : ObservableObject, IDispo
private readonly IElevationSampler? _elevationSampler;
private readonly IMapTileSource? _tileSource;
+ // The view the map opened with (region-derived center/zoom, or the hardcoded default) --
+ // kept so ResetMapViewCommand has a "home" to return to. Cursor-anchored wheel/pinch zoom
+ // shifts the center as a side effect, and there is no drag-to-pan, so without this a drifted
+ // view had no way back at all (#150 follow-up).
+ private readonly double _initialCenterLatitude;
+ private readonly double _initialCenterLongitude;
+ private readonly int _initialZoom;
+
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 +47,20 @@ 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);
+
+ _initialCenterLatitude = _mapCenterLatitude;
+ _initialCenterLongitude = _mapCenterLongitude;
+ _initialZoom = _mapZoom;
_session.Changed += Sync;
Sync();
@@ -67,12 +83,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 +105,29 @@ 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);
+ }
+
+ /// "Zentrieren": returns to the view the map opened with (#150 follow-up) — the only
+ /// way back once cursor-anchored zooming has drifted the center away from the region, since
+ /// there is no drag-to-pan.
+ [RelayCommand]
+ private void ResetMapView()
+ {
+ MapCenterLatitude = _initialCenterLatitude;
+ MapCenterLongitude = _initialCenterLongitude;
+ MapZoom = _initialZoom;
+ }
[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..b8189f9 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,112 @@ 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. 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 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), RawInputModifiers.Control);
+
+ 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 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), 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.
+ [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()
{
@@ -101,4 +211,52 @@ public void Right_click_invokes_UndoRequested_instead_of_PointClicked()
Assert.Equal(1, undoCount);
Assert.Null(clicked);
}
+
+ // #150 follow-up: cursor-anchored zoom drifts the center with no drag-to-pan to correct it,
+ // so once the view drifted off the region entirely there was no way back. Ctrl+drag pans
+ // (moving the map opposite the drag direction, standard map UX), without also adding a route
+ // point (which plain left-click still does).
+ [AvaloniaFact]
+ public void CtrlDrag_pans_the_map_and_does_not_add_a_route_point()
+ {
+ GeoPoint? clicked = null;
+ MapViewChange? change = null;
+ var pointCommand = new RelayCommand(p => clicked = p);
+ var viewCommand = new RelayCommand(c => change = c);
+ var (window, control) = ShowControl(onPointClicked: pointCommand, onViewChanged: viewCommand);
+
+ var start = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ var end = control.TranslatePoint(new Point(240, 150), window)!.Value; // drag 40px right
+
+ window.MouseDown(start, MouseButton.Left, RawInputModifiers.Control);
+ window.MouseMove(end, RawInputModifiers.LeftMouseButton);
+ window.MouseUp(end, MouseButton.Left);
+
+ Assert.Null(clicked); // dragging must not also place a route point
+ Assert.NotNull(change);
+ Assert.Equal(15, change!.Zoom); // pan alone leaves zoom untouched
+ // Dragging right reveals content that was to the left -- the center's longitude decreases.
+ Assert.True(change.CenterLongitude < 11.0, $"Expected longitude to decrease, was {change.CenterLongitude}");
+ Assert.Equal(48.0, change.CenterLatitude, 3); // purely horizontal drag -> latitude unchanged
+ }
+
+ [AvaloniaFact]
+ public void Plain_drag_without_ctrl_still_adds_a_route_point_and_does_not_pan()
+ {
+ GeoPoint? clicked = null;
+ MapViewChange? change = null;
+ var pointCommand = new RelayCommand(p => clicked = p);
+ var viewCommand = new RelayCommand(c => change = c);
+ var (window, control) = ShowControl(onPointClicked: pointCommand, onViewChanged: viewCommand);
+
+ var start = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ var end = control.TranslatePoint(new Point(240, 150), window)!.Value;
+
+ window.MouseDown(start, MouseButton.Left);
+ window.MouseMove(end);
+ window.MouseUp(end, MouseButton.Left);
+
+ Assert.NotNull(clicked); // existing click-to-add-point behaviour is unaffected
+ Assert.Null(change); // no Ctrl held -> no pan
+ }
}
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.Acceptance.Tests/WasserfoerderungTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/WasserfoerderungTabRenderTests.cs
index adda73b..64cf659 100644
--- a/tests/LageBuch.Acceptance.Tests/WasserfoerderungTabRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/WasserfoerderungTabRenderTests.cs
@@ -124,6 +124,15 @@ public void Karte_mode_draws_a_route_and_finishing_it_adds_a_route_based_leitung
Dispatcher.UIThread.RunJobs();
Assert.True(vm.Wasserfoerderung.IsMapMode);
+ // #150 follow-up regression: at this window size the tab's content is tall enough
+ // that the outer ScrollViewer shows its (Fluent overlay-style) vertical scrollbar,
+ // which renders on top of -- not narrowing -- the content. p2 deliberately clicks
+ // right at the map's own edge to prove that scrollbar no longer swallows the click
+ // (WasserfoerderungView.axaml reserves a matching right margin for exactly this).
+ var scrollBar = view.GetVisualDescendants().OfType()
+ .Single(sb => sb.Orientation == Avalonia.Layout.Orientation.Vertical && sb.IsVisible);
+ Assert.True(scrollBar.Bounds.Width > 0, "Test premise: a visible vertical scrollbar must exist here.");
+
var canvas = view.GetVisualDescendants().OfType().Single();
var p1 = canvas.TranslatePoint(new Point(10, 10), window)!.Value;
var p2 = canvas.TranslatePoint(new Point(canvas.Bounds.Width - 10, 10), window)!.Value;
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..a876dbd 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,70 @@ 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);
+ }
+
+ // #150 follow-up: with no drag-to-pan and no way back, zooming (cursor-anchored, so it shifts
+ // the center) could drift the operator's view off the region's tiles entirely with no way to
+ // recover. ResetMapViewCommand must restore the view exactly where it started.
+ [Fact]
+ public void ResetMapView_restores_the_initial_center_and_zoom_after_drifting_away()
+ {
+ var (session, _) = NewSession();
+ var vm = new WasserfoerderungViewModel(session, () => { }, new FakeElevationSampler(), new FakeTileSource(),
+ initialMapCenter: new GeoPoint(48.19, 11.15), initialMapZoom: 13, initialMinZoom: 11);
+
+ vm.ChangeMapViewCommand.Execute(new MapViewChange(50.0, 20.0, 19));
+ Assert.Equal(50.0, vm.MapCenterLatitude);
+
+ vm.ResetMapViewCommand.Execute(null);
+
+ Assert.Equal(48.19, vm.MapCenterLatitude);
+ Assert.Equal(11.15, vm.MapCenterLongitude);
+ Assert.Equal(13, vm.MapZoom);
+ }
+
+ [Fact]
+ public void ResetMapView_restores_the_hardcoded_default_when_no_initial_view_was_given()
+ {
+ var (session, _) = NewSession();
+ var vm = new WasserfoerderungViewModel(session, () => { }, new FakeElevationSampler(), new FakeTileSource());
+
+ vm.ChangeMapViewCommand.Execute(new MapViewChange(50.0, 20.0, 19));
+ vm.ResetMapViewCommand.Execute(null);
+
+ Assert.Equal(48.14, vm.MapCenterLatitude);
+ Assert.Equal(11.58, vm.MapCenterLongitude);
+ Assert.Equal(14, 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());
+ }
}