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
162 changes: 156 additions & 6 deletions src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,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 @@ -41,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 @@ -93,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 @@ -127,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);
}
}
37 changes: 35 additions & 2 deletions src/LageBuch.App.Shared/Controls/MapDrawing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,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
12 changes: 9 additions & 3 deletions src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
<!-- Everything below the header scrolls as one unit rather than fighting the header/each
other for space when the window is short or narrow (#150 phase 2 regression: a fixed
Height map plus stacked Bottom-docked panels overflowed a modest window and rendered on
top of the header buttons). -->
top of the header buttons). Right margin matches Fluent's overlay scrollbar width (#150
follow-up regression: once content needs scrolling, that scrollbar renders on top of, not
narrowing, the content, silently swallowing clicks on the map's own right edge). -->
<ScrollViewer>
<StackPanel Spacing="16">
<StackPanel Spacing="16" Margin="0,0,16,0">
<Border Background="#0C1119" BorderBrush="{StaticResource HairlineBrush}" BorderThickness="1"
CornerRadius="{StaticResource RadiusMd}" ClipToBounds="True" Height="260">
<DataGrid x:Name="WasserfoerderungGrid" ItemsSource="{Binding Rows}" IsReadOnly="{Binding IsReadOnly}"
Expand Down Expand Up @@ -103,13 +105,16 @@
</HeaderedContentControl>
<Button Content="−" Width="32" Command="{Binding ZoomOutCommand}" VerticalAlignment="Bottom" Margin="0,0,0,1" />
<Button Content="+" Width="32" Command="{Binding ZoomInCommand}" VerticalAlignment="Bottom" Margin="0,0,0,1" />
<Button Content="ZENTRIEREN" Command="{Binding ResetMapViewCommand}"
VerticalAlignment="Bottom" Margin="0,0,0,1" />
<Button Content="RÜCKGÄNGIG" Command="{Binding UndoLastRoutePointCommand}"
VerticalAlignment="Bottom" Margin="0,0,0,1" />
<Button Content="ROUTE LÖSCHEN" Command="{Binding ClearRouteCommand}"
VerticalAlignment="Bottom" Margin="0,0,0,1" />
<Button Classes="signal" Content="FERTIG" Command="{Binding FinishRouteCommand}"
VerticalAlignment="Bottom" Margin="0,0,0,1" />
</WrapPanel>
<TextBlock Text="Strg + Scrollen: Zoom · Strg + Ziehen: Verschieben" Classes="secondary hint" />
<TextBlock Text="{Binding ErrorMessage}" Foreground="#FF3D2E" Classes="hint"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
Expand All @@ -125,7 +130,8 @@
Zoom="{Binding MapZoom}"
RoutePoints="{Binding DrawnRoutePoints}"
PointClickedCommand="{Binding AddRoutePointCommand}"
UndoRequestedCommand="{Binding UndoLastRoutePointCommand}" />
UndoRequestedCommand="{Binding UndoLastRoutePointCommand}"
ViewChangedCommand="{Binding ChangeMapViewCommand}" />
</Border>
</StackPanel>
</ScrollViewer>
Expand Down
9 changes: 9 additions & 0 deletions src/LageBuch.AppLogic/Services/MapViewChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace LageBuch.AppLogic.Services;

/// <summary>
/// A wheel/pinch-driven map view change from <c>MapCanvasControl</c> (#150 follow-up) — the
/// control->VM counterpart of <c>GeoPoint</c> for <c>WasserfoerderungViewModel.ChangeMapViewCommand</c>,
/// living alongside <see cref="WebMercator"/> (not in a specific ViewModel or the Domain layer)
/// since both the App.Shared control and the AppLogic view model need to reference it.
/// </summary>
public sealed record MapViewChange(double CenterLatitude, double CenterLongitude, int Zoom);
13 changes: 8 additions & 5 deletions src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ private void BuildChildren()
var (elevationSampler, tileSource) = BuildWasserfoerderungMapSources();
var initialMapView = InitialMapViewFrom(tileSource);
Wasserfoerderung = new WasserfoerderungViewModel(
_session, OnChanged, elevationSampler, tileSource, initialMapView?.Center, initialMapView?.Zoom);
_session, OnChanged, elevationSampler, tileSource,
initialMapView?.Center, initialMapView?.Zoom, initialMinZoom: initialMapView?.Zoom);

Reminder?.Dispose();
// The ILS reminder is autonomous, time-driven host-side logging (§ IsRemote) — a joined
Expand Down Expand Up @@ -287,10 +288,12 @@ private void BuildChildren()
}

/// <summary>
/// Derives the Karte mode's initial view from the tiles the configured region actually has,
/// rather than an unrelated fixed fallback (#150 follow-up) — a real per-Landkreis pack has no
/// tiles anywhere near <see cref="WasserfoerderungViewModel"/>'s hardcoded German default, so
/// without this the map opened blank until the operator happened to pan to the right area.
/// Derives the Karte mode's initial view — and, doubling as the region's lowest usable zoom
/// (<c>initialMinZoom</c>), the floor past which zooming out has nothing to show — from the
/// tiles the configured region actually has, rather than an unrelated fixed fallback (#150
/// follow-up). A real per-Landkreis pack has no tiles anywhere near
/// <see cref="WasserfoerderungViewModel"/>'s hardcoded German default and only ever renders a
/// narrow zoom band, so without this the map opened blank (or could be zoomed out to blank).
/// </summary>
private static (GeoPoint Center, int Zoom)? InitialMapViewFrom(IMapTileSource? tileSource)
{
Expand Down
Loading
Loading