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 9d7bd52..d8a09e9 100644 --- a/src/LageBuch.App.Shared/CompositionRoot.cs +++ b/src/LageBuch.App.Shared/CompositionRoot.cs @@ -14,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, @@ -26,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, new RouteOverviewRenderer()); - var editor = new MasterDataEditorViewModel(masterData, dialogs, masterDataFileService); + 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 index d38f166..f831397 100644 --- a/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs +++ b/src/LageBuch.App.Shared/Controls/MapCanvasControl.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; +using LageBuch.AppLogic.Services; using LageBuch.Domain.Wasserfoerderung; using LageBuch.Persistence.Wasserfoerderung; @@ -15,7 +16,9 @@ namespace LageBuch.App.Shared.Controls; /// ), 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. +/// 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 { @@ -40,13 +43,35 @@ public sealed class MapCanvasControl : Control 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); } - public MapCanvasControl() => Focusable = true; + // 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 { @@ -92,6 +117,18 @@ public ICommand? UndoRequestedCommand 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); @@ -126,13 +163,127 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) 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 ((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 index 80f0447..36146a1 100644 --- a/src/LageBuch.App.Shared/Controls/MapDrawing.cs +++ b/src/LageBuch.App.Shared/Controls/MapDrawing.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Media; using Avalonia.Media.Imaging; +using LageBuch.AppLogic.Services; using LageBuch.Domain.Wasserfoerderung; using LageBuch.Persistence.Wasserfoerderung; @@ -38,25 +39,58 @@ private static void DrawTiles( 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) - continue; + { + 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, new Rect(bitmap.Size), destRect); + 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) diff --git a/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml index 2561551..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,18 +109,63 @@ - + - - - - - - - - + + + + + + + + + + + + +