diff --git a/src/LageBuch.App.Android/MainActivity.cs b/src/LageBuch.App.Android/MainActivity.cs index 21d06b6..0aee49d 100644 --- a/src/LageBuch.App.Android/MainActivity.cs +++ b/src/LageBuch.App.Android/MainActivity.cs @@ -65,6 +65,7 @@ protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) new NoopIncidentHostController(), new LageBuch.App.Shared.Services.AvaloniaUiDispatcher(), typeof(MainActivity).Assembly.GetName().Version?.ToString() ?? "0.0.0", + AndroidAppPaths.RegionsDir(this), lastSaveFolder: null, attachmentCacheRoot: AndroidAppPaths.AttachmentCacheDir(this)); return base.CustomizeAppBuilder(builder).WithInterFont(); diff --git a/src/LageBuch.App.Android/Services/AndroidAppPaths.cs b/src/LageBuch.App.Android/Services/AndroidAppPaths.cs index 62deec0..d053fb9 100644 --- a/src/LageBuch.App.Android/Services/AndroidAppPaths.cs +++ b/src/LageBuch.App.Android/Services/AndroidAppPaths.cs @@ -27,4 +27,7 @@ public static string RecentFilesJsonPath(Context context) => public static string AttachmentCacheDir(Context context) => System.IO.Path.Combine(CacheDir(context), "attachment-cache"); + + public static string RegionsDir(Context context) => + System.IO.Path.Combine(context.FilesDir!.AbsolutePath, "regions"); } diff --git a/src/LageBuch.App.Shared/CompositionRoot.cs b/src/LageBuch.App.Shared/CompositionRoot.cs index 0803b7e..d8a09e9 100644 --- a/src/LageBuch.App.Shared/CompositionRoot.cs +++ b/src/LageBuch.App.Shared/CompositionRoot.cs @@ -1,3 +1,4 @@ +using LageBuch.App.Shared.Services; using LageBuch.AppLogic.Services; using LageBuch.AppLogic.ViewModels; using LageBuch.Domain.Time; @@ -13,6 +14,13 @@ namespace LageBuch.App.Shared; /// public static class CompositionRoot { + /// + /// Raw-served manifest of published Wasserförderung region packs (#150 follow-up) — see + /// tools/build-region-pack/README.md for how a pack is built and published here. + /// + public const string RegionPackManifestUrl = + "https://raw.githubusercontent.com/CodeForFire/lagebuch-regions/main/regions.json"; + public static MainWindowViewModel CreateMainWindowViewModel( IIncidentStore store, IMasterDataProvider masterData, @@ -25,11 +33,15 @@ public static MainWindowViewModel CreateMainWindowViewModel( IIncidentHostController hostController, IUiDispatcher uiDispatcher, string appVersion, + string regionsDir, ILastSaveFolderStore? lastSaveFolder = null, string? attachmentCacheRoot = null) { - var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot); - var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService); + var home = new HomeViewModel(store, masterData, recent, dialogs, clock, ticker, alarm, hostController, appVersion, uiDispatcher, lastSaveFolder, attachmentCacheRoot, new RouteOverviewRenderer()); + var httpClient = new HttpClient(); + var regionCatalog = new RegionPackCatalogService(httpClient, RegionPackManifestUrl); + var regionInstaller = new RegionPackInstaller(httpClient, regionsDir); + var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService, regionCatalog, regionInstaller); return new MainWindowViewModel(home, editor, dialogs, appVersion); } } diff --git a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs new file mode 100644 index 0000000..f831397 --- /dev/null +++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs @@ -0,0 +1,289 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using LageBuch.AppLogic.Services; +using LageBuch.Domain.Wasserfoerderung; +using LageBuch.Persistence.Wasserfoerderung; + +namespace LageBuch.App.Shared.Controls; + +/// +/// Draws map tiles for the operator's Einsatzgebiet and the in-progress Wasserförderung route +/// (#150 Plan B). Plain with a hand-rolled — there's no +/// XAML template, just tiles and a polyline over them. Left-click adds a route point (via +/// ), right-click undoes the last one (via +/// ); finishing the route is a separate, explicit action in the +/// view (not a click gesture here), to avoid a fast double left-click accidentally placing two +/// points and then finishing. Ctrl+left-drag pans instead of adding a point (#150 follow-up) — +/// reusing the same Ctrl convention as wheel/pinch zoom, so the primary click-to-draw gesture is +/// never at risk of being misread as a pan. +/// +public sealed class MapCanvasControl : Control +{ + public static readonly StyledProperty TileSourceProperty = + AvaloniaProperty.Register(nameof(TileSource)); + + public static readonly StyledProperty CenterLatitudeProperty = + AvaloniaProperty.Register(nameof(CenterLatitude)); + + public static readonly StyledProperty CenterLongitudeProperty = + AvaloniaProperty.Register(nameof(CenterLongitude)); + + public static readonly StyledProperty ZoomProperty = + AvaloniaProperty.Register(nameof(Zoom), defaultValue: 15); + + public static readonly StyledProperty?> RoutePointsProperty = + AvaloniaProperty.Register?>(nameof(RoutePoints)); + + public static readonly StyledProperty PointClickedCommandProperty = + AvaloniaProperty.Register(nameof(PointClickedCommand)); + + public static readonly StyledProperty UndoRequestedCommandProperty = + AvaloniaProperty.Register(nameof(UndoRequestedCommand)); + + public static readonly StyledProperty ViewChangedCommandProperty = + AvaloniaProperty.Register(nameof(ViewChangedCommand)); + + static MapCanvasControl() + { + AffectsRender( + TileSourceProperty, CenterLatitudeProperty, CenterLongitudeProperty, ZoomProperty, RoutePointsProperty); + } + + // Cumulative pinch scale since the gesture started (Avalonia.Input.Gestures.PinchEvent reports + // Scale relative to gesture start, not incrementally) -- reset in OnPinchEnded. + private double _pinchStartScale = 1.0; + private int _pinchStartZoom; + + // Ctrl+drag panning state (#150 follow-up) -- null when not currently panning. + private Point? _panStartScreenPoint; + private double _panStartCenterLatitude; + private double _panStartCenterLongitude; + + public MapCanvasControl() + { + Focusable = true; + // Avalonia.Input.Gestures (which raises PinchEvent/PinchEndedEvent) is internal; the + // documented public way to opt a control into pinch recognition is registering a + // recognizer here, then handling the routed events declared on InputElement itself. + GestureRecognizers.Add(new PinchGestureRecognizer()); + AddHandler(PinchEvent, OnPinch); + AddHandler(PinchEndedEvent, OnPinchEnded); + } + + public IMapTileSource? TileSource + { + get => GetValue(TileSourceProperty); + set => SetValue(TileSourceProperty, value); + } + + public double CenterLatitude + { + get => GetValue(CenterLatitudeProperty); + set => SetValue(CenterLatitudeProperty, value); + } + + public double CenterLongitude + { + get => GetValue(CenterLongitudeProperty); + set => SetValue(CenterLongitudeProperty, value); + } + + public int Zoom + { + get => GetValue(ZoomProperty); + set => SetValue(ZoomProperty, value); + } + + public IReadOnlyList? RoutePoints + { + get => GetValue(RoutePointsProperty); + set => SetValue(RoutePointsProperty, value); + } + + /// Invoked with the clicked point's on a left click. + public ICommand? PointClickedCommand + { + get => GetValue(PointClickedCommandProperty); + set => SetValue(PointClickedCommandProperty, value); + } + + /// Invoked (no parameter) on a right click. + public ICommand? UndoRequestedCommand + { + get => GetValue(UndoRequestedCommandProperty); + set => SetValue(UndoRequestedCommandProperty, value); + } + + /// + /// Invoked with a from a wheel or pinch zoom (#150 follow-up). + /// A command rather than two-way binding CenterLatitude/CenterLongitude/Zoom, matching this + /// control's existing pattern for control->VM communication (, + /// ) instead of changing those three properties' binding mode. + /// + public ICommand? ViewChangedCommand + { + get => GetValue(ViewChangedCommandProperty); + set => SetValue(ViewChangedCommandProperty, value); + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var width = Bounds.Width; + var height = Bounds.Height; + if (width <= 0 || height <= 0) + return; + + // Avalonia's compositor hit-tests against painted geometry, not just layout bounds — a + // control that draws nothing where a tile is missing (or before any route point exists) + // would be unclickable there. This transparent fill keeps the whole control clickable + // regardless of tile/route state. + context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height)); + + MapDrawing.Draw(context, TileSource, RoutePoints, CenterLatitude, CenterLongitude, Zoom, width, height); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + var current = e.GetCurrentPoint(this); + if (current.Properties.IsRightButtonPressed) + { + if (UndoRequestedCommand?.CanExecute(null) == true) + UndoRequestedCommand.Execute(null); + e.Handled = true; + return; + } + + if (!current.Properties.IsLeftButtonPressed) + return; + + if ((e.KeyModifiers & KeyModifiers.Control) != 0) + { + _panStartScreenPoint = current.Position; + _panStartCenterLatitude = CenterLatitude; + _panStartCenterLongitude = CenterLongitude; + e.Handled = true; + return; + } + + var geoPoint = ScreenToGeo(current.Position); + + if (PointClickedCommand?.CanExecute(geoPoint) == true) + PointClickedCommand.Execute(geoPoint); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if (_panStartScreenPoint is not { } start) + return; + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + _panStartScreenPoint = null; + return; + } + + var current = e.GetPosition(this); + var (startWorldX, startWorldY) = WebMercator.ToWorldPixel( + new GeoPoint(_panStartCenterLatitude, _panStartCenterLongitude), Zoom); + // Dragging reveals content on the opposite side, so the center moves against the drag. + var newWorldX = startWorldX - (current.X - start.X); + var newWorldY = startWorldY - (current.Y - start.Y); + var newCenter = WebMercator.ToGeo(newWorldX, newWorldY, Zoom); + var change = new MapViewChange(newCenter.Latitude, newCenter.Longitude, Zoom); + + if (ViewChangedCommand?.CanExecute(change) == true) + ViewChangedCommand.Execute(change); + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + if (_panStartScreenPoint is null) + return; + _panStartScreenPoint = null; + e.Handled = true; + } + + private GeoPoint ScreenToGeo(Point screenPoint) + { + var (centerX, centerY) = WebMercator.ToWorldPixel(new GeoPoint(CenterLatitude, CenterLongitude), Zoom); + var worldX = screenPoint.X + centerX - Bounds.Width / 2; + var worldY = screenPoint.Y + centerY - Bounds.Height / 2; + return WebMercator.ToGeo(worldX, worldY, Zoom); + } + + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + + // The map sits inside the Wasserförderung tab's own scrollable page (see + // WasserfoerderungView.axaml) -- a plain scroll must fall through to that ScrollViewer + // instead of zooming here, or the page becomes unscrollable wherever the cursor happens to + // land on the map, and a laptop trackpad's rapid wheel-delta stream during a scroll swipe + // zooms wildly, rendering the map unreadable (#150 follow-up). Ctrl+scroll to zoom matches + // the same convention most embedded maps use for exactly this reason. + if ((e.KeyModifiers & KeyModifiers.Control) == 0) + return; + + var newZoom = Zoom + (e.Delta.Y > 0 ? 1 : -1); + ZoomAt(e.GetPosition(this), newZoom); + e.Handled = true; + } + + // PinchEventArgs.Scale is cumulative relative to the gesture's start, not incremental between + // events, so the target zoom is derived fresh each time from the gesture's starting zoom. + private void OnPinch(object? sender, PinchEventArgs e) + { + if (_pinchStartZoom == 0) + { + _pinchStartScale = e.Scale; + _pinchStartZoom = Zoom; + } + + var relativeScale = e.Scale / _pinchStartScale; + var newZoom = _pinchStartZoom + PinchScaleToZoomDelta(relativeScale); + var origin = new Point(e.ScaleOrigin.X * Bounds.Width, e.ScaleOrigin.Y * Bounds.Height); + ZoomAt(origin, newZoom); + e.Handled = true; + } + + private void OnPinchEnded(object? sender, PinchEndedEventArgs e) + { + _pinchStartZoom = 0; + _pinchStartScale = 1.0; + } + + /// Each zoom level doubles resolution, so a relative pinch scale of 2x is +1 zoom + /// level — public/static for direct unit testing since Avalonia.Headless cannot synthesize + /// pinch/touch input to drive this through a full gesture pipeline (#150 follow-up). + public static int PinchScaleToZoomDelta(double relativeScale) => + (int)Math.Round(Math.Log2(Math.Max(relativeScale, 0.01))); + + /// Zooms to while keeping the geo point currently under + /// stationary on screen (#150 follow-up) — the standard + /// "zoom to cursor"/"zoom to pinch centroid" map UX. + private void ZoomAt(Point screenPoint, int newZoom) + { + var anchorGeo = ScreenToGeo(screenPoint); + + var (anchorWorldX, anchorWorldY) = WebMercator.ToWorldPixel(anchorGeo, newZoom); + var newCenterX = anchorWorldX - (screenPoint.X - Bounds.Width / 2); + var newCenterY = anchorWorldY - (screenPoint.Y - Bounds.Height / 2); + var newCenter = WebMercator.ToGeo(newCenterX, newCenterY, newZoom); + var change = new MapViewChange(newCenter.Latitude, newCenter.Longitude, newZoom); + + if (ViewChangedCommand?.CanExecute(change) == true) + ViewChangedCommand.Execute(change); + } +} diff --git a/src/LageBuch.App.Shared/Controls/MapDrawing.cs b/src/LageBuch.App.Shared/Controls/MapDrawing.cs new file mode 100644 index 0000000..36146a1 --- /dev/null +++ b/src/LageBuch.App.Shared/Controls/MapDrawing.cs @@ -0,0 +1,112 @@ +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using LageBuch.AppLogic.Services; +using LageBuch.Domain.Wasserfoerderung; +using LageBuch.Persistence.Wasserfoerderung; + +namespace LageBuch.App.Shared.Controls; + +/// +/// The tile+polyline drawing shared by 's live view and +/// RouteOverviewRenderer's off-screen PDF snapshot (#150 Plan B) — one implementation of +/// "paint the map centered at (lat,lon)/zoom into this rectangle" for both. +/// +public static class MapDrawing +{ + private static readonly IPen RoutePen = new Pen(Brushes.OrangeRed, 3); + private static readonly IBrush RoutePointBrush = Brushes.OrangeRed; + private const double RoutePointRadius = 5; + + public static void Draw( + DrawingContext context, IMapTileSource? tileSource, IReadOnlyList? routePoints, + double centerLatitude, double centerLongitude, int zoom, double width, double height) + { + if (width <= 0 || height <= 0) + return; + + var (centerX, centerY) = WebMercator.ToWorldPixel(new GeoPoint(centerLatitude, centerLongitude), zoom); + + DrawTiles(context, tileSource, zoom, centerX, centerY, width, height); + DrawRoute(context, routePoints, zoom, centerX, centerY, width, height); + } + + private static void DrawTiles( + DrawingContext context, IMapTileSource? tileSource, int zoom, double centerX, double centerY, double width, double height) + { + if (tileSource is null) + return; + + var (firstTileX, firstTileY) = WebMercator.ToTileIndex(centerX - width / 2, centerY - height / 2); + var (lastTileX, lastTileY) = WebMercator.ToTileIndex(centerX + width / 2, centerY + height / 2); + var sourceMaxZoom = tileSource.GetMaxZoom(); + + for (var tx = firstTileX; tx <= lastTileX; tx++) + { + for (var ty = firstTileY; ty <= lastTileY; ty++) + { + var bytes = tileSource.GetTile(zoom, tx, ty); + var sourceRect = (Rect?)null; + + if (bytes is null) + { + if (ComputeOverzoomTile(zoom, tx, ty, sourceMaxZoom) is not { } overzoom) + continue; + bytes = tileSource.GetTile(overzoom.Zoom, overzoom.X, overzoom.Y); + if (bytes is null) + continue; + sourceRect = overzoom.SourceRect; + } + + using var stream = new MemoryStream(bytes); + using var bitmap = new Bitmap(stream); + var screenX = tx * WebMercator.TileSizePixels - centerX + width / 2; + var screenY = ty * WebMercator.TileSizePixels - centerY + height / 2; + var destRect = new Rect(screenX, screenY, WebMercator.TileSizePixels, WebMercator.TileSizePixels); + context.DrawImage(bitmap, sourceRect ?? new Rect(bitmap.Size), destRect); + } + } + } + + /// + /// When the exact tile at (, , ) + /// isn't available and is past the source's actual max detail + /// (), computes the ancestor tile at that max zoom and the + /// source sub-rect within it covering this tile's area — "overzoom": an increasingly blurry + /// but still-oriented view past the region's native detail, instead of drawing nothing + /// (#150 follow-up). Null when zoom is already within range, or the source's max is unknown + /// (an empty tile source). + /// + public static (int Zoom, int X, int Y, Rect SourceRect)? ComputeOverzoomTile(int zoom, int x, int y, int? sourceMaxZoom) + { + if (sourceMaxZoom is not { } maxZoom || zoom <= maxZoom) + return null; + + var levels = zoom - maxZoom; + var ancestorX = x >> levels; + var ancestorY = y >> levels; + var subSize = (double)WebMercator.TileSizePixels / (1 << levels); + var subX = (x - (ancestorX << levels)) * subSize; + var subY = (y - (ancestorY << levels)) * subSize; + return (maxZoom, ancestorX, ancestorY, new Rect(subX, subY, subSize, subSize)); + } + + private static void DrawRoute( + DrawingContext context, IReadOnlyList? routePoints, int zoom, double centerX, double centerY, + double width, double height) + { + if (routePoints is not { Count: > 0 }) + return; + + Point? previous = null; + foreach (var geoPoint in routePoints) + { + var (worldX, worldY) = WebMercator.ToWorldPixel(geoPoint, zoom); + var screen = new Point(worldX - centerX + width / 2, worldY - centerY + height / 2); + if (previous is { } prev) + context.DrawLine(RoutePen, prev, screen); + context.DrawEllipse(RoutePointBrush, null, screen, RoutePointRadius, RoutePointRadius); + previous = screen; + } + } +} diff --git a/src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs b/src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs new file mode 100644 index 0000000..1459e35 --- /dev/null +++ b/src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs @@ -0,0 +1,62 @@ +using Avalonia; +using Avalonia.Media.Imaging; +using LageBuch.App.Shared.Controls; +using LageBuch.AppLogic.Services; +using LageBuch.Domain.Wasserfoerderung; +using LageBuch.Persistence.Wasserfoerderung; + +namespace LageBuch.App.Shared.Services; + +/// +/// Renders a small map snapshot of a drawn route off-screen for the PDF (#150 phase 2), sharing +/// with 's live view. Lives here (not in +/// LageBuch.App) because App.Shared already has the Avalonia/Skia reference every view uses, and +/// is already where the composition root wires this kind of cross-cutting service. +/// +public sealed class RouteOverviewRenderer : IRouteOverviewRenderer +{ + private const int ImageWidth = 640; + private const int ImageHeight = 400; + private const double Margin = 40; + private const int MaxZoom = 18; + private const int MinZoom = 1; + + public byte[]? Render(IReadOnlyList routePoints, IMapTileSource tiles) + { + ArgumentNullException.ThrowIfNull(routePoints); + ArgumentNullException.ThrowIfNull(tiles); + if (routePoints.Count < 2) + return null; + + var minLat = routePoints.Min(p => p.Latitude); + var maxLat = routePoints.Max(p => p.Latitude); + var minLon = routePoints.Min(p => p.Longitude); + var maxLon = routePoints.Max(p => p.Longitude); + var center = new GeoPoint((minLat + maxLat) / 2, (minLon + maxLon) / 2); + var zoom = FitZoom(minLat, minLon, maxLat, maxLon); + + using var bitmap = new RenderTargetBitmap(new PixelSize(ImageWidth, ImageHeight)); + using (var context = bitmap.CreateDrawingContext()) + { + MapDrawing.Draw(context, tiles, routePoints, center.Latitude, center.Longitude, zoom, ImageWidth, ImageHeight); + } + + using var stream = new MemoryStream(); + bitmap.Save(stream, PngBitmapEncoderOptions.Default); + return stream.ToArray(); + } + + /// Largest zoom at which the route's bounding box still fits inside the image (minus ). + private static int FitZoom(double minLat, double minLon, double maxLat, double maxLon) + { + for (var zoom = MaxZoom; zoom > MinZoom; zoom--) + { + var (minX, minY) = WebMercator.ToWorldPixel(new GeoPoint(maxLat, minLon), zoom); // north-west + var (maxX, maxY) = WebMercator.ToWorldPixel(new GeoPoint(minLat, maxLon), zoom); // south-east + if (maxX - minX <= ImageWidth - 2 * Margin && maxY - minY <= ImageHeight - 2 * Margin) + return zoom; + } + + return MinZoom; + } +} diff --git a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml index 2cf24ac..a14a27b 100644 --- a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml +++ b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml @@ -286,6 +286,11 @@ + + + + + @@ -108,6 +109,65 @@ + + + + + + + + + + + + + + +