Skip to content
Open
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");
}
16 changes: 14 additions & 2 deletions src/LageBuch.App.Shared/CompositionRoot.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using LageBuch.App.Shared.Services;
using LageBuch.AppLogic.Services;
using LageBuch.AppLogic.ViewModels;
using LageBuch.Domain.Time;
Expand All @@ -13,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 @@ -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);
}
}
138 changes: 138 additions & 0 deletions src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Draws map tiles for the operator's Einsatzgebiet and the in-progress Wasserförderung route
/// (#150 Plan B). Plain <see cref="Control"/> with a hand-rolled <see cref="Render"/> — there's no
/// XAML template, just tiles and a polyline over them. Left-click adds a route point (via
/// <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.
/// </summary>
public sealed class MapCanvasControl : Control
{
public static readonly StyledProperty<IMapTileSource?> TileSourceProperty =
AvaloniaProperty.Register<MapCanvasControl, IMapTileSource?>(nameof(TileSource));

public static readonly StyledProperty<double> CenterLatitudeProperty =
AvaloniaProperty.Register<MapCanvasControl, double>(nameof(CenterLatitude));

public static readonly StyledProperty<double> CenterLongitudeProperty =
AvaloniaProperty.Register<MapCanvasControl, double>(nameof(CenterLongitude));

public static readonly StyledProperty<int> ZoomProperty =
AvaloniaProperty.Register<MapCanvasControl, int>(nameof(Zoom), defaultValue: 15);

public static readonly StyledProperty<IReadOnlyList<GeoPoint>?> RoutePointsProperty =
AvaloniaProperty.Register<MapCanvasControl, IReadOnlyList<GeoPoint>?>(nameof(RoutePoints));

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

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

static MapCanvasControl()
{
AffectsRender<MapCanvasControl>(
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<GeoPoint>? RoutePoints
{
get => GetValue(RoutePointsProperty);
set => SetValue(RoutePointsProperty, value);
}

/// <summary>Invoked with the clicked point's <see cref="GeoPoint"/> on a left click.</summary>
public ICommand? PointClickedCommand
{
get => GetValue(PointClickedCommandProperty);
set => SetValue(PointClickedCommandProperty, value);
}

/// <summary>Invoked (no parameter) on a right click.</summary>
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;
}
}
78 changes: 78 additions & 0 deletions src/LageBuch.App.Shared/Controls/MapDrawing.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The tile+polyline drawing shared by <see cref="MapCanvasControl"/>'s live view and
/// <c>RouteOverviewRenderer</c>'s off-screen PDF snapshot (#150 Plan B) — one implementation of
/// "paint the map centered at (lat,lon)/zoom into this rectangle" for both.
/// </summary>
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<GeoPoint>? 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<GeoPoint>? 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;
}
}
}
35 changes: 35 additions & 0 deletions src/LageBuch.App.Shared/Controls/WebMercator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using LageBuch.Domain.Wasserfoerderung;

namespace LageBuch.App.Shared.Controls;

/// <summary>
/// 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.
/// </summary>
public static class WebMercator
{
public const int TileSizePixels = 256;

/// <summary>Lat/lon (degrees) to the pixel position in the whole rendered world map at <paramref name="zoom"/>.</summary>
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);
}

/// <summary>Inverse of <see cref="ToWorldPixel"/>.</summary>
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));
}
62 changes: 62 additions & 0 deletions src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Renders a small map snapshot of a drawn route off-screen for the PDF (#150 phase 2), sharing
/// <see cref="MapDrawing"/> with <see cref="MapCanvasControl"/>'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.
/// </summary>
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<GeoPoint> 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();
}

/// <summary>Largest zoom at which the route's bounding box still fits inside the image (minus <see cref="Margin"/>).</summary>
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;
}
}
Loading
Loading