From 7575f611df26796aea77d91e0bc146c6f74884ca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20M=C3=BCller?=
<1005065+DeepDiver1975@users.noreply.github.com>
Date: Sun, 30 Aug 2026 00:20:47 +0200
Subject: [PATCH 1/2] =?UTF-8?q?feat(wasserfoerderung):=20F=C3=B6rderstreck?=
=?UTF-8?q?en=20planen=20=E2=80=94=20Rechner,=20Tab,=20PDF=20(#150)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
---
.../Views/IncidentWorkspaceView.axaml | 5 +
.../Views/WasserfoerderungView.axaml | 76 +++++++++
.../Views/WasserfoerderungView.axaml.cs | 8 +
src/LageBuch.AppLogic/LocalIncidentSession.cs | 6 +
.../ViewModels/IncidentWorkspaceViewModel.cs | 5 +
.../ViewModels/WasserfoerderungViewModel.cs | 146 ++++++++++++++++++
src/LageBuch.Documents/Formatting.cs | 2 +
.../IncidentReportDocument.cs | 1 +
.../Sections/WasserfoerderungSection.cs | 70 +++++++++
src/LageBuch.Domain/Incident.cs | 39 ++++-
.../F\303\266rderstreckeConfig.cs" | 38 +++++
.../F\303\266rderstreckePlanner.cs" | 78 ++++++++++
.../WasserfoerderungLeitung.cs | 96 ++++++++++++
.../IncidentRepository.cs | 35 ++++-
src/LageBuch.Persistence/Sqlite/Migrations.cs | 30 +++-
src/LageBuch.Sync/CommandApplier.cs | 6 +
src/LageBuch.Sync/IIncidentSession.cs | 6 +
src/LageBuch.Sync/IncidentSnapshot.cs | 18 ++-
src/LageBuch.Sync/RemoteIncidentSession.cs | 6 +
src/LageBuch.Sync/SnapshotMapper.cs | 13 +-
src/LageBuch.Sync/SyncCommand.cs | 9 ++
.../FilesTabRenderTests.cs | 4 +-
.../LinksTabRenderTests.cs | 4 +-
.../TasksTabRenderTests.cs | 4 +-
.../WasserfoerderungTabRenderTests.cs | 63 ++++++++
.../WorkspaceAcceptanceTests.cs | 4 +-
.../WasserfoerderungSectionTests.cs | 26 ++++
...03\266rderstreckePlannerElevationTests.cs" | 46 ++++++
.../F\303\266rderstreckePlannerTests.cs" | 54 +++++++
.../WasserfoerderungAggregateTests.cs | 80 ++++++++++
.../IncidentRoundTripTests.cs | 31 ++++
.../MigrationForwardCompatTests.cs | 28 ++++
.../MigrationsTests.cs | 10 ++
.../CommandApplierTests.cs | 32 ++++
.../CommandSerializationTests.cs | 3 +
.../SnapshotRoundTripTests.cs | 26 ++++
36 files changed, 1093 insertions(+), 15 deletions(-)
create mode 100644 src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
create mode 100644 src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml.cs
create mode 100644 src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
create mode 100644 src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
create mode 100644 "src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckeConfig.cs"
create mode 100644 "src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
create mode 100644 src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
create mode 100644 tests/LageBuch.Acceptance.Tests/WasserfoerderungTabRenderTests.cs
create mode 100644 tests/LageBuch.Documents.Tests/WasserfoerderungSectionTests.cs
create mode 100644 "tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerElevationTests.cs"
create mode 100644 "tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerTests.cs"
create mode 100644 tests/LageBuch.Domain.Tests/WasserfoerderungAggregateTests.cs
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml.cs b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml.cs
new file mode 100644
index 0000000..48cc1e5
--- /dev/null
+++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml.cs
@@ -0,0 +1,8 @@
+using Avalonia.Controls;
+
+namespace LageBuch.App.Shared.Views;
+
+public partial class WasserfoerderungView : UserControl
+{
+ public WasserfoerderungView() => InitializeComponent();
+}
\ No newline at end of file
diff --git a/src/LageBuch.AppLogic/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs
index 8c7e1ce..319603e 100644
--- a/src/LageBuch.AppLogic/LocalIncidentSession.cs
+++ b/src/LageBuch.AppLogic/LocalIncidentSession.cs
@@ -154,6 +154,12 @@ public void AddTask(string text, string? assignee, TaskImportance importance, Ta
public void SetTaskCompleted(Guid taskId, bool isDone) =>
Mutate(() => Incident.SetTaskCompleted(taskId, isDone, _clock, RequireOperator()));
+ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprechpartner, double lengthMeters, double elevationRiseMeters) =>
+ Mutate(() => Incident.AddWasserfoerderungLeitung(uebergabestelle, ansprechpartner, lengthMeters, elevationRiseMeters));
+
+ public void RemoveWasserfoerderungLeitung(Guid leitungId) =>
+ Mutate(() => Incident.RemoveWasserfoerderungLeitung(leitungId));
+
public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
index d07165a..5b4bcc2 100644
--- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
@@ -156,6 +156,7 @@ private void ConfirmIncidentNumber()
public LinksViewModel Links { get; private set; } = null!;
public TasksViewModel Tasks { get; private set; } = null!;
public ReminderViewModel? Reminder { get; private set; }
+ public WasserfoerderungViewModel Wasserfoerderung { get; private set; } = null!;
public string StatusDisplay => Formatting.State(_session.Incident.State);
@@ -233,6 +234,9 @@ private void BuildChildren()
Tasks?.Dispose();
Tasks = new TasksViewModel(_session, _clock, _ticker, _alarm, _masterData, OnChanged);
+ Wasserfoerderung?.Dispose();
+ Wasserfoerderung = new WasserfoerderungViewModel(_session, OnChanged);
+
Reminder?.Dispose();
// The ILS reminder is autonomous, time-driven host-side logging (§ IsRemote) — a joined
// client must not run its own, or the host's journal would be double-logged.
@@ -252,6 +256,7 @@ private void BuildChildren()
OnPropertyChanged(nameof(Files));
OnPropertyChanged(nameof(Links));
OnPropertyChanged(nameof(Tasks));
+ OnPropertyChanged(nameof(Wasserfoerderung));
OnPropertyChanged(nameof(Reminder));
OnPropertyChanged(nameof(HasReminder));
}
diff --git a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
new file mode 100644
index 0000000..5ae4f94
--- /dev/null
+++ b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
@@ -0,0 +1,146 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Documents;
+using LageBuch.Sync;
+
+namespace LageBuch.AppLogic.ViewModels;
+
+///
+/// The WASSERFÖRDERUNG tab (#150, Plan A): plans one Förderstrecke-Leitung (Ltg 1, Ltg 2, …)
+/// per row. Derivation lives entirely in — this VM only sends
+/// the plan inputs through the session and renders the resulting immutable figures. Rows rebuild
+/// wholesale on every change (remote broadcast included), mirroring .
+/// Domain guards (zero/negative length, a climb too steep for one hose) surface on
+/// instead of crashing the app — the same pattern FilesViewModel uses for its async failures.
+///
+public sealed partial class WasserfoerderungViewModel : ObservableObject, IDisposable
+{
+ private readonly IIncidentSession _session;
+ private readonly Action _onChanged;
+
+ public WasserfoerderungViewModel(IIncidentSession session, Action onChanged)
+ {
+ _session = session;
+ _onChanged = onChanged;
+ IsReadOnly = session.IsReadOnly;
+ Rows = new ObservableCollection();
+ _session.Changed += Sync;
+ Sync();
+ }
+
+ public bool IsReadOnly { get; }
+ public ObservableCollection Rows { get; }
+
+ [ObservableProperty]
+ private string? _newUebergabestelle;
+
+ [ObservableProperty]
+ private string? _newAnsprechpartner;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddLeitungCommand))]
+ private double? _newLengthMeters;
+
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(AddLeitungCommand))]
+ private double? _newElevationRiseMeters;
+
+ [ObservableProperty]
+ private string? _errorMessage;
+
+ private bool CanAddLeitung =>
+ !IsReadOnly && NewLengthMeters is { } length && length > 0 && NewElevationRiseMeters is { } rise && rise >= 0;
+
+ [RelayCommand(CanExecute = nameof(CanAddLeitung))]
+ private void AddLeitung()
+ {
+ ErrorMessage = null;
+ try
+ {
+ _session.AddWasserfoerderungLeitung(NewUebergabestelle, NewAnsprechpartner,
+ NewLengthMeters!.Value, NewElevationRiseMeters!.Value);
+ NewUebergabestelle = string.Empty;
+ NewAnsprechpartner = string.Empty;
+ NewLengthMeters = null;
+ NewElevationRiseMeters = null;
+ _onChanged();
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ public void Sync()
+ {
+ var rows = _session.Incident.Wasserfoerderung
+ .Select(l => new WasserfoerderungLeitungRow(_session, l, IsReadOnly, _onChanged))
+ .ToList();
+
+ Rows.Clear();
+ foreach (var row in rows)
+ Rows.Add(row);
+ }
+
+ public void Dispose()
+ {
+ _session.Changed -= Sync;
+ }
+}
+
+/// One rendered Leitung row. Immutable display strings plus the remove action.
+public sealed partial class WasserfoerderungLeitungRow : ObservableObject
+{
+ private readonly IIncidentSession _session;
+ private readonly Guid _id;
+ private readonly Action _onChanged;
+
+ public WasserfoerderungLeitungRow(IIncidentSession session, WasserfoerderungLeitung leitung, bool isReadOnly, Action onChanged)
+ {
+ _session = session;
+ _id = leitung.Id;
+ _onChanged = onChanged;
+ IsReadOnly = isReadOnly;
+ NumberDisplay = $"Ltg {leitung.Number}";
+ UebergabestelleDisplay = string.IsNullOrWhiteSpace(leitung.Uebergabestelle) ? "—" : leitung.Uebergabestelle;
+ AnsprechpartnerDisplay = string.IsNullOrWhiteSpace(leitung.Ansprechpartner) ? "—" : leitung.Ansprechpartner;
+ LengthDisplay = Formatting.Meters(leitung.LengthMeters);
+ RiseDisplay = leitung.ElevationRiseMeters > 0 ? Formatting.Meters(leitung.ElevationRiseMeters) : "—";
+ BLengthsDisplay = leitung.HoseCount.ToString();
+ FlowDisplay = $"{leitung.FlowLMin} l/min";
+ PumpDisplay = leitung.PumpCount.ToString();
+ ReservePumpDisplay = leitung.ReservePumpCount.ToString();
+ ReserveHoseDisplay = leitung.ReserveHoseCount.ToString();
+ ResultDisplay = BuildResult(leitung);
+ }
+
+ public Guid Id => _id;
+ public bool IsReadOnly { get; }
+ public string NumberDisplay { get; }
+ public string UebergabestelleDisplay { get; }
+ public string AnsprechpartnerDisplay { get; }
+ public string LengthDisplay { get; }
+ public string RiseDisplay { get; }
+ public string BLengthsDisplay { get; }
+ public string FlowDisplay { get; }
+ public string PumpDisplay { get; }
+ public string ReservePumpDisplay { get; }
+ public string ReserveHoseDisplay { get; }
+ public string ResultDisplay { get; }
+
+ [RelayCommand]
+ private void Remove()
+ {
+ _session.RemoveWasserfoerderungLeitung(_id);
+ _onChanged();
+ }
+
+ private static string BuildResult(WasserfoerderungLeitung l) => l.PumpCount switch
+ {
+ 0 => "Direktleitung",
+ 1 => $"{l.PumpCount} Pumpe",
+ _ => $"{l.PumpCount} Pumpen",
+ };
+}
\ No newline at end of file
diff --git a/src/LageBuch.Documents/Formatting.cs b/src/LageBuch.Documents/Formatting.cs
index dccb004..e4f62db 100644
--- a/src/LageBuch.Documents/Formatting.cs
+++ b/src/LageBuch.Documents/Formatting.cs
@@ -43,4 +43,6 @@ public static string OrDash(string? value) =>
TaskUrgency.Medium => "Mittel",
_ => "Niedrig",
};
+
+ public static string Meters(double meters) => $"{meters:0.###} m";
}
diff --git a/src/LageBuch.Documents/IncidentReportDocument.cs b/src/LageBuch.Documents/IncidentReportDocument.cs
index c497c3a..b8e5b7e 100644
--- a/src/LageBuch.Documents/IncidentReportDocument.cs
+++ b/src/LageBuch.Documents/IncidentReportDocument.cs
@@ -51,6 +51,7 @@ public void Compose(IDocumentContainer document)
column.Item().Element(c => RolesSection.Compose(c, _incident));
column.Item().Element(c => ForcesSection.Compose(c, _incident));
column.Item().Element(c => TasksSection.Compose(c, _incident));
+ column.Item().Element(c => WasserfoerderungSection.Compose(c, _incident));
column.Item().Element(c => AtemschutzSection.Compose(c, _incident));
column.Item().Element(c => CoMessprotokollSection.Compose(c, _incident));
column.Item().Element(c => FilesSection.Compose(c, _incident.Files, _imageBytesById));
diff --git a/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs b/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
new file mode 100644
index 0000000..9e12bd1
--- /dev/null
+++ b/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
@@ -0,0 +1,70 @@
+using LageBuch.Domain;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace LageBuch.Documents.Sections;
+
+public static class WasserfoerderungSection
+{
+ public static void Compose(IContainer container, Incident incident)
+ {
+ container.Column(column =>
+ {
+ column.Spacing(4);
+ column.Item().Text("Wasserförderung").FontSize(14).SemiBold().FontColor(Colors.Blue.Darken1);
+
+ if (incident.Wasserfoerderung.Count == 0)
+ {
+ column.Item().Text("— keine Förderstrecke geplant —").Italic().FontColor(Colors.Grey.Medium);
+ return;
+ }
+
+ column.Item().Table(table =>
+ {
+ table.ColumnsDefinition(columns =>
+ {
+ columns.ConstantColumn(46);
+ columns.RelativeColumn(2);
+ columns.RelativeColumn(1.5f);
+ columns.ConstantColumn(50);
+ columns.ConstantColumn(55);
+ columns.ConstantColumn(65);
+ columns.ConstantColumn(80);
+ columns.ConstantColumn(80);
+ });
+
+ table.Header(header =>
+ {
+ foreach (var title in new[] { "Leitung", "Übergabestelle", "Ansprechpartner", "B-Längen",
+ "Länge", "Höhen-unterschied", "Verstärker-pumpen", "Reserve-pumpen" })
+ header.Cell().Element(HeaderCell).Text(title).SemiBold();
+ });
+
+ foreach (var leitung in incident.Wasserfoerderung)
+ {
+ table.Cell().Element(BodyCell).Text($"Ltg {leitung.Number}");
+ table.Cell().Element(BodyCell).Text(Formatting.OrDash(leitung.Uebergabestelle));
+ table.Cell().Element(BodyCell).Text(Formatting.OrDash(leitung.Ansprechpartner));
+ table.Cell().Element(BodyCell).Text(leitung.HoseCount.ToString());
+ table.Cell().Element(BodyCell).Text(Formatting.Meters(leitung.LengthMeters));
+ table.Cell().Element(BodyCell).Text(
+ leitung.ElevationRiseMeters > 0 ? Formatting.Meters(leitung.ElevationRiseMeters) : "—");
+ table.Cell().Element(BodyCell).Text(leitung.PumpCount.ToString());
+ table.Cell().Element(BodyCell).Text(leitung.ReservePumpCount.ToString());
+ }
+ });
+
+ column.Item().PaddingTop(2).Text(
+ "Planung: B-800, B-Schlauch 20 m, 8 bar Speisedruck, 1,5 bar Pumpeneingang, " +
+ "3 % Reserveschlauch pro Teilstrecke.")
+ .FontSize(8).Italic().FontColor(Colors.Grey.Medium);
+ });
+ }
+
+ private static IContainer HeaderCell(IContainer c) =>
+ c.Background(Colors.Grey.Lighten3).PaddingVertical(3).PaddingHorizontal(4).BorderBottom(1).BorderColor(Colors.Grey.Medium);
+
+ private static IContainer BodyCell(IContainer c) =>
+ c.PaddingVertical(2).PaddingHorizontal(4).BorderBottom(1).BorderColor(Colors.Grey.Lighten2);
+}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs
index 89c6ca4..94c0d6b 100644
--- a/src/LageBuch.Domain/Incident.cs
+++ b/src/LageBuch.Domain/Incident.cs
@@ -4,6 +4,7 @@
using LageBuch.Domain.Files;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.Time;
+using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Domain.ValueObjects;
namespace LageBuch.Domain;
@@ -24,6 +25,7 @@ public sealed class Incident
private readonly List _tasks = new();
private readonly List _buildings = new();
private readonly List _dwellings = new();
+ private readonly List _wasserfoerderung = new();
private Incident() { }
@@ -60,6 +62,9 @@ private Incident() { }
public IReadOnlyList Buildings => _buildings;
public IReadOnlyList Dwellings => _dwellings;
+ /// Planned Wasserförderungs-Leitungen (#150), in creation order (Ltg 1, Ltg 2, …).
+ public IReadOnlyList Wasserfoerderung => _wasserfoerderung;
+
/// The persisted state of the timer with this key, or null if none has been recorded.
public IncidentTimerState? FindTimer(string key) => _timers.Find(t => t.Key == key);
@@ -124,7 +129,8 @@ public static Incident Rehydrate(
IEnumerable files,
IEnumerable tasks,
IEnumerable buildings,
- IEnumerable dwellings)
+ IEnumerable dwellings,
+ IEnumerable? wasserfoerderung = null)
{
var incident = new Incident
{
@@ -151,6 +157,8 @@ public static Incident Rehydrate(
incident._tasks.AddRange(tasks);
incident._buildings.AddRange(buildings);
incident._dwellings.AddRange(dwellings);
+ if (wasserfoerderung is not null)
+ incident._wasserfoerderung.AddRange(wasserfoerderung);
return incident;
}
@@ -740,6 +748,35 @@ public IncidentTask SetTaskCompleted(Guid taskId, bool isDone, IClock clock, Ses
return updated;
}
+ ///
+ /// Plans and records one Förderstrecke-Leitung (#150). The pump/pressure figures are computed
+ /// by at creation and stored on the Leitung — the PDF and
+ /// remote clients never recompute. Silently planned (no ETB line), like tasks.
+ ///
+ public WasserfoerderungLeitung AddWasserfoerderungLeitung(
+ string? uebergabestelle, string? ansprechpartner, double lengthM, double riseM)
+ {
+ EnsureOpen();
+ var leitung = WasserfoerderungLeitung.Create(
+ number: _wasserfoerderung.Count + 1,
+ uebergabestelle: uebergabestelle,
+ ansprechpartner: ansprechpartner,
+ lengthM: lengthM,
+ riseM: riseM);
+ _wasserfoerderung.Add(leitung);
+ return leitung;
+ }
+
+ /// Removes the planned Leitung. Unknown ids throw so a replayed command fails loudly.
+ public void RemoveWasserfoerderungLeitung(Guid leitungId)
+ {
+ EnsureOpen();
+ var index = _wasserfoerderung.FindIndex(l => l.Id == leitungId);
+ if (index < 0)
+ throw new KeyNotFoundException($"Wasserförderungsleitung {leitungId} nicht gefunden.");
+ _wasserfoerderung.RemoveAt(index);
+ }
+
private AtemschutzTrupp FindScbaTrupp(Guid truppId) =>
_scbaTrupps.FirstOrDefault(t => t.Id == truppId)
?? throw new KeyNotFoundException($"Atemschutz-Trupp {truppId} not found.");
diff --git "a/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckeConfig.cs" "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckeConfig.cs"
new file mode 100644
index 0000000..6f04398
--- /dev/null
+++ "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckeConfig.cs"
@@ -0,0 +1,38 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+///
+/// Tunables for . Defaults mirror the verified B-line figures:
+/// TS 8/8 (8 bar @ 800 l/min), 1.5 bar inlet at the next pump (closed Schaltreihe), 20 m B-hose
+/// (DIN 14811), 3% headroom per leg, one reserve pump per four Verstärkerpumpen.
+///
+public sealed record FörderstreckeConfig
+{
+ /// Nominal B-flow in l/min; drives the friction-loss lookup table.
+ public int FlowLMin { get; init; } = 800;
+
+ /// Discharge pressure of each pump (weakest pump governs the chain).
+ public double FeedPressureBar { get; init; } = 8;
+
+ /// Required suction pressure at the next pump before it can re-pressurize.
+ public double InletPressureBar { get; init; } = 1.5;
+
+ /// Fraction of the usable pressure budget kept in reserve on every leg.
+ public double HeadroomPercent { get; init; } = 0.03;
+
+ /// Length of one B-Schlauch in meters; legs snap down to whole hoses.
+ public double HoseLengthMeters { get; init; } = 20;
+
+ /// One reserve pump per this many Verstärkerpumpen (rule of thumb 1:3–5).
+ public int ReservePumpEveryNPumps { get; init; } = 4;
+
+ public static FörderstreckeConfig Default => new();
+}
+
+/// The result of a run.
+public sealed record FörderstreckePlan(
+ double LengthMeters,
+ int HoseCount,
+ int ReserveHoseCount,
+ int PumpCount,
+ int ReservePumpCount,
+ IReadOnlyList PumpPositionsMeters);
\ No newline at end of file
diff --git "a/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs" "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
new file mode 100644
index 0000000..9f05170
--- /dev/null
+++ "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
@@ -0,0 +1,78 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+///
+/// Pure engine that places Verstärkerpumpen along a Förderstrecke (#150, Plan A). Given a total
+/// length, a total elevation rise (treated as a uniform gradient — Plan B replaces that with a
+/// sampled profile) and a , it computes B-hose count, pump
+/// positions and reserve figures. Physics is linear and therefore testable without deps:
+///
+/// head-loss per leg = friction + elevation friction = loss/100m at flow
+/// usable budget = (feed − inlet) · (1 − headroom)
+/// legs snap down to whole hoses; every pump restarts from feed pressure (closed Schaltreihe),
+/// the feed pump at the water source is NOT counted as a Verstärkerpumpe (user decision).
+///
+public static class FörderstreckePlanner
+{
+ // Verified B-75 mm table: bar per 100 m at 200/400/600/800/1000/1200 l/min (midpoints).
+ private static readonly double[] FlowPoints = { 200, 400, 600, 800, 1000, 1200 };
+ private static readonly double[] LossBarPer100M = { 0.1, 0.3, 0.6, 1.0, 1.5, 2.25 };
+
+ // 10 m elevation rise costs 1 bar, so 0.1 bar per meter of rise.
+ private const double ElevationBarPerMeter = 0.1;
+
+ public static FörderstreckePlan Plan(double lengthM, double riseM, FörderstreckeConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ if (lengthM <= 0)
+ throw new ArgumentException("Die Förderstrecke muss länger als 0 m sein.", nameof(lengthM));
+
+ var lossPerMeter = LossPer100Meters(config.FlowLMin) / 100;
+ var headPerMeter = lossPerMeter + ElevationBarPerMeter * riseM / lengthM;
+ var hoseCount = (int)Math.Ceiling(lengthM / config.HoseLengthMeters);
+ var reserveHoseCount = (int)Math.Ceiling(lengthM / 100);
+
+ // Gravity assist (or a flat short line) means one leg can carry the whole route.
+ var legLengthM = headPerMeter <= 0
+ ? lengthM
+ : BudgetPerLegBar(config) / headPerMeter;
+
+ var legSnapped = Math.Min(
+ Math.Floor(legLengthM / config.HoseLengthMeters) * config.HoseLengthMeters,
+ lengthM);
+
+ // A single hose cannot carry a leg: the climb physically does not fit.
+ if (legSnapped < config.HoseLengthMeters)
+ throw new ArgumentException(
+ "Die Steigung ist zu stark — ein B-Schlauch (20 m) trägt das Gefälle bereits über das Druckbudget.");
+
+ var positions = new List();
+ for (var pos = 0.0; pos < lengthM; pos += legSnapped)
+ positions.Add(pos);
+
+ var pumpCount = Math.Max(0, positions.Count - 1); // exclude the feed pump at 0
+ var reservePumpCount = (int)Math.Ceiling((double)pumpCount / config.ReservePumpEveryNPumps);
+
+ return new FörderstreckePlan(lengthM, hoseCount, reserveHoseCount, pumpCount, reservePumpCount, positions);
+ }
+
+ private static double BudgetPerLegBar(FörderstreckeConfig config) =>
+ (config.FeedPressureBar - config.InletPressureBar) * (1 - config.HeadroomPercent);
+
+ private static double LossPer100Meters(int flowLMin)
+ {
+ if (flowLMin < FlowPoints[0] || flowLMin > FlowPoints[^1])
+ throw new ArgumentException(
+ $"Durchfluss {flowLMin} l/min liegt außerhalb der Tabelle (200–1200 l/min).", nameof(flowLMin));
+
+ var flow = (double)flowLMin;
+ for (var i = 0; i < FlowPoints.Length - 1; i++)
+ {
+ if (flow <= FlowPoints[i + 1])
+ {
+ var t = (flow - FlowPoints[i]) / (FlowPoints[i + 1] - FlowPoints[i]);
+ return LossBarPer100M[i] + t * (LossBarPer100M[i + 1] - LossBarPer100M[i]);
+ }
+ }
+ return LossBarPer100M[^1];
+ }
+}
\ No newline at end of file
diff --git a/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs b/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
new file mode 100644
index 0000000..3967029
--- /dev/null
+++ b/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
@@ -0,0 +1,96 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+///
+/// One planned Förderstrecke-Leitung (Ltg 1, Ltg 2, …) on the incident (#150, Plan A). Immutable
+/// like every other aggregate child; the plan figures are computed once at creation by
+/// and stored, so the PDF and remote clients always render the
+/// exact numbers that were planned — never a recomputation that could drift.
+///
+public sealed record WasserfoerderungLeitung
+{
+ private WasserfoerderungLeitung() { }
+
+ public Guid Id { get; private init; }
+ public int Number { get; private init; }
+
+ /// Excel "Übergabestelle [Fzg., Behälter, …]" — the vehicle/container at the line end.
+ public string? Uebergabestelle { get; private init; }
+
+ /// Excel "Ansprechpartner Funkrufname".
+ public string? Ansprechpartner { get; private init; }
+
+ public int FlowLMin { get; private init; }
+ public double FeedPressureBar { get; private init; }
+ public double LengthMeters { get; private init; }
+ public double ElevationRiseMeters { get; private init; }
+ public int HoseCount { get; private init; }
+ public int ReserveHoseCount { get; private init; }
+ public int PumpCount { get; private init; }
+ public int ReservePumpCount { get; private init; }
+
+ /// Meters from the water source where a pump sits; index 0 is the feed pump.
+ public IReadOnlyList PumpPositionsMeters { get; private init; } = Array.Empty();
+
+ public static WasserfoerderungLeitung Create(
+ int number,
+ string? uebergabestelle,
+ string? ansprechpartner,
+ double lengthM,
+ double riseM,
+ FörderstreckeConfig? config = null)
+ {
+ if (number < 1)
+ throw new ArgumentException(nameof(number), "Die Leitungsnummer muss >= 1 sein.");
+
+ config ??= FörderstreckeConfig.Default;
+ var plan = FörderstreckePlanner.Plan(lengthM, riseM, config);
+
+ return new WasserfoerderungLeitung
+ {
+ Id = Guid.NewGuid(),
+ Number = number,
+ Uebergabestelle = string.IsNullOrWhiteSpace(uebergabestelle) ? null : uebergabestelle.Trim(),
+ Ansprechpartner = string.IsNullOrWhiteSpace(ansprechpartner) ? null : ansprechpartner.Trim(),
+ FlowLMin = config.FlowLMin,
+ FeedPressureBar = config.FeedPressureBar,
+ LengthMeters = plan.LengthMeters,
+ ElevationRiseMeters = riseM,
+ HoseCount = plan.HoseCount,
+ ReserveHoseCount = plan.ReserveHoseCount,
+ PumpCount = plan.PumpCount,
+ ReservePumpCount = plan.ReservePumpCount,
+ PumpPositionsMeters = plan.PumpPositionsMeters,
+ };
+ }
+
+ public static WasserfoerderungLeitung Rehydrate(
+ Guid id,
+ int number,
+ string? uebergabestelle,
+ string? ansprechpartner,
+ int flowLMin,
+ double feedPressureBar,
+ double lengthM,
+ double elevationRiseM,
+ int hoseCount,
+ int reserveHoseCount,
+ int pumpCount,
+ int reservePumpCount,
+ IReadOnlyList pumpPositionsMeters)
+ => new()
+ {
+ Id = id,
+ Number = number,
+ Uebergabestelle = uebergabestelle,
+ Ansprechpartner = ansprechpartner,
+ FlowLMin = flowLMin,
+ FeedPressureBar = feedPressureBar,
+ LengthMeters = lengthM,
+ ElevationRiseMeters = elevationRiseM,
+ HoseCount = hoseCount,
+ ReserveHoseCount = reserveHoseCount,
+ PumpCount = pumpCount,
+ ReservePumpCount = reservePumpCount,
+ PumpPositionsMeters = pumpPositionsMeters,
+ };
+}
\ No newline at end of file
diff --git a/src/LageBuch.Persistence/IncidentRepository.cs b/src/LageBuch.Persistence/IncidentRepository.cs
index ddf0877..6a1335b 100644
--- a/src/LageBuch.Persistence/IncidentRepository.cs
+++ b/src/LageBuch.Persistence/IncidentRepository.cs
@@ -20,7 +20,7 @@ public void Save(string path, Incident incident)
"role_assignments", "force_units", "scba_trupps",
"scba_trupp_members", "scba_pressure_readings", "audit_events",
"incident_timers", "incident_files", "incident_tasks",
- "co_buildings", "co_dwellings" })
+ "co_buildings", "co_dwellings", "wass_leitungen" })
{
Exec(cn, tx, $"DELETE FROM {table};");
}
@@ -243,6 +243,27 @@ public void Save(string path, Incident incident)
});
}
+ for (var i = 0; i < incident.Wasserfoerderung.Count; i++)
+ {
+ var w = incident.Wasserfoerderung[i];
+ var positionsJson = System.Text.Json.JsonSerializer.Serialize(w.PumpPositionsMeters);
+ Run(cn, tx,
+ "INSERT INTO wass_leitungen (id, ordinal, number, uebergabestelle, ansprechpartner, flow_lmin, feed_pressure_bar, " +
+ "length_m, elevation_rise_m, hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions) " +
+ "VALUES ($id,$o,$num,$ueb,$ap,$flow,$feed,$len,$rise,$hc,$rch,$pc,$rpc,$pos);",
+ p =>
+ {
+ p("$id", w.Id.ToString()); p("$o", i); p("$num", w.Number);
+ p("$ueb", (object?)w.Uebergabestelle ?? DBNull.Value);
+ p("$ap", (object?)w.Ansprechpartner ?? DBNull.Value);
+ p("$flow", w.FlowLMin); p("$feed", w.FeedPressureBar);
+ p("$len", w.LengthMeters); p("$rise", w.ElevationRiseMeters);
+ p("$hc", w.HoseCount); p("$rch", w.ReserveHoseCount);
+ p("$pc", w.PumpCount); p("$rpc", w.ReservePumpCount);
+ p("$pos", positionsJson);
+ });
+ }
+
tx.Commit();
}
@@ -438,6 +459,16 @@ public Incident Load(string path)
(Domain.Tasks.TaskUrgency)r.GetInt32(4), r.GetString(5), ParseDate(r.GetString(7)),
NullableDate(r, 8), Str(r, 9)));
+ var wasserfoerderung = ReadAll(cn,
+ "SELECT id, number, uebergabestelle, ansprechpartner, flow_lmin, feed_pressure_bar, length_m, elevation_rise_m, " +
+ "hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions FROM wass_leitungen ORDER BY ordinal;",
+ r => Domain.Wasserfoerderung.WasserfoerderungLeitung.Rehydrate(
+ Guid.Parse(r.GetString(0)), r.GetInt32(1), Str(r, 2), Str(r, 3),
+ r.GetInt32(4), r.GetDouble(5), r.GetDouble(6), r.GetDouble(7),
+ r.GetInt32(8), r.GetInt32(9), r.GetInt32(10), r.GetInt32(11),
+ System.Text.Json.JsonSerializer.Deserialize>(r.GetString(12))
+ ?? Array.Empty()));
+
// Legacy fallback: files written before the Einsatznummer unification carry the 4-digit
// number in ils_number and nothing in incident_number. Load that old value as the
// Einsatznummer so pre-existing incidents keep their number.
@@ -458,7 +489,7 @@ public Incident Load(string path)
meta[9] is string ca ? ParseDate(ca) : null,
meta[10] as string,
checklistAufbau, checklistAbbau, journal, roles, forces, scbaTrupps, audit, timers, files, tasks,
- buildings, dwellings);
+ buildings, dwellings, wasserfoerderung);
}
private static DateTimeOffset ParseDate(string s) =>
diff --git a/src/LageBuch.Persistence/Sqlite/Migrations.cs b/src/LageBuch.Persistence/Sqlite/Migrations.cs
index 68bcc78..17b3fd9 100644
--- a/src/LageBuch.Persistence/Sqlite/Migrations.cs
+++ b/src/LageBuch.Persistence/Sqlite/Migrations.cs
@@ -5,7 +5,7 @@ namespace LageBuch.Persistence.Sqlite;
public static class Migrations
{
- public const int CurrentVersion = 17;
+ public const int CurrentVersion = 18;
public static int GetVersion(SqliteConnection cn)
{
@@ -100,6 +100,10 @@ public static void Migrate(SqliteConnection cn)
{
ApplyV17(cn, tx);
}
+ if (version < 18)
+ {
+ ApplyV18(cn, tx);
+ }
SetVersion(cn, tx, CurrentVersion);
tx.Commit();
}
@@ -539,6 +543,30 @@ INSERT INTO scba_trupps_v17
Exec(cn, tx, "ALTER TABLE scba_trupps_v17 RENAME TO scba_trupps;");
}
+ private static void ApplyV18(SqliteConnection cn, SqliteTransaction tx)
+ {
+ // Wasserförderungs-Leitungen (#150, Plan A). The planned figures are stored verbatim so
+ // the PDF and remote clients never recompute them.
+ Exec(cn, tx, """
+ CREATE TABLE IF NOT EXISTS wass_leitungen (
+ id TEXT PRIMARY KEY,
+ ordinal INTEGER NOT NULL,
+ number INTEGER NOT NULL,
+ uebergabestelle TEXT,
+ ansprechpartner TEXT,
+ flow_lmin INTEGER NOT NULL,
+ feed_pressure_bar REAL NOT NULL,
+ length_m REAL NOT NULL,
+ elevation_rise_m REAL NOT NULL,
+ hose_count INTEGER NOT NULL,
+ reserve_hose_count INTEGER NOT NULL,
+ pump_count INTEGER NOT NULL,
+ reserve_pump_count INTEGER NOT NULL,
+ pump_positions TEXT NOT NULL DEFAULT '[]'
+ );
+ """);
+ }
+
private static void SetVersion(SqliteConnection cn, SqliteTransaction tx, int version)
{
Exec(cn, tx, "DELETE FROM schema_version;");
diff --git a/src/LageBuch.Sync/CommandApplier.cs b/src/LageBuch.Sync/CommandApplier.cs
index 241dd20..716c6ca 100644
--- a/src/LageBuch.Sync/CommandApplier.cs
+++ b/src/LageBuch.Sync/CommandApplier.cs
@@ -135,6 +135,12 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A
case SetApartmentLabelCommand c:
incident.SetApartmentLabel(c.BuildingId, c.ApartmentNumber, c.Label);
break;
+ case AddWasserfoerderungLeitungCommand c:
+ incident.AddWasserfoerderungLeitung(c.Uebergabestelle, c.Ansprechpartner, c.LengthMeters, c.ElevationRiseMeters);
+ break;
+ case RemoveWasserfoerderungLeitungCommand c:
+ incident.RemoveWasserfoerderungLeitung(c.LeitungId);
+ break;
default:
throw new ArgumentOutOfRangeException(nameof(command),
$"Unbekannter Befehl: {command.GetType().Name}");
diff --git a/src/LageBuch.Sync/IIncidentSession.cs b/src/LageBuch.Sync/IIncidentSession.cs
index cc333f5..3402d09 100644
--- a/src/LageBuch.Sync/IIncidentSession.cs
+++ b/src/LageBuch.Sync/IIncidentSession.cs
@@ -66,6 +66,12 @@ void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
/// Stamps/clears a task's completion (#88).
void SetTaskCompleted(Guid taskId, bool isDone);
+
+ /// Plans one Förderstrecke-Leitung (#150, Plan A). Silent — no ETB line, no attribution,
+ /// exactly like tasks. Length/elevation ride the wire as plan inputs; the host computes the number
+ /// and every derived figure.
+ void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprechpartner, double lengthMeters, double elevationRiseMeters);
+ void RemoveWasserfoerderungLeitung(Guid leitungId);
void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.Sync/IncidentSnapshot.cs b/src/LageBuch.Sync/IncidentSnapshot.cs
index a167610..0a132e4 100644
--- a/src/LageBuch.Sync/IncidentSnapshot.cs
+++ b/src/LageBuch.Sync/IncidentSnapshot.cs
@@ -34,7 +34,8 @@ public sealed record IncidentSnapshot(
IReadOnlyList Files,
IReadOnlyList Tasks,
IReadOnlyList Buildings,
- IReadOnlyList Dwellings);
+ IReadOnlyList Dwellings,
+ IReadOnlyList Wasserfoerderung);
public sealed record TimerDto(
string Key,
@@ -140,3 +141,18 @@ public sealed record BuildingDto(
public sealed record DwellingDto(
Guid Id, Guid BuildingId, int FloorOrdinal, int ApartmentNumber,
string? ResidentName, DwellingStatus Status, bool? KeyAvailable, int? CoValue);
+
+public sealed record WasserfoerderungLeitungDto(
+ Guid Id,
+ int Number,
+ string? Uebergabestelle,
+ string? Ansprechpartner,
+ int FlowLMin,
+ double FeedPressureBar,
+ double LengthMeters,
+ double ElevationRiseMeters,
+ int HoseCount,
+ int ReserveHoseCount,
+ int PumpCount,
+ int ReservePumpCount,
+ IReadOnlyList PumpPositionsMeters);
diff --git a/src/LageBuch.Sync/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs
index ae5465a..b3baca1 100644
--- a/src/LageBuch.Sync/RemoteIncidentSession.cs
+++ b/src/LageBuch.Sync/RemoteIncidentSession.cs
@@ -182,6 +182,12 @@ public void AddTask(string text, string? assignee, TaskImportance importance, Ta
public void SetTaskCompleted(Guid taskId, bool isDone) =>
Send(new SetTaskCompletedCommand(Op(), taskId, isDone));
+ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprechpartner, double lengthMeters, double elevationRiseMeters) =>
+ Send(new AddWasserfoerderungLeitungCommand(uebergabestelle, ansprechpartner, lengthMeters, elevationRiseMeters));
+
+ public void RemoveWasserfoerderungLeitung(Guid leitungId) =>
+ Send(new RemoveWasserfoerderungLeitungCommand(leitungId));
+
public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.Sync/SnapshotMapper.cs b/src/LageBuch.Sync/SnapshotMapper.cs
index 8f53d65..d29aeb7 100644
--- a/src/LageBuch.Sync/SnapshotMapper.cs
+++ b/src/LageBuch.Sync/SnapshotMapper.cs
@@ -6,6 +6,7 @@
using LageBuch.Domain.Tasks;
using LageBuch.Domain.Time;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -52,7 +53,11 @@ public static IncidentSnapshot ToSnapshot(Incident incident)
b.ApartmentLabels.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))).ToList(),
incident.Dwellings.Select(d => new DwellingDto(
d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber,
- d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)).ToList());
+ d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)).ToList(),
+ incident.Wasserfoerderung.Select(w => new WasserfoerderungLeitungDto(
+ w.Id, w.Number, w.Uebergabestelle, w.Ansprechpartner, w.FlowLMin, w.FeedPressureBar,
+ w.LengthMeters, w.ElevationRiseMeters, w.HoseCount, w.ReserveHoseCount,
+ w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters)).ToList());
}
public static Incident FromSnapshot(IncidentSnapshot snapshot)
@@ -94,7 +99,11 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot)
kv => kv.Value))),
snapshot.Dwellings.Select(d => Dwelling.Rehydrate(
d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber,
- d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)));
+ d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)),
+ snapshot.Wasserfoerderung.Select(w => WasserfoerderungLeitung.Rehydrate(
+ w.Id, w.Number, w.Uebergabestelle, w.Ansprechpartner, w.FlowLMin, w.FeedPressureBar,
+ w.LengthMeters, w.ElevationRiseMeters, w.HoseCount, w.ReserveHoseCount,
+ w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters)));
}
private static ScbaTruppDto ToDto(AtemschutzTrupp t) => new(
diff --git a/src/LageBuch.Sync/SyncCommand.cs b/src/LageBuch.Sync/SyncCommand.cs
index 29ba013..78b74ab 100644
--- a/src/LageBuch.Sync/SyncCommand.cs
+++ b/src/LageBuch.Sync/SyncCommand.cs
@@ -45,6 +45,8 @@ namespace LageBuch.Sync;
[JsonDerivedType(typeof(UpdateDwellingDetailsCommand), "updateDwellingDetails")]
[JsonDerivedType(typeof(SetFloorDescriptionCommand), "setFloorDescription")]
[JsonDerivedType(typeof(SetApartmentLabelCommand), "setApartmentLabel")]
+[JsonDerivedType(typeof(AddWasserfoerderungLeitungCommand), "addWasserfoerderungLeitung")]
+[JsonDerivedType(typeof(RemoveWasserfoerderungLeitungCommand), "removeWasserfoerderungLeitung")]
public abstract record SyncCommand;
/// The operator at the sending device — carried on attributed mutations (see §6).
@@ -153,3 +155,10 @@ public sealed record SetFloorDescriptionCommand(
// No operator (silent)
public sealed record SetApartmentLabelCommand(
Guid BuildingId, int ApartmentNumber, string? Label) : SyncCommand;
+
+// No operator: plan lines are silent (no ETB line, no audit), so the wire carries just the plan
+// inputs — the host computes Number and every derived figure with its own planner.
+public sealed record AddWasserfoerderungLeitungCommand(
+ string? Uebergabestelle, string? Ansprechpartner, double LengthMeters, double ElevationRiseMeters) : SyncCommand;
+
+public sealed record RemoveWasserfoerderungLeitungCommand(Guid LeitungId) : SyncCommand;
diff --git a/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs
index 5960cb5..f8d7913 100644
--- a/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs
@@ -41,12 +41,12 @@ private static TabControl Tabs(Window window) =>
((IncidentWorkspaceView)window.Content!).GetControl("ModuleTabs");
[AvaloniaFact]
- public void Workspace_renders_eight_tabs_before_dateien_is_opened()
+ public void Workspace_renders_all_tabs_before_dateien_is_opened()
{
var (window, _, _) = ShowWorkspace();
var tabs = Tabs(window);
- Assert.Equal(10, tabs.Items.Count);
+ Assert.Equal(11, tabs.Items.Count);
Capture(window, "files-before.png");
}
diff --git a/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs
index 3b2fb70..dae61a5 100644
--- a/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs
@@ -41,12 +41,12 @@ private static TabControl Tabs(Window window) =>
((IncidentWorkspaceView)window.Content!).GetControl("ModuleTabs");
[AvaloniaFact]
- public void Workspace_renders_eight_tabs_before_links_is_opened()
+ public void Workspace_renders_all_tabs_before_links_is_opened()
{
var (window, _) = ShowWorkspace();
var tabs = Tabs(window);
- Assert.Equal(10, tabs.Items.Count);
+ Assert.Equal(11, tabs.Items.Count);
Capture(window, "links-before.png");
}
diff --git a/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs
index 4957250..4b25b08 100644
--- a/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs
@@ -44,10 +44,10 @@ private static TabControl Tabs(Window window) =>
((IncidentWorkspaceView)window.Content!).GetControl("ModuleTabs");
[AvaloniaFact]
- public void Workspace_now_nine_tabs_with_aufgaben_third()
+ public void Workspace_renders_all_tabs_with_aufgaben_third()
{
var (window, _, _, _, _) = ShowWorkspace();
- Assert.Equal(10, Tabs(window).Items.Count());
+ Assert.Equal(11, Tabs(window).Items.Count());
var aufgabenTab = (TabItem)Tabs(window).Items.ElementAt(2)!;
Assert.Equal("AUFGABEN", (string)aufgabenTab.Header!);
}
diff --git a/tests/LageBuch.Acceptance.Tests/WasserfoerderungTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/WasserfoerderungTabRenderTests.cs
new file mode 100644
index 0000000..183a03d
--- /dev/null
+++ b/tests/LageBuch.Acceptance.Tests/WasserfoerderungTabRenderTests.cs
@@ -0,0 +1,63 @@
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Threading;
+using LageBuch.App.Shared.Views;
+using LageBuch.AppLogic;
+using LageBuch.AppLogic.Services;
+using LageBuch.AppLogic.ViewModels;
+using LageBuch.Domain;
+
+namespace LageBuch.Acceptance.Tests;
+
+// Issue #150 (Plan A): the new WASSERFÖRDERUNG tab. Doubles as the PR screenshot capture
+// (RENDER_OUT), same idiom as TasksTabRenderTests/ForcesTabRenderTests.
+public class WasserfoerderungTabRenderTests
+{
+ private static (Window Window, IncidentWorkspaceViewModel Vm, LocalIncidentSession Session) ShowWorkspace()
+ {
+ var clock = new FixedClock();
+ var session = LocalIncidentSession.StartNew(new FakeStore(), clock,
+ new SessionOperator("Müller", "FFB 12/1"), "/x.fwincident",
+ Array.Empty<(string, bool)>(), Array.Empty<(string, bool)>());
+ var vm = new IncidentWorkspaceViewModel(session, clock, new ManualTicker(), WorkspaceRenderHelper.MasterData(),
+ new FakeDialogs(), new NoopAlarmService(), new NoopIncidentHostController());
+ var window = new Window { Content = new IncidentWorkspaceView { DataContext = vm }, Width = 1920, Height = 1032 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ return (window, vm, session);
+ }
+
+ private static void Capture(Window window, string name)
+ {
+ var dir = Environment.GetEnvironmentVariable("RENDER_OUT");
+ if (string.IsNullOrWhiteSpace(dir))
+ return;
+ Directory.CreateDirectory(dir);
+ using var frame = window.CaptureRenderedFrame()!;
+ frame.SavePng(Path.Combine(dir, name));
+ }
+
+ private static TabControl Tabs(Window window) =>
+ ((IncidentWorkspaceView)window.Content!).GetControl("ModuleTabs");
+
+ [AvaloniaFact]
+ public void Wasserfoerderung_tab_renders_empty_then_planned_streets()
+ {
+ var (window, vm, session) = ShowWorkspace();
+
+ Tabs(window).SelectedIndex = 9; // WASSERFÖRDERUNG
+ Dispatcher.UIThread.RunJobs();
+ Assert.Empty(vm.Wasserfoerderung.Rows);
+ Capture(window, "wasserfoerderung-before.png");
+
+ session.AddWasserfoerderungLeitung("TLF 20/8", "FFB 1/44/1", 2000, 100);
+ session.AddWasserfoerderungLeitung(null, null, 400, 0);
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.Equal(2, vm.Wasserfoerderung.Rows.Count);
+ Assert.Equal("Ltg 1", vm.Wasserfoerderung.Rows[0].NumberDisplay);
+ Assert.Equal(4, session.Incident.Wasserfoerderung[0].PumpCount);
+ Capture(window, "wasserfoerderung-after.png");
+ }
+}
\ No newline at end of file
diff --git a/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs b/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs
index e8e4c6e..4f75db0 100644
--- a/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs
@@ -107,14 +107,14 @@ private static IncidentWorkspaceViewModel BuildReadOnlyWorkspace(bool closed = f
}
[AvaloniaFact]
- public void Workspace_renders_with_eight_tabs()
+ public void Workspace_renders_with_all_workspace_tabs()
{
var vm = BuildWorkspace(out _);
var window = new Window { Content = new IncidentWorkspaceView { DataContext = vm }, Width = 1000, Height = 700 };
window.Show();
var tabs = window.GetVisualDescendants().OfType().Single();
- Assert.Equal(10, tabs.Items.Count);
+ Assert.Equal(11, tabs.Items.Count);
}
[AvaloniaFact]
diff --git a/tests/LageBuch.Documents.Tests/WasserfoerderungSectionTests.cs b/tests/LageBuch.Documents.Tests/WasserfoerderungSectionTests.cs
new file mode 100644
index 0000000..5439413
--- /dev/null
+++ b/tests/LageBuch.Documents.Tests/WasserfoerderungSectionTests.cs
@@ -0,0 +1,26 @@
+using LageBuch.Domain;
+using LageBuch.Domain.Time;
+
+namespace LageBuch.Documents.Tests;
+
+public class WasserfoerderungSectionTests
+{
+ [Fact]
+ public void Pdf_contains_wasserfoerderung_section_when_leitungen_planned()
+ {
+ var clock = new FixedClock(new DateTimeOffset(2026, 8, 30, 9, 0, 0, TimeSpan.FromHours(2)));
+ var op = new SessionOperator("Müller");
+ var incident = Incident.Start(clock, op, "Brand");
+ incident.AddWasserfoerderungLeitung("TLF 20/8", "FFB 1/44/1", 2000, 100);
+
+ var pdf = IncidentPdf.Generate(incident, new Dictionary());
+
+ Assert.True(pdf.Length > 1000);
+ Assert.Equal(0x25, pdf[0]); // '%'
+ }
+
+ private sealed class FixedClock(DateTimeOffset now) : IClock
+ {
+ public DateTimeOffset Now { get; set; } = now;
+ }
+}
\ No newline at end of file
diff --git "a/tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerElevationTests.cs" "b/tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerElevationTests.cs"
new file mode 100644
index 0000000..54eae65
--- /dev/null
+++ "b/tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerElevationTests.cs"
@@ -0,0 +1,46 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.Domain.Tests;
+
+public class FörderstreckePlannerElevationTests
+{
+ private static readonly FörderstreckeConfig Default = FörderstreckeConfig.Default;
+
+ [Fact]
+ public void Plan_uphill_shortens_legs_and_adds_pumps()
+ {
+ var plan = FörderstreckePlanner.Plan(2000, 100, Default);
+
+ // head = 0.01 fric + 0.1*100/2000 rise = 0.015 bar/m -> leg = 6.305/0.015 = 420.3 -> 420 m
+ Assert.Equal(new[] { 0.0, 420, 840, 1260, 1680 }, plan.PumpPositionsMeters);
+ Assert.Equal(4, plan.PumpCount); // 3 on the flat route of the same length
+ }
+
+ [Fact]
+ public void Plan_downhill_lengthens_legs_and_needs_fewer_pumps()
+ {
+ var plan = FörderstreckePlanner.Plan(2000, -100, Default);
+
+ // head = 0.01 - 0.005 = 0.005 bar/m -> leg = 6.305/0.005 = 1261 -> 1260 m
+ Assert.Equal(new[] { 0.0, 1260 }, plan.PumpPositionsMeters);
+ Assert.Equal(1, plan.PumpCount);
+ }
+
+ [Fact]
+ public void Plan_descent_cancelling_friction_fits_in_one_leg()
+ {
+ // 0.1*200/2000 exactly offsets the 0.01 friction loss -> a single leg reaches the end.
+ var plan = FörderstreckePlanner.Plan(2000, -200, Default);
+
+ Assert.Equal(new[] { 0.0 }, plan.PumpPositionsMeters);
+ Assert.Equal(0, plan.PumpCount);
+ Assert.Equal(100, plan.HoseCount);
+ }
+
+ [Fact]
+ public void Plan_rejects_a_climb_too_steep_for_a_single_hose()
+ {
+ // 0.1*400/100 = 0.4 + 0.01 fric -> 0.41 bar/m -> leg 15.4 m < one 20 m hose.
+ Assert.Throws(() => FörderstreckePlanner.Plan(100, 400, Default));
+ }
+}
\ No newline at end of file
diff --git "a/tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerTests.cs" "b/tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerTests.cs"
new file mode 100644
index 0000000..662b7d6
--- /dev/null
+++ "b/tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerTests.cs"
@@ -0,0 +1,54 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.Domain.Tests;
+
+public class FörderstreckePlannerTests
+{
+ private static readonly FörderstreckeConfig Default = FörderstreckeConfig.Default;
+
+ [Fact]
+ public void Plan_flat_route_counts_hoses_by_20m_lengths()
+ {
+ var plan = FörderstreckePlanner.Plan(2000, 0, Default);
+
+ Assert.Equal(100, plan.HoseCount); // ceil(2000/20)
+ Assert.Equal(20, plan.ReserveHoseCount); // ceil(2000/100)
+ }
+
+ [Fact]
+ public void Plan_flat_route_spaces_pumps_at_about_630m_excluding_the_feed_pump()
+ {
+ var plan = FörderstreckePlanner.Plan(2000, 0, Default);
+
+ // budget = (8 - 1.5) * 0.97 = 6.305 bar; at 1.0 bar/100m -> 630.5 m, snapped to 620 m.
+ Assert.Equal(new[] { 0.0, 620, 1240, 1860 }, plan.PumpPositionsMeters);
+ Assert.Equal(3, plan.PumpCount); // positions minus the feed pump at 0
+ }
+
+ [Fact]
+ public void Plan_short_line_needs_no_Verstaerkerpumpe()
+ {
+ var plan = FörderstreckePlanner.Plan(400, 0, Default);
+
+ Assert.Equal(20, plan.HoseCount);
+ Assert.Equal(0, plan.PumpCount);
+ Assert.Equal(new[] { 0.0 }, plan.PumpPositionsMeters);
+ }
+
+ [Fact]
+ public void Plan_computes_one_reserve_pump_per_four_Verstaerkerpumpen()
+ {
+ var few = FörderstreckePlanner.Plan(4000, 0, Default); // 6 pumps -> ceil(6/4) = 2
+ var none = FörderstreckePlanner.Plan(400, 0, Default); // 0 pumps -> 0
+
+ Assert.Equal(2, few.ReservePumpCount);
+ Assert.Equal(0, none.ReservePumpCount);
+ }
+
+ [Fact]
+ public void Plan_rejects_zero_length_and_out_of_table_flow()
+ {
+ Assert.Throws(() => FörderstreckePlanner.Plan(0, 0, Default));
+ Assert.Throws(() => FörderstreckePlanner.Plan(2000, 0, Default with { FlowLMin = 1500 }));
+ }
+}
\ No newline at end of file
diff --git a/tests/LageBuch.Domain.Tests/WasserfoerderungAggregateTests.cs b/tests/LageBuch.Domain.Tests/WasserfoerderungAggregateTests.cs
new file mode 100644
index 0000000..93fbff1
--- /dev/null
+++ b/tests/LageBuch.Domain.Tests/WasserfoerderungAggregateTests.cs
@@ -0,0 +1,80 @@
+namespace LageBuch.Domain.Tests;
+
+public class WasserfoerderungAggregateTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 8, 30, 9, 0, 0, TimeSpan.FromHours(2));
+
+ private static (Incident Incident, FixedClock Clock) NewIncident()
+ {
+ var clock = new FixedClock(T0);
+ return (Incident.Start(clock, new SessionOperator("Müller")), clock);
+ }
+
+ [Fact]
+ public void AddLeitung_numbers_sequentially_and_computes_the_plan()
+ {
+ var (incident, _) = NewIncident();
+
+ incident.AddWasserfoerderungLeitung("TLF 20/8", "FFB 1/44/1", 2000, 0);
+ incident.AddWasserfoerderungLeitung(null, null, 400, 0);
+
+ Assert.Equal(2, incident.Wasserfoerderung.Count);
+ Assert.Equal(1, incident.Wasserfoerderung[0].Number);
+ Assert.Equal(2, incident.Wasserfoerderung[1].Number);
+ Assert.Equal("TLF 20/8", incident.Wasserfoerderung[0].Uebergabestelle);
+ Assert.Equal(100, incident.Wasserfoerderung[0].HoseCount);
+ Assert.Equal(3, incident.Wasserfoerderung[0].PumpCount);
+ Assert.Equal(0, incident.Wasserfoerderung[1].PumpCount);
+ Assert.Null(incident.Wasserfoerderung[1].Uebergabestelle);
+ }
+
+ [Fact]
+ public void RemoveLeitung_removes_the_matching_leitung()
+ {
+ var (incident, _) = NewIncident();
+ incident.AddWasserfoerderungLeitung(null, null, 2000, 0);
+ var second = incident.AddWasserfoerderungLeitung(null, null, 400, 0);
+
+ incident.RemoveWasserfoerderungLeitung(second.Id);
+
+ Assert.Single(incident.Wasserfoerderung);
+ Assert.Equal(1, incident.Wasserfoerderung[0].Number);
+ }
+
+ [Fact]
+ public void RemoveLeitung_unknown_id_throws()
+ {
+ var (incident, _) = NewIncident();
+ Assert.Throws(() => incident.RemoveWasserfoerderungLeitung(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Closed_incident_rejects_leitung_mutations()
+ {
+ var (incident, clock) = NewIncident();
+ incident.Close(clock, new SessionOperator("Müller"));
+
+ Assert.Throws(() => incident.AddWasserfoerderungLeitung(null, null, 2000, 0));
+ Assert.Throws(() => incident.RemoveWasserfoerderungLeitung(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Rehydrate_round_trips_leitungen_in_order()
+ {
+ var (seed, _) = NewIncident();
+ seed.AddWasserfoerderungLeitung("TLF 20/8", "FFB 1/44/1", 2000, 100);
+ seed.AddWasserfoerderungLeitung(null, null, 400, 0);
+
+ var restored = Incident.Rehydrate(
+ seed.Id, seed.StartedAt, seed.State, seed.IncidentNumber, seed.Keyword, seed.Street,
+ seed.District, seed.Status, seed.ClosedAt, seed.ClosedBy,
+ seed.ChecklistAufbau, seed.ChecklistAbbau, seed.Journal, seed.Roles, seed.Forces,
+ seed.ScbaTrupps, seed.Audit, seed.Timers, seed.Files, seed.Tasks,
+ seed.Buildings, seed.Dwellings, seed.Wasserfoerderung);
+
+ Assert.Equal(2, restored.Wasserfoerderung.Count);
+ Assert.Equal(4, restored.Wasserfoerderung[0].PumpCount);
+ Assert.Equal(2, restored.Wasserfoerderung[1].Number);
+ Assert.Equal(seed.Wasserfoerderung[0].PumpPositionsMeters, restored.Wasserfoerderung[0].PumpPositionsMeters);
+ }
+}
\ No newline at end of file
diff --git a/tests/LageBuch.Persistence.Tests/IncidentRoundTripTests.cs b/tests/LageBuch.Persistence.Tests/IncidentRoundTripTests.cs
index 63e3bbd..1451820 100644
--- a/tests/LageBuch.Persistence.Tests/IncidentRoundTripTests.cs
+++ b/tests/LageBuch.Persistence.Tests/IncidentRoundTripTests.cs
@@ -387,4 +387,35 @@ public void V13_file_gains_an_empty_tasks_table_and_still_loads()
Assert.Empty(loaded.Tasks); // old file has no tasks — loads cleanly, migrates to V14
Assert.Equal("Brand", loaded.Keyword);
}
+
+ [Fact]
+ public void Wasserfoerderung_leitungen_round_trip()
+ {
+ var clock = new Clock();
+ var op = new SessionOperator("Müller", "FFB 12/1");
+ var incident = Incident.Start(clock, op, "Brand");
+ incident.AddWasserfoerderungLeitung("TLF 20/8", "FFB 1/44/1", 2000, 100);
+ incident.AddWasserfoerderungLeitung(null, null, 400, 0);
+
+ var repo = new IncidentRepository();
+ repo.Save(_path, incident);
+ var loaded = repo.Load(_path);
+
+ Assert.Equal(2, loaded.Wasserfoerderung.Count);
+ var first = loaded.Wasserfoerderung[0];
+ Assert.Equal(1, first.Number);
+ Assert.Equal("TLF 20/8", first.Uebergabestelle);
+ Assert.Equal("FFB 1/44/1", first.Ansprechpartner);
+ Assert.Equal(800, first.FlowLMin);
+ Assert.Equal(2000, first.LengthMeters);
+ Assert.Equal(100, first.ElevationRiseMeters);
+ Assert.Equal(100, first.HoseCount);
+ Assert.Equal(4, first.PumpCount);
+ Assert.Equal(1, first.ReservePumpCount);
+ Assert.Equal(20, first.ReserveHoseCount);
+ Assert.Equal(incident.Wasserfoerderung[0].PumpPositionsMeters, first.PumpPositionsMeters);
+ Assert.Equal(2, loaded.Wasserfoerderung[1].Number);
+ Assert.Null(loaded.Wasserfoerderung[1].Uebergabestelle);
+ Assert.Equal(0, loaded.Wasserfoerderung[1].PumpCount);
+ }
}
diff --git a/tests/LageBuch.Persistence.Tests/MigrationForwardCompatTests.cs b/tests/LageBuch.Persistence.Tests/MigrationForwardCompatTests.cs
index 5bbeb3f..5c8e84c 100644
--- a/tests/LageBuch.Persistence.Tests/MigrationForwardCompatTests.cs
+++ b/tests/LageBuch.Persistence.Tests/MigrationForwardCompatTests.cs
@@ -328,6 +328,34 @@ INSERT INTO etb_entries (id, ordinal, timestamp, direction, from_party, to_party
}
}
+ [Fact]
+ public void V17_file_gains_an_empty_wass_leitungen_table_on_upgrade_to_v18()
+ {
+ // V18 adds wass_leitungen (#150 Plan A). A v17 file must upgrade cleanly and simply gain
+ // the empty table -- its existing rows are untouched.
+ using (var cn = SqliteConnectionFactory.OpenReadWrite(_path))
+ using (var cmd = cn.CreateCommand())
+ {
+ cmd.CommandText = """
+ CREATE TABLE schema_version (version INTEGER NOT NULL);
+ INSERT INTO schema_version (version) VALUES (17);
+ """;
+ cmd.ExecuteNonQuery();
+ }
+ SqliteConnection.ClearAllPools();
+
+ using (var cn = SqliteConnectionFactory.OpenReadWrite(_path))
+ {
+ Assert.Equal(17, Migrations.GetVersion(cn));
+ Migrations.Migrate(cn);
+ Assert.Equal(Migrations.CurrentVersion, Migrations.GetVersion(cn));
+
+ using var read = cn.CreateCommand();
+ read.CommandText = "SELECT count(*) FROM wass_leitungen;";
+ Assert.Equal(0L, (long)read.ExecuteScalar()!);
+ }
+ }
+
[Fact]
public void A_file_from_a_newer_version_is_refused_and_its_marker_left_alone()
{
diff --git a/tests/LageBuch.Persistence.Tests/MigrationsTests.cs b/tests/LageBuch.Persistence.Tests/MigrationsTests.cs
index c4f02ad..7df07d1 100644
--- a/tests/LageBuch.Persistence.Tests/MigrationsTests.cs
+++ b/tests/LageBuch.Persistence.Tests/MigrationsTests.cs
@@ -68,6 +68,16 @@ public void Migrate_adds_the_display_name_column_to_incident_files()
Assert.Equal(1L, (long)cmd.ExecuteScalar()!);
}
+ [Fact]
+ public void V18_creates_the_wasserfoerderung_table()
+ {
+ using var cn = SqliteConnectionFactory.OpenReadWrite(_path);
+ Migrations.Migrate(cn);
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='wass_leitungen';";
+ Assert.Equal(1L, (long)cmd.ExecuteScalar()!);
+ }
+
[Fact]
public void Migrate_adds_the_officer_count_column_and_the_force_unit_edits_table()
{
diff --git a/tests/LageBuch.Sync.Tests/CommandApplierTests.cs b/tests/LageBuch.Sync.Tests/CommandApplierTests.cs
index b2b460c..b7b8178 100644
--- a/tests/LageBuch.Sync.Tests/CommandApplierTests.cs
+++ b/tests/LageBuch.Sync.Tests/CommandApplierTests.cs
@@ -239,4 +239,36 @@ public void Apply_SetDwellingStatus_SetsStatus()
d.BuildingId == buildingId && d.FloorOrdinal == 0 && d.ApartmentNumber == 1);
Assert.Equal(DwellingStatus.Searched, dwelling.Status);
}
+
+ [Fact]
+ public void AddWasserfoerderungLeitung_over_the_wire_plans_on_the_host()
+ {
+ var clock = new FixedClock();
+ var incident = NewIncident(clock);
+ var cmd = new AddWasserfoerderungLeitungCommand("TLF 20/8", "FFB 1/44/1", 2000, 100);
+
+ ApplyOverWire(cmd, incident, clock);
+
+ var leitung = Assert.Single(incident.Wasserfoerderung);
+ Assert.Equal(1, leitung.Number);
+ Assert.Equal("TLF 20/8", leitung.Uebergabestelle);
+ Assert.Equal(2000, leitung.LengthMeters);
+ Assert.Equal(100, leitung.ElevationRiseMeters);
+ Assert.Equal(4, leitung.PumpCount);
+ // The host (defaults) computes the derived figures — not the sending client.
+ Assert.Equal(800, leitung.FlowLMin);
+ }
+
+ [Fact]
+ public void RemoveWasserfoerderungLeitung_over_the_wire_takes_the_line_off()
+ {
+ var clock = new FixedClock();
+ var incident = NewIncident(clock);
+ incident.AddWasserfoerderungLeitung("TLF 20/8", "FFB 1/44/1", 2000, 100);
+ var id = incident.Wasserfoerderung[0].Id;
+
+ ApplyOverWire(new RemoveWasserfoerderungLeitungCommand(id), incident, clock);
+
+ Assert.Empty(incident.Wasserfoerderung);
+ }
}
diff --git a/tests/LageBuch.Sync.Tests/CommandSerializationTests.cs b/tests/LageBuch.Sync.Tests/CommandSerializationTests.cs
index 4c9419b..20bff9d 100644
--- a/tests/LageBuch.Sync.Tests/CommandSerializationTests.cs
+++ b/tests/LageBuch.Sync.Tests/CommandSerializationTests.cs
@@ -42,6 +42,9 @@ public class CommandSerializationTests
new AddTaskCommand(Op, "Nachfordern", "", TaskImportance.Low, TaskUrgency.Low, 30),
new SetTaskCompletedCommand(Op, Guid.NewGuid(), true),
new SetTaskCompletedCommand(Op, Guid.NewGuid(), false),
+ new AddWasserfoerderungLeitungCommand("TLF 20/8", "FFB 1/44/1", 2000, 100),
+ new AddWasserfoerderungLeitungCommand(null, null, 400, 0),
+ new RemoveWasserfoerderungLeitungCommand(Guid.NewGuid()),
}.Select(c => new object[] { c });
[Theory]
diff --git a/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs b/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs
index 8011e51..8f26765 100644
--- a/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs
+++ b/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs
@@ -200,4 +200,30 @@ public void SnapshotRoundTrip_BuildingsAndDwellings()
d.FloorOrdinal == 0 && d.ApartmentNumber == 1);
Assert.Equal(45, dwelling.CoValue);
}
+
+ [Fact]
+ public void Wasserfoerderung_round_trips_through_the_snapshot()
+ {
+ var clock = new FixedClock();
+ var op = new SessionOperator("Test", null);
+ var original = Incident.Start(clock, op);
+ original.AddWasserfoerderungLeitung("TLF 20/8", "FFB 1/44/1", 2000, 100);
+ original.AddWasserfoerderungLeitung(null, null, 400, 0);
+
+ var snapshot = SnapshotMapper.ToSnapshot(original);
+ var restored = SnapshotMapper.FromSnapshot(snapshot);
+
+ Assert.Equal(2, restored.Wasserfoerderung.Count);
+ Assert.Equal(original.Wasserfoerderung[0].Id, restored.Wasserfoerderung[0].Id);
+ Assert.Equal(1, restored.Wasserfoerderung[0].Number);
+ Assert.Equal(2, restored.Wasserfoerderung[1].Number);
+ Assert.Equal("TLF 20/8", restored.Wasserfoerderung[0].Uebergabestelle);
+ Assert.Equal(2000, restored.Wasserfoerderung[0].LengthMeters);
+ Assert.Equal(100, restored.Wasserfoerderung[0].ElevationRiseMeters);
+ Assert.Equal(4, restored.Wasserfoerderung[0].PumpCount);
+ Assert.Equal(1, restored.Wasserfoerderung[0].ReservePumpCount);
+ Assert.Equal(original.Wasserfoerderung[0].PumpPositionsMeters, restored.Wasserfoerderung[0].PumpPositionsMeters);
+ Assert.Null(restored.Wasserfoerderung[1].Uebergabestelle);
+ Assert.Equal(0, restored.Wasserfoerderung[1].PumpCount);
+ }
}
From e19f955c7798e6327b10d525e2f94c91e39a2ec3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20M=C3=BCller?=
<1005065+DeepDiver1975@users.noreply.github.com>
Date: Mon, 31 Aug 2026 17:59:15 +0200
Subject: [PATCH 2/2] =?UTF-8?q?feat(wasserfoerderung):=20draw=20F=C3=B6rde?=
=?UTF-8?q?rstrecken=20on=20a=20map=20with=20real=20elevation=20profiles?=
=?UTF-8?q?=20(#150,=20Plan=20B)=20(#164)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(wasserfoerderung): draw Förderstrecken on a map with real elevation profiles (#150, Plan B)
Phase 2 of #150: lets the operator draw a Wasserförderung route on a map
instead of typing length/rise by hand. Terrain is sampled from a bundled
DEM (custom binary heightmap) along the drawn polyline, and pump
placement is computed leg-by-leg against the actual profile instead of
assuming one uniform gradient — catching interior crests (a climb then
descent back to the same net height) that the flat Plan A formula would
miss. Map tiles come from a bundled MBTiles file, both referenced from a
new "Einsatzgebiet" (region of operation) global Stammdaten setting.
Fully offline like the rest of the app, no new NuGet dependency: MBTiles
is read via the already-referenced Microsoft.Data.Sqlite, and the map
canvas is a hand-rolled Avalonia control using standard Web Mercator
tile math. Manuell (Plan A) entry stays available unchanged wherever a
region isn't configured. The route also gets a small map snapshot
embedded in the PDF export next to its numeric row.
826 -> 882 tests, all green.
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* fix(wasserfoerderung): stop Karte-mode content overlapping the header at small windows
The Karte-mode layout used a DockPanel with a fixed Height="360" map
plus stacked Bottom-docked input-dock panels and no ScrollViewer. In a
window short enough that the DataGrid's share of the DockPanel
collapsed to zero (confirmed at 1920x700), the map Border — later in
Z-order — rendered on top of and overlapped the mode-toggle header
buttons instead of being pushed below them. Separately, the Karte
input dock's unwrapped horizontal button row pushed "FERTIG" past the
window's right edge entirely at narrower widths (confirmed at 1080
wide).
Root-caused by reproducing both at exact window sizes via the headless
harness (not by guessing from the screenshot) after an initial
attempt to reproduce via the live desktop app under Xvfb touched a
real incident file's mtime through its native open dialog — that
route was abandoned once caught.
Fix: wrap everything below the header in a single ScrollViewer with
naturally-stacked content (same pattern already used by
ChecklistView/FilesView/etc. in this codebase) so insufficient space
scrolls instead of overlaps, and switch both input docks' button rows
from an unwrapped horizontal StackPanel to a WrapPanel so buttons wrap
instead of overflowing the window.
New regression test asserts the map never starts above the header's
bottom edge and FERTIG's right edge never exceeds the window width, at
the exact size that reproduced the bug.
882 -> 883 tests, all green.
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* feat(wasserfoerderung): downloadable region packs for Einsatzgebiet (#150) (#168)
* feat(wasserfoerderung): downloadable region packs for Einsatzgebiet (#150)
Stammdaten's Einsatzgebiet config was a raw folder-path text field with no
way to actually get map data onto a machine. Replace it with a dropdown
fed by a published region-pack catalog (regions.json + GitHub Releases,
one pack per Landkreis): pick a region, hit "Herunterladen", the app
fetches and extracts region.mbtiles/region.dem via IRegionPackCatalogService
and IRegionPackInstaller. Manual folder entry stays available under an
"Erweitert" fallback for a self-built or hand-placed pack.
The pack-building side (osmium extract, raster tile rendering, SRTM
elevation conversion) stays a documented external runbook under
tools/build-region-pack/, deliberately kept out of LageBuch.sln — it's a
one-time-per-region maintainer task, not something every installation
needs to run itself.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* fix(wasserfoerderung): reject path-traversal slugs in region-pack manifest
RegionPackInstaller joins pack.Slug straight onto the regions base
directory (/). Since regions.json is fetched from a
third-party-controlled URL, a manifest entry with a slug like "../../etc"
could extract a downloaded pack outside the regions directory.
Reject unsafe slugs at parse time in RegionPackCatalogJson (same
"malformed entry is skipped, not thrown" defensive style already used for
missing/wrong-typed fields), and add a defense-in-depth path-containment
check in RegionPackInstaller itself in case a slug ever reaches it another
way.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
---------
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5
* fix(wasserfoerderung): center Karte mode on the configured region's real tiles (#169)
* feat(wasserfoerderung): downloadable region packs for Einsatzgebiet (#150)
Stammdaten's Einsatzgebiet config was a raw folder-path text field with no
way to actually get map data onto a machine. Replace it with a dropdown
fed by a published region-pack catalog (regions.json + GitHub Releases,
one pack per Landkreis): pick a region, hit "Herunterladen", the app
fetches and extracts region.mbtiles/region.dem via IRegionPackCatalogService
and IRegionPackInstaller. Manual folder entry stays available under an
"Erweitert" fallback for a self-built or hand-placed pack.
The pack-building side (osmium extract, raster tile rendering, SRTM
elevation conversion) stays a documented external runbook under
tools/build-region-pack/, deliberately kept out of LageBuch.sln — it's a
one-time-per-region maintainer task, not something every installation
needs to run itself.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* fix(wasserfoerderung): reject path-traversal slugs in region-pack manifest
RegionPackInstaller joins pack.Slug straight onto the regions base
directory (/). Since regions.json is fetched from a
third-party-controlled URL, a manifest entry with a slug like "../../etc"
could extract a downloaded pack outside the regions directory.
Reject unsafe slugs at parse time in RegionPackCatalogJson (same
"malformed entry is skipped, not thrown" defensive style already used for
missing/wrong-typed fields), and add a defense-in-depth path-containment
check in RegionPackInstaller itself in case a slug ever reaches it another
way.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* fix(wasserfoerderung): center Karte mode on the configured region's real tiles
Root cause (systematic-debugging): WasserfoerderungViewModel's initial map
center/zoom were a hardcoded constant (48.14, 11.58 — near Munich) picked
before any Einsatzgebiet had a knowable location. The real, published
Fürstenfeldbruck pack's tiles don't cover that point at all (z14 tile
column 8719 vs. the pack's actual [8691,8712] range), so Karte mode opened
on a blank map with no visible tiles — matching the report exactly.
Fix: derive the initial view from the tiles the configured region.mbtiles
actually has, via a new IMapTileSource.GetTileBounds() (lowest zoom level
present, TMS rows flipped back to XYZ), converted to a center through
WebMercator — moved from LageBuch.App.Shared to LageBuch.AppLogic since
IncidentWorkspaceViewModel now needs it too. This self-corrects for any
region (downloaded or manually placed under "Erweitert") since it reads
the actual tile data rather than trusting separately-tracked metadata.
The hardcoded fallback stays for the truly-no-tiles case.
Verified against the real published Fürstenfeldbruck pack: Karte mode now
opens already showing Fürstenfeldbruck/Olching/Puchheim/Germering, not a
blank canvas.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* fix(wasserfoerderung): add wheel/pinch zoom and overzoom to Karte mode (#170)
* fix(wasserfoerderung): add wheel/pinch zoom and overzoom to Karte mode
Reported: "I cannot zoom into the map data" (initially proposed introducing
Mapsui). Investigated first: MapCanvasControl had no scroll-wheel or pinch
zoom at all -- only two tiny +/- buttons -- and WasserfoerderungViewModel's
MinZoom/MaxZoom were fixed constants (3/19) unrelated to what the
configured region's region.mbtiles actually contains. The real,
published Fürstenfeldbruck pack only renders z11-15; MapDrawing silently
skipped any missing tile, so clicking + past z15 or - past z11 showed a
blank canvas with zero feedback. Mapsui would fix this too, but at real
cost (this app's first third-party dependency, blocked on Android today
since it needs Avalonia >=11.3.1 and Android is pinned to 11.2.2, and a
much bigger rewrite than the bug needs) -- fixed the actual root cause
instead.
- IMapTileSource.GetMaxZoom() (MbTilesFileSource: SELECT MAX(zoom_level)).
- WasserfoerderungViewModel: per-region MinZoom (from the same
GetTileBounds() the center fix already computes -- the region's own
lowest rendered zoom), a new ChangeMapViewCommand(MapViewChange) command
applying a wheel/pinch-driven view change, clamped to Min/MaxZoom.
- MapCanvasControl: OnPointerWheelChanged and a PinchGestureRecognizer
handler, both zooming while keeping the gesture's focal point
geographically stationary, routed through the new ViewChangedCommand
(matching the control's existing PointClickedCommand/UndoRequestedCommand
pattern rather than two-way property binding).
- MapDrawing.DrawTiles: when the exact tile is missing past the source's
actual max zoom, draws a cropped ancestor tile from the max zoom instead
("overzoom" -- standard map-app behavior past native detail).
Verified against the real published Fürstenfeldbruck pack: scrolling in
6 levels past z15 shows a legible overzoomed view (not blank); scrolling
out clamps cleanly at z11 (the pack's real minimum), never blank.
Pinch-to-zoom's scale->zoom-delta math is unit-tested directly
(Avalonia.Headless has no touch/gesture simulation API to drive the full
gesture pipeline).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* fix(wasserfoerderung): require Ctrl for wheel-zoom so plain scroll reaches the page
Root cause (systematic-debugging): the wheel-zoom just added in this same
branch unconditionally captured every wheel event over MapCanvasControl
(e.Handled = true, no modifier check). The map sits inside the
Wasserförderung tab's own ScrollViewer (WasserfoerderungView.axaml) at
360px tall — on a laptop where the window is short enough that the tab
needs to scroll, the cursor is very likely to land on the map while the
operator scrolls the page. Every such scroll got swallowed as a zoom
instead of reaching the ScrollViewer, and a laptop trackpad's rapid
wheel-delta stream during a single scroll swipe could zoom dozens of
levels in an instant — "renders it unreadable," exactly as reported.
Fix: gate wheel-zoom on Ctrl (KeyModifiers.Control), matching how most
embedded maps resolve this exact conflict (Leaflet, Google Maps embeds,
etc.). Plain scroll no longer sets e.Handled, so it bubbles to the
ScrollViewer as normal; Ctrl+scroll still zooms exactly as before,
keeping the cursor's geo point stationary. Pinch-to-zoom is unaffected —
a pinch gesture doesn't conflict with page-scroll the way a plain wheel
notch does.
Verified against the real published Fürstenfeldbruck pack: plain scroll
over the map no longer changes zoom; Ctrl+scroll still zooms in by one
level as designed.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
* fix(wasserfoerderung): add Ctrl+drag pan and a reset-view control to Karte mode
Root cause (systematic-debugging): cursor-anchored wheel/pinch zoom
shifts the map's center as a side effect (correct, by design), but
MapCanvasControl had no drag-to-pan at all and WasserfoerderungViewModel
never kept the region's initial center/zoom anywhere. Once the view
drifted away from the configured region (trivial after a few zooms near
an edge), there was no way back at all -- matching the report exactly.
Considered Mapsui again; same conclusion as before (first third-party
dependency, blocked on Android's Avalonia pin, much bigger than this
gap needs) -- added the missing controls to the existing hand-rolled map
instead.
- WasserfoerderungViewModel: stores the view it opened with and exposes
a new ResetMapViewCommand ("ZENTRIEREN") that returns to it.
- MapCanvasControl: Ctrl+left-drag pans (moving the center opposite the
drag direction, standard map UX), routed through the same
ViewChangedCommand as wheel/pinch zoom. Reuses the Ctrl convention
already established for zoom, so it never risks being confused with
the primary plain-left-click-to-draw-a-route-point gesture.
- Added a small "Strg + Scrollen: Zoom / Strg + Ziehen: Verschieben"
hint next to the new button, since both interactions are otherwise
undiscoverable.
Also fixed a real regression these additions exposed: the extra button
and hint line made the tab tall enough to trigger the outer
ScrollViewer's (Fluent overlay-style) vertical scrollbar at window sizes
that previously fit without scrolling. That scrollbar renders on top of
-- not narrowing -- the content, so it silently swallowed clicks on the
map's own right edge. Fixed by reserving a right margin matching the
scrollbar's width, so real content never sits flush against that edge
regardless of what triggers scrolling in the future.
Verified against the real published Fürstenfeldbruck pack: drifting the
view to Berlin, then Ctrl+drag panning and clicking ZENTRIEREN, both
bring it back to Fürstenfeldbruck exactly.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01UwN31QccH98YV9eEc2bue2
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
---------
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5
---------
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5
---------
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5
---
src/LageBuch.App.Android/MainActivity.cs | 1 +
.../Services/AndroidAppPaths.cs | 3 +
src/LageBuch.App.Shared/CompositionRoot.cs | 16 +-
.../Controls/MapCanvasControl.cs | 289 ++++++++++++++++++
.../Controls/MapDrawing.cs | 112 +++++++
.../Services/RouteOverviewRenderer.cs | 62 ++++
.../Views/MasterDataEditorView.axaml | 60 ++++
.../Views/WasserfoerderungView.axaml | 195 ++++++++----
src/LageBuch.App/AppPaths.cs | 3 +
src/LageBuch.App/Program.cs | 1 +
src/LageBuch.AppLogic/LocalIncidentSession.cs | 10 +-
.../Services/IRegionPackCatalogService.cs | 28 ++
.../Services/IRegionPackInstaller.cs | 8 +
.../Services/IRouteOverviewRenderer.cs | 15 +
.../Services/MapViewChange.cs | 9 +
.../Services/RegionPackCatalogJson.cs | 85 ++++++
.../Services/RegionPackCatalogService.cs | 23 ++
.../Services/RegionPackInstaller.cs | 63 ++++
src/LageBuch.AppLogic/Services/WebMercator.cs | 35 +++
.../ViewModels/EinsatzgebietSection.cs | 114 +++++++
.../ViewModels/HomeViewModel.cs | 9 +-
.../ViewModels/IncidentWorkspaceViewModel.cs | 79 ++++-
.../ViewModels/MasterDataEditorViewModel.cs | 13 +-
.../ViewModels/WasserfoerderungViewModel.cs | 132 +++++++-
src/LageBuch.Documents/IncidentPdf.cs | 13 +-
.../IncidentReportDocument.cs | 10 +-
.../Sections/WasserfoerderungSection.cs | 19 +-
src/LageBuch.Domain/Incident.cs | 22 ++
.../ElevationProfileSample.cs | 4 +
.../F\303\266rderstreckePlanner.cs" | 105 +++++--
.../Wasserfoerderung/GeoPoint.cs | 4 +
.../WasserfoerderungLeitung.cs | 46 ++-
.../IncidentRepository.cs | 15 +-
.../MasterData/MasterDataSet.cs | 43 ++-
.../MasterData/MasterDataStore.cs | 24 +-
src/LageBuch.Persistence/Sqlite/Migrations.cs | 13 +-
.../DemFileElevationSampler.cs | 145 +++++++++
.../Wasserfoerderung/IElevationSampler.cs | 9 +
.../Wasserfoerderung/IMapTileSource.cs | 22 ++
.../Wasserfoerderung/MbTilesFileSource.cs | 62 ++++
src/LageBuch.Sync/CommandApplier.cs | 3 +
src/LageBuch.Sync/IIncidentSession.cs | 7 +
src/LageBuch.Sync/IncidentSnapshot.cs | 4 +-
src/LageBuch.Sync/RemoteIncidentSession.cs | 6 +
src/LageBuch.Sync/SnapshotMapper.cs | 4 +-
src/LageBuch.Sync/SyncCommand.cs | 11 +
.../AboutRenderTests.cs | 14 +-
.../MapCanvasControlTests.cs | 262 ++++++++++++++++
.../MapDrawingTests.cs | 62 ++++
.../MasterDataEditorRenderTests.cs | 71 ++++-
.../RouteOverviewRendererTests.cs | 43 +++
.../WasserfoerderungTabRenderTests.cs | 156 +++++++++-
.../WebMercatorTests.cs | 60 ++++
.../EinsatzgebietSectionTests.cs | 169 ++++++++++
.../IncidentSessionTests.cs | 27 ++
.../IncidentWorkspaceViewModelTests.cs | 105 +++++++
.../MainWindowViewModelTests.cs | 6 +-
.../MasterDataEditorViewModelTests.cs | 53 +++-
.../RegionPackCatalogJsonTests.cs | 115 +++++++
.../RegionPackCatalogServiceTests.cs | 73 +++++
.../RegionPackInstallerTests.cs | 109 +++++++
.../WasserfoerderungViewModelTests.cs | 253 +++++++++++++++
.../WasserfoerderungSectionTests.cs | 39 +++
...\303\266rderstreckePlannerProfileTests.cs" | 49 +++
.../WasserfoerderungRouteAggregateTests.cs | 103 +++++++
.../DemFileElevationSamplerTests.cs | 142 +++++++++
.../IncidentRoundTripTests.cs | 27 ++
.../MasterDataJsonTests.cs | 24 ++
.../MasterDataStoreTests.cs | 30 ++
.../MbTilesFileSourceTests.cs | 146 +++++++++
.../MigrationForwardCompatTests.cs | 43 +++
.../MigrationsTests.cs | 10 +
.../CommandApplierTests.cs | 25 ++
.../CommandSerializationTests.cs | 5 +
.../SnapshotRoundTripTests.cs | 18 ++
tools/build-region-pack/README.md | 157 ++++++++++
76 files changed, 4220 insertions(+), 132 deletions(-)
create mode 100644 src/LageBuch.App.Shared/Controls/MapCanvasControl.cs
create mode 100644 src/LageBuch.App.Shared/Controls/MapDrawing.cs
create mode 100644 src/LageBuch.App.Shared/Services/RouteOverviewRenderer.cs
create mode 100644 src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
create mode 100644 src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
create mode 100644 src/LageBuch.AppLogic/Services/IRouteOverviewRenderer.cs
create mode 100644 src/LageBuch.AppLogic/Services/MapViewChange.cs
create mode 100644 src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
create mode 100644 src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
create mode 100644 src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
create mode 100644 src/LageBuch.AppLogic/Services/WebMercator.cs
create mode 100644 src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
create mode 100644 src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs
create mode 100644 src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs
create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs
create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs
create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
create mode 100644 src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
create mode 100644 tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
create mode 100644 tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs
create mode 100644 tests/LageBuch.Acceptance.Tests/RouteOverviewRendererTests.cs
create mode 100644 tests/LageBuch.Acceptance.Tests/WebMercatorTests.cs
create mode 100644 tests/LageBuch.AppLogic.Tests/EinsatzgebietSectionTests.cs
create mode 100644 tests/LageBuch.AppLogic.Tests/RegionPackCatalogJsonTests.cs
create mode 100644 tests/LageBuch.AppLogic.Tests/RegionPackCatalogServiceTests.cs
create mode 100644 tests/LageBuch.AppLogic.Tests/RegionPackInstallerTests.cs
create mode 100644 tests/LageBuch.AppLogic.Tests/WasserfoerderungViewModelTests.cs
create mode 100644 "tests/LageBuch.Domain.Tests/F\303\266rderstreckePlannerProfileTests.cs"
create mode 100644 tests/LageBuch.Domain.Tests/WasserfoerderungRouteAggregateTests.cs
create mode 100644 tests/LageBuch.Persistence.Tests/DemFileElevationSamplerTests.cs
create mode 100644 tests/LageBuch.Persistence.Tests/MbTilesFileSourceTests.cs
create mode 100644 tools/build-region-pack/README.md
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/MasterDataEditorView.axaml b/src/LageBuch.App.Shared/Views/MasterDataEditorView.axaml
index b07efcb..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,6 +109,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
index 83d7baa..49c9cce 100644
--- a/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
+++ b/src/LageBuch.App.Shared/Views/WasserfoerderungView.axaml
@@ -1,76 +1,139 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/LageBuch.App/AppPaths.cs b/src/LageBuch.App/AppPaths.cs
index 48937db..4d44bd7 100644
--- a/src/LageBuch.App/AppPaths.cs
+++ b/src/LageBuch.App/AppPaths.cs
@@ -13,6 +13,9 @@ public static class AppPaths
public static string AttachmentCacheDir => Path.Combine(AppDataDir, "attachment-cache");
+ /// Where downloaded Wasserförderung region packs (#150 follow-up) get extracted, one subfolder per slug.
+ public static string RegionsDir => Path.Combine(AppDataDir, "regions");
+
public static string GetAppDataDir(string baseDir)
{
var dir = Path.Combine(baseDir, "Lagebuch");
diff --git a/src/LageBuch.App/Program.cs b/src/LageBuch.App/Program.cs
index f36d325..2fa7e10 100644
--- a/src/LageBuch.App/Program.cs
+++ b/src/LageBuch.App/Program.cs
@@ -37,6 +37,7 @@ private static MainWindowViewModel CreateMainViewModel()
new IncidentHostController(clock, version, uiDispatcher),
uiDispatcher,
version,
+ AppPaths.RegionsDir,
new JsonLastSaveFolderStore(AppPaths.LastSaveFolderJsonPath),
AppPaths.AttachmentCacheDir);
}
diff --git a/src/LageBuch.AppLogic/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs
index 319603e..84890a3 100644
--- a/src/LageBuch.AppLogic/LocalIncidentSession.cs
+++ b/src/LageBuch.AppLogic/LocalIncidentSession.cs
@@ -8,6 +8,7 @@
using LageBuch.Domain.Time;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Sync;
namespace LageBuch.AppLogic;
@@ -103,7 +104,7 @@ public void ContinueEditing(SessionOperator op)
// every attached file's bytes land in this device's own sibling folder the moment it's added —
// whether typed here or uploaded by a joined client via AddFileCommand — so this never needs a
// network pull, only IIncidentStore.
- public Task ExportPdfAsync()
+ public Task ExportPdfAsync(IReadOnlyDictionary? routeOverviewPngById = null)
{
var fileBytes = new Dictionary();
foreach (var file in Incident.Files)
@@ -112,7 +113,7 @@ public Task ExportPdfAsync()
if (bytes is not null)
fileBytes[file.Id] = bytes;
}
- return Task.FromResult(IncidentPdf.Generate(Incident, fileBytes));
+ return Task.FromResult(IncidentPdf.Generate(Incident, fileBytes, routeOverviewPngById));
}
// --- IIncidentSession mutation surface: apply → persist → notify. ---
@@ -160,6 +161,11 @@ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprech
public void RemoveWasserfoerderungLeitung(Guid leitungId) =>
Mutate(() => Incident.RemoveWasserfoerderungLeitung(leitungId));
+ public void AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle, string? ansprechpartner,
+ IReadOnlyList routePoints, IReadOnlyList profile) =>
+ Mutate(() => Incident.AddWasserfoerderungLeitungFromRoute(uebergabestelle, ansprechpartner, routePoints, profile));
+
public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs b/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
new file mode 100644
index 0000000..58286bc
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRegionPackCatalogService.cs
@@ -0,0 +1,28 @@
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Lists the region packs (map tiles + elevation) publicly available for download (#150 follow-up)
+/// — the map-side counterpart to Stammdaten's Einsatzgebiet, which used to require hand-preparing
+/// region.mbtiles/region.dem with no guidance at all.
+///
+public interface IRegionPackCatalogService
+{
+ ///
+ /// Never throws — a Stammdaten editor must stay usable offline, so a fetch/parse failure
+ /// yields an empty list rather than propagating an exception.
+ ///
+ Task> GetAvailableRegionsAsync(CancellationToken ct = default);
+}
+
+/// One published, downloadable region pack.
+public sealed record RegionPackInfo(
+ string Name,
+ string Slug,
+ string DownloadUrl,
+ long SizeBytes,
+ double MinLat,
+ double MinLon,
+ double MaxLat,
+ double MaxLon,
+ string BuiltAt,
+ string Attribution);
diff --git a/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs b/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
new file mode 100644
index 0000000..1b3e45a
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRegionPackInstaller.cs
@@ -0,0 +1,8 @@
+namespace LageBuch.AppLogic.Services;
+
+/// Downloads and unpacks a region pack (#150 follow-up) into a local folder.
+public interface IRegionPackInstaller
+{
+ /// Returns the folder the pack was extracted into (ready to use as Einsatzgebiet.FolderPath).
+ Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default);
+}
diff --git a/src/LageBuch.AppLogic/Services/IRouteOverviewRenderer.cs b/src/LageBuch.AppLogic/Services/IRouteOverviewRenderer.cs
new file mode 100644
index 0000000..298d58a
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/IRouteOverviewRenderer.cs
@@ -0,0 +1,15 @@
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Persistence.Wasserfoerderung;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Renders a small map snapshot (tiles + polyline) of a drawn Wasserförderung route for the PDF
+/// (#150 phase 2). Implemented in LageBuch.App.Shared using Avalonia's off-screen rendering —
+/// AppLogic and Documents stay Avalonia-free, so this is the one port between them.
+///
+public interface IRouteOverviewRenderer
+{
+ /// PNG bytes framing the whole route, or null if it can't be rendered.
+ byte[]? Render(IReadOnlyList routePoints, IMapTileSource tiles);
+}
diff --git a/src/LageBuch.AppLogic/Services/MapViewChange.cs b/src/LageBuch.AppLogic/Services/MapViewChange.cs
new file mode 100644
index 0000000..87fa3e3
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/MapViewChange.cs
@@ -0,0 +1,9 @@
+namespace LageBuch.AppLogic.Services;
+
+///
+/// A wheel/pinch-driven map view change from MapCanvasControl (#150 follow-up) — the
+/// control->VM counterpart of GeoPoint for WasserfoerderungViewModel.ChangeMapViewCommand,
+/// living alongside (not in a specific ViewModel or the Domain layer)
+/// since both the App.Shared control and the AppLogic view model need to reference it.
+///
+public sealed record MapViewChange(double CenterLatitude, double CenterLongitude, int Zoom);
diff --git a/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs b/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
new file mode 100644
index 0000000..08df716
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackCatalogJson.cs
@@ -0,0 +1,85 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Reads the region-pack manifest format (#150 follow-up) — a flat JSON array, each entry
+/// describing one downloadable pack. Defensive like MasterDataJson: malformed input, or an
+/// entry missing a required field, is skipped rather than thrown — the manifest is fetched from a
+/// third-party-controlled URL, so a partially bad response must degrade gracefully, not crash
+/// Stammdaten.
+///
+public static class RegionPackCatalogJson
+{
+ // RegionPackInstaller joins this straight onto a base directory (/) —
+ // reject anything that could escape that directory (path separators, "..", empty) here rather
+ // than trusting the installer alone to catch it.
+ private static readonly Regex SafeSlug = new("^[a-z0-9][a-z0-9_-]{0,63}$", RegexOptions.Compiled);
+
+ public static IReadOnlyList Parse(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.ValueKind != JsonValueKind.Array)
+ return Array.Empty();
+
+ var result = new List();
+ foreach (var entry in doc.RootElement.EnumerateArray())
+ {
+ if (TryParseEntry(entry, out var region))
+ result.Add(region);
+ }
+ return result;
+ }
+ catch (JsonException)
+ {
+ return Array.Empty();
+ }
+ }
+
+ private static bool TryParseEntry(JsonElement entry, out RegionPackInfo region)
+ {
+ region = null!;
+ if (entry.ValueKind != JsonValueKind.Object)
+ return false;
+
+ if (!TryGetString(entry, "name", out var name) ||
+ !TryGetString(entry, "slug", out var slug) || !SafeSlug.IsMatch(slug) ||
+ !TryGetString(entry, "downloadUrl", out var downloadUrl) ||
+ !TryGetString(entry, "builtAt", out var builtAt) ||
+ !TryGetString(entry, "attribution", out var attribution) ||
+ !entry.TryGetProperty("sizeBytes", out var sizeBytesEl) || sizeBytesEl.ValueKind != JsonValueKind.Number ||
+ !entry.TryGetProperty("boundingBox", out var bbox) || bbox.ValueKind != JsonValueKind.Object ||
+ !TryGetNumber(bbox, "minLat", out var minLat) ||
+ !TryGetNumber(bbox, "minLon", out var minLon) ||
+ !TryGetNumber(bbox, "maxLat", out var maxLat) ||
+ !TryGetNumber(bbox, "maxLon", out var maxLon))
+ {
+ return false;
+ }
+
+ region = new RegionPackInfo(name, slug, downloadUrl, sizeBytesEl.GetInt64(),
+ minLat, minLon, maxLat, maxLon, builtAt, attribution);
+ return true;
+ }
+
+ private static bool TryGetString(JsonElement e, string prop, out string value)
+ {
+ value = string.Empty;
+ if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.String)
+ return false;
+ value = v.GetString()!;
+ return true;
+ }
+
+ private static bool TryGetNumber(JsonElement e, string prop, out double value)
+ {
+ value = 0;
+ if (!e.TryGetProperty(prop, out var v) || v.ValueKind != JsonValueKind.Number)
+ return false;
+ value = v.GetDouble();
+ return true;
+ }
+}
diff --git a/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs b/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
new file mode 100644
index 0000000..dd6e0bb
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackCatalogService.cs
@@ -0,0 +1,23 @@
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Fetches the region-pack manifest over HTTP (#150 follow-up). The one place this offline-first
+/// app makes an unprompted network call — but only when the operator opens the Einsatzgebiet
+/// section, and it degrades to an empty list rather than surfacing any error, so Stammdaten stays
+/// fully usable without a connection.
+///
+public sealed class RegionPackCatalogService(HttpClient httpClient, string manifestUrl) : IRegionPackCatalogService
+{
+ public async Task> GetAvailableRegionsAsync(CancellationToken ct = default)
+ {
+ try
+ {
+ var json = await httpClient.GetStringAsync(manifestUrl, ct);
+ return RegionPackCatalogJson.Parse(json);
+ }
+ catch (HttpRequestException)
+ {
+ return Array.Empty();
+ }
+ }
+}
diff --git a/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs b/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
new file mode 100644
index 0000000..d8bd319
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/RegionPackInstaller.cs
@@ -0,0 +1,63 @@
+using System.IO.Compression;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// Downloads a region pack's zip (region.mbtiles + region.dem) and extracts it into
+/// <regionsBaseDir>/<slug> (#150 follow-up). A re-install of the same slug replaces the
+/// folder outright, so a pack update never leaves stale files from a previous version behind.
+///
+public sealed class RegionPackInstaller(HttpClient httpClient, string regionsBaseDir) : IRegionPackInstaller
+{
+ // Downloading is the slow part; reserve a small tail of the progress range for extraction so
+ // the caller sees forward motion continue past "download done" instead of jumping straight to 1.0.
+ private const double DownloadProgressShare = 0.9;
+
+ public async Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default)
+ {
+ progress?.Report(0.0);
+
+ var zipBytes = await DownloadAsync(pack.DownloadUrl, progress, ct);
+
+ // Defense in depth: RegionPackCatalogJson already rejects unsafe slugs when parsing the
+ // manifest, but a slug ending up here from anywhere else must not be able to escape
+ // regionsBaseDir either.
+ var baseFull = Path.GetFullPath(regionsBaseDir) + Path.DirectorySeparatorChar;
+ var folder = Path.GetFullPath(Path.Combine(regionsBaseDir, pack.Slug));
+ if (!folder.StartsWith(baseFull, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Region slug '{pack.Slug}' escapes the regions directory.");
+
+ if (Directory.Exists(folder))
+ Directory.Delete(folder, recursive: true);
+ Directory.CreateDirectory(folder);
+
+ using (var zip = new ZipArchive(new MemoryStream(zipBytes), ZipArchiveMode.Read))
+ zip.ExtractToDirectory(folder);
+
+ progress?.Report(1.0);
+ return folder;
+ }
+
+ private async Task DownloadAsync(string url, IProgress? progress, CancellationToken ct)
+ {
+ using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ var totalBytes = response.Content.Headers.ContentLength;
+ await using var source = await response.Content.ReadAsStreamAsync(ct);
+ using var buffer = new MemoryStream();
+
+ var chunk = new byte[81920];
+ long readSoFar = 0;
+ int read;
+ while ((read = await source.ReadAsync(chunk, ct)) > 0)
+ {
+ await buffer.WriteAsync(chunk.AsMemory(0, read), ct);
+ readSoFar += read;
+ if (totalBytes is > 0)
+ progress?.Report(Math.Min(DownloadProgressShare, (double)readSoFar / totalBytes.Value * DownloadProgressShare));
+ }
+
+ return buffer.ToArray();
+ }
+}
diff --git a/src/LageBuch.AppLogic/Services/WebMercator.cs b/src/LageBuch.AppLogic/Services/WebMercator.cs
new file mode 100644
index 0000000..a4df931
--- /dev/null
+++ b/src/LageBuch.AppLogic/Services/WebMercator.cs
@@ -0,0 +1,35 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.AppLogic.Services;
+
+///
+/// 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, the "click adds a route
+/// point" conversion, and deriving a region's initial map view from its tile bounds.
+///
+public static class WebMercator
+{
+ public const int TileSizePixels = 256;
+
+ /// Lat/lon (degrees) to the pixel position in the whole rendered world map at .
+ 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);
+ }
+
+ /// Inverse of .
+ 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));
+}
diff --git a/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
new file mode 100644
index 0000000..ff4f68b
--- /dev/null
+++ b/src/LageBuch.AppLogic/ViewModels/EinsatzgebietSection.cs
@@ -0,0 +1,114 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LageBuch.AppLogic.Services;
+using LageBuch.Persistence.MasterData;
+
+namespace LageBuch.AppLogic.ViewModels;
+
+///
+/// Editor for the Wasserförderung region of operation (#150 phase 2, region-pack follow-up):
+/// a name and a folder path expected to hold region.mbtiles and region.dem.
+/// Primarily populated by downloading a published pack from
+/// via — manual / entry
+/// stays available as a fallback for a self-built or hand-placed pack.
+///
+public sealed partial class EinsatzgebietSection : EditorSection
+{
+ private readonly Action _onChanged;
+ private readonly IRegionPackCatalogService _catalog;
+ private readonly IRegionPackInstaller _installer;
+
+ public EinsatzgebietSection(
+ string title, Einsatzgebiet einsatzgebiet, Action onChanged,
+ IRegionPackCatalogService catalog, IRegionPackInstaller installer) : base(title)
+ {
+ _onChanged = onChanged;
+ _catalog = catalog;
+ _installer = installer;
+ _name = einsatzgebiet.Name;
+ _folderPath = einsatzgebiet.FolderPath;
+ }
+
+ [ObservableProperty]
+ private string _name = string.Empty;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(KartendatenGefunden))]
+ [NotifyPropertyChangedFor(nameof(KartendatenStatus))]
+ private string _folderPath = string.Empty;
+
+ partial void OnNameChanged(string value) => _onChanged();
+ partial void OnFolderPathChanged(string value) => _onChanged();
+
+ public Einsatzgebiet ToEinsatzgebiet() => new(Name, FolderPath);
+
+ // --- Region-pack catalog / download ---
+
+ public ObservableCollection AvailableRegions { get; } = new();
+
+ [ObservableProperty]
+ private RegionPackInfo? _selectedRegion;
+
+ [ObservableProperty]
+ private string? _catalogStatus;
+
+ [ObservableProperty]
+ private double _downloadProgress;
+
+ [RelayCommand]
+ private async Task LoadCatalog()
+ {
+ var regions = await _catalog.GetAvailableRegionsAsync();
+ AvailableRegions.Clear();
+ foreach (var region in regions)
+ AvailableRegions.Add(region);
+
+ CatalogStatus = AvailableRegions.Count == 0
+ ? "Keine Regionen verfügbar — bitte Internetverbindung prüfen, oder Ordner manuell angeben."
+ : null;
+ }
+
+ private bool CanDownloadSelectedRegion => SelectedRegion is not null;
+
+ [RelayCommand(CanExecute = nameof(CanDownloadSelectedRegion))]
+ private async Task DownloadSelectedRegion()
+ {
+ var region = SelectedRegion!;
+ DownloadProgress = 0;
+ var progress = new Progress(p => DownloadProgress = p);
+ var folder = await _installer.DownloadAndInstallAsync(region, progress);
+
+ Name = region.Name;
+ FolderPath = folder;
+ _onChanged();
+ }
+
+ partial void OnSelectedRegionChanged(RegionPackInfo? value) => DownloadSelectedRegionCommand.NotifyCanExecuteChanged();
+
+ // --- File-presence validation: is region.mbtiles/region.dem actually at FolderPath? ---
+
+ public bool KartendatenGefunden =>
+ !string.IsNullOrWhiteSpace(FolderPath)
+ && File.Exists(Path.Combine(FolderPath, "region.mbtiles"))
+ && File.Exists(Path.Combine(FolderPath, "region.dem"));
+
+ public string? KartendatenStatus
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(FolderPath))
+ return null;
+
+ var mbtilesFound = File.Exists(Path.Combine(FolderPath, "region.mbtiles"));
+ var demFound = File.Exists(Path.Combine(FolderPath, "region.dem"));
+ if (mbtilesFound && demFound)
+ return "✓ Kartendaten gefunden.";
+
+ var missing = new List();
+ if (!mbtilesFound) missing.Add("region.mbtiles");
+ if (!demFound) missing.Add("region.dem");
+ return $"✗ Fehlt: {string.Join(", ", missing)}.";
+ }
+ }
+}
diff --git a/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs b/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
index b63ddbd..e9d86a1 100644
--- a/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/HomeViewModel.cs
@@ -32,8 +32,11 @@ public sealed partial class HomeViewModel : ObservableObject
// Where a joined client caches pulled attachment bytes (see RemoteIncidentSession.GetFileBytesAsync).
// Null (most tests) just means "no caching" -- correct, only not free -- not an error.
private readonly string? _attachmentCacheRoot;
+ // Renders a route's map snapshot for the PDF (#150 phase 2). Null (most tests, and any build
+ // without Avalonia access) just means "no image in the PDF" -- the numeric table still exports.
+ private readonly IRouteOverviewRenderer? _routeOverviewRenderer;
- public HomeViewModel(IIncidentStore store, IMasterDataProvider masterData, IRecentFilesStore recent, IFileDialogService dialogs, IClock clock, ITicker ticker, IAlarmService alarm, IIncidentHostController hostController, string appVersion, IUiDispatcher? uiDispatcher = null, ILastSaveFolderStore? lastSaveFolder = null, string? attachmentCacheRoot = null)
+ public HomeViewModel(IIncidentStore store, IMasterDataProvider masterData, IRecentFilesStore recent, IFileDialogService dialogs, IClock clock, ITicker ticker, IAlarmService alarm, IIncidentHostController hostController, string appVersion, IUiDispatcher? uiDispatcher = null, ILastSaveFolderStore? lastSaveFolder = null, string? attachmentCacheRoot = null, IRouteOverviewRenderer? routeOverviewRenderer = null)
{
_store = store;
_masterData = masterData;
@@ -47,6 +50,7 @@ public HomeViewModel(IIncidentStore store, IMasterDataProvider masterData, IRece
_uiDispatcher = uiDispatcher ?? new ImmediateUiDispatcher();
_lastSaveFolder = lastSaveFolder;
_attachmentCacheRoot = attachmentCacheRoot;
+ _routeOverviewRenderer = routeOverviewRenderer;
RecentFiles = new ObservableCollection(
SortByFileNameDescending(recent.GetRecent().Select(path => new RecentFileItem(path, IsClosed(path)))));
}
@@ -153,7 +157,8 @@ private void OpenWorkspace(LocalIncidentSession session, string path, Persistenc
if (existing is not null)
RecentFiles.Remove(existing);
InsertSortedByFileNameDescending(new RecentFileItem(path, session.Incident.State == IncidentState.Closed));
- var workspace = new IncidentWorkspaceViewModel(session, _clock, _ticker, md, _dialogs, _alarm, _hostController);
+ var workspace = new IncidentWorkspaceViewModel(
+ session, _clock, _ticker, md, _dialogs, _alarm, _hostController, _routeOverviewRenderer);
WorkspaceOpened?.Invoke(workspace);
}
diff --git a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
index 5b4bcc2..db34f86 100644
--- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs
@@ -5,7 +5,9 @@
using LageBuch.Domain;
using LageBuch.Domain.Time;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Persistence.MasterData;
+using LageBuch.Persistence.Wasserfoerderung;
using LageBuch.Sync;
namespace LageBuch.AppLogic.ViewModels;
@@ -22,8 +24,9 @@ public sealed partial class IncidentWorkspaceViewModel : ObservableObject
private readonly IFileDialogService _dialogs;
private readonly IAlarmService _alarm;
private readonly IIncidentHostController _hostController;
+ private readonly IRouteOverviewRenderer? _routeOverviewRenderer;
- public IncidentWorkspaceViewModel(IIncidentSession session, IClock clock, ITicker ticker, MasterDataSet masterData, IFileDialogService dialogs, IAlarmService alarm, IIncidentHostController hostController)
+ public IncidentWorkspaceViewModel(IIncidentSession session, IClock clock, ITicker ticker, MasterDataSet masterData, IFileDialogService dialogs, IAlarmService alarm, IIncidentHostController hostController, IRouteOverviewRenderer? routeOverviewRenderer = null)
{
_session = session;
_local = session as LocalIncidentSession;
@@ -33,6 +36,7 @@ public IncidentWorkspaceViewModel(IIncidentSession session, IClock clock, ITicke
_dialogs = dialogs;
_alarm = alarm;
_hostController = hostController;
+ _routeOverviewRenderer = routeOverviewRenderer;
IsReadOnly = session.IsReadOnly;
// Seed the backing field directly so initialization doesn't trigger a write-back/save.
_incidentNumberInput = _session.Incident.IncidentNumber?.Value ?? string.Empty;
@@ -235,7 +239,11 @@ private void BuildChildren()
Tasks = new TasksViewModel(_session, _clock, _ticker, _alarm, _masterData, OnChanged);
Wasserfoerderung?.Dispose();
- Wasserfoerderung = new WasserfoerderungViewModel(_session, OnChanged);
+ var (elevationSampler, tileSource) = BuildWasserfoerderungMapSources();
+ var initialMapView = InitialMapViewFrom(tileSource);
+ Wasserfoerderung = new WasserfoerderungViewModel(
+ _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
@@ -261,6 +269,44 @@ private void BuildChildren()
OnPropertyChanged(nameof(HasReminder));
}
+ ///
+ /// Builds the map data sources for the Wasserförderung tab's "Karte" mode (#150 phase 2) from
+ /// the Stammdaten-configured Einsatzgebiet, or (null, null) when unconfigured or the region
+ /// folder is missing either file — in which case the tab silently falls back to Manuell entry.
+ ///
+ private (IElevationSampler? ElevationSampler, IMapTileSource? TileSource) BuildWasserfoerderungMapSources()
+ {
+ if (!_masterData.Einsatzgebiet.IsConfigured)
+ return (null, null);
+
+ var demPath = Path.Combine(_masterData.Einsatzgebiet.FolderPath, "region.dem");
+ var mbtilesPath = Path.Combine(_masterData.Einsatzgebiet.FolderPath, "region.mbtiles");
+ if (!File.Exists(demPath) || !File.Exists(mbtilesPath))
+ return (null, null);
+
+ return (new DemFileElevationSampler(demPath), new MbTilesFileSource(mbtilesPath));
+ }
+
+ ///
+ /// Derives the Karte mode's initial view — and, doubling as the region's lowest usable zoom
+ /// (initialMinZoom), 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
+ /// '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).
+ ///
+ private static (GeoPoint Center, int Zoom)? InitialMapViewFrom(IMapTileSource? tileSource)
+ {
+ if (tileSource?.GetTileBounds() is not { } bounds)
+ return null;
+
+ var centerTileX = (bounds.MinX + bounds.MaxX + 1) / 2.0;
+ var centerTileY = (bounds.MinY + bounds.MaxY + 1) / 2.0;
+ var center = WebMercator.ToGeo(
+ centerTileX * WebMercator.TileSizePixels, centerTileY * WebMercator.TileSizePixels, bounds.Zoom);
+ return (center, bounds.Zoom);
+ }
+
private bool CanClose => !IsReadOnly;
// Closing is permanent (the incident becomes read-only), so confirm first. If a Trupp is
@@ -332,10 +378,37 @@ private async Task ExportPdfAsync()
var path = await _dialogs.PickExportPdfAsync(suggested);
if (string.IsNullOrWhiteSpace(path))
return;
- await File.WriteAllBytesAsync(path, await _local!.ExportPdfAsync());
+ await File.WriteAllBytesAsync(path, await _local!.ExportPdfAsync(BuildRouteOverviewPngById()));
await _dialogs.ShareFileAsync(path, "application/pdf");
}
+ ///
+ /// Renders a map snapshot for every route-based Wasserförderung Leitung (#150 phase 2), when
+ /// both a renderer and the region's tiles are available; a Leitung the renderer fails on (or
+ /// with no route at all — Plan A manual entry) simply has no entry, unchanged from Phase 1.
+ ///
+ private IReadOnlyDictionary BuildRouteOverviewPngById()
+ {
+ var result = new Dictionary();
+ if (_routeOverviewRenderer is null)
+ return result;
+
+ var (_, tileSource) = BuildWasserfoerderungMapSources();
+ if (tileSource is null)
+ return result;
+
+ foreach (var leitung in _session.Incident.Wasserfoerderung)
+ {
+ if (leitung.RoutePoints is null)
+ continue;
+ var png = _routeOverviewRenderer.Render(leitung.RoutePoints, tileSource);
+ if (png is not null)
+ result[leitung.Id] = png;
+ }
+
+ return result;
+ }
+
// ===== Multi-device hosting (#52): flip "Im Netzwerk freigeben" to expose this open incident. =====
// Only offered on a platform that can host and while the incident is editable — a read-only
diff --git a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
index 7f92f60..a210e7d 100644
--- a/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/MasterDataEditorViewModel.cs
@@ -17,6 +17,8 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private readonly IMasterDataProvider _provider;
private readonly IFileDialogService _dialogs;
private readonly IMasterDataFileService _files;
+ private readonly IRegionPackCatalogService _regionCatalog;
+ private readonly IRegionPackInstaller _regionInstaller;
private MasterDataSet _original = MasterDataSet.Empty;
private bool _originalIsEmpty = true;
@@ -28,12 +30,17 @@ public sealed partial class MasterDataEditorViewModel : ObservableObject
private PersonnelSection _personnel = null!;
private VehiclesSection _vehicles = null!;
private SettingsSection _settings = null!;
+ private EinsatzgebietSection _einsatzgebiet = null!;
- public MasterDataEditorViewModel(IMasterDataProvider provider, IFileDialogService dialogs, IMasterDataFileService files)
+ public MasterDataEditorViewModel(
+ IMasterDataProvider provider, IFileDialogService dialogs, IMasterDataFileService files,
+ IRegionPackCatalogService regionCatalog, IRegionPackInstaller regionInstaller)
{
_provider = provider;
_dialogs = dialogs;
_files = files;
+ _regionCatalog = regionCatalog;
+ _regionInstaller = regionInstaller;
Load();
}
@@ -99,6 +106,9 @@ private void PopulateSections(MasterDataSet set)
Sections.Add(_checklistAbbau = new ChecklistTemplateSection("Checkliste Abbau", set.ChecklistTemplateAbbau, MarkDirty));
Sections.Add(_personnel = new PersonnelSection("Personal", set.Personnel, MarkDirty));
Sections.Add(_vehicles = new VehiclesSection("Fahrzeuge", set.Vehicles, set.Brigades, set.RadioCallSigns, OnVehiclesChanged));
+ Sections.Add(_einsatzgebiet = new EinsatzgebietSection(
+ "Einsatzgebiet", set.Einsatzgebiet, MarkDirty, _regionCatalog, _regionInstaller));
+ _einsatzgebiet.LoadCatalogCommand.Execute(null);
SelectedSection = Sections[Math.Clamp(previousIndex < 0 ? 0 : previousIndex, 0, Sections.Count - 1)];
}
@@ -140,6 +150,7 @@ private MasterDataSet BuildSet() => _original with
Personnel = _personnel.ToPeople(),
Vehicles = _vehicles.ToValues(),
Settings = _settings.ToSettings(),
+ Einsatzgebiet = _einsatzgebiet.ToEinsatzgebiet(),
// Streets are not editable here; _original carries them through unchanged.
};
diff --git a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
index 5ae4f94..c8db13e 100644
--- a/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
+++ b/src/LageBuch.AppLogic/ViewModels/WasserfoerderungViewModel.cs
@@ -1,8 +1,10 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using LageBuch.AppLogic.Services;
using LageBuch.Domain.Wasserfoerderung;
using LageBuch.Documents;
+using LageBuch.Persistence.Wasserfoerderung;
using LageBuch.Sync;
namespace LageBuch.AppLogic.ViewModels;
@@ -19,13 +21,47 @@ public sealed partial class WasserfoerderungViewModel : ObservableObject, IDispo
{
private readonly IIncidentSession _session;
private readonly Action _onChanged;
+ private readonly IElevationSampler? _elevationSampler;
+ private readonly IMapTileSource? _tileSource;
- public WasserfoerderungViewModel(IIncidentSession session, Action onChanged)
+ // The view the map opened with (region-derived center/zoom, or the hardcoded default) --
+ // kept so ResetMapViewCommand has a "home" to return to. Cursor-anchored wheel/pinch zoom
+ // shifts the center as a side effect, and there is no drag-to-pan, so without this a drifted
+ // view had no way back at all (#150 follow-up).
+ private readonly double _initialCenterLatitude;
+ private readonly double _initialCenterLongitude;
+ private readonly int _initialZoom;
+
+ public WasserfoerderungViewModel(
+ IIncidentSession session, Action onChanged,
+ IElevationSampler? elevationSampler = null, IMapTileSource? tileSource = null,
+ GeoPoint? initialMapCenter = null, int? initialMapZoom = null, int? initialMinZoom = null)
{
_session = session;
_onChanged = onChanged;
+ _elevationSampler = elevationSampler;
+ _tileSource = tileSource;
IsReadOnly = session.IsReadOnly;
Rows = new ObservableCollection();
+ DrawnRoutePoints = new ObservableCollection();
+ DrawnRoutePoints.CollectionChanged += (_, _) => UndoLastRoutePointCommand.NotifyCanExecuteChanged();
+ DrawnRoutePoints.CollectionChanged += (_, _) => FinishRouteCommand.NotifyCanExecuteChanged();
+
+ if (initialMinZoom is { } minZoom)
+ _minZoom = minZoom;
+
+ if (initialMapCenter is { } center)
+ {
+ _mapCenterLatitude = center.Latitude;
+ _mapCenterLongitude = center.Longitude;
+ }
+ if (initialMapZoom is { } zoom)
+ _mapZoom = Math.Clamp(zoom, _minZoom, MaxZoom);
+
+ _initialCenterLatitude = _mapCenterLatitude;
+ _initialCenterLongitude = _mapCenterLongitude;
+ _initialZoom = _mapZoom;
+
_session.Changed += Sync;
Sync();
}
@@ -33,6 +69,100 @@ public WasserfoerderungViewModel(IIncidentSession session, Action onChanged)
public bool IsReadOnly { get; }
public ObservableCollection Rows { get; }
+ /// True once both a tile source and an elevation sampler are configured — i.e. the
+ /// operator's Einsatzgebiet points at a folder that actually holds region.mbtiles + region.dem.
+ public bool IsMapModeAvailable => _elevationSampler is not null && _tileSource is not null;
+
+ public IMapTileSource? TileSource => _tileSource;
+
+ /// The in-progress polyline drawn on the map (#150 Plan B); cleared once a Leitung is finished.
+ public ObservableCollection DrawnRoutePoints { get; }
+
+ /// Manuell (Plan A) vs. Karte (Plan B) input mode. The view gates the toggle on
+ /// — this property itself does not re-check it.
+ [ObservableProperty]
+ private bool _isMapMode;
+
+ // The constructor overrides MinZoom from the configured region's actual tile bounds
+ // (IncidentWorkspaceViewModel, #150 follow-up) whenever one is available — going lower than
+ // the region's own lowest rendered zoom has nothing to show. A region with no tiles at all
+ // (or no Einsatzgebiet configured) falls back to this fixed default. MaxZoom stays a generous
+ // constant regardless: zooming past a region's native detail is handled by MapDrawing's
+ // overzoom fallback (an increasingly blurry but still-oriented view), not blocked here.
+ private readonly int _minZoom = 3;
+ private const int MaxZoom = 19;
+
+ [ObservableProperty]
+ private double _mapCenterLatitude = 48.14;
+
+ [ObservableProperty]
+ private double _mapCenterLongitude = 11.58;
+
+ [ObservableProperty]
+ private int _mapZoom = 14;
+
+ [RelayCommand]
+ private void ZoomIn() => MapZoom = Math.Min(MaxZoom, MapZoom + 1);
+
+ [RelayCommand]
+ private void ZoomOut() => MapZoom = Math.Max(_minZoom, MapZoom - 1);
+
+ /// Applies a wheel/pinch-driven view change from the map canvas (#150 follow-up) —
+ /// the control->VM counterpart of /,
+ /// used instead of two-way property binding on CenterLatitude/CenterLongitude/Zoom.
+ [RelayCommand]
+ private void ChangeMapView(MapViewChange change)
+ {
+ MapCenterLatitude = change.CenterLatitude;
+ MapCenterLongitude = change.CenterLongitude;
+ MapZoom = Math.Clamp(change.Zoom, _minZoom, MaxZoom);
+ }
+
+ /// "Zentrieren": returns to the view the map opened with (#150 follow-up) — the only
+ /// way back once cursor-anchored zooming has drifted the center away from the region, since
+ /// there is no drag-to-pan.
+ [RelayCommand]
+ private void ResetMapView()
+ {
+ MapCenterLatitude = _initialCenterLatitude;
+ MapCenterLongitude = _initialCenterLongitude;
+ MapZoom = _initialZoom;
+ }
+
+ [RelayCommand]
+ private void AddRoutePoint(GeoPoint point) => DrawnRoutePoints.Add(point);
+
+ private bool CanUndoLastRoutePoint => DrawnRoutePoints.Count > 0;
+
+ [RelayCommand(CanExecute = nameof(CanUndoLastRoutePoint))]
+ private void UndoLastRoutePoint() => DrawnRoutePoints.RemoveAt(DrawnRoutePoints.Count - 1);
+
+ [RelayCommand]
+ private void ClearRoute() => DrawnRoutePoints.Clear();
+
+ private bool CanFinishRoute => !IsReadOnly && DrawnRoutePoints.Count >= 2 && _elevationSampler is not null;
+
+ /// "Fertig": samples the drawn polyline and records the Leitung from it (#150 Plan B).
+ [RelayCommand(CanExecute = nameof(CanFinishRoute))]
+ private void FinishRoute()
+ {
+ ErrorMessage = null;
+ try
+ {
+ var route = DrawnRoutePoints.ToList();
+ var profile = _elevationSampler!.Sample(route);
+ _session.AddWasserfoerderungLeitungFromRoute(NewUebergabestelle, NewAnsprechpartner, route, profile);
+ NewUebergabestelle = string.Empty;
+ NewAnsprechpartner = string.Empty;
+ DrawnRoutePoints.Clear();
+ _onChanged();
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = ex.Message;
+ }
+ }
+
[ObservableProperty]
private string? _newUebergabestelle;
diff --git a/src/LageBuch.Documents/IncidentPdf.cs b/src/LageBuch.Documents/IncidentPdf.cs
index 32606fd..fd18d22 100644
--- a/src/LageBuch.Documents/IncidentPdf.cs
+++ b/src/LageBuch.Documents/IncidentPdf.cs
@@ -13,13 +13,22 @@ public static class IncidentPdf
/// pages via . An entry with no bytes supplied (a missing
/// sibling-folder file) is skipped rather than failing the export.
///
- public static byte[] Generate(Incident incident, IReadOnlyDictionary? fileBytes = null)
+ ///
+ /// PNG bytes for a route-based Wasserförderung Leitung's map snapshot (#150 Plan B), keyed by
+ /// WasserfoerderungLeitung.Id — rendered by the caller (this project stays Avalonia-free).
+ /// A Leitung with no entry (manual Plan A entry, or rendering failed) shows the numeric table
+ /// row only, unchanged from Phase 1.
+ ///
+ public static byte[] Generate(
+ Incident incident,
+ IReadOnlyDictionary? fileBytes = null,
+ IReadOnlyDictionary? routeOverviewPngById = null)
{
ArgumentNullException.ThrowIfNull(incident);
PdfLicense.Ensure();
fileBytes ??= new Dictionary();
- var baseReport = new IncidentReportDocument(incident, fileBytes).GeneratePdf();
+ var baseReport = new IncidentReportDocument(incident, fileBytes, routeOverviewPngById).GeneratePdf();
var pdfAttachments = incident.Files
.Where(f => f.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
diff --git a/src/LageBuch.Documents/IncidentReportDocument.cs b/src/LageBuch.Documents/IncidentReportDocument.cs
index b8e5b7e..0f24cbe 100644
--- a/src/LageBuch.Documents/IncidentReportDocument.cs
+++ b/src/LageBuch.Documents/IncidentReportDocument.cs
@@ -10,6 +10,7 @@ public sealed class IncidentReportDocument : IDocument
{
private readonly Incident _incident;
private readonly IReadOnlyDictionary _imageBytesById;
+ private readonly IReadOnlyDictionary _routeOverviewPngById;
/// The incident to render.
///
@@ -18,7 +19,11 @@ public sealed class IncidentReportDocument : IDocument
/// by name regardless of whether bytes were supplied; only image entries with bytes present
/// are additionally rendered inline (see ).
///
- public IncidentReportDocument(Incident incident, IReadOnlyDictionary? fileBytes = null)
+ /// See .
+ public IncidentReportDocument(
+ Incident incident,
+ IReadOnlyDictionary? fileBytes = null,
+ IReadOnlyDictionary? routeOverviewPngById = null)
{
ArgumentNullException.ThrowIfNull(incident);
_incident = incident;
@@ -26,6 +31,7 @@ public IncidentReportDocument(Incident incident, IReadOnlyDictionary f.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
.Where(f => fileBytes is not null && fileBytes.ContainsKey(f.Id))
.ToDictionary(f => f.Id, f => fileBytes![f.Id]);
+ _routeOverviewPngById = routeOverviewPngById ?? new Dictionary();
}
public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
@@ -51,7 +57,7 @@ public void Compose(IDocumentContainer document)
column.Item().Element(c => RolesSection.Compose(c, _incident));
column.Item().Element(c => ForcesSection.Compose(c, _incident));
column.Item().Element(c => TasksSection.Compose(c, _incident));
- column.Item().Element(c => WasserfoerderungSection.Compose(c, _incident));
+ column.Item().Element(c => WasserfoerderungSection.Compose(c, _incident, _routeOverviewPngById));
column.Item().Element(c => AtemschutzSection.Compose(c, _incident));
column.Item().Element(c => CoMessprotokollSection.Compose(c, _incident));
column.Item().Element(c => FilesSection.Compose(c, _incident.Files, _imageBytesById));
diff --git a/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs b/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
index 9e12bd1..45454c3 100644
--- a/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
+++ b/src/LageBuch.Documents/Sections/WasserfoerderungSection.cs
@@ -7,8 +7,10 @@ namespace LageBuch.Documents.Sections;
public static class WasserfoerderungSection
{
- public static void Compose(IContainer container, Incident incident)
+ public static void Compose(
+ IContainer container, Incident incident, IReadOnlyDictionary? routeOverviewPngById = null)
{
+ routeOverviewPngById ??= new Dictionary();
container.Column(column =>
{
column.Spacing(4);
@@ -55,6 +57,21 @@ public static void Compose(IContainer container, Incident incident)
}
});
+ // Plan B (#150 phase 2): a small route-overview snapshot per drawn Leitung, when one
+ // was rendered for it. A manually entered (Plan A) Leitung has no RoutePoints and so
+ // never has an entry here — this loop leaves the Phase 1 layout untouched for those.
+ foreach (var leitung in incident.Wasserfoerderung)
+ {
+ if (leitung.RoutePoints is null || !routeOverviewPngById.TryGetValue(leitung.Id, out var png))
+ continue;
+
+ column.Item().PaddingTop(4).Column(overview =>
+ {
+ overview.Item().Text($"Ltg {leitung.Number} — Kartenübersicht").FontSize(9).SemiBold();
+ overview.Item().MaxWidth(280).Image(png);
+ });
+ }
+
column.Item().PaddingTop(2).Text(
"Planung: B-800, B-Schlauch 20 m, 8 bar Speisedruck, 1,5 bar Pumpeneingang, " +
"3 % Reserveschlauch pro Teilstrecke.")
diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs
index 94c0d6b..7728f61 100644
--- a/src/LageBuch.Domain/Incident.cs
+++ b/src/LageBuch.Domain/Incident.cs
@@ -767,6 +767,28 @@ public WasserfoerderungLeitung AddWasserfoerderungLeitung(
return leitung;
}
+ ///
+ /// Plan B (#150 phase 2): plans and records a Leitung from a route drawn on the map. The
+ /// elevation profile is sampled by the caller (before this runs) so every replica stores the
+ /// same computed numbers regardless of local DEM-file differences.
+ ///
+ public WasserfoerderungLeitung AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle,
+ string? ansprechpartner,
+ IReadOnlyList routePoints,
+ IReadOnlyList profile)
+ {
+ EnsureOpen();
+ var leitung = WasserfoerderungLeitung.CreateFromRoute(
+ number: _wasserfoerderung.Count + 1,
+ uebergabestelle: uebergabestelle,
+ ansprechpartner: ansprechpartner,
+ routePoints: routePoints,
+ profile: profile);
+ _wasserfoerderung.Add(leitung);
+ return leitung;
+ }
+
/// Removes the planned Leitung. Unknown ids throw so a replayed command fails loudly.
public void RemoveWasserfoerderungLeitung(Guid leitungId)
{
diff --git a/src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs b/src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs
new file mode 100644
index 0000000..621f908
--- /dev/null
+++ b/src/LageBuch.Domain/Wasserfoerderung/ElevationProfileSample.cs
@@ -0,0 +1,4 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+/// One sampled terrain point along a drawn route, distance from the route start.
+public sealed record ElevationProfileSample(double DistanceMeters, double ElevationMeters);
diff --git "a/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs" "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
index 9f05170..1884562 100644
--- "a/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
+++ "b/src/LageBuch.Domain/Wasserfoerderung/F\303\266rderstreckePlanner.cs"
@@ -1,10 +1,11 @@
namespace LageBuch.Domain.Wasserfoerderung;
///
-/// Pure engine that places Verstärkerpumpen along a Förderstrecke (#150, Plan A). Given a total
-/// length, a total elevation rise (treated as a uniform gradient — Plan B replaces that with a
-/// sampled profile) and a , it computes B-hose count, pump
-/// positions and reserve figures. Physics is linear and therefore testable without deps:
+/// Pure engine that places Verstärkerpumpen along a Förderstrecke (#150).
+/// (Plan A) takes a total length and a single net elevation rise, treated as a uniform gradient;
+/// (Plan B) walks an actual sampled terrain profile instead, so an
+/// interior crest is caught even when the endpoints alone look fine. Physics is linear and
+/// therefore testable without deps:
///
/// head-loss per leg = friction + elevation friction = loss/100m at flow
/// usable budget = (feed − inlet) · (1 − headroom)
@@ -26,28 +27,76 @@ public static FörderstreckePlan Plan(double lengthM, double riseM, Förderstrec
if (lengthM <= 0)
throw new ArgumentException("Die Förderstrecke muss länger als 0 m sein.", nameof(lengthM));
+ return PlanFromProfile(
+ new[] { new ElevationProfileSample(0, 0), new ElevationProfileSample(lengthM, riseM) },
+ config);
+ }
+
+ ///
+ /// Plan B (#150 phase 2): same physics as , but walks a sampled terrain
+ /// profile instead of assuming one uniform gradient, so an interior crest (climb then
+ /// descend back to the same net height) is caught even when the leg's endpoints alone would
+ /// look fine.
+ ///
+ public static FörderstreckePlan PlanFromProfile(
+ IReadOnlyList profile, FörderstreckeConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(profile);
+ ArgumentNullException.ThrowIfNull(config);
+ if (profile.Count < 2)
+ throw new ArgumentException("Das Höhenprofil braucht mindestens zwei Punkte.", nameof(profile));
+
+ var lengthM = profile[^1].DistanceMeters;
+ if (lengthM <= 0)
+ throw new ArgumentException("Die Förderstrecke muss länger als 0 m sein.", nameof(profile));
+
var lossPerMeter = LossPer100Meters(config.FlowLMin) / 100;
- var headPerMeter = lossPerMeter + ElevationBarPerMeter * riseM / lengthM;
- var hoseCount = (int)Math.Ceiling(lengthM / config.HoseLengthMeters);
+ var budgetPerLeg = BudgetPerLegBar(config);
+ var hoseLen = config.HoseLengthMeters;
+ var hoseCount = (int)Math.Ceiling(lengthM / hoseLen);
var reserveHoseCount = (int)Math.Ceiling(lengthM / 100);
- // Gravity assist (or a flat short line) means one leg can carry the whole route.
- var legLengthM = headPerMeter <= 0
- ? lengthM
- : BudgetPerLegBar(config) / headPerMeter;
+ double Cost(double a, double b)
+ {
+ // The binding constraint is the worst (highest cumulative) pressure drop anywhere
+ // along the leg, not just at its endpoint — an interior crest can exceed budget even
+ // when the leg nets back down to a fine endpoint value.
+ var worst = double.NegativeInfinity;
+ foreach (var sample in profile)
+ {
+ if (sample.DistanceMeters > a && sample.DistanceMeters < b)
+ worst = Math.Max(worst, CumulativeLossBar(a, sample.DistanceMeters));
+ }
- var legSnapped = Math.Min(
- Math.Floor(legLengthM / config.HoseLengthMeters) * config.HoseLengthMeters,
- lengthM);
+ return Math.Max(worst, CumulativeLossBar(a, b));
+ }
- // A single hose cannot carry a leg: the climb physically does not fit.
- if (legSnapped < config.HoseLengthMeters)
- throw new ArgumentException(
- "Die Steigung ist zu stark — ein B-Schlauch (20 m) trägt das Gefälle bereits über das Druckbudget.");
+ double CumulativeLossBar(double a, double d) =>
+ lossPerMeter * (d - a) + ElevationBarPerMeter * (ElevationAt(profile, d) - ElevationAt(profile, a));
- var positions = new List();
- for (var pos = 0.0; pos < lengthM; pos += legSnapped)
- positions.Add(pos);
+ var positions = new List { 0 };
+ var pos = 0.0;
+ while (pos < lengthM)
+ {
+ var remaining = lengthM - pos;
+ if (remaining >= hoseLen && Cost(pos, lengthM) <= budgetPerLeg)
+ {
+ pos = lengthM;
+ break;
+ }
+
+ var reach = 0.0;
+ while (pos + reach + hoseLen <= lengthM && Cost(pos, pos + reach + hoseLen) <= budgetPerLeg)
+ reach += hoseLen;
+
+ if (reach == 0)
+ throw new ArgumentException(
+ "Die Steigung ist zu stark — ein B-Schlauch (20 m) trägt das Gefälle bereits über das Druckbudget.");
+
+ pos += reach;
+ if (pos < lengthM)
+ positions.Add(pos);
+ }
var pumpCount = Math.Max(0, positions.Count - 1); // exclude the feed pump at 0
var reservePumpCount = (int)Math.Ceiling((double)pumpCount / config.ReservePumpEveryNPumps);
@@ -55,6 +104,22 @@ public static FörderstreckePlan Plan(double lengthM, double riseM, Förderstrec
return new FörderstreckePlan(lengthM, hoseCount, reserveHoseCount, pumpCount, reservePumpCount, positions);
}
+ private static double ElevationAt(IReadOnlyList profile, double distanceM)
+ {
+ for (var i = 0; i < profile.Count - 1; i++)
+ {
+ var a = profile[i];
+ var b = profile[i + 1];
+ if (distanceM <= b.DistanceMeters)
+ {
+ var t = (distanceM - a.DistanceMeters) / (b.DistanceMeters - a.DistanceMeters);
+ return a.ElevationMeters + t * (b.ElevationMeters - a.ElevationMeters);
+ }
+ }
+
+ return profile[^1].ElevationMeters;
+ }
+
private static double BudgetPerLegBar(FörderstreckeConfig config) =>
(config.FeedPressureBar - config.InletPressureBar) * (1 - config.HeadroomPercent);
diff --git a/src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs b/src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs
new file mode 100644
index 0000000..b786363
--- /dev/null
+++ b/src/LageBuch.Domain/Wasserfoerderung/GeoPoint.cs
@@ -0,0 +1,4 @@
+namespace LageBuch.Domain.Wasserfoerderung;
+
+/// One vertex of a route drawn on the map (#150, Plan B), WGS84 degrees.
+public sealed record GeoPoint(double Latitude, double Longitude);
diff --git a/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs b/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
index 3967029..e7f4c4f 100644
--- a/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
+++ b/src/LageBuch.Domain/Wasserfoerderung/WasserfoerderungLeitung.cs
@@ -31,6 +31,9 @@ private WasserfoerderungLeitung() { }
/// Meters from the water source where a pump sits; index 0 is the feed pump.
public IReadOnlyList PumpPositionsMeters { get; private init; } = Array.Empty();
+ /// The drawn polyline (#150, Plan B); null when the Leitung came from manual entry (Plan A).
+ public IReadOnlyList? RoutePoints { get; private init; }
+
public static WasserfoerderungLeitung Create(
int number,
string? uebergabestelle,
@@ -76,7 +79,8 @@ public static WasserfoerderungLeitung Rehydrate(
int reserveHoseCount,
int pumpCount,
int reservePumpCount,
- IReadOnlyList pumpPositionsMeters)
+ IReadOnlyList pumpPositionsMeters,
+ IReadOnlyList? routePoints = null)
=> new()
{
Id = id,
@@ -92,5 +96,45 @@ public static WasserfoerderungLeitung Rehydrate(
PumpCount = pumpCount,
ReservePumpCount = reservePumpCount,
PumpPositionsMeters = pumpPositionsMeters,
+ RoutePoints = routePoints,
+ };
+
+ ///
+ /// Plan B (#150 phase 2): plans from an already-sampled terrain profile along a drawn route
+ /// instead of a single manually entered length/rise. The profile is sampled once (by the
+ /// caller, before this runs) so every replica stores the same numbers regardless of local DEM
+ /// differences — see .
+ ///
+ public static WasserfoerderungLeitung CreateFromRoute(
+ int number,
+ string? uebergabestelle,
+ string? ansprechpartner,
+ IReadOnlyList routePoints,
+ IReadOnlyList profile,
+ FörderstreckeConfig? config = null)
+ {
+ if (number < 1)
+ throw new ArgumentException(nameof(number), "Die Leitungsnummer muss >= 1 sein.");
+
+ config ??= FörderstreckeConfig.Default;
+ var plan = FörderstreckePlanner.PlanFromProfile(profile, config);
+
+ return new WasserfoerderungLeitung
+ {
+ Id = Guid.NewGuid(),
+ Number = number,
+ Uebergabestelle = string.IsNullOrWhiteSpace(uebergabestelle) ? null : uebergabestelle.Trim(),
+ Ansprechpartner = string.IsNullOrWhiteSpace(ansprechpartner) ? null : ansprechpartner.Trim(),
+ FlowLMin = config.FlowLMin,
+ FeedPressureBar = config.FeedPressureBar,
+ LengthMeters = plan.LengthMeters,
+ ElevationRiseMeters = profile[^1].ElevationMeters - profile[0].ElevationMeters,
+ HoseCount = plan.HoseCount,
+ ReserveHoseCount = plan.ReserveHoseCount,
+ PumpCount = plan.PumpCount,
+ ReservePumpCount = plan.ReservePumpCount,
+ PumpPositionsMeters = plan.PumpPositionsMeters,
+ RoutePoints = routePoints,
};
+ }
}
\ No newline at end of file
diff --git a/src/LageBuch.Persistence/IncidentRepository.cs b/src/LageBuch.Persistence/IncidentRepository.cs
index 6a1335b..28e31a7 100644
--- a/src/LageBuch.Persistence/IncidentRepository.cs
+++ b/src/LageBuch.Persistence/IncidentRepository.cs
@@ -247,10 +247,13 @@ public void Save(string path, Incident incident)
{
var w = incident.Wasserfoerderung[i];
var positionsJson = System.Text.Json.JsonSerializer.Serialize(w.PumpPositionsMeters);
+ var routePointsJson = w.RoutePoints is null
+ ? (object)DBNull.Value
+ : System.Text.Json.JsonSerializer.Serialize(w.RoutePoints);
Run(cn, tx,
"INSERT INTO wass_leitungen (id, ordinal, number, uebergabestelle, ansprechpartner, flow_lmin, feed_pressure_bar, " +
- "length_m, elevation_rise_m, hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions) " +
- "VALUES ($id,$o,$num,$ueb,$ap,$flow,$feed,$len,$rise,$hc,$rch,$pc,$rpc,$pos);",
+ "length_m, elevation_rise_m, hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions, route_points_json) " +
+ "VALUES ($id,$o,$num,$ueb,$ap,$flow,$feed,$len,$rise,$hc,$rch,$pc,$rpc,$pos,$route);",
p =>
{
p("$id", w.Id.ToString()); p("$o", i); p("$num", w.Number);
@@ -261,6 +264,7 @@ public void Save(string path, Incident incident)
p("$hc", w.HoseCount); p("$rch", w.ReserveHoseCount);
p("$pc", w.PumpCount); p("$rpc", w.ReservePumpCount);
p("$pos", positionsJson);
+ p("$route", routePointsJson);
});
}
@@ -461,13 +465,16 @@ public Incident Load(string path)
var wasserfoerderung = ReadAll(cn,
"SELECT id, number, uebergabestelle, ansprechpartner, flow_lmin, feed_pressure_bar, length_m, elevation_rise_m, " +
- "hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions FROM wass_leitungen ORDER BY ordinal;",
+ "hose_count, reserve_hose_count, pump_count, reserve_pump_count, pump_positions, route_points_json FROM wass_leitungen ORDER BY ordinal;",
r => Domain.Wasserfoerderung.WasserfoerderungLeitung.Rehydrate(
Guid.Parse(r.GetString(0)), r.GetInt32(1), Str(r, 2), Str(r, 3),
r.GetInt32(4), r.GetDouble(5), r.GetDouble(6), r.GetDouble(7),
r.GetInt32(8), r.GetInt32(9), r.GetInt32(10), r.GetInt32(11),
System.Text.Json.JsonSerializer.Deserialize>(r.GetString(12))
- ?? Array.Empty()));
+ ?? Array.Empty(),
+ r.IsDBNull(13)
+ ? null
+ : System.Text.Json.JsonSerializer.Deserialize>(r.GetString(13))));
// Legacy fallback: files written before the Einsatznummer unification carry the 4-digit
// number in ils_number and nothing in incident_number. Load that old value as the
diff --git a/src/LageBuch.Persistence/MasterData/MasterDataSet.cs b/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
index 998f968..f2657eb 100644
--- a/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
+++ b/src/LageBuch.Persistence/MasterData/MasterDataSet.cs
@@ -170,6 +170,18 @@ public static class AnonymizedExampleData
};
}
+///
+/// The operator's configured region of operation (#150, Plan B) — a folder expected to hold
+/// region.mbtiles (map tiles) and region.dem (elevation), set up once at
+/// installation. Global config, like everything else in .
+///
+public sealed record Einsatzgebiet(string Name, string FolderPath)
+{
+ public static Einsatzgebiet Empty { get; } = new(string.Empty, string.Empty);
+
+ public bool IsConfigured => !string.IsNullOrWhiteSpace(Name) && !string.IsNullOrWhiteSpace(FolderPath);
+}
+
public sealed record MasterDataSet(
IReadOnlyList Roles,
IReadOnlyList Status,
@@ -192,7 +204,10 @@ public sealed record MasterDataSet(
IReadOnlyList Vehicles,
// Operational defaults (timers, durations). Unlike the lists, always populated — a store with
// no overrides yields IncidentSettings.Defaults, never a zeroed record.
- IncidentSettings Settings)
+ IncidentSettings Settings,
+ // Region of operation for the Wasserförderung map (#150 phase 2). Unlike the lists, always
+ // populated — a store with no override yields Einsatzgebiet.Empty, never a null.
+ Einsatzgebiet Einsatzgebiet)
{
///
/// Every category empty. Intended for tests and for callers that need a starting point to
@@ -206,13 +221,15 @@ public sealed record MasterDataSet(
Array.Empty(), Array.Empty(),
Array.Empty(), Array.Empty(), Array.Empty(),
Array.Empty(),
- IncidentSettings.Defaults);
+ IncidentSettings.Defaults,
+ Einsatzgebiet.Empty);
///
/// True when no category holds a single entry. A fresh install starts here, and it is the
/// condition under which the Stammdaten editor offers Import — a bootstrap, not a merge.
- /// deliberately does not count: it always carries defaults, and letting it
- /// mark the set non-empty would suppress the Import bootstrap on an otherwise fresh install.
+ /// and the Einsatzgebiet field deliberately do not count: they always
+ /// carry a value (defaults, or an empty region), and letting either mark the set non-empty
+ /// would suppress the Import bootstrap on an otherwise fresh install.
///
public bool IsEmpty =>
Roles.Count == 0 && Status.Count == 0 && Equipment.Count == 0 && Districts.Count == 0
@@ -285,7 +302,8 @@ static IReadOnlyList Arr(JsonElement e, string prop) =>
ParsePersonnel(root),
Arr(root, "einsatzarten"),
vehicles,
- ParseSettings(root));
+ ParseSettings(root),
+ ParseEinsatzgebiet(root));
}
///
@@ -339,6 +357,20 @@ static int Int(JsonElement e, string prop, int fallback) =>
Int(s, "returnPressureBar", d.ReturnPressureBar));
}
+ ///
+ /// Reads the optional einsatzgebiet object. A missing object falls back to
+ /// so an older file still yields a complete record.
+ ///
+ private static Einsatzgebiet ParseEinsatzgebiet(JsonElement root)
+ {
+ if (!root.TryGetProperty("einsatzgebiet", out var e) || e.ValueKind != JsonValueKind.Object)
+ return Einsatzgebiet.Empty;
+
+ return new Einsatzgebiet(
+ e.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty,
+ e.TryGetProperty("folderPath", out var f) ? f.GetString() ?? string.Empty : string.Empty);
+ }
+
private static IReadOnlyList ParsePersonnel(JsonElement root)
{
if (!root.TryGetProperty("personnel", out var arr) || arr.ValueKind != JsonValueKind.Array)
@@ -397,6 +429,7 @@ public static string Serialize(MasterDataSet set)
pressureControlIntervalMinutes = set.Settings.PressureControlIntervalMinutes,
returnPressureBar = set.Settings.ReturnPressureBar,
},
+ einsatzgebiet = new { name = set.Einsatzgebiet.Name, folderPath = set.Einsatzgebiet.FolderPath },
};
return JsonSerializer.Serialize(model, new JsonSerializerOptions
diff --git a/src/LageBuch.Persistence/MasterData/MasterDataStore.cs b/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
index 36249de..fe4c7fc 100644
--- a/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
+++ b/src/LageBuch.Persistence/MasterData/MasterDataStore.cs
@@ -74,6 +74,14 @@ public void Save(string path, MasterDataSet set)
"INSERT INTO md_settings (key, value) VALUES ($k,$v) ON CONFLICT(key) DO UPDATE SET value=excluded.value;",
p => { p("$k", key); p("$v", value); });
+ // Single fixed row (id=0), UPSERT like settings.
+ Run(cn, tx,
+ """
+ INSERT INTO md_einsatzgebiet (id, name, folder_path) VALUES (0, $n, $f)
+ ON CONFLICT(id) DO UPDATE SET name=excluded.name, folder_path=excluded.folder_path;
+ """,
+ p => { p("$n", set.Einsatzgebiet.Name); p("$f", set.Einsatzgebiet.FolderPath); });
+
tx.Commit();
}
@@ -137,6 +145,11 @@ CREATE TABLE IF NOT EXISTS md_personnel (
phone TEXT
);
CREATE TABLE IF NOT EXISTS md_settings (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS md_einsatzgebiet (
+ id INTEGER PRIMARY KEY CHECK (id = 0),
+ name TEXT NOT NULL DEFAULT '',
+ folder_path TEXT NOT NULL DEFAULT ''
+ );
""");
// Widen a pre-existing md_checklist_template that predates the Aufbau/Abbau split — this
@@ -164,7 +177,8 @@ private static MasterDataSet Read(SqliteConnection cn)
ReadPersonnel(cn),
ReadColumn(cn, "SELECT value FROM md_einsatzarten;"),
ReadVehicles(cn),
- ReadSettings(cn));
+ ReadSettings(cn),
+ ReadEinsatzgebiet(cn));
}
// Rows are ordered globally by ordinal (Aufbau's block precedes Abbau's — see
@@ -209,6 +223,14 @@ private static IncidentSettings ReadSettings(SqliteConnection cn)
Get("return_pressure_bar", d.ReturnPressureBar));
}
+ private static Einsatzgebiet ReadEinsatzgebiet(SqliteConnection cn)
+ {
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText = "SELECT name, folder_path FROM md_einsatzgebiet WHERE id = 0;";
+ using var r = cmd.ExecuteReader();
+ return r.Read() ? new Einsatzgebiet(r.GetString(0), r.GetString(1)) : Einsatzgebiet.Empty;
+ }
+
private static void InsertList(SqliteConnection cn, SqliteTransaction tx, string table, IReadOnlyList values)
{
foreach (var v in values)
diff --git a/src/LageBuch.Persistence/Sqlite/Migrations.cs b/src/LageBuch.Persistence/Sqlite/Migrations.cs
index 17b3fd9..922540d 100644
--- a/src/LageBuch.Persistence/Sqlite/Migrations.cs
+++ b/src/LageBuch.Persistence/Sqlite/Migrations.cs
@@ -5,7 +5,7 @@ namespace LageBuch.Persistence.Sqlite;
public static class Migrations
{
- public const int CurrentVersion = 18;
+ public const int CurrentVersion = 19;
public static int GetVersion(SqliteConnection cn)
{
@@ -104,6 +104,10 @@ public static void Migrate(SqliteConnection cn)
{
ApplyV18(cn, tx);
}
+ if (version < 19)
+ {
+ ApplyV19(cn, tx);
+ }
SetVersion(cn, tx, CurrentVersion);
tx.Commit();
}
@@ -567,6 +571,13 @@ pump_positions TEXT NOT NULL DEFAULT '[]'
""");
}
+ private static void ApplyV19(SqliteConnection cn, SqliteTransaction tx)
+ {
+ // Plan B (#150 phase 2): the drawn route, when the Leitung came from the map. NULL means
+ // the Leitung was entered manually (Plan A) -- LengthMeters/ElevationRiseMeters apply either way.
+ SchemaHelpers.AddColumnIfMissing(cn, tx, "wass_leitungen", "route_points_json", "TEXT");
+ }
+
private static void SetVersion(SqliteConnection cn, SqliteTransaction tx, int version)
{
Exec(cn, tx, "DELETE FROM schema_version;");
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs b/src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs
new file mode 100644
index 0000000..f0fa656
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/DemFileElevationSampler.cs
@@ -0,0 +1,145 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+///
+/// Reads the custom flat binary heightmap format (#150, Plan B, data-prep contract — see the
+/// implementation plan for the exact byte layout) and samples elevation along a drawn route.
+///
+/// Format: 40-byte little-endian header (magic "FWDM", format version, origin lat/lon, cell size
+/// in degrees, rows, cols), then a row-major Int16 body in meters (row 0 = north, col 0 = west;
+/// marks a missing cell).
+///
+public sealed class DemFileElevationSampler : IElevationSampler
+{
+ private const short NoData = short.MinValue;
+ private const double EarthRadiusMeters = 6371000;
+
+ private readonly double _originLatitude;
+ private readonly double _originLongitude;
+ private readonly double _cellSizeDegrees;
+ private readonly int _rows;
+ private readonly int _cols;
+ private readonly short[] _body;
+ private readonly double _sampleIntervalMeters;
+
+ public DemFileElevationSampler(string demFilePath, double sampleIntervalMeters = 20.0)
+ {
+ _sampleIntervalMeters = sampleIntervalMeters;
+
+ using var stream = File.OpenRead(demFilePath);
+ using var reader = new BinaryReader(stream);
+
+ var magic = System.Text.Encoding.ASCII.GetString(reader.ReadBytes(4));
+ if (magic != "FWDM")
+ throw new InvalidDataException($"'{demFilePath}' ist keine gültige DEM-Datei (Magic '{magic}').");
+
+ _ = reader.ReadInt32(); // format version, currently always 1
+ _originLatitude = reader.ReadDouble();
+ _originLongitude = reader.ReadDouble();
+ _cellSizeDegrees = reader.ReadDouble();
+ _rows = reader.ReadInt32();
+ _cols = reader.ReadInt32();
+
+ _body = new short[_rows * _cols];
+ for (var i = 0; i < _body.Length; i++)
+ _body[i] = reader.ReadInt16();
+ }
+
+ public IReadOnlyList Sample(IReadOnlyList polyline)
+ {
+ ArgumentNullException.ThrowIfNull(polyline);
+ if (polyline.Count < 2)
+ throw new ArgumentException("Die Route braucht mindestens zwei Punkte.", nameof(polyline));
+
+ var cumulative = new double[polyline.Count];
+ for (var i = 1; i < polyline.Count; i++)
+ cumulative[i] = cumulative[i - 1] + HaversineMeters(polyline[i - 1], polyline[i]);
+ var totalLength = cumulative[^1];
+
+ var samples = new List();
+ var segment = 0;
+ // The epsilon keeps a total length that lands almost exactly on a sample boundary (a
+ // floating-point hair above it) from producing a near-duplicate of the final-vertex
+ // sample appended below.
+ for (var d = 0.0; d < totalLength - 1e-6; d += _sampleIntervalMeters)
+ {
+ while (segment < polyline.Count - 2 && cumulative[segment + 1] < d)
+ segment++;
+ samples.Add(SampleAt(polyline, cumulative, segment, d));
+ }
+
+ samples.Add(SampleAt(polyline, cumulative, polyline.Count - 2, totalLength));
+ return samples;
+ }
+
+ private ElevationProfileSample SampleAt(
+ IReadOnlyList polyline, double[] cumulative, int segment, double distance)
+ {
+ var segStart = cumulative[segment];
+ var segEnd = cumulative[segment + 1];
+ var t = segEnd > segStart ? (distance - segStart) / (segEnd - segStart) : 0;
+ var a = polyline[segment];
+ var b = polyline[segment + 1];
+ var lat = a.Latitude + t * (b.Latitude - a.Latitude);
+ var lon = a.Longitude + t * (b.Longitude - a.Longitude);
+ return new ElevationProfileSample(distance, ElevationAt(lat, lon));
+ }
+
+ private static double HaversineMeters(GeoPoint a, GeoPoint b)
+ {
+ var dLat = ToRadians(b.Latitude - a.Latitude);
+ var dLon = ToRadians(b.Longitude - a.Longitude);
+ var lat1 = ToRadians(a.Latitude);
+ var lat2 = ToRadians(b.Latitude);
+ var sinDLat = Math.Sin(dLat / 2);
+ var sinDLon = Math.Sin(dLon / 2);
+ var h = sinDLat * sinDLat + Math.Cos(lat1) * Math.Cos(lat2) * sinDLon * sinDLon;
+ return 2 * EarthRadiusMeters * Math.Atan2(Math.Sqrt(h), Math.Sqrt(1 - h));
+ }
+
+ private static double ToRadians(double degrees) => degrees * Math.PI / 180.0;
+
+ private double ElevationAt(double lat, double lon)
+ {
+ var rowF = (_originLatitude - lat) / _cellSizeDegrees;
+ var colF = (lon - _originLongitude) / _cellSizeDegrees;
+
+ var r0 = Math.Clamp((int)Math.Floor(rowF), 0, _rows - 1);
+ var r1 = Math.Clamp(r0 + 1, 0, _rows - 1);
+ var c0 = Math.Clamp((int)Math.Floor(colF), 0, _cols - 1);
+ var c1 = Math.Clamp(c0 + 1, 0, _cols - 1);
+ var fr = Math.Clamp(rowF - r0, 0, 1);
+ var fc = Math.Clamp(colF - c0, 0, 1);
+
+ var topLeft = CellAt(r0, c0);
+ var topRight = CellAt(r0, c1);
+ var bottomLeft = CellAt(r1, c0);
+ var bottomRight = CellAt(r1, c1);
+ ResolveNoData(ref topLeft, ref topRight, ref bottomLeft, ref bottomRight);
+
+ var top = topLeft * (1 - fc) + topRight * fc;
+ var bottom = bottomLeft * (1 - fc) + bottomRight * fc;
+ return top * (1 - fr) + bottom * fr;
+ }
+
+ private double CellAt(int row, int col) => _body[row * _cols + col];
+
+ ///
+ /// A NoData corner is replaced by the average of the bilinear stencil's other valid corners —
+ /// the nearest valid values available to this interpolation, per the DEM edge-case contract.
+ ///
+ private static void ResolveNoData(ref double topLeft, ref double topRight, ref double bottomLeft, ref double bottomRight)
+ {
+ var corners = new[] { topLeft, topRight, bottomLeft, bottomRight };
+ var validCorners = corners.Where(v => v != NoData).ToArray();
+ if (validCorners.Length == 0 || validCorners.Length == corners.Length)
+ return;
+
+ var fallback = validCorners.Average();
+ if (topLeft == NoData) topLeft = fallback;
+ if (topRight == NoData) topRight = fallback;
+ if (bottomLeft == NoData) bottomLeft = fallback;
+ if (bottomRight == NoData) bottomRight = fallback;
+ }
+}
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs b/src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs
new file mode 100644
index 0000000..f290ed5
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/IElevationSampler.cs
@@ -0,0 +1,9 @@
+using LageBuch.Domain.Wasserfoerderung;
+
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+/// Samples terrain elevation along a drawn route (#150, Plan B).
+public interface IElevationSampler
+{
+ IReadOnlyList Sample(IReadOnlyList polyline);
+}
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
new file mode 100644
index 0000000..138aa35
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/IMapTileSource.cs
@@ -0,0 +1,22 @@
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+/// Reads raster map tiles for the operator's configured Einsatzgebiet (#150, Plan B).
+public interface IMapTileSource
+{
+ /// Raw PNG/JPEG bytes for the XYZ/slippy-map tile, or null when it isn't present.
+ byte[]? GetTile(int zoom, int x, int y);
+
+ ///
+ /// The XYZ tile-index bounds (inclusive) at the lowest zoom level this source has any tiles
+ /// at, or null when it has none — lets a caller derive a sensible initial map view from the
+ /// tiles actually present, instead of an unrelated fixed fallback (#150 follow-up).
+ ///
+ (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds();
+
+ ///
+ /// The highest zoom level this source has any tiles at, or null when it has none — lets a
+ /// caller detect when a requested zoom is past the region's native detail and fall back to
+ /// overzooming an ancestor tile instead of drawing nothing (#150 follow-up).
+ ///
+ int? GetMaxZoom();
+}
diff --git a/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
new file mode 100644
index 0000000..4f4735a
--- /dev/null
+++ b/src/LageBuch.Persistence/Wasserfoerderung/MbTilesFileSource.cs
@@ -0,0 +1,62 @@
+using LageBuch.Persistence.Sqlite;
+
+namespace LageBuch.Persistence.Wasserfoerderung;
+
+///
+/// Reads an MBTiles file (a SQLite database with a standard tiles table) for the
+/// operator's configured Einsatzgebiet (#150, Plan B). MBTiles stores rows in TMS scheme
+/// (row 0 = south); everything else in this app uses XYZ/slippy-map scheme (row 0 = north), so
+/// every read flips the row.
+///
+public sealed class MbTilesFileSource(string mbtilesFilePath) : IMapTileSource
+{
+ public byte[]? GetTile(int zoom, int x, int y)
+ {
+ var tmsRow = (1 << zoom) - 1 - y;
+
+ using var cn = SqliteConnectionFactory.OpenReadOnly(mbtilesFilePath);
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText =
+ "SELECT tile_data FROM tiles WHERE zoom_level = $z AND tile_column = $x AND tile_row = $y;";
+ cmd.Parameters.AddWithValue("$z", zoom);
+ cmd.Parameters.AddWithValue("$x", x);
+ cmd.Parameters.AddWithValue("$y", tmsRow);
+
+ var result = cmd.ExecuteScalar();
+ return result as byte[];
+ }
+
+ public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds()
+ {
+ using var cn = SqliteConnectionFactory.OpenReadOnly(mbtilesFilePath);
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText =
+ "SELECT zoom_level, MIN(tile_column), MAX(tile_column), MIN(tile_row), MAX(tile_row) " +
+ "FROM tiles WHERE zoom_level = (SELECT MIN(zoom_level) FROM tiles) GROUP BY zoom_level;";
+
+ using var reader = cmd.ExecuteReader();
+ if (!reader.Read())
+ return null;
+
+ var zoom = reader.GetInt32(0);
+ var minX = reader.GetInt32(1);
+ var maxX = reader.GetInt32(2);
+ var minTmsRow = reader.GetInt32(3);
+ var maxTmsRow = reader.GetInt32(4);
+
+ // Stored rows are TMS (row 0 = south); flipping back to XYZ (row 0 = north) inverts the
+ // ordering, so the max TMS row becomes the min XYZ row and vice versa.
+ var maxRow = (1 << zoom) - 1;
+ return (zoom, minX, maxX, maxRow - maxTmsRow, maxRow - minTmsRow);
+ }
+
+ public int? GetMaxZoom()
+ {
+ using var cn = SqliteConnectionFactory.OpenReadOnly(mbtilesFilePath);
+ using var cmd = cn.CreateCommand();
+ cmd.CommandText = "SELECT MAX(zoom_level) FROM tiles;";
+
+ var result = cmd.ExecuteScalar();
+ return result is long max ? (int)max : null;
+ }
+}
diff --git a/src/LageBuch.Sync/CommandApplier.cs b/src/LageBuch.Sync/CommandApplier.cs
index 716c6ca..f04b740 100644
--- a/src/LageBuch.Sync/CommandApplier.cs
+++ b/src/LageBuch.Sync/CommandApplier.cs
@@ -141,6 +141,9 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A
case RemoveWasserfoerderungLeitungCommand c:
incident.RemoveWasserfoerderungLeitung(c.LeitungId);
break;
+ case AddWasserfoerderungLeitungFromRouteCommand c:
+ incident.AddWasserfoerderungLeitungFromRoute(c.Uebergabestelle, c.Ansprechpartner, c.RoutePoints, c.Profile);
+ break;
default:
throw new ArgumentOutOfRangeException(nameof(command),
$"Unbekannter Befehl: {command.GetType().Name}");
diff --git a/src/LageBuch.Sync/IIncidentSession.cs b/src/LageBuch.Sync/IIncidentSession.cs
index 3402d09..1a7817e 100644
--- a/src/LageBuch.Sync/IIncidentSession.cs
+++ b/src/LageBuch.Sync/IIncidentSession.cs
@@ -4,6 +4,7 @@
using LageBuch.Domain.Etb;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -72,6 +73,12 @@ void AddForceUnit(string brigade, int personnelCount, string? callSign = null,
/// and every derived figure.
void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprechpartner, double lengthMeters, double elevationRiseMeters);
void RemoveWasserfoerderungLeitung(Guid leitungId);
+
+ void AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle,
+ string? ansprechpartner,
+ IReadOnlyList routePoints,
+ IReadOnlyList profile);
void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.Sync/IncidentSnapshot.cs b/src/LageBuch.Sync/IncidentSnapshot.cs
index 0a132e4..5816163 100644
--- a/src/LageBuch.Sync/IncidentSnapshot.cs
+++ b/src/LageBuch.Sync/IncidentSnapshot.cs
@@ -3,6 +3,7 @@
using LageBuch.Domain.CoMeasurement;
using LageBuch.Domain.Etb;
using LageBuch.Domain.Tasks;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -155,4 +156,5 @@ public sealed record WasserfoerderungLeitungDto(
int ReserveHoseCount,
int PumpCount,
int ReservePumpCount,
- IReadOnlyList PumpPositionsMeters);
+ IReadOnlyList PumpPositionsMeters,
+ IReadOnlyList? RoutePoints = null);
diff --git a/src/LageBuch.Sync/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs
index b3baca1..748b60c 100644
--- a/src/LageBuch.Sync/RemoteIncidentSession.cs
+++ b/src/LageBuch.Sync/RemoteIncidentSession.cs
@@ -8,6 +8,7 @@
using LageBuch.Domain.Files;
using LageBuch.Domain.Tasks;
using LageBuch.Domain.ValueObjects;
+using LageBuch.Domain.Wasserfoerderung;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
@@ -188,6 +189,11 @@ public void AddWasserfoerderungLeitung(string? uebergabestelle, string? ansprech
public void RemoveWasserfoerderungLeitung(Guid leitungId) =>
Send(new RemoveWasserfoerderungLeitungCommand(leitungId));
+ public void AddWasserfoerderungLeitungFromRoute(
+ string? uebergabestelle, string? ansprechpartner,
+ IReadOnlyList routePoints, IReadOnlyList profile) =>
+ Send(new AddWasserfoerderungLeitungFromRouteCommand(uebergabestelle, ansprechpartner, routePoints, profile));
+
public void AddScbaTrupp(string designation, IEnumerable members, int entryPressure,
int? truppNumber = null,
string? callSign = null,
diff --git a/src/LageBuch.Sync/SnapshotMapper.cs b/src/LageBuch.Sync/SnapshotMapper.cs
index d29aeb7..d33c936 100644
--- a/src/LageBuch.Sync/SnapshotMapper.cs
+++ b/src/LageBuch.Sync/SnapshotMapper.cs
@@ -57,7 +57,7 @@ public static IncidentSnapshot ToSnapshot(Incident incident)
incident.Wasserfoerderung.Select(w => new WasserfoerderungLeitungDto(
w.Id, w.Number, w.Uebergabestelle, w.Ansprechpartner, w.FlowLMin, w.FeedPressureBar,
w.LengthMeters, w.ElevationRiseMeters, w.HoseCount, w.ReserveHoseCount,
- w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters)).ToList());
+ w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters, w.RoutePoints)).ToList());
}
public static Incident FromSnapshot(IncidentSnapshot snapshot)
@@ -103,7 +103,7 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot)
snapshot.Wasserfoerderung.Select(w => WasserfoerderungLeitung.Rehydrate(
w.Id, w.Number, w.Uebergabestelle, w.Ansprechpartner, w.FlowLMin, w.FeedPressureBar,
w.LengthMeters, w.ElevationRiseMeters, w.HoseCount, w.ReserveHoseCount,
- w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters)));
+ w.PumpCount, w.ReservePumpCount, w.PumpPositionsMeters, w.RoutePoints)));
}
private static ScbaTruppDto ToDto(AtemschutzTrupp t) => new(
diff --git a/src/LageBuch.Sync/SyncCommand.cs b/src/LageBuch.Sync/SyncCommand.cs
index 78b74ab..108d19b 100644
--- a/src/LageBuch.Sync/SyncCommand.cs
+++ b/src/LageBuch.Sync/SyncCommand.cs
@@ -2,6 +2,7 @@
using LageBuch.Domain.CoMeasurement;
using LageBuch.Domain.Etb;
using LageBuch.Domain.Tasks;
+using LageBuch.Domain.Wasserfoerderung;
namespace LageBuch.Sync;
@@ -47,6 +48,7 @@ namespace LageBuch.Sync;
[JsonDerivedType(typeof(SetApartmentLabelCommand), "setApartmentLabel")]
[JsonDerivedType(typeof(AddWasserfoerderungLeitungCommand), "addWasserfoerderungLeitung")]
[JsonDerivedType(typeof(RemoveWasserfoerderungLeitungCommand), "removeWasserfoerderungLeitung")]
+[JsonDerivedType(typeof(AddWasserfoerderungLeitungFromRouteCommand), "addWasserfoerderungLeitungFromRoute")]
public abstract record SyncCommand;
/// The operator at the sending device — carried on attributed mutations (see §6).
@@ -162,3 +164,12 @@ public sealed record AddWasserfoerderungLeitungCommand(
string? Uebergabestelle, string? Ansprechpartner, double LengthMeters, double ElevationRiseMeters) : SyncCommand;
public sealed record RemoveWasserfoerderungLeitungCommand(Guid LeitungId) : SyncCommand;
+
+// Plan B (#150 phase 2): carries the already-sampled profile so every replica computes the same
+// pump placement without needing its own copy of the DEM file — see
+// Incident.AddWasserfoerderungLeitungFromRoute.
+public sealed record AddWasserfoerderungLeitungFromRouteCommand(
+ string? Uebergabestelle,
+ string? Ansprechpartner,
+ IReadOnlyList RoutePoints,
+ IReadOnlyList Profile) : SyncCommand;
diff --git a/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
index dfd56d1..7a9f813 100644
--- a/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/AboutRenderTests.cs
@@ -93,7 +93,7 @@ private static MainWindowViewModel BuildMainWindowViewModel()
var home = new HomeViewModel(new FakeStore(), masterData,
new EmptyRecent(), dialogs, new FixedClock(), new NoopTicker(), new NoopAlarmService(),
new NoopIncidentHostController(), "0.1.0");
- var editor = new MasterDataEditorViewModel(masterData, dialogs, new NoFiles());
+ var editor = new MasterDataEditorViewModel(masterData, dialogs, new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
return new MainWindowViewModel(home, editor, dialogs, "0.1.0");
}
@@ -109,6 +109,18 @@ private sealed class NoFiles : IMasterDataFileService
public void Write(string path, MasterDataSet set) { }
}
+ private sealed class NoRegionCatalog : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
+ }
+
+ private sealed class NoRegionInstaller : IRegionPackInstaller
+ {
+ public Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default) =>
+ Task.FromResult(string.Empty);
+ }
+
private sealed class EmptyRecent : IRecentFilesStore
{
private readonly List _list = new();
diff --git a/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
new file mode 100644
index 0000000..b8189f9
--- /dev/null
+++ b/tests/LageBuch.Acceptance.Tests/MapCanvasControlTests.cs
@@ -0,0 +1,262 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.Input;
+using LageBuch.App.Shared.Controls;
+using LageBuch.AppLogic.Services;
+using LageBuch.Domain.Wasserfoerderung;
+using LageBuch.Persistence.Wasserfoerderung;
+
+namespace LageBuch.Acceptance.Tests;
+
+// Issue #150 (Plan B): the map canvas the operator draws a Wasserförderung route on.
+public class MapCanvasControlTests
+{
+ private sealed class FakeTileSource : IMapTileSource
+ {
+ public byte[]? GetTile(int zoom, int x, int y) => SolidTilePng.Bytes;
+ public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds() => null;
+ public int? GetMaxZoom() => null;
+ }
+
+ // A real, decodable 4x4 solid-color PNG (built once via Avalonia's own encoder) so
+ // MapCanvasControl's Bitmap(stream) decode path is exercised with genuine image bytes.
+ private static class SolidTilePng
+ {
+ public static readonly byte[] Bytes = Build();
+
+ private static byte[] Build()
+ {
+ using var bitmap = new RenderTargetBitmap(new PixelSize(4, 4));
+ using (var ctx = bitmap.CreateDrawingContext())
+ ctx.FillRectangle(Avalonia.Media.Brushes.SteelBlue, new Rect(0, 0, 4, 4));
+ using var ms = new MemoryStream();
+ bitmap.Save(ms, PngBitmapEncoderOptions.Default);
+ return ms.ToArray();
+ }
+ }
+
+ private static (Window Window, MapCanvasControl Control) ShowControl(
+ IReadOnlyList? routePoints = null, RelayCommand? onPointClicked = null,
+ RelayCommand? onUndo = null, RelayCommand? onViewChanged = null)
+ {
+ var control = new MapCanvasControl
+ {
+ Width = 400,
+ Height = 300,
+ TileSource = new FakeTileSource(),
+ CenterLatitude = 48.0,
+ CenterLongitude = 11.0,
+ Zoom = 15,
+ RoutePoints = routePoints,
+ PointClickedCommand = onPointClicked,
+ UndoRequestedCommand = onUndo,
+ ViewChangedCommand = onViewChanged,
+ };
+ var window = new Window { Content = control, Width = 400, Height = 300 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ return (window, control);
+ }
+
+ [AvaloniaFact]
+ public void Renders_tiles_and_a_route_without_throwing()
+ {
+ var (window, _) = ShowControl(routePoints: new[] { new GeoPoint(48.0, 11.0), new GeoPoint(48.002, 11.0) });
+
+ using var frame = window.CaptureRenderedFrame();
+
+ Assert.NotNull(frame);
+ }
+
+ // #150 follow-up: zooming past a tile source's actual max detail must still draw something
+ // (an overzoomed ancestor tile), not silently skip the tile.
+ private sealed class MaxZoomSpyTileSource : IMapTileSource
+ {
+ public readonly List<(int Zoom, int X, int Y)> Requested = new();
+
+ public byte[]? GetTile(int zoom, int x, int y)
+ {
+ Requested.Add((zoom, x, y));
+ return zoom <= 15 ? SolidTilePng.Bytes : null;
+ }
+
+ public (int Zoom, int MinX, int MaxX, int MinY, int MaxY)? GetTileBounds() => null;
+ public int? GetMaxZoom() => 15;
+ }
+
+ [AvaloniaFact]
+ public void Zooming_past_the_sources_max_detail_falls_back_to_the_overzoomed_ancestor_tile()
+ {
+ var spy = new MaxZoomSpyTileSource();
+ var control = new MapCanvasControl
+ {
+ Width = 256,
+ Height = 256,
+ TileSource = spy,
+ CenterLatitude = 48.0,
+ CenterLongitude = 11.0,
+ Zoom = 17,
+ };
+ var window = new Window { Content = control, Width = 256, Height = 256 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+
+ using var frame = window.CaptureRenderedFrame();
+
+ Assert.NotNull(frame);
+ Assert.Contains(spy.Requested, r => r.Zoom == 17); // tried the exact tile first
+ Assert.Contains(spy.Requested, r => r.Zoom == 15); // fell back to the max-zoom ancestor
+ }
+
+ // #150 follow-up: scroll-wheel zoom was entirely missing before this fix -- only two tiny
+ // +/- buttons existed. Ctrl+scrolling up over the control's exact center must zoom in by one
+ // level while keeping that same geo point (the center) stationary, i.e. center unchanged.
+ [AvaloniaFact]
+ public void CtrlScrolling_up_zooms_in_by_one_level_keeping_the_cursors_geo_point_stationary()
+ {
+ MapViewChange? change = null;
+ var command = new RelayCommand(c => change = c);
+ var (window, control) = ShowControl(onViewChanged: command);
+
+ var center = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ window.MouseWheel(center, new Vector(0, 1), RawInputModifiers.Control);
+
+ Assert.NotNull(change);
+ Assert.Equal(16, change!.Zoom);
+ Assert.Equal(48.0, change.CenterLatitude, 3);
+ Assert.Equal(11.0, change.CenterLongitude, 3);
+ }
+
+ [AvaloniaFact]
+ public void CtrlScrolling_down_zooms_out_by_one_level()
+ {
+ MapViewChange? change = null;
+ var command = new RelayCommand(c => change = c);
+ var (window, control) = ShowControl(onViewChanged: command);
+
+ var center = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ window.MouseWheel(center, new Vector(0, -1), RawInputModifiers.Control);
+
+ Assert.NotNull(change);
+ Assert.Equal(14, change!.Zoom);
+ }
+
+ // #150 follow-up regression fix: the map sits inside the tab's outer ScrollViewer (see
+ // WasserfoerderungView.axaml) -- plain scroll (no Ctrl) must NOT be swallowed as a zoom, or
+ // the whole page becomes unscrollable wherever the cursor happens to be over the map, and a
+ // laptop trackpad's rapid wheel-delta stream during a scroll swipe zooms wildly, rendering the
+ // map unreadable.
+ [AvaloniaFact]
+ public void Plain_scrolling_without_ctrl_does_not_zoom()
+ {
+ MapViewChange? change = null;
+ var command = new RelayCommand(c => change = c);
+ var (window, control) = ShowControl(onViewChanged: command);
+
+ var center = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ window.MouseWheel(center, new Vector(0, 1));
+ window.MouseWheel(center, new Vector(0, -1));
+
+ Assert.Null(change);
+ Assert.Equal(15, control.Zoom);
+ }
+
+ // #150 follow-up: pinch-to-zoom (primarily for Android touch) can't be driven through
+ // Avalonia.Headless (no touch/gesture simulation API exists there), so its scale->zoom-delta
+ // math is unit-tested directly instead.
+ [Theory]
+ [InlineData(1.0, 0)] // no pinch movement -> no zoom change
+ [InlineData(2.0, 1)] // pinch-out to double size -> zoom in one level
+ [InlineData(4.0, 2)] // quadruple size -> zoom in two levels
+ [InlineData(0.5, -1)] // pinch-in to half size -> zoom out one level
+ [InlineData(1.4, 0)] // small movement rounds back to no change
+ [InlineData(1.6, 1)] // past the rounding midpoint commits to the next level
+ public void PinchScaleToZoomDelta_rounds_to_the_nearest_zoom_level(double relativeScale, int expectedDelta)
+ => Assert.Equal(expectedDelta, MapCanvasControl.PinchScaleToZoomDelta(relativeScale));
+
+ [AvaloniaFact]
+ public void Left_click_at_the_controls_center_invokes_PointClicked_with_the_center_geo_point()
+ {
+ GeoPoint? clicked = null;
+ var command = new RelayCommand(p => clicked = p);
+ var (window, control) = ShowControl(onPointClicked: command);
+
+ var center = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ window.MouseDown(center, MouseButton.Left);
+ window.MouseUp(center, MouseButton.Left);
+
+ Assert.NotNull(clicked);
+ Assert.Equal(48.0, clicked!.Latitude, 3);
+ Assert.Equal(11.0, clicked.Longitude, 3);
+ }
+
+ [AvaloniaFact]
+ public void Right_click_invokes_UndoRequested_instead_of_PointClicked()
+ {
+ GeoPoint? clicked = null;
+ var undoCount = 0;
+ var pointCommand = new RelayCommand(p => clicked = p);
+ var undoCommand = new RelayCommand(() => undoCount++);
+ var (window, control) = ShowControl(onPointClicked: pointCommand, onUndo: undoCommand);
+
+ var center = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ window.MouseDown(center, MouseButton.Right);
+ window.MouseUp(center, MouseButton.Right);
+
+ Assert.Equal(1, undoCount);
+ Assert.Null(clicked);
+ }
+
+ // #150 follow-up: cursor-anchored zoom drifts the center with no drag-to-pan to correct it,
+ // so once the view drifted off the region entirely there was no way back. Ctrl+drag pans
+ // (moving the map opposite the drag direction, standard map UX), without also adding a route
+ // point (which plain left-click still does).
+ [AvaloniaFact]
+ public void CtrlDrag_pans_the_map_and_does_not_add_a_route_point()
+ {
+ GeoPoint? clicked = null;
+ MapViewChange? change = null;
+ var pointCommand = new RelayCommand(p => clicked = p);
+ var viewCommand = new RelayCommand(c => change = c);
+ var (window, control) = ShowControl(onPointClicked: pointCommand, onViewChanged: viewCommand);
+
+ var start = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ var end = control.TranslatePoint(new Point(240, 150), window)!.Value; // drag 40px right
+
+ window.MouseDown(start, MouseButton.Left, RawInputModifiers.Control);
+ window.MouseMove(end, RawInputModifiers.LeftMouseButton);
+ window.MouseUp(end, MouseButton.Left);
+
+ Assert.Null(clicked); // dragging must not also place a route point
+ Assert.NotNull(change);
+ Assert.Equal(15, change!.Zoom); // pan alone leaves zoom untouched
+ // Dragging right reveals content that was to the left -- the center's longitude decreases.
+ Assert.True(change.CenterLongitude < 11.0, $"Expected longitude to decrease, was {change.CenterLongitude}");
+ Assert.Equal(48.0, change.CenterLatitude, 3); // purely horizontal drag -> latitude unchanged
+ }
+
+ [AvaloniaFact]
+ public void Plain_drag_without_ctrl_still_adds_a_route_point_and_does_not_pan()
+ {
+ GeoPoint? clicked = null;
+ MapViewChange? change = null;
+ var pointCommand = new RelayCommand(p => clicked = p);
+ var viewCommand = new RelayCommand(c => change = c);
+ var (window, control) = ShowControl(onPointClicked: pointCommand, onViewChanged: viewCommand);
+
+ var start = control.TranslatePoint(new Point(200, 150), window)!.Value;
+ var end = control.TranslatePoint(new Point(240, 150), window)!.Value;
+
+ window.MouseDown(start, MouseButton.Left);
+ window.MouseMove(end);
+ window.MouseUp(end, MouseButton.Left);
+
+ Assert.NotNull(clicked); // existing click-to-add-point behaviour is unaffected
+ Assert.Null(change); // no Ctrl held -> no pan
+ }
+}
diff --git a/tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs b/tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs
new file mode 100644
index 0000000..874bac0
--- /dev/null
+++ b/tests/LageBuch.Acceptance.Tests/MapDrawingTests.cs
@@ -0,0 +1,62 @@
+using Avalonia;
+using LageBuch.App.Shared.Controls;
+
+namespace LageBuch.Acceptance.Tests;
+
+// #150 follow-up: overzoom -- when a requested zoom is past the configured region's actual max
+// rendered detail, MapDrawing falls back to the nearest ancestor tile it does have instead of
+// drawing nothing, matching every other map app's "blurry but oriented" behavior past native zoom.
+public class MapDrawingTests
+{
+ [Fact]
+ public void ComputeOverzoomTile_returns_null_when_zoom_is_within_the_sources_range()
+ {
+ Assert.Null(MapDrawing.ComputeOverzoomTile(zoom: 14, x: 100, y: 200, sourceMaxZoom: 15));
+ Assert.Null(MapDrawing.ComputeOverzoomTile(zoom: 15, x: 100, y: 200, sourceMaxZoom: 15));
+ }
+
+ [Fact]
+ public void ComputeOverzoomTile_returns_null_when_the_source_has_no_known_max_zoom()
+ {
+ Assert.Null(MapDrawing.ComputeOverzoomTile(zoom: 20, x: 100, y: 200, sourceMaxZoom: null));
+ }
+
+ [Fact]
+ public void ComputeOverzoomTile_finds_the_direct_parent_one_level_up()
+ {
+ // Tile (x=100,y=200) at z16 is the top-left quadrant of its z15 parent (x=50,y=100).
+ var overzoom = MapDrawing.ComputeOverzoomTile(zoom: 16, x: 100, y: 200, sourceMaxZoom: 15);
+
+ Assert.NotNull(overzoom);
+ Assert.Equal(15, overzoom!.Value.Zoom);
+ Assert.Equal(50, overzoom.Value.X);
+ Assert.Equal(100, overzoom.Value.Y);
+ Assert.Equal(new Rect(0, 0, 128, 128), overzoom.Value.SourceRect);
+ }
+
+ [Fact]
+ public void ComputeOverzoomTile_picks_the_correct_quadrant_for_an_odd_tile_index()
+ {
+ // Tile (x=101,y=201) at z16 is the bottom-right quadrant of the same z15 parent (x=50,y=100).
+ var overzoom = MapDrawing.ComputeOverzoomTile(zoom: 16, x: 101, y: 201, sourceMaxZoom: 15);
+
+ Assert.NotNull(overzoom);
+ Assert.Equal(15, overzoom!.Value.Zoom);
+ Assert.Equal(50, overzoom.Value.X);
+ Assert.Equal(100, overzoom.Value.Y);
+ Assert.Equal(new Rect(128, 128, 128, 128), overzoom.Value.SourceRect);
+ }
+
+ [Fact]
+ public void ComputeOverzoomTile_climbs_multiple_levels_when_zoomed_in_further()
+ {
+ // Two levels up (z17 -> z15): a quarter-size crop of the ancestor tile.
+ var overzoom = MapDrawing.ComputeOverzoomTile(zoom: 17, x: 400, y: 800, sourceMaxZoom: 15);
+
+ Assert.NotNull(overzoom);
+ Assert.Equal(15, overzoom!.Value.Zoom);
+ Assert.Equal(100, overzoom.Value.X);
+ Assert.Equal(200, overzoom.Value.Y);
+ Assert.Equal(new Rect(0, 0, 64, 64), overzoom.Value.SourceRect);
+ }
+}
diff --git a/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs b/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
index 0b2f435..de9f816 100644
--- a/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
+++ b/tests/LageBuch.Acceptance.Tests/MasterDataEditorRenderTests.cs
@@ -51,18 +51,30 @@ private sealed class NoFiles : IMasterDataFileService
public void Write(string path, MasterDataSet set) { }
}
+ private sealed class NoRegionCatalog : IRegionPackCatalogService
+ {
+ public Task> GetAvailableRegionsAsync(CancellationToken ct = default) =>
+ Task.FromResult>(Array.Empty());
+ }
+
+ private sealed class NoRegionInstaller : IRegionPackInstaller
+ {
+ public Task DownloadAndInstallAsync(RegionPackInfo pack, IProgress? progress, CancellationToken ct = default) =>
+ Task.FromResult(string.Empty);
+ }
+
[AvaloniaFact]
public void The_editor_renders_with_every_category()
{
- var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles());
+ var vm = new MasterDataEditorViewModel(new SampleProvider(), new FakeDialogs(), new NoFiles(), new NoRegionCatalog(), new NoRegionInstaller());
var view = new MasterDataEditorView { DataContext = vm };
var window = new Window { Content = view, Width = 1080, Height = 680 };
window.Show();
Dispatcher.UIThread.RunJobs();
var list = view.GetControl("CategoryList");
- // 14 categories plus #76's Fahrzeuge.
- Assert.Equal(15, list.ItemCount);
+ // 14 categories plus #76's Fahrzeuge plus #150's Einsatzgebiet.
+ Assert.Equal(16, list.ItemCount);
Assert.True(view.GetControl