Skip to content

feat(wasserfoerderung): Förderstrecken planen — Rechner, Tab, PDF (#150, Plan A) - #152

Open
DeepDiver1975 wants to merge 9 commits into
mainfrom
feat/wasserfoerderung-plan-a
Open

feat(wasserfoerderung): Förderstrecken planen — Rechner, Tab, PDF (#150, Plan A)#152
DeepDiver1975 wants to merge 9 commits into
mainfrom
feat/wasserfoerderung-plan-a

Conversation

@DeepDiver1975

@DeepDiver1975 DeepDiver1975 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Zusammenfassung

Plan A von #150: neuer WASSERFÖRDERUNG-Tab im Lagebuch, der Förderstrecken-Leitungen (Ltg 1, Ltg 2, …) rein offline plant. Ein hydraulischer Rechner (FörderstreckePlanner) leitet aus Länge + Höhenunterschied die B-Schlauch- und Verstärkerpumpen-Positionen ab; die Zahlen sind unveränderlich gespeichert, damit PDF, Sync und alle Clients immer dasselbe zeigen.

  • Domain: FörderstreckeConfig / FörderstreckePlanner mit der B-800-Verlusttabelle (0,1 … 2,25 bar/100 m), Steigung 0,1 bar/m, flache Teilstrecke 620 m, 3 % Reserveschlauch, 1 Reservepumpe je 4 Verstärkerpumpen (Speisepumpe ausgenommen, wie abgestimmt).
  • Persistenz: Migration v18 (wass_leitungen), voller Round-Trip inkl. Pumpenpositionen (JSON).
  • Sync: add/removeWasserfoerderungLeitung-Kommandos, Snapshot-DTO, Applier; die Leitungsnummer und alle abgeleiteten Werte berechnet der Host.
  • UI: 12. Tab „WASSERFÖRDERUNG“ mit Eingabedock und DataGrid; Abbruch beim Kappen der Steigung wird als Fehlermeldung angezeigt statt App-Crash.
  • PDF: neuer Abschnitt „Wasserförderung“ (Leitung / Übergabestelle / Ansprechpartner / B-Längen / Länge / Höhenunterschied / Verstärkerpumpen / Reservepumpen).

Tests

826 Tests grün (Domain 232, AppLogic 294, Sync 60, Sync.Hosting 33, Persistence 91, Documents 25, Acceptance 89, App 2).

Screenshots

Vorher (leerer Tab):
wasserfoerderung-before

Nachher (zwei geplante Leitungen, 2000 m/100 m und 400 m/0 m):
wasserfoerderung-after

Planungshinweis: B-800, B-Schlauch 20 m, 8 bar Speisedruck, 1,5 bar Pumpeneingang (Config, anpassbar).

DeepDiver1975 and others added 2 commits September 2, 2026 22:41
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…ion profiles (#150, Plan B) (#164)

* 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 <noreply@anthropic.com>
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 (<regionsBaseDir>/<slug>). 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
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 (<regionsBaseDir>/<slug>). 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

---------

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

---------

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
@DeepDiver1975
DeepDiver1975 force-pushed the feat/wasserfoerderung-plan-a branch from 91a4e7e to 5153ae6 Compare September 2, 2026 20:49
DeepDiver1975 and others added 7 commits September 2, 2026 23:07
…oom glyphs

Two Plan B map bugs:

- A newly drawn waypoint didn't appear until something unrelated
  triggered a repaint. RoutePoints binds to the VM's live
  ObservableCollection<GeoPoint>, mutated in place (Add/RemoveAt/Clear)
  rather than reassigned, but AffectsRender only reacts to the
  property's own reference changing. MapCanvasControl now
  subscribes/unsubscribes CollectionChanged on the bound collection and
  invalidates the visual on each mutation.

- The zoom +/- buttons rendered as empty squares: the default Button
  style's 16px-per-side padding filled the entire 32px-wide button,
  leaving 0px for the glyph. Set Padding="0" on both.

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…w is selected

Selecting a Leitung in the grid did nothing: the DataGrid had no
SelectedItem binding, WasserfoerderungLeitungRow never exposed the
underlying RoutePoints, and MapCanvasControl only ever drew the
in-progress DrawnRoutePoints sketch — there was no code path connecting
grid selection to the map at all.

WasserfoerderungViewModel now tracks SelectedRow (bound to the
DataGrid's SelectedItem) and exposes SelectedRoutePoints from it,
switching to KARTE mode when the selected Leitung has a saved route.
MapCanvasControl gained a SelectedRoutePoints property drawn via
MapDrawing as a DodgerBlue overlay, visually distinct from the
OrangeRed in-progress sketch, so a reference route and an
actively-drawn one are never confused.

Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…t count+1

Removing a non-last Leitung then adding a new one collided: the new
number was derived from _wasserfoerderung.Count + 1, which can already
be taken by a surviving Leitung. Mirrors the existing
NextFreeScbaTruppNumber pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqpTMbqRRgLQSjX9T2VUFF
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
…e hose

PlanFromProfile's "finish the leg here" shortcut required the remaining
distance to be at least one hose length before even checking the cost
budget, so any route under 20m (a valid input -- CanAddLeitung only
requires length > 0) threw "Die Steigung ist zu stark" even on flat
ground, since the per-hose stepping loop below it can't make progress
either when reach never reaches a full hose length.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqpTMbqRRgLQSjX9T2VUFF
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
… a dash

RiseDisplay (grid) and the PDF's Wasserfoerderung table both hid the
elevation change as "-" whenever ElevationRiseMeters wasn't positive,
so a real, valid negative rise (a route that descends net elevation --
already exercised by FörderstreckePlannerElevationTests) was rendered
as if nothing were recorded, in both places.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqpTMbqRRgLQSjX9T2VUFF
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
GetAvailableRegionsAsync's own doc comment promises it "degrades to an
empty list rather than surfacing any error", but only HttpRequestException
was caught -- an HttpClient timeout throws TaskCanceledException instead,
which escaped uncaught. Since the caller invokes this via a fire-and-forget
command, the fault went unobserved and the region dropdown was left
silently empty/frozen. The caller's own cancellation still propagates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqpTMbqRRgLQSjX9T2VUFF
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
MasterDataSet gained a top-level Einsatzgebiet field, but the example
wasn't updated in the same PR as AGENTS.md requires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqpTMbqRRgLQSjX9T2VUFF
Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com>
@DeepDiver1975

Copy link
Copy Markdown
Collaborator Author

Code review

Note: this branch has a real merge conflict against main (gh pr update-branch --rebase failed with RebaseConflictError), so it was reviewed at its current head without rebasing.

This is a large diff, so review ran across correctness, cleanup, and convention angles. Found 10 issues; 5 are fixed and pushed (5df4d8b), 5 are left as observations.

Fixed:

  1. New Leitungen were numbered _wasserfoerderung.Count + 1, so removing a non-last Leitung then adding a new one collides with a surviving one's number (both display as e.g. "Ltg 2"). Now numbered by smallest free slot, mirroring the existing NextFreeScbaTruppNumber pattern.

/// <summary>
/// Plans and records one Förderstrecke-Leitung (#150). The pump/pressure figures are computed
/// by <see cref="FörderstreckePlanner"/> at creation and stored on the Leitung — the PDF and
/// remote clients never recompute. Silently planned (no ETB line), like tasks.
/// </summary>
public WasserfoerderungLeitung AddWasserfoerderungLeitung(
string? uebergabestelle, string? ansprechpartner, double lengthM, double riseM)
{
EnsureOpen();
var leitung = WasserfoerderungLeitung.Create(
number: NextFreeWasserfoerderungLeitungNumber(),
uebergabestelle: uebergabestelle,
ansprechpartner: ansprechpartner,
lengthM: lengthM,
riseM: riseM);
_wasserfoerderung.Add(leitung);
return leitung;
}
/// <summary>
/// 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.
/// </summary>
public WasserfoerderungLeitung AddWasserfoerderungLeitungFromRoute(
string? uebergabestelle,
string? ansprechpartner,
IReadOnlyList<GeoPoint> routePoints,
IReadOnlyList<ElevationProfileSample> profile)
{
EnsureOpen();
var leitung = WasserfoerderungLeitung.CreateFromRoute(
number: NextFreeWasserfoerderungLeitungNumber(),
uebergabestelle: uebergabestelle,

  1. FörderstreckePlanner.PlanFromProfile threw "Die Steigung ist zu stark" for any route shorter than one 20m hose, even perfectly flat, because the "finish here" shortcut required the remaining distance to be at least one hose length before even checking the cost budget. Nothing upstream enforces a minimum length. Fixed to check budget regardless of remaining distance.

var positions = new List<double> { 0 };
var pos = 0.0;
while (pos < lengthM)
{
// If the rest of the route fits one leg's budget, finish here -- regardless of whether
// what's left happens to be a whole number of hose lengths (a route shorter than one
// hose, e.g. a trivial flat connection, is a valid input: CanAddLeitung only requires
// length > 0).
if (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)
{

  1. RiseDisplay (grid) and the PDF's Wasserfoerderung table both rendered a downhill (negative) elevation change as "—", as if nothing were recorded — ElevationRiseMeters is a real, valid negative value for a descending route (already exercised by FörderstreckePlannerElevationTests). Now always shown.

RiseDisplay = Formatting.Meters(leitung.ElevationRiseMeters);

  1. RegionPackCatalogService.GetAvailableRegionsAsync's own doc comment promises it degrades to an empty list rather than surfacing any error, but only caught HttpRequestException — an HttpClient timeout throws TaskCanceledException instead, which escaped uncaught into a fire-and-forget command's unobserved task fault, leaving the region dropdown silently empty/frozen. Now caught too (the caller's own cancellation still propagates).

{
public async Task<IReadOnlyList<RegionPackInfo>> GetAvailableRegionsAsync(CancellationToken ct = default)
{
try
{
var json = await httpClient.GetStringAsync(new Uri(manifestUrl), ct);
return RegionPackCatalogJson.Parse(json);
}
catch (HttpRequestException)
{
return Array.Empty<RegionPackInfo>();
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// HttpClient's own request timeout throws this (TaskCanceledException), distinct from

  1. MasterDataSet gained a top-level Einsatzgebiet field, but docs/master-data.example.json wasn't updated in this PR, as this repo's own AGENTS.md requires whenever that happens.

https://github.com/CodeForFire/lagebuch/blob/5df4d8bfe0c2a8242d93d477f7695f6baceb0f58/docs/master-data.example.json

Left as observations (design calls / larger scope, not applied):

  1. EinsatzgebietSection.DownloadSelectedRegion has no try/catch around DownloadAndInstallAsync, and nothing binds to the command's fault state — a 404/corrupt zip/invalid slug becomes an unobserved task fault with the progress bar stuck and no way for the operator to know or retry short of restarting. Worth a UI-visible error/status treatment, similar to CatalogStatus.

  2. (PLAUSIBLE, not confirmed) PopulateSections() rebuilds _einsatzgebiet as a fresh EinsatzgebietSection on every Load/Save/Discard, potentially orphaning an in-flight download on the previous instance if a save happens mid-download.

  3. BuildRouteOverviewPngById() rebuilds the Wasserfoerderung map/tile source from scratch during PDF export instead of reusing the workspace's already-built Wasserfoerderung.TileSource — wasted I/O on every export.

  4. WasserfoerderungLeitung.Create/CreateFromRoute are near-duplicate factories (~20 of ~30 lines identical) — a third planning mode would need a third copy-paste with no compiler check keeping them in sync.

  5. MbTilesFileSource opens a brand-new SqliteConnection per tile call with no caching, and MapDrawing.DrawTiles() re-fetches/re-decodes every visible tile on every render tick (wired to pan/zoom) — likely to be visibly laggy with a real region pack loaded.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant