Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/LageBuch.App.Android/MainActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/LageBuch.App.Android/Services/AndroidAppPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
13 changes: 12 additions & 1 deletion src/LageBuch.App.Shared/CompositionRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ namespace LageBuch.App.Shared;
/// </summary>
public static class CompositionRoot
{
/// <summary>
/// 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.
/// </summary>
public const string RegionPackManifestUrl =
"https://raw.githubusercontent.com/CodeForFire/lagebuch-regions/main/regions.json";

public static MainWindowViewModel CreateMainWindowViewModel(
IIncidentStore store,
IMasterDataProvider masterData,
Expand All @@ -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);
}
}
163 changes: 157 additions & 6 deletions src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -15,7 +16,9 @@ namespace LageBuch.App.Shared.Controls;
/// <see cref="PointClickedCommand"/>), right-click undoes the last one (via
/// <see cref="UndoRequestedCommand"/>); 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.
/// </summary>
public sealed class MapCanvasControl : Control
{
Expand All @@ -40,13 +43,35 @@ public sealed class MapCanvasControl : Control
public static readonly StyledProperty<ICommand?> UndoRequestedCommandProperty =
AvaloniaProperty.Register<MapCanvasControl, ICommand?>(nameof(UndoRequestedCommand));

public static readonly StyledProperty<ICommand?> ViewChangedCommandProperty =
AvaloniaProperty.Register<MapCanvasControl, ICommand?>(nameof(ViewChangedCommand));

static MapCanvasControl()
{
AffectsRender<MapCanvasControl>(
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
{
Expand Down Expand Up @@ -92,6 +117,18 @@ public ICommand? UndoRequestedCommand
set => SetValue(UndoRequestedCommandProperty, value);
}

/// <summary>
/// Invoked with a <see cref="MapViewChange"/> 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 (<see cref="PointClickedCommand"/>,
/// <see cref="UndoRequestedCommand"/>) instead of changing those three properties' binding mode.
/// </summary>
public ICommand? ViewChangedCommand
{
get => GetValue(ViewChangedCommandProperty);
set => SetValue(ViewChangedCommandProperty, value);
}

public override void Render(DrawingContext context)
{
base.Render(context);
Expand Down Expand Up @@ -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;
}

/// <summary>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).</summary>
public static int PinchScaleToZoomDelta(double relativeScale) =>
(int)Math.Round(Math.Log2(Math.Max(relativeScale, 0.01)));

/// <summary>Zooms to <paramref name="newZoom"/> while keeping the geo point currently under
/// <paramref name="screenPoint"/> stationary on screen (#150 follow-up) — the standard
/// "zoom to cursor"/"zoom to pinch centroid" map UX.</summary>
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);
}
}
38 changes: 36 additions & 2 deletions src/LageBuch.App.Shared/Controls/MapDrawing.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
}
}
}

/// <summary>
/// When the exact tile at (<paramref name="zoom"/>, <paramref name="x"/>, <paramref name="y"/>)
/// isn't available and <paramref name="zoom"/> is past the source's actual max detail
/// (<paramref name="sourceMaxZoom"/>), 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).
/// </summary>
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<GeoPoint>? routePoints, int zoom, double centerX, double centerY,
double width, double height)
Expand Down
Loading
Loading