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 0803b7e..d8a09e9 100644
--- a/src/LageBuch.App.Shared/CompositionRoot.cs
+++ b/src/LageBuch.App.Shared/CompositionRoot.cs
@@ -1,3 +1,4 @@
+using LageBuch.App.Shared.Services;
using LageBuch.AppLogic.Services;
using LageBuch.AppLogic.ViewModels;
using LageBuch.Domain.Time;
@@ -13,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,
@@ -25,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);
- var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService);
+ var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot, new RouteOverviewRenderer());
+ 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
new file mode 100644
index 0000000..f831397
--- /dev/null
+++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
@@ -0,0 +1,289 @@
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using LageBuch.AppLogic.Services;
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Persistence.Wasserfoerderung;
+
+namespace LageBuch.App.Shared.Controls;
+
+///
+/// Draws map tiles for the operator's Einsatzgebiet and the in-progress Wasserförderung route
+/// (#150 Plan B). Plain with a hand-rolled — there's no
+/// XAML template, just tiles and a polyline over them. Left-click adds a route point (via
+/// ), 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. 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
+{
+ public static readonly StyledProperty TileSourceProperty =
+ AvaloniaProperty.Register(nameof(TileSource));
+
+ public static readonly StyledProperty CenterLatitudeProperty =
+ AvaloniaProperty.Register(nameof(CenterLatitude));
+
+ public static readonly StyledProperty CenterLongitudeProperty =
+ AvaloniaProperty.Register(nameof(CenterLongitude));
+
+ public static readonly StyledProperty ZoomProperty =
+ AvaloniaProperty.Register(nameof(Zoom), defaultValue: 15);
+
+ public static readonly StyledProperty?> RoutePointsProperty =
+ AvaloniaProperty.Register?>(nameof(RoutePoints));
+
+ public static readonly StyledProperty PointClickedCommandProperty =
+ AvaloniaProperty.Register(nameof(PointClickedCommand));
+
+ 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);
+ }
+
+ // 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
+ {
+ get => GetValue(TileSourceProperty);
+ set => SetValue(TileSourceProperty, value);
+ }
+
+ public double CenterLatitude
+ {
+ get => GetValue(CenterLatitudeProperty);
+ set => SetValue(CenterLatitudeProperty, value);
+ }
+
+ public double CenterLongitude
+ {
+ get => GetValue(CenterLongitudeProperty);
+ set => SetValue(CenterLongitudeProperty, value);
+ }
+
+ public int Zoom
+ {
+ get => GetValue(ZoomProperty);
+ set => SetValue(ZoomProperty, value);
+ }
+
+ public IReadOnlyList? RoutePoints
+ {
+ get => GetValue(RoutePointsProperty);
+ set => SetValue(RoutePointsProperty, value);
+ }
+
+ /// Invoked with the clicked point's on a left click.
+ public ICommand? PointClickedCommand
+ {
+ get => GetValue(PointClickedCommandProperty);
+ set => SetValue(PointClickedCommandProperty, value);
+ }
+
+ /// Invoked (no parameter) on a right click.
+ public ICommand? UndoRequestedCommand
+ {
+ get => GetValue(UndoRequestedCommandProperty);
+ 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);
+
+ var width = Bounds.Width;
+ var height = Bounds.Height;
+ if (width <= 0 || height <= 0)
+ return;
+
+ // Avalonia's compositor hit-tests against painted geometry, not just layout bounds — a
+ // control that draws nothing where a tile is missing (or before any route point exists)
+ // would be unclickable there. This transparent fill keeps the whole control clickable
+ // regardless of tile/route state.
+ context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height));
+
+ MapDrawing.Draw(context, TileSource, RoutePoints, CenterLatitude, CenterLongitude, Zoom, width, height);
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+
+ var current = e.GetCurrentPoint(this);
+ if (current.Properties.IsRightButtonPressed)
+ {
+ if (UndoRequestedCommand?.CanExecute(null) == true)
+ UndoRequestedCommand.Execute(null);
+ e.Handled = true;
+ return;
+ }
+
+ if (!current.Properties.IsLeftButtonPressed)
+ return;
+
+ if ((e.KeyModifiers & KeyModifiers.Control) != 0)
+ {
+ _panStartScreenPoint = current.Position;
+ _panStartCenterLatitude = CenterLatitude;
+ _panStartCenterLongitude = CenterLongitude;
+ e.Handled = true;
+ return;
+ }
+
+ var geoPoint = ScreenToGeo(current.Position);
+
+ if (PointClickedCommand?.CanExecute(geoPoint) == true)
+ 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
new file mode 100644
index 0000000..36146a1
--- /dev/null
+++ b/src/LageBuch.App.Shared/Controls/MapDrawing.cs
@@ -0,0 +1,112 @@
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using LageBuch.AppLogic.Services;
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Persistence.Wasserfoerderung;
+
+namespace LageBuch.App.Shared.Controls;
+
+///
+/// The tile+polyline drawing shared by 's live view and
+/// RouteOverviewRenderer's off-screen PDF snapshot (#150 Plan B) — one implementation of
+/// "paint the map centered at (lat,lon)/zoom into this rectangle" for both.
+///
+public static class MapDrawing
+{
+ private static readonly IPen RoutePen = new Pen(Brushes.OrangeRed, 3);
+ private static readonly IBrush RoutePointBrush = Brushes.OrangeRed;
+ private const double RoutePointRadius = 5;
+
+ public static void Draw(
+ DrawingContext context, IMapTileSource? tileSource, IReadOnlyList? routePoints,
+ double centerLatitude, double centerLongitude, int zoom, double width, double height)
+ {
+ if (width <= 0 || height <= 0)
+ return;
+
+ var (centerX, centerY) = WebMercator.ToWorldPixel(new GeoPoint(centerLatitude, centerLongitude), zoom);
+
+ DrawTiles(context, tileSource, zoom, centerX, centerY, width, height);
+ DrawRoute(context, routePoints, zoom, centerX, centerY, width, height);
+ }
+
+ private static void DrawTiles(
+ DrawingContext context, IMapTileSource? tileSource, int zoom, double centerX, double centerY, double width, double height)
+ {
+ if (tileSource is null)
+ return;
+
+ 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)
+ {
+ 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, 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)
+ {
+ if (routePoints is not { Count: > 0 })
+ return;
+
+ Point? previous = null;
+ foreach (var geoPoint in routePoints)
+ {
+ var (worldX, worldY) = WebMercator.ToWorldPixel(geoPoint, zoom);
+ var screen = new Point(worldX - centerX + width / 2, worldY - centerY + height / 2);
+ if (previous is { } prev)
+ context.DrawLine(RoutePen, prev, screen);
+ context.DrawEllipse(RoutePointBrush, null, screen, RoutePointRadius, RoutePointRadius);
+ previous = screen;
+ }
+ }
+}
diff --git a/src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs b/src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs
new file mode 100644
index 0000000..1459e35
--- /dev/null
+++ b/src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs
@@ -0,0 +1,62 @@
+using Avalonia;
+using Avalonia.Media.Imaging;
+using LageBuch.App.Shared.Controls;
+using LageBuch.AppLogic.Services;
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Persistence.Wasserfoerderung;
+
+namespace LageBuch.App.Shared.Services;
+
+///
+/// Renders a small map snapshot of a drawn route off-screen for the PDF (#150 phase 2), sharing
+/// with 's live view. Lives here (not in
+/// LageBuch.App) because App.Shared already has the Avalonia/Skia reference every view uses, and
+/// is already where the composition root wires this kind of cross-cutting service.
+///
+public sealed class RouteOverviewRenderer : IRouteOverviewRenderer
+{
+ private const int ImageWidth = 640;
+ private const int ImageHeight = 400;
+ private const double Margin = 40;
+ private const int MaxZoom = 18;
+ private const int MinZoom = 1;
+
+ public byte[]? Render(IReadOnlyList routePoints, IMapTileSource tiles)
+ {
+ ArgumentNullException.ThrowIfNull(routePoints);
+ ArgumentNullException.ThrowIfNull(tiles);
+ if (routePoints.Count < 2)
+ return null;
+
+ var minLat = routePoints.Min(p => p.Latitude);
+ var maxLat = routePoints.Max(p => p.Latitude);
+ var minLon = routePoints.Min(p => p.Longitude);
+ var maxLon = routePoints.Max(p => p.Longitude);
+ var center = new GeoPoint((minLat + maxLat) / 2, (minLon + maxLon) / 2);
+ var zoom = FitZoom(minLat, minLon, maxLat, maxLon);
+
+ using var bitmap = new RenderTargetBitmap(new PixelSize(ImageWidth, ImageHeight));
+ using (var context = bitmap.CreateDrawingContext())
+ {
+ MapDrawing.Draw(context, tiles, routePoints, center.Latitude, center.Longitude, zoom, ImageWidth, ImageHeight);
+ }
+
+ using var stream = new MemoryStream();
+ bitmap.Save(stream, PngBitmapEncoderOptions.Default);
+ return stream.ToArray();
+ }
+
+ /// Largest zoom at which the route's bounding box still fits inside the image (minus ).
+ private static int FitZoom(double minLat, double minLon, double maxLat, double maxLon)
+ {
+ for (var zoom = MaxZoom; zoom > MinZoom; zoom--)
+ {
+ var (minX, minY) = WebMercator.ToWorldPixel(new GeoPoint(maxLat, minLon), zoom); // north-west
+ var (maxX, maxY) = WebMercator.ToWorldPixel(new GeoPoint(minLat, maxLon), zoom); // south-east
+ if (maxX - minX <= ImageWidth - 2 * Margin && maxY - minY <= ImageHeight - 2 * Margin)
+ return zoom;
+ }
+
+ return MinZoom;
+ }
+}
diff --git a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml
index 2cf24ac..a14a27b 100644
--- a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml
+++ b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml
@@ -286,6 +286,11 @@
+
+
+
+
+
@@ -108,6 +109,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
new file mode 100644
index 0000000..49c9cce
--- /dev/null
+++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml.cs b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml.cs
new file mode 100644
index 0000000..48cc1e5
--- /dev/null
+++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml.cs
@@ -0,0 +1,8 @@
+using Avalonia.Controls;
+
+namespace LageBuch.App.Shared.Views;
+
+public partial class WasserfoerderungView : UserControl
+{
+ public WasserfoerderungView() => InitializeComponent();
+}
\ No newline at end of file
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/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs
index 8c7e1ce..84890a3 100644
--- a/src/LageBuch.AppLogic/LocalIncidentSession.cs
+++ b/src/LageBuch.AppLogic/LocalIncidentSession.cs
@@ -8,6 +8,7 @@
using LageBuch.Domain.Time;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Sync;
namespace LageBuch.AppLogic;
@@ -103,7 +104,7 @@ public void ContinueEditing(SessionOperator op)
// every attached file's bytes land in this device's own sibling folder the moment it's added —
// whether typed here or uploaded by a joined client via AddFileCommand — so this never needs a
// network pull, only IIncidentStore.
- public Task ExportPdfAsync()
+ public Task ExportPdfAsync(IReadOnlyDictionary? routeOverviewPngById = null)
{
var fileBytes = new Dictionary();
foreach (var file in Incident.Files)
@@ -112,7 +113,7 @@ public Task ExportPdfAsync()
if (bytes is not null)
fileBytes[file.Id] = bytes;
}
- return Task.FromResult(IncidentPdf.Generate(Incident, fileBytes));
+ return Task.FromResult(IncidentPdf.Generate(Incident, fileBytes, routeOverviewPngById));
}
// --- IIncidentSession mutation surface: apply → persist → notify. ---
@@ -154,6 +155,17 @@ public void AddTask(string text, string? assignee, TaskImportance importance, Ta
public void SetTaskCompleted(Guid taskId, bool isDone) =>
Mutate(() => Incident.SetTaskCompleted(taskId, isDone, _clock, RequireOperator()));
+ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprechpartner, double lengthMeters, double elevationRiseMeters) =>
+ Mutate(() => Incident.AddWasserfoerderungLeitung(uebergabestelle, ansprechpartner, lengthMeters, elevationRiseMeters));
+
+ public void RemoveWasserfoerderungLeitung(Guid leitungId) =>
+ Mutate(() => Incident.RemoveWasserfoerderungLeitung(leitungId));
+
+ public void AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle, string? ansprechpartner,
+ IReadOnlyList routePoints, IReadOnlyList profile) =>
+ Mutate(() => Incident.AddWasserfoerderungLeitungFromRoute(uebergabestelle, ansprechpartner, routePoints, profile));
+
public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
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/IRouteOverviewRenderer.cs b/src/LageBuch.AppLogic/Services/IRouteOverviewRenderer.cs
new file mode 100644
index 0000000..298d58a
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRouteOverviewRenderer.cs
@@ -0,0 +1,15 @@
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Persistence.Wasserfoerderung;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Renders a small map snapshot (tiles + polyline) of a drawn Wasserförderung route for the PDF
+/// (#150 phase 2). Implemented in LageBuch.App.Shared using Avalonia's off-screen rendering —
+/// AppLogic and Documents stay Avalonia-free, so this is the one port between them.
+///
+public interface IRouteOverviewRenderer
+{
+ /// PNG bytes framing the whole route, or null if it can't be rendered.
+ byte[]? Render(IReadOnlyList routePoints, IMapTileSource tiles);
+}
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.AppLogic/Services/WebMercator.cs b/src/LageBuch.AppLogic/Services/WebMercator.cs
new file mode 100644
index 0000000..a4df931
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/WebMercator.cs
@@ -0,0 +1,35 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+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, the "click adds a route
+/// point" conversion, and deriving a region's initial map view from its tile bounds.
+///
+public static class WebMercator
+{
+ public const int TileSizePixels = 256;
+
+ /// Lat/lon (degrees) to the pixel position in the whole rendered world map at .
+ public static (double X, double Y) ToWorldPixel(GeoPoint point, int zoom)
+ {
+ var n = TileSizePixels * Math.Pow(2, zoom);
+ var x = n * (point.Longitude / 360.0 + 0.5);
+ var sinLat = Math.Sin(point.Latitude * Math.PI / 180.0);
+ var y = n * (0.5 - Math.Log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI));
+ return (x, y);
+ }
+
+ /// Inverse of .
+ public static GeoPoint ToGeo(double worldX, double worldY, int zoom)
+ {
+ var n = TileSizePixels * Math.Pow(2, zoom);
+ var lon = (worldX / n - 0.5) * 360.0;
+ var latRad = 2 * Math.Atan(Math.Exp(Math.PI * (1 - 2 * worldY / n))) - Math.PI / 2;
+ return new GeoPoint(latRad * 180.0 / Math.PI, lon);
+ }
+
+ public static (int X, int Y) ToTileIndex(double worldX, double worldY) =>
+ ((int)Math.Floor(worldX / TileSizePixels), (int)Math.Floor(worldY / TileSizePixels));
+}
diff --git a/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
new file mode 100644
index 0000000..ff4f68b
--- /dev/null
+++ b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
@@ -0,0 +1,114 @@
+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, 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,
+ IRegionPackCatalogService catalog, IRegionPackInstaller installer) : base(title)
+ {
+ _onChanged = onChanged;
+ _catalog = catalog;
+ _installer = installer;
+ _name = einsatzgebiet.Name;
+ _folderPath = einsatzgebiet.FolderPath;
+ }
+
+ [ObservableProperty]
+ 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/HomeViewModel.cs b/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
index b63ddbd..e9d86a1 100644
--- a/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
@@ -32,8 +32,11 @@ public sealed partial class HomeViewModel : ObservableObject
// Where a joined client caches pulled attachment bytes (see RemoteIncidentSession.GetFileBytesAsync).
// Null (most tests) just means "no caching" -- correct, only not free -- not an error.
private readonly string? _attachmentCacheRoot;
+ // Renders a route's map snapshot for the PDF (#150 phase 2). Null (most tests, and any build
+ // without Avalonia access) just means "no image in the PDF" -- the numeric table still exports.
+ private readonly IRouteOverviewRenderer? _routeOverviewRenderer;
- public HomeViewModel(IIncidentStore store, IMasterDataProvider masterData, IRecentFilesStore recent, IFileDialogService dialogs, IClock clock, ITicker ticker, IAlarmService alarm, IIncidentHostController hostController, string appVersion, IUiDispatcher? uiDispatcher = null, ILastSaveFolderStore? lastSaveFolder = null, string? attachmentCacheRoot = null)
+ public HomeViewModel(IIncidentStore store, IMasterDataProvider masterData, IRecentFilesStore recent, IFileDialogService dialogs, IClock clock, ITicker ticker, IAlarmService alarm, IIncidentHostController hostController, string appVersion, IUiDispatcher? uiDispatcher = null, ILastSaveFolderStore? lastSaveFolder = null, string? attachmentCacheRoot = null, IRouteOverviewRenderer? routeOverviewRenderer = null)
{
_store = store;
_masterData = masterData;
@@ -47,6 +50,7 @@ public HomeViewModel(IIncidentStore store, IMasterDataProvider masterData, IRece
_uiDispatcher = uiDispatcher ?? new ImmediateUiDispatcher();
_lastSaveFolder = lastSaveFolder;
_attachmentCacheRoot = attachmentCacheRoot;
+ _routeOverviewRenderer = routeOverviewRenderer;
RecentFiles = new ObservableCollection(
SortByFileNameDescending(recent.GetRecent().Select(path => new RecentFileItem(path, IsClosed(path)))));
}
@@ -153,7 +157,8 @@ private void OpenWorkspace(LocalIncidentSession session, string path, Persistenc
if (existing is not null)
RecentFiles.Remove(existing);
InsertSortedByFileNameDescending(new RecentFileItem(path, session.Incident.State == IncidentState.Closed));
- var workspace = new IncidentWorkspaceViewModel(session, _clock, _ticker, md, _dialogs, _alarm, _hostController);
+ var workspace = new IncidentWorkspaceViewModel(
+ session, _clock, _ticker, md, _dialogs, _alarm, _hostController, _routeOverviewRenderer);
WorkspaceOpened?.Invoke(workspace);
}
diff --git a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
index d07165a..db34f86 100644
--- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
@@ -5,7 +5,9 @@
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;
namespace LageBuch.AppLogic.ViewModels;
@@ -22,8 +24,9 @@ public sealed partial class IncidentWorkspaceViewModel : ObservableObject
private readonly IFileDialogService _dialogs;
private readonly IAlarmService _alarm;
private readonly IIncidentHostController _hostController;
+ private readonly IRouteOverviewRenderer? _routeOverviewRenderer;
- public IncidentWorkspaceViewModel(IIncidentSession session, IClock clock, ITicker ticker, MasterDataSet masterData, IFileDialogService dialogs, IAlarmService alarm, IIncidentHostController hostController)
+ public IncidentWorkspaceViewModel(IIncidentSession session, IClock clock, ITicker ticker, MasterDataSet masterData, IFileDialogService dialogs, IAlarmService alarm, IIncidentHostController hostController, IRouteOverviewRenderer? routeOverviewRenderer = null)
{
_session = session;
_local = session as LocalIncidentSession;
@@ -33,6 +36,7 @@ public IncidentWorkspaceViewModel(IIncidentSession session, IClock clock, ITicke
_dialogs = dialogs;
_alarm = alarm;
_hostController = hostController;
+ _routeOverviewRenderer = routeOverviewRenderer;
IsReadOnly = session.IsReadOnly;
// Seed the backing field directly so initialization doesn't trigger a write-back/save.
_incidentNumberInput = _session.Incident.IncidentNumber?.Value ?? string.Empty;
@@ -156,6 +160,7 @@ private void ConfirmIncidentNumber()
public LinksViewModel Links { get; private set; } = null!;
public TasksViewModel Tasks { get; private set; } = null!;
public ReminderViewModel? Reminder { get; private set; }
+ public WasserfoerderungViewModel Wasserfoerderung { get; private set; } = null!;
public string StatusDisplay => Formatting.State(_session.Incident.State);
@@ -233,6 +238,13 @@ private void BuildChildren()
Tasks?.Dispose();
Tasks = new TasksViewModel(_session, _clock, _ticker, _alarm, _masterData, OnChanged);
+ Wasserfoerderung?.Dispose();
+ var (elevationSampler, tileSource) = BuildWasserfoerderungMapSources();
+ 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
// client must not run its own, or the host's journal would be double-logged.
@@ -252,10 +264,49 @@ private void BuildChildren()
OnPropertyChanged(nameof(Files));
OnPropertyChanged(nameof(Links));
OnPropertyChanged(nameof(Tasks));
+ OnPropertyChanged(nameof(Wasserfoerderung));
OnPropertyChanged(nameof(Reminder));
OnPropertyChanged(nameof(HasReminder));
}
+ ///
+ /// Builds the map data sources for the Wasserförderung tab's "Karte" mode (#150 phase 2) from
+ /// the Stammdaten-configured Einsatzgebiet, or (null, null) when unconfigured or the region
+ /// folder is missing either file — in which case the tab silently falls back to Manuell entry.
+ ///
+ private (IElevationSampler? ElevationSampler, IMapTileSource? TileSource) BuildWasserfoerderungMapSources()
+ {
+ if (!_masterData.Einsatzgebiet.IsConfigured)
+ return (null, null);
+
+ var demPath = Path.Combine(_masterData.Einsatzgebiet.FolderPath, "region.dem");
+ var mbtilesPath = Path.Combine(_masterData.Einsatzgebiet.FolderPath, "region.mbtiles");
+ if (!File.Exists(demPath) || !File.Exists(mbtilesPath))
+ return (null, null);
+
+ 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
@@ -327,10 +378,37 @@ private async Task ExportPdfAsync()
var path = await _dialogs.PickExportPdfAsync(suggested);
if (string.IsNullOrWhiteSpace(path))
return;
- await File.WriteAllBytesAsync(path, await _local!.ExportPdfAsync());
+ await File.WriteAllBytesAsync(path, await _local!.ExportPdfAsync(BuildRouteOverviewPngById()));
await _dialogs.ShareFileAsync(path, "application/pdf");
}
+ ///
+ /// Renders a map snapshot for every route-based Wasserförderung Leitung (#150 phase 2), when
+ /// both a renderer and the region's tiles are available; a Leitung the renderer fails on (or
+ /// with no route at all — Plan A manual entry) simply has no entry, unchanged from Phase 1.
+ ///
+ private IReadOnlyDictionary BuildRouteOverviewPngById()
+ {
+ var result = new Dictionary();
+ if (_routeOverviewRenderer is null)
+ return result;
+
+ var (_, tileSource) = BuildWasserfoerderungMapSources();
+ if (tileSource is null)
+ return result;
+
+ foreach (var leitung in _session.Incident.Wasserfoerderung)
+ {
+ if (leitung.RoutePoints is null)
+ continue;
+ var png = _routeOverviewRenderer.Render(leitung.RoutePoints, tileSource);
+ if (png is not null)
+ result[leitung.Id] = png;
+ }
+
+ return result;
+ }
+
// ===== Multi-device hosting (#52): flip "Im Netzwerk freigeben" to expose this open incident. =====
// Only offered on a platform that can host and while the incident is editable — a read-only
diff --git a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
index 7f92f60..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;
@@ -28,12 +30,17 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private PersonnelSection _personnel = null!;
private VehiclesSection _vehicles = null!;
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();
}
@@ -99,6 +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, _regionCatalog, _regionInstaller));
+ _einsatzgebiet.LoadCatalogCommand.Execute(null);
SelectedSection = Sections[Math.Clamp(previousIndex < 0 ? 0 : previousIndex, 0, Sections.Count - 1)];
}
@@ -140,6 +150,7 @@ private MasterDataSet BuildSet() => _original with
Personnel = _personnel.ToPeople(),
Vehicles = _vehicles.ToValues(),
Settings = _settings.ToSettings(),
+ Einsatzgebiet = _einsatzgebiet.ToEinsatzgebiet(),
// Streets are not editable here; _original carries them through unchanged.
};
diff --git a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
new file mode 100644
index 0000000..c8db13e
--- /dev/null
+++ b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
@@ -0,0 +1,276 @@
+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;
+using LageBuch.Sync;
+
+namespace LageBuch.AppLogic.ViewModels;
+
+///
+/// The WASSERFÖRDERUNG tab (#150, Plan A): plans one Förderstrecke-Leitung (Ltg 1, Ltg 2, …)
+/// per row. Derivation lives entirely in — this VM only sends
+/// the plan inputs through the session and renders the resulting immutable figures. Rows rebuild
+/// wholesale on every change (remote broadcast included), mirroring .
+/// Domain guards (zero/negative length, a climb too steep for one hose) surface on
+/// instead of crashing the app — the same pattern FilesViewModel uses for its async failures.
+///
+public sealed partial class WasserfoerderungViewModel : ObservableObject, IDisposable
+{
+ private readonly IIncidentSession _session;
+ private readonly Action _onChanged;
+ 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, int? initialMinZoom = null)
+ {
+ _session = session;
+ _onChanged = onChanged;
+ _elevationSampler = elevationSampler;
+ _tileSource = tileSource;
+ IsReadOnly = session.IsReadOnly;
+ Rows = new ObservableCollection();
+ 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();
+ }
+
+ public bool IsReadOnly { get; }
+ public ObservableCollection Rows { get; }
+
+ /// True once both a tile source and an elevation sampler are configured — i.e. the
+ /// operator's Einsatzgebiet points at a folder that actually holds region.mbtiles + region.dem.
+ public bool IsMapModeAvailable => _elevationSampler is not null && _tileSource is not null;
+
+ public IMapTileSource? TileSource => _tileSource;
+
+ /// The in-progress polyline drawn on the map (#150 Plan B); cleared once a Leitung is finished.
+ public ObservableCollection DrawnRoutePoints { get; }
+
+ /// Manuell (Plan A) vs. Karte (Plan B) input mode. The view gates the toggle on
+ /// — this property itself does not re-check it.
+ [ObservableProperty]
+ private bool _isMapMode;
+
+ // 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]
+ private double _mapCenterLatitude = 48.14;
+
+ [ObservableProperty]
+ private double _mapCenterLongitude = 11.58;
+
+ [ObservableProperty]
+ private int _mapZoom = 14;
+
+ [RelayCommand]
+ private void ZoomIn() => MapZoom = Math.Min(MaxZoom, MapZoom + 1);
+
+ [RelayCommand]
+ 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);
+
+ private bool CanUndoLastRoutePoint => DrawnRoutePoints.Count > 0;
+
+ [RelayCommand(CanExecute = nameof(CanUndoLastRoutePoint))]
+ private void UndoLastRoutePoint() => DrawnRoutePoints.RemoveAt(DrawnRoutePoints.Count - 1);
+
+ [RelayCommand]
+ private void ClearRoute() => DrawnRoutePoints.Clear();
+
+ private bool CanFinishRoute => !IsReadOnly && DrawnRoutePoints.Count >= 2 && _elevationSampler is not null;
+
+ /// "Fertig": samples the drawn polyline and records the Leitung from it (#150 Plan B).
+ [RelayCommand(CanExecute = nameof(CanFinishRoute))]
+ private void FinishRoute()
+ {
+ ErrorMessage = null;
+ try
+ {
+ var route = DrawnRoutePoints.ToList();
+ var profile = _elevationSampler!.Sample(route);
+ _session.AddWasserfoerderungLeitungFromRoute(NewUebergabestelle, NewAnsprechpartner, route, profile);
+ NewUebergabestelle = string.Empty;
+ NewAnsprechpartner = string.Empty;
+ DrawnRoutePoints.Clear();
+ _onChanged();
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ [ObservableProperty]
+ private string? _newUebergabestelle;
+
+ [ObservableProperty]
+ private string? _newAnsprechpartner;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddLeitungCommand))]
+ private double? _newLengthMeters;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddLeitungCommand))]
+ private double? _newElevationRiseMeters;
+
+ [ObservableProperty]
+ private string? _errorMessage;
+
+ private bool CanAddLeitung =>
+ !IsReadOnly && NewLengthMeters is { } length && length > 0 && NewElevationRiseMeters is { } rise && rise >= 0;
+
+ [RelayCommand(CanExecute = nameof(CanAddLeitung))]
+ private void AddLeitung()
+ {
+ ErrorMessage = null;
+ try
+ {
+ _session.AddWasserfoerderungLeitung(NewUebergabestelle, NewAnsprechpartner,
+ NewLengthMeters!.Value, NewElevationRiseMeters!.Value);
+ NewUebergabestelle = string.Empty;
+ NewAnsprechpartner = string.Empty;
+ NewLengthMeters = null;
+ NewElevationRiseMeters = null;
+ _onChanged();
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ public void Sync()
+ {
+ var rows = _session.Incident.Wasserfoerderung
+ .Select(l => new WasserfoerderungLeitungRow(_session, l, IsReadOnly, _onChanged))
+ .ToList();
+
+ Rows.Clear();
+ foreach (var row in rows)
+ Rows.Add(row);
+ }
+
+ public void Dispose()
+ {
+ _session.Changed -= Sync;
+ }
+}
+
+/// One rendered Leitung row. Immutable display strings plus the remove action.
+public sealed partial class WasserfoerderungLeitungRow : ObservableObject
+{
+ private readonly IIncidentSession _session;
+ private readonly Guid _id;
+ private readonly Action _onChanged;
+
+ public WasserfoerderungLeitungRow(IIncidentSession session, WasserfoerderungLeitung leitung, bool isReadOnly, Action onChanged)
+ {
+ _session = session;
+ _id = leitung.Id;
+ _onChanged = onChanged;
+ IsReadOnly = isReadOnly;
+ NumberDisplay = $"Ltg {leitung.Number}";
+ UebergabestelleDisplay = string.IsNullOrWhiteSpace(leitung.Uebergabestelle) ? "—" : leitung.Uebergabestelle;
+ AnsprechpartnerDisplay = string.IsNullOrWhiteSpace(leitung.Ansprechpartner) ? "—" : leitung.Ansprechpartner;
+ LengthDisplay = Formatting.Meters(leitung.LengthMeters);
+ RiseDisplay = leitung.ElevationRiseMeters > 0 ? Formatting.Meters(leitung.ElevationRiseMeters) : "—";
+ BLengthsDisplay = leitung.HoseCount.ToString();
+ FlowDisplay = $"{leitung.FlowLMin} l/min";
+ PumpDisplay = leitung.PumpCount.ToString();
+ ReservePumpDisplay = leitung.ReservePumpCount.ToString();
+ ReserveHoseDisplay = leitung.ReserveHoseCount.ToString();
+ ResultDisplay = BuildResult(leitung);
+ }
+
+ public Guid Id => _id;
+ public bool IsReadOnly { get; }
+ public string NumberDisplay { get; }
+ public string UebergabestelleDisplay { get; }
+ public string AnsprechpartnerDisplay { get; }
+ public string LengthDisplay { get; }
+ public string RiseDisplay { get; }
+ public string BLengthsDisplay { get; }
+ public string FlowDisplay { get; }
+ public string PumpDisplay { get; }
+ public string ReservePumpDisplay { get; }
+ public string ReserveHoseDisplay { get; }
+ public string ResultDisplay { get; }
+
+ [RelayCommand]
+ private void Remove()
+ {
+ _session.RemoveWasserfoerderungLeitung(_id);
+ _onChanged();
+ }
+
+ private static string BuildResult(WasserfoerderungLeitung l) => l.PumpCount switch
+ {
+ 0 => "Direktleitung",
+ 1 => $"{l.PumpCount} Pumpe",
+ _ => $"{l.PumpCount} Pumpen",
+ };
+}
\ No newline at end of file
diff --git a/src/LageBuch.Documents/Formatting.cs b/src/LageBuch.Documents/Formatting.cs
index dccb004..e4f62db 100644
--- a/src/LageBuch.Documents/Formatting.cs
+++ b/src/LageBuch.Documents/Formatting.cs
@@ -43,4 +43,6 @@ public static string OrDash(string? value) =>
TaskUrgency.Medium => "Mittel",
_ => "Niedrig",
};
+
+ public static string Meters(double meters) => $"{meters:0.###} m";
}
diff --git a/src/LageBuch.Documents/IncidentPdf.cs b/src/LageBuch.Documents/IncidentPdf.cs
index 32606fd..fd18d22 100644
--- a/src/LageBuch.Documents/IncidentPdf.cs
+++ b/src/LageBuch.Documents/IncidentPdf.cs
@@ -13,13 +13,22 @@ public static class IncidentPdf
/// pages via . An entry with no bytes supplied (a missing
/// sibling-folder file) is skipped rather than failing the export.
///
- public static byte[] Generate(Incident incident, IReadOnlyDictionary? fileBytes = null)
+ ///
+ /// PNG bytes for a route-based Wasserförderung Leitung's map snapshot (#150 Plan B), keyed by
+ /// WasserfoerderungLeitung.Id — rendered by the caller (this project stays Avalonia-free).
+ /// A Leitung with no entry (manual Plan A entry, or rendering failed) shows the numeric table
+ /// row only, unchanged from Phase 1.
+ ///
+ public static byte[] Generate(
+ Incident incident,
+ IReadOnlyDictionary? fileBytes = null,
+ IReadOnlyDictionary? routeOverviewPngById = null)
{
ArgumentNullException.ThrowIfNull(incident);
PdfLicense.Ensure();
fileBytes ??= new Dictionary();
- var baseReport = new IncidentReportDocument(incident, fileBytes).GeneratePdf();
+ var baseReport = new IncidentReportDocument(incident, fileBytes, routeOverviewPngById).GeneratePdf();
var pdfAttachments = incident.Files
.Where(f => f.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
diff --git a/src/LageBuch.Documents/IncidentReportDocument.cs b/src/LageBuch.Documents/IncidentReportDocument.cs
index c497c3a..0f24cbe 100644
--- a/src/LageBuch.Documents/IncidentReportDocument.cs
+++ b/src/LageBuch.Documents/IncidentReportDocument.cs
@@ -10,6 +10,7 @@ public sealed class IncidentReportDocument : IDocument
{
private readonly Incident _incident;
private readonly IReadOnlyDictionary _imageBytesById;
+ private readonly IReadOnlyDictionary _routeOverviewPngById;
/// The incident to render.
///
@@ -18,7 +19,11 @@ public sealed class IncidentReportDocument : IDocument
/// by name regardless of whether bytes were supplied; only image entries with bytes present
/// are additionally rendered inline (see ).
///
- public IncidentReportDocument(Incident incident, IReadOnlyDictionary? fileBytes = null)
+ /// See .
+ public IncidentReportDocument(
+ Incident incident,
+ IReadOnlyDictionary? fileBytes = null,
+ IReadOnlyDictionary? routeOverviewPngById = null)
{
ArgumentNullException.ThrowIfNull(incident);
_incident = incident;
@@ -26,6 +31,7 @@ public IncidentReportDocument(Incident incident, IReadOnlyDictionary f.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
.Where(f => fileBytes is not null && fileBytes.ContainsKey(f.Id))
.ToDictionary(f => f.Id, f => fileBytes![f.Id]);
+ _routeOverviewPngById = routeOverviewPngById ?? new Dictionary();
}
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
@@ -51,6 +57,7 @@ public void Compose(IDocumentContainer document)
column.Item().Element(c => RolesSection.Compose(c, _incident));
column.Item().Element(c => ForcesSection.Compose(c, _incident));
column.Item().Element(c => TasksSection.Compose(c, _incident));
+ column.Item().Element(c => WasserfoerderungSection.Compose(c, _incident, _routeOverviewPngById));
column.Item().Element(c => AtemschutzSection.Compose(c, _incident));
column.Item().Element(c => CoMessprotokollSection.Compose(c, _incident));
column.Item().Element(c => FilesSection.Compose(c, _incident.Files, _imageBytesById));
diff --git a/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs b/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
new file mode 100644
index 0000000..45454c3
--- /dev/null
+++ b/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
@@ -0,0 +1,87 @@
+using LageBuch.Domain;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace LageBuch.Documents.Sections;
+
+public static class WasserfoerderungSection
+{
+ public static void Compose(
+ IContainer container, Incident incident, IReadOnlyDictionary? routeOverviewPngById = null)
+ {
+ routeOverviewPngById ??= new Dictionary();
+ container.Column(column =>
+ {
+ column.Spacing(4);
+ column.Item().Text("Wasserförderung").FontSize(14).SemiBold().FontColor(Colors.Blue.Darken1);
+
+ if (incident.Wasserfoerderung.Count == 0)
+ {
+ column.Item().Text("— keine Förderstrecke geplant —").Italic().FontColor(Colors.Grey.Medium);
+ return;
+ }
+
+ column.Item().Table(table =>
+ {
+ table.ColumnsDefinition(columns =>
+ {
+ columns.ConstantColumn(46);
+ columns.RelativeColumn(2);
+ columns.RelativeColumn(1.5f);
+ columns.ConstantColumn(50);
+ columns.ConstantColumn(55);
+ columns.ConstantColumn(65);
+ columns.ConstantColumn(80);
+ columns.ConstantColumn(80);
+ });
+
+ table.Header(header =>
+ {
+ foreach (var title in new[] { "Leitung", "Übergabestelle", "Ansprechpartner", "B-Längen",
+ "Länge", "Höhen-unterschied", "Verstärker-pumpen", "Reserve-pumpen" })
+ header.Cell().Element(HeaderCell).Text(title).SemiBold();
+ });
+
+ foreach (var leitung in incident.Wasserfoerderung)
+ {
+ table.Cell().Element(BodyCell).Text($"Ltg {leitung.Number}");
+ table.Cell().Element(BodyCell).Text(Formatting.OrDash(leitung.Uebergabestelle));
+ table.Cell().Element(BodyCell).Text(Formatting.OrDash(leitung.Ansprechpartner));
+ table.Cell().Element(BodyCell).Text(leitung.HoseCount.ToString());
+ table.Cell().Element(BodyCell).Text(Formatting.Meters(leitung.LengthMeters));
+ table.Cell().Element(BodyCell).Text(
+ leitung.ElevationRiseMeters > 0 ? Formatting.Meters(leitung.ElevationRiseMeters) : "—");
+ table.Cell().Element(BodyCell).Text(leitung.PumpCount.ToString());
+ table.Cell().Element(BodyCell).Text(leitung.ReservePumpCount.ToString());
+ }
+ });
+
+ // Plan B (#150 phase 2): a small route-overview snapshot per drawn Leitung, when one
+ // was rendered for it. A manually entered (Plan A) Leitung has no RoutePoints and so
+ // never has an entry here — this loop leaves the Phase 1 layout untouched for those.
+ foreach (var leitung in incident.Wasserfoerderung)
+ {
+ if (leitung.RoutePoints is null || !routeOverviewPngById.TryGetValue(leitung.Id, out var png))
+ continue;
+
+ column.Item().PaddingTop(4).Column(overview =>
+ {
+ overview.Item().Text($"Ltg {leitung.Number} — Kartenübersicht").FontSize(9).SemiBold();
+ overview.Item().MaxWidth(280).Image(png);
+ });
+ }
+
+ column.Item().PaddingTop(2).Text(
+ "Planung: B-800, B-Schlauch 20 m, 8 bar Speisedruck, 1,5 bar Pumpeneingang, " +
+ "3 % Reserveschlauch pro Teilstrecke.")
+ .FontSize(8).Italic().FontColor(Colors.Grey.Medium);
+ });
+ }
+
+ private static IContainer HeaderCell(IContainer c) =>
+ c.Background(Colors.Grey.Lighten3).PaddingVertical(3).PaddingHorizontal(4).BorderBottom(1).BorderColor(Colors.Grey.Medium);
+
+ private static IContainer BodyCell(IContainer c) =>
+ c.PaddingVertical(2).PaddingHorizontal(4).BorderBottom(1).BorderColor(Colors.Grey.Lighten2);
+}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs
index 89c6ca4..7728f61 100644
--- a/src/LageBuch.Domain/Incident.cs
+++ b/src/LageBuch.Domain/Incident.cs
@@ -4,6 +4,7 @@
using LageBuch.Domain.Files;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.Time;
+using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Domain.ValueObjects;
namespace LageBuch.Domain;
@@ -24,6 +25,7 @@ public sealed class Incident
private readonly List _tasks = new();
private readonly List _buildings = new();
private readonly List _dwellings = new();
+ private readonly List _wasserfoerderung = new();
private Incident() { }
@@ -60,6 +62,9 @@ private Incident() { }
public IReadOnlyList Buildings => _buildings;
public IReadOnlyList Dwellings => _dwellings;
+ /// Planned Wasserförderungs-Leitungen (#150), in creation order (Ltg 1, Ltg 2, …).
+ public IReadOnlyList Wasserfoerderung => _wasserfoerderung;
+
/// The persisted state of the timer with this key, or null if none has been recorded.
public IncidentTimerState? FindTimer(string key) => _timers.Find(t => t.Key == key);
@@ -124,7 +129,8 @@ public static Incident Rehydrate(
IEnumerable files,
IEnumerable tasks,
IEnumerable buildings,
- IEnumerable dwellings)
+ IEnumerable dwellings,
+ IEnumerable? wasserfoerderung = null)
{
var incident = new Incident
{
@@ -151,6 +157,8 @@ public static Incident Rehydrate(
incident._tasks.AddRange(tasks);
incident._buildings.AddRange(buildings);
incident._dwellings.AddRange(dwellings);
+ if (wasserfoerderung is not null)
+ incident._wasserfoerderung.AddRange(wasserfoerderung);
return incident;
}
@@ -740,6 +748,57 @@ public IncidentTask SetTaskCompleted(Guid taskId, bool isDone, IClock clock, Ses
return updated;
}
+ ///
+ /// Plans and records one Förderstrecke-Leitung (#150). The pump/pressure figures are computed
+ /// by at creation and stored on the Leitung — the PDF and
+ /// remote clients never recompute. Silently planned (no ETB line), like tasks.
+ ///
+ public WasserfoerderungLeitung AddWasserfoerderungLeitung(
+ string? uebergabestelle, string? ansprechpartner, double lengthM, double riseM)
+ {
+ EnsureOpen();
+ var leitung = WasserfoerderungLeitung.Create(
+ number: _wasserfoerderung.Count + 1,
+ uebergabestelle: uebergabestelle,
+ ansprechpartner: ansprechpartner,
+ lengthM: lengthM,
+ riseM: riseM);
+ _wasserfoerderung.Add(leitung);
+ return leitung;
+ }
+
+ ///
+ /// Plan B (#150 phase 2): plans and records a Leitung from a route drawn on the map. The
+ /// elevation profile is sampled by the caller (before this runs) so every replica stores the
+ /// same computed numbers regardless of local DEM-file differences.
+ ///
+ public WasserfoerderungLeitung AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle,
+ string? ansprechpartner,
+ IReadOnlyList routePoints,
+ IReadOnlyList profile)
+ {
+ EnsureOpen();
+ var leitung = WasserfoerderungLeitung.CreateFromRoute(
+ number: _wasserfoerderung.Count + 1,
+ uebergabestelle: uebergabestelle,
+ ansprechpartner: ansprechpartner,
+ routePoints: routePoints,
+ profile: profile);
+ _wasserfoerderung.Add(leitung);
+ return leitung;
+ }
+
+ /// Removes the planned Leitung. Unknown ids throw so a replayed command fails loudly.
+ public void RemoveWasserfoerderungLeitung(Guid leitungId)
+ {
+ EnsureOpen();
+ var index = _wasserfoerderung.FindIndex(l => l.Id == leitungId);
+ if (index < 0)
+ throw new KeyNotFoundException($"Wasserförderungsleitung {leitungId} nicht gefunden.");
+ _wasserfoerderung.RemoveAt(index);
+ }
+
private AtemschutzTrupp FindScbaTrupp(Guid truppId) =>
_scbaTrupps.FirstOrDefault(t => t.Id == truppId)
?? throw new KeyNotFoundException($"Atemschutz-Trupp {truppId} not found.");
diff --git a/src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs b/src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs
new file mode 100644
index 0000000..621f908
--- /dev/null
+++ b/src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs
@@ -0,0 +1,4 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+/// One sampled terrain point along a drawn route, distance from the route start.
+public sealed record ElevationProfileSample(double DistanceMeters, double ElevationMeters);
diff --git "a/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckeConfig.cs" "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckeConfig.cs"
new file mode 100644
index 0000000..6f04398
--- /dev/null
+++ "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckeConfig.cs"
@@ -0,0 +1,38 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+///
+/// Tunables for . Defaults mirror the verified B-line figures:
+/// TS 8/8 (8 bar @ 800 l/min), 1.5 bar inlet at the next pump (closed Schaltreihe), 20 m B-hose
+/// (DIN 14811), 3% headroom per leg, one reserve pump per four Verstärkerpumpen.
+///
+public sealed record FörderstreckeConfig
+{
+ /// Nominal B-flow in l/min; drives the friction-loss lookup table.
+ public int FlowLMin { get; init; } = 800;
+
+ /// Discharge pressure of each pump (weakest pump governs the chain).
+ public double FeedPressureBar { get; init; } = 8;
+
+ /// Required suction pressure at the next pump before it can re-pressurize.
+ public double InletPressureBar { get; init; } = 1.5;
+
+ /// Fraction of the usable pressure budget kept in reserve on every leg.
+ public double HeadroomPercent { get; init; } = 0.03;
+
+ /// Length of one B-Schlauch in meters; legs snap down to whole hoses.
+ public double HoseLengthMeters { get; init; } = 20;
+
+ /// One reserve pump per this many Verstärkerpumpen (rule of thumb 1:3–5).
+ public int ReservePumpEveryNPumps { get; init; } = 4;
+
+ public static FörderstreckeConfig Default => new();
+}
+
+/// The result of a run.
+public sealed record FörderstreckePlan(
+ double LengthMeters,
+ int HoseCount,
+ int ReserveHoseCount,
+ int PumpCount,
+ int ReservePumpCount,
+ IReadOnlyList PumpPositionsMeters);
\ No newline at end of file
diff --git "a/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs" "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
new file mode 100644
index 0000000..1884562
--- /dev/null
+++ "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
@@ -0,0 +1,143 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+///
+/// Pure engine that places Verstärkerpumpen along a Förderstrecke (#150).
+/// (Plan A) takes a total length and a single net elevation rise, treated as a uniform gradient;
+/// (Plan B) walks an actual sampled terrain profile instead, so an
+/// interior crest is caught even when the endpoints alone look fine. Physics is linear and
+/// therefore testable without deps:
+///
+/// head-loss per leg = friction + elevation friction = loss/100m at flow
+/// usable budget = (feed − inlet) · (1 − headroom)
+/// legs snap down to whole hoses; every pump restarts from feed pressure (closed Schaltreihe),
+/// the feed pump at the water source is NOT counted as a Verstärkerpumpe (user decision).
+///
+public static class FörderstreckePlanner
+{
+ // Verified B-75 mm table: bar per 100 m at 200/400/600/800/1000/1200 l/min (midpoints).
+ private static readonly double[] FlowPoints = { 200, 400, 600, 800, 1000, 1200 };
+ private static readonly double[] LossBarPer100M = { 0.1, 0.3, 0.6, 1.0, 1.5, 2.25 };
+
+ // 10 m elevation rise costs 1 bar, so 0.1 bar per meter of rise.
+ private const double ElevationBarPerMeter = 0.1;
+
+ public static FörderstreckePlan Plan(double lengthM, double riseM, FörderstreckeConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ if (lengthM <= 0)
+ throw new ArgumentException("Die Förderstrecke muss länger als 0 m sein.", nameof(lengthM));
+
+ return PlanFromProfile(
+ new[] { new ElevationProfileSample(0, 0), new ElevationProfileSample(lengthM, riseM) },
+ config);
+ }
+
+ ///
+ /// Plan B (#150 phase 2): same physics as , but walks a sampled terrain
+ /// profile instead of assuming one uniform gradient, so an interior crest (climb then
+ /// descend back to the same net height) is caught even when the leg's endpoints alone would
+ /// look fine.
+ ///
+ public static FörderstreckePlan PlanFromProfile(
+ IReadOnlyList profile, FörderstreckeConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+ ArgumentNullException.ThrowIfNull(config);
+ if (profile.Count < 2)
+ throw new ArgumentException("Das Höhenprofil braucht mindestens zwei Punkte.", nameof(profile));
+
+ var lengthM = profile[^1].DistanceMeters;
+ if (lengthM <= 0)
+ throw new ArgumentException("Die Förderstrecke muss länger als 0 m sein.", nameof(profile));
+
+ var lossPerMeter = LossPer100Meters(config.FlowLMin) / 100;
+ var budgetPerLeg = BudgetPerLegBar(config);
+ var hoseLen = config.HoseLengthMeters;
+ var hoseCount = (int)Math.Ceiling(lengthM / hoseLen);
+ var reserveHoseCount = (int)Math.Ceiling(lengthM / 100);
+
+ double Cost(double a, double b)
+ {
+ // The binding constraint is the worst (highest cumulative) pressure drop anywhere
+ // along the leg, not just at its endpoint — an interior crest can exceed budget even
+ // when the leg nets back down to a fine endpoint value.
+ var worst = double.NegativeInfinity;
+ foreach (var sample in profile)
+ {
+ if (sample.DistanceMeters > a && sample.DistanceMeters < b)
+ worst = Math.Max(worst, CumulativeLossBar(a, sample.DistanceMeters));
+ }
+
+ return Math.Max(worst, CumulativeLossBar(a, b));
+ }
+
+ double CumulativeLossBar(double a, double d) =>
+ lossPerMeter * (d - a) + ElevationBarPerMeter * (ElevationAt(profile, d) - ElevationAt(profile, a));
+
+ var positions = new List { 0 };
+ var pos = 0.0;
+ while (pos < lengthM)
+ {
+ var remaining = lengthM - pos;
+ if (remaining >= hoseLen && Cost(pos, lengthM) <= budgetPerLeg)
+ {
+ pos = lengthM;
+ break;
+ }
+
+ var reach = 0.0;
+ while (pos + reach + hoseLen <= lengthM && Cost(pos, pos + reach + hoseLen) <= budgetPerLeg)
+ reach += hoseLen;
+
+ if (reach == 0)
+ throw new ArgumentException(
+ "Die Steigung ist zu stark — ein B-Schlauch (20 m) trägt das Gefälle bereits über das Druckbudget.");
+
+ pos += reach;
+ if (pos < lengthM)
+ positions.Add(pos);
+ }
+
+ var pumpCount = Math.Max(0, positions.Count - 1); // exclude the feed pump at 0
+ var reservePumpCount = (int)Math.Ceiling((double)pumpCount / config.ReservePumpEveryNPumps);
+
+ return new FörderstreckePlan(lengthM, hoseCount, reserveHoseCount, pumpCount, reservePumpCount, positions);
+ }
+
+ private static double ElevationAt(IReadOnlyList profile, double distanceM)
+ {
+ for (var i = 0; i < profile.Count - 1; i++)
+ {
+ var a = profile[i];
+ var b = profile[i + 1];
+ if (distanceM <= b.DistanceMeters)
+ {
+ var t = (distanceM - a.DistanceMeters) / (b.DistanceMeters - a.DistanceMeters);
+ return a.ElevationMeters + t * (b.ElevationMeters - a.ElevationMeters);
+ }
+ }
+
+ return profile[^1].ElevationMeters;
+ }
+
+ private static double BudgetPerLegBar(FörderstreckeConfig config) =>
+ (config.FeedPressureBar - config.InletPressureBar) * (1 - config.HeadroomPercent);
+
+ private static double LossPer100Meters(int flowLMin)
+ {
+ if (flowLMin < FlowPoints[0] || flowLMin > FlowPoints[^1])
+ throw new ArgumentException(
+ $"Durchfluss {flowLMin} l/min liegt außerhalb der Tabelle (200–1200 l/min).", nameof(flowLMin));
+
+ var flow = (double)flowLMin;
+ for (var i = 0; i < FlowPoints.Length - 1; i++)
+ {
+ if (flow <= FlowPoints[i + 1])
+ {
+ var t = (flow - FlowPoints[i]) / (FlowPoints[i + 1] - FlowPoints[i]);
+ return LossBarPer100M[i] + t * (LossBarPer100M[i + 1] - LossBarPer100M[i]);
+ }
+ }
+ return LossBarPer100M[^1];
+ }
+}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs b/src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs
new file mode 100644
index 0000000..b786363
--- /dev/null
+++ b/src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs
@@ -0,0 +1,4 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+/// One vertex of a route drawn on the map (#150, Plan B), WGS84 degrees.
+public sealed record GeoPoint(double Latitude, double Longitude);
diff --git a/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs b/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
new file mode 100644
index 0000000..e7f4c4f
--- /dev/null
+++ b/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
@@ -0,0 +1,140 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+///
+/// One planned Förderstrecke-Leitung (Ltg 1, Ltg 2, …) on the incident (#150, Plan A). Immutable
+/// like every other aggregate child; the plan figures are computed once at creation by
+/// and stored, so the PDF and remote clients always render the
+/// exact numbers that were planned — never a recomputation that could drift.
+///
+public sealed record WasserfoerderungLeitung
+{
+ private WasserfoerderungLeitung() { }
+
+ public Guid Id { get; private init; }
+ public int Number { get; private init; }
+
+ /// Excel "Übergabestelle [Fzg., Behälter, …]" — the vehicle/container at the line end.
+ public string? Uebergabestelle { get; private init; }
+
+ /// Excel "Ansprechpartner Funkrufname".
+ public string? Ansprechpartner { get; private init; }
+
+ public int FlowLMin { get; private init; }
+ public double FeedPressureBar { get; private init; }
+ public double LengthMeters { get; private init; }
+ public double ElevationRiseMeters { get; private init; }
+ public int HoseCount { get; private init; }
+ public int ReserveHoseCount { get; private init; }
+ public int PumpCount { get; private init; }
+ public int ReservePumpCount { get; private init; }
+
+ /// Meters from the water source where a pump sits; index 0 is the feed pump.
+ public IReadOnlyList PumpPositionsMeters { get; private init; } = Array.Empty();
+
+ /// The drawn polyline (#150, Plan B); null when the Leitung came from manual entry (Plan A).
+ public IReadOnlyList? RoutePoints { get; private init; }
+
+ public static WasserfoerderungLeitung Create(
+ int number,
+ string? uebergabestelle,
+ string? ansprechpartner,
+ double lengthM,
+ double riseM,
+ FörderstreckeConfig? config = null)
+ {
+ if (number < 1)
+ throw new ArgumentException(nameof(number), "Die Leitungsnummer muss >= 1 sein.");
+
+ config ??= FörderstreckeConfig.Default;
+ var plan = FörderstreckePlanner.Plan(lengthM, riseM, config);
+
+ return new WasserfoerderungLeitung
+ {
+ Id = Guid.NewGuid(),
+ Number = number,
+ Uebergabestelle = string.IsNullOrWhiteSpace(uebergabestelle) ? null : uebergabestelle.Trim(),
+ Ansprechpartner = string.IsNullOrWhiteSpace(ansprechpartner) ? null : ansprechpartner.Trim(),
+ FlowLMin = config.FlowLMin,
+ FeedPressureBar = config.FeedPressureBar,
+ LengthMeters = plan.LengthMeters,
+ ElevationRiseMeters = riseM,
+ HoseCount = plan.HoseCount,
+ ReserveHoseCount = plan.ReserveHoseCount,
+ PumpCount = plan.PumpCount,
+ ReservePumpCount = plan.ReservePumpCount,
+ PumpPositionsMeters = plan.PumpPositionsMeters,
+ };
+ }
+
+ public static WasserfoerderungLeitung Rehydrate(
+ Guid id,
+ int number,
+ string? uebergabestelle,
+ string? ansprechpartner,
+ int flowLMin,
+ double feedPressureBar,
+ double lengthM,
+ double elevationRiseM,
+ int hoseCount,
+ int reserveHoseCount,
+ int pumpCount,
+ int reservePumpCount,
+ IReadOnlyList pumpPositionsMeters,
+ IReadOnlyList? routePoints = null)
+ => new()
+ {
+ Id = id,
+ Number = number,
+ Uebergabestelle = uebergabestelle,
+ Ansprechpartner = ansprechpartner,
+ FlowLMin = flowLMin,
+ FeedPressureBar = feedPressureBar,
+ LengthMeters = lengthM,
+ ElevationRiseMeters = elevationRiseM,
+ HoseCount = hoseCount,
+ ReserveHoseCount = reserveHoseCount,
+ PumpCount = pumpCount,
+ ReservePumpCount = reservePumpCount,
+ PumpPositionsMeters = pumpPositionsMeters,
+ RoutePoints = routePoints,
+ };
+
+ ///
+ /// Plan B (#150 phase 2): plans from an already-sampled terrain profile along a drawn route
+ /// instead of a single manually entered length/rise. The profile is sampled once (by the
+ /// caller, before this runs) so every replica stores the same numbers regardless of local DEM
+ /// differences — see .
+ ///
+ public static WasserfoerderungLeitung CreateFromRoute(
+ int number,
+ string? uebergabestelle,
+ string? ansprechpartner,
+ IReadOnlyList routePoints,
+ IReadOnlyList profile,
+ FörderstreckeConfig? config = null)
+ {
+ if (number < 1)
+ throw new ArgumentException(nameof(number), "Die Leitungsnummer muss >= 1 sein.");
+
+ config ??= FörderstreckeConfig.Default;
+ var plan = FörderstreckePlanner.PlanFromProfile(profile, config);
+
+ return new WasserfoerderungLeitung
+ {
+ Id = Guid.NewGuid(),
+ Number = number,
+ Uebergabestelle = string.IsNullOrWhiteSpace(uebergabestelle) ? null : uebergabestelle.Trim(),
+ Ansprechpartner = string.IsNullOrWhiteSpace(ansprechpartner) ? null : ansprechpartner.Trim(),
+ FlowLMin = config.FlowLMin,
+ FeedPressureBar = config.FeedPressureBar,
+ LengthMeters = plan.LengthMeters,
+ ElevationRiseMeters = profile[^1].ElevationMeters - profile[0].ElevationMeters,
+ HoseCount = plan.HoseCount,
+ ReserveHoseCount = plan.ReserveHoseCount,
+ PumpCount = plan.PumpCount,
+ ReservePumpCount = plan.ReservePumpCount,
+ PumpPositionsMeters = plan.PumpPositionsMeters,
+ RoutePoints = routePoints,
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/LageBuch.Persistence/IncidentRepository.cs b/src/LageBuch.Persistence/IncidentRepository.cs
index ddf0877..28e31a7 100644
--- a/src/LageBuch.Persistence/IncidentRepository.cs
+++ b/src/LageBuch.Persistence/IncidentRepository.cs
@@ -20,7 +20,7 @@ public void Save(string path, Incident incident)
"role_assignments", "force_units", "scba_trupps",
"scba_trupp_members", "scba_pressure_readings", "audit_events",
"incident_timers", "incident_files", "incident_tasks",
- "co_buildings", "co_dwellings" })
+ "co_buildings", "co_dwellings", "wass_leitungen" })
{
Exec(cn, tx, $"DELETE FROM {table};");
}
@@ -243,6 +243,31 @@ public void Save(string path, Incident incident)
});
}
+ for (var i = 0; i < incident.Wasserfoerderung.Count; i++)
+ {
+ var w = incident.Wasserfoerderung[i];
+ var positionsJson = System.Text.Json.JsonSerializer.Serialize(w.PumpPositionsMeters);
+ var routePointsJson = w.RoutePoints is null
+ ? (object)DBNull.Value
+ : System.Text.Json.JsonSerializer.Serialize(w.RoutePoints);
+ Run(cn, tx,
+ "INSERT INTO wass_leitungen (id, ordinal, number, uebergabestelle, ansprechpartner, flow_lmin, feed_pressure_bar, " +
+ "length_m, elevation_rise_m, hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions, route_points_json) " +
+ "VALUES ($id,$o,$num,$ueb,$ap,$flow,$feed,$len,$rise,$hc,$rch,$pc,$rpc,$pos,$route);",
+ p =>
+ {
+ p("$id", w.Id.ToString()); p("$o", i); p("$num", w.Number);
+ p("$ueb", (object?)w.Uebergabestelle ?? DBNull.Value);
+ p("$ap", (object?)w.Ansprechpartner ?? DBNull.Value);
+ p("$flow", w.FlowLMin); p("$feed", w.FeedPressureBar);
+ p("$len", w.LengthMeters); p("$rise", w.ElevationRiseMeters);
+ p("$hc", w.HoseCount); p("$rch", w.ReserveHoseCount);
+ p("$pc", w.PumpCount); p("$rpc", w.ReservePumpCount);
+ p("$pos", positionsJson);
+ p("$route", routePointsJson);
+ });
+ }
+
tx.Commit();
}
@@ -438,6 +463,19 @@ public Incident Load(string path)
(Domain.Tasks.TaskUrgency)r.GetInt32(4), r.GetString(5), ParseDate(r.GetString(7)),
NullableDate(r, 8), Str(r, 9)));
+ var wasserfoerderung = ReadAll(cn,
+ "SELECT id, number, uebergabestelle, ansprechpartner, flow_lmin, feed_pressure_bar, length_m, elevation_rise_m, " +
+ "hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions, route_points_json FROM wass_leitungen ORDER BY ordinal;",
+ r => Domain.Wasserfoerderung.WasserfoerderungLeitung.Rehydrate(
+ Guid.Parse(r.GetString(0)), r.GetInt32(1), Str(r, 2), Str(r, 3),
+ r.GetInt32(4), r.GetDouble(5), r.GetDouble(6), r.GetDouble(7),
+ r.GetInt32(8), r.GetInt32(9), r.GetInt32(10), r.GetInt32(11),
+ System.Text.Json.JsonSerializer.Deserialize>(r.GetString(12))
+ ?? Array.Empty(),
+ r.IsDBNull(13)
+ ? null
+ : System.Text.Json.JsonSerializer.Deserialize>(r.GetString(13))));
+
// Legacy fallback: files written before the Einsatznummer unification carry the 4-digit
// number in ils_number and nothing in incident_number. Load that old value as the
// Einsatznummer so pre-existing incidents keep their number.
@@ -458,7 +496,7 @@ public Incident Load(string path)
meta[9] is string ca ? ParseDate(ca) : null,
meta[10] as string,
checklistAufbau, checklistAbbau, journal, roles, forces, scbaTrupps, audit, timers, files, tasks,
- buildings, dwellings);
+ buildings, dwellings, wasserfoerderung);
}
private static DateTimeOffset ParseDate(string s) =>
diff --git a/src/LageBuch.Persistence/MasterData/MasterDataSet.cs b/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
index 998f968..f2657eb 100644
--- a/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
+++ b/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
@@ -170,6 +170,18 @@ public static class AnonymizedExampleData
};
}
+///
+/// The operator's configured region of operation (#150, Plan B) — a folder expected to hold
+/// region.mbtiles (map tiles) and region.dem (elevation), set up once at
+/// installation. Global config, like everything else in .
+///
+public sealed record Einsatzgebiet(string Name, string FolderPath)
+{
+ public static Einsatzgebiet Empty { get; } = new(string.Empty, string.Empty);
+
+ public bool IsConfigured => !string.IsNullOrWhiteSpace(Name) && !string.IsNullOrWhiteSpace(FolderPath);
+}
+
public sealed record MasterDataSet(
IReadOnlyList Roles,
IReadOnlyList Status,
@@ -192,7 +204,10 @@ public sealed record MasterDataSet(
IReadOnlyList Vehicles,
// Operational defaults (timers, durations). Unlike the lists, always populated — a store with
// no overrides yields IncidentSettings.Defaults, never a zeroed record.
- IncidentSettings Settings)
+ IncidentSettings Settings,
+ // Region of operation for the Wasserförderung map (#150 phase 2). Unlike the lists, always
+ // populated — a store with no override yields Einsatzgebiet.Empty, never a null.
+ Einsatzgebiet Einsatzgebiet)
{
///
/// Every category empty. Intended for tests and for callers that need a starting point to
@@ -206,13 +221,15 @@ public sealed record MasterDataSet(
Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(), Array.Empty(),
Array.Empty(),
- IncidentSettings.Defaults);
+ IncidentSettings.Defaults,
+ Einsatzgebiet.Empty);
///
/// True when no category holds a single entry. A fresh install starts here, and it is the
/// condition under which the Stammdaten editor offers Import — a bootstrap, not a merge.
- /// deliberately does not count: it always carries defaults, and letting it
- /// mark the set non-empty would suppress the Import bootstrap on an otherwise fresh install.
+ /// and the Einsatzgebiet field deliberately do not count: they always
+ /// carry a value (defaults, or an empty region), and letting either mark the set non-empty
+ /// would suppress the Import bootstrap on an otherwise fresh install.
///
public bool IsEmpty =>
Roles.Count == 0 && Status.Count == 0 && Equipment.Count == 0 && Districts.Count == 0
@@ -285,7 +302,8 @@ static IReadOnlyList Arr(JsonElement e, string prop) =>
ParsePersonnel(root),
Arr(root, "einsatzarten"),
vehicles,
- ParseSettings(root));
+ ParseSettings(root),
+ ParseEinsatzgebiet(root));
}
///
@@ -339,6 +357,20 @@ static int Int(JsonElement e, string prop, int fallback) =>
Int(s, "returnPressureBar", d.ReturnPressureBar));
}
+ ///
+ /// Reads the optional einsatzgebiet object. A missing object falls back to
+ /// so an older file still yields a complete record.
+ ///
+ private static Einsatzgebiet ParseEinsatzgebiet(JsonElement root)
+ {
+ if (!root.TryGetProperty("einsatzgebiet", out var e) || e.ValueKind != JsonValueKind.Object)
+ return Einsatzgebiet.Empty;
+
+ return new Einsatzgebiet(
+ e.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty,
+ e.TryGetProperty("folderPath", out var f) ? f.GetString() ?? string.Empty : string.Empty);
+ }
+
private static IReadOnlyList ParsePersonnel(JsonElement root)
{
if (!root.TryGetProperty("personnel", out var arr) || arr.ValueKind != JsonValueKind.Array)
@@ -397,6 +429,7 @@ public static string Serialize(MasterDataSet set)
pressureControlIntervalMinutes = set.Settings.PressureControlIntervalMinutes,
returnPressureBar = set.Settings.ReturnPressureBar,
},
+ einsatzgebiet = new { name = set.Einsatzgebiet.Name, folderPath = set.Einsatzgebiet.FolderPath },
};
return JsonSerializer.Serialize(model, new JsonSerializerOptions
diff --git a/src/LageBuch.Persistence/MasterData/MasterDataStore.cs b/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
index 36249de..fe4c7fc 100644
--- a/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
+++ b/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
@@ -74,6 +74,14 @@ public void Save(string path, MasterDataSet set)
"INSERT INTO md_settings (key, value) VALUES ($k,$v) ON CONFLICT(key) DO UPDATE SET value=excluded.value;",
p => { p("$k", key); p("$v", value); });
+ // Single fixed row (id=0), UPSERT like settings.
+ Run(cn, tx,
+ """
+ INSERT INTO md_einsatzgebiet (id, name, folder_path) VALUES (0, $n, $f)
+ ON CONFLICT(id) DO UPDATE SET name=excluded.name, folder_path=excluded.folder_path;
+ """,
+ p => { p("$n", set.Einsatzgebiet.Name); p("$f", set.Einsatzgebiet.FolderPath); });
+
tx.Commit();
}
@@ -137,6 +145,11 @@ CREATE TABLE IF NOT EXISTS md_personnel (
phone TEXT
);
CREATE TABLE IF NOT EXISTS md_settings (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS md_einsatzgebiet (
+ id INTEGER PRIMARY KEY CHECK (id = 0),
+ name TEXT NOT NULL DEFAULT '',
+ folder_path TEXT NOT NULL DEFAULT ''
+ );
""");
// Widen a pre-existing md_checklist_template that predates the Aufbau/Abbau split — this
@@ -164,7 +177,8 @@ private static MasterDataSet Read(SqliteConnection cn)
ReadPersonnel(cn),
ReadColumn(cn, "SELECT value FROM md_einsatzarten;"),
ReadVehicles(cn),
- ReadSettings(cn));
+ ReadSettings(cn),
+ ReadEinsatzgebiet(cn));
}
// Rows are ordered globally by ordinal (Aufbau's block precedes Abbau's — see
@@ -209,6 +223,14 @@ private static IncidentSettings ReadSettings(SqliteConnection cn)
Get("return_pressure_bar", d.ReturnPressureBar));
}
+ private static Einsatzgebiet ReadEinsatzgebiet(SqliteConnection cn)
+ {
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText = "SELECT name, folder_path FROM md_einsatzgebiet WHERE id = 0;";
+ using var r = cmd.ExecuteReader();
+ return r.Read() ? new Einsatzgebiet(r.GetString(0), r.GetString(1)) : Einsatzgebiet.Empty;
+ }
+
private static void InsertList(SqliteConnection cn, SqliteTransaction tx, string table, IReadOnlyList values)
{
foreach (var v in values)
diff --git a/src/LageBuch.Persistence/Sqlite/Migrations.cs b/src/LageBuch.Persistence/Sqlite/Migrations.cs
index 68bcc78..922540d 100644
--- a/src/LageBuch.Persistence/Sqlite/Migrations.cs
+++ b/src/LageBuch.Persistence/Sqlite/Migrations.cs
@@ -5,7 +5,7 @@ namespace LageBuch.Persistence.Sqlite;
public static class Migrations
{
- public const int CurrentVersion = 17;
+ public const int CurrentVersion = 19;
public static int GetVersion(SqliteConnection cn)
{
@@ -100,6 +100,14 @@ public static void Migrate(SqliteConnection cn)
{
ApplyV17(cn, tx);
}
+ if (version < 18)
+ {
+ ApplyV18(cn, tx);
+ }
+ if (version < 19)
+ {
+ ApplyV19(cn, tx);
+ }
SetVersion(cn, tx, CurrentVersion);
tx.Commit();
}
@@ -539,6 +547,37 @@ INSERT INTO scba_trupps_v17
Exec(cn, tx, "ALTER TABLE scba_trupps_v17 RENAME TO scba_trupps;");
}
+ private static void ApplyV18(SqliteConnection cn, SqliteTransaction tx)
+ {
+ // Wasserförderungs-Leitungen (#150, Plan A). The planned figures are stored verbatim so
+ // the PDF and remote clients never recompute them.
+ Exec(cn, tx, """
+ CREATE TABLE IF NOT EXISTS wass_leitungen (
+ id TEXT PRIMARY KEY,
+ ordinal INTEGER NOT NULL,
+ number INTEGER NOT NULL,
+ uebergabestelle TEXT,
+ ansprechpartner TEXT,
+ flow_lmin INTEGER NOT NULL,
+ feed_pressure_bar REAL NOT NULL,
+ length_m REAL NOT NULL,
+ elevation_rise_m REAL NOT NULL,
+ hose_count INTEGER NOT NULL,
+ reserve_hose_count INTEGER NOT NULL,
+ pump_count INTEGER NOT NULL,
+ reserve_pump_count INTEGER NOT NULL,
+ pump_positions TEXT NOT NULL DEFAULT '[]'
+ );
+ """);
+ }
+
+ private static void ApplyV19(SqliteConnection cn, SqliteTransaction tx)
+ {
+ // Plan B (#150 phase 2): the drawn route, when the Leitung came from the map. NULL means
+ // the Leitung was entered manually (Plan A) -- LengthMeters/ElevationRiseMeters apply either way.
+ SchemaHelpers.AddColumnIfMissing(cn, tx, "wass_leitungen", "route_points_json", "TEXT");
+ }
+
private static void SetVersion(SqliteConnection cn, SqliteTransaction tx, int version)
{
Exec(cn, tx, "DELETE FROM schema_version;");
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs b/src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs
new file mode 100644
index 0000000..f0fa656
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs
@@ -0,0 +1,145 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+///
+/// Reads the custom flat binary heightmap format (#150, Plan B, data-prep contract — see the
+/// implementation plan for the exact byte layout) and samples elevation along a drawn route.
+///
+/// Format: 40-byte little-endian header (magic "FWDM", format version, origin lat/lon, cell size
+/// in degrees, rows, cols), then a row-major Int16 body in meters (row 0 = north, col 0 = west;
+/// marks a missing cell).
+///
+public sealed class DemFileElevationSampler : IElevationSampler
+{
+ private const short NoData = short.MinValue;
+ private const double EarthRadiusMeters = 6371000;
+
+ private readonly double _originLatitude;
+ private readonly double _originLongitude;
+ private readonly double _cellSizeDegrees;
+ private readonly int _rows;
+ private readonly int _cols;
+ private readonly short[] _body;
+ private readonly double _sampleIntervalMeters;
+
+ public DemFileElevationSampler(string demFilePath, double sampleIntervalMeters = 20.0)
+ {
+ _sampleIntervalMeters = sampleIntervalMeters;
+
+ using var stream = File.OpenRead(demFilePath);
+ using var reader = new BinaryReader(stream);
+
+ var magic = System.Text.Encoding.ASCII.GetString(reader.ReadBytes(4));
+ if (magic != "FWDM")
+ throw new InvalidDataException($"'{demFilePath}' ist keine gültige DEM-Datei (Magic '{magic}').");
+
+ _ = reader.ReadInt32(); // format version, currently always 1
+ _originLatitude = reader.ReadDouble();
+ _originLongitude = reader.ReadDouble();
+ _cellSizeDegrees = reader.ReadDouble();
+ _rows = reader.ReadInt32();
+ _cols = reader.ReadInt32();
+
+ _body = new short[_rows * _cols];
+ for (var i = 0; i < _body.Length; i++)
+ _body[i] = reader.ReadInt16();
+ }
+
+ public IReadOnlyList Sample(IReadOnlyList polyline)
+ {
+ ArgumentNullException.ThrowIfNull(polyline);
+ if (polyline.Count < 2)
+ throw new ArgumentException("Die Route braucht mindestens zwei Punkte.", nameof(polyline));
+
+ var cumulative = new double[polyline.Count];
+ for (var i = 1; i < polyline.Count; i++)
+ cumulative[i] = cumulative[i - 1] + HaversineMeters(polyline[i - 1], polyline[i]);
+ var totalLength = cumulative[^1];
+
+ var samples = new List();
+ var segment = 0;
+ // The epsilon keeps a total length that lands almost exactly on a sample boundary (a
+ // floating-point hair above it) from producing a near-duplicate of the final-vertex
+ // sample appended below.
+ for (var d = 0.0; d < totalLength - 1e-6; d += _sampleIntervalMeters)
+ {
+ while (segment < polyline.Count - 2 && cumulative[segment + 1] < d)
+ segment++;
+ samples.Add(SampleAt(polyline, cumulative, segment, d));
+ }
+
+ samples.Add(SampleAt(polyline, cumulative, polyline.Count - 2, totalLength));
+ return samples;
+ }
+
+ private ElevationProfileSample SampleAt(
+ IReadOnlyList polyline, double[] cumulative, int segment, double distance)
+ {
+ var segStart = cumulative[segment];
+ var segEnd = cumulative[segment + 1];
+ var t = segEnd > segStart ? (distance - segStart) / (segEnd - segStart) : 0;
+ var a = polyline[segment];
+ var b = polyline[segment + 1];
+ var lat = a.Latitude + t * (b.Latitude - a.Latitude);
+ var lon = a.Longitude + t * (b.Longitude - a.Longitude);
+ return new ElevationProfileSample(distance, ElevationAt(lat, lon));
+ }
+
+ private static double HaversineMeters(GeoPoint a, GeoPoint b)
+ {
+ var dLat = ToRadians(b.Latitude - a.Latitude);
+ var dLon = ToRadians(b.Longitude - a.Longitude);
+ var lat1 = ToRadians(a.Latitude);
+ var lat2 = ToRadians(b.Latitude);
+ var sinDLat = Math.Sin(dLat / 2);
+ var sinDLon = Math.Sin(dLon / 2);
+ var h = sinDLat * sinDLat + Math.Cos(lat1) * Math.Cos(lat2) * sinDLon * sinDLon;
+ return 2 * EarthRadiusMeters * Math.Atan2(Math.Sqrt(h), Math.Sqrt(1 - h));
+ }
+
+ private static double ToRadians(double degrees) => degrees * Math.PI / 180.0;
+
+ private double ElevationAt(double lat, double lon)
+ {
+ var rowF = (_originLatitude - lat) / _cellSizeDegrees;
+ var colF = (lon - _originLongitude) / _cellSizeDegrees;
+
+ var r0 = Math.Clamp((int)Math.Floor(rowF), 0, _rows - 1);
+ var r1 = Math.Clamp(r0 + 1, 0, _rows - 1);
+ var c0 = Math.Clamp((int)Math.Floor(colF), 0, _cols - 1);
+ var c1 = Math.Clamp(c0 + 1, 0, _cols - 1);
+ var fr = Math.Clamp(rowF - r0, 0, 1);
+ var fc = Math.Clamp(colF - c0, 0, 1);
+
+ var topLeft = CellAt(r0, c0);
+ var topRight = CellAt(r0, c1);
+ var bottomLeft = CellAt(r1, c0);
+ var bottomRight = CellAt(r1, c1);
+ ResolveNoData(ref topLeft, ref topRight, ref bottomLeft, ref bottomRight);
+
+ var top = topLeft * (1 - fc) + topRight * fc;
+ var bottom = bottomLeft * (1 - fc) + bottomRight * fc;
+ return top * (1 - fr) + bottom * fr;
+ }
+
+ private double CellAt(int row, int col) => _body[row * _cols + col];
+
+ ///
+ /// A NoData corner is replaced by the average of the bilinear stencil's other valid corners —
+ /// the nearest valid values available to this interpolation, per the DEM edge-case contract.
+ ///
+ private static void ResolveNoData(ref double topLeft, ref double topRight, ref double bottomLeft, ref double bottomRight)
+ {
+ var corners = new[] { topLeft, topRight, bottomLeft, bottomRight };
+ var validCorners = corners.Where(v => v != NoData).ToArray();
+ if (validCorners.Length == 0 || validCorners.Length == corners.Length)
+ return;
+
+ var fallback = validCorners.Average();
+ if (topLeft == NoData) topLeft = fallback;
+ if (topRight == NoData) topRight = fallback;
+ if (bottomLeft == NoData) bottomLeft = fallback;
+ if (bottomRight == NoData) bottomRight = fallback;
+ }
+}
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs b/src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs
new file mode 100644
index 0000000..f290ed5
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs
@@ -0,0 +1,9 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+/// Samples terrain elevation along a drawn route (#150, Plan B).
+public interface IElevationSampler
+{
+ IReadOnlyList Sample(IReadOnlyList polyline);
+}
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
new file mode 100644
index 0000000..138aa35
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
@@ -0,0 +1,22 @@
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+/// Reads raster map tiles for the operator's configured Einsatzgebiet (#150, Plan B).
+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
new file mode 100644
index 0000000..4f4735a
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
@@ -0,0 +1,62 @@
+using LageBuch.Persistence.Sqlite;
+
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+///
+/// Reads an MBTiles file (a SQLite database with a standard tiles table) for the
+/// operator's configured Einsatzgebiet (#150, Plan B). MBTiles stores rows in TMS scheme
+/// (row 0 = south); everything else in this app uses XYZ/slippy-map scheme (row 0 = north), so
+/// every read flips the row.
+///
+public sealed class MbTilesFileSource(string mbtilesFilePath) : IMapTileSource
+{
+ public byte[]? GetTile(int zoom, int x, int y)
+ {
+ var tmsRow = (1 << zoom) - 1 - y;
+
+ using var cn = SqliteConnectionFactory.OpenReadOnly(mbtilesFilePath);
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText =
+ "SELECT tile_data FROM tiles WHERE zoom_level = $z AND tile_column = $x AND tile_row = $y;";
+ cmd.Parameters.AddWithValue("$z", zoom);
+ cmd.Parameters.AddWithValue("$x", x);
+ cmd.Parameters.AddWithValue("$y", tmsRow);
+
+ 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/src/LageBuch.Sync/CommandApplier.cs b/src/LageBuch.Sync/CommandApplier.cs
index 241dd20..f04b740 100644
--- a/src/LageBuch.Sync/CommandApplier.cs
+++ b/src/LageBuch.Sync/CommandApplier.cs
@@ -135,6 +135,15 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A
case SetApartmentLabelCommand c:
incident.SetApartmentLabel(c.BuildingId, c.ApartmentNumber, c.Label);
break;
+ case AddWasserfoerderungLeitungCommand c:
+ incident.AddWasserfoerderungLeitung(c.Uebergabestelle, c.Ansprechpartner, c.LengthMeters, c.ElevationRiseMeters);
+ break;
+ case RemoveWasserfoerderungLeitungCommand c:
+ incident.RemoveWasserfoerderungLeitung(c.LeitungId);
+ break;
+ case AddWasserfoerderungLeitungFromRouteCommand c:
+ incident.AddWasserfoerderungLeitungFromRoute(c.Uebergabestelle, c.Ansprechpartner, c.RoutePoints, c.Profile);
+ break;
default:
throw new ArgumentOutOfRangeException(nameof(command),
$"Unbekannter Befehl: {command.GetType().Name}");
diff --git a/src/LageBuch.Sync/IIncidentSession.cs b/src/LageBuch.Sync/IIncidentSession.cs
index cc333f5..1a7817e 100644
--- a/src/LageBuch.Sync/IIncidentSession.cs
+++ b/src/LageBuch.Sync/IIncidentSession.cs
@@ -4,6 +4,7 @@
using LageBuch.Domain.Etb;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -66,6 +67,18 @@ void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
/// Stamps/clears a task's completion (#88).
void SetTaskCompleted(Guid taskId, bool isDone);
+
+ /// Plans one Förderstrecke-Leitung (#150, Plan A). Silent — no ETB line, no attribution,
+ /// exactly like tasks. Length/elevation ride the wire as plan inputs; the host computes the number
+ /// and every derived figure.
+ void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprechpartner, double lengthMeters, double elevationRiseMeters);
+ void RemoveWasserfoerderungLeitung(Guid leitungId);
+
+ void AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle,
+ string? ansprechpartner,
+ IReadOnlyList routePoints,
+ IReadOnlyList profile);
void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.Sync/IncidentSnapshot.cs b/src/LageBuch.Sync/IncidentSnapshot.cs
index a167610..5816163 100644
--- a/src/LageBuch.Sync/IncidentSnapshot.cs
+++ b/src/LageBuch.Sync/IncidentSnapshot.cs
@@ -3,6 +3,7 @@
using LageBuch.Domain.CoMeasurement;
using LageBuch.Domain.Etb;
using LageBuch.Domain.Tasks;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -34,7 +35,8 @@ public sealed record IncidentSnapshot(
IReadOnlyList Files,
IReadOnlyList Tasks,
IReadOnlyList Buildings,
- IReadOnlyList Dwellings);
+ IReadOnlyList Dwellings,
+ IReadOnlyList Wasserfoerderung);
public sealed record TimerDto(
string Key,
@@ -140,3 +142,19 @@ public sealed record BuildingDto(
public sealed record DwellingDto(
Guid Id, Guid BuildingId, int FloorOrdinal, int ApartmentNumber,
string? ResidentName, DwellingStatus Status, bool? KeyAvailable, int? CoValue);
+
+public sealed record WasserfoerderungLeitungDto(
+ Guid Id,
+ int Number,
+ string? Uebergabestelle,
+ string? Ansprechpartner,
+ int FlowLMin,
+ double FeedPressureBar,
+ double LengthMeters,
+ double ElevationRiseMeters,
+ int HoseCount,
+ int ReserveHoseCount,
+ int PumpCount,
+ int ReservePumpCount,
+ IReadOnlyList PumpPositionsMeters,
+ IReadOnlyList? RoutePoints = null);
diff --git a/src/LageBuch.Sync/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs
index ae5465a..748b60c 100644
--- a/src/LageBuch.Sync/RemoteIncidentSession.cs
+++ b/src/LageBuch.Sync/RemoteIncidentSession.cs
@@ -8,6 +8,7 @@
using LageBuch.Domain.Files;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
@@ -182,6 +183,17 @@ public void AddTask(string text, string? assignee, TaskImportance importance, Ta
public void SetTaskCompleted(Guid taskId, bool isDone) =>
Send(new SetTaskCompletedCommand(Op(), taskId, isDone));
+ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprechpartner, double lengthMeters, double elevationRiseMeters) =>
+ Send(new AddWasserfoerderungLeitungCommand(uebergabestelle, ansprechpartner, lengthMeters, elevationRiseMeters));
+
+ public void RemoveWasserfoerderungLeitung(Guid leitungId) =>
+ Send(new RemoveWasserfoerderungLeitungCommand(leitungId));
+
+ public void AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle, string? ansprechpartner,
+ IReadOnlyList routePoints, IReadOnlyList profile) =>
+ Send(new AddWasserfoerderungLeitungFromRouteCommand(uebergabestelle, ansprechpartner, routePoints, profile));
+
public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.Sync/SnapshotMapper.cs b/src/LageBuch.Sync/SnapshotMapper.cs
index 8f53d65..d33c936 100644
--- a/src/LageBuch.Sync/SnapshotMapper.cs
+++ b/src/LageBuch.Sync/SnapshotMapper.cs
@@ -6,6 +6,7 @@
using LageBuch.Domain.Tasks;
using LageBuch.Domain.Time;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -52,7 +53,11 @@ public static IncidentSnapshot ToSnapshot(Incident incident)
b.ApartmentLabels.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))).ToList(),
incident.Dwellings.Select(d => new DwellingDto(
d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber,
- d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)).ToList());
+ d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)).ToList(),
+ incident.Wasserfoerderung.Select(w => new WasserfoerderungLeitungDto(
+ w.Id, w.Number, w.Uebergabestelle, w.Ansprechpartner, w.FlowLMin, w.FeedPressureBar,
+ w.LengthMeters, w.ElevationRiseMeters, w.HoseCount, w.ReserveHoseCount,
+ w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters, w.RoutePoints)).ToList());
}
public static Incident FromSnapshot(IncidentSnapshot snapshot)
@@ -94,7 +99,11 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot)
kv => kv.Value))),
snapshot.Dwellings.Select(d => Dwelling.Rehydrate(
d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber,
- d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)));
+ d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)),
+ snapshot.Wasserfoerderung.Select(w => WasserfoerderungLeitung.Rehydrate(
+ w.Id, w.Number, w.Uebergabestelle, w.Ansprechpartner, w.FlowLMin, w.FeedPressureBar,
+ w.LengthMeters, w.ElevationRiseMeters, w.HoseCount, w.ReserveHoseCount,
+ w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters, w.RoutePoints)));
}
private static ScbaTruppDto ToDto(AtemschutzTrupp t) => new(
diff --git a/src/LageBuch.Sync/SyncCommand.cs b/src/LageBuch.Sync/SyncCommand.cs
index 29ba013..108d19b 100644
--- a/src/LageBuch.Sync/SyncCommand.cs
+++ b/src/LageBuch.Sync/SyncCommand.cs
@@ -2,6 +2,7 @@
using LageBuch.Domain.CoMeasurement;
using LageBuch.Domain.Etb;
using LageBuch.Domain.Tasks;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -45,6 +46,9 @@ namespace LageBuch.Sync;
[JsonDerivedType(typeof(UpdateDwellingDetailsCommand), "updateDwellingDetails")]
[JsonDerivedType(typeof(SetFloorDescriptionCommand), "setFloorDescription")]
[JsonDerivedType(typeof(SetApartmentLabelCommand), "setApartmentLabel")]
+[JsonDerivedType(typeof(AddWasserfoerderungLeitungCommand), "addWasserfoerderungLeitung")]
+[JsonDerivedType(typeof(RemoveWasserfoerderungLeitungCommand), "removeWasserfoerderungLeitung")]
+[JsonDerivedType(typeof(AddWasserfoerderungLeitungFromRouteCommand), "addWasserfoerderungLeitungFromRoute")]
public abstract record SyncCommand;
/// The operator at the sending device — carried on attributed mutations (see §6).
@@ -153,3 +157,19 @@ public sealed record SetFloorDescriptionCommand(
// No operator (silent)
public sealed record SetApartmentLabelCommand(
Guid BuildingId, int ApartmentNumber, string? Label) : SyncCommand;
+
+// No operator: plan lines are silent (no ETB line, no audit), so the wire carries just the plan
+// inputs — the host computes Number and every derived figure with its own planner.
+public sealed record AddWasserfoerderungLeitungCommand(
+ string? Uebergabestelle, string? Ansprechpartner, double LengthMeters, double ElevationRiseMeters) : SyncCommand;
+
+public sealed record RemoveWasserfoerderungLeitungCommand(Guid LeitungId) : SyncCommand;
+
+// Plan B (#150 phase 2): carries the already-sampled profile so every replica computes the same
+// pump placement without needing its own copy of the DEM file — see
+// Incident.AddWasserfoerderungLeitungFromRoute.
+public sealed record AddWasserfoerderungLeitungFromRouteCommand(
+ string? Uebergabestelle,
+ string? Ansprechpartner,
+ IReadOnlyList RoutePoints,
+ IReadOnlyList Profile) : SyncCommand;
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/FilesTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs
index 5960cb5..f8d7913 100644
--- a/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs
@@ -41,12 +41,12 @@ private static TabControl Tabs(Window window) =>
((IncidentWorkspaceView)window.Content!).GetControl("ModuleTabs");
[AvaloniaFact]
- public void Workspace_renders_eight_tabs_before_dateien_is_opened()
+ public void Workspace_renders_all_tabs_before_dateien_is_opened()
{
var (window, _, _) = ShowWorkspace();
var tabs = Tabs(window);
- Assert.Equal(10, tabs.Items.Count);
+ Assert.Equal(11, tabs.Items.Count);
Capture(window, "files-before.png");
}
diff --git a/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs
index 3b2fb70..dae61a5 100644
--- a/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs
@@ -41,12 +41,12 @@ private static TabControl Tabs(Window window) =>
((IncidentWorkspaceView)window.Content!).GetControl("ModuleTabs");
[AvaloniaFact]
- public void Workspace_renders_eight_tabs_before_links_is_opened()
+ public void Workspace_renders_all_tabs_before_links_is_opened()
{
var (window, _) = ShowWorkspace();
var tabs = Tabs(window);
- Assert.Equal(10, tabs.Items.Count);
+ Assert.Equal(11, tabs.Items.Count);
Capture(window, "links-before.png");
}
diff --git a/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
new file mode 100644
index 0000000..b8189f9
--- /dev/null
+++ b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
@@ -0,0 +1,262 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.Input;
+using LageBuch.App.Shared.Controls;
+using LageBuch.AppLogic.Services;
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Persistence.Wasserfoerderung;
+
+namespace LageBuch.Acceptance.Tests;
+
+// Issue #150 (Plan B): the map canvas the operator draws a Wasserförderung route on.
+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
+ // MapCanvasControl's Bitmap(stream) decode path is exercised with genuine image bytes.
+ private static class SolidTilePng
+ {
+ public static readonly byte[] Bytes = Build();
+
+ private static byte[] Build()
+ {
+ using var bitmap = new RenderTargetBitmap(new PixelSize(4, 4));
+ using (var ctx = bitmap.CreateDrawingContext())
+ ctx.FillRectangle(Avalonia.Media.Brushes.SteelBlue, new Rect(0, 0, 4, 4));
+ using var ms = new MemoryStream();
+ bitmap.Save(ms, PngBitmapEncoderOptions.Default);
+ return ms.ToArray();
+ }
+ }
+
+ private static (Window Window, MapCanvasControl Control) ShowControl(
+ IReadOnlyList? routePoints = null, RelayCommand? onPointClicked = null,
+ RelayCommand? onUndo = null, RelayCommand? onViewChanged = null)
+ {
+ var control = new MapCanvasControl
+ {
+ Width = 400,
+ Height = 300,
+ TileSource = new FakeTileSource(),
+ CenterLatitude = 48.0,
+ CenterLongitude = 11.0,
+ Zoom = 15,
+ RoutePoints = routePoints,
+ PointClickedCommand = onPointClicked,
+ UndoRequestedCommand = onUndo,
+ ViewChangedCommand = onViewChanged,
+ };
+ var window = new Window { Content = control, Width = 400, Height = 300 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ return (window, control);
+ }
+
+ [AvaloniaFact]
+ public void Renders_tiles_and_a_route_without_throwing()
+ {
+ var (window, _) = ShowControl(routePoints: new[] { new GeoPoint(48.0, 11.0), new GeoPoint(48.002, 11.0) });
+
+ using var frame = window.CaptureRenderedFrame();
+
+ 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()
+ {
+ GeoPoint? clicked = null;
+ var command = new RelayCommand(p => clicked = p);
+ var (window, control) = ShowControl(onPointClicked: command);
+
+ var center = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ window.MouseDown(center, MouseButton.Left);
+ window.MouseUp(center, MouseButton.Left);
+
+ Assert.NotNull(clicked);
+ Assert.Equal(48.0, clicked!.Latitude, 3);
+ Assert.Equal(11.0, clicked.Longitude, 3);
+ }
+
+ [AvaloniaFact]
+ public void Right_click_invokes_UndoRequested_instead_of_PointClicked()
+ {
+ GeoPoint? clicked = null;
+ var undoCount = 0;
+ var pointCommand = new RelayCommand(p => clicked = p);
+ var undoCommand = new RelayCommand(() => undoCount++);
+ var (window, control) = ShowControl(onPointClicked: pointCommand, onUndo: undoCommand);
+
+ var center = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ window.MouseDown(center, MouseButton.Right);
+ window.MouseUp(center, MouseButton.Right);
+
+ 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 0b2f435..de9f816 100644
--- a/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
@@ -51,18 +51,30 @@ 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();
Dispatcher.UIThread.RunJobs();
var list = view.GetControl("CategoryList");
- // 14 categories plus #76's Fahrzeuge.
- Assert.Equal(15, list.ItemCount);
+ // 14 categories plus #76's Fahrzeuge plus #150's Einsatzgebiet.
+ Assert.Equal(16, list.ItemCount);
Assert.True(view.GetControl