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 @@ + + + + + + + + + + + + + + +