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..d38f166
--- /dev/null
+++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
@@ -0,0 +1,138 @@
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+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.
+///
+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));
+
+ static MapCanvasControl()
+ {
+ AffectsRender(
+ TileSourceProperty, CenterLatitudeProperty, CenterLongitudeProperty, ZoomProperty, RoutePointsProperty);
+ }
+
+ public MapCanvasControl() => Focusable = true;
+
+ 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);
+ }
+
+ 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;
+
+ var (centerX, centerY) = WebMercator.ToWorldPixel(new GeoPoint(CenterLatitude, CenterLongitude), Zoom);
+ var worldX = current.Position.X + centerX - Bounds.Width / 2;
+ var worldY = current.Position.Y + centerY - Bounds.Height / 2;
+ var geoPoint = WebMercator.ToGeo(worldX, worldY, Zoom);
+
+ if (PointClickedCommand?.CanExecute(geoPoint) == true)
+ PointClickedCommand.Execute(geoPoint);
+ e.Handled = true;
+ }
+}
diff --git a/src/LageBuch.App.Shared/Controls/MapDrawing.cs b/src/LageBuch.App.Shared/Controls/MapDrawing.cs
new file mode 100644
index 0000000..80f0447
--- /dev/null
+++ b/src/LageBuch.App.Shared/Controls/MapDrawing.cs
@@ -0,0 +1,78 @@
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+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);
+
+ for (var tx = firstTileX; tx <= lastTileX; tx++)
+ {
+ for (var ty = firstTileY; ty <= lastTileY; ty++)
+ {
+ var bytes = tileSource.GetTile(zoom, tx, ty);
+ if (bytes is null)
+ continue;
+
+ using var stream = new MemoryStream(bytes);
+ using var bitmap = new Bitmap(stream);
+ var screenX = tx * WebMercator.TileSizePixels - centerX + width / 2;
+ var screenY = ty * WebMercator.TileSizePixels - centerY + height / 2;
+ var destRect = new Rect(screenX, screenY, WebMercator.TileSizePixels, WebMercator.TileSizePixels);
+ context.DrawImage(bitmap, new Rect(bitmap.Size), destRect);
+ }
+ }
+ }
+
+ 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/Controls/WebMercator.cs b/src/LageBuch.App.Shared/Controls/WebMercator.cs
new file mode 100644
index 0000000..5b7bad3
--- /dev/null
+++ b/src/LageBuch.App.Shared/Controls/WebMercator.cs
@@ -0,0 +1,35 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.App.Shared.Controls;
+
+///
+/// Standard OSM/slippy-map Web Mercator tile math (#150 Plan B) — pure functions, no Avalonia
+/// dependency, shared by the Wasserförderung map canvas's pan/zoom and the "click adds a route
+/// point" conversion.
+///
+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.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/MasterDataEditorView.axaml b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
index b07efcb..8bdea8f 100644
--- a/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
+++ b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LageBuch.AppLogic.ViewModels;assembly=LageBuch.AppLogic"
xmlns:md="clr-namespace:LageBuch.Persistence.MasterData;assembly=LageBuch.Persistence"
+ xmlns:services="clr-namespace:LageBuch.AppLogic.Services;assembly=LageBuch.AppLogic"
x:Class="LageBuch.App.Shared.Views.MasterDataEditorView"
x:DataType="vm:MasterDataEditorViewModel">
@@ -108,6 +109,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
index 83d7baa..675cd64 100644
--- a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
+++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
@@ -1,76 +1,133 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 319603e..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. ---
@@ -160,6 +161,11 @@ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprech
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/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/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 5b4bcc2..070df24 100644
--- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
@@ -6,6 +6,7 @@
using LageBuch.Domain.Time;
using LageBuch.Domain.ValueObjects;
using LageBuch.Persistence.MasterData;
+using LageBuch.Persistence.Wasserfoerderung;
using LageBuch.Sync;
namespace LageBuch.AppLogic.ViewModels;
@@ -22,8 +23,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 +35,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;
@@ -235,7 +238,8 @@ private void BuildChildren()
Tasks = new TasksViewModel(_session, _clock, _ticker, _alarm, _masterData, OnChanged);
Wasserfoerderung?.Dispose();
- Wasserfoerderung = new WasserfoerderungViewModel(_session, OnChanged);
+ var (elevationSampler, tileSource) = BuildWasserfoerderungMapSources();
+ Wasserfoerderung = new WasserfoerderungViewModel(_session, OnChanged, elevationSampler, tileSource);
Reminder?.Dispose();
// The ILS reminder is autonomous, time-driven host-side logging (§ IsRemote) — a joined
@@ -261,6 +265,24 @@ private void BuildChildren()
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));
+ }
+
private bool CanClose => !IsReadOnly;
// Closing is permanent (the incident becomes read-only), so confirm first. If a Trupp is
@@ -332,10 +354,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
index 5ae4f94..a079484 100644
--- a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
@@ -3,6 +3,7 @@
using CommunityToolkit.Mvvm.Input;
using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Documents;
+using LageBuch.Persistence.Wasserfoerderung;
using LageBuch.Sync;
namespace LageBuch.AppLogic.ViewModels;
@@ -19,13 +20,22 @@ public sealed partial class WasserfoerderungViewModel : ObservableObject, IDispo
{
private readonly IIncidentSession _session;
private readonly Action _onChanged;
+ private readonly IElevationSampler? _elevationSampler;
+ private readonly IMapTileSource? _tileSource;
- public WasserfoerderungViewModel(IIncidentSession session, Action onChanged)
+ public WasserfoerderungViewModel(
+ IIncidentSession session, Action onChanged,
+ IElevationSampler? elevationSampler = null, IMapTileSource? tileSource = 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();
_session.Changed += Sync;
Sync();
}
@@ -33,6 +43,75 @@ public WasserfoerderungViewModel(IIncidentSession session, Action onChanged)
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;
+
+ // No configured Einsatzgebiet has an obvious default location, so the map opens on a fixed,
+ // reasonable German fallback; the operator pans from there. Bounds keep zooming out from
+ // going past a whole-continent view or in past building-level detail.
+ private const int MinZoom = 3;
+ 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);
+
+ [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;
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 b8e5b7e..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,7 +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));
+ 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
index 9e12bd1..45454c3 100644
--- a/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
+++ b/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
@@ -7,8 +7,10 @@ namespace LageBuch.Documents.Sections;
public static class WasserfoerderungSection
{
- public static void Compose(IContainer container, Incident incident)
+ public static void Compose(
+ IContainer container, Incident incident, IReadOnlyDictionary? routeOverviewPngById = null)
{
+ routeOverviewPngById ??= new Dictionary();
container.Column(column =>
{
column.Spacing(4);
@@ -55,6 +57,21 @@ public static void Compose(IContainer container, Incident incident)
}
});
+ // 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.")
diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs
index 94c0d6b..7728f61 100644
--- a/src/LageBuch.Domain/Incident.cs
+++ b/src/LageBuch.Domain/Incident.cs
@@ -767,6 +767,28 @@ public WasserfoerderungLeitung AddWasserfoerderungLeitung(
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)
{
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\266rderstreckePlanner.cs" "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
index 9f05170..1884562 100644
--- "a/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
+++ "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
@@ -1,10 +1,11 @@
namespace LageBuch.Domain.Wasserfoerderung;
///
-/// Pure engine that places Verstärkerpumpen along a Förderstrecke (#150, Plan A). Given a total
-/// length, a total elevation rise (treated as a uniform gradient — Plan B replaces that with a
-/// sampled profile) and a , it computes B-hose count, pump
-/// positions and reserve figures. Physics is linear and therefore testable without deps:
+/// 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)
@@ -26,28 +27,76 @@ public static class FörderstreckePlanner
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 headPerMeter = lossPerMeter + ElevationBarPerMeter * riseM / lengthM;
- var hoseCount = (int)Math.Ceiling(lengthM / config.HoseLengthMeters);
+ var budgetPerLeg = BudgetPerLegBar(config);
+ var hoseLen = config.HoseLengthMeters;
+ var hoseCount = (int)Math.Ceiling(lengthM / hoseLen);
var reserveHoseCount = (int)Math.Ceiling(lengthM / 100);
- // Gravity assist (or a flat short line) means one leg can carry the whole route.
- var legLengthM = headPerMeter <= 0
- ? lengthM
- : BudgetPerLegBar(config) / headPerMeter;
+ 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));
+ }
- var legSnapped = Math.Min(
- Math.Floor(legLengthM / config.HoseLengthMeters) * config.HoseLengthMeters,
- lengthM);
+ return Math.Max(worst, CumulativeLossBar(a, b));
+ }
- // A single hose cannot carry a leg: the climb physically does not fit.
- if (legSnapped < config.HoseLengthMeters)
- throw new ArgumentException(
- "Die Steigung ist zu stark — ein B-Schlauch (20 m) trägt das Gefälle bereits über das Druckbudget.");
+ double CumulativeLossBar(double a, double d) =>
+ lossPerMeter * (d - a) + ElevationBarPerMeter * (ElevationAt(profile, d) - ElevationAt(profile, a));
- var positions = new List();
- for (var pos = 0.0; pos < lengthM; pos += legSnapped)
- positions.Add(pos);
+ 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);
@@ -55,6 +104,22 @@ public static class FörderstreckePlanner
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);
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
index 3967029..e7f4c4f 100644
--- a/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
+++ b/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
@@ -31,6 +31,9 @@ private WasserfoerderungLeitung() { }
/// 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,
@@ -76,7 +79,8 @@ public static WasserfoerderungLeitung Rehydrate(
int reserveHoseCount,
int pumpCount,
int reservePumpCount,
- IReadOnlyList pumpPositionsMeters)
+ IReadOnlyList pumpPositionsMeters,
+ IReadOnlyList? routePoints = null)
=> new()
{
Id = id,
@@ -92,5 +96,45 @@ public static WasserfoerderungLeitung Rehydrate(
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 6a1335b..28e31a7 100644
--- a/src/LageBuch.Persistence/IncidentRepository.cs
+++ b/src/LageBuch.Persistence/IncidentRepository.cs
@@ -247,10 +247,13 @@ public void Save(string path, Incident incident)
{
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) " +
- "VALUES ($id,$o,$num,$ueb,$ap,$flow,$feed,$len,$rise,$hc,$rch,$pc,$rpc,$pos);",
+ "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);
@@ -261,6 +264,7 @@ public void Save(string path, Incident incident)
p("$hc", w.HoseCount); p("$rch", w.ReserveHoseCount);
p("$pc", w.PumpCount); p("$rpc", w.ReservePumpCount);
p("$pos", positionsJson);
+ p("$route", routePointsJson);
});
}
@@ -461,13 +465,16 @@ public Incident Load(string path)
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 FROM wass_leitungen ORDER BY ordinal;",
+ "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()));
+ ?? 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
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 17b3fd9..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 = 18;
+ public const int CurrentVersion = 19;
public static int GetVersion(SqliteConnection cn)
{
@@ -104,6 +104,10 @@ public static void Migrate(SqliteConnection cn)
{
ApplyV18(cn, tx);
}
+ if (version < 19)
+ {
+ ApplyV19(cn, tx);
+ }
SetVersion(cn, tx, CurrentVersion);
tx.Commit();
}
@@ -567,6 +571,13 @@ 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..e7bb604
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
@@ -0,0 +1,8 @@
+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);
+}
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
new file mode 100644
index 0000000..2f77759
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
@@ -0,0 +1,28 @@
+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[];
+ }
+}
diff --git a/src/LageBuch.Sync/CommandApplier.cs b/src/LageBuch.Sync/CommandApplier.cs
index 716c6ca..f04b740 100644
--- a/src/LageBuch.Sync/CommandApplier.cs
+++ b/src/LageBuch.Sync/CommandApplier.cs
@@ -141,6 +141,9 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A
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 3402d09..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;
@@ -72,6 +73,12 @@ void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
/// 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 0a132e4..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;
@@ -155,4 +156,5 @@ public sealed record WasserfoerderungLeitungDto(
int ReserveHoseCount,
int PumpCount,
int ReservePumpCount,
- IReadOnlyList PumpPositionsMeters);
+ IReadOnlyList PumpPositionsMeters,
+ IReadOnlyList? RoutePoints = null);
diff --git a/src/LageBuch.Sync/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs
index b3baca1..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;
@@ -188,6 +189,11 @@ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprech
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 d29aeb7..d33c936 100644
--- a/src/LageBuch.Sync/SnapshotMapper.cs
+++ b/src/LageBuch.Sync/SnapshotMapper.cs
@@ -57,7 +57,7 @@ public static IncidentSnapshot ToSnapshot(Incident incident)
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)).ToList());
+ w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters, w.RoutePoints)).ToList());
}
public static Incident FromSnapshot(IncidentSnapshot snapshot)
@@ -103,7 +103,7 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot)
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.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 78b74ab..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;
@@ -47,6 +48,7 @@ namespace LageBuch.Sync;
[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).
@@ -162,3 +164,12 @@ 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/MapCanvasControlTests.cs b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
new file mode 100644
index 0000000..c002440
--- /dev/null
+++ b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
@@ -0,0 +1,103 @@
+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.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;
+ }
+
+ // 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)
+ {
+ 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,
+ };
+ 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);
+ }
+
+ [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);
+ }
+}
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