diff --git a/src/LageBuch.App.Android/MainActivity.cs b/src/LageBuch.App.Android/MainActivity.cs
index 21d06b6..0aee49d 100644
--- a/src/LageBuch.App.Android/MainActivity.cs
+++ b/src/LageBuch.App.Android/MainActivity.cs
@@ -65,6 +65,7 @@ protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
new NoopIncidentHostController(),
new LageBuch.App.Shared.Services.AvaloniaUiDispatcher(),
typeof(MainActivity).Assembly.GetName().Version?.ToString() ?? "0.0.0",
+ AndroidAppPaths.RegionsDir(this),
lastSaveFolder: null,
attachmentCacheRoot: AndroidAppPaths.AttachmentCacheDir(this));
return base.CustomizeAppBuilder(builder).WithInterFont();
diff --git a/src/LageBuch.App.Android/Services/AndroidAppPaths.cs b/src/LageBuch.App.Android/Services/AndroidAppPaths.cs
index 62deec0..d053fb9 100644
--- a/src/LageBuch.App.Android/Services/AndroidAppPaths.cs
+++ b/src/LageBuch.App.Android/Services/AndroidAppPaths.cs
@@ -27,4 +27,7 @@ public static string RecentFilesJsonPath(Context context) =>
public static string AttachmentCacheDir(Context context) =>
System.IO.Path.Combine(CacheDir(context), "attachment-cache");
+
+ public static string RegionsDir(Context context) =>
+ System.IO.Path.Combine(context.FilesDir!.AbsolutePath, "regions");
}
diff --git a/src/LageBuch.App.Shared/CompositionRoot.cs b/src/LageBuch.App.Shared/CompositionRoot.cs
index 9d7bd52..d8a09e9 100644
--- a/src/LageBuch.App.Shared/CompositionRoot.cs
+++ b/src/LageBuch.App.Shared/CompositionRoot.cs
@@ -14,6 +14,13 @@ namespace LageBuch.App.Shared;
///
public static class CompositionRoot
{
+ ///
+ /// Raw-served manifest of published Wasserförderung region packs (#150 follow-up) — see
+ /// tools/build-region-pack/README.md for how a pack is built and published here.
+ ///
+ public const string RegionPackManifestUrl =
+ "https://raw.githubusercontent.com/CodeForFire/lagebuch-regions/main/regions.json";
+
public static MainWindowViewModel CreateMainWindowViewModel(
IIncidentStore store,
IMasterDataProvider masterData,
@@ -26,11 +33,15 @@ public static MainWindowViewModel CreateMainWindowViewModel(
IIncidentHostController hostController,
IUiDispatcher uiDispatcher,
string appVersion,
+ string regionsDir,
ILastSaveFolderStore? lastSaveFolder = null,
string? attachmentCacheRoot = null)
{
var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot, new RouteOverviewRenderer());
- var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService);
+ var httpClient = new HttpClient();
+ var regionCatalog = new RegionPackCatalogService(httpClient, RegionPackManifestUrl);
+ var regionInstaller = new RegionPackInstaller(httpClient, regionsDir);
+ var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService, regionCatalog, regionInstaller);
return new MainWindowViewModel(home, editor, dialogs, appVersion);
}
}
diff --git a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
index d38f166..f831397 100644
--- a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
+++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
@@ -3,6 +3,7 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
+using LageBuch.AppLogic.Services;
using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Persistence.Wasserfoerderung;
@@ -15,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
{
@@ -40,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
{
@@ -92,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);
@@ -126,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 80f0447..36146a1 100644
--- a/src/LageBuch.App.Shared/Controls/MapDrawing.cs
+++ b/src/LageBuch.App.Shared/Controls/MapDrawing.cs
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Media;
using Avalonia.Media.Imaging;
+using LageBuch.AppLogic.Services;
using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Persistence.Wasserfoerderung;
@@ -38,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/MasterDataEditorView.axaml b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
index 2561551..8bdea8f 100644
--- a/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
+++ b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LageBuch.AppLogic.ViewModels;assembly=LageBuch.AppLogic"
xmlns:md="clr-namespace:LageBuch.Persistence.MasterData;assembly=LageBuch.Persistence"
+ xmlns:services="clr-namespace:LageBuch.AppLogic.Services;assembly=LageBuch.AppLogic"
x:Class="LageBuch.App.Shared.Views.MasterDataEditorView"
x:DataType="vm:MasterDataEditorViewModel">
@@ -108,18 +109,63 @@
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
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.App/AppPaths.cs b/src/LageBuch.App/AppPaths.cs
index 48937db..4d44bd7 100644
--- a/src/LageBuch.App/AppPaths.cs
+++ b/src/LageBuch.App/AppPaths.cs
@@ -13,6 +13,9 @@ public static class AppPaths
public static string AttachmentCacheDir => Path.Combine(AppDataDir, "attachment-cache");
+ /// Where downloaded Wasserförderung region packs (#150 follow-up) get extracted, one subfolder per slug.
+ public static string RegionsDir => Path.Combine(AppDataDir, "regions");
+
public static string GetAppDataDir(string baseDir)
{
var dir = Path.Combine(baseDir, "Lagebuch");
diff --git a/src/LageBuch.App/Program.cs b/src/LageBuch.App/Program.cs
index f36d325..2fa7e10 100644
--- a/src/LageBuch.App/Program.cs
+++ b/src/LageBuch.App/Program.cs
@@ -37,6 +37,7 @@ private static MainWindowViewModel CreateMainViewModel()
new IncidentHostController(clock, version, uiDispatcher),
uiDispatcher,
version,
+ AppPaths.RegionsDir,
new JsonLastSaveFolderStore(AppPaths.LastSaveFolderJsonPath),
AppPaths.AttachmentCacheDir);
}
diff --git a/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs b/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
new file mode 100644
index 0000000..58286bc
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
@@ -0,0 +1,28 @@
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Lists the region packs (map tiles + elevation) publicly available for download (#150 follow-up)
+/// — the map-side counterpart to Stammdaten's Einsatzgebiet, which used to require hand-preparing
+/// region.mbtiles/region.dem with no guidance at all.
+///
+public interface IRegionPackCatalogService
+{
+ ///
+ /// Never throws — a Stammdaten editor must stay usable offline, so a fetch/parse failure
+ /// yields an empty list rather than propagating an exception.
+ ///
+ Task> GetAvailableRegionsAsync(CancellationToken ct = default);
+}
+
+/// One published, downloadable region pack.
+public sealed record RegionPackInfo(
+ string Name,
+ string Slug,
+ string DownloadUrl,
+ long SizeBytes,
+ double MinLat,
+ double MinLon,
+ double MaxLat,
+ double MaxLon,
+ string BuiltAt,
+ string Attribution);
diff --git a/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs b/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
new file mode 100644
index 0000000..1b3e45a
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
@@ -0,0 +1,8 @@
+namespace LageBuch.AppLogic.Services;
+
+/// Downloads and unpacks a region pack (#150 follow-up) into a local folder.
+public interface IRegionPackInstaller
+{
+ /// Returns the folder the pack was extracted into (ready to use as Einsatzgebiet.FolderPath).
+ Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default);
+}
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/Services/RegionPackCatalogJson.cs b/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
new file mode 100644
index 0000000..08df716
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
@@ -0,0 +1,85 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Reads the region-pack manifest format (#150 follow-up) — a flat JSON array, each entry
+/// describing one downloadable pack. Defensive like MasterDataJson: malformed input, or an
+/// entry missing a required field, is skipped rather than thrown — the manifest is fetched from a
+/// third-party-controlled URL, so a partially bad response must degrade gracefully, not crash
+/// Stammdaten.
+///
+public static class RegionPackCatalogJson
+{
+ // RegionPackInstaller joins this straight onto a base directory (/) —
+ // reject anything that could escape that directory (path separators, "..", empty) here rather
+ // than trusting the installer alone to catch it.
+ private static readonly Regex SafeSlug = new("^[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.Compiled);
+
+ public static IReadOnlyList Parse(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.ValueKind != JsonValueKind.Array)
+ return Array.Empty();
+
+ var result = new List();
+ foreach (var entry in doc.RootElement.EnumerateArray())
+ {
+ if (TryParseEntry(entry, out var region))
+ result.Add(region);
+ }
+ return result;
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static bool TryParseEntry(JsonElement entry, out RegionPackInfo region)
+ {
+ region = null!;
+ if (entry.ValueKind != JsonValueKind.Object)
+ return false;
+
+ if (!TryGetString(entry, "name", out var name) ||
+ !TryGetString(entry, "slug", out var slug) || !SafeSlug.IsMatch(slug) ||
+ !TryGetString(entry, "downloadUrl", out var downloadUrl) ||
+ !TryGetString(entry, "builtAt", out var builtAt) ||
+ !TryGetString(entry, "attribution", out var attribution) ||
+ !entry.TryGetProperty("sizeBytes", out var sizeBytesEl) || sizeBytesEl.ValueKind != JsonValueKind.Number ||
+ !entry.TryGetProperty("boundingBox", out var bbox) || bbox.ValueKind != JsonValueKind.Object ||
+ !TryGetNumber(bbox, "minLat", out var minLat) ||
+ !TryGetNumber(bbox, "minLon", out var minLon) ||
+ !TryGetNumber(bbox, "maxLat", out var maxLat) ||
+ !TryGetNumber(bbox, "maxLon", out var maxLon))
+ {
+ return false;
+ }
+
+ region = new RegionPackInfo(name, slug, downloadUrl, sizeBytesEl.GetInt64(),
+ minLat, minLon, maxLat, maxLon, builtAt, attribution);
+ return true;
+ }
+
+ private static bool TryGetString(JsonElement e, string prop, out string value)
+ {
+ value = string.Empty;
+ if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.String)
+ return false;
+ value = v.GetString()!;
+ return true;
+ }
+
+ private static bool TryGetNumber(JsonElement e, string prop, out double value)
+ {
+ value = 0;
+ if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.Number)
+ return false;
+ value = v.GetDouble();
+ return true;
+ }
+}
diff --git a/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs b/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
new file mode 100644
index 0000000..dd6e0bb
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
@@ -0,0 +1,23 @@
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Fetches the region-pack manifest over HTTP (#150 follow-up). The one place this offline-first
+/// app makes an unprompted network call — but only when the operator opens the Einsatzgebiet
+/// section, and it degrades to an empty list rather than surfacing any error, so Stammdaten stays
+/// fully usable without a connection.
+///
+public sealed class RegionPackCatalogService(HttpClient httpClient, string manifestUrl) : IRegionPackCatalogService
+{
+ public async Task> GetAvailableRegionsAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var json = await httpClient.GetStringAsync(manifestUrl, ct);
+ return RegionPackCatalogJson.Parse(json);
+ }
+ catch (HttpRequestException)
+ {
+ return Array.Empty();
+ }
+ }
+}
diff --git a/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs b/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
new file mode 100644
index 0000000..d8bd319
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
@@ -0,0 +1,63 @@
+using System.IO.Compression;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Downloads a region pack's zip (region.mbtiles + region.dem) and extracts it into
+/// <regionsBaseDir>/<slug> (#150 follow-up). A re-install of the same slug replaces the
+/// folder outright, so a pack update never leaves stale files from a previous version behind.
+///
+public sealed class RegionPackInstaller(HttpClient httpClient, string regionsBaseDir) : IRegionPackInstaller
+{
+ // Downloading is the slow part; reserve a small tail of the progress range for extraction so
+ // the caller sees forward motion continue past "download done" instead of jumping straight to 1.0.
+ private const double DownloadProgressShare = 0.9;
+
+ public async Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default)
+ {
+ progress?.Report(0.0);
+
+ var zipBytes = await DownloadAsync(pack.DownloadUrl, progress, ct);
+
+ // Defense in depth: RegionPackCatalogJson already rejects unsafe slugs when parsing the
+ // manifest, but a slug ending up here from anywhere else must not be able to escape
+ // regionsBaseDir either.
+ var baseFull = Path.GetFullPath(regionsBaseDir) + Path.DirectorySeparatorChar;
+ var folder = Path.GetFullPath(Path.Combine(regionsBaseDir, pack.Slug));
+ if (!folder.StartsWith(baseFull, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Region slug '{pack.Slug}' escapes the regions directory.");
+
+ if (Directory.Exists(folder))
+ Directory.Delete(folder, recursive: true);
+ Directory.CreateDirectory(folder);
+
+ using (var zip = new ZipArchive(new MemoryStream(zipBytes), ZipArchiveMode.Read))
+ zip.ExtractToDirectory(folder);
+
+ progress?.Report(1.0);
+ return folder;
+ }
+
+ private async Task DownloadAsync(string url, IProgress? progress, CancellationToken ct)
+ {
+ using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ var totalBytes = response.Content.Headers.ContentLength;
+ await using var source = await response.Content.ReadAsStreamAsync(ct);
+ using var buffer = new MemoryStream();
+
+ var chunk = new byte[81920];
+ long readSoFar = 0;
+ int read;
+ while ((read = await source.ReadAsync(chunk, ct)) > 0)
+ {
+ await buffer.WriteAsync(chunk.AsMemory(0, read), ct);
+ readSoFar += read;
+ if (totalBytes is > 0)
+ progress?.Report(Math.Min(DownloadProgressShare, (double)readSoFar / totalBytes.Value * DownloadProgressShare));
+ }
+
+ return buffer.ToArray();
+ }
+}
diff --git a/src/LageBuch.App.Shared/Controls/WebMercator.cs b/src/LageBuch.AppLogic/Services/WebMercator.cs
similarity index 89%
rename from src/LageBuch.App.Shared/Controls/WebMercator.cs
rename to src/LageBuch.AppLogic/Services/WebMercator.cs
index 5b7bad3..a4df931 100644
--- a/src/LageBuch.App.Shared/Controls/WebMercator.cs
+++ b/src/LageBuch.AppLogic/Services/WebMercator.cs
@@ -1,11 +1,11 @@
using LageBuch.Domain.Wasserfoerderung;
-namespace LageBuch.App.Shared.Controls;
+namespace LageBuch.AppLogic.Services;
///
/// Standard OSM/slippy-map Web Mercator tile math (#150 Plan B) — pure functions, no Avalonia
-/// dependency, shared by the Wasserförderung map canvas's pan/zoom and the "click adds a route
-/// point" conversion.
+/// dependency, shared by the Wasserförderung map canvas's pan/zoom, the "click adds a route
+/// point" conversion, and deriving a region's initial map view from its tile bounds.
///
public static class WebMercator
{
diff --git a/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
index 72b7e12..ff4f68b 100644
--- a/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
+++ b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
@@ -1,21 +1,31 @@
+using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LageBuch.AppLogic.Services;
using LageBuch.Persistence.MasterData;
namespace LageBuch.AppLogic.ViewModels;
///
-/// Editor for the Wasserförderung region of operation (#150 phase 2): a name and a folder path
-/// expected to hold region.mbtiles and region.dem. Two scalar strings, so this
-/// mirrors 's pattern rather than the list sections' — one bindable
-/// property each, reporting through the shared dirty callback.
+/// Editor for the Wasserförderung region of operation (#150 phase 2, region-pack follow-up):
+/// a name and a folder path expected to hold region.mbtiles and region.dem.
+/// Primarily populated by downloading a published pack from
+/// via — manual / entry
+/// stays available as a fallback for a self-built or hand-placed pack.
///
public sealed partial class EinsatzgebietSection : EditorSection
{
private readonly Action _onChanged;
+ private readonly IRegionPackCatalogService _catalog;
+ private readonly IRegionPackInstaller _installer;
- public EinsatzgebietSection(string title, Einsatzgebiet einsatzgebiet, Action onChanged) : base(title)
+ public EinsatzgebietSection(
+ string title, Einsatzgebiet einsatzgebiet, Action onChanged,
+ IRegionPackCatalogService catalog, IRegionPackInstaller installer) : base(title)
{
_onChanged = onChanged;
+ _catalog = catalog;
+ _installer = installer;
_name = einsatzgebiet.Name;
_folderPath = einsatzgebiet.FolderPath;
}
@@ -24,10 +34,81 @@ public EinsatzgebietSection(string title, Einsatzgebiet einsatzgebiet, Action on
private string _name = string.Empty;
[ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(KartendatenGefunden))]
+ [NotifyPropertyChangedFor(nameof(KartendatenStatus))]
private string _folderPath = string.Empty;
partial void OnNameChanged(string value) => _onChanged();
partial void OnFolderPathChanged(string value) => _onChanged();
public Einsatzgebiet ToEinsatzgebiet() => new(Name, FolderPath);
+
+ // --- Region-pack catalog / download ---
+
+ public ObservableCollection AvailableRegions { get; } = new();
+
+ [ObservableProperty]
+ private RegionPackInfo? _selectedRegion;
+
+ [ObservableProperty]
+ private string? _catalogStatus;
+
+ [ObservableProperty]
+ private double _downloadProgress;
+
+ [RelayCommand]
+ private async Task LoadCatalog()
+ {
+ var regions = await _catalog.GetAvailableRegionsAsync();
+ AvailableRegions.Clear();
+ foreach (var region in regions)
+ AvailableRegions.Add(region);
+
+ CatalogStatus = AvailableRegions.Count == 0
+ ? "Keine Regionen verfügbar — bitte Internetverbindung prüfen, oder Ordner manuell angeben."
+ : null;
+ }
+
+ private bool CanDownloadSelectedRegion => SelectedRegion is not null;
+
+ [RelayCommand(CanExecute = nameof(CanDownloadSelectedRegion))]
+ private async Task DownloadSelectedRegion()
+ {
+ var region = SelectedRegion!;
+ DownloadProgress = 0;
+ var progress = new Progress(p => DownloadProgress = p);
+ var folder = await _installer.DownloadAndInstallAsync(region, progress);
+
+ Name = region.Name;
+ FolderPath = folder;
+ _onChanged();
+ }
+
+ partial void OnSelectedRegionChanged(RegionPackInfo? value) => DownloadSelectedRegionCommand.NotifyCanExecuteChanged();
+
+ // --- File-presence validation: is region.mbtiles/region.dem actually at FolderPath? ---
+
+ public bool KartendatenGefunden =>
+ !string.IsNullOrWhiteSpace(FolderPath)
+ && File.Exists(Path.Combine(FolderPath, "region.mbtiles"))
+ && File.Exists(Path.Combine(FolderPath, "region.dem"));
+
+ public string? KartendatenStatus
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(FolderPath))
+ return null;
+
+ var mbtilesFound = File.Exists(Path.Combine(FolderPath, "region.mbtiles"));
+ var demFound = File.Exists(Path.Combine(FolderPath, "region.dem"));
+ if (mbtilesFound && demFound)
+ return "✓ Kartendaten gefunden.";
+
+ var missing = new List();
+ if (!mbtilesFound) missing.Add("region.mbtiles");
+ if (!demFound) missing.Add("region.dem");
+ return $"✗ Fehlt: {string.Join(", ", missing)}.";
+ }
+ }
}
diff --git a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
index 070df24..db34f86 100644
--- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
@@ -5,6 +5,7 @@
using LageBuch.Domain;
using LageBuch.Domain.Time;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Persistence.MasterData;
using LageBuch.Persistence.Wasserfoerderung;
using LageBuch.Sync;
@@ -239,7 +240,10 @@ private void BuildChildren()
Wasserfoerderung?.Dispose();
var (elevationSampler, tileSource) = BuildWasserfoerderungMapSources();
- Wasserfoerderung = new WasserfoerderungViewModel(_session, OnChanged, elevationSampler, tileSource);
+ var initialMapView = InitialMapViewFrom(tileSource);
+ Wasserfoerderung = new WasserfoerderungViewModel(
+ _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
@@ -283,6 +287,26 @@ private void BuildChildren()
return (new DemFileElevationSampler(demPath), new MbTilesFileSource(mbtilesPath));
}
+ ///
+ /// 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)
+ {
+ if (tileSource?.GetTileBounds() is not { } bounds)
+ return null;
+
+ var centerTileX = (bounds.MinX + bounds.MaxX + 1) / 2.0;
+ var centerTileY = (bounds.MinY + bounds.MaxY + 1) / 2.0;
+ var center = WebMercator.ToGeo(
+ centerTileX * WebMercator.TileSizePixels, centerTileY * WebMercator.TileSizePixels, bounds.Zoom);
+ return (center, bounds.Zoom);
+ }
+
private bool CanClose => !IsReadOnly;
// Closing is permanent (the incident becomes read-only), so confirm first. If a Trupp is
diff --git a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
index a8d7cb4..a210e7d 100644
--- a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
@@ -17,6 +17,8 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private readonly IMasterDataProvider _provider;
private readonly IFileDialogService _dialogs;
private readonly IMasterDataFileService _files;
+ private readonly IRegionPackCatalogService _regionCatalog;
+ private readonly IRegionPackInstaller _regionInstaller;
private MasterDataSet _original = MasterDataSet.Empty;
private bool _originalIsEmpty = true;
@@ -30,11 +32,15 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private SettingsSection _settings = null!;
private EinsatzgebietSection _einsatzgebiet = null!;
- public MasterDataEditorViewModel(IMasterDataProvider provider, IFileDialogService dialogs, IMasterDataFileService files)
+ public MasterDataEditorViewModel(
+ IMasterDataProvider provider, IFileDialogService dialogs, IMasterDataFileService files,
+ IRegionPackCatalogService regionCatalog, IRegionPackInstaller regionInstaller)
{
_provider = provider;
_dialogs = dialogs;
_files = files;
+ _regionCatalog = regionCatalog;
+ _regionInstaller = regionInstaller;
Load();
}
@@ -100,7 +106,9 @@ private void PopulateSections(MasterDataSet set)
Sections.Add(_checklistAbbau = new ChecklistTemplateSection("Checkliste Abbau", set.ChecklistTemplateAbbau, MarkDirty));
Sections.Add(_personnel = new PersonnelSection("Personal", set.Personnel, MarkDirty));
Sections.Add(_vehicles = new VehiclesSection("Fahrzeuge", set.Vehicles, set.Brigades, set.RadioCallSigns, OnVehiclesChanged));
- Sections.Add(_einsatzgebiet = new EinsatzgebietSection("Einsatzgebiet", set.Einsatzgebiet, MarkDirty));
+ Sections.Add(_einsatzgebiet = new EinsatzgebietSection(
+ "Einsatzgebiet", set.Einsatzgebiet, MarkDirty, _regionCatalog, _regionInstaller));
+ _einsatzgebiet.LoadCatalogCommand.Execute(null);
SelectedSection = Sections[Math.Clamp(previousIndex < 0 ? 0 : previousIndex, 0, Sections.Count - 1)];
}
diff --git a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
index a079484..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,9 +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)
+ IElevationSampler? elevationSampler = null, IMapTileSource? tileSource = null,
+ GeoPoint? initialMapCenter = null, int? initialMapZoom = null, int? initialMinZoom = null)
{
_session = session;
_onChanged = onChanged;
@@ -36,6 +46,22 @@ public WasserfoerderungViewModel(
DrawnRoutePoints = new ObservableCollection();
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);
+
+ _initialCenterLatitude = _mapCenterLatitude;
+ _initialCenterLongitude = _mapCenterLongitude;
+ _initialZoom = _mapZoom;
+
_session.Changed += Sync;
Sync();
}
@@ -57,10 +83,13 @@ public WasserfoerderungViewModel(
[ObservableProperty]
private bool _isMapMode;
- // No configured Einsatzgebiet has an obvious default location, so the map opens on a fixed,
- // reasonable German fallback; 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]
@@ -76,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 e7bb604..138aa35 100644
--- a/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
+++ b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
@@ -5,4 +5,18 @@ public interface IMapTileSource
{
/// Raw PNG/JPEG bytes for the XYZ/slippy-map tile, or null when it isn't present.
byte[]? GetTile(int zoom, int x, int y);
+
+ ///
+ /// The XYZ tile-index bounds (inclusive) at the lowest zoom level this source has any tiles
+ /// at, or null when it has none — lets a caller derive a sensible initial map view from the
+ /// 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 2f77759..4f4735a 100644
--- a/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
+++ b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
@@ -25,4 +25,38 @@ public sealed class MbTilesFileSource(string mbtilesFilePath) : IMapTileSource
var result = cmd.ExecuteScalar();
return result as byte[];
}
+
+ public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds()
+ {
+ using var cn = SqliteConnectionFactory.OpenReadOnly(mbtilesFilePath);
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText =
+ "SELECT zoom_level, MIN(tile_column), MAX(tile_column), MIN(tile_row), MAX(tile_row) " +
+ "FROM tiles WHERE zoom_level = (SELECT MIN(zoom_level) FROM tiles) GROUP BY zoom_level;";
+
+ using var reader = cmd.ExecuteReader();
+ if (!reader.Read())
+ return null;
+
+ var zoom = reader.GetInt32(0);
+ var minX = reader.GetInt32(1);
+ var maxX = reader.GetInt32(2);
+ var minTmsRow = reader.GetInt32(3);
+ var maxTmsRow = reader.GetInt32(4);
+
+ // Stored rows are TMS (row 0 = south); flipping back to XYZ (row 0 = north) inverts the
+ // ordering, so the max TMS row becomes the min XYZ row and vice versa.
+ 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/AboutRenderTests.cs b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
index dfd56d1..7a9f813 100644
--- a/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
@@ -93,7 +93,7 @@ private static MainWindowViewModel BuildMainWindowViewModel()
var home = new HomeViewModel(new FakeStore(), masterData,
new EmptyRecent(), dialogs, new FixedClock(), new NoopTicker(), new NoopAlarmService(),
new NoopIncidentHostController(), "0.1.0");
- var editor = new MasterDataEditorViewModel(masterData, dialogs, new NoFiles());
+ var editor = new MasterDataEditorViewModel(masterData, dialogs, new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
return new MainWindowViewModel(home, editor, dialogs, "0.1.0");
}
@@ -109,6 +109,18 @@ private sealed class NoFiles : IMasterDataFileService
public void Write(string path, MasterDataSet set) { }
}
+ private sealed class NoRegionCatalog : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
+ }
+
+ private sealed class NoRegionInstaller : IRegionPackInstaller
+ {
+ public Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default) =>
+ Task.FromResult(string.Empty);
+ }
+
private sealed class EmptyRecent : IRecentFilesStore
{
private readonly List _list = new();
diff --git a/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
index c002440..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;
@@ -18,6 +19,8 @@ public class MapCanvasControlTests
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
@@ -38,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
{
@@ -51,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();
@@ -68,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()
{
@@ -100,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/MasterDataEditorRenderTests.cs b/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
index d294d42..de9f816 100644
--- a/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
@@ -51,10 +51,22 @@ private sealed class NoFiles : IMasterDataFileService
public void Write(string path, MasterDataSet set) { }
}
+ private sealed class NoRegionCatalog : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
+ }
+
+ private sealed class NoRegionInstaller : IRegionPackInstaller
+ {
+ public Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default) =>
+ Task.FromResult(string.Empty);
+ }
+
[AvaloniaFact]
public void The_editor_renders_with_every_category()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
var view = new MasterDataEditorView { DataContext = vm };
var window = new Window { Content = view, Width = 1080, Height = 680 };
window.Show();
@@ -80,7 +92,7 @@ public void The_editor_renders_with_every_category()
[AvaloniaFact]
public void Checkliste_aufbau_section_renders_text_and_mandatory_checkbox_per_row()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
vm.SelectedSection = vm.Sections.Single(s => s.Title == "Checkliste Aufbau");
var view = new MasterDataEditorView { DataContext = vm };
var window = new Window { Content = view, Width = 1080, Height = 680 };
@@ -104,7 +116,7 @@ public void Checkliste_aufbau_section_renders_text_and_mandatory_checkbox_per_ro
[AvaloniaFact]
public void Links_section_renders_name_and_url_per_row()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
vm.SelectedSection = vm.Sections.Single(s => s.Title == "Links");
var view = new MasterDataEditorView { DataContext = vm };
var window = new Window { Content = view, Width = 1080, Height = 680 };
@@ -127,7 +139,7 @@ public void Links_section_renders_name_and_url_per_row()
[AvaloniaFact]
public void Fahrzeuge_section_renders_wache_callsign_and_seats_per_row()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
var section = (VehiclesSection)vm.Sections.Single(s => s.Title == "Fahrzeuge");
section.AddCommand.Execute(null);
section.Rows[0].Wache = "FFB Wache 1";
@@ -158,4 +170,51 @@ public void Fahrzeuge_section_renders_wache_callsign_and_seats_per_row()
using var frame = window.CaptureRenderedFrame()!;
frame.SavePng(Path.Combine(dir, "master-data-editor-fahrzeuge-after.png"));
}
+
+ private sealed class FakeRegionCatalog(IReadOnlyList regions) : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult(regions);
+ }
+
+ // #150 follow-up (downloadable region packs): a dropdown lists the published catalog, and
+ // the old manual-folder fields collapse behind "Erweitert" instead of being the primary path.
+ [AvaloniaFact]
+ public void Einsatzgebiet_section_renders_region_dropdown_with_advanced_fields_collapsed()
+ {
+ var pack = new RegionPackInfo(
+ "Landkreis Fürstenfeldbruck", "ffb", "https://example.org/ffb.zip", 12_345_678,
+ 48.0877067, 10.9930275, 48.2967233, 11.4128816, "2026-09-01",
+ "© OpenStreetMap contributors (ODbL). Höhendaten: SRTM (NASA/USGS, gemeinfrei).");
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(),
+ new FakeRegionCatalog(new[] { pack }), new NoRegionInstaller());
+ vm.SelectedSection = vm.Sections.Single(s => s.Title == "Einsatzgebiet");
+ var view = new MasterDataEditorView { DataContext = vm };
+ var window = new Window { Content = view, Width = 1080, Height = 680 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+
+ var comboBox = view.GetVisualDescendants().OfType().Single(c => c.Name == "RegionComboBox");
+ Assert.Single(comboBox.ItemsSource!.Cast