From 153780bbed805efc7ba443b623a65a9f40bb4774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:53:05 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(wasserfoerderung):=20draw=20F=C3=B6rde?= =?UTF-8?q?rstrecken=20on=20a=20map=20with=20real=20elevation=20profiles?= =?UTF-8?q?=20(#150,=20Plan=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #150: lets the operator draw a Wasserförderung route on a map instead of typing length/rise by hand. Terrain is sampled from a bundled DEM (custom binary heightmap) along the drawn polyline, and pump placement is computed leg-by-leg against the actual profile instead of assuming one uniform gradient — catching interior crests (a climb then descent back to the same net height) that the flat Plan A formula would miss. Map tiles come from a bundled MBTiles file, both referenced from a new "Einsatzgebiet" (region of operation) global Stammdaten setting. Fully offline like the rest of the app, no new NuGet dependency: MBTiles is read via the already-referenced Microsoft.Data.Sqlite, and the map canvas is a hand-rolled Avalonia control using standard Web Mercator tile math. Manuell (Plan A) entry stays available unchanged wherever a region isn't configured. The route also gets a small map snapshot embedded in the PDF export next to its numeric row. 826 -> 882 tests, all green. Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.App.Shared/CompositionRoot.cs | 3 +- .../Controls/MapCanvasControl.cs | 138 +++++++++++++++ .../Controls/MapDrawing.cs | 78 +++++++++ .../Controls/WebMercator.cs | 35 ++++ .../Services/RouteOverviewRenderer.cs | 62 +++++++ .../Views/MasterDataEditorView.axaml | 14 ++ .../Views/WasserfoerderungView.axaml | 64 ++++++- src/LageBuch.AppLogic/LocalIncidentSession.cs | 10 +- .../Services/IRouteOverviewRenderer.cs | 15 ++ .../ViewModels/EinsatzgebietSection.cs | 33 ++++ .../ViewModels/HomeViewModel.cs | 9 +- .../ViewModels/IncidentWorkspaceViewModel.cs | 55 +++++- .../ViewModels/MasterDataEditorViewModel.cs | 3 + .../ViewModels/WasserfoerderungViewModel.cs | 81 ++++++++- src/LageBuch.Documents/IncidentPdf.cs | 13 +- .../IncidentReportDocument.cs | 10 +- .../Sections/WasserfoerderungSection.cs | 19 ++- src/LageBuch.Domain/Incident.cs | 22 +++ .../ElevationProfileSample.cs | 4 + .../F\303\266rderstreckePlanner.cs" | 105 +++++++++--- .../Wasserfoerderung/GeoPoint.cs | 4 + .../WasserfoerderungLeitung.cs | 46 ++++- .../IncidentRepository.cs | 15 +- .../MasterData/MasterDataSet.cs | 43 ++++- .../MasterData/MasterDataStore.cs | 24 ++- src/LageBuch.Persistence/Sqlite/Migrations.cs | 13 +- .../DemFileElevationSampler.cs | 145 ++++++++++++++++ .../Wasserfoerderung/IElevationSampler.cs | 9 + .../Wasserfoerderung/IMapTileSource.cs | 8 + .../Wasserfoerderung/MbTilesFileSource.cs | 28 +++ src/LageBuch.Sync/CommandApplier.cs | 3 + src/LageBuch.Sync/IIncidentSession.cs | 7 + src/LageBuch.Sync/IncidentSnapshot.cs | 4 +- src/LageBuch.Sync/RemoteIncidentSession.cs | 6 + src/LageBuch.Sync/SnapshotMapper.cs | 4 +- src/LageBuch.Sync/SyncCommand.cs | 11 ++ .../MapCanvasControlTests.cs | 103 +++++++++++ .../MasterDataEditorRenderTests.cs | 4 +- .../RouteOverviewRendererTests.cs | 41 +++++ .../WasserfoerderungTabRenderTests.cs | 98 ++++++++++- .../WebMercatorTests.cs | 60 +++++++ .../IncidentSessionTests.cs | 27 +++ .../MasterDataEditorViewModelTests.cs | 36 +++- .../WasserfoerderungViewModelTests.cs | 161 ++++++++++++++++++ .../WasserfoerderungSectionTests.cs | 39 +++++ ...\303\266rderstreckePlannerProfileTests.cs" | 49 ++++++ .../WasserfoerderungRouteAggregateTests.cs | 103 +++++++++++ .../DemFileElevationSamplerTests.cs | 142 +++++++++++++++ .../IncidentRoundTripTests.cs | 27 +++ .../MasterDataJsonTests.cs | 24 +++ .../MasterDataStoreTests.cs | 30 ++++ .../MbTilesFileSourceTests.cs | 64 +++++++ .../MigrationForwardCompatTests.cs | 43 +++++ .../MigrationsTests.cs | 10 ++ .../CommandApplierTests.cs | 25 +++ .../CommandSerializationTests.cs | 5 + .../SnapshotRoundTripTests.cs | 18 ++ 57 files changed, 2191 insertions(+), 61 deletions(-) create mode 100644 src/LageBuch.App.Shared/Controls/MapCanvasControl.cs create mode 100644 src/LageBuch.App.Shared/Controls/MapDrawing.cs create mode 100644 src/LageBuch.App.Shared/Controls/WebMercator.cs create mode 100644 src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs create mode 100644 src/LageBuch.AppLogic/Services/IRouteOverviewRenderer.cs create mode 100644 src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs create mode 100644 src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs create mode 100644 src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs create mode 100644 tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs create mode 100644 tests/LageBuch.Acceptance.Tests/RouteOverviewRendererTests.cs create mode 100644 tests/LageBuch.Acceptance.Tests/WebMercatorTests.cs create mode 100644 tests/LageBuch.AppLogic.Tests/WasserfoerderungViewModelTests.cs create mode 100644 "tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerProfileTests.cs" create mode 100644 tests/LageBuch.Domain.Tests/WasserfoerderungRouteAggregateTests.cs create mode 100644 tests/LageBuch.Persistence.Tests/DemFileElevationSamplerTests.cs create mode 100644 tests/LageBuch.Persistence.Tests/MbTilesFileSourceTests.cs diff --git a/src/LageBuch.App.Shared/CompositionRoot.cs b/src/LageBuch.App.Shared/CompositionRoot.cs index 0803b7e..9d7bd52 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; @@ -28,7 +29,7 @@ public static MainWindowViewModel CreateMainWindowViewModel( ILastSaveFolderStore? lastSaveFolder = null, string? attachmentCacheRoot = null) { - var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot); + var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot, new RouteOverviewRenderer()); var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService); 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..2561551 100644 --- a/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml +++ b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml @@ -108,6 +108,20 @@ + + + + + + + + + + + + + + diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml index 83d7baa..7cbf60e 100644 --- a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml +++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml @@ -1,17 +1,28 @@ - - - - + + + + + + + + + + + - + @@ -44,6 +55,47 @@ + + + + + + + + + + +